Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 1m16s
Tests / Test (Go stable) (pull_request) Successful in 1m36s
Promote the generic, reusable parts of the private oo-workspace into the public library/CLI, so oo-workspace can shrink to business glue. - board.go: Board types + (c *Client) SyncBoard — upsert project milestones/tasks by exact title from a YAML board (dry-run when apply=false). CLI: oo projects board-sync. - crm_audit.go: OpportunityAudit + (c *Client) AuditOpportunities — file/task/member counts with a generic class (ok|dup|empty|junk-title). CLI: oo crm audit [--out]. - catalog/names.go: CleanPersonNames / GuessNameFromEmail / FormatProjectTitle (ported from oo-workspace). - catalog: adopt the newer apply/match logic — name repair (never encode company in lastName), company grouping key, contact-info type normalization, preserve an already-applied oo_id. Keeps the local Address/OOProjects fields and the config-driven classifier. Business-only oo-workspace commands (crm clean/migrate, folders, search) deliberately stay private.
206 lines
5.2 KiB
Go
206 lines
5.2 KiB
Go
package onlyoffice
|
|
|
|
// Project board (Gantt) upsert from a YAML board file.
|
|
//
|
|
// A board describes projects, their milestones and tasks by exact title. Sync
|
|
// creates only what is missing: existing milestones/tasks (matched by title)
|
|
// are left untouched, so the file can be the source of truth for a project
|
|
// plan and re-applied safely. Dry-run (apply=false) reports counts without
|
|
// writing.
|
|
//
|
|
// The file format is deliberately small and presentation-free:
|
|
//
|
|
// projects:
|
|
// - id: 42
|
|
// name: "Example"
|
|
// milestones:
|
|
// - title: "Kickoff"
|
|
// deadline: "2026-01-15"
|
|
// key: true
|
|
// tasks:
|
|
// - title: "Draft"
|
|
// start: "2026-01-02"
|
|
// deadline: "2026-01-10"
|
|
// description: "…"
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Board is a YAML mapping from a project plan onto OnlyOffice milestones/tasks.
|
|
type Board struct {
|
|
Projects []BoardProject `yaml:"projects"`
|
|
}
|
|
|
|
// BoardProject is one project with its milestones.
|
|
type BoardProject struct {
|
|
ID int `yaml:"id"`
|
|
Name string `yaml:"name,omitempty"`
|
|
Milestones []BoardMilestone `yaml:"milestones"`
|
|
}
|
|
|
|
// BoardMilestone is a milestone ("key" marks it as a key milestone).
|
|
type BoardMilestone struct {
|
|
Title string `yaml:"title"`
|
|
Deadline string `yaml:"deadline"`
|
|
Key bool `yaml:"key,omitempty"`
|
|
Tasks []BoardTask `yaml:"tasks"`
|
|
}
|
|
|
|
// BoardTask is a task inside a milestone. Start falls back to Deadline.
|
|
type BoardTask struct {
|
|
Title string `yaml:"title"`
|
|
Start string `yaml:"start,omitempty"`
|
|
Deadline string `yaml:"deadline"`
|
|
Description string `yaml:"description,omitempty"`
|
|
}
|
|
|
|
// BoardSyncResult counts what SyncBoard created or skipped.
|
|
type BoardSyncResult struct {
|
|
CreatedMilestones int
|
|
SkippedMilestones int
|
|
CreatedTasks int
|
|
SkippedTasks int
|
|
DryRun bool
|
|
}
|
|
|
|
// LoadBoard reads a board YAML file.
|
|
func LoadBoard(path string) (*Board, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseBoard(b)
|
|
}
|
|
|
|
// ParseBoard decodes a board from YAML bytes.
|
|
func ParseBoard(data []byte) (*Board, error) {
|
|
var board Board
|
|
if err := yaml.Unmarshal(data, &board); err != nil {
|
|
return nil, fmt.Errorf("board: %w", err)
|
|
}
|
|
if len(board.Projects) == 0 {
|
|
return nil, fmt.Errorf("board: no projects")
|
|
}
|
|
return &board, nil
|
|
}
|
|
|
|
func boardDay(s string) (Time, error) {
|
|
t, err := time.Parse("2006-01-02", s)
|
|
if err != nil {
|
|
return Time{}, err
|
|
}
|
|
return Time(t), nil
|
|
}
|
|
|
|
func boardMilestoneIDs(ms []*Milestone) map[string]int64 {
|
|
out := map[string]int64{}
|
|
for _, m := range ms {
|
|
if m == nil || m.Title == nil || m.ID == nil {
|
|
continue
|
|
}
|
|
out[*m.Title] = *m.ID
|
|
}
|
|
return out
|
|
}
|
|
|
|
func boardTaskTitles(rows []map[string]any) map[string]struct{} {
|
|
out := map[string]struct{}{}
|
|
for _, r := range rows {
|
|
if t, _ := r["title"].(string); t != "" {
|
|
out[t] = struct{}{}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SyncBoard upserts milestones and tasks by exact title. With apply=false it
|
|
// only counts what would be created.
|
|
func (c *Client) SyncBoard(ctx context.Context, board *Board, apply bool) (*BoardSyncResult, error) {
|
|
if board == nil || len(board.Projects) == 0 {
|
|
return nil, fmt.Errorf("board: no projects")
|
|
}
|
|
res := &BoardSyncResult{DryRun: !apply}
|
|
for _, p := range board.Projects {
|
|
pid := p.ID
|
|
existing, err := c.GetProjectMilestones(&Project{ID: &pid})
|
|
if err != nil {
|
|
return res, fmt.Errorf("project %d milestones: %w", pid, err)
|
|
}
|
|
haveMS := boardMilestoneIDs(existing)
|
|
tasks, err := c.ListTasks(ctx, strconv.Itoa(pid), "")
|
|
if err != nil {
|
|
return res, fmt.Errorf("project %d tasks: %w", pid, err)
|
|
}
|
|
haveTask := boardTaskTitles(tasks)
|
|
|
|
for _, m := range p.Milestones {
|
|
msID, ok := haveMS[m.Title]
|
|
if !ok {
|
|
res.CreatedMilestones++
|
|
if apply {
|
|
dl, err := boardDay(m.Deadline)
|
|
if err != nil {
|
|
return res, fmt.Errorf("milestone %q deadline: %w", m.Title, err)
|
|
}
|
|
created, err := c.CreateMilestone(NewMilestoneRequest{
|
|
ProjectID: pid,
|
|
Title: m.Title,
|
|
Deadline: dl,
|
|
IsKey: m.Key,
|
|
})
|
|
if err != nil {
|
|
return res, fmt.Errorf("create milestone %q: %w", m.Title, err)
|
|
}
|
|
if created.ID != nil {
|
|
msID = *created.ID
|
|
}
|
|
haveMS[m.Title] = msID
|
|
}
|
|
} else {
|
|
res.SkippedMilestones++
|
|
}
|
|
for _, t := range m.Tasks {
|
|
if _, exists := haveTask[t.Title]; exists {
|
|
res.SkippedTasks++
|
|
continue
|
|
}
|
|
res.CreatedTasks++
|
|
if !apply {
|
|
continue
|
|
}
|
|
start := t.Start
|
|
if start == "" {
|
|
start = t.Deadline
|
|
}
|
|
st, err := boardDay(start)
|
|
if err != nil {
|
|
return res, fmt.Errorf("task %q start: %w", t.Title, err)
|
|
}
|
|
dl, err := boardDay(t.Deadline)
|
|
if err != nil {
|
|
return res, fmt.Errorf("task %q deadline: %w", t.Title, err)
|
|
}
|
|
if _, err := c.CreateProjectTask(NewProjectTaskRequest{
|
|
ProjectId: pid,
|
|
Title: t.Title,
|
|
Description: t.Description,
|
|
StartDate: st,
|
|
Deadline: dl,
|
|
MilestoneId: int(msID),
|
|
}); err != nil {
|
|
return res, fmt.Errorf("create task %q: %w", t.Title, err)
|
|
}
|
|
haveTask[t.Title] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|