Files
larksuite-cli/shortcuts/doc/docs_search.go
arnold9672 5efaf65aec feat: surface search API notices (#1413)
* feat: surface search API notices

sa: safe
doc: none
cfg: none
test: unit test

* fix: surface search notices in default output

* docs: add search notice doc comments

* docs: expand search notice doc comments
2026-06-23 14:27:04 +08:00

344 lines
8.7 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,
}
if notice, _ := data["notice"].(string); notice != "" {
resultData["notice"] = notice
}
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
}
}