Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a4b9f846 | ||
|
|
aad6315731 | ||
|
|
329c0b9561 | ||
|
|
74e72d0e06 | ||
|
|
534661dec7 | ||
|
|
55356558a1 | ||
|
|
aa069bea56 | ||
|
|
a8fa202496 | ||
|
|
87ff317166 | ||
|
|
dc62570a5c | ||
|
|
a9a77e110d | ||
|
|
82da45dc4d | ||
|
|
8848ee00f1 | ||
|
|
ff46aad615 | ||
|
|
5e73f6c335 | ||
|
|
7c089a6439 | ||
|
|
4515a88fe8 | ||
|
|
58b905cc49 | ||
|
|
520d6d05c8 | ||
|
|
df38b75011 | ||
|
|
ee8e88fb9b | ||
|
|
e323a43317 | ||
|
|
71d421fb41 | ||
|
|
afb93feae5 | ||
|
|
b493472f2e | ||
|
|
1dc99d6997 | ||
|
|
9b21d7e52a | ||
|
|
c4beba5173 | ||
|
|
2a187ae132 | ||
|
|
c9e16c7169 | ||
|
|
194ae62720 | ||
|
|
0d73146ebc | ||
|
|
46661f51f8 | ||
|
|
f22d714be7 | ||
|
|
6f85c206cf | ||
|
|
1c00edbd0b | ||
|
|
9cd157c59f | ||
|
|
932cd2895a | ||
|
|
5588c2d3f7 | ||
|
|
f8783b86a8 | ||
|
|
d9db1c5e56 | ||
|
|
c551634e55 | ||
|
|
a88fd5b40c | ||
|
|
9b149281a8 | ||
|
|
d6b8eb7777 | ||
|
|
8a17a32269 | ||
|
|
43a1a47faa | ||
|
|
620505cb24 | ||
|
|
a6bba30438 | ||
|
|
6549fbf7e2 | ||
|
|
1a8bf0f83c | ||
|
|
046882b8ef | ||
|
|
a3ef83a961 | ||
|
|
54f825f8e8 | ||
|
|
6a4940ba82 | ||
|
|
1da368fd5d | ||
|
|
66a6b57401 | ||
|
|
075c66d4d7 | ||
|
|
c59ad645ea | ||
|
|
c76ab7267d | ||
|
|
3cc288d281 | ||
|
|
ce4778bdf1 | ||
|
|
3fe43ee82c | ||
|
|
6d93ab5b1f | ||
|
|
95d79925ea | ||
|
|
bf3ef025aa | ||
|
|
ba29738482 | ||
|
|
72c7cd5173 | ||
|
|
b3dfff1a4c | ||
|
|
b5a61ad420 | ||
|
|
1aba545e1d | ||
|
|
94951fc6c5 | ||
|
|
d95985e6d0 | ||
|
|
57b383d579 | ||
|
|
a73f6c172d | ||
|
|
e49693ad2c | ||
|
|
b86d71211f | ||
|
|
a2b919478e | ||
|
|
ccabc11153 | ||
|
|
9a6da1a4f7 | ||
|
|
504d13ed08 | ||
|
|
efc0864d42 | ||
|
|
59caf5e560 | ||
|
|
e7803c5269 | ||
|
|
a08e7c49ad | ||
|
|
4310aa7002 | ||
|
|
1a7a962183 | ||
|
|
af8e3b053a | ||
|
|
8ac777c031 | ||
|
|
c576bfe2ee | ||
|
|
47a2c256f3 | ||
|
|
c1881e1e61 | ||
|
|
7f56332285 | ||
|
|
a5807b9c31 | ||
|
|
d650a16a36 | ||
|
|
3da11586f9 | ||
|
|
e9c969a89c | ||
|
|
4d91726179 | ||
|
|
4d8af7a2fe | ||
|
|
34745e349e | ||
|
|
dfea57a57b | ||
|
|
8595c17f25 | ||
|
|
61da2fb88b | ||
|
|
24ca144b22 | ||
|
|
93828ee19d | ||
|
|
35f0cb8d20 | ||
|
|
ca5c85a4e2 | ||
|
|
220c7e8265 | ||
|
|
ff4c0481ba | ||
|
|
68445b0dfd | ||
|
|
8739d45b1a | ||
|
|
f3150436fd | ||
|
|
a30ae20f32 | ||
|
|
5a0b2ab412 | ||
|
|
69844c9122 | ||
|
|
b04d610275 | ||
|
|
6b3f40e1a1 | ||
|
|
ba8148a9e1 | ||
|
|
f1739dc9dc | ||
|
|
5b812ce346 | ||
|
|
309ae44ee4 | ||
|
|
ff2bc539f5 | ||
|
|
d9a7adcd94 | ||
|
|
3713ed6c51 | ||
|
|
ac06d86363 | ||
|
|
7d397ac680 | ||
|
|
e52cb31bb7 | ||
|
|
a8cb4e9780 | ||
|
|
622dcdb7bf | ||
|
|
2b267d36a8 | ||
|
|
50bd47570b | ||
|
|
096a996726 | ||
|
|
625dd0a60a | ||
|
|
e531de280f | ||
|
|
129e3a58cc | ||
|
|
70e605b4ca | ||
|
|
a264cbd61c | ||
|
|
a97a160c44 | ||
|
|
df447f2673 | ||
|
|
db9ef12ff4 | ||
|
|
2c0df0d55f | ||
|
|
239c5ea672 | ||
|
|
6ea2fbadf7 | ||
|
|
50d8974154 | ||
|
|
3fd3f797dc | ||
|
|
0e299d0692 | ||
|
|
9ead554f5c | ||
|
|
77c188c640 | ||
|
|
2a59817f51 | ||
|
|
9c450a0352 | ||
|
|
ab7dfbd859 | ||
|
|
dda2bd3ca5 | ||
|
|
ceea6d909e | ||
|
|
ecf34ba51a | ||
|
|
ebbd5d5373 | ||
|
|
20a09530cd | ||
|
|
666be883dc | ||
|
|
65cd3f5c74 | ||
|
|
1ef0670624 | ||
|
|
10c1cfc6a7 | ||
|
|
8ced804a6f | ||
|
|
256864f613 | ||
|
|
a3a6ad3543 | ||
|
|
0161a92c52 | ||
|
|
0e80a1a058 | ||
|
|
8b27539145 | ||
|
|
a0add3b771 | ||
|
|
b58940fcf2 | ||
|
|
11aa23f1a1 | ||
|
|
42c31d4b48 | ||
|
|
f95830961d | ||
|
|
6b5a82bef8 | ||
|
|
490426c277 | ||
|
|
31c556d34c | ||
|
|
7f35b0e910 | ||
|
|
73b1050a83 | ||
|
|
d81e7de911 | ||
|
|
3201a58df7 | ||
|
|
19a7e9cbf8 | ||
|
|
2e1624920e | ||
|
|
1bf417d70f | ||
|
|
aa79a83c0a | ||
|
|
179f30644d | ||
|
|
b334f9ff86 | ||
|
|
e07cf743f9 | ||
|
|
8b764a36a1 | ||
|
|
aa4e71b9d2 | ||
|
|
1a93579635 | ||
|
|
76067f65d0 | ||
|
|
3b55de5b1d | ||
|
|
715ed23733 | ||
|
|
c7275b5c98 | ||
|
|
b9cea62d46 | ||
|
|
e3c706aefe | ||
|
|
6ea76c64c3 | ||
|
|
8804bd3030 | ||
|
|
c8b30a36ee | ||
|
|
3969d045cc | ||
|
|
09e242d7c3 | ||
|
|
0ed2d9b0f5 | ||
|
|
fab2977cb1 | ||
|
|
eed57a68d4 | ||
|
|
b2aae21390 | ||
|
|
3d4aa10cf9 | ||
|
|
3b47d1400b | ||
|
|
a413d409b5 | ||
|
|
cac0ffa2a0 | ||
|
|
cad68f64c6 | ||
|
|
4046f4c46a | ||
|
|
6d4df4edee | ||
|
|
3931eeaabb | ||
|
|
2959168d76 | ||
|
|
f8a73edef3 | ||
|
|
4acec6bbc9 | ||
|
|
2e77d8e5c7 | ||
|
|
87ce3257a8 | ||
|
|
a59b4fb296 | ||
|
|
b79c097874 | ||
|
|
1b238aca5a | ||
|
|
4eb14c082c | ||
|
|
0973975471 |
@@ -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
|
||||
+58
-1
@@ -1,4 +1,4 @@
|
||||
# OnlyOffice Workspace credentials. Used by cmd/oo-cli (the library itself
|
||||
# OnlyOffice Workspace credentials. Used by cmd/oo (the library itself
|
||||
# does not load .env files — only its CLI does, via godotenv).
|
||||
#
|
||||
# Primary names:
|
||||
@@ -10,7 +10,64 @@ ONLYOFFICE_PASS=
|
||||
# ONLYOFFICE_HOST=
|
||||
# ONLYOFFICE_NAME=
|
||||
# ONLYOFFICE_PASSWORD=
|
||||
#
|
||||
# Optional CLI-only aliases:
|
||||
# OO_URL=
|
||||
# OO_USER=
|
||||
# OO_PASS=
|
||||
|
||||
# Optional defaults for calendar/project helpers:
|
||||
ONLYOFFICE_CALENDAR_ID=1
|
||||
ONLYOFFICE_PROJECT_ID=33
|
||||
|
||||
# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon).
|
||||
|
||||
# deploy/docker-compose.rclone-webdav.yml — rclone FUSE mount of the oo-webdav
|
||||
# sidecar. Reuses ONLYOFFICE_USER and accepts ONLYOFFICE_PASSWORD (alias
|
||||
# ONLYOFFICE_PASS). Set ONLYOFFICE_WEBDAV_URL only if the sidecar is not on
|
||||
# the default docker bridge address. See docs/rclone-webdav.md.
|
||||
# ONLYOFFICE_WEBDAV_URL=http://172.17.0.1:8098/webdav
|
||||
|
||||
# cmd/office TUI — optional Document Server for DOCX→HTML preview:
|
||||
# ONLYOFFICE_DOCS_URL=https://docs.example.com
|
||||
# ONLYOFFICE_DOCS_SECRET=
|
||||
|
||||
# MinIO download fallback for the portal's stale AWS S3 consumer (older
|
||||
# Documents folders). When the portal redirects to amazonaws.com with access
|
||||
# key "minio" (403 InvalidAccessKeyId), files are fetched from the configured
|
||||
# MinIO store instead. Without endpoint + key/secret the fallback is disabled.
|
||||
# MINIO_ENDPOINT=http://minio.example.com:9000
|
||||
# MINIO_BUCKET=office
|
||||
# MINIO_ACCESS_KEY=
|
||||
# MINIO_SECRET_KEY=
|
||||
|
||||
# Catalog scan classification rules (client mail domains, project remotes/names).
|
||||
# Copy catalog/classify.example.yaml and point this at it; no rules means
|
||||
# nothing is classified as work.
|
||||
# OO_CATALOG_CONFIG=~/.config/oo/catalog-classify.yaml
|
||||
|
||||
# oo search — direct Elasticsearch access for name + content search. ES lives
|
||||
# inside the OnlyOffice VM on localhost:9200; expose it with an SSH tunnel
|
||||
# (see docs/elasticsearch.md). ONLYOFFICE_ES_INDEX defaults to files_file.
|
||||
# ONLYOFFICE_ES_URL=http://127.0.0.1:9200
|
||||
# ONLYOFFICE_ES_INDEX=files_file
|
||||
# ONLYOFFICE_TENANT=
|
||||
|
||||
# Read-only SQL file store over the Community Server database (see
|
||||
# docs/community-server-db.md). The live portal runs MySQL; ONLYOFFICE_DSN is
|
||||
# `user:pass@tcp(host:port)/onlyoffice?parseTime=true`, or a `postgres://` URL.
|
||||
# ONLYOFFICE_DSN=
|
||||
# ONLYOFFICE_PG_DRIVER= # postgres | mysql (auto-detected from DSN)
|
||||
# ONLYOFFICE_PG_TENANT=1 # falls back to ONLYOFFICE_TENANT
|
||||
# Alternatively build a PostgreSQL DSN from parts:
|
||||
# ONLYOFFICE_PG_HOST=
|
||||
# ONLYOFFICE_PG_PORT=5432
|
||||
# ONLYOFFICE_PG_USER=
|
||||
# ONLYOFFICE_PG_PASSWORD=
|
||||
# ONLYOFFICE_PG_DBNAME=onlyoffice
|
||||
# ONLYOFFICE_PG_SSLMODE=disable
|
||||
|
||||
# oo index / oo search --backend own — own full-text index for PDF/scans,
|
||||
# filled by `oo index` from internal/docpipe (pdftotext + OCR). Defaults to
|
||||
# oo_docs_text. Uses the same ONLYOFFICE_ES_URL.
|
||||
# ONLYOFFICE_ES_TEXT_INDEX=oo_docs_text
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
github: eSlider
|
||||
ko_fi: eslider
|
||||
liberapay: eslider
|
||||
patreon: eslider
|
||||
custom:
|
||||
- https://polar.sh/eslider
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Git tag to release (e.g. v0.4.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
name: GoReleaser
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Always clone default branch first. workflow_dispatch often races with
|
||||
# tag creation; fetching refs/tags/X before it exists fails checkout.
|
||||
# Retry fetch, then check out the tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout release tag
|
||||
env:
|
||||
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Releasing tag: $TAG"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"; then
|
||||
break
|
||||
fi
|
||||
echo "tag $TAG not visible yet (attempt $i); sleeping…"
|
||||
sleep 10
|
||||
done
|
||||
git rev-parse "refs/tags/${TAG}"
|
||||
git checkout --force "refs/tags/${TAG}"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
cache: true
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
secret-scan:
|
||||
name: Secret scan (gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute scan range (diff of new commits only)
|
||||
id: range
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
BEFORE="${{ github.event.before }}"
|
||||
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||
RANGE="$(git rev-list --max-parents=0 HEAD | tail -1)..$GITHUB_SHA"
|
||||
else
|
||||
RANGE="$BEFORE..$GITHUB_SHA"
|
||||
fi
|
||||
fi
|
||||
echo "RANGE=$RANGE" >> "$GITHUB_ENV"
|
||||
echo "Scanning range: $RANGE"
|
||||
|
||||
# Install the gitleaks binary instead of a docker action: the
|
||||
# docker://zricethezav/gitleaks action hardcodes /github/workspace,
|
||||
# which does not exist on the Gitea (act) runner. $GITHUB_WORKSPACE is
|
||||
# the checkout dir on BOTH runners (GitHub and Gitea act). Mirrors the
|
||||
# fix applied to 2dph (issue #142).
|
||||
- name: Gitleaks (diff-only, fail on leak)
|
||||
env:
|
||||
GITLEAKS_RANGE: ${{ env.RANGE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSLo /tmp/gitleaks.tar.gz \
|
||||
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||
chmod +x /tmp/gitleaks
|
||||
/tmp/gitleaks detect \
|
||||
--source "$GITHUB_WORKSPACE" \
|
||||
--log-opts="$GITLEAKS_RANGE" \
|
||||
--redact \
|
||||
--verbose
|
||||
|
||||
test:
|
||||
name: Test (Go ${{ matrix.go }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go: ["1.25", "stable"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
cache: true
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
go mod verify
|
||||
go mod download
|
||||
|
||||
- name: Check go mod tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
git diff --exit-code go.mod go.sum
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build oo
|
||||
run: go build -trimpath -buildvcs=false -o /tmp/oo ./cmd/oo
|
||||
|
||||
- name: Build office
|
||||
run: go build -trimpath -buildvcs=false -o /tmp/office ./cmd/office
|
||||
|
||||
- name: Run tests
|
||||
run: go test -race -shuffle=on -count=1 ./...
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Build artifacts (compiled top-level binary only; do NOT match cmd/oo-cli source tree)
|
||||
# Build artifacts (top-level compiled binaries; cmd/oo/ source tree is tracked)
|
||||
/oo
|
||||
/oo-cli
|
||||
/bin/
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
version: 2
|
||||
|
||||
project_name: go-onlyoffice
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go mod verify
|
||||
|
||||
builds:
|
||||
- id: oo
|
||||
main: ./cmd/oo
|
||||
binary: oo
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
flags:
|
||||
- -trimpath
|
||||
- -buildvcs=false
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.Commit}}
|
||||
- -X main.date={{.Date}}
|
||||
- id: office
|
||||
main: ./cmd/office
|
||||
binary: office
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
flags:
|
||||
- -trimpath
|
||||
- -buildvcs=false
|
||||
|
||||
archives:
|
||||
- id: oo
|
||||
ids: [oo]
|
||||
name_template: "oo_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
- id: office
|
||||
ids: [office]
|
||||
name_template: "office_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
algorithm: sha256
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ .Tag }}-next"
|
||||
|
||||
changelog:
|
||||
use: github
|
||||
sort: asc
|
||||
groups:
|
||||
- title: Features
|
||||
regexp: '^.*?feat(\(.+\))??!?:.+$'
|
||||
order: 0
|
||||
- title: Bug fixes
|
||||
regexp: '^.*?fix(\(.+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: Performance
|
||||
regexp: '^.*?perf(\(.+\))??!?:.+$'
|
||||
order: 2
|
||||
- title: Others
|
||||
order: 999
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^ci:"
|
||||
- "^chore:"
|
||||
- "^style:"
|
||||
- "^build:"
|
||||
- "Merge pull request"
|
||||
- "Merge branch"
|
||||
|
||||
release:
|
||||
draft: false
|
||||
prerelease: auto
|
||||
mode: replace
|
||||
header: |
|
||||
## Release {{ .Tag }}
|
||||
|
||||
Install:
|
||||
|
||||
```sh
|
||||
go install github.com/eslider/go-onlyoffice/cmd/oo@{{ .Tag }}
|
||||
go install github.com/eslider/go-onlyoffice/cmd/office@{{ .Tag }}
|
||||
```
|
||||
|
||||
Or download prebuilt binaries below.
|
||||
footer: |
|
||||
**Full Changelog**: https://github.com/eSlider/go-onlyoffice/compare/{{ .PreviousTag }}...{{ .Tag }}
|
||||
@@ -1,18 +1,43 @@
|
||||
# AGENTS — go-onlyoffice
|
||||
|
||||
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo-cli` command.
|
||||
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo` command.
|
||||
|
||||
## Topology
|
||||
|
||||
- **Library (root package `onlyoffice`)** — `onlyoffice.go`, `httpx.go`, `calendar.go`, `crm.go`, `tasks_extra.go`, `files.go`. Pure stdlib + `google/go-querystring`; no UI, no dotenv.
|
||||
- **CLI (`cmd/oo-cli` + `internal/cli`)** — Cobra wrapper that loads `.env` via `godotenv` at startup. CLI-only deps (`spf13/cobra`, `joho/godotenv`) must stay out of the library surface.
|
||||
- **Applications sync (`internal/applications`)** — README/CRM bridge, consumes the library.
|
||||
- **Library — flat package `onlyoffice` at repo root.** Split by *domain file*, not by subpackage, so every call site reads `c.XxxYyy()` against a single `*Client`. Files:
|
||||
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
|
||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
||||
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `files_webdav.go`, `files_stem.go`, `retry.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`UpdateFile`, `UploadToFolderReplacing`). **`files_webdav.go`** — Documents module by id (`ListDavFolder`, `MoveDavItems`/`CopyDavItems` with per-operation error surfacing, `ListFileOps`). **`retry.go`** — `DoRetry`: deterministic exponential backoff (no jitter) on 429/502/503/504; every bulk tool routes API calls through it, and the HTTP transport + auth (`retryRaw`, `AuthenticateContext`) retry transient answers centrally. `ratelimit.go` adds a process-wide token bucket (`OO_RATE_LIMIT`/`OO_BURST`) and a shared 429 cooldown gate, installed via `pacedTransport` in `NewClient`; `Retry-After` is parsed into `*TransientError` and honoured. See [`docs/rate-limiting.md`](docs/rate-limiting.md). **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules live with the private `oo-workspace` tooling.
|
||||
- **Unified file client (epic #34) — `filestore_core.go`, `filestore_rest.go`, `filestore_dav.go`, `filestore_pg.go`, `filestore_es.go`, `filestore_es_text.go`, `filestore_text_index.go`, `filestore_facade.go`.** `filestore_core.go` — model (`Entry`, `Kind`) + `FileStore`/`Searcher`; `filestore_rest.go`/`filestore_dav.go` — REST/WebDAV adapters; `filestore_pg.go` — **read-only** SQL store (PostgreSQL/MySQL, `ErrReadOnly` on writes); `filestore_es.go` — OnlyOffice Elasticsearch searcher; `filestore_es_text.go`/`filestore_text_index.go` — own PDF/scan index (`oo_docs_text`, PDF attachments via pdfdetach); `filestore_facade.go` — `FileClient` with read/write/search order and transient fallback. Use `c.Files()` (facade), `c.FileStore("rest"|"dav"|"pg"|"sql")` or `c.SQLFileStore()`; contract and how to add a backend: [`docs/unified-file-client.md`](docs/unified-file-client.md).
|
||||
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
|
||||
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
|
||||
- `main.go` — entry point (docstring lists the command tree).
|
||||
- `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.go`, `crm_tasks.go`, `catalog.go`, `docs.go`, `dav.go`, `search.go`, `index.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|ensure-path|upload|rename-file|rename-folder|download|fileops`); `search.go` runs `oo search QUERY` (name/content, `--backend oo|own`); `index.go` fills the own full-text index (`oo index folder|files`, see [`docs/unified-file-client.md`](docs/unified-file-client.md)).
|
||||
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
|
||||
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
|
||||
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
|
||||
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
|
||||
- **Bulk Documents tools live in the private `oo-workspace` repo** (`ooscan`, `pdfamount`, `kontoblatt`, `kontolink`), not in this public tree. They use the public client and its `DoRetry` pacing.
|
||||
- **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in a private companion repo `eSlider/oo-workspace` (the `oow` CLI), not in this public tree.
|
||||
|
||||
## Rules
|
||||
|
||||
- Library must never call `godotenv.Load()` — the CLI does that.
|
||||
- New endpoints go into the library first; CLI commands are thin wrappers.
|
||||
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
|
||||
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
|
||||
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain. The subject→verb tree in `cmd/oo/main.go` and the README table are documentation — update them with the code.
|
||||
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). All upload paths default to **upsert** by `stem` with the extension matched conversion-aware (legacy `.xls/.doc/.ppt` ↔ OOXML `.xlsx/.docx/.pptx`, since OnlyOffice converts them on upload), so a repeated `.xls` upload updates the saved `.xlsx` instead of appending a duplicate (`--replace`, default true); `--no-replace` fails on conflict; `--allow-duplicate` opts into raw OO append. `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`; includes project root folder).
|
||||
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
|
||||
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
|
||||
- **No host/client specifics in the tree.** Endpoints, IPs/ports, client mail
|
||||
domains, project remotes/names and personal names stay out of source and
|
||||
fixtures — they come from env/config (`MINIO_*`, `OO_CATALOG_CONFIG`, see
|
||||
[`catalog/classify.example.yaml`](catalog/classify.example.yaml)). This repo is
|
||||
mirrored to GitHub as a public showroom, so the tree must stay project-generic.
|
||||
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
||||
|
||||
### Testing policy (2026-04-24)
|
||||
@@ -36,6 +61,7 @@ write `mux.HandleFunc("/api/2.0/...")` to emulate OnlyOffice, we write an
|
||||
|
||||
## Related
|
||||
|
||||
- [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions).
|
||||
- [`eSlider/inventar-sync`](https://git.produktor.io/eSlider/inventar-sync) — OnlyOffice → Gitea issue sync, consumes this library.
|
||||
- [`produktor.io/vidarr`](https://git.produktor.io/produktor.io/vidarr) — legacy consumer being migrated from `pkg/onlyoffice` to this module.
|
||||
- [`docs/README.md`](docs/README.md) — reference index (file client, ES, SQL, rclone).
|
||||
- `eSlider/inventar` — ASR/ADR (see ASR-0008 Go library module conventions).
|
||||
- `eSlider/inventar-sync` — OnlyOffice → Gitea issue sync, consumes this library.
|
||||
- `vidarr` — legacy consumer being migrated from `pkg/onlyoffice` to this module.
|
||||
|
||||
+517
@@ -4,8 +4,525 @@ All notable changes to this project are documented here. The format is based on
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.21.0](https://git.produktor.io/eSlider/go-onlyoffice/compare/v0.20.0...v0.21.0) (2026-09-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **retry:** global token-bucket rate limit (`OO_RATE_LIMIT`/`OO_BURST`), typed
|
||||
`TransientError` with `Retry-After`, exponential backoff
|
||||
(`OO_RETRY_ATTEMPTS`/`_BASE`/`_MAX`) and a process-wide 429 cooldown gate.
|
||||
All HTTP paths are paced via `pacedTransport` (#70).
|
||||
* **oo:** native document conversion (`oo docs pdf`), deep links, sheet-aware
|
||||
export (`oo docs csv`/`json`), `oo docs presigned`, plus project team and
|
||||
user lifecycle, milestone-delete and file update/replace (#73, #75).
|
||||
* **catalog:** upstream generic workspace tooling (board-sync, CRM audit,
|
||||
catalog names) (#76).
|
||||
|
||||
|
||||
### Chores
|
||||
|
||||
* move business tooling (`ooscan`, `pdfamount`, `kontoblatt`, `kontolink`) out
|
||||
of the public library into the private `oo-workspace` repo; tidy filestore
|
||||
naming (#77).
|
||||
* keep host/client specifics out of the tree (#74).
|
||||
|
||||
## [0.18.0](https://github.com/eSlider/go-onlyoffice/compare/v0.17.0...v0.18.0) (2026-09-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** add UpdateContactName and CloseCRMTask helpers ([4d8af7a](https://github.com/eSlider/go-onlyoffice/commit/4d8af7a2fef1fcfd96ad913cefc6497e3389963b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **crm:** deterministic sortBy=id in contact paged lists ([4d91726](https://github.com/eSlider/go-onlyoffice/commit/4d917261792203732ac739644c9c82124b2166eb))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **funding:** eSlider support links (reverse-import GitHub e9c969a) ([d650a16](https://github.com/eSlider/go-onlyoffice/commit/d650a16a36037949505eb017c92bd3283e6e2f0f))
|
||||
|
||||
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **mailsync:** FetchMailFolder — integration-layer walk for ETL consumers ([35f0cb8](https://github.com/eSlider/go-onlyoffice/commit/35f0cb8d20076244141065c07e203e633bc3612a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **files:** upsert uploads by default and dedupe project root ([24ca144](https://github.com/eSlider/go-onlyoffice/commit/24ca144b22abc5d056a5fd1ed9a1887f26a79d15))
|
||||
|
||||
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
|
||||
|
||||
### Features
|
||||
|
||||
* **docs:** `put-xlsx` — multi-sheet бюджеты с named inputs, SUM/AVG/MIN
|
||||
формулами, cross-sheet ссылками и cell comments (`internal/xlspipe`,
|
||||
excelize) ([68445b0](https://github.com/eSlider/go-onlyoffice/commit/68445b0))
|
||||
|
||||
### Added
|
||||
|
||||
* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
|
||||
* **crm:** `ForceRegenerateInvoicePDF`, `SetInvoiceStatus`, `PurgeStaleInvoicePDFs`, contact/opportunity file list helpers
|
||||
* **oo:** `invoices pdf`, `pdf-cleanup`, `status`; create `--consignee`; draft-invoice force-regens PDF
|
||||
|
||||
### Fixed
|
||||
|
||||
* Document that invoice→deal must be set at create (`update --opportunity` often HTTP 400)
|
||||
|
||||
## [0.15.0](https://github.com/eSlider/go-onlyoffice/compare/v0.14.0...v0.15.0) (2026-08-29)
|
||||
|
||||
### Features
|
||||
|
||||
* **files:** `ListFolder`, `CreateFolder`, `MoveFiles`, `UploadToFolder` — Documents folder helpers for OO Documents ingestion ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fba))
|
||||
* **files:** dedupe by stem|ext (`files_stem.go`) + `oo projects files dedupe` ([ac06d86](https://github.com/eSlider/go-onlyoffice/commit/ac06d86))
|
||||
* **docs:** `internal/docpipe` — md↔docx convert, OCR→PDF, hOCR→Markdown via go-hocr, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fba), [a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd))
|
||||
* **docs:** optimize PDF via Ghostscript pdfwrite ([6b3f40e](https://github.com/eSlider/go-onlyoffice/commit/6b3f40e))
|
||||
* **security:** gitleaks secret-scan in CI + pre-push/pre-commit hooks ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554))
|
||||
|
||||
### Fixes
|
||||
|
||||
* **files:** `DeleteFiles` via per-file DELETE API; `DeleteDavItems` with `Immediately` true ([622dcdb](https://github.com/eSlider/go-onlyoffice/commit/622dcdb), [d9a7adc](https://github.com/eSlider/go-onlyoffice/commit/d9a7adc))
|
||||
* **docs:** put-txt preserves line breaks in DOCX; fixed-width extracts in code block; put-md upsert by stem ([309ae44](https://github.com/eSlider/go-onlyoffice/commit/309ae44), [f1739dc](https://github.com/eSlider/go-onlyoffice/commit/f1739dc), [50bd475](https://github.com/eSlider/go-onlyoffice/commit/50bd475))
|
||||
* **ci:** point gitleaks at `/github/workspace` in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d))
|
||||
|
||||
## [0.14.0](https://github.com/eSlider/go-onlyoffice/compare/v0.13.0...v0.14.0) (2026-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
|
||||
* **docs:** hOCR → Markdown via go-hocr ([e531de2](https://github.com/eSlider/go-onlyoffice/commit/e531de280f2c521a7411f75212803649b177db58))
|
||||
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
|
||||
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
|
||||
* **docs:** oo docs hocr — tesseract hOCR → go-hocr Markdown/YAML ([a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd61c12be9098c8d16e7c1c1253eb460816))
|
||||
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
|
||||
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
|
||||
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
|
||||
* **docs:** put-md upsert — no duplicate folder files ([2b267d3](https://github.com/eSlider/go-onlyoffice/commit/2b267d36a8086b14a94706468f7e92e9179e62d4))
|
||||
* **docs:** put-md upsert by stem to avoid duplicate folder files ([50bd475](https://github.com/eSlider/go-onlyoffice/commit/50bd47570b6430cebde43641a7b1d2f8d54d32f2))
|
||||
* **files:** DeleteFiles actually removes files on produktor OO ([a8cb4e9](https://github.com/eSlider/go-onlyoffice/commit/a8cb4e97805226e8af694ec6a64ee3ac6a5e9fdd))
|
||||
* **files:** DeleteFiles via per-file DELETE API ([622dcdb](https://github.com/eSlider/go-onlyoffice/commit/622dcdb7bffd49acbea5ef07f5d5d009f44b1358))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **readme:** document oo docs hocr ([70e605b](https://github.com/eSlider/go-onlyoffice/commit/70e605b4ca8ee005e4351b2fc2d2eec081604998))
|
||||
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
|
||||
|
||||
## [0.13.0](https://github.com/eSlider/go-onlyoffice/compare/v0.12.0...v0.13.0) (2026-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
|
||||
* **docs:** hOCR → Markdown via go-hocr ([e531de2](https://github.com/eSlider/go-onlyoffice/commit/e531de280f2c521a7411f75212803649b177db58))
|
||||
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
|
||||
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
|
||||
* **docs:** oo docs hocr — tesseract hOCR → go-hocr Markdown/YAML ([a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd61c12be9098c8d16e7c1c1253eb460816))
|
||||
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
|
||||
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
|
||||
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
|
||||
* **files:** rewrite viewUrl host to API base on download ([ecf34ba](https://github.com/eSlider/go-onlyoffice/commit/ecf34ba51aae77f1626a60795f7329f25d9344e5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **readme:** document oo docs hocr ([70e605b](https://github.com/eSlider/go-onlyoffice/commit/70e605b4ca8ee005e4351b2fc2d2eec081604998))
|
||||
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
|
||||
|
||||
## [0.12.0](https://github.com/eSlider/go-onlyoffice/compare/v0.11.0...v0.12.0) (2026-08-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
|
||||
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
|
||||
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
|
||||
* **files:** add WebDAV-oriented Files operations ([ebbd5d5](https://github.com/eSlider/go-onlyoffice/commit/ebbd5d5373abfeca5f160312f201cbd683f55e42))
|
||||
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
|
||||
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
|
||||
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
|
||||
* **files:** rewrite viewUrl host to API base on download ([ecf34ba](https://github.com/eSlider/go-onlyoffice/commit/ecf34ba51aae77f1626a60795f7329f25d9344e5))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
|
||||
|
||||
## [0.11.0](https://github.com/eSlider/go-onlyoffice/compare/v0.10.0...v0.11.0) (2026-08-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **catalog:** port oo catalog (scan/merge/match/apply) from legacy branch ([256864f](https://github.com/eSlider/go-onlyoffice/commit/256864f613f31570cf995dddc51255a9a91a38f8))
|
||||
|
||||
## [0.10.0](https://github.com/eSlider/go-onlyoffice/compare/v0.9.0...v0.10.0) (2026-08-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **crm:** add invoices CLI and opportunity update ([19a7e9c](https://github.com/eSlider/go-onlyoffice/commit/19a7e9cbf8f7bd335c4e4cfd4d3b8a84a350cc41))
|
||||
* **crm:** allow updating invoice terms via oo ([31c556d](https://github.com/eSlider/go-onlyoffice/commit/31c556d34cedd65d6957441f448520af9a7e380b))
|
||||
* **crm:** invoice update notes and PO fields ([d81e7de](https://github.com/eSlider/go-onlyoffice/commit/d81e7de9114a29439142efba67f205653f562730))
|
||||
* **crm:** invoices CLI and opportunity update ([f958309](https://github.com/eSlider/go-onlyoffice/commit/f95830961d3aecba65f3cb074d33c9c98f6c6cf2))
|
||||
* **mail:** draft, attach, and draft-invoice CLI ([73b1050](https://github.com/eSlider/go-onlyoffice/commit/73b1050a83e9e49dcf99d8a966a84746376d7dd9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **crm:** JSON person update and oo persons update CLI ([42c31d4](https://github.com/eSlider/go-onlyoffice/commit/42c31d4b485bfab6903aeed0dd6fb2c43f61b4f2))
|
||||
* **crm:** link invoices to opportunities ([3201a58](https://github.com/eSlider/go-onlyoffice/commit/3201a58df75bceb7d41034970cf384a9169163ed))
|
||||
* **mail:** German spacing for invoice draft template ([7f35b0e](https://github.com/eSlider/go-onlyoffice/commit/7f35b0e91075d871f87de95b6c84b2353e02f9cf))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **crm:** capture association graph and invoice/mail quirks ([490426c](https://github.com/eSlider/go-onlyoffice/commit/490426c2779160f1a9b168f6ccae9dd8dccf89e9))
|
||||
* **crm:** clarify mail API send vs signature for chat links ([6b5a82b](https://github.com/eSlider/go-onlyoffice/commit/6b5a82bef831341aee16b0b0ea1fe6db2c24567d))
|
||||
* **crm:** Team vs Contacts and persons update note ([11aa23f](https://github.com/eSlider/go-onlyoffice/commit/11aa23f1a17471bad2bd95fddb769a94afbcf97a))
|
||||
|
||||
## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **catalog:** mbox header scan, company legal-suffix match, apply order ([8b29be2](https://github.com/eSlider/go-onlyoffice/commit/8b29be2d9bb71154d473f780ad247c5c440c6021))
|
||||
* **catalog:** scan Thunderbird address books and Gloda contacts ([502b373](https://github.com/eSlider/go-onlyoffice/commit/502b373e3726ded8b9c50e061ff30aea83205a34))
|
||||
* **oo:** catalog scan/match/apply for CRM clients and contacts ([547f063](https://github.com/eSlider/go-onlyoffice/commit/547f063f7fe922fe784358093a89730f9aaf0683))
|
||||
* **oo:** catalog scan/match/apply for CRM clients and contacts ([c32e4dd](https://github.com/eSlider/go-onlyoffice/commit/c32e4dd6116f0b8df67e93c5b589a417dfee59aa))
|
||||
* **projects:** CRM contacts + git author linking ([457c589](https://github.com/eSlider/go-onlyoffice/commit/457c589abdc2c9fe8edd42b4b7445581976cc5a2))
|
||||
* **projects:** link CRM companies and git authors to projects ([5a8735e](https://github.com/eSlider/go-onlyoffice/commit/5a8735e2898cf53e330e183250cf3c5d3c84da55))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalog:** oo_projects field + safer match ([8d5ca67](https://github.com/eSlider/go-onlyoffice/commit/8d5ca67d64da83c70d8ae293f4c7c23f9f9f59d3))
|
||||
* **catalog:** preserve oo_id on match; add oo_projects field ([d873cb7](https://github.com/eSlider/go-onlyoffice/commit/d873cb748adf4458369d8a2167abd8da987737c5))
|
||||
* **crm:** clean person names; canonical project titles ([b9591f5](https://github.com/eSlider/go-onlyoffice/commit/b9591f5fc3efc13b78dbe932ff32c2f2905e18fc))
|
||||
* **crm:** person names + CC | Company | Title projects ([6305135](https://github.com/eSlider/go-onlyoffice/commit/6305135bd799efe42dbb900a694e82aeae9f9e58))
|
||||
|
||||
## [0.8.3](https://github.com/eSlider/go-onlyoffice/compare/v0.8.2...v0.8.3) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** dispatch GoReleaser after release-please; allow Tests dispatch ([#13](https://github.com/eSlider/go-onlyoffice/issues/13)) ([bf2fa63](https://github.com/eSlider/go-onlyoffice/commit/bf2fa637aaf0419ba5af40b25b64f9ebe24116e8))
|
||||
|
||||
## [0.8.2](https://github.com/eSlider/go-onlyoffice/compare/v0.8.1...v0.8.2) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** retry tag fetch in Release workflow ([#11](https://github.com/eSlider/go-onlyoffice/issues/11)) ([31b4aa0](https://github.com/eSlider/go-onlyoffice/commit/31b4aa0d90393e6d93513284e453b7da7e92c62d))
|
||||
|
||||
## [0.8.1](https://github.com/eSlider/go-onlyoffice/compare/v0.8.0...v0.8.1) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oo:** assign owner and deadline on task create ([#9](https://github.com/eSlider/go-onlyoffice/issues/9)) ([f608207](https://github.com/eSlider/go-onlyoffice/commit/f60820734924284d6c40b46c79380a24573432f3))
|
||||
|
||||
## [0.8.0](https://github.com/eSlider/go-onlyoffice/compare/v0.7.0...v0.8.0) (2026-07-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
|
||||
* **search:** add produktor SearXNG JSON client ([79b1a6b](https://github.com/eSlider/go-onlyoffice/commit/79b1a6b2c397c2a8f8aeef7e2d8e6d81e0a93db2))
|
||||
* **search:** add produktor SearXNG JSON client ([bf144f2](https://github.com/eSlider/go-onlyoffice/commit/bf144f2bb37c44aa37ff33c6b03f59d7b5023dc7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
|
||||
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
|
||||
|
||||
## [0.7.0](https://github.com/eSlider/go-onlyoffice/compare/v0.6.0...v0.7.0) (2026-07-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **office:** mail preview, infinite scroll, pane resize, and scrollbars ([72844df](https://github.com/eSlider/go-onlyoffice/commit/72844dfe243e04c61ea9bc4db66c55abc08d3ae8))
|
||||
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
|
||||
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
|
||||
|
||||
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **office:** scrollable panes, nav tree drill-down, and item actions ([fe2ee48](https://github.com/eSlider/go-onlyoffice/commit/fe2ee481b9c0f959b798fd40679084a8c4bc4ba2))
|
||||
|
||||
## [0.4.0](https://github.com/eSlider/go-onlyoffice/compare/v0.3.2...v0.4.0) (2026-06-24)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **cli:** subject-based command tree (tea-style) + global --output flag
|
||||
* rename oo-cli → oo, split library by domain, relocate applications
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** subject-based command tree (tea-style) + global --output flag ([1e6d22f](https://github.com/eSlider/go-onlyoffice/commit/1e6d22f38dd7a70d4cf1b69ef0b8749b01fded1a))
|
||||
* **crm:** dedupe duplicates, fix deal titles, and add cleanup CLI ([c77519f](https://github.com/eSlider/go-onlyoffice/commit/c77519fac5ae9dffaad3f1f0ae722d969e62dde9))
|
||||
* **crm:** merge company slogan variants in dedupe grouping ([3eb649c](https://github.com/eSlider/go-onlyoffice/commit/3eb649c58bf489b221f90734e04f33063f736914))
|
||||
* **files:** project/task Documents API + oo projects|tasks files ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
|
||||
* **mails:** add Workspace mail CLI with pagination and parsed from fields ([1cb228e](https://github.com/eSlider/go-onlyoffice/commit/1cb228e5d83ab453660bbace5f936a77f9381fc2))
|
||||
* **office:** add Workspace TUI with shared bootstrap and test suite ([7358ff5](https://github.com/eSlider/go-onlyoffice/commit/7358ff5c326cdc873cb68b3f96d31155ecba1ee3))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* rename oo-cli → oo, split library by domain, relocate applications ([cff8145](https://github.com/eSlider/go-onlyoffice/commit/cff8145f8c35656a77b7a36338428bf9be0eda0f))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* CHANGELOG 0.6.0, README, AGENTS.md, cmd/oo/main.go tree. ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.0] — 2026-06-24
|
||||
|
||||
### Added — `office` TUI
|
||||
|
||||
- **Pane layout** — default **10% / 60% / 30%** split (nav / list / detail); drag vertical borders to resize; **Alt+1/2/3** still toggles panes.
|
||||
- **Filter mode** (`f`) — live filter on nav + list; right pane is the search query; **Esc** clears.
|
||||
- **Mail preview** — HTML bodies rendered colorized in the terminal (glamour); read-only scrollable document pane.
|
||||
- **Mail infinite scroll** — loads the next page automatically when the cursor nears the end of the list.
|
||||
- **Scrollbars** — vertical scrollbar on nav, list body, and detail preview when content exceeds the viewport.
|
||||
- **Calendar** — unified calendars + events leaf with **Type** column and date range (−7d … +30d).
|
||||
- **Tasks** — humanized status labels and relative deadlines in the list.
|
||||
- **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
|
||||
|
||||
- `UpdateProjectStatus` — `PUT /api/2.0/project/{id}/status` for open/closed lifecycle.
|
||||
|
||||
## [0.5.1] — 2026-06-24
|
||||
|
||||
### Added — `office` TUI
|
||||
|
||||
- **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.
|
||||
|
||||
### Changed — `office` TUI
|
||||
|
||||
- Split detail pane: form/document top (~72%), CRUD action bar bottom.
|
||||
- Middle pane: multi-column table with sort, selection, and full-width columns.
|
||||
- Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
|
||||
- Row selection auto-loads detail; files show document preview, entities show forms.
|
||||
|
||||
## [0.6.0] - 2026-04-24
|
||||
|
||||
### Added — library
|
||||
|
||||
- **Project & task Documents API** in [`files.go`](files.go):
|
||||
- `FileEntry`, `FolderEntry`, `ProjectFilesResponse` types.
|
||||
- `GetProjectFiles`, `GetTaskFiles`, `GetFile`.
|
||||
- `UploadProjectFile` — `POST /api/2.0/files/{folderId}/upload` into the
|
||||
project's `projectFolder` (resolved via `GetProjectByID` or first folder
|
||||
from `GetProjectFiles`).
|
||||
- `AttachFilesToTask` — `POST .../project/task/{id}/files` with form
|
||||
`files=<id>` (OnlyOffice expects **existing** file ids, not multipart).
|
||||
- `UploadTaskFile` — uploads via `UploadProjectFile` using the task's
|
||||
`projectOwner.id`, then attaches.
|
||||
- `DetachTaskFile` — `DELETE .../files?fileid=`.
|
||||
- `RenameFile` — `PUT /api/2.0/files/file/{id}.json` with JSON body.
|
||||
- `DeleteFiles` — `PUT /api/2.0/files/fileops/delete.json` with `fileIds`.
|
||||
- `DownloadFile` — `GetFile` then `GET` on `viewUrl` with `Authorization`.
|
||||
- Helpers: `FileEntryNumericID`, `FileEntryTitle`, `SafeLocalFileName`.
|
||||
- **`putJSON`** on `*Client` in [`http.go`](http.go) for JSON PUT bodies.
|
||||
|
||||
### Added — CLI
|
||||
|
||||
- `oo projects files list|upload|download|rename|delete` — see
|
||||
[`cmd/oo/projects_files.go`](cmd/oo/projects_files.go); `list` supports
|
||||
`--folders`.
|
||||
- `oo tasks files list|upload|detach` — see [`cmd/oo/tasks_files.go`](cmd/oo/tasks_files.go).
|
||||
|
||||
### Added — tests
|
||||
|
||||
- [`files_integration_test.go`](files_integration_test.go) — live roundtrip
|
||||
against OnlyOffice (same credential rules as `client_test.go`).
|
||||
- [`files_test.go`](files_test.go) + `testdata/*.json` — envelope decode unit
|
||||
tests (no network).
|
||||
|
||||
## [0.5.0] - 2026-04-24
|
||||
|
||||
### Changed — CLI **BREAKING**
|
||||
|
||||
- **Subject-based command tree** (`tea`-style). The flat `oo verb-noun`
|
||||
layout is replaced with `oo <subject> <verb>`:
|
||||
|
||||
| Old | New |
|
||||
|---|---|
|
||||
| `oo cal-list` | `oo calendar list` |
|
||||
| `oo cal-events` | `oo calendar events` |
|
||||
| `oo cal-add` / `cal-delete` | `oo calendar add` / `calendar delete` |
|
||||
| `oo task-list` | `oo tasks list` |
|
||||
| `oo task-add` | `oo tasks create` |
|
||||
| `oo task-update` | `oo tasks update` (deletion moved to `oo tasks delete`) |
|
||||
| `oo subtask-add` | `oo tasks subtask add` |
|
||||
| `oo crm-contacts` | `oo contacts list` (plus filtered `oo persons list` / `oo companies list`) |
|
||||
| `oo crm-add-contact --company …` | `oo companies create --name …` |
|
||||
| `oo crm-add-contact --person-first …` | `oo persons create --first …` |
|
||||
| `oo crm-deals` | `oo opportunities list` |
|
||||
| `oo crm-deals --stages` | `oo opportunities stages` |
|
||||
| `oo crm-add-deal` | `oo opportunities create` |
|
||||
| `oo crm-cases` | `oo cases list` |
|
||||
| `oo applications-sync` | `oo applications sync` |
|
||||
|
||||
- **New subjects**: `oo projects {list,get,milestones,create,update,delete}`,
|
||||
`oo users {list,self}` (plus top-level `oo whoami`), `oo crm-tasks
|
||||
{list,create,delete,categories}`, `oo cases {create,delete,member-add}`,
|
||||
`oo contacts {get,info-add}`.
|
||||
|
||||
- **Global `--output/-o` flag**: all list-like commands now support
|
||||
`--output table` (default; tabwriter-aligned, truncated to 80 chars
|
||||
per cell) and `--output json`. Nested `bidCurrency` flattened to its
|
||||
`abbreviation` in the table view.
|
||||
|
||||
- **Module aliases**: `oo calendar|cal`, `oo projects|prj`, `oo tasks|task`,
|
||||
`oo persons|person`, `oo companies|company`, `oo opportunities|deals|deal`,
|
||||
`oo cases|case`, `oo applications|apps`. `delete|rm` on every leaf that
|
||||
removes things.
|
||||
|
||||
### Added
|
||||
|
||||
- `cmd/oo/common.go` — shared `printTable(headers, rows)` and
|
||||
`printObject(v)` helpers that dispatch on the `--output` flag.
|
||||
- `cmd/oo/users.go` — exposes `oo users list`, `oo users self`, `oo whoami`
|
||||
via the library's `GetUsers` + `SelfUserID`.
|
||||
- `cmd/oo/projects.go` — full CRUD for projects backed by `CreateProject`,
|
||||
`UpdateProject`, `DeleteProject`, `GetProjectByID`, `GetProjectMilestones`.
|
||||
- `cmd/oo/crm_tasks.go` — dedicated `oo crm-tasks` subject (distinct from
|
||||
project `oo tasks`).
|
||||
- `cmd/oo/contacts.go` — unified contacts/persons/companies with shared
|
||||
list/filter implementation.
|
||||
|
||||
### Changed — library (minor)
|
||||
|
||||
- `newOO` in `cmd/oo/common.go` now calls `AuthenticateContext(cmd.Context())`
|
||||
so CLI aborts propagate to the auth request.
|
||||
|
||||
## [0.4.0] - 2026-04-24
|
||||
|
||||
### Changed — project structure
|
||||
|
||||
- **Library files reorganised by domain** (mechanical split; zero API surface
|
||||
change). The former monolithic `onlyoffice.go` (687 LOC) is now split into:
|
||||
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
|
||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`,
|
||||
`Permissions`, `requestBodyReader`.
|
||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`,
|
||||
`Auth`, `ensureToken`, `authHeader`, `tokenValid`.
|
||||
- `http.go` — transport helpers and DRY response decoders
|
||||
(`ResponseArray`, `ResponseObject`, `postFormObject`, `putFormObject`,
|
||||
`deleteObject`, `unmarshalResponseObject`).
|
||||
- `projects.go` — `Project`, `Projects`, `Milestone`, `ProjectOwner` +
|
||||
`GetProjects` / `CreateProject` / `UpdateProject` / `DeleteProject` /
|
||||
`GetProjectByID` / `GetProjectMilestones`.
|
||||
- `tasks.go` — `Task`, `ProjectTaskStatus`, `TaskPriority`,
|
||||
`ProjectGetTasksRequest` et al. **plus** the form-encoded helpers
|
||||
formerly in `tasks_extra.go` (`ListTasks`, `AddTask`, `AddSubtask`,
|
||||
`UpdateTaskStatus`, `DeleteTask`, `GetTaskByID`).
|
||||
- `users.go` — `User`, `Contact`, `Group`, `GetUsers`, `SelfUserID`.
|
||||
- `calendar.go`, `crm.go`, `files.go` — unchanged in scope, refactored
|
||||
through the new DRY helpers.
|
||||
- `httpx.go` → **renamed** `http.go`.
|
||||
- `onlyoffice.go` and `tasks_extra.go` — **deleted** (content redistributed).
|
||||
|
||||
### Changed — CLI **BREAKING**
|
||||
|
||||
- **Binary renamed `oo-cli` → `oo`.** Install with
|
||||
`go install github.com/eslider/go-onlyoffice/cmd/oo@latest`.
|
||||
- **Package path `cmd/oo-cli` → `cmd/oo`.** The old path is removed.
|
||||
- **`internal/cli` is gone.** Cobra commands now live directly under
|
||||
`cmd/oo/` as `package main`, split by domain: `calendar.go`, `crm.go`,
|
||||
`tasks.go`, `apps.go`, `common.go`. Rationale: cobra wiring is a CLI-only
|
||||
concern and does not belong inside a `pkg-level internal/`.
|
||||
- **`internal/applications` → `cmd/oo/applications/`.** This is a
|
||||
CV-specific CRM workflow — not a general OnlyOffice feature — and is only
|
||||
consumed by the `oo` CLI. Keeping it under `cmd/oo/` prevents accidental
|
||||
external adoption and makes the coupling explicit.
|
||||
- **`examples/applications/` removed.** It imported an internal package,
|
||||
which was a policy smell. Remaining examples (`basic`, `calendar`, `crm`,
|
||||
`subtasks`) use only the exported library surface.
|
||||
|
||||
### Added
|
||||
|
||||
- `(*Client).ResponseObject` — GET-and-decode-object counterpart to the
|
||||
existing `ResponseArray`.
|
||||
- `(*Client).postFormObject` / `putFormObject` / `deleteObject` — eliminate
|
||||
the ~15 identical "form request → `responseField` → `json.Unmarshal`"
|
||||
blocks previously duplicated across `crm.go` / `tasks_extra.go` /
|
||||
`calendar.go` / `files.go`.
|
||||
|
||||
### Migration
|
||||
|
||||
External library consumers: **no changes required**. The module path
|
||||
(`github.com/eslider/go-onlyoffice`), the `onlyoffice` package name, and
|
||||
every exported symbol are unchanged.
|
||||
|
||||
CLI users: replace `oo-cli` with `oo` in scripts and CI. The command set
|
||||
and flags are identical.
|
||||
|
||||
## [0.3.2] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Example Author
|
||||
Copyright (c) 2026 Andriy Oblivantsev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[](https://pkg.go.dev/github.com/eslider/go-onlyoffice)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev)
|
||||
[](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml)
|
||||
[](https://github.com/eSlider/go-onlyoffice/releases)
|
||||
[](https://github.com/eSlider/go-onlyoffice/stargazers)
|
||||
|
||||
@@ -475,6 +476,11 @@ type Task struct {
|
||||
| `UpdateProject(req)` | Update project details |
|
||||
| `DeleteProject(id)` | Delete a project |
|
||||
| `GetProjectMilestones(project)` | Get milestones with task counts |
|
||||
| `DeleteMilestone(id)` | Remove a milestone |
|
||||
| `ListProjectTeam(ctx, id)` | Portal users on the project team |
|
||||
| `AddProjectTeamUser(ctx, id, userID)` | Add a portal user to the team |
|
||||
| `RemoveProjectTeamUser(ctx, id, userID)` | Remove a portal user from the team |
|
||||
| `SetProjectTeam(ctx, id, participants, notify)` | Replace the team with the given user ids |
|
||||
|
||||
### Tasks
|
||||
|
||||
@@ -501,6 +507,61 @@ type Task struct {
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `GetUsers()` | List all users with profiles |
|
||||
| `GetUser(ctx, id)` | One user profile by id |
|
||||
| `CreateUser(ctx, NewUserRequest)` | Add a portal user |
|
||||
| `UpdateUser(ctx, id, body)` | Update profile fields (JSON PUT) |
|
||||
| `DeleteUser(ctx, id)` | Delete permanently (auto-terminates first — OO refuses active users) |
|
||||
| `BlockUser(ctx, id)` / `UnblockUser(ctx, id)` | Terminate / reactivate (login kept/denied) |
|
||||
| `ChangeUserPassword(ctx, id, pw)` | Set a new password |
|
||||
| `ChangeUserStatus(ctx, id, active)` | Activate / Terminate via `people/status` |
|
||||
| `AuthenticateAs(ctx, login, pw)` | Verify a login without mutating the cached token |
|
||||
|
||||
### Documents Files
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `ListDavFolder(ctx, id)` | List a Documents folder (`@root` for virtual sections) |
|
||||
| `ListDavSections(ctx)` | Virtual sections (Documents, Projects, …) |
|
||||
| `CreateDavFolder(ctx, parentID, title)` | Create a subfolder |
|
||||
| `RenameDavFolder(ctx, id, title)` / `RenameDavFile(ctx, id, title)` | Rename folder / file |
|
||||
| `DownloadFile(ctx, id, dst)` / `DownloadDavFile(ctx, id, w)` | Download file bytes |
|
||||
| `UploadDavFile(ctx, folderID, fileName, src)` | Upload from a reader |
|
||||
| `UploadToFolder(ctx, folderID, localPath)` | Upload a local file into a folder |
|
||||
| `UploadToFolderReplacing(ctx, folderID, localPath)` | Upsert by `stem\|ext`; returns replaced ids |
|
||||
| `UpdateFile(ctx, fileID, localPath)` | New version of an existing file (same id, no copy) |
|
||||
| `MoveDavItems(ctx, folderIDs, fileIDs, dest)` | Move (`resolveType=Skip`); per-operation errors surfaced, not silent nil |
|
||||
| `CopyDavItems(ctx, folderIDs, fileIDs, dest)` | Copy (`conflictResolveType=Skip`); errors surfaced |
|
||||
| `MoveFiles(ctx, destFolderID, fileIDs)` | Move with `resolveType=Skip` + `holdResult`; errors surfaced |
|
||||
| `ListFileOps(ctx)` | Active file operations (move/copy status polling) |
|
||||
| `FolderFiles(ctx, folderID)` | Flat file list of a folder (stem helpers) |
|
||||
| `DeleteFilesByStem(ctx, folderID, stem)` | Remove `stem\|ext` copies |
|
||||
| `DoRetry(ctx, policy, fn)` | Deterministic exponential backoff (`Base·2^(N-1)`, no jitter) on 429/502/503/504; honours `Retry-After` and the process-wide cooldown gate |
|
||||
| `DefaultRetryPolicy()` | From env: 7 attempts, 2s base, 2m cap (`OO_RETRY_ATTEMPTS/_BASE/_MAX`) |
|
||||
| `Transient(err)` | True for retriable OnlyOffice answers (`*TransientError` or HTTP 429/502/503/504 text) |
|
||||
|
||||
### Deep links & conversion
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `FileEditorURL(portalBase, id)` / `(c *Client).FileEditorURL(id)` | DocEditor deep link `/Products/Files/DocEditor.aspx?fileid=` |
|
||||
| `FolderURL(portalBase, id)` / `(c *Client).FolderURL(id)` | Documents folder link |
|
||||
| `PresignedURI(ctx, fileID)` | Short-lived fetchable URL of a portal file (`presigneduri`) |
|
||||
| `SignJWT(secret, payload)` | HS256 JWT, stdlib only |
|
||||
| `ConvertDocument(ctx, docsBase, secret, req)` | OnlyOffice DocumentServer conversion (`/converter`; legacy `/ConvertService.ashx`) |
|
||||
| `DownloadURLTo(ctx, url, w)` | Stream an absolute URL into a writer |
|
||||
| `SyncBoard(ctx, board, apply)` | Upsert project milestones/tasks from a YAML board (dry-run when apply=false) |
|
||||
| `AuditOpportunities(ctx)` | Opportunities with file/task/member counts and a coarse class |
|
||||
| `WorkbookSheetNames(data)` | Worksheet names of an XLS/XLSX/ODS workbook |
|
||||
| `WorkbookSheetCSV(data, sheet, delim)` | One worksheet → CSV (sheet-aware, excelize) |
|
||||
| `WorkbookSheetJSON(data, sheet)` | One worksheet → rows as objects (first row = header) |
|
||||
|
||||
Example — convert a portal file (or a local file) to PDF with the native engine:
|
||||
|
||||
```bash
|
||||
export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret>
|
||||
oo docs pdf 1234 --out out.pdf # OO file id → PDF
|
||||
oo docs pdf ./report.docx --to pdf # local file → PDF (temp upload, auto-cleanup)
|
||||
```
|
||||
|
||||
### Helper Types
|
||||
|
||||
@@ -532,24 +593,476 @@ deals, total, _ := client.ListOpportunities(ctx, 50, 0)
|
||||
company, _ := client.FindCompany(ctx, "ACME")
|
||||
|
||||
// Subtasks (form-encoded)
|
||||
client.AddSubtask(ctx, "4242", "Prepare CV")
|
||||
client.AddSubtask(ctx, "4242", "Prepare notes")
|
||||
```
|
||||
|
||||
### oo-cli (bundled command)
|
||||
### oo (bundled CLI)
|
||||
|
||||
A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo-cli`:
|
||||
A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the
|
||||
library lives under [`cmd/oo`](cmd/oo/). The command tree is **subject-based**,
|
||||
mirroring the [`tea`](https://gitea.com/gitea/tea) CLI:
|
||||
|
||||
```bash
|
||||
go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest
|
||||
oo-cli cal-events
|
||||
oo-cli task-list --all --verbose
|
||||
oo-cli subtask-add 4242 "Prepare CV"
|
||||
oo-cli applications-sync --path ./applications/2026 --apply
|
||||
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
|
||||
|
||||
# Global: every list-style command takes -o table|json (default: table)
|
||||
oo whoami
|
||||
oo users list
|
||||
oo calendar events --start 2026-04-24 --end 2026-05-01
|
||||
oo projects list
|
||||
oo projects get 33
|
||||
oo tasks list --all --verbose
|
||||
oo tasks subtask add 4242 "Prepare notes"
|
||||
oo persons create --first Jane --last Doe --email jane@example.com
|
||||
oo companies create --name "Acme GmbH" --website https://acme.com
|
||||
oo opportunities list
|
||||
oo opportunities stages
|
||||
oo cases list
|
||||
oo crm-tasks categories
|
||||
|
||||
# Deep links & native document conversion
|
||||
oo link 1234 2345 # DocEditor URL for file ids (title + url)
|
||||
oo docs presigned 1234 # short-lived fetchable URL of an OO file
|
||||
oo docs pdf 1234 --out out.pdf # OO file → PDF (via DocumentServer)
|
||||
oo docs pdf ./report.docx --to pdf # local file → PDF on the fly (temp upload+cleanup)
|
||||
oo docs pdf 1234 --stream > out.pdf # pipe: bytes to stdout (alias --pipe)
|
||||
|
||||
# Spreadsheet export (sheet-aware; local reader — the DS csv output is first-sheet-only)
|
||||
oo docs csv 1234 --sheet 2 # XLS/XLSX/ODS worksheet → CSV (--sheet N, 1-based)
|
||||
oo docs csv 1234 --delimiter ';' # ; | | \t via --delimiter
|
||||
oo docs json 1234 --sheet 2 # worksheet → JSON rows (first row = header)
|
||||
oo docs csv ./book.xlsx --sheet 1 --out sheet1.csv
|
||||
# export ONLYOFFICE_DS_SECRET=<DocumentServer CoAuthoring secret>
|
||||
# docs base: $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + /ds-vpath
|
||||
|
||||
# Project team (portal users) CRUD
|
||||
oo projects team list 42
|
||||
oo projects team add 42 <user_id> [<user_id>...]
|
||||
oo projects team remove 42 <user_id>
|
||||
oo projects team set 42 <user_id> [...] # replace team (may lag; verify with list)
|
||||
oo projects milestone-delete 7
|
||||
|
||||
# Project documents: fresh re-upload (single clean version) / new version
|
||||
oo projects files replace-in 1 ./contract.pdf # hard delete same stem|ext in folder + upload
|
||||
oo projects files update 1 ./contract.docx # overwrite content, same file id
|
||||
|
||||
# Users lifecycle
|
||||
oo users list ; oo users get <user_id>
|
||||
oo users create --first Jane --last Doe --email jane.doe@example.com --password '…'
|
||||
oo users check --login jane.doe@example.com # verify login (email works when userName 500s)
|
||||
oo users update <user_id> --title "…" --location "…"
|
||||
oo users block <user_id> ; oo users unblock <user_id>
|
||||
oo users password <user_id> # reads the new password from stdin
|
||||
oo users delete <user_id> [<user_id>...]
|
||||
```
|
||||
|
||||
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library
|
||||
itself never loads dotfiles). See [`cmd/oo-cli/README.md`](cmd/oo-cli/) for
|
||||
the command reference.
|
||||
### office (TUI)
|
||||
|
||||
Terminal UI for browsing OnlyOffice Workspace — module tree (left), selectable
|
||||
lists (center), and markdown preview (right). Uses the same `.env` credentials
|
||||
as `oo`. Lives under [`cmd/office`](cmd/office/).
|
||||
|
||||
```bash
|
||||
go install github.com/eslider/go-onlyoffice/cmd/office@latest
|
||||
office
|
||||
```
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Next pane (menu → list → preview) |
|
||||
| `Shift+Tab` | Previous pane |
|
||||
| `↑↓` / `j` / `k` | Navigate / scroll within focused pane |
|
||||
| `Enter` / `→` | Expand tree branch or open leaf list |
|
||||
| `Space` | Toggle multi-select on list row |
|
||||
| `Enter` / `a` | Action menu (view, delete, download, …) |
|
||||
| `r` | Refresh current list |
|
||||
| `q` | Quit |
|
||||
|
||||
Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves.
|
||||
|
||||
Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)):
|
||||
|
||||
```bash
|
||||
ONLYOFFICE_DOCS_URL=https://docs.example.com
|
||||
ONLYOFFICE_DOCS_SECRET=… # when JWT signing is enabled
|
||||
```
|
||||
|
||||
Spreadsheet files: inline CSV/JSON preview in the right pane; install
|
||||
[`vex`](https://github.com/CodeOne45/vex-tui) on `PATH` for full-screen
|
||||
xlsx/csv viewing (`v` on a file row — coming in next iteration).
|
||||
|
||||
Integration tests for list loaders and preview (live API only — no mocks):
|
||||
|
||||
```bash
|
||||
go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
|
||||
```
|
||||
|
||||
**Project / task documents (`oo`):**
|
||||
|
||||
```bash
|
||||
# Project Documents (files module)
|
||||
oo projects files list 33
|
||||
oo projects files upload 33 ./notes.docx
|
||||
oo projects files download 12345 --to ./copy.docx
|
||||
oo projects files rename 12345 notes-v2.docx
|
||||
oo projects files delete 12345
|
||||
oo projects files dedupe 7 # dry-run duplicate report
|
||||
oo projects files dedupe 7 --apply # remove older stem|ext copies per folder
|
||||
oo projects files dedupe 7 --cross --apply # cross-folder; keep non-_trash
|
||||
|
||||
# Agent document pipeline (md in git ↔ docx in OO; OCR scans)
|
||||
oo docs tools
|
||||
oo docs convert ./note.md # → note.docx
|
||||
oo docs convert ./note.docx # → note.md
|
||||
oo docs ocr ./scan.jpg --md ./scan.md # searchable PDF + markdown
|
||||
oo docs hocr ./scan.jpg --lang spa --md ./scan.hocr.md --yaml ./scan.yml
|
||||
oo docs put-md 7 ./OO-HONDA-7-INDEX.md --folder 490
|
||||
oo docs put-txt 7 ./notes.txt --folder 490
|
||||
oo docs put-xlsx 7 ./table.xlsx --folder 490
|
||||
oo docs as-md 2815 --to ./parte.md # download OO file as MD (OCR if needed)
|
||||
oo docs as-md 307 --hocr --lang spa # OO download via go-hocr structure
|
||||
oo projects files put-md 7 ./note.md # alias
|
||||
oo projects files as-md 2815 # alias
|
||||
oo tasks files list 208
|
||||
oo tasks files upload 208 ./notes.pdf
|
||||
oo tasks files detach 208 12345
|
||||
```
|
||||
|
||||
### Documents module (`oo dav`)
|
||||
|
||||
Direct access to the Documents module by folder/file id — the same calls that
|
||||
back `oo-webdav` and the project/task file commands. `move` sends
|
||||
`resolveType=Skip` + `holdResult=true`: without those params the legacy
|
||||
`fileops/move` endpoint answers 200 without moving anything, and the library
|
||||
surfaces such per-operation errors instead of a silent nil
|
||||
(`MoveDavItems` / `CopyDavItems` / `MoveFiles`).
|
||||
|
||||
```bash
|
||||
oo dav ls 659
|
||||
oo dav ls @root # virtual sections (Documents, Projects, …)
|
||||
oo dav mkdir 659 "2026 inbox"
|
||||
oo dav ensure-path "Banks/Caixa" # resolve-or-create; idempotent, prints the folder id
|
||||
oo dav ensure-path "Banks/Caixa" --under 659 # start under an explicit folder id
|
||||
oo dav upload 659 ./historico.xlsx # multipart upload, upsert by stem (legacy↔OOXML ext aware)
|
||||
oo dav upload 659 ./a.xlsx ./b.pdf --replace=false # fail on name conflict instead of replacing
|
||||
oo dav move 659 22881 22882 # DEST_FOLDER_ID FILE_ID…
|
||||
oo dav move 659 22881 --folders 670 # move folders along with files
|
||||
oo dav copy 659 22881
|
||||
oo dav rename-file 22881 invoice-v2.pdf
|
||||
oo dav rename-folder 671 o2-archive
|
||||
oo dav download 22881 --to ./copy.pdf # default path: ./<server title>
|
||||
oo dav fileops # active move/copy operations (status polling)
|
||||
```
|
||||
|
||||
`ensure-path` defaults to the concrete **My documents** section id (resolved
|
||||
from `@root`, `rootFolderType=5`); `--under FOLDER_ID` overrides it. `upload`
|
||||
reuses `UploadToFolderReplacing` (`--replace`, default) or `UploadToFolder`
|
||||
after an `AssertNoFileConflict` check (`--no-replace`), so no new HTTP paths.
|
||||
|
||||
### Search and index (`oo search`, `oo index`)
|
||||
|
||||
Full-text search over the Documents index. The REST endpoint
|
||||
`/api/2.0/files/@search/{query}` only searches file names in the database, so
|
||||
`oo search` talks to the OnlyOffice **Elasticsearch** directly (index
|
||||
`files_file`). Name search is default; `--content` also matches extracted
|
||||
document text (`document.attachment.content`, Office formats only).
|
||||
See [`docs/elasticsearch.md`](docs/elasticsearch.md) for the tunnel setup.
|
||||
|
||||
```bash
|
||||
oo search "Rechnung" # names only
|
||||
oo search "Mahngebühr" --content # names + document text
|
||||
oo search "Rechnung" --folder 649 --limit 50
|
||||
oo search "Rechnung" --json # shorthand for -o json
|
||||
```
|
||||
|
||||
Requires `ONLYOFFICE_ES_URL` (plus optional `ONLYOFFICE_ES_INDEX`,
|
||||
`ONLYOFFICE_TENANT`).
|
||||
|
||||
#### PDF/scans: own index (`oo index` + `--backend own`)
|
||||
|
||||
The OnlyOffice index covers Office formats only, so PDFs (`S1019`-style invoice
|
||||
numbers) are not searchable by content. `oo index` extracts PDF text with
|
||||
`internal/docpipe` (pdftotext, OCR for scans) — including the text of embedded
|
||||
PDF attachments (`pdfdetach`: `<doc>.md`, `.xml`, covers the original/scan and
|
||||
ZUGFeRD e-invoice XML) — into a separate index (`ONLYOFFICE_ES_TEXT_INDEX`,
|
||||
default `oo_docs_text`); the OnlyOffice server and its index are **not**
|
||||
modified. Then search it with `--backend own`.
|
||||
|
||||
```bash
|
||||
oo index folder 634 --recursive --exts pdf # populate (idempotent upsert)
|
||||
oo index files 3576 3578 # specific files
|
||||
oo index folder 634 --dry-run # plan only
|
||||
oo search "S1021" --content --backend own # finds the PDF
|
||||
oo search "Rechnung" --backend own --folder 634 --json
|
||||
```
|
||||
|
||||
See [`docs/elasticsearch.md`](docs/elasticsearch.md) for the decision and
|
||||
trade-offs.
|
||||
|
||||
### Unified file client
|
||||
|
||||
All file backends (REST, WebDAV, read-only SQL, Elasticsearch) sit behind one
|
||||
facade: `c.Files()` returns a `*FileClient` that also implements `FileStore`,
|
||||
so old call sites keep working. Pick a transport per call with
|
||||
`c.FileStore("rest"|"dav"|"pg"|"sql")` (SQL is read-only), open the SQL store
|
||||
with `c.SQLFileStore()`, or register a backend on the facade
|
||||
(`RegisterStore`/`RegisterSearcher`). Contract, model (`Entry`/`Kind`),
|
||||
fallback rules, env names and how to add a backend:
|
||||
[`docs/unified-file-client.md`](docs/unified-file-client.md).
|
||||
|
||||
### Bulk tools
|
||||
|
||||
Business / one-off bulk tools (`ooscan`, `pdfamount`, `kontoblatt`,
|
||||
`kontolink`) live in the private `oo-workspace` repo, not in this public
|
||||
library. They build on the public client and the same `DoRetry` pacing.
|
||||
|
||||
| Subject | Verbs |
|
||||
|---|---|
|
||||
| `calendar` | `list`, `events`, `add`, `delete` |
|
||||
| `projects` | `list`, `get`, `milestones`, `milestone-create`, `board-sync`, `create`, `update`, `delete`, `contacts` (`add`, `remove`), `link-authors`, `link-git`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`, `dedupe`, `as-md`, `put-md`, `put-txt`, `put-xlsx`) |
|
||||
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
||||
| `users` | `list`, `self` (alias: `oo whoami`) |
|
||||
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info`, `tags`, `tag-add`, `tag-create`, `tag-remove` |
|
||||
| `persons` | `list`, `create`, `delete`, `dedupe` |
|
||||
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
|
||||
| `opportunities` | `list`, `get`, `create`, `update`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
|
||||
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
|
||||
| `crm` | `audit`, `cleanup` |
|
||||
| `mails` | `accounts`, `folders`, `list`, `get`, `download-attachment`, `draft`, `attach`, `draft-invoice`, `send`, `delete` |
|
||||
| `cases` | `list`, `create`, `delete`, `member-add` |
|
||||
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
|
||||
| `docs` | `tools`, `convert`, `pdf`, `presigned`, `csv`, `json`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
|
||||
| `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` |
|
||||
| `dav` | `ls`, `move`, `copy`, `mkdir`, `ensure-path`, `upload`, `rename-file`, `rename-folder`, `download`, `fileops` |
|
||||
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) |
|
||||
| `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) |
|
||||
|
||||
The CLI reads only `.env` from the current working directory (godotenv is a
|
||||
CLI-only concern — the library itself never loads dotfiles).
|
||||
|
||||
Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases:
|
||||
`OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
|
||||
|
||||
Catalog scanning (`oo catalog scan-projects` / `scan-thunderbird`) classifies
|
||||
clients from rules in `$OO_CATALOG_CONFIG` (or `--config`); see
|
||||
[`catalog/classify.example.yaml`](catalog/classify.example.yaml). Without rules
|
||||
nothing is classified as work. The MinIO download fallback is off unless
|
||||
`MINIO_ENDPOINT` + `MINIO_ACCESS_KEY` + `MINIO_SECRET_KEY` are set.
|
||||
|
||||
Run `oo --help` or `oo <subject> --help` for the full command reference.
|
||||
|
||||
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
|
||||
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, …) were
|
||||
> replaced by subject-based equivalents (`oo calendar events`, `oo tasks list`,
|
||||
> `oo contacts list`). Flags on leaf commands are unchanged.
|
||||
|
||||
## oo CLI use cases
|
||||
|
||||
The `oo` binary is the day-to-day operator interface. It loads credentials from
|
||||
`.env` in the **current working directory** (copy from [`.env.example`](.env.example)):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# ONLYOFFICE_URL=https://office.example.com
|
||||
# ONLYOFFICE_USER=you@example.com
|
||||
# ONLYOFFICE_PASS=…
|
||||
|
||||
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
|
||||
oo whoami
|
||||
```
|
||||
|
||||
Every list command accepts `-o table` (default) or `-o json` for scripting.
|
||||
|
||||
### CRM cleanup after imports or sync drift
|
||||
|
||||
**Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice,
|
||||
the same email on a contact three times, deals titled ` @ Acme`, or the same
|
||||
HR contact linked to a deal twice.
|
||||
|
||||
**One-shot fix** — runs every dedupe pass in order:
|
||||
|
||||
```bash
|
||||
oo crm cleanup -o json
|
||||
```
|
||||
|
||||
Steps inside `crm cleanup`:
|
||||
|
||||
| Step | What it does |
|
||||
|------|----------------|
|
||||
| `companies` | Merge companies with the same normalized name (slogan variants like `Affirm` / `Affirm — Fraud Engineering` count as one) |
|
||||
| `persons` | Merge duplicate persons globally (same first+last) |
|
||||
| `company-persons` | Merge duplicate persons under each company |
|
||||
| `contact-info` | Remove duplicate email/phone/website rows |
|
||||
| `opportunity-members` | Drop duplicate contacts on the same deal |
|
||||
| `opportunities` | Merge duplicate deals by title |
|
||||
| `fix-titles` | Repair malformed titles (` @ Company` → `Company`) |
|
||||
|
||||
**Targeted passes** when you only want one kind of fix:
|
||||
|
||||
```bash
|
||||
# Duplicate company records
|
||||
oo companies dedupe
|
||||
|
||||
# Same person entered twice under one employer
|
||||
oo companies dedupe-persons
|
||||
|
||||
# Global person duplicates (same name, different ids)
|
||||
oo persons dedupe
|
||||
|
||||
# Repeated email/phone rows on contacts
|
||||
oo contacts dedupe-info
|
||||
|
||||
# Two deals with the same title
|
||||
oo opportunities dedupe
|
||||
|
||||
# Same contact attached twice to one deal
|
||||
oo opportunities dedupe-members
|
||||
|
||||
# Titles like " @ Acme" or extra whitespace
|
||||
oo opportunities fix-titles
|
||||
```
|
||||
|
||||
Merge two known company ids (keeps `INTO`):
|
||||
|
||||
```bash
|
||||
oo contacts merge FROM_ID INTO_ID
|
||||
```
|
||||
|
||||
Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks live
|
||||
with the private `oo-workspace` tooling.
|
||||
|
||||
### Invoices (`oo invoices`)
|
||||
|
||||
**Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF
|
||||
attachments, prepare OnlyOffice Mail — without inventing a second bill-to company.
|
||||
|
||||
```bash
|
||||
# Always pass --opportunity at create (update --opportunity often HTTP 400)
|
||||
oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \
|
||||
--price 300 --opportunity DEAL_ID --language de-DE \
|
||||
--line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID"
|
||||
|
||||
oo invoices pdf INVOICE_ID --force
|
||||
oo invoices pdf-cleanup INVOICE_ID
|
||||
oo invoices status INVOICE_ID --status draft
|
||||
oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com
|
||||
```
|
||||
|
||||
**Deal grouping flag** — when the same role at the same company created
|
||||
separate deals (`Engineer @ Acme` vs `Engineer`):
|
||||
|
||||
```bash
|
||||
oo opportunities dedupe --ignore-company-suffix
|
||||
oo crm cleanup --ignore-company-suffix
|
||||
```
|
||||
|
||||
**Inspect before/after:**
|
||||
|
||||
```bash
|
||||
oo opportunities list --count 200
|
||||
oo contacts get CONTACT_ID -o json
|
||||
oo crm cleanup -o json
|
||||
```
|
||||
|
||||
|
||||
### Workspace mail (`oo mails`)
|
||||
|
||||
**Problem:** Mail lives in OnlyOffice Mail (`/addons/mail/#inbox`), bound to your
|
||||
portal account — not a separate archive service. You want to list, read, or
|
||||
remove messages from the shell.
|
||||
|
||||
Uses the same `ONLYOFFICE_*` credentials as every other `oo` command.
|
||||
|
||||
```bash
|
||||
# Which mailbox is linked?
|
||||
oo mails accounts
|
||||
|
||||
# Folder counters (inbox unread, trash size, …)
|
||||
oo mails folders
|
||||
|
||||
# Latest inbox messages (API returns 25 per page; --limit paginates automatically)
|
||||
oo mails list --folder inbox --limit 50
|
||||
|
||||
# Page through older mail
|
||||
oo mails list --folder inbox --limit 100 --offset 100
|
||||
|
||||
# Other folders
|
||||
oo mails list --folder sent --limit 20
|
||||
oo mails list --folder spam --limit 100
|
||||
|
||||
# Read full message (subject, htmlBody, attachments metadata)
|
||||
oo mails get 5664 -o json | jq '{subject, from, to, date}'
|
||||
|
||||
# Remove one or more messages (server moves to trash or deletes per Mail rules)
|
||||
oo mails delete 5664
|
||||
oo mails delete 5664 5663 5661
|
||||
|
||||
# Create / update a draft (HTML body field is API "body"; plain text is wrapped)
|
||||
oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
|
||||
--body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen"
|
||||
|
||||
# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
|
||||
oo mails attach 7301 --file-id 12345
|
||||
|
||||
# Regenerate invoice PDF (force) + draft + attach (does not send)
|
||||
oo mails draft-invoice --invoice 16 --to info@example.com
|
||||
```
|
||||
|
||||
Matrix / chat URLs in signatures: use plain text
|
||||
`chat: https://matrix.to/#/@user:server` — HTML `<a href="…#…">` truncates at `#`.
|
||||
|
||||
**Table output** splits the `from` header into `fromName` and `fromAddress`
|
||||
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
|
||||
API payload.
|
||||
|
||||
**Scripting example** — export today's inbox subjects:
|
||||
|
||||
```bash
|
||||
oo mails list --folder inbox --limit 200 -o json \
|
||||
| jq -r '.[] | "\(.id)\t\(.subject)"'
|
||||
```
|
||||
|
||||
### Contacts, companies, and deals (everyday CRM)
|
||||
|
||||
```bash
|
||||
# Search companies
|
||||
oo companies list --search acme
|
||||
|
||||
# Create company + person with primary email
|
||||
oo companies create --name "Acme GmbH" --website https://acme.com
|
||||
oo persons create --first Jane --last Doe --email jane@acme.com --company-id 42
|
||||
|
||||
# Attach email or LinkedIn to existing contact
|
||||
oo contacts info-add 42 --type Email --value jane@acme.com --primary
|
||||
|
||||
# Pipeline overview
|
||||
oo opportunities list --count 100
|
||||
oo opportunities stages
|
||||
oo opportunities get 231 -o json
|
||||
```
|
||||
|
||||
### Calendar and project ops
|
||||
|
||||
```bash
|
||||
# Next week's events
|
||||
oo calendar events --start 2026-06-24 --end 2026-07-01
|
||||
|
||||
# Schedule interview block
|
||||
oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z
|
||||
|
||||
# Attach a file to a task
|
||||
oo tasks files upload 208 ./notes.pdf
|
||||
oo projects files list 33
|
||||
```
|
||||
|
||||
### Suggested maintenance cadence
|
||||
|
||||
| When | Command |
|
||||
|------|---------|
|
||||
| After bulk CRM imports | `oo crm cleanup` |
|
||||
| After bulk CSV import into CRM | `oo crm cleanup` |
|
||||
| Weekly inbox triage | `oo mails list --folder inbox --limit 100` |
|
||||
| Before exec reporting | `oo opportunities list` + `oo projects list` |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -560,6 +1073,66 @@ the command reference.
|
||||
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
||||
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
||||
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
||||
| `OO_URL`, `OO_USER`, `OO_PASS` | Optional CLI-only aliases for `ONLYOFFICE_*` |
|
||||
| `OO_RATE_LIMIT` | Process-wide request pacing, req/s (default `4`; `0` disables) |
|
||||
| `OO_BURST` | Token-bucket burst (default `1`) |
|
||||
| `OO_RETRY_ATTEMPTS` | Transient retries, total attempts (default `7`) |
|
||||
| `OO_RETRY_BASE` | Exponential backoff base (default `2s`) |
|
||||
| `OO_RETRY_MAX` | Backoff cap (default `2m`) |
|
||||
| `ONLYOFFICE_ES_URL` | Elasticsearch base URL (`oo search`, own index); see [`docs/elasticsearch.md`](docs/elasticsearch.md) |
|
||||
| `ONLYOFFICE_ES_INDEX` | OnlyOffice index (default `files_file`) |
|
||||
| `ONLYOFFICE_ES_TEXT_INDEX` | Own PDF/scan index (default `oo_docs_text`) |
|
||||
| `ONLYOFFICE_TENANT` | `tenantId` filter for ES/SQL (empty = all) |
|
||||
| `ONLYOFFICE_DSN` | Read-only SQL DSN (MySQL or `postgres://`); see [`docs/community-server-db.md`](docs/community-server-db.md) |
|
||||
| `ONLYOFFICE_PG_DRIVER`, `ONLYOFFICE_PG_TENANT`, `ONLYOFFICE_PG_HOST/_PORT/_USER/_PASSWORD/_DBNAME/_SSLMODE` | SQL store override / DSN by parts (PostgreSQL) |
|
||||
| `MINIO_ENDPOINT`, `MINIO_BUCKET`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY` | Object-store layout for SQL `Download` |
|
||||
| `ONLYOFFICE_WEBDAV_URL` | rclone WebDAV sidecar URL (default `http://172.17.0.1:8098/webdav`) |
|
||||
|
||||
Mail and CRM cleanup are documented in [oo CLI use cases](#oo-cli-use-cases) above. Personal disk inventory / dossier sync lives in the private `oo-workspace` (`oow`) tooling.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go build ./... && go vet ./...
|
||||
go test ./... # unit — no network, no vendor mocks
|
||||
go test -race ./...
|
||||
go test -tags=integration ./... # live OnlyOffice (skip without creds)
|
||||
```
|
||||
|
||||
Unit tests are pure Go (parsers, encoders, conversions). Integration tests
|
||||
(`//go:build integration`) hit a live instance and **skip** cleanly when the
|
||||
env is missing, so `go test ./...` stays green offline. New endpoints ship with
|
||||
an integration test before merge (policy in [`AGENTS.md`](AGENTS.md)).
|
||||
|
||||
Live runs need credentials (`ONLYOFFICE_URL`, `ONLYOFFICE_USER`,
|
||||
`ONLYOFFICE_PASS`) and, per backend:
|
||||
|
||||
- **Elasticsearch** (`oo search`, own index) — ES lives on `127.0.0.1:9200`
|
||||
inside the OnlyOffice VM; expose it over SSH
|
||||
(`-L 9200:127.0.0.1:9200`) and set `ONLYOFFICE_ES_URL`
|
||||
(see [`docs/elasticsearch.md`](docs/elasticsearch.md)).
|
||||
- **SQL backend** (`FileClient`, `SQLFileStore`) — MySQL on `127.0.0.1:3306`
|
||||
in the same VM; tunnel `-L 3306:127.0.0.1:3306`, then set `ONLYOFFICE_DSN`
|
||||
(see [`docs/community-server-db.md`](docs/community-server-db.md)).
|
||||
`ONLYOFFICE_PG_TEST_FILE_ID` / `ONLYOFFICE_PG_TEST_FOLDER_ID` select a real
|
||||
file for the REST cross-check; `MINIO_*` enable the download check.
|
||||
|
||||
### rclone WebDAV mount
|
||||
|
||||
The rclone WebDAV mount (compose + `oo-webdav` sidecar) is deployment
|
||||
tooling and lives in the private `oo-workspace` repo. Set
|
||||
`ONLYOFFICE_WEBDAV_URL` to point the client at it.
|
||||
|
||||
### CI / releases
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` |
|
||||
| `release.yml` | tag `v*` | GoReleaser cross-platform `oo` + `office` binaries |
|
||||
|
||||
Gitea is canonical; tags are created there per SemVer (`fix:` → patch,
|
||||
`feat:` → minor, `!` → major). GoReleaser publishes assets to
|
||||
[GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases).
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -569,8 +1142,8 @@ the command reference.
|
||||
| [calendar](examples/calendar/) | List calendars and events, create a new event |
|
||||
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
|
||||
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
|
||||
| [applications](examples/applications/) | Job application folder tree → OnlyOffice CRM (library-level usage of the internal applications package) |
|
||||
| [`cmd/oo-cli`](cmd/oo-cli/) | Full-featured CLI using all modules |
|
||||
| [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules |
|
||||
| [`cmd/office`](cmd/office/) | Terminal UI — browse Workspace with markdown preview |
|
||||
|
||||
## Related Libraries
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package onlyoffice
|
||||
|
||||
// Authentication primitives: token lifecycle, eager / context-aware auth, and
|
||||
// token invalidation. Split out of http.go so auth concerns live together.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Auth authenticates using the given credentials and returns a fresh token.
|
||||
// Most callers should not call this directly — use Authenticate /
|
||||
// AuthenticateContext, or let Query fetch a token lazily. Kept exported for
|
||||
// backwards compatibility.
|
||||
func (c *Client) Auth(creds *Credentials) (*Token, error) {
|
||||
t := &Token{}
|
||||
return t, c.Query(Request{
|
||||
Uri: "/api/2.0/authentication.json",
|
||||
Method: http.MethodPost,
|
||||
Body: creds,
|
||||
NoAuth: true,
|
||||
}, &struct {
|
||||
MetaResponse `json:",inline"`
|
||||
Response *Token `json:"response"`
|
||||
}{
|
||||
Response: t,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticate validates credentials and primes the token. Library users may
|
||||
// call this eagerly to surface auth errors at startup; otherwise the token
|
||||
// is fetched lazily on the first request.
|
||||
//
|
||||
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
|
||||
func (c *Client) Authenticate() error { return c.ensureToken() }
|
||||
|
||||
// AuthenticateContext is the context-aware variant of Authenticate. If the
|
||||
// cached token is still valid it returns immediately; otherwise it performs
|
||||
// a POST to /api/2.0/authentication.json that is cancellable via ctx.
|
||||
//
|
||||
// Transient answers from the edge (openresty 429/502/503/504) are retried with
|
||||
// the same deterministic policy as every other request (see retry.go), because
|
||||
// the server rate-limits authentication and the integration suite otherwise
|
||||
// fails with a raw HTML 429 page.
|
||||
//
|
||||
// This is the recommended entry point for long-running syncs (cron,
|
||||
// watchers) because it guarantees that a stalled auth call will not block
|
||||
// the caller past its deadline.
|
||||
func (c *Client) AuthenticateContext(ctx context.Context) error {
|
||||
if c.tokenValid() {
|
||||
return nil
|
||||
}
|
||||
return DoRetry(ctx, DefaultRetryPolicy(), func() error {
|
||||
return c.authenticateOnce(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// authenticateOnce performs a single authentication POST. Callers must handle
|
||||
// retries; use AuthenticateContext.
|
||||
func (c *Client) authenticateOnce(ctx context.Context) error {
|
||||
body, err := json.Marshal(c.credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return statusError(resp.StatusCode, retryAfterOf(resp), "auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
var env struct {
|
||||
Response *Token `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return fmt.Errorf("auth decode: %w", err)
|
||||
}
|
||||
if env.Response == nil || env.Response.Value == "" {
|
||||
return fmt.Errorf("auth: empty token in response")
|
||||
}
|
||||
c.token = env.Response
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthenticateAs verifies a login/password pair against the portal WITHOUT
|
||||
// mutating the client's cached token. It returns nil when the portal issues a
|
||||
// token, and the portal error otherwise.
|
||||
//
|
||||
// OnlyOffice accepts either the userName or the account email as the login. On
|
||||
// some portals the account userName login returns HTTP 500 "User authentication
|
||||
// failed" while the account email succeeds — confirmed for a freshly created
|
||||
// guest user. Use this probe before sharing credentials (see `oo users check`),
|
||||
// and prefer the email as the login.
|
||||
func (c *Client) AuthenticateAs(ctx context.Context, login, password string) error {
|
||||
body, err := json.Marshal(Credentials{User: login, Password: password})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
var env struct {
|
||||
Response *Token `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return fmt.Errorf("auth decode: %w", err)
|
||||
}
|
||||
if env.Response == nil || env.Response.Value == "" {
|
||||
return fmt.Errorf("auth: empty token in response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateToken clears the cached authentication token. The next request
|
||||
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
|
||||
//
|
||||
// Use this to recover from a mid-sync 401 when the server has revoked or
|
||||
// rotated the session while the Expires timestamp still looks fresh locally.
|
||||
func (c *Client) InvalidateToken() { c.token = nil }
|
||||
|
||||
// tokenValid reports whether the cached token is present and not expired.
|
||||
func (c *Client) tokenValid() bool {
|
||||
return c.token != nil && !time.Time(c.token.Expires).Before(time.Now())
|
||||
}
|
||||
|
||||
// ensureToken refreshes the authentication token when missing or expired.
|
||||
// Mirrors the logic inline in Query() but is safe to call from helpers that
|
||||
// bypass the typed Request abstraction. It shares AuthenticateContext so the
|
||||
// transient-retry policy applies to every code path.
|
||||
func (c *Client) ensureToken() error {
|
||||
if c.tokenValid() {
|
||||
return nil
|
||||
}
|
||||
return c.AuthenticateContext(context.Background())
|
||||
}
|
||||
|
||||
// authHeader returns the value for the Authorization header, ensuring a token.
|
||||
func (c *Client) authHeader() (string, error) {
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.token.Value, nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package onlyoffice
|
||||
|
||||
// Project board (Gantt) upsert from a YAML board file.
|
||||
//
|
||||
// A board describes projects, their milestones and tasks by exact title. Sync
|
||||
// creates only what is missing: existing milestones/tasks (matched by title)
|
||||
// are left untouched, so the file can be the source of truth for a project
|
||||
// plan and re-applied safely. Dry-run (apply=false) reports counts without
|
||||
// writing.
|
||||
//
|
||||
// The file format is deliberately small and presentation-free:
|
||||
//
|
||||
// projects:
|
||||
// - id: 42
|
||||
// name: "Example"
|
||||
// milestones:
|
||||
// - title: "Kickoff"
|
||||
// deadline: "2026-01-15"
|
||||
// key: true
|
||||
// tasks:
|
||||
// - title: "Draft"
|
||||
// start: "2026-01-02"
|
||||
// deadline: "2026-01-10"
|
||||
// description: "…"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Board is a YAML mapping from a project plan onto OnlyOffice milestones/tasks.
|
||||
type Board struct {
|
||||
Projects []BoardProject `yaml:"projects"`
|
||||
}
|
||||
|
||||
// BoardProject is one project with its milestones.
|
||||
type BoardProject struct {
|
||||
ID int `yaml:"id"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Milestones []BoardMilestone `yaml:"milestones"`
|
||||
}
|
||||
|
||||
// BoardMilestone is a milestone ("key" marks it as a key milestone).
|
||||
type BoardMilestone struct {
|
||||
Title string `yaml:"title"`
|
||||
Deadline string `yaml:"deadline"`
|
||||
Key bool `yaml:"key,omitempty"`
|
||||
Tasks []BoardTask `yaml:"tasks"`
|
||||
}
|
||||
|
||||
// BoardTask is a task inside a milestone. Start falls back to Deadline.
|
||||
type BoardTask struct {
|
||||
Title string `yaml:"title"`
|
||||
Start string `yaml:"start,omitempty"`
|
||||
Deadline string `yaml:"deadline"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
}
|
||||
|
||||
// BoardSyncResult counts what SyncBoard created or skipped.
|
||||
type BoardSyncResult struct {
|
||||
CreatedMilestones int
|
||||
SkippedMilestones int
|
||||
CreatedTasks int
|
||||
SkippedTasks int
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// LoadBoard reads a board YAML file.
|
||||
func LoadBoard(path string) (*Board, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseBoard(b)
|
||||
}
|
||||
|
||||
// ParseBoard decodes a board from YAML bytes.
|
||||
func ParseBoard(data []byte) (*Board, error) {
|
||||
var board Board
|
||||
if err := yaml.Unmarshal(data, &board); err != nil {
|
||||
return nil, fmt.Errorf("board: %w", err)
|
||||
}
|
||||
if len(board.Projects) == 0 {
|
||||
return nil, fmt.Errorf("board: no projects")
|
||||
}
|
||||
return &board, nil
|
||||
}
|
||||
|
||||
func boardDay(s string) (Time, error) {
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return Time{}, err
|
||||
}
|
||||
return Time(t), nil
|
||||
}
|
||||
|
||||
func boardMilestoneIDs(ms []*Milestone) map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
for _, m := range ms {
|
||||
if m == nil || m.Title == nil || m.ID == nil {
|
||||
continue
|
||||
}
|
||||
out[*m.Title] = *m.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boardTaskTitles(rows []map[string]any) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, r := range rows {
|
||||
if t, _ := r["title"].(string); t != "" {
|
||||
out[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SyncBoard upserts milestones and tasks by exact title. With apply=false it
|
||||
// only counts what would be created.
|
||||
func (c *Client) SyncBoard(ctx context.Context, board *Board, apply bool) (*BoardSyncResult, error) {
|
||||
if board == nil || len(board.Projects) == 0 {
|
||||
return nil, fmt.Errorf("board: no projects")
|
||||
}
|
||||
res := &BoardSyncResult{DryRun: !apply}
|
||||
for _, p := range board.Projects {
|
||||
pid := p.ID
|
||||
existing, err := c.GetProjectMilestones(&Project{ID: &pid})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("project %d milestones: %w", pid, err)
|
||||
}
|
||||
haveMS := boardMilestoneIDs(existing)
|
||||
tasks, err := c.ListTasks(ctx, strconv.Itoa(pid), "")
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("project %d tasks: %w", pid, err)
|
||||
}
|
||||
haveTask := boardTaskTitles(tasks)
|
||||
|
||||
for _, m := range p.Milestones {
|
||||
msID, ok := haveMS[m.Title]
|
||||
if !ok {
|
||||
res.CreatedMilestones++
|
||||
if apply {
|
||||
dl, err := boardDay(m.Deadline)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("milestone %q deadline: %w", m.Title, err)
|
||||
}
|
||||
created, err := c.CreateMilestone(NewMilestoneRequest{
|
||||
ProjectID: pid,
|
||||
Title: m.Title,
|
||||
Deadline: dl,
|
||||
IsKey: m.Key,
|
||||
})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("create milestone %q: %w", m.Title, err)
|
||||
}
|
||||
if created.ID != nil {
|
||||
msID = *created.ID
|
||||
}
|
||||
haveMS[m.Title] = msID
|
||||
}
|
||||
} else {
|
||||
res.SkippedMilestones++
|
||||
}
|
||||
for _, t := range m.Tasks {
|
||||
if _, exists := haveTask[t.Title]; exists {
|
||||
res.SkippedTasks++
|
||||
continue
|
||||
}
|
||||
res.CreatedTasks++
|
||||
if !apply {
|
||||
continue
|
||||
}
|
||||
start := t.Start
|
||||
if start == "" {
|
||||
start = t.Deadline
|
||||
}
|
||||
st, err := boardDay(start)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("task %q start: %w", t.Title, err)
|
||||
}
|
||||
dl, err := boardDay(t.Deadline)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("task %q deadline: %w", t.Title, err)
|
||||
}
|
||||
if _, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||
ProjectId: pid,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
StartDate: st,
|
||||
Deadline: dl,
|
||||
MilestoneId: int(msID),
|
||||
}); err != nil {
|
||||
return res, fmt.Errorf("create task %q: %w", t.Title, err)
|
||||
}
|
||||
haveTask[t.Title] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package onlyoffice
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBoard(t *testing.T) {
|
||||
b, err := ParseBoard([]byte(`
|
||||
projects:
|
||||
- id: 13
|
||||
name: Example
|
||||
milestones:
|
||||
- title: "[lq] Test"
|
||||
deadline: "2026-01-15"
|
||||
key: true
|
||||
tasks:
|
||||
- title: Draft
|
||||
start: "2026-01-02"
|
||||
deadline: "2026-01-10"
|
||||
description: "…"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b.Projects) != 1 || b.Projects[0].ID != 13 {
|
||||
t.Fatalf("projects: %+v", b.Projects)
|
||||
}
|
||||
ms := b.Projects[0].Milestones[0]
|
||||
if ms.Title != "[lq] Test" || !ms.Key || ms.Deadline != "2026-01-15" {
|
||||
t.Fatalf("milestone: %+v", ms)
|
||||
}
|
||||
if len(ms.Tasks) != 1 || ms.Tasks[0].Title != "Draft" || ms.Tasks[0].Start != "2026-01-02" {
|
||||
t.Fatalf("task: %+v", ms.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBoardEmpty(t *testing.T) {
|
||||
if _, err := ParseBoard([]byte("projects: []")); err == nil {
|
||||
t.Fatal("expected error for empty board")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardMilestoneIDs(t *testing.T) {
|
||||
title := "[lq] Test"
|
||||
id := int64(9)
|
||||
got := boardMilestoneIDs([]*Milestone{{Title: &title, ID: &id}, nil})
|
||||
if got[title] != 9 {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardTaskTitles(t *testing.T) {
|
||||
got := boardTaskTitles([]map[string]any{{"title": "a"}, {"title": "b"}, {"nope": 1}})
|
||||
if _, ok := got["a"]; !ok {
|
||||
t.Fatal("missing a")
|
||||
}
|
||||
if _, ok := got["b"]; !ok {
|
||||
t.Fatal("missing b")
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardDay(t *testing.T) {
|
||||
if _, err := boardDay("2026-01-15"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := boardDay("15.01.2026"); err == nil {
|
||||
t.Fatal("expected error for non-ISO date")
|
||||
}
|
||||
}
|
||||
+6
-12
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD).
|
||||
@@ -29,6 +30,9 @@ func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[strin
|
||||
// AddEvent creates a simple one-shot calendar event. When calendarID is empty
|
||||
// the configured default (SetDefaults or GetEnvironmentDefaults) is used.
|
||||
// Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601).
|
||||
//
|
||||
// The underlying API returns an array wrapper — this helper unwraps the first
|
||||
// element for convenience.
|
||||
func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) {
|
||||
if calendarID == "" {
|
||||
calendarID = c.defaults.CalendarID
|
||||
@@ -43,7 +47,7 @@ func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, de
|
||||
fields.Set("endDate", end)
|
||||
fields.Set("repeatType", "")
|
||||
fields.Set("alertType", "0")
|
||||
fields.Set("isAllDayLong", fmt.Sprintf("%t", allDay))
|
||||
fields.Set("isAllDayLong", strconv.FormatBool(allDay))
|
||||
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -64,15 +68,5 @@ func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, de
|
||||
|
||||
// DeleteEvent removes a single calendar event by its event ID.
|
||||
func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) {
|
||||
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := responseField(raw, "response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(resp, &out)
|
||||
return out, nil
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMergeAddressesDedup(t *testing.T) {
|
||||
dst := []Address{{Street: "Weg 1", City: "Stadt", Zip: "1"}}
|
||||
got := mergeAddresses(dst, []Address{
|
||||
{Street: "weg 1", City: "stadt", Zip: "1"}, // duplicate (case-insensitive)
|
||||
{Street: "Weg 2", City: "Stadt", Zip: "2"}, // new
|
||||
})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d addresses, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[1].Street != "Weg 2" {
|
||||
t.Errorf("second = %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryAddressesYAML(t *testing.T) {
|
||||
doc := &Document{Entries: []Entry{{
|
||||
ID: "person:max maier", Kind: "person", Name: "Max Maier",
|
||||
Addresses: []Address{{Street: "Weg 1", City: "Stadt", Zip: "12345", Category: "Billing", Primary: true}},
|
||||
}}}
|
||||
merged := MergeDocs(doc)
|
||||
if len(merged.Entries) != 1 || len(merged.Entries[0].Addresses) != 1 {
|
||||
t.Fatalf("merged = %+v", merged.Entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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) {
|
||||
org := strings.TrimSpace(e.Org)
|
||||
first, last := CleanPersonNames(e.First, e.Last, e.Name, org, e.Emails)
|
||||
e.First, e.Last = first, last
|
||||
if first == "" {
|
||||
return false, fmt.Errorf("person missing name")
|
||||
}
|
||||
e.Name = strings.TrimSpace(first + " " + strings.Trim(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 org != "" {
|
||||
if co, ferr := client.FindCompany(ctx, org); ferr == nil && co != nil {
|
||||
companyID, _ = strconv.Atoi(contactIDString(co))
|
||||
}
|
||||
}
|
||||
if p == nil {
|
||||
about := ""
|
||||
if org != "" {
|
||||
about = "org: " + org
|
||||
}
|
||||
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
created = true
|
||||
} else {
|
||||
// Repair names + ensure company link (never encode company in lastName).
|
||||
id := contactIDString(p)
|
||||
if _, err := client.UpdatePerson(ctx, id, first, last, companyID, "", ""); err != nil {
|
||||
return false, fmt.Errorf("update person %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
for _, a := range e.Addresses {
|
||||
if strings.TrimSpace(a.Street) == "" && strings.TrimSpace(a.City) == "" && strings.TrimSpace(a.Zip) == "" {
|
||||
continue
|
||||
}
|
||||
if onlyoffice.HasContactAddress(existing, a.Street, a.City, a.Zip, a.Category) {
|
||||
continue
|
||||
}
|
||||
if _, err := client.AddContactAddress(ctx, contactID, a.Street, a.City, a.State, a.Zip, a.Country, a.Category, a.Primary); err != nil {
|
||||
return fmt.Errorf("add address %s: %w", strings.TrimSpace(a.Street), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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, "acme-demo")
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := &Classifier{WorkNames: []string{"acme"}}
|
||||
doc, err := ScanProjectsRootOpts(root, 3, ScanOptions{Classifier: cl})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range doc.Entries {
|
||||
if e.Kind == "company" && e.Name == "acme-demo" {
|
||||
found = true
|
||||
if e.Role != "work" || e.Zone != "warm" {
|
||||
t.Fatalf("role=%q zone=%q", e.Role, e.Zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing company: %+v", doc.Entries)
|
||||
}
|
||||
|
||||
// Neutral default leaves it unclassified.
|
||||
doc, err = ScanProjectsRoot(root, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range doc.Entries {
|
||||
if e.Kind == "company" && e.Name == "acme-demo" && e.Role != "unknown" {
|
||||
t.Fatalf("neutral role=%q", e.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,26 @@
|
||||
# Deployment classification rules for `oo catalog scan-projects` /
|
||||
# `oo catalog scan-thunderbird`. Point OO_CATALOG_CONFIG (or --config) at a copy
|
||||
# of this file. Keep your real rules out of the repository — they name your
|
||||
# clients and hosts. The library defaults to no rules (nothing is "work").
|
||||
#
|
||||
# work_remotes: a git remote containing any of these substrings → work/hot.
|
||||
work_remotes:
|
||||
- git.internal.example
|
||||
- github.com/acme
|
||||
#
|
||||
# work_names: a project directory name containing any of these substrings → work/warm.
|
||||
work_names:
|
||||
- acme
|
||||
#
|
||||
# mail_orgs: ordered rules for Thunderbird/mbox identities; the first match wins.
|
||||
# Match by exact `domain`, `suffix` (e.g. ".example.com") and/or display `name`.
|
||||
# `zone` defaults to hot, `role` to work.
|
||||
mail_orgs:
|
||||
- domain: acme.example
|
||||
org: Acme GmbH
|
||||
zone: hot
|
||||
role: work
|
||||
- suffix: .gov.example
|
||||
org: Public Sector
|
||||
zone: warm
|
||||
role: work
|
||||
@@ -0,0 +1,127 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Classifier maps project trees and mail identities to catalog org/zone/role.
|
||||
//
|
||||
// The library ships with neutral defaults: nothing is classified as work unless
|
||||
// the deployment supplies rules. Those rules are deployment-specific, so they
|
||||
// live in a YAML config file (path from OO_CATALOG_CONFIG or the --config flag),
|
||||
// not in the code. See catalog/classify.example.yaml.
|
||||
type Classifier struct {
|
||||
// WorkRemotes: a git remote containing any of these substrings → work/hot.
|
||||
WorkRemotes []string `yaml:"work_remotes,omitempty"`
|
||||
// WorkNames: a project name containing any of these substrings → work/warm.
|
||||
WorkNames []string `yaml:"work_names,omitempty"`
|
||||
// MailOrgs: ordered mail-identity rules; the first match wins.
|
||||
MailOrgs []MailRule `yaml:"mail_orgs,omitempty"`
|
||||
}
|
||||
|
||||
// MailRule maps an email domain and/or a display-name substring to an org with
|
||||
// a zone/role. At least one of Domain, Suffix or Name must be set.
|
||||
type MailRule struct {
|
||||
Domain string `yaml:"domain,omitempty"` // exact domain, case-insensitive
|
||||
Suffix string `yaml:"suffix,omitempty"` // domain suffix, e.g. ".example.com"
|
||||
Name string `yaml:"name,omitempty"` // substring of the display name
|
||||
Org string `yaml:"org"`
|
||||
Zone string `yaml:"zone,omitempty"` // default "hot"
|
||||
Role string `yaml:"role,omitempty"` // default "work"
|
||||
}
|
||||
|
||||
// DefaultClassifier returns the neutral classifier (no deployment rules).
|
||||
func DefaultClassifier() *Classifier { return &Classifier{} }
|
||||
|
||||
// LoadClassifier reads a classifier config from a YAML file.
|
||||
func LoadClassifier(path string) (*Classifier, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Classifier
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse classifier config %s: %w", path, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// LoadClassifierFromEnv loads the classifier named by OO_CATALOG_CONFIG. An
|
||||
// empty variable yields the neutral classifier.
|
||||
func LoadClassifierFromEnv() (*Classifier, error) {
|
||||
path := strings.TrimSpace(os.Getenv("OO_CATALOG_CONFIG"))
|
||||
if path == "" {
|
||||
return DefaultClassifier(), nil
|
||||
}
|
||||
return LoadClassifier(path)
|
||||
}
|
||||
|
||||
// ClassifyProject returns (role, zone) for a project name and git remote.
|
||||
// Generic name heuristics come first; deployment rules supply the work cases.
|
||||
func (c *Classifier) ClassifyProject(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"
|
||||
}
|
||||
if c != nil {
|
||||
for _, r := range c.WorkRemotes {
|
||||
if r != "" && strings.Contains(remoteL, strings.ToLower(r)) {
|
||||
return "work", "hot"
|
||||
}
|
||||
}
|
||||
for _, n := range c.WorkNames {
|
||||
if n != "" && strings.Contains(lower, strings.ToLower(n)) {
|
||||
return "work", "warm"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown", "warm"
|
||||
}
|
||||
|
||||
// ClassifyMail returns (org, zone, role) for a mail identity. name is the
|
||||
// display name (may be empty); email is the address.
|
||||
func (c *Classifier) ClassifyMail(name, email string) (org, zone, role string) {
|
||||
em := NormalizeEmail(email)
|
||||
_, domain, _ := strings.Cut(em, "@")
|
||||
nameL := strings.ToLower(strings.TrimSpace(name))
|
||||
if c != nil {
|
||||
for _, r := range c.MailOrgs {
|
||||
if !mailRuleMatches(r, domain, nameL) {
|
||||
continue
|
||||
}
|
||||
z, ro := r.Zone, r.Role
|
||||
if z == "" {
|
||||
z = "hot"
|
||||
}
|
||||
if ro == "" {
|
||||
ro = "work"
|
||||
}
|
||||
return r.Org, z, ro
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(domain, ".de") && looksPublicSector(domain) {
|
||||
return domain, "warm", "work"
|
||||
}
|
||||
return "", "private", "unknown"
|
||||
}
|
||||
|
||||
func mailRuleMatches(r MailRule, domain, nameL string) bool {
|
||||
if r.Domain != "" && domain == strings.ToLower(strings.TrimSpace(r.Domain)) {
|
||||
return true
|
||||
}
|
||||
if r.Suffix != "" && strings.HasSuffix(domain, strings.ToLower(strings.TrimSpace(r.Suffix))) {
|
||||
return true
|
||||
}
|
||||
if r.Name != "" && nameL != "" && strings.Contains(nameL, strings.ToLower(strings.TrimSpace(r.Name))) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifierRules(t *testing.T) {
|
||||
cl := &Classifier{
|
||||
WorkRemotes: []string{"git.internal.example"},
|
||||
WorkNames: []string{"acme"},
|
||||
MailOrgs: []MailRule{
|
||||
{Domain: "acme.example", Org: "Acme", Zone: "hot", Role: "work"},
|
||||
{Suffix: ".gov.example", Org: "Public", Zone: "warm", Role: "work"},
|
||||
},
|
||||
}
|
||||
if role, zone := cl.ClassifyProject("acme-app", "git@git.internal.example:team/acme-app.git"); role != "work" || zone != "hot" {
|
||||
t.Fatalf("remote: %s/%s", role, zone)
|
||||
}
|
||||
if role, zone := cl.ClassifyProject("acme-demo", ""); role != "work" || zone != "warm" {
|
||||
t.Fatalf("name: %s/%s", role, zone)
|
||||
}
|
||||
if role, zone := cl.ClassifyProject("experiment-x", ""); role != "experiment" || zone != "cold" {
|
||||
t.Fatalf("experiment: %s/%s", role, zone)
|
||||
}
|
||||
if org, zone, role := cl.ClassifyMail("", "bob@acme.example"); org != "Acme" || zone != "hot" || role != "work" {
|
||||
t.Fatalf("mail: %s/%s/%s", org, zone, role)
|
||||
}
|
||||
if org, _, _ := cl.ClassifyMail("X", "x@team.gov.example"); org != "Public" {
|
||||
t.Fatalf("suffix: %s", org)
|
||||
}
|
||||
if org, zone, role := cl.ClassifyMail("", "someone@unknown.example"); org != "" || zone != "private" || role != "unknown" {
|
||||
t.Fatalf("neutral: %s/%s/%s", org, zone, role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClassifier(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "classify.yaml")
|
||||
if err := os.WriteFile(path, []byte("work_names:\n - acme\nmail_orgs:\n - domain: acme.example\n org: Acme\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl, err := LoadClassifier(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role, _ := cl.ClassifyProject("acme-app", ""); role != "work" {
|
||||
t.Fatalf("role=%s", role)
|
||||
}
|
||||
if org, zone, _ := cl.ClassifyMail("", "a@acme.example"); org != "Acme" || zone != "hot" {
|
||||
t.Fatalf("org=%s zone=%s", org, zone)
|
||||
}
|
||||
|
||||
t.Setenv("OO_CATALOG_CONFIG", path)
|
||||
if envCl, err := LoadClassifierFromEnv(); err != nil || envCl == nil || len(envCl.WorkNames) == 0 {
|
||||
t.Fatalf("env: %+v %v", envCl, err)
|
||||
}
|
||||
t.Setenv("OO_CATALOG_CONFIG", "")
|
||||
neutral, err := LoadClassifierFromEnv()
|
||||
if err != nil || len(neutral.WorkNames) != 0 || len(neutral.MailOrgs) != 0 {
|
||||
t.Fatalf("neutral: %+v %v", neutral, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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 := onlyoffice.CompanyGroupingKey(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[onlyoffice.CompanyGroupingKey(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
|
||||
}
|
||||
// Keep a previously applied oo_id (list payloads often omit emails, so
|
||||
// email match can miss persons that already exist in CRM).
|
||||
if strings.TrimSpace(e.OOID) != "" {
|
||||
e.Status = "exists"
|
||||
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) {
|
||||
if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "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)
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
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
|
||||
// Classifier supplies deployment classification rules; nil → neutral default.
|
||||
Classifier *Classifier
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
cl := opts.Classifier
|
||||
if cl == nil {
|
||||
cl = DefaultClassifier()
|
||||
}
|
||||
|
||||
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, cl)
|
||||
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, cl)
|
||||
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, cl)
|
||||
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, cl *Classifier) ([]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 := cl.ClassifyMail("", 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: Alice Smith <alice.smith@acme.example>
|
||||
To: Bob Jones <bob.jones@acme.example>
|
||||
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@acme.example>
|
||||
To: list@acme.example
|
||||
|
||||
more body
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ents, err := parseMboxHeaderEmails(path, DefaultClassifier())
|
||||
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["alice.smith@acme.example"] || !got["bob.jones@acme.example"] {
|
||||
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@acme.example\nTo: b@acme.example\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,152 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
parenSuffixRE = regexp.MustCompile(`(?i)\s*[\(\[\{][^)\]\}]*[\)\]\}]\s*$`)
|
||||
dashCompanyRE = regexp.MustCompile(`(?i)\s+[-–—]\s+[A-Za-z0-9].*$`)
|
||||
emailLocalRE = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
|
||||
nonNameTokenRE = regexp.MustCompile(`[^a-zA-ZÀ-öø-ÿĀ-ž0-9'’.\-]+`)
|
||||
)
|
||||
|
||||
// CleanPersonNames strips company annotations from display names and fills
|
||||
// first/last from the email local-part when the source used an address as the
|
||||
// name. Company affiliation belongs on Org / the CRM companyId — never in LastName.
|
||||
func CleanPersonNames(first, last, display, org string, emails []string) (cleanFirst, cleanLast string) {
|
||||
first = strings.TrimSpace(first)
|
||||
last = strings.TrimSpace(last)
|
||||
display = strings.TrimSpace(display)
|
||||
org = strings.TrimSpace(org)
|
||||
|
||||
if looksLikeEmail(first) {
|
||||
ef, el := GuessNameFromEmail(first)
|
||||
first, last = ef, el
|
||||
}
|
||||
if looksLikeEmail(display) && first == "" && last == "" {
|
||||
display = ""
|
||||
}
|
||||
|
||||
if first == "" && last == "" && display != "" {
|
||||
first, last = SplitDisplayName(display)
|
||||
}
|
||||
|
||||
first = stripCompanyAnnotation(first, org)
|
||||
last = stripCompanyAnnotation(last, org)
|
||||
|
||||
// "Smith - Acme" / "Jones (Acme)" landed in last.
|
||||
last = stripCompanyAnnotation(last, org)
|
||||
if i := strings.IndexAny(first, "(["); i > 0 {
|
||||
first = strings.TrimSpace(first[:i])
|
||||
}
|
||||
// Entire last name is just the company (e.g. last="Acme").
|
||||
if org != "" && personLastIsOrg(last, org) {
|
||||
last = ""
|
||||
}
|
||||
|
||||
if (first == "" || looksLikeEmail(first)) && len(emails) > 0 {
|
||||
ef, el := GuessNameFromEmail(emails[0])
|
||||
if first == "" || looksLikeEmail(first) {
|
||||
first = ef
|
||||
}
|
||||
if last == "" || last == "-" {
|
||||
last = el
|
||||
}
|
||||
}
|
||||
|
||||
first = strings.TrimSpace(first)
|
||||
last = strings.TrimSpace(last)
|
||||
if last == "" {
|
||||
last = "-"
|
||||
}
|
||||
return first, last
|
||||
}
|
||||
|
||||
func stripCompanyAnnotation(s, org string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = parenSuffixRE.ReplaceAllString(s, "")
|
||||
s = strings.TrimSpace(s)
|
||||
s = dashCompanyRE.ReplaceAllString(s, "")
|
||||
s = strings.TrimSpace(s)
|
||||
if org != "" {
|
||||
for _, sep := range []string{" - ", " – ", " — ", " / "} {
|
||||
if i := strings.LastIndex(strings.ToLower(s), strings.ToLower(sep+org)); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
}
|
||||
suf := " (" + org + ")"
|
||||
if strings.HasSuffix(strings.ToLower(s), strings.ToLower(suf)) {
|
||||
s = strings.TrimSpace(s[:len(s)-len(suf)])
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func personLastIsOrg(last, org string) bool {
|
||||
last = NormalizeName(last)
|
||||
org = NormalizeName(org)
|
||||
if last == "" || org == "" {
|
||||
return false
|
||||
}
|
||||
if last == org {
|
||||
return true
|
||||
}
|
||||
// "Acme" vs "Acme GmbH & Co. KG"
|
||||
return strings.HasPrefix(org, last+" ") || strings.HasPrefix(org, last+",")
|
||||
}
|
||||
|
||||
func looksLikeEmail(s string) bool {
|
||||
return emailLocalRE.MatchString(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// GuessNameFromEmail turns local@domain into Title-Case first/last when the
|
||||
// local part looks like first.last / first_last / first-last.
|
||||
func GuessNameFromEmail(email string) (first, last string) {
|
||||
email = NormalizeEmail(email)
|
||||
local, _, ok := strings.Cut(email, "@")
|
||||
if !ok || local == "" {
|
||||
return "", ""
|
||||
}
|
||||
local = strings.Split(local, "+")[0]
|
||||
parts := strings.FieldsFunc(local, func(r rune) bool {
|
||||
return r == '.' || r == '_' || r == '-'
|
||||
})
|
||||
if len(parts) == 0 {
|
||||
return titleToken(local), ""
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return titleToken(parts[0]), ""
|
||||
}
|
||||
return titleToken(parts[0]), titleToken(strings.Join(parts[1:], " "))
|
||||
}
|
||||
|
||||
func titleToken(s string) string {
|
||||
s = nonNameTokenRE.ReplaceAllString(s, " ")
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.ToLower(s))
|
||||
runes[0] = unicode.ToTitle(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// FormatProjectTitle builds "CC | Company | Title" (spaces around |).
|
||||
// Country should be a short code (DE, TF, UA, …). Empty segments are dropped.
|
||||
func FormatProjectTitle(country, company, title string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
for _, p := range []string{country, company, title} {
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.ReplaceAll(p, "|", "/")
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCleanPersonNamesStripsCompanyParen(t *testing.T) {
|
||||
f, l := CleanPersonNames("John", "Smith (Acme)", "John Smith (Acme)", "Acme", nil)
|
||||
if f != "John" || l != "Smith" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPersonNamesStripsDashCompany(t *testing.T) {
|
||||
f, l := CleanPersonNames("Jens", "Meyer - Acme", "", "Acme", nil)
|
||||
if f != "Jens" || l != "Meyer" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPersonNamesFromEmail(t *testing.T) {
|
||||
f, l := CleanPersonNames("david.patzke@acme.example", "-", "", "Acme",
|
||||
[]string{"david.patzke@acme.example"})
|
||||
if f != "David" || l != "Patzke" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPersonNamesLastIsCompany(t *testing.T) {
|
||||
f, l := CleanPersonNames("Thorsten", "Acme", "", "Acme GmbH & Co. KG", nil)
|
||||
if f != "Thorsten" || l != "-" {
|
||||
t.Fatalf("got %q %q", f, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProjectTitle(t *testing.T) {
|
||||
if got := FormatProjectTitle("DE", "Acme", "Golang"); got != "DE | Acme | Golang" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := FormatProjectTitle("", "Acme", ""); got != "Acme" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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) {
|
||||
return ScanProjectsRootOpts(root, maxDepth, ScanOptions{})
|
||||
}
|
||||
|
||||
// ScanProjectsRootOpts is ScanProjectsRoot with deployment classification rules.
|
||||
func ScanProjectsRootOpts(root string, maxDepth int, opts ScanOptions) (*Document, error) {
|
||||
root = filepath.Clean(root)
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 4
|
||||
}
|
||||
cl := opts.Classifier
|
||||
if cl == nil {
|
||||
cl = DefaultClassifier()
|
||||
}
|
||||
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, cl, &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 := cl.ClassifyProject(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, cl *Classifier, 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 := cl.ClassifyProject(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, cl, 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))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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, cl *Classifier) ([]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 := cl.ClassifyMail("", 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, cl *Classifier) ([]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 := cl.ClassifyMail(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 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,109 @@
|
||||
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("alice.smith@acme.example") {
|
||||
t.Fatal("should keep a human work address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMABEmails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "abook.mab")
|
||||
body := `// mork junk
|
||||
PrimaryEmail=alice.smith@acme.example
|
||||
noreply@github.com
|
||||
bob.jones@acme.example
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Neutral default: no deployment rules → unclassified.
|
||||
ents, err := parseMABEmails(path, DefaultClassifier())
|
||||
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 != "" || e.Role != "unknown" {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
}
|
||||
// A deployment rule classifies the domain as work.
|
||||
cl := &Classifier{MailOrgs: []MailRule{{Domain: "acme.example", Org: "Acme", Zone: "warm", Role: "work"}}}
|
||||
ents, err = parseMABEmails(path, cl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range ents {
|
||||
if e.Org != "Acme" || e.Role != "work" || e.Zone != "warm" {
|
||||
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, 'Alice Smith');
|
||||
INSERT INTO identities VALUES (1, 1, 'email', 'alice.smith@acme.example');
|
||||
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, DefaultClassifier())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ents) != 1 {
|
||||
t.Fatalf("got %d %+v", len(ents), ents)
|
||||
}
|
||||
if ents[0].First != "Alice" || ents[0].Emails[0] != "alice.smith@acme.example" {
|
||||
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@acme.example\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,244 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Address is one postal address of a catalog row. Category is the OO
|
||||
// AddressCategory label (Home|Postal|Office|Billing|Other|Work).
|
||||
type Address struct {
|
||||
Street string `yaml:"street,omitempty"`
|
||||
City string `yaml:"city,omitempty"`
|
||||
State string `yaml:"state,omitempty"`
|
||||
Zip string `yaml:"zip,omitempty"`
|
||||
Country string `yaml:"country,omitempty"`
|
||||
Category string `yaml:"category,omitempty"`
|
||||
Primary bool `yaml:"primary,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Addresses []Address `yaml:"addresses,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...))
|
||||
dst.Addresses = mergeAddresses(dst.Addresses, src.Addresses)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// mergeAddresses appends src addresses not already present (by street/city/zip).
|
||||
func mergeAddresses(dst, src []Address) []Address {
|
||||
for _, a := range src {
|
||||
exists := false
|
||||
for _, b := range dst {
|
||||
if strings.EqualFold(strings.TrimSpace(a.Street), strings.TrimSpace(b.Street)) &&
|
||||
strings.EqualFold(strings.TrimSpace(a.City), strings.TrimSpace(b.City)) &&
|
||||
strings.EqualFold(strings.TrimSpace(a.Zip), strings.TrimSpace(b.Zip)) {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
dst = append(dst, a)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package onlyoffice provides a Go client for the OnlyOffice Workspace
|
||||
// (formerly ONLYOFFICE) REST API. It is organised as a flat single package
|
||||
// intentionally: a single *Client exposes Projects, Tasks, Calendar, CRM and
|
||||
// Files operations as receiver methods. Domain split is by file, not by
|
||||
// subpackage, to keep call sites uniform (c.ListContacts, c.GetTasks,
|
||||
// c.AddEvent, c.UploadOpportunityFile all live on the same handle).
|
||||
//
|
||||
// The CLI binary `oo` (see cmd/oo) is a thin cobra wrapper on top of this
|
||||
// library and mirrors the Python cv/bin/office reference tooling.
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Client of OnlyOffice API uses credentials to get a token and query the API
|
||||
// by every request.
|
||||
//
|
||||
// Construct with NewClient; optionally set fallbacks via SetDefaults. The
|
||||
// zero value is NOT usable — credentials are required. A single client is
|
||||
// safe for sequential use from one goroutine; for concurrent use, callers
|
||||
// should wrap with their own synchronization or create one client per
|
||||
// goroutine.
|
||||
type Client struct {
|
||||
client *http.Client
|
||||
credentials *Credentials
|
||||
token *Token
|
||||
|
||||
defaults Defaults // optional fallbacks for calendar/project IDs
|
||||
selfID string // cached /api/2.0/people/@self id
|
||||
noteCatID int // cached CRM history category id for "note"
|
||||
|
||||
folderTitles map[string]string // cached Documents folder id -> title (F9)
|
||||
folderTitlesMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewClient returns a new Client whose transport is paced by the process-wide
|
||||
// rate limiter and 429 cooldown gate (OO_RATE_LIMIT/OO_BURST; see ratelimit.go).
|
||||
func NewClient(c Credentials) *Client {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Client{
|
||||
client: &http.Client{
|
||||
Jar: jar,
|
||||
Transport: &pacedTransport{base: http.DefaultTransport},
|
||||
},
|
||||
credentials: &c,
|
||||
}
|
||||
}
|
||||
|
||||
// Credentials of OnlyOffice User. The Url field is NOT sent with the auth
|
||||
// payload — it only determines the host.
|
||||
type Credentials struct {
|
||||
Url string `json:"-"`
|
||||
User string `json:"userName"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Defaults holds optional fallbacks used by package-level helpers when callers
|
||||
// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults
|
||||
// or read from env via GetEnvironmentDefaults.
|
||||
type Defaults struct {
|
||||
CalendarID string
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
// SetDefaults configures optional identifiers used as fallbacks by methods
|
||||
// such as AddEvent (when calendarID == "") or AddTask (when projectID == "").
|
||||
func (c *Client) SetDefaults(d Defaults) { c.defaults = d }
|
||||
|
||||
// GetEnvironmentCredentials reads OnlyOffice credentials from environment.
|
||||
//
|
||||
// Primary variables (documented):
|
||||
// - ONLYOFFICE_URL
|
||||
// - ONLYOFFICE_USER
|
||||
// - ONLYOFFICE_PASS
|
||||
//
|
||||
// Additional aliases accepted for interoperability with sibling tools:
|
||||
// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL)
|
||||
// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER)
|
||||
// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS)
|
||||
func GetEnvironmentCredentials() Credentials {
|
||||
url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST"))
|
||||
url = strings.TrimRight(url, "/")
|
||||
return Credentials{
|
||||
Url: url,
|
||||
User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")),
|
||||
Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")),
|
||||
}
|
||||
}
|
||||
|
||||
// GetEnvironmentDefaults reads optional library defaults from environment:
|
||||
//
|
||||
// - ONLYOFFICE_CALENDAR_ID (default: "1")
|
||||
// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33")
|
||||
func GetEnvironmentDefaults() Defaults {
|
||||
return Defaults{
|
||||
CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"),
|
||||
ProjectID: firstNonEmpty(
|
||||
os.Getenv("ONLYOFFICE_PROJECT_ID"),
|
||||
os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"),
|
||||
"33",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty trimmed value, or "" if none.
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -139,6 +139,23 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
||||
|
||||
start := Time(time.Now().AddDate(0, 0, -2))
|
||||
deadline := Time(time.Now().AddDate(0, 0, 2))
|
||||
ms, err := c.CreateMilestone(NewMilestoneRequest{
|
||||
ProjectID: *project.ID,
|
||||
Title: "integration milestone",
|
||||
Deadline: deadline,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMilestone: %v", err)
|
||||
}
|
||||
if ms == nil || ms.ID == nil {
|
||||
t.Fatal("created milestone without id")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := c.DeleteMilestone(*ms.ID); err != nil {
|
||||
t.Logf("cleanup DeleteMilestone: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
task, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||
ProjectId: *project.ID,
|
||||
Title: "integration parent task",
|
||||
@@ -146,6 +163,7 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
||||
StartDate: start,
|
||||
Deadline: deadline,
|
||||
Priority: int(TaskPriorityNormal),
|
||||
MilestoneId: int(*ms.ID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProjectTask: %v", err)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package bootstrap loads CLI environment and constructs an authenticated
|
||||
// OnlyOffice client. Shared by cmd/oo and cmd/office; the library never
|
||||
// loads dotfiles.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// LoadEnv reads .env from the current working directory and applies OO_* aliases.
|
||||
func LoadEnv() {
|
||||
_ = godotenv.Load(".env")
|
||||
applyEnvAliases()
|
||||
}
|
||||
|
||||
// NewClient loads env, validates credentials, and authenticates against OnlyOffice.
|
||||
func NewClient(ctx context.Context) (*onlyoffice.Client, error) {
|
||||
LoadEnv()
|
||||
creds := onlyoffice.GetEnvironmentCredentials()
|
||||
if creds.Url == "" || creds.User == "" || creds.Password == "" {
|
||||
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
|
||||
}
|
||||
c := onlyoffice.NewClient(creds)
|
||||
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
|
||||
if err := c.AuthenticateContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func applyEnvAliases() {
|
||||
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
|
||||
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
|
||||
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
|
||||
}
|
||||
|
||||
func setEnvIfEmpty(dst, src string) {
|
||||
if strings.TrimSpace(os.Getenv(dst)) != "" {
|
||||
return
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
|
||||
_ = os.Setenv(dst, v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package bootstrap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
)
|
||||
|
||||
func TestLoadEnvFromCWDWithOOAliases(t *testing.T) {
|
||||
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
|
||||
dir := t.TempDir()
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldwd) })
|
||||
|
||||
if err := os.WriteFile(".env", []byte("OO_URL=https://office.example.com\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bootstrap.LoadEnv()
|
||||
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.example.com" {
|
||||
t.Fatalf("ONLYOFFICE_URL=%q", got)
|
||||
}
|
||||
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
|
||||
t.Fatalf("ONLYOFFICE_USER=%q", got)
|
||||
}
|
||||
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
|
||||
t.Fatalf("ONLYOFFICE_PASS=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvDoesNotOverrideCanonicalWithAlias(t *testing.T) {
|
||||
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
|
||||
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
|
||||
t.Setenv("OO_URL", "https://alias.example")
|
||||
|
||||
bootstrap.LoadEnv()
|
||||
|
||||
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
|
||||
t.Fatalf("ONLYOFFICE_URL=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientReturnsErrorWithoutCredentials(t *testing.T) {
|
||||
clearEnv(t,
|
||||
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
|
||||
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
|
||||
"OO_URL", "OO_USER", "OO_PASS",
|
||||
)
|
||||
dir := t.TempDir()
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldwd) })
|
||||
|
||||
_, err = bootstrap.NewClient(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error without credentials")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearEnv(t *testing.T, keys ...string) {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
old, ok := os.LookupEnv(key)
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if ok {
|
||||
_ = os.Setenv(key, old)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(key)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package fetch_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func skipWithoutLiveAPI(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
||||
t.Skip("ONLYOFFICE_URL not set")
|
||||
}
|
||||
if os.Getenv("ONLYOFFICE_USER") == "" && os.Getenv("ONLYOFFICE_NAME") == "" {
|
||||
t.Skip("ONLYOFFICE_USER not set")
|
||||
}
|
||||
if os.Getenv("ONLYOFFICE_PASS") == "" && os.Getenv("ONLYOFFICE_PASSWORD") == "" {
|
||||
t.Skip("ONLYOFFICE_PASS not set")
|
||||
}
|
||||
}
|
||||
|
||||
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
|
||||
t.Helper()
|
||||
skipWithoutLiveAPI(t)
|
||||
client, err := bootstrap.NewClient(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fetch.Loader{Client: client}, context.Background()
|
||||
}
|
||||
|
||||
func TestIntegrationListAllSubjects(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
|
||||
cases := []model.ListSpec{
|
||||
{Subject: model.SubjectProjects},
|
||||
{Subject: model.SubjectTasks},
|
||||
{Subject: model.SubjectCalendar},
|
||||
{Subject: model.SubjectContacts},
|
||||
{Subject: model.SubjectPersons},
|
||||
{Subject: model.SubjectCompanies},
|
||||
{Subject: model.SubjectOpportunities},
|
||||
{Subject: model.SubjectCases},
|
||||
{Subject: model.SubjectCRMTasks},
|
||||
{Subject: model.SubjectMailInbox},
|
||||
{Subject: model.SubjectUsers},
|
||||
}
|
||||
|
||||
for _, spec := range cases {
|
||||
t.Run(string(spec.Subject), func(t *testing.T) {
|
||||
items, err := loader.List(ctx, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("List(%s): %v", spec.Subject, err)
|
||||
}
|
||||
t.Logf("%s: %d items", spec.Subject, len(items))
|
||||
for i, it := range items {
|
||||
if it.ID == "" {
|
||||
t.Errorf("item[%d] missing ID", i)
|
||||
}
|
||||
if it.Title == "" {
|
||||
t.Errorf("item[%d] missing Title", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationListProjectsMapsRealFields(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
spec := model.ListSpec{Subject: model.SubjectProjects}
|
||||
items, err := loader.List(ctx, spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no projects on instance")
|
||||
}
|
||||
detail, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Detail: %v", err)
|
||||
}
|
||||
if detail == nil {
|
||||
t.Fatal("nil detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationMailInboxDetail(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("inbox empty")
|
||||
}
|
||||
detail, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatalf("GetMailMessage: %v", err)
|
||||
}
|
||||
if detail["subject"] == nil {
|
||||
t.Fatalf("message missing subject: %+v", detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"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.
|
||||
type Loader struct {
|
||||
Client *onlyoffice.Client
|
||||
|
||||
// Files is the backend-agnostic file store used for file download, preview
|
||||
// and delete. When nil it falls back to Client.FileStore(ProviderREST).
|
||||
Files onlyoffice.FileStore
|
||||
}
|
||||
|
||||
// fileStore returns the configured file store, defaulting to REST.
|
||||
func (l *Loader) fileStore() onlyoffice.FileStore {
|
||||
if l.Files != nil {
|
||||
return l.Files
|
||||
}
|
||||
return l.Client.FileStore(onlyoffice.ProviderREST)
|
||||
}
|
||||
|
||||
// List returns items for the given list spec (nav leaf).
|
||||
func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
|
||||
if l == nil || l.Client == nil {
|
||||
return nil, fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
switch spec.Subject {
|
||||
case model.SubjectProjects:
|
||||
return l.listProjects(ctx)
|
||||
case model.SubjectTasks:
|
||||
if spec.ProjectID != "" {
|
||||
return l.listTasksForProject(ctx, spec.ProjectID)
|
||||
}
|
||||
return l.listTasks(ctx)
|
||||
case model.SubjectCalendar:
|
||||
return l.ListCalendar(ctx)
|
||||
case model.SubjectCalendars:
|
||||
return l.ListCalendar(ctx)
|
||||
case model.SubjectEvents:
|
||||
return l.ListCalendar(ctx)
|
||||
case model.SubjectContacts:
|
||||
return l.listContacts(ctx, nil)
|
||||
case model.SubjectPersons:
|
||||
falseVal := false
|
||||
return l.listContacts(ctx, &falseVal)
|
||||
case model.SubjectCompanies:
|
||||
trueVal := true
|
||||
return l.listContacts(ctx, &trueVal)
|
||||
case model.SubjectOpportunities:
|
||||
return l.listOpportunities(ctx)
|
||||
case model.SubjectCases:
|
||||
return l.listCases(ctx)
|
||||
case model.SubjectCRMTasks:
|
||||
return l.listCRMTasks(ctx)
|
||||
case model.SubjectMailInbox:
|
||||
return l.listMail(ctx, onlyoffice.MailFolderInbox, 0)
|
||||
case model.SubjectMailSent:
|
||||
return l.listMail(ctx, onlyoffice.MailFolderSent, 0)
|
||||
case model.SubjectMailDrafts:
|
||||
return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0)
|
||||
case model.SubjectMailTrash:
|
||||
return l.listMail(ctx, onlyoffice.MailFolderTrash, 0)
|
||||
case model.SubjectMailSpam:
|
||||
return l.listMail(ctx, onlyoffice.MailFolderSpam, 0)
|
||||
case model.SubjectUsers:
|
||||
return l.listUsers(ctx)
|
||||
case model.SubjectProjectFiles:
|
||||
pid := spec.ProjectID
|
||||
if pid == "" {
|
||||
pid = onlyoffice.GetEnvironmentDefaults().ProjectID
|
||||
}
|
||||
if pid == "" {
|
||||
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID or pick a project in the tree")
|
||||
}
|
||||
return l.listProjectFiles(ctx, pid)
|
||||
case model.SubjectTaskFiles:
|
||||
if spec.TaskID == "" {
|
||||
return nil, fmt.Errorf("pick a task under Projects in the tree")
|
||||
}
|
||||
return l.listTaskFiles(ctx, spec.TaskID)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported subject %q", spec.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadProjectsForNav returns projects to inject as dynamic tree nodes.
|
||||
func (l *Loader) LoadProjectsForNav(ctx context.Context) ([]model.Item, error) {
|
||||
return l.listProjects(ctx)
|
||||
}
|
||||
|
||||
// Detail fetches full record data for preview when list row is insufficient.
|
||||
func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) {
|
||||
switch item.Kind {
|
||||
case model.KindOpportunity:
|
||||
return l.Client.GetOpportunity(ctx, item.ID)
|
||||
case model.KindContact:
|
||||
return l.Client.GetContact(ctx, item.ID)
|
||||
case model.KindMail:
|
||||
return l.Client.GetMailMessage(ctx, item.ID)
|
||||
case model.KindTask, model.KindCRMTask:
|
||||
return l.Client.GetTaskByID(ctx, item.ID)
|
||||
case model.KindProject:
|
||||
return l.Client.GetProjectByID(ctx, item.ID)
|
||||
case model.KindUser:
|
||||
return l.Client.GetUser(ctx, item.ID)
|
||||
default:
|
||||
if item.Raw != nil {
|
||||
return item.Raw, nil
|
||||
}
|
||||
return map[string]any{"id": item.ID, "title": item.Title}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs a user-selected action on an item.
|
||||
func (l *Loader) Execute(ctx context.Context, action model.ActionID, item model.Item, destPath string) (string, error) {
|
||||
switch action {
|
||||
case model.ActionView:
|
||||
return "view", nil
|
||||
case model.ActionDelete:
|
||||
return l.executeDelete(ctx, item)
|
||||
case model.ActionDownload:
|
||||
return l.executeDownload(ctx, item, destPath)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, error) {
|
||||
switch item.Kind {
|
||||
case model.KindProject:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := l.Client.DeleteProject(id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted project %s", item.Title), nil
|
||||
case model.KindTask:
|
||||
if _, err := l.Client.DeleteTask(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted task %s", item.Title), nil
|
||||
case model.KindContact:
|
||||
if _, err := l.Client.DeleteContact(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted contact %s", item.Title), nil
|
||||
case model.KindOpportunity:
|
||||
if _, err := l.Client.DeleteOpportunity(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted deal %s", item.Title), nil
|
||||
case model.KindCase:
|
||||
if _, err := l.Client.DeleteCase(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted case %s", item.Title), nil
|
||||
case model.KindCRMTask:
|
||||
if _, err := l.Client.DeleteCRMTask(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted CRM task %s", item.Title), nil
|
||||
case model.KindMail:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := l.Client.RemoveMailMessages(ctx, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted message %s", item.Title), nil
|
||||
case model.KindFile:
|
||||
if err := l.fileStore().Delete(ctx, []string{item.ID}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted file %s", item.Title), nil
|
||||
default:
|
||||
return "", fmt.Errorf("delete not supported for %s", item.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath string) (string, error) {
|
||||
if item.Kind != model.KindFile {
|
||||
return "", fmt.Errorf("download only for files")
|
||||
}
|
||||
if destPath == "" {
|
||||
destPath = filepath.Join(os.TempDir(), "office", item.Title)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := l.fileStore().Download(ctx, item.ID, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Downloaded to %s", destPath), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listTasksForProject(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||
rows, err := l.Client.ListTasks(ctx, projectID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listTaskFiles(ctx context.Context, taskID string) ([]model.Item, error) {
|
||||
files, err := l.Client.GetTaskFiles(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []model.Item
|
||||
for _, f := range files {
|
||||
id, title := "", ""
|
||||
if f.ID != nil {
|
||||
id = f.ID.String()
|
||||
}
|
||||
if f.Title != nil {
|
||||
title = *f.Title
|
||||
}
|
||||
items = append(items, model.Item{
|
||||
ID: id, Title: title, Kind: model.KindFile,
|
||||
Raw: map[string]any{"id": id, "title": title},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
|
||||
projects, err := l.Client.GetProjects()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]model.Item, len(projects))
|
||||
for i, p := range projects {
|
||||
id := ""
|
||||
if p.ID != nil {
|
||||
id = fmt.Sprint(*p.ID)
|
||||
}
|
||||
title := ""
|
||||
if p.Title != nil {
|
||||
title = *p.Title
|
||||
}
|
||||
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 {
|
||||
raw["status"] = *p.Status
|
||||
}
|
||||
items[i] = model.Item{
|
||||
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
|
||||
rows, err := l.Client.ListAllTasks(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
|
||||
rows, err := l.Client.ListAllContacts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if companyOnly != nil {
|
||||
filtered := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
isCo, _ := r["isCompany"].(bool)
|
||||
if isCo == *companyOnly {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
rows = filtered
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindContact, ContactItemFields), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listOpportunities(ctx context.Context) ([]model.Item, error) {
|
||||
rows, err := l.Client.ListAllOpportunities(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindOpportunity, FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "stage"}), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listCases(ctx context.Context) ([]model.Item, error) {
|
||||
cases, _, err := l.Client.ListCases(ctx, 100, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(cases, model.KindCase, FieldMap{IDKey: "id", TitleKey: "title"}), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
|
||||
rows, _, err := l.Client.ListCRMTasks(ctx, 100, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
|
||||
}
|
||||
|
||||
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: MailListPageSize,
|
||||
StartIndex: startIndex,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
|
||||
}
|
||||
|
||||
// ListMailMore loads the next page of messages for an active mail folder list.
|
||||
func (l *Loader) ListMailMore(ctx context.Context, spec model.ListSpec, startIndex int) ([]model.Item, error) {
|
||||
folder, ok := mailFolderForSubject(spec.Subject)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("fetch: %q is not a mail folder", spec.Subject)
|
||||
}
|
||||
return l.listMail(ctx, folder, startIndex)
|
||||
}
|
||||
|
||||
func mailFolderForSubject(subject model.Subject) (int, bool) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||
resp, err := l.Client.GetProjectFiles(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []model.Item
|
||||
for _, f := range resp.Files {
|
||||
id, title := "", ""
|
||||
if f.ID != nil {
|
||||
id = f.ID.String()
|
||||
}
|
||||
if f.Title != nil {
|
||||
title = *f.Title
|
||||
}
|
||||
items = append(items, model.Item{
|
||||
ID: id, Title: title, Kind: model.KindFile,
|
||||
Raw: map[string]any{"id": id, "title": title},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// FieldMap names the JSON keys used when building list items from API rows.
|
||||
type FieldMap struct {
|
||||
IDKey string
|
||||
TitleKey string
|
||||
SubtitleKey string
|
||||
}
|
||||
|
||||
// TaskItemFields is the default field map for project tasks.
|
||||
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title"}
|
||||
|
||||
// ProjectItemFields is the default field map for projects.
|
||||
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
|
||||
|
||||
// ContactItemFields is the default field map for CRM contacts.
|
||||
var ContactItemFields = FieldMap{IDKey: "id", TitleKey: "displayName", SubtitleKey: "primaryEmail"}
|
||||
|
||||
// MailItemFields is the default field map for mail messages.
|
||||
var MailItemFields = FieldMap{IDKey: "id", TitleKey: "subject", SubtitleKey: "from"}
|
||||
|
||||
// ItemsFromMaps converts OnlyOffice list rows into TUI items.
|
||||
func ItemsFromMaps(rows []map[string]any, kind model.Kind, fields FieldMap) []model.Item {
|
||||
out := make([]model.Item, len(rows))
|
||||
for i, row := range rows {
|
||||
title := str(row, fields.TitleKey)
|
||||
if title == "" {
|
||||
title = str(row, "title")
|
||||
}
|
||||
if title == "" {
|
||||
title = str(row, "name")
|
||||
}
|
||||
if title == "" {
|
||||
title = "(untitled)"
|
||||
}
|
||||
raw := row
|
||||
out[i] = model.Item{
|
||||
ID: idStr(row, fields.IDKey),
|
||||
Title: title,
|
||||
Subtitle: str(row, fields.SubtitleKey),
|
||||
Kind: kind,
|
||||
Raw: raw,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func idStr(m map[string]any, key string) string {
|
||||
if key == "" {
|
||||
key = "id"
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprint(m[key])
|
||||
}
|
||||
}
|
||||
|
||||
func str(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
if m[key] == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(m[key])
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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 e, err := l.fileStore().Stat(ctx, item.ID); err == nil && e.Title != "" {
|
||||
name = e.Title
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := l.fileStore().Download(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,64 @@
|
||||
//go:build integration
|
||||
|
||||
package fetch_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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) {
|
||||
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")
|
||||
}
|
||||
fields, err := loader.DetailForm(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fields.Primary == "" {
|
||||
t.Fatal("empty title")
|
||||
}
|
||||
_ = fields.Secondary
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]model.Item, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
raw := userToRaw(u)
|
||||
id := strMap(raw, "id")
|
||||
userName := strMap(raw, "userName")
|
||||
if userName == "" {
|
||||
userName = strMap(raw, "email")
|
||||
}
|
||||
items = append(items, model.Item{
|
||||
ID: id,
|
||||
Title: userName,
|
||||
Kind: model.KindUser,
|
||||
Raw: raw,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func userToRaw(u *onlyoffice.User) map[string]any {
|
||||
b, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func strMap(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
if m[key] == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(m[key])
|
||||
}
|
||||
|
||||
func userProfileUpdateBody(isAdmin bool, modules []string) map[string]any {
|
||||
body := map[string]any{
|
||||
"isAdmin": isAdmin,
|
||||
}
|
||||
if !isAdmin {
|
||||
body["listAdminModules"] = modules
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// SaveUser persists user account settings from the detail form.
|
||||
func (l *Loader) SaveUser(ctx context.Context, userID string, fields model.FormFields) error {
|
||||
if l == nil || l.Client == nil {
|
||||
return fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
raw, err := l.Client.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wasEnabled := model.UserIsEnabled(raw)
|
||||
|
||||
if fields.UserEnabled && !wasEnabled {
|
||||
if err := l.Client.ChangeUserStatus(ctx, userID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin, modules := fields.UserACL.APIPayload()
|
||||
body := userProfileUpdateBody(isAdmin, modules)
|
||||
if _, err := l.updateUserProfile(ctx, userID, body, fields.UserEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !fields.UserEnabled && wasEnabled {
|
||||
if err := l.Client.ChangeUserStatus(ctx, userID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if fields.UserPassword != "" {
|
||||
if err := l.Client.ChangeUserPassword(ctx, userID, fields.UserPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Loader) updateUserProfile(ctx context.Context, userID string, body map[string]any, wantEnabled bool) (map[string]any, error) {
|
||||
out, err := l.Client.UpdateUser(ctx, userID, body)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if wantEnabled && isSuspendedUserError(err) {
|
||||
if actErr := l.Client.ChangeUserStatus(ctx, userID, true); actErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.Client.UpdateUser(ctx, userID, body)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func isSuspendedUserError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "suspended") || strings.Contains(msg, "terminated")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserProfileUpdateBodyOmitsStatus(t *testing.T) {
|
||||
body := userProfileUpdateBody(true, nil)
|
||||
if _, ok := body["status"]; ok {
|
||||
t.Fatal("profile update must not include status")
|
||||
}
|
||||
if body["isAdmin"] != true {
|
||||
t.Fatal("expected isAdmin true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserProfileUpdateBodyPartialAdminModules(t *testing.T) {
|
||||
body := userProfileUpdateBody(false, []string{"documents", "crm"})
|
||||
if body["isAdmin"] != false {
|
||||
t.Fatal("expected partial admin")
|
||||
}
|
||||
mods, ok := body["listAdminModules"].([]string)
|
||||
if !ok || len(mods) != 2 {
|
||||
t.Fatalf("modules=%v", body["listAdminModules"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSuspendedUserError(t *testing.T) {
|
||||
err := fmt.Errorf(`PUT JSON /api/2.0/people/x: 500 {"error":{"message":"The user is suspended"}}`)
|
||||
if !isSuspendedUserError(err) {
|
||||
t.Fatal("expected suspended detection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// LoadUserChoices returns portal users for responsible pickers.
|
||||
func (l *Loader) LoadUserChoices(ctx context.Context) ([]model.UserOption, error) {
|
||||
if l == nil || l.Client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
users, err := l.Client.GetUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.UserOption, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil || u.ID == nil || *u.ID == "" {
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
if u.DisplayName != nil {
|
||||
name = *u.DisplayName
|
||||
}
|
||||
if name == "" && u.Email != nil {
|
||||
name = *u.Email
|
||||
}
|
||||
out = append(out, model.UserOption{ID: *u.ID, Name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Command office is a terminal UI for OnlyOffice Workspace.
|
||||
//
|
||||
// Three-pane layout: module tree (left), selectable list (center),
|
||||
// markdown preview (right). Uses github.com/eslider/go-onlyoffice for API calls.
|
||||
//
|
||||
// Build & install:
|
||||
//
|
||||
// go install github.com/eslider/go-onlyoffice/cmd/office@latest
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := bootstrap.NewClient(context.Background())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
m := ui.NewModel(client)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package model
|
||||
|
||||
// ActionID identifies an operation on a list item.
|
||||
type ActionID string
|
||||
|
||||
const (
|
||||
ActionView ActionID = "view"
|
||||
ActionSave ActionID = "save"
|
||||
ActionClose ActionID = "close"
|
||||
ActionDelete ActionID = "delete"
|
||||
ActionRefresh ActionID = "refresh"
|
||||
ActionDownload ActionID = "download"
|
||||
)
|
||||
|
||||
// ItemAction is one CRUD-style operation offered for an item.
|
||||
type ItemAction struct {
|
||||
ID ActionID
|
||||
Label string
|
||||
Danger bool
|
||||
}
|
||||
|
||||
// ActionsFor returns available operations for an item kind (detail pane action bar).
|
||||
func ActionsFor(kind Kind) []ItemAction {
|
||||
switch kind {
|
||||
case KindProject:
|
||||
return []ItemAction{
|
||||
{ID: ActionSave, Label: "Save"},
|
||||
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||
}
|
||||
case KindTask:
|
||||
return []ItemAction{
|
||||
{ID: ActionSave, Label: "Save"},
|
||||
{ID: ActionClose, Label: "Close"},
|
||||
}
|
||||
case KindCRMTask:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||
}
|
||||
case KindContact:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete contact", Danger: true},
|
||||
}
|
||||
case KindOpportunity:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete deal", Danger: true},
|
||||
}
|
||||
case KindCase:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete case", Danger: true},
|
||||
}
|
||||
case KindMail:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete message", Danger: true},
|
||||
}
|
||||
case KindFile:
|
||||
return []ItemAction{
|
||||
{ID: ActionDownload, Label: "Download"},
|
||||
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
||||
}
|
||||
case KindUser:
|
||||
return []ItemAction{
|
||||
{ID: ActionSave, Label: "Save"},
|
||||
}
|
||||
case KindEvent, KindCalendar:
|
||||
return nil
|
||||
default:
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package model
|
||||
|
||||
// Kind identifies the OnlyOffice entity type for list items and preview routing.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindProject Kind = "project"
|
||||
KindTask Kind = "task"
|
||||
KindContact Kind = "contact"
|
||||
KindOpportunity Kind = "opportunity"
|
||||
KindCase Kind = "case"
|
||||
KindCRMTask Kind = "crm_task"
|
||||
KindMail Kind = "mail"
|
||||
KindEvent Kind = "event"
|
||||
KindCalendar Kind = "calendar"
|
||||
KindFile Kind = "file"
|
||||
KindUser Kind = "user"
|
||||
)
|
||||
|
||||
// Subject identifies a menu leaf / list source.
|
||||
type Subject string
|
||||
|
||||
const (
|
||||
SubjectProjects Subject = "projects"
|
||||
SubjectTasks Subject = "tasks"
|
||||
SubjectCalendars Subject = "calendars"
|
||||
SubjectEvents Subject = "events"
|
||||
SubjectContacts Subject = "contacts"
|
||||
SubjectPersons Subject = "persons"
|
||||
SubjectCompanies Subject = "companies"
|
||||
SubjectOpportunities Subject = "opportunities"
|
||||
SubjectCases Subject = "cases"
|
||||
SubjectCRMTasks Subject = "crm_tasks"
|
||||
SubjectMailInbox Subject = "mail_inbox"
|
||||
SubjectMailSent Subject = "mail_sent"
|
||||
SubjectMailDrafts Subject = "mail_drafts"
|
||||
SubjectMailTrash Subject = "mail_trash"
|
||||
SubjectMailSpam Subject = "mail_spam"
|
||||
SubjectProjectFiles Subject = "project_files"
|
||||
SubjectTaskFiles Subject = "task_files"
|
||||
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.
|
||||
type FocusPane int
|
||||
|
||||
const (
|
||||
FocusMenu FocusPane = iota
|
||||
FocusList
|
||||
FocusPreview
|
||||
)
|
||||
|
||||
// PrevFocusPane cycles preview → list → menu → preview.
|
||||
func PrevFocusPane(p FocusPane) FocusPane {
|
||||
switch p {
|
||||
case FocusMenu:
|
||||
return FocusPreview
|
||||
case FocusList:
|
||||
return FocusMenu
|
||||
default:
|
||||
return FocusList
|
||||
}
|
||||
}
|
||||
|
||||
// NextFocusPane cycles menu → list → preview → menu.
|
||||
func NextFocusPane(p FocusPane) FocusPane {
|
||||
switch p {
|
||||
case FocusMenu:
|
||||
return FocusList
|
||||
case FocusList:
|
||||
return FocusPreview
|
||||
default:
|
||||
return FocusMenu
|
||||
}
|
||||
}
|
||||
|
||||
// Item is one row in the center list pane.
|
||||
type Item struct {
|
||||
ID, Title, Subtitle string
|
||||
Kind Kind
|
||||
Raw map[string]any
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// Selection tracks multi-selected item IDs within the current subject.
|
||||
type Selection struct {
|
||||
ids map[string]struct{}
|
||||
}
|
||||
|
||||
// NewSelection returns an empty selection set.
|
||||
func NewSelection() *Selection {
|
||||
return &Selection{ids: make(map[string]struct{})}
|
||||
}
|
||||
|
||||
// Toggle flips selection on items[idx] and updates the ID set.
|
||||
func (s *Selection) Toggle(items *[]Item, idx int) {
|
||||
if idx < 0 || idx >= len(*items) {
|
||||
return
|
||||
}
|
||||
it := &(*items)[idx]
|
||||
it.Selected = !it.Selected
|
||||
if it.Selected {
|
||||
s.ids[it.ID] = struct{}{}
|
||||
} else {
|
||||
delete(s.ids, it.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the number of selected items.
|
||||
func (s *Selection) Count() int {
|
||||
return len(s.ids)
|
||||
}
|
||||
|
||||
// Clear removes all selections.
|
||||
func (s *Selection) Clear() {
|
||||
s.ids = make(map[string]struct{})
|
||||
}
|
||||
|
||||
// IDs returns selected item IDs in stable order (map iteration order is fine for display).
|
||||
func (s *Selection) IDs() []string {
|
||||
out := make([]string, 0, len(s.ids))
|
||||
for id := range s.ids {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestSelectionToggle(t *testing.T) {
|
||||
items := []model.Item{
|
||||
{ID: "1", Title: "A"},
|
||||
{ID: "2", Title: "B"},
|
||||
}
|
||||
sel := model.NewSelection()
|
||||
if sel.Count() != 0 {
|
||||
t.Fatalf("initial count=%d", sel.Count())
|
||||
}
|
||||
sel.Toggle(&items, 0)
|
||||
if !items[0].Selected || sel.Count() != 1 {
|
||||
t.Fatalf("after toggle 0: selected=%v count=%d", items[0].Selected, sel.Count())
|
||||
}
|
||||
sel.Toggle(&items, 0)
|
||||
if items[0].Selected || sel.Count() != 0 {
|
||||
t.Fatalf("after second toggle: selected=%v count=%d", items[0].Selected, sel.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionClearOnSubjectChange(t *testing.T) {
|
||||
items := []model.Item{{ID: "1", Title: "A", Selected: true}}
|
||||
sel := model.NewSelection()
|
||||
sel.Toggle(&items, 0)
|
||||
sel.Clear()
|
||||
if sel.Count() != 0 {
|
||||
t.Fatalf("count=%d after clear", sel.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFocusPane(t *testing.T) {
|
||||
if got := model.NextFocusPane(model.FocusMenu); got != model.FocusList {
|
||||
t.Fatalf("menu->list got %v", got)
|
||||
}
|
||||
if got := model.NextFocusPane(model.FocusPreview); got != model.FocusMenu {
|
||||
t.Fatalf("preview->menu got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
// ListSpec tells the fetch layer what to load when a nav leaf is active.
|
||||
type ListSpec struct {
|
||||
Subject Subject
|
||||
ProjectID string
|
||||
TaskID string
|
||||
}
|
||||
|
||||
// NavNode is one row in the navigation tree.
|
||||
type NavNode struct {
|
||||
ID string
|
||||
Label string
|
||||
Branch bool
|
||||
List *ListSpec // set on leaves that open the center list
|
||||
ParentID string
|
||||
}
|
||||
|
||||
// NavTree is a hierarchical navigator; the center list loads only on leaves.
|
||||
type NavTree struct {
|
||||
nodes map[string]NavNode
|
||||
roots []string
|
||||
children map[string][]string
|
||||
expanded map[string]bool
|
||||
cursor int
|
||||
visible []string
|
||||
filter string
|
||||
}
|
||||
|
||||
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
|
||||
func DefaultNavTree() *NavTree {
|
||||
t := &NavTree{
|
||||
nodes: make(map[string]NavNode),
|
||||
children: make(map[string][]string),
|
||||
expanded: make(map[string]bool),
|
||||
}
|
||||
add := func(id, label, parent string, branch bool, list *ListSpec) {
|
||||
t.nodes[id] = NavNode{ID: id, Label: label, Branch: branch, List: list, ParentID: parent}
|
||||
if parent == "" {
|
||||
t.roots = append(t.roots, id)
|
||||
} else {
|
||||
t.children[parent] = append(t.children[parent], id)
|
||||
}
|
||||
}
|
||||
|
||||
add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects})
|
||||
add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks})
|
||||
add("projects.dynamic", "By project", "", true, nil)
|
||||
|
||||
add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar})
|
||||
|
||||
add("crm", "CRM", "", true, nil)
|
||||
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
|
||||
add("crm.persons", "Persons", "crm", false, &ListSpec{Subject: SubjectPersons})
|
||||
add("crm.companies", "Companies", "crm", false, &ListSpec{Subject: SubjectCompanies})
|
||||
add("crm.opportunities", "Opportunities", "crm", false, &ListSpec{Subject: SubjectOpportunities})
|
||||
add("crm.cases", "Cases", "crm", false, &ListSpec{Subject: SubjectCases})
|
||||
add("crm.tasks", "CRM tasks", "crm", false, &ListSpec{Subject: SubjectCRMTasks})
|
||||
|
||||
add("mail", "Mail", "", true, nil)
|
||||
add("mail.inbox", "Inbox", "mail", false, &ListSpec{Subject: SubjectMailInbox})
|
||||
add("mail.sent", "Sent", "mail", false, &ListSpec{Subject: SubjectMailSent})
|
||||
add("mail.drafts", "Drafts", "mail", false, &ListSpec{Subject: SubjectMailDrafts})
|
||||
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
|
||||
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
|
||||
|
||||
add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers})
|
||||
|
||||
t.rebuildVisible()
|
||||
return t
|
||||
}
|
||||
|
||||
// RootLabels returns top-level labels (for tests).
|
||||
func (t *NavTree) RootLabels() []string {
|
||||
out := make([]string, len(t.roots))
|
||||
for i, id := range t.roots {
|
||||
out[i] = t.nodes[id].Label
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *NavTree) Cursor() int { return t.cursor }
|
||||
|
||||
func (t *NavTree) VisibleCount() int { return len(t.visible) }
|
||||
|
||||
func (t *NavTree) NodeAtVisible(i int) (NavNode, bool) {
|
||||
if i < 0 || i >= len(t.visible) {
|
||||
return NavNode{}, false
|
||||
}
|
||||
n, ok := t.nodes[t.visible[i]]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
func (t *NavTree) DepthAtVisible(i int) int {
|
||||
if i < 0 || i >= len(t.visible) {
|
||||
return 0
|
||||
}
|
||||
depth := 0
|
||||
id := t.visible[i]
|
||||
for {
|
||||
n, ok := t.nodes[id]
|
||||
if !ok || n.ParentID == "" {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
id = n.ParentID
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
func (t *NavTree) IsExpandable(i int) bool {
|
||||
n, ok := t.NodeAtVisible(i)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return n.Branch && len(t.children[n.ID]) > 0
|
||||
}
|
||||
|
||||
func (t *NavTree) IsExpanded(i int) bool {
|
||||
n, ok := t.NodeAtVisible(i)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return t.expanded[n.ID]
|
||||
}
|
||||
|
||||
func (t *NavTree) ToggleExpand(i int) {
|
||||
n, ok := t.NodeAtVisible(i)
|
||||
if !ok || !n.Branch {
|
||||
return
|
||||
}
|
||||
if t.expanded[n.ID] {
|
||||
delete(t.expanded, n.ID)
|
||||
} else {
|
||||
t.expanded[n.ID] = true
|
||||
}
|
||||
t.rebuildVisible()
|
||||
}
|
||||
|
||||
func (t *NavTree) MoveUp() { t.SetCursor(t.cursor - 1) }
|
||||
func (t *NavTree) MoveDown() { t.SetCursor(t.cursor + 1) }
|
||||
|
||||
func (t *NavTree) SetCursor(c int) {
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
if c >= len(t.visible) {
|
||||
c = len(t.visible) - 1
|
||||
}
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
t.cursor = c
|
||||
}
|
||||
|
||||
// CurrentListSpec returns the list spec when the cursor is on a leaf.
|
||||
func (t *NavTree) CurrentListSpec() (*ListSpec, bool) {
|
||||
n, ok := t.NodeAtVisible(t.cursor)
|
||||
if !ok || n.List == nil {
|
||||
return nil, false
|
||||
}
|
||||
spec := *n.List
|
||||
return &spec, true
|
||||
}
|
||||
|
||||
// Activate expands a branch or returns leaf list spec on Enter.
|
||||
func (t *NavTree) Activate() (*ListSpec, bool) {
|
||||
n, ok := t.NodeAtVisible(t.cursor)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if n.Branch {
|
||||
t.expanded[n.ID] = true
|
||||
t.rebuildVisible()
|
||||
return nil, false
|
||||
}
|
||||
if n.List != nil {
|
||||
spec := *n.List
|
||||
return &spec, true
|
||||
}
|
||||
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() {
|
||||
t.visible = t.visible[:0]
|
||||
var walk func(id string)
|
||||
walk = func(id string) {
|
||||
if !t.visibleUnderFilter(id) {
|
||||
return
|
||||
}
|
||||
t.visible = append(t.visible, id)
|
||||
expanded := t.expanded[id]
|
||||
if t.filter != "" && t.hasMatchingDescendant(id) {
|
||||
expanded = true
|
||||
}
|
||||
if !expanded {
|
||||
return
|
||||
}
|
||||
for _, child := range t.children[id] {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, root := range t.roots {
|
||||
walk(root)
|
||||
}
|
||||
if t.cursor >= len(t.visible) {
|
||||
t.cursor = len(t.visible) - 1
|
||||
}
|
||||
if t.cursor < 0 {
|
||||
t.cursor = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
// InjectProjectNodes adds dynamic project branches under projects.dynamic.
|
||||
func (t *NavTree) InjectProjectNodes(projects []Item) {
|
||||
// Drop prior dynamic project.* nodes.
|
||||
for id := range t.nodes {
|
||||
if strings.HasPrefix(id, "project.") {
|
||||
delete(t.nodes, id)
|
||||
}
|
||||
}
|
||||
t.children["projects.dynamic"] = nil
|
||||
for _, p := range projects {
|
||||
pid := "project." + p.ID
|
||||
t.nodes[pid] = NavNode{ID: pid, Label: p.Title, Branch: true, ParentID: "projects.dynamic"}
|
||||
t.children["projects.dynamic"] = append(t.children["projects.dynamic"], pid)
|
||||
taskID := pid + ".tasks"
|
||||
fileID := pid + ".files"
|
||||
t.nodes[taskID] = NavNode{
|
||||
ID: taskID, Label: "Tasks", Branch: false, ParentID: pid,
|
||||
List: &ListSpec{Subject: SubjectTasks, ProjectID: p.ID},
|
||||
}
|
||||
t.nodes[fileID] = NavNode{
|
||||
ID: fileID, Label: "Files", Branch: false, ParentID: pid,
|
||||
List: &ListSpec{Subject: SubjectProjectFiles, ProjectID: p.ID},
|
||||
}
|
||||
t.children[pid] = []string{taskID, fileID}
|
||||
}
|
||||
t.rebuildVisible()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestNavTreeHasExpectedRoots(t *testing.T) {
|
||||
tree := model.DefaultNavTree()
|
||||
roots := tree.RootLabels()
|
||||
want := []string{"Projects", "Tasks", "By project", "Calendar", "CRM", "Mail", "Users"}
|
||||
if len(roots) != len(want) {
|
||||
t.Fatalf("roots=%v want %v", roots, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if roots[i] != w {
|
||||
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavExpandCollapse(t *testing.T) {
|
||||
tree := model.DefaultNavTree()
|
||||
var crm int = -1
|
||||
for i := 0; i < tree.VisibleCount(); i++ {
|
||||
n, ok := tree.NodeAtVisible(i)
|
||||
if ok && n.Label == "CRM" {
|
||||
crm = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if crm < 0 {
|
||||
t.Fatal("CRM node not found")
|
||||
}
|
||||
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 TestNavCalendarLeafReturnsListSpec(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 == "Calendar" {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
t.Fatal("Calendar node not found")
|
||||
}
|
||||
tree.SetCursor(found)
|
||||
spec, ok := tree.CurrentListSpec()
|
||||
if !ok || spec.Subject != model.SubjectCalendar {
|
||||
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) {
|
||||
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
if got := model.PrevFocusPane(model.FocusMenu); got != model.FocusPreview {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
acts := model.ActionsFor(model.KindContact)
|
||||
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
md "github.com/JohannesKaufmann/html-to-markdown/v2"
|
||||
)
|
||||
|
||||
// HTMLToMarkdown converts Document Server HTML output to markdown for TUI preview.
|
||||
func HTMLToMarkdown(html string) (string, error) {
|
||||
return md.ConvertString(html)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package preview_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
func TestHTMLToMarkdown(t *testing.T) {
|
||||
html := `<h1>Title</h1><p>Body text</p>`
|
||||
md, err := preview.HTMLToMarkdown(html)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(md, "Title") || !strings.Contains(md, "Body text") {
|
||||
t.Fatalf("conversion failed: %q", md)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// ContactMarkdown formats a CRM contact or company for preview.
|
||||
func ContactMarkdown(m map[string]any) string {
|
||||
name := str(m, "displayName")
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(str(m, "firstName") + " " + str(m, "lastName"))
|
||||
}
|
||||
kind := "Person"
|
||||
if boolVal(m, "isCompany") {
|
||||
kind = "Company"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", name)
|
||||
fmt.Fprintf(&b, "**Type:** %s\n\n", kind)
|
||||
if about := str(m, "about"); about != "" {
|
||||
fmt.Fprintf(&b, "## About\n\n%s\n\n", about)
|
||||
}
|
||||
appendInfoList(&b, m)
|
||||
return strings.TrimSpace(b.String()) + "\n"
|
||||
}
|
||||
|
||||
// OpportunityMarkdown formats a CRM deal for preview.
|
||||
func OpportunityMarkdown(m map[string]any) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
|
||||
if v := m["bidValue"]; v != nil {
|
||||
fmt.Fprintf(&b, "**Value:** %v %s\n\n", v, str(m, "bidCurrency"))
|
||||
}
|
||||
if d := str(m, "description"); d != "" {
|
||||
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
|
||||
}
|
||||
return strings.TrimSpace(b.String()) + "\n"
|
||||
}
|
||||
|
||||
// MailMarkdown formats a mail message for terminal preview (HTML body is converted to markdown).
|
||||
func MailMarkdown(m map[string]any) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
|
||||
fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from"))
|
||||
if to := mailAddressLine(m, "to"); to != "" {
|
||||
fmt.Fprintf(&b, "**To:** %s\n\n", to)
|
||||
}
|
||||
if cc := mailAddressLine(m, "cc"); cc != "" {
|
||||
fmt.Fprintf(&b, "**Cc:** %s\n\n", cc)
|
||||
}
|
||||
if date := str(m, "date"); date != "" {
|
||||
fmt.Fprintf(&b, "**Date:** %s\n\n", date)
|
||||
}
|
||||
if body := mailBodyMarkdown(m); body != "" {
|
||||
fmt.Fprintf(&b, "---\n\n%s\n", body)
|
||||
}
|
||||
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.
|
||||
func EventMarkdown(m map[string]any) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
|
||||
fmt.Fprintf(&b, "**Start:** %s\n\n", str(m, "start"))
|
||||
fmt.Fprintf(&b, "**End:** %s\n\n", str(m, "end"))
|
||||
if d := str(m, "description"); d != "" {
|
||||
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
|
||||
}
|
||||
return strings.TrimSpace(b.String()) + "\n"
|
||||
}
|
||||
|
||||
// TaskMarkdown formats a project or CRM task for preview.
|
||||
func TaskMarkdown(m map[string]any) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
|
||||
if s := str(m, "status"); s != "" {
|
||||
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
|
||||
}
|
||||
if d := str(m, "deadline"); d != "" {
|
||||
fmt.Fprintf(&b, "**Deadline:** %s\n\n", d)
|
||||
}
|
||||
if desc := str(m, "description"); desc != "" {
|
||||
fmt.Fprintf(&b, "## Description\n\n%s\n\n", desc)
|
||||
}
|
||||
return strings.TrimSpace(b.String()) + "\n"
|
||||
}
|
||||
|
||||
// ProjectMarkdown formats a project for preview.
|
||||
func ProjectMarkdown(m map[string]any) string {
|
||||
var b strings.Builder
|
||||
title := str(m, "title")
|
||||
if title == "" {
|
||||
title = str(m, "name")
|
||||
}
|
||||
fmt.Fprintf(&b, "# %s\n\n", title)
|
||||
if s := str(m, "status"); s != "" {
|
||||
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
|
||||
}
|
||||
if d := str(m, "description"); d != "" {
|
||||
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
|
||||
}
|
||||
return strings.TrimSpace(b.String()) + "\n"
|
||||
}
|
||||
|
||||
// EntityMarkdown picks a formatter based on item kind.
|
||||
func EntityMarkdown(kind string, m map[string]any) string {
|
||||
switch kind {
|
||||
case "contact", "person", "company":
|
||||
return ContactMarkdown(m)
|
||||
case "opportunity":
|
||||
return OpportunityMarkdown(m)
|
||||
case "mail":
|
||||
return MailMarkdown(m)
|
||||
case "event":
|
||||
return EventMarkdown(m)
|
||||
case "task", "crm_task":
|
||||
return TaskMarkdown(m)
|
||||
case "project":
|
||||
return ProjectMarkdown(m)
|
||||
default:
|
||||
return mapToMarkdown(m)
|
||||
}
|
||||
}
|
||||
|
||||
func mapToMarkdown(m map[string]any) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Details\n\n")
|
||||
for k, v := range m {
|
||||
fmt.Fprintf(&b, "- **%s:** %v\n", k, v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendInfoList(b *strings.Builder, m map[string]any) {
|
||||
if infos, ok := m["contactInfos"].([]any); ok && len(infos) > 0 {
|
||||
b.WriteString("## Contact info\n\n")
|
||||
for _, raw := range infos {
|
||||
if row, ok := raw.(map[string]any); ok {
|
||||
fmt.Fprintf(b, "- %s: %s\n", str(row, "infoType"), str(row, "data"))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func stripHTML(s string) string {
|
||||
s = htmlTagRe.ReplaceAllString(s, "")
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func str(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
if m[key] == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(m[key])
|
||||
}
|
||||
|
||||
func boolVal(m map[string]any, key string) bool {
|
||||
v, ok := m[key].(bool)
|
||||
return ok && v
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//go:build integration
|
||||
|
||||
package preview_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
func skipWithoutLiveAPI(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
||||
t.Skip("ONLYOFFICE_URL not set")
|
||||
}
|
||||
}
|
||||
|
||||
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
|
||||
t.Helper()
|
||||
skipWithoutLiveAPI(t)
|
||||
client, err := bootstrap.NewClient(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fetch.Loader{Client: client}, context.Background()
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewProjectFromAPI(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")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.EntityMarkdown(string(model.KindProject), raw)
|
||||
if md == "" {
|
||||
t.Fatal("empty markdown")
|
||||
}
|
||||
if !strings.Contains(md, items[0].Title) {
|
||||
t.Fatalf("markdown missing project title %q:\n%s", items[0].Title, md)
|
||||
}
|
||||
rendered, err := preview.RenderMarkdown(md, 80)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rendered == "" {
|
||||
t.Fatal("glamour produced empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewContactFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectContacts})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no contacts")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.ContactMarkdown(raw)
|
||||
if md == "" || !strings.Contains(md, "#") {
|
||||
t.Fatalf("unexpected contact markdown: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectOpportunities})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no opportunities")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.OpportunityMarkdown(raw)
|
||||
if !strings.Contains(md, items[0].Title) {
|
||||
t.Fatalf("markdown missing deal title:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewMailFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("inbox empty")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.MailMarkdown(raw)
|
||||
if md == "" || !strings.Contains(md, "From:") {
|
||||
t.Fatalf("unexpected mail markdown:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewEventFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectCalendar})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no events in range")
|
||||
}
|
||||
md := preview.EventMarkdown(items[0].Raw)
|
||||
if !strings.Contains(md, items[0].Title) {
|
||||
t.Fatalf("markdown missing event title:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewTaskFromAPI(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")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.TaskMarkdown(raw)
|
||||
if !strings.Contains(md, items[0].Title) {
|
||||
t.Fatalf("markdown missing task title:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
var csvItem *model.Item
|
||||
for i := range items {
|
||||
if strings.HasSuffix(strings.ToLower(items[i].Title), ".csv") {
|
||||
csvItem = &items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if csvItem == nil {
|
||||
t.Skip("no csv file in project documents")
|
||||
}
|
||||
var buf strings.Builder
|
||||
n, err := loader.Client.DownloadFile(ctx, csvItem.ID, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadFile: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("empty download")
|
||||
}
|
||||
md, err := preview.CSVToMarkdownTable([]byte(buf.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("CSVToMarkdownTable: %v", err)
|
||||
}
|
||||
if !strings.Contains(md, "|") {
|
||||
t.Fatalf("expected pipe table: %q", md)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/glamour"
|
||||
)
|
||||
|
||||
// RenderMarkdown renders GitHub-flavoured markdown for terminal display.
|
||||
func RenderMarkdown(md string, width int) (string, error) {
|
||||
if md == "" {
|
||||
return "", nil
|
||||
}
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Render(md)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package preview_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
func TestRenderMarkdown(t *testing.T) {
|
||||
out, err := preview.RenderMarkdown("# Hello\n\nWorld", 40)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("empty render output")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(out), "hello") {
|
||||
t.Fatalf("expected rendered heading: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownEmpty(t *testing.T) {
|
||||
out, err := preview.RenderMarkdown("", 40)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(out) != "" {
|
||||
t.Fatalf("expected empty, got %q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
xls "github.com/eslider/go-xls/v2"
|
||||
)
|
||||
|
||||
// CSVToMarkdownTable parses CSV bytes into a GitHub pipe table markdown string.
|
||||
func CSVToMarkdownTable(data []byte) (string, error) {
|
||||
tab, err := parseCSV(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := xls.WriteMarkdownTable(&buf, tab); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()) + "\n", nil
|
||||
}
|
||||
|
||||
func parseCSV(data []byte) (xls.Table, error) {
|
||||
r := csv.NewReader(bytes.NewReader(data))
|
||||
r.TrimLeadingSpace = true
|
||||
records, err := r.ReadAll()
|
||||
if err != nil {
|
||||
return xls.Table{}, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return xls.Table{}, fmt.Errorf("preview: empty csv")
|
||||
}
|
||||
return xls.Table{Columns: records[0], Rows: records[1:]}, nil
|
||||
}
|
||||
|
||||
// JSONToMarkdown wraps JSON in a fenced code block for preview.
|
||||
func JSONToMarkdown(data []byte) (string, error) {
|
||||
var v any
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pretty, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("```json\n%s\n```\n", string(pretty)), nil
|
||||
}
|
||||
|
||||
// FileBytesToMarkdown picks a preview format based on file extension.
|
||||
func FileBytesToMarkdown(name string, data []byte) (string, error) {
|
||||
ext := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(ext, ".csv"):
|
||||
return CSVToMarkdownTable(data)
|
||||
case strings.HasSuffix(ext, ".json"):
|
||||
return JSONToMarkdown(data)
|
||||
case strings.HasSuffix(ext, ".md"), strings.HasSuffix(ext, ".txt"):
|
||||
return string(data), nil
|
||||
default:
|
||||
return fmt.Sprintf("```\n(binary or unsupported preview for %s, %d bytes)\n```\n", name, len(data)), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package preview_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
func TestCSVToMarkdownTable(t *testing.T) {
|
||||
csv := "name,score\nAlice,10\nBob,20\n"
|
||||
md, err := preview.CSVToMarkdownTable([]byte(csv))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(md, "name") || !strings.Contains(md, "Alice") {
|
||||
t.Fatalf("table missing data: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "|") {
|
||||
t.Fatalf("expected pipe table: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONToMarkdown(t *testing.T) {
|
||||
md, err := preview.JSONToMarkdown([]byte(`{"a":1}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(md, "```") || !strings.Contains(md, `"a"`) {
|
||||
t.Fatalf("expected fenced json: %q", md)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
package ui_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/ui"
|
||||
)
|
||||
|
||||
func TestKeyActionMenuDown(t *testing.T) {
|
||||
if got := ui.KeyAction("j", model.FocusMenu); got != ui.ActionMoveDown {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionShiftTabPrevPane(t *testing.T) {
|
||||
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionFilter(t *testing.T) {
|
||||
if got := ui.KeyAction("f", model.FocusList); got != ui.ActionFilter {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidths(t *testing.T) {
|
||||
menu, list, preview := ui.LayoutWidthsLegacy(120)
|
||||
if menu+list+preview != 120 {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ui
|
||||
|
||||
import "github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
|
||||
// Action is a keyboard command outcome for the TUI.
|
||||
type Action int
|
||||
|
||||
const (
|
||||
ActionNone Action = iota
|
||||
ActionMoveUp
|
||||
ActionMoveDown
|
||||
ActionMoveLeft
|
||||
ActionMoveRight
|
||||
ActionSort
|
||||
ActionToggleSelect
|
||||
ActionFocusDetail
|
||||
ActionToggleMenuPane
|
||||
ActionToggleListPane
|
||||
ActionToggleDetailPane
|
||||
ActionFilter
|
||||
ActionNextPane
|
||||
ActionPrevPane
|
||||
ActionRefresh
|
||||
ActionQuit
|
||||
)
|
||||
|
||||
// KeyAction maps a key string and focused pane to an action.
|
||||
func KeyAction(key string, pane model.FocusPane) Action {
|
||||
switch key {
|
||||
case "q", "ctrl+c":
|
||||
return ActionQuit
|
||||
case "tab":
|
||||
if pane != model.FocusPreview {
|
||||
return ActionNextPane
|
||||
}
|
||||
case "shift+tab", "backtab":
|
||||
if pane != model.FocusPreview {
|
||||
return ActionPrevPane
|
||||
}
|
||||
case "r":
|
||||
return ActionRefresh
|
||||
case "f", "/":
|
||||
if pane == model.FocusList {
|
||||
return ActionFilter
|
||||
}
|
||||
if pane == model.FocusMenu {
|
||||
return ActionFilter
|
||||
}
|
||||
case "up", "k":
|
||||
return ActionMoveUp
|
||||
case "down", "j":
|
||||
return ActionMoveDown
|
||||
case "s":
|
||||
if pane == model.FocusList {
|
||||
return ActionSort
|
||||
}
|
||||
case "left", "h":
|
||||
return ActionMoveLeft
|
||||
case "right", "l":
|
||||
return ActionMoveRight
|
||||
case " ":
|
||||
if pane == model.FocusList {
|
||||
return ActionToggleSelect
|
||||
}
|
||||
case "v", "p":
|
||||
if pane == model.FocusList {
|
||||
return ActionFocusDetail
|
||||
}
|
||||
case "alt+1":
|
||||
return ActionToggleMenuPane
|
||||
case "alt+2":
|
||||
return ActionToggleListPane
|
||||
case "alt+3":
|
||||
return ActionToggleDetailPane
|
||||
}
|
||||
return ActionNone
|
||||
}
|
||||
|
||||
// LayoutWidthsLegacy is kept for tests that expect the old signature.
|
||||
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
|
||||
pw := LayoutWidths(total, defaultPaneVisibility())
|
||||
return pw.Menu, pw.List, pw.Detail
|
||||
}
|
||||
|
||||
// ResolveMoveUp returns the action for upward navigation keys.
|
||||
func ResolveMoveUp(pane model.FocusPane) Action {
|
||||
switch pane {
|
||||
case model.FocusMenu, model.FocusList, model.FocusPreview:
|
||||
return ActionMoveUp
|
||||
default:
|
||||
return ActionNone
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user