{
  "Name": "Archive Yeeflow Files to SharePoint v2",
  "Description": "Archives one or multiple Yeeflow files to a SharePoint document library root or subfolder through Microsoft Graph. Supports fail, replace, automatic rename, and skip conflict policies.",
  "ImplType": 0,
  "DraftCode": "function requireText(value, name) {\n  const text = String(value ?? \"\").trim();\n  if (!text) throw new Error(`Required parameter is missing: ${name}`);\n  return text;\n}\n\nfunction sanitizeError(error) {\n  const message = error instanceof Error ? error.message : \"Unknown error\";\n  return String(message).slice(0, 500);\n}\n\nfunction normalizeConflictBehavior(value) {\n  const behavior = String(value ?? \"\").trim().toLowerCase() || \"fail\";\n  if (![\"fail\", \"replace\", \"rename\", \"skip\"].includes(behavior)) {\n    throw new Error(\n      \"conflictBehavior must be one of: fail, replace, rename, skip.\"\n    );\n  }\n  return behavior;\n}\n\nfunction normalizeFiles(value, allowDuplicateNames) {\n  const source = Array.isArray(value) ? value : value ? [value] : [];\n  const files = source.filter((file) =>\n    file && String(file.id ?? \"\").trim() && String(file.name ?? \"\").trim()\n  );\n  if (!files.length) {\n    throw new Error(\"Required file parameter is missing: documentFiles\");\n  }\n\n  const names = new Set();\n  for (const file of files) {\n    const name = normalizeFileName(file.name);\n    const key = name.toLowerCase();\n    if (!allowDuplicateNames && names.has(key)) {\n      throw new Error(`Duplicate file name in documentFiles: ${name}`);\n    }\n    names.add(key);\n\n    const size = Number(file.fileSize ?? 0);\n    if (Number.isFinite(size) && size > 250 * 1024 * 1024) {\n      throw new Error(`File exceeds the 250 MB simple-upload limit: ${name}`);\n    }\n  }\n  return files;\n}\n\nfunction normalizeFileName(value) {\n  const name = requireText(value, \"fileName\");\n  if (name === \".\" || name === \"..\" || /[\\/\\\\\\u0000-\\u001f\"*:<>?|]/.test(name)) {\n    throw new Error(`Invalid destination file name: ${name}`);\n  }\n  return name;\n}\n\nfunction normalizeFolderSegments(value) {\n  const raw = String(value ?? \"\").trim();\n  if (!raw) return [];\n  const segments = raw.replace(/\\\\/g, \"/\").split(\"/\")\n    .map((segment) => segment.trim()).filter(Boolean);\n  for (const segment of segments) {\n    if (segment === \".\" || segment === \"..\" || /[\\u0000-\\u001f\"*:<>?|]/.test(segment)) {\n      throw new Error(`Invalid folder path segment: ${segment}`);\n    }\n  }\n  return segments;\n}\n\nfunction parseSiteUrl(value) {\n  const raw = requireText(value, \"siteUrl\");\n  let parsed;\n  try {\n    parsed = new URL(raw);\n  } catch {\n    throw new Error(\"siteUrl must be a valid absolute HTTPS URL.\");\n  }\n  if (parsed.protocol !== \"https:\") throw new Error(\"siteUrl must use HTTPS.\");\n  if (parsed.username || parsed.password || parsed.search || parsed.hash) {\n    throw new Error(\"siteUrl must not contain credentials, query parameters, or fragments.\");\n  }\n  const result = Object.create(null);\n  result.hostname = parsed.hostname;\n  result.sitePath = parsed.pathname === \"/\" ? \"/\" : parsed.pathname.replace(/\\/+$/, \"\");\n  return result;\n}\n\nfunction encodePath(segments) {\n  return segments.map((segment) => encodeURIComponent(segment)).join(\"/\");\n}\n\nasync function readJsonSafely(response) {\n  try {\n    return await response.json();\n  } catch {\n    return null;\n  }\n}\n\nasync function graphRequest(\n  modules,\n  connection,\n  url,\n  options,\n  allowedStatuses = []\n) {\n  const response = await modules.fetch(url, { ...options, connection });\n  const status = Number(response?.status ?? 0);\n  const data = await readJsonSafely(response);\n  if ((status < 200 || status >= 300) && !allowedStatuses.includes(status)) {\n    const detail = data?.error?.message || data?.error?.code || `HTTP ${status || \"unknown\"}`;\n    throw new Error(`Microsoft Graph request failed: ${String(detail).slice(0, 500)}`);\n  }\n  const result = Object.create(null);\n  result.status = status;\n  result.data = data;\n  return result;\n}\n\nasync function resolveSite(modules, connection, siteUrl) {\n  const parsed = parseSiteUrl(siteUrl);\n  const endpoint = `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(parsed.hostname)}:${encodeURI(parsed.sitePath)}?$select=id,displayName,webUrl`;\n  const result = await graphRequest(modules, connection, endpoint, { method: \"GET\" });\n  if (!result.data?.id) throw new Error(\"Microsoft Graph did not return a SharePoint site ID.\");\n  return result.data;\n}\n\nasync function resolveDrive(modules, connection, siteId, nameOrId) {\n  const selector = requireText(nameOrId, \"libraryNameOrId\");\n  let nextUrl = `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drives?$select=id,name,webUrl`;\n  while (nextUrl) {\n    const result = await graphRequest(modules, connection, nextUrl, { method: \"GET\" });\n    const drives = Array.isArray(result.data?.value) ? result.data.value : [];\n    const match = drives.find((drive) =>\n      String(drive?.id ?? \"\") === selector ||\n      String(drive?.name ?? \"\").toLowerCase() === selector.toLowerCase()\n    );\n    if (match?.id) return match;\n    nextUrl = String(result.data?.[\"@odata.nextLink\"] ?? \"\");\n  }\n  throw new Error(`SharePoint document library was not found: ${selector}`);\n}\n\nasync function getDriveRoot(modules, connection, driveId) {\n  const endpoint = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root?$select=id,name,webUrl,folder`;\n  const result = await graphRequest(modules, connection, endpoint, { method: \"GET\" });\n  if (!result.data?.id) throw new Error(\"Microsoft Graph did not return the document library root.\");\n  return result.data;\n}\n\nasync function findFolder(modules, connection, driveId, segments) {\n  const endpoint = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:/${encodePath(segments)}?$select=id,name,webUrl,folder`;\n  const result = await graphRequest(modules, connection, endpoint, { method: \"GET\" }, [404]);\n  if (result.status === 404) return null;\n  if (!result.data?.id || !result.data?.folder) {\n    throw new Error(`The target path exists but is not a folder: ${segments.join(\"/\")}`);\n  }\n  return result.data;\n}\n\nasync function createFolder(modules, connection, driveId, parentId, name) {\n  const endpoint = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(parentId)}/children`;\n  const result = await graphRequest(modules, connection, endpoint, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      name,\n      folder: {},\n      \"@microsoft.graph.conflictBehavior\": \"fail\"\n    })\n  }, [409]);\n  if (result.status === 409) return null;\n  if (!result.data?.id) throw new Error(`Microsoft Graph did not return the created folder: ${name}`);\n  return result.data;\n}\n\nasync function ensureFolderPath(modules, connection, driveId, segments) {\n  let parent = await getDriveRoot(modules, connection, driveId);\n  const completed = [];\n  for (const segment of segments) {\n    completed.push(segment);\n    let folder = await findFolder(modules, connection, driveId, completed);\n    if (!folder) {\n      folder = await createFolder(modules, connection, driveId, parent.id, segment);\n      if (!folder) folder = await findFolder(modules, connection, driveId, completed);\n    }\n    if (!folder?.id) throw new Error(`Unable to resolve or create folder: ${completed.join(\"/\")}`);\n    parent = folder;\n  }\n  return parent;\n}\n\nasync function findChildItem(modules, connection, driveId, folderId, fileName) {\n  const endpoint = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}?$select=id,name,webUrl,file,folder,size`;\n  const result = await graphRequest(modules, connection, endpoint, { method: \"GET\" }, [404]);\n  return result.status === 404 ? null : result.data;\n}\n\nasync function uploadFile(\n  modules,\n  connection,\n  driveId,\n  folderId,\n  fileName,\n  content,\n  conflictBehavior\n) {\n  const graphBehavior = conflictBehavior === \"skip\" ? \"fail\" : conflictBehavior;\n  const allowedStatuses = conflictBehavior === \"skip\" ? [409] : [];\n  const endpoint = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content?@microsoft.graph.conflictBehavior=${graphBehavior}`;\n  const result = await graphRequest(modules, connection, endpoint, {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/octet-stream\" },\n    body: content\n  }, allowedStatuses);\n\n  if (result.status === 409 && conflictBehavior === \"skip\") {\n    const existing = await findChildItem(\n      modules,\n      connection,\n      driveId,\n      folderId,\n      fileName\n    );\n    if (!existing?.id) {\n      throw new Error(`A file-name conflict occurred but the existing item could not be resolved: ${fileName}`);\n    }\n    if (!existing.file) {\n      throw new Error(`The conflicting SharePoint item is not a file: ${fileName}`);\n    }\n    const skippedResult = Object.create(null);\n    skippedResult.outcome = \"skipped\";\n    skippedResult.item = existing;\n    skippedResult.httpStatus = result.status;\n    return skippedResult;\n  }\n\n  if (!result.data?.id) {\n    throw new Error(`Microsoft Graph did not return the uploaded file ID: ${fileName}`);\n  }\n  const uploadResult = Object.create(null);\n  uploadResult.outcome = \"uploaded\";\n  uploadResult.item = result.data;\n  uploadResult.httpStatus = result.status;\n  return uploadResult;\n}\n\nexport async function main({ connections, params, modules }: ServiceContext) {\n  const connection = connections?.sharePointConnection;\n  if (!connection) throw new Error(\"Required connection is missing: sharePointConnection\");\n\n  const conflictBehavior = normalizeConflictBehavior(params?.conflictBehavior);\n  const files = normalizeFiles(\n    params?.documentFiles,\n    conflictBehavior === \"rename\"\n  );\n  const folderSegments = normalizeFolderSegments(params?.folderPath);\n  const site = await resolveSite(modules, connection, params?.siteUrl);\n  const drive = await resolveDrive(modules, connection, site.id, params?.libraryNameOrId);\n  const targetFolder = folderSegments.length\n    ? await ensureFolderPath(modules, connection, drive.id, folderSegments)\n    : await getDriveRoot(modules, connection, drive.id);\n\n  const results = [];\n  let uploadedCount = 0;\n  let skippedCount = 0;\n  let failedCount = 0;\n\n  for (const file of files) {\n    const requestedFileName = normalizeFileName(file.name);\n    try {\n      const fileResponse = await modules.yeeSDKClient.files.getContent(file.id);\n      const content = fileResponse?.data;\n      if (!content) throw new Error(\"Yeeflow returned no file content.\");\n      const uploadResult = await uploadFile(\n        modules,\n        connection,\n        drive.id,\n        targetFolder.id,\n        requestedFileName,\n        content,\n        conflictBehavior\n      );\n      const item = uploadResult.item;\n\n      if (uploadResult.outcome === \"skipped\") {\n        skippedCount += 1;\n        results.push({\n          sourceFileId: String(file.id),\n          requestedFileName,\n          fileName: String(item.name ?? requestedFileName),\n          sharePointItemId: String(item.id),\n          webUrl: String(item.webUrl ?? \"\"),\n          status: \"skipped\",\n          conflictBehavior\n        });\n        continue;\n      }\n\n      uploadedCount += 1;\n      const actualFileName = String(item.name ?? requestedFileName);\n      let status = \"uploaded\";\n      if (conflictBehavior === \"rename\" && actualFileName !== requestedFileName) {\n        status = \"renamed\";\n      } else if (conflictBehavior === \"replace\" && uploadResult.httpStatus === 200) {\n        status = \"replaced\";\n      }\n      results.push({\n        sourceFileId: String(file.id),\n        requestedFileName,\n        fileName: actualFileName,\n        sharePointItemId: String(item.id),\n        webUrl: String(item.webUrl ?? \"\"),\n        status,\n        conflictBehavior\n      });\n    } catch (error) {\n      failedCount += 1;\n      results.push({\n        sourceFileId: String(file.id),\n        requestedFileName,\n        status: \"failed\",\n        conflictBehavior,\n        error: sanitizeError(error)\n      });\n    }\n  }\n\n  return {\n    archived: failedCount === 0,\n    uploadedCount,\n    skippedCount,\n    failedCount,\n    archiveFolderWebUrl: String(targetFolder.webUrl ?? \"\"),\n    archiveResultsJson: JSON.stringify(results)\n  };\n}\n",
  "DraftConfig": "{\"params\":[{\"id\":\"documentFiles\",\"type\":\"file\",\"desc\":\"One Yeeflow File variable containing one or multiple files to archive.\"},{\"id\":\"siteUrl\",\"type\":\"text\",\"desc\":\"Target SharePoint site URL.\"},{\"id\":\"libraryNameOrId\",\"type\":\"text\",\"desc\":\"Target SharePoint document library name or Microsoft Graph drive ID.\"},{\"id\":\"folderPath\",\"type\":\"text\",\"desc\":\"Optional folder path relative to the document library root. Leave empty to upload to the root. Forward and backslash separators are accepted.\"},{\"id\":\"conflictBehavior\",\"type\":\"text\",\"desc\":\"File-name conflict policy: fail, replace, rename, or skip. Rename automatically keeps both files by assigning a unique SharePoint file name. Empty values default to fail.\"}],\"connections\":[{\"id\":\"sharePointConnection\",\"type\":\"http\",\"desc\":\"Microsoft Graph OAuth connection for SharePoint folder and file operations.\"}],\"outputs\":[{\"id\":\"archived\",\"type\":\"boolean\",\"desc\":\"True when no input file failed. Skipped files are treated as successfully resolved.\"},{\"id\":\"uploadedCount\",\"type\":\"number\",\"desc\":\"Number of files uploaded, replaced, or automatically renamed successfully.\"},{\"id\":\"skippedCount\",\"type\":\"number\",\"desc\":\"Number of existing files intentionally skipped by the skip policy.\"},{\"id\":\"failedCount\",\"type\":\"number\",\"desc\":\"Number of files that failed to upload.\"},{\"id\":\"archiveFolderWebUrl\",\"type\":\"text\",\"desc\":\"SharePoint web URL of the resolved or created target folder or document library root.\"},{\"id\":\"archiveResultsJson\",\"type\":\"text\",\"desc\":\"JSON array containing one sanitized result per input file, including requested and actual SharePoint file names.\"}]}",
  "ExtData": null
}
