- GetAllTaskFiles: retrieve all files attached to a task - CreateTaskFile: upload file with base64 encoding - DownloadTaskFile: download file with base64 decoding - RemoveTaskFile: delete a task file - TaskScope.GetFiles and UploadFile for fluent API - Automatic base64 encoding/decoding for file content - Comprehensive test coverage
283 lines
8.1 KiB
Go
283 lines
8.1 KiB
Go
package kanboard
|
|
|
|
import "context"
|
|
|
|
// TaskScope provides fluent task-scoped operations.
|
|
type TaskScope struct {
|
|
client *Client
|
|
taskID int
|
|
}
|
|
|
|
// Task returns a TaskScope for fluent task-scoped operations.
|
|
func (c *Client) Task(taskID int) *TaskScope {
|
|
return &TaskScope{
|
|
client: c,
|
|
taskID: taskID,
|
|
}
|
|
}
|
|
|
|
// Get returns the task.
|
|
func (t *TaskScope) Get(ctx context.Context) (*Task, error) {
|
|
return t.client.GetTask(ctx, t.taskID)
|
|
}
|
|
|
|
// Close closes the task (sets it to inactive).
|
|
func (t *TaskScope) Close(ctx context.Context) error {
|
|
return t.client.CloseTask(ctx, t.taskID)
|
|
}
|
|
|
|
// Open opens the task (sets it to active).
|
|
func (t *TaskScope) Open(ctx context.Context) error {
|
|
return t.client.OpenTask(ctx, t.taskID)
|
|
}
|
|
|
|
// MoveToColumn moves the task to a different column.
|
|
// The task is placed at the end of the column (position=0).
|
|
// Requires the project ID to be fetched from the task.
|
|
func (t *TaskScope) MoveToColumn(ctx context.Context, columnID int) error {
|
|
task, err := t.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, columnID, 0, int(task.SwimlaneID))
|
|
}
|
|
|
|
// MoveToNextColumn moves the task to the next column in the workflow.
|
|
// Columns are ordered by their position field.
|
|
// Returns ErrAlreadyInLastColumn if the task is already in the last column.
|
|
func (t *TaskScope) MoveToNextColumn(ctx context.Context) error {
|
|
// Get task to find current column and project
|
|
task, err := t.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get all columns for the project (already sorted by position)
|
|
columns, err := t.client.GetColumns(ctx, int(task.ProjectID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(columns) == 0 {
|
|
return ErrAlreadyInLastColumn
|
|
}
|
|
|
|
// Find the current column index
|
|
currentColumnID := int(task.ColumnID)
|
|
currentIndex := -1
|
|
for i, col := range columns {
|
|
if int(col.ID) == currentColumnID {
|
|
currentIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// If current column not found or is the last column
|
|
if currentIndex == -1 || currentIndex >= len(columns)-1 {
|
|
return ErrAlreadyInLastColumn
|
|
}
|
|
|
|
// Move to the next column
|
|
nextColumn := columns[currentIndex+1]
|
|
return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, int(nextColumn.ID), 0, int(task.SwimlaneID))
|
|
}
|
|
|
|
// MoveToPreviousColumn moves the task to the previous column in the workflow.
|
|
// Columns are ordered by their position field.
|
|
// Returns ErrAlreadyInFirstColumn if the task is already in the first column.
|
|
func (t *TaskScope) MoveToPreviousColumn(ctx context.Context) error {
|
|
// Get task to find current column and project
|
|
task, err := t.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get all columns for the project (already sorted by position)
|
|
columns, err := t.client.GetColumns(ctx, int(task.ProjectID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(columns) == 0 {
|
|
return ErrAlreadyInFirstColumn
|
|
}
|
|
|
|
// Find the current column index
|
|
currentColumnID := int(task.ColumnID)
|
|
currentIndex := -1
|
|
for i, col := range columns {
|
|
if int(col.ID) == currentColumnID {
|
|
currentIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// If current column not found or is the first column
|
|
if currentIndex <= 0 {
|
|
return ErrAlreadyInFirstColumn
|
|
}
|
|
|
|
// Move to the previous column
|
|
prevColumn := columns[currentIndex-1]
|
|
return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, int(prevColumn.ID), 0, int(task.SwimlaneID))
|
|
}
|
|
|
|
// MoveToProject moves the task to a different project.
|
|
func (t *TaskScope) MoveToProject(ctx context.Context, projectID int) error {
|
|
return t.client.MoveTaskToProject(ctx, t.taskID, projectID)
|
|
}
|
|
|
|
// Update updates the task using TaskUpdateParams.
|
|
func (t *TaskScope) Update(ctx context.Context, params *TaskUpdateParams) error {
|
|
return t.client.UpdateTaskFromParams(ctx, t.taskID, params)
|
|
}
|
|
|
|
// GetTags returns the tags assigned to this task as a map of tagID to tag name.
|
|
func (t *TaskScope) GetTags(ctx context.Context) (map[int]string, error) {
|
|
return t.client.GetTaskTags(ctx, t.taskID)
|
|
}
|
|
|
|
// SetTags sets the tags for this task, replacing all existing tags.
|
|
// Tags are specified by name. Non-existent tags will be auto-created.
|
|
//
|
|
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
|
|
func (t *TaskScope) SetTags(ctx context.Context, tags ...string) error {
|
|
task, err := t.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return t.client.SetTaskTags(ctx, int(task.ProjectID), t.taskID, tags)
|
|
}
|
|
|
|
// ClearTags removes all tags from this task.
|
|
//
|
|
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
|
|
func (t *TaskScope) ClearTags(ctx context.Context) error {
|
|
return t.SetTags(ctx)
|
|
}
|
|
|
|
// AddTag adds a tag to this task using a read-modify-write pattern.
|
|
// If the tag already exists on the task, this is a no-op (idempotent).
|
|
//
|
|
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
|
|
func (t *TaskScope) AddTag(ctx context.Context, tag string) error {
|
|
// Get task info for project_id
|
|
task, err := t.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current tags
|
|
currentTags, err := t.client.GetTaskTags(ctx, t.taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if tag already exists (idempotent)
|
|
for _, existingTag := range currentTags {
|
|
if existingTag == tag {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Build new tag list
|
|
tagNames := make([]string, 0, len(currentTags)+1)
|
|
for _, name := range currentTags {
|
|
tagNames = append(tagNames, name)
|
|
}
|
|
tagNames = append(tagNames, tag)
|
|
|
|
// Set updated tags
|
|
return t.client.SetTaskTags(ctx, int(task.ProjectID), t.taskID, tagNames)
|
|
}
|
|
|
|
// RemoveTag removes a tag from this task using a read-modify-write pattern.
|
|
// If the tag doesn't exist on the task, this is a no-op (idempotent).
|
|
//
|
|
// WARNING: This operation is not atomic. Concurrent tag modifications may cause data loss.
|
|
func (t *TaskScope) RemoveTag(ctx context.Context, tag string) error {
|
|
// Get task info for project_id
|
|
task, err := t.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current tags
|
|
currentTags, err := t.client.GetTaskTags(ctx, t.taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Filter out the tag to remove
|
|
tagNames := make([]string, 0, len(currentTags))
|
|
found := false
|
|
for _, name := range currentTags {
|
|
if name == tag {
|
|
found = true
|
|
continue
|
|
}
|
|
tagNames = append(tagNames, name)
|
|
}
|
|
|
|
// If tag wasn't found, nothing to do (idempotent)
|
|
if !found {
|
|
return nil
|
|
}
|
|
|
|
// Set updated tags
|
|
return t.client.SetTaskTags(ctx, int(task.ProjectID), t.taskID, tagNames)
|
|
}
|
|
|
|
// HasTag checks if this task has a specific tag.
|
|
func (t *TaskScope) HasTag(ctx context.Context, tag string) (bool, error) {
|
|
tags, err := t.client.GetTaskTags(ctx, t.taskID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, name := range tags {
|
|
if name == tag {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// GetComments returns all comments for this task.
|
|
func (t *TaskScope) GetComments(ctx context.Context) ([]Comment, error) {
|
|
return t.client.GetAllComments(ctx, t.taskID)
|
|
}
|
|
|
|
// AddComment adds a comment to this task and returns the created comment.
|
|
// The userID is the ID of the user creating the comment.
|
|
func (t *TaskScope) AddComment(ctx context.Context, userID int, content string) (*Comment, error) {
|
|
return t.client.CreateComment(ctx, t.taskID, userID, content)
|
|
}
|
|
|
|
// GetLinks returns all links for this task.
|
|
func (t *TaskScope) GetLinks(ctx context.Context) ([]TaskLink, error) {
|
|
return t.client.GetAllTaskLinks(ctx, t.taskID)
|
|
}
|
|
|
|
// LinkTo creates a link from this task to another task.
|
|
// The linkID specifies the type of relationship (e.g., "blocks", "is blocked by").
|
|
func (t *TaskScope) LinkTo(ctx context.Context, oppositeTaskID, linkID int) error {
|
|
_, err := t.client.CreateTaskLink(ctx, t.taskID, oppositeTaskID, linkID)
|
|
return err
|
|
}
|
|
|
|
// GetFiles returns all files attached to this task.
|
|
func (t *TaskScope) GetFiles(ctx context.Context) ([]TaskFile, error) {
|
|
return t.client.GetAllTaskFiles(ctx, t.taskID)
|
|
}
|
|
|
|
// UploadFile uploads a file to this task and returns the file ID.
|
|
// The file content is automatically base64 encoded.
|
|
func (t *TaskScope) UploadFile(ctx context.Context, filename string, content []byte) (int, error) {
|
|
task, err := t.Get(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return t.client.CreateTaskFile(ctx, int(task.ProjectID), t.taskID, filename, content)
|
|
}
|