diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index e5f8366..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Release Please - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - issues: write - actions: write - -jobs: - release-please: - name: Release Please - if: github.server_url == 'https://github.com' - runs-on: ubuntu-latest - steps: - - name: Run release-please - uses: googleapis/release-please-action@v4 - id: release - with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json - token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} - - # Tags/releases created with GITHUB_TOKEN do not trigger other workflows - # (push:tags on Release.yml never fires). Dispatch GoReleaser explicitly. - - name: Trigger GoReleaser - if: ${{ steps.release.outputs.release_created == 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.release.outputs.tag_name }} - run: | - set -euo pipefail - echo "Dispatching Release workflow for $TAG" - gh workflow run release.yml --repo "${{ github.repository }}" -f "tag=${TAG}" - - outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4553864..b2f2756 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,8 @@ jobs: runs-on: ubuntu-latest steps: # Always clone default branch first. workflow_dispatch often races with - # release-please creating the tag; fetching refs/tags/X before it exists - # fails checkout. Retry fetch, then check out the tag. + # tag creation; fetching refs/tags/X before it exists fails checkout. + # Retry fetch, then check out the tag. - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 5e39b94..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "0.18.0" -} diff --git a/AGENTS.md b/AGENTS.md index ec519e3..c8e3e9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`. - `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle. - `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`). - - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `files_webdav.go`, `files_stem.go`, `retry.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`UpdateFile`, `UploadToFolderReplacing`). **`files_webdav.go`** — Documents module by id (`ListDavFolder`, `MoveDavItems`/`CopyDavItems` with per-operation error surfacing, `ListFileOps`). **`retry.go`** — `DoRetry`: deterministic exponential backoff (no jitter) on 429/502/503/504; every bulk tool routes API calls through it, and the HTTP transport + auth (`retryRaw`, `AuthenticateContext`) retry transient answers centrally. `ratelimit.go` adds a process-wide token bucket (`OO_RATE_LIMIT`/`OO_BURST`) and a shared 429 cooldown gate, installed via `pacedTransport` in `NewClient`; `Retry-After` is parsed into `*TransientError` and honoured. See [`docs/rate-limiting.md`](docs/rate-limiting.md). **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md). - - **Unified file client (epic #34) — `file_core.go`, `file_rest.go`, `file_dav.go`, `file_pg.go`, `file_es.go`, `file_es_text.go`, `file_text_index.go`, `file_facade.go`.** `file_core.go` — model (`Entry`, `Kind`) + `FileStore`/`Searcher`; `file_rest.go`/`file_dav.go` — REST/WebDAV adapters; `file_pg.go` — **read-only** SQL store (PostgreSQL/MySQL, `ErrReadOnly` on writes); `file_es.go` — OnlyOffice Elasticsearch searcher; `file_es_text.go`/`file_text_index.go` — own PDF/scan index (`oo_docs_text`, PDF attachments via pdfdetach); `file_facade.go` — `FileClient` with read/write/search order and transient fallback. Use `c.Files()` (facade), `c.FileStore("rest"|"dav"|"pg"|"sql")` or `c.SQLFileStore()`; contract and how to add a backend: [`docs/unified-file-client.md`](docs/unified-file-client.md). + - `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `files_webdav.go`, `files_stem.go`, `retry.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`UpdateFile`, `UploadToFolderReplacing`). **`files_webdav.go`** — Documents module by id (`ListDavFolder`, `MoveDavItems`/`CopyDavItems` with per-operation error surfacing, `ListFileOps`). **`retry.go`** — `DoRetry`: deterministic exponential backoff (no jitter) on 429/502/503/504; every bulk tool routes API calls through it, and the HTTP transport + auth (`retryRaw`, `AuthenticateContext`) retry transient answers centrally. `ratelimit.go` adds a process-wide token bucket (`OO_RATE_LIMIT`/`OO_BURST`) and a shared 429 cooldown gate, installed via `pacedTransport` in `NewClient`; `Retry-After` is parsed into `*TransientError` and honoured. See [`docs/rate-limiting.md`](docs/rate-limiting.md). **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules live with the private `oo-workspace` tooling. + - **Unified file client (epic #34) — `filestore_core.go`, `filestore_rest.go`, `filestore_dav.go`, `filestore_pg.go`, `filestore_es.go`, `filestore_es_text.go`, `filestore_text_index.go`, `filestore_facade.go`.** `filestore_core.go` — model (`Entry`, `Kind`) + `FileStore`/`Searcher`; `filestore_rest.go`/`filestore_dav.go` — REST/WebDAV adapters; `filestore_pg.go` — **read-only** SQL store (PostgreSQL/MySQL, `ErrReadOnly` on writes); `filestore_es.go` — OnlyOffice Elasticsearch searcher; `filestore_es_text.go`/`filestore_text_index.go` — own PDF/scan index (`oo_docs_text`, PDF attachments via pdfdetach); `filestore_facade.go` — `FileClient` with read/write/search order and transient fallback. Use `c.Files()` (facade), `c.FileStore("rest"|"dav"|"pg"|"sql")` or `c.SQLFileStore()`; contract and how to add a backend: [`docs/unified-file-client.md`](docs/unified-file-client.md). - Pure stdlib + `google/go-querystring`; no UI, no dotenv. - **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). @@ -20,7 +20,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - **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. -- **Bulk Documents tools — `cmd/ooscan/`, `cmd/pdfamount/`, `cmd/kontoblatt/`, `cmd/kontolink/`.** Single-purpose binaries (folder index, PDF amounts, Kontoblatt summary/linking). Pace requests, route API calls through `DoRetry`; usage in README. +- **Bulk Documents tools live in the private `oo-workspace` repo** (`ooscan`, `pdfamount`, `kontoblatt`, `kontolink`), not in this public tree. They use the public client and its `DoRetry` pacing. - **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in a private companion repo `eSlider/oo-workspace` (the `oow` CLI), not in this public tree. ## Rules diff --git a/README.md b/README.md index 756b806..ab5e7a2 100644 --- a/README.md +++ b/README.md @@ -801,26 +801,11 @@ with `c.SQLFileStore()`, or register a backend on the facade fallback rules, env names and how to add a backend: [`docs/unified-file-client.md`](docs/unified-file-client.md). -### Bulk tools (`cmd/`) +### Bulk tools -Small single-purpose binaries for bulk Documents work. All of them pace -requests through a process-wide token bucket (default ~4 req/s, `OO_RATE_LIMIT`/ -`OO_BURST`) and retry transient OnlyOffice answers (429/502/503/504) with a -deterministic exponential backoff — no jitter, same waits on every run. A 429 -opens a shared cooldown gate and `Retry-After` is honoured (see `DoRetry` and -[`docs/rate-limiting.md`](docs/rate-limiting.md)). Build with -`go build ./cmd/`. - -```bash -ooscan 659 # recursive index → TSV: file_id, folder_id, path, title -ooscan 659 666 > oo-index.tsv # several roots into one index -pdfamount 671 # "Zu zahlender Betrag" per PDF → TSV: file_id, title, amount -kontoblatt 1234 ./kontoblatt.xlsx # summary (Gegenkonto/Monat) uploaded next to source -kontolink IN.xlsx oo-index.tsv OUT.xlsx [FILE_ID] [AMOUNTS_TSV] -# kontolink writes DocEditor links into the Link column: Beleg → supplier+month -# → amount+date (5th arg = pdfamount output); with FILE_ID it updates the -# source file in place, else uploads an "(links)" copy next to it. -``` +Business / one-off bulk tools (`ooscan`, `pdfamount`, `kontoblatt`, +`kontolink`) live in the private `oo-workspace` repo, not in this public +library. They build on the public client and the same `DoRetry` pacing. | Subject | Verbs | |---|---| @@ -934,8 +919,8 @@ Merge two known company ids (keeps `INTO`): oo contacts merge FROM_ID INTO_ID ``` -Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks: -[docs/crm-associations.md](docs/crm-associations.md). +Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks live +with the private `oo-workspace` tooling. ### Invoices (`oo invoices`) @@ -1125,24 +1110,20 @@ Live runs need credentials (`ONLYOFFICE_URL`, `ONLYOFFICE_USER`, ### rclone WebDAV mount -`deploy/docker-compose.rclone-webdav.yml` mounts the Documents tree as a -filesystem (compose, not systemd; container `rclone-webdav`) — read/write like -a normal FS over the `oo-webdav` sidecar. Setup, smoke log and limitations: -[`docs/rclone-webdav.md`](docs/rclone-webdav.md). +The rclone WebDAV mount (compose + `oo-webdav` sidecar) is deployment +tooling and lives in the private `oo-workspace` repo. Set +`ONLYOFFICE_WEBDAV_URL` to point the client at it. ### CI / releases -GitHub Actions (pattern from [`eSlider/go-config`](https://github.com/eSlider/go-config)): - | Workflow | Trigger | Purpose | |---|---|---| | `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` | -| `release-please.yml` | push to `main` | semver PR from conventional commits | | `release.yml` | tag `v*` | GoReleaser cross-platform `oo` + `office` binaries | -Repo setting required once: **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**. - -Merge the release-please PR to tag a version; GoReleaser publishes assets to [GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases). +Gitea is canonical; tags are created there per SemVer (`fix:` → patch, +`feat:` → minor, `!` → major). GoReleaser publishes assets to +[GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases). ## Examples diff --git a/cmd/kontoblatt/main.go b/cmd/kontoblatt/main.go deleted file mode 100644 index 48698ee..0000000 --- a/cmd/kontoblatt/main.go +++ /dev/null @@ -1,220 +0,0 @@ -// Command kontoblatt builds a summary ("сводная таблица") of a Kontoblatt XLSX -// (Datum, Gegenkonto, Buchungstext, Beleg, Soll, Haben, Bemerkung) and uploads -// it back to the same OnlyOffice folder as the source file. -// -// Usage: kontoblatt -package main - -import ( - "context" - "fmt" - "os" - "regexp" - "sort" - "strconv" - "strings" - - onlyoffice "github.com/eslider/go-onlyoffice" - "github.com/xuri/excelize/v2" -) - -type agg struct { - count int - soll float64 - haben float64 - reFehlt int -} - -type rec struct { - date, month, konto, text string - soll, haben float64 - reFehlt bool -} - -var dateRe = regexp.MustCompile(`^\d{2}\.\d{2}\.\d{4}$`) - -func parseAmount(s string) float64 { - s = strings.TrimSpace(s) - s = strings.ReplaceAll(s, "€", "") - s = strings.ReplaceAll(s, " ", "") - s = strings.ReplaceAll(s, ",", "") // German thousands separator - s = strings.TrimSpace(s) - if s == "" { - return 0 - } - v, err := strconv.ParseFloat(s, 64) - if err != nil { - return 0 - } - return v -} - -func cell(row []string, i int) string { - if i < len(row) { - return strings.TrimSpace(row[i]) - } - return "" -} - -func main() { - if len(os.Args) < 3 { - fmt.Fprintln(os.Stderr, "usage: kontoblatt ") - os.Exit(2) - } - fileID, path := os.Args[1], os.Args[2] - ctx := context.Background() - - f, err := excelize.OpenFile(path) - if err != nil { - panic(err) - } - defer f.Close() - - var recs []rec - for _, sh := range f.GetSheetList() { - rows, err := f.GetRows(sh) - if err != nil { - continue - } - for _, r := range rows { - d := cell(r, 0) - if !dateRe.MatchString(d) { - continue - } - text := cell(r, 2) - recs = append(recs, rec{ - date: d, - month: d[3:10], - konto: cell(r, 1), - text: text, - soll: parseAmount(cell(r, 4)), - haben: parseAmount(cell(r, 5)), - reFehlt: strings.Contains(strings.ToUpper(text), "FEHLT"), - }) - } - } - - byKonto := map[string]*agg{} - byMonth := map[string]*agg{} - getK := func(k string) *agg { - if byKonto[k] == nil { - byKonto[k] = &agg{} - } - return byKonto[k] - } - getM := func(k string) *agg { - if byMonth[k] == nil { - byMonth[k] = &agg{} - } - return byMonth[k] - } - var tot agg - for _, r := range recs { - k := getK(r.konto) - k.count++ - k.soll += r.soll - k.haben += r.haben - if r.reFehlt { - k.reFehlt++ - } - m := getM(r.month) - m.count++ - m.soll += r.soll - m.haben += r.haben - if r.reFehlt { - m.reFehlt++ - } - tot.count++ - tot.soll += r.soll - tot.haben += r.haben - if r.reFehlt { - tot.reFehlt++ - } - } - - out := excelize.NewFile() - defer out.Close() - writeSheet(out, "Nach Gegenkonto", "Gegenkonto", byKonto, tot) - writeSheet(out, "Nach Monat", "Monat", byMonth, tot) - outPath := "/tmp/opencode/kontoblatt-zusammenfassung.xlsx" - if err := out.SaveAs(outPath); err != nil { - panic(err) - } - - // upload next to the source file - creds := onlyoffice.GetEnvironmentCredentials() - c := onlyoffice.NewClient(creds) - var src *onlyoffice.FileEntry - if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error { - var err error - src, err = c.GetFile(ctx, fileID) - return err - }); derr != nil { - panic(derr) - } - folder := "" - if src.FolderID != nil { - folder = src.FolderID.String() - } - title := "" - if src.Title != nil { - title = *src.Title - } - fmt.Printf("source: id=%s title=%q folder=%s\n", fileID, title, folder) - - name := "Kontoblatt-Zusammenfassung.xlsx" - tmp := "/tmp/opencode/" + name - data, _ := os.ReadFile(outPath) - if err := os.WriteFile(tmp, data, 0o600); err != nil { - panic(err) - } - var entry *onlyoffice.FileEntry - if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error { - var err error - entry, _, err = c.UploadToFolderReplacing(ctx, folder, tmp) - return err - }); derr != nil { - panic(derr) - } - fmt.Printf("uploaded: %s -> folder %s (id %v)\n", name, folder, entry.ID) - - // print the summary - printAgg("Nach Gegenkonto", byKonto, tot) - printAgg("Nach Monat", byMonth, tot) -} - -func writeSheet(f *excelize.File, sheet, key string, m map[string]*agg, tot agg) { - f.NewSheet(sheet) - rows := [][]any{{key, "Anzahl", "Soll", "Haben", "Saldo", `davon "fehlt"`}} - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - a := m[k] - rows = append(rows, []any{k, a.count, a.soll, a.haben, a.soll - a.haben, a.reFehlt}) - } - rows = append(rows, []any{"GESAMT", tot.count, tot.soll, tot.haben, tot.soll - tot.haben, tot.reFehlt}) - for i, row := range rows { - for j, v := range row { - cellRef, _ := excelize.CoordinatesToCellName(j+1, i+1) - _ = f.SetCellValue(sheet, cellRef, v) - } - } -} - -func printAgg(title string, m map[string]*agg, tot agg) { - fmt.Printf("\n== %s ==\n", title) - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - fmt.Printf("%-12s %6s %12s %12s %12s %7s\n", "key", "count", "soll", "haben", "saldo", "fehlt") - for _, k := range keys { - a := m[k] - fmt.Printf("%-12s %6d %12.2f %12.2f %12.2f %7d\n", k, a.count, a.soll, a.haben, a.soll-a.haben, a.reFehlt) - } - fmt.Printf("%-12s %6d %12.2f %12.2f %12.2f %7d\n", "GESAMT", tot.count, tot.soll, tot.haben, tot.soll-tot.haben, tot.reFehlt) -} diff --git a/cmd/kontolink/main.go b/cmd/kontolink/main.go deleted file mode 100644 index f3bc994..0000000 --- a/cmd/kontolink/main.go +++ /dev/null @@ -1,492 +0,0 @@ -// Command kontolink fills the "Link" column of a Kontoblatt ("ungeklärte -// Posten") XLSX by matching each row to an OnlyOffice document. -// -// Strategy (deterministic, conservative — no LLM): -// 1. Beleg token (letters/digits from the "Beleg" column) appears in the file -// title; among candidates prefer (a) the row's month, (b) real invoices over -// copies/dupes, and require the result to be unique; -// 2. else supplier + row month + "rechnung", again unique. -// -// A file is linked at most once (rows already carrying a link are kept and their -// file counts as used). Ambiguous rows are left UNLINKED for manual review. -// -// Usage: kontolink -package main - -import ( - "context" - - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - onlyoffice "github.com/eslider/go-onlyoffice" - "github.com/xuri/excelize/v2" -) - -var ( - dateRe = regexp.MustCompile(`^\d{2}\.\d{2}\.\d{4}$`) - nonAln = regexp.MustCompile(`[^0-9a-z]+`) - fileID = regexp.MustCompile(`fileid=(\d+)`) -) - -func parseDay(s string) (time.Time, bool) { - t, err := time.Parse("02.01.2006", strings.TrimSpace(s)) - return t, err == nil -} - -func titleDay(title string) (time.Time, bool) { - if len(title) >= 10 { - if t, err := time.Parse("2006-01-02", title[:10]); err == nil { - return t, true - } - } - return time.Time{}, false -} - -// nearest picks the candidate whose title date is closest to rd. Ties and -// undated candidates (when >1) are rejected. -func nearest(cands []entry, rd time.Time) (entry, bool) { - if len(cands) == 1 { - return cands[0], true - } - best, bestD, tie := -1, 0.0, false - for i, e := range cands { - td, ok := titleDay(e.title) - if !ok { - continue - } - d := td.Sub(rd).Hours() / 24 - if d < 0 { - d = -d - } - if best < 0 || d < bestD { - best, bestD, tie = i, d, false - } else if d == bestD { - tie = true - } - } - if best < 0 || tie { - return entry{}, false - } - return cands[best], true -} - -// portalBaseFromEnv resolves the portal base URL used to build document links. -func portalBaseFromEnv() string { - for _, k := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "OO_URL"} { - if v := strings.TrimSpace(os.Getenv(k)); v != "" { - return strings.TrimRight(v, "/") - } - } - return "" -} - -type entry struct { - id, path, title, norm string -} - -func norm(s string) string { return nonAln.ReplaceAllString(strings.ToLower(s), "") } - -func main() { - if len(os.Args) < 4 { - fmt.Fprintln(os.Stderr, "usage: kontolink ") - os.Exit(2) - } - in, idxPath, out := os.Args[1], os.Args[2], os.Args[3] - - portal := portalBaseFromEnv() - if portal == "" { - fmt.Fprintln(os.Stderr, "set ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL) to build document links") - os.Exit(2) - } - - idxRaw, err := os.ReadFile(idxPath) - if err != nil { - panic(err) - } - var entries []entry - for _, line := range strings.Split(string(idxRaw), "\n") { - parts := strings.Split(line, "\t") - if len(parts) < 4 || parts[0] == "" { - continue - } - entries = append(entries, entry{id: parts[0], path: parts[2], title: parts[3], norm: norm(parts[3])}) - } - - f, err := excelize.OpenFile(in) - if err != nil { - panic(err) - } - defer f.Close() - sheet := f.GetSheetList()[0] - rows, err := f.GetRows(sheet) - if err != nil { - panic(err) - } - - // optional 5th arg: amounts TSV "file_id\ttitle\tamount" (see cmd/pdfamount) - var amts []amtEntry - if len(os.Args) >= 6 && os.Args[5] != "" { - amts = loadAmounts(os.Args[5]) - } - - used := map[string]bool{} - for _, r := range rows { - if m := fileID.FindStringSubmatch(cell(r, 7)); m != nil { - used[m[1]] = true - } - } - - var linked, byBeleg, bySupplier, byAmount, unmatched, ambiguous int - for i, r := range rows { - if i == 0 || !dateRe.MatchString(cell(r, 0)) || strings.TrimSpace(cell(r, 7)) != "" { - continue - } - beleg := norm(cell(r, 3)) - supplier := supplierNorm(cell(r, 2)) - month := monthYear(cell(r, 0)) - rd, _ := parseDay(cell(r, 0)) - - e, kind, ok := pick(entries, used, beleg, supplier, month, rd) - if !ok { - if ae, aok := amountPick(amts, used, supplier, rowAmount(r), rd); aok { - e, kind, ok = entry{id: ae.id, title: ae.title}, "amount", true - } - } - if !ok { - if beleg != "" { - ambiguous++ - } else { - unmatched++ - } - continue - } - ref, _ := excelize.CoordinatesToCellName(8, i+1) - if err := f.SetCellValue(sheet, ref, portal+"/Products/Files/DocEditor.aspx?fileid="+e.id); err != nil { - panic(err) - } - used[e.id] = true - linked++ - switch kind { - case "beleg": - byBeleg++ - case "supplier": - bySupplier++ - case "amount": - byAmount++ - } - fmt.Printf("row %3d %-30s -> %s [%s]\n", i+1, cell(r, 2), e.title, kind) - } - - if err := f.SaveAs(out); err != nil { - panic(err) - } - fmt.Printf("\nlinked=%d (beleg=%d, supplier=%d, amount=%d), ambiguous=%d, no-candidate=%d\n", - linked, byBeleg, bySupplier, byAmount, ambiguous, unmatched) - - // Optional 4th arg: source OnlyOffice file id. Try to update it in place; - // if it is locked (OnlyOffice 500), upload a "(links)" copy next to it. - if len(os.Args) >= 5 && os.Args[4] != "" { - c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials()) - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - var src *onlyoffice.FileEntry - if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error { - var err error - src, err = c.GetFile(ctx, os.Args[4]) - return err - }); derr != nil { - panic(derr) - } - folder, title := "", "" - if src.FolderID != nil { - folder = src.FolderID.String() - } - if src.Title != nil { - title = *src.Title - } - uderr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error { - _, err := c.UpdateFile(ctx, os.Args[4], out) - return err - }) - if uderr == nil { - fmt.Printf("updated file %s in place\n", os.Args[4]) - return - } - fmt.Printf("in-place update failed (locked?); uploading a copy to folder %s\n", folder) - ext := filepath.Ext(title) - name := strings.TrimSuffix(title, ext) + " (links)" + ext - tmp := filepath.Join(os.TempDir(), name) - data, _ := os.ReadFile(out) - if err := os.WriteFile(tmp, data, 0o600); err != nil { - panic(err) - } - if derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error { - _, _, err := c.UploadToFolderReplacing(ctx, folder, tmp) - return err - }); derr != nil { - panic(derr) - } - fmt.Printf("uploaded copy: %s -> folder %s\n", name, folder) - } -} - -func cell(r []string, i int) string { - if i < len(r) { - return strings.TrimSpace(r[i]) - } - return "" -} - -func supplierNorm(s string) string { - s = strings.ToUpper(s) - if i := strings.Index(s, ","); i >= 0 { - s = s[:i] - } - for _, w := range []string{"RE FEHLT", "GS FEHLT", "WOFR", "WOFÜR"} { - s = strings.ReplaceAll(s, w, "") - } - return norm(s) -} - -func monthYear(date string) string { - if len(date) == 10 { - return date[6:10] + "-" + date[3:5] - } - return "" -} - -// pick returns an unused candidate. Beleg match wins; supplier+month is a -// fallback. When several candidates qualify, the one closest in time to the row -// date wins; a tie is rejected (ambiguous) rather than guessed. -func pick(entries []entry, used map[string]bool, beleg, supplier, month string, rd time.Time) (entry, string, bool) { - free := func(e entry) bool { return !used[e.id] } - - if len(beleg) >= 5 { - var inMonth []entry - for _, e := range entries { - if free(e) && belegMatches(e.norm, beleg) && - (month == "" || strings.Contains(e.title, month)) { - inMonth = append(inMonth, e) - } - } - if supplier != "" { - var s []entry - for _, e := range inMonth { - if strings.Contains(e.norm, supplier) { - s = append(s, e) - } - } - if len(s) > 0 { - inMonth = s - } - } - inMonth = topRank(inMonth) - if e, ok := nearest(inMonth, rd); ok { - return e, "beleg", true - } - // A Beleg is present but no file carries it: do NOT fall back to a - // supplier guess (that links the wrong invoice). - return entry{}, "", false - } - - if supplier != "" && month != "" { - var c []entry - for _, e := range entries { - if free(e) && strings.Contains(e.norm, supplier) && - strings.Contains(e.title, month) && strings.Contains(e.norm, "rechnung") { - c = append(c, e) - } - } - c = topRank(c) - if e, ok := nearest(c, rd); ok { - return e, "supplier", true - } - } - return entry{}, "", false -} - -// topRank keeps only the highest-ranked candidates (real invoice over copy / -// dupe / op), so a tie with a duplicate does not mask the real file. -func topRank(cands []entry) []entry { - if len(cands) < 2 { - return cands - } - best := 0 - for _, e := range cands { - if rank(e) > best { - best = rank(e) - } - } - out := cands[:0] - for _, e := range cands { - if rank(e) == best { - out = append(out, e) - } - } - return out -} - -func rank(e entry) int { - s := 0 - if strings.Contains(e.path, "/2025") || strings.Contains(e.path, "/2024") { - s += 4 - } - if strings.Contains(e.norm, "rechnung") { - s += 2 - } - if strings.Contains(e.norm, "dupe") || strings.Contains(e.norm, "copy") || - strings.Contains(e.norm, "op") { - s-- - } - return s -} - -// belegMatches reports whether a Beleg identifies the file: the whole normalized -// Beleg appears, or (for long numeric Belege, e.g. "24/641393110") an 8-digit -// window of its longest digit run appears. -func belegMatches(titleNorm, beleg string) bool { - if strings.Contains(titleNorm, beleg) { - return true - } - run := longestDigitRun(beleg) - for i := 0; i+8 <= len(run); i++ { - if strings.Contains(titleNorm, run[i:i+8]) { - return true - } - } - return false -} - -func longestDigitRun(s string) string { - var best, cur strings.Builder - for _, r := range s { - if r >= '0' && r <= '9' { - cur.WriteRune(r) - if cur.Len() > best.Len() { - best.Reset() - best.WriteString(cur.String()) - } - } else { - cur.Reset() - } - } - return best.String() -} - -type amtEntry struct { - id string - title string - norm string - amount float64 - date time.Time - hasDate bool -} - -func loadAmounts(path string) []amtEntry { - raw, err := os.ReadFile(path) - if err != nil { - return nil - } - var out []amtEntry - for _, line := range strings.Split(string(raw), "\n") { - p := strings.Split(line, "\t") - if len(p) < 3 { - continue - } - v, err := strconv.ParseFloat(strings.TrimSpace(p[2]), 64) - if err != nil { - continue - } - e := amtEntry{id: p[0], title: p[1], norm: norm(p[1]), amount: v} - if len(p[1]) >= 10 { - if t, err := time.Parse("2006-01-02", p[1][:10]); err == nil { - e.date, e.hasDate = t, true - } - } - out = append(out, e) - } - return out -} - -func rowAmount(r []string) float64 { - if v := parseAmount(cell(r, 4)); v != 0 { - return v - } - return parseAmount(cell(r, 5)) -} - -func parseAmount(s string) float64 { - s = strings.ReplaceAll(s, "€", "") - s = strings.ReplaceAll(s, " ", "") - s = strings.ReplaceAll(s, ",", ".") - if s == "" { - return 0 - } - v, err := strconv.ParseFloat(s, 64) - if err != nil { - return 0 - } - return v -} - -// amountPick matches a row to an O2 invoice by amount + nearest date. Scoped to -// Telefonica/O2 rows and O2 files, so it cannot cross-link other suppliers. -func amountPick(amts []amtEntry, used map[string]bool, supplier string, amt float64, rd time.Time) (amtEntry, bool) { - if amt <= 0 || len(amts) == 0 { - return amtEntry{}, false - } - if !strings.Contains(supplier, "telefonica") && !strings.Contains(supplier, "o2") { - return amtEntry{}, false - } - var cands []amtEntry - for _, a := range amts { - if used[a.id] || !strings.Contains(a.norm, "o2") { - continue - } - d := a.amount - amt - if d < 0 { - d = -d - } - if d > 0.005 { - continue - } - if a.hasDate && !rd.IsZero() { - days := a.date.Sub(rd).Hours() / 24 - if days < 0 { - days = -days - } - if days > 75 { - continue - } - } - cands = append(cands, a) - } - if len(cands) == 1 { - return cands[0], true - } - best, bestD, tie := -1, 0.0, false - for i, a := range cands { - if !a.hasDate { - continue - } - d := a.date.Sub(rd).Hours() / 24 - if d < 0 { - d = -d - } - if best < 0 || d < bestD { - best, bestD, tie = i, d, false - } else if d == bestD { - tie = true - } - } - if best < 0 || tie { - return amtEntry{}, false - } - return cands[best], true -} diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 9cac059..0fc85a4 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -14,7 +14,6 @@ import ( onlyoffice "github.com/eslider/go-onlyoffice" "github.com/eslider/go-onlyoffice/internal/docpipe" - "github.com/eslider/go-onlyoffice/internal/xlspipe" "github.com/spf13/cobra" ) @@ -31,7 +30,7 @@ func docsCmd() *cobra.Command { OnlyOffice Documents UI is poor for .md/.txt — keep sources in git, store .docx in OO. Upload Markdown as DOCX: oo docs put-md PROJECT_ID file.md Upload plain text: oo docs put-txt PROJECT_ID file.txt (preserves line breaks) -Upload/generate XLSX: oo docs put-xlsx PROJECT_ID [--template cutover-portugal | FILE.xlsx] +Upload XLSX: oo docs put-xlsx PROJECT_ID FILE.xlsx Read an OO file as MD: oo docs as-md FILE_ID OCR a scan locally: oo docs ocr scan.pdf --md out.md Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, @@ -810,64 +809,27 @@ func docsPutTxtCmd() *cobra.Command { } func docsPutXlsxCmd() *cobra.Command { - var folderID, template, title, keepLocal string + var folderID, keepLocal string var replace bool cmd := &cobra.Command{ - Use: "put-xlsx PROJECT_ID [LOCAL_XLSX]", - Short: "Upload or generate an XLSX workbook into a project (excelize templates with formulas)", - Long: `Spreadsheets live in OnlyOffice — not in git. Generate multi-sheet workbooks with -formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an existing .xlsx. + Use: "put-xlsx PROJECT_ID LOCAL_XLSX", + Short: "Upload an XLSX workbook into a project (upsert by stem|ext)", + Long: `Spreadsheets live in OnlyOffice — not in git. Uploads an existing .xlsx into the +project Documents (or --folder), replacing the same stem|ext by default. - oo docs put-xlsx 218 --template cutover-portugal - oo docs put-xlsx 218 --template cutover-portugal --title 2026-08-28-cutover-budget.xlsx oo docs put-xlsx 218 ./my.xlsx`, - Args: cobra.RangeArgs(1, 2), + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { pid := args[0] c, err := newOO(cmd) if err != nil { return err } - dir, err := os.MkdirTemp("", "oo-docs-put-xlsx-*") - if err != nil { - return err - } - defer os.RemoveAll(dir) - - var xlsxPath string - var srcLabel string - switch { - case template != "": - wb, err := xlspipe.BuildTemplate(template) - if err != nil { - return err - } - name := title - if name == "" { - name = "cutover-budget.xlsx" - if template == xlspipe.TemplateCutoverPortugal { - name = "2026-08-28-cutover-budget.xlsx" - } - } - if !strings.HasSuffix(strings.ToLower(name), ".xlsx") { - name += ".xlsx" - } - xlsxPath = filepath.Join(dir, name) - if err := xlspipe.Save(wb, xlsxPath); err != nil { - wb.Close() - return err - } - wb.Close() - srcLabel = "template:" + template - case len(args) == 2: - xlsxPath = args[1] - if docpipe.Ext(xlsxPath) != ".xlsx" { - return fmt.Errorf("expected .xlsx, got %s", docpipe.Ext(xlsxPath)) - } - srcLabel = xlsxPath - default: - return fmt.Errorf("pass LOCAL_XLSX or --template") + xlsxPath := args[1] + if docpipe.Ext(xlsxPath) != ".xlsx" { + return fmt.Errorf("expected .xlsx, got %s", docpipe.Ext(xlsxPath)) } + srcLabel := xlsxPath if keepLocal != "" { b, err := os.ReadFile(xlsxPath) @@ -903,9 +865,7 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an }, } cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)") - cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)") - cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template") - cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path") + cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also copy the uploaded bytes to this local path") cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken") return cmd } diff --git a/cmd/oo/invoices.go b/cmd/oo/invoices.go index 47b2b1a..2559879 100644 --- a/cmd/oo/invoices.go +++ b/cmd/oo/invoices.go @@ -91,7 +91,7 @@ func invoiceCreateCmd() *cobra.Command { Long: `Create a CRM invoice (Draft) with a single line. Always pass --opportunity when a deal exists (entity link at create). Updating ---opportunity later often fails with HTTP 400 — see docs/crm-associations.md. +--opportunity later often fails with HTTP 400 — see the CRM association rules. Example: oo invoices create --number INV-2026-01 --contact CONTACT_ID --item ITEM_ID \ @@ -108,7 +108,7 @@ Example: issueDate = issueDate + "T00:00:00.0000000+01:00" } if dueDate == "" { - dueDate = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00" + dueDate = time.Now().Add(14*24*time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00" } else if !strings.Contains(dueDate, "T") { dueDate = dueDate + "T00:00:00.0000000+01:00" } @@ -175,7 +175,7 @@ func invoiceUpdateCmd() *cobra.Command { Long: `Update Draft invoice fields. --opportunity often returns HTTP 400 on existing invoices. Prefer -oo invoices create … --opportunity, or delete+recreate. See docs/crm-associations.md. +oo invoices create … --opportunity, or delete+recreate. See the CRM association rules. `, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -274,7 +274,7 @@ func invoiceStatusCmd() *cobra.Command { Long: `PUT /api/2.0/crm/invoice/status/{id}. Billed invoices are not content-editable. Billed→Draft often does not work — -recreate as Draft instead (docs/crm-associations.md). +recreate as Draft instead (the CRM association rules). `, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 7f52849..552c942 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -22,7 +22,7 @@ // oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json] // oo index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run] // -// CRM association rules: docs/crm-associations.md +// CRM association rules live with the private oo-workspace tooling. // // Every list supports `--output/-o json|table` (table is the default). // diff --git a/cmd/ooscan/main.go b/cmd/ooscan/main.go deleted file mode 100644 index 7ef0276..0000000 --- a/cmd/ooscan/main.go +++ /dev/null @@ -1,50 +0,0 @@ -// Command ooscan recursively lists OnlyOffice Documents folders into a TSV -// index: file_id, folder_id, path, title. -// -// Usage: ooscan [...] -package main - -import ( - "context" - "fmt" - "os" - "time" - - onlyoffice "github.com/eslider/go-onlyoffice" -) - -func main() { - ctx := context.Background() - c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials()) - seen := map[string]bool{} - for _, root := range os.Args[1:] { - walk(ctx, c, root, "", 0, seen) - } -} - -func walk(ctx context.Context, c *onlyoffice.Client, folderID, path string, depth int, seen map[string]bool) { - if depth > 8 || seen[folderID] { - return - } - seen[folderID] = true - // Throttle: OnlyOffice rate-limits (429) and the host must not be flooded. - time.Sleep(350 * time.Millisecond) - ctx, cancel := context.WithTimeout(ctx, 60*time.Second) - defer cancel() - var l *onlyoffice.DavListing - derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error { - var err error - l, err = c.ListDavFolder(ctx, folderID) - return err - }) - if derr != nil { - fmt.Fprintf(os.Stderr, "list %s (%s): %v\n", path, folderID, derr) - return - } - for _, f := range l.Files { - fmt.Printf("%s\t%s\t%s\t%s\n", f.ID, folderID, path, f.Title) - } - for _, sub := range l.Folders { - walk(ctx, c, sub.ID, path+"/"+sub.Title, depth+1, seen) - } -} diff --git a/cmd/pdfamount/main.go b/cmd/pdfamount/main.go deleted file mode 100644 index 6690caa..0000000 --- a/cmd/pdfamount/main.go +++ /dev/null @@ -1,308 +0,0 @@ -// Command pdfamount walks a Documents folder, downloads matching PDFs and -// extracts the payable amount, printing "file_id\ttitle\tamount". -// -// Usage: pdfamount [TITLE_FILTER_REGEX] -package main - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "regexp" - "strconv" - "strings" - "time" - - onlyoffice "github.com/eslider/go-onlyoffice" -) - -// amountPat is the amount capture shared by every amount regex. -const amountPat = `([0-9]+(?:[.,][0-9]+)*)` - -// amountRE builds "