b5a203d2aa
- Add bulk delete, bulk tags, and bulk set-tags engine endpoints (POST /api/v1/bulk/delete, /bulk/tags, /bulk/set-tags) - Filter-based selection: by tags, doc_type, ID list, ID range - Safety threshold (KB_BULK_SAFETY_PERCENT, default 70%) prevents accidental mass operations unless force=true - Synchronous execution with audit trail via jobs table - Add kb_bulk_delete, kb_bulk_tags, kb_bulk_set_tags MCP tools - Add kb bulk-remove, bulk-tag, bulk-set-tags CLI commands - Remove collection abstraction from MCP server (use tags instead) - Remove kb_set_collection MCP tool - Update SKILL.md, MCP.md, README.md documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
187 lines
4.5 KiB
Go
187 lines
4.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/kb-search/kb/internal/api"
|
|
"github.com/kb-search/kb/internal/output"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var bulkRemoveCmd = &cobra.Command{
|
|
Use: "bulk-remove",
|
|
Short: "Delete multiple documents matching a filter",
|
|
RunE: runBulkRemove,
|
|
}
|
|
|
|
func init() {
|
|
addBulkFilterFlags(bulkRemoveCmd)
|
|
rootCmd.AddCommand(bulkRemoveCmd)
|
|
}
|
|
|
|
func runBulkRemove(cmd *cobra.Command, args []string) error {
|
|
body, err := buildBulkBody(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
yes, _ := cmd.Flags().GetBool("yes")
|
|
if !yes {
|
|
desc := describeBulkFilter(cmd)
|
|
fmt.Printf("This will delete documents matching: %s\nProceed? [y/N] ", desc)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
answer, _ := reader.ReadString('\n')
|
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
if answer != "y" && answer != "yes" {
|
|
fmt.Println("Cancelled.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
client := api.NewClient()
|
|
resp, err := client.Post("/api/v1/bulk/delete", body)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
if err := api.CheckError(resp); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := api.DecodeJSON(resp, &result); err != nil {
|
|
return fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
if output.IsJSON() {
|
|
output.PrintJSON(result)
|
|
} else {
|
|
printBulkResult("Deleted", result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers for all bulk commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func addBulkFilterFlags(cmd *cobra.Command) {
|
|
cmd.Flags().String("tags", "", "filter by tags (comma-separated)")
|
|
cmd.Flags().String("type", "", "filter by document type")
|
|
cmd.Flags().String("ids", "", "filter by document IDs (comma-separated)")
|
|
cmd.Flags().Int("from-id", 0, "filter by id >= value")
|
|
cmd.Flags().Int("to-id", 0, "filter by id <= value")
|
|
cmd.Flags().BoolP("force", "f", false, "override safety threshold")
|
|
cmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt")
|
|
}
|
|
|
|
func buildBulkBody(cmd *cobra.Command) (map[string]interface{}, error) {
|
|
body := map[string]interface{}{}
|
|
|
|
tagsStr, _ := cmd.Flags().GetString("tags")
|
|
if tagsStr != "" {
|
|
body["tags"] = splitTags(tagsStr)
|
|
}
|
|
|
|
docType, _ := cmd.Flags().GetString("type")
|
|
if docType != "" {
|
|
body["doc_type"] = docType
|
|
}
|
|
|
|
idsStr, _ := cmd.Flags().GetString("ids")
|
|
if idsStr != "" {
|
|
ids, err := parseIntList(idsStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid --ids: %w", err)
|
|
}
|
|
body["document_ids"] = ids
|
|
}
|
|
|
|
fromID, _ := cmd.Flags().GetInt("from-id")
|
|
if fromID > 0 {
|
|
body["from_id"] = fromID
|
|
}
|
|
|
|
toID, _ := cmd.Flags().GetInt("to-id")
|
|
if toID > 0 {
|
|
body["to_id"] = toID
|
|
}
|
|
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
if force {
|
|
body["force"] = true
|
|
}
|
|
|
|
// Ensure at least one filter
|
|
hasFilter := tagsStr != "" || docType != "" || idsStr != "" || fromID > 0 || toID > 0
|
|
if !hasFilter {
|
|
return nil, fmt.Errorf("at least one filter is required (--tags, --type, --ids, --from-id, --to-id)")
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
func describeBulkFilter(cmd *cobra.Command) string {
|
|
var parts []string
|
|
|
|
tagsStr, _ := cmd.Flags().GetString("tags")
|
|
if tagsStr != "" {
|
|
parts = append(parts, fmt.Sprintf("tags=[%s]", tagsStr))
|
|
}
|
|
|
|
docType, _ := cmd.Flags().GetString("type")
|
|
if docType != "" {
|
|
parts = append(parts, fmt.Sprintf("type=%s", docType))
|
|
}
|
|
|
|
idsStr, _ := cmd.Flags().GetString("ids")
|
|
if idsStr != "" {
|
|
parts = append(parts, fmt.Sprintf("ids=[%s]", idsStr))
|
|
}
|
|
|
|
fromID, _ := cmd.Flags().GetInt("from-id")
|
|
if fromID > 0 {
|
|
parts = append(parts, fmt.Sprintf("from_id=%d", fromID))
|
|
}
|
|
|
|
toID, _ := cmd.Flags().GetInt("to-id")
|
|
if toID > 0 {
|
|
parts = append(parts, fmt.Sprintf("to_id=%d", toID))
|
|
}
|
|
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func printBulkResult(action string, result map[string]interface{}) {
|
|
matched := int(result["matched"].(float64))
|
|
succeeded := int(result["succeeded"].(float64))
|
|
failed := int(result["failed"].(float64))
|
|
|
|
fmt.Printf("%s %d of %d documents", action, succeeded, matched)
|
|
if failed > 0 {
|
|
fmt.Printf(" (%d failed)", failed)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
func parseIntList(s string) ([]int, error) {
|
|
var ids []int
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
id, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid ID %q: %w", part, err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
}
|