From 4cf2df5be6945a90c004162db6ea7ff2034d5040 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 23:16:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(vikunja):=20Vikunja=20=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8F=92=E4=BB=B6=EF=BC=8828=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token 走 password+Secret 配置项、每次调用前 ensure() 重读(换 token 无需重启); v1/v2 差异在插件内处理(建任务 PUT/POST、改任务整对象/PATCH、搜索 ?s=/?q=、 标签对象/{label_id}、TimeEntry 无 seconds 语义);16 项单测 + 沙盒 homed 实测 28 工具全注册。 --- example/vikunja/README.md | 81 + example/vikunja/go.mod | 11 + example/vikunja/plg.json | 12 + example/vikunja/plugin.go | 2534 ++++++++++++++++++++++++++++++++ example/vikunja/plugin_test.go | 403 +++++ 5 files changed, 3041 insertions(+) create mode 100644 example/vikunja/README.md create mode 100644 example/vikunja/go.mod create mode 100644 example/vikunja/plg.json create mode 100644 example/vikunja/plugin.go create mode 100644 example/vikunja/plugin_test.go diff --git a/example/vikunja/README.md b/example/vikunja/README.md new file mode 100644 index 0000000..26ca406 --- /dev/null +++ b/example/vikunja/README.md @@ -0,0 +1,81 @@ +# Vikunja 插件(HomeAgent) + +把 [Vikunja](https://vikunja.io) 待办/任务管理接入 HomeAgent:用自然语言查任务、建任务、改期、完成、看板拖动、指派、评论、时间跟踪、导入数据等。 + +## 配置项(全部可在插件配置界面修改) + +| 键 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `url` | string | `https://vikunja.jianfgit.xyz` | 站点根地址,**不带** `/api` | +| `token` | password(secret) | 空 | **必填**。Vikunja → 设置 → API Tokens 生成(`tk_` 开头)。令牌的权限范围决定本插件能力上限:勾全范围即为完整能力 | +| `api_version` | select | `v2` | `v2`(推荐,标准 REST,含时间跟踪等新能力)或 `v1`(用于 v2 暂未提供的端点) | +| `default_project_id` | string | 空 | 新建任务未指定项目时落到这里;留空则必须显式指定 | +| `max_items` | int | `25` | 列表类工具的默认条数,控制上下文体积 | +| `compact_output` | bool | `true` | 任务/项目/标签列表只返回关键字段;关闭则返回 Vikunja 完整对象 | +| `timeout_seconds` | int | `20` | 单次 HTTP 超时 | +| `verify_tls` | bool | `true` | 自签证书站点可关闭(不建议) | + +配置在**每次工具调用前重新读取**,因此换了 token 不必重启插件。 + +## 工具 + +| 工具 | 能力 | +|---|---| +| `vikunja_status` | 连接/配置自检:地址、token 对应的用户、API 版本、服务器能力、CalDAV 地址 | +| `vikunja_tasks` | 列任务:按项目、完成状态、截止(today/this_week/overdue/no_due)、关键词、原生 filter 表达式 | +| `vikunja_task_get` / `task_create` / `task_update` / `task_done` / `task_delete` | 任务增删改查(`task_update` 只传要改的字段) | +| `vikunja_task_bulk` | 批量改完成状态/项目/优先级/截止/标签 | +| `vikunja_task_assignees` / `task_labels` / `task_comments` / `task_relations` / `task_attachments` | 指派、标签、评论、关联(子任务/依赖/相关)、附件(支持上传本地文件) | +| `vikunja_projects` / `project_views` | 项目增删改查、归档;视图与看板桶(把任务移入桶=看板拖动) | +| `vikunja_labels` / `filters` | 标签、保存的筛选器(Saved Filter) | +| `vikunja_teams` / `sharing` | 团队与成员;项目分享(用户/团队授权、链接分享含密码) | +| `vikunja_notifications` / `subscriptions` / `webhooks` | 通知、订阅、Webhook 管理 | +| `vikunja_time_entries` | 时间跟踪(**仅 v2**):补录/修改/删除、开始与停止计时器 | +| `vikunja_migrate` | 从 TickTick/WeKan/CSV/Planka/Vikunja 文件(v2)与 Todoist/Trello/微软待办(v1)导入 | +| `vikunja_user` / `vikunja_admin` | 当前账号(设置、登录会话、API Token)与实例管理(用户增删/提权/停用/改密、项目归属转移,需实例管理员) | +| `vikunja_reactions` | 任务/评论的表情回应 | +| `vikunja_api` | **通用直通**:调任意端点,未封装的能力走这里(可强制指定 v1/v2),保证能力无死角 | + +## v1 / v2 差异(已按实例自带规范逐条核对) + +插件默认 v2,并自动处理下列差异: + +| 操作 | v1 | v2 | +|---|---|---| +| 建任务 | `PUT /projects/{id}/tasks` | `POST /projects/{id}/tasks` | +| 改任务 | `POST /tasks/{id}`(必须整对象 → 插件自动取回-合并-提交) | `PATCH /tasks/{id}`(merge-patch,只发变更字段;被拒则回落取回-合并-PUT) | +| 搜索参数 | `?s=` | `?q=` | +| 加标签 | `PUT`(Label 对象) | `POST`(`{"label_id":N}`) | +| 批量改 | `POST /tasks/bulk` | `PUT /tasks/bulk` | +| 时间跟踪 | 不支持 | `/time-entries`(`end_time` 为 null 即计时中;停止用 `/time-entries/timer/stop`) | +| 导入 | Todoist / Trello / 微软待办 | TickTick / WeKan / CSV / Planka / Vikunja 文件 | + +> 官方路线:v1 仍支持但新端点只进 v2,3.0 弃用、4.0 移除。除“导入”外建议一律用 v2。 + +## 开发与构建 + +```bash +cd third_party/homeagent-sdk/example/vikunja + +go test -count=1 -race ./... # 16 项测试(httptest 打桩,不需要真 token) +hmapdev build # 产出 dist/vikunja_bundle.hmap +``` + +`go.mod` 里的 `replace` 把 SDK 指向仓库内的 `third_party/homeagent-sdk`,因此无需联网拉私有模块。 + +### 部署到运行实例 + +`.hmap` 包内是 `plugin.json` + `plugin.bin..`,安装时按运行平台重命名入口文件: + +```bash +unzip -o dist/vikunja_bundle.hmap -d /home/newqqagent/plugins/vikunja +cd /home/newqqagent/plugins/vikunja && mv plugin.bin.linux.amd64 plugin.bin +# 然后重载插件(或重启 homeagent.service) +``` + +## 已知边界 + +- **附件下载**未单独封装:`task_attachments` 支持列出/上传/删除,下载请用 `vikunja_api` 访问附件 URL。 +- **链接分享的字段**(`right`/`password`)按 Vikunja 版本语义透传;如遇 4xx,可直接用 `raw` 参数传完整 JSON。 +- **批量改标签**的 `fields` 结构以 `BulkTask` 为准,未在真实实例上验证过(缺少可用 token),如有偏差请用 `vikunja_api` 直通。 +- CalDAV 是客户端协议,插件只提供地址(`vikunja_status` 里的 `caldav_url`),不做 CalDAV 同步。 diff --git a/example/vikunja/go.mod b/example/vikunja/go.mod new file mode 100644 index 0000000..e18d757 --- /dev/null +++ b/example/vikunja/go.mod @@ -0,0 +1,11 @@ +module vikunja-plugin + +go 1.25.0 + +require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0 + +// 与同目录其它示例一致:SDK 指向仓库内的 vendored 副本 + + + +replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0 diff --git a/example/vikunja/plg.json b/example/vikunja/plg.json new file mode 100644 index 0000000..beebe1e --- /dev/null +++ b/example/vikunja/plg.json @@ -0,0 +1,12 @@ +{ + "name": "vikunja", + "name_zh": "Vikunja 待办", + "name_en": "Vikunja", + "version": "1.0.0", + "description": "Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底", + "author": "HomeAgent", + "entry": "plugin.bin", + "sdk": "1.2.0", + "tags": ["vikunja", "todo", "task", "gtd", "productivity"], + "targets": "linux/amd64" +} diff --git a/example/vikunja/plugin.go b/example/vikunja/plugin.go new file mode 100644 index 0000000..56859e0 --- /dev/null +++ b/example/vikunja/plugin.go @@ -0,0 +1,2534 @@ +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-entries(v2 独有) +// 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=PUT,v2=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 用 s,v2 用 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 != "" && 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 != "" && 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-patch;v1 会合并到完整对象后提交)"), + }, "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("用户名或用户 ID(add/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("标签 ID(add/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("评论 ID(update/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("另一个任务 ID(add/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("附件 ID(delete 时必填)"), + }, "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("项目 ID(get/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("桶 ID(bucket_tasks/move_to_bucket)"), + "task_id": pStr("任务 ID(move_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("标签 ID(update/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("团队 ID(add_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("通知 ID(mark_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 ID(update/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 / 任务 ID(task_entries 时为任务 ID)"), + "task_id": pStr("任务 ID(create/timer_start)"), + "project_id": pStr("项目 ID(create,或与 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 回调 code(Todoist/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("会话 ID(revoke_session)"), + "token_id": pStr("Token ID(delete_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("项目 ID(reassign_owner)"), + "owner_id": pStr("新所有者用户 ID(reassign_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 != "" { + 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() { + // v2:PATCH 支持 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) + } + // v1:POST /tasks/{id} 要求完整对象,先取回再合并 + return p.updateTaskByMerge(id, body) +} + +// updateTaskByMerge 取回任务 → 合并变更 → 整体提交(v1 用 POST,v2 用 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 v2(2.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 需要参数 title(filter 建议一并给出)") + } + 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_url(events 建议一并给出,如 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 需要参数 settings(JSON 对象)") + } + 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 需要参数 username(email/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("需要参数 kind(tasks|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 需要参数 value(emoji)") + } + return p.call(http.MethodPost, path, map[string]interface{}{"value": v}) + case "remove": + v := argStr(args, "value") + if v == "" { + return nil, errors.New("remove 需要参数 value(emoji)") + } + 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, "") +} diff --git a/example/vikunja/plugin_test.go b/example/vikunja/plugin_test.go new file mode 100644 index 0000000..0707f22 --- /dev/null +++ b/example/vikunja/plugin_test.go @@ -0,0 +1,403 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// newTestPlugin 构造一个不依赖 sdk 的插件实例,指向 httptest 服务。 +// ensure() 在 sdk==nil 时会保留已设置的字段,因此可以这样直接测处理器。 +func newTestPlugin(t *testing.T, h http.HandlerFunc) (*Plugin, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + p := &Plugin{ + name: "vikunja", + baseURL: srv.URL, + token: "tk_test", + apiVer: "v2", + maxItems: 5, + compact: true, + http: srv.Client(), + } + return p, srv +} + +func mustJSON(t *testing.T, v interface{}) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +// 1) 列表:v2 用 q= 搜索,且 filter 会带上默认 done 条件 +func TestTasksListV2(t *testing.T) { + var gotQuery string + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + if r.Header.Get("Authorization") != "Bearer tk_test" { + t.Errorf("缺少 Bearer 头: %q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"title":"写周报","done":false,"project_id":3,"due_date":"2026-09-13T10:00:00Z","labels":[{"title":"工作"}],"assignees":[{"username":"jianf"}]}]`)) + }) + res, err := p.handleTasksList(map[string]interface{}{"search": "周报", "limit": float64(5)}) + if err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(gotQuery, "q=%E5%91%A8%E6%8A%A5") { + t.Errorf("v2 应使用 q= 搜索,实际 query=%s", gotQuery) + } + if strings.Contains(gotQuery, "s=") { + t.Errorf("v2 不应使用 s=,实际 query=%s", gotQuery) + } + if !strings.Contains(gotQuery, "per_page=5") { + t.Errorf("per_page 未生效: %s", gotQuery) + } + if !strings.Contains(gotQuery, "filter=done+%3D+false") && !strings.Contains(gotQuery, "filter=done%20%3D%20false") { + t.Errorf("默认应过滤未完成,实际 filter 片段: %s", gotQuery) + } + m, ok := res.(map[string]interface{}) + if !ok { + t.Fatalf("结果应为 map,实际 %T", res) + } + if m["count"].(int) != 1 { + t.Errorf("count 应为 1,实际 %v", m["count"]) + } + tasks := m["tasks"].([]interface{}) + tk := tasks[0].(map[string]interface{}) + if _, ok := tk["labels"].([]string); !ok { + t.Errorf("标签应被投影成名称数组,实际 %T", tk["labels"]) + } + if _, ok := tk["description"]; ok { + t.Errorf("精简输出不应出现 description") + } +} + +// 2) 列表:v1 用 s= 搜索 +func TestTasksListV1SearchParam(t *testing.T) { + var gotQuery string + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`[]`)) + }) + p.apiVer = "v1" + if _, err := p.handleTasksList(map[string]interface{}{"search": "abc"}); err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(gotQuery, "s=abc") { + t.Errorf("v1 应使用 s= 搜索,实际 %s", gotQuery) + } + if strings.Contains(gotQuery, "q=") { + t.Errorf("v1 不应出现 q=,实际 %s", gotQuery) + } +} + +// 3) 建任务:v1=PUT、v2=POST(同路径,方法不同) +func TestTaskCreateMethodByVersion(t *testing.T) { + for _, tc := range []struct { + ver string + method string + }{ + {"v1", http.MethodPut}, + {"v2", http.MethodPost}, + } { + var gotMethod, gotPath string + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + _, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`)) + }) + p.apiVer = tc.ver + if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": "3", "title": "买菜"}); err != nil { + t.Fatalf("[%s] err: %v", tc.ver, err) + } + if gotMethod != tc.method { + t.Errorf("[%s] 期望 %s,实际 %s", tc.ver, tc.method, gotMethod) + } + if gotPath != "/api/"+tc.ver+"/projects/3/tasks" { + t.Errorf("[%s] 路径错误: %s", tc.ver, gotPath) + } + } +} + +// 4) 改任务(v2):走 merge-patch,只发变更字段 +func TestTaskUpdateV2MergePatch(t *testing.T) { + var method, ctype string + var body map[string]interface{} + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + method = r.Method + ctype = r.Header.Get("Content-Type") + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + _, _ = w.Write([]byte(`{"id":7,"done":true}`)) + }) + if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "done": true}); err != nil { + t.Fatalf("err: %v", err) + } + if method != http.MethodPatch { + t.Errorf("v2 应用 PATCH,实际 %s", method) + } + if !strings.Contains(ctype, "merge-patch") { + t.Errorf("应使用 merge-patch 内容类型,实际 %s", ctype) + } + if len(body) != 1 || body["done"] != true { + t.Errorf("只应发送变更字段,实际 %v", body) + } +} + +// 5) 改任务(v2)回退:merge-patch 被拒 → 取回-合并-PUT +func TestTaskUpdateV2FallbackToMergePut(t *testing.T) { + var calls []string + var putBody map[string]interface{} + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + switch { + case r.Method == http.MethodPatch: + w.WriteHeader(http.StatusUnsupportedMediaType) + _, _ = w.Write([]byte(`{"code":9,"message":"unsupported media type"}`)) + case r.Method == http.MethodGet: + _, _ = w.Write([]byte(`{"id":7,"title":"旧标题","done":false,"priority":1}`)) + case r.Method == http.MethodPut: + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &putBody) + _, _ = w.Write([]byte(`{"id":7,"title":"新标题","done":false,"priority":1}`)) + default: + t.Errorf("意外请求: %s %s", r.Method, r.URL.Path) + } + }) + if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "7", "title": "新标题"}); err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"PATCH /api/v2/tasks/7", "GET /api/v2/tasks/7", "PUT /api/v2/tasks/7"} + if len(calls) != len(want) { + t.Fatalf("调用序列不符: %v", calls) + } + for i := range want { + if calls[i] != want[i] { + t.Errorf("第 %d 步期望 %s,实际 %s", i+1, want[i], calls[i]) + } + } + if putBody["title"] != "新标题" { + t.Errorf("合并后的 body 应含新标题,实际 %v", putBody) + } + if putBody["priority"] != float64(1) { + t.Errorf("合并必须保留原有字段(priority),实际 %v", putBody) + } +} + +// 6) 改任务(v1):没有 merge-patch,必须取回-合并-POST +func TestTaskUpdateV1FetchMergePost(t *testing.T) { + var calls []string + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"id":9,"title":"旧","priority":2}`)) + return + } + _, _ = w.Write([]byte(`{"id":9,"title":"新","priority":2}`)) + }) + p.apiVer = "v1" + if _, err := p.handleTaskUpdate(map[string]interface{}{"id": "9", "title": "新"}); err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"GET /api/v1/tasks/9", "POST /api/v1/tasks/9"} + if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] { + t.Fatalf("v1 应为 GET→POST,实际 %v", calls) + } +} + +// 7) 错误映射:401 提示检查 token +func TestErrorHint401(t *testing.T) { + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":11,"message":"invalid token"}`)) + }) + _, err := p.handleTasksList(map[string]interface{}{}) + if err == nil { + t.Fatal("应返回错误") + } + msg := err.Error() + if !strings.Contains(msg, "401") || !strings.Contains(msg, "code=11") { + t.Errorf("错误信息应含状态码与 Vikunja code,实际 %s", msg) + } + if !strings.Contains(msg, "token") { + t.Errorf("401 应给出 token 提示,实际 %s", msg) + } +} + +// 8) 未配置 token 时应给出可操作提示,而不是发出无凭据请求 +func TestMissingToken(t *testing.T) { + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("未配置 token 时不应发请求") + }) + p.token = "" + _, err := p.handleTasksList(map[string]interface{}{}) + if err == nil || !strings.Contains(err.Error(), "vikunja.token") { + t.Fatalf("应提示配置项名,实际 %v", err) + } +} + +// 9) 导入:Todoist 必须走 v1(即使插件默认是 v2) +func TestMigrateUsesV1ForTodoist(t *testing.T) { + var path string + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "todoist", "code": "abc"}); err != nil { + t.Fatalf("err: %v", err) + } + if path != "/api/v1/migration/todoist/migrate" { + t.Errorf("Todoist 导入必须走 v1,实际 %s", path) + } +} + +// 10) 导入:WeKan 走 v2 +func TestMigrateUsesV2ForWekan(t *testing.T) { + var path, method string + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + path, method = r.URL.Path, r.Method + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + if _, err := p.handleMigrate(map[string]interface{}{"action": "start", "source": "wekan"}); err != nil { + t.Fatalf("err: %v", err) + } + if path != "/api/v2/migration/wekan/migrate" || method != http.MethodPost { + t.Errorf("WeKan 应走 v2 POST,实际 %s %s", method, path) + } +} + +// 11) 时间跟踪:秒数换算成 end_time;计时开始则不带 end_time +func TestTimeEntrySecondsBecomesEndTime(t *testing.T) { + var body map[string]interface{} + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + _, _ = w.Write([]byte(`{"id":1}`)) + }) + start := "2026-09-12T10:00:00+08:00" + if _, err := p.handleTimeEntries(map[string]interface{}{ + "action": "create", "task_id": "5", "seconds": float64(600), + "start_time": start, + }); err != nil { + t.Fatalf("err: %v", err) + } + // 判据不写死字符串:按时区无关的方式比较两个时间点 + sStart, err := time.Parse(time.RFC3339, start) + if err != nil { + t.Fatalf("case 自身时间写错: %v", err) + } + gotEnd, ok := body["end_time"].(string) + if !ok { + t.Fatalf("应有 end_time,实际 %v", body["end_time"]) + } + tEnd, err := time.Parse(time.RFC3339, gotEnd) + if err != nil { + t.Fatalf("end_time 不是 RFC3339: %q", gotEnd) + } + if diff := tEnd.Sub(sStart); diff != 10*time.Minute { + t.Errorf("end_time 应由 start_time+600s 推出,实际差值 %v", diff) + } + if _, ok := body["seconds"]; ok { + t.Errorf("TimeEntry 没有 seconds 字段,不应发送:%v", body) + } + + body = nil + if _, err := p.handleTimeEntries(map[string]interface{}{"action": "timer_start", "task_id": "5"}); err != nil { + t.Fatalf("err: %v", err) + } + v, present := body["end_time"] + if !present || v != nil { + t.Errorf("计时开始应显式 end_time=null(live timer),实际 %v", body) + } +} + +// 12) 时间跟踪在 v1 下应给出明确不可用提示 +func TestTimeEntryUnavailableOnV1(t *testing.T) { + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {}) + p.apiVer = "v1" + _, err := p.handleTimeEntries(map[string]interface{}{"action": "list"}) + if err == nil || !strings.Contains(err.Error(), "v2") { + t.Fatalf("v1 下应提示改用 v2,实际 %v", err) + } +} + +// 13) 标签:v1 收 Label 对象、v2 收 label_id +func TestLabelBodyByVersion(t *testing.T) { + var body map[string]interface{} + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + _, _ = w.Write([]byte(`{}`)) + }) + if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil { + t.Fatalf("err: %v", err) + } + if body["label_id"] != float64(5) { + t.Errorf("v2 应发送 label_id,实际 %v", body) + } + + body = nil + p.apiVer = "v1" + if _, err := p.handleTaskLabels(map[string]interface{}{"action": "add", "id": "1", "label_id": "5"}); err != nil { + t.Fatalf("err: %v", err) + } + if body["id"] != float64(5) { + t.Errorf("v1 应发送 Label 对象(id),实际 %v", body) + } +} + +// 14) 时间字符串容忍:today / +3d / ISO +func TestNormalizeTime(t *testing.T) { + for _, in := range []string{"today", "tomorrow", "+3d", "2026-09-12 18:00", "2026-09-12T18:00:00+08:00"} { + got := normalizeTime(in) + s, ok := got.(string) + if !ok { + t.Fatalf("%s: 期望字符串,实际 %T", in, got) + } + if _, err := time.Parse(time.RFC3339, s); err != nil { + t.Errorf("%s → %s 不是 RFC3339: %v", in, s, err) + } + } +} + +// 15) 通用直通:可指定 api_version,method 大小写不敏感 +func TestRawAPI(t *testing.T) { + var method, path string + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + _, _ = w.Write([]byte(`[]`)) + }) + if _, err := p.handleRawAPI(map[string]interface{}{"method": "get", "path": "projects", "api_version": "v1"}); err != nil { + t.Fatalf("err: %v", err) + } + if method != http.MethodGet || path != "/api/v1/projects" { + t.Errorf("直通参数未生效: %s %s", method, path) + } +} + +// 16) 精简输出可关闭(关闭时返回原样) +func TestCompactToggle(t *testing.T) { + p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"id":1,"title":"t","description":"很长的描述","done":false}]`)) + }) + p.compact = false + res, err := p.handleTasksList(map[string]interface{}{}) + if err != nil { + t.Fatalf("err: %v", err) + } + arr, ok := res.([]interface{}) + if !ok { + t.Fatalf("关闭精简后应原样返回数组,实际 %T", res) + } + if _, ok := arr[0].(map[string]interface{})["description"]; !ok { + t.Errorf("关闭精简后应保留 description") + } +}