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 4s
Tests / Test (Go 1.25) (pull_request) Successful in 59s
Tests / Test (Go stable) (pull_request) Successful in 1m7s
Showroom-safe: the tree no longer carries internal hosts, IPs, ports, personal names, client domains or client file names. Behaviour is unchanged and now supplied per deployment. - catalog: hardcoded mail-org / project classifiers become a YAML-driven Classifier (OO_CATALOG_CONFIG or --config); neutral default classifies nothing as work. New catalog/classify.go + example + tests. - storage_fallback: drop the baked-in MinIO endpoint IP; require MINIO_ENDPOINT (+ keys) from the env. - kontolink: build DocEditor links from $ONLYOFFICE_URL instead of a hardcoded portal host; kontoblatt: no client file id in the output name. - oo: load .env CLI-wide (bootstrap.LoadEnv in execute) so non-authenticating commands (catalog scans) also see config. - genericize comments/docs/fixtures (AGENTS, README, .env.example, crm-associations, catalog tests, ES/pdfattach tests, mails).
321 lines
9.7 KiB
Go
321 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/eslider/go-onlyoffice/catalog"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var catalogCmd = &cobra.Command{
|
|
Use: "catalog",
|
|
Short: "Inventory clients/contacts from disk; match and apply to OO CRM",
|
|
Long: `Build a YAML catalog of persons/companies from contacts folders and project
|
|
trees, match against live OnlyOffice CRM, then create/update only rows with
|
|
approve: true.
|
|
|
|
Typical flow:
|
|
oo catalog scan-contacts --root PATH -O /tmp/contacts.yaml
|
|
oo catalog scan-projects --root ~/projects -O /tmp/projects.yaml
|
|
oo catalog scan-thunderbird --root PATH -O /tmp/thunderbird.yaml
|
|
oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -i /tmp/thunderbird.yaml -O docs/catalog/clients-contacts.yaml
|
|
oo catalog match -i docs/catalog/clients-contacts.yaml
|
|
# edit approve: true on pilot rows
|
|
oo catalog apply --dry-run -i docs/catalog/clients-contacts.yaml
|
|
oo catalog apply --apply -i docs/catalog/clients-contacts.yaml
|
|
`}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(catalogCmd)
|
|
catalogCmd.AddCommand(catalogScanContactsCmd())
|
|
catalogCmd.AddCommand(catalogScanProjectsCmd())
|
|
catalogCmd.AddCommand(catalogScanThunderbirdCmd())
|
|
catalogCmd.AddCommand(catalogMergeCmd())
|
|
catalogCmd.AddCommand(catalogMatchCmd())
|
|
catalogCmd.AddCommand(catalogApplyCmd())
|
|
}
|
|
|
|
func catalogScanContactsCmd() *cobra.Command {
|
|
var outPath string
|
|
cmd := &cobra.Command{
|
|
Use: "scan-contacts",
|
|
Short: "Parse VCF + folder stubs + email txt under a contacts root",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
root, _ := cmd.Flags().GetString("root")
|
|
if root == "" {
|
|
return fmt.Errorf("--root is required")
|
|
}
|
|
doc, err := catalog.ScanContactsRoot(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeCatalog(cmd, doc, outPath)
|
|
},
|
|
}
|
|
cmd.Flags().String("root", "", "contacts directory (local path)")
|
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path (default: stdout summary)")
|
|
_ = cmd.MarkFlagRequired("root")
|
|
return cmd
|
|
}
|
|
|
|
func catalogScanProjectsCmd() *cobra.Command {
|
|
var outPath string
|
|
var maxDepth int
|
|
var configPath string
|
|
cmd := &cobra.Command{
|
|
Use: "scan-projects",
|
|
Short: "Git roots / remotes / top-level dirs → company rows",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
root, _ := cmd.Flags().GetString("root")
|
|
if root == "" {
|
|
return fmt.Errorf("--root is required")
|
|
}
|
|
cl, err := catalogClassifier(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
doc, err := catalog.ScanProjectsRootOpts(root, maxDepth, catalog.ScanOptions{Classifier: cl})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeCatalog(cmd, doc, outPath)
|
|
},
|
|
}
|
|
cmd.Flags().String("root", "", "projects directory (local path)")
|
|
cmd.Flags().IntVar(&maxDepth, "max-depth", 4, "max directory depth for git roots")
|
|
cmd.Flags().StringVar(&configPath, "config", "", "classification rules YAML (default $OO_CATALOG_CONFIG)")
|
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
|
|
_ = cmd.MarkFlagRequired("root")
|
|
return cmd
|
|
}
|
|
|
|
func catalogScanThunderbirdCmd() *cobra.Command {
|
|
var outPath string
|
|
var mboxHeaders bool
|
|
var configPath string
|
|
cmd := &cobra.Command{
|
|
Use: "scan-thunderbird",
|
|
Short: "Thunderbird profiles: abook/history.mab + Gloda SQLite contacts",
|
|
Long: `Walk a directory tree for Thunderbird profiles and extract person rows from:
|
|
- *.mab address books (email regex)
|
|
- global-messages-db.sqlite Gloda contacts/identities
|
|
- optional --mbox-headers: From/To/Cc/Reply-To from mbox folder files (no bodies)
|
|
|
|
Noisy senders (noreply, Amazon marketplace, GitHub reply, …) are skipped.
|
|
Default zone is private; work domains/names are classified from the rules in
|
|
$OO_CATALOG_CONFIG (or --config) — none are hardcoded.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
root, _ := cmd.Flags().GetString("root")
|
|
if root == "" {
|
|
return fmt.Errorf("--root is required")
|
|
}
|
|
cl, err := catalogClassifier(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
doc, err := catalog.ScanThunderbirdRootOpts(root, catalog.ScanOptions{MboxHeaders: mboxHeaders, Classifier: cl})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeCatalog(cmd, doc, outPath)
|
|
},
|
|
}
|
|
cmd.Flags().String("root", "", "Thunderbird profile or parent directory (local path)")
|
|
cmd.Flags().BoolVar(&mboxHeaders, "mbox-headers", false, "also extract emails from mbox From/To/Cc headers")
|
|
cmd.Flags().StringVar(&configPath, "config", "", "classification rules YAML (default $OO_CATALOG_CONFIG)")
|
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
|
|
_ = cmd.MarkFlagRequired("root")
|
|
return cmd
|
|
}
|
|
|
|
// catalogClassifier loads classification rules from --config, else
|
|
// $OO_CATALOG_CONFIG, else the neutral default (no rules).
|
|
func catalogClassifier(configPath string) (*catalog.Classifier, error) {
|
|
if configPath == "" {
|
|
return catalog.LoadClassifierFromEnv()
|
|
}
|
|
return catalog.LoadClassifier(configPath)
|
|
}
|
|
|
|
func catalogMergeCmd() *cobra.Command {
|
|
var inputs []string
|
|
var outPath string
|
|
cmd := &cobra.Command{
|
|
Use: "merge",
|
|
Short: "Union + normalize catalog YAML files by entry id",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(inputs) == 0 {
|
|
return fmt.Errorf("at least one -i/--input required")
|
|
}
|
|
var docs []*catalog.Document
|
|
for _, p := range inputs {
|
|
d, err := catalog.LoadYAML(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
docs = append(docs, d)
|
|
}
|
|
merged := catalog.MergeDocs(docs...)
|
|
if outPath == "" {
|
|
return fmt.Errorf("--out is required for merge")
|
|
}
|
|
if err := ensureParent(outPath); err != nil {
|
|
return err
|
|
}
|
|
if err := catalog.SaveYAML(outPath, merged); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "merged %d entries → %s\n", len(merged.Entries), outPath)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringArrayVarP(&inputs, "input", "i", nil, "input catalog YAML (repeatable)")
|
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output catalog YAML")
|
|
_ = cmd.MarkFlagRequired("out")
|
|
return cmd
|
|
}
|
|
|
|
func catalogMatchCmd() *cobra.Command {
|
|
var inPath, outPath string
|
|
cmd := &cobra.Command{
|
|
Use: "match",
|
|
Short: "Diff catalog vs live OO; set status new|exists|conflict and oo_id",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if inPath == "" {
|
|
return fmt.Errorf("--input is required")
|
|
}
|
|
doc, err := catalog.LoadYAML(inPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := catalog.MatchAgainstOO(cmd.Context(), client, doc); err != nil {
|
|
return err
|
|
}
|
|
if outPath == "" {
|
|
outPath = inPath
|
|
}
|
|
if err := catalog.SaveYAML(outPath, doc); err != nil {
|
|
return err
|
|
}
|
|
counts := map[string]int{}
|
|
for _, e := range doc.Entries {
|
|
counts[e.Status]++
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "matched → %s new=%d exists=%d conflict=%d\n",
|
|
outPath, counts["new"], counts["exists"], counts["conflict"])
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
|
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output path (default: overwrite input)")
|
|
_ = cmd.MarkFlagRequired("input")
|
|
return cmd
|
|
}
|
|
|
|
func catalogApplyCmd() *cobra.Command {
|
|
var inPath, outPath string
|
|
var dryRun, doApply bool
|
|
cmd := &cobra.Command{
|
|
Use: "apply",
|
|
Short: "Create/update OO contacts for approve:true rows",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if !dryRun && !doApply {
|
|
return fmt.Errorf("pass --dry-run or --apply")
|
|
}
|
|
if dryRun && doApply {
|
|
return fmt.Errorf("use either --dry-run or --apply, not both")
|
|
}
|
|
if inPath == "" {
|
|
return fmt.Errorf("--input is required")
|
|
}
|
|
doc, err := catalog.LoadYAML(inPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, err := newOO(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := catalog.ApplyApproved(cmd.Context(), client, doc, dryRun)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !dryRun {
|
|
if outPath == "" {
|
|
outPath = inPath
|
|
}
|
|
if err := catalog.SaveYAML(outPath, doc); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if outputFormat == "json" {
|
|
printJSON(res)
|
|
return nil
|
|
}
|
|
mode := "apply"
|
|
if dryRun {
|
|
mode = "dry-run"
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: created=%d updated=%d skipped=%d errors=%d\n",
|
|
mode, res.Created, res.Updated, res.Skipped, len(res.Errors))
|
|
for _, e := range res.Errors {
|
|
fmt.Fprintf(cmd.ErrOrStderr(), " error: %s\n", e)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
|
|
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write updated catalog after apply (default: overwrite input)")
|
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "count actions without writing to OO")
|
|
cmd.Flags().BoolVar(&doApply, "apply", false, "perform OO creates/updates")
|
|
_ = cmd.MarkFlagRequired("input")
|
|
return cmd
|
|
}
|
|
|
|
func writeCatalog(cmd *cobra.Command, doc *catalog.Document, outPath string) error {
|
|
if outPath != "" {
|
|
if err := ensureParent(outPath); err != nil {
|
|
return err
|
|
}
|
|
if err := catalog.SaveYAML(outPath, doc); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "wrote %d entries → %s\n", len(doc.Entries), outPath)
|
|
return nil
|
|
}
|
|
if outputFormat == "json" {
|
|
b, err := json.MarshalIndent(doc, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintln(cmd.OutOrStdout(), string(b))
|
|
return nil
|
|
}
|
|
persons, companies := 0, 0
|
|
for _, e := range doc.Entries {
|
|
if e.Kind == "company" {
|
|
companies++
|
|
} else {
|
|
persons++
|
|
}
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "entries=%d persons=%d companies=%d (pass -O PATH to write YAML)\n",
|
|
len(doc.Entries), persons, companies)
|
|
return nil
|
|
}
|
|
|
|
func ensureParent(path string) error {
|
|
dir := filepath.Dir(path)
|
|
if dir == "" || dir == "." {
|
|
return nil
|
|
}
|
|
return os.MkdirAll(dir, 0o755)
|
|
}
|