PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Modules / MCP / Tools / LabelTools.php

LabelTools.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/MCP/Tools/LabelTools.php

200 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\MCP\Tools;
4
5 use FluentCart\App\Modules\MCP\Support\MCPHelper;
6 use FluentCart\App\Modules\MCP\Support\PermissionGate;
7
8 /**
9 * Label tools — the cross-entity tagging system.
10 *
11 * apply-labels attaches/detaches existing labels to an order, customer, or
12 * subscription. It works on label IDs (from list-reference-data with
13 * kind=labels), so it never needs to know how a label's value is stored.
14 *
15 * Parameter design: explicit add/remove delta arrays rather than a "set the
16 * full list" payload — the agent rarely knows the current set, and deltas are
17 * what it actually intends ("tag this order VIP"). We return the resulting set
18 * so the agent can confirm.
19 *
20 * Managing label DEFINITIONS (create/rename/delete the labels themselves) is
21 * deliberately not exposed yet — the underlying value column is a serialized
22 * blob whose shape we don't want an agent guessing at.
23 */
24 class LabelTools
25 {
26 const TYPE_MAP = ['order' => 'Order', 'customer' => 'Customer', 'subscription' => 'Subscription'];
27
28 public static function definitions()
29 {
30 return [
31 'fluent-cart/apply-labels' => [
32 'label' => __('Apply Labels', 'fluent-cart'),
33 'description' => __('Add or remove labels on an order, customer, or subscription. Pass add_label_ids and/or remove_label_ids; use list-reference-data with kind=labels to find label ids. Returns the resulting label set so you can confirm.', 'fluent-cart'),
34 'input_schema' => [
35 'type' => 'object',
36 'properties' => [
37 'entity_type' => ['type' => 'string', 'enum' => ['order', 'customer', 'subscription']],
38 'entity_id' => ['type' => 'integer'],
39 'add_label_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
40 'remove_label_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
41 ],
42 'required' => ['entity_type', 'entity_id'],
43 ],
44 'execute_callback' => [self::class, 'applyLabels'],
45 'permission_callback' => function () {
46 return PermissionGate::can('labels/manage');
47 },
48 // Mutating but reversible (labels can be removed) and re-applying
49 // the same set is a no-op — idempotent. 'bulk' was a non-standard
50 // hint the MCP client could not read; readonly:false is the real
51 // "this mutates" signal.
52 'annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true],
53 ],
54 ];
55 }
56
57 public static function applyLabels($params = [])
58 {
59 $type = isset($params['entity_type']) ? sanitize_text_field($params['entity_type']) : '';
60 if (!isset(self::TYPE_MAP[$type])) {
61 return MCPHelper::error('invalid_entity_type', __('entity_type must be one of: order, customer, subscription.', 'fluent-cart'));
62 }
63
64 $entityId = isset($params['entity_id']) ? (int) $params['entity_id'] : 0;
65 if (!$entityId) {
66 return MCPHelper::error('missing_identifier', __('entity_id is required.', 'fluent-cart'));
67 }
68
69 $modelClass = 'FluentCart\\App\\Models\\' . self::TYPE_MAP[$type];
70 if (!class_exists($modelClass) || !$modelClass::query()->where('id', $entityId)->exists()) {
71 return MCPHelper::error('entity_not_found', __('No matching record found for entity_type and entity_id.', 'fluent-cart'));
72 }
73
74 $rel = 'FluentCart\\App\\Models\\LabelRelationship';
75 if (!class_exists($rel)) {
76 return MCPHelper::error('not_available', __('Labels are not available on this install.', 'fluent-cart'));
77 }
78
79 $add = array_values(array_unique(array_map('intval', (array) (isset($params['add_label_ids']) ? $params['add_label_ids'] : []))));
80 $remove = array_values(array_unique(array_map('intval', (array) (isset($params['remove_label_ids']) ? $params['remove_label_ids'] : []))));
81
82 if (!$add && !$remove) {
83 return MCPHelper::error('missing_param', __('Provide add_label_ids and/or remove_label_ids.', 'fluent-cart'), ['fields' => ['add_label_ids', 'remove_label_ids']]);
84 }
85
86 // The same id in both lists is a contradictory instruction (add then
87 // remove nets to nothing yet reports as both added and removed). Reject
88 // so the agent clarifies intent.
89 $overlap = array_values(array_intersect($add, $remove));
90 if ($overlap) {
91 return MCPHelper::error(
92 'conflicting_labels',
93 __('A label id cannot be in both add_label_ids and remove_label_ids.', 'fluent-cart'),
94 ['fields' => ['add_label_ids', 'remove_label_ids'], 'conflicting_label_ids' => $overlap]
95 );
96 }
97
98 // Reject label IDs to ADD that don't exist, so entities can't be tagged
99 // with phantom labels that render with a null title. (Removal of an
100 // unknown id is harmless — it just no-ops.)
101 if ($add && class_exists('\FluentCart\App\Models\Label')) {
102 $known = array_map('intval', (array) \FluentCart\App\Models\Label::query()->whereIn('id', $add)->pluck('id')->toArray());
103 $unknown = array_values(array_diff($add, $known));
104 if ($unknown) {
105 return MCPHelper::error(
106 'label_not_found',
107 __('One or more label IDs do not exist. Use list-reference-data with kind=labels to find valid ids.', 'fluent-cart'),
108 ['fields' => ['add_label_ids'], 'unknown_label_ids' => $unknown]
109 );
110 }
111 }
112
113 $added = [];
114 foreach ($add as $labelId) {
115 $exists = $rel::query()
116 ->where('label_id', $labelId)
117 ->where('labelable_id', $entityId)
118 ->where('labelable_type', $modelClass)
119 ->exists();
120 if (!$exists) {
121 $rel::query()->create([
122 'label_id' => $labelId,
123 'labelable_id' => $entityId,
124 'labelable_type' => $modelClass,
125 ]);
126 $added[] = $labelId;
127 }
128 }
129
130 $removed = [];
131 foreach ($remove as $labelId) {
132 $deleted = $rel::query()
133 ->where('label_id', $labelId)
134 ->where('labelable_id', $entityId)
135 ->where('labelable_type', $modelClass)
136 ->delete();
137 if ($deleted) {
138 $removed[] = $labelId;
139 }
140 }
141
142 $current = array_map('intval', (array) $rel::query()
143 ->where('labelable_id', $entityId)
144 ->where('labelable_type', $modelClass)
145 ->pluck('label_id')
146 ->toArray());
147
148 return MCPHelper::envelope(
149 sprintf(
150 /* translators: 1: labels added, 2: labels removed */
151 __('Labels updated: %1$d added, %2$d removed.', 'fluent-cart'),
152 count($added),
153 count($removed)
154 ),
155 [
156 'entity_type' => $type,
157 'entity_id' => $entityId,
158 'added' => $added,
159 'removed' => $removed,
160 'current_label_ids' => $current,
161 'current_labels' => self::resolveLabels($current),
162 ]
163 );
164 }
165
166 /**
167 * Resolve label IDs to {id, title} so the caller doesn't need a follow-up
168 * reference-data lookup. fct_label keeps its name in a (maybe-serialized)
169 * `value` column — a plain string or an array {title|value, color}.
170 */
171 private static function resolveLabels(array $ids)
172 {
173 if (!$ids) {
174 return [];
175 }
176
177 $labelClass = 'FluentCart\\App\\Models\\Label';
178 if (!class_exists($labelClass)) {
179 return array_map(function ($id) {
180 return ['id' => $id, 'title' => null];
181 }, $ids);
182 }
183
184 $titles = [];
185 foreach ($labelClass::query()->whereIn('id', $ids)->get() as $label) {
186 $val = $label->value;
187 $title = is_array($val)
188 ? (isset($val['title']) ? $val['title'] : (isset($val['value']) ? $val['value'] : null))
189 : $val;
190 $titles[(int) $label->id] = $title;
191 }
192
193 $out = [];
194 foreach ($ids as $id) {
195 $out[] = ['id' => $id, 'title' => isset($titles[$id]) ? $titles[$id] : null];
196 }
197 return $out;
198 }
199 }
200