"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 };