From 77a64455ae47f70d62db5b604a768aa55d4fb0c8 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 30 Apr 2026 17:13:04 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20AMPP=20folder=20placer=20?= =?UTF-8?q?=E2=80=94=20Node.js=20port=20of=20folder-organizer-v3.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ampp-folder-placer.js | 210 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 lib/ampp-folder-placer.js diff --git a/lib/ampp-folder-placer.js b/lib/ampp-folder-placer.js new file mode 100644 index 0000000..38c22f5 --- /dev/null +++ b/lib/ampp-folder-placer.js @@ -0,0 +1,210 @@ +"use strict"; + +// ================================================================ +// AMPP Folder Placer — lib/ampp-folder-placer.js +// Node.js port of folder-organizer-v3.5.py +// +// Given an AMPP base URL, Bearer token, asset ID, and asset name +// (with "--" as the path delimiter), finds or creates the nested +// AMPP virtual-folder hierarchy, then links the asset to the +// deepest folder. +// +// All v3.5 defensive guards are ported: +// 1. Validate resp is a plain object before .get() +// 2. Validate hlist is an array before indexing +// 3. Only trust a FULL depth match from the hierarchy lookup +// 4. Specific catch on hierarchy lookup — fall through to create, +// don't silently swallow ALL errors +// 5. Asset link failure throws — caller sees it in the AMPP job log +// 6. Defensive .toString().trim() on every folder:id returned +// ================================================================ + +const PREFIX_DELIM = "--"; + +/** + * Low-level AMPP REST call. + * Throws on non-2xx responses with a descriptive message. + * + * @param {"GET"|"POST"} method + * @param {string} baseUrl e.g. "https://us-east-1.gvampp.com" + * @param {string} token Bearer access token + * @param {string} apiPath relative path, e.g. "api/v1/store/folder/folders" + * @param {object|string|null} body + * @returns {Promise} + */ +async function amppRequest(method, baseUrl, token, apiPath, body = null) { + const url = `${baseUrl.replace(/\/$/, "")}/${apiPath}`; + const opts = { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(15000), + }; + if (body !== null) { + opts.body = typeof body === "string" ? body : JSON.stringify(body); + } + const r = await fetch(url, opts); + if (!r.ok) { + const text = await r.text().catch(() => ""); + throw new Error( + `AMPP API ${method} ${apiPath} → HTTP ${r.status}: ${text.slice(0, 200)}` + ); + } + return r.json(); +} + +/** + * placeAsset — find/create nested AMPP virtual folders and link an asset. + * + * Mirrors folder-organizer-v3.5.py exactly: + * - Splits assetName on "--", uses all but the last segment as folder names. + * - Walks the hierarchy left-to-right, doing a GET lookup at each level. + * - Only trusts a lookup result when returned depth == expected depth (v3.5 fix #3). + * - Creates the folder (with correct parentFolders:tags) if not found. + * - Links the asset to the deepest folder after all levels are resolved. + * + * @param {string} baseUrl AMPP base URL + * @param {string} assetName Filename with "--" delimiters (e.g. "NEWS--PKG--clip.mxf") + * @param {string} assetId AMPP asset:id (string) + * @param {string} token Bearer access token + * @returns {Promise<{ placed: boolean, folderId: string|null, path: string, reason?: string }>} + */ +async function placeAsset(baseUrl, assetName, assetId, token) { + // No delimiter → skip (same behaviour as the IronPython script's `pass`) + if (!assetName.includes(PREFIX_DELIM)) { + return { + placed: false, + folderId: null, + path: "", + reason: `no "${PREFIX_DELIM}" delimiter in filename`, + }; + } + + const parts = assetName.split(PREFIX_DELIM); + if (parts.length < 2) { + return { + placed: false, + folderId: null, + path: "", + reason: "fewer than 2 parts after split", + }; + } + + // All segments except the last are folder names; the last is the filename. + const folderNames = parts.slice(0, -1); + let parentId = null; + let currentPath = ""; + let expectedDepth = 0; // how many path segments we've built so far + + for (const rawName of folderNames) { + const fname = rawName.trim(); + if (!fname) continue; + + currentPath = currentPath ? `${currentPath}/${fname}` : fname; + expectedDepth++; + let folderId = null; + + // ── Hierarchy lookup (v3.5 fixes #1 #2 #3 #4) ───────────────────────── + try { + // URL-encode the path but keep "/" intact (matches urllib.parse.quote safe="/") + const encoded = currentPath + .split("/") + .map(encodeURIComponent) + .join("/"); + + const resp = await amppRequest( + "GET", + baseUrl, + token, + `api/v1/store/folder/folders/hierarchy?path=${encoded}` + ); + + // Fix #1 — resp must be a plain object + if (!resp || typeof resp !== "object" || Array.isArray(resp)) { + throw new Error(`Hierarchy resp is not an object: ${typeof resp}`); + } + + const hlist = resp["hierarchy:list"]; + + // Fix #2 — hlist must be an array + if (!Array.isArray(hlist)) { + throw new Error(`hierarchy:list is not an array: ${typeof hlist}`); + } + + // Fix #3 — only trust a FULL depth match + // A partial match (API returns A, A/B when we need A/B/C) means the full + // path doesn't exist yet — fall through to creation. + if (hlist.length === expectedDepth) { + const last = hlist[hlist.length - 1]; + // Fix #6 — defensive strip on folder:id + const candidateId = (last?.["folder:id"] ?? "").toString().trim(); + if (candidateId) folderId = candidateId; + } + } catch (lookupEx) { + // Fix #4 — specific catch: log and fall through to create. + // Don't hard-fail the whole placement for a lookup error. + folderId = null; + console.warn( + `[AMPP folder] Hierarchy lookup failed for '${currentPath}': ${lookupEx.message}` + ); + } + + // ── Create folder if not found ───────────────────────────────────────── + if (!folderId) { + const createBody = { "name:text": fname }; + if (parentId) createBody["parentFolders:tags"] = [parentId]; + + try { + const fresp = await amppRequest( + "POST", + baseUrl, + token, + "api/v1/store/folder/folders", + createBody + ); + + // Fix #1 applied to create response too + if (!fresp || typeof fresp !== "object" || Array.isArray(fresp)) { + throw new Error( + `Folder create resp is not an object: ${typeof fresp}` + ); + } + // Fix #6 — defensive strip + const createdId = (fresp["folder:id"] ?? "").toString().trim(); + if (!createdId) throw new Error("Folder create resp missing folder:id"); + folderId = createdId; + } catch (ex) { + // Re-throw with context so the caller (and AMPP job log) can see it + throw new Error( + `Failed to create folder '${fname}' under path '${currentPath}': ${ex.message}` + ); + } + } + + parentId = folderId; + } + + // ── Link asset to deepest folder (fix #5 — throw on failure) ───────────── + if (parentId) { + // Compact JSON — same as the IronPython separators=(',',':') + const linkBody = JSON.stringify( + { "folder:id": parentId, "asset:id": assetId }, + null, + 0 + ); + // If this throws, the caller sees it — no silent swallow. + await amppRequest( + "POST", + baseUrl, + token, + "api/v1/store/folder/references", + linkBody + ); + } + + return { placed: true, folderId: parentId, path: currentPath }; +} + +module.exports = { placeAsset, amppRequest };