460 lines
19 KiB
Go
460 lines
19 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0
|
||
|
||
package todo
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||
)
|
||
|
||
const todoCompositeReason = "Reviewed Todo Shortcut composite: the executable CLI owns strict response validation, pagination, write/read-back verification, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||
|
||
func todoContract(command, description, useWhen string, result *contract.ResultSpec, examples ...string) corecmd.ContractDecl {
|
||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||
cliPath := "todo " + command
|
||
return corecmd.ContractDecl{
|
||
Description: description,
|
||
Result: result,
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "todo", Name: name, CanonicalPath: "todo." + name,
|
||
CLIPath: cliPath, PrimaryCLIPath: cliPath,
|
||
},
|
||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: todoCompositeReason},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: description,
|
||
UseWhen: []string{useWhen},
|
||
AvoidWhen: []string{"需要底层未公开参数或原始响应时改用对应原子命令"},
|
||
Examples: examples,
|
||
},
|
||
}
|
||
}
|
||
|
||
func todoWriteSafety(idempotency string) contract.SafetySpec {
|
||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
|
||
}
|
||
|
||
func todoReadSafety() contract.SafetySpec {
|
||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||
}
|
||
|
||
func parseTodoMillis(flag, value string) (int64, error) {
|
||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||
if err != nil {
|
||
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 ISO8601 时间", flag))
|
||
}
|
||
return parsed.UnixMilli(), nil
|
||
}
|
||
|
||
func createTodo(rt *shortcut.RuntimeContext) error {
|
||
title := rt.Str("title")
|
||
executors := nonEmptyStrings(rt.StrSlice("executors"))
|
||
if title == "" || len(executors) == 0 {
|
||
return apperrors.NewValidation("--title 与至少一个 --executors 均为必填")
|
||
}
|
||
vo := map[string]any{"subject": title, "executorIds": executors}
|
||
if rt.Changed("due") {
|
||
millis, err := parseTodoMillis("due", rt.Str("due"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
vo["dueTime"] = millis
|
||
}
|
||
if rt.Changed("priority") {
|
||
priority := rt.Int("priority")
|
||
if priority != 10 && priority != 20 && priority != 30 && priority != 40 {
|
||
return apperrors.NewValidation("--priority 仅接受 10/20/30/40")
|
||
}
|
||
vo["priority"] = priority
|
||
}
|
||
if rt.DryRun() {
|
||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "create", "subject": title})
|
||
}
|
||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", map[string]any{"PersonalTodoCreateVO": vo})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := requireTodoWriteReceipt(data, "todo/create_personal_todo"); err != nil {
|
||
return err
|
||
}
|
||
taskID := todoCreatedTaskID(data)
|
||
if taskID == "" {
|
||
return todoResponseError("todo/create_personal_todo", "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
||
}
|
||
detail, err := readTodoDetail(rt, taskID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if subject, _ := detail["subject"].(string); subject != title {
|
||
return todoResponseError("todo/create_personal_todo", "verification_mismatch", "创建后读回的标题不一致")
|
||
}
|
||
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
|
||
}
|
||
|
||
var Create = shortcut.Shortcut{
|
||
OutputRollout: output.RolloutUnifiedActive,
|
||
Service: "todo", Command: "+create", Product: "todo",
|
||
Description: "创建待办并读回验证", Intent: "创建一条个人待办;只有取得稳定 taskId 且详情读回一致才报告成功。",
|
||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
|
||
Contract: todoContract("+create", "创建待办并读回验证", "标题和执行人已确定,需要创建待办时使用", todoObjectResult("已验证的新建待办"), `dws todo +create --title "提交报告" --executors <USER_ID>`),
|
||
Flags: []shortcut.Flag{
|
||
{Name: "title", Type: shortcut.FlagString, Desc: "待办标题", Required: true},
|
||
{Name: "executors", Type: shortcut.FlagStringSlice, Desc: "执行人 userId", Required: true},
|
||
{Name: "due", Type: shortcut.FlagString, Desc: "截止时间(ISO8601)"},
|
||
{Name: "priority", Type: shortcut.FlagInt, Desc: "优先级 10/20/30/40"},
|
||
},
|
||
Execute: createTodo,
|
||
}
|
||
|
||
func updateTodo(rt *shortcut.RuntimeContext) error {
|
||
if !rt.Changed("title") && !rt.Changed("due") && !rt.Changed("priority") {
|
||
return apperrors.NewValidation("至少指定 --title、--due、--priority 之一")
|
||
}
|
||
taskID := rt.Str("task-id")
|
||
request := map[string]any{"taskId": taskID}
|
||
if rt.Changed("title") {
|
||
if rt.Str("title") == "" {
|
||
return apperrors.NewValidation("--title 不能为空")
|
||
}
|
||
request["subject"] = rt.Str("title")
|
||
}
|
||
if rt.Changed("due") {
|
||
millis, err := parseTodoMillis("due", rt.Str("due"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
request["dueTime"] = millis
|
||
}
|
||
if rt.Changed("priority") {
|
||
priority := rt.Int("priority")
|
||
if priority != 10 && priority != 20 && priority != 30 && priority != 40 {
|
||
return apperrors.NewValidation("--priority 仅接受 10/20/30/40")
|
||
}
|
||
request["priority"] = priority
|
||
}
|
||
if rt.DryRun() {
|
||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "update", "taskId": taskID})
|
||
}
|
||
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_task", map[string]any{"TodoUpdateRequest": request})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := requireTodoWriteReceipt(data, "todo/update_todo_task"); err != nil {
|
||
return err
|
||
}
|
||
detail, err := readTodoDetail(rt, taskID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for key, expected := range request {
|
||
if key == "taskId" {
|
||
continue
|
||
}
|
||
if fmt.Sprint(detail[key]) != fmt.Sprint(expected) {
|
||
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
|
||
}
|
||
}
|
||
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
|
||
}
|
||
|
||
var Update = shortcut.Shortcut{
|
||
OutputRollout: output.RolloutUnifiedActive,
|
||
Service: "todo", Command: "+update", Product: "todo",
|
||
Description: "更新待办并读回验证", Intent: "按 taskId 修改标题、截止时间或优先级,并严格读回核验。",
|
||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
|
||
Contract: todoContract("+update", "更新待办并读回验证", "已知 taskId 且需要修改待办字段时使用", todoObjectResult("已验证的更新结果"), `dws todo +update --task-id <TASK_ID> --title "新标题"`),
|
||
Flags: []shortcut.Flag{
|
||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||
{Name: "title", Type: shortcut.FlagString, Desc: "新标题"},
|
||
{Name: "due", Type: shortcut.FlagString, Desc: "新截止时间(ISO8601)"},
|
||
{Name: "priority", Type: shortcut.FlagInt, Desc: "新优先级 10/20/30/40"},
|
||
},
|
||
Execute: updateTodo,
|
||
}
|
||
|
||
func setTodoDone(rt *shortcut.RuntimeContext, target bool) error {
|
||
taskID := rt.Str("task-id")
|
||
if rt.DryRun() {
|
||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": taskID, "isDone": target})
|
||
}
|
||
before, err := readTodoDetail(rt, taskID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
current, ok := before["isDone"].(bool)
|
||
if !ok {
|
||
return todoResponseError("todo/get_todo_detail", "malformed_done_status", "待办详情缺少布尔 isDone,拒绝盲目写状态")
|
||
}
|
||
if current == target {
|
||
return rt.Output(map[string]any{"taskId": taskID, "isDone": target, "verified": true, "alreadyInTargetState": true})
|
||
}
|
||
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_done_status", map[string]any{"taskId": taskID, "isDone": strconv.FormatBool(target)})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := requireTodoWriteReceipt(data, "todo/update_todo_done_status"); err != nil {
|
||
return err
|
||
}
|
||
detail, err := readTodoDetail(rt, taskID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
done, ok := detail["isDone"].(bool)
|
||
if !ok || done != target {
|
||
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "完成状态读回不一致或缺失")
|
||
}
|
||
return rt.Output(map[string]any{"taskId": taskID, "isDone": target, "verified": true, "alreadyInTargetState": false})
|
||
}
|
||
|
||
func doneShortcut(command string, target bool) shortcut.Shortcut {
|
||
description := "完成待办并读回验证"
|
||
if !target {
|
||
description = "重新打开待办并读回验证"
|
||
}
|
||
return shortcut.Shortcut{
|
||
OutputRollout: output.RolloutUnifiedActive,
|
||
Service: "todo", Command: command, Product: "todo", Description: description, Intent: description,
|
||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
|
||
Contract: todoContract(command, description, "已知 taskId 且需要改变完成状态时使用", todoObjectResult("已验证的待办状态"), "dws todo "+command+" --task-id <TASK_ID>"),
|
||
Flags: []shortcut.Flag{{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true}},
|
||
Execute: func(rt *shortcut.RuntimeContext) error { return setTodoDone(rt, target) },
|
||
}
|
||
}
|
||
|
||
var Complete = doneShortcut("+complete", true)
|
||
var Reopen = doneShortcut("+reopen", false)
|
||
|
||
var Search = shortcut.Shortcut{
|
||
OutputRollout: output.RolloutUnifiedActive,
|
||
Service: "todo", Command: "+search", Product: "todo",
|
||
Description: "搜索与我相关的全部待办", Intent: "分页遍历当前组织下与我相关的待办并按标题关键词匹配。",
|
||
Risk: shortcut.RiskRead, Safety: todoReadSafety(),
|
||
Contract: todoContract("+search", "搜索与我相关的全部待办", "需要按标题关键词跨全部分页搜索待办时使用", todoCollectionResult("todos", "待办搜索结果"), `dws todo +search --query "周报"`),
|
||
Flags: []shortcut.Flag{
|
||
{Name: "query", Type: shortcut.FlagString, Desc: "标题关键词", Required: true},
|
||
{Name: "status", Type: shortcut.FlagString, Enum: []string{"true", "false"}, Desc: "完成状态"},
|
||
{Name: "max-pages", Type: shortcut.FlagInt, Default: "40", Desc: "最大遍历页数(1-40)"},
|
||
},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
query := strings.ToLower(rt.Str("query"))
|
||
if query == "" {
|
||
return apperrors.NewValidation("--query 不能为空")
|
||
}
|
||
maxPages := rt.Int("max-pages")
|
||
if maxPages < 1 || maxPages > todoMaxPages {
|
||
return apperrors.NewValidation("--max-pages 必须是 1 到 40")
|
||
}
|
||
base := map[string]any{"roleTypes": []string{"creator", "executor", "participant"}}
|
||
if rt.Changed("status") {
|
||
base["todoStatus"] = rt.Str("status")
|
||
}
|
||
cards, err := listAllTodoCards(rt, base, maxPages)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
matches := make([]map[string]any, 0)
|
||
for _, card := range cards {
|
||
subject, ok := card["subject"].(string)
|
||
if !ok {
|
||
return todoResponseError("todo/+search", "malformed_subject", "待办条目缺少字符串 subject")
|
||
}
|
||
if strings.Contains(strings.ToLower(subject), query) {
|
||
matches = append(matches, card)
|
||
}
|
||
}
|
||
return rt.Output(map[string]any{"count": len(matches), "todos": matches, "complete": true})
|
||
},
|
||
}
|
||
|
||
var Comment = shortcut.Shortcut{
|
||
OutputRollout: output.RolloutUnifiedActive,
|
||
Service: "todo", Command: "+comment", Product: "todo",
|
||
Description: "添加待办评论并读回验证", Intent: "向指定待办添加评论;取得稳定 commentId 并从评论列表读回才报告成功。",
|
||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
|
||
Contract: todoContract("+comment", "添加待办评论并读回验证", "已知 taskId 且需要发表明确评论时使用", todoObjectResult("已验证的新评论"), `dws todo +comment --task-id <TASK_ID> --content "已处理"`),
|
||
Flags: []shortcut.Flag{
|
||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||
{Name: "content", Type: shortcut.FlagString, Desc: "评论内容", Required: true},
|
||
},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
taskID, content := rt.Str("task-id"), rt.Str("content")
|
||
if content == "" {
|
||
return apperrors.NewValidation("--content 不能为空")
|
||
}
|
||
if rt.DryRun() {
|
||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": taskID})
|
||
}
|
||
before, err := listAllTodoComments(rt, taskID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
beforeIDs := make(map[string]bool, len(before))
|
||
for _, comment := range before {
|
||
beforeIDs[todoStableString(comment, "commentId", "id")] = true
|
||
}
|
||
data, err := rt.CallMCPWriteDataStrict("todo", "add_todo_comment", map[string]any{"taskId": taskID, "content": content})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := requireTodoWriteReceipt(data, "todo/add_todo_comment"); err != nil {
|
||
return err
|
||
}
|
||
after, err := listAllTodoComments(rt, taskID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
matches := make([]map[string]any, 0, 1)
|
||
for _, comment := range after {
|
||
id := todoStableString(comment, "commentId", "id")
|
||
text, _ := comment["content"].(string)
|
||
if id != "" && !beforeIDs[id] && text == content {
|
||
matches = append(matches, comment)
|
||
}
|
||
}
|
||
if len(matches) != 1 {
|
||
return todoResponseError("todo/add_todo_comment", "verification_ambiguous", "评论写入后无法在完整列表中唯一识别新增评论")
|
||
}
|
||
commentID := todoStableString(matches[0], "commentId", "id")
|
||
return rt.Output(map[string]any{"taskId": taskID, "commentId": commentID, "content": content, "verified": true})
|
||
},
|
||
}
|
||
|
||
func listAllTodoComments(rt *shortcut.RuntimeContext, taskID string) ([]map[string]any, error) {
|
||
all := make([]map[string]any, 0)
|
||
for page := 1; page <= todoMaxPages; page++ {
|
||
data, err := rt.CallMCPReadData("todo", "list_todo_comment", map[string]any{
|
||
"taskId": taskID, "page": strconv.Itoa(page), "pageSize": strconv.Itoa(todoPageSize),
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
comments, err := listCommentProjectStrict(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hasMore, err := todoHasMore(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
all = append(all, comments...)
|
||
if !hasMore {
|
||
return all, nil
|
||
}
|
||
}
|
||
return nil, todoResponseError("todo/list_todo_comment", "pagination_limit_reached", "评论列表达到 40 页仍未耗尽,拒绝把不完整列表用于写后验证")
|
||
}
|
||
|
||
var Reminder = shortcut.Shortcut{
|
||
OutputRollout: output.RolloutUnifiedActive,
|
||
Service: "todo", Command: "+reminder", Product: "todo",
|
||
Description: "设置或清除待办提醒(仅终端回执)", Intent: "设置或清除提醒;上游无提醒读接口,结果固定 verified=false。",
|
||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("unknown"),
|
||
Contract: todoContract("+reminder", "设置或清除待办提醒(仅终端回执)", "接受无法读回核验且需要设置/清除提醒时使用", &contract.ResultSpec{
|
||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||
DataSchema: json.RawMessage(`{"type":"object","description":"提醒写入终端回执","properties":{"taskId":{"type":"string","description":"待办 taskId"},"action":{"type":"string","description":"set 或 clear"},"terminalReceipt":{"type":"boolean","description":"是否取得终端成功回执"},"verified":{"type":"boolean","description":"固定 false;上游无法读回提醒规则"}},"required":["taskId","action","terminalReceipt","verified"],"additionalProperties":false}`),
|
||
}, `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30`),
|
||
Flags: []shortcut.Flag{
|
||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||
{Name: "clear", Type: shortcut.FlagBool, Desc: "清除全部提醒规则"},
|
||
{Name: "base-time", Type: shortcut.FlagString, Enum: []string{"dueTime", "customTime"}, Desc: "提醒基准"},
|
||
{Name: "due-date-offset", Type: shortcut.FlagInt, Desc: "相对截止时间的分钟偏移"},
|
||
{Name: "at", Type: shortcut.FlagString, Desc: "customTime 的 ISO8601 时间"},
|
||
},
|
||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||
taskID := rt.Str("task-id")
|
||
clear := rt.Bool("clear")
|
||
baseTime := rt.Str("base-time")
|
||
if clear == (baseTime != "") {
|
||
return apperrors.NewValidation("必须且只能选择 --clear 或 --base-time")
|
||
}
|
||
tool := "reset_todo_reminder"
|
||
params := map[string]any{"todoReminderUpdateRequest": map[string]any{"taskId": taskID, "reminderRules": []any{}}}
|
||
action := "clear"
|
||
if !clear {
|
||
request := map[string]any{"taskId": taskID, "baseTime": baseTime}
|
||
if baseTime == "dueTime" {
|
||
if !rt.Changed("due-date-offset") {
|
||
return apperrors.NewValidation("--base-time=dueTime 要求 --due-date-offset")
|
||
}
|
||
request["dueDateOffset"] = strconv.Itoa(rt.Int("due-date-offset"))
|
||
} else {
|
||
if !rt.Changed("at") {
|
||
return apperrors.NewValidation("--base-time=customTime 要求 --at")
|
||
}
|
||
millis, err := parseTodoMillis("at", rt.Str("at"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
request["reminderTimeStamp"] = millis
|
||
}
|
||
tool = "add_todo_reminder"
|
||
params = map[string]any{"todoReminderAddRequest": request}
|
||
action = "set"
|
||
}
|
||
if rt.DryRun() {
|
||
return rt.Output(map[string]any{"taskId": taskID, "action": action, "terminalReceipt": false, "verified": false, "dryRun": true})
|
||
}
|
||
data, err := rt.CallMCPWriteDataStrict("todo", tool, params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := requireTodoWriteReceipt(data, "todo/"+tool); err != nil {
|
||
return err
|
||
}
|
||
return rt.Output(map[string]any{"taskId": taskID, "action": action, "terminalReceipt": true, "verified": false})
|
||
},
|
||
}
|
||
|
||
var UploadAttachment = shortcut.Shortcut{
|
||
OutputRollout: output.RolloutUnifiedActive,
|
||
Service: "todo",
|
||
Command: "+upload-attachment",
|
||
Product: "todo",
|
||
Description: "上传待办附件(当前请使用原子命令)",
|
||
Intent: "当前 Shortcut 运行时不能安全复用原子命令的带请求头上传事务,因此明确路由到 todo task add-attachment。",
|
||
Risk: shortcut.RiskWrite,
|
||
Safety: todoWriteSafety("unknown"),
|
||
Contract: todoContract(
|
||
"+upload-attachment",
|
||
"上传待办附件(当前请使用原子命令)",
|
||
"需要上传本地文件为待办附件时,使用公开的原子命令完成完整上传事务",
|
||
todoObjectResult("附件上传结果"),
|
||
`dws todo +upload-attachment --task-id <TASK_ID> --file-path ./report.pdf`,
|
||
),
|
||
Flags: []shortcut.Flag{
|
||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||
{Name: "file-path", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
|
||
},
|
||
Execute: func(*shortcut.RuntimeContext) error {
|
||
return apperrors.NewValidation("+upload-attachment 暂不复制原子上传事务;请使用 dws todo task add-attachment --task-id <TASK_ID> --file <PATH>")
|
||
},
|
||
}
|
||
|
||
func nonEmptyStrings(values []string) []string {
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
if value = strings.TrimSpace(value); value != "" {
|
||
out = append(out, value)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func init() {
|
||
items := []*shortcut.Shortcut{&Create, &Update, &Complete, &Reopen, &Search, &Comment, &UploadAttachment, &Reminder}
|
||
for _, item := range items {
|
||
item.Contract.Selection.AgentSummary = item.Description
|
||
item.Contract.Selection.UseWhen = []string{item.Intent}
|
||
}
|
||
shortcut.Register(Create, Update, Complete, Reopen, Search, Comment, UploadAttachment, Reminder)
|
||
}
|