@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
1,069 lines (1,067 loc) • 40.6 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 { getStateMapFromLegacyItem } from './state';
import { nnou, nou, reversePluckProps } from './object';
import { deserialize, getInnerHtml, getInnerHtmlNumber, wrapElementInAuxDocument } from './xml';
import { fileNameFromPath, replaceAccentedVowels, unescapeHTML } from './string';
import { getRootPath, isRootPath, withIndex, withoutIndex } from './path';
import { isFolder, isNavigable, isPreviewable } from '../components/PathNavigator/utils';
import {
CONTENT_CHANGE_TYPE_MASK,
CONTENT_COPY_MASK,
CONTENT_CREATE_MASK,
CONTENT_CUT_MASK,
CONTENT_DELETE_CONTROLLER_MASK,
CONTENT_DELETE_MASK,
CONTENT_DELETE_TEMPLATE_MASK,
CONTENT_DUPLICATE_MASK,
CONTENT_EDIT_CONTROLLER_MASK,
CONTENT_EDIT_MASK,
CONTENT_EDIT_TEMPLATE_MASK,
CONTENT_GET_DEPENDENCIES_ACTION_MASK,
CONTENT_ITEM_UNLOCK,
CONTENT_PASTE_MASK,
CONTENT_READ_VERSION_HISTORY_MASK,
CONTENT_RENAME_MASK,
CONTENT_REVERT_MASK,
CONTENT_UPLOAD_MASK,
FOLDER_CREATE_MASK,
pageControllersFieldId,
pageControllersLegacyFieldId,
PUBLISH_APPROVE_MASK,
PUBLISH_MASK,
PUBLISH_REJECT_MASK,
PUBLISH_REQUEST_MASK,
PUBLISH_SCHEDULE_MASK,
PUBLISHING_DESTINATION_MASK,
PUBLISHING_LIVE_MASK,
PUBLISHING_STAGED_MASK,
READ_MASK,
STATE_DELETED_MASK,
STATE_DISABLED_MASK,
STATE_LOCKED_MASK,
STATE_MODIFIED_MASK,
STATE_NEW_MASK,
STATE_PUBLISHING_MASK,
STATE_SCHEDULED_MASK,
STATE_SUBMITTED_MASK,
STATE_SYSTEM_PROCESSING_MASK,
STATE_TRANSLATION_IN_PROGRESS_MASK,
STATE_TRANSLATION_PENDING_MASK,
STATE_TRANSLATION_UP_TO_DATE_MASK
} from './constants';
import { getStateBitmap } from '../components/WorkflowStateManagement/utils';
import { forEach } from './array';
import slugify from 'slugify';
import { showCodeEditorDialog, showEditDialog } from '../state/actions/dialogs';
import { findParentModelId, getModelIdFromInheritedField, isInheritedField } from './model';
export function isEditableAsset(path) {
return (
path.endsWith('.ftl') ||
path.endsWith('.css') ||
path.endsWith('.js') ||
path.endsWith('.groovy') ||
path.endsWith('.txt') ||
path.endsWith('.html') ||
path.endsWith('.hbs') ||
(path.endsWith('.xml') && !path.startsWith('/config/studio/content-types')) ||
path.endsWith('.tmpl') ||
path.endsWith('.htm') ||
path.endsWith('.sass') ||
path.endsWith('.scss') ||
path.endsWith('.less') ||
path.endsWith('.csv') ||
path.endsWith('.json') ||
path.endsWith('.yaml') ||
path.endsWith('.yml')
);
}
export function isAsset(path) {
return (
path.endsWith('.jpg') ||
path.endsWith('.png') ||
path.endsWith('.svg') ||
path.endsWith('.jpeg') ||
path.endsWith('.gif') ||
path.endsWith('.pdf') ||
path.endsWith('.doc') ||
path.endsWith('.docx') ||
path.endsWith('.xls') ||
path.endsWith('.xlsx') ||
path.endsWith('.ppt') ||
path.endsWith('.pptx') ||
path.endsWith('.mp4') ||
path.endsWith('.avi') ||
path.endsWith('.webm') ||
path.endsWith('.mpg')
);
}
export function isCode(path) {
return (
path.endsWith('.ftl') ||
path.endsWith('.css') ||
path.endsWith('.js') ||
path.endsWith('.groovy') ||
path.endsWith('.html') ||
path.endsWith('.hbs') ||
path.endsWith('.tmpl') ||
path.endsWith('.htm')
);
}
export function isImage(path) {
return (
path.endsWith('.jpg') ||
path.endsWith('.png') ||
path.endsWith('.svg') ||
path.endsWith('.jpeg') ||
path.endsWith('.gif')
);
}
export function isItemLockedForMe(item, username) {
return item ? isLockedState(item.state) && item.lockOwner.username !== username : true;
}
export function isBlobUrl(url) {
return url.startsWith('blob:');
}
/**
* TODO: Remove?
* Returns the boolean intersection of editMode, lock status and the item's edit permission
*/
export function getComputedEditMode({ item, username, editMode }) {
return isItemLockedForMe(item, username) || !hasEditAction(item.availableActions) ? false : editMode;
}
export function getSystemTypeFromPath(path) {
const rootPath = getRootPath(path);
if (rootPath.includes('/site/website')) {
return 'page';
} else if (rootPath.includes('/components')) {
return 'component';
} else if (rootPath.includes('/taxonomy')) {
return 'taxonomy';
} else if (rootPath.includes('/templates')) {
return 'renderingTemplate';
} else if (rootPath.includes('/static-assets')) {
return 'asset';
} else if (rootPath.includes('script')) {
return 'script';
} else {
return 'file';
}
}
function getLegacyItemSystemType(item) {
switch (true) {
case item.contentType === 'renderingTemplate': {
return 'renderingTemplate';
}
case item.contentType === 'script': {
return 'script';
}
case item.contentType === 'folder': {
return 'folder';
}
case item.asset || item.isAsset: {
return 'asset';
}
case item.component || item.isComponent: {
return 'component';
}
case item.page || item.isPage: {
return 'page';
}
case item.folder:
case item.container || item.isContainer: {
return 'folder';
}
case item.contentType === 'taxonomy': {
return 'taxonomy';
}
default: {
return 'file';
}
}
}
export function parseLegacyItemToBaseItem(item) {
const stateMap = getStateMapFromLegacyItem(item);
const state = getStateBitmap(stateMap);
return {
id: item.uri ?? item.path,
label: item.internalName ?? item.name,
parentId: null,
contentTypeId: item.contentType,
path: item.uri ?? item.path,
// Assuming folders aren't navigable
previewUrl: item.uri?.includes('index.xml') ? item.browserUri || '/' : null,
systemType: getLegacyItemSystemType(item),
mimeType: item.mimeType,
state,
stateMap,
lockOwner: null,
disabled: null,
localeCode: 'en',
translationSourceId: null,
availableActions: null,
availableActionsMap: null,
childrenCount: 0
};
}
export function parseLegacyItemToSandBoxItem(item) {
if (Array.isArray(item)) {
// If no internalName then skipping (e.g. level descriptors)
return item.flatMap((i) => (i.internalName || i.name ? [parseLegacyItemToSandBoxItem(i)] : []));
}
return {
...parseLegacyItemToBaseItem(item),
creator: null,
dateCreated: null,
modifier: {
username: item.user,
firstName: null,
lastName: null,
avatar: null
},
dateModified: item.lastEditDate,
dateSubmitted: null,
sizeInBytes: null,
expiresOn: null,
submitter: null
};
}
export function parseLegacyItemToDetailedItem(item) {
if (Array.isArray(item)) {
// If no internalName then skipping (e.g. level descriptors)
return item.flatMap((i) => (i.internalName || i.name ? [parseLegacyItemToDetailedItem(i)] : []));
}
return {
...parseLegacyItemToBaseItem(item),
sandbox: {
creator: null,
dateCreated: null,
modifier: {
username: item.user,
firstName: null,
lastName: null,
avatar: null
},
dateModified: item.lastEditDate,
dateSubmitted: null,
sizeInBytes: null,
expiresOn: null,
submitter: null
},
staging: {
dateScheduled: item.scheduledDate,
datePublished: item.publishedDate ?? item.eventDate,
publisher: item.user,
expiresOn: null
},
live: {
dateScheduled: item.scheduledDate,
datePublished: item.publishedDate ?? item.eventDate,
publisher: item.user,
expiresOn: null
}
};
}
export function parseSandBoxItemToDetailedItem(item, detailedItemComplement) {
if (Array.isArray(item)) {
// including level descriptors to avoid issues on pathNavigator;
return item.map((i) => parseSandBoxItemToDetailedItem(i, detailedItemComplement?.[i.path]));
}
return {
sandbox: {
creator: item.creator,
dateCreated: item.dateCreated,
modifier: item.modifier,
dateModified: item.dateModified,
dateSubmitted: item.dateSubmitted,
sizeInBytes: item.sizeInBytes,
expiresOn: item.expiresOn,
submitter: item.submitter
},
staging: detailedItemComplement?.staging ?? null,
live: detailedItemComplement?.live ?? null,
...reversePluckProps(item, 'creator', 'dateCreated', 'modifier', 'dateModified', 'sizeInBytes')
};
}
const systemPropsList = [
'orderDefault_f',
'savedAsDraft',
'content-type',
'display-template',
'no-template-required',
'merge-strategy',
'objectGroupId',
'objectId',
'file-name',
'folder-name',
'internal-name',
'disabled',
'createdDate',
'createdDate_dt',
'lastModifiedDate',
'lastModifiedDate_dt'
];
/**
* doc {XMLDocument}
* path {string}
* contentTypesLookup {LookupTable<ContentType>}
* instanceLookup {LookupTable<ContentInstance>}
* unflattenedPaths {LookupTable<ContentInstance>} A lookup table directly completed/mutated by this function indexed by path of those objects that are incomplete/unflattened
*/
export function parseContentXML(doc, path = null, contentTypesLookup, instanceLookup, unflattenedPaths) {
let id = nnou(doc) ? getInnerHtml(doc.querySelector(':scope > objectId')) : null;
if (id === null && !/^[a-f\d]{4}(?:[a-f\d]{4}-){4}[a-f\d]{12}$/i.test((id = fileNameFromPath(path)))) {
// If the id is not a guid by now, then is simply not available at this time.
id = null;
}
const contentTypeId = nnou(doc) ? getInnerHtml(doc.querySelector(':scope > content-type')) : null;
const current = {
craftercms: {
id,
path,
label: null,
dateCreated: null,
dateModified: null,
contentTypeId,
disabled: false,
sourceMap: {}
}
};
// We're assuming that contentTypeId is null when the content is not flattened
if (contentTypeId === null && unflattenedPaths) {
unflattenedPaths[path] = current;
}
if (nnou(doc)) {
current.craftercms.label = getInnerHtml(
doc.querySelector(':scope > internal-name') ?? doc.querySelector(':scope > file-name'),
{ applyLegacyUnescaping: true }
);
current.craftercms.dateCreated = getInnerHtml(doc.querySelector(':scope > createdDate_dt'));
current.craftercms.dateModified = getInnerHtml(doc.querySelector(':scope > lastModifiedDate_dt'));
}
id && (instanceLookup[id] = current);
if (nnou(doc)) {
Array.from(doc.documentElement.children).forEach((element) => {
const tagName = element.tagName;
if (!systemPropsList.includes(tagName)) {
let sourceContentTypeId;
const source = element.getAttribute('crafter-source');
if (source) {
current.craftercms.sourceMap[tagName] = source;
sourceContentTypeId = element.getAttribute('crafter-source-content-type-id');
if (!sourceContentTypeId) {
console.error(
`[parseContentXML] No "crafter-source-content-type-id" attribute found together with "crafter-source".`
);
}
}
const field = contentTypesLookup[sourceContentTypeId ?? contentTypeId]?.fields?.[tagName];
if (!field) {
// date-time control handles the timezone field using {controlName}_tz format. So if the field without `tz` is
// found in the content type, we can ignore the `tz` field (and don't log an error).
let isTimezoneField = false;
if (tagName.endsWith('_tz')) {
const withoutTz = tagName.replace(/_tz$/, '');
isTimezoneField = Boolean(contentTypesLookup[sourceContentTypeId ?? contentTypeId]?.fields?.[withoutTz]);
}
if (!isTimezoneField) {
console.error(
`[parseContentXML] Field "${tagName}" was not found on "${sourceContentTypeId ?? contentTypeId}" content type. "${source ?? path}" may have stale/outdated content properties.`
);
}
}
current[tagName] = parseElementByContentType(
element,
field,
contentTypesLookup,
instanceLookup,
unflattenedPaths
);
}
});
}
return current;
}
/**
* element {Element}
* field {ContentTypeField}
* contentTypesLookup {LookupTable<ContentType>}
* instanceLookup {LookupTable<ContentInstance>}
* unflattenedPaths {LookupTable<ContentInstance>} A lookup table directly completed/mutated by this function indexed by path of those objects that are incomplete/unflattened
*/
function parseElementByContentType(element, field, contentTypesLookup, instanceLookup, unflattenedPaths) {
if (!field) {
return getInnerHtml(element) ?? '';
}
const type = field.type;
// Some of this parsing (e.g. converting to booleans & numbers) is great but
// the delivery side APIs don't have this intelligence. Could this cause any issues?
// In any case, in the future we should go rather by a data-type instead of id of
// the control as, various controls may produce same data type and the list
// needn't be updated when new controls are added with a sound list of data types.
switch (type) {
case 'repeat': {
const array = [];
element.querySelectorAll(':scope > item').forEach((item) => {
const repeatItem = {};
item.querySelectorAll(':scope > *').forEach((fieldTag) => {
let fieldTagName = fieldTag.tagName;
repeatItem[fieldTagName] = parseElementByContentType(
fieldTag,
field.fields[fieldTagName],
contentTypesLookup,
instanceLookup,
unflattenedPaths
);
});
array.push(repeatItem);
});
return array;
}
case 'node-selector': {
const array = [];
const items = element.querySelectorAll(':scope > item');
if (field.id === pageControllersFieldId || field.id === pageControllersLegacyFieldId) {
items.forEach((item) => {
array.push({
key: getInnerHtml(item.querySelector(':scope > key')),
value: getInnerHtml(item.querySelector(':scope > value'))
});
});
} else {
items.forEach((item) => {
const key = getInnerHtml(item.querySelector(':scope > key'));
if (key) {
// Note: as it stands, taxonomies would be considered as "files" and not expanded/parsed as components.
const isFile =
// If the `key` tag value is not a path rooted at `/site/website` or `/site/components`
// or not an `xml` would mean is something other than content (asset, template, script, etc).
key.match(/^\/site\/(website|components)\/.+\.xml$/) === null &&
// Embedded components don't have a path as the value of `key` (the guid is the value),
// but/and they have an `inline` attribute.
item.getAttribute('inline') !== 'true';
if (isFile) {
array.push({
key,
value: getInnerHtml(item.querySelector(':scope > value'))
});
} else {
const component = item.querySelector(':scope > component');
const path = getInnerHtml(item.querySelector(':scope > include')) || (!component ? key : null);
const instance = parseContentXML(
component ? wrapElementInAuxDocument(component) : null,
path,
contentTypesLookup,
instanceLookup,
unflattenedPaths
);
array.push(instance);
}
} else {
// Not sure if there can be a case without a `key`. Leaving this case based on the previous code checking for it.
array.push(item);
}
});
}
return array;
}
case 'html':
return unescapeHTML(getInnerHtml(element));
case 'checkbox-group': {
const deserialized = deserialize(element);
const extract = deserialized[element.tagName].item;
return nou(extract) ? [] : Array.isArray(extract) ? extract : [extract];
}
case 'text':
case 'textarea':
return getInnerHtml(element, { applyLegacyUnescaping: true });
case 'image':
case 'date-time':
case 'time':
return getInnerHtml(element);
case 'dropdown':
if (field.id.endsWith('_i') || field.id.endsWith('_f')) {
return getInnerHtmlNumber(element, parseFloat);
} else {
return getInnerHtml(element);
}
case 'boolean':
case 'page-nav-order':
return getInnerHtml(element) === 'true';
case 'numeric-input':
return getInnerHtmlNumber(element, parseFloat);
default:
!['transcoded-video', 'transcoded-video-picker', 'taxonomy-selector'].includes(type) &&
console.log(
`%c[parseElementByContentType] Missing type "${type}" on switch statement for field "${field.id}".`,
'color: blue',
element
);
try {
const extract = deserialize(element)?.[element.tagName] ?? '';
return extract.item ? (Array.isArray(extract.item) ? extract.item : [extract.item]) : extract;
} catch (e) {
console.error('[parseElementByContentType] Error deserializing element', element, e);
return getInnerHtml(element);
}
}
}
// region export function createModelHierarchyDescriptor() { ... }
export const createModelHierarchyDescriptor = (
modelId = null,
parentId = null,
parentContainerFieldPath = null,
parentContainerFieldIndex = null,
children = []
) => ({
modelId,
parentId,
parentContainerFieldPath,
parentContainerFieldIndex,
children
});
// endregion
let contentTypeMissingWarningQueue = [];
let contentTypeMissingWarningTimeout;
export function createModelHierarchyDescriptorMap(normalizedModels, contentTypes) {
const lookup = {};
// region Internal utils
const getFields = (contentTypeId) =>
contentTypes[contentTypeId]?.fields ? Object.values(contentTypes[contentTypeId]?.fields) : null;
const cleanCarryOver = (carryOver) => carryOver.replace(/(^\.+)|(\.+$)/g, '').replace(/\.{2,}/g, '.');
const contentTypeMissingWarning = (model) => {
// Show this warning only if the model has a content type id defined (not null),
// but it's not present in the content type lookup table.
if (model.craftercms.contentTypeId && !contentTypes[model.craftercms.contentTypeId]) {
contentTypeMissingWarningQueue.push(
`Content type with id "${model.craftercms.contentTypeId}" was not found. ` +
`Unable to fully process model at "${model.craftercms.path}" with id "${model.craftercms.id}"`
);
clearTimeout(contentTypeMissingWarningTimeout);
contentTypeMissingWarningTimeout = setTimeout(() => {
console.log(
`%c[createModelHierarchyDescriptorMap]: \n- ${contentTypeMissingWarningQueue.join('\n- ')}`,
'color: #f00'
);
contentTypeMissingWarningQueue = [];
}, 200);
}
};
// endregion
// region Process function
function process(model, source, fields, fieldCarryOver = '', indexCarryOver = '') {
const currentModelId = model.craftercms.id;
if (!lookup[currentModelId]) {
lookup[currentModelId] = createModelHierarchyDescriptor(currentModelId);
}
fields?.forEach((field) => {
if (
// Check the field in the model isn't null in case the field isn't required and isn't present on current model.
source[field.id] &&
// Only care for these field types: those that can hold components.
(field.type === 'node-selector' || field.type === 'repeat')
) {
if (field.type === 'node-selector') {
field.id !== pageControllersFieldId &&
field.id !== pageControllersLegacyFieldId &&
source[field.id]
// Just as controllers are not included in HierarchyDescriptor, files inside a node-selector are not included either.
// (files and controllers are stored as a key/value object)
.filter((componentId) => typeof componentId === 'string')
.forEach((componentId, index) => {
lookup[currentModelId].children.push(componentId);
if (lookup[componentId]) {
if (lookup[componentId].parentId !== null && lookup[componentId].parentId !== model.craftercms.id) {
console.error.apply(
console,
[
`Model ${componentId} was found in multiple parents (${lookup[componentId].parentId} and ${model.craftercms.id}). ` +
`Same model twice on a single page may have unexpected behaviours for in-context editing.`,
// @ts-ignore
typeof componentId !== 'string' && componentId
].filter(Boolean)
);
}
} else {
// This assignment it's to avoid having to optionally chain multiple times
// the access to `lookup[component]` below.
lookup[componentId] = lookup[componentId] ?? {};
}
// Because there's no real warranty that the parent of a model will be processed first
lookup[componentId] = createModelHierarchyDescriptor(
componentId,
model.craftercms.id,
lookup[componentId].parentContainerFieldPath ?? cleanCarryOver(`${fieldCarryOver}.${field.id}`),
lookup[componentId].parentContainerFieldIndex ?? cleanCarryOver(`${indexCarryOver}.${index}`),
lookup[componentId].children
);
});
} else if (field.type === 'repeat') {
source[field.id].forEach((repeatItem, index) => {
process(
model,
repeatItem,
Object.values(field.fields),
cleanCarryOver(`${fieldCarryOver}.${field.id}`),
cleanCarryOver(`${indexCarryOver}.${index}`)
);
});
}
}
});
}
// endregion
Object.values(normalizedModels).forEach((model) => {
process(model, model, getFields(model.craftercms.contentTypeId));
contentTypeMissingWarning(model);
});
return lookup;
}
/**
* Returns an array with the ids of the direct descendants of a given model
*/
export function createChildModelIdList(model, contentTypes) {
const children = [];
if (contentTypes) {
const processFields = (model, fields, children) =>
fields.forEach((field) => {
// Check the field in the model isn't null in case the field isn't required and isn't present on current model.
if (model[field.id]) {
if (field.type === 'node-selector') {
model[field.id].forEach((mdl) => children.push(mdl.craftercms.id));
} else if (field.type === 'repeat') {
model[field.id].forEach((mdl) => {
processFields(mdl, Object.values(field.fields), children);
});
}
}
});
if (contentTypes[model.craftercms.contentTypeId]) {
processFields(model, Object.values(contentTypes[model.craftercms.contentTypeId].fields), children);
}
} else {
Object.entries(model).forEach(([prop, value]) => {
if (prop.endsWith('_o') && Array.isArray(value)) {
const collection = value;
forEach(collection, (item) => {
if ('craftercms' in item && item.craftercms.id !== null) {
// Node selector
children.push(item.craftercms.id);
} else {
// Repeating group item
forEach(Object.entries(item), ([_prop, _value]) => {
if (_prop.endsWith('_o') && Array.isArray(_value)) {
const _collection = _value;
forEach(_collection, (_item) => {
if ('craftercms' in _item && _item.craftercms.id !== null) {
children.push(_item.craftercms.id);
} else {
// Not a node selector, no point to continue iterating
// Subsequent levels are calculated by calling this function
// with that model as the argument
return 'break';
}
});
}
});
}
});
}
});
}
return children;
}
/**
* Returns a lookup table as `{ [modelId]: [childModelId1, childModelId2, ...], ... }`
*/
export function createChildModelLookup(models, contentTypes) {
const lookup = {};
Object.values(models).forEach((model) => {
lookup[model.craftercms.id] = createChildModelIdList(model, contentTypes);
});
return lookup;
}
export function normalizeModelsLookup(models) {
const lookup = {};
Object.entries(models).forEach(([id, model]) => {
lookup[id] = normalizeModel(model);
});
return lookup;
}
export function normalizeModel(model) {
const normalized = { ...model };
Object.entries(model).forEach(([prop, value]) => {
if (prop === pageControllersFieldId) {
normalized[prop] = value;
} else if (
// Using `prop.endsWith('_o')` causes issues with old sites which might not be using the post fix.
Array.isArray(value) &&
value.length
) {
const collection = value;
const isComponentsNodeSelector = collection.every((item) => Boolean(item.craftercms?.id));
if (isComponentsNodeSelector) {
normalized[prop] = collection.map((item) => item.craftercms.id);
} else {
normalized[prop] = collection.map((item) => normalizeModel(item));
}
}
});
return normalized;
}
export function denormalizeModel(normalized, modelLookup) {
const model = { ...normalized };
Object.entries(model).forEach(([prop, value]) => {
if (prop.endsWith('_o')) {
const collection = value;
// Cover cases (collection?.length) where the xml has an empty tag corresponding to the `someField_o` without content.
if (Array.isArray(collection) && collection.length) {
const isNodeSelector = typeof collection[0] === 'string';
if (isNodeSelector) {
model[prop] = collection.map((item) => denormalizeModel(modelLookup[item], modelLookup));
} else {
model[prop] = collection.map((item) => denormalizeModel(item, modelLookup));
}
}
}
});
return model;
}
export function getNumOfMenuOptionsForItem(item) {
if (isNavigable(item)) {
return isRootPath(item.path) ? 11 : 16;
} else if (isFolder(item)) {
return isRootPath(item.path)
? item.path.startsWith('/templates') || item.path.startsWith('/scripts')
? 4
: 3
: item.path.startsWith('/templates') || item.path.startsWith('/scripts')
? 7
: 6;
} else if (isPreviewable(item)) {
return item.systemType === 'component' || item.systemType === 'taxonomy' ? 11 : 10;
}
}
// region State checker functions
export const isNewState = (value) => Boolean(value & STATE_NEW_MASK);
export const isModifiedState = (value) => Boolean(value & STATE_MODIFIED_MASK);
export const isDeletedState = (value) => Boolean(value & STATE_DELETED_MASK);
export const isLockedState = (value) => Boolean(value & STATE_LOCKED_MASK);
export const isSystemProcessingState = (value) => Boolean(value & STATE_SYSTEM_PROCESSING_MASK);
export const isSubmittedState = (value) => Boolean(value & STATE_SUBMITTED_MASK);
export const isScheduledState = (value) => Boolean(value & STATE_SCHEDULED_MASK);
export const isPublishingState = (value) => Boolean(value & STATE_PUBLISHING_MASK);
export const isSubmittedToStaging = (value) =>
(isSubmittedState(value) || isScheduledState(value) || isPublishingState(value)) &&
!Boolean(value & PUBLISHING_DESTINATION_MASK);
export const isSubmittedToLive = (value) =>
(isSubmittedState(value) || isScheduledState(value) || isPublishingState(value)) &&
Boolean(value & PUBLISHING_DESTINATION_MASK);
export const isStaged = (value) => Boolean(value & PUBLISHING_STAGED_MASK);
export const isLive = (value) => Boolean(value & PUBLISHING_LIVE_MASK);
export const isDisabled = (value) => Boolean(value & STATE_DISABLED_MASK);
export const isTranslationUpToDateState = (value) => Boolean(value & STATE_TRANSLATION_UP_TO_DATE_MASK);
export const isTranslationPendingState = (value) => Boolean(value & STATE_TRANSLATION_PENDING_MASK);
export const isTranslationInProgressState = (value) => Boolean(value & STATE_TRANSLATION_IN_PROGRESS_MASK);
// endregion
export const createItemStateMap = (status) => ({
new: isNewState(status),
modified: isModifiedState(status),
deleted: isDeletedState(status),
locked: isLockedState(status),
systemProcessing: isSystemProcessingState(status),
submitted: isSubmittedState(status),
scheduled: isScheduledState(status),
publishing: isPublishingState(status),
submittedToStaging: isSubmittedToStaging(status),
submittedToLive: isSubmittedToLive(status),
staged: isStaged(status),
live: isLive(status),
disabled: isDisabled(status),
translationUpToDate: isTranslationUpToDateState(status),
translationPending: isTranslationPendingState(status),
translationInProgress: isTranslationInProgressState(status)
});
// region Action presence checker functions
export const hasReadAction = (value) => Boolean(value & READ_MASK);
export const hasCopyAction = (value) => Boolean(value & CONTENT_COPY_MASK);
export const hasReadHistoryAction = (value) => Boolean(value & CONTENT_READ_VERSION_HISTORY_MASK);
export const hasGetDependenciesAction = (value) => Boolean(value & CONTENT_GET_DEPENDENCIES_ACTION_MASK);
export const hasPublishRequestAction = (value) => Boolean(value & PUBLISH_REQUEST_MASK);
export const hasCreateAction = (value) => Boolean(value & CONTENT_CREATE_MASK);
export const hasPasteAction = (value) => Boolean(value & CONTENT_PASTE_MASK);
export const hasEditAction = (value) => Boolean(value & CONTENT_EDIT_MASK);
export const hasRenameAction = (value) => Boolean(value & CONTENT_RENAME_MASK);
export const hasCutAction = (value) => Boolean(value & CONTENT_CUT_MASK);
export const hasUploadAction = (value) => Boolean(value & CONTENT_UPLOAD_MASK);
export const hasDuplicateAction = (value) => Boolean(value & CONTENT_DUPLICATE_MASK);
export const hasChangeTypeAction = (value) => Boolean(value & CONTENT_CHANGE_TYPE_MASK);
export const hasRevertAction = (value) => Boolean(value & CONTENT_REVERT_MASK);
export const hasEditControllerAction = (value) => Boolean(value & CONTENT_EDIT_CONTROLLER_MASK);
export const hasEditTemplateAction = (value) => Boolean(value & CONTENT_EDIT_TEMPLATE_MASK);
export const hasCreateFolderAction = (value) => Boolean(value & FOLDER_CREATE_MASK);
export const hasContentDeleteAction = (value) => Boolean(value & CONTENT_DELETE_MASK);
export const hasDeleteControllerAction = (value) => Boolean(value & CONTENT_DELETE_CONTROLLER_MASK);
export const hasDeleteTemplateAction = (value) => Boolean(value & CONTENT_DELETE_TEMPLATE_MASK);
export const hasPublishAction = (value) => Boolean(value & PUBLISH_MASK);
export const hasApprovePublishAction = (value) => Boolean(value & PUBLISH_APPROVE_MASK);
export const hasSchedulePublishAction = (value) => Boolean(value & PUBLISH_SCHEDULE_MASK);
export const hasPublishRejectAction = (value) => Boolean(value & PUBLISH_REJECT_MASK);
export const hasUnlockAction = (value) => Boolean(value & CONTENT_ITEM_UNLOCK);
// endregion
export const createItemActionMap = (value) => ({
view: hasReadAction(value),
copy: hasCopyAction(value),
history: hasReadHistoryAction(value),
dependencies: hasGetDependenciesAction(value),
requestPublish: hasPublishRequestAction(value),
createContent: hasCreateAction(value),
paste: hasPasteAction(value),
edit: hasEditAction(value),
unlock: hasUnlockAction(value),
rename: hasRenameAction(value),
cut: hasCutAction(value),
upload: hasUploadAction(value),
duplicate: hasDuplicateAction(value),
changeContentType: hasChangeTypeAction(value),
revert: hasRevertAction(value),
editController: hasEditControllerAction(value),
editTemplate: hasEditTemplateAction(value),
createFolder: hasCreateFolderAction(value),
delete: hasContentDeleteAction(value),
deleteController: hasDeleteControllerAction(value),
deleteTemplate: hasDeleteTemplateAction(value),
publish: hasPublishAction(value),
approvePublish: hasApprovePublishAction(value),
schedulePublish: hasSchedulePublishAction(value),
rejectPublish: hasPublishRejectAction(value)
});
/**
* Given an item lookup table, tries to find the path with and without the "/index.xml" portion of the path.
* This reconciles path differences when working with pages between folder and index (i.e. /site/website vs /site/website/index.xml),
* which refer to the same item in most contexts.
* path {string} The path to look for
* lookupTable {Record<string, T>} The map-like object containing all items in which to look the path up
* @returns {T} The item if found, undefined otherwise
**/
export function lookupItemByPath(path, lookupTable) {
return lookupTable[withIndex(path)] ?? lookupTable[withoutIndex(path)];
}
export function modelsToLookup(models) {
const lookup = {};
models.forEach((model) => {
modelsToLookupModelParser(model, lookup);
});
return lookup;
}
function modelsToLookupModelParser(model, lookup) {
if ('craftercms' in model) {
if (model.craftercms.id === null) {
// e.g. In editorial, related-articles-widget (some
// items can use key/value without being "includes")
// it may simply be a key/value pair. This is an issue
// of the parseDescriptor function of the @craftercms/content package
// <scripts_o item-list="true">
// <item>
// <key>/scripts/components/related-articles.groovy</key>
// <value>related-articles.groovy</value>
// </item>
// </scripts_o>
return;
}
lookup[model.craftercms.id] = model;
}
Object.entries(model).forEach(([prop, value]) => {
if (prop.endsWith('_o')) {
const collection = value;
forEach(collection, (item) => {
if ('craftercms' in item) {
if (item.craftercms.id === null) {
return 'continue';
}
// Add model to lookup table
lookup[item.craftercms.id] = item;
}
modelsToLookupModelParser(item, lookup);
});
}
});
}
export function createPathIdMap(models) {
const map = {};
Object.entries(models).forEach(([id, model]) => {
if (model.craftercms.path) {
map[model.craftercms.path] = id;
}
});
return map;
}
export function getEditorMode(mimeType) {
switch (mimeType) {
case 'text/x-freemarker':
return 'ftl';
case 'text/x-groovy':
return 'groovy';
case 'application/javascript':
return 'javascript';
case 'text/css':
return 'css';
default:
return 'text';
}
}
export function prepareVirtualItemProps(item) {
return {
...item,
stateMap: createItemStateMap(item.state),
availableActionsMap: createItemActionMap(item.availableActions)
};
}
export function getDateScheduled(item) {
return item.live?.dateScheduled ?? item.staging?.dateScheduled ?? null;
}
export function getDatePublished(item) {
return item.live?.datePublished ?? item.staging?.datePublished ?? null;
}
export function getComputedPublishingTarget(item) {
// prettier-ignore
return item.stateMap.submittedToLive
? 'live'
: item.stateMap.submittedToStaging
? 'staging'
: null;
}
export function applyFolderNameRules(name, options) {
return (
// Replace accented vowels with their non-accented counterpart
replaceAccentedVowels(name)
// replace spaces with dashes
.replace(/\s+/g, '-')
// replace multiple consecutive dashes with a single dash
.replace(/-+/g, '-')
// remove any character that is not a letter, number, dash or underscore, and allow braces if specified
.replace(options?.allowBraces ? /[^a-zA-Z0-9-_{}]/g : /[^a-zA-Z0-9-_]/g, '')
);
}
export function applyAssetNameRules(name, options) {
return name.replace(options?.allowBraces ? /[^a-zA-Z0-9-_{}.]/g : /[^a-zA-Z0-9-_.]/g, '').replace(/\.{1,}/g, '.');
}
/**
* Utility to clean up a content name (pages/components/taxonomies). It removes any character that is not a lowercase
* letter, number, dash or underscore.
*/
export function applyContentNameRules(name) {
return slugify(name, {
lower: true,
// Setting `strict: true` would disallow `_`, which we don't want.
strict: false,
// Because of the moment where the library trims, `trim: true` caused undesired replacement of `-`
// at the beginning or end of the slug.
trim: false
}).replace(/[^a-z0-9-_]/g, '');
}
export const openItemEditor = (item, authoringBase, siteId, dispatch, onSaveSuccess) => {
let type = 'controller';
if (item.systemType === 'component' || item.systemType === 'page') {
type = 'form';
} else if (item.contentTypeId === 'renderingTemplate') {
type = 'template';
}
if (type === 'form') {
dispatch(showEditDialog({ path: item.path, authoringBase, site: siteId, onSaveSuccess }));
} else {
dispatch(
showCodeEditorDialog({
site: siteId,
authoringBase,
path: item.path,
type,
mode: getEditorMode(item.mimeType),
onSuccess: onSaveSuccess
})
);
}
};
export function generateComponentBasePath(contentType) {
return `/site/components/${contentType.replace('/component/', '')}s/`.replace(/\/{1,}$/m, '');
}
export function generateComponentPath(modelId, contentType) {
return `${generateComponentBasePath(contentType)}/${modelId}.xml`;
}
/**
* If the field is inherited, swaps the modelId and parentModelId with
* the inheritance parent's. */
export function getInheritanceParentIdsForField(
fieldId,
modelLookup,
modelId,
parentModelId,
modelIdByPath,
hierarchyMap
) {
const ids = { modelId, parentModelId };
if (isInheritedField(modelLookup[modelId], fieldId)) {
ids.modelId = getModelIdFromInheritedField(modelLookup[modelId], fieldId, modelIdByPath);
ids.parentModelId = findParentModelId(modelId, hierarchyMap, modelLookup);
}
return ids;
}
export function generatePlaceholderImageDataUrl(attributes) {
let attrs = Object.assign(
{
width: 300,
height: 150,
fillStyle: '#f0f0f0',
text: 'Sample Image',
textPositionX: 150,
textPositionY: 88.24,
textFillStyle: 'black',
font: '30px Arial',
textAlign: 'center',
textBaseline: 'middle'
},
attributes
);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = attrs.width;
canvas.height = attrs.height;
// Set background color
context.fillStyle = attrs.fillStyle;
context.fillRect(0, 0, attrs.width, attrs.height);
// Render text
context.font = attrs.font;
context.fillStyle = attrs.textFillStyle;
context.textAlign = attrs.textAlign;
context.textBaseline = attrs.textBaseline;
context.fillText(attrs.text, attrs.textPositionX, attrs.textPositionY);
return canvas.toDataURL();
}