feat(search): full unique path first + immediate folder in results (#51)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 21s
Tests / Test (Go stable) (pull_request) Successful in 26s
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 21s
Tests / Test (Go stable) (pull_request) Successful in 26s
This commit is contained in:
@@ -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.
|
||||
|
||||
+6
-1
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
+10
-4
@@ -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{
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user