feat: merge oo-cli and extend library with Calendar/CRM/subtasks/files
- Add Calendar helpers (ListCalendars/ListEvents/AddEvent/DeleteEvent) - Add CRM helpers: contacts, opportunities, cases, CRM tasks, history notes - Add project-task extras (ListTasks/ListAllTasks/GetTaskByID/AddTask/ AddSubtask/UpdateTaskStatus/DeleteTask, GetProjectByID) - Add UploadOpportunityFile (multipart) and SelfUserID caching - Introduce Defaults struct, SetDefaults, GetEnvironmentDefaults; GetEnvironmentCredentials now accepts ONLYOFFICE_HOST/NAME/PASSWORD aliases - Add public Authenticate() helper that eagerly primes the token - Bundle Cobra CLI at cmd/oo-cli with full command surface (calendar, tasks, subtasks, CRM, applications-sync); CLI-only deps (cobra, godotenv) kept out of the library surface - Add httptest-based unit tests for form/multipart helpers - Docs: CHANGELOG, AGENTS, .env.example, README updates for v0.3 features Consolidates the standalone eSlider/oo-cli tool into this canonical Go module (see eSlider/inventar ASR-0008). Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
// Package applications syncs job application README trees into OnlyOffice CRM
|
||||
// (ported from cv/bin/office/sync-applications.py).
|
||||
package applications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
)
|
||||
|
||||
var docExt = map[string]bool{".pdf": true, ".docx": true, ".xlsx": true, ".doc": true, ".xls": true}
|
||||
|
||||
type RecruiterInfo struct {
|
||||
First, Last, JobTitle, Company, Email, Phone, LinkedIn string
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Path string
|
||||
Folder string
|
||||
Position string
|
||||
Company string
|
||||
Location string
|
||||
Salary string
|
||||
SalaryValue float64
|
||||
Contract string
|
||||
Source string
|
||||
Link string
|
||||
Recruiter RecruiterInfo
|
||||
Summary string
|
||||
Documents []string
|
||||
}
|
||||
|
||||
func Discover(base string) ([]string, error) {
|
||||
var out []string
|
||||
err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if d.Name() == "pdfs" {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Base(path) != "README.md" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if filepath.Clean(dir) == filepath.Clean(base) {
|
||||
return nil
|
||||
}
|
||||
out = append(out, path)
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func ParseReadme(path string) (Data, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Data{}, err
|
||||
}
|
||||
text := string(b)
|
||||
app := Data{Path: path}
|
||||
app.Folder = relFolder(path)
|
||||
app.Position = extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
app.Company = extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
if app.Company != "" {
|
||||
app.Company = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(app.Company, "$1")
|
||||
app.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(app.Company), "")
|
||||
}
|
||||
app.Location = extract(text, `(?i)\*\*Location\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
app.Salary = extract(text, `(?i)\*\*(?:Salary|Gehalt|Rate)\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
app.SalaryValue = parseSalary(app.Salary)
|
||||
app.Contract = extract(text, `(?i)\*\*Contract\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
app.Link = extract(text, `(?i)\*\*Link\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
if m := regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`).FindStringSubmatch(app.Link); len(m) > 2 {
|
||||
app.Link = m[2]
|
||||
}
|
||||
app.Source = extract(text, `(?i)\*\*Source\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
app.Recruiter = parseRecruiter(text)
|
||||
if app.Source == "" {
|
||||
app.Source = filepath.Base(filepath.Dir(filepath.Dir(path)))
|
||||
}
|
||||
if app.Company == "" || strings.EqualFold(app.Company, "undisclosed") || app.Company == "?" || strings.EqualFold(app.Company, "tbd") {
|
||||
parts := strings.Split(filepath.Base(filepath.Dir(path)), "-")
|
||||
if len(parts) >= 2 {
|
||||
app.Company = titleWord(parts[0])
|
||||
}
|
||||
}
|
||||
app.Summary = buildSummary(app, text)
|
||||
app.Documents = discoverDocs(filepath.Dir(path))
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func relFolder(readmePath string) string {
|
||||
// Python: path.parent.relative_to(path.parent.parent.parent.parent) — approximate: last 3 parts
|
||||
p := filepath.Clean(readmePath)
|
||||
parts := strings.Split(p, string(filepath.Separator))
|
||||
if len(parts) >= 3 {
|
||||
return filepath.Join(parts[len(parts)-3:]...)
|
||||
}
|
||||
return filepath.Dir(readmePath)
|
||||
}
|
||||
|
||||
func extract(text, pat string) string {
|
||||
re := regexp.MustCompile(pat)
|
||||
m := re.FindStringSubmatch(text)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func parseSalary(raw string) float64 {
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
cleaned := strings.ReplaceAll(strings.ReplaceAll(raw, ",", ""), ".", "")
|
||||
if m := regexp.MustCompile(`(\d{4,6})`).FindAllStringSubmatch(cleaned, -1); len(m) > 0 {
|
||||
var maxv int
|
||||
for _, x := range m {
|
||||
n, _ := strconv.Atoi(x[1])
|
||||
if n > maxv {
|
||||
maxv = n
|
||||
}
|
||||
}
|
||||
return float64(maxv)
|
||||
}
|
||||
if m := regexp.MustCompile(`(?i)(\d{2,4})\s*/\s*day`).FindStringSubmatch(raw); len(m) > 1 {
|
||||
n, _ := strconv.ParseFloat(m[1], 64)
|
||||
return n * 220
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseRecruiter(text string) RecruiterInfo {
|
||||
var r RecruiterInfo
|
||||
raw := extract(text, `(?i)\*\*(?:Recruiter|Consultant)\*\*:\s*(.+?)(?:\s{2,}|\n)`)
|
||||
if raw == "" {
|
||||
return r
|
||||
}
|
||||
if m := regexp.MustCompile(`<([^>]+@[^>]+)>`).FindStringSubmatch(raw); len(m) > 1 {
|
||||
r.Email = strings.TrimSpace(m[1])
|
||||
if i := strings.Index(raw, "<"); i >= 0 {
|
||||
raw = strings.TrimSpace(raw[:i])
|
||||
}
|
||||
raw = strings.TrimRight(raw, " —–-")
|
||||
}
|
||||
if m := regexp.MustCompile(`(?i)\s+@\s+(.+)$`).FindStringSubmatch(raw); len(m) > 1 {
|
||||
r.Company = strings.TrimSpace(m[1])
|
||||
if i := strings.Index(raw, m[0]); i >= 0 {
|
||||
raw = strings.TrimSpace(raw[:i])
|
||||
}
|
||||
}
|
||||
if m := regexp.MustCompile(`,?\s*\[([^\]]+)\]\([^)]+\)`).FindStringSubmatch(raw); len(m) > 1 {
|
||||
r.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(m[1]), "")
|
||||
if i := strings.Index(raw, m[0]); i >= 0 {
|
||||
raw = strings.TrimSpace(raw[:i])
|
||||
}
|
||||
}
|
||||
parts := strings.SplitN(raw, ",", 2)
|
||||
namePart := strings.TrimSpace(parts[0])
|
||||
if len(parts) > 1 {
|
||||
r.JobTitle = strings.TrimSpace(parts[1])
|
||||
}
|
||||
toks := strings.Fields(namePart)
|
||||
if len(toks) >= 2 {
|
||||
r.First = toks[0]
|
||||
r.Last = strings.Join(toks[1:], " ")
|
||||
} else if len(toks) == 1 {
|
||||
r.First = toks[0]
|
||||
}
|
||||
if m := regexp.MustCompile(`(?i)\*\*?(?:Telefon|Phone)\*\*?:\s*([\d+\-\s]+)`).FindStringSubmatch(text); len(m) > 1 {
|
||||
r.Phone = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := regexp.MustCompile(`(?i)\*\*LinkedIn\*\*:\s*(https://[^\s]+)`).FindStringSubmatch(text); len(m) > 1 {
|
||||
r.LinkedIn = strings.TrimSpace(m[1])
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func buildSummary(app Data, text string) string {
|
||||
var lines []string
|
||||
lines = append(lines, "Position: "+app.Position)
|
||||
lines = append(lines, "Company: "+app.Company)
|
||||
if app.Location != "" {
|
||||
lines = append(lines, "Location: "+app.Location)
|
||||
}
|
||||
if app.Salary != "" {
|
||||
lines = append(lines, "Salary: "+app.Salary)
|
||||
}
|
||||
if app.Contract != "" {
|
||||
lines = append(lines, "Contract: "+app.Contract)
|
||||
}
|
||||
if app.Source != "" {
|
||||
lines = append(lines, "Source: "+app.Source)
|
||||
}
|
||||
if app.Link != "" {
|
||||
lines = append(lines, "Link: "+app.Link)
|
||||
}
|
||||
lines = append(lines, "Folder: "+app.Folder)
|
||||
if m := regexp.MustCompile(`(?i)## Application Status\s*\n((?:[-*\[\]xX ].+\n?)+)`).FindStringSubmatch(text); len(m) > 1 {
|
||||
lines = append(lines, "", "Status:", strings.TrimSpace(m[1]))
|
||||
}
|
||||
if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?=\n## |\z)`).FindStringSubmatch(text); len(m) > 1 {
|
||||
ft := strings.TrimSpace(m[1])
|
||||
if len(ft) > 500 {
|
||||
ft = ft[:500] + "..."
|
||||
}
|
||||
lines = append(lines, "", "Fit Assessment:", ft)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func discoverDocs(dir string) []string {
|
||||
var docs []string
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if docExt[ext] {
|
||||
docs = append(docs, filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Companies, Persons, Deals, Tasks, Docs, Notes int
|
||||
}
|
||||
|
||||
// Sync mirrors Python sync_to_crm.
|
||||
func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, verbose bool) Stats {
|
||||
var st Stats
|
||||
deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
|
||||
const stageInitial = 1
|
||||
for _, app := range apps {
|
||||
dealTitle := app.Position
|
||||
if app.Company != "" {
|
||||
dealTitle = app.Position + " @ " + app.Company
|
||||
}
|
||||
fmt.Println(strings.Repeat("─", 60))
|
||||
fmt.Println(" ", app.Folder)
|
||||
fmt.Println(" ", dealTitle)
|
||||
if verbose {
|
||||
// minimal verbose
|
||||
if app.Salary != "" {
|
||||
fmt.Printf(" Salary: %s (parsed: %.0f)\n", app.Salary, app.SalaryValue)
|
||||
}
|
||||
}
|
||||
action := "would create"
|
||||
if !dryRun {
|
||||
action = "creating"
|
||||
}
|
||||
companyID := 0
|
||||
if app.Company != "" {
|
||||
if !dryRun {
|
||||
if ex, _ := client.FindCompany(ctx, app.Company); ex != nil {
|
||||
companyID = int(flexID(ex["id"]))
|
||||
fmt.Printf(" [company] found: [%d] %s\n", companyID, app.Company)
|
||||
} else {
|
||||
co, err := client.CreateCompany(ctx, app.Company)
|
||||
if err != nil {
|
||||
fmt.Printf(" [company] ERR: %v\n", err)
|
||||
} else {
|
||||
companyID = int(flexID(co["id"]))
|
||||
fmt.Printf(" [company] created: [%d] %s\n", companyID, app.Company)
|
||||
if app.Link != "" {
|
||||
_, _ = client.AddContactInfo(ctx, strconv.Itoa(companyID), "Website", app.Link, "Work", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" [company] %s: %s\n", action, app.Company)
|
||||
}
|
||||
st.Companies++
|
||||
}
|
||||
personID := 0
|
||||
r := app.Recruiter
|
||||
if r.First != "" && r.Last != "" {
|
||||
if !dryRun {
|
||||
if ex, _ := client.FindPerson(ctx, r.First, r.Last); ex != nil {
|
||||
personID = int(flexID(ex["id"]))
|
||||
fmt.Printf(" [person] found: [%d] %s %s\n", personID, r.First, r.Last)
|
||||
} else {
|
||||
p, err := client.CreatePerson(ctx, r.First, r.Last, companyID, r.JobTitle, "")
|
||||
if err != nil {
|
||||
fmt.Printf(" [person] ERR: %v\n", err)
|
||||
} else {
|
||||
personID = int(flexID(p["id"]))
|
||||
fmt.Printf(" [person] created: [%d] %s %s\n", personID, r.First, r.Last)
|
||||
if r.Email != "" {
|
||||
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Email", r.Email, "Work", true)
|
||||
}
|
||||
if r.Phone != "" {
|
||||
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Phone", r.Phone, "Work", false)
|
||||
}
|
||||
if r.LinkedIn != "" {
|
||||
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "LinkedIn", r.LinkedIn, "Work", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" [person] %s: %s %s\n", action, r.First, r.Last)
|
||||
}
|
||||
st.Persons++
|
||||
}
|
||||
var dealID int
|
||||
if dryRun {
|
||||
fmt.Printf(" [deal] %s: %s\n", action, dealTitle)
|
||||
} else {
|
||||
if ex := findExistingOpp(ctx, client, dealTitle); ex != nil {
|
||||
dealID = int(flexID(ex["id"]))
|
||||
fmt.Printf(" [deal] found: [%d] %s\n", dealID, dealTitle)
|
||||
} else {
|
||||
d, err := client.CreateOpportunity(ctx, dealTitle, stageInitial, "", "EUR", "", app.SalaryValue)
|
||||
if err != nil {
|
||||
fmt.Printf(" [deal] ERR: %v\n", err)
|
||||
} else {
|
||||
dealID = int(flexID(d["id"]))
|
||||
fmt.Printf(" [deal] created: [%d] %s\n", dealID, dealTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
st.Deals++
|
||||
if !dryRun && dealID != 0 {
|
||||
if companyID != 0 {
|
||||
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(companyID))
|
||||
}
|
||||
if personID != 0 {
|
||||
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(personID))
|
||||
}
|
||||
}
|
||||
if app.Summary != "" {
|
||||
if dryRun {
|
||||
fmt.Printf(" [note] %s: application summary\n", action)
|
||||
} else if dealID != 0 {
|
||||
if _, err := client.AddHistoryNote(ctx, "opportunity", dealID, app.Summary, 0); err == nil {
|
||||
fmt.Println(" [note] added application summary")
|
||||
st.Notes++
|
||||
}
|
||||
}
|
||||
}
|
||||
titles := []string{"Write application", "Apply application", "Send CV"}
|
||||
if dryRun {
|
||||
for _, t := range titles {
|
||||
fmt.Printf(" [task] %s: %s\n", action, t)
|
||||
}
|
||||
} else if dealID != 0 {
|
||||
for _, t := range titles {
|
||||
if _, err := client.CreateCRMTask(ctx, t, deadline, 2, 0, "opportunity", dealID, ""); err == nil {
|
||||
fmt.Printf(" [task] created: %s\n", t)
|
||||
st.Tasks++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, doc := range app.Documents {
|
||||
if dryRun {
|
||||
fmt.Printf(" [doc] %s: %s\n", action, filepath.Base(doc))
|
||||
} else if dealID != 0 {
|
||||
if _, err := client.UploadOpportunityFile(ctx, strconv.Itoa(dealID), doc); err == nil {
|
||||
fmt.Printf(" [doc] uploaded: %s\n", filepath.Base(doc))
|
||||
st.Docs++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mode := "DRY RUN"
|
||||
if !dryRun {
|
||||
mode = "APPLIED"
|
||||
}
|
||||
fmt.Println(strings.Repeat("═", 60))
|
||||
fmt.Printf(" %s: deals=%d companies=%d persons=%d notes=%d tasks=%d docs=%d\n",
|
||||
mode, st.Deals, st.Companies, st.Persons, st.Notes, st.Tasks, st.Docs)
|
||||
return st
|
||||
}
|
||||
|
||||
func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} {
|
||||
deals, total, _ := c.ListOpportunities(ctx, 100, 0)
|
||||
for _, d := range deals {
|
||||
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
if total > 100 {
|
||||
deals2, _, _ := c.ListOpportunities(ctx, 100, 100)
|
||||
for _, d := range deals2 {
|
||||
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func titleWord(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||
}
|
||||
|
||||
func flexID(v interface{}) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case int:
|
||||
return float64(x)
|
||||
case json.Number:
|
||||
f, _ := x.Float64()
|
||||
return f
|
||||
default:
|
||||
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
|
||||
return f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package applications
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractField(t *testing.T) {
|
||||
text := "**Position**: Senior Dev\n**Company**: Acme\n"
|
||||
if g := extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Senior Dev" {
|
||||
t.Fatalf("position: %q", g)
|
||||
}
|
||||
if g := extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Acme" {
|
||||
t.Fatalf("company: %q", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSalary(t *testing.T) {
|
||||
if v := parseSalary("€50,000"); v < 40000 {
|
||||
t.Fatalf("annual: %v", v)
|
||||
}
|
||||
if v := parseSalary("400 / day"); v != 400*220 {
|
||||
t.Fatalf("daily: %v", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/internal/applications"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "oo-cli",
|
||||
Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)",
|
||||
}
|
||||
|
||||
// Execute runs the root command; the binary in cmd/oo-cli is a thin wrapper.
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
|
||||
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
|
||||
func newOO() (*onlyoffice.Client, error) {
|
||||
_ = godotenv.Load()
|
||||
creds := onlyoffice.GetEnvironmentCredentials()
|
||||
if creds.Url == "" || creds.User == "" || creds.Password == "" {
|
||||
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)")
|
||||
}
|
||||
c := onlyoffice.NewClient(creds)
|
||||
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
|
||||
if err := c.Authenticate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func printJSON(v any) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(cmdCalList())
|
||||
rootCmd.AddCommand(cmdCalEvents())
|
||||
rootCmd.AddCommand(cmdCalAdd())
|
||||
rootCmd.AddCommand(cmdCalDel())
|
||||
rootCmd.AddCommand(cmdTaskList())
|
||||
rootCmd.AddCommand(cmdTaskAdd())
|
||||
rootCmd.AddCommand(cmdSubtaskAdd())
|
||||
rootCmd.AddCommand(cmdTaskUpdate())
|
||||
rootCmd.AddCommand(cmdCRMContacts())
|
||||
rootCmd.AddCommand(cmdCRMAddContact())
|
||||
rootCmd.AddCommand(cmdCRMDeals())
|
||||
rootCmd.AddCommand(cmdCRMAddDeal())
|
||||
rootCmd.AddCommand(cmdCRMCases())
|
||||
rootCmd.AddCommand(cmdAppsSync())
|
||||
}
|
||||
|
||||
func cmdCalList() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "cal-list",
|
||||
Short: "List calendars (default date span)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.ListCalendars(cmd.Context(), "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCalEvents() *cobra.Command {
|
||||
var start, end string
|
||||
cmd := &cobra.Command{
|
||||
Use: "cal-events",
|
||||
Short: "List calendar data for period (default: next 7 days)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if start == "" || end == "" {
|
||||
// Match list-events.py default window
|
||||
start = time.Now().Format("2006-01-02")
|
||||
end = time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
||||
}
|
||||
out, err := c.ListEvents(cmd.Context(), start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&start, "start", "", "start date YYYY-MM-DD")
|
||||
cmd.Flags().StringVar(&end, "end", "", "end date YYYY-MM-DD")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdCalAdd() *cobra.Command {
|
||||
var cal, desc string
|
||||
var allDay bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "cal-add TITLE START END",
|
||||
Short: "Add calendar event",
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev, err := c.AddEvent(cmd.Context(), cal, args[0], args[1], args[2], desc, allDay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(ev)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env)")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "")
|
||||
cmd.Flags().BoolVar(&allDay, "all-day", false, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdCalDel() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "cal-delete EVENT_ID [EVENT_ID...]",
|
||||
Short: "Delete calendar events",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range args {
|
||||
out, err := c.DeleteEvent(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cmdTaskList() *cobra.Command {
|
||||
var project, status string
|
||||
var all, verbose bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "task-list",
|
||||
Short: "List project tasks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var tasks []map[string]interface{}
|
||||
if all {
|
||||
tasks, err = c.ListAllTasks(cmd.Context(), status)
|
||||
} else {
|
||||
tasks, err = c.ListTasks(cmd.Context(), project, status)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
fmt.Printf("[%v] %v status=%v\n", t["id"], t["title"], t["status"])
|
||||
if verbose {
|
||||
d, _ := c.GetTaskByID(cmd.Context(), fmt.Sprint(t["id"]))
|
||||
subs, _ := d["subtasks"].([]interface{})
|
||||
for _, s := range subs {
|
||||
sm, _ := s.(map[string]interface{})
|
||||
fmt.Printf(" └─ [%v] %v\n", sm["id"], sm["title"])
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&project, "project", "p", "", "project id")
|
||||
cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed")
|
||||
cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)")
|
||||
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdTaskAdd() *cobra.Command {
|
||||
var project, desc, deadline string
|
||||
var prio string
|
||||
cmd := &cobra.Command{
|
||||
Use: "task-add TITLE",
|
||||
Short: "Add project task",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := 0
|
||||
if prio == "high" {
|
||||
p = 1
|
||||
}
|
||||
if prio == "low" {
|
||||
p = -1
|
||||
}
|
||||
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "", "")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "")
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "")
|
||||
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdSubtaskAdd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "subtask-add PARENT_TASK_ID TITLE [TITLE...]",
|
||||
Short: "Add subtask(s)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent := args[0]
|
||||
for _, title := range args[1:] {
|
||||
out, err := c.AddSubtask(cmd.Context(), parent, title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cmdTaskUpdate() *cobra.Command {
|
||||
var del bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "task-update TASK_ID open|closed",
|
||||
Short: "Update task status or delete",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := args[0]
|
||||
if del {
|
||||
out, err := c.DeleteTask(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("need status or --delete")
|
||||
}
|
||||
out, err := c.UpdateTaskStatus(cmd.Context(), id, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&del, "delete", false, "delete task")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdCRMContacts() *cobra.Command {
|
||||
var companies, persons bool
|
||||
var search string
|
||||
cmd := &cobra.Command{
|
||||
Use: "crm-contacts",
|
||||
Short: "List CRM contacts",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list, _, err := c.ListContacts(cmd.Context(), 50, 0, search)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range list {
|
||||
isCo, _ := row["isCompany"].(bool)
|
||||
if companies && !isCo {
|
||||
continue
|
||||
}
|
||||
if persons && isCo {
|
||||
continue
|
||||
}
|
||||
printJSON(row)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&companies, "companies", false, "")
|
||||
cmd.Flags().BoolVar(&persons, "persons", false, "")
|
||||
cmd.Flags().StringVar(&search, "search", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdCRMAddContact() *cobra.Command {
|
||||
var company, email, website, linkedin string
|
||||
var personFirst, personLast string
|
||||
var companyID int
|
||||
cmd := &cobra.Command{
|
||||
Use: "crm-add-contact",
|
||||
Short: "Add company or person",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if company != "" {
|
||||
out, err := c.CreateCompany(cmd.Context(), company)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cid := int(flexIDMap(out["id"]))
|
||||
if email != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Email", email, "Work", true)
|
||||
}
|
||||
if website != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Website", website, "Work", false)
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
if personFirst != "" && personLast != "" {
|
||||
out, err := c.CreatePerson(cmd.Context(), personFirst, personLast, companyID, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid := fmt.Sprint(int(flexIDMap(out["id"])))
|
||||
if email != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
||||
}
|
||||
if linkedin != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("set --company or --person-first and --person-last")
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&company, "company", "", "")
|
||||
cmd.Flags().StringVar(&personFirst, "person-first", "", "")
|
||||
cmd.Flags().StringVar(&personLast, "person-last", "", "")
|
||||
cmd.Flags().IntVar(&companyID, "company-id", 0, "")
|
||||
cmd.Flags().StringVar(&email, "email", "", "")
|
||||
cmd.Flags().StringVar(&website, "website", "", "")
|
||||
cmd.Flags().StringVar(&linkedin, "linkedin", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func flexIDMap(v interface{}) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case int:
|
||||
return float64(x)
|
||||
default:
|
||||
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCRMDeals() *cobra.Command {
|
||||
var stages bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "crm-deals",
|
||||
Short: "List opportunities / stages",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stages {
|
||||
out, err := c.ListDealStages(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
list, total, err := c.ListOpportunities(cmd.Context(), 100, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("total:", total)
|
||||
printJSON(list)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&stages, "stages", false, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdCRMAddDeal() *cobra.Command {
|
||||
var stage int
|
||||
var bid float64
|
||||
var contacts []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "crm-add-deal TITLE",
|
||||
Short: "Create opportunity",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stage == 0 {
|
||||
stage = 1
|
||||
}
|
||||
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, "", "EUR", "", bid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
did := fmt.Sprint(int(flexIDMap(out["id"])))
|
||||
for _, cid := range contacts {
|
||||
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
|
||||
}
|
||||
printJSON(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&stage, "stage", 1, "")
|
||||
cmd.Flags().Float64Var(&bid, "bid", 0, "")
|
||||
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id (repeatable)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cmdCRMCases() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "crm-cases",
|
||||
Short: "List CRM cases",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list, total, err := c.ListCases(cmd.Context(), 50, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("total:", total)
|
||||
printJSON(list)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cmdAppsSync() *cobra.Command {
|
||||
var apply, verbose bool
|
||||
var base string
|
||||
cmd := &cobra.Command{
|
||||
Use: "applications-sync",
|
||||
Short: "Sync applications/*/README.md into CRM",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if base == "" {
|
||||
return fmt.Errorf("use --path to applications year dir (e.g. .../applications/2026)")
|
||||
}
|
||||
paths, err := applications.Discover(base)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var apps []applications.Data
|
||||
for _, p := range paths {
|
||||
a, err := applications.ParseReadme(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Position == "" && a.Company == "" {
|
||||
continue
|
||||
}
|
||||
apps = append(apps, a)
|
||||
}
|
||||
fmt.Printf("Found %d application(s)\n", len(apps))
|
||||
applications.Sync(cmd.Context(), c, apps, !apply, verbose)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&apply, "apply", false, "write to CRM (default dry-run)")
|
||||
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
|
||||
cmd.Flags().StringVar(&base, "path", "", "applications base directory")
|
||||
return cmd
|
||||
}
|
||||
Reference in New Issue
Block a user