From c59ad645eada71d0b6ba8d4c5f29e5db44471ae5 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 16 Sep 2026 17:37:15 +0000 Subject: [PATCH] docs(files): add unified file client contract (#39) --- AGENTS.md | 3 +- README.md | 12 ++- docs/unified-file-client.md | 195 ++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 docs/unified-file-client.md diff --git a/AGENTS.md b/AGENTS.md index e9bdaaf..f6259f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,11 +10,12 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - `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 linear backoff (no jitter) on 429/502/503/504; every bulk tool routes API calls through it. **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md). + - **Unified file client (epic #34) — `file_core.go`, `file_rest.go`, `file_dav.go`, `file_pg.go`, `file_es.go`, `file_es_text.go`, `file_text_index.go`, `file_facade.go`.** `file_core.go` — model (`Entry`, `Kind`) + `FileStore`/`Searcher`; `file_rest.go`/`file_dav.go` — REST/WebDAV adapters; `file_pg.go` — **read-only** SQL store (PostgreSQL/MySQL, `ErrReadOnly` on writes); `file_es.go` — OnlyOffice Elasticsearch searcher; `file_es_text.go`/`file_text_index.go` — own PDF/scan index (`oo_docs_text`, PDF attachments via pdfdetach); `file_facade.go` — `FileClient` with read/write/search order and transient fallback. Use `c.Files()` (facade) or `c.FileStore("rest"|"dav")`; 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`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|rename-file|rename-folder|download|fileops`). + - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm.go`, `crm_tasks.go`, `catalog.go`, `docs.go`, `dav.go`, `search.go`, `index.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|rename-file|rename-folder|download|fileops`); `search.go` runs `oo search QUERY` (name/content, `--backend oo|own`); `index.go` fills the own full-text index (`oo index folder|files`, see [`docs/unified-file-client.md`](docs/unified-file-client.md)). - CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. - **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`. - **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables. diff --git a/README.md b/README.md index bd97800..43f3783 100644 --- a/README.md +++ b/README.md @@ -678,7 +678,7 @@ oo dav download 22881 --to ./copy.pdf # default path: ./ oo dav fileops # active move/copy operations (status polling) ``` -### Search (`oo search`) +### 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 @@ -718,6 +718,16 @@ 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")`, or register a backend on the facade +(`RegisterStore`/`RegisterSearcher`). Contract, model (`Entry`/`Kind`), +fallback rules, env names and how to add a backend: +[`docs/unified-file-client.md`](docs/unified-file-client.md). + ### Bulk tools (`cmd/`) Small single-purpose binaries for bulk Documents work. All of them pace diff --git a/docs/unified-file-client.md b/docs/unified-file-client.md new file mode 100644 index 0000000..752255f --- /dev/null +++ b/docs/unified-file-client.md @@ -0,0 +1,195 @@ +--- +type: reference +status: current +related: + - README.md + - file_core.go + - file_facade.go + - docs/elasticsearch.md + - docs/community-server-db.md +--- + +# Unified file client — контракт файловых бэкендов + +## Что это + +Один файловый клиент на все бэкенды (эпик #34). Модель и интерфейсы — +`file_core.go`. Фасад `FileClient` — `file_facade.go`. Бэкенды: +REST, WebDAV, SQL (PostgreSQL/MySQL), Elasticsearch. Правило одно: +код зовёт `c.Files()` и не знает про транспорт. + +## Модель + +- `Kind` — `File` (0) или `Folder` (1). +- `Entry` — бэкенд-независимая строка: `ID`, `ParentID`, `Title`, `Kind`, + `Size`, `MIME`, `Created`, `Modified`, `Updated` (сырая строка API), + `Version`, `Provider`, `FilesCount`/`FoldersCount` (папки). + Чего бэкенд не даёт — остаётся в нуле. +- `SearchQuery` — `Text`, `InContent`, `FolderID`, `Extensions`, `Limit`. +- `SearchHit` — `Entry` + `Score`, `Highlight`, `Path`. + +## Интерфейсы + +`FileStore` — операции с файлами: + +```go +type FileStore interface { + Name() string + List(ctx, parentID) ([]Entry, error) + Stat(ctx, id) (Entry, error) + CreateFolder(ctx, parentID, title) (Entry, error) + Upload(ctx, parentID, title, r) (Entry, error) + Download(ctx, id, w) (int64, error) + Move(ctx, ids, parentID) error + Copy(ctx, ids, parentID) error + Rename(ctx, id, title) error + Delete(ctx, ids) error +} +``` + +`Searcher` — поиск (необязательный): + +```go +type Searcher interface { + Search(ctx, q SearchQuery) ([]SearchHit, error) + Name() string +} +``` + +`TextIndex` (`file_es_text.go`) — свой индекс: `Put`, `Delete`, `Search`, +`Name`. `ESTextIndex` реализует и `Searcher`, и `TextIndex`. + +## Бэкенды + +| бэкенд | провайдер | файл | что умеет | +|--------|-----------|------|-----------| +| REST | `rest` | `file_rest.go` | read + write, Documents API | +| WebDAV | `dav` | `file_dav.go` | read + write, Documents fileops | +| SQL | `postgres` / `mysql` | `file_pg.go` | **read-only** | +| OnlyOffice ES | `elasticsearch` | `file_es.go` | поиск (имя + контент Office) | +| свой ES-индекс | `es-text` | `file_es_text.go` | поиск + запись (PDF/сканы) | + +- REST: `Stat` знает только файлы; папки — через `List`. +- WebDAV: `Move`/`Copy`/`Delete` сперва `Stat`-ят id (папка/файл), потом зовут + fileops. +- SQL: `List`/`Stat`/`Download`/`Search` (по имени). Все write-методы → + `ErrReadOnly`. `Download` идёт в S3/MinIO по layout портала. +- OnlyOffice ES: индекс `files_file`, контент только для docx/xlsx/pptx. +- Свой ES: индекс `oo_docs_text`, контент из `internal/docpipe`, в т.ч. + встроенные PDF-вложения. + +## Фасад `FileClient` + +`c.Files()` → `*FileClient`. Он же реализует `FileStore`, старый код +компилируется. + +- `Read()` — первый зарегистрированный из `readOrder`: + `postgres` → `rest` → `dav`. +- `Write()` — первый из `writeOrder`: `rest` → `dav`. SQL не пишет. +- `Search()` — первый из `searchOrder`: `elasticsearch`. Нет бэкенда → + ошибка (`ONLYOFFICE_ES_URL`). +- `RegisterStore(name, s)` / `RegisterSearcher(name, s)` — добавить бэкенд. + +Fallback: + +- `List`/`Stat` идут по `readOrder`; переходят к следующему только на + transient-ошибке (429/502/503/504). Иначе ошибка финальная. +- `Download` **без** fallback: часть байтов уже в `w`, второй бэкенд допишет. +- Запись (`CreateFolder`/`Upload`/`Move`/`Copy`/`Rename`/`Delete`) — только + `Write()`, без fallback. + +`newFileClient` сам кладёт `rest` и `dav`; ES-поиск — если задан +`ONLYOFFICE_ES_URL`. SQL-стор регистрирует вызывающий: фасад создаётся на +каждый `c.Files()`, регистрируй на том же экземпляре. + +```go +f := c.Files() +if pg, err := onlyoffice.NewPGStore(onlyoffice.PGConfigFromEnv()); err == nil { + defer pg.Close() + f.RegisterStore(onlyoffice.ProviderPG, pg) +} +entries, _ := f.List(ctx, "649") // пойдёт в SQL +``` + +## CLI + +```bash +# поиск: --backend oo (индекс OnlyOffice) | own (свой oo_docs_text) +oo search "Rechnung" +oo search "Mahngebühr" --content +oo search "S1021" --content --backend own --folder 634 --limit 50 --json + +# наполнение своего индекса (PDF/сканы, idempotent upsert по file id) +oo index folder 634 +oo index folder 634 --recursive --exts pdf,png --limit 100 +oo index files 3576 3578 +oo index folder 634 --dry-run +``` + +`oo index` флаги: `--recursive`, `--exts` (default `pdf`), `--limit`, +`--workers` (3), `--lang` (`deu+eng`), `--min-chars`, `--work-dir`, +`--backend rest|dav`, `--dry-run`, `--json`. + +Библиотека: + +```go +idx, _ := onlyoffice.NewESTextIndex(onlyoffice.ESTextConfigFromEnv()) +ti := onlyoffice.NewTextIndexer(store, idx) // store = FileStore +res, _ := ti.IndexFolder(ctx, "634", onlyoffice.IndexOptions{Recursive: true}) +``` + +## Env (только имена) + +| env | default | кто читает | +|-----|---------|------------| +| `ONLYOFFICE_ES_URL` | — | ES (оба индекса), обязателен | +| `ONLYOFFICE_ES_INDEX` | `files_file` | индекс OnlyOffice | +| `ONLYOFFICE_ES_TEXT_INDEX` | `oo_docs_text` | свой индекс | +| `ONLYOFFICE_TENANT` | пусто | фильтр `tenantId` | +| `ONLYOFFICE_DSN` | — | SQL DSN (MySQL/PostgreSQL) | +| `ONLYOFFICE_PG_DRIVER` | auto | `postgres` / `mysql` | +| `ONLYOFFICE_PG_TENANT` | `ONLYOFFICE_TENANT` | SQL tenant | +| `ONLYOFFICE_PG_HOST` `_PORT` `_USER` `_PASSWORD` `_DBNAME` `_SSLMODE` | — | DSN по частям | +| `MINIO_ENDPOINT` `MINIO_BUCKET` `MINIO_ACCESS_KEY` `MINIO_SECRET_KEY` | — | download SQL-стора | +| `OO_URL` `OO_USER` `OO_PASS` | — | CLI-алиасы | + +Имена — в [`.env.example`](../.env.example). Секретов в репо нет. + +## Ограничения + +- OnlyOffice ES: контент только Office-форматов. PDF — только по имени. + Встроенные вложения PDF сервер не индексирует. +- Свой индекс `oo_docs_text`: покрывает PDF/сканы и вложения (pdfdetach), но + наполняется вручную (`oo index`) и идемпотентен. Фильтр `folder` — id папки, + не путь. Дубли дают несколько строк — дедуп на потребителе. +- SQL: read-only. `InContent` игнорируется (только имя). Download — через + MinIO-схему, не HTTP. +- ES: без auth, слушает localhost внутри VM — нужен SSH-туннель + (см. [elasticsearch.md](elasticsearch.md)). +- `oo index` качает каждый файл и для сканов гоняет OCR — медленно; отсюда + `--limit` и `--exts`. Нужен `pdfdetach` (poppler); без него — только тело PDF. + +## Как добавить бэкенд + +1. Файл `file_.go`. Реализуй `FileStore` (`Name` + 9 методов). Нужен + поиск — добавь `Searcher`; нужна запись своего индекса — `TextIndex`. +2. Добавь const провайдера рядом с `ProviderREST`/`ProviderDAV`. +3. Зарегистрируй: в `newFileClient` или снаружи через + `RegisterStore`/`RegisterSearcher`. +4. Внеси имя в `readOrder` / `writeOrder` / `searchOrder`. +5. Есть CLI-команда — добавь значение в `--backend`. +6. Тесты: unit (чистые builders/парсеры, без сети) + интеграционный + (`//go:build integration`, skip без кред). + +## Тесты + +```bash +go test ./... # unit, без сети +go test -tags=integration ./... # live (креды в .env) +go test ./ -run 'FileStore|Facade|ESText|PG' +``` + +## См. также + +- [elasticsearch.md](elasticsearch.md) — индекс OnlyOffice и свой `oo_docs_text`. +- [community-server-db.md](community-server-db.md) — SQL-стор и схема БД. -- 2.54.0