From 793f49c061b518f44252e35c16ebf76a7b871eec Mon Sep 17 00:00:00 2001 From: Rob Gietema Date: Sun, 1 Dec 2024 15:46:02 -0300 Subject: [PATCH] Add support for images in controlpanels. Add preview link support. Add block types index. --- CHANGELOG.md | 4 + packages/nick-blog/package.json | 33 +++++++ .../src/profiles/default/metadata.json | 6 ++ .../src/profiles/default/types/author.json | 22 +++++ .../profiles/default/types/blogfolder.json | 22 +++++ .../src/profiles/default/types/post.json | 22 +++++ .../src/profiles/default/types/tag.json | 22 +++++ scripts/convert.js | 86 +++++++++++++++++-- src/helpers/content/content.js | 21 ++++- src/helpers/fs/fs.js | 18 ++-- src/models/controlpanel/controlpanel.js | 18 ++++ src/models/document/document.js | 51 +++++++++++ src/models/type/type.js | 6 +- .../core/behaviors/preview_image_link.json | 27 ++++++ src/profiles/core/catalog.json | 16 ++++ src/routes/content/content.js | 11 ++- src/routes/controlpanels/controlpanels.js | 9 ++ src/routes/navigation/navigation.js | 8 +- src/routes/site/site.js | 12 ++- src/seeds/controlpanel/controlpanel.js | 46 +++++++--- src/seeds/document/document.js | 4 +- 21 files changed, 420 insertions(+), 44 deletions(-) create mode 100644 packages/nick-blog/package.json create mode 100644 packages/nick-blog/src/profiles/default/metadata.json create mode 100644 packages/nick-blog/src/profiles/default/types/author.json create mode 100644 packages/nick-blog/src/profiles/default/types/blogfolder.json create mode 100644 packages/nick-blog/src/profiles/default/types/post.json create mode 100644 packages/nick-blog/src/profiles/default/types/tag.json create mode 100644 src/profiles/core/behaviors/preview_image_link.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca0963..32006a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - Add multilingual support @robgietema - Add converter from plone.exportimport @robgietema - Add Nick endpoint @robgietema +- Add support for images in controlpanels @robgietema +- Add preview link support @robgietema +- Add block types index @robgietema ### Bugfix @@ -62,6 +65,7 @@ - Fix delete within transaction @robgietema - Fix keywords endpoint @robgietema - Fix supported languages endpoint @robgietema +- Fix site logo @robgietema ### Internal diff --git a/packages/nick-blog/package.json b/packages/nick-blog/package.json new file mode 100644 index 0000000..0ec11a8 --- /dev/null +++ b/packages/nick-blog/package.json @@ -0,0 +1,33 @@ +{ + "name": "@robgietema/nick-blog", + "description": "Blog for Nick", + "maintainers": [ + { + "name": "Rob Gietema", + "email": "rob.gietema@gmail.com", + "url": "https://robgietema.nl" + } + ], + "license": "MIT", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "git@github.com:robgietema/nick.git" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://github.com/robgietema/nick/issues", + "email": "info@nickcms.org" + }, + "homepage": "https://nickcms.org", + "keywords": [ + "nick", + "blog", + "cms" + ], + "engines": { + "node": "^20" + } +} diff --git a/packages/nick-blog/src/profiles/default/metadata.json b/packages/nick-blog/src/profiles/default/metadata.json new file mode 100644 index 0000000..c6c82c4 --- /dev/null +++ b/packages/nick-blog/src/profiles/default/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "@robgietema/nick-blog:default", + "title": "Blog Default", + "description": "Blog functionality of Nick", + "version": 1000 +} diff --git a/packages/nick-blog/src/profiles/default/types/author.json b/packages/nick-blog/src/profiles/default/types/author.json new file mode 100644 index 0000000..8a44c2b --- /dev/null +++ b/packages/nick-blog/src/profiles/default/types/author.json @@ -0,0 +1,22 @@ +{ + "id": "Author", + "title:i18n": "Blog Author", + "description:i18n": "A Blog Author", + "global_allow": true, + "filter_content_types": false, + "allowed_content_types": [], + "schema": { + "behaviors": [ + "dublin_core", + "dates", + "blocks", + "versioning", + "short_name", + "id_from_title", + "folderish", + "exclude_from_nav", + "preview_image_link" + ] + }, + "workflow": "simple_publication_workflow" +} diff --git a/packages/nick-blog/src/profiles/default/types/blogfolder.json b/packages/nick-blog/src/profiles/default/types/blogfolder.json new file mode 100644 index 0000000..21772d3 --- /dev/null +++ b/packages/nick-blog/src/profiles/default/types/blogfolder.json @@ -0,0 +1,22 @@ +{ + "id": "BlogFolder", + "title:i18n": "Blog Folder", + "description:i18n": "A folderish content item representing a Blog", + "global_allow": true, + "filter_content_types": true, + "allowed_content_types": ["Page", "File", "Image", "Post"], + "schema": { + "behaviors": [ + "dublin_core", + "dates", + "blocks", + "versioning", + "short_name", + "id_from_title", + "folderish", + "exclude_from_nav", + "preview_image_link" + ] + }, + "workflow": "simple_publication_workflow" +} diff --git a/packages/nick-blog/src/profiles/default/types/post.json b/packages/nick-blog/src/profiles/default/types/post.json new file mode 100644 index 0000000..26bf50a --- /dev/null +++ b/packages/nick-blog/src/profiles/default/types/post.json @@ -0,0 +1,22 @@ +{ + "id": "Post", + "title:i18n": "Blog Post", + "description:i18n": "A Blog Post", + "global_allow": true, + "filter_content_types": true, + "allowed_content_types": ["File", "Image"], + "schema": { + "behaviors": [ + "dublin_core", + "dates", + "blocks", + "versioning", + "short_name", + "id_from_title", + "folderish", + "exclude_from_nav", + "preview_image_link" + ] + }, + "workflow": "simple_publication_workflow" +} diff --git a/packages/nick-blog/src/profiles/default/types/tag.json b/packages/nick-blog/src/profiles/default/types/tag.json new file mode 100644 index 0000000..5d7aae1 --- /dev/null +++ b/packages/nick-blog/src/profiles/default/types/tag.json @@ -0,0 +1,22 @@ +{ + "id": "BlogTag", + "title:i18n": "Blog Tag", + "description:i18n": "A Blog Tag", + "global_allow": true, + "filter_content_types": false, + "allowed_content_types": [], + "schema": { + "behaviors": [ + "dublin_core", + "dates", + "blocks", + "versioning", + "short_name", + "id_from_title", + "folderish", + "exclude_from_nav", + "preview_image_link" + ] + }, + "workflow": "simple_publication_workflow" +} diff --git a/scripts/convert.js b/scripts/convert.js index 021ae70..b4179c1 100644 --- a/scripts/convert.js +++ b/scripts/convert.js @@ -4,9 +4,17 @@ * @module scripts/convert */ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { + existsSync, + copyFileSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; import { sync as glob } from 'glob'; -import { endsWith, keys, map } from 'lodash'; +import { endsWith, keys, last, map } from 'lodash'; +import { v4 as uuid } from 'uuid'; /** * Read file @@ -75,22 +83,83 @@ const convertGroups = (groups, path) => { const convertDocuments = (input, path) => { console.log('Converting documents...'); + // Remove folder if exists + if (existsSync(`${path}/documents`)) { + rmSync(`${path}/documents`, { recursive: true }); + } + // Create folder mkdirSync(`${path}/documents`); + mkdirSync(`${path}/documents/images`); map(glob(`${input}/content/**/*.json`), (filename) => { if (endsWith(filename, '__metadata__.json')) { return; } let document = JSON.parse(readFileSync(filename, 'utf8')); + + // Set correct uuid field document.uuid = document.UID; delete document.UID; + // Convert types + if (document['@type'] === 'Plone Site') { + document['@type'] = 'Site'; + document.uuid = uuid(); + } + if (document['@type'] === 'LRF') { + document['@type'] = 'Languageroot'; + } + if (document['@type'] === 'Document') { + document['@type'] = 'Page'; + } + if (document['@type'] === 'LIF') { + document['@type'] = 'Folder'; + } + document.type = document['@type']; + delete document['@type']; + + // Remove fields + delete document.version; + + // Fix language + if (document.language === '##DEFAULT##') { + document.language = 'en'; + } + + // Fix images + if (document.type === 'Image') { + const file = last(document.image.blob_path.split('/')).replace(' ', '_'); + mkdirSync(`${path}/documents/images/${document.uuid}`); + copyFileSync( + `${input}/content/${document.image.blob_path}`, + `${path}/documents/images/${document.uuid}/${file}`, + ); + document.image = `/images/${document.uuid}/${file}`; + } + + // Convert to string + let json = JSON.stringify(document); + json = json.replaceAll(/\"[^"]*resolveuid\/([^"]*)\"/g, (result, uuid) => { + const targetfile = `${input}/content/${uuid}/data.json`; + if (existsSync(targetfile)) { + const target = JSON.parse( + readFileSync(`${input}/content/${uuid}/data.json`, 'utf8'), + ); + return `"${target['@id'].replace('.', '-')}"`; + } else { + console.log(`Not found: ${targetfile}`); + } + return `"${uuid}"`; + }); + + const output = + document['@id'] === '/Plone' + ? `${path}/documents/_root.json` + : `${path}/documents/${document['@id'].replaceAll('.', '-').replaceAll('/', '.').substring(1)}.json`; + // Write files - writeFileSync( - `${path}/documents/${document['@id'].replaceAll('.', '-').replaceAll('/', '.').substring(1)}.json`, - JSON.stringify(document), - ); + writeFileSync(output, json); }); console.log('done!'); @@ -113,10 +182,9 @@ async function main() { const outputfolder = process.argv[3]; // Create output folder - if (existsSync(outputfolder)) { - rmSync(outputfolder, { recursive: true }); + if (!existsSync(outputfolder)) { + mkdirSync(outputfolder, { recursive: true }); } - mkdirSync(outputfolder, { recursive: true }); // Convert principals if (existsSync(`${inputfolder}/principals.json`)) { diff --git a/src/helpers/content/content.js b/src/helpers/content/content.js index 8951797..1f5c3a4 100644 --- a/src/helpers/content/content.js +++ b/src/helpers/content/content.js @@ -35,7 +35,7 @@ export async function handleFiles(json, type, profile) { fields[field] = { data: readProfileFile(profile, fields[field]), encoding: 'base64', - 'content-type': mime.lookup(`${profile}/documents${fields[field]}`), + 'content-type': mime.lookup(`${profile}${fields[field]}`), filename: last(fields[field].split('/')), }; } @@ -83,7 +83,7 @@ export async function handleImages(json, type, profile) { fields[field] = { data: readProfileFile(profile, fields[field]), encoding: 'base64', - 'content-type': mime.lookup(`${profile}/documents${fields[field]}`), + 'content-type': mime.lookup(`${profile}${fields[field]}`), filename: last(fields[field].split('/')), }; } @@ -118,10 +118,9 @@ export async function handleImages(json, type, profile) { * @method handleRelationLists * @param {Object} json Current json object. * @param {Object} type Type object. - * @param {string} profile Path of the profile. * @returns {Object} Fields with uuid info. */ -export async function handleRelationLists(json, type, profile) { +export async function handleRelationLists(json, type) { // Make a copy of the json data const fields = { ...json }; @@ -141,3 +140,17 @@ export async function handleRelationLists(json, type, profile) { // Return new field data return fields; } + +/** + * Handle block references + * @method handleBlockReferences + * @param {Object} json Current json object. + * @returns {Object} Json with references expanded. + */ +export async function handleBlockReferences(json) { + // Make a copy of the json data + const output = { ...json }; + + // Return new json data + return output; +} diff --git a/src/helpers/fs/fs.js b/src/helpers/fs/fs.js index 1d62f8f..a3b5659 100644 --- a/src/helpers/fs/fs.js +++ b/src/helpers/fs/fs.js @@ -67,7 +67,7 @@ export function readFile(uuid) { * @returns {string} Base64 string of the file */ export function readProfileFile(profile, path) { - const file = `${profile}/documents${path}`; + const file = `${profile}${path}`; if (!existsSync(file)) { throw `Can not read file: ${file}`; } @@ -118,12 +118,16 @@ export async function writeImage(data, encoding) { // Write scales await mapAsync(keys(config.imageScales), async (scale) => { const scaleId = uuid(); - const scaleImage = sharp(buffer) - .rotate() - .resize(...config.imageScales[scale], { - fit: 'inside', - }); - await scaleImage.toFile(`${config.blobsDir}/${scaleId}`); + if ((await image.metadata()).format === 'svg') { + writeFileSync(`${config.blobsDir}/${scaleId}`, buffer); + } else { + const scaleImage = sharp(buffer) + .rotate() + .resize(...config.imageScales[scale], { + fit: 'inside', + }); + await scaleImage.toFile(`${config.blobsDir}/${scaleId}`); + } const [scaleWidth, scaleHeight] = getScaleDimensions( width, height, diff --git a/src/models/controlpanel/controlpanel.js b/src/models/controlpanel/controlpanel.js index 8196bb8..0a9e932 100644 --- a/src/models/controlpanel/controlpanel.js +++ b/src/models/controlpanel/controlpanel.js @@ -3,6 +3,7 @@ * @module models/controlpanel/controlpanel */ +import { compact, keys, map } from 'lodash'; import { getRootUrl, translateSchema } from '../../helpers'; import { Model } from '../../models'; @@ -32,4 +33,21 @@ export class Controlpanel extends Model { ? { ...json, data: this.data, schema: translateSchema(this.schema, req) } : json; } + + /** + * Get factory fields. + * @method getFactoryFields + * @static + * @param {string} factory Factory field. + * @returns {Array} Array of fields with given factory. + */ + getFactoryFields(factory) { + const properties = this.schema.properties; + + // Get factory fields + const factoryFields = map(keys(properties), (property) => + properties[property].factory === factory ? property : false, + ); + return compact(factoryFields); + } } diff --git a/src/models/document/document.js b/src/models/document/document.js index c38afc5..407e7d8 100644 --- a/src/models/document/document.js +++ b/src/models/document/document.js @@ -9,6 +9,7 @@ import _, { concat, drop, findIndex, + flatten, head, includes, isArray, @@ -758,6 +759,56 @@ export class Document extends Model { return uniq(concat(globalRoles, workflowRoles, localUsersGroups)); } + /** + * Has preview image + * @method hasPreviewImage + * @return {Boolean} True if has preview image + */ + hasPreviewImage() { + return this.preview_image || this.preview_image_link ? true : false; + } + + /** + * Get block types + * @method getBlockTypes + * @return {Array} Array with block types + */ + getBlockTypes() { + return this.json.blocks + ? uniq( + flatten( + map(keys(this.json.blocks), (block) => [ + this.json.blocks[block]['@type'], + ...(this.json.blocks[block].blocks + ? map( + keys(this.json.blocks[block].blocks), + (subblock) => + this.json.blocks[block].blocks[subblock]['@type'], + ) + : []), + ]), + ), + ) + : []; + } + + /** + * Get the image field + * @method getImageField + * @return {String} Image field + */ + getImageField() { + if (this._type._schema.properties.preview_image_link) { + return 'preview_image_link'; + } else if (this._type._schema.properties.preview_image) { + return 'preview_image'; + } else if (this._type._schema.properties.image) { + return 'image'; + } else { + return ''; + } + } + /** * Re index children * @method reindexChildren diff --git a/src/models/type/type.js b/src/models/type/type.js index ebc70ff..f24049a 100644 --- a/src/models/type/type.js +++ b/src/models/type/type.js @@ -84,10 +84,10 @@ export class Type extends Model { getFactoryFields(factory) { const properties = this._schema.properties; - // Get file fields - const fileFields = map(keys(properties), (property) => + // Get factory fields + const factoryFields = map(keys(properties), (property) => properties[property].factory === factory ? property : false, ); - return compact(fileFields); + return compact(factoryFields); } } diff --git a/src/profiles/core/behaviors/preview_image_link.json b/src/profiles/core/behaviors/preview_image_link.json new file mode 100644 index 0000000..94c7986 --- /dev/null +++ b/src/profiles/core/behaviors/preview_image_link.json @@ -0,0 +1,27 @@ +{ + "id": "preview_image_link", + "title:i18n": "Preview Image Link", + "description:i18n": "Gives the ability to rename an item from its edit form.", + "schema": { + "fieldsets": [ + { + "fields": ["preview_image_link", "preview_caption_link"], + "id": "default", + "title:i18n": "Default" + } + ], + "properties": { + "preview_image_link": { + "title:i18n": "Preview image", + "description:i18n": "Select an image that will be used in listing and teaser blocks.", + "widget": "object_browser", + "mode": "image" + }, + "preview_caption_link": { + "description:i18n": "", + "title:i18n": "Preview image caption", + "type": "string" + } + } + } +} diff --git a/src/profiles/core/catalog.json b/src/profiles/core/catalog.json index b28232c..13079f9 100644 --- a/src/profiles/core/catalog.json +++ b/src/profiles/core/catalog.json @@ -308,6 +308,12 @@ "attr": "allowedUsersGroupsRoles", "enabled": false }, + { + "name": "block_types", + "type": "string[]", + "attr": "getBlockTypes", + "enabled": false + }, { "name": "getRawRelatedItems", "type": "uuid[]", @@ -901,6 +907,16 @@ "name": "ModificationDate", "type": "date", "attr": "modified" + }, + { + "name": "image_field", + "type": "string", + "attr": "getImageField" + }, + { + "name": "hasPreviewImage", + "type": "boolean", + "attr": "hasPreviewImage" } ] } diff --git a/src/routes/content/content.js b/src/routes/content/content.js index c38833b..1cb0532 100644 --- a/src/routes/content/content.js +++ b/src/routes/content/content.js @@ -33,6 +33,7 @@ import { handleFiles, handleImages, handleRelationLists, + handleBlockReferences, } from '../../helpers'; import { Document, Type } from '../../models'; @@ -272,7 +273,9 @@ export default [ await req.document.fetchVersion(parseInt(req.params.version, 10), trx); await req.document.fetchRelationLists(trx); return { - json: await req.document.toJSON(req, await getComponents(req, trx)), + json: await handleBlockReferences( + await req.document.toJSON(req, await getComponents(req, trx)), + ), }; }, }, @@ -300,7 +303,9 @@ export default [ handler: async (req, trx) => { const buffer = readFile(req.params.uuid); return { - headers: { 'Content-Type': `image/${req.params.ext}` }, + headers: { + 'Content-Type': `image/${req.params.ext}`, + }, binary: buffer, }; }, @@ -442,6 +447,7 @@ export default [ json = await handleFiles(json, type); json = await handleImages(json, type); json = await handleRelationLists(json, req.type); + json = await handleBlockReferences(json); // Create new document let document = Document.fromJson({ @@ -598,6 +604,7 @@ export default [ json = await handleFiles(json, req.type); json = await handleImages(json, req.type); json = await handleRelationLists(json, req.type); + json = await handleBlockReferences(json); // Create new version const modified = moment.utc().format(); diff --git a/src/routes/controlpanels/controlpanels.js b/src/routes/controlpanels/controlpanels.js index 8669132..7b5663d 100644 --- a/src/routes/controlpanels/controlpanels.js +++ b/src/routes/controlpanels/controlpanels.js @@ -4,6 +4,7 @@ */ import { Controlpanel } from '../../models'; +import { handleFiles, handleImages } from '../../helpers'; export default [ { @@ -40,7 +41,15 @@ export default [ view: '/@controlpanels/:id', permission: 'Manage Site', handler: async (req, trx) => { + // Make a copy + let json = { ...req.body }; + const controlpanel = await Controlpanel.fetchById(req.params.id, {}, trx); + + // Handle images + json = await handleFiles(json, controlpanel); + json = await handleImages(json, controlpanel); + await Controlpanel.update( req.params.id, { diff --git a/src/routes/navigation/navigation.js b/src/routes/navigation/navigation.js index 66bb5e6..0a68943 100644 --- a/src/routes/navigation/navigation.js +++ b/src/routes/navigation/navigation.js @@ -36,9 +36,15 @@ export const handler = async (req, trx) => { title: navitem[0], description: navitem[1], '@id': navitem[2], + items: [], + }; + }), + ...map(await items.toJSON(req), (item) => { + return { + ...item, + items: [], }; }), - ...(await items.toJSON(req)), ], }, }; diff --git a/src/routes/site/site.js b/src/routes/site/site.js index 9fb7b9b..617177c 100644 --- a/src/routes/site/site.js +++ b/src/routes/site/site.js @@ -3,8 +3,10 @@ * @module routes/site/site */ +import { last } from 'lodash'; import { Controlpanel } from '../../models'; import { getRootUrl } from '../../helpers'; +const { config } = require(`${process.cwd()}/config`); export default [ { @@ -13,15 +15,17 @@ export default [ permission: 'View', handler: async (req, trx) => { const controlpanel = await Controlpanel.fetchById('site', {}, trx); - const config = controlpanel.data; + const site = controlpanel.data; // Return database information return { json: { '@id': `${getRootUrl(req)}/@site`, - robots_txt: config?.robots_txt, - site_logo: config?.site_logo, - site_title: config?.site_title, + 'plone.robots_txt': site?.robots_txt, + 'plone.site_logo': site?.site_logo + ? `${config.frontendUrl}/en/@@images/${site.site_logo.uuid}.${last(site.site_logo.filename.split('.'))}` + : null, + 'plone.site_title': site?.site_title, }, }; }, diff --git a/src/seeds/controlpanel/controlpanel.js b/src/seeds/controlpanel/controlpanel.js index 0c27c5e..2813790 100644 --- a/src/seeds/controlpanel/controlpanel.js +++ b/src/seeds/controlpanel/controlpanel.js @@ -1,36 +1,58 @@ -import { dropRight, map, merge } from 'lodash'; +import { dropRight, endsWith, filter, map, merge } from 'lodash'; import { promises as fs } from 'fs'; -import { dirExists, mapAsync, stripI18n } from '../../helpers'; +import { + dirExists, + handleFiles, + handleImages, + mapAsync, + stripI18n, +} from '../../helpers'; import { Controlpanel } from '../../models'; export const seedControlpanel = async (trx, profilePath) => { if (dirExists(`${profilePath}/controlpanels`)) { // Get controlpanel profiles const controlpanels = map( - await fs.readdir(`${profilePath}/controlpanels`), + filter(await fs.readdir(`${profilePath}/controlpanels`), (file) => + endsWith(file, '.json'), + ), (file) => dropRight(file.split('.')).join('.'), ).sort(); // Import controlpanels await mapAsync(controlpanels, async (controlpanel) => { - const data = stripI18n( + let data = stripI18n( require(`${profilePath}/controlpanels/${controlpanel}`), ); // Check if controlpanel exists - const current = await Controlpanel.fetchById(data.id, {}, trx); + let current = await Controlpanel.fetchById(data.id, {}, trx); - // If doesn't exist + // Create control panel if not already there if (!current) { await Controlpanel.create(data, {}, trx); - } else { - await Controlpanel.update( - data.id, - merge(current.$toDatabaseJson(), data), - trx, - ); } + current = await Controlpanel.fetchById(data.id, {}, trx); + + // Handle files and images + data.data = await handleFiles( + data.data, + current, + `${profilePath}/controlpanels`, + ); + data.data = await handleImages( + data.data, + current, + `${profilePath}/controlpanels`, + ); + + // Update record + await Controlpanel.update( + data.id, + merge(current.$toDatabaseJson(), data), + trx, + ); }); console.log('Controlpanels imported'); } diff --git a/src/seeds/document/document.js b/src/seeds/document/document.js index efbc283..f757272 100644 --- a/src/seeds/document/document.js +++ b/src/seeds/document/document.js @@ -72,8 +72,8 @@ export const seedDocument = async (trx, profilePath) => { // Handle files and images const type = await Type.fetchById(document.type || 'Page', {}, trx); - document = await handleFiles(document, type, profilePath); - document = await handleImages(document, type, profilePath); + document = await handleFiles(document, type, `${profilePath}/documents`); + document = await handleImages(document, type, `${profilePath}/documents`); const newUuid = document.uuid || uuid();