package main import ( "fmt" "os" "os/exec" "strconv" "strings" "time" onlyoffice "github.com/eslider/go-onlyoffice" "github.com/spf13/cobra" ) var projectsCmd = &cobra.Command{ Use: "projects", Aliases: []string{"prj"}, Short: "OnlyOffice Projects", } func init() { rootCmd.AddCommand(projectsCmd) projectsCmd.AddCommand(prjListCmd()) projectsCmd.AddCommand(prjGetCmd()) projectsCmd.AddCommand(prjMilestonesCmd()) projectsCmd.AddCommand(prjMilestoneCreateCmd()) projectsCmd.AddCommand(prjMilestoneDeleteCmd()) projectsCmd.AddCommand(prjCreateCmd()) projectsCmd.AddCommand(prjUpdateCmd()) projectsCmd.AddCommand(prjDeleteCmd()) projectsCmd.AddCommand(prjContactsCmd()) projectsCmd.AddCommand(prjTeamCmd()) } func prjTeamCmd() *cobra.Command { cmd := &cobra.Command{ Use: "team", Short: "Project team (portal users) — CRUD", Long: `Project team members are portal users (People), not CRM contacts. CRM companies/persons linked to a project live under 'oo projects contacts'.`, } cmd.AddCommand(prjTeamListCmd()) cmd.AddCommand(prjTeamAddCmd()) cmd.AddCommand(prjTeamRemoveCmd()) cmd.AddCommand(prjTeamSetCmd()) return cmd } func prjTeamListCmd() *cobra.Command { return &cobra.Command{ Use: "list PROJECT_ID", Short: "List portal users on the project team", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } c, err := newOO(cmd) if err != nil { return err } list, err := c.ListProjectTeam(cmd.Context(), pid) if err != nil { return err } printTable([]string{"id", "displayName", "userName", "email", "isAdmin"}, teamRows(list)) return nil }, } } func prjTeamAddCmd() *cobra.Command { return &cobra.Command{ Use: "add PROJECT_ID USER_ID [USER_ID...]", Short: "Add portal user(s) to the project team", Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } c, err := newOO(cmd) if err != nil { return err } for _, uid := range args[1:] { if _, err := c.AddProjectTeamUser(cmd.Context(), pid, uid); err != nil { return fmt.Errorf("add %s: %w", uid, err) } printObject(map[string]any{"project_id": pid, "user_id": uid, "added": true}) } return nil }, } } func prjTeamRemoveCmd() *cobra.Command { return &cobra.Command{ Use: "remove PROJECT_ID USER_ID [USER_ID...]", Short: "Remove portal user(s) from the project team", Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } c, err := newOO(cmd) if err != nil { return err } for _, uid := range args[1:] { if _, err := c.RemoveProjectTeamUser(cmd.Context(), pid, uid); err != nil { return fmt.Errorf("remove %s: %w", uid, err) } printObject(map[string]any{"project_id": pid, "user_id": uid, "removed": true}) } return nil }, } } func prjTeamSetCmd() *cobra.Command { var notify bool cmd := &cobra.Command{ Use: "set PROJECT_ID USER_ID [USER_ID...]", Short: "Replace the project team with the given users (register several at once)", Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } c, err := newOO(cmd) if err != nil { return err } team, err := c.SetProjectTeam(cmd.Context(), pid, args[1:], notify) if err != nil { return err } printTable([]string{"id", "displayName", "userName", "email", "isAdmin"}, teamRows(team)) return nil }, } cmd.Flags().BoolVar(¬ify, "notify", false, "notify added members") return cmd } // teamRows maps raw team member maps into table rows. func teamRows(list []map[string]any) []map[string]any { rows := make([]map[string]any, 0, len(list)) for _, m := range list { rows = append(rows, map[string]any{ "id": idString(m, "id"), "displayName": m["displayName"], "userName": m["userName"], "email": m["email"], "isAdmin": m["isAdmin"], }) } return rows } func prjListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List all projects", RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } list, err := c.GetProjects() if err != nil { return err } rows := make([]map[string]any, 0, len(list)) for _, p := range list { row := map[string]any{ "id": derefInt(p.ID), "title": p.String(), "status": derefInt(p.Status), } if p.TaskCount != nil { row["tasks"] = *p.TaskCount } if p.IsPrivate != nil { row["private"] = *p.IsPrivate } rows = append(rows, row) } printTable([]string{"id", "title", "status", "tasks", "private"}, rows) return nil }, } } func prjGetCmd() *cobra.Command { return &cobra.Command{ Use: "get [PROJECT_ID]", Short: "Show a single project (default: $OO_PROJECT_ID)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } id := "" if len(args) == 1 { id = args[0] } out, err := c.GetProjectByID(cmd.Context(), id) if err != nil { return err } printObject(out) return nil }, } } func prjMilestonesCmd() *cobra.Command { return &cobra.Command{ Use: "milestones PROJECT_ID", Short: "List milestones of a project", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id must be integer: %w", err) } ms, err := c.GetProjectMilestones(&onlyoffice.Project{ID: &pid}) if err != nil { return err } rows := make([]map[string]any, 0, len(ms)) for _, m := range ms { rows = append(rows, map[string]any{ "id": derefInt64(m.ID), "title": derefString(m.Title), "status": derefInt64(m.Status), }) } printTable([]string{"id", "title", "status"}, rows) return nil }, } } func prjMilestoneCreateCmd() *cobra.Command { var deadline, desc string var key bool cmd := &cobra.Command{ Use: "milestone-create PROJECT_ID TITLE", Short: "Create a project milestone (Gantt row)", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id must be integer: %w", err) } if deadline == "" { return fmt.Errorf("--deadline YYYY-MM-DD is required") } day, err := time.Parse("2006-01-02", deadline) if err != nil { return fmt.Errorf("deadline: %w", err) } ms, err := c.CreateMilestone(onlyoffice.NewMilestoneRequest{ ProjectID: pid, Title: args[1], Deadline: onlyoffice.Time(day), Description: desc, IsKey: key, }) if err != nil { return err } printObject(map[string]any{ "id": derefInt64(ms.ID), "title": derefString(ms.Title), }) return nil }, } cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD") cmd.Flags().StringVar(&desc, "description", "", "description") cmd.Flags().BoolVar(&key, "key", false, "mark as key milestone") return cmd } func prjMilestoneDeleteCmd() *cobra.Command { return &cobra.Command{ Use: "milestone-delete MILESTONE_ID [MILESTONE_ID...]", Aliases: []string{"milestone-rm"}, Short: "Delete project milestone(s) by id", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } for _, raw := range args { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { return fmt.Errorf("milestone id %q must be integer: %w", raw, err) } if err := c.DeleteMilestone(id); err != nil { return fmt.Errorf("delete milestone %d: %w", id, err) } printObject(map[string]any{"milestone_id": id, "deleted": true}) } return nil }, } } func prjCreateCmd() *cobra.Command { var desc, resp string var country, company string cmd := &cobra.Command{ Use: "create TITLE", Short: "Create a new project", Long: `Create a project. Prefer canonical titles: CC | Company | Title Examples: oo projects create "Mapbender" --country DE --company "Stadt Mainz" oo projects create "DE | Acme | Geo Engineer" When --country and --company are set, TITLE is only the third segment and the full title is composed as "CC | Company | Title".`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } title := args[0] if country != "" || company != "" { title = onlyoffice.FormatProjectTitle(country, company, args[0]) } p, err := c.CreateProject(onlyoffice.NewProjectRequest{ Title: title, Description: desc, ResponsibleID: resp, }) if err != nil { return err } printObject(map[string]any{ "id": derefInt(p.ID), "title": p.String(), }) return nil }, } cmd.Flags().StringVar(&desc, "description", "", "project description") cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)") cmd.Flags().StringVar(&country, "country", "", "country/region code (DE, TF, UA, …)") cmd.Flags().StringVar(&company, "company", "", "CRM company / engagement org name") return cmd } func prjUpdateCmd() *cobra.Command { var title, desc, resp string cmd := &cobra.Command{ Use: "update PROJECT_ID", Short: "Update project fields (only non-empty)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } id, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id must be integer: %w", err) } // OO update requires responsibleId; reuse current when not passed. if resp == "" { cur, gerr := c.GetProjectByID(cmd.Context(), args[0]) if gerr != nil { return gerr } if r, ok := cur["responsible"].(map[string]any); ok { resp = fmt.Sprint(r["id"]) } if resp == "" || resp == "" { resp = strings.TrimSpace(fmt.Sprint(cur["responsibleId"])) } if title == "" { title = strings.TrimSpace(fmt.Sprint(cur["title"])) } if desc == "" { desc = strings.TrimSpace(fmt.Sprint(cur["description"])) } } p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{ ID: id, Title: title, Description: desc, ResponsibleID: resp, }) if err != nil { return err } printObject(map[string]any{ "id": derefInt(p.ID), "title": p.String(), }) // Confirm via GET — some OO builds return an empty body on PUT. got, gerr := c.GetProjectByID(cmd.Context(), args[0]) if gerr == nil && got != nil { printObject(map[string]any{"confirmed_title": got["title"], "confirmed_id": got["id"]}) } return nil }, } cmd.Flags().StringVar(&title, "title", "", "new title") cmd.Flags().StringVar(&desc, "description", "", "new description") cmd.Flags().StringVar(&resp, "responsible", "", "new responsible user id") return cmd } func prjDeleteCmd() *cobra.Command { return &cobra.Command{ Use: "delete PROJECT_ID [PROJECT_ID...]", Aliases: []string{"rm"}, Short: "Delete one or more projects", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } for _, raw := range args { id, err := strconv.Atoi(raw) if err != nil { return fmt.Errorf("project id %q must be integer: %w", raw, err) } p, err := c.DeleteProject(id) if err != nil { return err } printObject(map[string]any{"id": derefInt(p.ID), "title": p.String()}) } return nil }, } } func derefString(p *string) string { if p == nil { return "" } return *p } func prjContactsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "contacts", Short: "CRM contacts linked to a project (companies + persons)", Long: `OnlyOffice Projects have two participant layers: - Team (portal users) — ProjectTeam.aspx - Contacts (CRM companies/persons) — linked via this API Link employers/clients and people who worked on the engagement (e.g. git authors).`, } cmd.AddCommand(prjContactsListCmd()) cmd.AddCommand(prjContactsAddCmd()) cmd.AddCommand(prjContactsRemoveCmd()) cmd.AddCommand(prjContactsLinkGitCmd()) cmd.AddCommand(prjContactsLinkAuthorsCmd()) return cmd } func prjContactsListCmd() *cobra.Command { return &cobra.Command{ Use: "list PROJECT_ID", Short: "List CRM contacts linked to the project", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } list, err := c.ListProjectContacts(cmd.Context(), pid) if err != nil { return err } rows := make([]map[string]any, 0, len(list)) for _, row := range list { rows = append(rows, map[string]any{ "id": row["id"], "name": row["displayName"], "company": row["isCompany"], "companyOf": row["companyName"], }) } printTable([]string{"id", "name", "company", "companyOf"}, rows) return nil }, } } func prjContactsAddCmd() *cobra.Command { return &cobra.Command{ Use: "add PROJECT_ID CONTACT_ID [CONTACT_ID...]", Short: "Link CRM contact(s) to the project", Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } for _, raw := range args[1:] { cid, err := strconv.Atoi(raw) if err != nil { return fmt.Errorf("contact id %q: %w", raw, err) } if _, err := c.AddProjectContact(cmd.Context(), pid, cid); err != nil { return fmt.Errorf("add %d: %w", cid, err) } printObject(map[string]any{"project_id": pid, "contact_id": cid, "linked": true}) } return nil }, } } func prjContactsRemoveCmd() *cobra.Command { return &cobra.Command{ Use: "remove PROJECT_ID CONTACT_ID [CONTACT_ID...]", Short: "Unlink CRM contact(s) from the project", Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } for _, raw := range args[1:] { cid, err := strconv.Atoi(raw) if err != nil { return fmt.Errorf("contact id %q: %w", raw, err) } if err := c.RemoveProjectContact(cmd.Context(), pid, cid); err != nil { return fmt.Errorf("remove %d: %w", cid, err) } printObject(map[string]any{"project_id": pid, "contact_id": cid, "unlinked": true}) } return nil }, } } func prjContactsLinkGitCmd() *cobra.Command { var gitRoot string var minCommits int var dryRun bool var companyIDs []int cmd := &cobra.Command{ Use: "link-git PROJECT_ID", Short: "Link CRM persons matched from git shortlog (+ optional companies)", Long: `Runs git shortlog -sne --all in --git-root, matches author emails to CRM persons (FindPersonByEmail), and links found contacts to the project. Does not create new persons — create/link them in CRM first. Also links --company-id contacts (employer and/or client company).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if gitRoot == "" && len(companyIDs) == 0 { return fmt.Errorf("need --git-root and/or --company-id") } c, err := newOO(cmd) if err != nil { return err } pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } var authors []gitAuthor if gitRoot != "" { authors, err = gitShortlogAuthors(gitRoot, minCommits) if err != nil { return err } } return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun) }, } cmd.Flags().StringVar(&gitRoot, "git-root", "", "local git repository path") cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count") cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only") return cmd } func prjContactsLinkAuthorsCmd() *cobra.Command { var authorsFile string var minCommits int var dryRun bool var companyIDs []int cmd := &cobra.Command{ Use: "link-authors PROJECT_ID", Short: "Link CRM persons from a git shortlog file (+ companies)", Long: `Same as link-git, but reads authors from a file produced by: git shortlog -sne --all > authors.txt Then: oo projects contacts link-authors 59 --from authors.txt --company-id COMPANY_ID`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if authorsFile == "" && len(companyIDs) == 0 { return fmt.Errorf("need --from and/or --company-id") } c, err := newOO(cmd) if err != nil { return err } pid, err := strconv.Atoi(args[0]) if err != nil { return fmt.Errorf("project id: %w", err) } var authors []gitAuthor if authorsFile != "" { b, err := os.ReadFile(authorsFile) if err != nil { return err } authors = parseShortlog(string(b), minCommits) } return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun) }, } cmd.Flags().StringVar(&authorsFile, "from", "", "path to git shortlog -sne output") cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count") cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only") return cmd } func linkProjectPeople(cmd *cobra.Command, c *onlyoffice.Client, pid int, authors []gitAuthor, companyIDs []int, dryRun bool) error { ctx := cmd.Context() existing, err := c.ListProjectContacts(ctx, pid) if err != nil { return err } have := map[int]struct{}{} for _, row := range existing { have[int(flexIDFloat(row["id"]))] = struct{}{} } linked, skipped := 0, 0 for _, cid := range companyIDs { if _, ok := have[cid]; ok { skipped++ continue } if dryRun { printObject(map[string]any{"would_link_company": cid}) linked++ continue } if _, err := c.AddProjectContact(ctx, pid, cid); err != nil { return fmt.Errorf("company %d: %w", cid, err) } have[cid] = struct{}{} linked++ printObject(map[string]any{"linked_company": cid}) } for _, a := range authors { p, err := c.FindPersonByEmail(ctx, a.Email) if err != nil { return err } if p == nil { printObject(map[string]any{ "skipped_email": a.Email, "name": a.Name, "commits": a.Commits, "reason": "no_crm_person", }) skipped++ continue } cid := int(flexIDFloat(p["id"])) if _, ok := have[cid]; ok { skipped++ continue } if dryRun { printObject(map[string]any{"would_link_person": cid, "email": a.Email, "commits": a.Commits}) linked++ continue } if _, err := c.AddProjectContact(ctx, pid, cid); err != nil { return fmt.Errorf("person %d (%s): %w", cid, a.Email, err) } have[cid] = struct{}{} linked++ printObject(map[string]any{"linked_person": cid, "email": a.Email, "commits": a.Commits}) } printObject(map[string]any{"project_id": pid, "linked": linked, "skipped": skipped, "dry_run": dryRun}) return nil } func parseShortlog(text string, minCommits int) []gitAuthor { var authors []gitAuthor seen := map[string]struct{}{} for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.SplitN(line, "\t", 2) if len(parts) != 2 { continue } n, _ := strconv.Atoi(strings.TrimSpace(parts[0])) if n < minCommits { continue } rest := parts[1] lt := strings.LastIndex(rest, "<") gt := strings.LastIndex(rest, ">") if lt < 0 || gt <= lt { continue } email := strings.ToLower(strings.TrimSpace(rest[lt+1 : gt])) name := strings.TrimSpace(rest[:lt]) if email == "" { continue } if _, ok := seen[email]; ok { continue } seen[email] = struct{}{} authors = append(authors, gitAuthor{Commits: n, Name: name, Email: email}) } return authors } type gitAuthor struct { Commits int Name string Email string } func gitShortlogAuthors(root string, minCommits int) ([]gitAuthor, error) { cmd := exec.Command("git", "-C", root, "shortlog", "-sne", "--all") out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("git shortlog in %s: %w", root, err) } return parseShortlog(string(out), minCommits), nil } func derefInt(p *int) int { if p == nil { return 0 } return *p } func derefInt64(p *int64) int64 { if p == nil { return 0 } return *p }