diff --git a/lib/ampp-folder-placer.js b/lib/ampp-folder-placer.js index 38c22f5..e09ceda 100644 --- a/lib/ampp-folder-placer.js +++ b/lib/ampp-folder-placer.js @@ -2,36 +2,16 @@ // ================================================================ // 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 +// placeAsset() — original v3.5 port (filename -- parsing) +// placeAssetInFolderById() — direct placement by known folder ID +// listAmppFolders() — fetch AMPP virtual folder tree (probes endpoints) +// amppRequest() — shared low-level REST helper // ================================================================ 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} - */ +// ── Low-level REST helper ───────────────────────────────────────────────────── async function amppRequest(method, baseUrl, token, apiPath, body = null) { const url = `${baseUrl.replace(/\/$/, "")}/${apiPath}`; const opts = { @@ -55,48 +35,82 @@ async function amppRequest(method, baseUrl, token, apiPath, body = null) { 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 }>} - */ +// ── List AMPP virtual folders ──────────────────────────────────────────────── +// Probes multiple candidate endpoints in order; uses the first that responds. +// Returns: { folders: [{id, name, parentId, path}], endpoint } +const FOLDER_LIST_CANDIDATES = [ + "api/v1/store/folder/folders?limit=500", + "api/v1/store/folder/folders", + "api/v1/store/folder/folder/list", + "api/v1/store/folder/folders/list", +]; + +async function listAmppFolders(baseUrl, token) { + let lastError = null; + + for (const candidate of FOLDER_LIST_CANDIDATES) { + try { + const resp = await amppRequest("GET", baseUrl, token, candidate); + + // Normalise whatever shape AMPP returns into an array + const raw = Array.isArray(resp) + ? resp + : resp?.["folder:list"] ?? resp?.folders ?? resp?.items ?? resp?.results ?? []; + + if (!Array.isArray(raw)) { + console.warn(`[AMPP folders] ${candidate} returned non-array: ${typeof raw}`); + continue; + } + + const folders = raw.map((f) => ({ + id: (f["folder:id"] ?? f.id ?? "").toString().trim(), + name: (f["name:text"] ?? f.name ?? f["folder:name"] ?? "").toString().trim(), + parentId: (f["parentFolder:id"] ?? f.parentId ?? f["parent:id"] ?? "").toString().trim(), + path: (f["folder:path"] ?? f.path ?? "").toString().trim(), + })).filter(f => f.id && f.name); + + console.log(`[AMPP folders] ${folders.length} folders via ${candidate}`); + return { folders, endpoint: candidate }; + } catch (err) { + lastError = err; + console.warn(`[AMPP folders] ${candidate} failed: ${err.message}`); + } + } + + throw new Error(`Could not list AMPP folders — all endpoints failed. Last error: ${lastError?.message}`); +} + +// ── Direct placement by known folder ID ────────────────────────────────────── +// Used by the background placement worker. No filename parsing — we already +// know the folder ID from what the user selected in the UI. +async function placeAssetInFolderById(baseUrl, assetId, folderId, token) { + if (!assetId) throw new Error("assetId is required"); + if (!folderId) throw new Error("folderId is required"); + + const linkBody = JSON.stringify( + { "folder:id": folderId, "asset:id": assetId }, + null, 0 + ); + await amppRequest("POST", baseUrl, token, "api/v1/store/folder/references", linkBody); + return { placed: true, folderId, assetId }; +} + +// ── Original v3.5 placement (filename -- parsing) ──────────────────────────── +// Kept for the manual /api/ampp/place endpoint. 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`, - }; + 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", - }; + 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 + let expectedDepth = 0; for (const rawName of folderNames) { const fname = rawName.trim(); @@ -106,105 +120,45 @@ async function placeAsset(baseUrl, assetName, assetId, token) { 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 encoded = currentPath.split("/").map(encodeURIComponent).join("/"); + const resp = await amppRequest("GET", baseUrl, token, `api/v1/store/folder/folders/hierarchy?path=${encoded}`); + 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 (!Array.isArray(hlist)) throw new Error(`hierarchy:list is not an array: ${typeof hlist}`); 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}` - ); + 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 fresp = await amppRequest("POST", baseUrl, token, "api/v1/store/folder/folders", createBody); + if (!fresp || typeof fresp !== "object" || Array.isArray(fresp)) throw new Error(`Folder create resp is not an object: ${typeof fresp}`); 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}` - ); + 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 - ); + const linkBody = JSON.stringify({ "folder:id": parentId, "asset:id": assetId }, null, 0); + await amppRequest("POST", baseUrl, token, "api/v1/store/folder/references", linkBody); } return { placed: true, folderId: parentId, path: currentPath }; } -module.exports = { placeAsset, amppRequest }; +module.exports = { placeAsset, placeAssetInFolderById, listAmppFolders, amppRequest };