211 lines
7.2 KiB
JavaScript
211 lines
7.2 KiB
JavaScript
|
|
"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<object>}
|
||
|
|
*/
|
||
|
|
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 };
|