Files
dhkatzandClaude Opus 5.5 171e8173af
CI / build (push) Successful in 34s
Recently added, shop sorting, and outfit cards with T5 products
- 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]>
2026-10-30 02:45:02 -07:00

176 lines
4.9 KiB
Go

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
}