{
  "Name": "将易企办附件归档到 SharePoint",
  "Description": "通过已配置的 Microsoft Graph 连接归档易企办文件，支持多级目录和四种重名处理策略。",
  "ImplType": 0,
  "DraftCode": "function requireText(value, name) {\n  const text = String(value ?? \"\").trim();\n  if (!text) throw new Error(`缺少必填参数：${name}`);\n  return text;\n}\n\nfunction sanitizeError(error) {\n  const message = error instanceof Error ? error.message : \"未知错误\";\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      \"重名策略必须是 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(\"缺少必填的文件参数 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(`待归档文件中存在重名：${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(`文件超过 250 MB 的简单上传上限：${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(`目标文件名不合法：${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(`文件夹路径片段不合法：${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(\"站点地址必须是有效的 HTTPS 绝对地址。\");\n  }\n  if (parsed.protocol !== \"https:\") throw new Error(\"站点地址必须使用 HTTPS。\");\n  if (parsed.username || parsed.password || parsed.search || parsed.hash) {\n    throw new Error(\"站点地址不能含凭据、查询参数或片段。\");\n  }\n  const result = Object.create(null);\n  result.hostname = parsed.hostname;\n  result.graphOrigin = parsed.hostname.endsWith(\".sharepoint.cn\")\n    ? \"https://microsoftgraph.chinacloudapi.cn\"\n    : \"https://graph.microsoft.com\";\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(modules, connection, graphOrigin, path, options, allowedStatuses = []) {\n  const url = new URL(path, graphOrigin);\n  if (url.origin !== graphOrigin || url.username || url.password || !url.pathname.startsWith(\"/v1.0/\")) {\n    throw new Error(\"Microsoft Graph 请求地址与配置的站点云环境不符。\");\n  }\n  const response = await modules.fetch(url.href, { ...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 请求失败：${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(graph, parsed) {\n  const endpoint = `/v1.0/sites/${encodeURIComponent(parsed.hostname)}:${parsed.sitePath}?$select=id,displayName,webUrl`;\n  const result = await graph(endpoint, { method: \"GET\" });\n  if (!result.data?.id) throw new Error(\"Microsoft Graph 没有返回 SharePoint 站点 ID。\");\n  return result.data;\n}\n\nasync function resolveDrive(graph, siteId, nameOrId) {\n  const selector = requireText(nameOrId, \"libraryNameOrId\");\n  let nextUrl = `/v1.0/sites/${encodeURIComponent(siteId)}/drives?$select=id,name,webUrl`;\n  while (nextUrl) {\n    const result = await graph(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 文档库：${selector}`);\n}\n\nasync function getDriveRoot(graph, driveId) {\n  const endpoint = `/v1.0/drives/${encodeURIComponent(driveId)}/root?$select=id,name,webUrl,folder`;\n  const result = await graph(endpoint, { method: \"GET\" });\n  if (!result.data?.id) throw new Error(\"Microsoft Graph 没有返回文档库根目录。\");\n  return result.data;\n}\n\nasync function findFolder(graph, driveId, segments) {\n  const endpoint = `/v1.0/drives/${encodeURIComponent(driveId)}/root:/${encodePath(segments)}?$select=id,name,webUrl,folder`;\n  const result = await graph(endpoint, { method: \"GET\" }, [404]);\n  if (result.status === 404) return null;\n  if (!result.data?.id || !result.data?.folder) {\n    throw new Error(`目标路径已存在，但不是文件夹：${segments.join(\"/\")}`);\n  }\n  return result.data;\n}\n\nasync function createFolder(graph, driveId, parentId, name) {\n  const endpoint = `/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(parentId)}/children`;\n  const result = await graph(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 没有返回新建的文件夹：${name}`);\n  return result.data;\n}\n\nasync function ensureFolderPath(graph, driveId, segments) {\n  let parent = await getDriveRoot(graph, driveId);\n  const completed = [];\n  for (const segment of segments) {\n    completed.push(segment);\n    let folder = await findFolder(graph, driveId, completed);\n    if (!folder) {\n      folder = await createFolder(graph, driveId, parent.id, segment);\n      if (!folder) folder = await findFolder(graph, driveId, completed);\n    }\n    if (!folder?.id) throw new Error(`无法找到或创建文件夹：${completed.join(\"/\")}`);\n    parent = folder;\n  }\n  return parent;\n}\n\nasync function findChildItem(graph, driveId, folderId, fileName) {\n  const endpoint = `/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}?$select=id,name,webUrl,file,folder,size`;\n  const result = await graph(endpoint, { method: \"GET\" }, [404]);\n  return result.status === 404 ? null : result.data;\n}\n\nasync function uploadFile(\n  graph,\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 = `/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(folderId)}:/${encodeURIComponent(fileName)}:/content?@microsoft.graph.conflictBehavior=${graphBehavior}`;\n  const result = await graph(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      graph,\n      driveId,\n      folderId,\n      fileName\n    );\n    if (!existing?.id) {\n      throw new Error(`文件重名，且无法读取已存在的文件：${fileName}`);\n    }\n    if (!existing.file) {\n      throw new Error(`SharePoint 中的同名项目不是文件：${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 没有返回已上传文件的 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(\"缺少所需连接 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 parsed = parseSiteUrl(params?.siteUrl);\n  const graph = (path, options, allowedStatuses) => graphRequest(modules, connection, parsed.graphOrigin, path, options, allowedStatuses);\n  const site = await resolveSite(graph, parsed);\n  const drive = await resolveDrive(graph, site.id, params?.libraryNameOrId);\n  const targetFolder = folderSegments.length\n    ? await ensureFolderPath(graph, drive.id, folderSegments)\n    : await getDriveRoot(graph, 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(\"易企办没有返回文件内容。\");\n      const uploadResult = await uploadFile(\n        graph,\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\":\"待归档的易企办文件或附件变量，可包含一个或多个文件。\"},{\"id\":\"siteUrl\",\"type\":\"text\",\"desc\":\"目标 SharePoint 站点的 HTTPS 地址。\"},{\"id\":\"libraryNameOrId\",\"type\":\"text\",\"desc\":\"文档库显示名称或 Microsoft Graph drive ID。\"},{\"id\":\"folderPath\",\"type\":\"text\",\"desc\":\"相对于文档库根目录的可选路径。留空归档到根目录，可用正斜线或反斜线分隔。\"},{\"id\":\"conflictBehavior\",\"type\":\"text\",\"desc\":\"重名策略可选 fail、replace、rename 或 skip，留空使用 fail。\"}],\"connections\":[{\"id\":\"sharePointConnection\",\"type\":\"http\",\"desc\":\"可访问目标站点的 Microsoft Graph OAuth HTTP 连接，应与站点所属云匹配。\"}],\"outputs\":[{\"id\":\"archived\",\"type\":\"boolean\",\"desc\":\"所有文件都没有失败时返回 true。按规则跳过的文件不计为失败。\"},{\"id\":\"uploadedCount\",\"type\":\"number\",\"desc\":\"成功上传、替换或重命名的文件数量。\"},{\"id\":\"skippedCount\",\"type\":\"number\",\"desc\":\"按 skip 策略跳过的文件数量。\"},{\"id\":\"failedCount\",\"type\":\"number\",\"desc\":\"处理失败的文件数量。\"},{\"id\":\"archiveFolderWebUrl\",\"type\":\"text\",\"desc\":\"目标文件夹或文档库根目录的 SharePoint 链接。\"},{\"id\":\"archiveResultsJson\",\"type\":\"text\",\"desc\":\"逐文件结果的 JSON 数组，含原文件名、实际归档文件名和状态。\"}]}",
  "ExtData": null
}
