From b58940fcf2469836cfe2875261fb509db7eb2fa0 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 10 Aug 2026 00:12:54 +0100 Subject: [PATCH] chore: redact personal ops from public tree Move catalog, dossier sync, and SearXNG to private oo-workspace; keep generic CRM name/title helpers and anonymized docs/examples. --- AGENTS.md | 5 +- README.md | 100 +++++------------------ cmd/oo/cli_test.go | 2 +- cmd/oo/common.go | 2 +- cmd/oo/contacts.go | 3 +- cmd/oo/invoices.go | 2 +- cmd/oo/mails.go | 17 ++-- cmd/oo/main.go | 2 - cmd/oo/projects.go | 11 +-- crm.go | 4 +- crm_names.go | 172 +++++++++++++++++++++++++++++++++++++++ crm_names_test.go | 39 +++++++++ crm_normalize.go | 2 +- docs/crm-associations.md | 42 +++++----- go.mod | 10 --- go.sum | 49 ----------- 16 files changed, 270 insertions(+), 192 deletions(-) create mode 100644 crm_names.go create mode 100644 crm_names_test.go diff --git a/AGENTS.md b/AGENTS.md index ee5051e..497fb4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,13 +14,12 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea): - `main.go` — entry point (docstring lists the command tree). - `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag. - - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go`, `catalog.go` — one file per subject (or per subject facet), each registers in `init()`. + - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. - CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. -- **Catalog inventory — `catalog/` package.** Filesystem VCF/project scan → YAML → match/apply against CRM. Used by `oo catalog`; not part of the flat `*Client` surface (uses Client as a dependency). - **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`. - **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables. - **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it. -- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature. +- **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in private [`eSlider/oo-workspace`](https://git.produktor.io/eSlider/oo-workspace) (`oow`), not in this public tree. ## Rules diff --git a/README.md b/README.md index bcfd1ae..bb0637b 100644 --- a/README.md +++ b/README.md @@ -533,7 +533,7 @@ deals, total, _ := client.ListOpportunities(ctx, 50, 0) company, _ := client.FindCompany(ctx, "ACME") // Subtasks (form-encoded) -client.AddSubtask(ctx, "4242", "Prepare CV") +client.AddSubtask(ctx, "4242", "Prepare notes") ``` ### oo (bundled CLI) @@ -552,14 +552,13 @@ oo calendar events --start 2026-04-24 --end 2026-05-01 oo projects list oo projects get 33 oo tasks list --all --verbose -oo tasks subtask add 4242 "Prepare CV" +oo tasks subtask add 4242 "Prepare notes" oo persons create --first Jane --last Doe --email jane@example.com oo companies create --name "Acme GmbH" --website https://acme.com oo opportunities list oo opportunities stages oo cases list oo crm-tasks categories -oo applications sync --path ./applications/2026 --apply ``` ### office (TUI) @@ -613,7 +612,7 @@ oo projects files download 12345 --to ./copy.md oo projects files rename 12345 notes-v2.md oo projects files delete 12345 oo tasks files list 208 -oo tasks files upload 208 ./cv.pdf +oo tasks files upload 208 ./notes.pdf oo tasks files detach 208 12345 ``` @@ -632,23 +631,19 @@ oo tasks files detach 208 12345 | `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` | | `cases` | `list`, `create`, `delete`, `member-add` | | `crm-tasks` | `list`, `create`, `delete`, `categories` | -| `applications` | `sync` | -| `catalog` | `scan-contacts`, `scan-projects`, `scan-thunderbird`, `merge`, `match`, `apply` | The CLI reads only `.env` from the current working directory (godotenv is a CLI-only concern — the library itself never loads dotfiles). -Canonical `ONLYOFFICE_*` variables win over aliases. For produktor.io operator -files, `OO_URL` / `OO_USER` / `OO_PASS` are accepted as CLI-only aliases for -`ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`. +Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases: +`OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`. Run `oo --help` or `oo --help` for the full command reference. > **0.5.0 migration note:** the command tree was flattened per-subject. Old -> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, -> `oo applications-sync`, …) were replaced by subject-based equivalents -> (`oo calendar events`, `oo tasks list`, `oo contacts list`, -> `oo applications sync`). Flags on leaf commands are unchanged. +> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, …) were +> replaced by subject-based equivalents (`oo calendar events`, `oo tasks list`, +> `oo contacts list`). Flags on leaf commands are unchanged. ## oo CLI use cases @@ -670,7 +665,7 @@ Every list command accepts `-o table` (default) or `-o json` for scripting. ### CRM cleanup after imports or sync drift **Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice, -the same email on a contact three times, deals titled ` @ contoso`, or the same +the same email on a contact three times, deals titled ` @ Acme`, or the same HR contact linked to a deal twice. **One-shot fix** — runs every dedupe pass in order: @@ -709,10 +704,10 @@ oo contacts dedupe-info # Two deals with the same title oo opportunities dedupe -# Same contact attached twice to one deal (common after applications sync) +# Same contact attached twice to one deal oo opportunities dedupe-members -# Titles like " @ contoso" or extra whitespace +# Titles like " @ Acme" or extra whitespace oo opportunities fix-titles ``` @@ -736,10 +731,10 @@ oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \ --price 300 --opportunity DEAL_ID --language de-DE \ --line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID" -oo invoices pdf 41 --force +oo invoices pdf INVOICE_ID --force oo invoices pdf-cleanup INVOICE_ID oo invoices status INVOICE_ID --status draft -oo mails draft-invoice --invoice INVOICE_ID --to info@client.de +oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com ``` **Deal grouping flag** — when the same role at the same company created @@ -753,66 +748,11 @@ oo crm cleanup --ignore-company-suffix **Inspect before/after:** ```bash -oo opportunities list --count 200 | grep -i contoso -oo contacts get 857 -o json +oo opportunities list --count 200 +oo contacts get CONTACT_ID -o json oo crm cleanup -o json ``` -### Clients/contacts inventory (`catalog`) - -**Problem:** Contacts live as VCF/folders on disk and project trees under -`~/work` / ops-host; OnlyOffice CRM is the SSOT for persons/companies but -starts sparse. You need a reviewable inventory before writes. - -```bash -oo catalog scan-contacts --root /path/to/contacts -O /tmp/contacts.yaml -oo catalog scan-projects --root ~/work -O /tmp/projects.yaml -oo catalog scan-thunderbird --root /path/to/thunderbird-profile -O /tmp/thunderbird.yaml -oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -i /tmp/thunderbird.yaml -O clients-contacts.yaml -oo catalog match -i clients-contacts.yaml -# set approve: true on pilot rows in the YAML -oo catalog apply --dry-run -i clients-contacts.yaml -oo catalog apply --apply -i clients-contacts.yaml -oo crm cleanup -``` - -YAML schema and ops runbook live in the inventar repo under `docs/catalog/` and -`docs/ops/oo-clients-contacts-sync.md`. Library helper: `FindPersonByEmail`. - -### Job applications → CRM (`applications sync`) - -**Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`) -and want companies, persons, deals, and history notes in OnlyOffice without -re-typing. - -**Dry-run first** (default — prints what would happen, writes nothing): - -```bash -oo applications sync --path ./applications/2026 --verbose -``` - -**Apply** when the preview looks right: - -```bash -oo applications sync --path ./applications/2026 --apply --verbose -``` - -Each dossier `README.md` is parsed for company, role, email, phone, LinkedIn, etc. -Discovery walks `--path` and **skips** junk trees (`node_modules`, `tools`, `.venv`, -`pdfs`, …). Only folders that look like CV application slugs (`source-NNN-…`, or -long hyphenated dossiers) are synced — not npm package READMEs. - -The sync creates or finds contacts, opens a deal, adds members, and appends a -history note. Re-running is safe: duplicate members and duplicate deal titles -are skipped when already present. - -**After a large sync**, run CRM cleanup to collapse duplicates introduced by -repeated runs or manual edits: - -```bash -oo applications sync --path ./applications/2026 --apply -oo crm cleanup -o json -``` ### Workspace mail (`oo mails`) @@ -899,8 +839,8 @@ oo calendar events --start 2026-06-24 --end 2026-07-01 # Schedule interview block oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z -# Attach CV to a hiring task -oo tasks files upload 208 ./cv.pdf +# Attach a file to a task +oo tasks files upload 208 ./notes.pdf oo projects files list 33 ``` @@ -908,7 +848,7 @@ oo projects files list 33 | When | Command | |------|---------| -| After `applications sync --apply` | `oo crm cleanup` | +| After bulk CRM imports | `oo crm cleanup` | | After bulk CSV import into CRM | `oo crm cleanup` | | Weekly inbox triage | `oo mails list --folder inbox --limit 100` | | Before exec reporting | `oo opportunities list` + `oo projects list` | @@ -922,9 +862,9 @@ oo projects files list 33 | `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password | | `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) | | `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) | -| `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` | +| `OO_URL`, `OO_USER`, `OO_PASS` | Optional CLI-only aliases for `ONLYOFFICE_*` | -Mail, CRM cleanup, and applications sync are documented in [oo CLI use cases](#oo-cli-use-cases) above. +Mail and CRM cleanup are documented in [oo CLI use cases](#oo-cli-use-cases) above. Personal disk inventory / dossier sync lives in the private `oo-workspace` (`oow`) tooling. ### CI / releases diff --git a/cmd/oo/cli_test.go b/cmd/oo/cli_test.go index 66467dc..6a9561e 100644 --- a/cmd/oo/cli_test.go +++ b/cmd/oo/cli_test.go @@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) { want := []string{ "calendar", "projects", "tasks", "users", "whoami", "contacts", "persons", "companies", - "opportunities", "cases", "crm-tasks", "applications", "crm", "mails", "catalog", "invoices", + "opportunities", "cases", "crm-tasks", "crm", "mails", "invoices", } got := make(map[string]bool, len(rootCmd.Commands())) for _, c := range rootCmd.Commands() { diff --git a/cmd/oo/common.go b/cmd/oo/common.go index b9b3592..687f801 100644 --- a/cmd/oo/common.go +++ b/cmd/oo/common.go @@ -20,7 +20,7 @@ var outputFormat = "table" var rootCmd = &cobra.Command{ Use: "oo", Short: "OnlyOffice Workspace CLI — subject-based command tree", - Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications, catalog, invoices).", + Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, mails, invoices).", Version: version, SilenceUsage: true, SilenceErrors: false, diff --git a/cmd/oo/contacts.go b/cmd/oo/contacts.go index e84114e..687fa88 100644 --- a/cmd/oo/contacts.go +++ b/cmd/oo/contacts.go @@ -6,7 +6,6 @@ import ( "strings" onlyoffice "github.com/eslider/go-onlyoffice" - "github.com/eslider/go-onlyoffice/catalog" "github.com/spf13/cobra" ) @@ -361,7 +360,7 @@ With --company-id, only persons linked to that company are scanned.`, } } } - cf, cl := catalog.CleanPersonNames(fn, ln, dn, org, emails) + cf, cl := onlyoffice.CleanPersonNames(fn, ln, dn, org, emails) needName := cf != fn || cl != ln linkID := companyID if linkID == 0 { diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go index da6555c..47b2b1a 100644 --- a/cmd/oo/invoices.go +++ b/cmd/oo/invoices.go @@ -94,7 +94,7 @@ Always pass --opportunity when a deal exists (entity link at create). Updating --opportunity later often fails with HTTP 400 — see docs/crm-associations.md. Example: - oo invoices create --number INV-2026-01 --contact CONTACT_ID --item 12 \ + oo invoices create --number INV-2026-01 --contact CONTACT_ID --item ITEM_ID \ --price 300 --opportunity OPPORTUNITY_ID --line-description "Service package" \ --terms "…payment terms…" `, diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index d6dc4fe..8573081 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -244,23 +244,16 @@ Does not send. Open /addons/mail/#drafts to review. number := strings.TrimSpace(fmt.Sprint(inv["number"])) cost := formatInvoiceCostEUR(inv["cost"]) if subject == "" { - subject = fmt.Sprintf("Rechnung %s — Service package (%s EUR)", number, cost) + subject = fmt.Sprintf("Invoice %s (%s EUR)", number, cost) } if body == "" { - body = fmt.Sprintf(`Guten Tag, + body = fmt.Sprintf(`Hello, -anbei übersende ich die Rechnung %s über %s EUR -für das Service package (OS image inkl. Backup, Monitoring, RustDesk, -gemeinsamem Datenträger und Netzwerkzugriff). +please find attached invoice %s for %s EUR. -Zahlungsziel: 14 Tage netto. +Payment terms: see invoice notes. -Steuerhinweis: siehe Notizen auf der Rechnung -(Reverse Charge / § 13b UStG). - -Mit freundlichen Grüßen - -Example Author`, number, cost) +Best regards`, number, cost) } draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{ From: from, diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 155cebe..13c38fc 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -15,8 +15,6 @@ // oo crm cleanup // oo mails accounts | folders | list | get | draft | attach | draft-invoice | delete // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … -// oo applications sync -// oo catalog scan-contacts | scan-projects | scan-thunderbird | merge | match | apply // // CRM association rules: docs/crm-associations.md // diff --git a/cmd/oo/projects.go b/cmd/oo/projects.go index 6299635..9822a4d 100644 --- a/cmd/oo/projects.go +++ b/cmd/oo/projects.go @@ -8,7 +8,6 @@ import ( "strings" onlyoffice "github.com/eslider/go-onlyoffice" - "github.com/eslider/go-onlyoffice/catalog" "github.com/spf13/cobra" ) @@ -143,7 +142,7 @@ full title is composed as "CC | Company | Title".`, } title := args[0] if country != "" || company != "" { - title = catalog.FormatProjectTitle(country, company, args[0]) + title = onlyoffice.FormatProjectTitle(country, company, args[0]) } p, err := c.CreateProject(onlyoffice.NewProjectRequest{ Title: title, @@ -381,9 +380,9 @@ func prjContactsLinkGitCmd() *cobra.Command { 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 — approve/apply them via catalog first. +Does not create new persons — create/link them in CRM first. -Also links --company-id contacts (e.g. Acme + end-client company).`, +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 { @@ -425,10 +424,8 @@ func prjContactsLinkAuthorsCmd() *cobra.Command { Long: `Same as link-git, but reads authors from a file produced by: git shortlog -sne --all > authors.txt - # or via ssh: - ssh ops-host 'git -C /path shortlog -sne --all' > authors.txt -Then: oo projects contacts link-authors 59 --from authors.txt --company-id 9`, +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 { diff --git a/crm.go b/crm.go index 3e11d3c..af03507 100644 --- a/crm.go +++ b/crm.go @@ -2,7 +2,7 @@ package onlyoffice // Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes. // These expose untyped maps for flexibility — they are primarily consumed by -// cmd/oo and the applications-sync workflow. +// cmd/oo and CRM sync tooling. import ( "context" @@ -663,7 +663,7 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05") } if categoryID == 0 { - categoryID = 2 // Opportunity — matches applications sync + categoryID = 2 // Opportunity (default CRM task category) } uid, err := c.SelfUserID(ctx) if err != nil { diff --git a/crm_names.go b/crm_names.go new file mode 100644 index 0000000..f115787 --- /dev/null +++ b/crm_names.go @@ -0,0 +1,172 @@ +package onlyoffice + +import ( + "regexp" + "strings" + "unicode" +) + +var ( + parenSuffixRE = regexp.MustCompile(`(?i)\s*[\(\[\{][^)\]\}]*[\)\]\}]\s*$`) + dashCompanyRE = regexp.MustCompile(`(?i)\s+[-–—]\s+[A-Za-z0-9].*$`) + emailLocalRE = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`) + nonNameTokenRE = regexp.MustCompile(`[^a-zA-ZÀ-öø-ÿĀ-ž0-9'’.\-]+`) +) + +// CleanPersonNames strips company annotations from display names and fills +// first/last from email local-part when the source used an address as the name. +// Company affiliation belongs on CRM companyId — never in LastName. +func CleanPersonNames(first, last, display, org string, emails []string) (cleanFirst, cleanLast string) { + first = strings.TrimSpace(first) + last = strings.TrimSpace(last) + display = strings.TrimSpace(display) + org = strings.TrimSpace(org) + + if looksLikeEmail(first) { + ef, el := GuessNameFromEmail(first) + first, last = ef, el + } + if looksLikeEmail(display) && first == "" && last == "" { + display = "" + } + + if first == "" && last == "" && display != "" { + first, last = SplitDisplayName(display) + } + + first = stripCompanyAnnotation(first, org) + last = stripCompanyAnnotation(last, org) + + last = stripCompanyAnnotation(last, org) + if i := strings.IndexAny(first, "(["); i > 0 { + first = strings.TrimSpace(first[:i]) + } + if org != "" && personLastIsOrg(last, org) { + last = "" + } + + if (first == "" || looksLikeEmail(first)) && len(emails) > 0 { + ef, el := GuessNameFromEmail(emails[0]) + if first == "" || looksLikeEmail(first) { + first = ef + } + if last == "" || last == "-" { + last = el + } + } + + first = strings.TrimSpace(first) + last = strings.TrimSpace(last) + if last == "" { + last = "-" + } + return first, last +} + +func stripCompanyAnnotation(s, org string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = parenSuffixRE.ReplaceAllString(s, "") + s = strings.TrimSpace(s) + s = dashCompanyRE.ReplaceAllString(s, "") + s = strings.TrimSpace(s) + if org != "" { + for _, sep := range []string{" - ", " – ", " — ", " / "} { + if i := strings.LastIndex(strings.ToLower(s), strings.ToLower(sep+org)); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + } + suf := " (" + org + ")" + if strings.HasSuffix(strings.ToLower(s), strings.ToLower(suf)) { + s = strings.TrimSpace(s[:len(s)-len(suf)]) + } + } + return strings.TrimSpace(s) +} + +func personLastIsOrg(last, org string) bool { + last = NormalizePersonNameKey(last) + org = NormalizePersonNameKey(org) + if last == "" || org == "" { + return false + } + if last == org { + return true + } + return strings.HasPrefix(org, last+" ") || strings.HasPrefix(org, last+",") +} + +func looksLikeEmail(s string) bool { + return emailLocalRE.MatchString(strings.TrimSpace(s)) +} + +// GuessNameFromEmail turns local@domain into Title-Case first/last when the +// local part looks like first.last / first_last / first-last. +func GuessNameFromEmail(email string) (first, last string) { + email = NormalizeContactEmail(email) + local, _, ok := strings.Cut(email, "@") + if !ok || local == "" { + return "", "" + } + local = strings.Split(local, "+")[0] + parts := strings.FieldsFunc(local, func(r rune) bool { + return r == '.' || r == '_' || r == '-' + }) + if len(parts) == 0 { + return titleNameToken(local), "" + } + if len(parts) == 1 { + return titleNameToken(parts[0]), "" + } + return titleNameToken(parts[0]), titleNameToken(strings.Join(parts[1:], " ")) +} + +func titleNameToken(s string) string { + s = nonNameTokenRE.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + if s == "" { + return "" + } + runes := []rune(strings.ToLower(s)) + runes[0] = unicode.ToTitle(runes[0]) + return string(runes) +} + +// FormatProjectTitle builds "CC | Company | Title" (spaces around |). +// Country should be a short code (DE, TF, UA, …). Empty segments are dropped. +func FormatProjectTitle(country, company, title string) string { + parts := make([]string, 0, 3) + for _, p := range []string{country, company, title} { + p = strings.TrimSpace(p) + p = strings.ReplaceAll(p, "|", "/") + if p != "" { + parts = append(parts, p) + } + } + return strings.Join(parts, " | ") +} + +// NormalizeContactEmail lowercases and trims. +func NormalizeContactEmail(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// NormalizePersonNameKey collapses whitespace and lowercases for matching. +func NormalizePersonNameKey(s string) string { + fields := strings.Fields(strings.ToLower(strings.TrimSpace(s))) + return strings.Join(fields, " ") +} + +// SplitDisplayName splits "First Last …" into first/last (last = remainder). +func SplitDisplayName(name string) (first, last string) { + parts := strings.Fields(strings.TrimSpace(name)) + if len(parts) == 0 { + return "", "" + } + if len(parts) == 1 { + return parts[0], "" + } + return parts[0], strings.Join(parts[1:], " ") +} diff --git a/crm_names_test.go b/crm_names_test.go new file mode 100644 index 0000000..933d744 --- /dev/null +++ b/crm_names_test.go @@ -0,0 +1,39 @@ +package onlyoffice + +import "testing" + +func TestCleanPersonNames_stripsCompanyParen(t *testing.T) { + f, l := CleanPersonNames("Jane", "Doe (Acme)", "Jane Doe (Acme)", "Acme", nil) + if f != "Jane" || l != "Doe" { + t.Fatalf("got %q %q", f, l) + } +} + +func TestCleanPersonNames_stripsDashCompany(t *testing.T) { + f, l := CleanPersonNames("Jane", "Doe - Acme", "", "Acme", nil) + if f != "Jane" || l != "Doe" { + t.Fatalf("got %q %q", f, l) + } +} + +func TestCleanPersonNames_fromEmail(t *testing.T) { + f, l := CleanPersonNames("jane.doe@example.com", "-", "", "Acme", + []string{"jane.doe@example.com"}) + if f != "Jane" || l != "Doe" { + t.Fatalf("got %q %q", f, l) + } +} + +func TestCleanPersonNames_lastIsCompany(t *testing.T) { + f, l := CleanPersonNames("Jane", "Acme", "", "Acme GmbH & Co. KG", nil) + if f != "Jane" || l != "-" { + t.Fatalf("got %q %q", f, l) + } +} + +func TestFormatProjectTitle(t *testing.T) { + got := FormatProjectTitle("DE", "Acme", "Map App") + if got != "DE | Acme | Map App" { + t.Fatalf("got %q", got) + } +} diff --git a/crm_normalize.go b/crm_normalize.go index 123e168..1bc63d2 100644 --- a/crm_normalize.go +++ b/crm_normalize.go @@ -69,7 +69,7 @@ func StripCompanySuffix(title string) string { } // FixDealTitle strips a leading @, normalizes separator spacing, and collapses -// empty-position titles like " @ contoso" to "contoso". +// empty-position titles like " @ Acme" to "Acme". func FixDealTitle(s string) string { s = strings.TrimSpace(s) for strings.HasPrefix(s, "@") { diff --git a/docs/crm-associations.md b/docs/crm-associations.md index 8e443e6..dfe744b 100644 --- a/docs/crm-associations.md +++ b/docs/crm-associations.md @@ -1,7 +1,7 @@ # CRM associations (company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail) -Operational rules learned from real billing work (AcmeClient INV-2026-01, AcmeProject INV-2026-01). -Tooling: `oo` CLI + this library. Business SSOT remains OnlyOffice (`ASR-0002`). +Operational rules for the `oo` CLI and this library. Business SSOT remains +OnlyOffice Workspace CRM + Projects. ## Canonical graph @@ -9,8 +9,8 @@ One **legal company** owns the relationship. Do not invent a second “bill-to company just for PDF layout. ```text -Company (#CONTACT_ID) -├── Person (GF / buyer contact) oo persons create --company-id +Company +├── Person (buyer contact) oo persons create --company-id ├── Opportunity / Deal oo opportunities … ; member-add company + person ├── Project (hub) oo projects … ; contacts add company + person │ └── Epic + subtasks @@ -34,7 +34,7 @@ UI checks (same company card): - `#deals` → opportunity - `#projects` → hub project - `#invoices` on the **deal** → invoice (needs `entity`) -- `#files` → preferably **one** current `P-….pdf` +- `#files` → preferably **one** current invoice PDF **Project Team ≠ Project Contacts.** Team = portal users. CRM people/companies show under the project **Contacts** tab (`oo projects contacts list`). @@ -57,7 +57,7 @@ show under the project **Contacts** tab (`oo projects contacts list`). **does not work** — delete + recreate Draft instead. 5. **Do not regenerate PDF in a loop** without cleanup. Each `GET …/crm/invoice/{id}/pdf` attaches a new file to the company (and often - the deal). Keep `invoice.fileID`; delete older `P-*.pdf` with + the deal). Keep `invoice.fileID`; delete older PDFs with `oo invoices pdf-cleanup ID` / Documents `fileops/delete`. ## Invoice PDF quirks @@ -69,24 +69,24 @@ show under the project **Contacts** tab (`oo projects contacts list`). | Separate bill-to company for newlines | **Forbidden** — merge back to the real company | | Invoice **number** won’t change on PUT | Delete Draft and recreate with the desired number | | Notizen / Bedingungen spacing | Leading `\n` and blank lines only — no HTML (tags print literally) | -| Issuer NIE / street lines | Organisation profile address (`street` with `\n`), not only terms | +| Issuer street lines | Organisation profile address (`street` with `\n`), not only terms | -Status ids commonly used on this portal: `1` Draft, `2` Billed, `3` Rejected, `4` Paid. +Status ids commonly used: `1` Draft, `2` Billed, `3` Rejected, `4` Paid. ## Mail quirks | Symptom | Workaround | |---------|------------| -| Signature / body doubles Matrix URL | Put chat in **one** place only. UI drafts: signature. API send: body (API **does not** append signature). | -| Signature / body cuts Matrix URL at `#` | Plain text `chat: https://matrix.to/#/@user:server` — avoid `` (or encode `#` as `%23` in href) | +| Signature / body doubles chat URL | Put chat in **one** place only. UI drafts: signature. API send: body (API **does not** append signature). | +| Signature / body cuts URL at `#` | Plain text URLs — avoid `` (or encode `#` as `%23` in href) | | German letter spacing | Blank `

 

` between blocks (`MailHTMLWithBlankParagraphs`) | | Send | `PUT /api/2.0/mail/messages/send.json` with `id/from/to/subject/body`; omit empty `cc`/`bcc`. Never auto-send; draft only until the human confirms | -Prefer OnlyOffice Mail (`/addons/mail/#drafts`) over Gmail MCP for invoice delivery. +Prefer OnlyOffice Mail (`/addons/mail/#drafts`) for invoice delivery until confirmed. ## Project / task quirks -- Hub title: `CC | Company` (e.g. `DE | AcmeClient Ambulanter Pflegedienst GmbH`). +- Hub title: `CC | Company` (e.g. `DE | Acme GmbH`). - Streams = epics/tasks under the hub, not a third title segment (unless the project itself is a named delivery stream). - Closing a **subtask**: @@ -99,26 +99,26 @@ Prefer OnlyOffice Mail (`/addons/mail/#drafts`) over Gmail MCP for invoice deliv ## Merge / cleanup cheat sheet ```bash -# Keep the human-created company (INTO), drop the duplicate (FROM) -oo contacts merge 1334 1328 +# Keep the preferred company (INTO), drop the duplicate (FROM) +oo contacts merge FROM_ID INTO_ID # Or by normalized name (careful — whole CRM) oo companies dedupe # Invoice ↔ deal must exist at create -oo invoices create --number P-YYYY-NN --contact CONTACT_ID --item N \ - --price 300 --opportunity OPPORTUNITY_ID --language de-DE … +oo invoices create --number P-YYYY-NN --contact COMPANY_ID --item ITEM_ID \ + --price 300 --opportunity DEAL_ID --language de-DE … -# Fresh PDF + prune older P-*.pdf on company/deal -oo invoices pdf 41 --force +# Fresh PDF + prune older PDFs on company/deal +oo invoices pdf INVOICE_ID --force oo invoices pdf-cleanup INVOICE_ID # Mail draft (no send) -oo mails draft-invoice --invoice INVOICE_ID --to info@client.de +oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com ``` ## Related - README § invoices / mail / CRM cleanup -- Skill `oo-clients-projects` (Cursor) -- inventar ASR-0014 / `ops/oo-clients-contacts-sync.md` +- Personal workspace tooling (disk inventory, dossier sync): private + `git.produktor.io/eSlider/oo-workspace` (`oow` CLI) diff --git a/go.mod b/go.mod index 6094400..7bd630c 100644 --- a/go.mod +++ b/go.mod @@ -9,15 +9,12 @@ require ( github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v0.12.1 github.com/charmbracelet/x/ansi v0.1.4 - github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 github.com/eslider/go-xls/v2 v2.1.0 github.com/google/go-querystring v1.2.0 github.com/joho/godotenv v1.5.1 github.com/mattn/go-runewidth v0.0.15 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 - gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.54.0 ) require ( @@ -28,8 +25,6 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect @@ -39,8 +34,6 @@ require ( github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/yuin/goldmark v1.8.2 // indirect @@ -50,7 +43,4 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect - modernc.org/libc v1.74.1 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 6c0a467..cbd1ee2 100644 --- a/go.sum +++ b/go.sum @@ -33,24 +33,14 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:Yyn github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI= -github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A= github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -76,12 +66,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -101,8 +87,6 @@ github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= @@ -114,37 +98,4 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= -modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=