feat: add listAmppFolders() + placeAssetInFolderById() — direct folder placement by ID

This commit is contained in:
Zac Gaetano 2026-04-30 17:37:41 -04:00
parent 6667f320c5
commit cdcb668e74

View file

@ -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<object>}
*/
// ── 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 };