Compare commits
51
Commits
v0.5.0
..
ae776aa396
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae776aa396 | ||
|
|
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 | ||
|
|
fab2977cb1 | ||
|
|
eed57a68d4 | ||
|
|
b2aae21390 | ||
|
|
3d4aa10cf9 | ||
|
|
3b47d1400b |
@@ -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
|
||||||
+1
-1
@@ -11,7 +11,7 @@ ONLYOFFICE_PASS=
|
|||||||
# ONLYOFFICE_NAME=
|
# ONLYOFFICE_NAME=
|
||||||
# ONLYOFFICE_PASSWORD=
|
# ONLYOFFICE_PASSWORD=
|
||||||
#
|
#
|
||||||
# produktor.io operator aliases (CLI-only):
|
# Optional CLI-only aliases:
|
||||||
# OO_URL=
|
# OO_URL=
|
||||||
# OO_USER=
|
# OO_USER=
|
||||||
# OO_PASS=
|
# OO_PASS=
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
issues: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-please:
|
release-please:
|
||||||
name: Release Please
|
name: Release Please
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Run release-please
|
- name: Run release-please
|
||||||
@@ -23,6 +25,18 @@ jobs:
|
|||||||
manifest-file: .release-please-manifest.json
|
manifest-file: .release-please-manifest.json
|
||||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
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:
|
outputs:
|
||||||
release_created: ${{ steps.release.outputs.release_created }}
|
release_created: ${{ steps.release.outputs.release_created }}
|
||||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||||
|
|||||||
@@ -16,12 +16,31 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
goreleaser:
|
goreleaser:
|
||||||
name: GoReleaser
|
name: GoReleaser
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -15,13 +16,13 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
go: ["1.22", "stable"]
|
go: ["1.25", "stable"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version: ${{ matrix.go }}
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.5.0"
|
".": "0.11.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
- `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`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents**. **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md).
|
||||||
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
|
- 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):
|
- **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).
|
- `main.go` — entry point (docstring lists the command tree).
|
||||||
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
|
- `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_tasks.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`.
|
||||||
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
- 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/`.
|
- **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.
|
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
||||||
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
|
- **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in private [`eSlider/oo-workspace`](https://git.produktor.io/eSlider/oo-workspace) (`oow`), not in this public tree.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
+164
-40
@@ -4,6 +4,142 @@ 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
|
[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).
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### 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.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)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **office:** table detail panes and Alt+1/2/3 layout toggles ([934af21](https://github.com/eSlider/go-onlyoffice/commit/934af21bc93a54df9f40aacb4d89468b00d7562e))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* CHANGELOG for office v0.5.1 release ([2985070](https://github.com/eSlider/go-onlyoffice/commit/29850701f8c9ab0c6d6427f63eaa189a1c558140))
|
||||||
|
|
||||||
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
|
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,56 +176,44 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-06-24
|
||||||
|
|
||||||
### Added — `office` TUI
|
### Added — `office` TUI
|
||||||
|
|
||||||
- **`cmd/office`** — Bubble Tea terminal UI mirroring OnlyOffice Workspace:
|
- **Pane layout** — default **10% / 60% / 30%** split (nav / list / detail); drag vertical borders to resize; **Alt+1/2/3** still toggles panes.
|
||||||
three-pane layout (module tree, multi-select list, markdown preview).
|
- **Filter mode** (`f`) — live filter on nav + list; right pane is the search query; **Esc** clears.
|
||||||
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`.
|
- **Mail preview** — HTML bodies rendered colorized in the terminal (glamour); read-only scrollable document pane.
|
||||||
- **`cmd/internal/bootstrap`** — shared env loading and client auth for `oo`
|
- **Mail infinite scroll** — loads the next page automatically when the cursor nears the end of the list.
|
||||||
and `office`.
|
- **Scrollbars** — vertical scrollbar on nav, list body, and detail preview when content exceeds the viewport.
|
||||||
- Integration tests under `cmd/office/fetch/` and `cmd/office/preview/`
|
- **Calendar** — unified calendars + events leaf with **Type** column and date range (−7d … +30d).
|
||||||
(`-tags=integration`) hit the live OnlyOffice API — no fixture maps or
|
- **Tasks** — humanized status labels and relative deadlines in the list.
|
||||||
httptest vendor mocks. Unit tests remain for pure UI/formatting helpers only.
|
- **Projects** — open/closed row colors; status toggle in the detail form; **Save** sends `responsibleId` and status via `UpdateProjectStatus`.
|
||||||
|
|
||||||
|
### Changed — `office` TUI
|
||||||
|
|
||||||
|
- Flattened navigation: **Projects**, **Tasks**, **By project**, **Calendar**, **CRM**, **Mail**, **Users** (removed Browse; Calendar no longer splits Calendars/Events).
|
||||||
|
- Middle pane truncates overflowing cells with `…`; full text on the cursor or Space-selected row.
|
||||||
|
- Detail form tab order: Title → Description → Status → Save → Delete.
|
||||||
|
- **j/k** scrolls mail/file preview and read-only detail forms; mouse wheel scrolls detail when hovering the content area.
|
||||||
|
|
||||||
### Added — library
|
### Added — library
|
||||||
|
|
||||||
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`:
|
- `UpdateProjectStatus` — `PUT /api/2.0/project/{id}/status` for open/closed lifecycle.
|
||||||
merge duplicate companies/persons/deals by normalized name; remove duplicate
|
|
||||||
contact-info rows and opportunity members (including same `displayName` with
|
|
||||||
different ids); fix malformed deal titles (` @ Company` → `Company`).
|
|
||||||
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
|
|
||||||
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
|
|
||||||
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
|
|
||||||
- Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names
|
|
||||||
like `Affirm` and `Affirm — Fraud Engineering` (companies, deals, members).
|
|
||||||
- **Workspace mail** — [`mails.go`](mails.go): `ListMailAccounts`, `ListMailFolders`,
|
|
||||||
`ListMailMessages`, `GetMailMessage`, `RemoveMailMessages` against OnlyOffice
|
|
||||||
Mail addon (`/api/2.0/mail/*`).
|
|
||||||
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
|
|
||||||
dedupe smoke tests.
|
|
||||||
|
|
||||||
### Added — CLI
|
## [0.5.1] — 2026-06-24
|
||||||
|
|
||||||
- `oo mails accounts|folders|list|get|delete` — OnlyOffice Workspace mail (same `ONLYOFFICE_*` creds).
|
### Added — `office` TUI
|
||||||
- `oo companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`,
|
|
||||||
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
|
|
||||||
|
|
||||||
### Added — CI/CD
|
- **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes.
|
||||||
|
- Visible panes share **100% terminal width** evenly; pane content fills its column.
|
||||||
|
- Tab focus skips hidden panes.
|
||||||
|
|
||||||
- GitHub Actions: `test.yml` (vet, unit tests, `oo` build), `release-please.yml`
|
### Changed — `office` TUI
|
||||||
(semver from conventional commits), `release.yml` (GoReleaser binaries for `oo`).
|
|
||||||
|
|
||||||
### Changed — CLI
|
- Split detail pane: form/document top (~72%), CRUD action bar bottom.
|
||||||
|
- Middle pane: multi-column table with sort, selection, and full-width columns.
|
||||||
- README: expanded **oo CLI use cases** (CRM cleanup, applications sync, mail, contacts).
|
- Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
|
||||||
- `oo applications sync` — empty position uses company-only deal title; skips
|
- Row selection auto-loads detail; files show document preview, entities show forms.
|
||||||
duplicate opportunity members; paginated find-or-create lookups.
|
|
||||||
- `oo` now loads OnlyOffice credentials only from `.env` in the current
|
|
||||||
working directory.
|
|
||||||
- Produktor.io shorthand env names are accepted by the CLI dotenv loader:
|
|
||||||
`OO_URL` → `ONLYOFFICE_URL`, `OO_USER` → `ONLYOFFICE_USER`, and
|
|
||||||
`OO_PASS` → `ONLYOFFICE_PASS`. Canonical `ONLYOFFICE_*` values still win
|
|
||||||
when already set in the process environment.
|
|
||||||
|
|
||||||
## [0.6.0] - 2026-04-24
|
## [0.6.0] - 2026-04-24
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ deals, total, _ := client.ListOpportunities(ctx, 50, 0)
|
|||||||
company, _ := client.FindCompany(ctx, "ACME")
|
company, _ := client.FindCompany(ctx, "ACME")
|
||||||
|
|
||||||
// Subtasks (form-encoded)
|
// Subtasks (form-encoded)
|
||||||
client.AddSubtask(ctx, "4242", "Prepare CV")
|
client.AddSubtask(ctx, "4242", "Prepare notes")
|
||||||
```
|
```
|
||||||
|
|
||||||
### oo (bundled CLI)
|
### oo (bundled CLI)
|
||||||
@@ -552,14 +552,13 @@ oo calendar events --start 2026-04-24 --end 2026-05-01
|
|||||||
oo projects list
|
oo projects list
|
||||||
oo projects get 33
|
oo projects get 33
|
||||||
oo tasks list --all --verbose
|
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 persons create --first Jane --last Doe --email jane@example.com
|
||||||
oo companies create --name "Acme GmbH" --website https://acme.com
|
oo companies create --name "Acme GmbH" --website https://acme.com
|
||||||
oo opportunities list
|
oo opportunities list
|
||||||
oo opportunities stages
|
oo opportunities stages
|
||||||
oo cases list
|
oo cases list
|
||||||
oo crm-tasks categories
|
oo crm-tasks categories
|
||||||
oo applications sync --path ./applications/2026 --apply
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### office (TUI)
|
### office (TUI)
|
||||||
@@ -613,7 +612,7 @@ oo projects files download 12345 --to ./copy.md
|
|||||||
oo projects files rename 12345 notes-v2.md
|
oo projects files rename 12345 notes-v2.md
|
||||||
oo projects files delete 12345
|
oo projects files delete 12345
|
||||||
oo tasks files list 208
|
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
|
oo tasks files detach 208 12345
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -623,33 +622,28 @@ oo tasks files detach 208 12345
|
|||||||
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
|
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
|
||||||
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
||||||
| `users` | `list`, `self` (alias: `oo whoami`) |
|
| `users` | `list`, `self` (alias: `oo whoami`) |
|
||||||
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info` |
|
||||||
| `persons` | `list` (filtered), `create`, `delete` |
|
|
||||||
| `companies` | `list` (filtered), `create`, `delete` |
|
|
||||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
|
|
||||||
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
||||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||||
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||||
|
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
|
||||||
| `crm` | `cleanup` |
|
| `crm` | `cleanup` |
|
||||||
| `mails` | `accounts`, `folders`, `list`, `get`, `delete` |
|
| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
|
||||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||||
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
|
||||||
| `applications` | `sync` |
|
|
||||||
|
|
||||||
The CLI reads only `.env` from the current working directory (godotenv is a
|
The CLI reads only `.env` from the current working directory (godotenv is a
|
||||||
CLI-only concern — the library itself never loads dotfiles).
|
CLI-only concern — the library itself never loads dotfiles).
|
||||||
|
|
||||||
Canonical `ONLYOFFICE_*` variables win over aliases. For produktor.io operator
|
Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases:
|
||||||
files, `OO_URL` / `OO_USER` / `OO_PASS` are accepted as CLI-only aliases for
|
`OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
||||||
`ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
|
||||||
|
|
||||||
Run `oo --help` or `oo <subject> --help` for the full command reference.
|
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
|
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
||||||
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`,
|
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, …) were
|
||||||
> `oo applications-sync`, …) were replaced by subject-based equivalents
|
> replaced by subject-based equivalents (`oo calendar events`, `oo tasks list`,
|
||||||
> (`oo calendar events`, `oo tasks list`, `oo contacts list`,
|
> `oo contacts list`). Flags on leaf commands are unchanged.
|
||||||
> `oo applications sync`). Flags on leaf commands are unchanged.
|
|
||||||
|
|
||||||
## oo CLI use cases
|
## oo CLI use cases
|
||||||
|
|
||||||
@@ -671,7 +665,7 @@ Every list command accepts `-o table` (default) or `-o json` for scripting.
|
|||||||
### CRM cleanup after imports or sync drift
|
### CRM cleanup after imports or sync drift
|
||||||
|
|
||||||
**Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice,
|
**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.
|
HR contact linked to a deal twice.
|
||||||
|
|
||||||
**One-shot fix** — runs every dedupe pass in order:
|
**One-shot fix** — runs every dedupe pass in order:
|
||||||
@@ -710,13 +704,39 @@ oo contacts dedupe-info
|
|||||||
# Two deals with the same title
|
# Two deals with the same title
|
||||||
oo opportunities dedupe
|
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
|
oo opportunities dedupe-members
|
||||||
|
|
||||||
# Titles like " @ contoso" or extra whitespace
|
# Titles like " @ Acme" or extra whitespace
|
||||||
oo opportunities fix-titles
|
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
|
**Deal grouping flag** — when the same role at the same company created
|
||||||
separate deals (`Engineer @ Acme` vs `Engineer`):
|
separate deals (`Engineer @ Acme` vs `Engineer`):
|
||||||
|
|
||||||
@@ -728,41 +748,11 @@ oo crm cleanup --ignore-company-suffix
|
|||||||
**Inspect before/after:**
|
**Inspect before/after:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
oo opportunities list --count 200 | grep -i contoso
|
oo opportunities list --count 200
|
||||||
oo contacts get 857 -o json
|
oo contacts get CONTACT_ID -o json
|
||||||
oo crm cleanup -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`)
|
### Workspace mail (`oo mails`)
|
||||||
|
|
||||||
@@ -795,8 +785,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)
|
# Remove one or more messages (server moves to trash or deletes per Mail rules)
|
||||||
oo mails delete 5664
|
oo mails delete 5664
|
||||||
oo mails delete 5664 5663 5661
|
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`
|
**Table output** splits the `from` header into `fromName` and `fromAddress`
|
||||||
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
|
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
|
||||||
API payload.
|
API payload.
|
||||||
@@ -836,8 +839,8 @@ oo calendar events --start 2026-06-24 --end 2026-07-01
|
|||||||
# Schedule interview block
|
# Schedule interview block
|
||||||
oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z
|
oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z
|
||||||
|
|
||||||
# Attach CV to a hiring task
|
# Attach a file to a task
|
||||||
oo tasks files upload 208 ./cv.pdf
|
oo tasks files upload 208 ./notes.pdf
|
||||||
oo projects files list 33
|
oo projects files list 33
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -845,7 +848,7 @@ oo projects files list 33
|
|||||||
|
|
||||||
| When | Command |
|
| 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` |
|
| After bulk CSV import into CRM | `oo crm cleanup` |
|
||||||
| Weekly inbox triage | `oo mails list --folder inbox --limit 100` |
|
| Weekly inbox triage | `oo mails list --folder inbox --limit 100` |
|
||||||
| Before exec reporting | `oo opportunities list` + `oo projects list` |
|
| Before exec reporting | `oo opportunities list` + `oo projects list` |
|
||||||
@@ -859,9 +862,9 @@ oo projects files list 33
|
|||||||
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
||||||
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
||||||
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
||||||
| `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` |
|
| `OO_URL`, `OO_USER`, `OO_PASS` | Optional CLI-only aliases for `ONLYOFFICE_*` |
|
||||||
|
|
||||||
Mail, CRM cleanup, and applications sync are documented in [oo CLI use cases](#oo-cli-use-cases) above.
|
Mail and CRM cleanup are documented in [oo CLI use cases](#oo-cli-use-cases) above. Personal disk inventory / dossier sync lives in the private `oo-workspace` (`oow`) tooling.
|
||||||
|
|
||||||
### CI / releases
|
### CI / releases
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +11,7 @@ package onlyoffice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -35,8 +36,9 @@ type Client struct {
|
|||||||
|
|
||||||
// NewClient returns a new Client backed by http.DefaultClient.
|
// NewClient returns a new Client backed by http.DefaultClient.
|
||||||
func NewClient(c Credentials) *Client {
|
func NewClient(c Credentials) *Client {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
return &Client{
|
return &Client{
|
||||||
client: http.DefaultClient,
|
client: &http.Client{Jar: jar},
|
||||||
credentials: &c,
|
credentials: &c,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,23 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
start := Time(time.Now().AddDate(0, 0, -2))
|
start := Time(time.Now().AddDate(0, 0, -2))
|
||||||
deadline := 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{
|
task, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||||
ProjectId: *project.ID,
|
ProjectId: *project.ID,
|
||||||
Title: "integration parent task",
|
Title: "integration parent task",
|
||||||
@@ -146,6 +163,7 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
|||||||
StartDate: start,
|
StartDate: start,
|
||||||
Deadline: deadline,
|
Deadline: deadline,
|
||||||
Priority: int(TaskPriorityNormal),
|
Priority: int(TaskPriorityNormal),
|
||||||
|
MilestoneId: int(*ms.ID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProjectTask: %v", err)
|
t.Fatalf("CreateProjectTask: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalendarItemFields maps calendar API rows to list items.
|
||||||
|
var CalendarItemFields = FieldMap{IDKey: "objectId", TitleKey: "title"}
|
||||||
|
|
||||||
|
// CalendarItemsFromRows converts API calendar/event rows into list items.
|
||||||
|
func CalendarItemsFromRows(rows []map[string]any) []model.Item {
|
||||||
|
out := make([]model.Item, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
kind, typeLabel := model.ClassifyCalendarRow(row)
|
||||||
|
title := str(row, "title")
|
||||||
|
if title == "" {
|
||||||
|
title = str(row, "name")
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "(untitled)"
|
||||||
|
}
|
||||||
|
raw := make(map[string]any, len(row)+1)
|
||||||
|
for k, v := range row {
|
||||||
|
raw[k] = v
|
||||||
|
}
|
||||||
|
raw["type"] = typeLabel
|
||||||
|
out = append(out, model.Item{
|
||||||
|
ID: idStr(row, "objectId"),
|
||||||
|
Title: title,
|
||||||
|
Kind: kind,
|
||||||
|
Raw: raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCalendar returns calendars and events for the upcoming date window.
|
||||||
|
func (l *Loader) ListCalendar(ctx context.Context) ([]model.Item, error) {
|
||||||
|
if l == nil || l.Client == nil {
|
||||||
|
return nil, fmt.Errorf("fetch: client is nil")
|
||||||
|
}
|
||||||
|
start := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
|
||||||
|
end := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
|
||||||
|
rows, err := l.Client.ListCalendars(ctx, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return CalendarItemsFromRows(rows), nil
|
||||||
|
}
|
||||||
@@ -39,8 +39,7 @@ func TestIntegrationListAllSubjects(t *testing.T) {
|
|||||||
cases := []model.ListSpec{
|
cases := []model.ListSpec{
|
||||||
{Subject: model.SubjectProjects},
|
{Subject: model.SubjectProjects},
|
||||||
{Subject: model.SubjectTasks},
|
{Subject: model.SubjectTasks},
|
||||||
{Subject: model.SubjectCalendars},
|
{Subject: model.SubjectCalendar},
|
||||||
{Subject: model.SubjectEvents},
|
|
||||||
{Subject: model.SubjectContacts},
|
{Subject: model.SubjectContacts},
|
||||||
{Subject: model.SubjectPersons},
|
{Subject: model.SubjectPersons},
|
||||||
{Subject: model.SubjectCompanies},
|
{Subject: model.SubjectCompanies},
|
||||||
|
|||||||
+60
-55
@@ -6,12 +6,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MailListPageSize is how many messages each infinite-scroll fetch loads.
|
||||||
|
const MailListPageSize = 25
|
||||||
|
|
||||||
// Loader fetches list items for a menu subject using the OnlyOffice client.
|
// Loader fetches list items for a menu subject using the OnlyOffice client.
|
||||||
type Loader struct {
|
type Loader struct {
|
||||||
Client *onlyoffice.Client
|
Client *onlyoffice.Client
|
||||||
@@ -30,10 +32,12 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e
|
|||||||
return l.listTasksForProject(ctx, spec.ProjectID)
|
return l.listTasksForProject(ctx, spec.ProjectID)
|
||||||
}
|
}
|
||||||
return l.listTasks(ctx)
|
return l.listTasks(ctx)
|
||||||
|
case model.SubjectCalendar:
|
||||||
|
return l.ListCalendar(ctx)
|
||||||
case model.SubjectCalendars:
|
case model.SubjectCalendars:
|
||||||
return l.listCalendars(ctx)
|
return l.ListCalendar(ctx)
|
||||||
case model.SubjectEvents:
|
case model.SubjectEvents:
|
||||||
return l.listEvents(ctx)
|
return l.ListCalendar(ctx)
|
||||||
case model.SubjectContacts:
|
case model.SubjectContacts:
|
||||||
return l.listContacts(ctx, nil)
|
return l.listContacts(ctx, nil)
|
||||||
case model.SubjectPersons:
|
case model.SubjectPersons:
|
||||||
@@ -49,15 +53,15 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e
|
|||||||
case model.SubjectCRMTasks:
|
case model.SubjectCRMTasks:
|
||||||
return l.listCRMTasks(ctx)
|
return l.listCRMTasks(ctx)
|
||||||
case model.SubjectMailInbox:
|
case model.SubjectMailInbox:
|
||||||
return l.listMail(ctx, onlyoffice.MailFolderInbox)
|
return l.listMail(ctx, onlyoffice.MailFolderInbox, 0)
|
||||||
case model.SubjectMailSent:
|
case model.SubjectMailSent:
|
||||||
return l.listMail(ctx, onlyoffice.MailFolderSent)
|
return l.listMail(ctx, onlyoffice.MailFolderSent, 0)
|
||||||
case model.SubjectMailDrafts:
|
case model.SubjectMailDrafts:
|
||||||
return l.listMail(ctx, onlyoffice.MailFolderDrafts)
|
return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0)
|
||||||
case model.SubjectMailTrash:
|
case model.SubjectMailTrash:
|
||||||
return l.listMail(ctx, onlyoffice.MailFolderTrash)
|
return l.listMail(ctx, onlyoffice.MailFolderTrash, 0)
|
||||||
case model.SubjectMailSpam:
|
case model.SubjectMailSpam:
|
||||||
return l.listMail(ctx, onlyoffice.MailFolderSpam)
|
return l.listMail(ctx, onlyoffice.MailFolderSpam, 0)
|
||||||
case model.SubjectUsers:
|
case model.SubjectUsers:
|
||||||
return l.listUsers(ctx)
|
return l.listUsers(ctx)
|
||||||
case model.SubjectProjectFiles:
|
case model.SubjectProjectFiles:
|
||||||
@@ -97,6 +101,8 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
|
|||||||
return l.Client.GetTaskByID(ctx, item.ID)
|
return l.Client.GetTaskByID(ctx, item.ID)
|
||||||
case model.KindProject:
|
case model.KindProject:
|
||||||
return l.Client.GetProjectByID(ctx, item.ID)
|
return l.Client.GetProjectByID(ctx, item.ID)
|
||||||
|
case model.KindUser:
|
||||||
|
return l.Client.GetUser(ctx, item.ID)
|
||||||
default:
|
default:
|
||||||
if item.Raw != nil {
|
if item.Raw != nil {
|
||||||
return item.Raw, nil
|
return item.Raw, nil
|
||||||
@@ -244,13 +250,27 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
|
|||||||
if p.Title != nil {
|
if p.Title != nil {
|
||||||
title = *p.Title
|
title = *p.Title
|
||||||
}
|
}
|
||||||
status := ""
|
raw := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"title": title,
|
||||||
|
}
|
||||||
|
if p.TaskCount != nil {
|
||||||
|
raw["taskCount"] = *p.TaskCount
|
||||||
|
}
|
||||||
|
if p.TaskCountTotal != nil {
|
||||||
|
raw["taskCountTotal"] = *p.TaskCountTotal
|
||||||
|
}
|
||||||
|
if p.DocumentsCount != nil {
|
||||||
|
raw["documentsCount"] = *p.DocumentsCount
|
||||||
|
}
|
||||||
|
if p.ParticipantCount != nil {
|
||||||
|
raw["participantCount"] = *p.ParticipantCount
|
||||||
|
}
|
||||||
if p.Status != nil {
|
if p.Status != nil {
|
||||||
status = fmt.Sprint(*p.Status)
|
raw["status"] = *p.Status
|
||||||
}
|
}
|
||||||
items[i] = model.Item{
|
items[i] = model.Item{
|
||||||
ID: id, Title: title, Subtitle: status, Kind: model.KindProject,
|
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
|
||||||
Raw: map[string]any{"id": id, "title": title, "status": status},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
@@ -264,24 +284,6 @@ func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
|
|||||||
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
|
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Loader) listCalendars(ctx context.Context) ([]model.Item, error) {
|
|
||||||
rows, err := l.Client.ListCalendars(ctx, "", "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ItemsFromMaps(rows, model.KindCalendar, FieldMap{IDKey: "objectId", TitleKey: "title"}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Loader) listEvents(ctx context.Context) ([]model.Item, error) {
|
|
||||||
start := time.Now().Format("2006-01-02")
|
|
||||||
end := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
|
||||||
rows, err := l.Client.ListEvents(ctx, start, end)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ItemsFromMaps(rows, model.KindEvent, FieldMap{IDKey: "objectId", TitleKey: "title", SubtitleKey: "start"}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
|
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
|
||||||
rows, err := l.Client.ListAllContacts(ctx)
|
rows, err := l.Client.ListAllContacts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -324,39 +326,42 @@ func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
|
|||||||
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
|
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Loader) listMail(ctx context.Context, folder int) ([]model.Item, error) {
|
func (l *Loader) listMail(ctx context.Context, folder int, startIndex int) ([]model.Item, error) {
|
||||||
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{Folder: folder, Count: 50})
|
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{
|
||||||
|
Folder: folder,
|
||||||
|
Count: MailListPageSize,
|
||||||
|
StartIndex: startIndex,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
|
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
// ListMailMore loads the next page of messages for an active mail folder list.
|
||||||
users, err := l.Client.GetUsers()
|
func (l *Loader) ListMailMore(ctx context.Context, spec model.ListSpec, startIndex int) ([]model.Item, error) {
|
||||||
if err != nil {
|
folder, ok := mailFolderForSubject(spec.Subject)
|
||||||
return nil, err
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("fetch: %q is not a mail folder", spec.Subject)
|
||||||
}
|
}
|
||||||
items := make([]model.Item, len(users))
|
return l.listMail(ctx, folder, startIndex)
|
||||||
for i, u := range users {
|
}
|
||||||
id := ""
|
|
||||||
if u.ID != nil {
|
func mailFolderForSubject(subject model.Subject) (int, bool) {
|
||||||
id = fmt.Sprint(*u.ID)
|
switch subject {
|
||||||
|
case model.SubjectMailInbox:
|
||||||
|
return onlyoffice.MailFolderInbox, true
|
||||||
|
case model.SubjectMailSent:
|
||||||
|
return onlyoffice.MailFolderSent, true
|
||||||
|
case model.SubjectMailDrafts:
|
||||||
|
return onlyoffice.MailFolderDrafts, true
|
||||||
|
case model.SubjectMailTrash:
|
||||||
|
return onlyoffice.MailFolderTrash, true
|
||||||
|
case model.SubjectMailSpam:
|
||||||
|
return onlyoffice.MailFolderSpam, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
}
|
}
|
||||||
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) {
|
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type FieldMap struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TaskItemFields is the default field map for project tasks.
|
// TaskItemFields is the default field map for project tasks.
|
||||||
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
|
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title"}
|
||||||
|
|
||||||
// ProjectItemFields is the default field map for projects.
|
// ProjectItemFields is the default field map for projects.
|
||||||
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
|
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreviewMarkdown loads item data and returns markdown for the preview pane.
|
||||||
|
func (l *Loader) PreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
|
||||||
|
if l == nil || l.Client == nil {
|
||||||
|
return "", fmt.Errorf("fetch: client is nil")
|
||||||
|
}
|
||||||
|
if item.Kind == model.KindFile {
|
||||||
|
return l.filePreviewMarkdown(ctx, item)
|
||||||
|
}
|
||||||
|
raw, err := l.Detail(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return preview.EntityMarkdown(string(item.Kind), raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
|
||||||
|
if item.ID == "" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveItem persists editable form fields for supported entity kinds.
|
||||||
|
func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.FormFields) error {
|
||||||
|
if l == nil || l.Client == nil {
|
||||||
|
return fmt.Errorf("fetch: client is nil")
|
||||||
|
}
|
||||||
|
switch item.Kind {
|
||||||
|
case model.KindTask:
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loader) saveProject(ctx context.Context, projectID string, fields model.FormFields) error {
|
||||||
|
id, err := strconv.Atoi(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fields.ResponsibleID == "" {
|
||||||
|
raw, derr := l.Detail(ctx, model.Item{ID: projectID, Kind: model.KindProject})
|
||||||
|
if derr == nil {
|
||||||
|
fields.ResponsibleID = model.ResponsibleIDFromRaw(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := onlyoffice.ProjectUpdateRequest{
|
||||||
|
ID: id,
|
||||||
|
Title: fields.Primary,
|
||||||
|
Description: fields.Secondary,
|
||||||
|
}
|
||||||
|
if fields.ResponsibleID != "" {
|
||||||
|
req.ResponsibleID = fields.ResponsibleID
|
||||||
|
}
|
||||||
|
if _, err := l.Client.UpdateProject(req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fields.HasStatus {
|
||||||
|
if _, err := l.Client.UpdateProjectStatus(id, string(fields.Status)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailForm loads form field values for the detail pane.
|
||||||
|
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
|
||||||
|
raw, err := l.Detail(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
return model.FormFields{}, err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package fetch_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationSaveProject(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no projects")
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
fields, err := loader.DetailForm(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DetailForm: %v", err)
|
||||||
|
}
|
||||||
|
if fields.ResponsibleID == "" {
|
||||||
|
t.Skip("project has no responsible id on this instance")
|
||||||
|
}
|
||||||
|
marker := " office-save-test " + time.Now().UTC().Format(time.RFC3339)
|
||||||
|
fields.Primary = fields.Primary + marker
|
||||||
|
if err := loader.SaveItem(ctx, item, fields); err != nil {
|
||||||
|
t.Fatalf("SaveItem: %v", err)
|
||||||
|
}
|
||||||
|
after, err := loader.DetailForm(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DetailForm after save: %v", err)
|
||||||
|
}
|
||||||
|
if after.Primary != fields.Primary {
|
||||||
|
t.Fatalf("title not saved: got %q want %q", after.Primary, fields.Primary)
|
||||||
|
}
|
||||||
|
fields.Primary = stringsTrimSuffixMarker(after.Primary, marker)
|
||||||
|
if err := loader.SaveItem(ctx, item, fields); err != nil {
|
||||||
|
t.Logf("cleanup save: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsTrimSuffixMarker(s, marker string) string {
|
||||||
|
if len(s) >= len(marker) && s[len(s)-len(marker):] == marker {
|
||||||
|
return s[:len(s)-len(marker)]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(taskID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("task id %q: %w", taskID, err)
|
||||||
|
}
|
||||||
|
status := onlyoffice.ProjectTaskStatus(fields.TaskStatus)
|
||||||
|
if closing {
|
||||||
|
status = onlyoffice.ProjectTaskStatusClosed
|
||||||
|
}
|
||||||
|
req := onlyoffice.ProjectTaskUpdateRequest{
|
||||||
|
ID: id,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package fetch_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no tasks")
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
fields, err := loader.DetailForm(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
restore := fields
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = loader.UpdateTask(context.Background(), item.ID, restore)
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
got, err := loader.DetailForm(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Primary != fields.Primary {
|
||||||
|
t.Fatalf("title: got %q want %q", got.Primary, fields.Primary)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no tasks")
|
||||||
|
}
|
||||||
|
title, desc, err := loader.TaskFields(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
t.Fatal("empty title")
|
||||||
|
}
|
||||||
|
_ = desc
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+1
-1
@@ -26,7 +26,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := ui.NewModel(client)
|
m := ui.NewModel(client)
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+19
-14
@@ -5,6 +5,8 @@ type ActionID string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ActionView ActionID = "view"
|
ActionView ActionID = "view"
|
||||||
|
ActionSave ActionID = "save"
|
||||||
|
ActionClose ActionID = "close"
|
||||||
ActionDelete ActionID = "delete"
|
ActionDelete ActionID = "delete"
|
||||||
ActionRefresh ActionID = "refresh"
|
ActionRefresh ActionID = "refresh"
|
||||||
ActionDownload ActionID = "download"
|
ActionDownload ActionID = "download"
|
||||||
@@ -17,48 +19,51 @@ type ItemAction struct {
|
|||||||
Danger bool
|
Danger bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionsFor returns available operations for an item kind.
|
// ActionsFor returns available operations for an item kind (detail pane action bar).
|
||||||
func ActionsFor(kind Kind) []ItemAction {
|
func ActionsFor(kind Kind) []ItemAction {
|
||||||
switch kind {
|
switch kind {
|
||||||
case KindProject:
|
case KindProject:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
{ID: ActionSave, Label: "Save"},
|
||||||
{ID: ActionDelete, Label: "Delete project", Danger: true},
|
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||||
}
|
}
|
||||||
case KindTask, KindCRMTask:
|
case KindTask:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
{ID: ActionSave, Label: "Save"},
|
||||||
{ID: ActionDelete, Label: "Delete task", Danger: true},
|
{ID: ActionClose, Label: "Close"},
|
||||||
|
}
|
||||||
|
case KindCRMTask:
|
||||||
|
return []ItemAction{
|
||||||
|
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||||
}
|
}
|
||||||
case KindContact:
|
case KindContact:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
|
||||||
{ID: ActionDelete, Label: "Delete contact", Danger: true},
|
{ID: ActionDelete, Label: "Delete contact", Danger: true},
|
||||||
}
|
}
|
||||||
case KindOpportunity:
|
case KindOpportunity:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
|
||||||
{ID: ActionDelete, Label: "Delete deal", Danger: true},
|
{ID: ActionDelete, Label: "Delete deal", Danger: true},
|
||||||
}
|
}
|
||||||
case KindCase:
|
case KindCase:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
|
||||||
{ID: ActionDelete, Label: "Delete case", Danger: true},
|
{ID: ActionDelete, Label: "Delete case", Danger: true},
|
||||||
}
|
}
|
||||||
case KindMail:
|
case KindMail:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "Read message"},
|
|
||||||
{ID: ActionDelete, Label: "Delete message", Danger: true},
|
{ID: ActionDelete, Label: "Delete message", Danger: true},
|
||||||
}
|
}
|
||||||
case KindFile:
|
case KindFile:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "Preview file"},
|
|
||||||
{ID: ActionDownload, Label: "Download"},
|
{ID: ActionDownload, Label: "Download"},
|
||||||
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
||||||
}
|
}
|
||||||
case KindEvent, KindCalendar, KindUser:
|
case KindUser:
|
||||||
return []ItemAction{{ID: ActionView, Label: "View details"}}
|
return []ItemAction{
|
||||||
|
{ID: ActionSave, Label: "Save"},
|
||||||
|
}
|
||||||
|
case KindEvent, KindCalendar:
|
||||||
|
return nil
|
||||||
default:
|
default:
|
||||||
return []ItemAction{{ID: ActionView, Label: "View details"}}
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ClassifyCalendarRow decides whether an API row is a calendar or an event.
|
||||||
|
func ClassifyCalendarRow(raw map[string]any) (Kind, string) {
|
||||||
|
if raw == nil {
|
||||||
|
return KindCalendar, "Calendar"
|
||||||
|
}
|
||||||
|
if strRaw(raw, "start") != "" || strRaw(raw, "end") != "" {
|
||||||
|
return KindEvent, "Event"
|
||||||
|
}
|
||||||
|
if strRaw(raw, "eventType") != "" {
|
||||||
|
return KindEvent, "Event"
|
||||||
|
}
|
||||||
|
return KindCalendar, "Calendar"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarTypeLabel returns the type column text for a calendar list row.
|
||||||
|
func CalendarTypeLabel(it Item) string {
|
||||||
|
if it.Raw != nil {
|
||||||
|
if t := strRaw(it.Raw, "type"); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, label := ClassifyCalendarRow(it.Raw)
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatCalendarDateTime formats start/end timestamps for the calendar table.
|
||||||
|
func FormatCalendarDateTime(v any) string {
|
||||||
|
t, ok := parseDeadlineTime(v)
|
||||||
|
if !ok {
|
||||||
|
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format("Jan 2 15:04")
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClassifyCalendarRow(t *testing.T) {
|
||||||
|
kind, label := ClassifyCalendarRow(map[string]any{
|
||||||
|
"title": "Standup",
|
||||||
|
"start": "2026-06-24T10:00:00Z",
|
||||||
|
})
|
||||||
|
if kind != KindEvent || label != "Event" {
|
||||||
|
t.Fatalf("got kind=%s label=%q", kind, label)
|
||||||
|
}
|
||||||
|
kind, label = ClassifyCalendarRow(map[string]any{"title": "Work"})
|
||||||
|
if kind != KindCalendar || label != "Calendar" {
|
||||||
|
t.Fatalf("got kind=%s label=%q", kind, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCalendarColumnsIncludesType(t *testing.T) {
|
||||||
|
items := []Item{{
|
||||||
|
ID: "1", Title: "Meet", Kind: KindEvent,
|
||||||
|
Raw: map[string]any{"type": "Event", "start": "2026-06-24T10:00:00Z"},
|
||||||
|
}}
|
||||||
|
cols := BuildColumns(SubjectCalendar, items)
|
||||||
|
found := false
|
||||||
|
for _, c := range cols {
|
||||||
|
if c.Key == "type" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if c.Key == "subtitle" {
|
||||||
|
t.Fatal("subtitle column should not appear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected type column")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TableColumn is one column in the center data table.
|
||||||
|
type TableColumn struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
Width int
|
||||||
|
}
|
||||||
|
|
||||||
|
var subjectExtraKeys = map[Subject][]string{
|
||||||
|
SubjectTasks: {"status", "deadline", "responsible"},
|
||||||
|
SubjectCalendars: {"description"},
|
||||||
|
SubjectEvents: {"start", "end"},
|
||||||
|
SubjectContacts: {"primaryEmail", "displayName"},
|
||||||
|
SubjectPersons: {"primaryEmail", "displayName"},
|
||||||
|
SubjectCompanies: {"primaryEmail", "displayName"},
|
||||||
|
SubjectOpportunities: {"stage", "bidValue", "bidCurrency"},
|
||||||
|
SubjectCases: {"status"},
|
||||||
|
SubjectCRMTasks: {"status", "deadline"},
|
||||||
|
SubjectMailInbox: {"from", "date"},
|
||||||
|
SubjectMailSent: {"to", "date"},
|
||||||
|
SubjectMailDrafts: {"to", "date"},
|
||||||
|
SubjectMailTrash: {"from", "date"},
|
||||||
|
SubjectMailSpam: {"from", "date"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildColumns derives table columns from the list subject and item payloads.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if subject == SubjectCalendar {
|
||||||
|
return buildCalendarColumns(items)
|
||||||
|
}
|
||||||
|
cols := []TableColumn{
|
||||||
|
{Key: "_sel", Title: "✓", Width: 3},
|
||||||
|
{Key: "id", Title: "ID", Width: 10},
|
||||||
|
{Key: "title", Title: "Title", Width: 28},
|
||||||
|
}
|
||||||
|
if hasSubtitle(items) {
|
||||||
|
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
|
||||||
|
for _, key := range subjectExtraKeys[subject] {
|
||||||
|
if seen[key] || !columnHasData(items, key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||||
|
}
|
||||||
|
|
||||||
|
discovered := discoverRawKeys(items, seen, 8)
|
||||||
|
for _, key := range discovered {
|
||||||
|
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeColumns(cols, items)
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCalendarColumns(items []Item) []TableColumn {
|
||||||
|
cols := []TableColumn{
|
||||||
|
{Key: "_sel", Title: "✓", Width: 3},
|
||||||
|
{Key: "id", Title: "ID", Width: 10},
|
||||||
|
{Key: "type", Title: "Type", Width: 10},
|
||||||
|
{Key: "title", Title: "Title", Width: 28},
|
||||||
|
}
|
||||||
|
for _, key := range []string{"start", "end"} {
|
||||||
|
if columnHasData(items, key) {
|
||||||
|
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeColumns(cols, items)
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTaskColumns(items []Item) []TableColumn {
|
||||||
|
cols := []TableColumn{
|
||||||
|
{Key: "_sel", Title: "✓", Width: 3},
|
||||||
|
{Key: "id", Title: "ID", Width: 8},
|
||||||
|
{Key: "title", Title: "Title", Width: 28},
|
||||||
|
{Key: "status", Title: "Status", Width: 10},
|
||||||
|
}
|
||||||
|
for _, key := range []string{"deadline", "responsible"} {
|
||||||
|
if columnHasData(items, key) {
|
||||||
|
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeColumns(cols, items)
|
||||||
|
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 {
|
||||||
|
_ = items
|
||||||
|
return []TableColumn{
|
||||||
|
{Key: "_sel", Title: "✓", Width: 3},
|
||||||
|
{Key: "id", Title: "ID", 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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CellText returns the display string for one table cell.
|
||||||
|
func CellText(it Item, key string) string {
|
||||||
|
switch key {
|
||||||
|
case "_sel":
|
||||||
|
if it.Selected {
|
||||||
|
return "●"
|
||||||
|
}
|
||||||
|
return "○"
|
||||||
|
case "id":
|
||||||
|
return it.ID
|
||||||
|
case "title":
|
||||||
|
return it.Title
|
||||||
|
case "type":
|
||||||
|
return CalendarTypeLabel(it)
|
||||||
|
case "start", "end":
|
||||||
|
if it.Raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if formatted := FormatCalendarDateTime(it.Raw[key]); formatted != "" {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
return formatAny(it.Raw[key])
|
||||||
|
case "status":
|
||||||
|
switch it.Kind {
|
||||||
|
case KindProject:
|
||||||
|
return ProjectStatusCell(it.Raw)
|
||||||
|
case KindUser:
|
||||||
|
return UserStatusLabel(it.Raw)
|
||||||
|
case KindTask, KindCRMTask:
|
||||||
|
if it.Raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return TaskStatusLabel(it.Raw["status"])
|
||||||
|
default:
|
||||||
|
if it.Raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatAny(it.Raw["status"])
|
||||||
|
}
|
||||||
|
case "deadline":
|
||||||
|
if it.Raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 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(rawDL)
|
||||||
|
case "responsible":
|
||||||
|
if it.Kind == KindTask || it.Kind == KindCRMTask {
|
||||||
|
return TaskResponsibleLabel(it.Raw)
|
||||||
|
}
|
||||||
|
if it.Raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
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":
|
||||||
|
return intRaw(it.Raw, "documentsCount")
|
||||||
|
case "users":
|
||||||
|
return intRaw(it.Raw, "participantCount")
|
||||||
|
case "kind":
|
||||||
|
return string(it.Kind)
|
||||||
|
default:
|
||||||
|
if it.Raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatAny(it.Raw[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatProjectTasks(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return "0/0"
|
||||||
|
}
|
||||||
|
open := intRawVal(raw, "taskCount")
|
||||||
|
total := intRawVal(raw, "taskCountTotal")
|
||||||
|
closed := total - open
|
||||||
|
if closed < 0 {
|
||||||
|
closed = 0
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d/%d", open, closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intRaw(raw map[string]any, key string) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v := intRawVal(raw, key)
|
||||||
|
if v == 0 {
|
||||||
|
if _, ok := raw[key]; !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intRawVal(raw map[string]any, key string) int {
|
||||||
|
if raw == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := raw[key].(type) {
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.Atoi(v)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
if raw[key] == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSubtitle(items []Item) bool {
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Subtitle != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnHasData(items []Item, key string) bool {
|
||||||
|
for _, it := range items {
|
||||||
|
if CellText(it, key) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Raw == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k := range it.Raw {
|
||||||
|
if seen[k] || k == "id" || k == "title" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts[k]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type kv struct {
|
||||||
|
k string
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
var ranked []kv
|
||||||
|
for k, n := range counts {
|
||||||
|
ranked = append(ranked, kv{k, n})
|
||||||
|
}
|
||||||
|
sort.Slice(ranked, func(i, j int) bool {
|
||||||
|
if ranked[i].n == ranked[j].n {
|
||||||
|
return ranked[i].k < ranked[j].k
|
||||||
|
}
|
||||||
|
return ranked[i].n > ranked[j].n
|
||||||
|
})
|
||||||
|
out := make([]string, 0, limit)
|
||||||
|
for _, r := range ranked {
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, r.k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleLabel(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(key, "_")
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultWidth(key string) int {
|
||||||
|
switch key {
|
||||||
|
case "id", "status", "stage":
|
||||||
|
return 10
|
||||||
|
case "start", "end", "deadline", "date":
|
||||||
|
return 20
|
||||||
|
case "from", "to", "email", "primaryEmail":
|
||||||
|
return 22
|
||||||
|
case "description", "displayName":
|
||||||
|
return 24
|
||||||
|
default:
|
||||||
|
return 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeColumns(cols []TableColumn, items []Item) {
|
||||||
|
for i := range cols {
|
||||||
|
maxW := runeLen(cols[i].Title)
|
||||||
|
for _, it := range items {
|
||||||
|
n := runeLen(CellText(it, cols[i].Key))
|
||||||
|
if n > maxW {
|
||||||
|
maxW = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxW += 2
|
||||||
|
if maxW < 4 {
|
||||||
|
maxW = 4
|
||||||
|
}
|
||||||
|
if maxW > 36 {
|
||||||
|
maxW = 36
|
||||||
|
}
|
||||||
|
cols[i].Width = maxW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runeLen(s string) int {
|
||||||
|
return len([]rune(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAny(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
case bool:
|
||||||
|
if x {
|
||||||
|
return "yes"
|
||||||
|
}
|
||||||
|
return "no"
|
||||||
|
case float64:
|
||||||
|
if x == float64(int64(x)) {
|
||||||
|
return fmt.Sprintf("%d", int64(x))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%g", x)
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildProjectColumns(t *testing.T) {
|
||||||
|
items := []Item{{
|
||||||
|
ID: "1", Title: "Alpha", Kind: KindProject,
|
||||||
|
Raw: map[string]any{
|
||||||
|
"taskCount": 3, "taskCountTotal": 5,
|
||||||
|
"documentsCount": 2, "participantCount": 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cols := BuildColumns(SubjectProjects, items)
|
||||||
|
want := []string{"_sel", "id", "status", "title", "tasks", "documents", "users"}
|
||||||
|
if len(cols) != len(want) {
|
||||||
|
t.Fatalf("got %d columns, want %d", len(cols), len(want))
|
||||||
|
}
|
||||||
|
for i, key := range want {
|
||||||
|
if cols[i].Key != key {
|
||||||
|
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range cols {
|
||||||
|
if c.Key == "subtitle" {
|
||||||
|
t.Fatalf("unexpected column %q", c.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCellTextProjectStatus(t *testing.T) {
|
||||||
|
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
|
||||||
|
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" {
|
||||||
|
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) {
|
||||||
|
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
|
||||||
|
if got := formatProjectTasks(raw); got != "3/5" {
|
||||||
|
t.Fatalf("got %q want 3/5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCellTextProjectDocumentsUsers(t *testing.T) {
|
||||||
|
it := Item{Raw: map[string]any{"documentsCount": 7, "participantCount": 2}}
|
||||||
|
if got := CellText(it, "documents"); got != "7" {
|
||||||
|
t.Fatalf("documents: %q", got)
|
||||||
|
}
|
||||||
|
if got := CellText(it, "users"); got != "2" {
|
||||||
|
t.Fatalf("users: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildColumnsIncludesSelectionAndCoreFields(t *testing.T) {
|
||||||
|
items := []Item{
|
||||||
|
{ID: "1", Title: "Alpha", Kind: KindTask, Raw: map[string]any{"status": "Open"}},
|
||||||
|
}
|
||||||
|
cols := BuildColumns(SubjectTasks, items)
|
||||||
|
if cols[0].Key != "_sel" {
|
||||||
|
t.Fatalf("first column should be selection, got %q", cols[0].Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FilterItems returns items whose searchable text contains query (case-insensitive).
|
||||||
|
func FilterItems(items []Item, query string) []Item {
|
||||||
|
q := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
if q == "" {
|
||||||
|
out := make([]Item, len(items))
|
||||||
|
copy(out, items)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out := make([]Item, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
if strings.Contains(strings.ToLower(ItemSearchText(it)), q) {
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemSearchText joins item fields used for list filtering.
|
||||||
|
func ItemSearchText(it Item) string {
|
||||||
|
var parts []string
|
||||||
|
parts = append(parts, it.ID, it.Title, it.Subtitle, string(it.Kind))
|
||||||
|
if it.Raw != nil {
|
||||||
|
for _, v := range it.Raw {
|
||||||
|
parts = append(parts, formatAny(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterItemsByTitle(t *testing.T) {
|
||||||
|
items := []model.Item{
|
||||||
|
{ID: "1", Title: "Alpha task"},
|
||||||
|
{ID: "2", Title: "Beta task"},
|
||||||
|
}
|
||||||
|
got := model.FilterItems(items, "alpha")
|
||||||
|
if len(got) != 1 || got[0].ID != "1" {
|
||||||
|
t.Fatalf("got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterItemsEmptyQueryReturnsAll(t *testing.T) {
|
||||||
|
items := []model.Item{{ID: "1", Title: "A"}, {ID: "2", Title: "B"}}
|
||||||
|
got := model.FilterItems(items, " ")
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got len=%d want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// FormFields holds editable/display fields loaded from API detail.
|
||||||
|
type FormFields struct {
|
||||||
|
PrimaryLabel string
|
||||||
|
SecondaryLabel string
|
||||||
|
Primary string
|
||||||
|
Secondary string
|
||||||
|
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.
|
||||||
|
func KindHeading(kind Kind, id string) string {
|
||||||
|
return fmt.Sprintf("%s %s", kindLabel(kind), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindLabel(kind Kind) string {
|
||||||
|
switch kind {
|
||||||
|
case KindProject:
|
||||||
|
return "Project"
|
||||||
|
case KindTask:
|
||||||
|
return "Task"
|
||||||
|
case KindCRMTask:
|
||||||
|
return "CRM task"
|
||||||
|
case KindContact:
|
||||||
|
return "Contact"
|
||||||
|
case KindOpportunity:
|
||||||
|
return "Deal"
|
||||||
|
case KindCase:
|
||||||
|
return "Case"
|
||||||
|
case KindMail:
|
||||||
|
return "Mail"
|
||||||
|
case KindEvent:
|
||||||
|
return "Event"
|
||||||
|
case KindCalendar:
|
||||||
|
return "Calendar"
|
||||||
|
case KindFile:
|
||||||
|
return "Document"
|
||||||
|
case KindUser:
|
||||||
|
return "User"
|
||||||
|
default:
|
||||||
|
return string(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormFieldsFromRaw maps API detail JSON to form fields for the right pane.
|
||||||
|
func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
||||||
|
switch kind {
|
||||||
|
case KindMail:
|
||||||
|
body := strRaw(raw, "body")
|
||||||
|
if body == "" {
|
||||||
|
body = strRaw(raw, "htmlBody")
|
||||||
|
}
|
||||||
|
return FormFields{
|
||||||
|
PrimaryLabel: "Subject", SecondaryLabel: "Body",
|
||||||
|
Primary: strRaw(raw, "subject"), Secondary: body,
|
||||||
|
ReadOnly: true,
|
||||||
|
}
|
||||||
|
case KindUser:
|
||||||
|
acl := UserACLFromRaw(raw)
|
||||||
|
return FormFields{
|
||||||
|
HasUserEdit: true,
|
||||||
|
ReadOnly: false,
|
||||||
|
UserEnabled: UserIsEnabled(raw),
|
||||||
|
UserACL: acl,
|
||||||
|
GroupsText: UserGroupsText(raw),
|
||||||
|
}
|
||||||
|
case KindContact:
|
||||||
|
name := strRaw(raw, "displayName")
|
||||||
|
if name == "" {
|
||||||
|
name = strRaw(raw, "title")
|
||||||
|
}
|
||||||
|
return FormFields{
|
||||||
|
PrimaryLabel: "Name", SecondaryLabel: "About",
|
||||||
|
Primary: name, Secondary: strRaw(raw, "about"),
|
||||||
|
ReadOnly: true,
|
||||||
|
}
|
||||||
|
case KindEvent, KindCalendar:
|
||||||
|
return FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
|
||||||
|
ReadOnly: true,
|
||||||
|
}
|
||||||
|
case KindProject:
|
||||||
|
title := strRaw(raw, "title")
|
||||||
|
if title == "" {
|
||||||
|
title = strRaw(raw, "name")
|
||||||
|
}
|
||||||
|
return FormFields{
|
||||||
|
PrimaryLabel: "Title",
|
||||||
|
SecondaryLabel: "Description",
|
||||||
|
Primary: title,
|
||||||
|
Secondary: strRaw(raw, "description"),
|
||||||
|
ReadOnly: false,
|
||||||
|
HasStatus: true,
|
||||||
|
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 == "" {
|
||||||
|
title = strRaw(raw, "name")
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = strRaw(raw, "subject")
|
||||||
|
}
|
||||||
|
return FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
Primary: title, Secondary: strRaw(raw, "description"),
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
if v, ok := m[key].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if m[key] == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(m[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsibleIDFromRaw extracts the project responsible user id from API detail.
|
||||||
|
func ResponsibleIDFromRaw(raw map[string]any) string {
|
||||||
|
if id := strRaw(raw, "responsibleId"); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
resp, ok := raw["responsible"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strRaw(resp, "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDocumentKind is true when the right pane should show rendered preview content, not an editable form.
|
||||||
|
func IsDocumentKind(kind Kind) bool {
|
||||||
|
return kind == KindFile || kind == KindMail
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFormFieldsFromRawMail(t *testing.T) {
|
||||||
|
raw := map[string]any{"subject": "Hi", "body": "Hello"}
|
||||||
|
f := FormFieldsFromRaw(KindMail, raw)
|
||||||
|
if f.Primary != "Hi" || f.Secondary != "Hello" || !f.ReadOnly {
|
||||||
|
t.Fatalf("unexpected mail fields: %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDocumentKind(t *testing.T) {
|
||||||
|
if !IsDocumentKind(KindFile) {
|
||||||
|
t.Fatal("file should be document")
|
||||||
|
}
|
||||||
|
if !IsDocumentKind(KindMail) {
|
||||||
|
t.Fatal("mail should be document preview")
|
||||||
|
}
|
||||||
|
if IsDocumentKind(KindTask) {
|
||||||
|
t.Fatal("task should be form")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,19 @@ const (
|
|||||||
SubjectProjectFiles Subject = "project_files"
|
SubjectProjectFiles Subject = "project_files"
|
||||||
SubjectTaskFiles Subject = "task_files"
|
SubjectTaskFiles Subject = "task_files"
|
||||||
SubjectUsers Subject = "users"
|
SubjectUsers Subject = "users"
|
||||||
|
SubjectCalendar Subject = "calendar"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SubjectIsMail reports whether a list subject loads mailbox messages.
|
||||||
|
func SubjectIsMail(s Subject) bool {
|
||||||
|
switch s {
|
||||||
|
case SubjectMailInbox, SubjectMailSent, SubjectMailDrafts, SubjectMailTrash, SubjectMailSpam:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FocusPane is which column has keyboard focus.
|
// FocusPane is which column has keyboard focus.
|
||||||
type FocusPane int
|
type FocusPane int
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSubjectIsMail(t *testing.T) {
|
||||||
|
if !SubjectIsMail(SubjectMailInbox) {
|
||||||
|
t.Fatal("inbox should be mail")
|
||||||
|
}
|
||||||
|
if SubjectIsMail(SubjectProjects) {
|
||||||
|
t.Fatal("projects should not be mail")
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-15
@@ -1,5 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// ListSpec tells the fetch layer what to load when a nav leaf is active.
|
// ListSpec tells the fetch layer what to load when a nav leaf is active.
|
||||||
type ListSpec struct {
|
type ListSpec struct {
|
||||||
Subject Subject
|
Subject Subject
|
||||||
@@ -24,6 +26,7 @@ type NavTree struct {
|
|||||||
expanded map[string]bool
|
expanded map[string]bool
|
||||||
cursor int
|
cursor int
|
||||||
visible []string
|
visible []string
|
||||||
|
filter string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
|
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
|
||||||
@@ -42,15 +45,11 @@ func DefaultNavTree() *NavTree {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
add("projects", "Projects", "", true, nil)
|
add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects})
|
||||||
add("projects.browse", "Browse", "projects", true, nil)
|
add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks})
|
||||||
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects})
|
add("projects.dynamic", "By project", "", true, nil)
|
||||||
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
|
|
||||||
add("projects.dynamic", "By project", "projects", true, nil)
|
|
||||||
|
|
||||||
add("calendar", "Calendar", "", true, nil)
|
add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar})
|
||||||
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
|
|
||||||
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
|
|
||||||
|
|
||||||
add("crm", "CRM", "", true, nil)
|
add("crm", "CRM", "", true, nil)
|
||||||
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
|
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
|
||||||
@@ -67,8 +66,7 @@ func DefaultNavTree() *NavTree {
|
|||||||
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
|
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
|
||||||
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
|
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
|
||||||
|
|
||||||
add("users", "Users", "", true, nil)
|
add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers})
|
||||||
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
|
|
||||||
|
|
||||||
t.rebuildVisible()
|
t.rebuildVisible()
|
||||||
return t
|
return t
|
||||||
@@ -185,20 +183,66 @@ func (t *NavTree) Activate() (*ListSpec, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *NavTree) SetFilter(query string) {
|
||||||
|
t.filter = strings.ToLower(strings.TrimSpace(query))
|
||||||
|
t.rebuildVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *NavTree) ClearFilter() {
|
||||||
|
t.filter = ""
|
||||||
|
t.rebuildVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *NavTree) FilterQuery() string { return t.filter }
|
||||||
|
|
||||||
|
func (t *NavTree) nodeLabelMatches(id string) bool {
|
||||||
|
if t.filter == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
n, ok := t.nodes[id]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(n.Label), t.filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *NavTree) hasMatchingDescendant(id string) bool {
|
||||||
|
for _, child := range t.children[id] {
|
||||||
|
if t.nodeLabelMatches(child) || t.hasMatchingDescendant(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *NavTree) visibleUnderFilter(id string) bool {
|
||||||
|
if t.filter == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return t.nodeLabelMatches(id) || t.hasMatchingDescendant(id)
|
||||||
|
}
|
||||||
|
|
||||||
func (t *NavTree) rebuildVisible() {
|
func (t *NavTree) rebuildVisible() {
|
||||||
t.visible = t.visible[:0]
|
t.visible = t.visible[:0]
|
||||||
var walk func(id string, depth int)
|
var walk func(id string)
|
||||||
walk = func(id string, depth int) {
|
walk = func(id string) {
|
||||||
|
if !t.visibleUnderFilter(id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
t.visible = append(t.visible, id)
|
t.visible = append(t.visible, id)
|
||||||
if !t.expanded[id] {
|
expanded := t.expanded[id]
|
||||||
|
if t.filter != "" && t.hasMatchingDescendant(id) {
|
||||||
|
expanded = true
|
||||||
|
}
|
||||||
|
if !expanded {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, child := range t.children[id] {
|
for _, child := range t.children[id] {
|
||||||
walk(child, depth+1)
|
walk(child)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, root := range t.roots {
|
for _, root := range t.roots {
|
||||||
walk(root, 0)
|
walk(root)
|
||||||
}
|
}
|
||||||
if t.cursor >= len(t.visible) {
|
if t.cursor >= len(t.visible) {
|
||||||
t.cursor = len(t.visible) - 1
|
t.cursor = len(t.visible) - 1
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func TestNavTreeHasExpectedRoots(t *testing.T) {
|
func TestNavTreeHasExpectedRoots(t *testing.T) {
|
||||||
tree := model.DefaultNavTree()
|
tree := model.DefaultNavTree()
|
||||||
roots := tree.RootLabels()
|
roots := tree.RootLabels()
|
||||||
want := []string{"Projects", "Calendar", "CRM", "Mail", "Users"}
|
want := []string{"Projects", "Tasks", "By project", "Calendar", "CRM", "Mail", "Users"}
|
||||||
if len(roots) != len(want) {
|
if len(roots) != len(want) {
|
||||||
t.Fatalf("roots=%v want %v", roots, want)
|
t.Fatalf("roots=%v want %v", roots, want)
|
||||||
}
|
}
|
||||||
@@ -22,42 +22,79 @@ func TestNavTreeHasExpectedRoots(t *testing.T) {
|
|||||||
|
|
||||||
func TestNavExpandCollapse(t *testing.T) {
|
func TestNavExpandCollapse(t *testing.T) {
|
||||||
tree := model.DefaultNavTree()
|
tree := model.DefaultNavTree()
|
||||||
if !tree.IsExpandable(0) {
|
var crm int = -1
|
||||||
t.Fatal("Projects should be expandable")
|
for i := 0; i < tree.VisibleCount(); i++ {
|
||||||
|
n, ok := tree.NodeAtVisible(i)
|
||||||
|
if ok && n.Label == "CRM" {
|
||||||
|
crm = i
|
||||||
|
break
|
||||||
}
|
}
|
||||||
tree.ToggleExpand(0)
|
|
||||||
if !tree.IsExpanded(0) {
|
|
||||||
t.Fatal("Projects should expand")
|
|
||||||
}
|
}
|
||||||
tree.ToggleExpand(0)
|
if crm < 0 {
|
||||||
if tree.IsExpanded(0) {
|
t.Fatal("CRM node not found")
|
||||||
t.Fatal("Projects should collapse")
|
}
|
||||||
|
if !tree.IsExpandable(crm) {
|
||||||
|
t.Fatal("CRM should be expandable")
|
||||||
|
}
|
||||||
|
tree.ToggleExpand(crm)
|
||||||
|
if !tree.IsExpanded(crm) {
|
||||||
|
t.Fatal("CRM should expand")
|
||||||
|
}
|
||||||
|
tree.ToggleExpand(crm)
|
||||||
|
if tree.IsExpanded(crm) {
|
||||||
|
t.Fatal("CRM should collapse")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNavLeafReturnsListSpec(t *testing.T) {
|
func TestNavCalendarLeafReturnsListSpec(t *testing.T) {
|
||||||
tree := model.DefaultNavTree()
|
tree := model.DefaultNavTree()
|
||||||
tree.ToggleExpand(0) // Projects
|
|
||||||
tree.ToggleExpand(1) // Browse
|
|
||||||
// Find "All projects" leaf cursor
|
|
||||||
var found int = -1
|
var found int = -1
|
||||||
for i := 0; i < tree.VisibleCount(); i++ {
|
for i := 0; i < tree.VisibleCount(); i++ {
|
||||||
n, ok := tree.NodeAtVisible(i)
|
n, ok := tree.NodeAtVisible(i)
|
||||||
if ok && n.List != nil && n.List.Subject == model.SubjectProjects {
|
if ok && n.Label == "Calendar" {
|
||||||
found = i
|
found = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found < 0 {
|
if found < 0 {
|
||||||
t.Fatal("all projects leaf not found")
|
t.Fatal("Calendar node not found")
|
||||||
}
|
}
|
||||||
tree.SetCursor(found)
|
tree.SetCursor(found)
|
||||||
spec, ok := tree.CurrentListSpec()
|
spec, ok := tree.CurrentListSpec()
|
||||||
if !ok || spec.Subject != model.SubjectProjects {
|
if !ok || spec.Subject != model.SubjectCalendar {
|
||||||
t.Fatalf("spec=%v ok=%v", spec, ok)
|
t.Fatalf("spec=%v ok=%v", spec, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNavProjectsLeafReturnsListSpec(t *testing.T) {
|
||||||
|
tree := model.DefaultNavTree()
|
||||||
|
tree.SetCursor(0)
|
||||||
|
spec, ok := tree.CurrentListSpec()
|
||||||
|
if !ok || spec.Subject != model.SubjectProjects {
|
||||||
|
t.Fatalf("projects spec=%v ok=%v", spec, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavTasksLeafReturnsListSpec(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 == "Tasks" {
|
||||||
|
found = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found < 0 {
|
||||||
|
t.Fatal("Tasks node not found")
|
||||||
|
}
|
||||||
|
tree.SetCursor(found)
|
||||||
|
spec, ok := tree.CurrentListSpec()
|
||||||
|
if !ok || spec.Subject != model.SubjectTasks {
|
||||||
|
t.Fatalf("tasks spec=%v ok=%v", spec, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrevFocusPane(t *testing.T) {
|
func TestPrevFocusPane(t *testing.T) {
|
||||||
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
|
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
|
||||||
t.Fatalf("got %v", got)
|
t.Fatalf("got %v", got)
|
||||||
@@ -67,9 +104,44 @@ func TestPrevFocusPane(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNavFilterByLabel(t *testing.T) {
|
||||||
|
tree := model.DefaultNavTree()
|
||||||
|
tree.SetFilter("inbox")
|
||||||
|
found := false
|
||||||
|
for i := 0; i < tree.VisibleCount(); i++ {
|
||||||
|
n, ok := tree.NodeAtVisible(i)
|
||||||
|
if ok && n.Label == "Inbox" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("Inbox should remain visible when filtering mail")
|
||||||
|
}
|
||||||
|
for i := 0; i < tree.VisibleCount(); i++ {
|
||||||
|
n, ok := tree.NodeAtVisible(i)
|
||||||
|
if ok && n.Label == "Calendar" {
|
||||||
|
t.Fatal("Calendar should be hidden when filtering inbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavClearFilterRestoresTree(t *testing.T) {
|
||||||
|
tree := model.DefaultNavTree()
|
||||||
|
before := tree.VisibleCount()
|
||||||
|
tree.SetFilter("zzz-no-match")
|
||||||
|
if tree.VisibleCount() >= before {
|
||||||
|
t.Fatalf("filter should shrink visible nodes")
|
||||||
|
}
|
||||||
|
tree.ClearFilter()
|
||||||
|
if tree.VisibleCount() != before {
|
||||||
|
t.Fatalf("clear filter should restore visible count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestActionsForContact(t *testing.T) {
|
func TestActionsForContact(t *testing.T) {
|
||||||
acts := model.ActionsFor(model.KindContact)
|
acts := model.ActionsFor(model.KindContact)
|
||||||
if len(acts) < 2 {
|
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
|
||||||
t.Fatalf("expected view+delete, got %d", len(acts))
|
t.Fatalf("expected delete only, got %v", acts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// ProjectLifecycle is the OnlyOffice project status string accepted by the API.
|
||||||
|
type ProjectLifecycle string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProjectLifecycleOpen ProjectLifecycle = "open"
|
||||||
|
ProjectLifecyclePaused ProjectLifecycle = "paused"
|
||||||
|
ProjectLifecycleClosed ProjectLifecycle = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectStatusFromAny maps API status (int or string) to a lifecycle value.
|
||||||
|
func ProjectStatusFromAny(v any) ProjectLifecycle {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
switch ProjectLifecycle(x) {
|
||||||
|
case ProjectLifecycleOpen, ProjectLifecyclePaused, ProjectLifecycleClosed:
|
||||||
|
return ProjectLifecycle(x)
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
return projectStatusFromInt(int(x))
|
||||||
|
case int:
|
||||||
|
return projectStatusFromInt(x)
|
||||||
|
case int64:
|
||||||
|
return projectStatusFromInt(int(x))
|
||||||
|
}
|
||||||
|
return ProjectLifecycleOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectStatusFromInt(n int) ProjectLifecycle {
|
||||||
|
switch n {
|
||||||
|
case 1:
|
||||||
|
return ProjectLifecyclePaused
|
||||||
|
case 2:
|
||||||
|
return ProjectLifecycleClosed
|
||||||
|
default:
|
||||||
|
return ProjectLifecycleOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectStatusLabel returns a short UI label for list cells.
|
||||||
|
func ProjectStatusLabel(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return "Open"
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ProjectStatusFromAny(raw["status"]) != ProjectLifecycleClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label returns a human-readable status name.
|
||||||
|
func (s ProjectLifecycle) Label() string {
|
||||||
|
switch s {
|
||||||
|
case ProjectLifecycleClosed:
|
||||||
|
return "Closed"
|
||||||
|
case ProjectLifecyclePaused:
|
||||||
|
return "Paused"
|
||||||
|
default:
|
||||||
|
return "Open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleOpenClosed flips between open and closed (paused maps to closed on toggle).
|
||||||
|
func (s ProjectLifecycle) ToggleOpenClosed() ProjectLifecycle {
|
||||||
|
if s == ProjectLifecycleClosed {
|
||||||
|
return ProjectLifecycleOpen
|
||||||
|
}
|
||||||
|
return ProjectLifecycleClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next cycles open → closed → open for keyboard toggling.
|
||||||
|
func (s ProjectLifecycle) Next() ProjectLifecycle {
|
||||||
|
return s.ToggleOpenClosed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev cycles closed → open → closed.
|
||||||
|
func (s ProjectLifecycle) Prev() ProjectLifecycle {
|
||||||
|
return s.ToggleOpenClosed()
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectStatusFromInt(t *testing.T) {
|
||||||
|
if got := model.ProjectStatusFromAny(2); got != model.ProjectLifecycleClosed {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := model.ProjectStatusFromAny(0); got != model.ProjectLifecycleOpen {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectStatusToggle(t *testing.T) {
|
||||||
|
s := model.ProjectLifecycleOpen
|
||||||
|
if s.Next() != model.ProjectLifecycleClosed {
|
||||||
|
t.Fatal("expected closed")
|
||||||
|
}
|
||||||
|
if s.Next().Prev() != model.ProjectLifecycleOpen {
|
||||||
|
t.Fatal("expected open after toggle back")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskStatusLabel returns a human-readable project/CRM task status.
|
||||||
|
func TaskStatusLabel(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
if label := taskStatusFromString(s); label != "" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := intRawVal(map[string]any{"status": v}, "status")
|
||||||
|
switch n {
|
||||||
|
case 0:
|
||||||
|
return "Not accepted"
|
||||||
|
case 1:
|
||||||
|
return "Open"
|
||||||
|
case 2:
|
||||||
|
return "Closed"
|
||||||
|
case 3:
|
||||||
|
return "Disabled"
|
||||||
|
case 4:
|
||||||
|
return "Unclassified"
|
||||||
|
case 5:
|
||||||
|
return "Not in milestone"
|
||||||
|
default:
|
||||||
|
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskStatusFromString(s string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "open":
|
||||||
|
return "Open"
|
||||||
|
case "closed":
|
||||||
|
return "Closed"
|
||||||
|
case "notaccept", "not accept", "not accepted":
|
||||||
|
return "Not accepted"
|
||||||
|
case "disable", "disabled":
|
||||||
|
return "Disabled"
|
||||||
|
case "unclassified":
|
||||||
|
return "Unclassified"
|
||||||
|
case "notinmilestone", "not in milestone":
|
||||||
|
return "Not in milestone"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRelativeDeadline formats a deadline as "in 2 hours" or "3 days ago".
|
||||||
|
func FormatRelativeDeadline(v any) string {
|
||||||
|
return FormatRelativeDeadlineAt(v, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRelativeDeadlineAt is FormatRelativeDeadline with a fixed clock (for tests).
|
||||||
|
func FormatRelativeDeadlineAt(v any, now time.Time) string {
|
||||||
|
t, ok := parseDeadlineTime(v)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return relativeTimePhrase(now, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeTimePhrase(now, target time.Time) string {
|
||||||
|
d := target.Sub(now)
|
||||||
|
future := d >= 0
|
||||||
|
if !future {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
phrase := func(n int, unit string) string {
|
||||||
|
if n == 1 {
|
||||||
|
unit = strings.TrimSuffix(unit, "s")
|
||||||
|
}
|
||||||
|
if future {
|
||||||
|
return fmt.Sprintf("in %d %s", n, unit)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d %s ago", n, unit)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
if future {
|
||||||
|
return "in 1 minute"
|
||||||
|
}
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
return phrase(int(d.Round(time.Minute)/time.Minute), "minutes")
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return phrase(int(d.Round(time.Hour)/time.Hour), "hours")
|
||||||
|
default:
|
||||||
|
return phrase(int(d.Round(24*time.Hour)/(24*time.Hour)), "days")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDeadlineTime(v any) (time.Time, bool) {
|
||||||
|
if v == nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return x, true
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(x)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
formats := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
"2006-01-02T15:04:05.0000000-07:00",
|
||||||
|
"2006-01-02T15:04:05-07:00",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, layout := range formats {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
case float64:
|
||||||
|
return time.Unix(int64(x), 0), true
|
||||||
|
case int64:
|
||||||
|
return time.Unix(x, 0), true
|
||||||
|
case int:
|
||||||
|
return time.Unix(int64(x), 0), true
|
||||||
|
default:
|
||||||
|
return parseDeadlineTime(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskResponsibleLabel returns the assignee display name when present.
|
||||||
|
func TaskResponsibleLabel(raw map[string]any) string {
|
||||||
|
if raw == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if name := personName(raw["responsible"]); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if list, ok := raw["responsibles"].([]any); ok {
|
||||||
|
names := make([]string, 0, len(list))
|
||||||
|
for _, entry := range list {
|
||||||
|
if name := personName(entry); name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) > 0 {
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := strRaw(raw, "responsible"); s != "" && !looksLikeID(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func personName(v any) string {
|
||||||
|
m, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if name := strRaw(m, "displayName"); name != "" {
|
||||||
|
if strings.EqualFold(name, "Profile has been removed") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
first := strRaw(m, "firstName")
|
||||||
|
last := strRaw(m, "lastName")
|
||||||
|
return strings.TrimSpace(first + " " + last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeID(s string) bool {
|
||||||
|
if len(s) == 36 && strings.Count(s, "-") == 4 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, err := strconv.Atoi(s)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskStatusLabelFromInt(t *testing.T) {
|
||||||
|
if got := TaskStatusLabel(1); got != "Open" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := TaskStatusLabel(2); got != "Closed" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRelativeDeadlineFuture(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
|
||||||
|
deadline := now.Add(2 * time.Hour)
|
||||||
|
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
|
||||||
|
if got != "in 2 hours" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRelativeDeadlineDays(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
|
||||||
|
deadline := now.Add(26 * time.Hour)
|
||||||
|
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
|
||||||
|
if got != "in 1 day" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTaskColumnsOmitsSubtitle(t *testing.T) {
|
||||||
|
items := []Item{{
|
||||||
|
ID: "1", Title: "Fix bug", Kind: KindTask, Subtitle: "1",
|
||||||
|
Raw: map[string]any{"status": 1, "deadline": "2026-06-25T12:00:00Z"},
|
||||||
|
}}
|
||||||
|
cols := BuildColumns(SubjectTasks, items)
|
||||||
|
for _, c := range cols {
|
||||||
|
if c.Key == "subtitle" {
|
||||||
|
t.Fatalf("unexpected subtitle column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if CellText(items[0], "status") != "Open" {
|
||||||
|
t.Fatalf("status not humanized")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -42,25 +42,71 @@ func OpportunityMarkdown(m map[string]any) string {
|
|||||||
return strings.TrimSpace(b.String()) + "\n"
|
return strings.TrimSpace(b.String()) + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailMarkdown formats a mail message for preview.
|
// MailMarkdown formats a mail message for terminal preview (HTML body is converted to markdown).
|
||||||
func MailMarkdown(m map[string]any) string {
|
func MailMarkdown(m map[string]any) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
|
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
|
||||||
fmt.Fprintf(&b, "**From:** %s\n\n", str(m, "from"))
|
fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from"))
|
||||||
if to := str(m, "to"); to != "" {
|
if to := mailAddressLine(m, "to"); to != "" {
|
||||||
fmt.Fprintf(&b, "**To:** %s\n\n", to)
|
fmt.Fprintf(&b, "**To:** %s\n\n", to)
|
||||||
}
|
}
|
||||||
body := str(m, "body")
|
if cc := mailAddressLine(m, "cc"); cc != "" {
|
||||||
if body == "" {
|
fmt.Fprintf(&b, "**Cc:** %s\n\n", cc)
|
||||||
body = str(m, "htmlBody")
|
|
||||||
}
|
}
|
||||||
body = stripHTML(body)
|
if date := str(m, "date"); date != "" {
|
||||||
if body != "" {
|
fmt.Fprintf(&b, "**Date:** %s\n\n", date)
|
||||||
fmt.Fprintf(&b, "## Body\n\n%s\n\n", body)
|
}
|
||||||
|
if body := mailBodyMarkdown(m); body != "" {
|
||||||
|
fmt.Fprintf(&b, "---\n\n%s\n", body)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(b.String()) + "\n"
|
return strings.TrimSpace(b.String()) + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mailAddressLine(m map[string]any, key string) string {
|
||||||
|
if v := str(m, key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
name := str(m, key+"Name")
|
||||||
|
addr := str(m, key+"Address")
|
||||||
|
switch {
|
||||||
|
case name != "" && addr != "":
|
||||||
|
return fmt.Sprintf("%s <%s>", name, addr)
|
||||||
|
case addr != "":
|
||||||
|
return addr
|
||||||
|
default:
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailBodyMarkdown(m map[string]any) string {
|
||||||
|
htmlBody := strings.TrimSpace(str(m, "htmlBody"))
|
||||||
|
plainBody := strings.TrimSpace(str(m, "body"))
|
||||||
|
|
||||||
|
for _, candidate := range []string{htmlBody, plainBody} {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if looksLikeHTML(candidate) {
|
||||||
|
if md, err := HTMLToMarkdown(candidate); err == nil {
|
||||||
|
if md = strings.TrimSpace(md); md != "" {
|
||||||
|
return md
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plainBody != "" {
|
||||||
|
return plainBody
|
||||||
|
}
|
||||||
|
if htmlBody != "" {
|
||||||
|
return stripHTML(htmlBody)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeHTML(s string) bool {
|
||||||
|
return strings.Contains(s, "<") && strings.Contains(s, ">") && htmlTagRe.MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
// EventMarkdown formats a calendar event for preview.
|
// EventMarkdown formats a calendar event for preview.
|
||||||
func EventMarkdown(m map[string]any) string {
|
func EventMarkdown(m map[string]any) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package preview_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMailMarkdownConvertsHTMLBody(t *testing.T) {
|
||||||
|
raw := map[string]any{
|
||||||
|
"subject": "Weekly update",
|
||||||
|
"from": `"Team" <team@example.com>`,
|
||||||
|
"htmlBody": `<p>Hello <strong>world</strong></p><p><a href="https://example.com">Link</a></p>`,
|
||||||
|
}
|
||||||
|
md := preview.MailMarkdown(raw)
|
||||||
|
if !strings.Contains(md, "Weekly update") {
|
||||||
|
t.Fatalf("missing subject:\n%s", md)
|
||||||
|
}
|
||||||
|
if !strings.Contains(md, "team@example.com") {
|
||||||
|
t.Fatalf("missing from:\n%s", md)
|
||||||
|
}
|
||||||
|
if strings.Contains(md, "<strong>") || strings.Contains(md, "<p>") {
|
||||||
|
t.Fatalf("expected HTML converted, got raw tags:\n%s", md)
|
||||||
|
}
|
||||||
|
if !strings.Contains(md, "world") {
|
||||||
|
t.Fatalf("missing body text:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailMarkdownRendersWithColor(t *testing.T) {
|
||||||
|
md := preview.MailMarkdown(map[string]any{
|
||||||
|
"subject": "Styled",
|
||||||
|
"from": "a@b.com",
|
||||||
|
"htmlBody": `<h2>Heading</h2><p>Plain text</p>`,
|
||||||
|
})
|
||||||
|
out, err := preview.RenderMarkdown(md, 60)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) == "" {
|
||||||
|
t.Fatal("empty render output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Heading") || !strings.Contains(out, "Plain text") {
|
||||||
|
t.Fatalf("missing rendered content:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailMarkdownPlainBodyFallback(t *testing.T) {
|
||||||
|
md := preview.MailMarkdown(map[string]any{
|
||||||
|
"subject": "Hi",
|
||||||
|
"from": "a@b.com",
|
||||||
|
"body": "Just plain text",
|
||||||
|
})
|
||||||
|
if !strings.Contains(md, "Just plain text") {
|
||||||
|
t.Fatalf("missing plain body:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,7 @@ func TestIntegrationPreviewMailFromAPI(t *testing.T) {
|
|||||||
|
|
||||||
func TestIntegrationPreviewEventFromAPI(t *testing.T) {
|
func TestIntegrationPreviewEventFromAPI(t *testing.T) {
|
||||||
loader, ctx := liveLoader(t)
|
loader, ctx := liveLoader(t)
|
||||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectEvents})
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectCalendar})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -151,6 +151,31 @@ func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPreviewFileFromAPI(t *testing.T) {
|
||||||
|
skipWithoutLiveAPI(t)
|
||||||
|
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
||||||
|
t.Skip("ONLYOFFICE_PROJECT_ID not set")
|
||||||
|
}
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no project files")
|
||||||
|
}
|
||||||
|
md, err := loader.PreviewMarkdown(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreviewMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if md == "" {
|
||||||
|
t.Fatal("empty preview")
|
||||||
|
}
|
||||||
|
if !strings.Contains(md, items[0].Title) && !strings.Contains(md, "|") && !strings.Contains(md, "```") {
|
||||||
|
t.Fatalf("unexpected file preview for %q:\n%s", items[0].Title, md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
|
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
|
||||||
skipWithoutLiveAPI(t)
|
skipWithoutLiveAPI(t)
|
||||||
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
||||||
|
|||||||
+751
-223
File diff suppressed because it is too large
Load Diff
+14
-11
@@ -17,26 +17,29 @@ func TestKeyActionShiftTabPrevPane(t *testing.T) {
|
|||||||
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
|
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
|
||||||
t.Fatalf("got %v", got)
|
t.Fatalf("got %v", got)
|
||||||
}
|
}
|
||||||
if got := ui.KeyAction("backtab", model.FocusPreview); got != ui.ActionPrevPane {
|
}
|
||||||
|
|
||||||
|
func TestKeyActionTabOnPreviewDoesNotSwitchPane(t *testing.T) {
|
||||||
|
if got := ui.KeyAction("tab", model.FocusPreview); got != ui.ActionNone {
|
||||||
|
t.Fatalf("tab on preview should stay in pane, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyActionSortAndColumns(t *testing.T) {
|
||||||
|
if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
|
||||||
t.Fatalf("got %v", got)
|
t.Fatalf("got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKeyActionTabNextPane(t *testing.T) {
|
func TestKeyActionFilter(t *testing.T) {
|
||||||
if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane {
|
if got := ui.KeyAction("f", model.FocusList); got != ui.ActionFilter {
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeyActionOpenActions(t *testing.T) {
|
|
||||||
if got := ui.KeyAction("a", model.FocusList); got != ui.ActionOpenActions {
|
|
||||||
t.Fatalf("got %v", got)
|
t.Fatalf("got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayoutWidths(t *testing.T) {
|
func TestLayoutWidths(t *testing.T) {
|
||||||
menu, list, preview := ui.LayoutWidths(120)
|
menu, list, preview := ui.LayoutWidthsLegacy(120)
|
||||||
if menu+list+preview > 120 {
|
if menu+list+preview != 120 {
|
||||||
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
|
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
const detailContentRatio = 0.72
|
||||||
|
|
||||||
|
type detailMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailEmpty detailMode = iota
|
||||||
|
detailDocument
|
||||||
|
detailForm
|
||||||
|
)
|
||||||
|
|
||||||
|
type detailZone int
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailZoneContent detailZone = iota
|
||||||
|
detailZoneActions
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetailPane is the right column: content top, CRUD actions bottom.
|
||||||
|
type DetailPane struct {
|
||||||
|
mode detailMode
|
||||||
|
item model.Item
|
||||||
|
loadedID string
|
||||||
|
actions []model.ItemAction
|
||||||
|
actionIdx int
|
||||||
|
tabStop int
|
||||||
|
zone detailZone
|
||||||
|
focused bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
form EntityForm
|
||||||
|
docVP viewport.Model
|
||||||
|
formVP viewport.Model
|
||||||
|
docText string
|
||||||
|
styles detailStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
type detailStyles struct {
|
||||||
|
sep lipgloss.Style
|
||||||
|
action lipgloss.Style
|
||||||
|
actionOn lipgloss.Style
|
||||||
|
actionDn lipgloss.Style
|
||||||
|
empty lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDetailPane() DetailPane {
|
||||||
|
d := DetailPane{
|
||||||
|
form: newEntityForm(),
|
||||||
|
docVP: viewport.New(40, 10),
|
||||||
|
formVP: viewport.New(40, 10),
|
||||||
|
styles: newDetailStyles(),
|
||||||
|
}
|
||||||
|
d.docVP.MouseWheelEnabled = true
|
||||||
|
d.formVP.MouseWheelEnabled = true
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDetailStyles() detailStyles {
|
||||||
|
btn := lipgloss.NewStyle().Padding(0, 1).MarginRight(1)
|
||||||
|
return detailStyles{
|
||||||
|
sep: lipgloss.NewStyle().Foreground(lipgloss.Color("238")),
|
||||||
|
action: btn.Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")),
|
||||||
|
actionOn: btn.Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||||
|
actionDn: btn.Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
|
||||||
|
empty: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) Clear() {
|
||||||
|
d.mode = detailEmpty
|
||||||
|
d.item = model.Item{}
|
||||||
|
d.loadedID = ""
|
||||||
|
d.actions = nil
|
||||||
|
d.actionIdx = 0
|
||||||
|
d.tabStop = 0
|
||||||
|
d.docText = ""
|
||||||
|
d.form.Clear()
|
||||||
|
d.docVP.SetContent("")
|
||||||
|
d.formVP.SetContent("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) SetFocused(on bool) {
|
||||||
|
d.focused = on
|
||||||
|
d.applyTabStop(d.tabStop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) FocusFirstStop() {
|
||||||
|
d.tabStop = 0
|
||||||
|
d.applyTabStop(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) maxTabStop() int {
|
||||||
|
switch d.mode {
|
||||||
|
case detailForm:
|
||||||
|
if d.form.readOnly {
|
||||||
|
if len(d.actions) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(d.actions) - 1
|
||||||
|
}
|
||||||
|
fields := d.form.FieldCount()
|
||||||
|
if len(d.actions) == 0 {
|
||||||
|
if fields == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fields - 1
|
||||||
|
}
|
||||||
|
return fields + len(d.actions) - 1
|
||||||
|
case detailDocument:
|
||||||
|
if len(d.actions) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(d.actions)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) applyTabStop(stop int) {
|
||||||
|
if stop < 0 {
|
||||||
|
stop = 0
|
||||||
|
}
|
||||||
|
max := d.maxTabStop()
|
||||||
|
if stop > max {
|
||||||
|
stop = max
|
||||||
|
}
|
||||||
|
d.tabStop = stop
|
||||||
|
|
||||||
|
switch d.mode {
|
||||||
|
case detailForm:
|
||||||
|
if d.form.readOnly {
|
||||||
|
d.zone = detailZoneActions
|
||||||
|
d.actionIdx = stop
|
||||||
|
d.form.SetFocused(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := d.form.FieldCount()
|
||||||
|
if stop < fields {
|
||||||
|
d.zone = detailZoneContent
|
||||||
|
d.form.SetFieldIndex(stop)
|
||||||
|
d.form.SetFocused(d.focused)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.zone = detailZoneActions
|
||||||
|
d.actionIdx = stop - fields
|
||||||
|
d.form.SetFocused(false)
|
||||||
|
case detailDocument:
|
||||||
|
if stop == 0 {
|
||||||
|
d.zone = detailZoneContent
|
||||||
|
d.form.SetFocused(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.zone = detailZoneActions
|
||||||
|
d.actionIdx = stop - 1
|
||||||
|
d.form.SetFocused(false)
|
||||||
|
default:
|
||||||
|
d.zone = detailZoneContent
|
||||||
|
d.form.SetFocused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabForward moves to the next field/button. It returns true when focus should leave the pane.
|
||||||
|
func (d *DetailPane) TabForward() bool {
|
||||||
|
max := d.maxTabStop()
|
||||||
|
if d.tabStop < max {
|
||||||
|
d.applyTabStop(d.tabStop + 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d.FocusFirstStop()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabBackward moves to the previous field/button. It returns true when focus should leave the pane.
|
||||||
|
func (d *DetailPane) TabBackward() bool {
|
||||||
|
if d.tabStop > 0 {
|
||||||
|
d.applyTabStop(d.tabStop - 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d.applyTabStop(d.maxTabStop())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) SetSize(w, h int) {
|
||||||
|
if w < 8 {
|
||||||
|
w = 8
|
||||||
|
}
|
||||||
|
if h < 8 {
|
||||||
|
h = 8
|
||||||
|
}
|
||||||
|
d.width = w
|
||||||
|
d.height = h
|
||||||
|
contentH, _ := d.splitHeights()
|
||||||
|
d.form.SetSize(w, contentH)
|
||||||
|
d.docVP.Width = w
|
||||||
|
d.docVP.Height = contentH
|
||||||
|
d.formVP.Width = w
|
||||||
|
d.formVP.Height = contentH
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) splitHeights() (contentH, actionH int) {
|
||||||
|
actionH = int(float64(d.height) * (1 - detailContentRatio))
|
||||||
|
if actionH < 3 {
|
||||||
|
actionH = 3
|
||||||
|
}
|
||||||
|
if actionH > 6 {
|
||||||
|
actionH = 6
|
||||||
|
}
|
||||||
|
contentH = d.height - actionH - 1
|
||||||
|
if contentH < 4 {
|
||||||
|
contentH = 4
|
||||||
|
}
|
||||||
|
return contentH, actionH
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
|
||||||
|
d.mode = detailForm
|
||||||
|
d.item = item
|
||||||
|
d.loadedID = item.ID
|
||||||
|
d.actions = model.ActionsFor(item.Kind)
|
||||||
|
d.actionIdx = 0
|
||||||
|
d.tabStop = 0
|
||||||
|
d.form.Load(item.Kind, item.ID, fields)
|
||||||
|
d.refreshFormViewport()
|
||||||
|
d.applyTabStop(0)
|
||||||
|
d.layoutContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth int) {
|
||||||
|
d.mode = detailDocument
|
||||||
|
d.item = item
|
||||||
|
d.loadedID = item.ID
|
||||||
|
d.actions = model.ActionsFor(item.Kind)
|
||||||
|
d.actionIdx = 0
|
||||||
|
d.tabStop = 0
|
||||||
|
d.form.Clear()
|
||||||
|
text, err := preview.RenderMarkdown(markdown, renderWidth)
|
||||||
|
if err != nil {
|
||||||
|
text = markdown
|
||||||
|
}
|
||||||
|
d.docText = text
|
||||||
|
d.docVP.SetContent(text)
|
||||||
|
d.docVP.GotoTop()
|
||||||
|
d.applyTabStop(0)
|
||||||
|
d.layoutContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) layoutContent() {
|
||||||
|
contentH, _ := d.splitHeights()
|
||||||
|
d.form.SetSize(d.width, contentH)
|
||||||
|
d.docVP.Width = d.width
|
||||||
|
d.docVP.Height = contentH
|
||||||
|
d.formVP.Width = d.width
|
||||||
|
d.formVP.Height = contentH
|
||||||
|
d.refreshFormViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) refreshFormViewport() {
|
||||||
|
if d.mode != detailForm || !d.form.readOnly {
|
||||||
|
d.formVP.SetContent("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.formVP.SetContent(d.form.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) LoadedID() string { return d.loadedID }
|
||||||
|
|
||||||
|
func (d *DetailPane) Item() model.Item { return d.item }
|
||||||
|
|
||||||
|
func (d *DetailPane) Zone() detailZone { return d.zone }
|
||||||
|
|
||||||
|
func (d *DetailPane) FocusContent() {
|
||||||
|
d.applyTabStop(d.tabStop)
|
||||||
|
if d.zone == detailZoneContent && d.mode == detailForm {
|
||||||
|
d.form.SetFocused(d.focused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) FocusActions() {
|
||||||
|
max := d.maxTabStop()
|
||||||
|
switch d.mode {
|
||||||
|
case detailForm:
|
||||||
|
if d.form.readOnly {
|
||||||
|
d.applyTabStop(max)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(d.actions) > 0 {
|
||||||
|
d.applyTabStop(d.form.FieldCount())
|
||||||
|
}
|
||||||
|
case detailDocument:
|
||||||
|
if len(d.actions) > 0 {
|
||||||
|
d.applyTabStop(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveTabStop moves between title, description, and action buttons without leaving the pane.
|
||||||
|
func (d *DetailPane) MoveTabStop(delta int) {
|
||||||
|
if delta == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := d.tabStop + delta
|
||||||
|
if next < 0 {
|
||||||
|
next = 0
|
||||||
|
}
|
||||||
|
max := d.maxTabStop()
|
||||||
|
if next > max {
|
||||||
|
next = max
|
||||||
|
}
|
||||||
|
d.applyTabStop(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) MoveAction(delta int) {
|
||||||
|
if len(d.actions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.actionIdx = clampInt(d.actionIdx+delta, 0, len(d.actions)-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
|
||||||
|
if d.actionIdx < 0 || d.actionIdx >= len(d.actions) {
|
||||||
|
return model.ItemAction{}, false
|
||||||
|
}
|
||||||
|
return d.actions[d.actionIdx], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DetailPane) IsDocumentContent() bool {
|
||||||
|
return d.mode == detailDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DetailPane) isReadOnlyFormContent() bool {
|
||||||
|
return d.mode == detailForm && d.form.readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DetailPane) documentContentHeight() int {
|
||||||
|
contentH, _ := d.splitHeights()
|
||||||
|
return contentH
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollDocument scrolls the read-only document viewport (mail, file preview).
|
||||||
|
func (d *DetailPane) ScrollDocument(key string) bool {
|
||||||
|
if d.IsDocumentContent() {
|
||||||
|
return scrollViewport(&d.docVP, key)
|
||||||
|
}
|
||||||
|
if d.isReadOnlyFormContent() && d.zone == detailZoneContent {
|
||||||
|
return scrollViewport(&d.formVP, key)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollDocumentMouse applies wheel events to scrollable preview content.
|
||||||
|
func (d *DetailPane) ScrollDocumentMouse(msg tea.MouseMsg) tea.Cmd {
|
||||||
|
var vp *viewport.Model
|
||||||
|
switch {
|
||||||
|
case d.IsDocumentContent():
|
||||||
|
vp = &d.docVP
|
||||||
|
case d.isReadOnlyFormContent():
|
||||||
|
vp = &d.formVP
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if msg.Y >= d.documentContentHeight() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !vp.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch msg.Button {
|
||||||
|
case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
*vp, cmd = vp.Update(msg)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentYOffset exposes scroll position for tests.
|
||||||
|
func (d DetailPane) DocumentYOffset() int {
|
||||||
|
return d.docVP.YOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
if !d.focused {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.zone == detailZoneContent {
|
||||||
|
switch d.mode {
|
||||||
|
case detailForm:
|
||||||
|
if d.form.readOnly {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if scrollViewport(&d.formVP, key.String()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
d.formVP, cmd = d.formVP.Update(msg)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
return d.form.Update(msg)
|
||||||
|
case detailDocument:
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if scrollViewport(&d.docVP, key.String()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
d.docVP, cmd = d.docVP.Update(msg)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DetailPane) View() string {
|
||||||
|
if d.mode == detailEmpty {
|
||||||
|
return d.styles.empty.Render("Select a row to preview or edit.")
|
||||||
|
}
|
||||||
|
contentH, actionH := d.splitHeights()
|
||||||
|
var top string
|
||||||
|
switch d.mode {
|
||||||
|
case detailForm:
|
||||||
|
if d.form.readOnly {
|
||||||
|
top = ApplyVerticalScrollbar(
|
||||||
|
d.formVP.View(),
|
||||||
|
d.formVP.Width,
|
||||||
|
d.formVP.Height,
|
||||||
|
d.formVP.TotalLineCount(),
|
||||||
|
d.formVP.YOffset,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
|
||||||
|
}
|
||||||
|
case detailDocument:
|
||||||
|
top = ApplyVerticalScrollbar(
|
||||||
|
d.docVP.View(),
|
||||||
|
d.docVP.Width,
|
||||||
|
d.docVP.Height,
|
||||||
|
d.docVP.TotalLineCount(),
|
||||||
|
d.docVP.YOffset,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
top = ""
|
||||||
|
}
|
||||||
|
sep := d.styles.sep.Width(d.width).Render(strings.Repeat("─", max(1, d.width)))
|
||||||
|
bottom := lipgloss.NewStyle().Width(d.width).Height(actionH).Render(d.renderActions())
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, top, sep, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DetailPane) renderActions() string {
|
||||||
|
if len(d.actions) == 0 {
|
||||||
|
hint := "No actions"
|
||||||
|
if d.focused && d.zone == detailZoneActions {
|
||||||
|
hint = lipgloss.NewStyle().Background(lipgloss.Color("238")).Render(" No actions ")
|
||||||
|
}
|
||||||
|
return d.styles.empty.Render(hint)
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(d.actions))
|
||||||
|
for i, act := range d.actions {
|
||||||
|
style := d.styles.action
|
||||||
|
if d.focused && d.zone == detailZoneActions && i == d.actionIdx {
|
||||||
|
if act.Danger {
|
||||||
|
style = d.styles.actionDn
|
||||||
|
} else {
|
||||||
|
style = d.styles.actionOn
|
||||||
|
}
|
||||||
|
} else if act.Danger {
|
||||||
|
style = d.styles.actionDn
|
||||||
|
}
|
||||||
|
parts = append(parts, style.Render(act.Label))
|
||||||
|
}
|
||||||
|
line := strings.Join(parts, "")
|
||||||
|
if d.focused && d.zone == detailZoneActions {
|
||||||
|
line += d.styles.empty.Render(" Tab · Enter run")
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DetailPane) BlinkCmd() tea.Cmd {
|
||||||
|
return entityFormBlinkCmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDetailPaneForTest exposes detail pane for tests.
|
||||||
|
func NewDetailPaneForTest() DetailPane {
|
||||||
|
return newDetailPane()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetailDocumentScrollByKey(t *testing.T) {
|
||||||
|
d := newDetailPane()
|
||||||
|
d.SetSize(50, 20)
|
||||||
|
longBody := "# Mail\n\n"
|
||||||
|
for i := 0; i < 80; i++ {
|
||||||
|
longBody += "line\n"
|
||||||
|
}
|
||||||
|
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
|
||||||
|
d.SetFocused(true)
|
||||||
|
|
||||||
|
if d.DocumentYOffset() != 0 {
|
||||||
|
t.Fatalf("expected top offset 0, got %d", d.DocumentYOffset())
|
||||||
|
}
|
||||||
|
if !d.ScrollDocument("down") {
|
||||||
|
t.Fatal("expected scroll down to succeed")
|
||||||
|
}
|
||||||
|
if d.DocumentYOffset() == 0 {
|
||||||
|
t.Fatal("expected offset after scroll down")
|
||||||
|
}
|
||||||
|
if !d.ScrollDocument("up") {
|
||||||
|
t.Fatal("expected scroll up to succeed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailDocumentMouseWheel(t *testing.T) {
|
||||||
|
d := newDetailPane()
|
||||||
|
d.SetSize(50, 20)
|
||||||
|
longBody := "# Mail\n\n"
|
||||||
|
for i := 0; i < 80; i++ {
|
||||||
|
longBody += "line\n"
|
||||||
|
}
|
||||||
|
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
|
||||||
|
|
||||||
|
d.ScrollDocumentMouse(tea.MouseMsg{
|
||||||
|
X: 10, Y: 2, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress,
|
||||||
|
})
|
||||||
|
if d.DocumentYOffset() == 0 {
|
||||||
|
t.Fatal("expected wheel down to scroll document")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailPaneXRange(t *testing.T) {
|
||||||
|
pw := PaneWidths{
|
||||||
|
Menu: 12, List: 72, Detail: 36,
|
||||||
|
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
|
||||||
|
}
|
||||||
|
start, end := DetailPaneXRange(pw)
|
||||||
|
if start != 84 || end != 120 {
|
||||||
|
t.Fatalf("range=%d..%d want 84..120", start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetailTabOrderProjectForm(t *testing.T) {
|
||||||
|
d := newDetailPane()
|
||||||
|
d.SetFocused(true)
|
||||||
|
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject, Title: "P"}, model.FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
Primary: "Alpha", Secondary: "Beta", HasStatus: true,
|
||||||
|
Status: model.ProjectLifecycleOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.tabStop != 0 || d.form.field != entityFieldPrimary {
|
||||||
|
t.Fatalf("start on title, tabStop=%d field=%d", d.tabStop, d.form.field)
|
||||||
|
}
|
||||||
|
if d.TabForward() {
|
||||||
|
t.Fatal("tab from title should stay in pane")
|
||||||
|
}
|
||||||
|
if d.form.field != entityFieldSecondary {
|
||||||
|
t.Fatalf("second stop should be description, field=%d", d.form.field)
|
||||||
|
}
|
||||||
|
if d.TabForward() {
|
||||||
|
t.Fatal("tab from description should stay in pane")
|
||||||
|
}
|
||||||
|
if d.form.field != entityFieldStatus {
|
||||||
|
t.Fatalf("third stop should be status, field=%d", d.form.field)
|
||||||
|
}
|
||||||
|
if d.TabForward() {
|
||||||
|
t.Fatal("tab from status should stay in pane")
|
||||||
|
}
|
||||||
|
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
|
||||||
|
t.Fatalf("fourth stop should be Save, zone=%d idx=%d", d.Zone(), d.actionIdx)
|
||||||
|
}
|
||||||
|
if d.TabForward() {
|
||||||
|
t.Fatal("tab from Save should stay in pane")
|
||||||
|
}
|
||||||
|
if d.actionIdx != 1 {
|
||||||
|
t.Fatalf("fifth stop should be Delete, idx=%d", d.actionIdx)
|
||||||
|
}
|
||||||
|
if !d.TabForward() {
|
||||||
|
t.Fatal("tab from Delete should leave pane")
|
||||||
|
}
|
||||||
|
if d.tabStop != 0 {
|
||||||
|
t.Fatalf("after leaving pane tab order resets, tabStop=%d", d.tabStop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailShiftTabFromTitleLeavesPane(t *testing.T) {
|
||||||
|
d := newDetailPane()
|
||||||
|
d.SetFocused(true)
|
||||||
|
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
})
|
||||||
|
if !d.TabBackward() {
|
||||||
|
t.Fatal("shift+tab from title should leave pane")
|
||||||
|
}
|
||||||
|
if d.tabStop != d.maxTabStop() {
|
||||||
|
t.Fatalf("should park on last stop, tabStop=%d max=%d", d.tabStop, d.maxTabStop())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package ui_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEntityFormLoadAndView(t *testing.T) {
|
||||||
|
form := ui.NewEntityFormForTest()
|
||||||
|
form.SetSize(40, 16)
|
||||||
|
form.Load(model.KindTask, "42", model.FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
Primary: "Fix bug", Secondary: "Details here",
|
||||||
|
})
|
||||||
|
view := form.View()
|
||||||
|
if !strings.Contains(view, "Fix bug") {
|
||||||
|
t.Fatal("view missing title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailPaneFormAndActions(t *testing.T) {
|
||||||
|
d := ui.NewDetailPaneForTest()
|
||||||
|
d.SetSize(50, 20)
|
||||||
|
d.SetFocused(true)
|
||||||
|
d.LoadForm(model.Item{ID: "1", Kind: model.KindTask, Title: "T"}, model.FormFields{
|
||||||
|
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||||
|
Primary: "Hello", Secondary: "World",
|
||||||
|
})
|
||||||
|
view := d.View()
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailPaneDocumentMode(t *testing.T) {
|
||||||
|
d := ui.NewDetailPaneForTest()
|
||||||
|
d.SetSize(50, 20)
|
||||||
|
d.LoadDocument(model.Item{ID: "9", Kind: model.KindFile, Title: "a.txt"}, "# Doc\n\nbody", 40)
|
||||||
|
view := d.View()
|
||||||
|
if !strings.Contains(view, "Download") {
|
||||||
|
t.Fatal("expected Download action")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type entityField int
|
||||||
|
|
||||||
|
const (
|
||||||
|
entityFieldPrimary entityField = iota
|
||||||
|
entityFieldSecondary
|
||||||
|
entityFieldStatus
|
||||||
|
entityFieldResponsible
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntityForm is the top section of the detail pane for editable entities.
|
||||||
|
type EntityForm struct {
|
||||||
|
active bool
|
||||||
|
kind model.Kind
|
||||||
|
itemID string
|
||||||
|
primaryLabel string
|
||||||
|
secondaryLabel string
|
||||||
|
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
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
styles entityFormStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
type entityFormStyles struct {
|
||||||
|
header lipgloss.Style
|
||||||
|
label lipgloss.Style
|
||||||
|
labelAct lipgloss.Style
|
||||||
|
meta lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEntityFormStyles() entityFormStyles {
|
||||||
|
return 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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEntityForm() EntityForm {
|
||||||
|
primary := textinput.New()
|
||||||
|
primary.CharLimit = 512
|
||||||
|
primary.Prompt = "> "
|
||||||
|
secondary := textarea.New()
|
||||||
|
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 || f.hasTaskStatus {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if f.hasResponsible {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) FormFields() model.FormFields {
|
||||||
|
return model.FormFields{
|
||||||
|
PrimaryLabel: f.primaryLabel,
|
||||||
|
SecondaryLabel: f.secondaryLabel,
|
||||||
|
Primary: f.primary.Value(),
|
||||||
|
Secondary: f.secondary.Value(),
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) Active() bool { return f.active }
|
||||||
|
|
||||||
|
func (f *EntityForm) ItemID() string { return f.itemID }
|
||||||
|
|
||||||
|
func (f *EntityForm) Primary() string { return f.primary.Value() }
|
||||||
|
|
||||||
|
func (f *EntityForm) Secondary() string { return f.secondary.Value() }
|
||||||
|
|
||||||
|
func (f *EntityForm) MarkClean() { f.dirty = false }
|
||||||
|
|
||||||
|
func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormFields) {
|
||||||
|
f.active = true
|
||||||
|
f.kind = kind
|
||||||
|
f.itemID = itemID
|
||||||
|
f.primaryLabel = fields.PrimaryLabel
|
||||||
|
f.secondaryLabel = fields.SecondaryLabel
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
f.focused = on && f.active
|
||||||
|
if f.focused {
|
||||||
|
f.applyFocus()
|
||||||
|
} else {
|
||||||
|
f.primary.Blur()
|
||||||
|
f.secondary.Blur()
|
||||||
|
f.password.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) SetSize(w, h int) {
|
||||||
|
if w < 12 {
|
||||||
|
w = 12
|
||||||
|
}
|
||||||
|
if h < 6 {
|
||||||
|
h = 6
|
||||||
|
}
|
||||||
|
f.width = w
|
||||||
|
f.height = h
|
||||||
|
f.layoutFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) layoutFields() {
|
||||||
|
if f.width == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inner := f.width - 2
|
||||||
|
if inner < 8 {
|
||||||
|
inner = 8
|
||||||
|
}
|
||||||
|
f.primary.Width = inner
|
||||||
|
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
|
||||||
|
}
|
||||||
|
f.secondary.SetWidth(inner)
|
||||||
|
f.secondary.SetHeight(secH)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) lastField() entityField {
|
||||||
|
if f.hasResponsible {
|
||||||
|
return entityFieldResponsible
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
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 || 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
|
||||||
|
}
|
||||||
|
f.applyFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) cycleStatus(delta int) {
|
||||||
|
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 {
|
||||||
|
f.status = f.status.Next()
|
||||||
|
} else {
|
||||||
|
f.status = f.status.Prev()
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch f.field {
|
||||||
|
case entityFieldPrimary:
|
||||||
|
f.primary.Focus()
|
||||||
|
f.secondary.Blur()
|
||||||
|
case entityFieldSecondary:
|
||||||
|
f.primary.Blur()
|
||||||
|
f.secondary.Focus()
|
||||||
|
default:
|
||||||
|
f.primary.Blur()
|
||||||
|
f.secondary.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
if !f.active || !f.focused || f.readOnly {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return nil
|
||||||
|
case "right", "l":
|
||||||
|
f.cycleStatus(1)
|
||||||
|
return nil
|
||||||
|
case " ":
|
||||||
|
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 {
|
||||||
|
case entityFieldPrimary:
|
||||||
|
f.primary, cmd = f.primary.Update(msg)
|
||||||
|
case entityFieldSecondary:
|
||||||
|
f.secondary, cmd = f.secondary.Update(msg)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
f.dirty = true
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
pLabel = f.styles.labelAct.Render(f.primaryLabel)
|
||||||
|
}
|
||||||
|
sLabel := f.styles.label.Render(f.secondaryLabel)
|
||||||
|
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
|
||||||
|
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
|
||||||
|
}
|
||||||
|
statusBlock := f.renderStatusBlock()
|
||||||
|
responsibleBlock := f.renderResponsibleBlock()
|
||||||
|
metaBlock := f.renderMetaBlock()
|
||||||
|
ro := ""
|
||||||
|
if f.readOnly {
|
||||||
|
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
|
||||||
|
}
|
||||||
|
parts := []string{
|
||||||
|
header + ro,
|
||||||
|
pLabel,
|
||||||
|
f.primary.View(),
|
||||||
|
"",
|
||||||
|
sLabel,
|
||||||
|
f.secondary.View(),
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEntityFormForTest exposes a form for unit tests.
|
||||||
|
func NewEntityFormForTest() EntityForm {
|
||||||
|
return newEntityForm()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FilterSearch is the right-pane query input for nav/list filtering.
|
||||||
|
type FilterSearch struct {
|
||||||
|
input textinput.Model
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
focused bool
|
||||||
|
styles filterSearchStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
type filterSearchStyles struct {
|
||||||
|
title lipgloss.Style
|
||||||
|
hint lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFilterSearch() FilterSearch {
|
||||||
|
in := textinput.New()
|
||||||
|
in.Prompt = "/ "
|
||||||
|
in.Placeholder = "Filter list…"
|
||||||
|
in.CharLimit = 256
|
||||||
|
return FilterSearch{
|
||||||
|
input: in,
|
||||||
|
styles: filterSearchStyles{
|
||||||
|
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||||
|
hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FilterSearch) Query() string { return f.input.Value() }
|
||||||
|
|
||||||
|
func (f *FilterSearch) SetFocused(on bool) {
|
||||||
|
f.focused = on
|
||||||
|
if on {
|
||||||
|
f.input.Focus()
|
||||||
|
} else {
|
||||||
|
f.input.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FilterSearch) SetSize(w, h int) {
|
||||||
|
if w < 12 {
|
||||||
|
w = 12
|
||||||
|
}
|
||||||
|
if h < 6 {
|
||||||
|
h = 6
|
||||||
|
}
|
||||||
|
f.width = w
|
||||||
|
f.height = h
|
||||||
|
inner := w - 2
|
||||||
|
if inner < 8 {
|
||||||
|
inner = 8
|
||||||
|
}
|
||||||
|
f.input.Width = inner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FilterSearch) Clear() {
|
||||||
|
f.input.SetValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
if !f.focused {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
f.input, cmd = f.input.Update(msg)
|
||||||
|
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())
|
||||||
|
hint := f.styles.hint.Render("Filters left nav and center table · Esc clear · f focus")
|
||||||
|
return strings.Join([]string{title, "", body, "", hint}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterSearchBlinkCmd() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
+39
-31
@@ -9,14 +9,19 @@ const (
|
|||||||
ActionNone Action = iota
|
ActionNone Action = iota
|
||||||
ActionMoveUp
|
ActionMoveUp
|
||||||
ActionMoveDown
|
ActionMoveDown
|
||||||
|
ActionMoveLeft
|
||||||
|
ActionMoveRight
|
||||||
|
ActionSort
|
||||||
ActionToggleSelect
|
ActionToggleSelect
|
||||||
ActionOpenPreview
|
ActionFocusDetail
|
||||||
ActionOpenActions
|
ActionToggleMenuPane
|
||||||
|
ActionToggleListPane
|
||||||
|
ActionToggleDetailPane
|
||||||
|
ActionFilter
|
||||||
ActionNextPane
|
ActionNextPane
|
||||||
ActionPrevPane
|
ActionPrevPane
|
||||||
ActionRefresh
|
ActionRefresh
|
||||||
ActionQuit
|
ActionQuit
|
||||||
ActionOpenVex
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyAction maps a key string and focused pane to an action.
|
// KeyAction maps a key string and focused pane to an action.
|
||||||
@@ -25,53 +30,56 @@ func KeyAction(key string, pane model.FocusPane) Action {
|
|||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return ActionQuit
|
return ActionQuit
|
||||||
case "tab":
|
case "tab":
|
||||||
|
if pane != model.FocusPreview {
|
||||||
return ActionNextPane
|
return ActionNextPane
|
||||||
|
}
|
||||||
case "shift+tab", "backtab":
|
case "shift+tab", "backtab":
|
||||||
|
if pane != model.FocusPreview {
|
||||||
return ActionPrevPane
|
return ActionPrevPane
|
||||||
|
}
|
||||||
case "r":
|
case "r":
|
||||||
return ActionRefresh
|
return ActionRefresh
|
||||||
|
case "f", "/":
|
||||||
|
if pane == model.FocusList {
|
||||||
|
return ActionFilter
|
||||||
|
}
|
||||||
|
if pane == model.FocusMenu {
|
||||||
|
return ActionFilter
|
||||||
|
}
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
return ActionMoveUp
|
return ActionMoveUp
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
return ActionMoveDown
|
return ActionMoveDown
|
||||||
|
case "s":
|
||||||
|
if pane == model.FocusList {
|
||||||
|
return ActionSort
|
||||||
|
}
|
||||||
|
case "left", "h":
|
||||||
|
return ActionMoveLeft
|
||||||
|
case "right", "l":
|
||||||
|
return ActionMoveRight
|
||||||
case " ":
|
case " ":
|
||||||
if pane == model.FocusList {
|
if pane == model.FocusList {
|
||||||
return ActionToggleSelect
|
return ActionToggleSelect
|
||||||
}
|
}
|
||||||
case "a":
|
case "v", "p":
|
||||||
if pane == model.FocusList {
|
if pane == model.FocusList {
|
||||||
return ActionOpenActions
|
return ActionFocusDetail
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if pane == model.FocusList {
|
|
||||||
return ActionOpenPreview
|
|
||||||
}
|
|
||||||
case "v":
|
|
||||||
if pane == model.FocusList {
|
|
||||||
return ActionOpenVex
|
|
||||||
}
|
}
|
||||||
|
case "alt+1":
|
||||||
|
return ActionToggleMenuPane
|
||||||
|
case "alt+2":
|
||||||
|
return ActionToggleListPane
|
||||||
|
case "alt+3":
|
||||||
|
return ActionToggleDetailPane
|
||||||
}
|
}
|
||||||
return ActionNone
|
return ActionNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutWidths splits total terminal width into menu, list, preview columns.
|
// LayoutWidthsLegacy is kept for tests that expect the old signature.
|
||||||
func LayoutWidths(total int) (menu, list, preview int) {
|
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
|
||||||
if total < 80 {
|
pw := LayoutWidths(total, defaultPaneVisibility())
|
||||||
total = 80
|
return pw.Menu, pw.List, pw.Detail
|
||||||
}
|
|
||||||
menu = total / 5
|
|
||||||
if menu < 22 {
|
|
||||||
menu = 22
|
|
||||||
}
|
|
||||||
preview = total / 3
|
|
||||||
if preview < 28 {
|
|
||||||
preview = 28
|
|
||||||
}
|
|
||||||
list = total - menu - preview - 2
|
|
||||||
if list < 24 {
|
|
||||||
list = 24
|
|
||||||
}
|
|
||||||
return menu, list, preview
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveMoveUp returns the action for upward navigation keys.
|
// ResolveMoveUp returns the action for upward navigation keys.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
|
||||||
|
// PaneVisibility controls which columns are shown.
|
||||||
|
type PaneVisibility struct {
|
||||||
|
Menu, List, Detail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaneWidths is outer lipgloss width per column (sums to terminal width).
|
||||||
|
type PaneWidths struct {
|
||||||
|
Menu, List, Detail int
|
||||||
|
Visibility PaneVisibility
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPaneVisibility() PaneVisibility {
|
||||||
|
return PaneVisibility{Menu: true, List: true, Detail: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMenuShare = 10
|
||||||
|
defaultListShare = 60
|
||||||
|
defaultDetailShare = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
// LayoutWidths splits total terminal width across visible panes (default 10% / 60% / 30%).
|
||||||
|
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
|
||||||
|
if total < 1 {
|
||||||
|
total = 80
|
||||||
|
}
|
||||||
|
v := vis
|
||||||
|
if countVisible(v) == 0 {
|
||||||
|
v = defaultPaneVisibility()
|
||||||
|
}
|
||||||
|
shareSum := 0
|
||||||
|
if v.Menu {
|
||||||
|
shareSum += defaultMenuShare
|
||||||
|
}
|
||||||
|
if v.List {
|
||||||
|
shareSum += defaultListShare
|
||||||
|
}
|
||||||
|
if v.Detail {
|
||||||
|
shareSum += defaultDetailShare
|
||||||
|
}
|
||||||
|
assign := func(visible bool, share int) int {
|
||||||
|
if !visible || shareSum == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total * share / shareSum
|
||||||
|
}
|
||||||
|
out := PaneWidths{Visibility: v}
|
||||||
|
out.Menu = assign(v.Menu, defaultMenuShare)
|
||||||
|
out.List = assign(v.List, defaultListShare)
|
||||||
|
out.Detail = assign(v.Detail, defaultDetailShare)
|
||||||
|
fixPaneWidthSum(&out, total)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailPaneXRange returns the [start, end) column span of the detail pane.
|
||||||
|
func DetailPaneXRange(pw PaneWidths) (start, end int) {
|
||||||
|
if !pw.Visibility.Detail {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
if pw.Visibility.Menu {
|
||||||
|
start += pw.Menu
|
||||||
|
}
|
||||||
|
if pw.Visibility.List {
|
||||||
|
start += pw.List
|
||||||
|
}
|
||||||
|
end = start + pw.Detail
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
func countVisible(v PaneVisibility) int {
|
||||||
|
n := 0
|
||||||
|
if v.Menu {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if v.List {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if v.Detail {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextVisibleFocus cycles focus forward, skipping hidden panes.
|
||||||
|
func NextVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
|
||||||
|
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
|
||||||
|
start := 0
|
||||||
|
for i, p := range order {
|
||||||
|
if p == cur {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for step := 1; step <= len(order); step++ {
|
||||||
|
p := order[(start+step)%len(order)]
|
||||||
|
if paneVisible(p, vis) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrevVisibleFocus cycles focus backward, skipping hidden panes.
|
||||||
|
func PrevVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
|
||||||
|
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
|
||||||
|
start := 0
|
||||||
|
for i, p := range order {
|
||||||
|
if p == cur {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for step := 1; step <= len(order); step++ {
|
||||||
|
p := order[(start-step+len(order))%len(order)]
|
||||||
|
if paneVisible(p, vis) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
func paneVisible(p model.FocusPane, vis PaneVisibility) bool {
|
||||||
|
switch p {
|
||||||
|
case model.FocusMenu:
|
||||||
|
return vis.Menu
|
||||||
|
case model.FocusList:
|
||||||
|
return vis.List
|
||||||
|
default:
|
||||||
|
return vis.Detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstVisibleFocus(vis PaneVisibility) model.FocusPane {
|
||||||
|
if vis.Menu {
|
||||||
|
return model.FocusMenu
|
||||||
|
}
|
||||||
|
if vis.List {
|
||||||
|
return model.FocusList
|
||||||
|
}
|
||||||
|
return model.FocusPreview
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minPaneOuterWidth = 14
|
||||||
|
resizeHitSlop = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type paneResizeState struct {
|
||||||
|
active bool
|
||||||
|
divider int
|
||||||
|
anchorX int
|
||||||
|
anchorPW PaneWidths
|
||||||
|
}
|
||||||
|
|
||||||
|
// FitPaneWidths scales stored pane widths to fill total width for visible panes.
|
||||||
|
func FitPaneWidths(total int, vis PaneVisibility, sizes PaneWidths) PaneWidths {
|
||||||
|
if total < 1 {
|
||||||
|
total = 80
|
||||||
|
}
|
||||||
|
out := PaneWidths{Visibility: vis}
|
||||||
|
if countVisible(vis) == 0 {
|
||||||
|
return LayoutWidths(total, vis)
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
if vis.Menu {
|
||||||
|
sum += sizes.Menu
|
||||||
|
}
|
||||||
|
if vis.List {
|
||||||
|
sum += sizes.List
|
||||||
|
}
|
||||||
|
if vis.Detail {
|
||||||
|
sum += sizes.Detail
|
||||||
|
}
|
||||||
|
if sum <= 0 {
|
||||||
|
return LayoutWidths(total, vis)
|
||||||
|
}
|
||||||
|
assign := func(visible bool, stored int) int {
|
||||||
|
if !visible {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return stored * total / sum
|
||||||
|
}
|
||||||
|
out.Menu = assign(vis.Menu, sizes.Menu)
|
||||||
|
out.List = assign(vis.List, sizes.List)
|
||||||
|
out.Detail = assign(vis.Detail, sizes.Detail)
|
||||||
|
fixPaneWidthSum(&out, total)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixPaneWidthSum(pw *PaneWidths, total int) {
|
||||||
|
if pw == nil || total < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type slot struct {
|
||||||
|
visible bool
|
||||||
|
width *int
|
||||||
|
}
|
||||||
|
slots := []slot{
|
||||||
|
{pw.Visibility.Menu, &pw.Menu},
|
||||||
|
{pw.Visibility.List, &pw.List},
|
||||||
|
{pw.Visibility.Detail, &pw.Detail},
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
var last *int
|
||||||
|
for _, s := range slots {
|
||||||
|
if !s.visible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum += *s.width
|
||||||
|
last = s.width
|
||||||
|
}
|
||||||
|
if last == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*last += total - sum
|
||||||
|
if *last < minPaneOuterWidth {
|
||||||
|
*last = minPaneOuterWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DividerPositions returns x coordinates of vertical pane separators.
|
||||||
|
func DividerPositions(pw PaneWidths) []int {
|
||||||
|
var out []int
|
||||||
|
x := 0
|
||||||
|
if pw.Visibility.Menu {
|
||||||
|
x += pw.Menu
|
||||||
|
if pw.Visibility.List || pw.Visibility.Detail {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pw.Visibility.List {
|
||||||
|
x += pw.List
|
||||||
|
if pw.Visibility.Detail {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DividerAt returns the divider index under x, or -1.
|
||||||
|
func DividerAt(x int, pw PaneWidths) int {
|
||||||
|
for i, pos := range DividerPositions(pw) {
|
||||||
|
if x >= pos-resizeHitSlop && x <= pos+resizeHitSlop {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// DragPaneDivider adjusts adjacent pane widths by deltaX pixels.
|
||||||
|
func DragPaneDivider(pw PaneWidths, divider, deltaX int) (PaneWidths, bool) {
|
||||||
|
if deltaX == 0 {
|
||||||
|
return pw, false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
switch divider {
|
||||||
|
case 0:
|
||||||
|
if pw.Visibility.Menu && pw.Visibility.List {
|
||||||
|
if resizePair(&pw.Menu, &pw.List, deltaX) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else if pw.Visibility.Menu && pw.Visibility.Detail {
|
||||||
|
if resizePair(&pw.Menu, &pw.Detail, deltaX) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
if pw.Visibility.List && pw.Visibility.Detail {
|
||||||
|
if resizePair(&pw.List, &pw.Detail, deltaX) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pw, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizePair(left, right *int, delta int) bool {
|
||||||
|
if left == nil || right == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
newLeft := *left + delta
|
||||||
|
newRight := *right - delta
|
||||||
|
if newLeft < minPaneOuterWidth || newRight < minPaneOuterWidth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*left = newLeft
|
||||||
|
*right = newRight
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) paneResizeMaxY() int {
|
||||||
|
return m.paneHeight() + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handlePaneResizeMouse(msg tea.MouseMsg) bool {
|
||||||
|
if msg.Y >= m.paneResizeMaxY() {
|
||||||
|
if m.resize.active && msg.Action == tea.MouseActionRelease {
|
||||||
|
m.resize.active = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := m.paneLayout()
|
||||||
|
switch msg.Action {
|
||||||
|
case tea.MouseActionPress:
|
||||||
|
if msg.Button != tea.MouseButtonLeft {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if div := DividerAt(msg.X, pw); div >= 0 {
|
||||||
|
m.paneSizes = pw
|
||||||
|
m.customPaneLayout = true
|
||||||
|
m.resize = paneResizeState{
|
||||||
|
active: true,
|
||||||
|
divider: div,
|
||||||
|
anchorX: msg.X,
|
||||||
|
anchorPW: pw,
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case tea.MouseActionRelease:
|
||||||
|
if m.resize.active {
|
||||||
|
m.resize.active = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case tea.MouseActionMotion:
|
||||||
|
if m.resize.active {
|
||||||
|
delta := msg.X - m.resize.anchorX
|
||||||
|
next, ok := DragPaneDivider(m.resize.anchorPW, m.resize.divider, delta)
|
||||||
|
if ok {
|
||||||
|
m.paneSizes = next
|
||||||
|
m.layoutViewports()
|
||||||
|
m.syncAllContent()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.resize.active
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDividerAtBetweenMenuAndList(t *testing.T) {
|
||||||
|
pw := PaneWidths{
|
||||||
|
Menu: 30, List: 50, Detail: 40,
|
||||||
|
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
|
||||||
|
}
|
||||||
|
if got := DividerAt(30, pw); got != 0 {
|
||||||
|
t.Fatalf("divider at 30=%d want 0", got)
|
||||||
|
}
|
||||||
|
if got := DividerAt(80, pw); got != 1 {
|
||||||
|
t.Fatalf("divider at 80=%d want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDragPaneDividerRespectsMinimum(t *testing.T) {
|
||||||
|
pw := PaneWidths{
|
||||||
|
Menu: 40, List: 40, Detail: 40,
|
||||||
|
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
|
||||||
|
}
|
||||||
|
next, ok := DragPaneDivider(pw, 0, -30)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected resize blocked by minimum, got %+v", next)
|
||||||
|
}
|
||||||
|
next, ok = DragPaneDivider(pw, 0, 5)
|
||||||
|
if !ok || next.Menu != 45 || next.List != 35 {
|
||||||
|
t.Fatalf("resize failed: ok=%v %+v", ok, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFitPaneWidthsScalesToTotal(t *testing.T) {
|
||||||
|
vis := PaneVisibility{Menu: true, List: true, Detail: true}
|
||||||
|
sizes := PaneWidths{Menu: 20, List: 30, Detail: 50, Visibility: vis}
|
||||||
|
out := FitPaneWidths(120, vis, sizes)
|
||||||
|
sum := out.Menu + out.List + out.Detail
|
||||||
|
if sum != 120 {
|
||||||
|
t.Fatalf("sum=%d want 120", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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) {
|
||||||
|
vis := PaneVisibility{Menu: true, List: true, Detail: true}
|
||||||
|
pw := LayoutWidths(120, vis)
|
||||||
|
sum := pw.Menu + pw.List + pw.Detail
|
||||||
|
if sum != 120 {
|
||||||
|
t.Fatalf("sum=%d want 120", sum)
|
||||||
|
}
|
||||||
|
if pw.Menu != 12 || pw.List != 72 || pw.Detail != 36 {
|
||||||
|
t.Fatalf("menu=%d list=%d detail=%d want 12/72/36 (10/60/30)", pw.Menu, pw.List, pw.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayoutWidthsSinglePane(t *testing.T) {
|
||||||
|
pw := LayoutWidths(100, PaneVisibility{Menu: false, List: true, Detail: false})
|
||||||
|
if pw.List != 100 {
|
||||||
|
t.Fatalf("list=%d want 100", pw.List)
|
||||||
|
}
|
||||||
|
if pw.Menu != 0 || pw.Detail != 0 {
|
||||||
|
t.Fatalf("unexpected widths: %+v", pw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayoutWidthsTwoPanes(t *testing.T) {
|
||||||
|
pw := LayoutWidths(80, PaneVisibility{Menu: true, List: false, Detail: true})
|
||||||
|
if pw.Menu+pw.Detail != 80 {
|
||||||
|
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
|
||||||
|
}
|
||||||
|
if pw.Menu != 20 || pw.Detail != 60 {
|
||||||
|
t.Fatalf("menu=%d detail=%d want 20/60 (10/30 of pair)", pw.Menu, pw.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailPaneXRangeSkipsHidden(t *testing.T) {
|
||||||
|
pw := PaneWidths{
|
||||||
|
Menu: 20, List: 60, Detail: 0,
|
||||||
|
Visibility: PaneVisibility{Menu: true, List: true, Detail: false},
|
||||||
|
}
|
||||||
|
start, end := DetailPaneXRange(pw)
|
||||||
|
if start != 0 || end != 0 {
|
||||||
|
t.Fatalf("hidden detail should have empty range, got %d..%d", start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
|
||||||
|
vis := PaneVisibility{Menu: false, List: true, Detail: true}
|
||||||
|
if got := NextVisibleFocus(model.FocusList, vis); got != model.FocusPreview {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
if got := NextVisibleFocus(model.FocusPreview, vis); got != model.FocusList {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
|
||||||
|
vis := PaneVisibility{Menu: true, List: false, Detail: true}
|
||||||
|
if got := PrevVisibleFocus(model.FocusPreview, vis); got != model.FocusMenu {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatalf("widths exceed total")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyActionTogglePanes(t *testing.T) {
|
||||||
|
if got := KeyAction("alt+1", model.FocusList); got != ActionToggleMenuPane {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
if got := KeyAction("alt+2", model.FocusMenu); got != ActionToggleListPane {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
if got := KeyAction("alt+3", model.FocusList); got != ActionToggleDetailPane {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
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":
|
||||||
|
m.listTable.PageScroll(1)
|
||||||
|
return true
|
||||||
|
case "pgup", "b", "ctrl+u":
|
||||||
|
m.listTable.PageScroll(-1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case model.FocusPreview:
|
||||||
|
if m.detail.Zone() != detailZoneContent {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return m.detail.ScrollDocument(key)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) paneHeight() int {
|
||||||
|
h := m.height - 3
|
||||||
|
if h < 3 {
|
||||||
|
h = 3
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := 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":
|
||||||
|
vp.LineUp(1)
|
||||||
|
return true
|
||||||
|
case "down", "j":
|
||||||
|
vp.LineDown(1)
|
||||||
|
return true
|
||||||
|
case "pgdown", "pgdn", "ctrl+d":
|
||||||
|
vp.ViewDown()
|
||||||
|
return true
|
||||||
|
case "pgup", "b", "ctrl+u":
|
||||||
|
vp.ViewUp()
|
||||||
|
return true
|
||||||
|
case "home", "g":
|
||||||
|
vp.GotoTop()
|
||||||
|
return true
|
||||||
|
case "end", "G":
|
||||||
|
vp.GotoBottom()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
|
||||||
|
func ScrollbarMetrics(totalLines, viewportHeight, yOffset int) (show bool, thumbStart, thumbEnd int) {
|
||||||
|
if totalLines <= viewportHeight || viewportHeight < 1 {
|
||||||
|
return false, 0, 0
|
||||||
|
}
|
||||||
|
maxOff := totalLines - viewportHeight
|
||||||
|
if maxOff < 0 {
|
||||||
|
maxOff = 0
|
||||||
|
}
|
||||||
|
thumbSize := viewportHeight * viewportHeight / totalLines
|
||||||
|
if thumbSize < 1 {
|
||||||
|
thumbSize = 1
|
||||||
|
}
|
||||||
|
if thumbSize > viewportHeight {
|
||||||
|
thumbSize = viewportHeight
|
||||||
|
}
|
||||||
|
travel := viewportHeight - thumbSize
|
||||||
|
thumbStart = 0
|
||||||
|
if maxOff > 0 {
|
||||||
|
thumbStart = yOffset * travel / maxOff
|
||||||
|
}
|
||||||
|
return true, thumbStart, thumbStart + thumbSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyVerticalScrollbar draws a 1-column scrollbar on the right when content overflows.
|
||||||
|
func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int) string {
|
||||||
|
show, thumbStart, thumbEnd := ScrollbarMetrics(totalLines, height, yOffset)
|
||||||
|
if !show || width < 2 {
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
contentWidth := width - 1
|
||||||
|
lines := strings.Split(view, "\n")
|
||||||
|
for len(lines) < height {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
if len(lines) > height {
|
||||||
|
lines = lines[:height]
|
||||||
|
}
|
||||||
|
track := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||||
|
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||||
|
out := make([]string, height)
|
||||||
|
for row := 0; row < height; row++ {
|
||||||
|
line := padANSIWidth(lines[row], contentWidth)
|
||||||
|
ch := "│"
|
||||||
|
style := track
|
||||||
|
if row >= thumbStart && row < thumbEnd {
|
||||||
|
ch = "█"
|
||||||
|
style = thumb
|
||||||
|
}
|
||||||
|
out[row] = line + style.Render(ch)
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func padDisplayWidth(line string, width int) string {
|
||||||
|
return padANSIWidth(line, width)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScrollbarMetricsHiddenWhenFits(t *testing.T) {
|
||||||
|
show, _, _ := ScrollbarMetrics(5, 10, 0)
|
||||||
|
if show {
|
||||||
|
t.Fatal("expected no scrollbar when content fits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrollbarMetricsVisibleWhenOverflow(t *testing.T) {
|
||||||
|
show, start, end := ScrollbarMetrics(100, 10, 50)
|
||||||
|
if !show || end <= start {
|
||||||
|
t.Fatalf("expected thumb, got %d..%d", start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyVerticalScrollbarAddsColumn(t *testing.T) {
|
||||||
|
view := "line1\nline2\nline3"
|
||||||
|
out := ApplyVerticalScrollbar(view, 12, 3, 10, 0)
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Fatalf("lines=%d", len(lines))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(lines[0], "█") && !strings.HasSuffix(lines[0], "│") {
|
||||||
|
t.Fatalf("expected scrollbar glyph: %q", lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tableStyles struct {
|
||||||
|
title lipgloss.Style
|
||||||
|
header lipgloss.Style
|
||||||
|
headerSort lipgloss.Style
|
||||||
|
cell lipgloss.Style
|
||||||
|
rowActive lipgloss.Style
|
||||||
|
colActive lipgloss.Style
|
||||||
|
cellActive lipgloss.Style
|
||||||
|
rowSelect lipgloss.Style
|
||||||
|
cellSelect lipgloss.Style
|
||||||
|
help lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTableStyles() tableStyles {
|
||||||
|
return tableStyles{
|
||||||
|
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||||
|
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")).Padding(0, 1),
|
||||||
|
headerSort: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Background(lipgloss.Color("62")).Padding(0, 1),
|
||||||
|
cell: lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Padding(0, 1),
|
||||||
|
rowActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("238")),
|
||||||
|
colActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("237")),
|
||||||
|
cellActive: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||||
|
rowSelect: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("22")),
|
||||||
|
cellSelect: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("34")),
|
||||||
|
help: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataTable is a spreadsheet-style listContent table for the list pane.
|
||||||
|
type DataTable struct {
|
||||||
|
spec model.ListSpec
|
||||||
|
columns []model.TableColumn
|
||||||
|
items []model.Item
|
||||||
|
order []int
|
||||||
|
cursorRow int
|
||||||
|
cursorCol int
|
||||||
|
colScroll int
|
||||||
|
sortCol int
|
||||||
|
sortAsc bool
|
||||||
|
focused bool
|
||||||
|
ready bool
|
||||||
|
loadingMore bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
viewport viewport.Model
|
||||||
|
styles tableStyles
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDataTable() DataTable {
|
||||||
|
t := DataTable{
|
||||||
|
sortCol: -1,
|
||||||
|
styles: defaultTableStyles(),
|
||||||
|
viewport: viewport.New(40, 10),
|
||||||
|
}
|
||||||
|
t.viewport.MouseWheelEnabled = true
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) SetSize(w, h int) {
|
||||||
|
if w < 8 {
|
||||||
|
w = 8
|
||||||
|
}
|
||||||
|
if h < 3 {
|
||||||
|
h = 3
|
||||||
|
}
|
||||||
|
t.width = w
|
||||||
|
t.height = h
|
||||||
|
t.viewport.Width = w
|
||||||
|
t.viewport.Height = h - 1 // column header
|
||||||
|
if t.viewport.Height < 1 {
|
||||||
|
t.viewport.Height = 1
|
||||||
|
}
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) SetFocused(f bool) {
|
||||||
|
t.focused = f
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) SetData(spec model.ListSpec, items []model.Item) {
|
||||||
|
t.spec = spec
|
||||||
|
t.items = items
|
||||||
|
t.columns = model.BuildColumns(spec.Subject, items)
|
||||||
|
t.order = make([]int, len(items))
|
||||||
|
for i := range t.order {
|
||||||
|
t.order[i] = i
|
||||||
|
}
|
||||||
|
t.cursorRow = 0
|
||||||
|
t.cursorCol = 0
|
||||||
|
t.colScroll = 0
|
||||||
|
t.ready = true
|
||||||
|
if t.sortCol >= 0 {
|
||||||
|
t.applySort()
|
||||||
|
}
|
||||||
|
t.clampCursor()
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) Clear() {
|
||||||
|
t.ready = false
|
||||||
|
t.items = nil
|
||||||
|
t.order = nil
|
||||||
|
t.columns = nil
|
||||||
|
t.cursorRow = 0
|
||||||
|
t.cursorCol = 0
|
||||||
|
t.viewport.SetContent("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) ItemIndex() int {
|
||||||
|
if t.cursorRow < 0 || t.cursorRow >= len(t.order) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return t.order[t.cursorRow]
|
||||||
|
}
|
||||||
|
|
||||||
|
// CursorRow is the visual row index in the current table order.
|
||||||
|
func (t DataTable) CursorRow() int {
|
||||||
|
return t.cursorRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items returns the backing slice (after appends).
|
||||||
|
func (t DataTable) Items() []model.Item {
|
||||||
|
return t.items
|
||||||
|
}
|
||||||
|
|
||||||
|
// NearEnd reports whether the cursor is within threshold rows of the last row.
|
||||||
|
func (t DataTable) NearEnd(threshold int) bool {
|
||||||
|
if !t.ready || len(t.order) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return t.cursorRow >= len(t.order)-threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) SetLoadingMore(on bool) {
|
||||||
|
t.loadingMore = on
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendItems adds new rows, skipping duplicate IDs. Returns how many were added.
|
||||||
|
func (t *DataTable) AppendItems(items []model.Item) int {
|
||||||
|
if !t.ready || len(items) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(t.items))
|
||||||
|
for _, it := range t.items {
|
||||||
|
if it.ID != "" {
|
||||||
|
seen[it.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
added := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID != "" {
|
||||||
|
if _, ok := seen[it.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[it.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
idx := len(t.items)
|
||||||
|
t.items = append(t.items, it)
|
||||||
|
t.order = append(t.order, idx)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
if added > 0 {
|
||||||
|
if t.sortCol >= 0 {
|
||||||
|
t.applySort()
|
||||||
|
}
|
||||||
|
t.clampCursor()
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) MoveRow(delta int) {
|
||||||
|
if !t.ready || len(t.order) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev := t.cursorRow
|
||||||
|
t.cursorRow = clampInt(t.cursorRow+delta, 0, len(t.order)-1)
|
||||||
|
t.syncRowScroll(prev)
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) MoveCol(delta int) {
|
||||||
|
if !t.ready || len(t.columns) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.cursorCol = clampInt(t.cursorCol+delta, 0, len(t.columns)-1)
|
||||||
|
t.ensureColVisible()
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) ToggleSort() {
|
||||||
|
if !t.ready || len(t.columns) == 0 || len(t.order) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.sortCol == t.cursorCol {
|
||||||
|
t.sortAsc = !t.sortAsc
|
||||||
|
} else {
|
||||||
|
t.sortCol = t.cursorCol
|
||||||
|
t.sortAsc = true
|
||||||
|
}
|
||||||
|
t.applySort()
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) applySort() {
|
||||||
|
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := t.columns[t.sortCol].Key
|
||||||
|
sort.SliceStable(t.order, func(i, j int) bool {
|
||||||
|
a := model.CellText(t.items[t.order[i]], key)
|
||||||
|
b := model.CellText(t.items[t.order[j]], key)
|
||||||
|
if t.sortAsc {
|
||||||
|
return strings.ToLower(a) < strings.ToLower(b)
|
||||||
|
}
|
||||||
|
return strings.ToLower(a) > strings.ToLower(b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) PageScroll(delta int) {
|
||||||
|
if delta > 0 {
|
||||||
|
t.viewport.ViewDown()
|
||||||
|
} else {
|
||||||
|
t.viewport.ViewUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
if !t.focused || !t.ready {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "pgdown", "pgdn", "ctrl+d":
|
||||||
|
t.viewport.ViewDown()
|
||||||
|
case "pgup", "b", "ctrl+u":
|
||||||
|
t.viewport.ViewUp()
|
||||||
|
}
|
||||||
|
case tea.MouseMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
t.viewport, cmd = t.viewport.Update(msg)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t DataTable) LoadingMore() bool { return t.loadingMore }
|
||||||
|
|
||||||
|
func (t DataTable) View() string {
|
||||||
|
if !t.ready {
|
||||||
|
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.width,
|
||||||
|
t.viewport.Height,
|
||||||
|
t.viewport.TotalLineCount(),
|
||||||
|
t.viewport.YOffset,
|
||||||
|
)
|
||||||
|
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) {
|
||||||
|
lay := t.computeLayout()
|
||||||
|
return lay.indices, lay.widths
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) renderHeader() string {
|
||||||
|
indices, widths := t.visibleLayout()
|
||||||
|
cells := make([]string, 0, len(indices))
|
||||||
|
for _, colIdx := range indices {
|
||||||
|
col := t.columns[colIdx]
|
||||||
|
w := widths[colIdx]
|
||||||
|
title := col.Title
|
||||||
|
if t.sortCol == colIdx {
|
||||||
|
if t.sortAsc {
|
||||||
|
title += " ▲"
|
||||||
|
} else {
|
||||||
|
title += " ▼"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := truncateCellText(title, w)
|
||||||
|
style := t.styles.header
|
||||||
|
if t.focused && colIdx == t.cursorCol {
|
||||||
|
style = t.styles.headerSort
|
||||||
|
}
|
||||||
|
cells = append(cells, renderTableCell(style, text, w))
|
||||||
|
}
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) refreshViewport() {
|
||||||
|
if !t.ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows := make([]string, 0, len(t.order))
|
||||||
|
for row := 0; row < len(t.order); row++ {
|
||||||
|
rows = append(rows, t.renderRow(row))
|
||||||
|
}
|
||||||
|
t.viewport.SetContent(strings.Join(rows, "\n"))
|
||||||
|
t.syncRowScroll(t.cursorRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) renderRow(row int) string {
|
||||||
|
item := t.items[t.order[row]]
|
||||||
|
selected := item.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 := truncateCellText(raw, w)
|
||||||
|
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w))
|
||||||
|
}
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
isRow := row == t.cursorRow
|
||||||
|
isCol := col == t.cursorCol
|
||||||
|
isCell := t.focused && isRow && isCol
|
||||||
|
|
||||||
|
var base lipgloss.Style
|
||||||
|
switch {
|
||||||
|
case selected && isCell:
|
||||||
|
base = t.styles.cellSelect
|
||||||
|
case selected && isRow:
|
||||||
|
base = t.styles.rowSelect
|
||||||
|
case selected:
|
||||||
|
base = t.styles.rowSelect
|
||||||
|
case isCell:
|
||||||
|
base = t.styles.cellActive
|
||||||
|
case t.focused && isRow:
|
||||||
|
base = t.styles.rowActive
|
||||||
|
case t.focused && isCol:
|
||||||
|
base = t.styles.colActive
|
||||||
|
default:
|
||||||
|
base = t.styles.cell
|
||||||
|
}
|
||||||
|
return renderTableCell(base, text, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) ensureColVisible() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
first, last := vis[0], vis[len(vis)-1]
|
||||||
|
if t.cursorCol < first {
|
||||||
|
t.colScroll = t.cursorCol
|
||||||
|
}
|
||||||
|
if t.cursorCol > last {
|
||||||
|
t.colScroll = t.cursorCol
|
||||||
|
for t.cursorCol >= 0 {
|
||||||
|
vis = pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
|
||||||
|
if len(vis) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
last = vis[len(vis)-1]
|
||||||
|
if t.cursorCol <= last {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if t.colScroll < len(t.columns)-1 {
|
||||||
|
t.colScroll++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) syncRowScroll(prev int) {
|
||||||
|
if t.cursorRow < t.viewport.YOffset {
|
||||||
|
t.viewport.YOffset = t.cursorRow
|
||||||
|
} else if t.cursorRow >= t.viewport.YOffset+t.viewport.Height {
|
||||||
|
t.viewport.YOffset = t.cursorRow - t.viewport.Height + 1
|
||||||
|
}
|
||||||
|
if t.viewport.YOffset < 0 {
|
||||||
|
t.viewport.YOffset = 0
|
||||||
|
}
|
||||||
|
maxOff := len(t.order) - t.viewport.Height
|
||||||
|
if maxOff < 0 {
|
||||||
|
maxOff = 0
|
||||||
|
}
|
||||||
|
if t.viewport.YOffset > maxOff {
|
||||||
|
t.viewport.YOffset = maxOff
|
||||||
|
}
|
||||||
|
_ = prev
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *DataTable) clampCursor() {
|
||||||
|
if len(t.order) == 0 {
|
||||||
|
t.cursorRow = 0
|
||||||
|
} else {
|
||||||
|
t.cursorRow = clampInt(t.cursorRow, 0, len(t.order)-1)
|
||||||
|
}
|
||||||
|
if len(t.columns) == 0 {
|
||||||
|
t.cursorCol = 0
|
||||||
|
} else {
|
||||||
|
t.cursorCol = clampInt(t.cursorCol, 0, len(t.columns)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableKeyMatches helps avoid importing key in app.go for sort-only bindings.
|
||||||
|
func (t *DataTable) UpdateItems(items []model.Item) {
|
||||||
|
t.items = items
|
||||||
|
t.clampCursor()
|
||||||
|
t.refreshViewport()
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDataTableAppendItemsDedupes(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(40, 10)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, []model.Item{
|
||||||
|
{ID: "1", Title: "A", Kind: model.KindMail},
|
||||||
|
})
|
||||||
|
added := tbl.AppendItems([]model.Item{
|
||||||
|
{ID: "1", Title: "dup", Kind: model.KindMail},
|
||||||
|
{ID: "2", Title: "B", Kind: model.KindMail},
|
||||||
|
})
|
||||||
|
if added != 1 || len(tbl.Items()) != 2 {
|
||||||
|
t.Fatalf("added=%d len=%d", added, len(tbl.Items()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableNearEnd(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(40, 10)
|
||||||
|
items := make([]model.Item, 10)
|
||||||
|
for i := range items {
|
||||||
|
items[i] = model.Item{ID: string(rune('0' + i)), Title: "x", Kind: model.KindMail}
|
||||||
|
}
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, items)
|
||||||
|
tbl.cursorRow = 7
|
||||||
|
if !tbl.NearEnd(3) {
|
||||||
|
t.Fatal("expected near end")
|
||||||
|
}
|
||||||
|
tbl.cursorRow = 0
|
||||||
|
if tbl.NearEnd(3) {
|
||||||
|
t.Fatal("expected not near end")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
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 {
|
||||||
|
return []model.Item{
|
||||||
|
{ID: "2", Title: "Bravo", Subtitle: "b", Kind: model.KindTask, Raw: map[string]any{"status": "Open"}},
|
||||||
|
{ID: "1", Title: "Alpha", Subtitle: "a", Kind: model.KindTask, Raw: map[string]any{"status": "Done"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableSortByTitle(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(80, 12)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||||
|
tbl.cursorCol = indexOfColumn(tbl, "title")
|
||||||
|
tbl.ToggleSort()
|
||||||
|
if model.CellText(tbl.items[tbl.order[0]], "title") != "Alpha" {
|
||||||
|
t.Fatalf("expected Alpha first after sort, order=%v", tbl.order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableMoveColScrolls(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(24, 10)
|
||||||
|
items := make([]model.Item, 1)
|
||||||
|
items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{
|
||||||
|
"alpha": "a", "beta": "b", "gamma": "c", "delta": "d",
|
||||||
|
}}
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectContacts}, items)
|
||||||
|
last := len(tbl.columns) - 1
|
||||||
|
tbl.cursorCol = last
|
||||||
|
tbl.ensureColVisible()
|
||||||
|
if tbl.colScroll == 0 && last > 2 {
|
||||||
|
t.Fatalf("expected horizontal scroll for wide table, colScroll=%d", tbl.colScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableViewHighlightsActiveCell(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(80, 12)
|
||||||
|
tbl.SetFocused(true)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||||
|
tbl.cursorRow = 1
|
||||||
|
tbl.cursorCol = 2
|
||||||
|
view := tbl.View()
|
||||||
|
if !strings.Contains(view, "Alpha") {
|
||||||
|
t.Fatal("view missing row data")
|
||||||
|
}
|
||||||
|
if !strings.Contains(view, "Title") {
|
||||||
|
t.Fatal("view missing header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableFillsPaneWidth(t *testing.T) {
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(80, 12)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||||
|
indices, widths := tbl.visibleLayout()
|
||||||
|
sum := 0
|
||||||
|
for _, i := range indices {
|
||||||
|
sum += widths[i]
|
||||||
|
}
|
||||||
|
if sum != 80 {
|
||||||
|
t.Fatalf("visible columns width sum=%d want 80", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
|
||||||
|
cols := []model.TableColumn{
|
||||||
|
{Key: "_sel", Width: 3},
|
||||||
|
{Key: "id", Width: 10},
|
||||||
|
{Key: "title", Width: 20},
|
||||||
|
}
|
||||||
|
indices := []int{0, 1, 2}
|
||||||
|
w := distributeColumnWidths(33, 80, indices, cols)
|
||||||
|
sum := w[0] + w[1] + w[2]
|
||||||
|
if sum != 80 {
|
||||||
|
t.Fatalf("sum=%d want 80", sum)
|
||||||
|
}
|
||||||
|
if w[2] <= 20 {
|
||||||
|
t.Fatalf("title should expand, got %d", w[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "…") {
|
||||||
|
t.Fatalf("expected ellipsis suffix, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
|
||||||
|
long := strings.Repeat("A", 60)
|
||||||
|
items := []model.Item{
|
||||||
|
{ID: "1", Title: "Short"},
|
||||||
|
{ID: "2", Title: long},
|
||||||
|
}
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(50, 10)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
|
||||||
|
tbl.cursorRow = 0
|
||||||
|
|
||||||
|
if strings.Contains(tbl.renderRow(1), long) {
|
||||||
|
t.Fatal("non-cursor row should truncate long title")
|
||||||
|
}
|
||||||
|
|
||||||
|
tbl.cursorRow = 1
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
|
||||||
|
long := strings.Repeat("B", 60)
|
||||||
|
items := []model.Item{
|
||||||
|
{ID: "1", Title: "Short"},
|
||||||
|
{ID: "2", Title: long, Selected: true},
|
||||||
|
}
|
||||||
|
tbl := newDataTable()
|
||||||
|
tbl.SetSize(50, 10)
|
||||||
|
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
|
||||||
|
tbl.cursorRow = 0
|
||||||
|
|
||||||
|
rowSelected := tbl.renderRow(1)
|
||||||
|
if !strings.Contains(rowSelected, strings.Repeat("B", 10)) {
|
||||||
|
t.Fatal("space-selected row should show title text within column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOfColumn(tbl DataTable, key string) int {
|
||||||
|
for i, c := range tbl.columns {
|
||||||
|
if c.Key == key {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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{
|
want := []string{
|
||||||
"calendar", "projects", "tasks", "users", "whoami",
|
"calendar", "projects", "tasks", "users", "whoami",
|
||||||
"contacts", "persons", "companies",
|
"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()))
|
got := make(map[string]bool, len(rootCmd.Commands()))
|
||||||
for _, c := range rootCmd.Commands() {
|
for _, c := range rootCmd.Commands() {
|
||||||
@@ -38,7 +38,7 @@ func TestRootHelpListsSubjects(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
help := out.String()
|
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) {
|
if !strings.Contains(help, snippet) {
|
||||||
t.Fatalf("help missing %q", snippet)
|
t.Fatalf("help missing %q", snippet)
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,13 @@ func TestProjectsAlias(t *testing.T) {
|
|||||||
if cmd.Name() != "projects" {
|
if cmd.Name() != "projects" {
|
||||||
t.Fatalf("prj alias resolved to %q", cmd.Name())
|
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) {
|
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ var outputFormat = "table"
|
|||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "oo",
|
Use: "oo",
|
||||||
Short: "OnlyOffice Workspace CLI — subject-based command tree",
|
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,
|
Version: version,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
|
|||||||
+323
-2
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -39,12 +40,20 @@ func init() {
|
|||||||
contactsCmd.AddCommand(contactsListCmd(nil))
|
contactsCmd.AddCommand(contactsListCmd(nil))
|
||||||
contactsCmd.AddCommand(contactsGetCmd())
|
contactsCmd.AddCommand(contactsGetCmd())
|
||||||
contactsCmd.AddCommand(contactsDeleteCmd())
|
contactsCmd.AddCommand(contactsDeleteCmd())
|
||||||
|
contactsCmd.AddCommand(contactsMergeCmd())
|
||||||
contactsCmd.AddCommand(contactsInfoAddCmd())
|
contactsCmd.AddCommand(contactsInfoAddCmd())
|
||||||
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
contactsCmd.AddCommand(contactsDedupeInfoCmd())
|
||||||
|
contactsCmd.AddCommand(contactsTagsCmd())
|
||||||
|
contactsCmd.AddCommand(contactsTagCreateCmd())
|
||||||
|
contactsCmd.AddCommand(contactsTagAddCmd())
|
||||||
|
contactsCmd.AddCommand(contactsTagRemoveCmd())
|
||||||
|
contactsCmd.AddCommand(contactsByTagCmd())
|
||||||
|
|
||||||
only := true
|
only := true
|
||||||
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
|
||||||
personsCmd.AddCommand(personsCreateCmd())
|
personsCmd.AddCommand(personsCreateCmd())
|
||||||
|
personsCmd.AddCommand(personsUpdateCmd())
|
||||||
|
personsCmd.AddCommand(personsFixNamesCmd())
|
||||||
personsCmd.AddCommand(contactsDeleteCmd())
|
personsCmd.AddCommand(contactsDeleteCmd())
|
||||||
personsCmd.AddCommand(personsDedupeCmd())
|
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 {
|
func contactsInfoAddCmd() *cobra.Command {
|
||||||
var infoType, value, category string
|
var infoType, value, category string
|
||||||
var isPrimary bool
|
var isPrimary bool
|
||||||
@@ -177,7 +206,7 @@ func contactsInfoAddCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func personsCreateCmd() *cobra.Command {
|
func personsCreateCmd() *cobra.Command {
|
||||||
var first, last, email, linkedin string
|
var first, last, email, linkedin, phone string
|
||||||
var companyID int
|
var companyID int
|
||||||
var jobTitle, about string
|
var jobTitle, about string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -200,6 +229,9 @@ func personsCreateCmd() *cobra.Command {
|
|||||||
if email != "" {
|
if email != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
|
||||||
}
|
}
|
||||||
|
if phone != "" {
|
||||||
|
_, _ = c.AddContactInfo(cmd.Context(), pid, "Phone", phone, "Work", true)
|
||||||
|
}
|
||||||
if linkedin != "" {
|
if linkedin != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
|
_, _ = 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(&jobTitle, "job-title", "", "")
|
||||||
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
cmd.Flags().StringVar(&about, "about", "", "about / bio")
|
||||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
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)")
|
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
|
||||||
return cmd
|
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 {
|
func companiesCreateCmd() *cobra.Command {
|
||||||
var name, email, website string
|
var name, email, website, phone, about, street, city, state, zip, country string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create",
|
Use: "create",
|
||||||
Aliases: []string{"add"},
|
Aliases: []string{"add"},
|
||||||
@@ -236,12 +425,34 @@ func companiesCreateCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
|
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 != "" {
|
if email != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
|
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
|
||||||
}
|
}
|
||||||
if website != "" {
|
if website != "" {
|
||||||
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
|
_, _ = 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)
|
printObject(out)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -249,6 +460,13 @@ func companiesCreateCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&name, "name", "", "company name")
|
cmd.Flags().StringVar(&name, "name", "", "company name")
|
||||||
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
|
||||||
cmd.Flags().StringVar(&website, "website", "", "website url (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
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -17,6 +18,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(crmTasksCmd)
|
rootCmd.AddCommand(crmTasksCmd)
|
||||||
crmTasksCmd.AddCommand(crmTasksListCmd())
|
crmTasksCmd.AddCommand(crmTasksListCmd())
|
||||||
crmTasksCmd.AddCommand(crmTasksCreateCmd())
|
crmTasksCmd.AddCommand(crmTasksCreateCmd())
|
||||||
|
crmTasksCmd.AddCommand(crmTasksReassignSelfCmd())
|
||||||
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
|
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
|
||||||
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
|
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,7 @@ func crmTasksListCmd() *cobra.Command {
|
|||||||
if outputFormat == "table" {
|
if outputFormat == "table" {
|
||||||
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create TITLE",
|
Use: "create TITLE",
|
||||||
Aliases: []string{"add"},
|
Aliases: []string{"add"},
|
||||||
Short: "Create a CRM task",
|
Short: "Create a CRM task (assigns self + deadline default now+14d)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newOO(cmd)
|
c, err := newOO(cmd)
|
||||||
@@ -68,7 +70,7 @@ func crmTasksCreateCmd() *cobra.Command {
|
|||||||
return nil
|
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(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
|
||||||
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
|
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
|
||||||
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
|
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
|
||||||
@@ -77,6 +79,104 @@ func crmTasksCreateCmd() *cobra.Command {
|
|||||||
return cmd
|
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 {
|
func crmTasksDeleteCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "delete TASK_ID [TASK_ID...]",
|
Use: "delete TASK_ID [TASK_ID...]",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user