feat: upstream generic workspace tooling (board-sync, crm audit, catalog names)
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
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.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user