diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a5bf4..babcee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Add preview link support @robgietema - Add block types index @robgietema - Expand image data in block data @robgietema +- Add querystring search @robgietema ### Bugfix @@ -76,6 +77,7 @@ - Split profiles in core and default @robgietema - Move apply behaviors to the document class @robgietema - Node 22 support @sneridagh +- Fetch indexes from database @robgietema ### Documentation diff --git a/docs/examples/form/post.req b/docs/examples/form/post.req new file mode 100644 index 0000000..8cb8bda --- /dev/null +++ b/docs/examples/form/post.req @@ -0,0 +1,12 @@ +POST /@schemaform-data HTTP/1.1 +Accept: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImZ1bGxuYW1lIjoiQWRtaW4iLCJpYXQiOjE2NDkzMTI0NDl9.RS1Ny_r0v7vIylFfK6q0JVJrkiDuTOh9iG9IL8xbzAk +Content-Type: application/json + +{ + "block_id": "669530d8-d319-48cc-ad4f-cd690ab7e472", + "data": { + "myfield": "Lorem Ipsum" + }, + "captcha": {} +} diff --git a/docs/examples/form/post.res b/docs/examples/form/post.res new file mode 100644 index 0000000..3fc4d8f --- /dev/null +++ b/docs/examples/form/post.res @@ -0,0 +1 @@ +HTTP/1.1 200 No Content diff --git a/docs/examples/vocabularies/list.res b/docs/examples/vocabularies/list.res index f461078..d00311d 100644 --- a/docs/examples/vocabularies/list.res +++ b/docs/examples/vocabularies/list.res @@ -18,6 +18,10 @@ Content-Type: application/json "@id": "http://localhost:8080/@vocabularies/boolean", "title": "boolean" }, + { + "@id": "http://localhost:8080/@vocabularies/captchaProviders", + "title": "captchaProviders" + }, { "@id": "http://localhost:8080/@vocabularies/groups", "title": "groups" diff --git a/docs/form.md b/docs/form.md new file mode 100644 index 0000000..ff9a6f8 --- /dev/null +++ b/docs/form.md @@ -0,0 +1,18 @@ +--- +nav_order: 26 +permalink: /form +--- + +# Form + +Form support is included in Nick. + +``` +{% include_relative examples/form/post.req %} +``` + +The API will return a 200 response: + +``` +{% include_relative examples/form/post.res %} +``` diff --git a/packages/generator-nick/generators/app/templates/config.js.tpl b/packages/generator-nick/generators/app/templates/config.js.tpl index 2a6c049..c6879c1 100644 --- a/packages/generator-nick/generators/app/templates/config.js.tpl +++ b/packages/generator-nick/generators/app/templates/config.js.tpl @@ -1,3 +1,5 @@ +import events from './src/events'; + export const config = { connection: { port: 5432, @@ -36,4 +38,5 @@ export const config = { `${__dirname}/src/develop/nick/src/profiles/core`, `${__dirname}/src/profiles/default`, ], + events, }; diff --git a/packages/generator-nick/generators/app/templates/src/events/index.js b/packages/generator-nick/generators/app/templates/src/events/index.js new file mode 100644 index 0000000..180ea6a --- /dev/null +++ b/packages/generator-nick/generators/app/templates/src/events/index.js @@ -0,0 +1,9 @@ +/** + * Point of contact for events. + * @module events + * @example import events from './events'; + */ + +import events from '@robgietema/nick/src/events'; + +export default events; diff --git a/scripts/convert.js b/scripts/convert.js index 8f6c97e..99a7e5a 100644 --- a/scripts/convert.js +++ b/scripts/convert.js @@ -176,6 +176,8 @@ const convertDocuments = (input, path) => { // Convert to string let json = JSON.stringify(document); + + // Resolve targets json = json.replaceAll(/\"[^"]*resolveuid\/([^"]*)\"/g, (result, uuid) => { const targetfile = `${input}/content/${uuid}/data.json`; if (existsSync(targetfile)) { @@ -189,6 +191,9 @@ const convertDocuments = (input, path) => { return `"${uuid}"`; }); + // Convert index operations + json = json.replaceAll('plone.app.querystring.operation.', ''); + const output = document['@id'] === '/Plone' ? `${path}/documents/_root.json` diff --git a/src/collections/index/index.js b/src/collections/index/index.js index 4b96ba2..0bb5a6a 100644 --- a/src/collections/index/index.js +++ b/src/collections/index/index.js @@ -23,7 +23,7 @@ export class IndexCollection extends Collection { // Add index to return json this.map((index) => { - json[index.id] = index.toJSON(req); + json[index.name] = index.toJSON(req); }); // Return json diff --git a/src/helpers/content/content.js b/src/helpers/content/content.js index b321174..03ad3f9 100644 --- a/src/helpers/content/content.js +++ b/src/helpers/content/content.js @@ -177,10 +177,7 @@ export async function handleBlockReferences(json, trx) { if (isObject(output.blocks[block].slides)) { await Promise.all( map(output.blocks[block].slides, async (slide, index) => { - console.log('image'); - console.log(index); if (isObject(output.blocks[block].slides[index].image)) { - console.log('image'); output.blocks[block].slides[index].image[0] = await extendHref( output.blocks[block].slides[index].image[0], trx, diff --git a/src/migrations/202412031416_metadata.js b/src/migrations/202412031416_metadata.js new file mode 100644 index 0000000..259a8ed --- /dev/null +++ b/src/migrations/202412031416_metadata.js @@ -0,0 +1,17 @@ +export const up = async (knex) => { + await knex.schema.alterTable('index', (table) => { + table.string('name'); + table.string('type'); + table.boolean('metadata').index(); + table.string('attr'); + }); +}; + +export const down = async (knex) => { + await knex.schema.alterTable('index', (table) => { + table.dropColumn('name'); + table.dropColumn('type'); + table.dropColumn('metadata'); + table.dropColumn('attr'); + }); +}; diff --git a/src/models/_model/_model.js b/src/models/_model/_model.js index 5091814..bc84101 100644 --- a/src/models/_model/_model.js +++ b/src/models/_model/_model.js @@ -59,10 +59,14 @@ export class Model extends mixin(ObjectionModel, [ mapKeys(where, (value, key) => { // user and group are reserved words so need to be wrapper in quotes const attribute = formatAttribute(key); - const operator = isArray(value) ? value[0] : '='; + let operator = isArray(value) ? value[0] : '='; const values = isArray(value) ? value[1] : value; let valueWrapper = isArray(values) && operator !== '&&' ? 'any(?)' : '?'; + if (isArray(values) && operator === 'all') { + operator = '='; + valueWrapper = 'all(?)'; + } if (operator === '@@') { valueWrapper = 'to_tsquery(?)'; } diff --git a/src/models/document/document.js b/src/models/document/document.js index 5a1b5df..45c19d6 100644 --- a/src/models/document/document.js +++ b/src/models/document/document.js @@ -30,7 +30,15 @@ import _, { import { v4 as uuid } from 'uuid'; import languages from '../../constants/languages'; -import { Catalog, Model, Permission, Redirect, Role, User } from '../../models'; +import { + Catalog, + Index, + Model, + Permission, + Redirect, + Role, + User, +} from '../../models'; import { copyFile, fileExists, @@ -962,60 +970,27 @@ export class Document extends Model { await this._type.fetchRelated('_workflow', trx); } + const indexes = await Index.fetchAll({}, {}, trx); await Promise.all( - map(config.profiles, async (profilePath) => { - if (fileExists(`${profilePath}/catalog`)) { - const profile = stripI18n(require(`${profilePath}/catalog`)); - - // Loop indexes - await Promise.all( - map(profile.indexes, async (index) => { - if (index.attr in this) { - fields[`_${index.name}`] = { type: index.type }; - if (isFunction(this[index.attr])) { - const value = this[index.attr](trx); - fields[`_${index.name}`].value = isPromise(value) - ? await value - : value; - } else { - fields[`_${index.name}`].value = this[index.attr]; - } - } else if (index.attr in this._type._schema.properties) { - fields[`_${index.name}`] = { - type: index.type, - value: - index.type === 'boolean' - ? !!this.json[index.attr] - : this.json[index.attr], - }; - } - }), - ); - - // Loop metadata - await Promise.all( - map(profile.metadata, async (metadata) => { - if (metadata.attr in this) { - fields[metadata.name] = { type: metadata.type }; - if (isFunction(this[metadata.attr])) { - const value = this[metadata.attr](trx); - fields[metadata.name].value = isPromise(value) - ? await value - : value; - } else { - fields[metadata.name].value = this[metadata.attr]; - } - } else if (metadata.attr in this._type._schema.properties) { - fields[metadata.name] = { - type: metadata.type, - value: - metadata.type === 'boolean' - ? !!this.json[metadata.attr] - : this.json[metadata.attr], - }; - } - }), - ); + // Loop indexes + indexes.map(async (index) => { + const name = index.metadata ? index.name : `_${index.name}`; + if (index.attr in this) { + fields[name] = { type: index.type }; + if (isFunction(this[index.attr])) { + const value = this[index.attr](trx); + fields[name].value = isPromise(value) ? await value : value; + } else { + fields[name].value = this[index.attr]; + } + } else if (index.attr in this._type._schema.properties) { + fields[name] = { + type: index.type, + value: + index.type === 'boolean' + ? !!this.json[index.attr] + : this.json[index.attr], + }; } }), ); diff --git a/src/routes/form/form.js b/src/routes/form/form.js new file mode 100644 index 0000000..5e22bac --- /dev/null +++ b/src/routes/form/form.js @@ -0,0 +1,17 @@ +/** + * Form routes. + * @module routes/form/form + */ + +export default [ + { + op: 'post', + view: '/@schemaform-data', + permission: 'View', + handler: async (req, trx) => { + return { + json: {}, + }; + }, + }, +]; diff --git a/src/routes/form/form.test.js b/src/routes/form/form.test.js new file mode 100644 index 0000000..4619d7b --- /dev/null +++ b/src/routes/form/form.test.js @@ -0,0 +1,6 @@ +import app from '../../app'; +import { testRequest } from '../../helpers'; + +describe('Form', () => { + it('should submit a form', () => testRequest(app, 'search/post')); +}); diff --git a/src/routes/querystring/querystring.js b/src/routes/querystring/querystring.js index c0a6be8..646ec1d 100644 --- a/src/routes/querystring/querystring.js +++ b/src/routes/querystring/querystring.js @@ -14,14 +14,14 @@ export default [ handler: async (req, trx) => { // Get all enabled indexes const indexes = await Index.fetchAll( - { enabled: true }, + { enabled: true, metadata: false }, { order: 'title' }, trx, ); // Get all enabled and sortable indexes const sortableIndexes = await Index.fetchAll( - { sortable: true, enabled: true }, + { sortable: true, enabled: true, metadata: false }, { order: 'title' }, trx, ); diff --git a/src/routes/search/search.js b/src/routes/search/search.js index e6d320f..408d598 100644 --- a/src/routes/search/search.js +++ b/src/routes/search/search.js @@ -4,24 +4,164 @@ */ import moment from 'moment'; -import { endsWith, find, includes, map, mapKeys, repeat } from 'lodash'; +import { normalize } from 'path'; +import { endsWith, includes, keys, map, mapKeys, repeat } from 'lodash'; -import { formatSize, getRootUrl, getUrl } from '../../helpers'; -import { Catalog } from '../../models'; - -import profile from '../../profiles/core/catalog'; +import { getUrl } from '../../helpers'; +import { Catalog, Index } from '../../models'; /** * Convert querystring to query. * @method querystringToQuery * @param {Object} querystring Querystring * @param {String} path Path to search + * @param {Object} req Request object + * @param {Object} trx Transaction object. + * @returns {Object} Query. + */ +const querystringToQuery = async (querystring, path = '/', req, trx) => { + // Get root url + const root = endsWith(path, '/') ? path : `${path}/`; + + // Get indexes + let indexes = {}; + (await Index.fetchAll({ metadata: false }, {}, trx)).map((index) => { + indexes[index.name] = index; + }); + + const where = {}; + + // Default options + const options = { + offset: 0, + limit: 100, + order: { + column: 'UID', + reverse: false, + }, + }; + + // Check sort + if (querystring.sort_on) { + if (includes(keys(indexes), querystring.sort_on)) { + options.order.column = `_${querystring.sort_on}`; + } + } + + // Check sort order + if (querystring.sort_order) { + options.order.reverse = querystring.sort_order !== 'ascending'; + } + + // Check path depth + if (querystring['path.depth']) { + where['_path'] = [ + '~', + `^${root}[^/]+${repeat('(/[^/]+)?', querystring['path.depth'] - 1)}$`, + ]; + } + + // Check batch size + if (querystring.b_size) { + options.limit = querystring.b_size; + } + + // Check batch start + if (querystring.b_start) { + options.offset = querystring.b_start; + } + + // Check metafields + if (querystring.meta_fields === '_all') { + // select column_name from information_schema.columns where table_name = 'catalog' and column_name ~ '^[^_]'; + } + + // Add query + map(querystring.query, (query) => { + switch (query.o) { + case 'selection.any': + where[query.i] = ['=', query.v]; + break; + case 'selection.all': + where[query.i] = ['all', query.v]; + break; + case 'date.today': + where[query.i] = ['>=', `${moment().format('MM-DD-YYYY')} 00:00:00`]; + where[query.i] = ['<=', `${moment().format('MM-DD-YYYY')} 23:59:59`]; + break; + case 'date.between': + where[query.i] = ['>', query.v[0]]; + where[query.i] = ['<', query.v[1]]; + break; + case 'date.lessThen': + where[query.i] = ['<', query.v]; + break; + case 'date.afterToday': + where[query.i] = ['>', `${moment().format('MM-DD-YYYY')} 23:59:59`]; + break; + case 'date.largerThan': + where[query.i] = ['>', query.v]; + break; + case 'date.beforeToday': + where[query.i] = ['<', `${moment().format('MM-DD-YYYY')} 00:00:00`]; + break; + case 'date.afterRelativeDate': + where[query.i] = ['>', query.v]; + break; + case 'date.beforeRelativeDate': + where[query.i] = ['<', query.v]; + break; + case 'date.lessThanRelativeDate': + where[query.i] = ['<', query.v]; + break; + case 'date.largerThanRelativeDate': + where[query.i] = ['>', query.v]; + break; + case 'string.is': + where[query.i] = query.v; + break; + case 'string.path': + where[query.i] = query.v; + break; + case 'string.absolutePath': + where[query.i] = query.v.replace(getUrlByPath(req, '/'), '/'); + break; + case 'string.relativePath': + where[query.i] = ['~', normalize(`${root}${query.v}`)]; + break; + case 'string.currentUser': + where[query.i] = req.user.id; + break; + case 'string.contains': + where[query.i] = ['like', `%${query.v}%`]; + break; + default: + break; + } + }); + + return [where, options]; +}; + +/** + * Convert queryparam to query. + * @method queryparamToQuery + * @param {Object} queryparam Querystring + * @param {String} path Path to search + * @param {Object} req Request object + * @param {Object} trx Transaction object. * @returns {Object} Query. */ -function querystringToQuery(querystring, path = '/') { +const queryparamToQuery = async (queryparam, path = '/', req, trx) => { // Get root url const root = endsWith(path, '/') ? path : `${path}/`; + // Get indexes + let indexes = {}; + (await Index.fetchAll({ metadata: false }, {}, trx)).map((index) => { + indexes[index.name] = index; + }); + // Set path search const where = { _path: ['~', `^${path}`], @@ -36,15 +176,12 @@ function querystringToQuery(querystring, path = '/') { reverse: false, }, }; - mapKeys(querystring, (value, key) => { + + // Loop through query params + mapKeys(queryparam, (value, key) => { switch (key) { case 'sort_on': - if ( - includes( - map(profile.indexes, (index) => index.name), - value, - ) - ) { + if (includes(keys(indexes), value)) { options.order.column = `_${value}`; } break; @@ -63,20 +200,14 @@ function querystringToQuery(querystring, path = '/') { case 'b_start': options.offset = value; break; - case 'metadata_fields': - if (value === '_all') { - options.select = map(profile.metadata, (metadata) => metadata.name); - } - break; default: break; } // Check if key in indexes - const index = find(profile.indexes, (index) => index.name === key); - if (index) { - const field = `_${index.name}`; - switch (index.type) { + if (indexes[key]) { + const field = `_${key}`; + switch (indexes[key].type) { case 'string': case 'integer': case 'path': @@ -97,7 +228,7 @@ function querystringToQuery(querystring, path = '/') { } }); return [where, options]; -} +}; export default [ { @@ -106,7 +237,7 @@ export default [ permission: 'View', handler: async (req, trx) => { const items = await Catalog.fetchAllRestricted( - ...querystringToQuery(req.query, req.document.path), + ...(await queryparamToQuery(req.query, req.document.path, req, trx)), trx, req, ); @@ -124,7 +255,11 @@ export default [ view: '/@querystring-search', permission: 'View', handler: async (req, trx) => { - const items = await Catalog.fetchAll({}, { order: 'UID' }, trx); + const items = await Catalog.fetchAllRestricted( + ...(await querystringToQuery(req.body, req.document.path, req, trx)), + trx, + req, + ); return { json: { '@id': `${getUrl(req)}/@search`, diff --git a/src/seeds/catalog/catalog.js b/src/seeds/catalog/catalog.js index 5451786..d8c4268 100644 --- a/src/seeds/catalog/catalog.js +++ b/src/seeds/catalog/catalog.js @@ -6,22 +6,24 @@ export const seedCatalog = async (trx, profilePath) => { const profile = stripI18n(require(`${profilePath}/catalog`)); await mapAsync(profile.indexes, async (index) => { // Add index - if (index.enabled) { - await Index.create( - { - id: index.name, - title: index.title, - description: index.description, - group: index.group, - enabled: index.enabled, - sortable: index.sortable, - operators: index.operators, - vocabulary: index.vocabulary, - }, - {}, - trx, - ); - } + await Index.create( + { + id: `_${index.name}`, + name: index.name, + title: index.title, + type: index.type, + attr: index.attr, + metadata: false, + description: index.description, + group: index.group, + enabled: index.enabled, + sortable: index.sortable, + operators: index.operators, + vocabulary: index.vocabulary, + }, + {}, + trx, + ); // Update catalog table await trx.schema.alterTable('catalog', async (table) => { const field = `_${index.name}`; @@ -60,6 +62,18 @@ export const seedCatalog = async (trx, profilePath) => { }); }); await mapAsync(profile.metadata, async (metadata) => { + // Add index + await Index.create( + { + id: metadata.name, + name: metadata.name, + type: metadata.type, + attr: metadata.attr, + metadata: true, + }, + {}, + trx, + ); await trx.schema.alterTable('catalog', async (table) => { switch (metadata.type) { case 'uuid': diff --git a/src/vocabularies/captcha-providers/captcha-providers.js b/src/vocabularies/captcha-providers/captcha-providers.js new file mode 100644 index 0000000..6245a31 --- /dev/null +++ b/src/vocabularies/captcha-providers/captcha-providers.js @@ -0,0 +1,18 @@ +/** + * Captcha providers. + * @module vocabularies/captcha-providers/captcha-providers + */ + +import { objectToVocabulary } from '../../helpers'; +const providers = { + 'norobots-captcha': 'NoRobots ReCaptcha Support', +}; + +/** + * Returns the captcha providers vocabulary. + * @method captchaProviders + * @returns {Array} Array of terms. + */ +export async function captchaProviders(req, trx) { + return objectToVocabulary(providers); +} diff --git a/src/vocabularies/index.js b/src/vocabularies/index.js index 7f4b898..c01a7e4 100644 --- a/src/vocabularies/index.js +++ b/src/vocabularies/index.js @@ -6,6 +6,7 @@ import { actions } from './actions/actions'; import { availableLanguages } from './available-languages/available-languages'; import { behaviors } from './behaviors/behaviors'; +import { captchaProviders } from './captcha-providers/captcha-providers'; import { groups } from './groups/groups'; import { imageScales } from './image-scales/image-scales'; import { permissions } from './permissions/permissions'; @@ -25,6 +26,7 @@ export const vocabularies = { actions, availableLanguages, behaviors, + captchaProviders, groups, imageScales, permissions,