Compare commits
161
Commits
v0.7.0
...
9cd157c59f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd157c59f | ||
|
|
932cd2895a | ||
|
|
c551634e55 | ||
|
|
a88fd5b40c | ||
|
|
9b149281a8 | ||
|
|
d6b8eb7777 | ||
|
|
8a17a32269 | ||
|
|
43a1a47faa | ||
|
|
620505cb24 | ||
|
|
a6bba30438 | ||
|
|
6549fbf7e2 | ||
|
|
1a8bf0f83c | ||
|
|
046882b8ef | ||
|
|
a3ef83a961 | ||
|
|
54f825f8e8 | ||
|
|
6a4940ba82 | ||
|
|
1da368fd5d | ||
|
|
66a6b57401 | ||
|
|
075c66d4d7 | ||
|
|
c59ad645ea | ||
|
|
c76ab7267d | ||
|
|
3cc288d281 | ||
|
|
ce4778bdf1 | ||
|
|
3fe43ee82c | ||
|
|
6d93ab5b1f | ||
|
|
95d79925ea | ||
|
|
bf3ef025aa | ||
|
|
ba29738482 | ||
|
|
72c7cd5173 | ||
|
|
b3dfff1a4c | ||
|
|
b5a61ad420 | ||
|
|
1aba545e1d | ||
|
|
94951fc6c5 | ||
|
|
d95985e6d0 | ||
|
|
57b383d579 | ||
|
|
a73f6c172d | ||
|
|
e49693ad2c | ||
|
|
b86d71211f | ||
|
|
a2b919478e | ||
|
|
ccabc11153 | ||
|
|
9a6da1a4f7 | ||
|
|
504d13ed08 | ||
|
|
efc0864d42 | ||
|
|
59caf5e560 | ||
|
|
e7803c5269 | ||
|
|
a08e7c49ad | ||
|
|
4310aa7002 | ||
|
|
1a7a962183 | ||
|
|
af8e3b053a | ||
|
|
8ac777c031 | ||
|
|
c576bfe2ee | ||
|
|
47a2c256f3 | ||
|
|
c1881e1e61 | ||
|
|
7f56332285 | ||
|
|
a5807b9c31 | ||
|
|
d650a16a36 | ||
|
|
3da11586f9 | ||
|
|
e9c969a89c | ||
|
|
4d91726179 | ||
|
|
4d8af7a2fe | ||
|
|
34745e349e | ||
|
|
dfea57a57b | ||
|
|
8595c17f25 | ||
|
|
61da2fb88b | ||
|
|
24ca144b22 | ||
|
|
93828ee19d | ||
|
|
35f0cb8d20 | ||
|
|
ca5c85a4e2 | ||
|
|
220c7e8265 | ||
|
|
ff4c0481ba | ||
|
|
68445b0dfd | ||
|
|
8739d45b1a | ||
|
|
f3150436fd | ||
|
|
a30ae20f32 | ||
|
|
5a0b2ab412 | ||
|
|
69844c9122 | ||
|
|
b04d610275 | ||
|
|
6b3f40e1a1 | ||
|
|
ba8148a9e1 | ||
|
|
f1739dc9dc | ||
|
|
5b812ce346 | ||
|
|
309ae44ee4 | ||
|
|
ff2bc539f5 | ||
|
|
d9a7adcd94 | ||
|
|
3713ed6c51 | ||
|
|
ac06d86363 | ||
|
|
7d397ac680 | ||
|
|
e52cb31bb7 | ||
|
|
a8cb4e9780 | ||
|
|
622dcdb7bf | ||
|
|
2b267d36a8 | ||
|
|
50bd47570b | ||
|
|
096a996726 | ||
|
|
625dd0a60a | ||
|
|
e531de280f | ||
|
|
129e3a58cc | ||
|
|
70e605b4ca | ||
|
|
a264cbd61c | ||
|
|
a97a160c44 | ||
|
|
df447f2673 | ||
|
|
db9ef12ff4 | ||
|
|
2c0df0d55f | ||
|
|
239c5ea672 | ||
|
|
6ea2fbadf7 | ||
|
|
50d8974154 | ||
|
|
3fd3f797dc | ||
|
|
0e299d0692 | ||
|
|
9ead554f5c | ||
|
|
77c188c640 | ||
|
|
2a59817f51 | ||
|
|
9c450a0352 | ||
|
|
ab7dfbd859 | ||
|
|
dda2bd3ca5 | ||
|
|
ceea6d909e | ||
|
|
ecf34ba51a | ||
|
|
ebbd5d5373 | ||
|
|
20a09530cd | ||
|
|
666be883dc | ||
|
|
65cd3f5c74 | ||
|
|
1ef0670624 | ||
|
|
10c1cfc6a7 | ||
|
|
8ced804a6f | ||
|
|
256864f613 | ||
|
|
a3a6ad3543 | ||
|
|
0161a92c52 | ||
|
|
0e80a1a058 | ||
|
|
8b27539145 | ||
|
|
a0add3b771 | ||
|
|
b58940fcf2 | ||
|
|
11aa23f1a1 | ||
|
|
42c31d4b48 | ||
|
|
f95830961d | ||
|
|
6b5a82bef8 | ||
|
|
490426c277 | ||
|
|
31c556d34c | ||
|
|
7f35b0e910 | ||
|
|
73b1050a83 | ||
|
|
d81e7de911 | ||
|
|
3201a58df7 | ||
|
|
19a7e9cbf8 | ||
|
|
2e1624920e | ||
|
|
1bf417d70f | ||
|
|
aa79a83c0a | ||
|
|
179f30644d | ||
|
|
b334f9ff86 | ||
|
|
e07cf743f9 | ||
|
|
8b764a36a1 | ||
|
|
aa4e71b9d2 | ||
|
|
1a93579635 | ||
|
|
76067f65d0 | ||
|
|
3b55de5b1d | ||
|
|
715ed23733 | ||
|
|
c7275b5c98 | ||
|
|
b9cea62d46 | ||
|
|
e3c706aefe | ||
|
|
6ea76c64c3 | ||
|
|
8804bd3030 | ||
|
|
c8b30a36ee | ||
|
|
3969d045cc | ||
|
|
09e242d7c3 | ||
|
|
0ed2d9b0f5 |
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: office-tui-table
|
||||
description: >-
|
||||
Build and fix Bubble Tea list tables in cmd/office (DataTable, column layout,
|
||||
lipgloss cell rendering, pane width). Use when changing office center-pane
|
||||
tables, column widths, ANSI styling, or list toolbar alignment.
|
||||
---
|
||||
# office TUI table (`cmd/office/ui`)
|
||||
|
||||
## Architecture
|
||||
|
||||
| Piece | File | Role |
|
||||
|-------|------|------|
|
||||
| `DataTable` | `table.go` | cursor, sort, viewport, header/row render |
|
||||
| Column layout | `table_layout.go` | flex vs horizontal-scroll policies |
|
||||
| Flex policy | `model/table_layout.go` | per-subject flex column key + min width |
|
||||
| Cell render | `table_render.go` | bubbles/table pattern + ANSI-safe padding |
|
||||
| Subject row style | `table_projects.go` | optional per-subject cell styles |
|
||||
| Pane sizing | `scroll.go` | `paneContentWidth`, `paneLipglossWidth` |
|
||||
| Scrollbar pad | `scrollbar.go` | `padANSIWidth` for styled lines |
|
||||
|
||||
## Layout policies
|
||||
|
||||
1. **Flex (all columns visible)** — register in `model.TableFlexLayoutFor`:
|
||||
- fixed columns keep `TableColumn.Width`
|
||||
- one `FlexColumnKey` (e.g. `title`, `email`) absorbs remainder
|
||||
- `layoutFlexTable` shrinks fixed cols down to `minFixedColumnWidth` before flex min
|
||||
|
||||
2. **Scrolling (default)** — `layoutScrollingTable`:
|
||||
- horizontal `colScroll`; subset of columns visible
|
||||
- `distributeColumnWidths` fills pane width
|
||||
|
||||
Add a new flex subject: one entry in `TableFlexLayoutFor` only — do **not** copy `layoutProjectTable`/`layoutUserTable` per subject.
|
||||
|
||||
## Cell rendering (required pattern)
|
||||
|
||||
Never `style.Width(w).Padding(0,1).Render(text)` on styled cells — padding doubles display width and breaks `JoinHorizontal`.
|
||||
|
||||
Use `renderTableCell` from `table_render.go` (matches `charmbracelet/bubbles/table`):
|
||||
|
||||
```go
|
||||
renderTableCell(outerStyle, plainText, columnWidth)
|
||||
```
|
||||
|
||||
- truncate **plain** text with `runewidth.Truncate` inside `renderTableCell`
|
||||
- inner box: `Width(innerW).Inline(true)` where `innerW = width - outer.GetHorizontalFrameSize()`
|
||||
- outer style carries padding, background, foreground
|
||||
|
||||
## ANSI rules
|
||||
|
||||
- **Never** `runewidth.Truncate` / `padDisplayWidth` on strings that already contain lipgloss ANSI codes — corrupts escapes and shows garbage like lone `ID`.
|
||||
- Use `padANSIWidth` (`charmbracelet/x/ansi`) for styled lines (headers, toolbar, scrollbar rows).
|
||||
- Do not wrap an already full-width styled header in another `lipgloss.Width()` — pad once.
|
||||
|
||||
## Pane width
|
||||
|
||||
Lipgloss bordered panes render **2 cells wider** than `Style.Width`:
|
||||
|
||||
- `paneLipglossWidth(rendered) = rendered - 2` for `.Width()` on pane style
|
||||
- `paneContentWidth(rendered) = rendered - 4` for table/viewport inner size (border + padding)
|
||||
|
||||
Pass `paneContentWidth(pw.List)` to `DataTable.SetSize` and list toolbar.
|
||||
|
||||
## Tests to add when touching tables
|
||||
|
||||
- `layoutFlexTable` width sum equals pane width
|
||||
- `TestProjectTableHeaderFitsPaneWidth` / per-line width at 68, 40, 30
|
||||
- `TestPadANSIWidthPreservesStyledLine`
|
||||
- `TestThreePaneRenderedWidthMatchesTerminal` top border + table lines ≤ inner width
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Per-subject duplicate layout files (`table_foo.go` with copy-pasted shrink loops)
|
||||
- Skipping truncation on selected rows (causes multi-line rows in viewport)
|
||||
- Using `bubbles/table` widget directly — office needs column cursor, multi-select, subject-specific row styles
|
||||
+42
-1
@@ -11,7 +11,7 @@ ONLYOFFICE_PASS=
|
||||
# ONLYOFFICE_NAME=
|
||||
# ONLYOFFICE_PASSWORD=
|
||||
#
|
||||
# produktor.io operator aliases (CLI-only):
|
||||
# Optional CLI-only aliases:
|
||||
# OO_URL=
|
||||
# OO_USER=
|
||||
# OO_PASS=
|
||||
@@ -22,6 +22,47 @@ ONLYOFFICE_PROJECT_ID=33
|
||||
|
||||
# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon).
|
||||
|
||||
# deploy/docker-compose.rclone-webdav.yml — rclone FUSE mount of the oo-webdav
|
||||
# sidecar. Reuses ONLYOFFICE_USER and accepts ONLYOFFICE_PASSWORD (alias
|
||||
# ONLYOFFICE_PASS). Set ONLYOFFICE_WEBDAV_URL only if the sidecar is not on
|
||||
# the default docker bridge address. See docs/rclone-webdav.md.
|
||||
# ONLYOFFICE_WEBDAV_URL=http://172.17.0.1:8098/webdav
|
||||
|
||||
# cmd/office TUI — optional Document Server for DOCX→HTML preview:
|
||||
# ONLYOFFICE_DOCS_URL=https://docs.example.com
|
||||
# ONLYOFFICE_DOCS_SECRET=
|
||||
|
||||
# MinIO download fallback for the portal's stale AWS S3 consumer (older
|
||||
# Documents folders). When the portal redirects to amazonaws.com with access
|
||||
# key "minio" (403 InvalidAccessKeyId), files are fetched from the local MinIO
|
||||
# store instead. Without a key/secret the fallback is disabled.
|
||||
# MINIO_ENDPOINT=http://192.168.188.10:9000
|
||||
# MINIO_BUCKET=office
|
||||
# MINIO_ACCESS_KEY=
|
||||
# MINIO_SECRET_KEY=
|
||||
|
||||
# oo search — direct Elasticsearch access for name + content search. ES lives
|
||||
# inside the OnlyOffice VM on localhost:9200; expose it with an SSH tunnel
|
||||
# (see docs/elasticsearch.md). ONLYOFFICE_ES_INDEX defaults to files_file.
|
||||
# ONLYOFFICE_ES_URL=http://127.0.0.1:9200
|
||||
# ONLYOFFICE_ES_INDEX=files_file
|
||||
# ONLYOFFICE_TENANT=
|
||||
|
||||
# Read-only SQL file store over the Community Server database (see
|
||||
# docs/community-server-db.md). The live portal runs MySQL; ONLYOFFICE_DSN is
|
||||
# `user:pass@tcp(host:port)/onlyoffice?parseTime=true`, or a `postgres://` URL.
|
||||
# ONLYOFFICE_DSN=
|
||||
# ONLYOFFICE_PG_DRIVER= # postgres | mysql (auto-detected from DSN)
|
||||
# ONLYOFFICE_PG_TENANT=1 # falls back to ONLYOFFICE_TENANT
|
||||
# Alternatively build a PostgreSQL DSN from parts:
|
||||
# ONLYOFFICE_PG_HOST=
|
||||
# ONLYOFFICE_PG_PORT=5432
|
||||
# ONLYOFFICE_PG_USER=
|
||||
# ONLYOFFICE_PG_PASSWORD=
|
||||
# ONLYOFFICE_PG_DBNAME=onlyoffice
|
||||
# ONLYOFFICE_PG_SSLMODE=disable
|
||||
|
||||
# oo index / oo search --backend own — own full-text index for PDF/scans,
|
||||
# filled by `oo index` from internal/docpipe (pdftotext + OCR). Defaults to
|
||||
# oo_docs_text. Uses the same ONLYOFFICE_ES_URL.
|
||||
# ONLYOFFICE_ES_TEXT_INDEX=oo_docs_text
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
github: eSlider
|
||||
ko_fi: eslider
|
||||
liberapay: eslider
|
||||
patreon: eslider
|
||||
custom:
|
||||
- https://polar.sh/eslider
|
||||
@@ -9,10 +9,12 @@ 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
|
||||
@@ -23,6 +25,18 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -16,12 +16,31 @@ permissions:
|
||||
jobs:
|
||||
goreleaser:
|
||||
name: GoReleaser
|
||||
if: github.server_url == 'https://github.com'
|
||||
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.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
|
||||
- name: Checkout release tag
|
||||
env:
|
||||
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Releasing tag: $TAG"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"; then
|
||||
break
|
||||
fi
|
||||
echo "tag $TAG not visible yet (attempt $i); sleeping…"
|
||||
sleep 10
|
||||
done
|
||||
git rev-parse "refs/tags/${TAG}"
|
||||
git checkout --force "refs/tags/${TAG}"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -4,24 +4,70 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
secret-scan:
|
||||
name: Secret scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute scan range (diff of new commits only)
|
||||
id: range
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
BEFORE="${{ github.event.before }}"
|
||||
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||
RANGE="$(git rev-list --max-parents=0 HEAD | tail -1)..$GITHUB_SHA"
|
||||
else
|
||||
RANGE="$BEFORE..$GITHUB_SHA"
|
||||
fi
|
||||
fi
|
||||
echo "RANGE=$RANGE" >> "$GITHUB_ENV"
|
||||
echo "Scanning range: $RANGE"
|
||||
|
||||
# Install the gitleaks binary instead of a docker action: the
|
||||
# docker://zricethezav/gitleaks action hardcodes /github/workspace,
|
||||
# which does not exist on the Gitea (act) runner. $GITHUB_WORKSPACE is
|
||||
# the checkout dir on BOTH runners (GitHub and Gitea act). Mirrors the
|
||||
# fix applied to 2dph (issue #142).
|
||||
- name: Gitleaks (diff-only, fail on leak)
|
||||
env:
|
||||
GITLEAKS_RANGE: ${{ env.RANGE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSLo /tmp/gitleaks.tar.gz \
|
||||
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||
chmod +x /tmp/gitleaks
|
||||
/tmp/gitleaks detect \
|
||||
--source "$GITHUB_WORKSPACE" \
|
||||
--log-opts="$GITLEAKS_RANGE" \
|
||||
--redact \
|
||||
--verbose
|
||||
|
||||
test:
|
||||
name: Test (Go ${{ matrix.go }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go: ["1.22", "stable"]
|
||||
go: ["1.25", "stable"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
cache: true
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.6.0"
|
||||
".": "0.18.0"
|
||||
}
|
||||
|
||||
@@ -9,16 +9,19 @@ 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`, `mails.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). **`mails.go`** — OnlyOffice Workspace Mail addon (`ListMailAccounts`, `ListMailFolders`, `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages`).
|
||||
- `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 linear 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. **`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).
|
||||
- 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).
|
||||
- `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` — 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.go`, `crm_tasks.go`, `catalog.go`, `docs.go`, `dav.go`, `search.go`, `index.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|rename-file|rename-folder|download|fileops`); `search.go` runs `oo search QUERY` (name/content, `--backend oo|own`); `index.go` fills the own full-text index (`oo index folder|files`, see [`docs/unified-file-client.md`](docs/unified-file-client.md)).
|
||||
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -26,7 +29,8 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
||||
- New endpoints go into the library first; CLI commands are thin wrappers.
|
||||
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
|
||||
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
|
||||
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
|
||||
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain. The subject→verb tree in `cmd/oo/main.go` and the README table are documentation — update them with the code.
|
||||
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). All upload paths default to **upsert** by `stem|ext` (`--replace`, default true); `--no-replace` fails on conflict; `--allow-duplicate` opts into raw OO append. `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`; includes project root folder).
|
||||
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
|
||||
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
|
||||
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
||||
@@ -52,6 +56,7 @@ write `mux.HandleFunc("/api/2.0/...")` to emulate OnlyOffice, we write an
|
||||
|
||||
## Related
|
||||
|
||||
- [`docs/README.md`](docs/README.md) — reference index (file client, ES, SQL, rclone).
|
||||
- [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions).
|
||||
- [`eSlider/inventar-sync`](https://git.produktor.io/eSlider/inventar-sync) — OnlyOffice → Gitea issue sync, consumes this library.
|
||||
- [`produktor.io/vidarr`](https://git.produktor.io/produktor.io/vidarr) — legacy consumer being migrated from `pkg/onlyoffice` to this module.
|
||||
|
||||
+256
@@ -4,6 +4,262 @@ All notable changes to this project are documented here. The format is based on
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## [0.18.0](https://github.com/eSlider/go-onlyoffice/compare/v0.17.0...v0.18.0) (2026-09-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** add UpdateContactName and CloseCRMTask helpers ([4d8af7a](https://github.com/eSlider/go-onlyoffice/commit/4d8af7a2fef1fcfd96ad913cefc6497e3389963b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **crm:** deterministic sortBy=id in contact paged lists ([4d91726](https://github.com/eSlider/go-onlyoffice/commit/4d917261792203732ac739644c9c82124b2166eb))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **funding:** eSlider support links (reverse-import GitHub e9c969a) ([d650a16](https://github.com/eSlider/go-onlyoffice/commit/d650a16a36037949505eb017c92bd3283e6e2f0f))
|
||||
|
||||
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **mailsync:** FetchMailFolder — integration-layer walk for ETL consumers ([35f0cb8](https://github.com/eSlider/go-onlyoffice/commit/35f0cb8d20076244141065c07e203e633bc3612a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **files:** upsert uploads by default and dedupe project root ([24ca144](https://github.com/eSlider/go-onlyoffice/commit/24ca144b22abc5d056a5fd1ed9a1887f26a79d15))
|
||||
|
||||
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **docs:** `put-xlsx` — multi-sheet бюджеты с named inputs, SUM/AVG/MIN
|
||||
формулами, cross-sheet ссылками и cell comments (`internal/xlspipe`,
|
||||
excelize) ([68445b0](https://github.com/eSlider/go-onlyoffice/commit/68445b0))
|
||||
|
||||
### Added
|
||||
|
||||
* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
|
||||
* **crm:** `ForceRegenerateInvoicePDF`, `SetInvoiceStatus`, `PurgeStaleInvoicePDFs`, contact/opportunity file list helpers
|
||||
* **oo:** `invoices pdf`, `pdf-cleanup`, `status`; create `--consignee`; draft-invoice force-regens PDF
|
||||
|
||||
### Fixed
|
||||
|
||||
* Document that invoice→deal must be set at create (`update --opportunity` often HTTP 400)
|
||||
|
||||
## [0.15.0](https://github.com/eSlider/go-onlyoffice/compare/v0.14.0...v0.15.0) (2026-08-29)
|
||||
|
||||
### Features
|
||||
|
||||
* **files:** `ListFolder`, `CreateFolder`, `MoveFiles`, `UploadToFolder` — Documents folder helpers for OO Documents ingestion ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fba))
|
||||
* **files:** dedupe by stem|ext (`files_stem.go`) + `oo projects files dedupe` ([ac06d86](https://github.com/eSlider/go-onlyoffice/commit/ac06d86))
|
||||
* **docs:** `internal/docpipe` — md↔docx convert, OCR→PDF, hOCR→Markdown via go-hocr, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fba), [a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd))
|
||||
* **docs:** optimize PDF via Ghostscript pdfwrite ([6b3f40e](https://github.com/eSlider/go-onlyoffice/commit/6b3f40e))
|
||||
* **security:** gitleaks secret-scan in CI + pre-push/pre-commit hooks ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554))
|
||||
|
||||
### Fixes
|
||||
|
||||
* **files:** `DeleteFiles` via per-file DELETE API; `DeleteDavItems` with `Immediately` true ([622dcdb](https://github.com/eSlider/go-onlyoffice/commit/622dcdb), [d9a7adc](https://github.com/eSlider/go-onlyoffice/commit/d9a7adc))
|
||||
* **docs:** put-txt preserves line breaks in DOCX; fixed-width extracts in code block; put-md upsert by stem ([309ae44](https://github.com/eSlider/go-onlyoffice/commit/309ae44), [f1739dc](https://github.com/eSlider/go-onlyoffice/commit/f1739dc), [50bd475](https://github.com/eSlider/go-onlyoffice/commit/50bd475))
|
||||
* **ci:** point gitleaks at `/github/workspace` in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d))
|
||||
|
||||
## [0.14.0](https://github.com/eSlider/go-onlyoffice/compare/v0.13.0...v0.14.0) (2026-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
|
||||
* **docs:** hOCR → Markdown via go-hocr ([e531de2](https://github.com/eSlider/go-onlyoffice/commit/e531de280f2c521a7411f75212803649b177db58))
|
||||
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
|
||||
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
|
||||
* **docs:** oo docs hocr — tesseract hOCR → go-hocr Markdown/YAML ([a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd61c12be9098c8d16e7c1c1253eb460816))
|
||||
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
|
||||
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
|
||||
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
|
||||
* **docs:** put-md upsert — no duplicate folder files ([2b267d3](https://github.com/eSlider/go-onlyoffice/commit/2b267d36a8086b14a94706468f7e92e9179e62d4))
|
||||
* **docs:** put-md upsert by stem to avoid duplicate folder files ([50bd475](https://github.com/eSlider/go-onlyoffice/commit/50bd47570b6430cebde43641a7b1d2f8d54d32f2))
|
||||
* **files:** DeleteFiles actually removes files on produktor OO ([a8cb4e9](https://github.com/eSlider/go-onlyoffice/commit/a8cb4e97805226e8af694ec6a64ee3ac6a5e9fdd))
|
||||
* **files:** DeleteFiles via per-file DELETE API ([622dcdb](https://github.com/eSlider/go-onlyoffice/commit/622dcdb7bffd49acbea5ef07f5d5d009f44b1358))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **readme:** document oo docs hocr ([70e605b](https://github.com/eSlider/go-onlyoffice/commit/70e605b4ca8ee005e4351b2fc2d2eec081604998))
|
||||
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
|
||||
|
||||
## [0.13.0](https://github.com/eSlider/go-onlyoffice/compare/v0.12.0...v0.13.0) (2026-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
|
||||
* **docs:** hOCR → Markdown via go-hocr ([e531de2](https://github.com/eSlider/go-onlyoffice/commit/e531de280f2c521a7411f75212803649b177db58))
|
||||
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
|
||||
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
|
||||
* **docs:** oo docs hocr — tesseract hOCR → go-hocr Markdown/YAML ([a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd61c12be9098c8d16e7c1c1253eb460816))
|
||||
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
|
||||
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
|
||||
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
|
||||
* **files:** rewrite viewUrl host to API base on download ([ecf34ba](https://github.com/eSlider/go-onlyoffice/commit/ecf34ba51aae77f1626a60795f7329f25d9344e5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **readme:** document oo docs hocr ([70e605b](https://github.com/eSlider/go-onlyoffice/commit/70e605b4ca8ee005e4351b2fc2d2eec081604998))
|
||||
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
|
||||
|
||||
## [0.12.0](https://github.com/eSlider/go-onlyoffice/compare/v0.11.0...v0.12.0) (2026-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
|
||||
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
|
||||
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
|
||||
* **files:** add WebDAV-oriented Files operations ([ebbd5d5](https://github.com/eSlider/go-onlyoffice/commit/ebbd5d5373abfeca5f160312f201cbd683f55e42))
|
||||
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
|
||||
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
|
||||
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
|
||||
* **files:** rewrite viewUrl host to API base on download ([ecf34ba](https://github.com/eSlider/go-onlyoffice/commit/ecf34ba51aae77f1626a60795f7329f25d9344e5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
|
||||
|
||||
## [0.11.0](https://github.com/eSlider/go-onlyoffice/compare/v0.10.0...v0.11.0) (2026-08-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **catalog:** port oo catalog (scan/merge/match/apply) from legacy branch ([256864f](https://github.com/eSlider/go-onlyoffice/commit/256864f613f31570cf995dddc51255a9a91a38f8))
|
||||
|
||||
## [0.10.0](https://github.com/eSlider/go-onlyoffice/compare/v0.9.0...v0.10.0) (2026-08-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** add invoices CLI and opportunity update ([19a7e9c](https://github.com/eSlider/go-onlyoffice/commit/19a7e9cbf8f7bd335c4e4cfd4d3b8a84a350cc41))
|
||||
* **crm:** allow updating invoice terms via oo ([31c556d](https://github.com/eSlider/go-onlyoffice/commit/31c556d34cedd65d6957441f448520af9a7e380b))
|
||||
* **crm:** invoice update notes and PO fields ([d81e7de](https://github.com/eSlider/go-onlyoffice/commit/d81e7de9114a29439142efba67f205653f562730))
|
||||
* **crm:** invoices CLI and opportunity update ([f958309](https://github.com/eSlider/go-onlyoffice/commit/f95830961d3aecba65f3cb074d33c9c98f6c6cf2))
|
||||
* **mail:** draft, attach, and draft-invoice CLI ([73b1050](https://github.com/eSlider/go-onlyoffice/commit/73b1050a83e9e49dcf99d8a966a84746376d7dd9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **crm:** JSON person update and oo persons update CLI ([42c31d4](https://github.com/eSlider/go-onlyoffice/commit/42c31d4b485bfab6903aeed0dd6fb2c43f61b4f2))
|
||||
* **crm:** link invoices to opportunities ([3201a58](https://github.com/eSlider/go-onlyoffice/commit/3201a58df75bceb7d41034970cf384a9169163ed))
|
||||
* **mail:** German spacing for invoice draft template ([7f35b0e](https://github.com/eSlider/go-onlyoffice/commit/7f35b0e91075d871f87de95b6c84b2353e02f9cf))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **crm:** capture association graph and invoice/mail quirks ([490426c](https://github.com/eSlider/go-onlyoffice/commit/490426c2779160f1a9b168f6ccae9dd8dccf89e9))
|
||||
* **crm:** clarify mail API send vs signature for chat links ([6b5a82b](https://github.com/eSlider/go-onlyoffice/commit/6b5a82bef831341aee16b0b0ea1fe6db2c24567d))
|
||||
* **crm:** Team vs Contacts and persons update note ([11aa23f](https://github.com/eSlider/go-onlyoffice/commit/11aa23f1a17471bad2bd95fddb769a94afbcf97a))
|
||||
|
||||
## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **catalog:** mbox header scan, company legal-suffix match, apply order ([8b29be2](https://github.com/eSlider/go-onlyoffice/commit/8b29be2d9bb71154d473f780ad247c5c440c6021))
|
||||
* **catalog:** scan Thunderbird address books and Gloda contacts ([502b373](https://github.com/eSlider/go-onlyoffice/commit/502b373e3726ded8b9c50e061ff30aea83205a34))
|
||||
* **oo:** catalog scan/match/apply for CRM clients and contacts ([547f063](https://github.com/eSlider/go-onlyoffice/commit/547f063f7fe922fe784358093a89730f9aaf0683))
|
||||
* **oo:** catalog scan/match/apply for CRM clients and contacts ([c32e4dd](https://github.com/eSlider/go-onlyoffice/commit/c32e4dd6116f0b8df67e93c5b589a417dfee59aa))
|
||||
* **projects:** CRM contacts + git author linking ([457c589](https://github.com/eSlider/go-onlyoffice/commit/457c589abdc2c9fe8edd42b4b7445581976cc5a2))
|
||||
* **projects:** link CRM companies and git authors to projects ([5a8735e](https://github.com/eSlider/go-onlyoffice/commit/5a8735e2898cf53e330e183250cf3c5d3c84da55))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalog:** oo_projects field + safer match ([8d5ca67](https://github.com/eSlider/go-onlyoffice/commit/8d5ca67d64da83c70d8ae293f4c7c23f9f9f59d3))
|
||||
* **catalog:** preserve oo_id on match; add oo_projects field ([d873cb7](https://github.com/eSlider/go-onlyoffice/commit/d873cb748adf4458369d8a2167abd8da987737c5))
|
||||
* **crm:** clean person names; canonical project titles ([b9591f5](https://github.com/eSlider/go-onlyoffice/commit/b9591f5fc3efc13b78dbe932ff32c2f2905e18fc))
|
||||
* **crm:** person names + CC | Company | Title projects ([6305135](https://github.com/eSlider/go-onlyoffice/commit/6305135bd799efe42dbb900a694e82aeae9f9e58))
|
||||
|
||||
## [0.8.3](https://github.com/eSlider/go-onlyoffice/compare/v0.8.2...v0.8.3) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** dispatch GoReleaser after release-please; allow Tests dispatch ([#13](https://github.com/eSlider/go-onlyoffice/issues/13)) ([bf2fa63](https://github.com/eSlider/go-onlyoffice/commit/bf2fa637aaf0419ba5af40b25b64f9ebe24116e8))
|
||||
|
||||
## [0.8.2](https://github.com/eSlider/go-onlyoffice/compare/v0.8.1...v0.8.2) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** retry tag fetch in Release workflow ([#11](https://github.com/eSlider/go-onlyoffice/issues/11)) ([31b4aa0](https://github.com/eSlider/go-onlyoffice/commit/31b4aa0d90393e6d93513284e453b7da7e92c62d))
|
||||
|
||||
## [0.8.1](https://github.com/eSlider/go-onlyoffice/compare/v0.8.0...v0.8.1) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oo:** assign owner and deadline on task create ([#9](https://github.com/eSlider/go-onlyoffice/issues/9)) ([f608207](https://github.com/eSlider/go-onlyoffice/commit/f60820734924284d6c40b46c79380a24573432f3))
|
||||
|
||||
## [0.8.0](https://github.com/eSlider/go-onlyoffice/compare/v0.7.0...v0.8.0) (2026-07-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
|
||||
* **search:** add produktor SearXNG JSON client ([79b1a6b](https://github.com/eSlider/go-onlyoffice/commit/79b1a6b2c397c2a8f8aeef7e2d8e6d81e0a93db2))
|
||||
* **search:** add produktor SearXNG JSON client ([bf144f2](https://github.com/eSlider/go-onlyoffice/commit/bf144f2bb37c44aa37ff33c6b03f59d7b5023dc7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
|
||||
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
|
||||
|
||||
## [0.7.0](https://github.com/eSlider/go-onlyoffice/compare/v0.6.0...v0.7.0) (2026-07-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **office:** mail preview, infinite scroll, pane resize, and scrollbars ([72844df](https://github.com/eSlider/go-onlyoffice/commit/72844dfe243e04c61ea9bc4db66c55abc08d3ae8))
|
||||
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
|
||||
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
|
||||
|
||||
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Example Author
|
||||
Copyright (c) 2026 Andriy Oblivantsev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -503,6 +503,29 @@ type Task struct {
|
||||
|---|---|
|
||||
| `GetUsers()` | List all users with profiles |
|
||||
|
||||
### Documents Files
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `ListDavFolder(ctx, id)` | List a Documents folder (`@root` for virtual sections) |
|
||||
| `ListDavSections(ctx)` | Virtual sections (Documents, Projects, …) |
|
||||
| `CreateDavFolder(ctx, parentID, title)` | Create a subfolder |
|
||||
| `RenameDavFolder(ctx, id, title)` / `RenameDavFile(ctx, id, title)` | Rename folder / file |
|
||||
| `DownloadFile(ctx, id, dst)` / `DownloadDavFile(ctx, id, w)` | Download file bytes |
|
||||
| `UploadDavFile(ctx, folderID, fileName, src)` | Upload from a reader |
|
||||
| `UploadToFolder(ctx, folderID, localPath)` | Upload a local file into a folder |
|
||||
| `UploadToFolderReplacing(ctx, folderID, localPath)` | Upsert by `stem\|ext`; returns replaced ids |
|
||||
| `UpdateFile(ctx, fileID, localPath)` | New version of an existing file (same id, no copy) |
|
||||
| `MoveDavItems(ctx, folderIDs, fileIDs, dest)` | Move (`resolveType=Skip`); per-operation errors surfaced, not silent nil |
|
||||
| `CopyDavItems(ctx, folderIDs, fileIDs, dest)` | Copy (`conflictResolveType=Skip`); errors surfaced |
|
||||
| `MoveFiles(ctx, destFolderID, fileIDs)` | Move with `resolveType=Skip` + `holdResult`; errors surfaced |
|
||||
| `ListFileOps(ctx)` | Active file operations (move/copy status polling) |
|
||||
| `FolderFiles(ctx, folderID)` | Flat file list of a folder (stem helpers) |
|
||||
| `DeleteFilesByStem(ctx, folderID, stem)` | Remove `stem\|ext` copies |
|
||||
| `DoRetry(ctx, policy, fn)` | Deterministic linear backoff (N·Base, no jitter) on 429/502/503/504 |
|
||||
| `DefaultRetryPolicy()` | 5 attempts, 1s·2s·3s·4s waits, 30s cap |
|
||||
| `Transient(err)` | True for retriable OnlyOffice answers |
|
||||
|
||||
### Helper Types
|
||||
|
||||
| Type | Description |
|
||||
@@ -533,7 +556,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 +575,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)
|
||||
@@ -608,48 +630,156 @@ go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
|
||||
```bash
|
||||
# Project Documents (files module)
|
||||
oo projects files list 33
|
||||
oo projects files upload 33 ./notes.md
|
||||
oo projects files download 12345 --to ./copy.md
|
||||
oo projects files rename 12345 notes-v2.md
|
||||
oo projects files upload 33 ./notes.docx
|
||||
oo projects files download 12345 --to ./copy.docx
|
||||
oo projects files rename 12345 notes-v2.docx
|
||||
oo projects files delete 12345
|
||||
oo projects files dedupe 7 # dry-run duplicate report
|
||||
oo projects files dedupe 7 --apply # remove older stem|ext copies per folder
|
||||
oo projects files dedupe 7 --cross --apply # cross-folder; keep non-_trash
|
||||
|
||||
# Agent document pipeline (md in git ↔ docx in OO; OCR scans)
|
||||
oo docs tools
|
||||
oo docs convert ./note.md # → note.docx
|
||||
oo docs convert ./note.docx # → note.md
|
||||
oo docs ocr ./scan.jpg --md ./scan.md # searchable PDF + markdown
|
||||
oo docs hocr ./scan.jpg --lang spa --md ./scan.hocr.md --yaml ./scan.yml
|
||||
oo docs put-md 7 ./OO-HONDA-7-INDEX.md --folder 490
|
||||
oo docs put-txt 7 ./notes.txt --folder 490
|
||||
oo docs put-xlsx 7 ./table.xlsx --folder 490
|
||||
oo docs as-md 2815 --to ./parte.md # download OO file as MD (OCR if needed)
|
||||
oo docs as-md 307 --hocr --lang spa # OO download via go-hocr structure
|
||||
oo projects files put-md 7 ./note.md # alias
|
||||
oo projects files as-md 2815 # alias
|
||||
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
|
||||
```
|
||||
|
||||
### Documents module (`oo dav`)
|
||||
|
||||
Direct access to the Documents module by folder/file id — the same calls that
|
||||
back `oo-webdav` and the project/task file commands. `move` sends
|
||||
`resolveType=Skip` + `holdResult=true`: without those params the legacy
|
||||
`fileops/move` endpoint answers 200 without moving anything, and the library
|
||||
surfaces such per-operation errors instead of a silent nil
|
||||
(`MoveDavItems` / `CopyDavItems` / `MoveFiles`).
|
||||
|
||||
```bash
|
||||
oo dav ls 659
|
||||
oo dav ls @root # virtual sections (Documents, Projects, …)
|
||||
oo dav mkdir 659 "2026 inbox"
|
||||
oo dav move 659 22881 22882 # DEST_FOLDER_ID FILE_ID…
|
||||
oo dav move 659 22881 --folders 670 # move folders along with files
|
||||
oo dav copy 659 22881
|
||||
oo dav rename-file 22881 invoice-v2.pdf
|
||||
oo dav rename-folder 671 o2-archive
|
||||
oo dav download 22881 --to ./copy.pdf # default path: ./<server title>
|
||||
oo dav fileops # active move/copy operations (status polling)
|
||||
```
|
||||
|
||||
### Search and index (`oo search`, `oo index`)
|
||||
|
||||
Full-text search over the Documents index. The REST endpoint
|
||||
`/api/2.0/files/@search/{query}` only searches file names in the database, so
|
||||
`oo search` talks to the OnlyOffice **Elasticsearch** directly (index
|
||||
`files_file`). Name search is default; `--content` also matches extracted
|
||||
document text (`document.attachment.content`, Office formats only).
|
||||
See [`docs/elasticsearch.md`](docs/elasticsearch.md) for the tunnel setup.
|
||||
|
||||
```bash
|
||||
oo search "Rechnung" # names only
|
||||
oo search "Mahngebühr" --content # names + document text
|
||||
oo search "Rechnung" --folder 649 --limit 50
|
||||
oo search "Rechnung" --json # shorthand for -o json
|
||||
```
|
||||
|
||||
Requires `ONLYOFFICE_ES_URL` (plus optional `ONLYOFFICE_ES_INDEX`,
|
||||
`ONLYOFFICE_TENANT`).
|
||||
|
||||
#### PDF/scans: own index (`oo index` + `--backend own`)
|
||||
|
||||
The OnlyOffice index covers Office formats only, so PDFs (`S1019`-style invoice
|
||||
numbers) are not searchable by content. `oo index` extracts PDF text with
|
||||
`internal/docpipe` (pdftotext, OCR for scans) — including the text of embedded
|
||||
PDF attachments (`pdfdetach`: `<doc>.md`, `.xml`, covers the original/scan and
|
||||
ZUGFeRD e-invoice XML) — into a separate index (`ONLYOFFICE_ES_TEXT_INDEX`,
|
||||
default `oo_docs_text`); the OnlyOffice server and its index are **not**
|
||||
modified. Then search it with `--backend own`.
|
||||
|
||||
```bash
|
||||
oo index folder 634 --recursive --exts pdf # populate (idempotent upsert)
|
||||
oo index files 3576 3578 # specific files
|
||||
oo index folder 634 --dry-run # plan only
|
||||
oo search "S1021" --content --backend own # finds the PDF
|
||||
oo search "Rechnung" --backend own --folder 634 --json
|
||||
```
|
||||
|
||||
See [`docs/elasticsearch.md`](docs/elasticsearch.md) for the decision and
|
||||
trade-offs.
|
||||
|
||||
### Unified file client
|
||||
|
||||
All file backends (REST, WebDAV, read-only SQL, Elasticsearch) sit behind one
|
||||
facade: `c.Files()` returns a `*FileClient` that also implements `FileStore`,
|
||||
so old call sites keep working. Pick a transport per call with
|
||||
`c.FileStore("rest"|"dav"|"pg"|"sql")` (SQL is read-only), open the SQL store
|
||||
with `c.SQLFileStore()`, or register a backend on the facade
|
||||
(`RegisterStore`/`RegisterSearcher`). Contract, model (`Entry`/`Kind`),
|
||||
fallback rules, env names and how to add a backend:
|
||||
[`docs/unified-file-client.md`](docs/unified-file-client.md).
|
||||
|
||||
### Bulk tools (`cmd/`)
|
||||
|
||||
Small single-purpose binaries for bulk Documents work. All of them pace
|
||||
requests and retry transient OnlyOffice answers (429/502/503/504) with a
|
||||
deterministic linear backoff — no jitter, same waits on every run
|
||||
(see `DoRetry` below). Build with `go build ./cmd/<tool>`.
|
||||
|
||||
```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 3906 ./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.
|
||||
```
|
||||
|
||||
| Subject | Verbs |
|
||||
|---|---|
|
||||
| `calendar` | `list`, `events`, `add`, `delete` |
|
||||
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
|
||||
| `projects` | `list`, `get`, `milestones`, `milestone-create`, `create`, `update`, `delete`, `contacts` (`add`, `remove`), `link-authors`, `link-git`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`, `dedupe`, `as-md`, `put-md`, `put-txt`, `put-xlsx`) |
|
||||
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
||||
| `users` | `list`, `self` (alias: `oo whoami`) |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
||||
| `persons` | `list` (filtered), `create`, `delete` |
|
||||
| `companies` | `list` (filtered), `create`, `delete` |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info`, `tags`, `tag-add`, `tag-create`, `tag-remove` |
|
||||
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||
| `opportunities` | `list`, `get`, `create`, `update`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
|
||||
| `crm` | `cleanup` |
|
||||
| `mails` | `accounts`, `folders`, `list`, `get`, `delete` |
|
||||
| `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` |
|
||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
||||
| `applications` | `sync` |
|
||||
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
||||
| `docs` | `tools`, `convert`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
|
||||
| `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` |
|
||||
| `dav` | `ls`, `move`, `copy`, `mkdir`, `rename-file`, `rename-folder`, `download`, `fileops` |
|
||||
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) |
|
||||
| `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) |
|
||||
|
||||
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 <subject> --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
|
||||
|
||||
@@ -671,7 +801,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:
|
||||
@@ -710,13 +840,39 @@ 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
|
||||
```
|
||||
|
||||
Merge two known company ids (keeps `INTO`):
|
||||
|
||||
```bash
|
||||
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).
|
||||
|
||||
### Invoices (`oo invoices`)
|
||||
|
||||
**Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF
|
||||
attachments, prepare OnlyOffice Mail — without inventing a second bill-to company.
|
||||
|
||||
```bash
|
||||
# Always pass --opportunity at create (update --opportunity often HTTP 400)
|
||||
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 INVOICE_ID --force
|
||||
oo invoices pdf-cleanup INVOICE_ID
|
||||
oo invoices status INVOICE_ID --status draft
|
||||
oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com
|
||||
```
|
||||
|
||||
**Deal grouping flag** — when the same role at the same company created
|
||||
separate deals (`Engineer @ Acme` vs `Engineer`):
|
||||
|
||||
@@ -728,41 +884,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
|
||||
```
|
||||
|
||||
### 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 `README.md` is parsed for company, role, email, phone, LinkedIn, etc.
|
||||
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`)
|
||||
|
||||
@@ -795,8 +921,21 @@ oo mails get 5664 -o json | jq '{subject, from, to, date}'
|
||||
# Remove one or more messages (server moves to trash or deletes per Mail rules)
|
||||
oo mails delete 5664
|
||||
oo mails delete 5664 5663 5661
|
||||
|
||||
# Create / update a draft (HTML body field is API "body"; plain text is wrapped)
|
||||
oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
|
||||
--body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen"
|
||||
|
||||
# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
|
||||
oo mails attach 7301 --file-id 12345
|
||||
|
||||
# Regenerate invoice PDF (force) + draft + attach (does not send)
|
||||
oo mails draft-invoice --invoice 16 --to info@example.com
|
||||
```
|
||||
|
||||
Matrix / chat URLs in signatures: use plain text
|
||||
`chat: https://matrix.to/#/@user:server` — HTML `<a href="…#…">` truncates at `#`.
|
||||
|
||||
**Table output** splits the `from` header into `fromName` and `fromAddress`
|
||||
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
|
||||
API payload.
|
||||
@@ -836,8 +975,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
|
||||
```
|
||||
|
||||
@@ -845,7 +984,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` |
|
||||
@@ -859,9 +998,51 @@ 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_*` |
|
||||
| `ONLYOFFICE_ES_URL` | Elasticsearch base URL (`oo search`, own index); see [`docs/elasticsearch.md`](docs/elasticsearch.md) |
|
||||
| `ONLYOFFICE_ES_INDEX` | OnlyOffice index (default `files_file`) |
|
||||
| `ONLYOFFICE_ES_TEXT_INDEX` | Own PDF/scan index (default `oo_docs_text`) |
|
||||
| `ONLYOFFICE_TENANT` | `tenantId` filter for ES/SQL (empty = all) |
|
||||
| `ONLYOFFICE_DSN` | Read-only SQL DSN (MySQL or `postgres://`); see [`docs/community-server-db.md`](docs/community-server-db.md) |
|
||||
| `ONLYOFFICE_PG_DRIVER`, `ONLYOFFICE_PG_TENANT`, `ONLYOFFICE_PG_HOST/_PORT/_USER/_PASSWORD/_DBNAME/_SSLMODE` | SQL store override / DSN by parts (PostgreSQL) |
|
||||
| `MINIO_ENDPOINT`, `MINIO_BUCKET`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY` | Object-store layout for SQL `Download` |
|
||||
| `ONLYOFFICE_WEBDAV_URL` | rclone WebDAV sidecar URL (default `http://172.17.0.1:8098/webdav`) |
|
||||
|
||||
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.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go build ./... && go vet ./...
|
||||
go test ./... # unit — no network, no vendor mocks
|
||||
go test -race ./...
|
||||
go test -tags=integration ./... # live OnlyOffice (skip without creds)
|
||||
```
|
||||
|
||||
Unit tests are pure Go (parsers, encoders, conversions). Integration tests
|
||||
(`//go:build integration`) hit a live instance and **skip** cleanly when the
|
||||
env is missing, so `go test ./...` stays green offline. New endpoints ship with
|
||||
an integration test before merge (policy in [`AGENTS.md`](AGENTS.md)).
|
||||
|
||||
Live runs need credentials (`ONLYOFFICE_URL`, `ONLYOFFICE_USER`,
|
||||
`ONLYOFFICE_PASS`) and, per backend:
|
||||
|
||||
- **Elasticsearch** (`oo search`, own index) — ES lives on `127.0.0.1:9200`
|
||||
inside the OnlyOffice VM; expose it over SSH
|
||||
(`-L 9200:127.0.0.1:9200`) and set `ONLYOFFICE_ES_URL`
|
||||
(see [`docs/elasticsearch.md`](docs/elasticsearch.md)).
|
||||
- **SQL backend** (`FileClient`, `SQLFileStore`) — MySQL on `127.0.0.1:3306`
|
||||
in the same VM; tunnel `-L 3306:127.0.0.1:3306`, then set `ONLYOFFICE_DSN`
|
||||
(see [`docs/community-server-db.md`](docs/community-server-db.md)).
|
||||
`ONLYOFFICE_PG_TEST_FILE_ID` / `ONLYOFFICE_PG_TEST_FOLDER_ID` select a real
|
||||
file for the REST cross-check; `MINIO_*` enable the download check.
|
||||
|
||||
### 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).
|
||||
|
||||
### CI / releases
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ func (c *Client) Authenticate() error { return c.ensureToken() }
|
||||
// cached token is still valid it returns immediately; otherwise it performs
|
||||
// a POST to /api/2.0/authentication.json that is cancellable via ctx.
|
||||
//
|
||||
// Transient answers from the edge (openresty 429/502/503/504) are retried with
|
||||
// the same deterministic policy as every other request (see retry.go), because
|
||||
// the server rate-limits authentication and the integration suite otherwise
|
||||
// fails with a raw HTML 429 page.
|
||||
//
|
||||
// This is the recommended entry point for long-running syncs (cron,
|
||||
// watchers) because it guarantees that a stalled auth call will not block
|
||||
// the caller past its deadline.
|
||||
@@ -50,6 +55,14 @@ func (c *Client) AuthenticateContext(ctx context.Context) error {
|
||||
if c.tokenValid() {
|
||||
return nil
|
||||
}
|
||||
return DoRetry(ctx, DefaultRetryPolicy(), func() error {
|
||||
return c.authenticateOnce(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// authenticateOnce performs a single authentication POST. Callers must handle
|
||||
// retries; use AuthenticateContext.
|
||||
func (c *Client) authenticateOnce(ctx context.Context) error {
|
||||
body, err := json.Marshal(c.credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
@@ -99,17 +112,13 @@ func (c *Client) tokenValid() bool {
|
||||
|
||||
// ensureToken refreshes the authentication token when missing or expired.
|
||||
// Mirrors the logic inline in Query() but is safe to call from helpers that
|
||||
// bypass the typed Request abstraction.
|
||||
// bypass the typed Request abstraction. It shares AuthenticateContext so the
|
||||
// transient-retry policy applies to every code path.
|
||||
func (c *Client) ensureToken() error {
|
||||
if c.tokenValid() {
|
||||
return nil
|
||||
}
|
||||
tok, err := c.Auth(c.credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.token = tok
|
||||
return nil
|
||||
return c.AuthenticateContext(context.Background())
|
||||
}
|
||||
|
||||
// authHeader returns the value for the Authorization header, ensuring a token.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
)
|
||||
|
||||
// ApplyResult summarizes one apply pass.
|
||||
type ApplyResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// ApplyApproved creates/updates OO contacts for entries with approve=true.
|
||||
// Companies are applied before persons so Org links resolve in the same run.
|
||||
func ApplyApproved(ctx context.Context, client *onlyoffice.Client, doc *Document, dryRun bool) (*ApplyResult, error) {
|
||||
res := &ApplyResult{DryRun: dryRun}
|
||||
order := make([]int, 0, len(doc.Entries))
|
||||
for i, e := range doc.Entries {
|
||||
if e.Kind == "company" {
|
||||
order = append(order, i)
|
||||
}
|
||||
}
|
||||
for i, e := range doc.Entries {
|
||||
if e.Kind != "company" {
|
||||
order = append(order, i)
|
||||
}
|
||||
}
|
||||
for _, i := range order {
|
||||
e := &doc.Entries[i]
|
||||
if !e.Approve {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if e.Status == "conflict" {
|
||||
res.Skipped++
|
||||
res.Errors = append(res.Errors, e.ID+": conflict — resolve manually")
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
if e.OOID != "" || e.Status == "exists" {
|
||||
res.Updated++
|
||||
} else {
|
||||
res.Created++
|
||||
}
|
||||
continue
|
||||
}
|
||||
created, err := applyOne(ctx, client, e)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, e.ID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if created {
|
||||
res.Created++
|
||||
} else {
|
||||
res.Updated++
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func applyOne(ctx context.Context, client *onlyoffice.Client, e *Entry) (created bool, err error) {
|
||||
switch e.Kind {
|
||||
case "company":
|
||||
return applyCompany(ctx, client, e)
|
||||
case "person":
|
||||
return applyPerson(ctx, client, e)
|
||||
default:
|
||||
return false, fmt.Errorf("unknown kind %q", e.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func applyCompany(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
|
||||
name := strings.TrimSpace(e.Name)
|
||||
if name == "" {
|
||||
return false, fmt.Errorf("company missing name")
|
||||
}
|
||||
var co map[string]any
|
||||
var err error
|
||||
if e.OOID != "" {
|
||||
co, err = client.GetContact(ctx, e.OOID)
|
||||
} else {
|
||||
co, err = client.FindCompany(ctx, name)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
created := false
|
||||
if co == nil {
|
||||
co, err = client.CreateCompany(ctx, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
created = true
|
||||
}
|
||||
id := contactIDString(co)
|
||||
e.OOID = id
|
||||
if err := ensureContactInfos(ctx, client, id, e); err != nil {
|
||||
return created, err
|
||||
}
|
||||
e.Status = "applied"
|
||||
if created {
|
||||
e.Notes = "created"
|
||||
} else {
|
||||
e.Notes = "updated"
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
|
||||
first := strings.TrimSpace(e.First)
|
||||
last := strings.TrimSpace(e.Last)
|
||||
if first == "" && last == "" {
|
||||
first, last = SplitDisplayName(e.Name)
|
||||
}
|
||||
if first == "" {
|
||||
first = strings.TrimSpace(e.Name)
|
||||
}
|
||||
if first == "" {
|
||||
return false, fmt.Errorf("person missing name")
|
||||
}
|
||||
if last == "" {
|
||||
last = "-"
|
||||
}
|
||||
|
||||
var p map[string]any
|
||||
var err error
|
||||
if e.OOID != "" {
|
||||
p, err = client.GetContact(ctx, e.OOID)
|
||||
} else if len(e.Emails) > 0 {
|
||||
p, err = client.FindPersonByEmail(ctx, e.Emails[0])
|
||||
} else {
|
||||
p, err = client.FindPerson(ctx, first, last)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
created := false
|
||||
companyID := 0
|
||||
if e.Org != "" {
|
||||
if co, ferr := client.FindCompany(ctx, e.Org); ferr == nil && co != nil {
|
||||
companyID, _ = strconv.Atoi(contactIDString(co))
|
||||
}
|
||||
}
|
||||
if p == nil {
|
||||
about := ""
|
||||
if e.Org != "" {
|
||||
about = "org: " + e.Org
|
||||
}
|
||||
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
created = true
|
||||
}
|
||||
id := contactIDString(p)
|
||||
e.OOID = id
|
||||
if err := ensureContactInfos(ctx, client, id, e); err != nil {
|
||||
return created, err
|
||||
}
|
||||
e.Status = "applied"
|
||||
if created {
|
||||
e.Notes = "created"
|
||||
} else {
|
||||
e.Notes = "updated"
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func ensureContactInfos(ctx context.Context, client *onlyoffice.Client, contactID string, e *Entry) error {
|
||||
existing, err := client.GetContact(ctx, contactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, em := range e.Emails {
|
||||
if onlyoffice.HasContactInfo(existing, "Email", em) {
|
||||
continue
|
||||
}
|
||||
if _, err := client.AddContactInfo(ctx, contactID, "Email", em, "Work", i == 0); err != nil {
|
||||
return fmt.Errorf("add email %s: %w", em, err)
|
||||
}
|
||||
}
|
||||
for _, ph := range e.Phones {
|
||||
if onlyoffice.HasContactInfo(existing, "Phone", ph) {
|
||||
continue
|
||||
}
|
||||
if _, err := client.AddContactInfo(ctx, contactID, "Phone", ph, "Work", false); err != nil {
|
||||
return fmt.Errorf("add phone %s: %w", ph, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseVCFFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sample.vcf")
|
||||
body := `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Ada Lovelace
|
||||
N:Lovelace;Ada;;;
|
||||
EMAIL;TYPE=INTERNET:ada@example.com
|
||||
TEL;TYPE=CELL:+1-555-0100
|
||||
ORG:Analytical Engines
|
||||
END:VCARD
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := ParseVCFFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("got %d entries", len(entries))
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Kind != "person" || e.First != "Ada" || e.Last != "Lovelace" {
|
||||
t.Fatalf("unexpected entry: %+v", e)
|
||||
}
|
||||
if len(e.Emails) != 1 || e.Emails[0] != "ada@example.com" {
|
||||
t.Fatalf("emails: %v", e.Emails)
|
||||
}
|
||||
if e.Org != "Analytical Engines" {
|
||||
t.Fatalf("org: %q", e.Org)
|
||||
}
|
||||
if e.Approve {
|
||||
t.Fatal("approve should default false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDocs(t *testing.T) {
|
||||
a := &Document{Entries: []Entry{{
|
||||
ID: "person:ada@example.com", Kind: "person", First: "Ada", Emails: []string{"ada@example.com"},
|
||||
Sources: []string{"a.vcf"}, Zone: "private", Role: "unknown",
|
||||
}}}
|
||||
b := &Document{Entries: []Entry{{
|
||||
ID: "person:ada@example.com", Kind: "person", Last: "Lovelace", Phones: []string{"+1"},
|
||||
Sources: []string{"folder/Ada"}, Zone: "warm", Role: "work", Approve: true,
|
||||
}}}
|
||||
m := MergeDocs(a, b)
|
||||
if len(m.Entries) != 1 {
|
||||
t.Fatalf("len=%d", len(m.Entries))
|
||||
}
|
||||
e := m.Entries[0]
|
||||
if e.First != "Ada" || e.Last != "Lovelace" {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
if len(e.Sources) != 2 || len(e.Phones) != 1 || !e.Approve {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
if e.Zone != "warm" || e.Role != "work" {
|
||||
t.Fatalf("zone/role: %s/%s", e.Zone, e.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanContactsRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
vcfDir := filepath.Join(root, "Contacts VCF's")
|
||||
if err := os.MkdirAll(vcfDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(vcfDir, "bob.vcf"), []byte(`BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Bob Builder
|
||||
EMAIL:bob@work.test
|
||||
END:VCARD
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
personDir := filepath.Join(root, "Carol Smith")
|
||||
if err := os.MkdirAll(personDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(personDir, "carol@example.com.txt"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := ScanContactsRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Entries) < 2 {
|
||||
t.Fatalf("expected >=2 entries, got %d: %+v", len(doc.Entries), doc.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanProjectsRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repo := filepath.Join(root, "produktor-demo")
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := ScanProjectsRoot(root, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range doc.Entries {
|
||||
if e.Kind == "company" && e.Name == "produktor-demo" {
|
||||
found = true
|
||||
if e.Role != "work" {
|
||||
t.Fatalf("role=%q", e.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing company: %+v", doc.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryID(t *testing.T) {
|
||||
if got := EntryID("person", "A@B.COM", ""); got != "person:a@b.com" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := EntryID("company", "", "Acme Corp"); got != "company:acme corp" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
)
|
||||
|
||||
// MatchAgainstOO sets status/oo_id by email then normalized name.
|
||||
func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Document) error {
|
||||
all, err := client.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
byEmail := map[string]map[string]any{}
|
||||
byPersonName := map[string]map[string]any{}
|
||||
byCompanyName := map[string]map[string]any{}
|
||||
|
||||
for _, c := range all {
|
||||
id := contactIDString(c)
|
||||
isCo, _ := c["isCompany"].(bool)
|
||||
for _, em := range contactEmails(c) {
|
||||
byEmail[NormalizeEmail(em)] = c
|
||||
}
|
||||
if isCo {
|
||||
key := NormalizeName(fmt.Sprint(c["displayName"]))
|
||||
if key != "" {
|
||||
byCompanyName[key] = c
|
||||
}
|
||||
continue
|
||||
}
|
||||
first := strings.TrimSpace(fmt.Sprint(c["firstName"]))
|
||||
last := strings.TrimSpace(fmt.Sprint(c["lastName"]))
|
||||
key := NormalizeName(first + " " + last)
|
||||
if key == "" {
|
||||
key = NormalizeName(fmt.Sprint(c["displayName"]))
|
||||
}
|
||||
if key != "" {
|
||||
byPersonName[key] = c
|
||||
}
|
||||
_ = id
|
||||
}
|
||||
|
||||
for i := range doc.Entries {
|
||||
e := &doc.Entries[i]
|
||||
// preserve approve
|
||||
matched := false
|
||||
conflict := false
|
||||
var oo map[string]any
|
||||
|
||||
for _, em := range e.Emails {
|
||||
if c, ok := byEmail[NormalizeEmail(em)]; ok {
|
||||
if oo != nil && contactIDString(oo) != contactIDString(c) {
|
||||
conflict = true
|
||||
}
|
||||
oo = c
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
if e.Kind == "company" {
|
||||
if c, ok := byCompanyName[NormalizeName(e.Name)]; ok {
|
||||
oo = c
|
||||
matched = true
|
||||
}
|
||||
} else {
|
||||
key := NormalizeName(strings.TrimSpace(e.First + " " + e.Last))
|
||||
if key == "" {
|
||||
key = NormalizeName(e.Name)
|
||||
}
|
||||
if c, ok := byPersonName[key]; ok {
|
||||
oo = c
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if conflict {
|
||||
e.Status = "conflict"
|
||||
e.Notes = strings.TrimSpace(e.Notes + " email_matches_multiple_oo")
|
||||
if oo != nil {
|
||||
e.OOID = contactIDString(oo)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if matched && oo != nil {
|
||||
e.Status = "exists"
|
||||
e.OOID = contactIDString(oo)
|
||||
continue
|
||||
}
|
||||
e.Status = "new"
|
||||
e.OOID = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactIDString(c map[string]any) string {
|
||||
switch v := c["id"].(type) {
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func contactEmails(c map[string]any) []string {
|
||||
var out []string
|
||||
if em := strings.TrimSpace(fmt.Sprint(c["email"])); em != "" && em != "<nil>" {
|
||||
out = append(out, em)
|
||||
}
|
||||
if em := strings.TrimSpace(fmt.Sprint(c["primaryEmail"])); em != "" && em != "<nil>" {
|
||||
out = append(out, em)
|
||||
}
|
||||
for _, row := range onlyoffice.ContactInfoRows(c) {
|
||||
t := strings.ToLower(fmt.Sprint(row["infoType"]))
|
||||
if t != "email" {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
||||
if data != "" {
|
||||
out = append(out, data)
|
||||
}
|
||||
}
|
||||
return uniqueEmails(out)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchAgainstOO_emailThenName(t *testing.T) {
|
||||
// MatchAgainstOO needs a live client; unit-test the helpers used by matching.
|
||||
c := map[string]any{
|
||||
"id": float64(42),
|
||||
"isCompany": false,
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"email": "ada@example.com",
|
||||
"commonData": []any{
|
||||
map[string]any{"infoType": "Email", "data": "ada.alt@example.com"},
|
||||
},
|
||||
}
|
||||
if contactIDString(c) != "42" {
|
||||
t.Fatal(contactIDString(c))
|
||||
}
|
||||
emails := contactEmails(c)
|
||||
if len(emails) < 2 {
|
||||
t.Fatalf("%v", emails)
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ScanOptions controls Thunderbird / mbox extraction depth.
|
||||
type ScanOptions struct {
|
||||
// MboxHeaders, when true, also walks mbox-like files under the root and
|
||||
// extracts From/To/Cc/Reply-To addresses (headers only, no bodies).
|
||||
MboxHeaders bool
|
||||
// MboxMaxBytes skips individual mbox files larger than this (0 = 256 MiB default).
|
||||
MboxMaxBytes int64
|
||||
}
|
||||
|
||||
// ScanThunderbirdRoot finds Thunderbird profiles under root and emits person rows
|
||||
// from address books (*.mab) and Gloda global-messages-db.sqlite.
|
||||
func ScanThunderbirdRoot(root string) (*Document, error) {
|
||||
return ScanThunderbirdRootOpts(root, ScanOptions{})
|
||||
}
|
||||
|
||||
// ScanThunderbirdRootOpts is ScanThunderbirdRoot with optional mbox header pass.
|
||||
func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*Document, error) {
|
||||
root = filepath.Clean(root)
|
||||
st, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return nil, fmt.Errorf("not a directory: %s", root)
|
||||
}
|
||||
if opts.MboxMaxBytes <= 0 {
|
||||
opts.MboxMaxBytes = 256 << 20
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
seenDB := map[string]struct{}{}
|
||||
seenMAB := map[string]struct{}{}
|
||||
seenMbox := map[string]struct{}{}
|
||||
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if d.IsDir() {
|
||||
switch name {
|
||||
case "Cache", "cache2", "startupCache", "OfflineCache", "minidumps",
|
||||
"crashes", "safebrowsing", "thumbnails", "chrome", "extensions",
|
||||
"node_modules", ".git":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case lower == "global-messages-db.sqlite":
|
||||
if _, ok := seenDB[path]; ok {
|
||||
return nil
|
||||
}
|
||||
seenDB[path] = struct{}{}
|
||||
parsed, perr := parseGlodaContacts(path)
|
||||
if perr != nil {
|
||||
entries = append(entries, Entry{
|
||||
ID: EntryID("person", "", filepath.Base(path)),
|
||||
Kind: "person",
|
||||
Name: "gloda",
|
||||
Sources: []string{path},
|
||||
Zone: "private",
|
||||
Role: "unknown",
|
||||
Notes: "gloda_parse_error: " + perr.Error(),
|
||||
Status: "new",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, parsed...)
|
||||
case strings.HasSuffix(lower, ".mab"):
|
||||
if _, ok := seenMAB[path]; ok {
|
||||
return nil
|
||||
}
|
||||
seenMAB[path] = struct{}{}
|
||||
parsed, perr := parseMABEmails(path)
|
||||
if perr != nil {
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, parsed...)
|
||||
case opts.MboxHeaders && isLikelyMboxFile(name, path):
|
||||
if _, ok := seenMbox[path]; ok {
|
||||
return nil
|
||||
}
|
||||
seenMbox[path] = struct{}{}
|
||||
info, ierr := d.Info()
|
||||
if ierr != nil {
|
||||
return nil
|
||||
}
|
||||
if info.Size() > opts.MboxMaxBytes {
|
||||
return nil
|
||||
}
|
||||
parsed, perr := parseMboxHeaderEmails(path)
|
||||
if perr != nil {
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, parsed...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return MergeDocs(&Document{Entries: entries}), nil
|
||||
}
|
||||
|
||||
func isLikelyMboxFile(name, path string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ".msf") || strings.HasSuffix(lower, ".sqlite") ||
|
||||
strings.HasSuffix(lower, ".mab") || strings.HasSuffix(lower, ".json") ||
|
||||
strings.HasSuffix(lower, ".dat") || strings.HasSuffix(lower, ".ini") ||
|
||||
strings.HasSuffix(lower, ".log") {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(lower, ".mbox") || strings.HasSuffix(lower, ".mbx") {
|
||||
return true
|
||||
}
|
||||
// Thunderbird: …/ImapMail/<server>/INBOX or …/Mail/Local Folders/Inbox
|
||||
// or …/INBOX.sbd/<folder>
|
||||
sep := string(filepath.Separator)
|
||||
norm := filepath.ToSlash(path)
|
||||
if strings.Contains(norm, "/ImapMail/") || strings.Contains(norm, "/Mail/") {
|
||||
if !strings.Contains(name, ".") {
|
||||
return true
|
||||
}
|
||||
known := map[string]struct{}{
|
||||
"inbox": {}, "sent": {}, "drafts": {}, "trash": {}, "archives": {}, "junk": {},
|
||||
}
|
||||
if _, ok := known[lower]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.Contains(filepath.Base(filepath.Dir(path)), ".sbd") && !strings.Contains(name, ".") {
|
||||
return true
|
||||
}
|
||||
_ = sep
|
||||
return false
|
||||
}
|
||||
|
||||
// parseMboxHeaderEmails extracts addresses from From/To/Cc/Reply-To headers only.
|
||||
func parseMboxHeaderEmails(path string) ([]Entry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
emails := map[string]struct{}{}
|
||||
br := bufio.NewReaderSize(f, 1<<20)
|
||||
inHeaders := false
|
||||
var headerBuf strings.Builder
|
||||
|
||||
flushHeaders := func() {
|
||||
if headerBuf.Len() == 0 {
|
||||
return
|
||||
}
|
||||
block := headerBuf.String()
|
||||
headerBuf.Reset()
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
lower := strings.ToLower(strings.TrimSpace(line))
|
||||
if strings.HasPrefix(lower, "from:") || strings.HasPrefix(lower, "to:") ||
|
||||
strings.HasPrefix(lower, "cc:") || strings.HasPrefix(lower, "reply-to:") ||
|
||||
strings.HasPrefix(lower, "sender:") {
|
||||
for _, m := range emailRE.FindAllString(line, -1) {
|
||||
em := NormalizeEmail(m)
|
||||
if !noisyEmail(em) {
|
||||
emails[em] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if len(line) > 0 {
|
||||
// Classic mbox separator
|
||||
if strings.HasPrefix(line, "From ") && (len(line) == 5 || line[5] != ':') {
|
||||
flushHeaders()
|
||||
inHeaders = true
|
||||
continue
|
||||
}
|
||||
if inHeaders {
|
||||
trimmed := strings.TrimRight(line, "\r\n")
|
||||
if trimmed == "" {
|
||||
flushHeaders()
|
||||
inHeaders = false
|
||||
continue
|
||||
}
|
||||
headerBuf.WriteString(trimmed)
|
||||
headerBuf.WriteByte('\n')
|
||||
// cap pathological headers
|
||||
if headerBuf.Len() > 64<<10 {
|
||||
flushHeaders()
|
||||
inHeaders = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
flushHeaders()
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
flushHeaders()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var out []Entry
|
||||
for em := range emails {
|
||||
org, zone, role := classifyMailIdentity("", em)
|
||||
out = append(out, Entry{
|
||||
ID: EntryID("person", em, ""),
|
||||
Kind: "person",
|
||||
Emails: []string{em},
|
||||
Org: org,
|
||||
Sources: []string{path},
|
||||
Zone: zone,
|
||||
Role: role,
|
||||
Approve: false,
|
||||
Status: "new",
|
||||
Notes: "thunderbird_mbox_header",
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMboxHeaderEmails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "INBOX")
|
||||
body := `From - Mon Jul 1 00:00:00 2016
|
||||
From: Axel Schaefer <axel.schaefer@wheregroup.com>
|
||||
To: Andriy Oblivantsev <andriy.oblivantsev@wheregroup.com>
|
||||
Cc: noreply@example.com, client@stadt-example.de
|
||||
Subject: test
|
||||
|
||||
Body line ignored
|
||||
From - Mon Jul 2 00:00:00 2016
|
||||
From: Someone <paul.schmidt@wheregroup.com>
|
||||
To: list@wheregroup.com
|
||||
|
||||
more body
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := parseMboxHeaderEmails(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
if len(e.Emails) > 0 {
|
||||
got[e.Emails[0]] = true
|
||||
}
|
||||
}
|
||||
if !got["axel.schaefer@wheregroup.com"] || !got["andriy.oblivantsev@wheregroup.com"] {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
if got["noreply@example.com"] {
|
||||
t.Fatal("noreply should be filtered")
|
||||
}
|
||||
if !got["client@stadt-example.de"] {
|
||||
t.Fatal("missing client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanThunderbirdRootOptsMbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
imap := filepath.Join(root, "ImapMail", "mail.example.com")
|
||||
if err := os.MkdirAll(imap, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(imap, "INBOX"), []byte(
|
||||
"From - x\nFrom: a@wheregroup.com\nTo: b@wheregroup.com\n\nbody\n",
|
||||
), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := ScanThunderbirdRootOpts(root, ScanOptions{MboxHeaders: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Entries) < 2 {
|
||||
t.Fatalf("%+v", doc.Entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ScanProjectsRoot finds git roots under root (max depth) and emits company rows.
|
||||
func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
|
||||
root = filepath.Clean(root)
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 4
|
||||
}
|
||||
st, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return nil, fmt.Errorf("not a directory: %s", root)
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
err = walkGitRoots(root, root, 0, maxDepth, &entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Also top-level dirs as company stubs (even without git) — coverage 1B.
|
||||
dents, _ := os.ReadDir(root)
|
||||
for _, d := range dents {
|
||||
if !d.IsDir() || strings.HasPrefix(d.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
name := d.Name()
|
||||
skip := map[string]struct{}{
|
||||
"node_modules": {}, "vendor": {}, ".cache": {},
|
||||
}
|
||||
if _, ok := skip[name]; ok {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
id := EntryID("company", "", name)
|
||||
role, zone := classifyProjectName(name, "")
|
||||
entries = append(entries, Entry{
|
||||
ID: id,
|
||||
Kind: "company",
|
||||
Name: name,
|
||||
Sources: []string{path},
|
||||
Zone: zone,
|
||||
Role: role,
|
||||
Status: "new",
|
||||
Notes: "top_level_dir",
|
||||
})
|
||||
}
|
||||
|
||||
return MergeDocs(&Document{Entries: entries}), nil
|
||||
}
|
||||
|
||||
func walkGitRoots(root, dir string, depth, maxDepth int, out *[]Entry) error {
|
||||
if depth > maxDepth {
|
||||
return nil
|
||||
}
|
||||
// is this a git root?
|
||||
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && (st.IsDir() || st.Mode().IsRegular()) {
|
||||
name := filepath.Base(dir)
|
||||
remote := gitRemoteOrigin(dir)
|
||||
role, zone := classifyProjectName(name, remote)
|
||||
*out = append(*out, Entry{
|
||||
ID: EntryID("company", "", name),
|
||||
Kind: "company",
|
||||
Name: name,
|
||||
Sources: []string{dir},
|
||||
Remote: remote,
|
||||
GitRoot: dir,
|
||||
Zone: zone,
|
||||
Role: role,
|
||||
Status: "new",
|
||||
})
|
||||
return nil // do not descend into nested repos from a git root
|
||||
}
|
||||
dents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, d := range dents {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := d.Name()
|
||||
if name == ".git" || name == "node_modules" || name == "vendor" || name == ".venv" || name == "dist" {
|
||||
continue
|
||||
}
|
||||
_ = walkGitRoots(root, filepath.Join(dir, name), depth+1, maxDepth, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func gitRemoteOrigin(dir string) string {
|
||||
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
|
||||
b, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func classifyProjectName(name, remote string) (role, zone string) {
|
||||
lower := strings.ToLower(name)
|
||||
remoteL := strings.ToLower(remote)
|
||||
switch {
|
||||
case strings.Contains(lower, "experiment") || strings.HasPrefix(lower, "test"):
|
||||
return "experiment", "cold"
|
||||
case lower == "mama" || lower == "personal" || strings.Contains(lower, "private"):
|
||||
return "personal", "private"
|
||||
case strings.Contains(remoteL, "git.produktor.io") || strings.Contains(remoteL, "github.com/eslider"):
|
||||
return "work", "hot"
|
||||
case strings.Contains(lower, "produktor") || strings.Contains(lower, "eslider") ||
|
||||
strings.Contains(lower, "asesoria") || strings.Contains(lower, "dyvenia") ||
|
||||
strings.Contains(lower, "onlyoffice"):
|
||||
return "work", "warm"
|
||||
default:
|
||||
return "unknown", "warm"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var emailRE = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
|
||||
|
||||
// noisyEmail reports addresses that should not become CRM catalog persons.
|
||||
func noisyEmail(email string) bool {
|
||||
e := NormalizeEmail(email)
|
||||
if e == "" || !strings.Contains(e, "@") {
|
||||
return true
|
||||
}
|
||||
local, domain, ok := strings.Cut(e, "@")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
noiseLocal := []string{
|
||||
"noreply", "no-reply", "donotreply", "mailer-daemon", "postmaster",
|
||||
"bounce", "notifications", "newsletter", "unsubscribe",
|
||||
"root", "admin", "abuse", "webmaster", "hostmaster",
|
||||
}
|
||||
for _, p := range noiseLocal {
|
||||
if local == p || strings.Contains(local, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// role / shared mailboxes (keep human dotted names)
|
||||
roleExact := map[string]struct{}{
|
||||
"info": {}, "alle": {}, "all": {}, "office": {}, "office@": {},
|
||||
"wartung": {}, "starface": {}, "gitlab": {}, "pl": {}, "gf": {},
|
||||
"umsetzung": {}, "support": {}, "sales": {}, "billing": {},
|
||||
}
|
||||
if _, ok := roleExact[local]; ok {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(local, "-request") || strings.HasSuffix(local, "-owner") {
|
||||
return true
|
||||
}
|
||||
noiseDomain := []string{
|
||||
"marketplace.amazon.", "reply.github.com", "users.noreply.github.com",
|
||||
"groups.facebook.com", "googlegroups.com", "yahoogroups.",
|
||||
"email.apple.com", "amazonses.com", "mailchimp.com",
|
||||
"sendgrid.net", "mailgun.org", "postmarkapp.com",
|
||||
"property.booking.com", "transvascular.com",
|
||||
}
|
||||
for _, p := range noiseDomain {
|
||||
if strings.Contains(domain, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// random marketplace / tracking locals
|
||||
if len(local) >= 16 && !strings.Contains(local, ".") && !strings.Contains(local, "-") {
|
||||
if strings.Contains(domain, "amazon.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseMABEmails(path string) ([]Entry, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := emailRE.FindAllString(string(b), -1)
|
||||
var out []Entry
|
||||
for _, raw := range found {
|
||||
em := NormalizeEmail(raw)
|
||||
if noisyEmail(em) {
|
||||
continue
|
||||
}
|
||||
org, zone, role := classifyMailIdentity("", em)
|
||||
out = append(out, Entry{
|
||||
ID: EntryID("person", em, ""),
|
||||
Kind: "person",
|
||||
Emails: []string{em},
|
||||
Org: org,
|
||||
Sources: []string{path},
|
||||
Zone: zone,
|
||||
Role: role,
|
||||
Approve: false,
|
||||
Status: "new",
|
||||
Notes: "thunderbird_mab",
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseGlodaContacts(dbPath string) ([]Entry, error) {
|
||||
// read-only URI; immutable=1 helps when WAL/shm are missing
|
||||
dsn := "file:" + dbPath + "?mode=ro&_pragma=query_only(1)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(c.name, ''), i.value
|
||||
FROM identities i
|
||||
LEFT JOIN contacts c ON c.id = i.contactID
|
||||
WHERE lower(i.kind) = 'email' AND i.value IS NOT NULL AND i.value != ''
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Entry
|
||||
for rows.Next() {
|
||||
var name, value string
|
||||
if err := rows.Scan(&name, &value); err != nil {
|
||||
return out, err
|
||||
}
|
||||
em := NormalizeEmail(value)
|
||||
// Gloda sometimes stores "Name <email>" in value
|
||||
if m := emailRE.FindString(em); m != "" {
|
||||
em = NormalizeEmail(m)
|
||||
}
|
||||
if noisyEmail(em) {
|
||||
continue
|
||||
}
|
||||
display := strings.TrimSpace(name)
|
||||
// strip wrapping quotes / email leftovers
|
||||
display = strings.Trim(display, `"'`)
|
||||
if idx := strings.Index(display, "<"); idx > 0 {
|
||||
display = strings.TrimSpace(display[:idx])
|
||||
}
|
||||
first, last := "", ""
|
||||
if display != "" {
|
||||
first, last = SplitDisplayName(display)
|
||||
}
|
||||
org, zone, role := classifyMailIdentity(display, em)
|
||||
out = append(out, Entry{
|
||||
ID: EntryID("person", em, display),
|
||||
Kind: "person",
|
||||
Name: display,
|
||||
First: first,
|
||||
Last: last,
|
||||
Emails: []string{em},
|
||||
Org: org,
|
||||
Sources: []string{dbPath},
|
||||
Zone: zone,
|
||||
Role: role,
|
||||
Approve: false,
|
||||
Status: "new",
|
||||
Notes: "thunderbird_gloda",
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func classifyMailIdentity(name, email string) (org, zone, role string) {
|
||||
em := NormalizeEmail(email)
|
||||
_, domain, _ := strings.Cut(em, "@")
|
||||
switch {
|
||||
case domain == "wheregroup.com" || strings.Contains(strings.ToLower(name), "wheregroup"):
|
||||
return "WhereGroup", "warm", "work"
|
||||
case domain == "produktor.io" || domain == "eslider.de" || strings.HasSuffix(domain, ".produktor.io"):
|
||||
return "produktor.io", "hot", "work"
|
||||
case domain == "dyvenia.com":
|
||||
return "Dyvenia", "warm", "work"
|
||||
case domain == "immowelt.de" || domain == "immowelt.com":
|
||||
return "Immowelt", "warm", "work"
|
||||
case strings.HasSuffix(domain, ".de") && looksPublicSector(domain):
|
||||
return domain, "warm", "work"
|
||||
default:
|
||||
return "", "private", "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func looksPublicSector(domain string) bool {
|
||||
d := strings.ToLower(domain)
|
||||
hints := []string{
|
||||
"stadt-", "stadt.", "kreis-", "gemeinde", "landkreis",
|
||||
"bund.", "bundes", "lvermgeo", "rvr.", "eba.",
|
||||
}
|
||||
for _, h := range hints {
|
||||
if strings.Contains(d, h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestNoisyEmail(t *testing.T) {
|
||||
if !noisyEmail("noreply@example.com") {
|
||||
t.Fatal("expected noisy")
|
||||
}
|
||||
if !noisyEmail("x@marketplace.amazon.de") {
|
||||
t.Fatal("amazon marketplace")
|
||||
}
|
||||
if noisyEmail("andriy.oblivantsev@wheregroup.com") {
|
||||
t.Fatal("should keep wheregroup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMABEmails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "abook.mab")
|
||||
body := `// mork junk
|
||||
PrimaryEmail=andriy.oblivantsev@wheregroup.com
|
||||
noreply@github.com
|
||||
axel.schaefer@wheregroup.com
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := parseMABEmails(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 2 {
|
||||
t.Fatalf("got %d: %+v", len(ents), ents)
|
||||
}
|
||||
for _, e := range ents {
|
||||
if e.Org != "WhereGroup" || e.Role != "work" {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlodaContacts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "global-messages-db.sqlite")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE contacts (id INTEGER PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE identities (id INTEGER PRIMARY KEY, contactID INTEGER, kind TEXT, value TEXT);
|
||||
INSERT INTO contacts VALUES (1, 'Axel Schaefer');
|
||||
INSERT INTO identities VALUES (1, 1, 'email', 'axel.schaefer@wheregroup.com');
|
||||
INSERT INTO contacts VALUES (2, 'Noise Bot');
|
||||
INSERT INTO identities VALUES (2, 2, 'email', 'noreply@example.com');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
ents, err := parseGlodaContacts(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 1 {
|
||||
t.Fatalf("got %d %+v", len(ents), ents)
|
||||
}
|
||||
if ents[0].First != "Axel" || ents[0].Emails[0] != "axel.schaefer@wheregroup.com" {
|
||||
t.Fatalf("%+v", ents[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanThunderbirdRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
prof := filepath.Join(root, "profile")
|
||||
if err := os.MkdirAll(prof, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(prof, "abook.mab"), []byte("mail=paul.schmidt@wheregroup.com\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := ScanThunderbirdRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Entries) < 1 {
|
||||
t.Fatal(doc.Entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package catalog builds a YAML inventory of CRM persons/companies from
|
||||
// filesystem contacts (VCF, folders) and project trees, then matches/applies
|
||||
// against OnlyOffice CRM.
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Entry is one catalog row (person or company).
|
||||
type Entry struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // person | company
|
||||
Name string `yaml:"name,omitempty"`
|
||||
First string `yaml:"first,omitempty"`
|
||||
Last string `yaml:"last,omitempty"`
|
||||
Emails []string `yaml:"emails,omitempty"`
|
||||
Phones []string `yaml:"phones,omitempty"`
|
||||
Org string `yaml:"org,omitempty"`
|
||||
Sources []string `yaml:"sources,omitempty"`
|
||||
Zone string `yaml:"zone"`
|
||||
Role string `yaml:"role"`
|
||||
OOID string `yaml:"oo_id,omitempty"`
|
||||
Approve bool `yaml:"approve"`
|
||||
Status string `yaml:"status,omitempty"` // new | exists | conflict | applied | skipped
|
||||
Notes string `yaml:"notes,omitempty"`
|
||||
Remote string `yaml:"remote,omitempty"`
|
||||
GitRoot string `yaml:"git_root,omitempty"`
|
||||
}
|
||||
|
||||
// Document is the on-disk catalog file.
|
||||
type Document struct {
|
||||
GeneratedAt string `yaml:"generated_at"`
|
||||
Entries []Entry `yaml:"entries"`
|
||||
}
|
||||
|
||||
// LoadYAML reads a catalog document from path.
|
||||
func LoadYAML(path string) (*Document, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc Document
|
||||
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// SaveYAML writes the catalog document.
|
||||
func SaveYAML(path string, doc *Document) error {
|
||||
doc.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
b, err := yaml.Marshal(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
// NormalizeEmail lowercases and trims.
|
||||
func NormalizeEmail(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// NormalizeName collapses whitespace and lowercases for matching.
|
||||
func NormalizeName(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:], " ")
|
||||
}
|
||||
|
||||
// EntryID builds a stable-ish id from kind + email or name.
|
||||
func EntryID(kind, email, name string) string {
|
||||
if e := NormalizeEmail(email); e != "" {
|
||||
return fmt.Sprintf("%s:%s", kind, e)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", kind, NormalizeName(name))
|
||||
}
|
||||
|
||||
// MergeDocs unions entries by id; later sources append sources[] and fill empties.
|
||||
func MergeDocs(docs ...*Document) *Document {
|
||||
byID := map[string]*Entry{}
|
||||
order := []string{}
|
||||
for _, d := range docs {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
for i := range d.Entries {
|
||||
e := d.Entries[i]
|
||||
id := e.ID
|
||||
if id == "" {
|
||||
primary := ""
|
||||
if len(e.Emails) > 0 {
|
||||
primary = e.Emails[0]
|
||||
}
|
||||
name := e.Name
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(e.First + " " + e.Last)
|
||||
}
|
||||
id = EntryID(e.Kind, primary, name)
|
||||
e.ID = id
|
||||
}
|
||||
if prev, ok := byID[id]; ok {
|
||||
mergeEntry(prev, &e)
|
||||
} else {
|
||||
cp := e
|
||||
byID[id] = &cp
|
||||
order = append(order, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := &Document{Entries: make([]Entry, 0, len(order))}
|
||||
for _, id := range order {
|
||||
out.Entries = append(out.Entries, *byID[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeEntry(dst, src *Entry) {
|
||||
dst.Sources = uniqueStrings(append(dst.Sources, src.Sources...))
|
||||
dst.Emails = uniqueEmails(append(dst.Emails, src.Emails...))
|
||||
dst.Phones = uniqueStrings(append(dst.Phones, src.Phones...))
|
||||
if dst.First == "" {
|
||||
dst.First = src.First
|
||||
}
|
||||
if dst.Last == "" {
|
||||
dst.Last = src.Last
|
||||
}
|
||||
if dst.Name == "" {
|
||||
dst.Name = src.Name
|
||||
}
|
||||
if dst.Org == "" {
|
||||
dst.Org = src.Org
|
||||
}
|
||||
if dst.Remote == "" {
|
||||
dst.Remote = src.Remote
|
||||
}
|
||||
if dst.GitRoot == "" {
|
||||
dst.GitRoot = src.GitRoot
|
||||
}
|
||||
if dst.Notes == "" {
|
||||
dst.Notes = src.Notes
|
||||
}
|
||||
// Prefer more specific zone/role from project scans over default private.
|
||||
if dst.Zone == "private" && src.Zone != "" && src.Zone != "private" {
|
||||
dst.Zone = src.Zone
|
||||
}
|
||||
if dst.Role == "" || (dst.Role == "unknown" && src.Role != "" && src.Role != "unknown") {
|
||||
dst.Role = src.Role
|
||||
}
|
||||
// Preserve approve/oo_id/status from whichever already set.
|
||||
if !dst.Approve && src.Approve {
|
||||
dst.Approve = true
|
||||
}
|
||||
if dst.OOID == "" {
|
||||
dst.OOID = src.OOID
|
||||
}
|
||||
if dst.Status == "" {
|
||||
dst.Status = src.Status
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueEmails(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, e := range in {
|
||||
e = NormalizeEmail(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[e]; ok {
|
||||
continue
|
||||
}
|
||||
seen[e] = struct{}{}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-vcard"
|
||||
)
|
||||
|
||||
// ParseVCFFile extracts person candidates from a vCard file (may contain many cards).
|
||||
func ParseVCFFile(path string) ([]Entry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
dec := vcard.NewDecoder(f)
|
||||
var out []Entry
|
||||
for {
|
||||
card, err := dec.Decode()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return out, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
e := entryFromCard(card, path)
|
||||
if e.Name == "" && e.First == "" && len(e.Emails) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func entryFromCard(card vcard.Card, source string) Entry {
|
||||
fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName))
|
||||
n := card.Name()
|
||||
first, last := "", ""
|
||||
if n != nil {
|
||||
first = strings.TrimSpace(n.GivenName)
|
||||
last = strings.TrimSpace(n.FamilyName)
|
||||
}
|
||||
if fn == "" {
|
||||
fn = strings.TrimSpace(first + " " + last)
|
||||
}
|
||||
if first == "" && last == "" && fn != "" {
|
||||
first, last = SplitDisplayName(fn)
|
||||
}
|
||||
var emails, phones []string
|
||||
for _, v := range card.Values(vcard.FieldEmail) {
|
||||
if e := NormalizeEmail(v); e != "" {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
}
|
||||
for _, v := range card.Values(vcard.FieldTelephone) {
|
||||
p := strings.TrimSpace(v)
|
||||
if p != "" {
|
||||
phones = append(phones, p)
|
||||
}
|
||||
}
|
||||
org := strings.TrimSpace(card.PreferredValue(vcard.FieldOrganization))
|
||||
primary := ""
|
||||
if len(emails) > 0 {
|
||||
primary = emails[0]
|
||||
}
|
||||
return Entry{
|
||||
ID: EntryID("person", primary, fn),
|
||||
Kind: "person",
|
||||
Name: fn,
|
||||
First: first,
|
||||
Last: last,
|
||||
Emails: uniqueEmails(emails),
|
||||
Phones: uniqueStrings(phones),
|
||||
Org: org,
|
||||
Sources: []string{source},
|
||||
Zone: "private",
|
||||
Role: "unknown",
|
||||
Approve: false,
|
||||
Status: "new",
|
||||
}
|
||||
}
|
||||
|
||||
// ScanContactsRoot walks a contacts directory: *.vcf anywhere, person-named
|
||||
// subdirs, and *@*.txt email hint files.
|
||||
func ScanContactsRoot(root string) (*Document, error) {
|
||||
root = filepath.Clean(root)
|
||||
st, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return nil, fmt.Errorf("not a directory: %s", root)
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
seenVCF := map[string]struct{}{}
|
||||
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable
|
||||
}
|
||||
if d.IsDir() {
|
||||
base := d.Name()
|
||||
if base == ".git" || base == "node_modules" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ".vcf") {
|
||||
if _, ok := seenVCF[path]; ok {
|
||||
return nil
|
||||
}
|
||||
seenVCF[path] = struct{}{}
|
||||
parsed, perr := ParseVCFFile(path)
|
||||
if perr != nil {
|
||||
entries = append(entries, Entry{
|
||||
ID: EntryID("person", "", filepath.Base(path)),
|
||||
Kind: "person",
|
||||
Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
|
||||
Sources: []string{path},
|
||||
Zone: "private",
|
||||
Role: "unknown",
|
||||
Notes: "vcf_parse_error: " + perr.Error(),
|
||||
Status: "new",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, parsed...)
|
||||
return nil
|
||||
}
|
||||
// email hint files: name contains @ and ends with .txt
|
||||
if strings.Contains(name, "@") && strings.HasSuffix(lower, ".txt") {
|
||||
email := NormalizeEmail(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||
if !strings.Contains(email, "@") {
|
||||
return nil
|
||||
}
|
||||
parent := filepath.Base(filepath.Dir(path))
|
||||
first, last := SplitDisplayName(parent)
|
||||
entries = append(entries, Entry{
|
||||
ID: EntryID("person", email, parent),
|
||||
Kind: "person",
|
||||
Name: parent,
|
||||
First: first,
|
||||
Last: last,
|
||||
Emails: []string{email},
|
||||
Sources: []string{path},
|
||||
Zone: "private",
|
||||
Role: "unknown",
|
||||
Status: "new",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Top-level person folders without VCF still become stub persons.
|
||||
dents, _ := os.ReadDir(root)
|
||||
for _, d := range dents {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := d.Name()
|
||||
if name == "Contacts VCF's" || strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
// skip obvious non-person dumps
|
||||
lower := strings.ToLower(name)
|
||||
if strings.Contains(lower, "vcf") {
|
||||
continue
|
||||
}
|
||||
first, last := SplitDisplayName(name)
|
||||
id := EntryID("person", "", name)
|
||||
entries = append(entries, Entry{
|
||||
ID: id,
|
||||
Kind: "person",
|
||||
Name: name,
|
||||
First: first,
|
||||
Last: last,
|
||||
Sources: []string{filepath.Join(root, name)},
|
||||
Zone: "private",
|
||||
Role: "unknown",
|
||||
Status: "new",
|
||||
Notes: "folder_stub",
|
||||
})
|
||||
}
|
||||
|
||||
doc := MergeDocs(&Document{Entries: entries})
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// ReadEmailsFromTxt reads loose emails from a text file (one per line or free text).
|
||||
func ReadEmailsFromTxt(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var out []string
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if strings.Contains(line, "@") && !strings.Contains(line, " ") {
|
||||
out = append(out, NormalizeEmail(line))
|
||||
}
|
||||
}
|
||||
return uniqueEmails(out), sc.Err()
|
||||
}
|
||||
@@ -11,8 +11,10 @@ package onlyoffice
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Client of OnlyOffice API uses credentials to get a token and query the API
|
||||
@@ -31,12 +33,16 @@ type Client struct {
|
||||
defaults Defaults // optional fallbacks for calendar/project IDs
|
||||
selfID string // cached /api/2.0/people/@self id
|
||||
noteCatID int // cached CRM history category id for "note"
|
||||
|
||||
folderTitles map[string]string // cached Documents folder id -> title (F9)
|
||||
folderTitlesMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewClient returns a new Client backed by http.DefaultClient.
|
||||
func NewClient(c Credentials) *Client {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Client{
|
||||
client: http.DefaultClient,
|
||||
client: &http.Client{Jar: jar},
|
||||
credentials: &c,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,23 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
||||
|
||||
start := Time(time.Now().AddDate(0, 0, -2))
|
||||
deadline := Time(time.Now().AddDate(0, 0, 2))
|
||||
ms, err := c.CreateMilestone(NewMilestoneRequest{
|
||||
ProjectID: *project.ID,
|
||||
Title: "integration milestone",
|
||||
Deadline: deadline,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMilestone: %v", err)
|
||||
}
|
||||
if ms == nil || ms.ID == nil {
|
||||
t.Fatal("created milestone without id")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := c.DeleteMilestone(*ms.ID); err != nil {
|
||||
t.Logf("cleanup DeleteMilestone: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
task, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||
ProjectId: *project.ID,
|
||||
Title: "integration parent task",
|
||||
@@ -146,6 +163,7 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
||||
StartDate: start,
|
||||
Deadline: deadline,
|
||||
Priority: int(TaskPriorityNormal),
|
||||
MilestoneId: int(*ms.ID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProjectTask: %v", err)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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 <FILE_ID> <LOCAL_XLSX>
|
||||
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 <FILE_ID> <LOCAL_XLSX>")
|
||||
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-1591-2025-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)
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
// 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 <IN_XLSX> <INDEX_TSV> <OUT_XLSX>
|
||||
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
|
||||
}
|
||||
|
||||
const linkPrefix = "https://office.pro-dukt.de/Products/Files/DocEditor.aspx?fileid="
|
||||
|
||||
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 <IN_XLSX> <INDEX_TSV> <OUT_XLSX>")
|
||||
os.Exit(2)
|
||||
}
|
||||
in, idxPath, out := os.Args[1], os.Args[2], os.Args[3]
|
||||
|
||||
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, linkPrefix+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
|
||||
}
|
||||
+16
-33
@@ -17,6 +17,18 @@ const MailListPageSize = 25
|
||||
// Loader fetches list items for a menu subject using the OnlyOffice client.
|
||||
type Loader struct {
|
||||
Client *onlyoffice.Client
|
||||
|
||||
// Files is the backend-agnostic file store used for file download, preview
|
||||
// and delete. When nil it falls back to Client.FileStore(ProviderREST).
|
||||
Files onlyoffice.FileStore
|
||||
}
|
||||
|
||||
// fileStore returns the configured file store, defaulting to REST.
|
||||
func (l *Loader) fileStore() onlyoffice.FileStore {
|
||||
if l.Files != nil {
|
||||
return l.Files
|
||||
}
|
||||
return l.Client.FileStore(onlyoffice.ProviderREST)
|
||||
}
|
||||
|
||||
// List returns items for the given list spec (nav leaf).
|
||||
@@ -101,6 +113,8 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
|
||||
return l.Client.GetTaskByID(ctx, item.ID)
|
||||
case model.KindProject:
|
||||
return l.Client.GetProjectByID(ctx, item.ID)
|
||||
case model.KindUser:
|
||||
return l.Client.GetUser(ctx, item.ID)
|
||||
default:
|
||||
if item.Raw != nil {
|
||||
return item.Raw, nil
|
||||
@@ -169,11 +183,7 @@ func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, er
|
||||
}
|
||||
return fmt.Sprintf("Deleted message %s", item.Title), nil
|
||||
case model.KindFile:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
|
||||
if err := l.fileStore().Delete(ctx, []string{item.ID}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted file %s", item.Title), nil
|
||||
@@ -197,7 +207,7 @@ func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
|
||||
if _, err := l.fileStore().Download(ctx, item.ID, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Downloaded to %s", destPath), nil
|
||||
@@ -362,33 +372,6 @@ func mailFolderForSubject(subject model.Subject) (int, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]model.Item, len(users))
|
||||
for i, u := range users {
|
||||
id := ""
|
||||
if u.ID != nil {
|
||||
id = fmt.Sprint(*u.ID)
|
||||
}
|
||||
title := ""
|
||||
if u.DisplayName != nil {
|
||||
title = *u.DisplayName
|
||||
}
|
||||
email := ""
|
||||
if u.Email != nil {
|
||||
email = *u.Email
|
||||
}
|
||||
items[i] = model.Item{
|
||||
ID: id, Title: title, Subtitle: email, Kind: model.KindUser,
|
||||
Raw: map[string]any{"id": id, "displayName": title, "email": email},
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||
resp, err := l.Client.GetProjectFiles(ctx, projectID)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
@@ -30,13 +29,11 @@ func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (stri
|
||||
return "", fmt.Errorf("file id missing")
|
||||
}
|
||||
name := item.Title
|
||||
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
|
||||
if t := onlyoffice.FileEntryTitle(meta); t != "" {
|
||||
name = t
|
||||
}
|
||||
if e, err := l.fileStore().Stat(ctx, item.ID); err == nil && e.Title != "" {
|
||||
name = e.Title
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
|
||||
if _, err := l.fileStore().Download(ctx, item.ID, &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
||||
|
||||
@@ -16,9 +16,11 @@ func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.For
|
||||
}
|
||||
switch item.Kind {
|
||||
case model.KindTask:
|
||||
return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary)
|
||||
return l.UpdateTask(ctx, item.ID, fields)
|
||||
case model.KindProject:
|
||||
return l.saveProject(ctx, item.ID, fields)
|
||||
case model.KindUser:
|
||||
return l.SaveUser(ctx, item.ID, fields)
|
||||
default:
|
||||
return fmt.Errorf("save not supported for %s", item.Kind)
|
||||
}
|
||||
@@ -60,5 +62,13 @@ func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFie
|
||||
if err != nil {
|
||||
return model.FormFields{}, err
|
||||
}
|
||||
return model.FormFieldsFromRaw(item.Kind, raw), nil
|
||||
fields := model.FormFieldsFromRaw(item.Kind, raw)
|
||||
if item.Kind == model.KindTask {
|
||||
choices, uerr := l.LoadUserChoices(ctx)
|
||||
if uerr != nil {
|
||||
return model.FormFields{}, uerr
|
||||
}
|
||||
fields.UserChoices = choices
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
+32
-15
@@ -9,8 +9,18 @@ import (
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// UpdateTask saves title and description for a project task.
|
||||
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error {
|
||||
// UpdateTask saves editable task fields via the typed JSON API.
|
||||
func (l *Loader) UpdateTask(ctx context.Context, taskID string, fields model.FormFields) error {
|
||||
return l.updateTask(ctx, taskID, fields, false)
|
||||
}
|
||||
|
||||
// CloseTask saves fields and sets status to closed.
|
||||
func (l *Loader) CloseTask(ctx context.Context, taskID string, fields model.FormFields) error {
|
||||
fields.TaskStatus = model.TaskLifecycleClosed
|
||||
return l.updateTask(ctx, taskID, fields, true)
|
||||
}
|
||||
|
||||
func (l *Loader) updateTask(ctx context.Context, taskID string, fields model.FormFields, closing bool) error {
|
||||
if l == nil || l.Client == nil {
|
||||
return fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
@@ -18,19 +28,26 @@ func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description stri
|
||||
if err != nil {
|
||||
return fmt.Errorf("task id %q: %w", taskID, err)
|
||||
}
|
||||
_, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
|
||||
status := onlyoffice.ProjectTaskStatus(fields.TaskStatus)
|
||||
if closing {
|
||||
status = onlyoffice.ProjectTaskStatusClosed
|
||||
}
|
||||
req := onlyoffice.ProjectTaskUpdateRequest{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Description: description,
|
||||
})
|
||||
Title: fields.Primary,
|
||||
Description: fields.Secondary,
|
||||
Status: status,
|
||||
}
|
||||
if fields.ResponsibleID != "" {
|
||||
req.Responsible = []string{fields.ResponsibleID}
|
||||
} else if !closing {
|
||||
raw, derr := l.Detail(ctx, model.Item{ID: taskID, Kind: model.KindTask})
|
||||
if derr == nil {
|
||||
if rid := model.TaskResponsibleIDFromRaw(raw); rid != "" {
|
||||
req.Responsible = []string{rid}
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = l.Client.UpdateProjectTask(req)
|
||||
return err
|
||||
}
|
||||
|
||||
// TaskFields loads title and description for a project task item.
|
||||
func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) {
|
||||
fields, err := l.DetailForm(ctx, item)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return fields.Primary, fields.Secondary, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package fetch_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
@@ -20,35 +19,32 @@ func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
|
||||
t.Skip("no tasks")
|
||||
}
|
||||
item := items[0]
|
||||
title, desc, err := loader.TaskFields(ctx, item)
|
||||
fields, err := loader.DetailForm(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoreTitle, restoreDesc := title, desc
|
||||
restore := fields
|
||||
t.Cleanup(func() {
|
||||
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc)
|
||||
_ = loader.UpdateTask(context.Background(), item.ID, restore)
|
||||
})
|
||||
newTitle := title + " (office TUI test)"
|
||||
newDesc := desc + "\n\n_edited by office integration test_"
|
||||
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil {
|
||||
fields.Primary = fields.Primary + " (office TUI test)"
|
||||
fields.Secondary = fields.Secondary + "\n\n_edited by office integration test_"
|
||||
if err := loader.UpdateTask(ctx, item.ID, fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotTitle, gotDesc, err := loader.TaskFields(ctx, item)
|
||||
got, err := loader.DetailForm(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTitle != newTitle {
|
||||
t.Fatalf("title: got %q want %q", gotTitle, newTitle)
|
||||
if got.Primary != fields.Primary {
|
||||
t.Fatalf("title: got %q want %q", got.Primary, fields.Primary)
|
||||
}
|
||||
if gotDesc != newDesc {
|
||||
if got.Secondary != fields.Secondary {
|
||||
t.Fatalf("description mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationTaskFieldsFromLiveAPI(t *testing.T) {
|
||||
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
||||
t.Skip("ONLYOFFICE_URL not set")
|
||||
}
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
|
||||
if err != nil {
|
||||
@@ -57,12 +53,12 @@ func TestIntegrationTaskFieldsFromLiveAPI(t *testing.T) {
|
||||
if len(items) == 0 {
|
||||
t.Skip("no tasks")
|
||||
}
|
||||
title, desc, err := loader.TaskFields(ctx, items[0])
|
||||
fields, err := loader.DetailForm(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title == "" {
|
||||
if fields.Primary == "" {
|
||||
t.Fatal("empty title")
|
||||
}
|
||||
_ = desc
|
||||
_ = fields.Secondary
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]model.Item, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
raw := userToRaw(u)
|
||||
id := strMap(raw, "id")
|
||||
userName := strMap(raw, "userName")
|
||||
if userName == "" {
|
||||
userName = strMap(raw, "email")
|
||||
}
|
||||
items = append(items, model.Item{
|
||||
ID: id,
|
||||
Title: userName,
|
||||
Kind: model.KindUser,
|
||||
Raw: raw,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func userToRaw(u *onlyoffice.User) map[string]any {
|
||||
b, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func strMap(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
if m[key] == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(m[key])
|
||||
}
|
||||
|
||||
func userProfileUpdateBody(isAdmin bool, modules []string) map[string]any {
|
||||
body := map[string]any{
|
||||
"isAdmin": isAdmin,
|
||||
}
|
||||
if !isAdmin {
|
||||
body["listAdminModules"] = modules
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// SaveUser persists user account settings from the detail form.
|
||||
func (l *Loader) SaveUser(ctx context.Context, userID string, fields model.FormFields) error {
|
||||
if l == nil || l.Client == nil {
|
||||
return fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
raw, err := l.Client.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wasEnabled := model.UserIsEnabled(raw)
|
||||
|
||||
if fields.UserEnabled && !wasEnabled {
|
||||
if err := l.Client.ChangeUserStatus(ctx, userID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin, modules := fields.UserACL.APIPayload()
|
||||
body := userProfileUpdateBody(isAdmin, modules)
|
||||
if _, err := l.updateUserProfile(ctx, userID, body, fields.UserEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !fields.UserEnabled && wasEnabled {
|
||||
if err := l.Client.ChangeUserStatus(ctx, userID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if fields.UserPassword != "" {
|
||||
if err := l.Client.ChangeUserPassword(ctx, userID, fields.UserPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Loader) updateUserProfile(ctx context.Context, userID string, body map[string]any, wantEnabled bool) (map[string]any, error) {
|
||||
out, err := l.Client.UpdateUser(ctx, userID, body)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if wantEnabled && isSuspendedUserError(err) {
|
||||
if actErr := l.Client.ChangeUserStatus(ctx, userID, true); actErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.Client.UpdateUser(ctx, userID, body)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func isSuspendedUserError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "suspended") || strings.Contains(msg, "terminated")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserProfileUpdateBodyOmitsStatus(t *testing.T) {
|
||||
body := userProfileUpdateBody(true, nil)
|
||||
if _, ok := body["status"]; ok {
|
||||
t.Fatal("profile update must not include status")
|
||||
}
|
||||
if body["isAdmin"] != true {
|
||||
t.Fatal("expected isAdmin true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserProfileUpdateBodyPartialAdminModules(t *testing.T) {
|
||||
body := userProfileUpdateBody(false, []string{"documents", "crm"})
|
||||
if body["isAdmin"] != false {
|
||||
t.Fatal("expected partial admin")
|
||||
}
|
||||
mods, ok := body["listAdminModules"].([]string)
|
||||
if !ok || len(mods) != 2 {
|
||||
t.Fatalf("modules=%v", body["listAdminModules"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSuspendedUserError(t *testing.T) {
|
||||
err := fmt.Errorf(`PUT JSON /api/2.0/people/x: 500 {"error":{"message":"The user is suspended"}}`)
|
||||
if !isSuspendedUserError(err) {
|
||||
t.Fatal("expected suspended detection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// LoadUserChoices returns portal users for responsible pickers.
|
||||
func (l *Loader) LoadUserChoices(ctx context.Context) ([]model.UserOption, error) {
|
||||
if l == nil || l.Client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.UserOption, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil || u.ID == nil || *u.ID == "" {
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
if u.DisplayName != nil {
|
||||
name = *u.DisplayName
|
||||
}
|
||||
if name == "" && u.Email != nil {
|
||||
name = *u.Email
|
||||
}
|
||||
out = append(out, model.UserOption{ID: *u.ID, Name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ type ActionID string
|
||||
const (
|
||||
ActionView ActionID = "view"
|
||||
ActionSave ActionID = "save"
|
||||
ActionClose ActionID = "close"
|
||||
ActionDelete ActionID = "delete"
|
||||
ActionRefresh ActionID = "refresh"
|
||||
ActionDownload ActionID = "download"
|
||||
@@ -29,7 +30,7 @@ func ActionsFor(kind Kind) []ItemAction {
|
||||
case KindTask:
|
||||
return []ItemAction{
|
||||
{ID: ActionSave, Label: "Save"},
|
||||
{ID: ActionDelete, Label: "Delete task", Danger: true},
|
||||
{ID: ActionClose, Label: "Close"},
|
||||
}
|
||||
case KindCRMTask:
|
||||
return []ItemAction{
|
||||
@@ -56,7 +57,11 @@ func ActionsFor(kind Kind) []ItemAction {
|
||||
{ID: ActionDownload, Label: "Download"},
|
||||
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
||||
}
|
||||
case KindEvent, KindCalendar, KindUser:
|
||||
case KindUser:
|
||||
return []ItemAction{
|
||||
{ID: ActionSave, Label: "Save"},
|
||||
}
|
||||
case KindEvent, KindCalendar:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
|
||||
+45
-12
@@ -29,7 +29,6 @@ var subjectExtraKeys = map[Subject][]string{
|
||||
SubjectMailDrafts: {"to", "date"},
|
||||
SubjectMailTrash: {"from", "date"},
|
||||
SubjectMailSpam: {"from", "date"},
|
||||
SubjectUsers: {"email", "displayName"},
|
||||
}
|
||||
|
||||
// BuildColumns derives table columns from the list subject and item payloads.
|
||||
@@ -37,6 +36,9 @@ func BuildColumns(subject Subject, items []Item) []TableColumn {
|
||||
if subject == SubjectProjects {
|
||||
return buildProjectColumns(items)
|
||||
}
|
||||
if subject == SubjectUsers {
|
||||
return buildUserColumns(items)
|
||||
}
|
||||
if subject == SubjectTasks {
|
||||
return buildTaskColumns(items)
|
||||
}
|
||||
@@ -102,18 +104,28 @@ func buildTaskColumns(items []Item) []TableColumn {
|
||||
return cols
|
||||
}
|
||||
|
||||
func buildUserColumns(items []Item) []TableColumn {
|
||||
_ = items
|
||||
return []TableColumn{
|
||||
{Key: "_sel", Title: "✓", Width: 3},
|
||||
{Key: "userName", Title: "User", Width: 14},
|
||||
{Key: "registration", Title: "Registered", Width: 12},
|
||||
{Key: "status", Title: "Status", Width: 10},
|
||||
{Key: "email", Title: "Email", Width: 20},
|
||||
}
|
||||
}
|
||||
|
||||
func buildProjectColumns(items []Item) []TableColumn {
|
||||
cols := []TableColumn{
|
||||
_ = items
|
||||
return []TableColumn{
|
||||
{Key: "_sel", Title: "✓", Width: 3},
|
||||
{Key: "id", Title: "ID", Width: 8},
|
||||
{Key: "status", Title: "Status", Width: 8},
|
||||
{Key: "title", Title: "Title", Width: 28},
|
||||
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
|
||||
{Key: "documents", Title: "Documents", Width: 11},
|
||||
{Key: "users", Title: "Users", Width: 8},
|
||||
{Key: "status", Title: "●", Width: 10},
|
||||
{Key: "title", Title: "Title", Width: 16},
|
||||
{Key: "tasks", Title: "Tasks", Width: 11},
|
||||
{Key: "documents", Title: "Docs", Width: 7},
|
||||
{Key: "users", Title: "Users", Width: 7},
|
||||
}
|
||||
sizeColumns(cols, items)
|
||||
return cols
|
||||
}
|
||||
|
||||
// CellText returns the display string for one table cell.
|
||||
@@ -141,7 +153,9 @@ func CellText(it Item, key string) string {
|
||||
case "status":
|
||||
switch it.Kind {
|
||||
case KindProject:
|
||||
return ProjectStatusLabel(it.Raw)
|
||||
return ProjectStatusCell(it.Raw)
|
||||
case KindUser:
|
||||
return UserStatusLabel(it.Raw)
|
||||
case KindTask, KindCRMTask:
|
||||
if it.Raw == nil {
|
||||
return ""
|
||||
@@ -157,10 +171,15 @@ func CellText(it Item, key string) string {
|
||||
if it.Raw == nil {
|
||||
return ""
|
||||
}
|
||||
if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" {
|
||||
// CRM tasks use camelCase deadLine; project tasks use deadline.
|
||||
rawDL := it.Raw["deadline"]
|
||||
if rawDL == nil || formatAny(rawDL) == "" {
|
||||
rawDL = it.Raw["deadLine"]
|
||||
}
|
||||
if rel := FormatRelativeDeadline(rawDL); rel != "" {
|
||||
return rel
|
||||
}
|
||||
return formatAny(it.Raw["deadline"])
|
||||
return formatAny(rawDL)
|
||||
case "responsible":
|
||||
if it.Kind == KindTask || it.Kind == KindCRMTask {
|
||||
return TaskResponsibleLabel(it.Raw)
|
||||
@@ -171,6 +190,20 @@ func CellText(it Item, key string) string {
|
||||
return formatAny(it.Raw["responsible"])
|
||||
case "subtitle":
|
||||
return it.Subtitle
|
||||
case "userName":
|
||||
if it.Raw != nil {
|
||||
if u := strRaw(it.Raw, "userName"); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return it.Title
|
||||
case "registration":
|
||||
return FormatUserRegistration(it.Raw)
|
||||
case "email":
|
||||
if it.Raw != nil {
|
||||
return strRaw(it.Raw, "email")
|
||||
}
|
||||
return it.Subtitle
|
||||
case "tasks":
|
||||
return formatProjectTasks(it.Raw)
|
||||
case "documents":
|
||||
|
||||
@@ -29,13 +29,26 @@ func TestBuildProjectColumns(t *testing.T) {
|
||||
|
||||
func TestCellTextProjectStatus(t *testing.T) {
|
||||
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
|
||||
if got := CellText(open, "status"); got != "Open" {
|
||||
if got := CellText(open, "status"); got != "🟢 Open" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}}
|
||||
if got := CellText(closed, "status"); got != "Closed" {
|
||||
if got := CellText(closed, "status"); got != "🔴 Closed" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
paused := Item{Kind: KindProject, Raw: map[string]any{"status": 1}}
|
||||
if got := CellText(paused, "status"); got != "🟡 Paused" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectColumnsStatusHeaderIsIcon(t *testing.T) {
|
||||
cols := BuildColumns(SubjectProjects, nil)
|
||||
for _, c := range cols {
|
||||
if c.Key == "status" && c.Title != "●" {
|
||||
t.Fatalf("status header=%q want ●", c.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProjectTasksOpenClosed(t *testing.T) {
|
||||
|
||||
@@ -11,7 +11,18 @@ type FormFields struct {
|
||||
ReadOnly bool
|
||||
HasStatus bool
|
||||
Status ProjectLifecycle
|
||||
HasTaskStatus bool
|
||||
TaskStatus TaskLifecycle
|
||||
HasResponsible bool
|
||||
ResponsibleID string
|
||||
UserChoices []UserOption
|
||||
ProjectTitle string
|
||||
TimingSummary string
|
||||
HasUserEdit bool
|
||||
UserEnabled bool
|
||||
UserACL UserACLState
|
||||
GroupsText string
|
||||
UserPassword string
|
||||
}
|
||||
|
||||
// KindHeading returns a short label for the detail pane header.
|
||||
@@ -62,10 +73,13 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
||||
ReadOnly: true,
|
||||
}
|
||||
case KindUser:
|
||||
acl := UserACLFromRaw(raw)
|
||||
return FormFields{
|
||||
PrimaryLabel: "Name", SecondaryLabel: "Email",
|
||||
Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"),
|
||||
ReadOnly: true,
|
||||
HasUserEdit: true,
|
||||
ReadOnly: false,
|
||||
UserEnabled: UserIsEnabled(raw),
|
||||
UserACL: acl,
|
||||
GroupsText: UserGroupsText(raw),
|
||||
}
|
||||
case KindContact:
|
||||
name := strRaw(raw, "displayName")
|
||||
@@ -98,6 +112,20 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
||||
Status: ProjectStatusFromAny(raw["status"]),
|
||||
ResponsibleID: ResponsibleIDFromRaw(raw),
|
||||
}
|
||||
case KindTask:
|
||||
return FormFields{
|
||||
PrimaryLabel: "Title",
|
||||
SecondaryLabel: "Description",
|
||||
Primary: strRaw(raw, "title"),
|
||||
Secondary: strRaw(raw, "description"),
|
||||
ReadOnly: false,
|
||||
HasTaskStatus: true,
|
||||
TaskStatus: TaskStatusFromAny(raw["status"]),
|
||||
HasResponsible: true,
|
||||
ResponsibleID: TaskResponsibleIDFromRaw(raw),
|
||||
ProjectTitle: TaskProjectTitle(raw),
|
||||
TimingSummary: TaskTimingSummary(raw),
|
||||
}
|
||||
default:
|
||||
title := strRaw(raw, "title")
|
||||
if title == "" {
|
||||
@@ -109,11 +137,71 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
||||
return FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: title, Secondary: strRaw(raw, "description"),
|
||||
ReadOnly: kind != KindTask && kind != KindProject,
|
||||
ReadOnly: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TaskResponsibleIDFromRaw returns the first assignee user id from task detail.
|
||||
func TaskResponsibleIDFromRaw(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
if ids, ok := raw["responsibleIds"].([]any); ok && len(ids) > 0 {
|
||||
return strRaw(map[string]any{"id": ids[0]}, "id")
|
||||
}
|
||||
if list, ok := raw["responsibles"].([]any); ok && len(list) > 0 {
|
||||
if m, ok := list[0].(map[string]any); ok {
|
||||
return strRaw(m, "id")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TaskProjectTitle returns the owning project title when present.
|
||||
func TaskProjectTitle(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
if po, ok := raw["projectOwner"].(map[string]any); ok {
|
||||
if t := strRaw(po, "title"); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return strRaw(raw, "projectTitle")
|
||||
}
|
||||
|
||||
// TaskTimingSummary formats start and deadline for the detail form.
|
||||
func TaskTimingSummary(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
start := formatTaskDateLabel(raw["startDate"])
|
||||
deadline := formatTaskDateLabel(raw["deadline"])
|
||||
switch {
|
||||
case start != "" && deadline != "":
|
||||
return start + " → " + deadline
|
||||
case deadline != "":
|
||||
return "Due " + deadline
|
||||
case start != "":
|
||||
return "From " + start
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatTaskDateLabel(v any) string {
|
||||
t, ok := parseDeadlineTime(v)
|
||||
if !ok {
|
||||
s := strRaw(map[string]any{"v": v}, "v")
|
||||
if s == "" || s == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func strRaw(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTaskStatusLifecycleCycle(t *testing.T) {
|
||||
cur := TaskLifecycleOpen
|
||||
cur = cur.Next()
|
||||
if cur != TaskLifecycleClosed {
|
||||
t.Fatalf("got %v", cur)
|
||||
}
|
||||
cur = cur.Prev()
|
||||
if cur != TaskLifecycleOpen {
|
||||
t.Fatalf("got %v", cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormFieldsFromRawTask(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"title": "Fix",
|
||||
"description": "Details",
|
||||
"status": 1,
|
||||
"projectOwner": map[string]any{"title": "Alpha"},
|
||||
"startDate": "2026-06-01T10:00:00Z",
|
||||
"deadline": "2026-06-10T18:00:00Z",
|
||||
"responsibles": []any{
|
||||
map[string]any{"id": "u1", "displayName": "Alice"},
|
||||
},
|
||||
}
|
||||
f := FormFieldsFromRaw(KindTask, raw)
|
||||
if !f.HasTaskStatus || f.TaskStatus != TaskLifecycleOpen {
|
||||
t.Fatalf("status: %+v", f)
|
||||
}
|
||||
if !f.HasResponsible || f.ResponsibleID != "u1" {
|
||||
t.Fatalf("responsible: %+v", f)
|
||||
}
|
||||
if f.ProjectTitle != "Alpha" {
|
||||
t.Fatalf("project: %q", f.ProjectTitle)
|
||||
}
|
||||
if f.TimingSummary == "" {
|
||||
t.Fatal("expected timing summary")
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,7 @@ func DefaultNavTree() *NavTree {
|
||||
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
|
||||
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
|
||||
|
||||
add("users", "Users", "", true, nil)
|
||||
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
|
||||
add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers})
|
||||
|
||||
t.rebuildVisible()
|
||||
return t
|
||||
|
||||
@@ -46,6 +46,27 @@ func ProjectStatusLabel(raw map[string]any) string {
|
||||
return ProjectStatusFromAny(raw["status"]).Label()
|
||||
}
|
||||
|
||||
// ProjectStatusIcon returns the list-cell emoji for a project lifecycle state.
|
||||
func (s ProjectLifecycle) Icon() string {
|
||||
switch s {
|
||||
case ProjectLifecycleClosed:
|
||||
return "🔴"
|
||||
case ProjectLifecyclePaused:
|
||||
return "🟡"
|
||||
default:
|
||||
return "🟢"
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectStatusCell returns emoji and label on one line for project table cells.
|
||||
func ProjectStatusCell(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return ProjectLifecycleOpen.Icon() + " Open"
|
||||
}
|
||||
s := ProjectStatusFromAny(raw["status"])
|
||||
return s.Icon() + " " + s.Label()
|
||||
}
|
||||
|
||||
// ProjectIsOpen is true when the project is not closed.
|
||||
func ProjectIsOpen(raw map[string]any) bool {
|
||||
if raw == nil {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
// TableFlexLayout describes a list table where every column stays visible and
|
||||
// one flex column absorbs leftover pane width.
|
||||
type TableFlexLayout struct {
|
||||
FlexColumnKey string
|
||||
MinFlexWidth int
|
||||
}
|
||||
|
||||
// TableFlexLayoutFor returns a flex layout policy for subjects that show all columns.
|
||||
func TableFlexLayoutFor(subject Subject) (TableFlexLayout, bool) {
|
||||
switch subject {
|
||||
case SubjectProjects:
|
||||
return TableFlexLayout{FlexColumnKey: "title", MinFlexWidth: 12}, true
|
||||
case SubjectUsers:
|
||||
return TableFlexLayout{FlexColumnKey: "email", MinFlexWidth: 16}, true
|
||||
default:
|
||||
return TableFlexLayout{}, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTableFlexLayoutFor(t *testing.T) {
|
||||
proj, ok := TableFlexLayoutFor(SubjectProjects)
|
||||
if !ok || proj.FlexColumnKey != "title" || proj.MinFlexWidth != 12 {
|
||||
t.Fatalf("projects layout=%+v ok=%v", proj, ok)
|
||||
}
|
||||
users, ok := TableFlexLayoutFor(SubjectUsers)
|
||||
if !ok || users.FlexColumnKey != "email" {
|
||||
t.Fatalf("users layout=%+v", users)
|
||||
}
|
||||
_, ok = TableFlexLayoutFor(SubjectTasks)
|
||||
if ok {
|
||||
t.Fatal("tasks should use scrolling layout")
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,9 @@ func personName(v any) string {
|
||||
return ""
|
||||
}
|
||||
if name := strRaw(m, "displayName"); name != "" {
|
||||
if strings.EqualFold(name, "Profile has been removed") {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
first := strRaw(m, "firstName")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package model
|
||||
|
||||
// TaskLifecycle is the OnlyOffice project task status code.
|
||||
type TaskLifecycle int
|
||||
|
||||
const (
|
||||
TaskLifecycleNotAccept TaskLifecycle = 0
|
||||
TaskLifecycleOpen TaskLifecycle = 1
|
||||
TaskLifecycleClosed TaskLifecycle = 2
|
||||
TaskLifecycleDisabled TaskLifecycle = 3
|
||||
TaskLifecycleUnclassified TaskLifecycle = 4
|
||||
TaskLifecycleNotInMilestone TaskLifecycle = 5
|
||||
)
|
||||
|
||||
var taskLifecycleCycle = []TaskLifecycle{
|
||||
TaskLifecycleOpen,
|
||||
TaskLifecycleClosed,
|
||||
TaskLifecycleNotAccept,
|
||||
TaskLifecycleDisabled,
|
||||
TaskLifecycleUnclassified,
|
||||
TaskLifecycleNotInMilestone,
|
||||
}
|
||||
|
||||
// TaskStatusFromAny maps API status to a lifecycle value.
|
||||
func TaskStatusFromAny(v any) TaskLifecycle {
|
||||
if v == nil {
|
||||
return TaskLifecycleOpen
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
switch taskStatusFromString(s) {
|
||||
case "Open":
|
||||
return TaskLifecycleOpen
|
||||
case "Closed":
|
||||
return TaskLifecycleClosed
|
||||
case "Not accepted":
|
||||
return TaskLifecycleNotAccept
|
||||
case "Disabled":
|
||||
return TaskLifecycleDisabled
|
||||
case "Unclassified":
|
||||
return TaskLifecycleUnclassified
|
||||
case "Not in milestone":
|
||||
return TaskLifecycleNotInMilestone
|
||||
}
|
||||
}
|
||||
switch n := intRawVal(map[string]any{"status": v}, "status"); n {
|
||||
case 0:
|
||||
return TaskLifecycleNotAccept
|
||||
case 2:
|
||||
return TaskLifecycleClosed
|
||||
case 3:
|
||||
return TaskLifecycleDisabled
|
||||
case 4:
|
||||
return TaskLifecycleUnclassified
|
||||
case 5:
|
||||
return TaskLifecycleNotInMilestone
|
||||
default:
|
||||
return TaskLifecycleOpen
|
||||
}
|
||||
}
|
||||
|
||||
// Label returns a human-readable status name.
|
||||
func (s TaskLifecycle) Label() string {
|
||||
return TaskStatusLabel(int(s))
|
||||
}
|
||||
|
||||
// Next cycles task status for keyboard toggling.
|
||||
func (s TaskLifecycle) Next() TaskLifecycle {
|
||||
return cycleTaskLifecycle(s, 1)
|
||||
}
|
||||
|
||||
// Prev cycles task status backward.
|
||||
func (s TaskLifecycle) Prev() TaskLifecycle {
|
||||
return cycleTaskLifecycle(s, -1)
|
||||
}
|
||||
|
||||
func cycleTaskLifecycle(cur TaskLifecycle, delta int) TaskLifecycle {
|
||||
idx := 0
|
||||
for i, v := range taskLifecycleCycle {
|
||||
if v == cur {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
n := len(taskLifecycleCycle)
|
||||
idx = (idx + delta%n + n) % n
|
||||
return taskLifecycleCycle[idx]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserACLDef is one administrator permission row in the user editor.
|
||||
type UserACLDef struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
// UserACLDefs lists portal ACL rows (Full access + per-module grants).
|
||||
var UserACLDefs = []UserACLDef{
|
||||
{Key: "full", Label: "Full access"},
|
||||
{Key: "documents", Label: "Documents"},
|
||||
{Key: "projects", Label: "Projects"},
|
||||
{Key: "crm", Label: "CRM"},
|
||||
{Key: "community", Label: "Community"},
|
||||
{Key: "people", Label: "People"},
|
||||
{Key: "sample", Label: "Sample"},
|
||||
{Key: "mail", Label: "Mail"},
|
||||
}
|
||||
|
||||
// UserACLState is the editable ACL snapshot for one user.
|
||||
type UserACLState struct {
|
||||
FullAccess bool
|
||||
Modules map[string]bool
|
||||
}
|
||||
|
||||
// UserACLFromRaw parses API user detail into ACL editor state.
|
||||
func UserACLFromRaw(raw map[string]any) UserACLState {
|
||||
modules := make(map[string]bool, len(UserACLDefs))
|
||||
for _, def := range UserACLDefs[1:] {
|
||||
modules[def.Key] = false
|
||||
}
|
||||
if list, ok := raw["listAdminModules"].([]any); ok {
|
||||
for _, v := range list {
|
||||
key := strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
|
||||
if key != "" {
|
||||
modules[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
full := boolRaw(raw, "isAdmin")
|
||||
if full {
|
||||
for k := range modules {
|
||||
modules[k] = true
|
||||
}
|
||||
}
|
||||
return UserACLState{FullAccess: full, Modules: modules}
|
||||
}
|
||||
|
||||
// APIPayload converts editor state to OnlyOffice update fields.
|
||||
func (s UserACLState) APIPayload() (isAdmin bool, modules []string) {
|
||||
if s.FullAccess {
|
||||
return true, nil
|
||||
}
|
||||
for _, def := range UserACLDefs[1:] {
|
||||
if s.Modules[def.Key] {
|
||||
modules = append(modules, def.Key)
|
||||
}
|
||||
}
|
||||
return false, modules
|
||||
}
|
||||
|
||||
// ACLModuleOn reports whether a module row should show as granted.
|
||||
func (s UserACLState) ACLModuleOn(key string) bool {
|
||||
if key == "full" {
|
||||
return s.FullAccess
|
||||
}
|
||||
if s.FullAccess {
|
||||
return true
|
||||
}
|
||||
return s.Modules[key]
|
||||
}
|
||||
|
||||
func boolRaw(m map[string]any, key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case bool:
|
||||
return v
|
||||
case float64:
|
||||
return v != 0
|
||||
case int:
|
||||
return v != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUserACLFromRawFullAccess(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"isAdmin": true,
|
||||
"listAdminModules": []any{"crm"},
|
||||
}
|
||||
acl := UserACLFromRaw(raw)
|
||||
if !acl.FullAccess {
|
||||
t.Fatal("expected full access")
|
||||
}
|
||||
if !acl.ACLModuleOn("documents") {
|
||||
t.Fatal("full access should show all modules on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserACLAPIPayloadPartial(t *testing.T) {
|
||||
acl := UserACLState{
|
||||
FullAccess: false,
|
||||
Modules: map[string]bool{
|
||||
"documents": true,
|
||||
"projects": true,
|
||||
},
|
||||
}
|
||||
isAdmin, mods := acl.APIPayload()
|
||||
if isAdmin {
|
||||
t.Fatal("expected partial admin")
|
||||
}
|
||||
if len(mods) != 2 {
|
||||
t.Fatalf("mods=%v", mods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIsEnabled(t *testing.T) {
|
||||
if !UserIsEnabled(map[string]any{"status": EmployeeStatusActive}) {
|
||||
t.Fatal("active user should be enabled")
|
||||
}
|
||||
if UserIsEnabled(map[string]any{"status": EmployeeStatusTerminated}) {
|
||||
t.Fatal("terminated user should be disabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
// UserOption is one portal user for responsible pickers.
|
||||
type UserOption struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
EmployeeStatusActive = 1
|
||||
EmployeeStatusTerminated = 2
|
||||
EmployeeStatusPending = 4
|
||||
)
|
||||
|
||||
// UserIsEnabled reports whether the portal user account is active.
|
||||
func UserIsEnabled(raw map[string]any) bool {
|
||||
if raw == nil {
|
||||
return true
|
||||
}
|
||||
if t, ok := raw["terminated"].(bool); ok && t {
|
||||
return false
|
||||
}
|
||||
switch intRawVal(raw, "status") {
|
||||
case EmployeeStatusTerminated:
|
||||
return false
|
||||
case EmployeeStatusPending:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// UserStatusLabel returns a short list-cell label for account state.
|
||||
func UserStatusLabel(raw map[string]any) string {
|
||||
if !UserIsEnabled(raw) {
|
||||
return "Disabled"
|
||||
}
|
||||
if intRawVal(raw, "status") == EmployeeStatusPending {
|
||||
return "Pending"
|
||||
}
|
||||
return "Active"
|
||||
}
|
||||
|
||||
// UserGroupsText formats group membership for the detail form.
|
||||
func UserGroupsText(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return "—"
|
||||
}
|
||||
list, ok := raw["groups"].([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return "—"
|
||||
}
|
||||
names := make([]string, 0, len(list))
|
||||
for _, g := range list {
|
||||
m, ok := g.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := strRaw(m, "name")
|
||||
if name == "" {
|
||||
name = strRaw(m, "title")
|
||||
}
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return stringsJoin(names, ", ")
|
||||
}
|
||||
|
||||
func stringsJoin(parts []string, sep string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
out += sep + parts[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatUserRegistration formats registration or work-from date for list cells.
|
||||
func FormatUserRegistration(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"registrationDate", "workFrom"} {
|
||||
if s := formatUserDate(raw[key]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatUserDate(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", t); err == nil {
|
||||
return parsed.Format("2006-01-02")
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
|
||||
return parsed.Format("2006-01-02")
|
||||
}
|
||||
if len(t) >= 10 {
|
||||
return t[:10]
|
||||
}
|
||||
return t
|
||||
default:
|
||||
s := strRaw(map[string]any{"v": v}, "v")
|
||||
if s == "" || s == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return formatUserDate(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestNavUsersLeafReturnsListSpec(t *testing.T) {
|
||||
tree := model.DefaultNavTree()
|
||||
var found int = -1
|
||||
for i := 0; i < tree.VisibleCount(); i++ {
|
||||
n, ok := tree.NodeAtVisible(i)
|
||||
if ok && n.Label == "Users" {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
t.Fatal("Users node not found")
|
||||
}
|
||||
tree.SetCursor(found)
|
||||
spec, ok := tree.CurrentListSpec()
|
||||
if !ok || spec.Subject != model.SubjectUsers {
|
||||
t.Fatalf("users spec=%v ok=%v", spec, ok)
|
||||
}
|
||||
if tree.IsExpandable(found) {
|
||||
t.Fatal("Users should be a direct leaf, not a branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserColumnsOmitsIDAndDisplayName(t *testing.T) {
|
||||
items := []model.Item{{
|
||||
ID: "1", Title: "jdoe", Kind: model.KindUser,
|
||||
Raw: map[string]any{
|
||||
"id": "1", "userName": "jdoe", "displayName": "John Doe",
|
||||
"email": "j@example.com", "registrationDate": "2024-01-02T00:00:00",
|
||||
},
|
||||
}}
|
||||
cols := model.BuildColumns(model.SubjectUsers, items)
|
||||
want := []string{"_sel", "userName", "registration", "status", "email"}
|
||||
if len(cols) != len(want) {
|
||||
t.Fatalf("got %v", columnKeys(cols))
|
||||
}
|
||||
for i, key := range want {
|
||||
if cols[i].Key != key {
|
||||
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func columnKeys(cols []model.TableColumn) []string {
|
||||
out := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
out[i] = c.Key
|
||||
}
|
||||
return out
|
||||
}
|
||||
+229
-83
@@ -66,12 +66,11 @@ type Model struct {
|
||||
loading bool
|
||||
menuVP viewport.Model
|
||||
listTable DataTable
|
||||
listToolbar ListToolbar
|
||||
detail DetailPane
|
||||
showMenu bool
|
||||
showList bool
|
||||
showDetail bool
|
||||
filterActive bool
|
||||
filterSearch FilterSearch
|
||||
customPaneLayout bool
|
||||
paneSizes PaneWidths
|
||||
resize paneResizeState
|
||||
@@ -86,14 +85,14 @@ func NewModel(client *onlyoffice.Client) Model {
|
||||
nav: model.DefaultNavTree(),
|
||||
selection: model.NewSelection(),
|
||||
focus: model.FocusMenu,
|
||||
status: "Tab: pane · f: filter · row select loads detail · Ctrl+S save · q quit",
|
||||
status: "Tab: pane · / filter · Space select row · 💾🗑 when selected · v: detail · q quit",
|
||||
height: h,
|
||||
width: w,
|
||||
}
|
||||
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
|
||||
m.listTable = newDataTable()
|
||||
m.listToolbar = newListToolbar()
|
||||
m.detail = newDetailPane()
|
||||
m.filterSearch = newFilterSearch()
|
||||
m.showMenu, m.showList, m.showDetail = true, true, true
|
||||
m.menuVP.MouseWheelEnabled = true
|
||||
return m
|
||||
@@ -117,20 +116,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
key := msg.String()
|
||||
if key == "esc" && m.filterActive {
|
||||
m.exitFilter()
|
||||
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
|
||||
m.listToolbar.FocusTable()
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
}
|
||||
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
|
||||
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Query() != "" {
|
||||
m.listToolbar.ClearFilter()
|
||||
m.nav.ClearFilter()
|
||||
m.syncMenuContent()
|
||||
m.syncListTable()
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneFilter {
|
||||
switch key {
|
||||
case "tab", "shift+tab", "backtab":
|
||||
// allow pane switching while filter stays active
|
||||
// allow pane switching
|
||||
default:
|
||||
cmd := m.filterSearch.Update(msg)
|
||||
cmd := m.listToolbar.Update(msg, m.listToolbarMeta())
|
||||
m.applyFilter()
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneActions {
|
||||
meta := m.listToolbarMeta()
|
||||
switch key {
|
||||
case "enter":
|
||||
if act, ok := m.listToolbar.SelectedAction(meta); ok && m.listToolbar.IsActionEnabled(act, meta) {
|
||||
return m, m.runListToolbarAction(act)
|
||||
}
|
||||
return m, nil
|
||||
case "tab", "shift+tab", "backtab":
|
||||
// allow pane switching
|
||||
default:
|
||||
cmd := m.listToolbar.Update(msg, meta)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
if key == "?" {
|
||||
m.status = helpText()
|
||||
return m, nil
|
||||
@@ -138,9 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.scrollFocusedPane(key) {
|
||||
return m, nil
|
||||
}
|
||||
if m.filterActive && m.focus == model.FocusPreview {
|
||||
// filter input owns keys except pane switching handled above
|
||||
} else if m.focus == model.FocusPreview {
|
||||
if m.focus == model.FocusPreview {
|
||||
if cmd, handled := m.handleDetailKey(key, msg); handled {
|
||||
return m, cmd
|
||||
}
|
||||
@@ -155,39 +175,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case ActionNextPane:
|
||||
prev := m.focus
|
||||
m.focus = NextVisibleFocus(m.focus, m.paneVis())
|
||||
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
|
||||
if m.focus == model.FocusPreview && prev != model.FocusPreview {
|
||||
m.detail.FocusFirstStop()
|
||||
}
|
||||
if m.focus == model.FocusList && prev != model.FocusList {
|
||||
m.listToolbar.FocusTable()
|
||||
}
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
case ActionPrevPane:
|
||||
prev := m.focus
|
||||
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
|
||||
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
|
||||
if m.focus == model.FocusPreview && prev != model.FocusPreview {
|
||||
m.detail.FocusFirstStop()
|
||||
}
|
||||
if m.focus == model.FocusList && prev != model.FocusList {
|
||||
m.listToolbar.FocusTable()
|
||||
}
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
case ActionMoveUp:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(-1)
|
||||
}
|
||||
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
|
||||
return m, nil
|
||||
}
|
||||
m.moveUp()
|
||||
m.syncFocusedPane()
|
||||
if m.focus == model.FocusList {
|
||||
return m, m.onListRowChanged()
|
||||
}
|
||||
return m, nil
|
||||
case ActionMoveDown:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(1)
|
||||
}
|
||||
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
|
||||
return m, nil
|
||||
}
|
||||
m.moveDown()
|
||||
m.syncFocusedPane()
|
||||
if m.focus == model.FocusList {
|
||||
return m, m.onListRowChanged()
|
||||
}
|
||||
return m, nil
|
||||
case ActionMoveLeft:
|
||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveTabStop(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
if m.focus == model.FocusMenu {
|
||||
return m.handleMenuHorizontal(-1)
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
|
||||
m.listTable.MoveCol(-1)
|
||||
}
|
||||
return m, nil
|
||||
@@ -196,12 +237,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.detail.MoveTabStop(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
if m.focus == model.FocusMenu {
|
||||
return m.handleMenuHorizontal(1)
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
|
||||
m.listTable.MoveCol(1)
|
||||
}
|
||||
return m, nil
|
||||
case ActionSort:
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
|
||||
m.listTable.ToggleSort()
|
||||
}
|
||||
return m, nil
|
||||
@@ -236,7 +280,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.togglePane(3)
|
||||
return m, nil
|
||||
case ActionFilter:
|
||||
m.enterFilter()
|
||||
if m.hasList {
|
||||
m.focus = model.FocusList
|
||||
m.listToolbar.FocusFilter()
|
||||
m.syncPaneFocus()
|
||||
}
|
||||
return m, nil
|
||||
case ActionRefresh:
|
||||
if m.hasList {
|
||||
@@ -257,13 +305,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.syncMenuContent()
|
||||
return m, nil
|
||||
case "right", "l":
|
||||
if spec, ok := m.nav.CurrentListSpec(); ok {
|
||||
return m.withList(*spec)
|
||||
}
|
||||
m.nav.Activate()
|
||||
m.syncMenuContent()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,10 +325,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.hasList = true
|
||||
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
|
||||
m.listTable.SetData(m.listSpec, m.items)
|
||||
if m.filterActive {
|
||||
m.applyFilter()
|
||||
}
|
||||
}
|
||||
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
|
||||
|
||||
case listMoreLoadedMsg:
|
||||
@@ -337,8 +376,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.updateItemAfterSave(msg.item, msg.fields)
|
||||
m.detail.LoadForm(msg.item, msg.fields)
|
||||
m.detail.form.MarkClean()
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||
if msg.fields.HasTaskStatus && msg.fields.TaskStatus == model.TaskLifecycleClosed {
|
||||
m.status = "Closed"
|
||||
} else {
|
||||
m.status = "Saved"
|
||||
}
|
||||
m.err = ""
|
||||
}
|
||||
return m, nil
|
||||
@@ -350,6 +394,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.status = msg.message
|
||||
m.err = ""
|
||||
m.selection.Clear()
|
||||
if m.hasList {
|
||||
m.loading = true
|
||||
return m, m.loadListCmd(m.listSpec)
|
||||
@@ -376,11 +421,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case model.FocusMenu:
|
||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||
case model.FocusList:
|
||||
if m.listToolbar.Zone() == listZoneTable {
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
if !m.filterActive {
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
case model.FocusPreview:
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -392,10 +437,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case model.FocusList:
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
if !m.filterActive {
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
@@ -425,6 +468,9 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
||||
if act.ID == model.ActionSave {
|
||||
return m.saveDetailCmd(), true
|
||||
}
|
||||
if act.ID == model.ActionClose && m.detail.Item().Kind == model.KindTask {
|
||||
return m.closeTaskCmd(), true
|
||||
}
|
||||
return m.executeActionCmd(act.ID, m.detail.Item()), true
|
||||
}
|
||||
}
|
||||
@@ -435,7 +481,7 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
||||
}
|
||||
|
||||
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
|
||||
if m.filterActive || !m.showDetail {
|
||||
if !m.showDetail {
|
||||
return nil, false
|
||||
}
|
||||
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
|
||||
@@ -476,7 +522,7 @@ func (m Model) View() string {
|
||||
h := m.paneHeight() + 2
|
||||
var parts []string
|
||||
if pw.Visibility.Menu {
|
||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
|
||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(paneLipglossWidth(pw.Menu)).Height(h)
|
||||
menuBody := ApplyVerticalScrollbar(
|
||||
m.menuVP.View(),
|
||||
m.menuVP.Width,
|
||||
@@ -487,18 +533,17 @@ func (m Model) View() string {
|
||||
parts = append(parts, menuStyle.Render(menuBody))
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
|
||||
parts = append(parts, listStyle.Render(m.listTable.View()))
|
||||
listStyle := paneStyle(m.focus == model.FocusList).Width(paneLipglossWidth(pw.List)).Height(h)
|
||||
meta := m.listToolbarMeta()
|
||||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.listToolbar.View(meta),
|
||||
m.listTable.View(),
|
||||
)
|
||||
parts = append(parts, listStyle.Render(body))
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
|
||||
var detailBody string
|
||||
if m.filterActive {
|
||||
detailBody = m.filterSearch.View()
|
||||
} else {
|
||||
detailBody = m.detail.View()
|
||||
}
|
||||
parts = append(parts, prevStyle.Render(detailBody))
|
||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(paneLipglossWidth(pw.Detail)).Height(h)
|
||||
parts = append(parts, prevStyle.Render(m.detail.View()))
|
||||
}
|
||||
|
||||
status := m.status
|
||||
@@ -526,16 +571,105 @@ func (m Model) paneLayout() PaneWidths {
|
||||
}
|
||||
|
||||
func (m *Model) syncPaneFocus() {
|
||||
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
|
||||
if m.filterActive {
|
||||
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
||||
m.detail.SetFocused(false)
|
||||
return
|
||||
onList := m.focus == model.FocusList && m.showList
|
||||
m.listTable.SetFocused(onList && m.listToolbar.Zone() == listZoneTable)
|
||||
if onList {
|
||||
m.listToolbar.syncInputFocus()
|
||||
} else {
|
||||
m.listToolbar.filter.SetFocused(false)
|
||||
}
|
||||
m.filterSearch.SetFocused(false)
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
||||
}
|
||||
|
||||
func (m Model) listToolbarMeta() ListToolbarMeta {
|
||||
meta := ListToolbarMeta{
|
||||
Subject: m.listTable.SubjectLabel(),
|
||||
Count: m.listTable.ItemCount(),
|
||||
SortLabel: m.listTable.SortHint(),
|
||||
LoadingMore: m.listTable.LoadingMore(),
|
||||
SaveEnabled: m.canSaveFromList(),
|
||||
DeleteEnabled: m.canDeleteFromList(),
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func (m Model) selectedItems() []model.Item {
|
||||
var out []model.Item
|
||||
for _, it := range m.items {
|
||||
if it.Selected {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) canSaveFromList() bool {
|
||||
selected := m.selectedItems()
|
||||
if len(selected) != 1 {
|
||||
return false
|
||||
}
|
||||
for _, act := range model.ActionsFor(selected[0].Kind) {
|
||||
if act.ID == model.ActionSave && m.detail.LoadedID() == selected[0].ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) canDeleteFromList() bool {
|
||||
selected := m.selectedItems()
|
||||
if len(selected) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, it := range selected {
|
||||
if !actionAvailable(model.ActionsFor(it.Kind), model.ActionDelete) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func actionAvailable(actions []model.ItemAction, id model.ActionID) bool {
|
||||
for _, a := range actions {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) runListToolbarAction(action model.ActionID) tea.Cmd {
|
||||
meta := m.listToolbarMeta()
|
||||
if !m.listToolbar.IsActionEnabled(action, meta) {
|
||||
return nil
|
||||
}
|
||||
switch action {
|
||||
case model.ActionSave:
|
||||
m.loading = true
|
||||
return m.saveDetailCmd()
|
||||
case model.ActionDelete:
|
||||
m.loading = true
|
||||
return m.deleteSelectedCmd()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) deleteSelectedCmd() tea.Cmd {
|
||||
selected := m.selectedItems()
|
||||
if len(selected) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(selected) == 1 {
|
||||
return m.executeActionCmd(model.ActionDelete, selected[0])
|
||||
}
|
||||
cmds := make([]tea.Cmd, len(selected))
|
||||
for i, it := range selected {
|
||||
cmds[i] = m.executeActionCmd(model.ActionDelete, it)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *Model) togglePane(which int) {
|
||||
switch which {
|
||||
case 1:
|
||||
@@ -587,11 +721,12 @@ func (m *Model) layoutViewports() {
|
||||
m.menuVP.Height = h
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
|
||||
w := m.paneInnerWidth(pw.List)
|
||||
m.listToolbar.SetWidth(w)
|
||||
m.listTable.SetSize(w, h-listToolbarHeight)
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,34 +741,14 @@ func (m *Model) syncListTable() {
|
||||
return
|
||||
}
|
||||
items := m.items
|
||||
if m.filterActive {
|
||||
items = model.FilterItems(m.items, m.filterSearch.Query())
|
||||
if q := m.listToolbar.Query(); q != "" {
|
||||
items = model.FilterItems(m.items, q)
|
||||
}
|
||||
m.listTable.UpdateItems(items)
|
||||
}
|
||||
|
||||
func (m *Model) enterFilter() {
|
||||
if !m.showDetail {
|
||||
m.showDetail = true
|
||||
m.layoutViewports()
|
||||
}
|
||||
m.filterActive = true
|
||||
m.focus = model.FocusPreview
|
||||
m.syncPaneFocus()
|
||||
m.applyFilter()
|
||||
}
|
||||
|
||||
func (m *Model) exitFilter() {
|
||||
m.filterActive = false
|
||||
m.filterSearch.Clear()
|
||||
m.nav.ClearFilter()
|
||||
m.syncMenuContent()
|
||||
m.syncListTable()
|
||||
m.syncPaneFocus()
|
||||
}
|
||||
|
||||
func (m *Model) applyFilter() {
|
||||
q := m.filterSearch.Query()
|
||||
q := m.listToolbar.Query()
|
||||
m.nav.SetFilter(q)
|
||||
m.syncMenuContent()
|
||||
m.syncListTable()
|
||||
@@ -645,8 +760,8 @@ func (m Model) listTableItem() (model.Item, bool) {
|
||||
return model.Item{}, false
|
||||
}
|
||||
items := m.items
|
||||
if m.filterActive {
|
||||
items = model.FilterItems(m.items, m.filterSearch.Query())
|
||||
if q := m.listToolbar.Query(); q != "" {
|
||||
items = model.FilterItems(m.items, q)
|
||||
}
|
||||
if idx >= len(items) {
|
||||
return model.Item{}, false
|
||||
@@ -657,7 +772,7 @@ func (m Model) listTableItem() (model.Item, bool) {
|
||||
func (m *Model) syncMenuContent() {
|
||||
var b strings.Builder
|
||||
title := "Navigation"
|
||||
if m.filterActive && m.filterSearch.Query() != "" {
|
||||
if m.listToolbar.Query() != "" {
|
||||
title += " (filtered)"
|
||||
}
|
||||
b.WriteString(title + "\n\n")
|
||||
@@ -705,6 +820,23 @@ func truncateRunes(s string, max int) string {
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func (m Model) handleMenuHorizontal(delta int) (Model, tea.Cmd) {
|
||||
cur := m.nav.Cursor()
|
||||
switch delta {
|
||||
case 1:
|
||||
if spec, ok := m.nav.CurrentListSpec(); ok {
|
||||
return m.withList(*spec)
|
||||
}
|
||||
m.nav.Activate()
|
||||
case -1:
|
||||
if m.nav.IsExpandable(cur) && m.nav.IsExpanded(cur) {
|
||||
m.nav.ToggleExpand(cur)
|
||||
}
|
||||
}
|
||||
m.syncMenuContent()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) syncFocusedPane() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
@@ -790,9 +922,6 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *Model) onListRowChanged() tea.Cmd {
|
||||
if m.filterActive {
|
||||
return nil
|
||||
}
|
||||
if !m.hasList {
|
||||
m.detail.Clear()
|
||||
return m.maybeLoadMoreList()
|
||||
@@ -814,7 +943,7 @@ func (m *Model) onListRowChanged() tea.Cmd {
|
||||
const mailListLoadThreshold = 3
|
||||
|
||||
func (m *Model) maybeLoadMoreList() tea.Cmd {
|
||||
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive {
|
||||
if !m.hasList || m.listLoadingMore || !m.listHasMore {
|
||||
return nil
|
||||
}
|
||||
if !model.SubjectIsMail(m.listSpec.Subject) {
|
||||
@@ -850,6 +979,17 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) closeTaskCmd() tea.Cmd {
|
||||
item := m.detail.Item()
|
||||
fields := m.detail.form.FormFields()
|
||||
fields.TaskStatus = model.TaskLifecycleClosed
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := m.loader.CloseTask(ctx, item.ID, fields)
|
||||
return detailSavedMsg{item: item, fields: fields, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) saveDetailCmd() tea.Cmd {
|
||||
item := m.detail.Item()
|
||||
fields := m.detail.form.FormFields()
|
||||
@@ -874,6 +1014,12 @@ func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
|
||||
if fields.HasStatus {
|
||||
m.items[i].Raw["status"] = statusInt(fields.Status)
|
||||
}
|
||||
if fields.HasTaskStatus {
|
||||
m.items[i].Raw["status"] = int(fields.TaskStatus)
|
||||
}
|
||||
if fields.ResponsibleID != "" {
|
||||
m.items[i].Raw["responsibleIds"] = []any{fields.ResponsibleID}
|
||||
}
|
||||
m.syncListTable()
|
||||
return
|
||||
}
|
||||
@@ -899,5 +1045,5 @@ func paneStyle(focused bool) lipgloss.Style {
|
||||
}
|
||||
|
||||
func helpText() string {
|
||||
return "Alt+1/2/3: toggle panes · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit"
|
||||
return "Alt+1/2/3: panes · / filter · Space select · 💾🗑 when selected · v: detail · Ctrl+S: save · q: quit"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestDetailTabOrderTaskForm(t *testing.T) {
|
||||
d := newDetailPane()
|
||||
d.SetFocused(true)
|
||||
d.LoadForm(model.Item{ID: "9", Kind: model.KindTask, Title: "T"}, model.FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: "Alpha", Secondary: "Beta",
|
||||
HasTaskStatus: true, TaskStatus: model.TaskLifecycleOpen,
|
||||
HasResponsible: true, ResponsibleID: "u1",
|
||||
UserChoices: []model.UserOption{{ID: "u1", Name: "Alice"}},
|
||||
ProjectTitle: "Proj", TimingSummary: "2026-01-01 → 2026-02-01",
|
||||
})
|
||||
|
||||
stops := []entityField{
|
||||
entityFieldPrimary, entityFieldSecondary, entityFieldStatus, entityFieldResponsible,
|
||||
}
|
||||
for i, want := range stops {
|
||||
if d.form.field != want {
|
||||
t.Fatalf("stop %d: field=%d want %d", i, d.form.field, want)
|
||||
}
|
||||
if d.TabForward() {
|
||||
t.Fatalf("tab from stop %d should stay in pane", i)
|
||||
}
|
||||
}
|
||||
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
|
||||
t.Fatalf("expected Save action, zone=%d idx=%d", d.Zone(), d.actionIdx)
|
||||
}
|
||||
if d.TabForward() {
|
||||
t.Fatal("tab from Save should stay")
|
||||
}
|
||||
if d.actionIdx != 1 {
|
||||
t.Fatalf("expected Close action, idx=%d", d.actionIdx)
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ func TestDetailPaneFormAndActions(t *testing.T) {
|
||||
Primary: "Hello", Secondary: "World",
|
||||
})
|
||||
view := d.View()
|
||||
if !strings.Contains(view, "Save") {
|
||||
t.Fatal("expected Save action button")
|
||||
if !strings.Contains(view, "Save") || !strings.Contains(view, "Close") {
|
||||
t.Fatal("expected Save and Close action buttons")
|
||||
}
|
||||
if !strings.Contains(view, "Hello") {
|
||||
t.Fatal("expected form content")
|
||||
|
||||
+266
-39
@@ -16,6 +16,7 @@ const (
|
||||
entityFieldPrimary entityField = iota
|
||||
entityFieldSecondary
|
||||
entityFieldStatus
|
||||
entityFieldResponsible
|
||||
)
|
||||
|
||||
// EntityForm is the top section of the detail pane for editable entities.
|
||||
@@ -28,9 +29,22 @@ type EntityForm struct {
|
||||
readOnly bool
|
||||
hasStatus bool
|
||||
status model.ProjectLifecycle
|
||||
hasTaskStatus bool
|
||||
taskStatus model.TaskLifecycle
|
||||
hasResponsible bool
|
||||
responsibleID string
|
||||
userChoices []model.UserOption
|
||||
responsibleIdx int
|
||||
projectTitle string
|
||||
timingSummary string
|
||||
hasUserEdit bool
|
||||
userEnabled bool
|
||||
userACL model.UserACLState
|
||||
groupsText string
|
||||
userFieldIdx int
|
||||
primary textinput.Model
|
||||
secondary textarea.Model
|
||||
password textinput.Model
|
||||
field entityField
|
||||
focused bool
|
||||
dirty bool
|
||||
@@ -43,6 +57,7 @@ type entityFormStyles struct {
|
||||
header lipgloss.Style
|
||||
label lipgloss.Style
|
||||
labelAct lipgloss.Style
|
||||
meta lipgloss.Style
|
||||
}
|
||||
|
||||
func newEntityFormStyles() entityFormStyles {
|
||||
@@ -50,6 +65,7 @@ func newEntityFormStyles() entityFormStyles {
|
||||
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||
meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,18 +77,30 @@ func newEntityForm() EntityForm {
|
||||
secondary.ShowLineNumbers = false
|
||||
secondary.CharLimit = 8000
|
||||
secondary.Prompt = "> "
|
||||
password := textinput.New()
|
||||
password.CharLimit = 128
|
||||
password.EchoMode = textinput.EchoPassword
|
||||
password.EchoCharacter = '•'
|
||||
password.Prompt = "> "
|
||||
return EntityForm{
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
password: password,
|
||||
field: entityFieldPrimary,
|
||||
styles: newEntityFormStyles(),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *EntityForm) FieldCount() int {
|
||||
if f.hasUserEdit {
|
||||
return f.userFieldCount()
|
||||
}
|
||||
n := 2
|
||||
if f.hasStatus {
|
||||
n = 3
|
||||
if f.hasStatus || f.hasTaskStatus {
|
||||
n++
|
||||
}
|
||||
if f.hasResponsible {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -86,7 +114,18 @@ func (f *EntityForm) FormFields() model.FormFields {
|
||||
ReadOnly: f.readOnly,
|
||||
HasStatus: f.hasStatus,
|
||||
Status: f.status,
|
||||
HasTaskStatus: f.hasTaskStatus,
|
||||
TaskStatus: f.taskStatus,
|
||||
HasResponsible: f.hasResponsible,
|
||||
ResponsibleID: f.responsibleID,
|
||||
UserChoices: f.userChoices,
|
||||
ProjectTitle: f.projectTitle,
|
||||
TimingSummary: f.timingSummary,
|
||||
HasUserEdit: f.hasUserEdit,
|
||||
UserEnabled: f.userEnabled,
|
||||
UserACL: f.userACL,
|
||||
GroupsText: f.groupsText,
|
||||
UserPassword: f.password.Value(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,29 +148,66 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
|
||||
f.readOnly = fields.ReadOnly
|
||||
f.hasStatus = fields.HasStatus
|
||||
f.status = fields.Status
|
||||
f.hasTaskStatus = fields.HasTaskStatus
|
||||
f.taskStatus = fields.TaskStatus
|
||||
f.hasResponsible = fields.HasResponsible
|
||||
f.userChoices = fields.UserChoices
|
||||
f.projectTitle = fields.ProjectTitle
|
||||
f.timingSummary = fields.TimingSummary
|
||||
f.responsibleID = fields.ResponsibleID
|
||||
f.responsibleIdx = indexUserChoice(fields.UserChoices, fields.ResponsibleID)
|
||||
f.hasUserEdit = fields.HasUserEdit
|
||||
f.userEnabled = fields.UserEnabled
|
||||
f.userACL = copyUserACLState(fields.UserACL)
|
||||
f.groupsText = fields.GroupsText
|
||||
f.userFieldIdx = 0
|
||||
f.primary.SetValue(fields.Primary)
|
||||
f.secondary.SetValue(fields.Secondary)
|
||||
f.password.SetValue("")
|
||||
f.dirty = false
|
||||
if f.hasUserEdit {
|
||||
f.userFieldIdx = 0
|
||||
} else {
|
||||
f.field = entityFieldPrimary
|
||||
if f.hasStatus && f.readOnly {
|
||||
f.field = entityFieldStatus
|
||||
}
|
||||
f.applyFocus()
|
||||
f.layoutFields()
|
||||
}
|
||||
|
||||
func indexUserChoice(choices []model.UserOption, id string) int {
|
||||
for i, c := range choices {
|
||||
if c.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *EntityForm) Clear() {
|
||||
f.active = false
|
||||
f.itemID = ""
|
||||
f.hasStatus = false
|
||||
f.hasTaskStatus = false
|
||||
f.hasResponsible = false
|
||||
f.status = model.ProjectLifecycleOpen
|
||||
f.taskStatus = model.TaskLifecycleOpen
|
||||
f.responsibleID = ""
|
||||
f.userChoices = nil
|
||||
f.responsibleIdx = 0
|
||||
f.projectTitle = ""
|
||||
f.timingSummary = ""
|
||||
f.hasUserEdit = false
|
||||
f.userEnabled = false
|
||||
f.userACL = model.UserACLState{}
|
||||
f.groupsText = ""
|
||||
f.userFieldIdx = 0
|
||||
f.primary.SetValue("")
|
||||
f.secondary.SetValue("")
|
||||
f.password.SetValue("")
|
||||
f.dirty = false
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
f.password.Blur()
|
||||
}
|
||||
|
||||
func (f *EntityForm) SetFocused(on bool) {
|
||||
@@ -141,6 +217,7 @@ func (f *EntityForm) SetFocused(on bool) {
|
||||
} else {
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
f.password.Blur()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +242,28 @@ func (f *EntityForm) layoutFields() {
|
||||
inner = 8
|
||||
}
|
||||
f.primary.Width = inner
|
||||
secH := f.height - 6
|
||||
f.password.Width = inner
|
||||
if f.hasUserEdit {
|
||||
return
|
||||
}
|
||||
metaLines := 0
|
||||
if f.projectTitle != "" {
|
||||
metaLines++
|
||||
}
|
||||
if f.timingSummary != "" {
|
||||
metaLines++
|
||||
}
|
||||
if metaLines > 0 {
|
||||
metaLines++ // blank line before meta
|
||||
}
|
||||
extra := 6 + metaLines
|
||||
if f.hasStatus || f.hasTaskStatus {
|
||||
extra += 2
|
||||
}
|
||||
if f.hasResponsible {
|
||||
extra += 2
|
||||
}
|
||||
secH := f.height - extra
|
||||
if secH < 3 {
|
||||
secH = 3
|
||||
}
|
||||
@@ -173,34 +271,49 @@ func (f *EntityForm) layoutFields() {
|
||||
f.secondary.SetHeight(secH)
|
||||
}
|
||||
|
||||
func (f *EntityForm) FocusNext() {
|
||||
last := entityFieldSecondary
|
||||
if f.hasStatus {
|
||||
last = entityFieldStatus
|
||||
func (f *EntityForm) lastField() entityField {
|
||||
if f.hasResponsible {
|
||||
return entityFieldResponsible
|
||||
}
|
||||
if f.field < last {
|
||||
if f.hasStatus || f.hasTaskStatus {
|
||||
return entityFieldStatus
|
||||
}
|
||||
return entityFieldSecondary
|
||||
}
|
||||
|
||||
func (f *EntityForm) FocusNext() {
|
||||
if f.field < f.lastField() {
|
||||
f.field++
|
||||
}
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) FocusPrev() {
|
||||
first := entityFieldPrimary
|
||||
if f.field > first {
|
||||
if f.field > entityFieldPrimary {
|
||||
f.field--
|
||||
}
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) SetFieldIndex(i int) {
|
||||
if f.hasUserEdit {
|
||||
f.setUserFieldIndex(i)
|
||||
return
|
||||
}
|
||||
switch i {
|
||||
case 0:
|
||||
f.field = entityFieldPrimary
|
||||
case 1:
|
||||
f.field = entityFieldSecondary
|
||||
case 2:
|
||||
if f.hasStatus {
|
||||
if f.hasStatus || f.hasTaskStatus {
|
||||
f.field = entityFieldStatus
|
||||
} else if f.hasResponsible {
|
||||
f.field = entityFieldResponsible
|
||||
}
|
||||
case 3:
|
||||
if f.hasResponsible && (f.hasStatus || f.hasTaskStatus) {
|
||||
f.field = entityFieldResponsible
|
||||
}
|
||||
default:
|
||||
return
|
||||
@@ -209,7 +322,19 @@ func (f *EntityForm) SetFieldIndex(i int) {
|
||||
}
|
||||
|
||||
func (f *EntityForm) cycleStatus(delta int) {
|
||||
if !f.hasStatus || f.readOnly {
|
||||
if f.readOnly {
|
||||
return
|
||||
}
|
||||
if f.hasTaskStatus {
|
||||
if delta > 0 {
|
||||
f.taskStatus = f.taskStatus.Next()
|
||||
} else {
|
||||
f.taskStatus = f.taskStatus.Prev()
|
||||
}
|
||||
f.dirty = true
|
||||
return
|
||||
}
|
||||
if !f.hasStatus {
|
||||
return
|
||||
}
|
||||
if delta > 0 {
|
||||
@@ -220,7 +345,36 @@ func (f *EntityForm) cycleStatus(delta int) {
|
||||
f.dirty = true
|
||||
}
|
||||
|
||||
func (f *EntityForm) cycleResponsible(delta int) {
|
||||
if !f.hasResponsible || f.readOnly || len(f.userChoices) == 0 {
|
||||
return
|
||||
}
|
||||
n := len(f.userChoices)
|
||||
f.responsibleIdx = (f.responsibleIdx + delta%n + n) % n
|
||||
f.responsibleID = f.userChoices[f.responsibleIdx].ID
|
||||
f.dirty = true
|
||||
}
|
||||
|
||||
func (f *EntityForm) responsibleLabel() string {
|
||||
if f.responsibleID == "" {
|
||||
return "—"
|
||||
}
|
||||
for _, c := range f.userChoices {
|
||||
if c.ID == f.responsibleID {
|
||||
if c.Name != "" {
|
||||
return c.Name
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
}
|
||||
return f.responsibleID
|
||||
}
|
||||
|
||||
func (f *EntityForm) applyFocus() {
|
||||
if f.hasUserEdit {
|
||||
f.applyUserFocus()
|
||||
return
|
||||
}
|
||||
if f.readOnly {
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
@@ -243,7 +397,12 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
|
||||
if !f.active || !f.focused || f.readOnly {
|
||||
return nil
|
||||
}
|
||||
if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus {
|
||||
if f.hasUserEdit {
|
||||
return f.updateUserForm(msg)
|
||||
}
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch f.field {
|
||||
case entityFieldStatus:
|
||||
switch key.String() {
|
||||
case "left", "h":
|
||||
f.cycleStatus(-1)
|
||||
@@ -255,6 +414,19 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
|
||||
f.cycleStatus(1)
|
||||
return nil
|
||||
}
|
||||
case entityFieldResponsible:
|
||||
switch key.String() {
|
||||
case "left", "h":
|
||||
f.cycleResponsible(-1)
|
||||
return nil
|
||||
case "right", "l":
|
||||
f.cycleResponsible(1)
|
||||
return nil
|
||||
case " ":
|
||||
f.cycleResponsible(1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
switch f.field {
|
||||
@@ -275,6 +447,9 @@ func (f EntityForm) View() string {
|
||||
if !f.active {
|
||||
return ""
|
||||
}
|
||||
if f.hasUserEdit {
|
||||
return f.userView()
|
||||
}
|
||||
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
|
||||
pLabel := f.styles.label.Render(f.primaryLabel)
|
||||
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
|
||||
@@ -284,43 +459,95 @@ func (f EntityForm) View() string {
|
||||
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
|
||||
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
|
||||
}
|
||||
statusBlock := ""
|
||||
if f.hasStatus {
|
||||
stLabel := f.styles.label.Render("Status")
|
||||
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
||||
stLabel = f.styles.labelAct.Render("Status")
|
||||
}
|
||||
valStyle := f.styles.label
|
||||
if f.status == model.ProjectLifecycleClosed {
|
||||
valStyle = valStyle.Foreground(lipgloss.Color("245"))
|
||||
} else {
|
||||
valStyle = valStyle.Foreground(lipgloss.Color("42"))
|
||||
}
|
||||
hint := ""
|
||||
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
||||
hint = f.styles.label.Render(" ←/→ toggle")
|
||||
}
|
||||
statusBlock = strings.Join([]string{
|
||||
"",
|
||||
stLabel,
|
||||
valStyle.Render(f.status.Label()) + hint,
|
||||
}, "\n")
|
||||
}
|
||||
statusBlock := f.renderStatusBlock()
|
||||
responsibleBlock := f.renderResponsibleBlock()
|
||||
metaBlock := f.renderMetaBlock()
|
||||
ro := ""
|
||||
if f.readOnly {
|
||||
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
parts := []string{
|
||||
header + ro,
|
||||
pLabel,
|
||||
f.primary.View(),
|
||||
"",
|
||||
sLabel,
|
||||
f.secondary.View(),
|
||||
statusBlock,
|
||||
}
|
||||
if statusBlock != "" {
|
||||
parts = append(parts, statusBlock)
|
||||
}
|
||||
if responsibleBlock != "" {
|
||||
parts = append(parts, responsibleBlock)
|
||||
}
|
||||
if metaBlock != "" {
|
||||
parts = append(parts, metaBlock)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func (f EntityForm) renderStatusBlock() string {
|
||||
if !f.hasStatus && !f.hasTaskStatus {
|
||||
return ""
|
||||
}
|
||||
stLabel := f.styles.label.Render("Status")
|
||||
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
||||
stLabel = f.styles.labelAct.Render("Status")
|
||||
}
|
||||
label := f.status.Label()
|
||||
valStyle := f.styles.label
|
||||
if f.hasTaskStatus {
|
||||
label = f.taskStatus.Label()
|
||||
if f.taskStatus == model.TaskLifecycleClosed {
|
||||
valStyle = valStyle.Foreground(lipgloss.Color("245"))
|
||||
} else if f.taskStatus == model.TaskLifecycleOpen {
|
||||
valStyle = valStyle.Foreground(lipgloss.Color("42"))
|
||||
}
|
||||
} else if f.status == model.ProjectLifecycleClosed {
|
||||
valStyle = valStyle.Foreground(lipgloss.Color("245"))
|
||||
} else {
|
||||
valStyle = valStyle.Foreground(lipgloss.Color("42"))
|
||||
}
|
||||
hint := ""
|
||||
if f.focused && f.field == entityFieldStatus && !f.readOnly {
|
||||
hint = f.styles.label.Render(" ←/→ cycle")
|
||||
}
|
||||
return strings.Join([]string{"", stLabel, valStyle.Render(label) + hint}, "\n")
|
||||
}
|
||||
|
||||
func (f EntityForm) renderResponsibleBlock() string {
|
||||
if !f.hasResponsible {
|
||||
return ""
|
||||
}
|
||||
rLabel := f.styles.label.Render("Responsible")
|
||||
if f.focused && f.field == entityFieldResponsible && !f.readOnly {
|
||||
rLabel = f.styles.labelAct.Render("Responsible")
|
||||
}
|
||||
hint := ""
|
||||
if f.focused && f.field == entityFieldResponsible && !f.readOnly && len(f.userChoices) > 0 {
|
||||
hint = f.styles.label.Render(" ←/→ choose")
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"",
|
||||
rLabel,
|
||||
f.styles.meta.Render(f.responsibleLabel()) + hint,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (f EntityForm) renderMetaBlock() string {
|
||||
if f.projectTitle == "" && f.timingSummary == "" {
|
||||
return ""
|
||||
}
|
||||
lines := []string{""}
|
||||
if f.projectTitle != "" {
|
||||
lines = append(lines, f.styles.label.Render("Project"), f.styles.meta.Render(f.projectTitle))
|
||||
}
|
||||
if f.timingSummary != "" {
|
||||
lines = append(lines, f.styles.label.Render("Timing"), f.styles.meta.Render(f.timingSummary))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func entityFormBlinkCmd() tea.Cmd {
|
||||
return tea.Batch(textinput.Blink, textarea.Blink)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func (f *EntityForm) userFieldCount() int {
|
||||
return 2 + len(model.UserACLDefs)
|
||||
}
|
||||
|
||||
func (f *EntityForm) userView() string {
|
||||
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
|
||||
lines := []string{
|
||||
header,
|
||||
f.styles.label.Render("ID"),
|
||||
f.styles.meta.Render(f.itemID),
|
||||
"",
|
||||
f.renderUserEnabledRow(),
|
||||
"",
|
||||
f.renderUserPasswordRow(),
|
||||
}
|
||||
for i, def := range model.UserACLDefs {
|
||||
lines = append(lines, f.renderUserACLRow(i, def.Label, f.userACL.ACLModuleOn(def.Key)))
|
||||
}
|
||||
lines = append(lines, "", f.styles.label.Render("Groups"), f.styles.meta.Render(f.groupsText))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (f *EntityForm) renderUserEnabledRow() string {
|
||||
label := f.styles.label.Render("Account")
|
||||
if f.focused && f.userFieldIdx == 0 {
|
||||
label = f.styles.labelAct.Render("Account")
|
||||
}
|
||||
val := "Disabled"
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||
if f.userEnabled {
|
||||
val = "Enabled"
|
||||
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
}
|
||||
hint := ""
|
||||
if f.focused && f.userFieldIdx == 0 {
|
||||
hint = f.styles.label.Render(" space toggle")
|
||||
}
|
||||
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
|
||||
}
|
||||
|
||||
func (f *EntityForm) renderUserPasswordRow() string {
|
||||
label := f.styles.label.Render("Password")
|
||||
if f.focused && f.userFieldIdx == 1 {
|
||||
label = f.styles.labelAct.Render("Password")
|
||||
}
|
||||
return strings.Join([]string{label, f.password.View(), f.styles.meta.Render("(leave blank to keep)")}, "\n")
|
||||
}
|
||||
|
||||
func (f *EntityForm) renderUserACLRow(idx int, title string, on bool) string {
|
||||
fieldIdx := idx + 2
|
||||
label := f.styles.label.Render(title)
|
||||
if f.focused && f.userFieldIdx == fieldIdx {
|
||||
label = f.styles.labelAct.Render(title)
|
||||
}
|
||||
val := "Off"
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||
if on {
|
||||
val = "On"
|
||||
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
}
|
||||
hint := ""
|
||||
if f.focused && f.userFieldIdx == fieldIdx {
|
||||
hint = f.styles.label.Render(" space toggle")
|
||||
}
|
||||
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
|
||||
}
|
||||
|
||||
func (f *EntityForm) setUserFieldIndex(i int) {
|
||||
if i < 0 {
|
||||
i = 0
|
||||
}
|
||||
max := f.userFieldCount() - 1
|
||||
if i > max {
|
||||
i = max
|
||||
}
|
||||
f.userFieldIdx = i
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) toggleUserEnabled() {
|
||||
f.userEnabled = !f.userEnabled
|
||||
f.dirty = true
|
||||
}
|
||||
|
||||
func (f *EntityForm) toggleUserACL(idx int) {
|
||||
if idx < 0 || idx >= len(model.UserACLDefs) {
|
||||
return
|
||||
}
|
||||
if idx == 0 {
|
||||
f.userACL.FullAccess = !f.userACL.FullAccess
|
||||
if f.userACL.FullAccess {
|
||||
for k := range f.userACL.Modules {
|
||||
f.userACL.Modules[k] = true
|
||||
}
|
||||
}
|
||||
f.dirty = true
|
||||
return
|
||||
}
|
||||
def := model.UserACLDefs[idx]
|
||||
if f.userACL.FullAccess {
|
||||
f.userACL.FullAccess = false
|
||||
for k := range f.userACL.Modules {
|
||||
f.userACL.Modules[k] = true
|
||||
}
|
||||
}
|
||||
f.userACL.Modules[def.Key] = !f.userACL.Modules[def.Key]
|
||||
f.dirty = true
|
||||
}
|
||||
|
||||
func (f *EntityForm) updateUserForm(msg tea.Msg) tea.Cmd {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch f.userFieldIdx {
|
||||
case 0:
|
||||
switch key.String() {
|
||||
case " ", "left", "right", "l", "h":
|
||||
f.toggleUserEnabled()
|
||||
return nil
|
||||
}
|
||||
case 1:
|
||||
var cmd tea.Cmd
|
||||
f.password, cmd = f.password.Update(msg)
|
||||
f.dirty = true
|
||||
return cmd
|
||||
default:
|
||||
switch key.String() {
|
||||
case " ", "left", "right", "l", "h":
|
||||
f.toggleUserACL(f.userFieldIdx - 2)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *EntityForm) applyUserFocus() {
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
if f.userFieldIdx == 1 {
|
||||
f.password.Focus()
|
||||
} else {
|
||||
f.password.Blur()
|
||||
}
|
||||
}
|
||||
|
||||
func copyUserACLState(s model.UserACLState) model.UserACLState {
|
||||
modules := make(map[string]bool, len(s.Modules))
|
||||
for k, v := range s.Modules {
|
||||
modules[k] = v
|
||||
}
|
||||
return model.UserACLState{FullAccess: s.FullAccess, Modules: modules}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestUserFormViewShowsIDAndACL(t *testing.T) {
|
||||
f := NewEntityFormForTest()
|
||||
f.SetSize(60, 30)
|
||||
f.Load(model.KindUser, "42", model.FormFields{
|
||||
HasUserEdit: true,
|
||||
UserEnabled: true,
|
||||
UserACL: model.UserACLState{
|
||||
FullAccess: false,
|
||||
Modules: map[string]bool{
|
||||
"documents": true,
|
||||
},
|
||||
},
|
||||
GroupsText: "Admins, Devs",
|
||||
})
|
||||
view := f.View()
|
||||
for _, want := range []string{"User 42", "ID", "42", "Account", "Password", "Full access", "Documents", "Groups", "Admins"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("view missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFormFieldCount(t *testing.T) {
|
||||
f := NewEntityFormForTest()
|
||||
f.Load(model.KindUser, "1", model.FormFields{HasUserEdit: true, UserACL: model.UserACLFromRaw(nil)})
|
||||
if got := f.FieldCount(); got != 2+len(model.UserACLDefs) {
|
||||
t.Fatalf("field count=%d want %d", got, len(model.UserACLDefs)+2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) {
|
||||
cols := model.BuildColumns(model.SubjectUsers, nil)
|
||||
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
|
||||
if !ok {
|
||||
t.Fatal("expected flex layout")
|
||||
}
|
||||
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||
sum := 0
|
||||
emailW := 0
|
||||
for _, i := range lay.indices {
|
||||
sum += lay.widths[i]
|
||||
if cols[i].Key == "email" {
|
||||
emailW = lay.widths[i]
|
||||
}
|
||||
}
|
||||
if sum != 100 {
|
||||
t.Fatalf("sum=%d want 100", sum)
|
||||
}
|
||||
if emailW < 30 {
|
||||
t.Fatalf("email width=%d should absorb extra space", emailW)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -25,7 +25,7 @@ type filterSearchStyles struct {
|
||||
func newFilterSearch() FilterSearch {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/ "
|
||||
in.Placeholder = "Filter navigation and list…"
|
||||
in.Placeholder = "Filter list…"
|
||||
in.CharLimit = 256
|
||||
return FilterSearch{
|
||||
input: in,
|
||||
@@ -76,6 +76,17 @@ func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (f *FilterSearch) SetInputWidth(inner int) {
|
||||
if inner < 8 {
|
||||
inner = 8
|
||||
}
|
||||
f.input.Width = inner
|
||||
}
|
||||
|
||||
func (f *FilterSearch) CompactView() string {
|
||||
return f.input.View()
|
||||
}
|
||||
|
||||
func (f FilterSearch) View() string {
|
||||
title := f.styles.title.Render("Filter")
|
||||
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
|
||||
|
||||
@@ -39,8 +39,13 @@ func KeyAction(key string, pane model.FocusPane) Action {
|
||||
}
|
||||
case "r":
|
||||
return ActionRefresh
|
||||
case "f":
|
||||
case "f", "/":
|
||||
if pane == model.FocusList {
|
||||
return ActionFilter
|
||||
}
|
||||
if pane == model.FocusMenu {
|
||||
return ActionFilter
|
||||
}
|
||||
case "up", "k":
|
||||
return ActionMoveUp
|
||||
case "down", "j":
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
|
||||
@@ -66,6 +71,53 @@ func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreePaneRenderedWidthMatchesTerminal(t *testing.T) {
|
||||
prev := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||
|
||||
total := 120
|
||||
pw := LayoutWidths(total, PaneVisibility{Menu: true, List: true, Detail: true})
|
||||
h := 12
|
||||
menu := paneStyle(false).Width(paneLipglossWidth(pw.Menu)).Height(h).Render(strings.Repeat("m", paneContentWidth(pw.Menu)))
|
||||
inner := paneContentWidth(pw.List)
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(inner, h-listToolbarHeight)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||
})
|
||||
toolbar := newListToolbar()
|
||||
toolbar.SetWidth(inner)
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, toolbar.View(ListToolbarMeta{Subject: "projects", Count: 1}), tbl.View())
|
||||
list := paneStyle(true).Width(paneLipglossWidth(pw.List)).Height(h).Render(body)
|
||||
detail := paneStyle(false).Width(paneLipglossWidth(pw.Detail)).Height(h).Render(strings.Repeat("d", paneContentWidth(pw.Detail)))
|
||||
full := lipgloss.JoinHorizontal(lipgloss.Top, menu, list, detail)
|
||||
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
top := strings.Split(full, "\n")[0]
|
||||
got := runewidth.StringWidth(re.ReplaceAllString(top, ""))
|
||||
if got != total {
|
||||
t.Fatalf("top border width=%d want %d", got, total)
|
||||
}
|
||||
for _, line := range strings.Split(tbl.View(), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
w := runewidth.StringWidth(re.ReplaceAllString(line, ""))
|
||||
if w > inner+1 {
|
||||
t.Fatalf("table line width=%d want <=%d", w, inner+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaneContentWidth(t *testing.T) {
|
||||
if got := paneContentWidth(72); got != 68 {
|
||||
t.Fatalf("content width=%d want 68", got)
|
||||
}
|
||||
if got := paneLipglossWidth(72); got != 70 {
|
||||
t.Fatalf("lipgloss width=%d want 70", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidthsLegacyHelper(t *testing.T) {
|
||||
menu, list, preview := LayoutWidthsLegacy(120)
|
||||
if menu+list+preview != 120 {
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
const listToolbarHeight = 2
|
||||
|
||||
type listZone int
|
||||
|
||||
const (
|
||||
listZoneTable listZone = iota
|
||||
listZoneFilter
|
||||
listZoneActions
|
||||
)
|
||||
|
||||
// ListToolbarMeta is the navigation/sort summary shown above the filter row.
|
||||
type ListToolbarMeta struct {
|
||||
Subject string
|
||||
Count int
|
||||
SortLabel string
|
||||
LoadingMore bool
|
||||
SaveEnabled bool
|
||||
DeleteEnabled bool
|
||||
}
|
||||
|
||||
// ListToolbar is the filter & navigation bar above the center table.
|
||||
type ListToolbar struct {
|
||||
filter FilterSearch
|
||||
width int
|
||||
zone listZone
|
||||
actionIdx int
|
||||
actionRects []toolbarBtnRect
|
||||
btnX0 int
|
||||
styles listToolbarStyles
|
||||
}
|
||||
|
||||
type toolbarBtnRect struct {
|
||||
action model.ActionID
|
||||
x0, x1 int
|
||||
}
|
||||
|
||||
type listToolbarStyles struct {
|
||||
title lipgloss.Style
|
||||
btn lipgloss.Style
|
||||
btnOn lipgloss.Style
|
||||
btnOff lipgloss.Style
|
||||
btnAct lipgloss.Style
|
||||
btnDn lipgloss.Style
|
||||
}
|
||||
|
||||
func newListToolbar() ListToolbar {
|
||||
return ListToolbar{
|
||||
filter: newFilterSearch(),
|
||||
styles: listToolbarStyles{
|
||||
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||
btn: lipgloss.NewStyle().Padding(0, 1),
|
||||
btnOn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")),
|
||||
btnOff: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("238")),
|
||||
btnAct: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||
btnDn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ListToolbar) SetWidth(w int) {
|
||||
if w < 12 {
|
||||
w = 12
|
||||
}
|
||||
b.width = w
|
||||
inner := w - 8
|
||||
if inner < 12 {
|
||||
inner = 12
|
||||
}
|
||||
b.filter.SetInputWidth(inner)
|
||||
}
|
||||
|
||||
func (b *ListToolbar) Zone() listZone { return b.zone }
|
||||
|
||||
func (b *ListToolbar) SetZone(z listZone) { b.zone = z }
|
||||
|
||||
func (b *ListToolbar) Query() string { return b.filter.Query() }
|
||||
|
||||
func (b *ListToolbar) ClearFilter() { b.filter.Clear() }
|
||||
|
||||
func (b *ListToolbar) syncInputFocus() {
|
||||
b.filter.SetFocused(b.zone == listZoneFilter)
|
||||
}
|
||||
|
||||
func (b *ListToolbar) FocusFilter() {
|
||||
b.zone = listZoneFilter
|
||||
b.filter.SetFocused(true)
|
||||
}
|
||||
|
||||
func (b *ListToolbar) FocusTable() {
|
||||
b.zone = listZoneTable
|
||||
b.filter.SetFocused(false)
|
||||
}
|
||||
|
||||
func (b *ListToolbar) TabForward(meta ListToolbarMeta) {
|
||||
switch b.zone {
|
||||
case listZoneTable:
|
||||
b.FocusFilter()
|
||||
case listZoneFilter:
|
||||
b.zone = listZoneActions
|
||||
b.actionIdx = 0
|
||||
b.filter.SetFocused(false)
|
||||
b.clampActionIdx(meta)
|
||||
case listZoneActions:
|
||||
if b.nextEnabledAction(meta, 1) {
|
||||
return
|
||||
}
|
||||
b.FocusTable()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ListToolbar) TabBackward(meta ListToolbarMeta) {
|
||||
switch b.zone {
|
||||
case listZoneTable:
|
||||
b.zone = listZoneActions
|
||||
b.actionIdx = b.lastEnabledAction(meta)
|
||||
b.filter.SetFocused(false)
|
||||
case listZoneActions:
|
||||
if b.nextEnabledAction(meta, -1) {
|
||||
return
|
||||
}
|
||||
b.FocusFilter()
|
||||
case listZoneFilter:
|
||||
b.FocusTable()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ListToolbar) toolbarActions(meta ListToolbarMeta) []model.ActionID {
|
||||
var out []model.ActionID
|
||||
if meta.SaveEnabled {
|
||||
out = append(out, model.ActionSave)
|
||||
}
|
||||
if meta.DeleteEnabled {
|
||||
out = append(out, model.ActionDelete)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *ListToolbar) clampActionIdx(meta ListToolbarMeta) {
|
||||
acts := b.toolbarActions(meta)
|
||||
if len(acts) == 0 {
|
||||
b.actionIdx = 0
|
||||
return
|
||||
}
|
||||
if b.actionIdx >= len(acts) {
|
||||
b.actionIdx = len(acts) - 1
|
||||
}
|
||||
if b.actionIdx < 0 {
|
||||
b.actionIdx = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ListToolbar) lastEnabledAction(meta ListToolbarMeta) int {
|
||||
acts := b.toolbarActions(meta)
|
||||
if len(acts) == 0 {
|
||||
return 0
|
||||
}
|
||||
return len(acts) - 1
|
||||
}
|
||||
|
||||
func (b *ListToolbar) nextEnabledAction(meta ListToolbarMeta, delta int) bool {
|
||||
acts := b.toolbarActions(meta)
|
||||
if len(acts) == 0 {
|
||||
return false
|
||||
}
|
||||
next := b.actionIdx + delta
|
||||
if next >= 0 && next < len(acts) {
|
||||
b.actionIdx = next
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *ListToolbar) SelectedAction(meta ListToolbarMeta) (model.ActionID, bool) {
|
||||
acts := b.toolbarActions(meta)
|
||||
if b.zone != listZoneActions || len(acts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
b.clampActionIdx(meta)
|
||||
return acts[b.actionIdx], true
|
||||
}
|
||||
|
||||
func (b *ListToolbar) MoveAction(delta int, meta ListToolbarMeta) {
|
||||
if b.zone != listZoneActions {
|
||||
return
|
||||
}
|
||||
b.nextEnabledAction(meta, delta)
|
||||
}
|
||||
|
||||
func (b *ListToolbar) Update(msg tea.Msg, meta ListToolbarMeta) tea.Cmd {
|
||||
if b.zone == listZoneFilter {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
cmd := b.filter.Update(msg)
|
||||
switch key.String() {
|
||||
case "tab":
|
||||
b.TabForward(meta)
|
||||
return cmd
|
||||
case "shift+tab", "backtab":
|
||||
b.TabBackward(meta)
|
||||
return cmd
|
||||
case "enter":
|
||||
b.zone = listZoneActions
|
||||
b.actionIdx = 0
|
||||
b.filter.SetFocused(false)
|
||||
b.clampActionIdx(meta)
|
||||
return cmd
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
return b.filter.Update(msg)
|
||||
}
|
||||
if b.zone == listZoneActions {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
case "left", "h":
|
||||
b.MoveAction(-1, meta)
|
||||
case "right", "l":
|
||||
b.MoveAction(1, meta)
|
||||
case "tab":
|
||||
b.TabForward(meta)
|
||||
case "shift+tab", "backtab":
|
||||
b.TabBackward(meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *ListToolbar) View(meta ListToolbarMeta) string {
|
||||
title := b.styles.title.Render(meta.Subject)
|
||||
if meta.Count > 0 || meta.Subject != "" {
|
||||
title = b.styles.title.Render(formatListToolbarTitle(meta))
|
||||
}
|
||||
if meta.LoadingMore {
|
||||
title += lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" …")
|
||||
}
|
||||
|
||||
filter := b.filter.CompactView()
|
||||
buttons, rects := b.renderButtons(meta)
|
||||
|
||||
avail := b.width - runewidth.StringWidth(buttons) - 1
|
||||
if avail < 8 {
|
||||
avail = 8
|
||||
}
|
||||
b.btnX0 = avail + 1
|
||||
for i := range rects {
|
||||
rects[i].x0 += b.btnX0
|
||||
rects[i].x1 += b.btnX0
|
||||
}
|
||||
b.actionRects = rects
|
||||
|
||||
filterLine := padDisplayWidth(filter, avail) + " " + buttons
|
||||
if runewidth.StringWidth(filterLine) > b.width {
|
||||
filterLine = runewidth.Truncate(filterLine, b.width, "")
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
padANSIWidth(title, b.width),
|
||||
padANSIWidth(filterLine, b.width),
|
||||
}
|
||||
return lipgloss.NewStyle().Width(b.width).MaxWidth(b.width).Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func formatListToolbarTitle(meta ListToolbarMeta) string {
|
||||
s := meta.Subject
|
||||
if meta.Count > 0 {
|
||||
s = fmt.Sprintf("%s (%d)", meta.Subject, meta.Count)
|
||||
}
|
||||
if meta.SortLabel != "" {
|
||||
s += " " + meta.SortLabel
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (b *ListToolbar) renderButtons(meta ListToolbarMeta) (string, []toolbarBtnRect) {
|
||||
type btn struct {
|
||||
action model.ActionID
|
||||
icon string
|
||||
enabled bool
|
||||
danger bool
|
||||
}
|
||||
defs := []btn{
|
||||
{model.ActionSave, "💾", meta.SaveEnabled, false},
|
||||
{model.ActionDelete, "🗑", meta.DeleteEnabled, true},
|
||||
}
|
||||
acts := b.toolbarActions(meta)
|
||||
rects := make([]toolbarBtnRect, 0, len(defs))
|
||||
parts := make([]string, 0, len(defs))
|
||||
x := 0
|
||||
for i, d := range defs {
|
||||
active := b.zone == listZoneActions && len(acts) > 0 && b.actionIdx < len(acts) && acts[b.actionIdx] == d.action
|
||||
var style lipgloss.Style
|
||||
switch {
|
||||
case !d.enabled:
|
||||
style = b.styles.btnOff
|
||||
case active && d.danger:
|
||||
style = b.styles.btnDn
|
||||
case active:
|
||||
style = b.styles.btnAct
|
||||
case d.danger:
|
||||
style = b.styles.btn.Foreground(lipgloss.Color("203"))
|
||||
default:
|
||||
style = b.styles.btnOn
|
||||
}
|
||||
rendered := style.Render(d.icon)
|
||||
w := runewidth.StringWidth(rendered)
|
||||
rects = append(rects, toolbarBtnRect{action: d.action, x0: x, x1: x + w})
|
||||
x += w
|
||||
parts = append(parts, rendered)
|
||||
_ = i
|
||||
}
|
||||
return strings.Join(parts, " "), rects
|
||||
}
|
||||
|
||||
func (b *ListToolbar) ActionAt(x int, meta ListToolbarMeta) (model.ActionID, bool) {
|
||||
_ = meta
|
||||
if x < 0 {
|
||||
return "", false
|
||||
}
|
||||
for _, r := range b.actionRects {
|
||||
if x >= r.x0 && x < r.x1 {
|
||||
action := r.action
|
||||
if b.IsActionEnabled(action, meta) {
|
||||
return action, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (b *ListToolbar) IsActionEnabled(action model.ActionID, meta ListToolbarMeta) bool {
|
||||
switch action {
|
||||
case model.ActionSave:
|
||||
return meta.SaveEnabled
|
||||
case model.ActionDelete:
|
||||
return meta.DeleteEnabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestListToolbarShowsFilterAndActions(t *testing.T) {
|
||||
b := newListToolbar()
|
||||
b.SetWidth(60)
|
||||
view := b.View(ListToolbarMeta{
|
||||
Subject: "projects",
|
||||
Count: 3,
|
||||
})
|
||||
if !strings.Contains(view, "projects (3)") {
|
||||
t.Fatalf("missing subject: %q", view)
|
||||
}
|
||||
if !strings.Contains(view, "💾") || !strings.Contains(view, "🗑") {
|
||||
t.Fatalf("missing action icons: %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListToolbarActionsEnabledFromMeta(t *testing.T) {
|
||||
b := newListToolbar()
|
||||
on := ListToolbarMeta{SaveEnabled: true, DeleteEnabled: true}
|
||||
off := ListToolbarMeta{}
|
||||
if !b.IsActionEnabled(model.ActionSave, on) {
|
||||
t.Fatal("save should be enabled")
|
||||
}
|
||||
if b.IsActionEnabled(model.ActionSave, off) {
|
||||
t.Fatal("save should be disabled")
|
||||
}
|
||||
if !b.IsActionEnabled(model.ActionDelete, on) {
|
||||
t.Fatal("delete should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanSaveFromListRequiresSelection(t *testing.T) {
|
||||
m := Model{
|
||||
items: []model.Item{
|
||||
{ID: "1", Title: "A", Kind: model.KindProject, Selected: true},
|
||||
},
|
||||
}
|
||||
if m.canSaveFromList() {
|
||||
t.Fatal("save should require matching loaded detail")
|
||||
}
|
||||
m.detail = newDetailPane()
|
||||
m.detail.SetSize(40, 20)
|
||||
m.detail.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: "A", HasStatus: true,
|
||||
})
|
||||
if !m.canSaveFromList() {
|
||||
t.Fatal("save should be enabled with one selected row and loaded detail")
|
||||
}
|
||||
}
|
||||
+30
-3
@@ -8,7 +8,11 @@ import (
|
||||
func (m *Model) scrollFocusedPane(key string) bool {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
switch key {
|
||||
case "pgdown", "pgdn", "ctrl+d", "pgup", "b", "ctrl+u", "home", "g", "end", "G":
|
||||
return scrollViewport(&m.menuVP, key)
|
||||
}
|
||||
return false
|
||||
case model.FocusList:
|
||||
switch key {
|
||||
case "pgdown", "pgdn", "ctrl+d":
|
||||
@@ -37,17 +41,40 @@ func (m *Model) paneHeight() int {
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Model) paneInnerWidth(outer int) int {
|
||||
if outer <= 0 {
|
||||
const (
|
||||
paneBorderChars = 2 // left + right border drawn outside lipgloss Width
|
||||
panePaddingChars = 2 // Padding(0, 1) on paneStyle
|
||||
)
|
||||
|
||||
// paneLipglossWidth maps an on-screen pane width to lipgloss Style.Width.
|
||||
// Bordered panes render two cells wider than the Width value.
|
||||
func paneLipglossWidth(rendered int) int {
|
||||
if rendered <= 0 {
|
||||
return 8
|
||||
}
|
||||
w := outer - 2 // border only; content fills inner box
|
||||
w := rendered - paneBorderChars
|
||||
if w < 8 {
|
||||
w = 8
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// paneContentWidth is the usable inner width for viewports and tables.
|
||||
func paneContentWidth(rendered int) int {
|
||||
if rendered <= 0 {
|
||||
return 8
|
||||
}
|
||||
w := rendered - paneBorderChars - panePaddingChars
|
||||
if w < 8 {
|
||||
w = 8
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (m *Model) paneInnerWidth(rendered int) int {
|
||||
return paneContentWidth(rendered)
|
||||
}
|
||||
|
||||
func scrollViewport(vp *viewport.Model, key string) bool {
|
||||
switch key {
|
||||
case "up", "k":
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestScrollFocusedPaneMenuUpDownMovesTree(t *testing.T) {
|
||||
m := NewModel(nil)
|
||||
m.focus = model.FocusMenu
|
||||
m.syncMenuContent()
|
||||
before := m.nav.Cursor()
|
||||
|
||||
if m.scrollFocusedPane("down") {
|
||||
t.Fatal("down should not be consumed by menu viewport scroll")
|
||||
}
|
||||
m.moveDown()
|
||||
m.syncFocusedPane()
|
||||
if m.nav.Cursor() != before+1 {
|
||||
t.Fatalf("cursor=%d want %d", m.nav.Cursor(), before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollFocusedPaneMenuPageKeysScrollViewport(t *testing.T) {
|
||||
m := NewModel(nil)
|
||||
m.focus = model.FocusMenu
|
||||
for i := 0; i < 40; i++ {
|
||||
m.nav.MoveDown()
|
||||
}
|
||||
m.syncMenuContent()
|
||||
if !m.scrollFocusedPane("pgup") {
|
||||
t.Fatal("pgup should scroll menu viewport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMenuHorizontalRightOpensLeaf(t *testing.T) {
|
||||
m := NewModel(nil)
|
||||
m.focus = model.FocusMenu
|
||||
// cursor starts on Projects leaf
|
||||
next, cmd := m.handleMenuHorizontal(1)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected load list command")
|
||||
}
|
||||
if next.listSpec.Subject != model.SubjectProjects {
|
||||
t.Fatalf("expected projects list, got %+v", next.listSpec)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
|
||||
@@ -49,7 +48,7 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
|
||||
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||
out := make([]string, height)
|
||||
for row := 0; row < height; row++ {
|
||||
line := padDisplayWidth(lines[row], contentWidth)
|
||||
line := padANSIWidth(lines[row], contentWidth)
|
||||
ch := "│"
|
||||
style := track
|
||||
if row >= thumbStart && row < thumbEnd {
|
||||
@@ -62,15 +61,5 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
|
||||
}
|
||||
|
||||
func padDisplayWidth(line string, width int) string {
|
||||
if width < 1 {
|
||||
return ""
|
||||
}
|
||||
w := runewidth.StringWidth(line)
|
||||
if w > width {
|
||||
return runewidth.Truncate(line, width, "")
|
||||
}
|
||||
if w < width {
|
||||
line += strings.Repeat(" ", width-w)
|
||||
}
|
||||
return line
|
||||
return padANSIWidth(line, width)
|
||||
}
|
||||
|
||||
+60
-172
@@ -9,7 +9,6 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
type tableStyles struct {
|
||||
@@ -80,7 +79,7 @@ func (t *DataTable) SetSize(w, h int) {
|
||||
t.width = w
|
||||
t.height = h
|
||||
t.viewport.Width = w
|
||||
t.viewport.Height = h - 2 // title + header
|
||||
t.viewport.Height = h - 1 // column header
|
||||
if t.viewport.Height < 1 {
|
||||
t.viewport.Height = 1
|
||||
}
|
||||
@@ -260,155 +259,63 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t DataTable) LoadingMore() bool { return t.loadingMore }
|
||||
|
||||
func (t DataTable) View() string {
|
||||
if !t.ready {
|
||||
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n"
|
||||
}
|
||||
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
|
||||
if t.loadingMore {
|
||||
title += t.styles.help.Render(" …")
|
||||
}
|
||||
sortHint := ""
|
||||
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
|
||||
dir := "▲"
|
||||
if !t.sortAsc {
|
||||
dir = "▼"
|
||||
}
|
||||
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
|
||||
return "\nSelect a leaf in the tree (•) and press Enter.\n"
|
||||
}
|
||||
header := t.renderHeader()
|
||||
if t.hasVerticalScrollbar() {
|
||||
header = padANSIWidth(header, t.lineContentWidth())
|
||||
}
|
||||
body := ApplyVerticalScrollbar(
|
||||
t.viewport.View(),
|
||||
t.viewport.Width,
|
||||
t.width,
|
||||
t.viewport.Height,
|
||||
t.viewport.TotalLineCount(),
|
||||
t.viewport.YOffset,
|
||||
)
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
|
||||
return lipgloss.NewStyle().Width(t.width).Render(content)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body)
|
||||
}
|
||||
|
||||
func (t DataTable) SortHint() string {
|
||||
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
|
||||
return ""
|
||||
}
|
||||
dir := "▲"
|
||||
if !t.sortAsc {
|
||||
dir = "▼"
|
||||
}
|
||||
return fmt.Sprintf("sort: %s %s", t.columns[t.sortCol].Title, dir)
|
||||
}
|
||||
|
||||
func (t DataTable) SubjectLabel() string {
|
||||
if !t.ready {
|
||||
return "List"
|
||||
}
|
||||
return string(t.spec.Subject)
|
||||
}
|
||||
|
||||
func (t DataTable) ItemCount() int {
|
||||
return len(t.items)
|
||||
}
|
||||
|
||||
func (t DataTable) hasVerticalScrollbar() bool {
|
||||
return t.ready && t.viewport.TotalLineCount() > t.viewport.Height
|
||||
}
|
||||
|
||||
func (t DataTable) lineContentWidth() int {
|
||||
w := t.width
|
||||
if t.hasVerticalScrollbar() && w > 1 {
|
||||
w--
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
|
||||
widths = make(map[int]int)
|
||||
if len(t.columns) == 0 || t.width <= 0 {
|
||||
return nil, widths
|
||||
}
|
||||
indices = t.pickVisibleColumnIndices()
|
||||
if len(indices) == 0 {
|
||||
return indices, widths
|
||||
}
|
||||
minSum := 0
|
||||
for _, i := range indices {
|
||||
minSum += t.columns[i].Width
|
||||
}
|
||||
widths = distributeColumnWidths(minSum, t.width, indices, t.columns)
|
||||
return indices, widths
|
||||
}
|
||||
|
||||
func (t *DataTable) pickVisibleColumnIndices() []int {
|
||||
var out []int
|
||||
used := 0
|
||||
for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ {
|
||||
w := t.columns[colIdx].Width
|
||||
if len(out) > 0 && used+w > t.width {
|
||||
break
|
||||
}
|
||||
out = append(out, colIdx)
|
||||
used += w
|
||||
}
|
||||
if len(out) == 0 {
|
||||
colIdx := t.colScroll
|
||||
if colIdx < 0 || colIdx >= len(t.columns) {
|
||||
colIdx = 0
|
||||
}
|
||||
out = []int{colIdx}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
|
||||
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
|
||||
out := make(map[int]int, len(indices))
|
||||
if len(indices) == 0 {
|
||||
return out
|
||||
}
|
||||
if total < len(indices) {
|
||||
total = len(indices)
|
||||
}
|
||||
if minSum <= 0 {
|
||||
each := total / len(indices)
|
||||
if each < 1 {
|
||||
each = 1
|
||||
}
|
||||
for _, i := range indices {
|
||||
out[i] = each
|
||||
}
|
||||
fixColumnWidthSum(out, indices, total)
|
||||
return out
|
||||
}
|
||||
for _, i := range indices {
|
||||
out[i] = cols[i].Width
|
||||
}
|
||||
if minSum >= total {
|
||||
for _, i := range indices {
|
||||
out[i] = cols[i].Width * total / minSum
|
||||
if out[i] < 1 {
|
||||
out[i] = 1
|
||||
}
|
||||
}
|
||||
fixColumnWidthSum(out, indices, total)
|
||||
return out
|
||||
}
|
||||
extra := total - minSum
|
||||
flex := make([]int, 0, len(indices))
|
||||
for _, i := range indices {
|
||||
switch cols[i].Key {
|
||||
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type":
|
||||
flex = append(flex, i)
|
||||
}
|
||||
}
|
||||
if len(flex) == 0 {
|
||||
flex = append(flex, indices...)
|
||||
}
|
||||
flexSum := 0
|
||||
for _, i := range flex {
|
||||
flexSum += cols[i].Width
|
||||
}
|
||||
if flexSum <= 0 {
|
||||
flexSum = len(flex)
|
||||
}
|
||||
for _, i := range flex {
|
||||
out[i] += extra * cols[i].Width / flexSum
|
||||
}
|
||||
fixColumnWidthSum(out, indices, total)
|
||||
return out
|
||||
}
|
||||
|
||||
func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
sum := 0
|
||||
for _, i := range indices {
|
||||
sum += widths[i]
|
||||
}
|
||||
widths[indices[len(indices)-1]] += total - sum
|
||||
if widths[indices[len(indices)-1]] < 1 {
|
||||
widths[indices[len(indices)-1]] = 1
|
||||
}
|
||||
}
|
||||
|
||||
const cellHPadding = 2 // lipgloss Padding(0, 1) on each side
|
||||
|
||||
func truncateCellText(text string, colWidth int) string {
|
||||
inner := colWidth - cellHPadding
|
||||
if inner < 1 {
|
||||
inner = 1
|
||||
}
|
||||
if runewidth.StringWidth(text) <= inner {
|
||||
return text
|
||||
}
|
||||
return runewidth.Truncate(text, inner, "...")
|
||||
lay := t.computeLayout()
|
||||
return lay.indices, lay.widths
|
||||
}
|
||||
|
||||
func (t *DataTable) renderHeader() string {
|
||||
@@ -430,7 +337,7 @@ func (t *DataTable) renderHeader() string {
|
||||
if t.focused && colIdx == t.cursorCol {
|
||||
style = t.styles.headerSort
|
||||
}
|
||||
cells = append(cells, style.Width(w).MaxWidth(w).Render(text))
|
||||
cells = append(cells, renderTableCell(style, text, w))
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||
}
|
||||
@@ -450,30 +357,24 @@ func (t *DataTable) refreshViewport() {
|
||||
func (t *DataTable) renderRow(row int) string {
|
||||
item := t.items[t.order[row]]
|
||||
selected := item.Selected
|
||||
fullRow := row == t.cursorRow || selected
|
||||
indices, widths := t.visibleLayout()
|
||||
cells := make([]string, 0, len(indices))
|
||||
for _, colIdx := range indices {
|
||||
col := t.columns[colIdx]
|
||||
w := widths[colIdx]
|
||||
raw := strings.TrimSpace(model.CellText(item, col.Key))
|
||||
text := raw
|
||||
if !fullRow {
|
||||
text = truncateCellText(raw, w)
|
||||
}
|
||||
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow))
|
||||
text := truncateCellText(raw, w)
|
||||
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w))
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||
}
|
||||
|
||||
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
|
||||
if full {
|
||||
return s
|
||||
}
|
||||
return s.Width(width).MaxWidth(width)
|
||||
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int) string {
|
||||
if t.spec.Subject == model.SubjectProjects {
|
||||
style := t.projectCellStyle(row, col, colKey, selected, item)
|
||||
return renderTableCell(style, text, width)
|
||||
}
|
||||
|
||||
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int, fullWidth bool) string {
|
||||
isRow := row == t.cursorRow
|
||||
isCol := col == t.cursorCol
|
||||
isCell := t.focused && isRow && isCol
|
||||
@@ -495,29 +396,16 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m
|
||||
default:
|
||||
base = t.styles.cell
|
||||
}
|
||||
base = t.applyProjectRowStyle(base, item, colKey)
|
||||
return applyCellWidth(base, width, fullWidth).Render(text)
|
||||
}
|
||||
|
||||
func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style {
|
||||
if t.spec.Subject != model.SubjectProjects {
|
||||
return base
|
||||
}
|
||||
open := model.ProjectIsOpen(item.Raw)
|
||||
if colKey == "status" {
|
||||
if open {
|
||||
return base.Foreground(lipgloss.Color("42")).Bold(true)
|
||||
}
|
||||
return base.Foreground(lipgloss.Color("245"))
|
||||
}
|
||||
if open {
|
||||
return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255"))
|
||||
}
|
||||
return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252"))
|
||||
return renderTableCell(base, text, width)
|
||||
}
|
||||
|
||||
func (t *DataTable) ensureColVisible() {
|
||||
vis := t.pickVisibleColumnIndices()
|
||||
if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok && flex.FlexColumnKey != "" {
|
||||
t.colScroll = 0
|
||||
return
|
||||
}
|
||||
contentW := t.lineContentWidth()
|
||||
vis := pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
|
||||
if len(vis) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -528,7 +416,7 @@ func (t *DataTable) ensureColVisible() {
|
||||
if t.cursorCol > last {
|
||||
t.colScroll = t.cursorCol
|
||||
for t.cursorCol >= 0 {
|
||||
vis = t.pickVisibleColumnIndices()
|
||||
vis = pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
|
||||
if len(vis) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
type columnLayout struct {
|
||||
indices []int
|
||||
widths map[int]int
|
||||
}
|
||||
|
||||
func (t *DataTable) computeLayout() columnLayout {
|
||||
contentW := t.lineContentWidth()
|
||||
if len(t.columns) == 0 || contentW <= 0 {
|
||||
return columnLayout{widths: map[int]int{}}
|
||||
}
|
||||
if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok {
|
||||
return layoutFlexTable(t.columns, contentW, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||
}
|
||||
return layoutScrollingTable(t.columns, t.colScroll, contentW)
|
||||
}
|
||||
|
||||
const minFixedColumnWidth = 3
|
||||
|
||||
// layoutFlexTable shows every column; flexKey absorbs leftover width.
|
||||
func layoutFlexTable(cols []model.TableColumn, totalW int, flexKey string, minFlexWidth int) columnLayout {
|
||||
indices := make([]int, len(cols))
|
||||
for i := range cols {
|
||||
indices[i] = i
|
||||
}
|
||||
widths := make(map[int]int, len(cols))
|
||||
|
||||
flexIdx := -1
|
||||
fixed := 0
|
||||
for i, col := range cols {
|
||||
if col.Key == flexKey {
|
||||
flexIdx = i
|
||||
continue
|
||||
}
|
||||
widths[i] = col.Width
|
||||
fixed += widths[i]
|
||||
}
|
||||
if flexIdx < 0 {
|
||||
return layoutScrollingTable(cols, 0, totalW)
|
||||
}
|
||||
|
||||
flexW := totalW - fixed
|
||||
if flexW < minFlexWidth {
|
||||
shrinkFixedColumns(widths, indices, flexIdx, fixed+minFlexWidth-totalW)
|
||||
fixed = 0
|
||||
for _, i := range indices {
|
||||
if i != flexIdx {
|
||||
fixed += widths[i]
|
||||
}
|
||||
}
|
||||
flexW = totalW - fixed
|
||||
if flexW < minFlexWidth {
|
||||
flexW = minFlexWidth
|
||||
}
|
||||
}
|
||||
widths[flexIdx] = flexW
|
||||
normalizeWidthSum(widths, indices, totalW, flexIdx)
|
||||
return columnLayout{indices: indices, widths: widths}
|
||||
}
|
||||
|
||||
func shrinkFixedColumns(widths map[int]int, indices []int, flexIdx, need int) {
|
||||
for need > 0 {
|
||||
changed := false
|
||||
for _, i := range indices {
|
||||
if i == flexIdx || widths[i] <= minFixedColumnWidth {
|
||||
continue
|
||||
}
|
||||
widths[i]--
|
||||
need--
|
||||
changed = true
|
||||
if need == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func layoutScrollingTable(cols []model.TableColumn, colScroll, contentW int) columnLayout {
|
||||
indices := pickVisibleColumnIndices(cols, colScroll, contentW)
|
||||
widths := make(map[int]int, len(indices))
|
||||
if len(indices) == 0 {
|
||||
return columnLayout{indices: indices, widths: widths}
|
||||
}
|
||||
minSum := 0
|
||||
for _, i := range indices {
|
||||
minSum += cols[i].Width
|
||||
}
|
||||
widths = distributeColumnWidths(minSum, contentW, indices, cols)
|
||||
return columnLayout{indices: indices, widths: widths}
|
||||
}
|
||||
|
||||
func pickVisibleColumnIndices(cols []model.TableColumn, colScroll, contentW int) []int {
|
||||
var out []int
|
||||
used := 0
|
||||
for colIdx := colScroll; colIdx < len(cols); colIdx++ {
|
||||
w := cols[colIdx].Width
|
||||
if len(out) > 0 && used+w > contentW {
|
||||
break
|
||||
}
|
||||
out = append(out, colIdx)
|
||||
used += w
|
||||
}
|
||||
if len(out) == 0 {
|
||||
colIdx := colScroll
|
||||
if colIdx < 0 || colIdx >= len(cols) {
|
||||
colIdx = 0
|
||||
}
|
||||
out = []int{colIdx}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
|
||||
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
|
||||
out := make(map[int]int, len(indices))
|
||||
if len(indices) == 0 {
|
||||
return out
|
||||
}
|
||||
if total < len(indices) {
|
||||
total = len(indices)
|
||||
}
|
||||
if minSum <= 0 {
|
||||
each := total / len(indices)
|
||||
if each < 1 {
|
||||
each = 1
|
||||
}
|
||||
for _, i := range indices {
|
||||
out[i] = each
|
||||
}
|
||||
normalizeWidthSum(out, indices, total, indices[len(indices)-1])
|
||||
return out
|
||||
}
|
||||
for _, i := range indices {
|
||||
out[i] = cols[i].Width
|
||||
}
|
||||
if minSum >= total {
|
||||
for _, i := range indices {
|
||||
out[i] = cols[i].Width * total / minSum
|
||||
if out[i] < 1 {
|
||||
out[i] = 1
|
||||
}
|
||||
}
|
||||
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
|
||||
return out
|
||||
}
|
||||
extra := total - minSum
|
||||
flex := flexColumnIndices(indices, cols)
|
||||
flexSum := 0
|
||||
for _, i := range flex {
|
||||
flexSum += cols[i].Width
|
||||
}
|
||||
if flexSum <= 0 {
|
||||
flexSum = len(flex)
|
||||
}
|
||||
for _, i := range flex {
|
||||
out[i] += extra * cols[i].Width / flexSum
|
||||
}
|
||||
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
|
||||
return out
|
||||
}
|
||||
|
||||
func flexColumnIndices(indices []int, cols []model.TableColumn) []int {
|
||||
flex := make([]int, 0, len(indices))
|
||||
for _, i := range indices {
|
||||
switch cols[i].Key {
|
||||
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "type":
|
||||
flex = append(flex, i)
|
||||
}
|
||||
}
|
||||
if len(flex) == 0 {
|
||||
flex = append(flex, indices...)
|
||||
}
|
||||
return flex
|
||||
}
|
||||
|
||||
func flexColumnIndex(indices []int, cols []model.TableColumn) int {
|
||||
flex := flexColumnIndices(indices, cols)
|
||||
if len(flex) == 0 {
|
||||
return indices[len(indices)-1]
|
||||
}
|
||||
for _, i := range indices {
|
||||
if cols[i].Key == "title" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return flex[0]
|
||||
}
|
||||
|
||||
func normalizeWidthSum(widths map[int]int, indices []int, total, adjustIdx int) {
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
sum := 0
|
||||
for _, i := range indices {
|
||||
sum += widths[i]
|
||||
}
|
||||
if adjustIdx < 0 {
|
||||
adjustIdx = indices[len(indices)-1]
|
||||
}
|
||||
widths[adjustIdx] += total - sum
|
||||
if widths[adjustIdx] < 1 {
|
||||
widths[adjustIdx] = 1
|
||||
}
|
||||
}
|
||||
|
||||
const cellHPadding = 0 // padding lives in outer lipgloss styles; column width is the full cell width
|
||||
|
||||
func truncateCellText(text string, colWidth int) string {
|
||||
if colWidth < 1 {
|
||||
colWidth = 1
|
||||
}
|
||||
if runewidth.StringWidth(text) <= colWidth {
|
||||
return text
|
||||
}
|
||||
return runewidth.Truncate(text, colWidth, "…")
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestLayoutFlexTableAllProjectColumnsVisible(t *testing.T) {
|
||||
cols := model.BuildColumns(model.SubjectProjects, nil)
|
||||
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
|
||||
if !ok {
|
||||
t.Fatal("expected flex layout for projects")
|
||||
}
|
||||
lay := layoutFlexTable(cols, 80, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||
if len(lay.indices) != 7 {
|
||||
t.Fatalf("got %d visible columns, want 7", len(lay.indices))
|
||||
}
|
||||
sum := 0
|
||||
for _, i := range lay.indices {
|
||||
sum += lay.widths[i]
|
||||
}
|
||||
if sum != 80 {
|
||||
t.Fatalf("width sum=%d want 80", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutFlexTableUserEmailAbsorbsWidth(t *testing.T) {
|
||||
cols := model.BuildColumns(model.SubjectUsers, nil)
|
||||
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
|
||||
if !ok {
|
||||
t.Fatal("expected flex layout for users")
|
||||
}
|
||||
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||
sum := 0
|
||||
emailW := 0
|
||||
for _, i := range lay.indices {
|
||||
sum += lay.widths[i]
|
||||
if cols[i].Key == "email" {
|
||||
emailW = lay.widths[i]
|
||||
}
|
||||
}
|
||||
if sum != 100 {
|
||||
t.Fatalf("sum=%d want 100", sum)
|
||||
}
|
||||
if emailW < 20 {
|
||||
t.Fatalf("email width=%d should absorb remainder", emailW)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func (t *DataTable) projectBaseStyle(item model.Item) lipgloss.Style {
|
||||
if model.ProjectIsOpen(item.Raw) {
|
||||
return lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("235")).
|
||||
Foreground(lipgloss.Color("108"))
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("238")).
|
||||
Foreground(lipgloss.Color("252"))
|
||||
}
|
||||
|
||||
func (t *DataTable) projectCellStyle(row, col int, colKey string, selected bool, item model.Item) lipgloss.Style {
|
||||
isRow := row == t.cursorRow
|
||||
isCol := col == t.cursorCol
|
||||
isCell := t.focused && isRow && isCol
|
||||
|
||||
switch {
|
||||
case selected && isCell:
|
||||
return t.styles.cellSelect
|
||||
case selected && isRow:
|
||||
return t.styles.rowSelect
|
||||
case selected:
|
||||
return t.styles.rowSelect
|
||||
case isCell:
|
||||
return t.styles.cellActive
|
||||
case t.focused && (isRow || isCol):
|
||||
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
|
||||
default:
|
||||
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DataTable) projectStatusStyle(colKey string, item model.Item, base lipgloss.Style) lipgloss.Style {
|
||||
if colKey != "status" {
|
||||
return base
|
||||
}
|
||||
if model.ProjectIsOpen(item.Raw) {
|
||||
return base.Foreground(lipgloss.Color("42")).Bold(true)
|
||||
}
|
||||
return base.Foreground(lipgloss.Color("245"))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
// renderTableCell renders one column using the bubbles/table pattern:
|
||||
// truncate plain text, constrain with an inline inner box, then apply the outer style.
|
||||
func renderTableCell(outer lipgloss.Style, text string, width int) string {
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
frame := outer.GetHorizontalFrameSize()
|
||||
innerW := width - frame
|
||||
if innerW < 1 {
|
||||
innerW = 1
|
||||
}
|
||||
truncated := runewidth.Truncate(text, innerW, "…")
|
||||
inner := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW).Inline(true)
|
||||
return outer.Render(inner.Render(truncated))
|
||||
}
|
||||
|
||||
// padANSIWidth pads or truncates a styled line without breaking ANSI sequences.
|
||||
func padANSIWidth(line string, width int) string {
|
||||
if width < 1 {
|
||||
return ""
|
||||
}
|
||||
w := ansi.StringWidth(line)
|
||||
if w > width {
|
||||
return ansi.Truncate(line, width, "")
|
||||
}
|
||||
if w < width {
|
||||
return line + repeatSpace(width-w)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func repeatSpace(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = ' '
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
func TestProjectTableHeaderFitsPaneWidth(t *testing.T) {
|
||||
prev := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||
|
||||
for _, w := range []int{68, 50, 40, 30} {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(w, 10)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||
})
|
||||
tbl.SetFocused(true)
|
||||
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
header := tbl.renderHeader()
|
||||
got := runewidth.StringWidth(re.ReplaceAllString(header, ""))
|
||||
if got != w {
|
||||
t.Fatalf("width=%d header display width=%d", w, got)
|
||||
}
|
||||
view := tbl.View()
|
||||
line0 := strings.Split(strings.TrimSuffix(view, "\n"), "\n")[0]
|
||||
gotView := runewidth.StringWidth(re.ReplaceAllString(line0, ""))
|
||||
if gotView < w-1 || gotView > w+1 {
|
||||
t.Fatalf("width=%d view header width=%d", w, gotView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadANSIWidthPreservesStyledLine(t *testing.T) {
|
||||
prev := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||
|
||||
line := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render("ID")
|
||||
padded := padANSIWidth(line, 10)
|
||||
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
if runewidth.StringWidth(re.ReplaceAllString(padded, "")) != 10 {
|
||||
t.Fatalf("expected padded width 10, got %q", padded)
|
||||
}
|
||||
if !strings.Contains(padded, "ID") {
|
||||
t.Fatalf("padding must not destroy cell text: %q", padded)
|
||||
}
|
||||
}
|
||||
+147
-5
@@ -1,11 +1,14 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
func sampleItems() []model.Item {
|
||||
@@ -89,13 +92,125 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTableTitleAbsorbsWidth(t *testing.T) {
|
||||
cols := model.BuildColumns(model.SubjectProjects, nil)
|
||||
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
|
||||
if !ok {
|
||||
t.Fatal("expected flex layout")
|
||||
}
|
||||
lay := layoutFlexTable(cols, 120, flex.FlexColumnKey, flex.MinFlexWidth)
|
||||
if len(lay.indices) != len(cols) {
|
||||
t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices))
|
||||
}
|
||||
sum := 0
|
||||
titleW := 0
|
||||
fixedSum := 0
|
||||
titleIdx := indexOfColumnKey(cols, "title")
|
||||
for _, i := range lay.indices {
|
||||
sum += lay.widths[i]
|
||||
if i == titleIdx {
|
||||
titleW = lay.widths[i]
|
||||
continue
|
||||
}
|
||||
fixedSum += lay.widths[i]
|
||||
}
|
||||
if sum != 120 {
|
||||
t.Fatalf("sum=%d want 120", sum)
|
||||
}
|
||||
if titleW != 120-fixedSum {
|
||||
t.Fatalf("title width=%d want remainder %d", titleW, 120-fixedSum)
|
||||
}
|
||||
if titleW < 40 {
|
||||
t.Fatalf("title should absorb extra width, got %d", titleW)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTableFillsPaneWidth(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(80, 12)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||
})
|
||||
indices, widths := tbl.visibleLayout()
|
||||
if len(indices) != len(tbl.columns) {
|
||||
t.Fatalf("expected all columns visible, got %d/%d", len(indices), len(tbl.columns))
|
||||
}
|
||||
sum := 0
|
||||
for _, i := range indices {
|
||||
sum += widths[i]
|
||||
}
|
||||
if sum != 80 {
|
||||
t.Fatalf("visible columns width sum=%d want 80", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRowRenderWidth(t *testing.T) {
|
||||
prev := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(120, 20)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||
{ID: "abc12345", Title: "My Project Title", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
|
||||
})
|
||||
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
_, widths := tbl.visibleLayout()
|
||||
want := 0
|
||||
for _, w := range widths {
|
||||
want += w
|
||||
}
|
||||
row := re.ReplaceAllString(tbl.renderRow(0), "")
|
||||
got := runewidth.StringWidth(row)
|
||||
if got < want-2 || got > want+2 {
|
||||
t.Fatalf("row display width=%d want ~%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRowStaysSingleLineWhenSelected(t *testing.T) {
|
||||
prev := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||
|
||||
long := strings.Repeat("X", 200)
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(120, 20)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
|
||||
{ID: "1", Title: long, Kind: model.KindProject, Raw: map[string]any{"status": 0}, Selected: true},
|
||||
})
|
||||
if strings.Contains(tbl.renderRow(0), "\n") {
|
||||
t.Fatal("selected project row must not wrap to multiple lines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRowStyleUniformBackground(t *testing.T) {
|
||||
prev := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
|
||||
|
||||
tbl := newDataTable()
|
||||
item := model.Item{Raw: map[string]any{"status": 0}}
|
||||
base := tbl.projectBaseStyle(item)
|
||||
titleStyled := tbl.projectStatusStyle("title", item, base).Render("Open Proj")
|
||||
statusStyled := tbl.projectStatusStyle("status", item, base).Render(model.ProjectStatusCell(item.Raw))
|
||||
if !strings.Contains(titleStyled, "\x1b[") {
|
||||
t.Fatalf("title cell should be styled: %q", titleStyled)
|
||||
}
|
||||
if !strings.Contains(statusStyled, "\x1b[") {
|
||||
t.Fatalf("status cell should be styled: %q", statusStyled)
|
||||
}
|
||||
if titleStyled == statusStyled {
|
||||
t.Fatal("status and title cells should use different foreground styles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateCellText(t *testing.T) {
|
||||
long := strings.Repeat("x", 40)
|
||||
got := truncateCellText(long, 10)
|
||||
if runewidth.StringWidth(got) > 10 {
|
||||
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatalf("expected ellipsis suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -116,8 +231,26 @@ func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
|
||||
}
|
||||
|
||||
tbl.cursorRow = 1
|
||||
if !strings.Contains(tbl.renderRow(1), long) {
|
||||
t.Fatal("cursor row should show full title")
|
||||
if !strings.Contains(tbl.renderRow(1), strings.Repeat("A", 10)) {
|
||||
t.Fatal("cursor row should show title text within column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableRowWidthMatchesColumns(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(80, 12)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||
tbl.SetFocused(true)
|
||||
_, widths := tbl.visibleLayout()
|
||||
want := 0
|
||||
for _, w := range widths {
|
||||
want += w
|
||||
}
|
||||
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
row := re.ReplaceAllString(tbl.renderRow(0), "")
|
||||
got := runewidth.StringWidth(row)
|
||||
if got < want-2 || got > want+2 {
|
||||
t.Fatalf("row display width=%d want ~%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +266,8 @@ func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
|
||||
tbl.cursorRow = 0
|
||||
|
||||
rowSelected := tbl.renderRow(1)
|
||||
if !strings.Contains(rowSelected, long) {
|
||||
t.Fatal("space-selected row should show full title")
|
||||
if !strings.Contains(rowSelected, strings.Repeat("B", 10)) {
|
||||
t.Fatal("space-selected row should show title text within column")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,3 +279,12 @@ func indexOfColumn(tbl DataTable, key string) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func indexOfColumnKey(cols []model.TableColumn, key string) int {
|
||||
for i, c := range cols {
|
||||
if c.Key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
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
|
||||
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")
|
||||
}
|
||||
doc, err := catalog.ScanProjectsRoot(root, maxDepth)
|
||||
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().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
|
||||
_ = cmd.MarkFlagRequired("root")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func catalogScanThunderbirdCmd() *cobra.Command {
|
||||
var outPath string
|
||||
var mboxHeaders bool
|
||||
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; known work domains (e.g. wheregroup.com) get zone=warm role=work.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
root, _ := cmd.Flags().GetString("root")
|
||||
if root == "" {
|
||||
return fmt.Errorf("--root is required")
|
||||
}
|
||||
doc, err := catalog.ScanThunderbirdRootOpts(root, catalog.ScanOptions{MboxHeaders: mboxHeaders})
|
||||
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().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
|
||||
_ = cmd.MarkFlagRequired("root")
|
||||
return cmd
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
+9
-2
@@ -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",
|
||||
"opportunities", "cases", "crm-tasks", "crm", "mails", "invoices",
|
||||
}
|
||||
got := make(map[string]bool, len(rootCmd.Commands()))
|
||||
for _, c := range rootCmd.Commands() {
|
||||
@@ -38,7 +38,7 @@ func TestRootHelpListsSubjects(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
help := out.String()
|
||||
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
|
||||
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities", "invoices"} {
|
||||
if !strings.Contains(help, snippet) {
|
||||
t.Fatalf("help missing %q", snippet)
|
||||
}
|
||||
@@ -53,6 +53,13 @@ func TestProjectsAlias(t *testing.T) {
|
||||
if cmd.Name() != "projects" {
|
||||
t.Fatalf("prj alias resolved to %q", cmd.Name())
|
||||
}
|
||||
ms, _, err := rootCmd.Find([]string{"projects", "milestone-create"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ms.Name() != "milestone-create" {
|
||||
t.Fatalf("milestone-create resolved to %q", ms.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -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).",
|
||||
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,
|
||||
|
||||
+323
-2
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -39,12 +40,20 @@ func init() {
|
||||
contactsCmd.AddCommand(contactsListCmd(nil))
|
||||
contactsCmd.AddCommand(contactsGetCmd())
|
||||
contactsCmd.AddCommand(contactsDeleteCmd())
|
||||
contactsCmd.AddCommand(contactsMergeCmd())
|
||||
contactsCmd.AddCommand(contactsInfoAddCmd())
|
||||
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
||||
contactsCmd.AddCommand(contactsTagsCmd())
|
||||
contactsCmd.AddCommand(contactsTagCreateCmd())
|
||||
contactsCmd.AddCommand(contactsTagAddCmd())
|
||||
contactsCmd.AddCommand(contactsTagRemoveCmd())
|
||||
contactsCmd.AddCommand(contactsByTagCmd())
|
||||
|
||||
only := true
|
||||
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
||||
personsCmd.AddCommand(personsCreateCmd())
|
||||
personsCmd.AddCommand(personsUpdateCmd())
|
||||
personsCmd.AddCommand(personsFixNamesCmd())
|
||||
personsCmd.AddCommand(contactsDeleteCmd())
|
||||
personsCmd.AddCommand(personsDedupeCmd())
|
||||
|
||||
@@ -146,6 +155,26 @@ func contactsDeleteCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func contactsMergeCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "merge FROM_ID INTO_ID",
|
||||
Short: "Merge FROM contact into INTO (FROM is removed)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.MergeContacts(cmd.Context(), args[1], args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func contactsInfoAddCmd() *cobra.Command {
|
||||
var infoType, value, category string
|
||||
var isPrimary bool
|
||||
@@ -177,7 +206,7 @@ func contactsInfoAddCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func personsCreateCmd() *cobra.Command {
|
||||
var first, last, email, linkedin string
|
||||
var first, last, email, linkedin, phone string
|
||||
var companyID int
|
||||
var jobTitle, about string
|
||||
cmd := &cobra.Command{
|
||||
@@ -200,6 +229,9 @@ func personsCreateCmd() *cobra.Command {
|
||||
if email != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
||||
}
|
||||
if phone != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "Phone", phone, "Work", true)
|
||||
}
|
||||
if linkedin != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
|
||||
}
|
||||
@@ -213,12 +245,169 @@ func personsCreateCmd() *cobra.Command {
|
||||
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
|
||||
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
||||
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
|
||||
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func personsUpdateCmd() *cobra.Command {
|
||||
var first, last string
|
||||
var companyID int
|
||||
var jobTitle, about string
|
||||
cmd := &cobra.Command{
|
||||
Use: "update PERSON_ID",
|
||||
Short: "Update a person (name, company, job title, about)",
|
||||
Long: `Updates CRM person fields via PUT /crm/contact/person/{id}.
|
||||
|
||||
--company-id 0 leaves the employer association unchanged.
|
||||
Omit --first/--last to keep current names (fetched first).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid := args[0]
|
||||
cur, err := c.GetContact(cmd.Context(), pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if first == "" {
|
||||
if v, ok := cur["firstName"].(string); ok {
|
||||
first = v
|
||||
}
|
||||
}
|
||||
if last == "" {
|
||||
if v, ok := cur["lastName"].(string); ok {
|
||||
last = v
|
||||
}
|
||||
}
|
||||
if first == "" || last == "" {
|
||||
return fmt.Errorf("first and last name required (pass --first/--last or ensure contact has them)")
|
||||
}
|
||||
out, err := c.UpdatePerson(cmd.Context(), pid, first, last, companyID, jobTitle, about)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&first, "first", "", "first name (default: current)")
|
||||
cmd.Flags().StringVar(&last, "last", "", "last name (default: current)")
|
||||
cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id (0 = leave unchanged)")
|
||||
cmd.Flags().StringVar(&jobTitle, "job-title", "", "job title")
|
||||
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func personsFixNamesCmd() *cobra.Command {
|
||||
var companyID int
|
||||
var dryRun bool
|
||||
var orgHint string
|
||||
cmd := &cobra.Command{
|
||||
Use: "fix-names",
|
||||
Short: "Strip company annotations from last names; keep companyId link",
|
||||
Long: `Repairs CRM persons whose lastName embeds a company ("Doe (Acme)")
|
||||
or whose firstName is an email. Company belongs on companyId, not in the name.
|
||||
|
||||
With --company-id, only persons linked to that company are scanned.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
var list []map[string]any
|
||||
if companyID > 0 {
|
||||
list, err = c.ListCompanyPersons(ctx, strconv.Itoa(companyID))
|
||||
} else {
|
||||
all, err2 := c.ListAllContacts(ctx)
|
||||
err = err2
|
||||
for _, row := range all {
|
||||
if isCo, _ := row["isCompany"].(bool); !isCo {
|
||||
list = append(list, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fixed, skipped := 0, 0
|
||||
for _, p := range list {
|
||||
id := fmt.Sprint(p["id"])
|
||||
// List payloads often omit contactInfos; refresh when name looks like email.
|
||||
fn := strings.TrimSpace(fmt.Sprint(p["firstName"]))
|
||||
if strings.Contains(fn, "@") || fn == "" {
|
||||
if full, gerr := c.GetContact(ctx, id); gerr == nil && full != nil {
|
||||
p = full
|
||||
}
|
||||
}
|
||||
fn = strings.TrimSpace(fmt.Sprint(p["firstName"]))
|
||||
ln := strings.TrimSpace(fmt.Sprint(p["lastName"]))
|
||||
dn := strings.TrimSpace(fmt.Sprint(p["displayName"]))
|
||||
org := orgHint
|
||||
if org == "" {
|
||||
if co, ok := p["company"].(map[string]any); ok {
|
||||
org = strings.TrimSpace(fmt.Sprint(co["displayName"]))
|
||||
}
|
||||
}
|
||||
emails := contactEmailsFromMap(p)
|
||||
for _, row := range onlyoffice.ContactInfoRows(p) {
|
||||
if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) == "email" {
|
||||
if data := strings.TrimSpace(fmt.Sprint(row["data"])); data != "" {
|
||||
emails = append(emails, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
cf, cl := onlyoffice.CleanPersonNames(fn, ln, dn, org, emails)
|
||||
needName := cf != fn || cl != ln
|
||||
linkID := companyID
|
||||
if linkID == 0 {
|
||||
if co, ok := p["company"].(map[string]any); ok {
|
||||
linkID = int(flexIDFloat(co["id"]))
|
||||
}
|
||||
}
|
||||
if !needName {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
row := map[string]any{
|
||||
"id": id, "from": fn + " / " + ln, "to": cf + " / " + cl, "companyId": linkID,
|
||||
}
|
||||
if dryRun {
|
||||
printObject(row)
|
||||
fixed++
|
||||
continue
|
||||
}
|
||||
if _, err := c.UpdatePerson(ctx, id, cf, cl, linkID, "", ""); err != nil {
|
||||
return fmt.Errorf("update %s: %w", id, err)
|
||||
}
|
||||
printObject(row)
|
||||
fixed++
|
||||
}
|
||||
printObject(map[string]any{"fixed": fixed, "skipped": skipped, "dry_run": dryRun})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&companyID, "company-id", 0, "limit to persons of this company")
|
||||
cmd.Flags().StringVar(&orgHint, "org", "", "org name hint for stripping suffixes")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned renames only")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func contactEmailsFromMap(p map[string]any) []string {
|
||||
var out []string
|
||||
for _, k := range []string{"email", "primaryEmail"} {
|
||||
if v := strings.TrimSpace(fmt.Sprint(p[k])); v != "" && v != "<nil>" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func companiesCreateCmd() *cobra.Command {
|
||||
var name, email, website string
|
||||
var name, email, website, phone, about, street, city, state, zip, country string
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Aliases: []string{"add"},
|
||||
@@ -236,12 +425,34 @@ func companiesCreateCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
|
||||
if about != "" || street != "" {
|
||||
aboutText := about
|
||||
if street != "" {
|
||||
addr := strings.TrimSpace(strings.Join([]string{street, zip, city, state, country}, ", "))
|
||||
if aboutText != "" {
|
||||
aboutText = aboutText + "\n" + addr
|
||||
} else {
|
||||
aboutText = "Billing address: " + addr
|
||||
}
|
||||
}
|
||||
if updated, err := c.UpdateCompany(cmd.Context(), cid, name, aboutText); err == nil {
|
||||
out = updated
|
||||
}
|
||||
}
|
||||
if email != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
|
||||
}
|
||||
if website != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
|
||||
}
|
||||
if phone != "" {
|
||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Phone", phone, "Work", true)
|
||||
}
|
||||
if street != "" {
|
||||
if _, err := c.AddContactAddress(cmd.Context(), cid, street, city, state, zip, country, "Billing", true); err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "warning: address API failed (stored in about): %v\n", err)
|
||||
}
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
@@ -249,6 +460,13 @@ func companiesCreateCmd() *cobra.Command {
|
||||
cmd.Flags().StringVar(&name, "name", "", "company name")
|
||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
||||
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
|
||||
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
|
||||
cmd.Flags().StringVar(&about, "about", "", "about / notes")
|
||||
cmd.Flags().StringVar(&street, "street", "", "billing street")
|
||||
cmd.Flags().StringVar(&city, "city", "", "billing city")
|
||||
cmd.Flags().StringVar(&state, "state", "", "billing state")
|
||||
cmd.Flags().StringVar(&zip, "zip", "", "billing zip")
|
||||
cmd.Flags().StringVar(&country, "country", "", "billing country")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -311,3 +529,106 @@ func contactsDedupeInfoCmd() *cobra.Command {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func contactsTagsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tags",
|
||||
Short: "List CRM contact tags",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list, err := c.ListContactTags(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printTable([]string{"title", "relativeItemsCount"}, list)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func contactsTagCreateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tag-create TAG",
|
||||
Short: "Create a CRM contact tag",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.CreateContactTag(cmd.Context(), args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func contactsTagAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tag-add CONTACT_ID TAG",
|
||||
Short: "Attach a tag to a contact",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.AddContactTag(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s ← %s\n", args[0], args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func contactsTagRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tag-remove CONTACT_ID TAG",
|
||||
Short: "Remove a tag from a contact",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.RemoveContactTag(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s ✕ %s\n", args[0], args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func contactsByTagCmd() *cobra.Command {
|
||||
var count, offset int
|
||||
cmd := &cobra.Command{
|
||||
Use: "by-tag TAG",
|
||||
Short: "List contacts with a given CRM tag (ignore-list = tag ignore)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list, total, err := c.ListContactsByTag(cmd.Context(), args[0], count, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outputFormat == "table" {
|
||||
fmt.Printf("tag=%s total: %d (shown: %d)\n", args[0], total, len(list))
|
||||
}
|
||||
printTable([]string{"id", "displayName", "isCompany", "title", "about"}, list)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+103
-3
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -17,6 +18,7 @@ func init() {
|
||||
rootCmd.AddCommand(crmTasksCmd)
|
||||
crmTasksCmd.AddCommand(crmTasksListCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksCreateCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksReassignSelfCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
|
||||
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
|
||||
}
|
||||
@@ -38,7 +40,7 @@ func crmTasksListCmd() *cobra.Command {
|
||||
if outputFormat == "table" {
|
||||
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
||||
}
|
||||
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list)
|
||||
printTable([]string{"id", "title", "deadLine", "isClosed", "categoryID"}, list)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -53,7 +55,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create TITLE",
|
||||
Aliases: []string{"add"},
|
||||
Short: "Create a CRM task",
|
||||
Short: "Create a CRM task (assigns self + deadline default now+14d)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
@@ -68,7 +70,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)")
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601); default now+14d")
|
||||
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
|
||||
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
|
||||
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
|
||||
@@ -77,6 +79,104 @@ func crmTasksCreateCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func crmTaskNeedsOwner(t map[string]any) (need bool, name string) {
|
||||
resp, _ := t["responsible"].(map[string]any)
|
||||
rid, _ := resp["id"].(string)
|
||||
name, _ = resp["displayName"].(string)
|
||||
need = rid == "" || strings.EqualFold(name, "Profile has been removed")
|
||||
return need, name
|
||||
}
|
||||
|
||||
func crmTaskCategoryID(t map[string]any) int {
|
||||
cat, ok := t["category"].(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch v := cat["id"].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func crmTasksReassignSelfCmd() *cobra.Command {
|
||||
var apply bool
|
||||
var count, max int
|
||||
cmd := &cobra.Command{
|
||||
Use: "reassign-self",
|
||||
Short: "Reassign CRM tasks with missing/removed owner to the authenticated user",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := c.SelfUserID(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type item struct {
|
||||
id, title, deadline, ownerName string
|
||||
categoryID int
|
||||
}
|
||||
var todos []item
|
||||
scanned := 0
|
||||
for start := 0; ; start += count {
|
||||
list, total, err := c.ListCRMTasks(cmd.Context(), count, start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range list {
|
||||
scanned++
|
||||
need, name := crmTaskNeedsOwner(t)
|
||||
if !need {
|
||||
continue
|
||||
}
|
||||
title, _ := t["title"].(string)
|
||||
dl, _ := t["deadLine"].(string)
|
||||
todos = append(todos, item{
|
||||
id: fmt.Sprint(t["id"]), title: title, deadline: dl,
|
||||
ownerName: name, categoryID: crmTaskCategoryID(t),
|
||||
})
|
||||
if max > 0 && len(todos) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (max > 0 && len(todos) >= max) || start+count >= total || len(list) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fixed := 0
|
||||
for _, t := range todos {
|
||||
fmt.Printf(" task %s %q owner=%q → %s\n", t.id, t.title, t.ownerName, uid)
|
||||
if !apply {
|
||||
fixed++
|
||||
continue
|
||||
}
|
||||
if _, err := c.UpdateCRMTask(cmd.Context(), t.id, t.title, t.deadline, t.categoryID, uid); err != nil {
|
||||
return fmt.Errorf("update %s: %w", t.id, err)
|
||||
}
|
||||
fixed++
|
||||
}
|
||||
|
||||
mode := "DRY-RUN"
|
||||
if apply {
|
||||
mode = "APPLIED"
|
||||
}
|
||||
fmt.Printf("%s: scanned=%d reassigned=%d (self=%s)\n", mode, scanned, fixed, uid)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&apply, "apply", false, "write changes (default dry-run)")
|
||||
cmd.Flags().IntVar(&count, "count", 100, "page size")
|
||||
cmd.Flags().IntVar(&max, "max", 0, "stop after N candidates (0 = all)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func crmTasksDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete TASK_ID [TASK_ID...]",
|
||||
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(davCmd())
|
||||
}
|
||||
|
||||
// davCmd exposes the Documents module through the backend-agnostic FileStore
|
||||
// (DAV backend). The underlying Dav calls are the oo-webdav proven path:
|
||||
// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy
|
||||
// fileops/move call without those params silently ignores (200 without move).
|
||||
func davCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dav",
|
||||
Short: "Documents module by folder/file id (oo-webdav proven path)",
|
||||
}
|
||||
cmd.AddCommand(davLsCmd())
|
||||
cmd.AddCommand(davMoveCmd())
|
||||
cmd.AddCommand(davCopyCmd())
|
||||
cmd.AddCommand(davMkdirCmd())
|
||||
cmd.AddCommand(davRemoveCmd())
|
||||
cmd.AddCommand(davRenameFileCmd())
|
||||
cmd.AddCommand(davRenameFolderCmd())
|
||||
cmd.AddCommand(davDownloadCmd())
|
||||
cmd.AddCommand(davFileOpsCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davLsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls FOLDER_ID",
|
||||
Short: "List a Documents folder (@root for virtual sections)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if args[0] == "@root" {
|
||||
sections, err := c.ListDavSections(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(sections))
|
||||
for _, s := range sections {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": s.ID,
|
||||
"title": s.Title,
|
||||
"filesCount": s.FilesCount,
|
||||
"foldersCount": s.FoldersCount,
|
||||
})
|
||||
}
|
||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
||||
return nil
|
||||
}
|
||||
entries, err := c.FileStore(onlyoffice.ProviderDAV).List(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folders := make([]onlyoffice.Entry, 0, len(entries))
|
||||
files := make([]onlyoffice.Entry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Kind == onlyoffice.Folder {
|
||||
folders = append(folders, e)
|
||||
} else {
|
||||
files = append(files, e)
|
||||
}
|
||||
}
|
||||
frows := make([]map[string]any, 0, len(folders))
|
||||
for _, f := range folders {
|
||||
frows = append(frows, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
"filesCount": f.FilesCount,
|
||||
"foldersCount": f.FoldersCount,
|
||||
})
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(files))
|
||||
for _, f := range files {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
"size": f.Size,
|
||||
"updated": entryUpdated(f),
|
||||
})
|
||||
}
|
||||
if outputFormat == "json" {
|
||||
printObject(map[string]any{"folders": frows, "files": rows})
|
||||
return nil
|
||||
}
|
||||
if len(frows) > 0 {
|
||||
fmt.Println("folders:")
|
||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
|
||||
}
|
||||
fmt.Println("files:")
|
||||
printTable([]string{"id", "title", "size", "updated"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// entryUpdated prefers the backend-native timestamp string so table/JSON output
|
||||
// round-trips what the API returned.
|
||||
func entryUpdated(e onlyoffice.Entry) string {
|
||||
if e.Updated != "" {
|
||||
return e.Updated
|
||||
}
|
||||
if e.Modified.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return e.Modified.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func davMoveCmd() *cobra.Command {
|
||||
var folderIDs []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "move DEST_FOLDER_ID FILE_ID [FILE_ID...]",
|
||||
Short: "Move file(s) into a Documents folder (resolveType=Skip, holdResult)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Move(cmd.Context(), ids, args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to move along with the files")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davCopyCmd() *cobra.Command {
|
||||
var folderIDs []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "copy DEST_FOLDER_ID FILE_ID [FILE_ID...]",
|
||||
Short: "Copy file(s) into a Documents folder (conflictResolveType=Skip)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := append(append([]string{}, folderIDs...), args[1:]...)
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Copy(cmd.Context(), ids, args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to copy along with the files")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davMkdirCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "mkdir PARENT_FOLDER_ID TITLE",
|
||||
Short: "Create a subfolder in Documents",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := c.FileStore(onlyoffice.ProviderDAV).CreateFolder(cmd.Context(), args[0], args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": f.ID, "title": f.Title, "parent": args[0]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func davRemoveCmd() *cobra.Command {
|
||||
var folderIDs []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm [FILE_ID...]",
|
||||
Aliases: []string{"delete"},
|
||||
Short: "Permanently delete file(s) and/or folder(s) from Documents",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 && len(folderIDs) == 0 {
|
||||
return fmt.Errorf("dav rm: give at least one FILE_ID or --folders")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := append(append([]string{}, args...), folderIDs...)
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Delete(cmd.Context(), ids); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"deleted_files": args, "deleted_folders": folderIDs})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to delete")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davRenameFileCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rename-file FILE_ID NEW_TITLE",
|
||||
Short: "Rename a Documents file (include extension in NEW_TITLE)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func davRenameFolderCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rename-folder FOLDER_ID NEW_TITLE",
|
||||
Short: "Rename a Documents folder",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.FileStore(onlyoffice.ProviderDAV).Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func davDownloadCmd() *cobra.Command {
|
||||
var to string
|
||||
cmd := &cobra.Command{
|
||||
Use: "download FILE_ID",
|
||||
Short: "Download Documents file bytes (default path: ./<title>)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
store := c.FileStore(onlyoffice.ProviderDAV)
|
||||
e, err := store.Stat(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := to
|
||||
if path == "" {
|
||||
path = onlyoffice.SafeLocalFileName(e.Title)
|
||||
}
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
n, err := store.Download(ctx, args[0], out)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"path": path, "bytes": n})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davFileOpsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "fileops",
|
||||
Short: "List active file operations (move/copy status polling)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops, err := c.ListFileOps(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": fmt.Sprint(op["id"]),
|
||||
"operation": fmt.Sprint(op["operation"]),
|
||||
"progress": fmt.Sprint(op["progress"]),
|
||||
"finished": fmt.Sprint(op["finished"]),
|
||||
"error": fmt.Sprint(op["error"]),
|
||||
})
|
||||
}
|
||||
printTable([]string{"id", "operation", "progress", "finished", "error"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/internal/docpipe"
|
||||
"github.com/eslider/go-onlyoffice/internal/xlspipe"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(docsCmd())
|
||||
}
|
||||
|
||||
func docsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "docs",
|
||||
Short: "Local document pipeline: md↔docx, OCR→PDF, extract Markdown",
|
||||
Long: `Agent-friendly conversions (requires pandoc / ocrmypdf / pdftotext on PATH).
|
||||
|
||||
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]
|
||||
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`,
|
||||
}
|
||||
cmd.AddCommand(docsConvertCmd())
|
||||
cmd.AddCommand(docsOptimizeCmd())
|
||||
cmd.AddCommand(docsOCRCmd())
|
||||
cmd.AddCommand(docsHOCRCmd())
|
||||
cmd.AddCommand(docsAsMDCmd())
|
||||
cmd.AddCommand(docsPutMDCmd())
|
||||
cmd.AddCommand(docsPutTxtCmd())
|
||||
cmd.AddCommand(docsPutXlsxCmd())
|
||||
cmd.AddCommand(docsToolsCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsToolsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tools",
|
||||
Short: "Show which converter binaries are on PATH",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
t := docpipe.LookPath()
|
||||
printObject(map[string]any{
|
||||
"pandoc": strOrNil(t.Pandoc),
|
||||
"ocrmypdf": strOrNil(t.OCRMyPDF),
|
||||
"pdftotext": strOrNil(t.PDFToText),
|
||||
"tesseract": strOrNil(t.Tesseract),
|
||||
"ghostscript": strOrNil(t.Ghostscript),
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func strOrNil(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func docsConvertCmd() *cobra.Command {
|
||||
var to string
|
||||
cmd := &cobra.Command{
|
||||
Use: "convert PATH",
|
||||
Short: "Convert a local file with pandoc (md↔docx by default)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
in := args[0]
|
||||
t := docpipe.LookPath()
|
||||
out := to
|
||||
if out == "" {
|
||||
switch docpipe.Ext(in) {
|
||||
case ".md", ".markdown":
|
||||
out = docpipe.SiblingDOCX(in)
|
||||
case ".docx":
|
||||
out = strings.TrimSuffix(in, docpipe.Ext(in)) + ".md"
|
||||
default:
|
||||
return fmt.Errorf("--to required for input type %s", docpipe.Ext(in))
|
||||
}
|
||||
}
|
||||
if err := docpipe.EnsureDir(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.ConvertFile(in, out); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"in": in, "out": out})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&to, "to", "", "output path (default: sibling .docx or .md)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsOptimizeCmd() *cobra.Command {
|
||||
var out string
|
||||
cmd := &cobra.Command{
|
||||
Use: "optimize PDF_PATH",
|
||||
Short: "Rewrite PDF via Ghostscript (PostScript pdfwrite) for OO-friendly size/text",
|
||||
Long: `Use for InDesign/iText PDFs with a good text layer — avoids ocrmypdf invisible
|
||||
text overlays that break OnlyOffice DocEditor. Skips OCR; rewrites via gs pdfwrite.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
in := args[0]
|
||||
t := docpipe.LookPath()
|
||||
if out == "" {
|
||||
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
|
||||
out = filepath.Join(filepath.Dir(in), base+".optimized.pdf")
|
||||
}
|
||||
if err := docpipe.EnsureDir(out); err != nil {
|
||||
return err
|
||||
}
|
||||
chars, _ := t.PDFTextLayerChars(in)
|
||||
if err := t.OptimizePDF(in, out); err != nil {
|
||||
return err
|
||||
}
|
||||
outChars, _ := t.PDFTextLayerChars(out)
|
||||
printObject(map[string]any{
|
||||
"in": in,
|
||||
"pdf": out,
|
||||
"text_chars_in": chars,
|
||||
"text_chars_out": outChars,
|
||||
"note": "native text layer preserved; no OCR overlay",
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&out, "out", "", "output PDF (default: <name>.optimized.pdf)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsOCRCmd() *cobra.Command {
|
||||
var out, mdOut, lang string
|
||||
var force bool
|
||||
var writeMD bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "ocr PATH",
|
||||
Short: "OCR image/PDF → searchable PDF (and optional Markdown)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
in := args[0]
|
||||
t := docpipe.LookPath()
|
||||
if out == "" {
|
||||
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
|
||||
out = filepath.Join(filepath.Dir(in), base+".ocr.pdf")
|
||||
}
|
||||
if err := docpipe.EnsureDir(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := t.OCRToPDF(in, out, force, lang); err != nil {
|
||||
return err
|
||||
}
|
||||
res := map[string]any{"in": in, "pdf": out}
|
||||
if writeMD || mdOut != "" {
|
||||
if mdOut == "" {
|
||||
mdOut = strings.TrimSuffix(out, filepath.Ext(out)) + ".md"
|
||||
}
|
||||
text, err := t.ExtractPDFText(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := "# " + filepath.Base(in) + "\n\n" + strings.TrimSpace(text) + "\n"
|
||||
if err := os.WriteFile(mdOut, []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
res["md"] = mdOut
|
||||
}
|
||||
printObject(res)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&out, "out", "", "output searchable PDF (default: <name>.ocr.pdf)")
|
||||
cmd.Flags().StringVar(&mdOut, "md", "", "write Markdown extraction to this path")
|
||||
cmd.Flags().BoolVar(&writeMD, "markdown", false, "also write sibling .md next to OCR PDF")
|
||||
cmd.Flags().StringVar(&lang, "lang", "eng", "OCR language(s) for tesseract/ocrmypdf")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "force OCR even if a text layer exists (default: skip when pdftotext finds enough text)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsHOCRCmd() *cobra.Command {
|
||||
var mdOut, yamlOut, hocrOut, lang string
|
||||
var dpi int
|
||||
var minConf float32
|
||||
cmd := &cobra.Command{
|
||||
Use: "hocr PATH",
|
||||
Short: "Tesseract hOCR → structured Markdown/YAML (via go-hocr)",
|
||||
Long: `Runs tesseract with hOCR output, parses with go-hocr, writes Markdown
|
||||
(and optional YAML). Better reading order / confidence than plain pdftotext.
|
||||
|
||||
For Spanish scans: --lang spa or spa+eng (needs tesseract-ocr-spa / TESSDATA_PREFIX).
|
||||
Phone photos: --dpi 200..300.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
in := args[0]
|
||||
t := docpipe.LookPath()
|
||||
dir, err := os.MkdirTemp("", "oo-docs-hocr-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
res, err := t.ToHOCRMarkdown(in, dir, lang, dpi, minConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mdOut == "" {
|
||||
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
|
||||
mdOut = filepath.Join(filepath.Dir(in), base+".hocr.md")
|
||||
}
|
||||
if err := docpipe.EnsureDir(mdOut); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(mdOut, []byte(res.Markdown), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := map[string]any{
|
||||
"in": in,
|
||||
"md": mdOut,
|
||||
"did_ocr": res.DidOCR,
|
||||
}
|
||||
if hocrOut != "" {
|
||||
if err := docpipe.EnsureDir(hocrOut); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(res.HOCRPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(hocrOut, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
obj["hocr"] = hocrOut
|
||||
} else {
|
||||
obj["hocr_tmp"] = res.HOCRPath
|
||||
}
|
||||
if yamlOut != "" {
|
||||
if err := docpipe.EnsureDir(yamlOut); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(yamlOut, []byte(res.YAML), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
obj["yaml"] = yamlOut
|
||||
}
|
||||
printObject(obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&mdOut, "md", "", "Markdown output (default: <name>.hocr.md)")
|
||||
cmd.Flags().StringVar(&yamlOut, "yaml", "", "also write structured YAML from go-hocr")
|
||||
cmd.Flags().StringVar(&hocrOut, "hocr", "", "also keep raw .hocr file at this path")
|
||||
cmd.Flags().StringVar(&lang, "lang", "eng", "tesseract language(s), e.g. spa+eng")
|
||||
cmd.Flags().IntVar(&dpi, "dpi", 220, "hint DPI for phone photos / scans (0 = tesseract default)")
|
||||
cmd.Flags().Float32Var(&minConf, "min-conf", 0, "drop words with OCR confidence below this (0 = keep all)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsAsMDCmd() *cobra.Command {
|
||||
var to, lang string
|
||||
var minChars int
|
||||
var uploadOCR bool
|
||||
var useHOCR bool
|
||||
var dpi int
|
||||
var minConf float32
|
||||
cmd := &cobra.Command{
|
||||
Use: "as-md FILE_ID",
|
||||
Short: "Download an OO Documents file and emit Markdown (OCR PDF/image if needed)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
meta, err := c.GetFile(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := onlyoffice.FileEntryTitle(meta)
|
||||
dir, err := os.MkdirTemp("", "oo-docs-as-md-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
local := filepath.Join(dir, onlyoffice.SafeLocalFileName(title))
|
||||
f, err := os.Create(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.DownloadFile(ctx, args[0], f); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
tools := docpipe.LookPath()
|
||||
var res docpipe.Result
|
||||
if useHOCR {
|
||||
hr, err := tools.ToHOCRMarkdown(local, dir, lang, dpi, minConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res = docpipe.Result{Markdown: hr.Markdown, DidOCR: hr.DidOCR, Source: hr.Source}
|
||||
} else {
|
||||
res, err = tools.ToMarkdown(local, dir, lang, minChars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
outPath := to
|
||||
if outPath == "" {
|
||||
base := strings.TrimSuffix(onlyoffice.SafeLocalFileName(title), filepath.Ext(onlyoffice.SafeLocalFileName(title)))
|
||||
if base == "" || base == "download" {
|
||||
base = "file-" + args[0]
|
||||
}
|
||||
outPath = base + ".md"
|
||||
}
|
||||
if err := docpipe.EnsureDir(outPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(outPath, []byte(res.Markdown), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obj := map[string]any{
|
||||
"file_id": args[0],
|
||||
"title": title,
|
||||
"md": outPath,
|
||||
"did_ocr": res.DidOCR,
|
||||
}
|
||||
if res.OCRPDFPath != "" && uploadOCR {
|
||||
folderID := onlyoffice.FileFolderID(meta)
|
||||
upName := strings.TrimSuffix(onlyoffice.SafeLocalFileName(title), filepath.Ext(onlyoffice.SafeLocalFileName(title))) + ".ocr.pdf"
|
||||
tmpUp := filepath.Join(dir, upName)
|
||||
data, err := os.ReadFile(res.OCRPDFPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmpUp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID == "" {
|
||||
obj["ocr_pdf_local"] = res.OCRPDFPath
|
||||
obj["note"] = "file has no folderId; OCR PDF left local — pass after moving into a folder"
|
||||
} else {
|
||||
ent, err := c.UploadToFolder(ctx, folderID, tmpUp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj["ocr_pdf_file_id"] = fileIDStr(ent)
|
||||
obj["ocr_pdf_title"] = onlyoffice.FileEntryTitle(ent)
|
||||
}
|
||||
} else if res.OCRPDFPath != "" {
|
||||
// Keep OCR PDF outside temp by copying beside md if requested via env-less default:
|
||||
kept := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + ".ocr.pdf"
|
||||
if b, err := os.ReadFile(res.OCRPDFPath); err == nil {
|
||||
_ = os.WriteFile(kept, b, 0o644)
|
||||
obj["ocr_pdf_local"] = kept
|
||||
} else {
|
||||
obj["ocr_pdf_local"] = res.OCRPDFPath
|
||||
}
|
||||
}
|
||||
printObject(obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&to, "to", "", "write Markdown to this path (default: ./<title>.md)")
|
||||
cmd.Flags().StringVar(&lang, "lang", "eng", "OCR language")
|
||||
cmd.Flags().IntVar(&minChars, "min-chars", docpipe.DefaultMinTextChars, "OCR PDF if text layer shorter than this")
|
||||
cmd.Flags().BoolVar(&uploadOCR, "upload-ocr", false, "upload searchable OCR PDF back into the same OO folder")
|
||||
cmd.Flags().BoolVar(&useHOCR, "hocr", false, "use tesseract hOCR + go-hocr instead of ocrmypdf/pdftotext")
|
||||
cmd.Flags().IntVar(&dpi, "dpi", 220, "DPI hint when --hocr (phone photos)")
|
||||
cmd.Flags().Float32Var(&minConf, "min-conf", 0, "drop low-confidence words when --hocr")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsPutMDCmd() *cobra.Command {
|
||||
var folderID string
|
||||
var keepLocalDOCX string
|
||||
var replace bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "put-md PROJECT_ID MARKDOWN_PATH",
|
||||
Short: "Convert Markdown→DOCX and upload DOCX into a project (OO-friendly)",
|
||||
Long: `Agents edit .md locally; this uploads .docx so OnlyOffice can open/version it.`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pid, mdPath := args[0], args[1]
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tools := docpipe.LookPath()
|
||||
dir, err := os.MkdirTemp("", "oo-docs-put-md-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
docxName := strings.TrimSuffix(filepath.Base(mdPath), filepath.Ext(mdPath)) + ".docx"
|
||||
docxPath := filepath.Join(dir, docxName)
|
||||
if err := tools.MDToDOCX(mdPath, docxPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if keepLocalDOCX != "" {
|
||||
if err := docpipe.EnsureDir(keepLocalDOCX); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(docxPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(keepLocalDOCX, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj := map[string]any{
|
||||
"project_id": pid,
|
||||
"md": mdPath,
|
||||
"uploaded": fileEntryToMap(ent),
|
||||
}
|
||||
if folderID != "" {
|
||||
obj["folder_id"] = folderID
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
obj["replaced_file_ids"] = deleted
|
||||
}
|
||||
printObject(obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
||||
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsPutTxtCmd() *cobra.Command {
|
||||
var folderID string
|
||||
var keepLocalDOCX string
|
||||
var replace bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "put-txt PROJECT_ID TEXT_PATH",
|
||||
Short: "Convert plain text→DOCX (preserve line breaks) and upload into a project",
|
||||
Long: `OnlyOffice cannot render .txt well. This keeps each source line on its own DOCX line.`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pid, txtPath := args[0], args[1]
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tools := docpipe.LookPath()
|
||||
dir, err := os.MkdirTemp("", "oo-docs-put-txt-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
docxName := strings.TrimSuffix(filepath.Base(txtPath), filepath.Ext(txtPath)) + ".docx"
|
||||
docxPath := filepath.Join(dir, docxName)
|
||||
if err := tools.TXTToDOCX(txtPath, docxPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if keepLocalDOCX != "" {
|
||||
if err := docpipe.EnsureDir(keepLocalDOCX); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(docxPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(keepLocalDOCX, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj := map[string]any{
|
||||
"project_id": pid,
|
||||
"txt": txtPath,
|
||||
"uploaded": fileEntryToMap(ent),
|
||||
}
|
||||
if folderID != "" {
|
||||
obj["folder_id"] = folderID
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
obj["replaced_file_ids"] = deleted
|
||||
}
|
||||
printObject(obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
||||
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func docsPutXlsxCmd() *cobra.Command {
|
||||
var folderID, template, title, 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.
|
||||
|
||||
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),
|
||||
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")
|
||||
}
|
||||
|
||||
if keepLocal != "" {
|
||||
b, err := os.ReadFile(xlsxPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := docpipe.EnsureDir(keepLocal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(keepLocal, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
ent, deleted, err := uploadProjectDoc(ctx, c, pid, xlsxPath, folderID, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj := map[string]any{
|
||||
"project_id": pid,
|
||||
"source": srcLabel,
|
||||
"uploaded": fileEntryToMap(ent),
|
||||
}
|
||||
if folderID != "" {
|
||||
obj["folder_id"] = folderID
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
obj["replaced_file_ids"] = deleted
|
||||
}
|
||||
printObject(obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
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().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// uploadProjectDoc upserts (--replace, default) or no-clobbers into project/folder Documents.
|
||||
func uploadProjectDoc(ctx context.Context, c *onlyoffice.Client, pid, localPath, folderID string, replace bool) (*onlyoffice.FileEntry, []int, error) {
|
||||
if folderID != "" {
|
||||
if replace {
|
||||
return c.UploadToFolderReplacing(ctx, folderID, localPath)
|
||||
}
|
||||
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ent, err := c.UploadToFolder(ctx, folderID, localPath)
|
||||
return ent, nil, err
|
||||
}
|
||||
if replace {
|
||||
return c.UploadProjectFileReplacing(ctx, pid, localPath)
|
||||
}
|
||||
ent, err := c.UploadProjectFileNoClobber(ctx, pid, localPath)
|
||||
return ent, nil, err
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(indexCmd())
|
||||
}
|
||||
|
||||
// indexFlags are shared by the `oo index folder` and `oo index files` verbs.
|
||||
type indexFlags struct {
|
||||
recursive bool
|
||||
exts string
|
||||
limit int
|
||||
workers int
|
||||
lang string
|
||||
minChars int
|
||||
workDir string
|
||||
backend string
|
||||
dryRun bool
|
||||
asJSON bool
|
||||
}
|
||||
|
||||
// indexCmd populates the own full-text index (oo_docs_text) that makes PDF and
|
||||
// scanned content searchable. The OnlyOffice index is left untouched.
|
||||
func indexCmd() *cobra.Command {
|
||||
f := &indexFlags{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "index",
|
||||
Short: "Populate the own full-text index for PDF/scan content",
|
||||
Long: "Index document text that the OnlyOffice Elasticsearch index does not\n" +
|
||||
"cover (PDFs and scans) into a separate index (ONLYOFFICE_ES_TEXT_INDEX,\n" +
|
||||
"default oo_docs_text). Text is extracted with docpipe (pdftotext, OCR)\n" +
|
||||
"and the OnlyOffice server is never modified.\n\n" +
|
||||
"Requires ONLYOFFICE_URL/USER/PASS (to download files) and ONLYOFFICE_ES_URL\n" +
|
||||
"(to write the index). See docs/elasticsearch.md.",
|
||||
}
|
||||
cmd.PersistentFlags().BoolVar(&f.recursive, "recursive", false, "folder: descend into subfolders")
|
||||
cmd.PersistentFlags().StringVar(&f.exts, "exts", "pdf", "comma-separated extensions to index")
|
||||
cmd.PersistentFlags().IntVar(&f.limit, "limit", 0, "maximum number of files to index (0 = all)")
|
||||
cmd.PersistentFlags().IntVar(&f.workers, "workers", 3, "parallel downloads/extractions")
|
||||
cmd.PersistentFlags().StringVar(&f.lang, "lang", "deu+eng", "OCR language(s)")
|
||||
cmd.PersistentFlags().IntVar(&f.minChars, "min-chars", 0, "text-layer threshold below which OCR runs")
|
||||
cmd.PersistentFlags().StringVar(&f.workDir, "work-dir", "", "temp dir for downloads (default: system temp)")
|
||||
cmd.PersistentFlags().StringVar(&f.backend, "backend", "rest", "file backend: rest|dav")
|
||||
cmd.PersistentFlags().BoolVar(&f.dryRun, "dry-run", false, "list what would be indexed, without changes")
|
||||
cmd.PersistentFlags().BoolVar(&f.asJSON, "json", false, "shorthand for --output json")
|
||||
|
||||
cmd.AddCommand(indexFolderCmd(f), indexFilesCmd(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func indexFolderCmd(f *indexFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "folder FOLDER_ID",
|
||||
Short: "Index every matching file in a Documents folder",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runIndex(cmd, f, args[0], nil)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func indexFilesCmd(f *indexFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "files FILE_ID...",
|
||||
Short: "Index specific Documents files",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runIndex(cmd, f, "", args)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runIndex(cmd *cobra.Command, f *indexFlags, folderID string, ids []string) error {
|
||||
if f.asJSON {
|
||||
outputFormat = "json"
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := onlyoffice.NewESTextIndex(onlyoffice.ESTextConfigFromEnv())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ti := onlyoffice.NewTextIndexer(c.FileStore(f.backend), idx)
|
||||
ti.WorkDir = f.workDir
|
||||
opts := onlyoffice.IndexOptions{
|
||||
Recursive: f.recursive,
|
||||
Extensions: splitList(f.exts),
|
||||
Limit: f.limit,
|
||||
Lang: f.lang,
|
||||
MinChars: f.minChars,
|
||||
Workers: f.workers,
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
|
||||
if f.dryRun {
|
||||
var entries []onlyoffice.Entry
|
||||
if folderID != "" {
|
||||
entries, err = ti.PlanFolder(ctx, folderID, opts)
|
||||
} else {
|
||||
entries, err = ti.PlanFiles(ctx, ids, opts)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": e.ID,
|
||||
"title": e.Title,
|
||||
"folder": e.ParentID,
|
||||
})
|
||||
}
|
||||
printTable([]string{"id", "title", "folder"}, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ti.Ensure(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
var res onlyoffice.IndexResult
|
||||
if folderID != "" {
|
||||
res, err = ti.IndexFolder(ctx, folderID, opts)
|
||||
} else {
|
||||
res, err = ti.IndexFiles(ctx, ids, opts)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{
|
||||
"index": idx.Index(),
|
||||
"scanned": res.Scanned,
|
||||
"indexed": res.Indexed,
|
||||
"skipped": res.Skipped,
|
||||
"failed": res.Failed,
|
||||
"errors": res.Errors,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitList parses a comma-separated flag value, dropping blanks.
|
||||
func splitList(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexCommandRegistered(t *testing.T) {
|
||||
cmd, _, err := rootCmd.Find([]string{"index"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd.Name() != "index" {
|
||||
t.Fatalf("index resolved to %q", cmd.Name())
|
||||
}
|
||||
for _, name := range []string{"exts", "limit", "workers", "lang", "min-chars", "work-dir", "backend", "dry-run", "recursive", "json"} {
|
||||
if cmd.PersistentFlags().Lookup(name) == nil {
|
||||
t.Errorf("index: missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if cmd.PersistentFlags().Lookup("exts").DefValue != "pdf" {
|
||||
t.Errorf("--exts default = %q, want pdf", cmd.PersistentFlags().Lookup("exts").DefValue)
|
||||
}
|
||||
for _, verb := range []string{"index folder", "index files"} {
|
||||
sub, _, err := rootCmd.Find(strings.Fields(verb))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", verb, err)
|
||||
}
|
||||
if sub.Name() != strings.Fields(verb)[1] {
|
||||
t.Errorf("%s resolved to %q", verb, sub.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexSearchBackendFlag(t *testing.T) {
|
||||
cmd, _, err := rootCmd.Find([]string{"search"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd.Flags().Lookup("backend") == nil {
|
||||
t.Fatal("search: missing --backend flag")
|
||||
}
|
||||
if cmd.Flags().Lookup("backend").DefValue != "oo" {
|
||||
t.Errorf("--backend default = %q, want oo", cmd.Flags().Lookup("backend").DefValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitList(t *testing.T) {
|
||||
got := splitList(" pdf , .PDF, docx ,, ")
|
||||
want := []string{"pdf", ".PDF", "docx"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("splitList = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var invoicesCmd = &cobra.Command{
|
||||
Use: "invoices",
|
||||
Aliases: []string{"invoice"},
|
||||
Short: "CRM invoices (billing)",
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(invoicesCmd)
|
||||
invoicesCmd.AddCommand(invoiceListCmd())
|
||||
invoicesCmd.AddCommand(invoiceGetCmd())
|
||||
invoicesCmd.AddCommand(invoiceCreateCmd())
|
||||
invoicesCmd.AddCommand(invoiceUpdateCmd())
|
||||
invoicesCmd.AddCommand(invoicePDFCmd())
|
||||
invoicesCmd.AddCommand(invoicePDFCleanupCmd())
|
||||
invoicesCmd.AddCommand(invoiceStatusCmd())
|
||||
invoicesCmd.AddCommand(invoiceDeleteCmd())
|
||||
invoicesCmd.AddCommand(invoiceItemsCmd())
|
||||
}
|
||||
|
||||
func invoiceListCmd() *cobra.Command {
|
||||
var count, offset int
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List CRM invoices",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list, total, err := c.ListInvoices(cmd.Context(), count, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(list))
|
||||
for _, inv := range list {
|
||||
rows = append(rows, onlyoffice.FlattenInvoiceRow(inv))
|
||||
}
|
||||
if outputFormat == "table" {
|
||||
fmt.Printf("total: %d (shown: %d)\n", total, len(rows))
|
||||
}
|
||||
printTable([]string{"id", "number", "cost", "statusTitle", "contactName", "issueDate", "dueDate"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoiceGetCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get INVOICE_ID",
|
||||
Short: "Show an invoice by id (incl. lines)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.GetInvoice(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func invoiceCreateCmd() *cobra.Command {
|
||||
var (
|
||||
number, issueDate, dueDate, language, currency, terms, description, po string
|
||||
contactID, consigneeID, itemID, opportunityID int64
|
||||
price, qty float64
|
||||
lineDesc string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a draft invoice with one line",
|
||||
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.
|
||||
|
||||
Example:
|
||||
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…"
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if number == "" || contactID == 0 || itemID == 0 {
|
||||
return fmt.Errorf("--number, --contact and --item are required")
|
||||
}
|
||||
if issueDate == "" {
|
||||
issueDate = time.Now().Format("2006-01-02") + "T00:00:00.0000000+01:00"
|
||||
} else if !strings.Contains(issueDate, "T") {
|
||||
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"
|
||||
} else if !strings.Contains(dueDate, "T") {
|
||||
dueDate = dueDate + "T00:00:00.0000000+01:00"
|
||||
}
|
||||
if qty == 0 {
|
||||
qty = 1
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.CreateInvoice(cmd.Context(), onlyoffice.CreateInvoiceParams{
|
||||
Number: number,
|
||||
IssueDate: issueDate,
|
||||
DueDate: dueDate,
|
||||
ContactID: contactID,
|
||||
ConsigneeID: consigneeID,
|
||||
EntityID: opportunityID,
|
||||
EntityType: 0, // Opportunity
|
||||
Language: language,
|
||||
Currency: currency,
|
||||
ExchangeRate: 1,
|
||||
PurchaseOrderNumber: po,
|
||||
Terms: terms,
|
||||
Description: description,
|
||||
Lines: []onlyoffice.InvoiceLine{{
|
||||
InvoiceItemID: itemID,
|
||||
Description: lineDesc,
|
||||
Quantity: qty,
|
||||
Price: price,
|
||||
SortOrder: 0,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
|
||||
cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to company contact id (canonical company, not a PDF-only clone)")
|
||||
cmd.Flags().Int64Var(&consigneeID, "consignee", 0, "optional Empfänger person/contact id")
|
||||
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id (set at create)")
|
||||
cmd.Flags().Int64Var(&itemID, "item", 0, "catalog invoice item id")
|
||||
cmd.Flags().Float64Var(&price, "price", 0, "line price")
|
||||
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
|
||||
cmd.Flags().StringVar(&lineDesc, "line-description", "", "line description override")
|
||||
cmd.Flags().StringVar(&issueDate, "issue-date", "", "YYYY-MM-DD (default today)")
|
||||
cmd.Flags().StringVar(&dueDate, "due-date", "", "YYYY-MM-DD (default +14d)")
|
||||
cmd.Flags().StringVar(&language, "language", "de-DE", "invoice language")
|
||||
cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency abbreviation")
|
||||
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer")
|
||||
cmd.Flags().StringVar(&description, "description", "", "invoice description")
|
||||
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoiceUpdateCmd() *cobra.Command {
|
||||
var opportunityID int64
|
||||
var description, po, terms string
|
||||
cmd := &cobra.Command{
|
||||
Use: "update INVOICE_ID",
|
||||
Short: "Update invoice (notes, PO, terms; opportunity link unreliable)",
|
||||
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.
|
||||
`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") && !cmd.Flags().Changed("terms") {
|
||||
return fmt.Errorf("set --opportunity and/or --description and/or --po and/or --terms")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.UpdateInvoice(cmd.Context(), args[0], onlyoffice.UpdateInvoiceParams{
|
||||
EntityID: opportunityID,
|
||||
EntityType: 0,
|
||||
Description: description,
|
||||
DescriptionSet: cmd.Flags().Changed("description"),
|
||||
PurchaseOrder: po,
|
||||
PurchaseOrderSet: cmd.Flags().Changed("po"),
|
||||
Terms: terms,
|
||||
TermsSet: cmd.Flags().Changed("terms"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id (may 400 — prefer create)")
|
||||
cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
|
||||
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
|
||||
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer (Bedingungen)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoicePDFCmd() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "pdf INVOICE_ID",
|
||||
Short: "Return / regenerate invoice PDF file metadata",
|
||||
Long: `GET /api/2.0/crm/invoice/{id}/pdf.
|
||||
|
||||
With --force, touches the Draft to clear cached fileID first (layout changes).
|
||||
`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out map[string]any
|
||||
if force {
|
||||
out, err = c.ForceRegenerateInvoicePDF(cmd.Context(), args[0])
|
||||
} else {
|
||||
out, err = c.InvoicePDFFile(cmd.Context(), args[0])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&force, "force", false, "clear PDF cache then regenerate")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoicePDFCleanupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "pdf-cleanup INVOICE_ID",
|
||||
Short: "Delete older P-*.pdf copies on company/deal; keep invoice.fileID",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted, err := c.PurgeStaleInvoicePDFs(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outputFormat == "json" {
|
||||
printObject(map[string]any{"deleted": deleted})
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("deleted %d stale PDF file(s): %v\n", len(deleted), deleted)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func invoiceStatusCmd() *cobra.Command {
|
||||
var status string
|
||||
cmd := &cobra.Command{
|
||||
Use: "status INVOICE_ID [INVOICE_ID...]",
|
||||
Short: "Set invoice status (draft|billed|rejected|paid)",
|
||||
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).
|
||||
`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
statusID, err := parseInvoiceStatus(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]int64, 0, len(args))
|
||||
for _, a := range args {
|
||||
var id int64
|
||||
if _, err := fmt.Sscan(a, &id); err != nil || id <= 0 {
|
||||
return fmt.Errorf("invalid invoice id %q", a)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.SetInvoiceStatus(cmd.Context(), statusID, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&status, "status", "draft", "draft|billed|rejected|paid or numeric id")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseInvoiceStatus(s string) (int, error) {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
switch s {
|
||||
case "", "draft", "1":
|
||||
return onlyoffice.InvoiceStatusDraft, nil
|
||||
case "billed", "2":
|
||||
return onlyoffice.InvoiceStatusBilled, nil
|
||||
case "rejected", "3":
|
||||
return onlyoffice.InvoiceStatusRejected, nil
|
||||
case "paid", "4":
|
||||
return onlyoffice.InvoiceStatusPaid, nil
|
||||
default:
|
||||
var n int
|
||||
if _, err := fmt.Sscan(s, &n); err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("unknown status %q (draft|billed|rejected|paid)", s)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
|
||||
func invoiceDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete INVOICE_ID [INVOICE_ID...]",
|
||||
Short: "Delete one or more invoices",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range args {
|
||||
if _, err := c.DeleteInvoice(cmd.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("deleted", id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func invoiceItemsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "items",
|
||||
Aliases: []string{"item"},
|
||||
Short: "Invoice catalog items",
|
||||
}
|
||||
cmd.AddCommand(invoiceItemsListCmd())
|
||||
cmd.AddCommand(invoiceItemsCreateCmd())
|
||||
cmd.AddCommand(invoiceItemsDeleteCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoiceItemsListCmd() *cobra.Command {
|
||||
var count, offset int
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List catalog invoice items",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list, total, err := c.ListInvoiceItems(cmd.Context(), count, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outputFormat == "table" {
|
||||
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
||||
for _, row := range list {
|
||||
if cur, ok := row["currency"].(map[string]any); ok {
|
||||
row["currency"] = cur["abbreviation"]
|
||||
}
|
||||
}
|
||||
}
|
||||
printTable([]string{"id", "title", "price", "currency", "description"}, list)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&count, "count", 50, "")
|
||||
cmd.Flags().IntVar(&offset, "offset", 0, "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoiceItemsCreateCmd() *cobra.Command {
|
||||
var title, description, currency string
|
||||
var price float64
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a catalog invoice item",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if title == "" {
|
||||
return fmt.Errorf("--title is required")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.CreateInvoiceItem(cmd.Context(), title, description, price, currency)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&title, "title", "", "item title")
|
||||
cmd.Flags().StringVar(&description, "description", "", "item description")
|
||||
cmd.Flags().Float64Var(&price, "price", 0, "unit price")
|
||||
cmd.Flags().StringVar(¤cy, "currency", "EUR", "currency")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func invoiceItemsDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete ITEM_ID [ITEM_ID...]",
|
||||
Short: "Delete catalog invoice items",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range args {
|
||||
if _, err := c.DeleteInvoiceItem(cmd.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("deleted", id)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
+288
-1
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -11,7 +13,7 @@ import (
|
||||
var mailsCmd = &cobra.Command{
|
||||
Use: "mails",
|
||||
Aliases: []string{"mail"},
|
||||
Short: "OnlyOffice Workspace mail — list, read, delete",
|
||||
Short: "OnlyOffice Workspace mail — list, read, draft, delete",
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -20,6 +22,11 @@ func init() {
|
||||
mailsCmd.AddCommand(mailsFoldersCmd())
|
||||
mailsCmd.AddCommand(mailsListCmd())
|
||||
mailsCmd.AddCommand(mailsGetCmd())
|
||||
mailsCmd.AddCommand(mailsDownloadAttachmentCmd())
|
||||
mailsCmd.AddCommand(mailsDraftCmd())
|
||||
mailsCmd.AddCommand(mailsAttachCmd())
|
||||
mailsCmd.AddCommand(mailsDraftInvoiceCmd())
|
||||
mailsCmd.AddCommand(mailsSendCmd())
|
||||
mailsCmd.AddCommand(mailsDeleteCmd())
|
||||
}
|
||||
|
||||
@@ -126,6 +133,286 @@ func mailsGetCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func mailsDownloadAttachmentCmd() *cobra.Command {
|
||||
var outPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "download-attachment ATTACHMENT_ID",
|
||||
Short: "Download a mail attachment by attachment id",
|
||||
Long: `Download a raw attachment from OnlyOffice Mail's download.ashx handler.
|
||||
|
||||
Example:
|
||||
oo mails download-attachment 12345 --out /tmp/attach.bin
|
||||
`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(outPath) == "" {
|
||||
return fmt.Errorf("--out is required")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := c.DownloadMailAttachment(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeMailAttachment(outPath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if outputFormat == "json" {
|
||||
printObject(map[string]any{
|
||||
"attachmentId": args[0],
|
||||
"bytes": len(body),
|
||||
"path": outPath,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("saved %d bytes to %s\n", len(body), outPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&outPath, "out", "", "output file path")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mailsDraftCmd() *cobra.Command {
|
||||
var from, to, cc, bcc, subject, body, html string
|
||||
var id int64
|
||||
cmd := &cobra.Command{
|
||||
Use: "draft",
|
||||
Short: "Create or update a mail draft (OnlyOffice Mail)",
|
||||
Long: `Save a draft in /addons/mail (PUT /api/2.0/mail/drafts/save).
|
||||
|
||||
oo mails draft --to a@b.com --subject "…" --body "<p>…</p>"
|
||||
oo mails draft --id 123 --to a@b.com --subject "…" --html "<p>…</p>"
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if to == "" {
|
||||
return fmt.Errorf("--to is required")
|
||||
}
|
||||
htmlBody := body
|
||||
if html != "" {
|
||||
htmlBody = html
|
||||
}
|
||||
if htmlBody == "" {
|
||||
return fmt.Errorf("--body or --html is required")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
|
||||
ID: id,
|
||||
From: from,
|
||||
To: to,
|
||||
Cc: cc,
|
||||
Bcc: bcc,
|
||||
Subject: subject,
|
||||
Body: onlyoffice.PlainTextToMailHTML(htmlBody),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id (0 = create)")
|
||||
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
|
||||
cmd.Flags().StringVar(&to, "to", "", "recipient (required)")
|
||||
cmd.Flags().StringVar(&cc, "cc", "", "cc")
|
||||
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
|
||||
cmd.Flags().StringVar(&subject, "subject", "", "subject")
|
||||
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
|
||||
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mailsAttachCmd() *cobra.Command {
|
||||
var fileID int64
|
||||
cmd := &cobra.Command{
|
||||
Use: "attach MESSAGE_ID",
|
||||
Short: "Attach an OnlyOffice Files document to a draft/message",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if fileID <= 0 {
|
||||
return fmt.Errorf("--file-id is required")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.AttachMailDocument(cmd.Context(), args[0], fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Int64Var(&fileID, "file-id", 0, "OnlyOffice file id (e.g. invoice PDF)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mailsDraftInvoiceCmd() *cobra.Command {
|
||||
var to, from, subject, body string
|
||||
var invoiceID int
|
||||
cmd := &cobra.Command{
|
||||
Use: "draft-invoice",
|
||||
Short: "Create a mail draft with regenerated invoice PDF attached",
|
||||
Long: `Regenerate the CRM invoice PDF, save an OnlyOffice Mail draft, and attach the PDF.
|
||||
|
||||
Does not send. Open /addons/mail/#drafts to review.
|
||||
|
||||
oo mails draft-invoice --invoice 16 --to info@example.com
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if invoiceID <= 0 || to == "" {
|
||||
return fmt.Errorf("--invoice and --to are required")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pdf, err := c.ForceRegenerateInvoicePDF(cmd.Context(), strconv.Itoa(invoiceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := onlyoffice.Int64FromMap(pdf, "id")
|
||||
if fileID <= 0 {
|
||||
return fmt.Errorf("invoice PDF has no file id: %+v", pdf)
|
||||
}
|
||||
inv, err := c.GetInvoice(cmd.Context(), strconv.Itoa(invoiceID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
number := strings.TrimSpace(fmt.Sprint(inv["number"]))
|
||||
cost := formatInvoiceCostEUR(inv["cost"])
|
||||
if subject == "" {
|
||||
subject = fmt.Sprintf("Invoice %s (%s EUR)", number, cost)
|
||||
}
|
||||
if body == "" {
|
||||
body = fmt.Sprintf(`Hello,
|
||||
|
||||
please find attached invoice %s for %s EUR.
|
||||
|
||||
Payment terms: see invoice notes.
|
||||
|
||||
Best regards`, number, cost)
|
||||
}
|
||||
draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: onlyoffice.MailHTMLWithBlankParagraphs(onlyoffice.PlainTextToMailHTML(body)),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mid := fmt.Sprint(draft["id"])
|
||||
att, err := c.AttachMailDocument(cmd.Context(), mid, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("draft %s created but attach failed: %w", mid, err)
|
||||
}
|
||||
if outputFormat == "json" {
|
||||
printObject(map[string]any{"draft": draft, "attachment": att, "pdfFileId": fileID})
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("draft %s to=%s subject=%q pdfFileId=%d\n", mid, to, subject, fileID)
|
||||
fmt.Printf("open: %s/addons/mail/#drafts\n", strings.TrimRight(os.Getenv("ONLYOFFICE_URL"), "/"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&invoiceID, "invoice", 0, "CRM invoice id")
|
||||
cmd.Flags().StringVar(&to, "to", "", "recipient")
|
||||
cmd.Flags().StringVar(&from, "from", "", "from address (default mailbox)")
|
||||
cmd.Flags().StringVar(&subject, "subject", "", "override subject")
|
||||
cmd.Flags().StringVar(&body, "body", "", "override plain-text body")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func formatInvoiceCostEUR(v any) string {
|
||||
s := strings.TrimSpace(fmt.Sprint(v))
|
||||
s = strings.TrimSuffix(s, ".00")
|
||||
s = strings.TrimSuffix(s, ".0")
|
||||
if s == "" || s == "<nil>" {
|
||||
return "?"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func writeMailAttachment(path string, body []byte) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("attachment output path is required")
|
||||
}
|
||||
return os.WriteFile(path, body, 0o644)
|
||||
}
|
||||
|
||||
func mailsSendCmd() *cobra.Command {
|
||||
var from, to, cc, bcc, subject, body, html string
|
||||
var id int64
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "Send a mail message (OnlyOffice Mail)",
|
||||
Long: `Send via PUT /api/2.0/mail/messages/send.json.
|
||||
|
||||
oo mails send --id 7803 --body "…" # send referencing a draft id
|
||||
oo mails send --to a@b.com --subject "…" --body "…"
|
||||
oo mails send --id 7803 --to a@b.com --subject "…" --body "…" --cc x@y.com
|
||||
|
||||
IMPORTANT: send.json does NOT copy subject/body from the referenced draft — the
|
||||
content must be in this request (--subject/--body). Cc/Bcc are omitted when empty
|
||||
(the API 400s on empty strings). The API send does not append the UI signature —
|
||||
put the chat line in --body if needed.
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if to == "" && id == 0 {
|
||||
return fmt.Errorf("--to is required (or --id of an existing draft)")
|
||||
}
|
||||
htmlBody := body
|
||||
if html != "" {
|
||||
htmlBody = html
|
||||
}
|
||||
if htmlBody == "" && id != 0 {
|
||||
// The send.json endpoint does NOT copy subject/body from the
|
||||
// referenced draft — an empty body here sends an empty message.
|
||||
// Warn instead of silently mailing an empty email.
|
||||
return fmt.Errorf("--body/--html is required when sending by --id (send.json needs the content in the request)")
|
||||
}
|
||||
if htmlBody == "" && to == "" {
|
||||
return fmt.Errorf("--body is required for a fresh message")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.SendMail(cmd.Context(), onlyoffice.SendMailParams{
|
||||
ID: id,
|
||||
From: from,
|
||||
To: to,
|
||||
Cc: cc,
|
||||
Bcc: bcc,
|
||||
Subject: subject,
|
||||
Body: htmlBody,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(raw))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id to send (0 = fresh message)")
|
||||
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
|
||||
cmd.Flags().StringVar(&to, "to", "", "recipient (required unless --id)")
|
||||
cmd.Flags().StringVar(&cc, "cc", "", "cc")
|
||||
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
|
||||
cmd.Flags().StringVar(&subject, "subject", "", "subject")
|
||||
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
|
||||
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mailsDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete ID [ID...]",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteMailAttachment(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "attach.bin")
|
||||
body := []byte("payload")
|
||||
if err := writeMailAttachment(path, body); err != nil {
|
||||
t.Fatalf("writeMailAttachment: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(got) != string(body) {
|
||||
t.Fatalf("body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMailAttachmentRequiresPath(t *testing.T) {
|
||||
if err := writeMailAttachment("", []byte("x")); err == nil {
|
||||
t.Fatal("expected error for empty path")
|
||||
}
|
||||
}
|
||||
+13
-6
@@ -3,18 +3,25 @@
|
||||
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
||||
//
|
||||
// oo calendar list | events | add | delete
|
||||
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
|
||||
// oo projects list | get | milestones | milestone-create | create | update | delete | contacts (add|remove) | link-authors | link-git | files (list|upload|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx)
|
||||
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||
// oo users list | self (alias: oo whoami)
|
||||
// oo contacts list | get | delete | info-add | dedupe-info
|
||||
// oo contacts list | get | delete | info-add | merge | dedupe-info | tags | tag-add | tag-create | tag-remove
|
||||
// oo persons list | create | delete | dedupe
|
||||
// oo companies list | create | delete | dedupe | dedupe-persons
|
||||
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
||||
// oo opportunities list | get | create | update | delete | stages | member-add | dedupe | dedupe-members | fix-titles
|
||||
// oo cases list | create | delete | member-add
|
||||
// oo crm-tasks list | create | delete | categories
|
||||
// oo crm-tasks list | create | delete | categories | reassign-self
|
||||
// oo crm cleanup
|
||||
// oo mails accounts | folders | list | get | delete
|
||||
// oo applications sync
|
||||
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | send | delete
|
||||
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
||||
// oo docs tools | convert | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
|
||||
// oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird
|
||||
// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops
|
||||
// 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
|
||||
//
|
||||
// Every list supports `--output/-o json|table` (table is the default).
|
||||
//
|
||||
|
||||
@@ -19,6 +19,7 @@ func init() {
|
||||
opportunitiesCmd.AddCommand(oppListCmd())
|
||||
opportunitiesCmd.AddCommand(oppGetCmd())
|
||||
opportunitiesCmd.AddCommand(oppCreateCmd())
|
||||
opportunitiesCmd.AddCommand(oppUpdateCmd())
|
||||
opportunitiesCmd.AddCommand(oppDeleteCmd())
|
||||
opportunitiesCmd.AddCommand(oppStagesCmd())
|
||||
opportunitiesCmd.AddCommand(oppMemberAddCmd())
|
||||
@@ -118,6 +119,40 @@ func oppCreateCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func oppUpdateCmd() *cobra.Command {
|
||||
var title, desc string
|
||||
var stage int64
|
||||
var bid float64
|
||||
cmd := &cobra.Command{
|
||||
Use: "update OPPORTUNITY_ID",
|
||||
Short: "Update opportunity title, description, stage, or bid",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := c.UpdateOpportunity(cmd.Context(), args[0], onlyoffice.UpdateOpportunityParams{
|
||||
Title: title,
|
||||
Description: desc,
|
||||
StageID: stage,
|
||||
BidValue: bid,
|
||||
BidValueSet: cmd.Flags().Changed("bid"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&title, "title", "", "new title")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "new description")
|
||||
cmd.Flags().Int64Var(&stage, "stage", 0, "pipeline stage id")
|
||||
cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func oppDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
|
||||
|
||||
+402
-1
@@ -2,7 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -19,9 +23,11 @@ func init() {
|
||||
projectsCmd.AddCommand(prjListCmd())
|
||||
projectsCmd.AddCommand(prjGetCmd())
|
||||
projectsCmd.AddCommand(prjMilestonesCmd())
|
||||
projectsCmd.AddCommand(prjMilestoneCreateCmd())
|
||||
projectsCmd.AddCommand(prjCreateCmd())
|
||||
projectsCmd.AddCommand(prjUpdateCmd())
|
||||
projectsCmd.AddCommand(prjDeleteCmd())
|
||||
projectsCmd.AddCommand(prjContactsCmd())
|
||||
}
|
||||
|
||||
func prjListCmd() *cobra.Command {
|
||||
@@ -114,19 +120,80 @@ func prjMilestonesCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func prjMilestoneCreateCmd() *cobra.Command {
|
||||
var deadline, desc string
|
||||
var key bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "milestone-create PROJECT_ID TITLE",
|
||||
Short: "Create a project milestone (Gantt row)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("project id must be integer: %w", err)
|
||||
}
|
||||
if deadline == "" {
|
||||
return fmt.Errorf("--deadline YYYY-MM-DD is required")
|
||||
}
|
||||
day, err := time.Parse("2006-01-02", deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deadline: %w", err)
|
||||
}
|
||||
ms, err := c.CreateMilestone(onlyoffice.NewMilestoneRequest{
|
||||
ProjectID: pid,
|
||||
Title: args[1],
|
||||
Deadline: onlyoffice.Time(day),
|
||||
Description: desc,
|
||||
IsKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{
|
||||
"id": derefInt64(ms.ID),
|
||||
"title": derefString(ms.Title),
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "description")
|
||||
cmd.Flags().BoolVar(&key, "key", false, "mark as key milestone")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func prjCreateCmd() *cobra.Command {
|
||||
var desc, resp string
|
||||
var country, company string
|
||||
cmd := &cobra.Command{
|
||||
Use: "create TITLE",
|
||||
Short: "Create a new project",
|
||||
Long: `Create a project. Prefer canonical titles:
|
||||
|
||||
CC | Company | Title
|
||||
|
||||
Examples:
|
||||
oo projects create "Mapbender" --country DE --company "Stadt Mainz"
|
||||
oo projects create "DE | Acme | Geo Engineer"
|
||||
|
||||
When --country and --company are set, TITLE is only the third segment and the
|
||||
full title is composed as "CC | Company | Title".`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := args[0]
|
||||
if country != "" || company != "" {
|
||||
title = onlyoffice.FormatProjectTitle(country, company, args[0])
|
||||
}
|
||||
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
|
||||
Title: args[0],
|
||||
Title: title,
|
||||
Description: desc,
|
||||
ResponsibleID: resp,
|
||||
})
|
||||
@@ -142,6 +209,8 @@ func prjCreateCmd() *cobra.Command {
|
||||
}
|
||||
cmd.Flags().StringVar(&desc, "description", "", "project description")
|
||||
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
|
||||
cmd.Flags().StringVar(&country, "country", "", "country/region code (DE, TF, UA, …)")
|
||||
cmd.Flags().StringVar(&company, "company", "", "CRM company / engagement org name")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -160,6 +229,25 @@ func prjUpdateCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("project id must be integer: %w", err)
|
||||
}
|
||||
// OO update requires responsibleId; reuse current when not passed.
|
||||
if resp == "" {
|
||||
cur, gerr := c.GetProjectByID(cmd.Context(), args[0])
|
||||
if gerr != nil {
|
||||
return gerr
|
||||
}
|
||||
if r, ok := cur["responsible"].(map[string]any); ok {
|
||||
resp = fmt.Sprint(r["id"])
|
||||
}
|
||||
if resp == "" || resp == "<nil>" {
|
||||
resp = strings.TrimSpace(fmt.Sprint(cur["responsibleId"]))
|
||||
}
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(fmt.Sprint(cur["title"]))
|
||||
}
|
||||
if desc == "" {
|
||||
desc = strings.TrimSpace(fmt.Sprint(cur["description"]))
|
||||
}
|
||||
}
|
||||
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
|
||||
ID: id,
|
||||
Title: title,
|
||||
@@ -173,6 +261,11 @@ func prjUpdateCmd() *cobra.Command {
|
||||
"id": derefInt(p.ID),
|
||||
"title": p.String(),
|
||||
})
|
||||
// Confirm via GET — some OO builds return an empty body on PUT.
|
||||
got, gerr := c.GetProjectByID(cmd.Context(), args[0])
|
||||
if gerr == nil && got != nil {
|
||||
printObject(map[string]any{"confirmed_title": got["title"], "confirmed_id": got["id"]})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -216,6 +309,314 @@ func derefString(p *string) string {
|
||||
return *p
|
||||
}
|
||||
|
||||
func prjContactsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "contacts",
|
||||
Short: "CRM contacts linked to a project (companies + persons)",
|
||||
Long: `OnlyOffice Projects have two participant layers:
|
||||
- Team (portal users) — ProjectTeam.aspx
|
||||
- Contacts (CRM companies/persons) — linked via this API
|
||||
|
||||
Link employers/clients and people who worked on the engagement (e.g. git authors).`,
|
||||
}
|
||||
cmd.AddCommand(prjContactsListCmd())
|
||||
cmd.AddCommand(prjContactsAddCmd())
|
||||
cmd.AddCommand(prjContactsRemoveCmd())
|
||||
cmd.AddCommand(prjContactsLinkGitCmd())
|
||||
cmd.AddCommand(prjContactsLinkAuthorsCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func prjContactsListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list PROJECT_ID",
|
||||
Short: "List CRM contacts linked to the project",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("project id: %w", err)
|
||||
}
|
||||
list, err := c.ListProjectContacts(cmd.Context(), pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(list))
|
||||
for _, row := range list {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": row["id"],
|
||||
"name": row["displayName"],
|
||||
"company": row["isCompany"],
|
||||
"companyOf": row["companyName"],
|
||||
})
|
||||
}
|
||||
printTable([]string{"id", "name", "company", "companyOf"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func prjContactsAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add PROJECT_ID CONTACT_ID [CONTACT_ID...]",
|
||||
Short: "Link CRM contact(s) to the project",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("project id: %w", err)
|
||||
}
|
||||
for _, raw := range args[1:] {
|
||||
cid, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("contact id %q: %w", raw, err)
|
||||
}
|
||||
if _, err := c.AddProjectContact(cmd.Context(), pid, cid); err != nil {
|
||||
return fmt.Errorf("add %d: %w", cid, err)
|
||||
}
|
||||
printObject(map[string]any{"project_id": pid, "contact_id": cid, "linked": true})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func prjContactsRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove PROJECT_ID CONTACT_ID [CONTACT_ID...]",
|
||||
Short: "Unlink CRM contact(s) from the project",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("project id: %w", err)
|
||||
}
|
||||
for _, raw := range args[1:] {
|
||||
cid, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("contact id %q: %w", raw, err)
|
||||
}
|
||||
if err := c.RemoveProjectContact(cmd.Context(), pid, cid); err != nil {
|
||||
return fmt.Errorf("remove %d: %w", cid, err)
|
||||
}
|
||||
printObject(map[string]any{"project_id": pid, "contact_id": cid, "unlinked": true})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func prjContactsLinkGitCmd() *cobra.Command {
|
||||
var gitRoot string
|
||||
var minCommits int
|
||||
var dryRun bool
|
||||
var companyIDs []int
|
||||
cmd := &cobra.Command{
|
||||
Use: "link-git PROJECT_ID",
|
||||
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 — create/link them in CRM first.
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("need --git-root and/or --company-id")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("project id: %w", err)
|
||||
}
|
||||
var authors []gitAuthor
|
||||
if gitRoot != "" {
|
||||
authors, err = gitShortlogAuthors(gitRoot, minCommits)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&gitRoot, "git-root", "", "local git repository path")
|
||||
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
|
||||
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func prjContactsLinkAuthorsCmd() *cobra.Command {
|
||||
var authorsFile string
|
||||
var minCommits int
|
||||
var dryRun bool
|
||||
var companyIDs []int
|
||||
cmd := &cobra.Command{
|
||||
Use: "link-authors PROJECT_ID",
|
||||
Short: "Link CRM persons from a git shortlog file (+ companies)",
|
||||
Long: `Same as link-git, but reads authors from a file produced by:
|
||||
|
||||
git shortlog -sne --all > authors.txt
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("need --from and/or --company-id")
|
||||
}
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := strconv.Atoi(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("project id: %w", err)
|
||||
}
|
||||
var authors []gitAuthor
|
||||
if authorsFile != "" {
|
||||
b, err := os.ReadFile(authorsFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authors = parseShortlog(string(b), minCommits)
|
||||
}
|
||||
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&authorsFile, "from", "", "path to git shortlog -sne output")
|
||||
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
|
||||
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func linkProjectPeople(cmd *cobra.Command, c *onlyoffice.Client, pid int, authors []gitAuthor, companyIDs []int, dryRun bool) error {
|
||||
ctx := cmd.Context()
|
||||
existing, err := c.ListProjectContacts(ctx, pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[int]struct{}{}
|
||||
for _, row := range existing {
|
||||
have[int(flexIDFloat(row["id"]))] = struct{}{}
|
||||
}
|
||||
linked, skipped := 0, 0
|
||||
for _, cid := range companyIDs {
|
||||
if _, ok := have[cid]; ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
printObject(map[string]any{"would_link_company": cid})
|
||||
linked++
|
||||
continue
|
||||
}
|
||||
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
|
||||
return fmt.Errorf("company %d: %w", cid, err)
|
||||
}
|
||||
have[cid] = struct{}{}
|
||||
linked++
|
||||
printObject(map[string]any{"linked_company": cid})
|
||||
}
|
||||
for _, a := range authors {
|
||||
p, err := c.FindPersonByEmail(ctx, a.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p == nil {
|
||||
printObject(map[string]any{
|
||||
"skipped_email": a.Email, "name": a.Name, "commits": a.Commits, "reason": "no_crm_person",
|
||||
})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
cid := int(flexIDFloat(p["id"]))
|
||||
if _, ok := have[cid]; ok {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
printObject(map[string]any{"would_link_person": cid, "email": a.Email, "commits": a.Commits})
|
||||
linked++
|
||||
continue
|
||||
}
|
||||
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
|
||||
return fmt.Errorf("person %d (%s): %w", cid, a.Email, err)
|
||||
}
|
||||
have[cid] = struct{}{}
|
||||
linked++
|
||||
printObject(map[string]any{"linked_person": cid, "email": a.Email, "commits": a.Commits})
|
||||
}
|
||||
printObject(map[string]any{"project_id": pid, "linked": linked, "skipped": skipped, "dry_run": dryRun})
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseShortlog(text string, minCommits int) []gitAuthor {
|
||||
var authors []gitAuthor
|
||||
seen := map[string]struct{}{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if n < minCommits {
|
||||
continue
|
||||
}
|
||||
rest := parts[1]
|
||||
lt := strings.LastIndex(rest, "<")
|
||||
gt := strings.LastIndex(rest, ">")
|
||||
if lt < 0 || gt <= lt {
|
||||
continue
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(rest[lt+1 : gt]))
|
||||
name := strings.TrimSpace(rest[:lt])
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[email]; ok {
|
||||
continue
|
||||
}
|
||||
seen[email] = struct{}{}
|
||||
authors = append(authors, gitAuthor{Commits: n, Name: name, Email: email})
|
||||
}
|
||||
return authors
|
||||
}
|
||||
|
||||
type gitAuthor struct {
|
||||
Commits int
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
func gitShortlogAuthors(root string, minCommits int) ([]gitAuthor, error) {
|
||||
cmd := exec.Command("git", "-C", root, "shortlog", "-sne", "--all")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git shortlog in %s: %w", root, err)
|
||||
}
|
||||
return parseShortlog(string(out), minCommits), nil
|
||||
}
|
||||
|
||||
func derefInt(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
|
||||
+150
-10
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -24,9 +25,43 @@ func projectFilesCmd() *cobra.Command {
|
||||
cmd.AddCommand(prjFilesDownloadCmd())
|
||||
cmd.AddCommand(prjFilesRenameCmd())
|
||||
cmd.AddCommand(prjFilesDeleteCmd())
|
||||
cmd.AddCommand(prjFilesDedupeCmd())
|
||||
// Convenience aliases into oo docs (md↔docx / OCR pipeline).
|
||||
cmd.AddCommand(aliasDocsAsMD())
|
||||
cmd.AddCommand(aliasDocsPutMD())
|
||||
cmd.AddCommand(aliasDocsPutTxt())
|
||||
cmd.AddCommand(aliasDocsPutXlsx())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func aliasDocsAsMD() *cobra.Command {
|
||||
c := docsAsMDCmd()
|
||||
c.Use = "as-md FILE_ID"
|
||||
c.Short = "Alias of `oo docs as-md` — download OO file as Markdown (OCR if needed)"
|
||||
return c
|
||||
}
|
||||
|
||||
func aliasDocsPutMD() *cobra.Command {
|
||||
c := docsPutMDCmd()
|
||||
c.Use = "put-md PROJECT_ID MARKDOWN_PATH"
|
||||
c.Short = "Alias of `oo docs put-md` — Markdown→DOCX upload into project"
|
||||
return c
|
||||
}
|
||||
|
||||
func aliasDocsPutTxt() *cobra.Command {
|
||||
c := docsPutTxtCmd()
|
||||
c.Use = "put-txt PROJECT_ID TEXT_PATH"
|
||||
c.Short = "Alias of `oo docs put-txt` — plain text→DOCX upload into project"
|
||||
return c
|
||||
}
|
||||
|
||||
func aliasDocsPutXlsx() *cobra.Command {
|
||||
c := docsPutXlsxCmd()
|
||||
c.Use = "put-xlsx PROJECT_ID [LOCAL_XLSX]"
|
||||
c.Short = "Alias of `oo docs put-xlsx` — generate/upload XLSX with formulas"
|
||||
return c
|
||||
}
|
||||
|
||||
func prjFilesListCmd() *cobra.Command {
|
||||
var showFolders bool
|
||||
cmd := &cobra.Command{
|
||||
@@ -73,9 +108,12 @@ func prjFilesListCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func prjFilesUploadCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var replace, allowDuplicate bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
|
||||
Short: "Upload file(s) into the project's Documents folder",
|
||||
Short: "Upload file(s) into the project's Documents folder (upsert by stem|ext)",
|
||||
Long: `Default: replace an existing file with the same logical name (stem|ext), like cp overwrite.
|
||||
Pass --no-replace to fail when the name is taken; --allow-duplicate to always create a new file id.`,
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
@@ -84,15 +122,31 @@ func prjFilesUploadCmd() *cobra.Command {
|
||||
}
|
||||
pid := args[0]
|
||||
for _, p := range args[1:] {
|
||||
entry, err := c.UploadProjectFile(cmd.Context(), pid, p)
|
||||
var entry *onlyoffice.FileEntry
|
||||
var deleted []int
|
||||
switch {
|
||||
case allowDuplicate:
|
||||
entry, err = c.UploadProjectFile(cmd.Context(), pid, p)
|
||||
case replace:
|
||||
entry, deleted, err = c.UploadProjectFileReplacing(cmd.Context(), pid, p)
|
||||
default:
|
||||
entry, err = c.UploadProjectFileNoClobber(cmd.Context(), pid, p)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(fileEntryToMap(entry))
|
||||
obj := fileEntryToMap(entry)
|
||||
if len(deleted) > 0 {
|
||||
obj["replaced_file_ids"] = deleted
|
||||
}
|
||||
printObject(obj)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext in project folder (default)")
|
||||
cmd.Flags().BoolVar(&allowDuplicate, "allow-duplicate", false, "always create a new file even when the name exists")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func prjFilesDownloadCmd() *cobra.Command {
|
||||
@@ -106,20 +160,22 @@ func prjFilesDownloadCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := c.GetFile(cmd.Context(), args[0])
|
||||
ctx := cmd.Context()
|
||||
store := c.Files()
|
||||
e, err := store.Stat(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := to
|
||||
if path == "" {
|
||||
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
||||
path = onlyoffice.SafeLocalFileName(e.Title)
|
||||
}
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
n, err := c.DownloadFile(cmd.Context(), args[0], out)
|
||||
n, err := store.Download(ctx, args[0], out)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
@@ -146,11 +202,16 @@ func prjFilesRenameCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := c.RenameFile(cmd.Context(), args[0], args[1])
|
||||
store := c.Files()
|
||||
if err := store.Rename(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := store.Stat(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(fileEntryToMap(entry))
|
||||
entry.Title = args[1]
|
||||
printObject(entryToMap(entry))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -175,7 +236,7 @@ func prjFilesDeleteCmd() *cobra.Command {
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := c.DeleteFiles(cmd.Context(), ids); err != nil {
|
||||
if err := c.Files().Delete(cmd.Context(), args); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"deleted": ids})
|
||||
@@ -184,6 +245,62 @@ func prjFilesDeleteCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func prjFilesDedupeCmd() *cobra.Command {
|
||||
var apply, cross bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "dedupe PROJECT_ID",
|
||||
Short: "Find (and optionally remove) duplicate files in project Documents folders",
|
||||
Long: `Duplicates share the same logical name: stem|ext (OO title+fileExst).
|
||||
|
||||
Default: dry-run report. Pass --apply to delete older copies (keeps newest; --cross prefers non-trash folders).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups, deleted, err := c.DedupeProject(cmd.Context(), args[0], onlyoffice.DedupOptions{
|
||||
CrossFolder: cross,
|
||||
}, apply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
row := map[string]any{
|
||||
"key": g.Key,
|
||||
"folder_id": g.FolderID,
|
||||
"folder_title": g.FolderTitle,
|
||||
"keep_id": fileIDStr(g.Keep),
|
||||
"keep_title": onlyoffice.FileEntryTitle(g.Keep),
|
||||
"remove_count": len(g.Remove),
|
||||
}
|
||||
removeIDs := make([]string, 0, len(g.Remove))
|
||||
for _, f := range g.Remove {
|
||||
removeIDs = append(removeIDs, fileIDStr(f))
|
||||
}
|
||||
row["remove_ids"] = removeIDs
|
||||
rows = append(rows, row)
|
||||
}
|
||||
out := map[string]any{
|
||||
"project_id": args[0],
|
||||
"dry_run": !apply,
|
||||
"cross": cross,
|
||||
"groups": len(groups),
|
||||
"duplicates": rows,
|
||||
}
|
||||
if apply {
|
||||
out["deleted_ids"] = deleted
|
||||
}
|
||||
printObject(out)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&apply, "apply", false, "delete duplicate files (default: report only)")
|
||||
cmd.Flags().BoolVar(&cross, "cross", false, "also dedupe same stem|ext across folders (prefers non-_trash)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func fileEntryRows(files []*onlyoffice.FileEntry) []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(files))
|
||||
for _, f := range files {
|
||||
@@ -208,6 +325,29 @@ func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any {
|
||||
return m
|
||||
}
|
||||
|
||||
// entryToMap renders a canonical Entry with the same keys as fileEntryToMap.
|
||||
func entryToMap(e onlyoffice.Entry) map[string]any {
|
||||
m := map[string]any{
|
||||
"id": e.ID,
|
||||
"title": e.Title,
|
||||
"fileExst": filepath.Ext(e.Title),
|
||||
"contentLength": contentLengthString(e.Size),
|
||||
}
|
||||
if e.Updated != "" {
|
||||
m["updated"] = e.Updated
|
||||
} else if !e.Modified.IsZero() {
|
||||
m["updated"] = e.Modified.Format(time.RFC3339)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func contentLengthString(n int64) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
func fileIDStr(f *onlyoffice.FileEntry) string {
|
||||
if f == nil || f.ID == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(searchCmd())
|
||||
}
|
||||
|
||||
// searchCmd queries the OnlyOffice document index through the file facade. The
|
||||
// REST /api/2.0/files/@search endpoint only searches file names in the database;
|
||||
// content search needs Elasticsearch (see docs/elasticsearch.md).
|
||||
func searchCmd() *cobra.Command {
|
||||
var (
|
||||
content bool
|
||||
folder string
|
||||
limit int
|
||||
backend string
|
||||
asJSON bool
|
||||
substring bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "search QUERY...",
|
||||
Short: "Full-text search over documents by name, optionally by content (Elasticsearch)",
|
||||
Long: "Search the OnlyOffice Documents index.\n\n" +
|
||||
"By default only file names are matched. With --content the query also\n" +
|
||||
"matches extracted document text (document.attachment.content); this covers\n" +
|
||||
"Office formats (docx/xlsx/pptx) and is slower.\n\n" +
|
||||
"--backend own queries the separate index populated by `oo index`\n" +
|
||||
"(ONLYOFFICE_ES_TEXT_INDEX, default oo_docs_text) instead, which also holds\n" +
|
||||
"PDFs and scans (see docs/elasticsearch.md).\n\n" +
|
||||
"Requires ONLYOFFICE_ES_URL (and optionally ONLYOFFICE_ES_INDEX,\n" +
|
||||
"ONLYOFFICE_TENANT). See docs/elasticsearch.md for the tunnel setup.",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if asJSON {
|
||||
outputFormat = "json"
|
||||
}
|
||||
bootstrap.LoadEnv()
|
||||
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||
var (
|
||||
searcher onlyoffice.Searcher
|
||||
err error
|
||||
)
|
||||
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||
case "", "oo", "elasticsearch":
|
||||
searcher, err = c.Files().Search()
|
||||
case "own", "es-text":
|
||||
searcher, err = onlyoffice.NewESTextIndex(onlyoffice.ESTextConfigFromEnv())
|
||||
default:
|
||||
return fmt.Errorf("unknown search backend %q (want oo|own)", backend)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hits, err := searcher.Search(cmd.Context(), onlyoffice.SearchQuery{
|
||||
Text: strings.Join(args, " "),
|
||||
InContent: content,
|
||||
FolderID: folder,
|
||||
Limit: limit,
|
||||
Substring: substring,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
folderPath := h.Path
|
||||
if len(folderPath) == 0 && h.ParentID != "" {
|
||||
folderPath = []string{h.ParentID}
|
||||
}
|
||||
rows = append(rows, map[string]any{
|
||||
"path": c.UniquePath(cmd.Context(), folderPath, h.Title),
|
||||
"id": h.ID,
|
||||
"title": h.Title,
|
||||
"folder": h.ParentID,
|
||||
"score": h.Score,
|
||||
"highlight": h.Highlight,
|
||||
})
|
||||
}
|
||||
if outputFormat == "json" {
|
||||
printJSON(rows)
|
||||
return nil
|
||||
}
|
||||
printTable([]string{"path", "id", "title", "folder", "score", "highlight"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&content, "content", false, "also match extracted document content")
|
||||
cmd.Flags().BoolVar(&substring, "substring", false, "case-insensitive *term* title match; multiple QUERY args are ANDed")
|
||||
cmd.Flags().StringVar(&folder, "folder", "", "limit to a Documents folder id (matches the folder subtree)")
|
||||
cmd.Flags().IntVar(&limit, "limit", 20, "maximum number of results")
|
||||
cmd.Flags().StringVar(&backend, "backend", "oo", "index to query: oo (OnlyOffice) | own (oo index)")
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "shorthand for --output json")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchCommandRegisteredWithFlags(t *testing.T) {
|
||||
cmd, _, err := rootCmd.Find([]string{"search"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd.Name() != "search" {
|
||||
t.Fatalf("search resolved to %q", cmd.Name())
|
||||
}
|
||||
for _, name := range []string{"content", "folder", "limit", "json"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("search: missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if got := cmd.Flags().Lookup("limit").DefValue; got != "20" {
|
||||
t.Errorf("--limit default = %q, want 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchWithoutESURLIsClearError(t *testing.T) {
|
||||
clearEnv(t, "ONLYOFFICE_ES_URL", "ONLYOFFICE_ES_INDEX", "ONLYOFFICE_TENANT")
|
||||
errBuf := &bytes.Buffer{}
|
||||
rootCmd.SetErr(errBuf)
|
||||
rootCmd.SetOut(&bytes.Buffer{})
|
||||
rootCmd.SetArgs([]string{"search", "Rechnung"})
|
||||
t.Cleanup(func() {
|
||||
rootCmd.SetArgs(nil)
|
||||
rootCmd.SetOut(nil)
|
||||
rootCmd.SetErr(nil)
|
||||
})
|
||||
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error without ONLYOFFICE_ES_URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ONLYOFFICE_ES_URL") {
|
||||
t.Fatalf("error %q missing ONLYOFFICE_ES_URL", err.Error())
|
||||
}
|
||||
}
|
||||
+55
-3
@@ -2,7 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -82,11 +86,12 @@ func taskGetCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func taskCreateCmd() *cobra.Command {
|
||||
var project, desc, deadline, prio string
|
||||
var project, desc, deadline, start, prio string
|
||||
var milestone int64
|
||||
cmd := &cobra.Command{
|
||||
Use: "create TITLE",
|
||||
Aliases: []string{"add"},
|
||||
Short: "Create a project task",
|
||||
Short: "Create a project task (Gantt bar when --start/--deadline/--milestone set)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
@@ -100,6 +105,51 @@ func taskCreateCmd() *cobra.Command {
|
||||
case "low":
|
||||
p = -1
|
||||
}
|
||||
if start != "" || milestone != 0 {
|
||||
pid, err := strconv.Atoi(project)
|
||||
if err != nil && project != "" {
|
||||
return fmt.Errorf("project id: %w", err)
|
||||
}
|
||||
if project == "" {
|
||||
pid, err = strconv.Atoi(os.Getenv("OO_PROJECT_ID"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--project or OO_PROJECT_ID required")
|
||||
}
|
||||
}
|
||||
req := onlyoffice.NewProjectTaskRequest{
|
||||
ProjectId: pid,
|
||||
Title: args[0],
|
||||
Description: desc,
|
||||
Priority: p,
|
||||
MilestoneId: int(milestone),
|
||||
}
|
||||
if deadline == "" {
|
||||
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02")
|
||||
}
|
||||
d, err := time.Parse("2006-01-02", deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deadline: %w", err)
|
||||
}
|
||||
req.Deadline = onlyoffice.Time(d)
|
||||
if start != "" {
|
||||
s, err := time.Parse("2006-01-02", start)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
req.StartDate = onlyoffice.Time(s)
|
||||
} else {
|
||||
req.StartDate = onlyoffice.Time(time.Now())
|
||||
}
|
||||
task, err := c.CreateProjectTask(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{
|
||||
"id": derefInt(task.ID),
|
||||
"title": derefString(task.Title),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -110,7 +160,9 @@ func taskCreateCmd() *cobra.Command {
|
||||
}
|
||||
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "description")
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
|
||||
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d)")
|
||||
cmd.Flags().StringVar(&start, "start", "", "start YYYY-MM-DD (uses CreateProjectTask / Gantt)")
|
||||
cmd.Flags().Int64Var(&milestone, "milestone", 0, "milestone id (Gantt row)")
|
||||
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Command ooscan recursively lists OnlyOffice Documents folders into a TSV
|
||||
// index: file_id, folder_id, path, title.
|
||||
//
|
||||
// Usage: ooscan <FOLDER_ID> [<FOLDER_ID>...]
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Command pdfamount walks a Documents folder, downloads matching PDFs and
|
||||
// extracts the payable amount, printing "file_id\ttitle\tamount".
|
||||
//
|
||||
// Usage: pdfamount <FOLDER_ID> [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 "<label> [optional (comment)] [: -] <number>".
|
||||
func amountRE(label string) *regexp.Regexp {
|
||||
return regexp.MustCompile(
|
||||
`(?i)\b` + regexp.QuoteMeta(label) + `\b\s*(?:\([^)]*\))?\s*[:\-]?\s*` + amountPat)
|
||||
}
|
||||
|
||||
// amountRes lists the payable-amount patterns in strict priority order: the
|
||||
// first pattern with a usable amount wins, and a lower-priority label can never
|
||||
// override a higher-priority one ("zu zahlender betrag" > "rechnungsbetrag" >
|
||||
// "rechnungsendbetrag" > "gesamtbetrag" > "gesamtsumme (inkl. steuern)").
|
||||
//
|
||||
// "gesamtbetrag" and "gesamtsumme" are not in the original set but are the real
|
||||
// labels on Diashop invoices ("Gesamtsumme (inkl. Steuern)"). The inclusive
|
||||
// variant is matched before a plain "gesamtsumme". Everything after those
|
||||
// primary labels is the broader fallback set, consulted only when no primary
|
||||
// label yields an amount. Within one pattern the last usable amount is taken,
|
||||
// because totals usually come last.
|
||||
var amountRes = []*regexp.Regexp{
|
||||
amountRE("zu zahlender betrag"),
|
||||
amountRE("rechnungsbetrag"),
|
||||
amountRE("rechnungsendbetrag"),
|
||||
amountRE("gesamtbetrag"),
|
||||
regexp.MustCompile(`(?i)\bgesamtsumme\b\s*\(\s*inkl\.?\s*steuern\s*\)\s*[:\-]?\s*` + amountPat),
|
||||
amountRE("gesamtsumme"),
|
||||
amountRE("endbetrag"),
|
||||
amountRE("zahlbetrag"),
|
||||
amountRE("bruttobetrag"),
|
||||
amountRE("betrag"),
|
||||
amountRE("total"),
|
||||
amountRE("summe"),
|
||||
}
|
||||
|
||||
// taxLineRe marks a line whose number is a tax rate/percentage: an explicit
|
||||
// percent sign or a VAT/tax keyword. "Steuern" (plural, as in "inkl. Steuern")
|
||||
// is handled separately so the inclusive total stays usable.
|
||||
var taxLineRe = regexp.MustCompile(`(?i)%|\bMwSt\b|\bUSt\b|\bProzent\b`)
|
||||
|
||||
// steuerRe finds "Steuer"/"Umsatzsteuer" etc. RE2 has no lookahead, so the
|
||||
// plural "Steuern" is excluded in isTaxLine.
|
||||
var steuerRe = regexp.MustCompile(`(?i)steuer`)
|
||||
|
||||
// percentAfterRe detects a percent sign directly after a number (spaces ok).
|
||||
var percentAfterRe = regexp.MustCompile(`^\s*%`)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: pdfamount <FOLDER_ID> [TITLE_FILTER_REGEX]")
|
||||
os.Exit(2)
|
||||
}
|
||||
folder := os.Args[1]
|
||||
filter := regexp.MustCompile(`(?i)rechnung`)
|
||||
if len(os.Args) >= 3 {
|
||||
filter = regexp.MustCompile(os.Args[2])
|
||||
}
|
||||
ctx := context.Background()
|
||||
c := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||
|
||||
files := listAll(ctx, c, folder)
|
||||
for _, f := range files {
|
||||
if !filter.MatchString(f.title) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(f.title), ".pdf") {
|
||||
continue
|
||||
}
|
||||
amount, err := pdfAmount(ctx, c, f.id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", f.title, err)
|
||||
continue
|
||||
}
|
||||
if amount == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s\t%s\t%s\n", f.id, f.title, amount)
|
||||
}
|
||||
}
|
||||
|
||||
type file struct{ id, title string }
|
||||
|
||||
func listAll(ctx context.Context, c *onlyoffice.Client, folder string) []file {
|
||||
seen := map[string]bool{}
|
||||
var out []file
|
||||
var walk func(string)
|
||||
walk = func(id string) {
|
||||
if seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
l, err := c.ListDavFolder(ctx, id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "list %s: %v\n", id, err)
|
||||
return
|
||||
}
|
||||
for _, f := range l.Files {
|
||||
out = append(out, file{f.ID, f.Title})
|
||||
}
|
||||
for _, sub := range l.Folders {
|
||||
walk(sub.ID)
|
||||
}
|
||||
}
|
||||
walk(folder)
|
||||
return out
|
||||
}
|
||||
|
||||
func pdfAmount(ctx context.Context, c *onlyoffice.Client, id string) (string, error) {
|
||||
time.Sleep(time.Second)
|
||||
tmp, err := os.CreateTemp("", "pdf-*.pdf")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
derr := onlyoffice.DoRetry(ctx, onlyoffice.DefaultRetryPolicy(), func() error {
|
||||
_ = tmp.Truncate(0)
|
||||
_, _ = tmp.Seek(0, 0)
|
||||
_, err := c.DownloadFile(ctx, id, tmp)
|
||||
return err
|
||||
})
|
||||
if derr != nil {
|
||||
tmp.Close()
|
||||
return "", derr
|
||||
}
|
||||
tmp.Close()
|
||||
var buf bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "pdftotext", "-layout", tmp.Name(), "-")
|
||||
cmd.Stdout = &buf
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractAmount(buf.String()), nil
|
||||
}
|
||||
|
||||
// extractAmount returns the normalised ("1234.56") payable amount found in
|
||||
// text, or "" if no usable amount matches.
|
||||
//
|
||||
// DKV invoices are special-cased first: they repeat a per-vehicle "TOTAL:" line
|
||||
// and carry the real total only in the "Gesamtsummenaufstellung" section.
|
||||
func extractAmount(text string) string {
|
||||
if v, ok := dkvGrandTotal(text); ok {
|
||||
return v
|
||||
}
|
||||
for _, re := range amountRes {
|
||||
if v, ok := lastUsableAmount(text, re); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dkvGrandTotal extracts the total of a DKV "Gesamtsummenaufstellung" section.
|
||||
//
|
||||
// Rule: DKV invoices repeat a per-vehicle "TOTAL:" line, so the last TOTAL is
|
||||
// not the invoice total. When a "Gesamtsummenaufstellung" section exists, its
|
||||
// total wins over every "TOTAL:" line: the first amount after the "»" marker,
|
||||
// or, if there is none, the last amount in the section. The section ends at the
|
||||
// page break (form feed) or end of text.
|
||||
func dkvGrandTotal(text string) (string, bool) {
|
||||
idx := strings.Index(strings.ToLower(text), "gesamtsummenaufstellung")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
section := text[idx:]
|
||||
if ff := strings.IndexByte(section, '\f'); ff >= 0 {
|
||||
section = section[:ff]
|
||||
}
|
||||
if m := strings.Index(section, "»"); m >= 0 {
|
||||
if v, ok := firstAmount(section[m:]); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return lastAmount(section)
|
||||
}
|
||||
|
||||
// lastUsableAmount returns the last amount matched by re that is not a tax rate
|
||||
// or percentage. Within one label the last usable amount wins.
|
||||
func lastUsableAmount(text string, re *regexp.Regexp) (string, bool) {
|
||||
ms := re.FindAllStringSubmatchIndex(text, -1)
|
||||
for i := len(ms) - 1; i >= 0; i-- {
|
||||
m := ms[i]
|
||||
if isTaxRate(text, m[2], m[3]) {
|
||||
continue
|
||||
}
|
||||
if v, ok := normalizeAmount(text[m[2]:m[3]]); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isTaxRate reports whether the number at text[start:end] is a tax rate or a
|
||||
// percentage instead of a payable amount. A candidate is rejected when the
|
||||
// token right after the number is "%" or the number's line carries a percent
|
||||
// sign or a tax keyword. Rejecting is deliberate: office matching treats a
|
||||
// known-but-different amount as a hard disqualifier, so an empty result is
|
||||
// safer than the VAT rate.
|
||||
func isTaxRate(text string, start, end int) bool {
|
||||
if percentAfterRe.MatchString(text[end:]) {
|
||||
return true
|
||||
}
|
||||
lineStart := strings.LastIndexByte(text[:start], '\n') + 1
|
||||
line := text[lineStart:]
|
||||
if n := strings.IndexByte(text[end:], '\n'); n >= 0 {
|
||||
line = text[lineStart : end+n]
|
||||
}
|
||||
return isTaxLine(line)
|
||||
}
|
||||
|
||||
// isTaxLine reports whether a line looks like a tax rate rather than a payable
|
||||
// amount. "Steuern" is treated as a qualifier ("inkl. Steuern"), not a rate.
|
||||
func isTaxLine(line string) bool {
|
||||
if taxLineRe.MatchString(line) {
|
||||
return true
|
||||
}
|
||||
for _, loc := range steuerRe.FindAllStringIndex(line, -1) {
|
||||
if loc[1] >= len(line) || (line[loc[1]] != 'n' && line[loc[1]] != 'N') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// numberRe finds bare numbers (with optional thousands/decimal separators).
|
||||
var numberRe = regexp.MustCompile(`[0-9]+(?:[.,][0-9]+)*`)
|
||||
|
||||
func firstAmount(s string) (string, bool) {
|
||||
for _, m := range numberRe.FindAllString(s, -1) {
|
||||
if v, ok := normalizeAmount(m); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func lastAmount(s string) (string, bool) {
|
||||
ms := numberRe.FindAllString(s, -1)
|
||||
for i := len(ms) - 1; i >= 0; i-- {
|
||||
if v, ok := normalizeAmount(ms[i]); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// normalizeAmount turns "1.234,56" (DE), "1,234.56" (EN) or "1234.56" into
|
||||
// "1234.56". The rightmost separator is decimal only when followed by one or
|
||||
// two digits; otherwise every separator is a thousands separator.
|
||||
func normalizeAmount(s string) (string, bool) {
|
||||
last := -1
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '.' || s[i] == ',' {
|
||||
last = i
|
||||
}
|
||||
}
|
||||
var dec byte
|
||||
if last >= 0 {
|
||||
digits := 0
|
||||
for i := last + 1; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return "", false
|
||||
}
|
||||
digits++
|
||||
}
|
||||
if digits == 1 || digits == 2 {
|
||||
dec = s[last]
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; {
|
||||
case c >= '0' && c <= '9':
|
||||
b.WriteByte(c)
|
||||
case (c == '.' || c == ',') && c == dec:
|
||||
b.WriteByte('.')
|
||||
case c == '.' || c == ',':
|
||||
// thousands separator
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
v, err := strconv.ParseFloat(b.String(), 64)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', 2, 64), true
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, text, want string
|
||||
}{
|
||||
{
|
||||
name: "rechnungsbetrag de format",
|
||||
text: "Rechnungsbetrag: 1.234,56 €",
|
||||
want: "1234.56",
|
||||
},
|
||||
{
|
||||
name: "rechnungsbetrag en thousands and dot",
|
||||
text: "Rechnungsbetrag: 1,234.56",
|
||||
want: "1234.56",
|
||||
},
|
||||
{
|
||||
name: "rechnungsbetrag plain dot",
|
||||
text: "Rechnungsbetrag: 1234.56",
|
||||
want: "1234.56",
|
||||
},
|
||||
{
|
||||
name: "rechnungsbetrag de comma only",
|
||||
text: "Rechnungsbetrag: 1234,56",
|
||||
want: "1234.56",
|
||||
},
|
||||
{
|
||||
name: "currency suffix eur",
|
||||
text: "Rechnungsbetrag: 1.234,56 EUR",
|
||||
want: "1234.56",
|
||||
},
|
||||
{
|
||||
name: "zu zahlender betrag wins over rechnungsbetrag",
|
||||
text: "Zu zahlender Betrag: 10,00\nRechnungsbetrag: 99,00",
|
||||
want: "10.00",
|
||||
},
|
||||
{
|
||||
name: "rechnungsbetrag wins over endbetrag",
|
||||
text: "Endbetrag: 20,00\nRechnungsbetrag: 30,00",
|
||||
want: "30.00",
|
||||
},
|
||||
{
|
||||
name: "bruttobetrag wins over bare betrag",
|
||||
text: "Bruttobetrag: 50,00\nBetrag: 10,00",
|
||||
want: "50.00",
|
||||
},
|
||||
{
|
||||
name: "gesamtbetrag wins over bare betrag",
|
||||
text: "Gesamtbetrag: 80,00\nBetrag: 10,00",
|
||||
want: "80.00",
|
||||
},
|
||||
{
|
||||
name: "last occurrence of same label wins",
|
||||
text: "Rechnungsbetrag: 10,00\nRechnungsbetrag: 20,00",
|
||||
want: "20.00",
|
||||
},
|
||||
{
|
||||
name: "endbetrag fallback",
|
||||
text: "Endbetrag: 42,00",
|
||||
want: "42.00",
|
||||
},
|
||||
{
|
||||
name: "zahlbetrag fallback without colon",
|
||||
text: "Zahlbetrag 7,50 €",
|
||||
want: "7.50",
|
||||
},
|
||||
{
|
||||
name: "rechnungsendbetrag beats endbetrag",
|
||||
text: "Rechnungsendbetrag: 12,00\nEndbetrag: 13,00",
|
||||
want: "12.00",
|
||||
},
|
||||
{
|
||||
name: "dkv style total line",
|
||||
text: "Kundenbezogene Daten\n» TOTAL: 123,45 100,00 23,45 123,45\n",
|
||||
want: "123.45",
|
||||
},
|
||||
{
|
||||
name: "dkv gesamtsummenaufstellung grand total after marker",
|
||||
text: "» TOTAL: 111,11 100,00 11,11 111,11\n" +
|
||||
"» TOTAL: 222,22 200,00 22,22 222,22\n" +
|
||||
"Gesamtsummenaufstellung\n" +
|
||||
"Netto 240,00\n" +
|
||||
"MwSt 47,25\n" +
|
||||
"» 287,25\n",
|
||||
want: "287.25",
|
||||
},
|
||||
{
|
||||
name: "dkv gesamtsummenaufstellung total on next line",
|
||||
text: "» TOTAL: 111,11\nGesamtsummenaufstellung\n»\n287,25\n",
|
||||
want: "287.25",
|
||||
},
|
||||
{
|
||||
name: "tax rate with percent sign is not an amount",
|
||||
text: "Betrag: 19,00 % MwSt",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "mehrwertsteuer rate is not an amount",
|
||||
text: "Gesamtsumme: 19,00% MwSt",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "steuer word on the number line rejects it",
|
||||
text: "Betrag: 2,83 Steuer",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "rejected primary falls back to a usable label",
|
||||
text: "Gesamtsumme: 19,00 % MwSt\nEndbetrag: 42,00",
|
||||
want: "42.00",
|
||||
},
|
||||
{
|
||||
name: "labeled zu zahlender betrag beats unlabeled larger number",
|
||||
text: "unlabeled 999,99\nZu zahlender Betrag: 10,00",
|
||||
want: "10.00",
|
||||
},
|
||||
{
|
||||
name: "labeled zu zahlender betrag beats lower label larger number",
|
||||
text: "Endbetrag: 999,99\nZu zahlender Betrag: 10,00",
|
||||
want: "10.00",
|
||||
},
|
||||
{
|
||||
name: "diashop style gesamtsumme with comment",
|
||||
text: "Zwischensumme\n12,34 €\nZwischensumme\n12,34 €\nVersand & Bearbeitung\n4,95 €\nGesamtsumme (inkl. Steuern)\n17,29 €\n",
|
||||
want: "17.29",
|
||||
},
|
||||
{
|
||||
name: "diashop picks inclusive total last",
|
||||
text: "Gesamtsumme (exkl. Steuern)\n12,34 €\nGesamtsumme (inkl. Steuern)\n17,29 €",
|
||||
want: "17.29",
|
||||
},
|
||||
{
|
||||
name: "no label",
|
||||
text: "some text without any amount label 12,34",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractAmount(tc.text); got != tc.want {
|
||||
t.Fatalf("extractAmount()=%q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"1.234,56", "1234.56", true},
|
||||
{"1,234.56", "1234.56", true},
|
||||
{"1234.56", "1234.56", true},
|
||||
{"1234,56", "1234.56", true},
|
||||
{"1.234.567,89", "1234567.89", true},
|
||||
{"1,234,567.89", "1234567.89", true},
|
||||
{"1.234", "1234.00", true},
|
||||
{"12,5", "12.50", true},
|
||||
{"12", "12.00", true},
|
||||
{"", "0.00", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := normalizeAmount(tc.in)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("normalizeAmount(%q) ok=%v want %v", tc.in, ok, tc.ok)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Fatalf("normalizeAmount(%q)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// History entities that OnlyOffice CRM actually accepts for history notes.
|
||||
// There is NO person/contact history in this API version: POST /api/2.0/crm/history.json
|
||||
// returns 400 "Value does not fall within the expected range." for entityType
|
||||
// contact/person/people/client/member. Verified against a live instance (#74).
|
||||
const (
|
||||
HistoryEntityOpportunity = "opportunity"
|
||||
HistoryEntityCase = "case"
|
||||
)
|
||||
|
||||
// IsCompany reports whether a CRM contact row is a company (vs a person).
|
||||
// The field arrives as JSON bool; be liberal about what we accept.
|
||||
func IsCompany(person map[string]any) bool {
|
||||
b, _ := person["isCompany"].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// ContactID returns the CRM id of a contact row as a plain string.
|
||||
func ContactID(row map[string]any) string {
|
||||
return fmt.Sprint(row["id"])
|
||||
}
|
||||
|
||||
// BuildContactEmailIndex scans all persons once and maps lowercase email →
|
||||
// contact id. Use this instead of calling FindPersonByEmail per address:
|
||||
// the index is O(N) over the whole CRM, the per-address lookup is O(N×M).
|
||||
func (c *Client) BuildContactEmailIndex(ctx context.Context) (map[string]string, error) {
|
||||
all, err := c.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := make(map[string]string, len(all)*2)
|
||||
for _, person := range all {
|
||||
if IsCompany(person) {
|
||||
continue
|
||||
}
|
||||
id := ContactID(person)
|
||||
for _, row := range ContactInfoRows(person) {
|
||||
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
||||
continue
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["data"])))
|
||||
if email != "" && email != "<nil>" {
|
||||
index[email] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// BuildPersonOpportunityIndex maps every opportunity member's contact id to a
|
||||
// deterministic representative opportunity: the one with the lowest numeric id.
|
||||
// OnlyOffice has no person-level history, so notes for a person go on their
|
||||
// deal — this index answers "which deal" in one pass.
|
||||
func (c *Client) BuildPersonOpportunityIndex(ctx context.Context) (map[string]string, error) {
|
||||
opps, err := c.ListAllOpportunities(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := map[string]string{}
|
||||
for _, opp := range opps {
|
||||
oppID := ContactID(opp)
|
||||
for _, member := range OpportunityMembers(opp) {
|
||||
pid := ContactID(member)
|
||||
if cur, ok := index[pid]; !ok || NumericIDLess(oppID, cur) {
|
||||
index[pid] = oppID
|
||||
}
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// NumericIDLess compares two string ids numerically when possible, falling
|
||||
// back to lexicographic order so results stay deterministic either way.
|
||||
func NumericIDLess(a, b string) bool {
|
||||
na, errA := strconv.Atoi(strings.TrimSpace(a))
|
||||
nb, errB := strconv.Atoi(strings.TrimSpace(b))
|
||||
if errA == nil && errB == nil && na != nb {
|
||||
return na < nb
|
||||
}
|
||||
return a < b
|
||||
}
|
||||
|
||||
// SortIDs orders id strings deterministically (numeric first, then lexical).
|
||||
func SortIDs(ids []string) {
|
||||
sort.Strings(ids)
|
||||
sort.SliceStable(ids, func(i, j int) bool { return NumericIDLess(ids[i], ids[j]) })
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package onlyoffice
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsCompany(t *testing.T) {
|
||||
if IsCompany(map[string]any{"isCompany": true}) != true {
|
||||
t.Fatal("true row not detected")
|
||||
}
|
||||
if IsCompany(map[string]any{"isCompany": false}) {
|
||||
t.Fatal("false row detected as company")
|
||||
}
|
||||
if IsCompany(map[string]any{}) {
|
||||
t.Fatal("missing field detected as company")
|
||||
}
|
||||
if IsCompany(nil) {
|
||||
t.Fatal("nil row detected as company")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericIDLess(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"9", "10", true},
|
||||
{"1747", "1748", true},
|
||||
{"abc", "abd", true},
|
||||
{"10", "9", false},
|
||||
{" 12 ", "13", true},
|
||||
{"x1", "2", false}, // non-numeric falls back lexical: "x1" > "2"
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := NumericIDLess(c.a, c.b); got != c.want {
|
||||
t.Errorf("NumericIDLess(%q,%q)=%v want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortIDs(t *testing.T) {
|
||||
ids := []string{"20", "3", "100", "1"}
|
||||
SortIDs(ids)
|
||||
want := "1 3 20 100"
|
||||
got := ""
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
got += " "
|
||||
}
|
||||
got += id
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("SortIDs=%q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactID(t *testing.T) {
|
||||
if ContactID(map[string]any{"id": float64(42)}) != "42" {
|
||||
t.Fatal("numeric id formatting broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryEntityConstants(t *testing.T) {
|
||||
if HistoryEntityOpportunity != "opportunity" || HistoryEntityCase != "case" {
|
||||
t.Fatal("history entity whitelist drifted from live-verified values")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -11,13 +11,19 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ListContacts returns a page of CRM contacts and the total count.
|
||||
// sortBy=id is always set: OnlyOffice filter.json without an explicit sort
|
||||
// order is non-deterministic on large contact sets, so a paged walk
|
||||
// (ListAllContacts, ListContactsByTag, FindCompany, FindPerson) can skip or
|
||||
// duplicate contacts across page boundaries.
|
||||
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
|
||||
q := url.Values{}
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
q.Set("sortBy", "id")
|
||||
if search != "" {
|
||||
q.Set("filterValue", search)
|
||||
}
|
||||
@@ -45,9 +51,12 @@ func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]a
|
||||
}
|
||||
|
||||
// FindCompany searches for a company contact with an exact (case-insensitive)
|
||||
// displayName match. Returns nil when not found.
|
||||
// displayName match after slogan/legal-suffix normalization. Returns nil when not found.
|
||||
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
|
||||
needle := CompanyGroupingKey(name)
|
||||
if needle == "" {
|
||||
return nil, nil
|
||||
}
|
||||
const page = 50
|
||||
for start := 0; ; start += page {
|
||||
items, total, err := c.ListContacts(ctx, page, start, name)
|
||||
@@ -66,6 +75,19 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any,
|
||||
break
|
||||
}
|
||||
}
|
||||
// Search filter may miss longer legal names; fall back to a full company scan.
|
||||
all, err := c.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, co := range all {
|
||||
if !isCompany(co) {
|
||||
continue
|
||||
}
|
||||
if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle {
|
||||
return co, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -95,6 +117,52 @@ func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// FindPersonByEmail finds a person whose primary or contact-info email matches
|
||||
// (case-insensitive). Companies are skipped. Returns nil when not found.
|
||||
func (c *Client) FindPersonByEmail(ctx context.Context, email string) (map[string]any, error) {
|
||||
needle := strings.ToLower(strings.TrimSpace(email))
|
||||
if needle == "" {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := c.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range all {
|
||||
if isCompany(p) {
|
||||
continue
|
||||
}
|
||||
for _, em := range contactEmailValues(p) {
|
||||
if strings.ToLower(strings.TrimSpace(em)) == needle {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func contactEmailValues(p map[string]any) []string {
|
||||
var out []string
|
||||
for _, key := range []string{"email", "primaryEmail"} {
|
||||
if v := strings.TrimSpace(fmt.Sprint(p[key])); v != "" && v != "<nil>" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
for _, row := range ContactInfoRows(p) {
|
||||
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(fmt.Sprint(row["data"]))
|
||||
if data == "" || data == "<nil>" {
|
||||
data = strings.TrimSpace(fmt.Sprint(row["value"]))
|
||||
}
|
||||
if data != "" {
|
||||
out = append(out, data)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isCompany(m map[string]any) bool {
|
||||
v, ok := m["isCompany"].(bool)
|
||||
return ok && v
|
||||
@@ -124,6 +192,44 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
|
||||
}
|
||||
|
||||
// UpdatePerson updates first/last name and optional company link on a person.
|
||||
// companyID == 0 leaves the company association unchanged (re-sends current
|
||||
// company id when present). OnlyOffice ignores companyId/about on form-encoded
|
||||
// PUT and may unlink the company when companyId is omitted — use JSON body.
|
||||
func (c *Client) UpdatePerson(ctx context.Context, personID, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
|
||||
body := map[string]any{
|
||||
"firstName": first,
|
||||
"lastName": last,
|
||||
}
|
||||
if companyID != 0 {
|
||||
body["companyId"] = companyID
|
||||
} else {
|
||||
// Preserve existing employer: omitted companyId unlinks on this API.
|
||||
if cur, err := c.GetContact(ctx, personID); err == nil {
|
||||
if co, ok := cur["company"].(map[string]any); ok {
|
||||
if id := flexInt(co["id"]); id != 0 {
|
||||
body["companyId"] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if jobTitle != "" {
|
||||
body["jobTitle"] = jobTitle
|
||||
}
|
||||
if about != "" {
|
||||
body["about"] = about
|
||||
}
|
||||
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), body)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// PUT response body is often stale; re-fetch for authoritative fields.
|
||||
if fresh, gerr := c.GetContact(ctx, personID); gerr == nil && fresh != nil {
|
||||
out = fresh
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddContactInfo attaches an email/website/phone/etc. to a contact.
|
||||
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
||||
if category == "" {
|
||||
@@ -142,6 +248,88 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
||||
}
|
||||
|
||||
// UpdateContactName renames a CRM company contact displayName.
|
||||
// Uses the company endpoint (person names go through /crm/contact/person/{id}).
|
||||
func (c *Client) UpdateContactName(ctx context.Context, contactID, newName string) (map[string]any, error) {
|
||||
body := map[string]any{
|
||||
"displayName": newName,
|
||||
"companyName": newName,
|
||||
"isCompany": true,
|
||||
}
|
||||
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(contactID)), body)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if fresh, gerr := c.GetContact(ctx, contactID); gerr == nil && fresh != nil {
|
||||
out = fresh
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
||||
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
||||
}
|
||||
|
||||
// CreateContactTag creates a contact tag by name. Idempotent: "already exists" is OK.
|
||||
func (c *Client) CreateContactTag(ctx context.Context, tagName string) error {
|
||||
fields := url.Values{}
|
||||
fields.Set("tagName", tagName)
|
||||
_, err := c.postFormObject(ctx, "/api/2.0/crm/contact/tag.json", fields)
|
||||
if err != nil && strings.Contains(err.Error(), "already exists") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// AddContactTag attaches tagName to a contact.
|
||||
func (c *Client) AddContactTag(ctx context.Context, contactID, tagName string) error {
|
||||
fields := url.Values{}
|
||||
fields.Set("tagName", tagName)
|
||||
_, err := c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveContactTag removes tagName from a contact.
|
||||
func (c *Client) RemoveContactTag(ctx context.Context, contactID, tagName string) error {
|
||||
fields := url.Values{}
|
||||
fields.Set("tagName", tagName)
|
||||
raw, err := c.deleteForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = unmarshalResponseObject(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListContactsByTag returns contacts filtered by a single CRM tag name.
|
||||
func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, startIndex int) ([]map[string]any, int, error) {
|
||||
if count <= 0 {
|
||||
count = 50
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
q.Set("tags", tagName)
|
||||
q.Set("sortBy", "id")
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var env struct {
|
||||
Response []map[string]any `json:"response"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := env.Total
|
||||
if total == 0 && len(env.Response) > 0 {
|
||||
total = len(env.Response)
|
||||
}
|
||||
return env.Response, total, nil
|
||||
}
|
||||
|
||||
// ListAllContacts paginates through every CRM contact.
|
||||
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
|
||||
const page = 100
|
||||
@@ -491,11 +679,25 @@ func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map
|
||||
}
|
||||
|
||||
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
|
||||
// Empty deadline defaults to now+14d (API requires a deadline). Empty
|
||||
// responsibleID falls back to the authenticated user — without this the
|
||||
// portal assigns a stub "Profile has been removed" owner.
|
||||
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
|
||||
if deadline == "" {
|
||||
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
|
||||
}
|
||||
if categoryID == 0 {
|
||||
categoryID = 2 // Opportunity (default CRM task category)
|
||||
}
|
||||
uid, err := c.SelfUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CreateCRMTask: resolve self: %w", err)
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
fields.Set("deadline", deadline)
|
||||
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||
fields.Set("responsibleId", uid)
|
||||
if contactID != 0 {
|
||||
fields.Set("contactId", strconv.Itoa(contactID))
|
||||
}
|
||||
@@ -511,11 +713,39 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
|
||||
}
|
||||
|
||||
// UpdateCRMTask updates title, deadline, category, and responsible on a CRM task.
|
||||
// Empty responsibleID falls back to SelfUserID; empty deadline is left unchanged
|
||||
// by the API only if omitted — callers should pass deadLine when known.
|
||||
func (c *Client) UpdateCRMTask(ctx context.Context, id, title, deadline string, categoryID int, responsibleID string) (map[string]any, error) {
|
||||
if responsibleID == "" {
|
||||
uid, err := c.SelfUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UpdateCRMTask: resolve self: %w", err)
|
||||
}
|
||||
responsibleID = uid
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
fields.Set("responsibleId", responsibleID)
|
||||
if deadline != "" {
|
||||
fields.Set("deadline", deadline)
|
||||
}
|
||||
if categoryID != 0 {
|
||||
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||
}
|
||||
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)), fields)
|
||||
}
|
||||
|
||||
// DeleteCRMTask removes a CRM task by id.
|
||||
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// CloseCRMTask closes (completes) a CRM task via the task close endpoint.
|
||||
func (c *Client) CloseCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s/close.json", url.PathEscape(id)), url.Values{})
|
||||
}
|
||||
|
||||
// ListTaskCategories returns CRM task categories.
|
||||
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
||||
|
||||
@@ -227,6 +227,11 @@ func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[st
|
||||
continue
|
||||
}
|
||||
cid := flexInt(row["companyId"])
|
||||
if cid == 0 {
|
||||
if co, ok := row["company"].(map[string]any); ok {
|
||||
cid = flexInt(co["id"])
|
||||
}
|
||||
}
|
||||
if cid == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
+172
@@ -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:], " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user