@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
1,151 lines (1,149 loc) • 41 kB
JavaScript
/*
* 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);
}
})
);
}