Skip to content

Commit bc29c75

Browse files
committed
wip: Add related_items output
#150
1 parent f2862ac commit bc29c75

File tree

8 files changed

+502
-3
lines changed

8 files changed

+502
-3
lines changed

app/Http/Controllers/Api/V2/AbstractApiV2Controller.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ enum: [
8484
'shops',
8585
'shops.items',
8686
'variants',
87+
'related_items',
8788
]
8889
),
8990
),

app/Http/Controllers/Api/V2/SC/ItemController.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use App\Http\Resources\SC\Item\ItemLinkResource;
1111
use App\Http\Resources\SC\Item\ItemResource;
1212
use App\Models\SC\Item\Item;
13+
use App\Support\QueryBuilder\Includes\IncludedPassthrough;
1314
use Illuminate\Database\Eloquent\Builder;
1415
use Illuminate\Database\Eloquent\ModelNotFoundException;
1516
use Illuminate\Http\Request;
@@ -18,6 +19,7 @@
1819
use Illuminate\Support\Facades\Validator;
1920
use OpenApi\Attributes as OA;
2021
use Spatie\QueryBuilder\AllowedFilter;
22+
use Spatie\QueryBuilder\AllowedInclude;
2123
use Spatie\QueryBuilder\QueryBuilder;
2224
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2325

@@ -55,7 +57,10 @@ public function index(Request $request): AnonymousResourceCollection
5557
AllowedFilter::exact('manufacturer', 'manufacturer.name'),
5658
AllowedFilter::custom('variants', new ItemVariantsFilter),
5759
])
58-
->allowedIncludes(ItemResource::validIncludes())
60+
->allowedIncludes(array_merge(
61+
ItemResource::validIncludes(),
62+
[AllowedInclude::custom('related_items', new IncludedPassthrough)]
63+
))
5964
->paginate($this->limit)
6065
->appends(request()->query());
6166

@@ -105,7 +110,10 @@ public function show(Request $request)
105110
$query->where('uuid', $identifier)
106111
->orWhere('name', $identifier);
107112
})
108-
->allowedIncludes(ItemResource::validIncludes())
113+
->allowedIncludes(array_merge(
114+
ItemResource::validIncludes(),
115+
[AllowedInclude::custom('related_items', new IncludedPassthrough)]
116+
))
109117
->with([
110118
'dimensions',
111119
'manufacturer',
@@ -187,7 +195,10 @@ public function search(ItemSearchRequest $request): JsonResource
187195
AllowedFilter::exact('manufacturer', 'manufacturer.name'),
188196
AllowedFilter::custom('variants', new ItemVariantsFilter),
189197
])
190-
->allowedIncludes(['shops.items']);
198+
->allowedIncludes(array_merge(
199+
['shops.items'],
200+
[AllowedInclude::custom('related_items', new IncludedPassthrough)]
201+
));
191202

192203
if ($request->has('shop') && $request->get('shop') !== null) {
193204
$items->whereRelation('shops', 'uuid', $request->get('shop'));

app/Http/Resources/SC/Item/ItemResource.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use App\Http\Resources\SC\Manufacturer\ManufacturerLinkResource;
3636
use App\Http\Resources\SC\Shop\ShopResource;
3737
use App\Http\Resources\SC\Vehicle\Weapon\VehicleWeaponResource;
38+
use App\Support\Items\RelatedItemsBuilder;
3839
use Illuminate\Http\Request;
3940
use OpenApi\Attributes as OA;
4041

@@ -138,6 +139,58 @@
138139
],
139140
)]
140141

142+
#[OA\Schema(
143+
schema: 'item_related_link_v2',
144+
title: 'Related Item Link',
145+
description: 'Minimal link information for a related item',
146+
properties: [
147+
new OA\Property(property: 'uuid', type: 'string'),
148+
new OA\Property(property: 'name', type: 'string'),
149+
new OA\Property(property: 'variant_name', type: 'string', nullable: true),
150+
new OA\Property(property: 'link', type: 'string'),
151+
],
152+
type: 'object'
153+
)]
154+
155+
#[OA\Schema(
156+
schema: 'item_related_link_ext_v2',
157+
title: 'Related Item Link (Extended)',
158+
description: 'Related item link with basic classification',
159+
allOf: [
160+
new OA\Schema(ref: '#/components/schemas/item_related_link_v2'),
161+
new OA\Schema(
162+
properties: [
163+
new OA\Property(property: 'type', type: 'string', nullable: true),
164+
new OA\Property(property: 'sub_type', type: 'string', nullable: true),
165+
],
166+
type: 'object'
167+
),
168+
],
169+
)]
170+
171+
#[OA\Schema(
172+
schema: 'item_related_items_v2',
173+
title: 'Related Items',
174+
description: 'Aggregated related information for base/variants and set items',
175+
properties: [
176+
new OA\Property(property: 'set_name', type: 'string', nullable: true),
177+
new OA\Property(property: 'base_item', ref: '#/components/schemas/item_related_link_v2', nullable: true),
178+
new OA\Property(
179+
property: 'variant_items',
180+
type: 'array',
181+
items: new OA\Items(ref: '#/components/schemas/item_related_link_v2'),
182+
nullable: true,
183+
),
184+
new OA\Property(
185+
property: 'set_items',
186+
type: 'array',
187+
items: new OA\Items(ref: '#/components/schemas/item_related_link_ext_v2'),
188+
nullable: true,
189+
),
190+
],
191+
type: 'object'
192+
)]
193+
141194
#[OA\Schema(
142195
schema: 'item_v2',
143196
title: 'Item',
@@ -188,6 +241,12 @@
188241
),
189242
]
190243
),
244+
new OA\Schema(
245+
properties: [
246+
new OA\Property(property: 'related_items', ref: '#/components/schemas/item_related_items_v2', nullable: true),
247+
],
248+
type: 'object'
249+
),
191250
]
192251
)]
193252
class ItemResource extends AbstractTranslationResource
@@ -208,6 +267,16 @@ public function toArray(Request $request): array
208267

209268
$vehicleItem = $this->vehicleItem;
210269

270+
// Determine if 'related_items' has been requested via include
271+
$includeParam = $request->query('include');
272+
$includeValues = [];
273+
if (is_string($includeParam)) {
274+
$includeValues = array_map('trim', explode(',', $includeParam));
275+
} elseif (is_array($includeParam)) {
276+
$includeValues = $includeParam;
277+
}
278+
$includeRelated = in_array('related_items', $includeValues, true);
279+
211280
return [
212281
'uuid' => $this->uuid,
213282
'name' => $this->name,
@@ -257,6 +326,9 @@ public function toArray(Request $request): array
257326
'base_variant' => new ItemLinkResource($this->baseVariant),
258327
]),
259328
'variants' => ItemLinkResource::collection($this->whenLoaded('variants')),
329+
$this->mergeWhen($includeRelated, [
330+
'related_items' => (new RelatedItemsBuilder)->build($this->resource),
331+
]),
260332
'updated_at' => $this->updated_at,
261333
'version' => $this->version,
262334
];
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Support\Items;
6+
7+
use App\Models\SC\Item\Item;
8+
9+
class RelatedItemsBuilder
10+
{
11+
/**
12+
* Build the related_items payload for a given Item.
13+
*
14+
* @return array{set_name:?string,base_item:?array,variant_items:array<int,array>,set_items:array<int,array>}
15+
*/
16+
public function build(Item $item): array
17+
{
18+
[$baseItem, $groupItems] = $this->gatherVariantGroup($item);
19+
20+
$names = collect($groupItems)->pluck('name')->all();
21+
if ($baseItem !== null) {
22+
array_unshift($names, $baseItem->name);
23+
}
24+
25+
[$setName, $variantNames] = $this->computeSetNameAndVariantNames($names, $baseItem, $groupItems);
26+
27+
$base = $baseItem !== null ? $this->toBaseLink($baseItem, $setName, true) : null;
28+
$variants = collect($groupItems)
29+
->filter(fn (Item $i) => $i->uuid !== $item->uuid) // exclude current item
30+
->map(function (Item $it) use ($variantNames) {
31+
$link = $this->toBaseLink($it, null, false);
32+
$link['variant_name'] = $variantNames[$it->uuid] ?? null;
33+
34+
return $link;
35+
})
36+
->values()
37+
->all();
38+
39+
$setItems = $this->findSetItems($item);
40+
41+
return [
42+
'set_name' => $setName,
43+
'base_item' => $base,
44+
'variant_items' => $variants,
45+
'set_items' => $setItems,
46+
];
47+
}
48+
49+
/**
50+
* Determine base item and full variant group for an item.
51+
*
52+
* @return array{0:?Item,1:array<int,Item>}
53+
*/
54+
public function gatherVariantGroup(Item $item): array
55+
{
56+
if ($item->base_id === null) {
57+
// Item is base
58+
$base = $item;
59+
$siblings = $item->variants()->get()->all();
60+
} else {
61+
$base = $item->baseVariant()->first();
62+
// Safety: base might be null
63+
$siblings = $base?->variants()->get()->all() ?? [];
64+
}
65+
66+
return [$base, $siblings];
67+
}
68+
69+
/**
70+
* Compute set name and per-item variant names.
71+
* - set name: longest common prefix among names
72+
* - variant name: item name with the set name prefix removed (trimmed); if empty, "Base".
73+
*
74+
* @param array<int,string> $names
75+
* @param array<int,Item> $group
76+
* @return array{0:?string,1:array<string,string>} [setName, map(uuid=>variantName)]
77+
*/
78+
public function computeSetNameAndVariantNames(array $names, ?Item $base, array $group): array
79+
{
80+
$rawPrefix = $this->longestCommonPrefix($names);
81+
if ($rawPrefix !== null) {
82+
$endsWithSpace = str_ends_with($rawPrefix, ' ');
83+
$setName = rtrim($rawPrefix);
84+
if (! $endsWithSpace) {
85+
$lastSpace = strrpos($setName, ' ');
86+
if ($lastSpace !== false) {
87+
$setName = substr($setName, 0, $lastSpace);
88+
}
89+
}
90+
$setName = $setName === '' ? null : $setName;
91+
} else {
92+
$setName = null;
93+
}
94+
95+
$map = [];
96+
if ($base !== null) {
97+
$map[$base->uuid] = 'Base';
98+
}
99+
100+
foreach ($group as $it) {
101+
$variant = $this->stripPrefix($it->name, (string) $setName);
102+
$map[$it->uuid] = $variant === '' ? 'Base' : $variant;
103+
}
104+
105+
return [$setName, $map];
106+
}
107+
108+
/**
109+
* Find set items for armor/clothing by replacing the part token in class_name.
110+
*
111+
* @return array<int,array{uuid:string,name:string,type:?string,sub_type:?string,link:string}>
112+
*/
113+
public function findSetItems(Item $item): array
114+
{
115+
$className = $item->class_name ?? '';
116+
if ($className === '') {
117+
return [];
118+
}
119+
120+
$parts = config('item_sets.parts', ['helmet', 'core', 'arms', 'legs']);
121+
$currentPart = null;
122+
foreach ($parts as $part) {
123+
if (str_contains($className, '_'.$part.'_')) {
124+
$currentPart = $part;
125+
break;
126+
}
127+
}
128+
if ($currentPart === null) {
129+
return [];
130+
}
131+
132+
$set = [];
133+
foreach ($parts as $part) {
134+
if ($part === $currentPart) {
135+
continue;
136+
}
137+
$candidate = $this->replaceFirst('_'.$currentPart.'_', '_'.$part.'_', $className);
138+
$found = Item::query()->where('class_name', $candidate)->first();
139+
if ($found !== null && $found->uuid !== $item->uuid) {
140+
$set[] = [
141+
'uuid' => $found->uuid,
142+
'name' => $found->name,
143+
'type' => $found->type,
144+
'sub_type' => $found->sub_type,
145+
'link' => $this->makeLink($found->uuid),
146+
];
147+
}
148+
}
149+
150+
return $set;
151+
}
152+
153+
private function toBaseLink(Item $it, ?string $setName, bool $includeVariantName): array
154+
{
155+
$link = [
156+
'uuid' => $it->uuid,
157+
'name' => $it->name,
158+
'link' => $this->makeLink($it->uuid),
159+
];
160+
if ($includeVariantName && $setName !== null) {
161+
$variant = $this->stripPrefix($it->name, $setName);
162+
$link['variant_name'] = $variant === '' ? 'Base' : $variant;
163+
}
164+
165+
return $link;
166+
}
167+
168+
private function makeLink(string $uuid): string
169+
{
170+
$baseUrl = rtrim((string) config('app.url'), '/');
171+
172+
return $baseUrl.'/api/v2/items/'.$uuid;
173+
}
174+
175+
private function replaceFirst(string $search, string $replace, string $subject): string
176+
{
177+
$pos = strpos($subject, $search);
178+
if ($pos === false) {
179+
return $subject;
180+
}
181+
182+
return substr($subject, 0, $pos).$replace.substr($subject, $pos + strlen($search));
183+
}
184+
185+
private function longestCommonPrefix(array $strings): ?string
186+
{
187+
if (count($strings) === 0) {
188+
return null;
189+
}
190+
$prefix = $strings[0];
191+
foreach ($strings as $s) {
192+
$i = 0;
193+
$max = min(strlen($prefix), strlen($s));
194+
while ($i < $max && $prefix[$i] === $s[$i]) {
195+
$i++;
196+
}
197+
$prefix = substr($prefix, 0, $i);
198+
if ($prefix === '') {
199+
return null;
200+
}
201+
}
202+
203+
return $prefix;
204+
}
205+
206+
private function stripPrefix(string $name, string $prefix): string
207+
{
208+
if ($prefix === '') {
209+
return $name;
210+
}
211+
if (str_starts_with($name, $prefix)) {
212+
$rest = substr($name, strlen($prefix));
213+
214+
return ltrim($rest);
215+
}
216+
217+
return $name;
218+
}
219+
}

0 commit comments

Comments
 (0)