- The inventory's Recently Added puts T5 products added to the library in the last 30 days first. - The shop's T5 listing follows its sort menu: Best Match ranks by name, Newest by upload date (oldest first ascending); ratings and prices (T5 products have neither) sort by newest. - Outfit cards: outfit_products.php gets the outfit's community products added (not for sale), find_bundle.php is asked about IMVU's products only, and removing one in the card (remove_pids) keeps it from IMVU and forgets it in the outfit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
af9158513b
commit
171e8173af
11 files changed
+463
-7
No files matched your search
@@ -153,7 +153,7 @@ func run(logger *log.Logger, listen, ready string, parentPID int, trace bool, pa
|
||||
fetcher.OnLibrary = prods.RefreshInventory
|
||||
prods.Want = fetcher.Want
|
||||
prods.HubDir, prods.Owned = hubProducts, fetcher.Owned
|
||||
prods.Shop, prods.Fetch = client.Products, fetcher.Ensure
|
||||
prods.Shop, prods.Fetch, prods.Library = client.Products, fetcher.Ensure, fetcher.Library
|
||||
prods.AddToLibrary = func(ctx context.Context, id string) error {
|
||||
return client.Call(ctx, "PUT", "/api/v1/library/"+id, map[string]any{}, nil)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type Fetcher struct {
|
||||
|
||||
mu sync.Mutex
|
||||
library map[string]bool // the person's library, as last read
|
||||
entries []hub.LibraryEntry
|
||||
inflight map[string]bool
|
||||
failed map[string]time.Time // products that failed lately (not retried for a while)
|
||||
access string
|
||||
@@ -77,7 +78,7 @@ func (f *Fetcher) SyncLibrary(ctx context.Context) {
|
||||
ids[e.Product.ID] = true
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.library = ids
|
||||
f.library, f.entries = ids, lib
|
||||
f.mu.Unlock()
|
||||
defer func() {
|
||||
if f.OnLibrary != nil {
|
||||
@@ -313,3 +314,10 @@ func (f *Fetcher) Owned(id string) bool {
|
||||
defer f.mu.Unlock()
|
||||
return f.library[id]
|
||||
}
|
||||
|
||||
// Library is the person's library, as last read.
|
||||
func (f *Fetcher) Library() []hub.LibraryEntry {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]hub.LibraryEntry(nil), f.entries...)
|
||||
}
|
||||
+4
-5
@@ -463,11 +463,9 @@ type Files struct {
|
||||
|
||||
// LibraryEntry is a product in the person's library.
|
||||
type LibraryEntry struct {
|
||||
Product struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"product"`
|
||||
StayOn *int `json:"stayOn"`
|
||||
Product Product `json:"product"`
|
||||
StayOn *int `json:"stayOn"`
|
||||
AddedAt time.Time `json:"addedAt"`
|
||||
}
|
||||
|
||||
// Library is the person's library.
|
||||
@@ -503,6 +501,7 @@ type Product struct {
|
||||
Parent int64 `json:"parent"`
|
||||
Image string `json:"image"`
|
||||
Categories []Category `json:"categories"` // the parent's IMVU category path, top first
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Category is an IMVU shop category.
|
||||
|
||||
@@ -54,6 +54,12 @@ func (p *Products) Intercept(x *proxy.Exchange) error {
|
||||
} else if len(x.Body) > 0 {
|
||||
return p.interceptProductInfo(x)
|
||||
}
|
||||
case "/api/outfit_products.php":
|
||||
return p.interceptOutfitProducts(x)
|
||||
case "/api/find_bundle.php":
|
||||
return p.interceptFindBundle(x)
|
||||
case "/api/recently_added_inventory.php":
|
||||
return p.interceptRecent(x)
|
||||
case "/api/shop/search_st.php":
|
||||
return p.interceptShopSearch(x)
|
||||
case "/api/service/shop/svc_catalog_purchase.php":
|
||||
@@ -625,8 +631,20 @@ func (p *Products) interceptOutfits(x *proxy.Exchange) error {
|
||||
if json.Unmarshal(x.Body, &save) != nil {
|
||||
return nil
|
||||
}
|
||||
removed := p.outfitRemovals(save) // the outfit card's remove_pids
|
||||
resave := func() error {
|
||||
body, _ := json.Marshal(save)
|
||||
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, body, x.URL.RawQuery) {
|
||||
return errNoKey
|
||||
}
|
||||
x.Body = body
|
||||
return nil
|
||||
}
|
||||
var products string
|
||||
if json.Unmarshal(save["products"], &products) != nil {
|
||||
if removed {
|
||||
return resave()
|
||||
}
|
||||
return nil // not a save (e.g. set_default_outfit_id)
|
||||
}
|
||||
var kept []string
|
||||
@@ -644,6 +662,9 @@ func (p *Products) interceptOutfits(x *proxy.Exchange) error {
|
||||
kept = append(kept, f)
|
||||
}
|
||||
if stripped == 0 {
|
||||
if removed {
|
||||
return resave()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
save["products"], _ = json.Marshal(strings.Join(kept, ","))
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// The client's outfit card (dialogs/outfit_card) lists an outfit's products from api/outfit_products.php, asks
|
||||
// api/find_bundle.php whether the outfit's products are a bundle on sale, and saves changes (removing products with
|
||||
// remove_pids) to api/outfits.php. IMVU only has the outfit without its community products (see interceptOutfits),
|
||||
// so the proxy adds them to the list, and keeps them out of the rest.
|
||||
|
||||
// interceptOutfitProducts adds an outfit's community products to its product list.
|
||||
func (p *Products) interceptOutfitProducts(x *proxy.Exchange) error {
|
||||
outfit := x.URL.Query().Get("outfit_id")
|
||||
products := p.state.outfit(outfit)
|
||||
if outfit == "" || len(products) == 0 {
|
||||
return nil
|
||||
}
|
||||
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||
var r map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &r) != nil {
|
||||
return body, nil
|
||||
}
|
||||
var list map[string]json.RawMessage
|
||||
if json.Unmarshal(r["result"], &list) != nil {
|
||||
if string(r["result"]) != "[]" { // PHP's empty object
|
||||
p.logf("products: outfit_products.php: unexpected result")
|
||||
return body, nil
|
||||
}
|
||||
list = map[string]json.RawMessage{}
|
||||
}
|
||||
for _, id := range products {
|
||||
if row, ok := p.outfitCardRow(id); ok {
|
||||
list[strconv.FormatInt(row.local, 10)], _ = json.Marshal(row.entry())
|
||||
}
|
||||
}
|
||||
r["result"], _ = json.Marshal(list)
|
||||
return json.Marshal(r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cardRow struct {
|
||||
hub.Product
|
||||
local int64
|
||||
}
|
||||
|
||||
// entry is the outfit card's product entry. Community products can't be in a bundle for sale on IMVU.
|
||||
func (r cardRow) entry() map[string]any {
|
||||
category, categoryID := "", int64(0)
|
||||
if n := len(r.Categories); n > 0 {
|
||||
category, categoryID = categoryText(r.Categories[n-1].Name), r.Categories[n-1].ID
|
||||
}
|
||||
ap := 0
|
||||
if r.Rating == "adult" {
|
||||
ap = 1
|
||||
}
|
||||
return map[string]any{"name": plain(r.Name), "image": r.Image, "category_id": categoryID, "category": category,
|
||||
"is_ap": ap, "is_ap_plus": 0, "is_ufi": 0, "is_bundleable": 0}
|
||||
}
|
||||
|
||||
// outfitCardRow is a community product as the outfit card shows it: from the library or the shop's listing if the
|
||||
// hub listed it, else from its pack.
|
||||
func (p *Products) outfitCardRow(id string) (cardRow, bool) {
|
||||
s := p.byProductID(id)
|
||||
if s == nil {
|
||||
return cardRow{}, false
|
||||
}
|
||||
if p.Library != nil {
|
||||
for _, e := range p.Library() {
|
||||
if e.Product.ID == id {
|
||||
return cardRow{e.Product, s.local}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
m := s.pack.Manifest
|
||||
p.mu.Lock()
|
||||
hp, ok := p.shopSeen[id]
|
||||
p.mu.Unlock()
|
||||
if !ok {
|
||||
hp = hub.Product{ID: id, Name: m.Name, Creator: m.Creator, Rating: m.Rating}
|
||||
}
|
||||
return cardRow{hp, s.local}, true
|
||||
}
|
||||
|
||||
// interceptFindBundle asks IMVU about the outfit's IMVU products only (find_bundle.php?pids=1,2,...).
|
||||
func (p *Products) interceptFindBundle(x *proxy.Exchange) error {
|
||||
parts := strings.Split(x.URL.RawQuery, "&")
|
||||
changed := false
|
||||
for i, part := range parts {
|
||||
name, value, ok := strings.Cut(part, "=")
|
||||
if !ok || name != "pids" {
|
||||
continue
|
||||
}
|
||||
var kept []string
|
||||
for _, f := range strings.Split(value, ",") {
|
||||
if id, ok := parseID(f); ok && IsCommunity(id) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, f)
|
||||
}
|
||||
parts[i] = name + "=" + strings.Join(kept, ",")
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
query := strings.Join(parts, "&")
|
||||
if !p.keys.resign(x.Request.Header, x.Body, x.URL.RawQuery, x.Body, query) {
|
||||
return errNoKey
|
||||
}
|
||||
x.URL.RawQuery = query
|
||||
x.Request.URL.RawQuery = query
|
||||
return nil
|
||||
}
|
||||
|
||||
// outfitRemovals takes community products out of an outfit card save's remove_pids, and out of the outfit as
|
||||
// remembered here. It reports whether the body changed.
|
||||
func (p *Products) outfitRemovals(save map[string]json.RawMessage) bool {
|
||||
var remove []json.RawMessage
|
||||
if json.Unmarshal(save["remove_pids"], &remove) != nil {
|
||||
return false
|
||||
}
|
||||
var kept []json.RawMessage
|
||||
var gone []string
|
||||
for _, raw := range remove {
|
||||
if id, ok := rawID(raw); ok && IsCommunity(id) {
|
||||
if pid, ok := p.state.productID(id); ok {
|
||||
gone = append(gone, pid)
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
if len(kept) == len(remove) {
|
||||
return false
|
||||
}
|
||||
if kept == nil {
|
||||
kept = []json.RawMessage{}
|
||||
}
|
||||
save["remove_pids"], _ = json.Marshal(kept)
|
||||
var outfit json.Number
|
||||
if json.Unmarshal(save["outfit_id"], &outfit) != nil {
|
||||
var s string
|
||||
json.Unmarshal(save["outfit_id"], &s)
|
||||
outfit = json.Number(s)
|
||||
}
|
||||
if outfit != "" && len(gone) > 0 {
|
||||
var left []string
|
||||
for _, pid := range p.state.outfit(outfit.String()) {
|
||||
if !contains(gone, pid) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
if err := p.state.setOutfit(outfit.String(), left); err != nil {
|
||||
p.logf("products: remembering an outfit: %v", err)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOutfitCard(t *testing.T) {
|
||||
h, _ := newHarness(t)
|
||||
local := h.p.byProductID("blue-coat").local
|
||||
h.do("POST", "https://secure.imvu.com/api/xmlrpc/client.php", call("test.avatarInfoForLogin2"), false, false)
|
||||
save := []byte(fmt.Sprintf(`{"outfit_name": "Mine", "products": "80,1,%d"}`, local))
|
||||
h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php", save, true, false)
|
||||
|
||||
// The product list has the community product too, not for sale.
|
||||
_, body := h.do("GET", "https://client-dynamic.imvu.com/api/outfit_products.php?outfit_id=555", nil, true, true)
|
||||
var r struct{ Result map[string]map[string]any }
|
||||
if json.Unmarshal(body, &r) != nil || len(r.Result) != 2 {
|
||||
t.Fatalf("products %s", body)
|
||||
}
|
||||
if e := r.Result[fmt.Sprint(local)]; e == nil || e["name"] != "Blue Coat" || e["is_bundleable"] != float64(0) {
|
||||
t.Fatalf("community entry %v", e)
|
||||
}
|
||||
|
||||
// The bundle check is asked about IMVU's products only (re-signed).
|
||||
h.do("GET", fmt.Sprintf("https://client-dynamic.imvu.com/api/find_bundle.php?pids=80,%d.0,1", local), nil, true, true)
|
||||
if got := h.imvu.asked["find bundle"]; len(got) != 1 || got[0] != "pids=80,1" {
|
||||
t.Fatalf("find_bundle: IMVU got %q", got)
|
||||
}
|
||||
|
||||
// Removing it in the card: IMVU hears of IMVU's removals only, and the outfit forgets it.
|
||||
h.do("POST", "https://client-dynamic.imvu.com/api/outfits.php",
|
||||
[]byte(fmt.Sprintf(`{"outfit_id": "555", "outfit_name": "Mine", "remove_pids": [1, %d]}`, local)), true, false)
|
||||
if got := h.imvu.asked["save outfit"]; len(got) != 2 || !strings.Contains(got[1], `"remove_pids":[1]`) {
|
||||
t.Fatalf("IMVU got %q", got)
|
||||
}
|
||||
if left := h.p.state.outfit("555"); len(left) != 0 {
|
||||
t.Fatalf("outfit still has %v", left)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ type Products struct {
|
||||
Shop func(ctx context.Context, query string) ([]hub.Product, error)
|
||||
Fetch func(ctx context.Context, id string) error
|
||||
AddToLibrary func(ctx context.Context, id string) error
|
||||
// Library, if set, is the person's hub library as last read (for the inventory's Recently Added).
|
||||
Library func() []hub.LibraryEntry
|
||||
|
||||
roomMu sync.Mutex
|
||||
room room
|
||||
|
||||
@@ -73,6 +73,14 @@ func (f *fakeIMVU) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return reply(`{"status": "success"}`)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/outfit_products.php":
|
||||
return reply(`{"result": {"80": {"pid": 80, "name": "Body", "is_bundleable": true}}, "bundle_id": 0}`)
|
||||
case "/api/find_bundle.php":
|
||||
checkSigned(true)
|
||||
f.record("find bundle", []byte(r.URL.RawQuery))
|
||||
return reply(`{"result": 0}`)
|
||||
case "/api/recently_added_inventory.php":
|
||||
return reply(`[31162856, 108363]`)
|
||||
case "/api/shop/product.php":
|
||||
checkSigned(false)
|
||||
f.record("product info", body)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// The inventory's Recently Added (api/recently_added_inventory.php?type=clothing|furni|scenes) is a list of product
|
||||
// IDs from IMVU; T5 products the person added to their library lately go first, newest first.
|
||||
|
||||
// recentFor is how long a T5 product counts as recently added.
|
||||
const recentFor = 30 * 24 * time.Hour
|
||||
|
||||
// IMVU's categories for furniture and rooms (the inventory's FURNI_CATEGORIES and ROOM_CATEGORIES, and rooms' 107).
|
||||
var (
|
||||
furniCategories = map[int64]bool{1027: true, 110: true}
|
||||
roomCategories = map[int64]bool{107: true, 109: true, 366: true, 626: true, 2386: true}
|
||||
)
|
||||
|
||||
// inventoryType is which Recently Added list a product belongs in: clothing (anything worn), furni or scenes.
|
||||
func inventoryType(categories []hub.Category) string {
|
||||
for _, c := range categories {
|
||||
if furniCategories[c.ID] {
|
||||
return "furni"
|
||||
}
|
||||
}
|
||||
for _, c := range categories {
|
||||
if roomCategories[c.ID] {
|
||||
return "scenes"
|
||||
}
|
||||
}
|
||||
return "clothing"
|
||||
}
|
||||
|
||||
func (p *Products) interceptRecent(x *proxy.Exchange) error {
|
||||
if p.Library == nil {
|
||||
return nil
|
||||
}
|
||||
kind := x.URL.Query().Get("type")
|
||||
x.ModifyResponse = func(resp *http.Response, body []byte) ([]byte, error) {
|
||||
var ids []json.RawMessage
|
||||
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &ids) != nil {
|
||||
return body, nil
|
||||
}
|
||||
ours := p.recentLocals(kind, time.Now())
|
||||
if len(ours) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
out := make([]json.RawMessage, 0, len(ours)+len(ids))
|
||||
for _, local := range ours {
|
||||
out = append(out, json.RawMessage(strconv.FormatInt(local, 10)))
|
||||
}
|
||||
return json.Marshal(append(out, ids...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recentLocals is the local IDs of T5 products of that kind added to the library within recentFor, newest first.
|
||||
func (p *Products) recentLocals(kind string, now time.Time) []int64 {
|
||||
lib := p.Library()
|
||||
sort.SliceStable(lib, func(i, j int) bool { return lib[i].AddedAt.After(lib[j].AddedAt) })
|
||||
var out []int64
|
||||
for _, e := range lib {
|
||||
if now.Sub(e.AddedAt) > recentFor || inventoryType(e.Product.Categories) != kind {
|
||||
continue
|
||||
}
|
||||
if s := p.byProductID(e.Product.ID); s != nil && p.inHubDir(e.Product.ID) {
|
||||
out = append(out, s.local)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package products
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t5de.dev/t5de/t5de-proxy/internal/hub"
|
||||
)
|
||||
|
||||
func TestRecentlyAdded(t *testing.T) {
|
||||
h, _ := newHarness(t)
|
||||
hubDir := t.TempDir()
|
||||
for _, id := range []string{"coatcoat22", "sofasofa22", "oldoldold2", "hairhair22"} {
|
||||
writePack(t, hubDir, id, 1)
|
||||
}
|
||||
h.p.dirs = append(h.p.dirs, hubDir)
|
||||
h.p.HubDir = hubDir
|
||||
h.p.Owned = func(string) bool { return true }
|
||||
h.p.refresh(true)
|
||||
now := time.Now()
|
||||
clothes := []hub.Category{{ID: 106}, {ID: 40}, {ID: 716}}
|
||||
h.p.Library = func() []hub.LibraryEntry {
|
||||
return []hub.LibraryEntry{
|
||||
{Product: hub.Product{ID: "coatcoat22", Categories: clothes}, AddedAt: now.Add(-2 * time.Hour)},
|
||||
{Product: hub.Product{ID: "sofasofa22", Categories: []hub.Category{{ID: 1027}, {ID: 1056}}}, AddedAt: now.Add(-time.Hour)},
|
||||
{Product: hub.Product{ID: "oldoldold2", Categories: clothes}, AddedAt: now.Add(-40 * 24 * time.Hour)},
|
||||
{Product: hub.Product{ID: "hairhair22"}, AddedAt: now.Add(-time.Hour)}, // no categories known: worn
|
||||
}
|
||||
}
|
||||
local := func(id string) int64 { return h.p.byProductID(id).local }
|
||||
get := func(kind string) []int64 {
|
||||
_, b := h.do("GET", "http://client-dynamic.imvu.com/api/recently_added_inventory.php?type="+kind, nil, false, false)
|
||||
var ids []int64
|
||||
if json.Unmarshal(b, &ids) != nil {
|
||||
t.Fatalf("%s: %s", kind, b)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
if got, want := fmt.Sprint(get("clothing")), fmt.Sprint([]int64{local("hairhair22"), local("coatcoat22"), 31162856, 108363}); got != want {
|
||||
t.Errorf("clothing %s, want %s", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprint(get("furni")), fmt.Sprint([]int64{local("sofasofa22"), 31162856, 108363}); got != want {
|
||||
t.Errorf("furni %s, want %s", got, want)
|
||||
}
|
||||
if got, want := fmt.Sprint(get("scenes")), fmt.Sprint([]int64{31162856, 108363}); got != want {
|
||||
t.Errorf("scenes %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopSorting(t *testing.T) {
|
||||
day := func(n int) time.Time { return time.Date(2026, 10, n, 0, 0, 0, 0, time.UTC) }
|
||||
list := func() []hub.Product {
|
||||
return []hub.Product{
|
||||
{ID: "a", Name: "Long Red Coat", CreatedAt: day(5)},
|
||||
{ID: "b", Name: "Coat", CreatedAt: day(1)},
|
||||
{ID: "c", Name: "Coats & Jackets Pack", CreatedAt: day(2)},
|
||||
{ID: "d", Name: "Raincoat", CreatedAt: day(4)},
|
||||
{ID: "e", Name: "Hat", Creator: "coat_maker", CreatedAt: day(3)},
|
||||
}
|
||||
}
|
||||
order := func(l []hub.Product) string {
|
||||
s := ""
|
||||
for _, p := range l {
|
||||
s += p.ID
|
||||
}
|
||||
return s
|
||||
}
|
||||
for _, c := range []struct{ query, field, dir, want string }{
|
||||
{"coat", "conversion_score_b", "desc", "bcade"}, // same name, starts with, word, inside a word, creator
|
||||
{"", "conversion_score_b", "desc", "adecb"}, // no search: newest
|
||||
{"coat", "id", "desc", "adecb"}, // Newest Products
|
||||
{"coat", "id", "asc", "bceda"},
|
||||
{"coat", "price", "asc", "adecb"}, // all free: newest
|
||||
{"coat", "user_rating", "desc", "adecb"},
|
||||
} {
|
||||
l := list()
|
||||
sortShop(l, c.query, c.field, c.dir)
|
||||
if got := order(l); got != c.want {
|
||||
t.Errorf("%q %s %s: %s, want %s", c.query, c.field, c.dir, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -37,6 +38,8 @@ func (p *Products) interceptShopSearch(x *proxy.Exchange) error {
|
||||
Page json.Number `json:"page"`
|
||||
Skip json.Number `json:"skip"`
|
||||
PerPage json.Number `json:"results_per_page"`
|
||||
Sort string `json:"sort_field"`
|
||||
Dir string `json:"sort_direction"`
|
||||
Limit json.Number `json:"solr_limit"`
|
||||
}
|
||||
if json.Unmarshal(x.Body, &req) != nil || req.Rating != ShopRating {
|
||||
@@ -67,6 +70,7 @@ func (p *Products) interceptShopSearch(x *proxy.Exchange) error {
|
||||
}
|
||||
p.mu.Unlock()
|
||||
list = inCategories(list, categoryIDs(req.Category))
|
||||
sortShop(list, query, req.Sort, req.Dir)
|
||||
p.fetchForShop(ctx, list)
|
||||
|
||||
var rows []shopRow
|
||||
@@ -244,6 +248,41 @@ func shopAnswer(rows []shopRow, page, skip, perPage, limit int) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// sortShop orders products for the shop's sort menu. T5 products have no ratings or prices, so those sorts are by
|
||||
// newest; Best Match (conversion_score_b, score) with a search puts the closest names first (the same name, then
|
||||
// names starting with it, then words starting with it, then the rest), newest first among equals; Newest
|
||||
// (id) is newest first, or oldest first ascending.
|
||||
func sortShop(list []hub.Product, query, field, direction string) {
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
rank := func(hp hub.Product) int {
|
||||
if q == "" || (field != "conversion_score_b" && field != "score" && field != "") {
|
||||
return 0
|
||||
}
|
||||
name := strings.ToLower(hp.Name)
|
||||
switch {
|
||||
case name == q:
|
||||
return 0
|
||||
case strings.HasPrefix(name, q):
|
||||
return 1
|
||||
case strings.Contains(" "+name, " "+q):
|
||||
return 2
|
||||
case strings.Contains(name, q):
|
||||
return 3
|
||||
}
|
||||
return 4 // matched by creator
|
||||
}
|
||||
oldest := field == "id" && direction == "asc"
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
if ri, rj := rank(list[i]), rank(list[j]); ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
if oldest {
|
||||
return list[i].CreatedAt.Before(list[j].CreatedAt)
|
||||
}
|
||||
return list[i].CreatedAt.After(list[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// categoryIDs reads the shop's category field: IDs separated by commas, as a string or number (empty: all).
|
||||
func categoryIDs(raw json.RawMessage) map[int64]bool {
|
||||
var s string
|
||||
|
||||
Reference in new issue
Block a user