diff --git a/lib/ampp-folder-placer.js b/lib/ampp-folder-placer.js index e09ceda..f2d97ca 100644 --- a/lib/ampp-folder-placer.js +++ b/lib/ampp-folder-placer.js @@ -3,10 +3,11 @@ // ================================================================ // AMPP Folder Placer — lib/ampp-folder-placer.js // -// 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 +// placeAsset() — original v3.5 port (filename -- parsing) +// placeAssetInFolderById() — direct placement by known folder ID +// getOrCreateFolderByPath() — hierarchy lookup + create, returns folder ID +// listAmppFolders() — fetch AMPP virtual folder tree (probes endpoints) +// amppRequest() — shared low-level REST helper // ================================================================ const PREFIX_DELIM = "--"; @@ -35,54 +36,138 @@ async function amppRequest(method, baseUrl, token, apiPath, body = null) { return r.json(); } +// ── Get or create a folder by slash-separated path ─────────────────────────── +// e.g. "NEWS/PACKAGES" → looks up hierarchy, creates missing levels, returns ID. +async function getOrCreateFolderByPath(baseUrl, folderPath, token) { + const parts = folderPath.split("/").map(s => s.trim()).filter(Boolean); + if (!parts.length) throw new Error("folderPath is empty"); + + let parentId = null; + let currentPath = ""; + let expectedDepth = 0; + + for (const fname of parts) { + currentPath = currentPath ? `${currentPath}/${fname}` : fname; + expectedDepth++; + let folderId = null; + + // Try hierarchy lookup first + try { + const encoded = currentPath.split("/").map(encodeURIComponent).join("/"); + const resp = await amppRequest("GET", baseUrl, token, + `api/v1/store/folder/folders/hierarchy?path=${encoded}`); + const hlist = resp?.["hierarchy:list"]; + if (Array.isArray(hlist) && hlist.length === expectedDepth) { + const last = hlist[hlist.length - 1]; + const candidateId = (last?.["folder:id"] ?? "").toString().trim(); + if (candidateId) folderId = candidateId; + } + } catch (err) { + console.warn(`[AMPP] Hierarchy lookup for '${currentPath}': ${err.message}`); + } + + // Create the folder if not found + if (!folderId) { + const createBody = { "name:text": fname }; + if (parentId) createBody["parentFolders:tags"] = [parentId]; + const fresp = await amppRequest("POST", baseUrl, token, + "api/v1/store/folder/folders", createBody); + const createdId = (fresp?.["folder:id"] ?? "").toString().trim(); + if (!createdId) throw new Error(`Folder create response missing folder:id for '${fname}'`); + folderId = createdId; + } + + parentId = folderId; + } + + return parentId; // ID of the deepest folder +} + // ── 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) { + // Helper: normalise raw array of folder objects + function normaliseFolders(raw) { + if (!Array.isArray(raw)) return null; + 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); + return folders; + } + + // 1. Try POST querypage (same pattern as the jobs API) + const queryPagePaths = [ + "api/v1/store/folder/folders/querypage?skip=0&limit=500&sort=name:text&asc=true", + "api/v1/store/folder/folders/querypage", + ]; + for (const candidate of queryPagePaths) { 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 resp = await amppRequest("POST", baseUrl, token, candidate, "{}"); + const raw = Array.isArray(resp) ? resp + : (resp?.["folder:list"] ?? resp?.items ?? resp?.results ?? resp?.folders ?? []); + const folders = normaliseFolders(raw); + if (folders && folders.length > 0) { + console.log(`[AMPP folders] ${folders.length} folders via POST ${candidate}`); + return { folders, endpoint: `POST ${candidate}` }; } - - 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}`); + console.warn(`[AMPP folders] POST ${candidate} failed: ${err.message}`); } } - throw new Error(`Could not list AMPP folders — all endpoints failed. Last error: ${lastError?.message}`); + // 2. Try GET candidates + const getCandidates = [ + "api/v1/store/folder/folders?limit=500", + "api/v1/store/folder/folders", + "api/v1/store/folder/folder/list", + "api/v1/store/folder/folders/list", + ]; + for (const candidate of getCandidates) { + try { + const resp = await amppRequest("GET", baseUrl, token, candidate); + const raw = Array.isArray(resp) ? resp + : (resp?.["folder:list"] ?? resp?.folders ?? resp?.items ?? resp?.results ?? []); + const folders = normaliseFolders(raw); + if (folders && folders.length > 0) { + console.log(`[AMPP folders] ${folders.length} folders via GET ${candidate}`); + return { folders, endpoint: candidate }; + } + } catch (err) { + lastError = err; + console.warn(`[AMPP folders] GET ${candidate} failed: ${err.message}`); + } + } + + // 3. Hierarchy walk: call hierarchy with empty path to probe root-level folders + try { + const resp = await amppRequest("GET", baseUrl, token, "api/v1/store/folder/folders/hierarchy?path="); + const hlist = resp?.["hierarchy:list"] ?? resp?.["folder:list"]; + if (Array.isArray(hlist) && hlist.length > 0) { + const folders = normaliseFolders(hlist); + if (folders && folders.length > 0) { + console.log(`[AMPP folders] ${folders.length} root folders via hierarchy walk`); + return { folders, endpoint: "hierarchy-root" }; + } + } + } catch (err) { + lastError = err; + console.warn(`[AMPP folders] hierarchy root probe failed: ${err.message}`); + } + + // All endpoints failed — caller should fall back to manual path entry + throw new Error( + `Could not list AMPP folders — no list endpoint available. ${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"); @@ -96,7 +181,6 @@ async function placeAssetInFolderById(baseUrl, assetId, folderId, token) { } // ── Original v3.5 placement (filename -- parsing) ──────────────────────────── -// Kept for the manual /api/ampp/place endpoint. async function placeAsset(baseUrl, assetName, assetId, token) { if (!assetName.includes(PREFIX_DELIM)) { return { placed: false, folderId: null, path: "", reason: `no "${PREFIX_DELIM}" delimiter in filename` }; @@ -108,57 +192,16 @@ async function placeAsset(baseUrl, assetName, assetId, token) { } const folderNames = parts.slice(0, -1); - let parentId = null; - let currentPath = ""; - let expectedDepth = 0; + const folderPath = folderNames.map(s => s.trim()).filter(Boolean).join("/"); - for (const rawName of folderNames) { - const fname = rawName.trim(); - if (!fname) continue; + const folderId = await getOrCreateFolderByPath(baseUrl, folderPath, token); - currentPath = currentPath ? `${currentPath}/${fname}` : fname; - expectedDepth++; - let folderId = null; - - try { - 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"]; - 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]; - const candidateId = (last?.["folder:id"] ?? "").toString().trim(); - if (candidateId) folderId = candidateId; - } - } catch (lookupEx) { - folderId = null; - console.warn(`[AMPP folder] Hierarchy lookup failed for '${currentPath}': ${lookupEx.message}`); - } - - 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); - 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) { - throw new Error(`Failed to create folder '${fname}' under path '${currentPath}': ${ex.message}`); - } - } - - parentId = folderId; - } - - if (parentId) { - const linkBody = JSON.stringify({ "folder:id": parentId, "asset:id": assetId }, null, 0); + if (folderId) { + 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: parentId, path: currentPath }; + return { placed: true, folderId, path: folderPath }; } -module.exports = { placeAsset, placeAssetInFolderById, listAmppFolders, amppRequest }; +module.exports = { placeAsset, placeAssetInFolderById, getOrCreateFolderByPath, listAmppFolders, amppRequest };