Files
homeagent-sdk/example/vikunja/plugin.go
JianFeeeee 4cf2df5be6 feat(vikunja): Vikunja 任务管理插件(28 工具)
token 走 password+Secret 配置项、每次调用前 ensure() 重读(换 token 无需重启);
v1/v2 差异在插件内处理(建任务 PUT/POST、改任务整对象/PATCH、搜索 ?s=/?q=、
标签对象/{label_id}、TimeEntry 无 seconds 语义);16 项单测 + 沙盒 homed 实测 28 工具全注册。
2026-09-12 23:16:33 +08:00

2535 lines
80 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// Vikunja 插件HomeAgent
//
// 设计要点:
// - 所有连接参数都是「插件配置项」url / token / api_version / …token 以 password 类型存储;
// 每次工具调用前重新读取配置,改完 token 不必重启插件。
// - v1 与 v2 的差异(路径、方法、请求体形状、搜索参数名)集中在 call/compat 段处理,见各 Compatibility 注释。
// - 另提供 vikunja_api 通用直通工具:任何本插件未封装的端点都能调,保证「完整能力」不出现死角。
//
// 版本差异速查(依据实例自带的 v1 swagger 与 v2 openapi 规范逐条核对):
//
// 操作 v1 v2
// 建任务 PUT /projects/{id}/tasks POST /projects/{id}/tasks
// 改任务 POST /tasks/{id}(必须整对象) PATCH /tasks/{id}merge-patch可只给变更字段
// 建评论 PUT /tasks/{id}/comments POST /tasks/{id}/comments
// 加标签 PUT /tasks/{id}/labels POST /tasks/{id}/labels{"label_id":N}
// 加指派 PUT /tasks/{id}/assignees POST /tasks/{id}/assignees
// 批量改 POST /tasks/bulk PUT /tasks/bulk
// 搜任务 ?s= ?q=
// 时间跟踪 — /time-entriesv2 独有)
// Todoist/Trello/微软待办 导入 有 —(只有 TickTick/WeKan/CSV/Planka/Vikunja 文件)
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// ======== 配置键 ========
const (
cfgURL = "url"
cfgToken = "token"
cfgAPIVersion = "api_version"
cfgDefProject = "default_project_id"
cfgMaxItems = "max_items"
cfgCompact = "compact_output"
cfgTimeout = "timeout_seconds"
cfgVerifyTLS = "verify_tls"
defaultURL = "https://vikunja.jianfgit.xyz"
)
// ======== 插件主体 ========
type Plugin struct {
name string
sdk *sdk.PluginSDK
http *http.Client
baseURL string
token string
apiVer string // "v1" 或 "v2"
defProj string
maxItems int
compact bool
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Stop() error { return nil }
// ======== 参数/配置读取小工具 ========
func argStr(a map[string]interface{}, key string) string {
if v, ok := a[key]; ok && v != nil {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return strings.TrimSpace(fmt.Sprint(v))
}
return ""
}
func argInt(a map[string]interface{}, key string, def int) int {
if v, ok := a[key]; ok && v != nil {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
case string:
if n2, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return n2
}
}
}
return def
}
func argBool(a map[string]interface{}, key string) (bool, bool) {
if v, ok := a[key]; ok && v != nil {
switch b := v.(type) {
case bool:
return b, true
case string:
if b2, err := strconv.ParseBool(strings.TrimSpace(b)); err == nil {
return b2, true
}
case float64:
return b != 0, true
}
}
return false, false
}
func argMap(a map[string]interface{}, key string) (map[string]interface{}, bool) {
if v, ok := a[key]; ok && v != nil {
if m, ok := v.(map[string]interface{}); ok {
return m, true
}
if s, ok := v.(string); ok && strings.HasPrefix(strings.TrimSpace(s), "{") {
var m map[string]interface{}
if json.Unmarshal([]byte(s), &m) == nil {
return m, true
}
}
}
return nil, false
}
// argID 读取 ID 参数:兼容数字与字符串两种传法
func argID(a map[string]interface{}, key string) string {
return argStr(a, key)
}
// cfgStr 读取插件配置core 侧 bool/int 以字符串存储,这里一并对齐
func cfgStr(st sdk.SettingsAPI, key, def string) string {
if st == nil {
return def
}
v, err := st.Get(key)
if err != nil || v == nil {
return def
}
if s, ok := v.(string); ok && s != "" {
return s
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
func cfgInt(st sdk.SettingsAPI, key string, def int) int {
s := cfgStr(st, key, "")
if s == "" {
return def
}
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
return def
}
func cfgBool(st sdk.SettingsAPI, key string, def bool) bool {
s := cfgStr(st, key, "")
if s == "" {
return def
}
if b, err := strconv.ParseBool(strings.TrimSpace(s)); err == nil {
return b
}
return def
}
// ======== 配置装载 ========
// ensure 每次调用前重新装载配置:用户改完 token/地址即时生效,无需重启插件。
// sdk 为 nil单测直接构造 Plugin时保留已设置的字段。
func (p *Plugin) ensure() {
if p.sdk == nil {
if p.baseURL == "" {
p.baseURL = defaultURL
}
if p.apiVer == "" {
p.apiVer = "v2"
}
if p.maxItems <= 0 {
p.maxItems = 25
}
if p.http == nil {
p.http = &http.Client{Timeout: 20 * time.Second}
}
return
}
st := p.sdk.Settings()
p.baseURL = strings.TrimRight(cfgStr(st, cfgURL, defaultURL), "/")
p.token = strings.TrimSpace(cfgStr(st, cfgToken, ""))
if v := cfgStr(st, cfgAPIVersion, "v2"); v == "v1" {
p.apiVer = "v1"
} else {
p.apiVer = "v2"
}
p.defProj = cfgStr(st, cfgDefProject, "")
if n := cfgInt(st, cfgMaxItems, 25); n > 0 {
p.maxItems = n
} else {
p.maxItems = 25
}
p.compact = cfgBool(st, cfgCompact, true)
timeout := cfgInt(st, cfgTimeout, 20)
if timeout <= 0 {
timeout = 20
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
if !cfgBool(st, cfgVerifyTLS, true) {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // 用户显式关闭校验
}
p.http = client
}
// ======== HTTP 层 ========
type apiError struct {
Status int
Code int
Message string
}
func (e *apiError) Error() string {
hint := ""
switch e.Status {
case 401:
hint = "(检查插件配置 vikunja.token 是否有效或已过期)"
case 403:
hint = "API Token 权限范围不足,或该操作需要实例管理员)"
case 404:
hint = "ID 不存在,或该端点在此 API 版本下不存在——可用 vikunja_status 看版本能力)"
}
if e.Code != 0 {
return fmt.Sprintf("Vikunja HTTP %d code=%d: %s%s", e.Status, e.Code, e.Message, hint)
}
return fmt.Sprintf("Vikunja HTTP %d: %s%s", e.Status, e.Message, hint)
}
func qv(path string, kv ...string) string {
if len(kv) == 0 {
return path
}
v := url.Values{}
for i := 0; i+1 < len(kv); i += 2 {
if kv[i+1] != "" {
v.Set(kv[i], kv[i+1])
}
}
if len(v) == 0 {
return path
}
return path + "?" + v.Encode()
}
// call 按当前配置的 API 版本请求
func (p *Plugin) call(method, path string, body interface{}) (interface{}, error) {
return p.callVer(p.apiVer, method, path, body, "")
}
// callVer 指定版本请求ver 传 "v1"/"v2" 便于个别端点强制走另一版
func (p *Plugin) callVer(ver, method, path string, body interface{}, contentType string) (interface{}, error) {
if p.token == "" {
return nil, errors.New("未配置 API Token请在插件配置 vikunja.token 填写Vikunja → 设置 → API Tokens 生成tk_ 开头)")
}
full := p.baseURL + "/api/" + ver + path
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("序列化请求体失败: %w", err)
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, full, rd)
if err != nil {
return nil, fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+p.token)
req.Header.Set("Accept", "application/json")
if body != nil {
if contentType == "" {
contentType = "application/json"
}
req.Header.Set("Content-Type", contentType)
}
resp, err := p.http.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 Vikunja 失败(%s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode >= 400 {
var e struct {
Code int `json:"code"`
Message string `json:"message"`
}
if json.Unmarshal(raw, &e) == nil && e.Message != "" {
return nil, &apiError{Status: resp.StatusCode, Code: e.Code, Message: e.Message}
}
return nil, &apiError{Status: resp.StatusCode, Message: strings.TrimSpace(string(raw))}
}
if len(bytes.TrimSpace(raw)) == 0 {
return map[string]interface{}{"ok": true}, nil
}
var out interface{}
if err := json.Unmarshal(raw, &out); err != nil {
return string(raw), nil
}
return out, nil
}
// callVerified 返回 (结果, 错误),供单测与兼容回退使用
func (p *Plugin) callVerified(method, path string, body interface{}) (interface{}, error) {
return p.call(method, path, body)
}
// ======== 版本兼容表 ========
func (p *Plugin) isV1() bool { return p.apiVer == "v1" }
// 建立任务v1=PUTv2=POST同一路径
func (p *Plugin) createTaskMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
// 更新任务v2 走 PATCH(merge-patch)v1 必须整对象 POST
func (p *Plugin) updateTaskMethod() string {
if p.isV1() {
return http.MethodPost
}
return http.MethodPatch
}
// 搜索参数名v1 用 sv2 用 q
func (p *Plugin) searchParam() string {
if p.isV1() {
return "s"
}
return "q"
}
func (p *Plugin) addCommentMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) addLabelMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) addAssigneeMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) bulkMethod() string {
if p.isV1() {
return http.MethodPost
}
return http.MethodPut
}
func (p *Plugin) createProjectMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) updateProjectMethod() string {
if p.isV1() {
return http.MethodPost
}
return http.MethodPatch
}
func (p *Plugin) createLabelMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) updateLabelMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPatch
}
func (p *Plugin) createTeamMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) addTeamMemberMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) subscribeMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) notificationReadMethod() string {
if p.isV1() {
return http.MethodPost
}
return http.MethodPut
}
func (p *Plugin) createWebhookMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
func (p *Plugin) updateWebhookMethod() string {
if p.isV1() {
return http.MethodPost
}
return http.MethodPut
}
func (p *Plugin) uploadAttachmentMethod() string {
if p.isV1() {
return http.MethodPut
}
return http.MethodPost
}
// ======== 输出精简 ========
func briefTask(v interface{}) interface{} {
m, ok := v.(map[string]interface{})
if !ok {
return v
}
out := map[string]interface{}{
"id": m["id"],
"title": m["title"],
"done": m["done"],
"project_id": m["project_id"],
}
for _, k := range []string{"due_date", "start_date", "end_date", "priority", "percent_done", "bucket_id", "is_favorite", "repeat_after"} {
if val, ok := m[k]; ok && val != nil && fmt.Sprint(val) != "" && fmt.Sprint(val) != "0" && fmt.Sprint(val) != "false" {
out[k] = val
}
}
if labels, ok := m["labels"].([]interface{}); ok && len(labels) > 0 {
names := make([]string, 0, len(labels))
for _, l := range labels {
if lm, ok := l.(map[string]interface{}); ok {
if t := fmt.Sprint(lm["title"]); t != "<nil>" && t != "" {
names = append(names, t)
}
}
}
if len(names) > 0 {
out["labels"] = names
}
}
if as, ok := m["assignees"].([]interface{}); ok && len(as) > 0 {
names := make([]string, 0, len(as))
for _, a := range as {
if am, ok := a.(map[string]interface{}); ok {
if u := fmt.Sprint(am["username"]); u != "<nil>" && u != "" {
names = append(names, u)
}
}
}
if len(names) > 0 {
out["assignees"] = names
}
}
return out
}
func (p *Plugin) briefTasks(v interface{}) interface{} {
arr, ok := v.([]interface{})
if !ok {
if m, ok := v.(map[string]interface{}); ok {
if items, ok := m["items"].([]interface{}); ok {
return p.projectList(items, briefTask)
}
}
return v
}
return p.projectList(arr, briefTask)
}
func (p *Plugin) projectList(items []interface{}, fn func(interface{}) interface{}) []interface{} {
out := make([]interface{}, 0, len(items))
for _, it := range items {
out = append(out, fn(it))
}
return out
}
func briefProject(v interface{}) interface{} {
m, ok := v.(map[string]interface{})
if !ok {
return v
}
out := map[string]interface{}{"id": m["id"], "title": m["title"]}
for _, k := range []string{"is_archived", "is_favorite", "hex_color", "parent_project_id", "position"} {
if val, ok := m[k]; ok && val != nil && fmt.Sprint(val) != "false" && fmt.Sprint(val) != "0" && fmt.Sprint(val) != "" {
out[k] = val
}
}
return out
}
func briefLabel(v interface{}) interface{} {
m, ok := v.(map[string]interface{})
if !ok {
return v
}
out := map[string]interface{}{"id": m["id"], "title": m["title"]}
if hc, ok := m["hex_color"]; ok && hc != nil && fmt.Sprint(hc) != "" {
out["hex_color"] = hc
}
return out
}
// briefList 按 fn 投影数组(非数组原样返回)
func briefList(v interface{}, fn func(interface{}) interface{}) interface{} {
arr, ok := v.([]interface{})
if !ok {
return v
}
out := make([]interface{}, 0, len(arr))
for _, it := range arr {
out = append(out, fn(it))
}
return out
}
// ======== 工具注册 ========
func schemas(props map[string]interface{}, required ...string) map[string]interface{} {
m := map[string]interface{}{"type": "object", "properties": props}
if len(required) > 0 {
m["required"] = required
}
return m
}
func pStr(desc string) map[string]interface{} {
return map[string]interface{}{"type": "string", "description": desc}
}
func pInt(desc string) map[string]interface{} {
return map[string]interface{}{"type": "integer", "description": desc}
}
func pBool(desc string) map[string]interface{} {
return map[string]interface{}{"type": "boolean", "description": desc}
}
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
st := s.Settings()
st.RegisterDef(sdk.ConfigDef{
Key: cfgURL, Type: "string", Default: defaultURL,
DisplayName: "Vikunja 地址", Description: "站点根地址(不要带 /api",
Category: "vikunja",
})
st.RegisterDef(sdk.ConfigDef{
Key: cfgToken, Type: "password", Default: "", Secret: true,
DisplayName: "API Token",
Description: "Vikunja → 设置 → API Tokens 生成tk_ 开头。Token 的权限范围决定本插件能力上限:勾选全部范围即为完整能力",
Category: "vikunja", Required: true,
})
st.RegisterDef(sdk.ConfigDef{
Key: cfgAPIVersion, Type: "select", Default: "v2", Options: []string{"v2", "v1"},
DisplayName: "API 版本",
Description: "v2推荐标准 REST含时间跟踪等新能力v1 用于 v2 暂未提供的端点(如 Todoist/Trello/微软待办 导入)",
Category: "vikunja",
})
st.RegisterDef(sdk.ConfigDef{
Key: cfgDefProject, Type: "string", Default: "",
DisplayName: "默认项目 ID", Description: "新建任务未指定 project_id 时落到这里(留空则必须显式指定)",
Category: "vikunja",
})
st.RegisterDef(sdk.ConfigDef{
Key: cfgMaxItems, Type: "int", Default: "25", Min: 1, Max: 200,
DisplayName: "列表返回条数", Description: "列表类工具的默认条数,控制上下文体积",
Category: "vikunja",
})
st.RegisterDef(sdk.ConfigDef{
Key: cfgCompact, Type: "bool", Default: "true",
DisplayName: "精简输出", Description: "任务列表只返回关键字段;关闭则返回 Vikunja 完整对象(体积大)",
Category: "vikunja",
})
st.RegisterDef(sdk.ConfigDef{
Key: cfgTimeout, Type: "int", Default: "20", Min: 1, Max: 300,
DisplayName: "超时(秒)", Category: "vikunja",
})
st.RegisterDef(sdk.ConfigDef{
Key: cfgVerifyTLS, Type: "bool", Default: "true",
DisplayName: "校验 TLS 证书", Description: "自签证书站点可关闭(不建议)",
Category: "vikunja",
})
p.ensure()
p.registerTools()
log.Printf("[%s] started (url=%s api=%s)\n", p.name, p.baseURL, p.apiVer)
return nil
}
func (p *Plugin) registerTools() {
tp := p.name + "_"
// ---- 自检 ----
p.sdk.RegisterTool(tp+"status", sdk.ToolDef{
Name: tp + "status", Description: "检查 Vikunja 连接与配置:地址、当前 Token 对应的用户、API 版本、服务器能力(含 CalDAV 地址)。排查前的第一步",
Parameters: schemas(map[string]interface{}{}),
}, p.handleStatus)
// ---- 任务核心 ----
p.sdk.RegisterTool(tp+"tasks", sdk.ToolDef{
Name: tp + "tasks", Description: "列出/筛选任务。可按项目、完成状态、截止时间、标签、关键词筛选;返回精简字段",
Parameters: schemas(map[string]interface{}{
"project_id": pStr("项目 ID留空则跨项目查询"),
"done": pStr("完成状态any|done|undone默认 undone"),
"due": pStr("截止筛选any|overdue|today|this_week|this_month|no_due默认 any"),
"search": pStr("标题关键词"),
"filter": pStr("Vikunja 原生过滤表达式,如 done = false && due_date < now+48h与 due/search 可叠加"),
"sort_by": pStr("排序字段,如 due_date|priority|title|created默认 due_date"),
"order_by": pStr("asc|desc默认 asc"),
"page": pInt("页码,从 1 开始"),
"limit": pInt("返回条数,默认取插件配置的 max_items"),
}),
}, p.handleTasksList)
p.sdk.RegisterTool(tp+"task_get", sdk.ToolDef{
Name: tp + "task_get", Description: "获取单个任务的完整信息(含描述、提醒、重复规则、附件、评论数等)",
Parameters: schemas(map[string]interface{}{"id": pStr("任务 ID")}, "id"),
}, p.handleTaskGet)
p.sdk.RegisterTool(tp+"task_create", sdk.ToolDef{
Name: tp + "task_create", Description: "新建任务。可用自然语言式参数:标题、描述、截止时间、优先级、标签、指派",
Parameters: schemas(map[string]interface{}{
"project_id": pStr("项目 ID留空用插件配置的默认项目"),
"title": pStr("任务标题"),
"description": pStr("任务描述(支持 Markdown"),
"due_date": pStr("截止时间RFC3339 或 2006-01-02 15:04也接受 today/tomorrow/+3d 这类相对写法"),
"start_date": pStr("开始时间,格式同 due_date"),
"end_date": pStr("结束时间,格式同 due_date"),
"priority": pInt("优先级0 无、1 低、2 中、3 高、4 紧急、5 最高"),
"percent_done": pInt("完成百分比 0-100"),
"labels": pStr("标签 ID逗号分隔"),
"assignees": pStr("指派用户名或用户 ID逗号分隔"),
"bucket_id": pInt("看板桶 ID可选"),
}, "title"),
}, p.handleTaskCreate)
p.sdk.RegisterTool(tp+"task_update", sdk.ToolDef{
Name: tp + "task_update", Description: "修改任务的任意字段(只传要改的字段即可)。改项目、改截止、改优先级、改描述都用它",
Parameters: schemas(map[string]interface{}{
"id": pStr("任务 ID"),
"title": pStr("新标题"),
"description": pStr("新描述"),
"due_date": pStr("新截止时间;传 clear 表示清空"),
"start_date": pStr("新开始时间;传 clear 表示清空"),
"end_date": pStr("新结束时间;传 clear 表示清空"),
"priority": pInt("新优先级 0-5"),
"percent_done": pInt("完成百分比 0-100"),
"done": pBool("是否完成"),
"project_id": pStr("移动到该项目的 ID"),
"repeat_after": pInt("重复间隔0 表示不重复"),
"repeat_mode": pInt("重复模式 0-2"),
"is_favorite": pBool("是否收藏"),
"bucket_id": pInt("移动到看板桶"),
"position": pInt("在看板/列表中的位置"),
"raw_patch": pStr("高级:直接给出 JSON 对象覆盖上述字段v2 走 merge-patchv1 会合并到完整对象后提交)"),
}, "id"),
}, p.handleTaskUpdate)
p.sdk.RegisterTool(tp+"task_done", sdk.ToolDef{
Name: tp + "task_done", Description: "标记任务完成或取消完成(语义化封装,比 task_update 更省参数)",
Parameters: schemas(map[string]interface{}{
"id": pStr("任务 ID"),
"done": pBool("true 完成 / false 取消完成(默认 true"),
}, "id"),
}, p.handleTaskDone)
p.sdk.RegisterTool(tp+"task_delete", sdk.ToolDef{
Name: tp + "task_delete", Description: "删除任务(不可恢复)",
Parameters: schemas(map[string]interface{}{"id": pStr("任务 ID")}, "id"),
NoMemory: true,
}, p.handleTaskDelete)
p.sdk.RegisterTool(tp+"task_bulk", sdk.ToolDef{
Name: tp + "task_bulk", Description: "批量修改任务:一次对多个任务设置完成状态/项目/优先级/截止时间/标签",
Parameters: schemas(map[string]interface{}{
"task_ids": pStr("任务 ID逗号分隔"),
"done": pBool("批量设完成状态"),
"project_id": pStr("批量移动到项目"),
"priority": pInt("批量设优先级"),
"due_date": pStr("批量设截止时间clear 清空)"),
"label_ids": pStr("批量覆盖标签(标签 ID 逗号分隔,空字符串表示清空标签)"),
}, "task_ids"),
}, p.handleTaskBulk)
// ---- 任务从属对象 ----
p.sdk.RegisterTool(tp+"task_assignees", sdk.ToolDef{
Name: tp + "task_assignees", Description: "查看/增删任务的指派对象(负责人)",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|add|remove默认 list"),
"id": pStr("任务 ID"),
"user": pStr("用户名或用户 IDadd/remove 时必填)"),
}, "id"),
}, p.handleTaskAssignees)
p.sdk.RegisterTool(tp+"task_labels", sdk.ToolDef{
Name: tp + "task_labels", Description: "查看/增删任务的标签",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|add|remove默认 list"),
"id": pStr("任务 ID"),
"label_id": pStr("标签 IDadd/remove 时必填)"),
}, "id"),
}, p.handleTaskLabels)
p.sdk.RegisterTool(tp+"task_comments", sdk.ToolDef{
Name: tp + "task_comments", Description: "任务的评论:列出、新增、修改、删除",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|add|update|delete默认 list"),
"id": pStr("任务 ID"),
"comment": pStr("评论内容add/update 时必填)"),
"comment_id": pStr("评论 IDupdate/delete 时必填)"),
}, "id"),
}, p.handleTaskComments)
p.sdk.RegisterTool(tp+"task_relations", sdk.ToolDef{
Name: tp + "task_relations", Description: "任务关联:子任务、前置/后置依赖、相关任务",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|add|remove默认 list"),
"id": pStr("任务 ID"),
"other_task_id": pStr("另一个任务 IDadd/remove 时必填)"),
"kind": pStr("关系类型subtask|parenttask|related|duplicateof|blocking|blocked|precedes|follows默认 subtask"),
}, "id"),
}, p.handleTaskRelations)
p.sdk.RegisterTool(tp+"task_attachments", sdk.ToolDef{
Name: tp + "task_attachments", Description: "任务附件:列出、上传本地文件、删除(下载请用 vikunja_api 取 attachment 的 URL/内容)",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|upload|delete默认 list"),
"id": pStr("任务 ID"),
"path": pStr("要上传的本地文件绝对路径upload 时必填)"),
"attachment_id": pStr("附件 IDdelete 时必填)"),
}, "id"),
}, p.handleTaskAttachments)
// ---- 项目 / 视图 / 桶 ----
p.sdk.RegisterTool(tp+"projects", sdk.ToolDef{
Name: tp + "projects", Description: "项目:列出、查看、新建、修改、删除、归档/取消归档",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|get|create|update|delete|archive默认 list"),
"id": pStr("项目 IDget/update/delete/archive"),
"title": pStr("项目名称create/update"),
"description": pStr("项目描述"),
"hex_color": pStr("颜色,如 #38bdf8"),
"parent_project_id": pStr("父项目 ID做子项目"),
"archived": pBool("归档状态archive"),
"is_favorite": pBool("是否收藏"),
"raw": pStr("高级:直接给出 JSON 对象作为请求体"),
}),
}, p.handleProjects)
p.sdk.RegisterTool(tp+"project_views", sdk.ToolDef{
Name: tp + "project_views", Description: "项目的视图与看板桶:列视图、列桶内任务、把任务移入某个桶(看板拖动的等价操作)",
Parameters: schemas(map[string]interface{}{
"action": pStr("list_views|list_buckets|bucket_tasks|move_to_bucket默认 list_views"),
"project_id": pStr("项目 ID"),
"view_id": pStr("视图 ID"),
"bucket_id": pStr("桶 IDbucket_tasks/move_to_bucket"),
"task_id": pStr("任务 IDmove_to_bucket"),
}, "project_id"),
}, p.handleProjectViews)
// ---- 标签 / 保存的筛选器 ----
p.sdk.RegisterTool(tp+"labels", sdk.ToolDef{
Name: tp + "labels", Description: "标签:列出、新建、修改、删除",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|create|update|delete默认 list"),
"id": pStr("标签 IDupdate/delete"),
"title": pStr("标签名"),
"hex_color": pStr("颜色,如 #22c55e"),
}),
}, p.handleLabels)
p.sdk.RegisterTool(tp+"filters", sdk.ToolDef{
Name: tp + "filters", Description: "保存的筛选器Saved Filter列出、新建、修改、删除",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|create|update|delete|get默认 list"),
"id": pStr("筛选器 ID"),
"title": pStr("名称"),
"filter": pStr("过滤表达式,如 done = false && due_date < now+7d"),
"raw": pStr("高级:直接给出 JSON 请求体"),
}),
}, p.handleFilters)
// ---- 团队 / 分享 ----
p.sdk.RegisterTool(tp+"teams", sdk.ToolDef{
Name: tp + "teams", Description: "团队与成员:列出、新建、改名、删除、加成员、移成员",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|get|create|update|delete|members|add_member|remove_member默认 list"),
"id": pStr("团队 ID"),
"name": pStr("团队名"),
"username": pStr("成员用户名add_member/remove_member"),
}),
}, p.handleTeams)
p.sdk.RegisterTool(tp+"sharing", sdk.ToolDef{
Name: tp + "sharing", Description: "项目分享:列出分享、按用户/团队授权与撤权、链接分享(生成/查看/删除公开链接)",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|add_user|update_user|remove_user|add_team|remove_team|links|create_link|update_link|delete_link默认 list"),
"project_id": pStr("项目 ID"),
"share_id": pStr("用户分享 ID / 团队分享 ID / 链接分享 ID"),
"username": pStr("用户名add_user"),
"team_id": pStr("团队 IDadd_team"),
"permission": pInt("权限位0 只读、1 读写、2 管理(依 Vikunja 版本语义)"),
"right": pInt("链接分享权限位create_link"),
"password": pStr("链接分享密码create_link可选"),
"raw": pStr("高级:直接给出 JSON 请求体"),
}, "project_id"),
}, p.handleSharing)
// ---- 通知 / 订阅 / Webhook ----
p.sdk.RegisterTool(tp+"notifications", sdk.ToolDef{
Name: tp + "notifications", Description: "通知中心:列出、标记单条已读、全部已读",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|mark_read|mark_all_read默认 list"),
"id": pStr("通知 IDmark_read"),
}),
}, p.handleNotifications)
p.sdk.RegisterTool(tp+"subscriptions", sdk.ToolDef{
Name: tp + "subscriptions", Description: "订阅/退订某个实体task/project 等),订阅后可收到变更通知",
Parameters: schemas(map[string]interface{}{
"action": pStr("subscribe|unsubscribe默认 subscribe"),
"entity": pStr("实体类型task|project|namespace 等"),
"entity_id": pStr("实体 ID"),
}, "entity", "entity_id"),
}, p.handleSubscriptions)
p.sdk.RegisterTool(tp+"webhooks", sdk.ToolDef{
Name: tp + "webhooks", Description: "Webhook列出、新建、修改、删除、查看可用事件",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|create|update|delete|events默认 list"),
"project_id": pStr("项目 ID"),
"id": pStr("Webhook IDupdate/delete"),
"target_url": pStr("回调地址create"),
"events": pStr("事件名,逗号分隔,如 task.created,task.updated可用 events 动作查全部"),
"secret": pStr("签名密钥(可选)"),
"raw": pStr("高级:直接给出 JSON 请求体"),
}, "project_id"),
}, p.handleWebhooks)
// ---- 时间跟踪v2 独有)----
p.sdk.RegisterTool(tp+"time_entries", sdk.ToolDef{
Name: tp + "time_entries", Description: "时间跟踪(仅 v2列出耗时记录、补录、修改、删除、开始/停止计时器",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|create|update|delete|timer_start|timer_stop|task_entries默认 list"),
"id": pStr("时长记录 ID / 任务 IDtask_entries 时为任务 ID"),
"task_id": pStr("任务 IDcreate/timer_start"),
"project_id": pStr("项目 IDcreate或与 task 二选一)"),
"seconds": pInt("耗时秒数create/update"),
"start_time": pStr("开始时间create"),
"end_time": pStr("结束时间create"),
"comment": pStr("备注"),
}),
}, p.handleTimeEntries)
// ---- 导入 ----
p.sdk.RegisterTool(tp+"migrate", sdk.ToolDef{
Name: tp + "migrate", Description: "从其它待办系统导入数据。自动选用正确 API 版本Todoist/Trello/微软待办 只有 v1 支持TickTick/WeKan/CSV/Planka/Vikunja 文件 用 v2",
Parameters: schemas(map[string]interface{}{
"action": pStr("sources|auth_url|start|status默认 sources"),
"source": pStr("来源名ticktick|wekan|csv|planka|vikunja-file|todoist|trello|microsoft-todo"),
"code": pStr("OAuth 回调 codeTodoist/Trello/微软待办)"),
"payload": pStr("高级:直接给出 JSON 请求体(如 CSV 导入的配置对象)"),
}),
}, p.handleMigrate)
// ---- 账号 / 实例管理 ----
p.sdk.RegisterTool(tp+"user", sdk.ToolDef{
Name: tp + "user", Description: "当前账号我的信息、通用设置、登录会话可踢设备、API Token 管理、CalDAV 凭据",
Parameters: schemas(map[string]interface{}{
"action": pStr("me|settings|update_settings|sessions|revoke_session|tokens|create_token|delete_token|change_password|caldav默认 me"),
"settings": pStr("高级update_settings 的 JSON 对象"),
"session_id": pStr("会话 IDrevoke_session"),
"token_id": pStr("Token IDdelete_token"),
"title": pStr("新 Token 名称create_token"),
"permissions": pStr("新 Token 权限列表create_token可用 vikunja_api 调 /routes 查看可用项"),
"old_password": pStr("旧密码change_password"),
"new_password": pStr("新密码change_password"),
}),
}, p.handleUser)
p.sdk.RegisterTool(tp+"admin", sdk.ToolDef{
Name: tp + "admin", Description: "实例管理(需要实例管理员):总览、用户列表/新建/删除/提权/停用、改用户密码、项目归属转移",
Parameters: schemas(map[string]interface{}{
"action": pStr("overview|users|create_user|delete_user|set_admin|set_status|set_password|projects|reassign_owner默认 overview"),
"user_id": pStr("用户 ID"),
"username": pStr("新用户用户名create_user"),
"email": pStr("邮箱create_user"),
"password": pStr("密码create_user/set_password"),
"admin": pBool("是否实例管理员set_admin"),
"status": pStr("0 启用 / 1 停用set_status"),
"project_id": pStr("项目 IDreassign_owner"),
"owner_id": pStr("新所有者用户 IDreassign_owner"),
}),
}, p.handleAdmin)
p.sdk.RegisterTool(tp+"reactions", sdk.ToolDef{
Name: tp + "reactions", Description: "表情回应:给任务或评论加/删/查 reaction",
Parameters: schemas(map[string]interface{}{
"action": pStr("list|add|remove默认 list"),
"kind": pStr("实体类型tasks|comments"),
"id": pStr("实体 ID"),
"value": pStr("emoji 字符add/remove"),
}, "kind", "id"),
}, p.handleReactions)
// ---- 兜底直通 ----
p.sdk.RegisterTool(tp+"api", sdk.ToolDef{
Name: tp + "api", Description: "通用 API 直通:调用任意 Vikunja 端点本插件未封装的能力走这里。path 相对 /api/<版本>,例如 GET /tasks/12、POST /tasks/12/relations",
Parameters: schemas(map[string]interface{}{
"method": pStr("GET|POST|PUT|PATCH|DELETE默认 GET"),
"path": pStr("路径,如 /tasks 或 /projects/3/tasks"),
"body": pStr("JSON 字符串或对象(写操作时)"),
"api_version": pStr("强制使用 v1 或 v2留空用插件配置"),
}, "path"),
}, p.handleRawAPI)
}
// ======== 处理器实现 ========
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
p.ensure()
out := map[string]interface{}{
"url": p.baseURL,
"api_version": p.apiVer,
"token_set": p.token != "",
"caldav_url": p.baseURL + "/dav/",
"default_project_id": p.defProj,
}
info, err := p.callVer("v1", http.MethodGet, "/info", nil, "")
if err == nil {
out["server"] = info
}
if p.token == "" {
out["user"] = nil
out["hint"] = "尚未配置 API Token在插件配置 vikunja.token 填入Vikunja → 设置 → API Tokens"
return out, nil
}
me, err := p.call(http.MethodGet, "/user", nil)
if err != nil {
out["user_error"] = err.Error()
return out, nil
}
if m, ok := me.(map[string]interface{}); ok {
out["user"] = map[string]interface{}{
"id": m["id"], "username": m["username"], "email": m["email"],
}
} else {
out["user"] = me
}
out["note"] = "v1 独有能力Todoist/Trello/微软待办 导入v2 独有时间跟踪、Bot、登录会话管理、看板桶任务、批量接口"
return out, nil
}
func (p *Plugin) handleTasksList(args map[string]interface{}) (interface{}, error) {
p.ensure()
limit := argInt(args, "limit", p.maxItems)
page := argInt(args, "page", 1)
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 25
}
donePref := strings.ToLower(argStr(args, "done"))
if donePref == "" {
donePref = "undone"
}
filter := argStr(args, "filter")
switch donePref {
case "done":
filter = joinFilter(filter, "done = true")
case "undone":
filter = joinFilter(filter, "done = false")
}
switch strings.ToLower(argStr(args, "due")) {
case "overdue":
filter = joinFilter(filter, "due_date < now && due_date != null && done = false")
case "today":
filter = joinFilter(filter, "due_date < now+1d && due_date > now-1d")
case "this_week":
filter = joinFilter(filter, "due_date < now+7d")
case "this_month":
filter = joinFilter(filter, "due_date < now+31d")
case "no_due":
filter = joinFilter(filter, "due_date = null")
}
sortBy := argStr(args, "sort_by")
orderBy := argStr(args, "order_by")
if sortBy == "" {
sortBy = "due_date"
}
if orderBy == "" {
orderBy = "asc"
}
projectID := argID(args, "project_id")
search := argStr(args, "search")
base := "/tasks"
if projectID != "" {
base = "/projects/" + projectID + "/tasks"
}
path := qv(base,
"page", strconv.Itoa(page),
"per_page", strconv.Itoa(limit),
"filter", filter,
"sort_by", sortBy,
"order_by", orderBy,
p.searchParam(), search,
)
res, err := p.call(http.MethodGet, path, nil)
if err != nil {
return nil, err
}
if !p.compact {
return res, nil
}
return map[string]interface{}{
"count": len(asSlice(res)),
"tasks": p.briefTasks(res),
}, nil
}
func asSlice(v interface{}) []interface{} {
if arr, ok := v.([]interface{}); ok {
return arr
}
if m, ok := v.(map[string]interface{}); ok {
if items, ok := m["items"].([]interface{}); ok {
return items
}
}
return nil
}
func joinFilter(a, b string) string {
if a == "" {
return b
}
if b == "" {
return a
}
return a + " && " + b
}
func (p *Plugin) handleTaskGet(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
return p.call(http.MethodGet, "/tasks/"+id, nil)
}
// taskBody 由工具参数拼出 Vikunja Task 风格的请求体(只含显式给出的字段)
func taskBody(args map[string]interface{}, includeTitle bool) map[string]interface{} {
body := map[string]interface{}{}
if includeTitle {
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
} else if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "description"); v != "" {
body["description"] = v
}
for _, k := range []string{"due_date", "start_date", "end_date"} {
if v := argStr(args, k); v != "" {
if strings.EqualFold(v, "clear") {
body[k] = nil
} else {
body[k] = normalizeTime(v)
}
}
}
if v, ok := argBool(args, "done"); ok {
body["done"] = v
}
if v, ok := argBool(args, "is_favorite"); ok {
body["is_favorite"] = v
}
for _, k := range []string{"priority", "percent_done", "repeat_after", "repeat_mode", "bucket_id", "position"} {
if _, present := args[k]; present {
body[k] = argInt(args, k, 0)
}
}
if v := argID(args, "project_id"); v != "" {
body["project_id"] = v
}
return body
}
// normalizeTime 容忍几种常见写法无法解析时原样透传Vikunja 会给出明确报错)
func normalizeTime(v string) interface{} {
s := strings.TrimSpace(v)
now := time.Now()
switch strings.ToLower(s) {
case "today":
return time.Date(now.Year(), now.Month(), now.Day(), 18, 0, 0, 0, now.Location()).Format(time.RFC3339)
case "tomorrow":
t := now.AddDate(0, 0, 1)
return time.Date(t.Year(), t.Month(), t.Day(), 18, 0, 0, 0, t.Location()).Format(time.RFC3339)
}
if strings.HasPrefix(s, "+") && strings.HasSuffix(s, "d") {
if n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(s, "+"), "d")); err == nil {
return now.AddDate(0, 0, n).Format(time.RFC3339)
}
}
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04", "2006-01-02 15:04", "2006-01-02"} {
if t, err := time.ParseInLocation(layout, s, now.Location()); err == nil {
return t.Format(time.RFC3339)
}
}
return s
}
func (p *Plugin) handleTaskCreate(args map[string]interface{}) (interface{}, error) {
p.ensure()
pid := argID(args, "project_id")
if pid == "" {
pid = p.defProj
}
if pid == "" {
return nil, errors.New("未指定 project_id且插件配置里没有默认项目vikunja.default_project_id")
}
if argStr(args, "title") == "" {
return nil, errors.New("缺少参数 title任务标题")
}
body := taskBody(args, true)
res, err := p.call(p.createTaskMethod(), "/projects/"+pid+"/tasks", body)
if err != nil {
return nil, err
}
// 标签与指派需要任务 ID创建后补挂
taskID := ""
if m, ok := res.(map[string]interface{}); ok {
taskID = fmt.Sprint(m["id"])
}
notes := []string{}
if taskID != "" && taskID != "<nil>" {
if labels := argStr(args, "labels"); labels != "" {
for _, l := range splitCSV(labels) {
if _, err := p.call(p.addLabelMethod(), "/tasks/"+taskID+"/labels", p.labelBody(l)); err != nil {
notes = append(notes, "标签 "+l+" 添加失败: "+err.Error())
}
}
}
if asg := argStr(args, "assignees"); asg != "" {
for _, u := range splitCSV(asg) {
if _, err := p.call(p.addAssigneeMethod(), "/tasks/"+taskID+"/assignees", map[string]interface{}{"user_id": u, "username": u}); err != nil {
notes = append(notes, "指派 "+u+" 失败: "+err.Error())
}
}
}
}
if len(notes) > 0 {
return map[string]interface{}{"task": res, "warnings": notes}, nil
}
return res, nil
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, x := range parts {
if t := strings.TrimSpace(x); t != "" {
out = append(out, t)
}
}
return out
}
// labelBody v1 收 Label 对象v2 收 {"label_id":N}
func (p *Plugin) labelBody(label string) map[string]interface{} {
if p.isV1() {
if id, err := strconv.Atoi(label); err == nil {
return map[string]interface{}{"id": id, "title": label}
}
return map[string]interface{}{"title": label}
}
if id, err := strconv.Atoi(label); err == nil {
return map[string]interface{}{"label_id": id}
}
return map[string]interface{}{"label_id": label}
}
func (p *Plugin) handleTaskUpdate(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
body := taskBody(args, false)
if raw, ok := argMap(args, "raw_patch"); ok {
for k, v := range raw {
body[k] = v
}
}
if len(body) == 0 {
return nil, errors.New("没有给出要修改的字段")
}
if !p.isV1() {
// v2PATCH 支持 merge-patch只发变更字段
res, err := p.callVer("v2", http.MethodPatch, "/tasks/"+id, body, "application/merge-patch+json")
if err == nil {
return res, nil
}
var ae *apiError
if !errors.As(err, &ae) || (ae.Status != 400 && ae.Status != 405 && ae.Status != 415) {
return nil, err
}
// merge-patch 被拒时退回「取回-合并-整体 PUT」
return p.updateTaskByMerge(id, body)
}
// v1POST /tasks/{id} 要求完整对象,先取回再合并
return p.updateTaskByMerge(id, body)
}
// updateTaskByMerge 取回任务 → 合并变更 → 整体提交v1 用 POSTv2 用 PUT
func (p *Plugin) updateTaskByMerge(id string, changes map[string]interface{}) (interface{}, error) {
cur, err := p.call(http.MethodGet, "/tasks/"+id, nil)
if err != nil {
return nil, err
}
obj, ok := cur.(map[string]interface{})
if !ok {
return nil, errors.New("取回任务失败:返回体不是对象")
}
for k, v := range changes {
obj[k] = v
}
if _, ok := obj["id"]; !ok {
obj["id"] = id
}
method := http.MethodPut
if p.isV1() {
method = http.MethodPost
}
return p.call(method, "/tasks/"+id, obj)
}
func (p *Plugin) handleTaskDone(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
done := true
if v, ok := argBool(args, "done"); ok {
done = v
}
return p.updateTaskByMerge(id, map[string]interface{}{"done": done})
}
func (p *Plugin) handleTaskDelete(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
if _, err := p.call(http.MethodDelete, "/tasks/"+id, nil); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "deleted_task_id": id}, nil
}
func (p *Plugin) handleTaskBulk(args map[string]interface{}) (interface{}, error) {
p.ensure()
idsRaw := argStr(args, "task_ids")
if idsRaw == "" {
return nil, errors.New("缺少参数 task_ids逗号分隔的任务 ID")
}
taskIDs := make([]interface{}, 0)
for _, s := range splitCSV(idsRaw) {
if n, err := strconv.Atoi(s); err == nil {
taskIDs = append(taskIDs, n)
} else {
taskIDs = append(taskIDs, s)
}
}
body := map[string]interface{}{"task_ids": taskIDs}
fields := map[string]interface{}{}
if v, ok := argBool(args, "done"); ok {
fields["done"] = v
}
if v := argID(args, "project_id"); v != "" {
fields["project_id"] = v
}
if _, present := args["priority"]; present {
fields["priority"] = argInt(args, "priority", 0)
}
if v := argStr(args, "due_date"); v != "" {
if strings.EqualFold(v, "clear") {
fields["due_date"] = nil
} else {
fields["due_date"] = normalizeTime(v)
}
}
if _, present := args["label_ids"]; present {
v := argStr(args, "label_ids")
fields["labels"] = collectLabels(v)
}
if len(fields) > 0 {
body["fields"] = fields
}
return p.call(p.bulkMethod(), "/tasks/bulk", body)
}
func collectLabels(v string) []interface{} {
out := []interface{}{}
for _, s := range splitCSV(v) {
if n, err := strconv.Atoi(s); err == nil {
out = append(out, map[string]interface{}{"id": n})
} else {
out = append(out, map[string]interface{}{"title": s})
}
}
return out
}
func (p *Plugin) handleTaskAssignees(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
switch action {
case "list":
return p.call(http.MethodGet, "/tasks/"+id+"/assignees", nil)
case "add":
user := argStr(args, "user")
if user == "" {
return nil, errors.New("add 需要参数 user用户名或用户 ID")
}
body := map[string]interface{}{"username": user}
if n, err := strconv.Atoi(user); err == nil {
body["user_id"] = n
}
return p.call(p.addAssigneeMethod(), "/tasks/"+id+"/assignees", body)
case "remove":
user := argStr(args, "user")
if user == "" {
return nil, errors.New("remove 需要参数 user用户 ID")
}
return p.call(http.MethodDelete, "/tasks/"+id+"/assignees/"+user, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|add|remove", action)
}
func (p *Plugin) handleTaskLabels(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
switch action {
case "list":
return p.call(http.MethodGet, "/tasks/"+id+"/labels", nil)
case "add":
label := argStr(args, "label_id")
if label == "" {
return nil, errors.New("add 需要参数 label_id")
}
return p.call(p.addLabelMethod(), "/tasks/"+id+"/labels", p.labelBody(label))
case "remove":
label := argStr(args, "label_id")
if label == "" {
return nil, errors.New("remove 需要参数 label_id")
}
return p.call(http.MethodDelete, "/tasks/"+id+"/labels/"+label, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|add|remove", action)
}
func (p *Plugin) handleTaskComments(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
switch action {
case "list":
return p.call(http.MethodGet, "/tasks/"+id+"/comments", nil)
case "add":
text := argStr(args, "comment")
if text == "" {
return nil, errors.New("add 需要参数 comment")
}
return p.call(p.addCommentMethod(), "/tasks/"+id+"/comments", map[string]interface{}{"comment": text})
case "update":
cid := argStr(args, "comment_id")
text := argStr(args, "comment")
if cid == "" || text == "" {
return nil, errors.New("update 需要参数 comment_id 与 comment")
}
method := http.MethodPut
if p.isV1() {
method = http.MethodPost
}
return p.call(method, "/tasks/"+id+"/comments/"+cid, map[string]interface{}{"comment": text})
case "delete":
cid := argStr(args, "comment_id")
if cid == "" {
return nil, errors.New("delete 需要参数 comment_id")
}
return p.call(http.MethodDelete, "/tasks/"+id+"/comments/"+cid, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|add|update|delete", action)
}
func (p *Plugin) handleTaskRelations(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
kind := argStr(args, "kind")
if kind == "" {
kind = "subtask"
}
switch action {
case "list":
return p.call(http.MethodGet, "/tasks/"+id+"/relations", nil)
case "add":
other := argStr(args, "other_task_id")
if other == "" {
return nil, errors.New("add 需要参数 other_task_id")
}
body := map[string]interface{}{
"task_id": parseID(other),
"relation_kind": kind,
}
return p.call(http.MethodPost, "/tasks/"+id+"/relations", body)
case "remove":
other := argStr(args, "other_task_id")
if other == "" {
return nil, errors.New("remove 需要参数 other_task_id")
}
return p.call(http.MethodDelete, "/tasks/"+id+"/relations/"+kind+"/"+other, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|add|remove", action)
}
func parseID(s string) interface{} {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
return s
}
func (p *Plugin) handleTaskAttachments(args map[string]interface{}) (interface{}, error) {
p.ensure()
id := argID(args, "id")
if id == "" {
return nil, errors.New("缺少参数 id任务 ID")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
switch action {
case "list":
return p.call(http.MethodGet, "/tasks/"+id+"/attachments", nil)
case "upload":
path := argStr(args, "path")
if path == "" {
return nil, errors.New("upload 需要参数 path本地文件绝对路径")
}
return p.uploadAttachment(id, path)
case "delete":
aid := argStr(args, "attachment_id")
if aid == "" {
return nil, errors.New("delete 需要参数 attachment_id")
}
return p.call(http.MethodDelete, "/tasks/"+id+"/attachments/"+aid, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|upload|delete", action)
}
func (p *Plugin) uploadAttachment(taskID, path string) (interface{}, error) {
if p.token == "" {
return nil, errors.New("未配置 API Token")
}
f, err := os.Open(filepath.Clean(path))
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
defer f.Close()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("files", filepath.Base(path))
if err != nil {
return nil, err
}
if _, err := io.Copy(part, f); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest(p.uploadAttachmentMethod(), p.baseURL+"/api/"+p.apiVer+"/tasks/"+taskID+"/attachments", &buf)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.token)
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := p.http.Do(req)
if err != nil {
return nil, fmt.Errorf("上传失败: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode >= 400 {
return nil, &apiError{Status: resp.StatusCode, Message: strings.TrimSpace(string(raw))}
}
var out interface{}
if json.Unmarshal(raw, &out) == nil {
return out, nil
}
return map[string]interface{}{"ok": true, "raw": string(raw)}, nil
}
// ---- 项目 ----
func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
id := argID(args, "id")
switch action {
case "list":
res, err := p.call(http.MethodGet, "/projects", nil)
if err != nil {
return nil, err
}
if !p.compact {
return res, nil
}
return map[string]interface{}{"count": len(asSlice(res)), "projects": briefList(res, briefProject)}, nil
case "get":
if id == "" {
return nil, errors.New("get 需要参数 id")
}
return p.call(http.MethodGet, "/projects/"+id, nil)
case "create":
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{}
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "description"); v != "" {
body["description"] = v
}
if v := argStr(args, "hex_color"); v != "" {
body["hex_color"] = v
}
if v := argStr(args, "parent_project_id"); v != "" {
body["parent_project_id"] = parseID(v)
}
}
if _, ok := body["title"]; !ok {
return nil, errors.New("create 需要参数 title")
}
return p.call(p.createProjectMethod(), "/projects", body)
case "update":
if id == "" {
return nil, errors.New("update 需要参数 id")
}
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{}
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "description"); v != "" {
body["description"] = v
}
if v := argStr(args, "hex_color"); v != "" {
body["hex_color"] = v
}
if b, ok := argBool(args, "is_favorite"); ok {
body["is_favorite"] = b
}
}
if len(body) == 0 {
return nil, errors.New("update 没有给出要修改的字段title/description/hex_color/is_favorite 或 raw")
}
return p.projectUpdate(id, body)
case "delete":
if id == "" {
return nil, errors.New("delete 需要参数 id")
}
if _, err := p.call(http.MethodDelete, "/projects/"+id, nil); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "deleted_project_id": id}, nil
case "archive":
if id == "" {
return nil, errors.New("archive 需要参数 id")
}
archived := true
if b, ok := argBool(args, "archived"); ok {
archived = b
}
return p.projectUpdate(id, map[string]interface{}{"is_archived": archived})
}
return nil, fmt.Errorf("未知 action: %s可用 list|get|create|update|delete|archive", action)
}
// projectUpdate v22.4+)项目更新以 PUT 为主、PATCH 亦可v1 用 POST
func (p *Plugin) projectUpdate(id string, changes map[string]interface{}) (interface{}, error) {
if p.isV1() {
cur, err := p.call(http.MethodGet, "/projects/"+id, nil)
if err != nil {
return nil, err
}
obj, ok := cur.(map[string]interface{})
if !ok {
return nil, errors.New("取回项目失败:返回体不是对象")
}
for k, v := range changes {
obj[k] = v
}
return p.call(http.MethodPost, "/projects/"+id, obj)
}
res, err := p.callVer("v2", http.MethodPatch, "/projects/"+id, changes, "application/merge-patch+json")
if err == nil {
return res, nil
}
var ae *apiError
if !errors.As(err, &ae) || (ae.Status != 400 && ae.Status != 405 && ae.Status != 415) {
return nil, err
}
cur, err2 := p.call(http.MethodGet, "/projects/"+id, nil)
if err2 != nil {
return nil, err
}
obj, ok := cur.(map[string]interface{})
if !ok {
return nil, errors.New("取回项目失败:返回体不是对象")
}
for k, v := range changes {
obj[k] = v
}
return p.call(http.MethodPut, "/projects/"+id, obj)
}
func (p *Plugin) handleProjectViews(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list_views"
}
pid := argID(args, "project_id")
if pid == "" {
return nil, errors.New("缺少参数 project_id")
}
vid := argStr(args, "view_id")
bid := argStr(args, "bucket_id")
switch action {
case "list_views":
return p.call(http.MethodGet, "/projects/"+pid+"/views", nil)
case "list_buckets":
if vid == "" {
return nil, errors.New("list_buckets 需要参数 view_id")
}
return p.call(http.MethodGet, "/projects/"+pid+"/views/"+vid+"/buckets", nil)
case "bucket_tasks":
if vid == "" || bid == "" {
return nil, errors.New("bucket_tasks 需要参数 view_id 与 bucket_id")
}
if p.isV1() {
// v1 用 /projects/{id}/views/{view}/tasks 拿全部再看桶
return p.call(http.MethodGet, "/projects/"+pid+"/views/"+vid+"/tasks", nil)
}
return p.call(http.MethodGet, "/projects/"+pid+"/views/"+vid+"/buckets/"+bid+"/tasks", nil)
case "move_to_bucket":
tid := argStr(args, "task_id")
if vid == "" || bid == "" || tid == "" {
return nil, errors.New("move_to_bucket 需要参数 view_id、bucket_id、task_id")
}
method := http.MethodPut
if p.isV1() {
method = http.MethodPost
}
path := "/projects/" + pid + "/views/" + vid + "/buckets/" + bid + "/tasks"
return p.call(method, path, map[string]interface{}{"task_id": parseID(tid)})
}
return nil, fmt.Errorf("未知 action: %s可用 list_views|list_buckets|bucket_tasks|move_to_bucket", action)
}
// ---- 标签 / 筛选器 ----
func (p *Plugin) handleLabels(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
id := argID(args, "id")
switch action {
case "list":
res, err := p.call(http.MethodGet, "/labels", nil)
if err != nil {
return nil, err
}
if !p.compact {
return res, nil
}
return map[string]interface{}{"count": len(asSlice(res)), "labels": briefList(res, briefLabel)}, nil
case "create":
body := map[string]interface{}{}
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "hex_color"); v != "" {
body["hex_color"] = v
}
if _, ok := body["title"]; !ok {
return nil, errors.New("create 需要参数 title")
}
return p.call(p.createLabelMethod(), "/labels", body)
case "update":
if id == "" {
return nil, errors.New("update 需要参数 id")
}
body := map[string]interface{}{}
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "hex_color"); v != "" {
body["hex_color"] = v
}
if len(body) == 0 {
return nil, errors.New("update 没有给出要修改的字段")
}
return p.call(p.updateLabelMethod(), "/labels/"+id, body)
case "delete":
if id == "" {
return nil, errors.New("delete 需要参数 id")
}
if _, err := p.call(http.MethodDelete, "/labels/"+id, nil); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "deleted_label_id": id}, nil
}
return nil, fmt.Errorf("未知 action: %s可用 list|create|update|delete", action)
}
func (p *Plugin) handleFilters(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
id := argID(args, "id")
switch action {
case "list":
return p.call(http.MethodGet, "/filters", nil)
case "get":
if id == "" {
return nil, errors.New("get 需要参数 id")
}
return p.call(http.MethodGet, "/filters/"+id, nil)
case "create":
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{}
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "filter"); v != "" {
body["filters"] = map[string]interface{}{"filter": v}
}
}
if _, ok := body["title"]; !ok {
return nil, errors.New("create 需要参数 titlefilter 建议一并给出)")
}
method := p.createLabelMethod() // v1 PUT / v2 POST与 labels 同源规则
return p.call(method, "/filters", body)
case "update":
if id == "" {
return nil, errors.New("update 需要参数 id")
}
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{}
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "filter"); v != "" {
body["filters"] = map[string]interface{}{"filter": v}
}
}
method := http.MethodPatch
if p.isV1() {
method = http.MethodPost
}
return p.call(method, "/filters/"+id, body)
case "delete":
if id == "" {
return nil, errors.New("delete 需要参数 id")
}
if _, err := p.call(http.MethodDelete, "/filters/"+id, nil); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "deleted_filter_id": id}, nil
}
return nil, fmt.Errorf("未知 action: %s可用 list|get|create|update|delete", action)
}
// ---- 团队 / 分享 ----
func (p *Plugin) handleTeams(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
id := argID(args, "id")
switch action {
case "list":
return p.call(http.MethodGet, "/teams", nil)
case "get":
return p.call(http.MethodGet, "/teams/"+id, nil)
case "create":
name := argStr(args, "name")
if name == "" {
return nil, errors.New("create 需要参数 name")
}
return p.call(p.createTeamMethod(), "/teams", map[string]interface{}{"name": name})
case "update":
name := argStr(args, "name")
if id == "" || name == "" {
return nil, errors.New("update 需要参数 id 与 name")
}
method := http.MethodPatch
if p.isV1() {
method = http.MethodPost
}
return p.call(method, "/teams/"+id, map[string]interface{}{"name": name})
case "delete":
if _, err := p.call(http.MethodDelete, "/teams/"+id, nil); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "deleted_team_id": id}, nil
case "members":
return p.call(http.MethodGet, "/teams/"+id+"/members", nil)
case "add_member":
u := argStr(args, "username")
if id == "" || u == "" {
return nil, errors.New("add_member 需要参数 id 与 username")
}
body := map[string]interface{}{"username": u}
if n, err := strconv.Atoi(u); err == nil {
body["user_id"] = n
}
return p.call(p.addTeamMemberMethod(), "/teams/"+id+"/members", body)
case "remove_member":
u := argStr(args, "username")
if id == "" || u == "" {
return nil, errors.New("remove_member 需要参数 id 与 username用户 ID")
}
return p.call(http.MethodDelete, "/teams/"+id+"/members/"+u, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|get|create|update|delete|members|add_member|remove_member", action)
}
func (p *Plugin) handleSharing(args map[string]interface{}) (interface{}, error) {
p.ensure()
pid := argID(args, "project_id")
if pid == "" {
return nil, errors.New("缺少参数 project_id")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
sid := argStr(args, "share_id")
switch action {
case "list":
return p.call(http.MethodGet, "/projects/"+pid+"/shares", nil)
case "add_user":
u := argStr(args, "username")
if u == "" {
return nil, errors.New("add_user 需要参数 username")
}
body := map[string]interface{}{"username": u, "right": argInt(args, "right", 0)}
if n, err := strconv.Atoi(u); err == nil {
body["user_id"] = n
}
return p.call(http.MethodPut, "/projects/"+pid+"/users", body)
case "update_user":
if sid == "" {
return nil, errors.New("update_user 需要参数 share_id")
}
return p.call(http.MethodPut, "/projects/"+pid+"/users/"+sid, map[string]interface{}{"right": argInt(args, "right", 0)})
case "remove_user":
if sid == "" {
return nil, errors.New("remove_user 需要参数 share_id")
}
return p.call(http.MethodDelete, "/projects/"+pid+"/users/"+sid, nil)
case "add_team":
tid := argStr(args, "team_id")
if tid == "" {
return nil, errors.New("add_team 需要参数 team_id")
}
return p.call(http.MethodPut, "/projects/"+pid+"/teams", map[string]interface{}{"team_id": parseID(tid), "right": argInt(args, "right", 0)})
case "remove_team":
if sid == "" {
return nil, errors.New("remove_team 需要参数 share_id")
}
return p.call(http.MethodDelete, "/projects/"+pid+"/teams/"+sid, nil)
case "links":
return p.call(http.MethodGet, "/projects/"+pid+"/shares", nil)
case "create_link":
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{"right": argInt(args, "right", 0)}
if v := argStr(args, "password"); v != "" {
body["password"] = v
}
}
return p.call(http.MethodPut, "/projects/"+pid+"/shares", body)
case "update_link":
if sid == "" {
return nil, errors.New("update_link 需要参数 share_id")
}
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{"right": argInt(args, "right", 0)}
if v := argStr(args, "password"); v != "" {
body["password"] = v
}
}
return p.call(http.MethodPost, "/projects/"+pid+"/shares/"+sid, body)
case "delete_link":
if sid == "" {
return nil, errors.New("delete_link 需要参数 share_id")
}
return p.call(http.MethodDelete, "/projects/"+pid+"/shares/"+sid, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|add_user|update_user|remove_user|add_team|remove_team|links|create_link|update_link|delete_link", action)
}
// ---- 通知 / 订阅 / Webhook ----
func (p *Plugin) handleNotifications(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
switch action {
case "list":
return p.call(http.MethodGet, qv("/notifications", "page", "1", "per_page", strconv.Itoa(p.maxItems)), nil)
case "mark_read":
id := argStr(args, "id")
if id == "" {
return nil, errors.New("mark_read 需要参数 id")
}
return p.call(p.notificationReadMethod(), "/notifications/"+id, nil)
case "mark_all_read":
return p.call(http.MethodPost, "/notifications", map[string]interface{}{"read_all": true})
}
return nil, fmt.Errorf("未知 action: %s可用 list|mark_read|mark_all_read", action)
}
func (p *Plugin) handleSubscriptions(args map[string]interface{}) (interface{}, error) {
p.ensure()
entity := argStr(args, "entity")
eid := argStr(args, "entity_id")
if entity == "" || eid == "" {
return nil, errors.New("需要参数 entity如 task与 entity_id")
}
if strings.EqualFold(argStr(args, "action"), "unsubscribe") {
return p.call(http.MethodDelete, "/subscriptions/"+entity+"/"+eid, nil)
}
return p.call(p.subscribeMethod(), "/subscriptions/"+entity+"/"+eid, nil)
}
func (p *Plugin) handleWebhooks(args map[string]interface{}) (interface{}, error) {
p.ensure()
pid := argID(args, "project_id")
if pid == "" {
return nil, errors.New("缺少参数 project_id")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
id := argStr(args, "id")
switch action {
case "list":
return p.call(http.MethodGet, "/projects/"+pid+"/webhooks", nil)
case "events":
return p.call(http.MethodGet, "/projects/"+pid+"/webhooks", nil)
case "create":
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{}
if v := argStr(args, "target_url"); v != "" {
body["target_url"] = v
}
if v := argStr(args, "events"); v != "" {
evs := make([]interface{}, 0)
for _, e := range splitCSV(v) {
evs = append(evs, e)
}
body["events"] = evs
}
if v := argStr(args, "secret"); v != "" {
body["secret"] = v
}
}
if _, ok := body["target_url"]; !ok {
return nil, errors.New("create 需要参数 target_urlevents 建议一并给出,如 task.created")
}
return p.call(p.createWebhookMethod(), "/projects/"+pid+"/webhooks", body)
case "update":
body, ok := argMap(args, "raw")
if !ok {
body = map[string]interface{}{}
if v := argStr(args, "target_url"); v != "" {
body["target_url"] = v
}
if v := argStr(args, "events"); v != "" {
evs := make([]interface{}, 0)
for _, e := range splitCSV(v) {
evs = append(evs, e)
}
body["events"] = evs
}
}
if id == "" {
return nil, errors.New("update 需要参数 id")
}
return p.call(p.updateWebhookMethod(), "/projects/"+pid+"/webhooks/"+id, body)
case "delete":
if id == "" {
return nil, errors.New("delete 需要参数 id")
}
return p.call(http.MethodDelete, "/projects/"+pid+"/webhooks/"+id, nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|events|create|update|delete", action)
}
// ---- 时间跟踪v2 独有)----
func (p *Plugin) handleTimeEntries(args map[string]interface{}) (interface{}, error) {
p.ensure()
if p.isV1() {
return nil, errors.New("时间跟踪只有 v2 提供:请把插件配置 vikunja.api_version 改为 v2")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
switch action {
case "list":
return p.call(http.MethodGet, qv("/time-entries", "page", "1", "per_page", strconv.Itoa(p.maxItems)), nil)
case "task_entries":
id := argStr(args, "id")
if id == "" {
return nil, errors.New("task_entries 需要参数 id任务 ID")
}
return p.call(http.MethodGet, "/tasks/"+id+"/time-entries", nil)
case "create":
body, err := p.timeEntryBody(args, false)
if err != nil {
return nil, err
}
return p.call(http.MethodPost, "/time-entries", body)
case "update":
id := argStr(args, "id")
if id == "" {
return nil, errors.New("update 需要参数 id")
}
body := map[string]interface{}{}
if v := argStr(args, "comment"); v != "" {
body["comment"] = v
}
if v := argStr(args, "start_time"); v != "" {
body["start_time"] = normalizeTime(v)
}
if v := argStr(args, "end_time"); v != "" {
if strings.EqualFold(v, "clear") {
body["end_time"] = nil
} else {
body["end_time"] = normalizeTime(v)
}
}
if len(body) == 0 {
return nil, errors.New("update 没有给出要修改的字段comment/start_time/end_time")
}
return p.call(http.MethodPatch, "/time-entries/"+id, body)
case "delete":
id := argStr(args, "id")
if id == "" {
return nil, errors.New("delete 需要参数 id")
}
return p.call(http.MethodDelete, "/time-entries/"+id, nil)
case "timer_start":
// v2 没有独立的 start 端点end_time 为空(null) 的 time entry 就是「计时中」
body, err := p.timeEntryBody(args, true)
if err != nil {
return nil, err
}
return p.call(http.MethodPost, "/time-entries", body)
case "timer_stop":
return p.call(http.MethodPost, "/time-entries/timer/stop", nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|task_entries|create|update|delete|timer_start|timer_stop", action)
}
// timeEntryBody 拼 TimeEntry 请求体。
// 注意v2 的 TimeEntry 没有 seconds 字段——带 end_time 即为已结束的记录,
// end_time 为 null 表示 live timer计时中。因此 seconds 参数在这里换算成 end_time。
func (p *Plugin) timeEntryBody(args map[string]interface{}, timerOnly bool) (map[string]interface{}, error) {
body := map[string]interface{}{}
if v := argStr(args, "task_id"); v != "" {
body["task_id"] = parseID(v)
}
if v := argID(args, "project_id"); v != "" {
body["project_id"] = parseID(v)
}
if _, ok := body["task_id"]; !ok {
if _, ok2 := body["project_id"]; !ok2 {
return nil, errors.New("需要 task_id 或 project_id二者只能给一个")
}
}
if v := argStr(args, "comment"); v != "" {
body["comment"] = v
}
startStr := argStr(args, "start_time")
if startStr == "" {
startStr = time.Now().Format(time.RFC3339)
}
startNorm := normalizeTime(startStr)
body["start_time"] = startNorm
if timerOnly {
body["end_time"] = nil // 显式 null = live timer比省略更少歧义
return body, nil
}
if secs := argInt(args, "seconds", 0); secs > 0 {
if t, err := time.Parse(time.RFC3339, fmt.Sprint(startNorm)); err == nil {
body["end_time"] = t.Add(time.Duration(secs) * time.Second).Format(time.RFC3339)
return body, nil
}
return nil, errors.New("start_time 无法解析为 RFC3339无法据 seconds 推算 end_time请直接传 end_time")
}
if v := argStr(args, "end_time"); v != "" {
if strings.EqualFold(v, "clear") {
body["end_time"] = nil
} else {
body["end_time"] = normalizeTime(v)
}
} else {
body["end_time"] = nil
}
return body, nil
}
// ---- 导入 ----
// migrateSpec 各来源的版本与方法(依据规范逐条核对)
type migrateSpec struct {
ver string
method string
oauth bool
}
var migrateSources = map[string]migrateSpec{
"ticktick": {ver: "v2", method: http.MethodPost},
"wekan": {ver: "v2", method: http.MethodPost},
"csv": {ver: "v2", method: http.MethodPost},
"planka": {ver: "v2", method: http.MethodPost},
"vikunja-file": {ver: "v2", method: http.MethodPost},
"todoist": {ver: "v1", method: http.MethodPost, oauth: true},
"trello": {ver: "v1", method: http.MethodPost, oauth: true},
"microsoft-todo": {ver: "v1", method: http.MethodPost, oauth: true},
}
func (p *Plugin) handleMigrate(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "sources"
}
if action == "sources" {
out := make([]map[string]interface{}, 0, len(migrateSources))
for name, sp := range migrateSources {
out = append(out, map[string]interface{}{
"source": name, "api_version": sp.ver, "needs_oauth": sp.oauth,
})
}
return map[string]interface{}{
"available": out,
"note": "Todoist/Trello/微软待办 只有 v1 提供;其余用 v2。本工具会自动切换版本不受插件默认版本影响",
}, nil
}
src := strings.ToLower(argStr(args, "source"))
sp, ok := migrateSources[src]
if !ok {
return nil, fmt.Errorf("未知来源 %q可用 sources 动作查看)", src)
}
switch action {
case "auth_url":
if !sp.oauth {
return nil, fmt.Errorf("来源 %s 不需要 OAuth 授权", src)
}
return p.callVer(sp.ver, http.MethodGet, "/migration/"+src+"/auth", nil, "")
case "start":
body, hasBody := argMap(args, "payload")
if !hasBody {
body = map[string]interface{}{}
if v := argStr(args, "code"); v != "" {
body["code"] = v
}
}
if src == "csv" {
return p.callVer("v2", http.MethodPost, "/migration/csv/migrate", body, "")
}
return p.callVer(sp.ver, sp.method, "/migration/"+src+"/migrate", body, "")
case "status":
if src == "csv" {
return p.callVer("v2", http.MethodGet, "/migration/csv/status", nil, "")
}
if src == "planka" {
return p.callVer("v2", http.MethodGet, "/migration/planka/status", nil, "")
}
if src == "microsoft-todo" {
return p.callVer("v1", http.MethodGet, "/migration/microsoft-todo/status", nil, "")
}
return p.callVer(sp.ver, http.MethodGet, "/migration/"+src+"/status", nil, "")
}
return nil, fmt.Errorf("未知 action: %s可用 sources|auth_url|start|status", action)
}
// ---- 账号 ----
func (p *Plugin) handleUser(args map[string]interface{}) (interface{}, error) {
p.ensure()
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "me"
}
switch action {
case "me":
return p.call(http.MethodGet, "/user", nil)
case "settings":
return p.call(http.MethodGet, "/user/settings/general", nil)
case "update_settings":
body, ok := argMap(args, "settings")
if !ok {
return nil, errors.New("update_settings 需要参数 settingsJSON 对象)")
}
method := http.MethodPut
if p.isV1() {
method = http.MethodPost
}
return p.call(method, "/user/settings/general", body)
case "sessions":
if p.isV1() {
return nil, errors.New("登录会话管理只有 v2 提供")
}
return p.call(http.MethodGet, "/user/sessions", nil)
case "revoke_session":
if p.isV1() {
return nil, errors.New("登录会话管理只有 v2 提供")
}
id := argStr(args, "session_id")
if id == "" {
return nil, errors.New("revoke_session 需要参数 session_id")
}
return p.call(http.MethodDelete, "/user/sessions/"+id, nil)
case "tokens":
return p.call(http.MethodGet, "/tokens", nil)
case "create_token":
body := map[string]interface{}{}
if v := argStr(args, "title"); v != "" {
body["title"] = v
}
if v := argStr(args, "permissions"); v != "" {
perms := make([]interface{}, 0)
for _, x := range splitCSV(v) {
perms = append(perms, x)
}
body["permissions"] = perms
}
method := http.MethodPost
if p.isV1() {
method = http.MethodPut
}
return p.call(method, "/tokens", body)
case "delete_token":
id := argStr(args, "token_id")
if id == "" {
return nil, errors.New("delete_token 需要参数 token_id")
}
return p.call(http.MethodDelete, "/tokens/"+id, nil)
case "change_password":
oldPw, newPw := argStr(args, "old_password"), argStr(args, "new_password")
if oldPw == "" || newPw == "" {
return nil, errors.New("change_password 需要参数 old_password 与 new_password")
}
return p.call(http.MethodPost, "/user/password", map[string]interface{}{"old_password": oldPw, "new_password": newPw})
case "caldav":
if p.isV1() {
return p.call(http.MethodGet, "/user/settings/token/caldav", nil)
}
return p.call(http.MethodGet, "/user/settings/token/caldav", nil)
}
return nil, fmt.Errorf("未知 action: %s可用 me|settings|update_settings|sessions|revoke_session|tokens|create_token|delete_token|change_password|caldav", action)
}
func (p *Plugin) handleAdmin(args map[string]interface{}) (interface{}, error) {
p.ensure()
if p.isV1() {
return nil, errors.New("实例管理端点在本插件中按 v2 实现,请把 api_version 设为 v2")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "overview"
}
uid := argStr(args, "user_id")
switch action {
case "overview":
return p.call(http.MethodGet, "/admin/overview", nil)
case "users":
return p.call(http.MethodGet, "/admin/users", nil)
case "create_user":
body := map[string]interface{}{}
if v := argStr(args, "username"); v != "" {
body["username"] = v
}
if v := argStr(args, "email"); v != "" {
body["email"] = v
}
if v := argStr(args, "password"); v != "" {
body["password"] = v
}
if _, ok := body["username"]; !ok {
return nil, errors.New("create_user 需要参数 usernameemail/password 建议一并给出)")
}
return p.call(http.MethodPost, "/admin/users", body)
case "delete_user":
if uid == "" {
return nil, errors.New("delete_user 需要参数 user_id")
}
return p.call(http.MethodDelete, "/admin/users/"+uid, nil)
case "set_admin":
if uid == "" {
return nil, errors.New("set_admin 需要参数 user_id 与 admin")
}
adm := true
if b, ok := argBool(args, "admin"); ok {
adm = b
}
return p.call(http.MethodPatch, "/admin/users/"+uid+"/admin", map[string]interface{}{"is_admin": adm})
case "set_status":
if uid == "" {
return nil, errors.New("set_status 需要参数 user_id 与 status")
}
st := argInt(args, "status", 0)
return p.call(http.MethodPatch, "/admin/users/"+uid+"/status", map[string]interface{}{"status": st})
case "set_password":
if uid == "" {
return nil, errors.New("set_password 需要参数 user_id 与 password")
}
pw := argStr(args, "password")
if pw == "" {
return nil, errors.New("set_password 需要参数 password")
}
return p.call(http.MethodPatch, "/admin/users/"+uid+"/password", map[string]interface{}{"password": pw})
case "projects":
return p.call(http.MethodGet, "/admin/projects", nil)
case "reassign_owner":
pid := argID(args, "project_id")
oid := argStr(args, "owner_id")
if pid == "" || oid == "" {
return nil, errors.New("reassign_owner 需要参数 project_id 与 owner_id")
}
return p.call(http.MethodPatch, "/admin/projects/"+pid+"/owner", map[string]interface{}{"owner_id": parseID(oid)})
}
return nil, fmt.Errorf("未知 action: %s可用 overview|users|create_user|delete_user|set_admin|set_status|set_password|projects|reassign_owner", action)
}
func (p *Plugin) handleReactions(args map[string]interface{}) (interface{}, error) {
p.ensure()
kind := strings.ToLower(argStr(args, "kind"))
id := argStr(args, "id")
if kind == "" || id == "" {
return nil, errors.New("需要参数 kindtasks|comments与 id")
}
action := strings.ToLower(argStr(args, "action"))
if action == "" {
action = "list"
}
path := "/" + kind + "/" + id + "/reactions"
switch action {
case "list":
return p.call(http.MethodGet, path, nil)
case "add":
v := argStr(args, "value")
if v == "" {
return nil, errors.New("add 需要参数 valueemoji")
}
return p.call(http.MethodPost, path, map[string]interface{}{"value": v})
case "remove":
v := argStr(args, "value")
if v == "" {
return nil, errors.New("remove 需要参数 valueemoji")
}
return p.call(http.MethodDelete, qv(path, "value", v), nil)
}
return nil, fmt.Errorf("未知 action: %s可用 list|add|remove", action)
}
// ---- 通用直通 ----
func (p *Plugin) handleRawAPI(args map[string]interface{}) (interface{}, error) {
p.ensure()
path := argStr(args, "path")
if path == "" {
return nil, errors.New("缺少参数 path")
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
method := strings.ToUpper(argStr(args, "method"))
if method == "" {
method = http.MethodGet
}
ver := argStr(args, "api_version")
if ver != "v1" && ver != "v2" {
ver = p.apiVer
}
var body interface{}
if raw := argStr(args, "body"); raw != "" {
if err := json.Unmarshal([]byte(raw), &body); err != nil {
return nil, fmt.Errorf("body 不是合法 JSON: %w", err)
}
} else if m, ok := args["body"]; ok && m != nil {
body = m
}
return p.callVer(ver, method, path, body, "")
}