feat(search): полный уникальный path первым + ближайшая папка в результатах (#51) #52
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client of OnlyOffice API uses credentials to get a token and query the API
|
// 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
|
defaults Defaults // optional fallbacks for calendar/project IDs
|
||||||
selfID string // cached /api/2.0/people/@self id
|
selfID string // cached /api/2.0/people/@self id
|
||||||
noteCatID int // cached CRM history category id for "note"
|
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.
|
// 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))
|
rows := make([]map[string]any, 0, len(hits))
|
||||||
for _, h := range hits {
|
for _, h := range hits {
|
||||||
|
folderPath := h.Path
|
||||||
|
if len(folderPath) == 0 && h.ParentID != "" {
|
||||||
|
folderPath = []string{h.ParentID}
|
||||||
|
}
|
||||||
rows = append(rows, map[string]any{
|
rows = append(rows, map[string]any{
|
||||||
|
"path": c.UniquePath(cmd.Context(), folderPath, h.Title),
|
||||||
"id": h.ID,
|
"id": h.ID,
|
||||||
"title": h.Title,
|
"title": h.Title,
|
||||||
"folder": h.ParentID,
|
"folder": h.ParentID,
|
||||||
@@ -83,7 +88,7 @@ func searchCmd() *cobra.Command {
|
|||||||
printJSON(rows)
|
printJSON(rows)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
printTable([]string{"id", "title", "folder", "score", "highlight"}, rows)
|
printTable([]string{"path", "id", "title", "folder", "score", "highlight"}, rows)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -274,13 +274,19 @@ func parseESSearchResponse(raw []byte) ([]SearchHit, error) {
|
|||||||
if h.Source.ID == 0 {
|
if h.Source.ID == 0 {
|
||||||
id = h.ID
|
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
|
var parent string
|
||||||
path := make([]string, 0, len(h.Source.Folders))
|
path := make([]string, 0, len(h.Source.Folders))
|
||||||
for i, f := range h.Source.Folders {
|
for _, f := range h.Source.Folders {
|
||||||
path = append(path, f.FolderID)
|
if strings.TrimSpace(f.FolderID) == "" {
|
||||||
if i == 0 {
|
continue
|
||||||
parent = f.FolderID
|
|
||||||
}
|
}
|
||||||
|
path = append(path, f.FolderID)
|
||||||
|
}
|
||||||
|
if len(path) > 0 {
|
||||||
|
parent = path[len(path)-1]
|
||||||
}
|
}
|
||||||
hits = append(hits, SearchHit{
|
hits = append(hits, SearchHit{
|
||||||
Entry: Entry{
|
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 {
|
if h0.ID != "2395" || h0.Title != "Rechnung-4711.pdf" || h0.Kind != File {
|
||||||
t.Errorf("hit0 entry = %+v", h0.Entry)
|
t.Errorf("hit0 entry = %+v", h0.Entry)
|
||||||
}
|
}
|
||||||
if h0.ParentID != "438" || !reflect.DeepEqual(h0.Path, []string{"438", "11"}) {
|
// folders is root → leaf; the immediate parent is the last entry.
|
||||||
t.Errorf("hit0 path = %v parent = %q", h0.Path, h0.ParentID)
|
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 {
|
if h0.Score != 7.31 {
|
||||||
t.Errorf("hit0 score = %v", h0.Score)
|
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