UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

1,151 lines (1,149 loc) 41 kB
/* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ /* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ import { errorSelectorApi1, get, getBinary, getGlobalHeaders, getText, post, postJSON } from '../utils/ajax'; import { catchError, map, pluck, switchMap, tap } from 'rxjs/operators'; import { forkJoin, Observable, of, zip } from 'rxjs'; import { beautify, cdataWrap, createElement, createElements, fromString, getInnerHtml, newXMLDocument, serialize } from '../utils/xml'; import { createLookupTable, nnou, nou, toQueryString } from '../utils/object'; import { dataUriToBlob, isBlank, isPath, popPiece, removeLastPiece } from '../utils/string'; import Core from '@uppy/core'; import XHRUpload from '@uppy/xhr-upload'; import { getRequestForgeryToken } from '../utils/auth'; import { generateComponentPath, parseContentXML, parseSandBoxItemToDetailedItem, prepareVirtualItemProps } from '../utils/content'; import { fetchContentTypes } from './contentTypes'; import { getFileNameFromPath, getPasteItemFromPath } from '../utils/path'; import { v4 as uuid } from 'uuid'; import { isPdfDocument, isMediaContent, isTextContent } from '../components/PathNavigator/utils'; import { fromPromise } from 'rxjs/internal/observable/innerFrom'; export function fetchComponentInstanceHTML(path) { return getText(`/crafter-controller/component.html${toQueryString({ path })}`).pipe(pluck('response')); } export function fetchContentXML(site, path, options) { options = Object.assign({ lock: false }, options); const qs = toQueryString({ site_id: site, path, edit: options.lock }); return get(`/studio/api/1/services/api/1/content/get-content.json${qs}`).pipe(pluck('response', 'content')); } export function fetchContentDOM(site, path) { return fetchContentXML(site, path).pipe(map(fromString)); } export function fetchDescriptorXML(site, path, options) { const qs = toQueryString({ siteId: site, path, flatten: true, ...options }); return get(`/studio/api/2/content/descriptor${qs}`).pipe(pluck('response', 'xml')); } export function fetchDescriptorDOM(site, path, options) { return fetchDescriptorXML(site, path, options).pipe(map(fromString)); } export function fetchSandboxItem(site, path, options) { return fetchItemsByPath(site, [path], options).pipe(map((items) => items[0])); } // endregion export function fetchDetailedItem(siteId, path, options) { const { preferContent } = { preferContent: true, ...options }; const qs = toQueryString({ siteId, path, preferContent }); return get(`/studio/api/2/content/item_by_path${qs}`).pipe( pluck('response', 'item'), map((item) => prepareVirtualItemProps(item)) ); } export function fetchDetailedItems(site, paths) { return forkJoin(paths.map((path) => fetchDetailedItem(site, path))); } export function fetchContentInstanceLookup(site, path, contentTypesLookup) { return fetchContentDOM(site, path).pipe( map((doc) => { const lookup = {}; parseContentXML(doc, path, contentTypesLookup, lookup); return lookup; }) ); } export function fetchContentInstance(site, path, contentTypesLookup) { return fetchContentDOM(site, path).pipe(map((doc) => parseContentXML(doc, path, contentTypesLookup, {}))); } export function writeContent(site, path, content, options) { options = Object.assign({ unlock: true }, options); const fileName = getFileNameFromPath(path); const pathToWrite = path.replace(`/${fileName}`, ''); return post( writeContentUrl({ site, path: pathToWrite, unlock: options.unlock ? 'true' : 'false', fileName }), content ).pipe( map((ajaxResponse) => { if (ajaxResponse.response.result?.error) { // eslint-disable-next-line no-throw-literal throw { ...ajaxResponse, status: 500, response: { message: ajaxResponse.response.result.error.message } }; } else return true; }) ); } export function fetchContentInstanceDescriptor(site, path, options, contentTypeLookup) { const unflattenedPaths = {}; return ( contentTypeLookup ? of(contentTypeLookup) : fetchContentTypes(site).pipe(map((contentTypes) => createLookupTable(contentTypes))) ).pipe( switchMap((contentTypeLookup) => fetchDescriptorDOM(site, path, options).pipe( map((doc) => { const modelLookup = {}; const model = parseContentXML(doc, path, contentTypeLookup, modelLookup, unflattenedPaths); return { model, modelLookup, unflattenedPaths }; }) ) ) ); } function writeContentUrl(qs) { qs = new URLSearchParams(qs); return `/studio/api/1/services/api/1/content/write-content.json?${qs.toString()}`; } function createComponentObject(instance, contentType, shouldSerializeValueFn) { const id = (instance.craftercms.id = instance.craftercms.id ?? uuid()); const path = (instance.craftercms.path = instance.craftercms.path ?? generateComponentPath(id, instance.craftercms.contentTypeId)); const fileName = getFileNameFromPath(path); const serializedInstance = {}; for (let key in instance) { if (key !== 'craftercms' && key !== 'fileName' && key !== 'internalName') { let value = instance[key]; serializedInstance[key] = nnou(value) && (typeof value !== 'string' || !isBlank(value)) && shouldSerializeValueFn?.(key) ? cdataWrap(`${value}`) : value; } } return mergeContentDocumentProps('component', { '@attributes': { id }, 'content-type': contentType.id, 'display-template': contentType.displayTemplate, // TODO: per this, at this point, internal-name is always cdata wrapped, not driven by config. 'internal-name': cdataWrap(instance.craftercms.label), 'file-name': fileName, objectId: id, ...serializedInstance }); } // region writeInstance /** * Creates a new content item xml document and writes it to the repo. */ export function writeInstance(site, instance, contentType, shouldSerializeValueFn) { const doc = newXMLDocument('component'); const transferObj = createComponentObject(instance, contentType, shouldSerializeValueFn); createElements(doc.documentElement, transferObj); return fromPromise(beautify(serialize(doc))).pipe( switchMap((xml) => writeContent(site, instance.craftercms.path, xml)) ); } // endregion // region updateField export function updateField(site, modelId, fieldId, indexToUpdate, path, value, serializeValue = false) { return performMutation( site, path, (element) => { let node = extractNode(element, removeLastPiece(fieldId) || fieldId, indexToUpdate); if (fieldId.includes('.')) { // node is <item /> inside collection const fieldToUpdate = popPiece(fieldId); let fieldNode = node.querySelector(`:scope > ${fieldToUpdate}`); if (nou(fieldNode)) { fieldNode = createElement(fieldToUpdate); node.appendChild(fieldNode); } node = fieldNode; } else if (!node) { // node is <fieldId /> inside the element node = createElement(fieldId); element.appendChild(node); } node.innerHTML = typeof serializeValue === 'function' ? serializeValue(value) : Boolean(serializeValue) ? cdataWrap(value) : value; }, modelId ); } // endregion // region performMutation function performMutation(site, path, mutation, modelId = null) { return fetchContentDOM(site, path).pipe( switchMap((doc) => { const documentModelId = doc.querySelector(':scope > objectId').innerHTML.trim(); if (nnou(modelId) && documentModelId !== modelId) { const component = doc.querySelector(`[id="${modelId}"]`); mutation(component); updateModifiedDateElement(component); } else { mutation(doc.documentElement); } updateModifiedDateElement(doc.documentElement); return fromPromise(beautify(serialize(doc))).pipe( switchMap((xml) => post( writeContentUrl({ site, path, unlock: 'true', fileName: getInnerHtml(doc.querySelector(':scope > file-name')) }), xml ).pipe(map(() => ({ updatedDocument: doc }))) ) ); }) ); } // endregion // region insertComponent /** * Inserts a *new* component item on the specified component collection field. In case of shared components, only * updates the target content item field to include the reference, does not create/write the shared component document. * */ export function insertComponent( siteId, parentDocPath, parentModelId, parentFieldId, targetIndex, parentContentType, insertedContentInstance, insertedItemContentType, isSharedInstance = false, shouldSerializeValueFn ) { return performMutation( siteId, parentDocPath, (element) => { const id = insertedContentInstance.craftercms.id; const path = isSharedInstance ? (insertedContentInstance.craftercms.path ?? generateComponentPath(id, insertedContentInstance.craftercms.contentTypeId)) : null; // Create the new `item` that holds or references (embedded vs shared) the component. const newItem = createElement('item'); const field = parentContentType.fields[parentFieldId]; // Add the child elements into the `item` node createElements(newItem, { '@attributes': { inline: !isSharedInstance }, key: isSharedInstance ? path : id, value: cdataWrap(insertedContentInstance.craftercms.label), ...(isSharedInstance ? { include: path, disableFlattening: String(field?.properties?.disableFlattening?.value ?? 'false') } : { component: createComponentObject(insertedContentInstance, insertedItemContentType, shouldSerializeValueFn) }) }); insertCollectionItem(element, parentFieldId, targetIndex, newItem); }, parentModelId ); } // endregion // region insertInstance /** * Insert an *existing* (i.e. shared) component on to the document * */ export function insertInstance( siteId, parentDocPath, parentModelId, parentFieldId, targetIndex, parentContentType, insertedInstance, datasource ) { return performMutation( siteId, parentDocPath, (element) => { const path = insertedInstance.craftercms.path; const newItem = createElement('item'); const field = parentContentType.fields[parentFieldId]; createElements(newItem, { '@attributes': { // TODO: Review datasource persistence. datasource: datasource ?? '' }, key: path, value: cdataWrap(insertedInstance.craftercms.label), include: path, disableFlattening: String(field?.properties?.disableFlattening?.value ?? 'false') }); insertCollectionItem(element, parentFieldId, targetIndex, newItem); }, parentModelId ); } // endregion // region insertItem export function insertItem(site, modelId, fieldId, index, instance, path, shouldSerializeValueFn) { return performMutation( site, path, (element) => { const node = extractNode(element, fieldId, index); const newItem = createElement('item'); const serializedInstance = {}; for (let key in instance) { if (key !== 'craftercms') { let value = instance[key]; serializedInstance[key] = nnou(value) && (typeof value !== 'string' || !isBlank(value)) && shouldSerializeValueFn?.(key) ? cdataWrap(`${value}`) : value; } } createElements(newItem, serializedInstance); node.appendChild(newItem); }, modelId ); } // endregion // region duplicateItem export function duplicateItem(site, modelId, fieldId, targetIndex, path) { return fetchContentDOM(site, path).pipe( switchMap((doc) => { const documentModelId = doc.querySelector(':scope > objectId').innerHTML.trim(); let parentElement = doc.documentElement; if (nnou(modelId) && documentModelId !== modelId) { parentElement = doc.querySelector(`[id="${modelId}"]`); } const indexBeingDuplicated = parseInt(popPiece(`${targetIndex}`)); const item = extractNode(parentElement, fieldId, targetIndex).cloneNode(true); // If it has a `key` element, then it may be an item reference. const itemPath = item.querySelector(':scope > key')?.textContent.trim(); const isEmbedded = Boolean(item.querySelector(':scope > component')); // If not an item reference, duplicating a repeat item. const isItemReference = isEmbedded || isPath(itemPath); // Remove last piece to get the parent of the item (i.e. the field) const field = extractNode(parentElement, fieldId, removeLastPiece(`${targetIndex}`)); const items = field.querySelectorAll(':scope > item'); const numOfItems = items.length; const newItemData = isItemReference ? updateItemId(item) : { modelId, path }; newItemData.path = newItemData.path ?? path; updateModifiedDateElement(parentElement); if (numOfItems - 1 === indexBeingDuplicated) { field.appendChild(item); } else { field.insertBefore(item, items[indexBeingDuplicated + 1]); } const returnValue = { updatedDocument: doc, newItem: newItemData }; if (!isItemReference || isEmbedded) { if (isEmbedded) { const component = item.querySelector(':scope > component'); updateModifiedDateElement(component); updateCreatedDateElement(component); } return fromPromise(beautify(serialize(doc))).pipe( switchMap((xml) => post( writeContentUrl({ site, path: path, unlock: 'true', fileName: getInnerHtml(doc.querySelector(':scope > file-name')) }), xml ).pipe(map(() => returnValue)) ) ); } else { return fetchContentDOM(site, itemPath).pipe( switchMap((componentDoc) => { // Update new shared component info (ids/date) updateComponentId(componentDoc.documentElement, newItemData.modelId); updateModifiedDateElement(componentDoc.documentElement); updateCreatedDateElement(componentDoc.documentElement); return forkJoin([ // Write the main document. fromPromise(beautify(serialize(doc))).pipe( switchMap((xml) => post( writeContentUrl({ site, path, unlock: 'true', fileName: getInnerHtml(doc.querySelector(':scope > file-name')) }), xml ) ) ), // Write the new/duplicated shared component. fromPromise(beautify(serialize(componentDoc))).pipe( switchMap((xml) => post( writeContentUrl({ site, path: newItemData.path, unlock: 'true', fileName: getInnerHtml(componentDoc.querySelector(':scope > file-name')) }), xml ) ) ) ]).pipe( map(() => { returnValue.newItem.path += `/${returnValue.newItem.modelId}.xml`; return returnValue; }) ); }) ); } }) ); } // endregion // region sortItem export function sortItem(site, modelId, fieldId, currentIndex, targetIndex, path) { return performMutation( site, path, (element) => { const item = extractNode(element, fieldId, currentIndex); insertCollectionItem(element, fieldId, targetIndex, item, currentIndex); }, modelId ); } // endregion // region moveItem export function moveItem( site, originalModelId, originalFieldId, originalIndex, targetModelId, targetFieldId, targetIndex, originalParentPath, targetParentPath ) { // TODO Warning: cannot perform as transaction whilst the UI is the one to do all this. // const isOriginalEmbedded = nnou(originalParentPath); // const isTargetEmbedded = nnou(targetParentPath); // When moving between inherited dropzone to other dropzone, the modelsIds will be different but in some cases the // parentId will be null for both targets in that case we need to add a nnou validation to parentsModelId; const isSameModel = originalModelId === targetModelId; const isSameDocument = originalParentPath === targetParentPath; if (isSameDocument || isSameModel) { // Moving items between two fields of the same document or model... return performMutation(site, originalParentPath, (element) => { // Item may be moving... // - from parent model to an embedded model // - from an embedded model to the parent model // - from an embedded model to another embedded model // - from a field to another WITHIN the same model (parent or embedded) const parentDocumentModelId = getInnerHtml(element.querySelector(':scope > objectId')); const sourceModelElement = parentDocumentModelId === originalModelId ? element : element.querySelector(`[id="${originalModelId}"]`); const targetModelElement = parentDocumentModelId === targetModelId ? element : element.querySelector(`[id="${targetModelId}"]`); const item = extractNode(sourceModelElement, originalFieldId, originalIndex); let targetField = extractNode(targetModelElement, targetFieldId, removeLastPiece(`${targetIndex}`)); if (!targetField) { const newField = createElement(originalFieldId); newField.setAttribute('item-list', 'true'); targetModelElement.appendChild(newField); targetField = newField; } const targetFieldItems = targetField.querySelectorAll(':scope > item'); const parsedTargetIndex = parseInt(popPiece(`${targetIndex}`)); if (targetFieldItems.length === parsedTargetIndex) { targetField.appendChild(item); } else { targetField.insertBefore(item, targetFieldItems[parsedTargetIndex]); } }); } else { let removedItemHTML; return performMutation( site, originalParentPath, (element) => { const item = extractNode(element, originalFieldId, originalIndex); const field = extractNode(element, originalFieldId, removeLastPiece(`${originalIndex}`)); removedItemHTML = item.outerHTML; field.removeChild(item); }, originalModelId ).pipe( switchMap(() => performMutation( site, targetParentPath, (element) => { const item = extractNode(element, targetFieldId, targetIndex); let field = extractNode(element, targetFieldId, removeLastPiece(`${targetIndex}`)); // If field doesn't exist yet in the document, create it if (!field) { const newField = createElement(originalFieldId); newField.setAttribute('item-list', 'true'); element.appendChild(newField); field = newField; } const auxElement = createElement('hold'); auxElement.innerHTML = removedItemHTML; field.insertBefore(auxElement.querySelector(':scope > item'), item); }, targetModelId ) ) ); } } // endregion // region deleteItem export function deleteItem(site, modelId, fieldId, indexToDelete, path) { return performMutation( site, path, (element) => { let index = indexToDelete; let fieldNode = element.querySelector(`:scope > ${fieldId}`); if (typeof indexToDelete === 'string') { index = parseInt(popPiece(indexToDelete)); // A fieldId can be in the form of `a.b`, which translates to `a > item > b` on the XML. // In terms of index, since all it should ever arrive here is collection items, // this assumes the index path points to the item itself, not the collection. // By calling removeLastPiece(indexToDelete), we should get the collection node here. fieldNode = extractNode(element, fieldId, removeLastPiece(`${indexToDelete}`)); } fieldNode.children[index].remove(); if (fieldNode.children.length === 0) { // If the node isn't completely blank, the xml formatter won't do it's job in converting to a self-closing tag. // Also, later on, when retrieved, some *legacy* functions would impaired as the deserializing into JSON had unexpected content fieldNode.innerHTML = ''; } }, modelId ); } export function fetchItemsByContentType(site, contentTypes, contentTypesLookup, options) { // If content types is null|undefined or if is empty (array or string), return empty values as there's no need to // make a request. if ( !contentTypes || (typeof contentTypes === 'string' && contentTypes === '') || (Array.isArray(contentTypes) && contentTypes.length === 0) ) { return of({ count: 0, lookup: {} }); } if (typeof contentTypes === 'string') { contentTypes = [contentTypes]; } return postJSON(`/studio/api/2/search/search.json?siteId=${site}`, { ...options, filters: { 'content-type': contentTypes } }).pipe( map(({ response }) => ({ count: response.result.total, paths: response.result.items.map((item) => item.path) })), switchMap(({ paths, count }) => zip( of(count), paths.length ? forkJoin( paths.reduce((array, path) => { array.push(fetchContentInstance(site, path, contentTypesLookup)); return array; }, []) ) : of([]) ) ), map(([count, array]) => { return { count, lookup: array.reduce( (hash, contentInstance) => Object.assign(hash, { [contentInstance.craftercms.path]: contentInstance }), {} ) }; }) ); } // endregion export function formatXML(site, path) { return fetchContentDOM(site, path).pipe( switchMap((doc) => fromPromise(beautify(serialize(doc))).pipe( switchMap((xml) => post( writeContentUrl({ site, path: path, unlock: 'true', fileName: getInnerHtml(doc.querySelector(':scope > file-name')) }), xml ) ) ) ), map(() => true) ); } // Updates a component's parent id (the item that contains the component). // If the component is embedded, update its ids too. When shared it needs to be done separately because the item // needs to be fetched. function updateItemId(item, skipShared = false) { const component = item.querySelector(':scope > component'); const key = item.querySelector(':scope > key'); const id = uuid(); if (component) { // embedded component updateComponentId(component, id); key.innerHTML = id; return { modelId: id, path: null }; } else if (!skipShared) { // shared component const originalPath = key.textContent; const basePath = originalPath.split('/').slice(0, -1).join('/'); const newPath = `${basePath}/${id}.xml`; const include = item.querySelector(':scope > include'); key.innerHTML = newPath; include.innerHTML = newPath; return { modelId: id, path: basePath }; } } // Updates the ids of a component (shared/embedded) function updateComponentId(component, id) { const objectId = component.querySelector(':scope > objectId'); const fileName = component.querySelector(':scope > file-name'); component.id = id; // Update the file name even if not a UUID (manually assigned name). Otherwise, it could override // the existing document when duplicating and the UI has no visibility of whether // there could be other duplicates already to create a meaningful name. fileName.innerHTML = `${id}.xml`; objectId.innerHTML = id; updateElementComponentsId(component); } // Updates the ids of the embedded components inside an element // It looks for items inside a component and update its ids (skipping shared). function updateElementComponentsId(element) { element.querySelectorAll('item').forEach((item) => { updateItemId(item, true); }); } function extractNode(doc, fieldId, index) { const indexes = index === '' || nou(index) ? [] : `${index}`.split('.').map((i) => parseInt(i, 10)); let aux = doc.documentElement ?? doc; if (nou(index) || isBlank(`${index}`)) { return aux.querySelector(`:scope > ${fieldId}`); } const fields = fieldId.split('.'); if (indexes.length > fields.length) { // There's more indexes than fields throw new Error( '[content/extractNode] Path not handled: indexes.length > fields.length. Indexes ' + `is ${indexes} and fields is ${fields}` ); } indexes.forEach((_index, i) => { const field = fields[i]; aux = aux.querySelectorAll(`:scope > ${field} > item`)[_index]; }); if (indexes.length === fields.length) { return aux; } else if (indexes.length < fields.length) { // There's one more field to use as there were less indexes // than there were fields. For example: fieldId: `items_o.content_o`, index: 0 // At this point, aux would be `items_o[0]` and we need to extract `content_o` const field = fields[fields.length - 1]; return aux.querySelector(`:scope > ${field}`); } } function mergeContentDocumentProps(type, data) { // Dasherized props... // content-type, display-template, no-template-required, internal-name, file-name // merge-strategy, folder-name, parent-descriptor const now = data.lastModifiedDate_dt && data.createdDate_dt ? null : createModifiedDate(); const dateCreated = data.createdDate_dt ? data.createdDate_dt : now; const dateModified = data.lastModifiedDate_dt ? data.lastModifiedDate_dt : now; return Object.assign( { 'content-type': '', 'display-template': '', 'internal-name': '', 'file-name': '', 'merge-strategy': 'inherit-levels', createdDate_dt: dateCreated, lastModifiedDate_dt: dateModified, objectId: '' }, type === 'page' ? { placeInNav: 'false' } : {}, data ); } function createModifiedDate() { return new Date().toISOString(); } function updateModifiedDateElement(doc) { const date = createModifiedDate(); (doc.querySelector(':scope > lastModifiedDate') ?? { innerHTML: '' }).innerHTML = date; (doc.querySelector(':scope > lastModifiedDate_dt') ?? { innerHTML: '' }).innerHTML = date; } function updateCreatedDateElement(doc) { const date = createModifiedDate(); (doc.querySelector(':scope > createdDate') ?? { innerHTML: '' }).innerHTML = date; (doc.querySelector(':scope > createdDate_dt') ?? { innerHTML: '' }).innerHTML = date; } function insertCollectionItem(element, fieldId, targetIndex, newItem, currentIndex) { let fieldNode = extractNode(element, fieldId, removeLastPiece(`${targetIndex}`)); let index = typeof targetIndex === 'string' ? parseInt(popPiece(targetIndex)) : targetIndex; // If currentIndex it means the op is a 'sort', and the index(targetIndex) needs to plus 1 or no if (nnou(currentIndex)) { let currentIndexParsed = typeof currentIndex === 'string' ? parseInt(popPiece(currentIndex)) : currentIndex; let targetIndexParsed = typeof targetIndex === 'string' ? parseInt(popPiece(targetIndex)) : targetIndex; if (currentIndexParsed > targetIndexParsed) { index = typeof targetIndex === 'string' ? parseInt(popPiece(targetIndex)) : targetIndex; } else { index = typeof targetIndex === 'string' ? parseInt(popPiece(targetIndex)) + 1 : targetIndex + 1; } } if (nou(fieldNode)) { fieldNode = createElement(fieldId); fieldNode.setAttribute('item-list', 'true'); element.appendChild(fieldNode); } const itemList = fieldNode.querySelectorAll(`:scope > item`); if (itemList.length === index) { fieldNode.appendChild(newItem); } else { fieldNode.insertBefore(newItem, itemList[index]); } } export function createFileUpload(uploadUrl, file, path, uploadMeta, xsrfArgumentName = '_csrf') { const blob = dataUriToBlob(file.dataUrl); return uploadBlob( uploadMeta?.site ?? uploadMeta?.siteId, path, { name: file.name, type: file.type, blob }, uploadMeta, uploadUrl, xsrfArgumentName ); } export function uploadBlob( site, path, fileData, uploadMeta = {}, uploadUrl = '/studio/api/1/services/api/1/content/write-content.json', xsrfArgumentName = '_csrf' ) { const qs = toQueryString({ path, site, [xsrfArgumentName]: getRequestForgeryToken() }); return new Observable((subscriber) => { const uppy = new Core({ autoProceed: true }); uppy.use(XHRUpload, { endpoint: `${uploadUrl}${qs}`, headers: getGlobalHeaders() }); uppy.setMeta({ ...uploadMeta, path, site }); uppy.on('upload-success', (file, response) => { subscriber.next({ type: 'complete', payload: response }); subscriber.complete(); }); uppy.on('upload-progress', (file, progress) => { subscriber.next({ type: 'progress', payload: { file, progress } }); }); uppy.on('upload-error', (file, error, response) => { // @ts-expect-error - The original response has a `status: number` and `body: any` only. // Looks like trying to match other responses having the error property that further down the chain, handlers inspect. response.error = response; subscriber.error(response); }); uppy.addFile({ name: fileData.name, type: fileData.type, data: fileData.blob }); return () => { uppy.cancelAll(); }; }); } // endregion export function uploadDataUrl(site, file, path, xsrfArgumentName) { return createFileUpload( '/studio/api/1/services/api/1/content/write-content.json', file, path, { site, name: file.name, type: file.type, path }, xsrfArgumentName ); } export function uploadToS3(site, file, path, profileId, xsrfArgumentName) { return createFileUpload( '/studio/api/2/aws/s3/upload.json', file, path, { name: file.name, type: file.type, siteId: site, path, profileId: profileId }, xsrfArgumentName ); } export function uploadToWebDAV(site, file, path, profileId, xsrfArgumentName) { return createFileUpload( '/studio/api/2/webdav/upload', file, path, { name: file.name, type: file.type, siteId: site, path, profileId: profileId }, xsrfArgumentName ); } export function uploadToCMIS(site, file, path, repositoryId, xsrfArgumentName) { return createFileUpload( '/studio/api/2/cmis/upload', file, path, { name: file.name, type: file.type, siteId: site, cmisPath: path, cmisRepoId: repositoryId }, xsrfArgumentName ); } export function getBulkUploadUrl(site, path) { const qs = toQueryString({ site, path, contentType: 'folder', createFolders: true, draft: false, duplicate: false, unlock: true, _csrf: getRequestForgeryToken() }); return `/studio/api/1/services/api/1/content/write-content.json${qs}`; } export function fetchQuickCreateList(site) { return get(`/studio/api/2/content/list_quick_create_content.json${toQueryString({ siteId: site })}`).pipe( pluck('response', 'items') ); } export function fetchItemHistory(site, path) { return get(`/studio/api/2/content/item_history${toQueryString({ siteId: site, path })}`).pipe( pluck('response', 'items') ); } export function revertTo(site, path, versionNumber) { return get( `/studio/api/1/services/api/1/content/revert-content.json${toQueryString({ site, path, version: versionNumber })}` ).pipe( pluck('response'), catchError((ajaxError) => { ajaxError.response = { response: { code: 1000, message: 'Unable to revert content at this time.', remedialAction: 'Content may be locked. Try again later.' } }; throw ajaxError; }) ); } export function fetchItemVersion(site, path, versionNumber) { return of({ site, path, versionNumber, content: null }); } export function fetchVersions(site, versions) { return of([ { site, path: versions[0].path, versionNumber: versions[0].versionNumber, content: null }, { site, path: versions[1].path, versionNumber: versions[1].versionNumber, content: null } ]); } export function fetchChildrenByPath(siteId, path, options) { return fetchChildrenByPaths(siteId, { [path]: options }).pipe(map((data) => data[path])); } /** * siteId {string} The site id. * fetchOptionsByPath {LookupTable<Partial<GetChildrenOptions>>} A lookup table of paths and their respective options. * options {GetChildrenOptions} Options that will be applied to all the path requests. * */ export function fetchChildrenByPaths(siteId, fetchOptionsByPath, options) { const paths = Object.keys(fetchOptionsByPath).map((path) => ({ path, ...options, ...fetchOptionsByPath[path] })); return paths.length === 0 ? of({}) : postJSON(`/studio/api/2/content/${siteId}/children`, { paths }).pipe( map(({ response: { items } }) => { const data = {}; items.forEach(({ children, levelDescriptor, total, offset, limit, path }) => { const totalWithDescriptor = levelDescriptor ? total + 1 : total; data[path] = Object.assign(children ? children.map((child) => prepareVirtualItemProps(child)) : [], { levelDescriptor: levelDescriptor ? prepareVirtualItemProps(levelDescriptor) : null, total: totalWithDescriptor, offset, limit }); }); return data; }) ); } export function fetchItemsByPath(siteId, paths, options) { if (!paths?.length) { return of([]); } const { castAsDetailedItem = false, preferContent = true } = options ?? {}; return postJSON('/studio/api/2/content/sandbox_items_by_path', { siteId, paths, preferContent }).pipe( pluck('response'), map(({ items, missingItems }) => Object.assign( items.map((item) => prepareVirtualItemProps(castAsDetailedItem ? parseSandBoxItemToDetailedItem(item) : item)), { missingItems } ) ) ); } export function fetchItemByPath(siteId, path, options) { return fetchItemsByPath(siteId, [path], options).pipe( tap((items) => { if (items[0] === void 0) { // Fake out the 404 which the backend won't return for this bulk API // eslint-disable-next-line no-throw-literal throw { name: 'AjaxError', status: 404, response: { response: { code: 7000, message: 'Content not found', remedialAction: `Check that path '${path}' is correct and it exists in site '${siteId}'`, documentationUrl: '' } } }; } }), pluck(0) ); } // endregion export function fetchItemWithChildrenByPath(siteId, path, options) { return forkJoin({ item: fetchItemByPath(siteId, path, { castAsDetailedItem: true }), children: fetchChildrenByPath(siteId, path, options) }); } export function paste(siteId, targetPath, clipboard) { return postJSON('/studio/api/2/content/paste', { siteId, operation: clipboard.type, targetPath, item: getPasteItemFromPath(clipboard.sourcePath, clipboard.paths) }).pipe(pluck('response')); } export function duplicate(siteId, path) { return postJSON('/studio/api/2/content/duplicate', { siteId, path }).pipe(pluck('response')); } export function deleteItems(siteId, items, comment, optionalDependencies) { return postJSON('/studio/api/2/content/delete', { siteId, items, optionalDependencies, comment }).pipe(map(() => true)); } export function lock(siteId, path) { return postJSON('/studio/api/2/content/item_lock_by_path', { siteId, path }).pipe(map(() => true)); } export function unlock(siteId, path) { return postJSON('/studio/api/2/content/item_unlock_by_path', { siteId, path }).pipe( map(() => true), // Do not throw/report 409 (item is already unlocked) as an error. catchError((error) => { if (error.status === 409) { return of(false); } else { throw new Error(error); } }) ); } export function fetchWorkflowAffectedItems(site, path) { return get( `/studio/api/2/workflow/affected_paths${toQueryString({ siteId: site, path })}` ).pipe(pluck('response', 'items')); } export function createFolder(site, path, name) { return post(`/studio/api/1/services/api/1/content/create-folder.json${toQueryString({ site, path, name })}`).pipe( pluck('response'), catchError(errorSelectorApi1) ); } export function createFile(site, path, fileName) { return post( `/studio/api/1/services/api/1/content/write-content.json${toQueryString({ site, path, phase: 'onSave', fileName, unlock: true })}` ).pipe(pluck('response'), catchError(errorSelectorApi1)); } export function renameFolder(site, path, name) { return post(`/studio/api/1/services/api/1/content/rename-folder.json${toQueryString({ site, path, name })}`).pipe( pluck('response'), catchError(errorSelectorApi1) ); } export function renameContent(siteId, path, name) { return postJSON(`/studio/api/2/content/rename`, { siteId, path, name }).pipe(pluck('response')); } export function changeContentType(site, path, contentType) { return post( `/studio/api/1/services/api/1/content/change-content-type.json${toQueryString({ site, path, contentType: contentType })}` ).pipe(pluck('response'), catchError(errorSelectorApi1)); } export function checkPathExistence(site, path) { return get(`/studio/api/1/services/api/1/content/content-exists.json${toQueryString({ site_id: site, path })}`).pipe( pluck('response', 'content'), catchError(errorSelectorApi1) ); } export function fetchLegacyItem(site, path) { return get(`/studio/api/1/services/api/1/content/get-item.json${toQueryString({ site_id: site, path })}`).pipe( pluck('response', 'item'), catchError(errorSelectorApi1) ); } export function fetchLegacyItemsTree(site, path, options) { return get( `/studio/api/1/services/api/1/content/get-items-tree.json${toQueryString({ site_id: site, path, ...options })}` ).pipe(pluck('response', 'item'), catchError(errorSelectorApi1)); } export function fetchContentByCommitId(site, path, commitId) { return getBinary( `/studio/api/2/content/get_content_by_commit_id${toQueryString({ siteId: site, path, commitId })}`, void 0, 'blob' ).pipe( switchMap((ajax) => { const blob = ajax.response; const type = ajax.xhr.getResponseHeader('content-type'); if (isMediaContent(type) || isPdfDocument(type)) { return of(URL.createObjectURL(blob)); } else if (isTextContent(type)) { return blob.text(); } else { return of(blob); } }) ); }