diff --git a/nx2/utils/api.d.ts b/nx2/utils/api.d.ts index 556594a8b..7fed1fec0 100644 --- a/nx2/utils/api.d.ts +++ b/nx2/utils/api.d.ts @@ -165,6 +165,23 @@ export const source: { * @param arg Path string (`/org/site/folder`) or `{ org, site, path }` */ deleteFolder(arg: any): Promise; + + /** + * Copy a folder recursively. `path` is the source folder; `destination` + * is the target folder path. `collision` sets conflict policy when the + * destination exists (e.g. `'overwrite'`). Returns an augmented `Response`. + * + * **hlx6** normalizes `path`/`destination` to a trailing slash before + * dispatch. **Legacy DA** uses them as-is (no trailing slash needed, + * as handled by da-admin). + * + * - **Object:** `copyFolder({ org, site, path, destination, collision? })` + * - **Path:** `copyFolder('/org/site/folder', { destination, collision? })` + * + * @param arg Path string (`/org/site/folder`) or `{ org, site, path, destination, collision? }` + * @param pathExtras Path-form only — `{ destination, collision? }` + */ + copyFolder(arg: any, pathExtras?: object): Promise; }; // ─── versions ─────────────────────────────────────────────────────────────── diff --git a/nx2/utils/api.js b/nx2/utils/api.js index 9a5c561a8..1d65ebce0 100644 --- a/nx2/utils/api.js +++ b/nx2/utils/api.js @@ -307,6 +307,26 @@ export const source = { const url = await getDaApiPath(SOURCE, org, site, `${path}/`); return daFetch({ url, opts: { method: 'DELETE' } }); }), + + copyFolder: withArgs(async ({ + org, site, path, destination, collision, + }) => { + const hlx6 = await isHlx6(org, site); + if (hlx6) { + const folderPath = path.endsWith('/') ? path : `${path}/`; + const folderDestination = destination.endsWith('/') ? destination : `${destination}/`; + const url = new URL(await getDaApiPath(SOURCE, org, site, folderDestination)); + url.searchParams.set('source', folderPath); + if (collision) url.searchParams.set('collision', collision); + return daFetch({ url: url.toString(), opts: { method: 'PUT' } }); + } + const formData = new FormData(); + formData.append('destination', destination); + return daFetch({ + url: `${DA_ADMIN}/copy/${org}/${site}${path}`, + opts: { method: 'POST', body: formData }, + }); + }), }; // status: single-path only. H6 has no bulk status endpoint. diff --git a/nx2/utils/api.md b/nx2/utils/api.md index d6a090015..58b541593 100644 --- a/nx2/utils/api.md +++ b/nx2/utils/api.md @@ -123,6 +123,7 @@ Document CRUD on `source` paths. Bridges DA's `/source` and AEM's `/sites/{site} | `move` | `({ org, site, path, destination, collision? })` or `(fullPath, { destination, collision? })` | Same shape as `copy`. Raw `Response`. Adds `?move=true` (hlx6) or POSTs to `/move/{org}/{site}{path}` (DA). | | `createFolder` | `({ org, site, path })` or `(fullPath)` | POST on `${path}/` (trailing slash). | | `deleteFolder` | `({ org, site, path })` or `(fullPath)` | DELETE on `${path}/`. | +| `copyFolder` | `({ org, site, path, destination, collision? })` or `(fullPath, { destination, collision? })` | Recursive folder copy. Same shape as `copy`. **hlx6**: `path`/`destination` are normalized to a trailing slash first, then PUT to dest URL with `?source=…/&collision=…` query. **DA**: POST `/copy/{org}/{site}{path}` with `multipart/form-data` field `destination` — no trailing-slash normalization (legacy DA doesn't need it). | ### URL shapes @@ -134,6 +135,7 @@ Document CRUD on `source` paths. Bridges DA's `/source` and AEM's `/sites/{site} | list (org-only) | n/a | `${DA_ADMIN}/list/{org}` | | list (with site, legacy) | n/a | `${DA_ADMIN}/list/{org}/{site}{path}` | | copy / move | PUT to dest URL with `?source=&collision=&move=` | POST to `${DA_ADMIN}/copy/{org}/{site}{path}` (or `/move`) with `destination` form field | +| copyFolder | PUT to dest URL (trailing slash normalized) with `?source=&collision=` | POST to `${DA_ADMIN}/copy/{org}/{site}{path}` with `destination` form field (no trailing-slash normalization) | ### Examples @@ -167,6 +169,16 @@ const copyResp = await source.copy({ destination: '/new.html', // dest collision: 'overwrite', }); + +// Copy a folder recursively — path/destination don't need a trailing slash. +// On hlx6 they're normalized to one before dispatch; legacy DA uses them as-is. +const copyFolderResp = await source.copyFolder({ + org: 'adobe', + site: 'aem-boilerplate', + path: '/old-folder', // source folder + destination: '/new-folder', // dest folder + collision: 'overwrite', +}); ``` --- diff --git a/test/nx2/utils/api.test.js b/test/nx2/utils/api.test.js index 0331404dd..0a1d7d47c 100644 --- a/test/nx2/utils/api.test.js +++ b/test/nx2/utils/api.test.js @@ -471,6 +471,45 @@ describe('api.js', () => { expect(last.url).to.equal(`${AEM_API}/${o}/sites/${s}/source/folder/`); expect(last.method).to.equal('DELETE'); }); + + it('source.copyFolder hlx6 PUTs with trailing-slash source/destination and collision query', async () => { + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + await source.copyFolder({ + org: o, site: s, path: '/src', destination: '/dest', collision: 'overwrite', + }); + const last = lastCall(); + expect(last.method).to.equal('PUT'); + const u = new URL(last.url); + expect(u.pathname).to.equal(`/${o}/sites/${s}/source/dest/`); + expect(u.searchParams.get('source')).to.equal('/src/'); + expect(u.searchParams.get('collision')).to.equal('overwrite'); + }); + + it('source.copyFolder legacy POSTs to /copy/{org}/{site}{path} with destination form field (no trailing slash)', async () => { + const { org: o, site: s } = makeOrgSite(); + await source.copyFolder({ org: o, site: s, path: '/src', destination: '/dest' }); + const last = lastCall(); + expect(last.url).to.equal(`${DA_ADMIN}/copy/${o}/${s}/src`); + expect(last.method).to.equal('POST'); + expect(last.body).to.be.instanceof(FormData); + expect(last.body.get('destination')).to.equal('/dest'); + }); + + it('source.copyFolder normalizes paths that already have a trailing slash', async () => { + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + await source.copyFolder({ org: o, site: s, path: '/src/', destination: '/dest/' }); + const u = new URL(lastCall().url); + expect(u.pathname).to.equal(`/${o}/sites/${s}/source/dest/`); + expect(u.searchParams.get('source')).to.equal('/src/'); + }); + + it('source.copyFolder accepts a path string with extras', async () => { + const { org: o, site: s } = makeOrgSite({ hlx6: true }); + await source.copyFolder(`/${o}/${s}/src`, { destination: '/dest' }); + const u = new URL(lastCall().url); + expect(u.pathname).to.equal(`/${o}/sites/${s}/source/dest/`); + expect(u.searchParams.get('source')).to.equal('/src/'); + }); }); describe('versions', () => {