├── test ├── .gitignore ├── package.json └── test.js ├── .eslintrc.js ├── .gitignore ├── CHANGELOG.md ├── package.json ├── LICENSE.md ├── README.md └── index.js /test/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | root: true, 5 | extends: [ 6 | 'apostrophe' 7 | ] 8 | }; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | # Never commit a CSS map file, anywhere 7 | *.css.map 8 | 9 | .vscode 10 | # vim swp files 11 | .*.sw* 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.1 (2023-07-20) 4 | 5 | Fixed bugs relating to localization, modes and the need to use `aposDocId` rather than `_id`. 6 | Also, populate `tagsFields` with empty objects to exactly match normal behavior for these relationships. 7 | 8 | ## 1.0.0 (2023-07-13) 9 | 10 | Initial release. 11 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "-//": "This package.json file is not actually installed.", 3 | "/-/": "Apostrophe requires that all npm modules to be loaded by moog", 4 | "//-": "exist in package.json at project level, which for a test is here", 5 | "dependencies": { 6 | "apostrophe": "^3.4.0", 7 | "@apostrophecms/import-a2-tags": "git://github.com/apostrophecms/import-a2-tags.git" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/import-a2-tags", 3 | "version": "1.0.1", 4 | "description": "Import tags from A2 to tag piece types corresponding to various piece and page types in your project", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "eslint && mocha", 8 | "eslint": "eslint ." 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/apostrophecms/import-a2-tags.git" 13 | }, 14 | "keywords": [ 15 | "apostrophecms" 16 | ], 17 | "author": "Apostrophe Technologies", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/apostrophecms/import-a2-tags/issues" 21 | }, 22 | "homepage": "https://github.com/apostrophecms/import-a2-tags#readme", 23 | "devDependencies": { 24 | "apostrophe": "^3.52.0", 25 | "eslint": "^8.44.0", 26 | "eslint-config-apostrophe": "^4.0.0", 27 | "mocha": "^10.2.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Apostrophe Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const testUtil = require('apostrophe/test-lib/test'); 3 | 4 | describe('A2 tag importer', function () { 5 | 6 | this.timeout(10000); 7 | 8 | let apos; 9 | 10 | after(() => { 11 | testUtil.destroy(apos); 12 | }); 13 | 14 | it('Should instantiate the module', async () => { 15 | apos = await testUtil.create({ 16 | shortname: 'import-a2-tags', 17 | testModule: true, 18 | modules: { 19 | '@apostrophecms/import-a2-tags': {}, 20 | article: { 21 | extend: '@apostrophecms/piece-type', 22 | fields: { 23 | add: { 24 | _tags: { 25 | type: 'relationship', 26 | withType: 'article-tag' 27 | } 28 | } 29 | } 30 | }, 31 | 'article-tag': { 32 | extend: '@apostrophecms/piece-type' 33 | } 34 | } 35 | }); 36 | assert(apos.modules['@apostrophecms/import-a2-tags']); 37 | }); 38 | 39 | it('Should be able to insert articles with legacy style tags properties', async () => { 40 | const req = apos.task.getReq(); 41 | for (let i = 1; (i <= 10); i++) { 42 | await apos.modules.article.insert(req, { 43 | title: `Article ${i}`, 44 | tags: [ 45 | `Tag ${i}`, `Tag ${i + 1}` 46 | ] 47 | }); 48 | } 49 | }); 50 | 51 | it('Should be able to import A2 legacy tags from articles as A3 relationships', async () => { 52 | const req = apos.task.getReq(); 53 | await apos.task.invoke('@apostrophecms/import-a2-tags:import', { 54 | types: 'article:article-tag' 55 | }); 56 | const articles = await apos.modules.article.find(req).toArray(); 57 | for (let i = 1; (i <= 10); i++) { 58 | const article = articles.find(article => article.title === `Article ${i}`); 59 | assert(article); 60 | assert(article.tagsIds.length === 2); 61 | assert(article.tagsFields); 62 | assert(Object.keys(article.tagsFields).length === 2); 63 | assert(article._tags); 64 | assert(article._tags.length === 2); 65 | assert(article._tags[0].title === `Tag ${i}`); 66 | assert(article._tags[1].title === `Tag ${i + 1}`); 67 | } 68 | const tags = await apos.modules['article-tag'].find(req).toArray(); 69 | assert(tags.length === 11); 70 | // Verify everything exists both as draft and as published 71 | const modes = [ 'draft', 'published' ]; 72 | for (const aposMode of modes) { 73 | assert((await apos.doc.db.countDocuments({ type: 'article', aposMode })) === 10); 74 | assert((await apos.doc.db.countDocuments({ type: 'article-tag', aposMode })) === 11); 75 | } 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@apostrophecms/import-a2-tags` 2 | 3 | ## Purpose 4 | 5 | While Apostrophe 2 documents have a `tags` array field, this field doesn't exist in 6 | Apostrophe 3. Instead, tags are represented as relationships to a "tag piece type" if 7 | and when this is desired in a particular project. 8 | 9 | This module provides a simple way to migrate A2-style, array-based `tags` properties to 10 | A3-style relationships to a "tag piece type" corresponding to a particular piece type, or 11 | to pages. 12 | 13 | This module is most often used after a successful run of `@apostrophecms/content-upgrader`, 14 | which copies the `tags` array property of each document over to A3 without modification. 15 | 16 | ## Installation 17 | 18 | ```bash 19 | npm install @apostrophecms/import-a2-tags 20 | ``` 21 | 22 | ## Configuration 23 | 24 | Note that in this example we are configuring tags for all page types, but you can also import 25 | tags for any piece type or individual page type in the same way. 26 | 27 | You can also add tags to *all* piece types by configuring them for `@apostrophecms/piece-type`, 28 | but usually this is a mistake because you will want to curate the tags for each piece type 29 | separately. 30 | 31 | ```javascript 32 | // in app.js 33 | modules: { 34 | // Create a "tag piece type" to hold the tags in A3, since 35 | // A3 does not use arrays for tags. 36 | // 37 | // For images and files you can skip this 38 | 'page-tag': {}, 39 | // Activate the import-a2-tags module 40 | '@apostrophecms/import-a2-tags': {} 41 | } 42 | 43 | // in modules/page-tag/index.js (for images and files you can skip this) 44 | module.exports = { 45 | extend: '@apostrophecms/piece-type' 46 | }, 47 | 48 | // in modules/@apostrophecms/page-type/index.js (for images and files you can skip this) 49 | module.exports = { 50 | fields: { 51 | add: { 52 | _tags: { 53 | type: 'relationship', 54 | withType: 'page-tag', 55 | label: 'Tags', 56 | help: 'Tags for this page' 57 | } 58 | } 59 | } 60 | }; 61 | ``` 62 | 63 | ## Usage 64 | 65 | ```bash 66 | # Import tags on images to the built-in image-tag piece type and create relationships 67 | node app @apostrophecms/import-a2-tags:import --types=@apostrophecms/image:@apostrophecms/image-tag 68 | # Requires additional configuration, see above 69 | node app @apostrophecms/import-a2-tags:import --types=@apostrophecms/page-type:page-tag 70 | ``` 71 | 72 | ### Specifying the piece type or page type to import tags from 73 | 74 | The type before the `:` is the page or piece type that has data in an existing `tags` array 75 | property in MongoDB (usually due to an import from A2). 76 | 77 | ### Specifying the "tag piece type" to represent the tags 78 | 79 | Since A3 does not have array-based tags, you'll need to add a piece type to your project 80 | to represent the tags. This change was made in A3 because it yields a better curation 81 | experience. 82 | 83 | The type after the `:` is a piece type that you have added to your A3 project to serve as a 84 | "tag type." You must also add a `_tags` relationship pointing at this type, as shown above. 85 | 86 | Note that images already have a tag piece type, `@apostrophecms/image-tag`, and 87 | files do too, `@apostrophecms/file-tag`. You don't have to create a new module in order 88 | to import these. 89 | 90 | ## Importing multiple types at once 91 | 92 | If you wish, you can specify multiple comma-separated pairs of types: 93 | 94 | ```bash 95 | node app @apostrophecms/import-a2-tags:import --types=type1:type1-tag,type2:type2-tag 96 | ``` 97 | 98 | Again, the tag piece types must exist in your project, except for 99 | `@apostrophecms/image-tag` and `@apostrophecms/file-tag` which exist by default. 100 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | init(self) { 3 | self.addMigration(); 4 | }, 5 | methods(self) { 6 | return { 7 | addMigration() { 8 | self.apos.migration.add('import-a2-tags-fix-invalid-ids', async () => { 9 | await self.apos.migration.eachDoc({ 10 | 'tagsIds.0': { $exists: 1 } 11 | }, async doc => { 12 | let changed = false; 13 | let tagsIds = doc.tagsIds.map(tagId => { 14 | if (tagId.includes(':')) { 15 | tagId = tagId.split(':')[0]; 16 | changed = true; 17 | } 18 | return tagId; 19 | }); 20 | if (changed) { 21 | await self.apos.doc.db.updateOne({ 22 | _id: doc._id 23 | }, { 24 | $set: { 25 | tagsIds 26 | } 27 | }); 28 | } 29 | }); 30 | }); 31 | } 32 | }; 33 | }, 34 | tasks(self) { 35 | return { 36 | import: { 37 | usage: 'Import A2-style tags properties to an A3 relationship with --types=type1:type1-tag', 38 | async task(argv = {}) { 39 | const req = self.apos.task.getReq(); 40 | const { types } = argv; 41 | if (!types) { 42 | throw 'You must specify --types=type1:type1-tag,type2:type2-tag'; 43 | } 44 | const pairs = types.split(','); 45 | for (const pair of pairs) { 46 | const [ fromType, toType ] = pair.split(':'); 47 | if (!fromType || !toType) { 48 | throw 'You must specify --types=type1:type1-tag,type2:type2-tag'; 49 | } 50 | if (!Object.hasOwn(self.apos.modules, toType)) { 51 | throw `The piece type module ${toType} does not exist`; 52 | } 53 | const to = self.apos.modules[toType]; 54 | // Find all subclasses too 55 | const fromTypes = Object.keys(self.apos.modules).filter(type => self.apos.modules[type].__meta.chain.find(entry => entry.name === fromType)); 56 | if (!fromTypes.length) { 57 | throw `The module ${fromType} does not exist and is not a base class for any other active module`; 58 | } 59 | for (const type of fromTypes) { 60 | const tagIds = new Map(); 61 | await self.apos.migration.eachDoc({ 62 | type, 63 | 'tags.0': { $exists: 1 } 64 | }, async doc => { 65 | const tags = doc.tags; 66 | for (const name of tags) { 67 | const localeParams = {}; 68 | if (doc.aposLocale) { 69 | localeParams.locale = doc.aposLocale.split(':')[0]; 70 | localeParams.mode = doc.aposMode; 71 | } 72 | const localeReq = req.clone(localeParams); 73 | const tagId = 74 | tagIds.get(name) || 75 | (await to.find(localeReq, { title: name }).toObject())?.aposDocId || 76 | (await insert({ 77 | title: name 78 | })).aposDocId; 79 | tagIds.set(name, tagId); 80 | await self.apos.doc.db.updateOne({ 81 | _id: doc._id 82 | }, { 83 | $addToSet: { 84 | tagsIds: tagId 85 | }, 86 | $set: { 87 | [`tagsFields.${tagId}`]: {} 88 | } 89 | }); 90 | 91 | async function insert(data) { 92 | const tag = await to.insert(localeReq, data); 93 | if (tag.aposMode === 'draft') { 94 | if (!to.options.autopublish) { 95 | await to.publish(localeReq, tag); 96 | } 97 | } 98 | return tag; 99 | } 100 | } 101 | }); 102 | } 103 | } 104 | } 105 | } 106 | }; 107 | } 108 | }; 109 | --------------------------------------------------------------------------------