diff --git a/CHANGELOG.md b/CHANGELOG.md index d27c282a1a..4a2dcab546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Add `@apostrophecms/migration:add-missing-schema-fields` task. This task does not run database migrations. * Translation strings added for the layout- and layout-column-widgets. +* Adds `@apostrophecms/doc:get-apos-doc-id` and `@apostrophecms/doc:set-apos-doc-id` tasks. * New `box` schema field type * When switching locale from the doc editor, ask if the user wants to localize the current document in the target locale or want to start a blank document. * Introduced a new `longPolling: false` option for the `@apostrophecms/notification` module. This eliminates long-pending requests when logged in, but also slows down the delivery of notifications. The behavior can be tuned further via the `pollingInterval` option, which defaults to `5000` milliseconds. diff --git a/modules/@apostrophecms/doc/index.js b/modules/@apostrophecms/doc/index.js index a65890e251..ac3f8a19ec 100644 --- a/modules/@apostrophecms/doc/index.js +++ b/modules/@apostrophecms/doc/index.js @@ -2,6 +2,8 @@ const _ = require('lodash'); const { createId } = require('@paralleldrive/cuid2'); const { SemanticAttributes } = require('@opentelemetry/semantic-conventions'); const { klona } = require('klona'); +const legacyMigrations = require('./lib/legacy-migrations.js'); +const migrations = require('./lib/migrations.js'); // This module is responsible for managing all of the documents (apostrophe // "docs") in the `aposDocs` mongodb collection. @@ -1843,8 +1845,125 @@ module.exports = { return existing?.aposDocId || self.apos.util.generateId(); }, - ...require('./lib/legacy-migrations')(self), - ...require('./lib/migrations')(self) + async getAposDocId({ + _id, slug, locale + }) { + if (!_id && !slug) { + throw self.apos.error('invalid', 'Either _id or slug must be provided'); + } + if (!_id && !locale) { + throw self.apos.error('invalid', 'Missing locale'); + } + + const criteria = _id + ? { _id } + : { + slug, + aposLocale: new RegExp(`^${self.apos.util.regExpQuote(locale)}:`) + }; + + const doc = await self.apos.doc.db + .findOne(criteria, { projection: { aposDocId: 1 } }); + if (!doc || !doc.aposDocId) { + throw self.apos.error('notfound'); + } + + return doc.aposDocId; + }, + async setAposDocId({ + newId, oldId, slug, locale + }) { + if (!newId) { + throw self.apos.error('invalid', 'Missing newId'); + } + if (!oldId && !slug) { + throw self.apos.error('invalid', 'Either oldId or slug must be provided'); + } + if (!locale) { + throw self.apos.error('invalid', 'Missing locale'); + } + + const originalId = (slug && !oldId) + ? await self.getAposDocId({ + slug, + locale + }) + : oldId; + + const modes = [ 'previous', 'draft', 'published' ]; + const pairs = modes.map(mode => + [ + `${originalId}:${locale}:${mode}`, + `${newId}:${locale}:${mode}` + ] + ); + + // Filter non existing from _id from the list + const existing = ( + await self.apos.doc.db + .find( + { _id: { $in: pairs.map(([ from ]) => from) } }, + { projection: { _id: 1 } } + ) + .toArray() + ) + .map(({ _id }) => _id); + const { renamed } = await self.changeDocIds( + pairs.filter(([ from ]) => existing.includes(from)), + { keep: false } + ); + + return { + oldId: originalId, + newId, + locale, + renamed + }; + }, + + ...legacyMigrations(self), + ...migrations(self) + }; + }, + tasks(self) { + return { + 'get-apos-doc-id': { + usage: 'Retrieve the aposDocId from the _id or slug, and the locale. Usage: "node app.js @apostrophecms/doc:get-apos-doc-id --_id=pfh0haxfpzowht3oi213cqos:fr:draft" or "node app.js @apostrophecms/doc:get-apos-doc-id --slug=test --locale=fr"', + task: async (argv) => { + const { + _id, slug, locale + } = argv; + + const aposDocId = await self.getAposDocId({ + _id, + slug, + locale + }); + + self.apos.util.info(aposDocId); + + return aposDocId; + } + }, + 'set-apos-doc-id': { + usage: 'Change the aposDocId of an existing document. You need the new aposDocId (new-id), the old aposDocId (old-id) or the slug, and the locale. Usage: "node app.js @apostrophecms/doc:set-apos-doc-id --new-id=tz4a98xxat96iws9zmbrgj3a --old-id=pfh0haxfpzowht3oi213cqos --locale=fr" or "node app.js @apostrophecms/doc:set-apos-doc-id --new-id=tz4a98xxat96iws9zmbrgj3a --slug=test --locale=fr"', + task: async (argv) => { + const { + 'new-id': newId, 'old-id': oldId, slug, locale + } = argv; + + const result = await self.setAposDocId({ + newId, + oldId, + slug, + locale + }); + + self.apos.util.info(`"${result.oldId}" has been changed to "${result.newId}" for locale "${result.locale}", ${result.renamed} documents changed.`); + + return result; + } + } }; } }; diff --git a/test/docs.js b/test/docs.js index 6104e008d9..4c787533bd 100644 --- a/test/docs.js +++ b/test/docs.js @@ -1,6 +1,6 @@ -const t = require('../test-lib/test.js'); -const assert = require('assert'); +const { strict: assert } = require('node:assert'); const _ = require('lodash'); +const t = require('../test-lib/test.js'); describe('Docs', function() { const apiKey = 'this is a test api key'; @@ -1234,8 +1234,412 @@ describe('Docs', function() { }); +describe('Docs: tasks', function () { + let apos; + + this.timeout(t.timeout); + + before(async function() { + apos = await t.create({ + root: module, + modules: { + '@apostrophecms/i18n': { + options: { + locales: { + en: {}, + fr: { + prefix: '/fr' + } + } + } + }, + 'test-people': { + extend: '@apostrophecms/piece-type', + fields: { + add: { + _friends: { + type: 'relationship', + withType: 'test-people', + label: 'Friends' + } + } + } + }, + unlocalized: { + extend: '@apostrophecms/piece-type', + options: { + localized: false + }, + fields: { + add: { + _friends: { + type: 'relationship', + max: 1, + withType: 'test-people', + label: 'Friends' + } + } + } + }, + '@apostrophecms/page': { + options: { + park: [], + types: [ + { + name: 'test-page', + label: 'Test Page' + } + ] + } + }, + 'test-page': { + extend: '@apostrophecms/page-type' + } + + } + }); + }); + + after(async function() { + await t.destroy(apos); + }); + + beforeEach(async function() { + await apos.doc.db.deleteMany({}); + await apos.lock.db.deleteMany({}); + }); + + it('should require the _id or slug when calling @apostrophecms/doc:get-apos-doc-id task', async function() { + await insertI18nFixtures(apos); + + const actual = async () => { + await apos.task.invoke( + '@apostrophecms/doc:get-apos-doc-id', + { + locale: 'fr' + } + ); + }; + const expected = { + message: 'Either _id or slug must be provided', + name: 'invalid' + }; + + await assert.rejects(actual, expected); + }); + + it('should not require the locale when calling @apostrophecms/doc:get-apos-doc-id task with _id', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'en' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + const actual = await apos.task.invoke( + '@apostrophecms/doc:get-apos-doc-id', + { + _id: doc._id + } + ); + const expected = doc.aposDocId; + + assert.equal(actual, expected); + }); + + it('should require the locale when calling @apostrophecms/doc:get-apos-doc-id task with slug', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'en' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + const actual = async () => { + await apos.task.invoke( + '@apostrophecms/doc:get-apos-doc-id', + { + slug: doc.slug + } + ); + }; + const expected = { + message: 'Missing locale', + name: 'invalid' + }; + + await assert.rejects(actual, expected); + }); + + it('should get the aposDocId when calling @apostrophecms/doc:get-apos-doc-id task with a slug', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'en' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + const actual = await apos.task.invoke( + '@apostrophecms/doc:get-apos-doc-id', + { + slug: doc.slug, + locale: 'en' + } + ); + const expected = doc.aposDocId; + + assert.equal(actual, expected); + }); + + it('should get the aposDocId when calling @apostrophecms/doc:get-apos-doc-id task with an _id', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'en' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + const actual = await apos.task.invoke( + '@apostrophecms/doc:get-apos-doc-id', + { + _id: doc._id, + locale: 'en' + } + ); + const expected = doc.aposDocId; + + assert.equal(actual, expected); + }); + + it('should require a new ID when calling @apostrophecms/doc:set-apos-doc-id task', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'fr' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + const actual = async () => { + await apos.task.invoke( + '@apostrophecms/doc:set-apos-doc-id', + { + 'old-id': doc.aposDocId, + locale: 'fr' + } + ); + }; + const expected = { + message: 'Missing newId', + name: 'invalid' + }; + + await assert.rejects(actual, expected); + }); + + it('should require an old-id or slug when calling @apostrophecms/doc:set-apos-doc-id task', async function() { + await insertI18nFixtures(apos); + + const actual = async () => { + await apos.task.invoke( + '@apostrophecms/doc:set-apos-doc-id', + { + 'new-id': 'carl', + locale: 'fr' + } + ); + }; + const expected = { + message: 'Either oldId or slug must be provided', + name: 'invalid' + }; + + await assert.rejects(actual, expected); + }); + + it('should require a locale when calling @apostrophecms/doc:set-apos-doc-id task', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'fr' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + const actual = async () => { + await apos.task.invoke( + '@apostrophecms/doc:set-apos-doc-id', + { + 'new-id': 'carl', + 'old-id': doc.aposDocId + } + ); + }; + const expected = { + message: 'Missing locale', + name: 'invalid' + }; + + await assert.rejects(actual, expected); + }); + + it('should update the aposDocId when calling @apostrophecms/doc:set-apos-doc-id task with a slug and a new aposDocId', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'fr' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + await apos.task.invoke( + '@apostrophecms/doc:set-apos-doc-id', + { + 'new-id': 'carl', + slug: doc.slug, + locale: 'fr' + } + ); + + const paul = await apos.doc.find( + apos.task.getReq({ locale: 'fr' }), + { slug: 'paul' } + ).toObject(); + const carl = await apos.doc.find( + apos.task.getReq({ locale: 'fr' }), + { slug: 'carl' } + ).toObject(); + + const actual = { + carl: { + _id: carl._id, + aposDocId: carl.aposDocId, + aposLocale: carl.aposLocale + }, + paul: { + friendsIds: paul.friendsIds + } + }; + const expected = { + carl: { + _id: 'carl:fr:published', + aposDocId: 'carl', + aposLocale: 'fr:published' + }, + paul: { + friendsIds: [ 'carl', 'test-people-larry' ] + } + }; + + assert.deepEqual(actual, expected); + }); + + it('should update the aposDocId when calling @apostrophecms/doc:set-apos-doc-id task with an old and new aposDocId', async function() { + await insertI18nFixtures(apos); + + const req = apos.task.getReq({ locale: 'fr' }); + const doc = await apos.doc.find(req, { + type: 'test-people', + slug: 'carl' + }).toObject(); + + await apos.task.invoke( + '@apostrophecms/doc:set-apos-doc-id', + { + 'new-id': 'carl', + 'old-id': doc.aposDocId, + locale: 'fr' + } + ); + + const paul = await apos.doc.find( + apos.task.getReq({ locale: 'fr' }), + { slug: 'paul' } + ).toObject(); + const carl = await apos.doc.find( + apos.task.getReq({ locale: 'fr' }), + { slug: 'carl' } + ).toObject(); + + const actual = { + carl: { + _id: carl._id, + aposDocId: carl.aposDocId, + aposLocale: carl.aposLocale + }, + paul: { + friendsIds: paul.friendsIds + } + }; + const expected = { + carl: { + _id: 'carl:fr:published', + aposDocId: 'carl', + aposLocale: 'fr:published' + }, + paul: { + friendsIds: [ 'carl', 'test-people-larry' ] + } + }; + + assert.deepEqual(actual, expected); + }); +}); + async function insertPeople(apos) { return apos.doc.db.insertMany([ + { + _id: 'lori:en:draft', + aposDocId: 'lori', + aposLocale: 'en:draft', + slug: 'lori', + visibility: 'public', + type: 'test-people', + firstName: 'Lori', + lastName: 'Pizzaroni', + age: 32, + alive: true + }, + { + _id: 'larry:en:draft', + aposDocId: 'larry', + aposLocale: 'en:draft', + slug: 'larry', + visibility: 'public', + type: 'test-people', + firstName: 'Larry', + lastName: 'Cherber', + age: 28, + alive: true + }, + { + _id: 'carl:en:draft', + aposDocId: 'carl', + aposLocale: 'en:draft', + slug: 'carl', + visibility: 'public', + type: 'test-people', + firstName: 'Carl', + lastName: 'Sagan', + age: 62, + alive: false, + friendsIds: [ 'larry' ] + }, + { + _id: 'peter:en:draft', + aposDocId: 'peter', + aposLocale: 'en:draft', + type: 'test-people', + visibility: 'loginRequired', + firstName: 'Peter', + lastName: 'Pan', + age: 70, + slug: 'peter' + }, { _id: 'lori:en:published', aposDocId: 'lori', @@ -1321,3 +1725,138 @@ async function archiveDoc(apos, doc) { archived: true }); } + +async function insertI18nFixtures(apos) { + const larry = await apos.modules['test-people'].insert( + apos.task.getReq({ + locale: 'en', + mode: 'published' + }), + { + ...apos.modules['test-people'].newInstance(), + // _id: 'larry:en:published', + aposDocId: 'larry', + aposLocale: 'en:published', + slug: 'larry', + visibility: 'public', + type: 'test-people', + firstName: 'Larry', + lastName: 'Cherber', + age: 28, + alive: true + } + ); + + const carl = await apos.modules['test-people'].insert( + apos.task.getReq({ + locale: 'en', + mode: 'published' + }), + { + ...apos.modules['test-people'].newInstance(), + // _id: 'carl:en:published', + aposDocId: 'carl', + aposLocale: 'en:published', + slug: 'carl', + visibility: 'public', + type: 'test-people', + firstName: 'Carl', + lastName: 'Sagan', + age: 62, + alive: false, + _friends: [ larry ] + } + ); + + const paul = await apos.modules['test-people'].insert( + apos.task.getReq({ + locale: 'en', + mode: 'published' + }), + { + ...apos.modules['test-people'].newInstance(), + // _id: 'paul:en:published', + aposDocId: 'paul', + aposLocale: 'en:published', + slug: 'paul', + visibility: 'public', + type: 'test-people', + firstName: 'Paul', + lastName: 'McCartney', + age: 24, + alive: false, + _friends: [ carl, larry ] + } + ); + + const larryFr = await apos.modules['test-people'].insert( + apos.task.getReq({ + locale: 'fr', + mode: 'published' + }), + { + ...apos.modules['test-people'].newInstance(), + // _id: 'larry:fr:published', + aposDocId: 'test-people-larry', + aposLocale: 'fr:published', + slug: 'larry', + visibility: 'public', + type: 'test-people', + firstName: 'Larry', + lastName: 'Cherber', + age: 28, + alive: true + } + ); + + const carlFr = await apos.modules['test-people'].insert( + apos.task.getReq({ + locale: 'fr', + mode: 'published' + }), + { + ...apos.modules['test-people'].newInstance(), + // _id: 'carl:fr:published', + aposDocId: 'test-people-carl', + aposLocale: 'fr:published', + slug: 'carl', + visibility: 'public', + type: 'test-people', + firstName: 'Carl', + lastName: 'Sagan', + age: 62, + alive: false, + _friends: [ larryFr ] + } + ); + + const paulFr = await apos.modules['test-people'].insert( + apos.task.getReq({ + locale: 'fr', + mode: 'published' + }), + { + ...apos.modules['test-people'].newInstance(), + // _id: 'paul:fr:published', + aposDocId: 'test-people-paul', + aposLocale: 'fr:published', + slug: 'paul', + visibility: 'public', + type: 'test-people', + firstName: 'Paul', + lastName: 'McCartney', + age: 24, + alive: false, + _friends: [ carlFr, larryFr ] + } + ); + + return { + larry, + carl, + paul, + larryFr, + carlFr, + paulFr + }; +}