Files
dingtalk-workspace-cli/internal/shortcut/smart/find_file.go

184 lines
7.4 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// FindFile: search 钉盘 files by name keyword and project the essentials.
//
// This is a one-step convenience wrapper over the drive MCP tool search_files.
// It mirrors helpers driveSearchCmd exactly for the "仅搜钉盘文件" path:
// - keyword ← --query (the free-text file-name search term; MCP arg "keyword")
// - searchTarget ← "file" (restrict to 钉盘 files/folders, no doc-space aggregation)
//
// The raw search response is then reduced locally to a compact list of
// {name, type, dentryId, fileSize} per hit and emitted via rt.Output, so it
// honours the root --format/--jq/--fields projection flags. Field parsing is
// defensive: both the container (result/data/items/files/nodes/list) and each
// item's fields (multiple candidate keys) are probed leniently.
//
// Read-only: it never mutates anything, it only searches and projects locally.
//
// dws drive +find-file --query 季度汇报
var FindFile = shortcut.Shortcut{
Service: "drive",
Command: "+find-file",
Product: "drive",
Description: "按名称关键词搜索钉盘文件并投影关键字段(只读)",
Intent: "当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看," +
"却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) " +
"并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。" +
"这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "shortcut_find_file",
CanonicalPath: "drive.shortcut_find_file",
CLIPath: "drive +find-file",
PrimaryCLIPath: "drive +find-file",
},
Description: "按名称关键词搜索钉盘文件并投影关键字段(只读)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按名称关键词搜索钉盘文件并投影关键字段(只读)",
UseWhen: []string{"当你只记得钉盘文件的名字(或其中一部分),想快速按文件名关键词找到它、拿到它的 dentryId 以便后续下载/查看,却不想手动翻目录或写复杂过滤条件时使用;内部调用钉盘的 search_files 工具,把 --query 作为文件名关键词(keyword) 并限定搜索范围为钉盘文件(searchTarget=file),再在本地把每条命中结果精简为「文件名、类型、dentryId、大小」四个字段后打印。这是纯只读操作,只做搜索与本地投影,不会创建、移动或删除任何文件;未命中时返回空列表。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws drive +find-file --query 季度汇报",
"dws drive +find-file --query 合同",
},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "文件名关键词(必填)", Required: true},
},
Tips: []string{
`dws drive +find-file --query 季度汇报`,
`dws drive +find-file --query 合同`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
keyword := strings.TrimSpace(rt.Str("query"))
// keyword / searchTarget mirror helpers driveSearchCmd (search_files),
// restricted to 钉盘 files only.
data, err := rt.CallMCPData("drive", "search_files", map[string]any{
"keyword": keyword,
"searchTarget": "file",
})
if err != nil {
return err
}
items := shortcutFindFileItems(data)
files := make([]map[string]any, 0, len(items))
for _, m := range items {
files = append(files, map[string]any{
"name": shortcutFindFileStr(m, "name", "fileName", "title", "dentryName"),
"type": shortcutFindFileStr(m, "type", "dentryType", "extension", "fileType"),
"dentryId": shortcutFindFileStr(m, "dentryId", "dentryUuid", "fileId", "nodeId", "id"),
"fileSize": shortcutFindFileSize(m),
})
}
return rt.Output(map[string]any{"files": files})
},
}
// shortcutFindFileItems locates the list of hit records inside the search_files
// response, tolerating an optional result/data wrapper and several common list
// key names. Returns a slice of map records (non-map entries are skipped).
func shortcutFindFileItems(data map[string]any) []map[string]any {
container := data
for _, wrap := range []string{"result", "data"} {
if inner, ok := container[wrap].(map[string]any); ok {
container = inner
}
}
for _, key := range []string{"items", "files", "nodes", "list", "dentries", "results", "records"} {
if arr, ok := container[key].([]any); ok {
return shortcutFindFileToMaps(arr)
}
}
// Fallback: the container itself might be the array under a differently
// named key — scan for the first []any value.
for _, v := range container {
if arr, ok := v.([]any); ok {
return shortcutFindFileToMaps(arr)
}
}
return nil
}
func shortcutFindFileToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, e := range arr {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// shortcutFindFileStr returns the first non-empty string value among the given
// candidate keys.
func shortcutFindFileStr(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok {
if s = strings.TrimSpace(s); s != "" {
return s
}
}
}
return ""
}
// shortcutFindFileSize reads a hit's file size, tolerating numeric (float64) and
// string JSON encodings across the common size key names. Returns nil when
// absent (e.g. folders), so the field is simply omitted-as-null in output.
func shortcutFindFileSize(m map[string]any) any {
for _, k := range []string{"fileSize", "size"} {
switch v := m[k].(type) {
case float64:
return int64(v)
case int64:
return v
case string:
if strings.TrimSpace(v) != "" {
return v
}
}
}
return nil
}
func init() {
shortcut.Register(FindFile)
}