mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 00:48:12 +00:00
线上真调用暴露:vikunja_task_create 把 project_id 当字符串发出 →
422 validation failed: expected integer at body.project_id。同类的还有指派,
而且 v2 的 assignees **根本不接受 username 字段**(422 unexpected property),
两个分支都是坏的。单测没盖到,因为从未发过真实请求体。
实测(vikunja v2.6.0,2026-09-12):
{"project_id":"1"} → 422 expected integer
{"user_id":"1"} → 422 expected integer
{"username":"jianf"} → 422 unexpected property
{"user_id":1} → 201 ✓
{"label_id":1} → 201 ✓(插件本来就 Atoi,无需改)
修法:
- taskBody:project_id 走 parseID(数字)
- 新增 resolveUserID:用户名 → 数字 id,查 GET /users?q=(v1 用 ?s=);
**只认精确匹配**,不做「只有一条就用它」的模糊兜底 —— 指派是写别人任务的动作,猜错人更贵
- assigneeBody:v2 只发 {"user_id":N},不再带 username
- task_assignees remove:路径也用解析后的数字 id
新增 5 项回归测试钉住请求体形状(数字 project_id / user_id、无 username 字段、
数字 ID 不查用户表、移除走数字路径、未知用户给可读错误)。
版本 1.0.0 → 1.0.1。线上复验:create(project_id=1, assignees=jianf) 不再报错、
add→list 显示 jianf、标签 add/remove 正常,测试数据已清理(任务/标签残留 0)。
2618 lines
83 KiB
Go
2618 lines
83 KiB
Go
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 != "<nil>" && t != "" {
|
||
names = append(names, t)
|
||
}
|
||
}
|
||
}
|
||
if len(names) > 0 {
|
||
out["labels"] = names
|
||
}
|
||
}
|
||
if as, ok := m["assignees"].([]interface{}); ok && len(as) > 0 {
|
||
names := make([]string, 0, len(as))
|
||
for _, a := range as {
|
||
if am, ok := a.(map[string]interface{}); ok {
|
||
if u := fmt.Sprint(am["username"]); u != "<nil>" && u != "" {
|
||
names = append(names, u)
|
||
}
|
||
}
|
||
}
|
||
if len(names) > 0 {
|
||
out["assignees"] = names
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (p *Plugin) briefTasks(v interface{}) interface{} {
|
||
arr, ok := v.([]interface{})
|
||
if !ok {
|
||
if m, ok := v.(map[string]interface{}); ok {
|
||
if items, ok := m["items"].([]interface{}); ok {
|
||
return p.projectList(items, briefTask)
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
return p.projectList(arr, briefTask)
|
||
}
|
||
|
||
func (p *Plugin) projectList(items []interface{}, fn func(interface{}) interface{}) []interface{} {
|
||
out := make([]interface{}, 0, len(items))
|
||
for _, it := range items {
|
||
out = append(out, fn(it))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func briefProject(v interface{}) interface{} {
|
||
m, ok := v.(map[string]interface{})
|
||
if !ok {
|
||
return v
|
||
}
|
||
out := map[string]interface{}{"id": m["id"], "title": m["title"]}
|
||
for _, k := range []string{"is_archived", "is_favorite", "hex_color", "parent_project_id", "position"} {
|
||
if val, ok := m[k]; ok && val != nil && fmt.Sprint(val) != "false" && fmt.Sprint(val) != "0" && fmt.Sprint(val) != "" {
|
||
out[k] = val
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func briefLabel(v interface{}) interface{} {
|
||
m, ok := v.(map[string]interface{})
|
||
if !ok {
|
||
return v
|
||
}
|
||
out := map[string]interface{}{"id": m["id"], "title": m["title"]}
|
||
if hc, ok := m["hex_color"]; ok && hc != nil && fmt.Sprint(hc) != "" {
|
||
out["hex_color"] = hc
|
||
}
|
||
return out
|
||
}
|
||
|
||
// briefList 按 fn 投影数组(非数组原样返回)
|
||
func briefList(v interface{}, fn func(interface{}) interface{}) interface{} {
|
||
arr, ok := v.([]interface{})
|
||
if !ok {
|
||
return v
|
||
}
|
||
out := make([]interface{}, 0, len(arr))
|
||
for _, it := range arr {
|
||
out = append(out, fn(it))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ======== 工具注册 ========
|
||
|
||
func schemas(props map[string]interface{}, required ...string) map[string]interface{} {
|
||
m := map[string]interface{}{"type": "object", "properties": props}
|
||
if len(required) > 0 {
|
||
m["required"] = required
|
||
}
|
||
return m
|
||
}
|
||
|
||
func pStr(desc string) map[string]interface{} {
|
||
return map[string]interface{}{"type": "string", "description": desc}
|
||
}
|
||
|
||
func pInt(desc string) map[string]interface{} {
|
||
return map[string]interface{}{"type": "integer", "description": desc}
|
||
}
|
||
|
||
func pBool(desc string) map[string]interface{} {
|
||
return map[string]interface{}{"type": "boolean", "description": desc}
|
||
}
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
p.sdk = s
|
||
s.SetAutoRestart(true)
|
||
|
||
st := s.Settings()
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgURL, Type: "string", Default: defaultURL,
|
||
DisplayName: "Vikunja 地址", Description: "站点根地址(不要带 /api)",
|
||
Category: "vikunja",
|
||
})
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgToken, Type: "password", Default: "", Secret: true,
|
||
DisplayName: "API Token",
|
||
Description: "Vikunja → 设置 → API Tokens 生成(tk_ 开头)。Token 的权限范围决定本插件能力上限:勾选全部范围即为完整能力",
|
||
Category: "vikunja", Required: true,
|
||
})
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgAPIVersion, Type: "select", Default: "v2", Options: []string{"v2", "v1"},
|
||
DisplayName: "API 版本",
|
||
Description: "v2(推荐,标准 REST,含时间跟踪等新能力);v1 用于 v2 暂未提供的端点(如 Todoist/Trello/微软待办 导入)",
|
||
Category: "vikunja",
|
||
})
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgDefProject, Type: "string", Default: "",
|
||
DisplayName: "默认项目 ID", Description: "新建任务未指定 project_id 时落到这里(留空则必须显式指定)",
|
||
Category: "vikunja",
|
||
})
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgMaxItems, Type: "int", Default: "25", Min: 1, Max: 200,
|
||
DisplayName: "列表返回条数", Description: "列表类工具的默认条数,控制上下文体积",
|
||
Category: "vikunja",
|
||
})
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgCompact, Type: "bool", Default: "true",
|
||
DisplayName: "精简输出", Description: "任务列表只返回关键字段;关闭则返回 Vikunja 完整对象(体积大)",
|
||
Category: "vikunja",
|
||
})
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgTimeout, Type: "int", Default: "20", Min: 1, Max: 300,
|
||
DisplayName: "超时(秒)", Category: "vikunja",
|
||
})
|
||
st.RegisterDef(sdk.ConfigDef{
|
||
Key: cfgVerifyTLS, Type: "bool", Default: "true",
|
||
DisplayName: "校验 TLS 证书", Description: "自签证书站点可关闭(不建议)",
|
||
Category: "vikunja",
|
||
})
|
||
|
||
p.ensure()
|
||
p.registerTools()
|
||
log.Printf("[%s] started (url=%s api=%s)\n", p.name, p.baseURL, p.apiVer)
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) registerTools() {
|
||
tp := p.name + "_"
|
||
|
||
// ---- 自检 ----
|
||
p.sdk.RegisterTool(tp+"status", sdk.ToolDef{
|
||
Name: tp + "status", Description: "检查 Vikunja 连接与配置:地址、当前 Token 对应的用户、API 版本、服务器能力(含 CalDAV 地址)。排查前的第一步",
|
||
Parameters: schemas(map[string]interface{}{}),
|
||
}, p.handleStatus)
|
||
|
||
// ---- 任务核心 ----
|
||
p.sdk.RegisterTool(tp+"tasks", sdk.ToolDef{
|
||
Name: tp + "tasks", Description: "列出/筛选任务。可按项目、完成状态、截止时间、标签、关键词筛选;返回精简字段",
|
||
Parameters: schemas(map[string]interface{}{
|
||
"project_id": pStr("项目 ID;留空则跨项目查询"),
|
||
"done": pStr("完成状态:any|done|undone(默认 undone)"),
|
||
"due": pStr("截止筛选:any|overdue|today|this_week|this_month|no_due(默认 any)"),
|
||
"search": pStr("标题关键词"),
|
||
"filter": pStr("Vikunja 原生过滤表达式,如 done = false && due_date < now+48h;与 due/search 可叠加"),
|
||
"sort_by": pStr("排序字段,如 due_date|priority|title|created(默认 due_date)"),
|
||
"order_by": pStr("asc|desc(默认 asc)"),
|
||
"page": pInt("页码,从 1 开始"),
|
||
"limit": pInt("返回条数,默认取插件配置的 max_items"),
|
||
}),
|
||
}, p.handleTasksList)
|
||
|
||
p.sdk.RegisterTool(tp+"task_get", sdk.ToolDef{
|
||
Name: tp + "task_get", Description: "获取单个任务的完整信息(含描述、提醒、重复规则、附件、评论数等)",
|
||
Parameters: schemas(map[string]interface{}{"id": pStr("任务 ID")}, "id"),
|
||
}, p.handleTaskGet)
|
||
|
||
p.sdk.RegisterTool(tp+"task_create", sdk.ToolDef{
|
||
Name: tp + "task_create", Description: "新建任务。可用自然语言式参数:标题、描述、截止时间、优先级、标签、指派",
|
||
Parameters: schemas(map[string]interface{}{
|
||
"project_id": pStr("项目 ID(留空用插件配置的默认项目)"),
|
||
"title": pStr("任务标题"),
|
||
"description": pStr("任务描述(支持 Markdown)"),
|
||
"due_date": pStr("截止时间,RFC3339 或 2006-01-02 15:04;也接受 today/tomorrow/+3d 这类相对写法"),
|
||
"start_date": pStr("开始时间,格式同 due_date"),
|
||
"end_date": pStr("结束时间,格式同 due_date"),
|
||
"priority": pInt("优先级:0 无、1 低、2 中、3 高、4 紧急、5 最高"),
|
||
"percent_done": pInt("完成百分比 0-100"),
|
||
"labels": pStr("标签 ID,逗号分隔"),
|
||
"assignees": pStr("指派用户名或用户 ID,逗号分隔"),
|
||
"bucket_id": pInt("看板桶 ID(可选)"),
|
||
}, "title"),
|
||
}, p.handleTaskCreate)
|
||
|
||
p.sdk.RegisterTool(tp+"task_update", sdk.ToolDef{
|
||
Name: tp + "task_update", Description: "修改任务的任意字段(只传要改的字段即可)。改项目、改截止、改优先级、改描述都用它",
|
||
Parameters: schemas(map[string]interface{}{
|
||
"id": pStr("任务 ID"),
|
||
"title": pStr("新标题"),
|
||
"description": pStr("新描述"),
|
||
"due_date": pStr("新截止时间;传 clear 表示清空"),
|
||
"start_date": pStr("新开始时间;传 clear 表示清空"),
|
||
"end_date": pStr("新结束时间;传 clear 表示清空"),
|
||
"priority": pInt("新优先级 0-5"),
|
||
"percent_done": pInt("完成百分比 0-100"),
|
||
"done": pBool("是否完成"),
|
||
"project_id": pStr("移动到该项目的 ID"),
|
||
"repeat_after": pInt("重复间隔(秒),0 表示不重复"),
|
||
"repeat_mode": pInt("重复模式 0-2"),
|
||
"is_favorite": pBool("是否收藏"),
|
||
"bucket_id": pInt("移动到看板桶"),
|
||
"position": pInt("在看板/列表中的位置"),
|
||
"raw_patch": pStr("高级:直接给出 JSON 对象覆盖上述字段(v2 走 merge-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 != "" {
|
||
// 必须是 JSON 数字:发字符串 "1" 会被 Vikunja 判 422 expected integer
|
||
// (v2 body.project_id 校验 integer;线上实测)
|
||
body["project_id"] = parseID(v)
|
||
}
|
||
return body
|
||
}
|
||
|
||
// normalizeTime 容忍几种常见写法;无法解析时原样透传(Vikunja 会给出明确报错)
|
||
func normalizeTime(v string) interface{} {
|
||
s := strings.TrimSpace(v)
|
||
now := time.Now()
|
||
switch strings.ToLower(s) {
|
||
case "today":
|
||
return time.Date(now.Year(), now.Month(), now.Day(), 18, 0, 0, 0, now.Location()).Format(time.RFC3339)
|
||
case "tomorrow":
|
||
t := now.AddDate(0, 0, 1)
|
||
return time.Date(t.Year(), t.Month(), t.Day(), 18, 0, 0, 0, t.Location()).Format(time.RFC3339)
|
||
}
|
||
if strings.HasPrefix(s, "+") && strings.HasSuffix(s, "d") {
|
||
if n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(s, "+"), "d")); err == nil {
|
||
return now.AddDate(0, 0, n).Format(time.RFC3339)
|
||
}
|
||
}
|
||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04", "2006-01-02 15:04", "2006-01-02"} {
|
||
if t, err := time.ParseInLocation(layout, s, now.Location()); err == nil {
|
||
return t.Format(time.RFC3339)
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
func (p *Plugin) handleTaskCreate(args map[string]interface{}) (interface{}, error) {
|
||
p.ensure()
|
||
pid := argID(args, "project_id")
|
||
if pid == "" {
|
||
pid = p.defProj
|
||
}
|
||
if pid == "" {
|
||
return nil, errors.New("未指定 project_id,且插件配置里没有默认项目(vikunja.default_project_id)")
|
||
}
|
||
if argStr(args, "title") == "" {
|
||
return nil, errors.New("缺少参数 title(任务标题)")
|
||
}
|
||
body := taskBody(args, true)
|
||
res, err := p.call(p.createTaskMethod(), "/projects/"+pid+"/tasks", body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 标签与指派需要任务 ID,创建后补挂
|
||
taskID := ""
|
||
if m, ok := res.(map[string]interface{}); ok {
|
||
taskID = fmt.Sprint(m["id"])
|
||
}
|
||
notes := []string{}
|
||
if taskID != "" && taskID != "<nil>" {
|
||
if labels := argStr(args, "labels"); labels != "" {
|
||
for _, l := range splitCSV(labels) {
|
||
if _, err := p.call(p.addLabelMethod(), "/tasks/"+taskID+"/labels", p.labelBody(l)); err != nil {
|
||
notes = append(notes, "标签 "+l+" 添加失败: "+err.Error())
|
||
}
|
||
}
|
||
}
|
||
if asg := argStr(args, "assignees"); asg != "" {
|
||
for _, u := range splitCSV(asg) {
|
||
body, berr := p.assigneeBody(u)
|
||
if berr != nil {
|
||
notes = append(notes, "指派 "+u+" 失败: "+berr.Error())
|
||
continue
|
||
}
|
||
if _, err := p.call(p.addAssigneeMethod(), "/tasks/"+taskID+"/assignees", body); 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, err := p.assigneeBody(user)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return p.call(p.addAssigneeMethod(), "/tasks/"+id+"/assignees", body)
|
||
case "remove":
|
||
user := argStr(args, "user")
|
||
if user == "" {
|
||
return nil, errors.New("remove 需要参数 user(用户名或用户 ID)")
|
||
}
|
||
uid, err := p.resolveUserID(user)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return p.call(http.MethodDelete, "/tasks/"+id+"/assignees/"+strconv.Itoa(uid), 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
|
||
}
|
||
|
||
// numOf 把 JSON 解出的值取成整数(SDK 放进 map 的数字是 float64)
|
||
func numOf(v interface{}) int {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return int(n)
|
||
case int:
|
||
return n
|
||
case int64:
|
||
return int(n)
|
||
case json.Number:
|
||
if i, err := n.Int64(); err == nil {
|
||
return int(i)
|
||
}
|
||
case string:
|
||
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// resolveUserID 把「用户名或 ID」解析成**数字** user_id。
|
||
//
|
||
// 为什么必须解析:v2 的 assignees 只接受 {"user_id":N}
|
||
// - 发 {"username":"x"} → 422 unexpected property
|
||
// - 发 {"user_id":"1"} → 422 expected integer
|
||
//
|
||
// 两种都已在线上实测确认(2026-09-12)。
|
||
func (p *Plugin) resolveUserID(user string) (int, error) {
|
||
u := strings.TrimSpace(user)
|
||
if u == "" {
|
||
return 0, errors.New("需要参数 user(用户名或用户 ID)")
|
||
}
|
||
if n, err := strconv.Atoi(u); err == nil {
|
||
return n, nil
|
||
}
|
||
res, err := p.call(http.MethodGet, qv("/users", p.searchParam(), u), nil)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("按用户名 %q 查用户失败: %w", u, err)
|
||
}
|
||
items := asSlice(res)
|
||
var names []string
|
||
for _, it := range items {
|
||
m, ok := it.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
name := fmt.Sprint(m["username"])
|
||
names = append(names, name)
|
||
if strings.EqualFold(name, u) {
|
||
if id := numOf(m["id"]); id > 0 {
|
||
return id, nil
|
||
}
|
||
}
|
||
}
|
||
// 只认精确匹配(不区分大小写)。不做「只有一条就用它」的模糊兜底:
|
||
// 指派会写到别人的任务上,猜错人比让模型改用数字 ID 更贵。
|
||
if len(names) > 0 {
|
||
return 0, fmt.Errorf("找不到用户 %q(同名/相近的:%s);也可直接传数字用户 ID", u, strings.Join(names, ", "))
|
||
}
|
||
return 0, fmt.Errorf("找不到用户 %q", u)
|
||
}
|
||
|
||
// assigneeBody 拼出指派请求体:只含数字 user_id(见 resolveUserID 的说明)
|
||
func (p *Plugin) assigneeBody(user string) (map[string]interface{}, error) {
|
||
uid, err := p.resolveUserID(user)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]interface{}{"user_id": uid}, nil
|
||
}
|
||
|
||
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, "")
|
||
}
|