diff --git a/client.go b/client.go index b187cc9..4997f27 100644 --- a/client.go +++ b/client.go @@ -14,6 +14,7 @@ import ( "net/http/cookiejar" "os" "strings" + "sync" ) // Client of OnlyOffice API uses credentials to get a token and query the API @@ -32,6 +33,9 @@ type Client struct { defaults Defaults // optional fallbacks for calendar/project IDs selfID string // cached /api/2.0/people/@self id noteCatID int // cached CRM history category id for "note" + + folderTitles map[string]string // cached Documents folder id -> title (F9) + folderTitlesMu sync.Mutex } // NewClient returns a new Client backed by http.DefaultClient. diff --git a/cmd/oo/search.go b/cmd/oo/search.go index 481800b..c61b4ea 100644 --- a/cmd/oo/search.go +++ b/cmd/oo/search.go @@ -71,7 +71,12 @@ func searchCmd() *cobra.Command { } rows := make([]map[string]any, 0, len(hits)) for _, h := range hits { + folderPath := h.Path + if len(folderPath) == 0 && h.ParentID != "" { + folderPath = []string{h.ParentID} + } rows = append(rows, map[string]any{ + "path": c.UniquePath(cmd.Context(), folderPath, h.Title), "id": h.ID, "title": h.Title, "folder": h.ParentID, @@ -83,7 +88,7 @@ func searchCmd() *cobra.Command { printJSON(rows) return nil } - printTable([]string{"id", "title", "folder", "score", "highlight"}, rows) + printTable([]string{"path", "id", "title", "folder", "score", "highlight"}, rows) return nil }, } diff --git a/file_es.go b/file_es.go index e0761e6..a1999d8 100644 --- a/file_es.go +++ b/file_es.go @@ -274,13 +274,19 @@ func parseESSearchResponse(raw []byte) ([]SearchHit, error) { if h.Source.ID == 0 { id = h.ID } + // Folders is the ancestor breadcrumb in root → leaf order, so the last + // entry is the immediate parent (the previous "first" value was the + // project root, which made every result look like it lived in #522). var parent string path := make([]string, 0, len(h.Source.Folders)) - for i, f := range h.Source.Folders { - path = append(path, f.FolderID) - if i == 0 { - parent = f.FolderID + for _, f := range h.Source.Folders { + if strings.TrimSpace(f.FolderID) == "" { + continue } + path = append(path, f.FolderID) + } + if len(path) > 0 { + parent = path[len(path)-1] } hits = append(hits, SearchHit{ Entry: Entry{ diff --git a/file_es_test.go b/file_es_test.go index ae7bc7f..c09deed 100644 --- a/file_es_test.go +++ b/file_es_test.go @@ -183,8 +183,9 @@ func TestParseESSearchResponse(t *testing.T) { if h0.ID != "2395" || h0.Title != "Rechnung-4711.pdf" || h0.Kind != File { t.Errorf("hit0 entry = %+v", h0.Entry) } - if h0.ParentID != "438" || !reflect.DeepEqual(h0.Path, []string{"438", "11"}) { - t.Errorf("hit0 path = %v parent = %q", h0.Path, h0.ParentID) + // folders is root → leaf; the immediate parent is the last entry. + if h0.ParentID != "11" || !reflect.DeepEqual(h0.Path, []string{"438", "11"}) { + t.Errorf("hit0 path = %v parent = %q, want parent 11", h0.Path, h0.ParentID) } if h0.Score != 7.31 { t.Errorf("hit0 score = %v", h0.Score) diff --git a/files_path.go b/files_path.go new file mode 100644 index 0000000..2b3c377 --- /dev/null +++ b/files_path.go @@ -0,0 +1,79 @@ +package onlyoffice + +// Human-readable folder paths for search results (F9). The OnlyOffice ES +// index stores only ancestor folder ids; titles live in the Documents tree, so +// resolving a path costs one GET /api/2.0/files/{id} per distinct folder, +// cached on the client. Folders that cannot be listed (e.g. a section root) +// fall back to their id, so a path is always produced. + +import ( + "context" + "strings" +) + +// FolderTitle returns the title of a Documents folder id, cached on the client. +// An empty id yields an empty title. Unknown/unlistable ids (section roots) +// return ("", nil) so callers can fall back to the id. +func (c *Client) FolderTitle(ctx context.Context, folderID string) (string, error) { + folderID = strings.TrimSpace(folderID) + if folderID == "" { + return "", nil + } + c.folderTitlesMu.Lock() + if c.folderTitles != nil { + if t, ok := c.folderTitles[folderID]; ok { + c.folderTitlesMu.Unlock() + return t, nil + } + } + c.folderTitlesMu.Unlock() + + title := "" + out, err := c.ListFolder(ctx, folderID) + if err == nil { + if cur, ok := out["current"].(map[string]any); ok { + if s, ok := cur["title"].(string); ok { + title = strings.TrimSpace(s) + } + } + } + + c.folderTitlesMu.Lock() + if c.folderTitles == nil { + c.folderTitles = map[string]string{} + } + c.folderTitles[folderID] = title + c.folderTitlesMu.Unlock() + return title, nil +} + +// FolderPath resolves an ancestor folder id chain (root → leaf, as the ES +// backend reports it) into folder titles, falling back to the id when a title +// cannot be read. The result never fails on a single lookup: only the whole +// call honours ctx cancellation. +func (c *Client) FolderPath(ctx context.Context, ids []string) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + if err := ctx.Err(); err != nil { + break + } + title, err := c.FolderTitle(ctx, id) + if err != nil || title == "" { + title = id + } + out = append(out, title) + } + return out +} + +// UniquePath builds a stable, human-readable, unique path for a result: the +// resolved folder chain plus the file title. "." separates nothing — the +// segments are joined with "/", matching the Documents breadcrumb the web UI +// shows. +func (c *Client) UniquePath(ctx context.Context, folderPath []string, title string) string { + parts := c.FolderPath(ctx, folderPath) + if t := strings.TrimSpace(title); t != "" { + parts = append(parts, t) + } + return strings.Join(parts, "/") +} diff --git a/files_path_integration_test.go b/files_path_integration_test.go new file mode 100644 index 0000000..e68858f --- /dev/null +++ b/files_path_integration_test.go @@ -0,0 +1,35 @@ +//go:build integration + +package onlyoffice + +import ( + "context" + "strings" + "testing" +) + +// TestIntegrationFolderPath resolves the real Fibu EDL folder chain +// (project root 522 → Eingangsrechnungen 647 → 2025 649) to titles. +func TestIntegrationFolderPath(t *testing.T) { + creds := GetEnvironmentCredentials() + if strings.TrimSpace(creds.Url) == "" || strings.TrimSpace(creds.User) == "" { + t.Skip("no ONLYOFFICE_URL/USER credentials") + } + c := NewClient(creds) + ctx := context.Background() + + path := c.FolderPath(ctx, []string{"522", "647", "649"}) + if len(path) != 3 { + t.Fatalf("FolderPath returned %v, want 3 segments", path) + } + for i, seg := range path { + if strings.TrimSpace(seg) == "" { + t.Errorf("segment %d empty: %v", i, path) + } + } + full := c.UniquePath(ctx, []string{"522", "647", "649"}, "Rechnung-x.pdf") + if !strings.HasSuffix(full, "Rechnung-x.pdf") || !strings.Contains(full, "/") { + t.Errorf("UniquePath = %q, want a slash-joined path ending in the file", full) + } + t.Logf("path=%v full=%q", path, full) +}