whale-code 6.4.0 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/swagmanager-mcp.js +7 -0
- package/dist/cli/app.js +30 -2
- package/dist/cli/chat/ChatApp.d.ts +4 -4
- package/dist/cli/chat/ChatApp.js +114 -44
- package/dist/cli/chat/ChatInput.d.ts +13 -6
- package/dist/cli/chat/ChatInput.js +433 -89
- package/dist/cli/chat/MemoryManager.d.ts +15 -0
- package/dist/cli/chat/MemoryManager.js +61 -0
- package/dist/cli/chat/MessageList.d.ts +8 -0
- package/dist/cli/chat/MessageList.js +1 -1
- package/dist/cli/chat/NodeManager.d.ts +30 -0
- package/dist/cli/chat/NodeManager.js +89 -0
- package/dist/cli/chat/NodeSelector.d.ts +19 -0
- package/dist/cli/chat/NodeSelector.js +37 -0
- package/dist/cli/chat/PlanApproval.d.ts +17 -0
- package/dist/cli/chat/PlanApproval.js +82 -0
- package/dist/cli/chat/SessionManager.d.ts +16 -0
- package/dist/cli/chat/SessionManager.js +43 -0
- package/dist/cli/chat/SlashMenu.d.ts +38 -0
- package/dist/cli/chat/SlashMenu.js +208 -0
- package/dist/cli/chat/StatusBar.d.ts +16 -0
- package/dist/cli/chat/StatusBar.js +22 -0
- package/dist/cli/chat/ThemeSelector.d.ts +14 -0
- package/dist/cli/chat/ThemeSelector.js +29 -0
- package/dist/cli/chat/ToolIndicator.d.ts +8 -0
- package/dist/cli/chat/ToolIndicator.js +33 -9
- package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
- package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
- package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
- package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
- package/dist/cli/commands/config-cmd.js +4 -25
- package/dist/cli/commands/db.d.ts +13 -0
- package/dist/cli/commands/db.js +243 -0
- package/dist/cli/commands/doctor.js +6 -9
- package/dist/cli/commands/mcp.js +1 -20
- package/dist/cli/services/agent-events.d.ts +22 -1
- package/dist/cli/services/agent-events.js +9 -0
- package/dist/cli/services/agent-loop.js +66 -2
- package/dist/cli/services/agent-worker-base.js +21 -6
- package/dist/cli/services/api-retry.d.ts +25 -0
- package/dist/cli/services/api-retry.js +91 -0
- package/dist/cli/services/auth-service.d.ts +1 -1
- package/dist/cli/services/auth-service.js +40 -19
- package/dist/cli/services/background-processes.js +26 -2
- package/dist/cli/services/config-store.d.ts +13 -1
- package/dist/cli/services/config-store.js +116 -13
- package/dist/cli/services/format-server-response.js +12 -6
- package/dist/cli/services/ink-resize-fix.d.ts +18 -0
- package/dist/cli/services/ink-resize-fix.js +66 -0
- package/dist/cli/services/interactive-tools.d.ts +14 -0
- package/dist/cli/services/interactive-tools.js +47 -2
- package/dist/cli/services/keybinding-manager.js +1 -1
- package/dist/cli/services/local-tools.js +35 -2
- package/dist/cli/services/server-tools.js +175 -3
- package/dist/cli/services/subagent.js +15 -3
- package/dist/cli/services/system-prompt.js +5 -3
- package/dist/cli/services/task-decomposer.d.ts +35 -0
- package/dist/cli/services/task-decomposer.js +199 -0
- package/dist/cli/services/team-lead.d.ts +18 -0
- package/dist/cli/services/team-lead.js +80 -0
- package/dist/cli/services/teammate.js +5 -5
- package/dist/cli/services/telemetry.d.ts +8 -2
- package/dist/cli/services/telemetry.js +116 -92
- package/dist/cli/services/tools/agent-tools.d.ts +1 -0
- package/dist/cli/services/tools/agent-tools.js +50 -4
- package/dist/cli/services/tools/file-ops.d.ts +2 -0
- package/dist/cli/services/tools/file-ops.js +71 -19
- package/dist/cli/services/tools/shell-exec.js +22 -12
- package/dist/cli/shared/Theme.d.ts +1 -2
- package/dist/cli/shared/Theme.js +1 -1
- package/dist/cli/shared/WhaleBanner.d.ts +4 -1
- package/dist/cli/shared/WhaleBanner.js +12 -8
- package/dist/cli/shared/markdown.d.ts +5 -4
- package/dist/cli/shared/markdown.js +376 -334
- package/dist/cli/shared/theme-manager.d.ts +27 -0
- package/dist/cli/shared/theme-manager.js +178 -0
- package/dist/cli/shared/theme-presets.d.ts +16 -0
- package/dist/cli/shared/theme-presets.js +265 -0
- package/dist/index.js +0 -51
- package/dist/node/adapters/imessage.d.ts +10 -0
- package/dist/node/adapters/imessage.js +45 -6
- package/dist/node/cli.js +459 -8
- package/dist/node/config.d.ts +17 -0
- package/dist/node/gateway-client.d.ts +55 -0
- package/dist/node/gateway-client.js +201 -0
- package/dist/node/portal/clipboard.d.ts +28 -0
- package/dist/node/portal/clipboard.js +183 -0
- package/dist/node/portal/discovery.d.ts +29 -0
- package/dist/node/portal/discovery.js +61 -0
- package/dist/node/portal/forward.d.ts +30 -0
- package/dist/node/portal/forward.js +90 -0
- package/dist/node/portal/index.d.ts +47 -0
- package/dist/node/portal/index.js +250 -0
- package/dist/node/portal/multiplexer.d.ts +48 -0
- package/dist/node/portal/multiplexer.js +207 -0
- package/dist/node/portal/permissions.d.ts +36 -0
- package/dist/node/portal/permissions.js +131 -0
- package/dist/node/portal/protocol.d.ts +140 -0
- package/dist/node/portal/protocol.js +193 -0
- package/dist/node/portal/screen.d.ts +18 -0
- package/dist/node/portal/screen.js +93 -0
- package/dist/node/portal/session.d.ts +68 -0
- package/dist/node/portal/session.js +127 -0
- package/dist/node/portal/shell.d.ts +26 -0
- package/dist/node/portal/shell.js +142 -0
- package/dist/node/portal/stream.d.ts +43 -0
- package/dist/node/portal/stream.js +90 -0
- package/dist/node/portal/transfer.d.ts +33 -0
- package/dist/node/portal/transfer.js +231 -0
- package/dist/node/portal/ui.d.ts +16 -0
- package/dist/node/portal/ui.js +148 -0
- package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
- package/dist/node/remote-desktop/compile-helper.js +73 -0
- package/dist/node/remote-desktop/index.d.ts +67 -0
- package/dist/node/remote-desktop/index.js +220 -0
- package/dist/node/remote-desktop/protocol.d.ts +96 -0
- package/dist/node/remote-desktop/protocol.js +67 -0
- package/dist/node/runtime.d.ts +8 -1
- package/dist/node/runtime.js +117 -9
- package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
- package/dist/server/handlers/__test-utils__/test-db.js +128 -0
- package/dist/server/handlers/api-keys.js +26 -2
- package/dist/server/handlers/browser.d.ts +0 -4
- package/dist/server/handlers/browser.js +0 -46
- package/dist/server/handlers/catalog.js +37 -14
- package/dist/server/handlers/clickhouse.d.ts +10 -0
- package/dist/server/handlers/clickhouse.js +215 -0
- package/dist/server/handlers/comms.d.ts +308 -4
- package/dist/server/handlers/comms.js +444 -11
- package/dist/server/handlers/creations.js +1 -1
- package/dist/server/handlers/crm.d.ts +54 -8
- package/dist/server/handlers/crm.js +353 -68
- package/dist/server/handlers/embeddings.js +3 -3
- package/dist/server/handlers/enrichment.js +39 -55
- package/dist/server/handlers/inventory.js +1 -1
- package/dist/server/handlers/kali.d.ts +9 -1
- package/dist/server/handlers/kali.js +50 -1
- package/dist/server/handlers/media.d.ts +8 -0
- package/dist/server/handlers/media.js +902 -0
- package/dist/server/handlers/meta-ads.js +6 -3
- package/dist/server/handlers/nodes.d.ts +2 -0
- package/dist/server/handlers/nodes.js +331 -40
- package/dist/server/handlers/operations.d.ts +4 -6
- package/dist/server/handlers/operations.js +99 -38
- package/dist/server/handlers/platform.js +224 -107
- package/dist/server/handlers/remove-bg.d.ts +6 -0
- package/dist/server/handlers/remove-bg.js +96 -0
- package/dist/server/handlers/storefront.d.ts +6 -0
- package/dist/server/handlers/storefront.js +477 -0
- package/dist/server/handlers/supply-chain.js +21 -3
- package/dist/server/handlers/workflow-steps.js +87 -31
- package/dist/server/handlers/workflows.js +4 -1
- package/dist/server/index.js +334 -88
- package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
- package/dist/server/lib/clickhouse-buffer.js +175 -0
- package/dist/server/lib/clickhouse-client.d.ts +112 -0
- package/dist/server/lib/clickhouse-client.js +141 -0
- package/dist/server/lib/coa-renderer.d.ts +91 -0
- package/dist/server/lib/coa-renderer.js +411 -0
- package/dist/server/lib/compaction-service.js +45 -1
- package/dist/server/lib/pdf-renderer.d.ts +143 -0
- package/dist/server/lib/pdf-renderer.js +867 -0
- package/dist/server/lib/react-pdf-layout.d.ts +40 -0
- package/dist/server/lib/react-pdf-layout.js +437 -0
- package/dist/server/lib/server-agent-loop.d.ts +2 -0
- package/dist/server/lib/server-agent-loop.js +61 -15
- package/dist/server/lib/server-subagent.d.ts +3 -0
- package/dist/server/lib/server-subagent.js +7 -4
- package/dist/server/lib/supabase-client.js +51 -3
- package/dist/server/lib/template-resolver.js +14 -4
- package/dist/server/lib/utils.js +15 -0
- package/dist/server/local-agent-gateway.d.ts +44 -0
- package/dist/server/local-agent-gateway.js +389 -49
- package/dist/server/providers/anthropic.js +12 -2
- package/dist/server/providers/gemini.js +17 -2
- package/dist/server/proxy-handlers.js +151 -0
- package/dist/server/tool-router.d.ts +2 -2
- package/dist/server/tool-router.js +25 -35
- package/dist/shared/agent-core.d.ts +5 -2
- package/dist/shared/agent-core.js +30 -4
- package/dist/shared/api-client.js +54 -3
- package/dist/shared/sse-parser.d.ts +1 -1
- package/dist/shared/sse-parser.js +5 -2
- package/dist/shared/tool-dispatch.js +1 -1
- package/package.json +16 -10
- package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
- package/dist/server/handlers/__test-utils__/mock-supabase.js +0 -393
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
// server/handlers/media.ts — Full media management (23 actions)
|
|
2
|
+
//
|
|
3
|
+
// Upload, search, organize, tag, link to products, track usage, analytics,
|
|
4
|
+
// duplicate, replace — complete media catalog management for AI agents.
|
|
5
|
+
//
|
|
6
|
+
// Input methods for upload/replace:
|
|
7
|
+
// - file_path → CLI reads local file, base64-encodes in-process (preferred)
|
|
8
|
+
// - base64 → raw base64 string (auto-strips data URI prefix)
|
|
9
|
+
// - file_url → public URL to fetch and re-upload
|
|
10
|
+
//
|
|
11
|
+
// Note: file_path is handled client-side in server-tools.ts (converted to base64
|
|
12
|
+
// before reaching this handler).
|
|
13
|
+
import { sanitizeFilterValue } from "../lib/utils.js";
|
|
14
|
+
const BUCKET = "product-images";
|
|
15
|
+
const ALL_ACTIONS = [
|
|
16
|
+
"upload", "bulk_upload", "list", "get", "update", "bulk_update",
|
|
17
|
+
"delete", "bulk_delete", "search", "move", "archive", "unarchive",
|
|
18
|
+
"tag", "folders_list", "folders_create", "folders_update", "folders_delete",
|
|
19
|
+
"link_product", "unlink_product", "usage", "analytics", "duplicate", "replace",
|
|
20
|
+
];
|
|
21
|
+
const LIST_COLUMNS = "id, file_name, file_url, file_size, file_type, category, folder, status, title, alt_text, custom_tags, usage_count, linked_product_ids, created_at, updated_at";
|
|
22
|
+
const VALID_CATEGORIES = ["product_photos", "marketing", "menus", "brand", "social_media", "print_marketing", "promotional", "brand_assets", "ai_generated"];
|
|
23
|
+
const VALID_STATUSES = ["active", "archived", "processing"];
|
|
24
|
+
const VALID_SORT_COLUMNS = ["created_at", "updated_at", "file_name", "file_size", "usage_count", "title"];
|
|
25
|
+
const UPDATE_WHITELIST = ["title", "alt_text", "notes", "category", "status", "folder", "custom_tags"];
|
|
26
|
+
// Detect mime type from base64 magic bytes
|
|
27
|
+
function detectMime(base64) {
|
|
28
|
+
if (base64.startsWith("iVBORw0KGgo"))
|
|
29
|
+
return "image/png";
|
|
30
|
+
if (base64.startsWith("/9j/"))
|
|
31
|
+
return "image/jpeg";
|
|
32
|
+
if (base64.startsWith("R0lGOD"))
|
|
33
|
+
return "image/gif";
|
|
34
|
+
if (base64.startsWith("UklGR"))
|
|
35
|
+
return "image/webp";
|
|
36
|
+
if (base64.startsWith("JVBER"))
|
|
37
|
+
return "application/pdf";
|
|
38
|
+
// Blender files start with "BLENDER" → base64 of "BLENDER" starts with "QkxFTkRFUg"
|
|
39
|
+
if (base64.startsWith("QkxFTkRFUg"))
|
|
40
|
+
return "application/x-blender";
|
|
41
|
+
return "application/octet-stream";
|
|
42
|
+
}
|
|
43
|
+
function mimeToExt(mime) {
|
|
44
|
+
const map = {
|
|
45
|
+
"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
|
|
46
|
+
"image/webp": "webp", "image/svg+xml": "svg", "application/pdf": "pdf",
|
|
47
|
+
"application/x-blender": "blend",
|
|
48
|
+
};
|
|
49
|
+
return map[mime] || "bin";
|
|
50
|
+
}
|
|
51
|
+
export async function handleMedia(sb, args, storeId) {
|
|
52
|
+
const action = args.action || "upload";
|
|
53
|
+
if (!storeId)
|
|
54
|
+
return { success: false, error: "store_id is required" };
|
|
55
|
+
switch (action) {
|
|
56
|
+
// ── UPLOAD ─────────────────────────────────────────────────────────────────
|
|
57
|
+
case "upload": {
|
|
58
|
+
let base64 = args.base64;
|
|
59
|
+
const fileUrl = args.file_url;
|
|
60
|
+
if (!base64 && !fileUrl) {
|
|
61
|
+
return { success: false, error: "Provide base64 (file contents), file_url (public URL), or file_path (local — handled by CLI)" };
|
|
62
|
+
}
|
|
63
|
+
if (base64)
|
|
64
|
+
base64 = base64.replace(/^data:[^;]+;base64,/, "").replace(/\s/g, "");
|
|
65
|
+
if (!base64 && fileUrl) {
|
|
66
|
+
if (fileUrl.startsWith("/") || /^[A-Z]:\\/i.test(fileUrl)) {
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
error: `Local file paths cannot be used as file_url. Use file_path instead: media(action="upload", file_path="${fileUrl}"). The CLI reads the file locally and uploads it.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
let fetchUrl = fileUrl;
|
|
73
|
+
if (!fetchUrl.startsWith("http://") && !fetchUrl.startsWith("https://")) {
|
|
74
|
+
fetchUrl = `https://${fetchUrl}`;
|
|
75
|
+
}
|
|
76
|
+
const resp = await fetch(fetchUrl);
|
|
77
|
+
if (!resp.ok)
|
|
78
|
+
return { success: false, error: `Failed to fetch file_url: ${resp.status}` };
|
|
79
|
+
const buf = await resp.arrayBuffer();
|
|
80
|
+
base64 = Buffer.from(buf).toString("base64");
|
|
81
|
+
}
|
|
82
|
+
const mime = args.mime_type || detectMime(base64);
|
|
83
|
+
const ext = mimeToExt(mime);
|
|
84
|
+
const originalName = args.file_name || "";
|
|
85
|
+
const folder = args.folder || "uploads";
|
|
86
|
+
const id = crypto.randomUUID();
|
|
87
|
+
const fileName = originalName
|
|
88
|
+
? `${id}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
|
|
89
|
+
: `${id}.${ext}`;
|
|
90
|
+
const storagePath = `${folder}/${storeId.toUpperCase()}/${fileName}`;
|
|
91
|
+
const buffer = Buffer.from(base64, "base64");
|
|
92
|
+
const { error: uploadErr } = await sb.storage
|
|
93
|
+
.from(BUCKET)
|
|
94
|
+
.upload(storagePath, buffer, { contentType: mime, upsert: true });
|
|
95
|
+
if (uploadErr)
|
|
96
|
+
return { success: false, error: `Upload failed: ${uploadErr.message}` };
|
|
97
|
+
const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(storagePath);
|
|
98
|
+
const cdnUrl = urlData.publicUrl;
|
|
99
|
+
const insertData = {
|
|
100
|
+
store_id: storeId,
|
|
101
|
+
file_name: fileName,
|
|
102
|
+
file_path: storagePath,
|
|
103
|
+
file_url: cdnUrl,
|
|
104
|
+
file_size: buffer.length,
|
|
105
|
+
file_type: mime,
|
|
106
|
+
category: args.category || "product_photos",
|
|
107
|
+
source: "media-upload",
|
|
108
|
+
folder,
|
|
109
|
+
};
|
|
110
|
+
if (args.title)
|
|
111
|
+
insertData.title = args.title;
|
|
112
|
+
if (args.alt_text)
|
|
113
|
+
insertData.alt_text = args.alt_text;
|
|
114
|
+
if (args.notes)
|
|
115
|
+
insertData.notes = args.notes;
|
|
116
|
+
if (args.tags)
|
|
117
|
+
insertData.custom_tags = args.tags;
|
|
118
|
+
const { data: mediaRow, error: mediaErr } = await sb
|
|
119
|
+
.from("store_media")
|
|
120
|
+
.insert(insertData)
|
|
121
|
+
.select("id")
|
|
122
|
+
.single();
|
|
123
|
+
if (mediaErr)
|
|
124
|
+
console.error("[media] store_media insert error:", mediaErr.message);
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
data: {
|
|
128
|
+
media_id: mediaRow?.id || id,
|
|
129
|
+
file_url: cdnUrl,
|
|
130
|
+
file_name: fileName,
|
|
131
|
+
file_size: buffer.length,
|
|
132
|
+
mime_type: mime,
|
|
133
|
+
storage_path: storagePath,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// ── BULK UPLOAD ────────────────────────────────────────────────────────────
|
|
138
|
+
case "bulk_upload": {
|
|
139
|
+
const files = args.files;
|
|
140
|
+
if (!files || !Array.isArray(files) || files.length === 0) {
|
|
141
|
+
return { success: false, error: "bulk_upload requires files: [{base64, file_name}]" };
|
|
142
|
+
}
|
|
143
|
+
const folder = args.folder || "uploads";
|
|
144
|
+
const category = args.category || "product_photos";
|
|
145
|
+
const readErrors = args._read_errors || [];
|
|
146
|
+
const results = await Promise.allSettled(files.map(async (file) => {
|
|
147
|
+
const b64 = file.base64.replace(/^data:[^;]+;base64,/, "").replace(/\s/g, "");
|
|
148
|
+
const mime = args.mime_type || detectMime(b64);
|
|
149
|
+
const ext = mimeToExt(mime);
|
|
150
|
+
const fid = crypto.randomUUID();
|
|
151
|
+
const originalName = file.file_name || "";
|
|
152
|
+
const fName = originalName
|
|
153
|
+
? `${fid}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
|
|
154
|
+
: `${fid}.${ext}`;
|
|
155
|
+
const storagePath = `${folder}/${storeId.toUpperCase()}/${fName}`;
|
|
156
|
+
const buffer = Buffer.from(b64, "base64");
|
|
157
|
+
const { error: uploadErr } = await sb.storage
|
|
158
|
+
.from(BUCKET)
|
|
159
|
+
.upload(storagePath, buffer, { contentType: mime, upsert: true });
|
|
160
|
+
if (uploadErr)
|
|
161
|
+
throw new Error(`Storage: ${uploadErr.message}`);
|
|
162
|
+
const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(storagePath);
|
|
163
|
+
const cdnUrl = urlData.publicUrl;
|
|
164
|
+
const insertData = {
|
|
165
|
+
store_id: storeId, file_name: fName, file_path: storagePath,
|
|
166
|
+
file_url: cdnUrl, file_size: buffer.length, file_type: mime,
|
|
167
|
+
category, source: "media-upload", folder,
|
|
168
|
+
};
|
|
169
|
+
if (args.title)
|
|
170
|
+
insertData.title = args.title;
|
|
171
|
+
if (args.alt_text)
|
|
172
|
+
insertData.alt_text = args.alt_text;
|
|
173
|
+
if (args.notes)
|
|
174
|
+
insertData.notes = args.notes;
|
|
175
|
+
if (args.tags)
|
|
176
|
+
insertData.custom_tags = args.tags;
|
|
177
|
+
const { data: mediaRow, error: mediaErr } = await sb
|
|
178
|
+
.from("store_media")
|
|
179
|
+
.insert(insertData)
|
|
180
|
+
.select("id")
|
|
181
|
+
.single();
|
|
182
|
+
if (mediaErr)
|
|
183
|
+
console.error("[media] bulk insert error:", mediaErr.message);
|
|
184
|
+
return {
|
|
185
|
+
media_id: mediaRow?.id || fid, file_url: cdnUrl, file_name: fName,
|
|
186
|
+
file_size: buffer.length, mime_type: mime, storage_path: storagePath,
|
|
187
|
+
};
|
|
188
|
+
}));
|
|
189
|
+
const uploaded = [];
|
|
190
|
+
const failed = [];
|
|
191
|
+
results.forEach((r, i) => {
|
|
192
|
+
if (r.status === "fulfilled") {
|
|
193
|
+
uploaded.push(r.value);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
failed.push({ file_name: files[i].file_name || `file_${i}`, error: r.reason?.message || String(r.reason) });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
for (const e of readErrors)
|
|
200
|
+
failed.push({ file_name: "(read error)", error: e });
|
|
201
|
+
return {
|
|
202
|
+
success: uploaded.length > 0,
|
|
203
|
+
data: {
|
|
204
|
+
uploaded_count: uploaded.length,
|
|
205
|
+
failed_count: failed.length,
|
|
206
|
+
total_size: uploaded.reduce((sum, u) => sum + u.file_size, 0),
|
|
207
|
+
files: uploaded,
|
|
208
|
+
...(failed.length > 0 ? { errors: failed } : {}),
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// ── LIST ───────────────────────────────────────────────────────────────────
|
|
213
|
+
case "list": {
|
|
214
|
+
const limit = Math.min(Number(args.limit) || 50, 200);
|
|
215
|
+
const offset = Number(args.offset) || 0;
|
|
216
|
+
const sortBy = VALID_SORT_COLUMNS.includes(args.sort_by) ? args.sort_by : "created_at";
|
|
217
|
+
const sortOrder = args.sort_order === "asc";
|
|
218
|
+
let query = sb.from("store_media")
|
|
219
|
+
.select(LIST_COLUMNS, { count: "exact" })
|
|
220
|
+
.eq("store_id", storeId)
|
|
221
|
+
.order(sortBy, { ascending: sortOrder })
|
|
222
|
+
.range(offset, offset + limit - 1);
|
|
223
|
+
// Default: exclude archived unless explicitly requested
|
|
224
|
+
if (args.status) {
|
|
225
|
+
query = query.eq("status", args.status);
|
|
226
|
+
}
|
|
227
|
+
else if (!args.include_archived) {
|
|
228
|
+
query = query.neq("status", "archived");
|
|
229
|
+
}
|
|
230
|
+
if (args.folder)
|
|
231
|
+
query = query.eq("folder", args.folder);
|
|
232
|
+
if (args.category)
|
|
233
|
+
query = query.eq("category", args.category);
|
|
234
|
+
if (args.file_type) {
|
|
235
|
+
const ft = sanitizeFilterValue(args.file_type);
|
|
236
|
+
query = query.ilike("file_type", `${ft}%`);
|
|
237
|
+
}
|
|
238
|
+
if (args.tags && Array.isArray(args.tags)) {
|
|
239
|
+
query = query.overlaps("custom_tags", args.tags);
|
|
240
|
+
}
|
|
241
|
+
const { data, error, count } = await query;
|
|
242
|
+
if (error)
|
|
243
|
+
return { success: false, error: error.message };
|
|
244
|
+
return {
|
|
245
|
+
success: true,
|
|
246
|
+
data: { total: count || 0, count: data?.length || 0, offset, files: data },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// ── GET ────────────────────────────────────────────────────────────────────
|
|
250
|
+
case "get": {
|
|
251
|
+
const mediaId = args.media_id;
|
|
252
|
+
if (!mediaId)
|
|
253
|
+
return { success: false, error: "media_id required" };
|
|
254
|
+
const { data, error } = await sb.from("store_media").select("*").eq("id", mediaId).eq("store_id", storeId).single();
|
|
255
|
+
if (error)
|
|
256
|
+
return { success: false, error: error.message };
|
|
257
|
+
return { success: true, data };
|
|
258
|
+
}
|
|
259
|
+
// ── UPDATE ─────────────────────────────────────────────────────────────────
|
|
260
|
+
case "update": {
|
|
261
|
+
const mediaId = args.media_id;
|
|
262
|
+
if (!mediaId)
|
|
263
|
+
return { success: false, error: "media_id required" };
|
|
264
|
+
const updates = {};
|
|
265
|
+
for (const key of UPDATE_WHITELIST) {
|
|
266
|
+
if (args[key] !== undefined)
|
|
267
|
+
updates[key] = args[key];
|
|
268
|
+
}
|
|
269
|
+
// Accept description as alias for notes
|
|
270
|
+
if (args.description !== undefined && updates.notes === undefined) {
|
|
271
|
+
updates.notes = args.description;
|
|
272
|
+
}
|
|
273
|
+
// Accept tags as alias for custom_tags
|
|
274
|
+
if (args.tags !== undefined && updates.custom_tags === undefined) {
|
|
275
|
+
updates.custom_tags = args.tags;
|
|
276
|
+
}
|
|
277
|
+
if (Object.keys(updates).length === 0) {
|
|
278
|
+
return { success: false, error: `Nothing to update. Updatable fields: ${UPDATE_WHITELIST.join(", ")}` };
|
|
279
|
+
}
|
|
280
|
+
// Validate enums
|
|
281
|
+
if (updates.category && !VALID_CATEGORIES.includes(updates.category)) {
|
|
282
|
+
return { success: false, error: `Invalid category. Valid: ${VALID_CATEGORIES.join(", ")}` };
|
|
283
|
+
}
|
|
284
|
+
if (updates.status && !VALID_STATUSES.includes(updates.status)) {
|
|
285
|
+
return { success: false, error: `Invalid status. Valid: ${VALID_STATUSES.join(", ")}` };
|
|
286
|
+
}
|
|
287
|
+
const { data, error } = await sb.from("store_media")
|
|
288
|
+
.update(updates)
|
|
289
|
+
.eq("id", mediaId)
|
|
290
|
+
.eq("store_id", storeId)
|
|
291
|
+
.select("*")
|
|
292
|
+
.single();
|
|
293
|
+
if (error)
|
|
294
|
+
return { success: false, error: error.message };
|
|
295
|
+
return { success: true, data };
|
|
296
|
+
}
|
|
297
|
+
// ── BULK UPDATE ────────────────────────────────────────────────────────────
|
|
298
|
+
// Two modes:
|
|
299
|
+
// 1. Uniform: media_ids[] + shared fields → applies same update to all
|
|
300
|
+
// 2. Per-item: items[] → each item has {id, title?, alt_text?, tags?, ...}
|
|
301
|
+
case "bulk_update": {
|
|
302
|
+
const items = args.items;
|
|
303
|
+
// ── Per-item mode ──
|
|
304
|
+
if (items && Array.isArray(items) && items.length > 0) {
|
|
305
|
+
if (items.length > 500) {
|
|
306
|
+
return { success: false, error: `Too many items (${items.length}). Max 500 per call.` };
|
|
307
|
+
}
|
|
308
|
+
// Validate all items up-front
|
|
309
|
+
for (let i = 0; i < items.length; i++) {
|
|
310
|
+
const item = items[i];
|
|
311
|
+
if (!item.id && !item.media_id) {
|
|
312
|
+
return { success: false, error: `items[${i}] missing id` };
|
|
313
|
+
}
|
|
314
|
+
if (item.category && !VALID_CATEGORIES.includes(item.category)) {
|
|
315
|
+
return { success: false, error: `items[${i}] invalid category: ${item.category}` };
|
|
316
|
+
}
|
|
317
|
+
if (item.status && !VALID_STATUSES.includes(item.status)) {
|
|
318
|
+
return { success: false, error: `items[${i}] invalid status: ${item.status}` };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Process in parallel batches of 25
|
|
322
|
+
const BATCH_SIZE = 25;
|
|
323
|
+
let updatedCount = 0;
|
|
324
|
+
let failedCount = 0;
|
|
325
|
+
const errors = [];
|
|
326
|
+
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
|
327
|
+
const batch = items.slice(i, i + BATCH_SIZE);
|
|
328
|
+
const results = await Promise.allSettled(batch.map(async (item) => {
|
|
329
|
+
const itemId = (item.id || item.media_id);
|
|
330
|
+
const updates = {};
|
|
331
|
+
for (const key of UPDATE_WHITELIST) {
|
|
332
|
+
if (item[key] !== undefined)
|
|
333
|
+
updates[key] = item[key];
|
|
334
|
+
}
|
|
335
|
+
if (item.description !== undefined && updates.notes === undefined) {
|
|
336
|
+
updates.notes = item.description;
|
|
337
|
+
}
|
|
338
|
+
if (item.tags !== undefined && updates.custom_tags === undefined) {
|
|
339
|
+
updates.custom_tags = item.tags;
|
|
340
|
+
}
|
|
341
|
+
if (Object.keys(updates).length === 0)
|
|
342
|
+
return null; // skip no-ops
|
|
343
|
+
const { error } = await sb.from("store_media")
|
|
344
|
+
.update(updates)
|
|
345
|
+
.eq("id", itemId)
|
|
346
|
+
.eq("store_id", storeId);
|
|
347
|
+
if (error)
|
|
348
|
+
throw new Error(`${itemId}: ${error.message}`);
|
|
349
|
+
return itemId;
|
|
350
|
+
}));
|
|
351
|
+
for (const r of results) {
|
|
352
|
+
if (r.status === "fulfilled" && r.value !== null)
|
|
353
|
+
updatedCount++;
|
|
354
|
+
else if (r.status === "rejected") {
|
|
355
|
+
failedCount++;
|
|
356
|
+
errors.push(r.reason?.message || String(r.reason));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
success: updatedCount > 0,
|
|
362
|
+
data: {
|
|
363
|
+
updated_count: updatedCount,
|
|
364
|
+
failed_count: failedCount,
|
|
365
|
+
...(errors.length > 0 ? { errors: errors.slice(0, 10) } : {}),
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
// ── Uniform mode ──
|
|
370
|
+
const ids = args.media_ids;
|
|
371
|
+
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
372
|
+
return { success: false, error: "Provide items[] (per-item updates) or media_ids[] + shared fields (uniform update)" };
|
|
373
|
+
}
|
|
374
|
+
const updates = {};
|
|
375
|
+
for (const key of UPDATE_WHITELIST) {
|
|
376
|
+
if (args[key] !== undefined)
|
|
377
|
+
updates[key] = args[key];
|
|
378
|
+
}
|
|
379
|
+
if (args.description !== undefined && updates.notes === undefined) {
|
|
380
|
+
updates.notes = args.description;
|
|
381
|
+
}
|
|
382
|
+
if (args.tags !== undefined && updates.custom_tags === undefined) {
|
|
383
|
+
updates.custom_tags = args.tags;
|
|
384
|
+
}
|
|
385
|
+
if (Object.keys(updates).length === 0) {
|
|
386
|
+
return { success: false, error: `Nothing to update. Updatable fields: ${UPDATE_WHITELIST.join(", ")}` };
|
|
387
|
+
}
|
|
388
|
+
if (updates.category && !VALID_CATEGORIES.includes(updates.category)) {
|
|
389
|
+
return { success: false, error: `Invalid category. Valid: ${VALID_CATEGORIES.join(", ")}` };
|
|
390
|
+
}
|
|
391
|
+
if (updates.status && !VALID_STATUSES.includes(updates.status)) {
|
|
392
|
+
return { success: false, error: `Invalid status. Valid: ${VALID_STATUSES.join(", ")}` };
|
|
393
|
+
}
|
|
394
|
+
const { data, error } = await sb.from("store_media")
|
|
395
|
+
.update(updates)
|
|
396
|
+
.in("id", ids)
|
|
397
|
+
.eq("store_id", storeId)
|
|
398
|
+
.select("id");
|
|
399
|
+
if (error)
|
|
400
|
+
return { success: false, error: error.message };
|
|
401
|
+
return { success: true, data: { updated_count: data?.length || 0 } };
|
|
402
|
+
}
|
|
403
|
+
// ── DELETE ─────────────────────────────────────────────────────────────────
|
|
404
|
+
case "delete": {
|
|
405
|
+
const mediaId = args.media_id;
|
|
406
|
+
if (!mediaId)
|
|
407
|
+
return { success: false, error: "media_id required" };
|
|
408
|
+
const { data: row } = await sb.from("store_media").select("file_path").eq("id", mediaId).eq("store_id", storeId).single();
|
|
409
|
+
if (row?.file_path) {
|
|
410
|
+
await sb.storage.from(BUCKET).remove([row.file_path]);
|
|
411
|
+
}
|
|
412
|
+
const { error } = await sb.from("store_media").delete().eq("id", mediaId).eq("store_id", storeId);
|
|
413
|
+
if (error)
|
|
414
|
+
return { success: false, error: error.message };
|
|
415
|
+
return { success: true, data: { deleted: mediaId } };
|
|
416
|
+
}
|
|
417
|
+
// ── BULK DELETE ────────────────────────────────────────────────────────────
|
|
418
|
+
case "bulk_delete": {
|
|
419
|
+
const ids = args.media_ids;
|
|
420
|
+
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
421
|
+
return { success: false, error: "media_ids[] required" };
|
|
422
|
+
}
|
|
423
|
+
// Fetch file paths for storage cleanup
|
|
424
|
+
const { data: rows } = await sb.from("store_media")
|
|
425
|
+
.select("id, file_path")
|
|
426
|
+
.in("id", ids)
|
|
427
|
+
.eq("store_id", storeId);
|
|
428
|
+
const filePaths = (rows || []).map(r => r.file_path).filter(Boolean);
|
|
429
|
+
if (filePaths.length > 0) {
|
|
430
|
+
await sb.storage.from(BUCKET).remove(filePaths);
|
|
431
|
+
}
|
|
432
|
+
const { error } = await sb.from("store_media").delete().in("id", ids).eq("store_id", storeId);
|
|
433
|
+
if (error)
|
|
434
|
+
return { success: false, error: error.message };
|
|
435
|
+
return { success: true, data: { deleted_count: rows?.length || 0 } };
|
|
436
|
+
}
|
|
437
|
+
// ── SEARCH ─────────────────────────────────────────────────────────────────
|
|
438
|
+
case "search": {
|
|
439
|
+
const query = args.query;
|
|
440
|
+
if (!query)
|
|
441
|
+
return { success: false, error: "query required for search" };
|
|
442
|
+
const sq = sanitizeFilterValue(query);
|
|
443
|
+
const limit = Math.min(Number(args.limit) || 50, 200);
|
|
444
|
+
const offset = Number(args.offset) || 0;
|
|
445
|
+
const sortBy = VALID_SORT_COLUMNS.includes(args.sort_by) ? args.sort_by : "created_at";
|
|
446
|
+
const sortOrder = args.sort_order === "asc";
|
|
447
|
+
let q = sb.from("store_media")
|
|
448
|
+
.select(LIST_COLUMNS, { count: "exact" })
|
|
449
|
+
.eq("store_id", storeId)
|
|
450
|
+
.or(`file_name.ilike.%${sq}%,title.ilike.%${sq}%,alt_text.ilike.%${sq}%,notes.ilike.%${sq}%`)
|
|
451
|
+
.order(sortBy, { ascending: sortOrder })
|
|
452
|
+
.range(offset, offset + limit - 1);
|
|
453
|
+
// Filters
|
|
454
|
+
if (args.status) {
|
|
455
|
+
q = q.eq("status", args.status);
|
|
456
|
+
}
|
|
457
|
+
else if (!args.include_archived) {
|
|
458
|
+
q = q.neq("status", "archived");
|
|
459
|
+
}
|
|
460
|
+
if (args.folder)
|
|
461
|
+
q = q.eq("folder", args.folder);
|
|
462
|
+
if (args.category)
|
|
463
|
+
q = q.eq("category", args.category);
|
|
464
|
+
if (args.file_type) {
|
|
465
|
+
const ft = sanitizeFilterValue(args.file_type);
|
|
466
|
+
q = q.ilike("file_type", `${ft}%`);
|
|
467
|
+
}
|
|
468
|
+
if (args.tags && Array.isArray(args.tags)) {
|
|
469
|
+
q = q.overlaps("custom_tags", args.tags);
|
|
470
|
+
}
|
|
471
|
+
if (args.date_from)
|
|
472
|
+
q = q.gte("created_at", args.date_from);
|
|
473
|
+
if (args.date_to)
|
|
474
|
+
q = q.lte("created_at", args.date_to);
|
|
475
|
+
if (args.min_usage !== undefined)
|
|
476
|
+
q = q.gte("usage_count", Number(args.min_usage));
|
|
477
|
+
if (args.max_usage !== undefined)
|
|
478
|
+
q = q.lte("usage_count", Number(args.max_usage));
|
|
479
|
+
const { data, error, count } = await q;
|
|
480
|
+
if (error)
|
|
481
|
+
return { success: false, error: error.message };
|
|
482
|
+
return {
|
|
483
|
+
success: true,
|
|
484
|
+
data: { total: count || 0, count: data?.length || 0, offset, query, files: data },
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
// ── MOVE ───────────────────────────────────────────────────────────────────
|
|
488
|
+
case "move": {
|
|
489
|
+
const folder = args.folder;
|
|
490
|
+
if (!folder)
|
|
491
|
+
return { success: false, error: "folder required" };
|
|
492
|
+
const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
|
|
493
|
+
if (ids.length === 0)
|
|
494
|
+
return { success: false, error: "media_id or media_ids[] required" };
|
|
495
|
+
const { data, error } = await sb.from("store_media")
|
|
496
|
+
.update({ folder })
|
|
497
|
+
.in("id", ids)
|
|
498
|
+
.eq("store_id", storeId)
|
|
499
|
+
.select("id");
|
|
500
|
+
if (error)
|
|
501
|
+
return { success: false, error: error.message };
|
|
502
|
+
return { success: true, data: { moved_count: data?.length || 0, folder } };
|
|
503
|
+
}
|
|
504
|
+
// ── ARCHIVE ────────────────────────────────────────────────────────────────
|
|
505
|
+
case "archive": {
|
|
506
|
+
const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
|
|
507
|
+
if (ids.length === 0)
|
|
508
|
+
return { success: false, error: "media_id or media_ids[] required" };
|
|
509
|
+
const { data, error } = await sb.from("store_media")
|
|
510
|
+
.update({ status: "archived" })
|
|
511
|
+
.in("id", ids)
|
|
512
|
+
.eq("store_id", storeId)
|
|
513
|
+
.select("id");
|
|
514
|
+
if (error)
|
|
515
|
+
return { success: false, error: error.message };
|
|
516
|
+
return { success: true, data: { archived_count: data?.length || 0 } };
|
|
517
|
+
}
|
|
518
|
+
// ── UNARCHIVE ──────────────────────────────────────────────────────────────
|
|
519
|
+
case "unarchive": {
|
|
520
|
+
const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
|
|
521
|
+
if (ids.length === 0)
|
|
522
|
+
return { success: false, error: "media_id or media_ids[] required" };
|
|
523
|
+
const { data, error } = await sb.from("store_media")
|
|
524
|
+
.update({ status: "active" })
|
|
525
|
+
.in("id", ids)
|
|
526
|
+
.eq("store_id", storeId)
|
|
527
|
+
.select("id");
|
|
528
|
+
if (error)
|
|
529
|
+
return { success: false, error: error.message };
|
|
530
|
+
return { success: true, data: { unarchived_count: data?.length || 0 } };
|
|
531
|
+
}
|
|
532
|
+
// ── TAG ────────────────────────────────────────────────────────────────────
|
|
533
|
+
case "tag": {
|
|
534
|
+
const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
|
|
535
|
+
if (ids.length === 0)
|
|
536
|
+
return { success: false, error: "media_id or media_ids[] required" };
|
|
537
|
+
const setTags = args.set;
|
|
538
|
+
const addTags = args.add;
|
|
539
|
+
const removeTags = args.remove;
|
|
540
|
+
if (!setTags && !addTags && !removeTags) {
|
|
541
|
+
return { success: false, error: "Provide set (replace all), add (append), or remove (delete specific tags)" };
|
|
542
|
+
}
|
|
543
|
+
if (setTags) {
|
|
544
|
+
// Direct replacement
|
|
545
|
+
const { data, error } = await sb.from("store_media")
|
|
546
|
+
.update({ custom_tags: setTags })
|
|
547
|
+
.in("id", ids)
|
|
548
|
+
.eq("store_id", storeId)
|
|
549
|
+
.select("id, custom_tags");
|
|
550
|
+
if (error)
|
|
551
|
+
return { success: false, error: error.message };
|
|
552
|
+
return { success: true, data: { updated_count: data?.length || 0, tags: setTags } };
|
|
553
|
+
}
|
|
554
|
+
// Incremental add/remove — read-modify-write per item
|
|
555
|
+
const { data: rows, error: fetchErr } = await sb.from("store_media")
|
|
556
|
+
.select("id, custom_tags")
|
|
557
|
+
.in("id", ids)
|
|
558
|
+
.eq("store_id", storeId);
|
|
559
|
+
if (fetchErr)
|
|
560
|
+
return { success: false, error: fetchErr.message };
|
|
561
|
+
let updatedCount = 0;
|
|
562
|
+
for (const row of rows || []) {
|
|
563
|
+
let tags = row.custom_tags || [];
|
|
564
|
+
if (addTags) {
|
|
565
|
+
const newTags = addTags.filter(t => !tags.includes(t));
|
|
566
|
+
tags = [...tags, ...newTags];
|
|
567
|
+
}
|
|
568
|
+
if (removeTags) {
|
|
569
|
+
tags = tags.filter(t => !removeTags.includes(t));
|
|
570
|
+
}
|
|
571
|
+
const { error: upErr } = await sb.from("store_media")
|
|
572
|
+
.update({ custom_tags: tags })
|
|
573
|
+
.eq("id", row.id)
|
|
574
|
+
.eq("store_id", storeId);
|
|
575
|
+
if (!upErr)
|
|
576
|
+
updatedCount++;
|
|
577
|
+
}
|
|
578
|
+
return { success: true, data: { updated_count: updatedCount } };
|
|
579
|
+
}
|
|
580
|
+
// ── FOLDERS LIST ───────────────────────────────────────────────────────────
|
|
581
|
+
case "folders_list": {
|
|
582
|
+
const { data, error } = await sb.from("media_folders")
|
|
583
|
+
.select("*")
|
|
584
|
+
.eq("store_id", storeId)
|
|
585
|
+
.order("name", { ascending: true });
|
|
586
|
+
if (error)
|
|
587
|
+
return { success: false, error: error.message };
|
|
588
|
+
return { success: true, data: { count: data?.length || 0, folders: data } };
|
|
589
|
+
}
|
|
590
|
+
// ── FOLDERS CREATE ─────────────────────────────────────────────────────────
|
|
591
|
+
case "folders_create": {
|
|
592
|
+
const name = args.name;
|
|
593
|
+
if (!name)
|
|
594
|
+
return { success: false, error: "name required" };
|
|
595
|
+
const insertData = { store_id: storeId, name };
|
|
596
|
+
if (args.parent_folder_id)
|
|
597
|
+
insertData.parent_folder_id = args.parent_folder_id;
|
|
598
|
+
if (args.color)
|
|
599
|
+
insertData.color = args.color;
|
|
600
|
+
if (args.icon)
|
|
601
|
+
insertData.icon = args.icon;
|
|
602
|
+
const { data, error } = await sb.from("media_folders")
|
|
603
|
+
.insert(insertData)
|
|
604
|
+
.select("*")
|
|
605
|
+
.single();
|
|
606
|
+
if (error)
|
|
607
|
+
return { success: false, error: error.message };
|
|
608
|
+
return { success: true, data };
|
|
609
|
+
}
|
|
610
|
+
// ── FOLDERS UPDATE ─────────────────────────────────────────────────────────
|
|
611
|
+
case "folders_update": {
|
|
612
|
+
const folderId = args.folder_id;
|
|
613
|
+
if (!folderId)
|
|
614
|
+
return { success: false, error: "folder_id required" };
|
|
615
|
+
const updates = {};
|
|
616
|
+
if (args.name !== undefined)
|
|
617
|
+
updates.name = args.name;
|
|
618
|
+
if (args.parent_folder_id !== undefined)
|
|
619
|
+
updates.parent_folder_id = args.parent_folder_id;
|
|
620
|
+
if (args.color !== undefined)
|
|
621
|
+
updates.color = args.color;
|
|
622
|
+
if (args.icon !== undefined)
|
|
623
|
+
updates.icon = args.icon;
|
|
624
|
+
if (Object.keys(updates).length === 0) {
|
|
625
|
+
return { success: false, error: "Nothing to update. Fields: name, parent_folder_id, color, icon" };
|
|
626
|
+
}
|
|
627
|
+
const { data, error } = await sb.from("media_folders")
|
|
628
|
+
.update(updates)
|
|
629
|
+
.eq("id", folderId)
|
|
630
|
+
.eq("store_id", storeId)
|
|
631
|
+
.select("*")
|
|
632
|
+
.single();
|
|
633
|
+
if (error)
|
|
634
|
+
return { success: false, error: error.message };
|
|
635
|
+
return { success: true, data };
|
|
636
|
+
}
|
|
637
|
+
// ── FOLDERS DELETE ─────────────────────────────────────────────────────────
|
|
638
|
+
case "folders_delete": {
|
|
639
|
+
const folderId = args.folder_id;
|
|
640
|
+
if (!folderId)
|
|
641
|
+
return { success: false, error: "folder_id required" };
|
|
642
|
+
// Optionally move orphaned media to another folder
|
|
643
|
+
if (args.move_contents_to) {
|
|
644
|
+
await sb.from("store_media")
|
|
645
|
+
.update({ folder: args.move_contents_to })
|
|
646
|
+
.eq("folder_id", folderId)
|
|
647
|
+
.eq("store_id", storeId);
|
|
648
|
+
}
|
|
649
|
+
// FK is ON DELETE SET NULL — safe to delete
|
|
650
|
+
const { error } = await sb.from("media_folders")
|
|
651
|
+
.delete()
|
|
652
|
+
.eq("id", folderId)
|
|
653
|
+
.eq("store_id", storeId);
|
|
654
|
+
if (error)
|
|
655
|
+
return { success: false, error: error.message };
|
|
656
|
+
return { success: true, data: { deleted: folderId } };
|
|
657
|
+
}
|
|
658
|
+
// ── LINK PRODUCT ───────────────────────────────────────────────────────────
|
|
659
|
+
case "link_product": {
|
|
660
|
+
const mediaId = args.media_id;
|
|
661
|
+
const productId = args.product_id;
|
|
662
|
+
if (!mediaId || !productId)
|
|
663
|
+
return { success: false, error: "media_id and product_id required" };
|
|
664
|
+
const { error } = await sb.rpc("link_media_to_product", {
|
|
665
|
+
p_media_id: mediaId,
|
|
666
|
+
p_product_id: productId,
|
|
667
|
+
});
|
|
668
|
+
if (error)
|
|
669
|
+
return { success: false, error: error.message };
|
|
670
|
+
return { success: true, data: { linked: true, media_id: mediaId, product_id: productId } };
|
|
671
|
+
}
|
|
672
|
+
// ── UNLINK PRODUCT ─────────────────────────────────────────────────────────
|
|
673
|
+
case "unlink_product": {
|
|
674
|
+
const mediaId = args.media_id;
|
|
675
|
+
const productId = args.product_id;
|
|
676
|
+
if (!mediaId || !productId)
|
|
677
|
+
return { success: false, error: "media_id and product_id required" };
|
|
678
|
+
const { error } = await sb.rpc("unlink_media_from_product", {
|
|
679
|
+
p_media_id: mediaId,
|
|
680
|
+
p_product_id: productId,
|
|
681
|
+
});
|
|
682
|
+
if (error)
|
|
683
|
+
return { success: false, error: error.message };
|
|
684
|
+
return { success: true, data: { unlinked: true, media_id: mediaId, product_id: productId } };
|
|
685
|
+
}
|
|
686
|
+
// ── USAGE ──────────────────────────────────────────────────────────────────
|
|
687
|
+
case "usage": {
|
|
688
|
+
const mediaId = args.media_id;
|
|
689
|
+
if (!mediaId)
|
|
690
|
+
return { success: false, error: "media_id required" };
|
|
691
|
+
// Get the media's file_url
|
|
692
|
+
const { data: media, error: mediaErr } = await sb.from("store_media")
|
|
693
|
+
.select("file_url")
|
|
694
|
+
.eq("id", mediaId)
|
|
695
|
+
.eq("store_id", storeId)
|
|
696
|
+
.single();
|
|
697
|
+
if (mediaErr)
|
|
698
|
+
return { success: false, error: mediaErr.message };
|
|
699
|
+
if (!media?.file_url)
|
|
700
|
+
return { success: false, error: "Media not found or has no file_url" };
|
|
701
|
+
// Query media_references for this URL
|
|
702
|
+
const { data: refs, error: refErr } = await sb.from("media_references")
|
|
703
|
+
.select("entity_type, entity_id, entity_name, field_name, created_at")
|
|
704
|
+
.eq("media_url", media.file_url)
|
|
705
|
+
.eq("store_id", storeId)
|
|
706
|
+
.order("created_at", { ascending: false });
|
|
707
|
+
if (refErr)
|
|
708
|
+
return { success: false, error: refErr.message };
|
|
709
|
+
return {
|
|
710
|
+
success: true,
|
|
711
|
+
data: {
|
|
712
|
+
media_id: mediaId,
|
|
713
|
+
reference_count: refs?.length || 0,
|
|
714
|
+
references: refs,
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
// ── ANALYTICS ──────────────────────────────────────────────────────────────
|
|
719
|
+
case "analytics": {
|
|
720
|
+
// Run parallel aggregate queries
|
|
721
|
+
const [activeRes, archivedRes, allRes, orphanRes, typeRes, catRes, topRes] = await Promise.all([
|
|
722
|
+
sb.from("store_media").select("id", { count: "exact", head: true }).eq("store_id", storeId).eq("status", "active"),
|
|
723
|
+
sb.from("store_media").select("id", { count: "exact", head: true }).eq("store_id", storeId).eq("status", "archived"),
|
|
724
|
+
sb.from("store_media").select("file_size, file_type, category").eq("store_id", storeId),
|
|
725
|
+
sb.from("store_media").select("id", { count: "exact", head: true }).eq("store_id", storeId).eq("usage_count", 0).neq("status", "archived"),
|
|
726
|
+
sb.from("store_media").select("file_type").eq("store_id", storeId),
|
|
727
|
+
sb.from("store_media").select("category").eq("store_id", storeId),
|
|
728
|
+
sb.from("store_media").select("id, file_name, title, file_url, usage_count").eq("store_id", storeId).order("usage_count", { ascending: false }).limit(10),
|
|
729
|
+
]);
|
|
730
|
+
// Compute total storage
|
|
731
|
+
const allRows = allRes.data || [];
|
|
732
|
+
const totalBytes = allRows.reduce((sum, r) => sum + (r.file_size || 0), 0);
|
|
733
|
+
// Type breakdown (group by prefix before /)
|
|
734
|
+
const typeBreakdown = {};
|
|
735
|
+
for (const row of typeRes.data || []) {
|
|
736
|
+
const prefix = (row.file_type || "unknown").split("/")[0];
|
|
737
|
+
typeBreakdown[prefix] = (typeBreakdown[prefix] || 0) + 1;
|
|
738
|
+
}
|
|
739
|
+
// Category breakdown
|
|
740
|
+
const catBreakdown = {};
|
|
741
|
+
for (const row of catRes.data || []) {
|
|
742
|
+
const cat = row.category || "unknown";
|
|
743
|
+
catBreakdown[cat] = (catBreakdown[cat] || 0) + 1;
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
success: true,
|
|
747
|
+
data: {
|
|
748
|
+
total_items: (activeRes.count || 0) + (archivedRes.count || 0),
|
|
749
|
+
active_count: activeRes.count || 0,
|
|
750
|
+
archived_count: archivedRes.count || 0,
|
|
751
|
+
orphan_count: orphanRes.count || 0,
|
|
752
|
+
total_storage_bytes: totalBytes,
|
|
753
|
+
total_storage_mb: Math.round(totalBytes / 1_048_576 * 100) / 100,
|
|
754
|
+
type_breakdown: typeBreakdown,
|
|
755
|
+
category_breakdown: catBreakdown,
|
|
756
|
+
top_used: topRes.data || [],
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
// ── DUPLICATE ──────────────────────────────────────────────────────────────
|
|
761
|
+
case "duplicate": {
|
|
762
|
+
const mediaId = args.media_id;
|
|
763
|
+
if (!mediaId)
|
|
764
|
+
return { success: false, error: "media_id required" };
|
|
765
|
+
// Fetch source row
|
|
766
|
+
const { data: source, error: srcErr } = await sb.from("store_media")
|
|
767
|
+
.select("*")
|
|
768
|
+
.eq("id", mediaId)
|
|
769
|
+
.eq("store_id", storeId)
|
|
770
|
+
.single();
|
|
771
|
+
if (srcErr)
|
|
772
|
+
return { success: false, error: srcErr.message };
|
|
773
|
+
// Download source file from storage
|
|
774
|
+
const { data: fileData, error: dlErr } = await sb.storage
|
|
775
|
+
.from(BUCKET)
|
|
776
|
+
.download(source.file_path);
|
|
777
|
+
if (dlErr)
|
|
778
|
+
return { success: false, error: `Download source failed: ${dlErr.message}` };
|
|
779
|
+
const buffer = Buffer.from(await fileData.arrayBuffer());
|
|
780
|
+
// Upload with new UUID path
|
|
781
|
+
const newId = crypto.randomUUID();
|
|
782
|
+
const ext = source.file_name.split(".").pop() || "bin";
|
|
783
|
+
const newFileName = `${newId}.${ext}`;
|
|
784
|
+
const newPath = `${source.folder || "uploads"}/${storeId.toUpperCase()}/${newFileName}`;
|
|
785
|
+
const { error: upErr } = await sb.storage
|
|
786
|
+
.from(BUCKET)
|
|
787
|
+
.upload(newPath, buffer, { contentType: source.file_type, upsert: true });
|
|
788
|
+
if (upErr)
|
|
789
|
+
return { success: false, error: `Upload duplicate failed: ${upErr.message}` };
|
|
790
|
+
const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(newPath);
|
|
791
|
+
const cdnUrl = urlData.publicUrl;
|
|
792
|
+
const newTitle = args.new_title || (source.title ? `${source.title} (copy)` : null);
|
|
793
|
+
const { data: newRow, error: insertErr } = await sb.from("store_media")
|
|
794
|
+
.insert({
|
|
795
|
+
store_id: storeId,
|
|
796
|
+
file_name: newFileName,
|
|
797
|
+
file_path: newPath,
|
|
798
|
+
file_url: cdnUrl,
|
|
799
|
+
file_size: buffer.length,
|
|
800
|
+
file_type: source.file_type,
|
|
801
|
+
category: source.category,
|
|
802
|
+
source: "media-duplicate",
|
|
803
|
+
folder: source.folder,
|
|
804
|
+
title: newTitle,
|
|
805
|
+
alt_text: source.alt_text,
|
|
806
|
+
notes: source.notes,
|
|
807
|
+
custom_tags: source.custom_tags,
|
|
808
|
+
})
|
|
809
|
+
.select("id, file_name, file_url, title")
|
|
810
|
+
.single();
|
|
811
|
+
if (insertErr)
|
|
812
|
+
return { success: false, error: insertErr.message };
|
|
813
|
+
return {
|
|
814
|
+
success: true,
|
|
815
|
+
data: {
|
|
816
|
+
original_id: mediaId,
|
|
817
|
+
id: newRow.id,
|
|
818
|
+
file_name: newRow.file_name,
|
|
819
|
+
file_url: newRow.file_url,
|
|
820
|
+
title: newRow.title,
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
// ── REPLACE ────────────────────────────────────────────────────────────────
|
|
825
|
+
case "replace": {
|
|
826
|
+
const mediaId = args.media_id;
|
|
827
|
+
if (!mediaId)
|
|
828
|
+
return { success: false, error: "media_id required" };
|
|
829
|
+
let base64 = args.base64;
|
|
830
|
+
const fileUrl = args.file_url;
|
|
831
|
+
if (!base64 && !fileUrl) {
|
|
832
|
+
return { success: false, error: "Provide base64, file_url, or file_path (local — handled by CLI)" };
|
|
833
|
+
}
|
|
834
|
+
if (base64)
|
|
835
|
+
base64 = base64.replace(/^data:[^;]+;base64,/, "").replace(/\s/g, "");
|
|
836
|
+
if (!base64 && fileUrl) {
|
|
837
|
+
if (fileUrl.startsWith("/") || /^[A-Z]:\\/i.test(fileUrl)) {
|
|
838
|
+
return { success: false, error: `Use file_path for local files: media(action="replace", media_id="...", file_path="${fileUrl}")` };
|
|
839
|
+
}
|
|
840
|
+
let fetchUrl = fileUrl;
|
|
841
|
+
if (!fetchUrl.startsWith("http://") && !fetchUrl.startsWith("https://")) {
|
|
842
|
+
fetchUrl = `https://${fetchUrl}`;
|
|
843
|
+
}
|
|
844
|
+
const resp = await fetch(fetchUrl);
|
|
845
|
+
if (!resp.ok)
|
|
846
|
+
return { success: false, error: `Failed to fetch file_url: ${resp.status}` };
|
|
847
|
+
const buf = await resp.arrayBuffer();
|
|
848
|
+
base64 = Buffer.from(buf).toString("base64");
|
|
849
|
+
}
|
|
850
|
+
// Fetch existing row
|
|
851
|
+
const { data: existing, error: getErr } = await sb.from("store_media")
|
|
852
|
+
.select("file_path")
|
|
853
|
+
.eq("id", mediaId)
|
|
854
|
+
.eq("store_id", storeId)
|
|
855
|
+
.single();
|
|
856
|
+
if (getErr)
|
|
857
|
+
return { success: false, error: getErr.message };
|
|
858
|
+
// Delete old storage file
|
|
859
|
+
if (existing.file_path) {
|
|
860
|
+
await sb.storage.from(BUCKET).remove([existing.file_path]);
|
|
861
|
+
}
|
|
862
|
+
const mime = args.mime_type || detectMime(base64);
|
|
863
|
+
const ext = mimeToExt(mime);
|
|
864
|
+
const buffer = Buffer.from(base64, "base64");
|
|
865
|
+
const newId = crypto.randomUUID();
|
|
866
|
+
const originalName = args.file_name || "";
|
|
867
|
+
const newFileName = originalName
|
|
868
|
+
? `${newId}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
|
|
869
|
+
: `${newId}.${ext}`;
|
|
870
|
+
const folder = args.folder || "uploads";
|
|
871
|
+
const newPath = `${folder}/${storeId.toUpperCase()}/${newFileName}`;
|
|
872
|
+
const { error: upErr } = await sb.storage
|
|
873
|
+
.from(BUCKET)
|
|
874
|
+
.upload(newPath, buffer, { contentType: mime, upsert: true });
|
|
875
|
+
if (upErr)
|
|
876
|
+
return { success: false, error: `Upload replacement failed: ${upErr.message}` };
|
|
877
|
+
const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(newPath);
|
|
878
|
+
const cdnUrl = urlData.publicUrl;
|
|
879
|
+
const { data, error } = await sb.from("store_media")
|
|
880
|
+
.update({
|
|
881
|
+
file_name: newFileName,
|
|
882
|
+
file_path: newPath,
|
|
883
|
+
file_url: cdnUrl,
|
|
884
|
+
file_size: buffer.length,
|
|
885
|
+
file_type: mime,
|
|
886
|
+
})
|
|
887
|
+
.eq("id", mediaId)
|
|
888
|
+
.eq("store_id", storeId)
|
|
889
|
+
.select("*")
|
|
890
|
+
.single();
|
|
891
|
+
if (error)
|
|
892
|
+
return { success: false, error: error.message };
|
|
893
|
+
return { success: true, data };
|
|
894
|
+
}
|
|
895
|
+
// ── DEFAULT ────────────────────────────────────────────────────────────────
|
|
896
|
+
default:
|
|
897
|
+
return {
|
|
898
|
+
success: false,
|
|
899
|
+
error: `Unknown action: ${action}. Available actions: ${ALL_ACTIONS.join(", ")}`,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
}
|