Added Revue Importer (#16012)

refs: https://www.getrevue.co/app/offboard

- Revue is stopping all paid subscriptions on 20th Dec, and shutting down on Jan 18th.
- This update allows Ghost to accept and handle the zip file Revue are providing as an export in Labs > Importer
- It will import posts (as best as we can with the data provided) and subscribers as free members
- At present it doesn't import paid subscribers, as we don't have that info, but you can disconnect Revue from your Stripe account to prevent all your subscriptions being cancelled & there's the option this can be fixed later
- There will be further updates to polish up this tooling - this is just a first pass to try to get something in people's hands

Co-authored-by: Paul Davis <PaulAdamDavis@users.noreply.github.com>
This commit is contained in:
Hannah Wolfe 2022-12-15 17:22:54 +00:00 committed by GitHub
parent 0825a2d7f4
commit 5f90baf6fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 716 additions and 34 deletions

View File

@ -2,6 +2,7 @@ const _ = require('lodash');
const fs = require('fs-extra');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const debug = require('@tryghost/debug')('importer:handler:data');
const messages = {
invalidJsonFormat: 'Invalid JSON format, expected `{ db: [exportedData] }`',
@ -17,6 +18,7 @@ JSONHandler = {
directories: [],
loadFile: async function (files, startDir) { // eslint-disable-line no-unused-vars
debug('loadFile', files);
// @TODO: Handle multiple JSON files
const filePath = files[0].path;

View File

@ -0,0 +1,44 @@
const _ = require('lodash');
const fs = require('fs-extra');
const debug = require('@tryghost/debug')('importer:handler:revue');
const hasIssuesCSV = (files) => {
return _.some(files, (file) => {
return file.name.match(/^issues.*?\.csv/);
});
};
const RevueHandler = {
type: 'revue',
extensions: ['.csv', '.json'],
contentTypes: ['application/octet-stream', 'application/json', 'text/plain'],
directories: [],
loadFile: function (files, startDir) {
debug('loadFile', files);
const startDirRegex = startDir ? new RegExp('^' + startDir + '/') : new RegExp('');
const idRegex = /_.*?\./;
const ops = [];
const revue = {};
if (!hasIssuesCSV(files)) {
return Promise.resolve();
}
_.each(files, function (file) {
ops.push(fs.readFile(file.path).then(function (content) {
// normalize the file name
file.name = file.name.replace(startDirRegex, '').replace(idRegex, '.');
const name = file.name.split('.')[0];
revue[name] = content.toString();
}));
});
return Promise.all(ops).then(() => {
return {meta: {revue: true}, revue};
});
}
};
module.exports = RevueHandler;

View File

@ -7,12 +7,15 @@ const uuid = require('uuid');
const config = require('../../../shared/config');
const {extract} = require('@tryghost/zip');
const tpl = require('@tryghost/tpl');
const debug = require('@tryghost/debug')('import-manager');
const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors');
const ImageHandler = require('./handlers/image');
const RevueHandler = require('./handlers/revue');
const JSONHandler = require('./handlers/json');
const MarkdownHandler = require('./handlers/markdown');
const ImageImporter = require('./importers/image');
const RevueImporter = require('@tryghost/importer-revue');
const DataImporter = require('./importers/data');
const urlUtils = require('../../../shared/url-utils');
const {GhostMailer} = require('../../services/mail');
@ -48,12 +51,12 @@ class ImportManager {
/**
* @type {Importer[]} importers
*/
this.importers = [ImageImporter, DataImporter];
this.importers = [ImageImporter, RevueImporter, DataImporter];
/**
* @type {Handler[]}
*/
this.handlers = [ImageHandler, JSONHandler, MarkdownHandler];
this.handlers = [ImageHandler, RevueHandler, JSONHandler, MarkdownHandler];
// Keep track of file to cleanup at the end
/**
@ -240,6 +243,8 @@ class ImportManager {
for (const handler of this.handlers) {
const files = this.getFilesFromZip(handler, zipDirectory);
debug('handler', handler.type, files);
if (files.length > 0) {
if (Object.prototype.hasOwnProperty.call(importData, handler.type)) {
// This limitation is here to reduce the complexity of the importer for now
@ -271,17 +276,19 @@ class ImportManager {
* @param {File} file
* @returns {Promise<ImportData>}
*/
processFile(file, ext) {
const fileHandler = _.find(this.handlers, function (handler) {
async processFile(file, ext) {
const fileHandlers = _.filter(this.handlers, function (handler) {
return _.includes(handler.extensions, ext);
});
return fileHandler.loadFile([_.pick(file, 'name', 'path')]).then(function (loadedData) {
// normalize the returned data
const importData = {};
importData[fileHandler.type] = loadedData;
return importData;
});
const importData = {};
await Promise.all(fileHandlers.map(async (fileHandler) => {
debug('fileHandler', fileHandler.type);
importData[fileHandler.type] = await fileHandler.loadFile([_.pick(file, 'name', 'path')]);
}));
return importData;
}
/**
@ -305,6 +312,7 @@ class ImportManager {
* @returns {Promise<ImportData>}
*/
async preProcess(importData) {
debug('preProcess');
for (const importer of this.importers) {
importData = importer.preProcess(importData);
}
@ -321,10 +329,12 @@ class ImportManager {
* @returns {Promise<Object.<string, ImportResult>>} importResults
*/
async doImport(importData, importOptions) {
debug('doImport', this.importers);
importOptions = importOptions || {};
const importResults = {};
for (const importer of this.importers) {
debug('importer looking for', importer.type, 'in', Object.keys(importData));
if (Object.prototype.hasOwnProperty.call(importData, importer.type)) {
importResults[importer.type] = await importer.doImport(importData[importer.type], importOptions);
}
@ -411,6 +421,8 @@ class ImportManager {
importData = await this.loadFile(file);
}
debug('importFromFile completed file load', importData);
const env = config.get('env');
if (!env?.startsWith('testing') && !importOptions.runningInJob) {
return jobManager.addJob({

View File

@ -14,6 +14,7 @@ const ProductsImporter = require('./products');
const StripeProductsImporter = require('./stripe-products');
const StripePricesImporter = require('./stripe-prices');
const CustomThemeSettingsImporter = require('./custom-theme-settings');
const RevueSubscriberImporter = require('./revue-subscriber');
const RolesImporter = require('./roles');
const {slugify} = require('@tryghost/string/lib');
@ -24,6 +25,7 @@ DataImporter = {
type: 'data',
preProcess: function preProcess(importData) {
debug('preProcess');
importData.preProcessedByData = true;
return importData;
},
@ -39,12 +41,14 @@ DataImporter = {
importers.stripe_prices = new StripePricesImporter(importData.data);
importers.posts = new PostsImporter(importData.data);
importers.custom_theme_settings = new CustomThemeSettingsImporter(importData.data);
importers.revue_subscribers = new RevueSubscriberImporter(importData.data);
return importData;
},
// Allow importing with an options object that is passed through the importer
doImport: async function doImport(importData, importOptions) {
debug('doImport');
importOptions = importOptions || {};
if (importOptions.importTag && importData?.data?.posts) {

View File

@ -0,0 +1,24 @@
const debug = require('@tryghost/debug')('importer:revue-subscriber');
const BaseImporter = require('./base');
class RevueSubscriberImporter extends BaseImporter {
constructor(allDataFromFile) {
super(allDataFromFile, {
modelName: 'Member',
dataKeyToImport: 'revue_subscribers'
});
}
beforeImport() {
debug('beforeImport');
return super.beforeImport();
}
async doImport(options, importOptions) {
debug('doImport', this.modelName, this.dataToImport.length);
return super.doImport(options, importOptions);
}
}
module.exports = RevueSubscriberImporter;

View File

@ -1,6 +1,8 @@
const testUtils = require('../../utils');
const importer = require('../../../core/server/data/importer');
const dataImporter = importer.importers[1];
const dataImporter = importer.importers.find((instance) => {
return instance.type === 'data';
});
const {exportedBodyLegacy} = require('../../utils/fixtures/export/body-generator');

View File

@ -3,7 +3,9 @@ const {exportedBodyV1} = require('../../utils/fixtures/export/body-generator');
const models = require('../../../core/server/models');
const importer = require('../../../core/server/data/importer');
const dataImporter = importer.importers[1];
const dataImporter = importer.importers.find((instance) => {
return instance.type === 'data';
});
const importOptions = {
returnImportedData: true

View File

@ -13,7 +13,9 @@ const db = require('../../../core/server/data/db');
const models = require('../../../core/server/models');
const importer = require('../../../core/server/data/importer');
const dataImporter = importer.importers[1];
const dataImporter = importer.importers.find((instance) => {
return instance.type === 'data';
});
const importOptions = {
returnImportedData: true

View File

@ -14,8 +14,10 @@ const ImportManager = require('../../../../../core/server/data/importer');
const JSONHandler = require('../../../../../core/server/data/importer/handlers/json');
let ImageHandler = rewire('../../../../../core/server/data/importer/handlers/image');
const MarkdownHandler = require('../../../../../core/server/data/importer/handlers/markdown');
const RevueHandler = require('../../../../../core/server/data/importer/handlers/revue');
const DataImporter = require('../../../../../core/server/data/importer/importers/data');
const ImageImporter = require('../../../../../core/server/data/importer/importers/image');
const RevueImporter = require('@tryghost/importer-revue');
const storage = require('../../../../../core/server/adapters/storage');
const configUtils = require('../../../../utils/configUtils');
@ -28,8 +30,8 @@ describe('Importer', function () {
describe('ImportManager', function () {
it('has the correct interface', function () {
ImportManager.handlers.should.be.instanceof(Array).and.have.lengthOf(3);
ImportManager.importers.should.be.instanceof(Array).and.have.lengthOf(2);
ImportManager.handlers.should.be.instanceof(Array).and.have.lengthOf(4);
ImportManager.importers.should.be.instanceof(Array).and.have.lengthOf(3);
ImportManager.loadFile.should.be.instanceof(Function);
ImportManager.preProcess.should.be.instanceof(Function);
ImportManager.doImport.should.be.instanceof(Function);
@ -37,7 +39,8 @@ describe('Importer', function () {
});
it('gets the correct extensions', function () {
ImportManager.getExtensions().should.be.instanceof(Array).and.have.lengthOf(12);
ImportManager.getExtensions().should.be.instanceof(Array).and.have.lengthOf(13);
ImportManager.getExtensions().should.containEql('.csv');
ImportManager.getExtensions().should.containEql('.json');
ImportManager.getExtensions().should.containEql('.zip');
ImportManager.getExtensions().should.containEql('.jpg');
@ -72,7 +75,7 @@ describe('Importer', function () {
it('globs extensions correctly', function () {
ImportManager.getGlobPattern(ImportManager.getExtensions())
.should.equal('+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.json|.md|.markdown|.zip)');
.should.equal('+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.csv|.json|.md|.markdown|.zip)');
ImportManager.getGlobPattern(ImportManager.getDirectories())
.should.equal('+(images|content)');
ImportManager.getGlobPattern(JSONHandler.extensions)
@ -80,19 +83,19 @@ describe('Importer', function () {
ImportManager.getGlobPattern(ImageHandler.extensions)
.should.equal('+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp)');
ImportManager.getExtensionGlob(ImportManager.getExtensions())
.should.equal('*+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.json|.md|.markdown|.zip)');
.should.equal('*+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.csv|.json|.md|.markdown|.zip)');
ImportManager.getDirectoryGlob(ImportManager.getDirectories())
.should.equal('+(images|content)');
ImportManager.getExtensionGlob(ImportManager.getExtensions(), 0)
.should.equal('*+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.json|.md|.markdown|.zip)');
.should.equal('*+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.csv|.json|.md|.markdown|.zip)');
ImportManager.getDirectoryGlob(ImportManager.getDirectories(), 0)
.should.equal('+(images|content)');
ImportManager.getExtensionGlob(ImportManager.getExtensions(), 1)
.should.equal('{*/*,*}+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.json|.md|.markdown|.zip)');
.should.equal('{*/*,*}+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.csv|.json|.md|.markdown|.zip)');
ImportManager.getDirectoryGlob(ImportManager.getDirectories(), 1)
.should.equal('{*/,}+(images|content)');
ImportManager.getExtensionGlob(ImportManager.getExtensions(), 2)
.should.equal('**/*+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.json|.md|.markdown|.zip)');
.should.equal('**/*+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.csv|.json|.md|.markdown|.zip)');
ImportManager.getDirectoryGlob(ImportManager.getDirectories(), 2)
.should.equal('**/+(images|content)');
});
@ -142,8 +145,8 @@ describe('Importer', function () {
// We need to make sure we don't actually extract a zip and leave temporary files everywhere!
it('knows when to process a zip', function (done) {
const testZip = {name: 'myFile.zip', path: '/my/path/myFile.zip'};
const zipSpy = sinon.stub(ImportManager, 'processZip').returns(Promise.resolve({}));
const fileSpy = sinon.stub(ImportManager, 'processFile').returns(Promise.resolve({}));
const zipSpy = sinon.stub(ImportManager, 'processZip').resolves({});
const fileSpy = sinon.stub(ImportManager, 'processFile').resolves({});
ImportManager.loadFile(testZip).then(function () {
zipSpy.calledOnce.should.be.true();
@ -157,26 +160,29 @@ describe('Importer', function () {
const testZip = {name: 'myFile.zip', path: '/my/path/myFile.zip'};
// need to stub out the extract and glob function for zip
const extractSpy = sinon.stub(ImportManager, 'extractZip').returns(Promise.resolve('/tmp/dir/'));
const extractSpy = sinon.stub(ImportManager, 'extractZip').resolves('/tmp/dir/');
const validSpy = sinon.stub(ImportManager, 'isValidZip').returns(true);
const baseDirSpy = sinon.stub(ImportManager, 'getBaseDirectory').returns('');
const getFileSpy = sinon.stub(ImportManager, 'getFilesFromZip');
const jsonSpy = sinon.stub(JSONHandler, 'loadFile').returns(Promise.resolve({posts: []}));
const revueSpy = sinon.stub(RevueHandler, 'loadFile').resolves();
const jsonSpy = sinon.stub(JSONHandler, 'loadFile').resolves({posts: []});
const imageSpy = sinon.stub(ImageHandler, 'loadFile');
const mdSpy = sinon.stub(MarkdownHandler, 'loadFile');
getFileSpy.returns([]);
getFileSpy.withArgs(JSONHandler, sinon.match.string).returns([{path: '/tmp/dir/myFile.json', name: 'myFile.json'}]);
getFileSpy.withArgs(RevueHandler, sinon.match.string).returns([{path: '/tmp/dir/myFile.json', name: 'myFile.json'}]);
ImportManager.processZip(testZip).then(function (zipResult) {
extractSpy.calledOnce.should.be.true();
validSpy.calledOnce.should.be.true();
baseDirSpy.calledOnce.should.be.true();
getFileSpy.calledThrice.should.be.true();
getFileSpy.callCount.should.eql(4);
jsonSpy.calledOnce.should.be.true();
imageSpy.called.should.be.false();
mdSpy.called.should.be.false();
revueSpy.called.should.be.true();
ImportManager.processFile(testFile, '.json').then(function (fileResult) {
jsonSpy.calledTwice.should.be.true();
@ -234,6 +240,7 @@ describe('Importer', function () {
this.beforeEach(() => {
sinon.stub(JSONHandler, 'loadFile').returns(Promise.resolve({posts: []}));
sinon.stub(ImageHandler, 'loadFile');
sinon.stub(RevueHandler, 'loadFile');
sinon.stub(MarkdownHandler, 'loadFile');
});
@ -353,8 +360,11 @@ describe('Importer', function () {
const dataSpy = sinon.spy(DataImporter, 'preProcess');
const imageSpy = sinon.spy(ImageImporter, 'preProcess');
const revueSpy = sinon.spy(RevueImporter, 'preProcess');
ImportManager.preProcess(inputCopy).then(function (output) {
revueSpy.calledOnce.should.be.true();
revueSpy.calledWith(inputCopy).should.be.true();
dataSpy.calledOnce.should.be.true();
dataSpy.calledWith(inputCopy).should.be.true();
imageSpy.calledOnce.should.be.true();

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -0,0 +1,22 @@
# Importer Revue
Revue importer
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1 @@
module.exports = require('./lib/importer-revue');

View File

@ -0,0 +1,129 @@
const debug = require('@tryghost/debug')('importer:revue');
const {slugify} = require('@tryghost/string');
const papaparse = require('papaparse');
const _ = require('lodash');
const JSONToHTML = require('../lib/json-to-html');
/**
* Build posts out of the issue and item data
*
* @param {Object} revueData
* @return {Array}
*/
const fetchPostsFromData = (revueData) => {
const itemData = JSON.parse(revueData.items);
const issueData = papaparse.parse(revueData.issues, {
header: true,
skipEmptyLines: true,
transform(value, header) {
if (header === 'id') {
return parseInt(value);
}
return value;
}
});
const posts = [];
issueData.data.forEach((postMeta) => {
// Convert issues to posts
if (!postMeta.subject) {
return;
}
const revuePostID = postMeta.id;
let postHTML = postMeta.description;
const postItems = _.filter(itemData, {issue_id: revuePostID});
const sortedPostItems = _.sortBy(postItems, o => o.order);
if (postItems) {
const convertedItems = JSONToHTML.itemsToHtml(sortedPostItems);
postHTML = `${postMeta.description}${convertedItems}`;
}
const postDate = JSONToHTML.getPostDate(postMeta);
posts.push({
comment_id: revuePostID,
title: postMeta.subject,
slug: slugify(postMeta.subject),
status: JSONToHTML.getPostStatus(postMeta),
visibility: 'public',
created_at: postDate,
published_at: postDate,
updated_at: postDate,
html: postHTML,
tags: ['#revue']
});
});
return posts;
};
/**
*
* @param {*} revueData
*/
const buildSubscriberList = (revueData) => {
const subscribers = [];
const subscriberData = papaparse.parse(revueData.subscribers, {
header: true,
skipEmptyLines: true
});
subscriberData.data.forEach((subscriber) => {
subscribers.push({
email: subscriber.email,
name: `${subscriber.first_name} ${subscriber.last_name}`.trim(),
created_at: subscriber.created_at
});
});
return subscribers;
};
const RevueImporter = {
type: 'revue',
preProcess: function (importData) {
debug('preProcess');
importData.preProcessedByRevue = true;
// TODO: this should really be in doImport
// No posts to prePprocess, return early
if (!importData?.revue?.revue?.issues) {
return importData;
}
// This processed data goes to the data importer
importData.data = {
meta: {version: '5.0.0'},
data: {}
};
importData.data.data.posts = this.importPosts(importData.revue.revue);
// No subscribers to import, we're done
if (!importData?.revue?.revue?.subscribers) {
return importData;
}
importData.data.data.revue_subscribers = this.importSubscribers(importData.revue.revue);
return importData;
},
doImport: function (importData) {
debug('doImport');
return importData;
},
importPosts: fetchPostsFromData,
importSubscribers: buildSubscriberList
};
module.exports = RevueImporter;

View File

@ -0,0 +1,95 @@
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
const imageCard = require('@tryghost/kg-default-cards/lib/cards/image.js');
const embedCard = require('@tryghost/kg-default-cards/lib/cards/embed.js');
// Take the array of items for a specific post and return the converted HTML
const itemsToHtml = (items) => {
let itemHTMLChunks = [];
items.forEach((item) => {
let type = item.item_type;
if (type === 'header') {
itemHTMLChunks.push(`<h3>${item.title}</h3>`);
} else if (type === 'text') {
itemHTMLChunks.push(item.description); // THis is basic text HTML with <p>, <b>, <a>, etc (no media)
} else if (type === 'image') {
// We have 2 values to work with here. `image` is smaller and most suitable, and `original_image_url` is the full-res that would need to be resized
// - item.image (https://s3.amazonaws.com/revue/items/images/019/005/542/web/anita-austvika-C-JUrfmYqcw-unsplash.jpg?1667924147)
// - item.original_image_url (https://s3.amazonaws.com/revue/items/images/019/005/542/original/anita-austvika-C-JUrfmYqcw-unsplash.jpg?1667924147)
let cardOpts = {
env: {dom: new SimpleDom.Document()},
payload: {
src: item.image,
caption: item.description
}
};
itemHTMLChunks.push(serializer.serialize(imageCard.render(cardOpts)));
} else if (type === 'link') {
// This could be a bookmark, or it could be a paragraph of text with a linked header, there's no way to tell
// The safest option here is to output an image with text under it
let cardOpts = {
env: {dom: new SimpleDom.Document()},
payload: {
src: item.image,
caption: item.title,
href: item.url
}
};
itemHTMLChunks.push(serializer.serialize(imageCard.render(cardOpts)));
let linkHTML = `<h4><a href="${item.url}">${item.title}</a></h4>${item.description}`;
itemHTMLChunks.push(linkHTML);
} else if (type === 'tweet') {
// Should this be an oEmbed call? Probably.
itemHTMLChunks.push(`<figure class="kg-card kg-embed-card">
<blockquote class="twitter-tweet"><a href="${item.url}"></a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</figure>`);
} else if (type === 'video') {
const isLongYouTube = /youtube.com/.test(item.url);
const isShortYouTube = /youtu.be/.test(item.url);
const isVimeo = /vimeo.com/.test(item.url);
let videoHTML = '';
if (isLongYouTube) {
let videoID = item.url.replace(/https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]*)/gi, '$1');
videoHTML = `<iframe width="200" height="113" src="https://www.youtube.com/embed/${videoID}?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
} else if (isShortYouTube) {
let videoID = item.url.replace(/https?:\/\/(?:www\.)?youtu\.be\/([a-zA-Z0-9_-]*)/gi, '$1');
videoHTML = `<iframe width="200" height="113" src="https://www.youtube.com/embed/${videoID}?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
} else if (isVimeo) {
let videoID = item.url.replace(/https?:\/\/(?:www\.)?vimeo\.com\/([0-9]+)/gi, '$1');
videoHTML = `<iframe src="https://player.vimeo.com/video/${videoID}" width="200" height="113" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
}
let cardOpts = {
env: {dom: new SimpleDom.Document()},
payload: {
html: videoHTML,
caption: item.description
}
};
itemHTMLChunks.push(serializer.serialize(embedCard.render(cardOpts)));
}
});
return itemHTMLChunks.join('\n');
};
const getPostDate = (data) => {
const isPublished = (data.sent_at) ? true : false; // This is how we determine is a post is published or not
const postDate = (isPublished) ? new Date(data.sent_at) : new Date();
return postDate.toISOString();
};
const getPostStatus = (data) => {
const isPublished = (data.sent_at) ? true : false; // This is how we determine is a post is published or not
return (isPublished) ? 'published' : 'draft';
};
module.exports = {
itemsToHtml,
getPostDate,
getPostStatus
};

View File

@ -0,0 +1,31 @@
{
"name": "@tryghost/importer-revue",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/importer-revue",
"author": "Ghost Foundation",
"private": true,
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter html --reporter cobertura mocha './test/**/*.test.js'",
"test": "yarn test:unit",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
},
"files": [
"index.js",
"lib"
],
"devDependencies": {
"sinon": "15.0.0"
},
"dependencies": {
"@tryghost/debug": "^0.1.20",
"@tryghost/kg-default-cards": "^5.18.7",
"@tryghost/string": "0.2.2",
"lodash": "^4.17.21",
"papaparse": "^5.3.2",
"simple-dom": "^1.4.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,227 @@
const assert = require('assert');
const sinon = require('sinon');
const RevueImporter = require('../index');
const JSONToHTML = require('../lib/json-to-html');
describe('Revue Importer', function () {
afterEach(function () {
sinon.restore();
});
describe('preProcess', function () {
it('marks any object as processed', function () {
let result;
result = RevueImporter.preProcess({});
assert.deepEqual(result.preProcessedByRevue, true, 'marks the object as processed');
result = RevueImporter.preProcess({revue: {}});
assert.deepEqual(result.preProcessedByRevue, true, 'marks the object as processed');
result = RevueImporter.preProcess({data: {}});
assert.deepEqual(result.preProcessedByRevue, true, 'marks the object as processed');
});
it('ignores empty revue object', function () {
const result = RevueImporter.preProcess({revue: {}});
assert.deepEqual(result.revue, {}, 'revue object left empty');
assert.deepEqual(result.data, undefined, 'no data object set');
});
it('ignores empty nested revue object', function () {
const result = RevueImporter.preProcess({revue: {revue: {}}});
assert.deepEqual(result.revue.revue, {}, 'revue object left empty');
assert.deepEqual(result.data, undefined, 'no data object set');
});
it('handles revue with issue and item data', function () {
const result = RevueImporter.preProcess({revue: {revue: {issues: 'id', items: '{}'}}});
assert.deepEqual(result.revue.revue, {issues: 'id', items: '{}'}, 'revue object left as-is');
assert.deepEqual(result.data, {meta: {version: '5.0.0'}, data: {posts: []}}, 'data object is set');
});
it('handles revue with issue, item and subscribers data', function () {
const result = RevueImporter.preProcess({revue: {revue: {issues: 'id', items: '{}', subscribers: 'email'}}});
assert.deepEqual(result.revue.revue, {issues: 'id', items: '{}', subscribers: 'email'}, 'revue object left as-is');
assert.deepEqual(result.data, {meta: {version: '5.0.0'}, data: {posts: [], revue_subscribers: []}}, 'data object is set');
});
});
describe('doImport', function () {
it('does nothing', function () {
const result = RevueImporter.doImport({x: {y: 'z'}});
assert.deepEqual(result, {x: {y: 'z'}}, 'is just a pass-through');
});
});
describe('importPosts', function () {
it('can process a published post without items', function () {
const result = RevueImporter.importPosts({items: '[]', issues: 'id,description,sent_at,subject,preheader\n123456,"<p>Hello World!</p>",2022-12-01 01:01:30 UTC,Hello World - Issue #8,'});
assert.deepEqual(result, [
{
comment_id: 123456,
title: 'Hello World - Issue #8',
slug: 'hello-world-issue-8',
status: 'published',
visibility: 'public',
created_at: '2022-12-01T01:01:30.000Z',
published_at: '2022-12-01T01:01:30.000Z',
updated_at: '2022-12-01T01:01:30.000Z',
html: '<p>Hello World!</p>',
tags: ['#revue']
}
]);
});
it('doesnt process a post with no subject', function () {
const result = RevueImporter.importPosts({items: '[{"title":"","issue_id":123456,"item_type":"text","url":"","description":"\u003cp\u003eGoodbye World!\u003c/p\u003e","order":0}]', issues: 'id,description,sent_at,subject,preheader\n123456,"<p>Hello World!</p>",2022-12-01 01:01:30 UTC,,'});
assert.deepEqual(result, []);
});
it('can process a published post with items', function () {
const result = RevueImporter.importPosts({items: '[{"title":"","issue_id":123456,"item_type":"text","url":"","description":"\u003cp\u003eGoodbye World!\u003c/p\u003e","order":0}]', issues: 'id,description,sent_at,subject,preheader\n123456,"<p>Hello World!</p>",2022-12-01 01:01:30 UTC,Hello World - Issue #8,'});
assert.deepEqual(result, [
{
comment_id: 123456,
created_at: '2022-12-01T01:01:30.000Z',
html: '<p>Hello World!</p><p>Goodbye World!</p>',
published_at: '2022-12-01T01:01:30.000Z',
status: 'published',
tags: [
'#revue'
],
title: 'Hello World - Issue #8',
slug: 'hello-world-issue-8',
updated_at: '2022-12-01T01:01:30.000Z',
visibility: 'public'
}
]);
});
it('can process a draft post with items', function () {
sinon.stub(JSONToHTML, 'getPostDate').returns('2022-12-01T01:02:03.123Z');
const result = RevueImporter.importPosts({items: '[{"title":"","issue_id":123456,"item_type":"text","url":"","description":"\u003cp\u003eGoodbye World!\u003c/p\u003e","order":0}]', issues: 'id,description,sent_at,subject,preheader\n123456,"<p>Hello World!</p>",,Hello World - Issue #8,'});
assert.deepEqual(result, [
{
comment_id: 123456,
title: 'Hello World - Issue #8',
slug: 'hello-world-issue-8',
status: 'draft',
visibility: 'public',
created_at: '2022-12-01T01:02:03.123Z',
published_at: '2022-12-01T01:02:03.123Z',
updated_at: '2022-12-01T01:02:03.123Z',
html: '<p>Hello World!</p><p>Goodbye World!</p>',
tags: ['#revue']
}
]);
});
});
describe('importSubscribers', function () {
it('can process a subscriber with only first name', function () {
const result = RevueImporter.importSubscribers({subscribers: 'email,first_name,last_name,created_at\njoe@bloggs.me,Joe,"",2022-12-01 01:02:03.123457'});
assert.deepEqual(result, [{email: 'joe@bloggs.me', name: 'Joe', created_at: '2022-12-01 01:02:03.123457'}]);
});
it('can process a subscriber with first and last name', function () {
const result = RevueImporter.importSubscribers({subscribers: 'email,first_name,last_name,created_at\njoe@bloggs.me,Joe,Bloggs,2022-12-01 01:02:03.123457'});
assert.deepEqual(result, [{email: 'joe@bloggs.me', name: 'Joe Bloggs', created_at: '2022-12-01 01:02:03.123457'}]);
});
it('can process multiple subscribers', function () {
const result = RevueImporter.importSubscribers({subscribers: 'email,first_name,last_name,created_at\njoe@bloggs.me,Joe,Bloggs,2022-12-01 01:02:03.123457\njo@bloggs.me,Jo,Bloggs,2022-12-01 01:02:04.123457'});
assert.deepEqual(result, [{email: 'joe@bloggs.me', name: 'Joe Bloggs', created_at: '2022-12-01 01:02:03.123457'},{email: 'jo@bloggs.me', name: 'Jo Bloggs', created_at: '2022-12-01 01:02:04.123457'}]);
});
});
describe('JSONToHTML helpers', function () {
describe('getPostData', function () {
it('can get date for published post', function () {
const result = JSONToHTML.getPostDate({sent_at: '2022-12-01 01:01:30 UTC'});
assert.deepEqual(result, '2022-12-01T01:01:30.000Z');
});
it('can get date for draft post', function () {
const result = JSONToHTML.getPostDate({});
assert.equal(result, new Date().toISOString());
});
});
describe('getPostStatus', function () {
it('can get date for published post', function () {
const result = JSONToHTML.getPostStatus({sent_at: '2022-12-01 01:01:30 UTC'});
assert.deepEqual(result, 'published');
});
it('can get date for draft post', function () {
const result = JSONToHTML.getPostStatus({});
assert.deepEqual(result, 'draft');
});
});
describe('itemsToHtml', function () {
it('can handle header item', function () {
const result = JSONToHTML.itemsToHtml([{title: 'Hello World',issue_id: 123456,item_type: 'header',url: '',description: '',order: 0}]);
assert.deepEqual(result, '<h3>Hello World</h3>');
});
it('can handle link item', function () {
const result = JSONToHTML.itemsToHtml([{title: 'Google',issue_id: 123456,item_type: 'link',url: 'https://google.com/',description: 'A search engine.',order: 0,image: 'https://s3.amazonaws.com/revue/items/images/012/345/678/web/google.png?1234556'}]);
assert.deepEqual(result, '<figure class="kg-card kg-image-card kg-card-hascaption"><a href="https://google.com/"><img src="https://s3.amazonaws.com/revue/items/images/012/345/678/web/google.png?1234556" class="kg-image" alt loading="lazy"></a><figcaption>Google</figcaption></figure>\n' +
'<h4><a href="https://google.com/">Google</a></h4>A search engine.');
});
it('can handle link item with html in description', function () {
const result = JSONToHTML.itemsToHtml([{title: 'Google',issue_id: 123456,item_type: 'link',url: 'https://google.com/',description: '<p>A <b>search</b> engine.</p>',order: 0,image: 'https://s3.amazonaws.com/revue/items/images/012/345/678/web/google.png?1234556'}]);
assert.deepEqual(result, '<figure class="kg-card kg-image-card kg-card-hascaption"><a href="https://google.com/"><img src="https://s3.amazonaws.com/revue/items/images/012/345/678/web/google.png?1234556" class="kg-image" alt loading="lazy"></a><figcaption>Google</figcaption></figure>\n' +
'<h4><a href="https://google.com/">Google</a></h4><p>A <b>search</b> engine.</p>');
});
it('can handle image item', function () {
const result = JSONToHTML.itemsToHtml([{title: '', issue_id: 123456, item_type: 'image', url: '', description: 'Hello', order: 0, image: 'https://s3.amazonaws.com/revue/items/images/012/345/678/web/google.png?1234556', original_image_url: 'https://s3.amazonaws.com/revue/items/images/012/345/678/original/google.png?1234556'}]);
assert.deepEqual(result, '<figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://s3.amazonaws.com/revue/items/images/012/345/678/web/google.png?1234556" class="kg-image" alt loading="lazy"><figcaption>Hello</figcaption></figure>');
});
it('can handle tweet item', function () {
const result = JSONToHTML.itemsToHtml([{title: 'Ghost',issue_id: 123456,item_type: 'tweet',url: 'https://twitter.com/Ghost/status/123456',description: 'Hello world',order: 0,tweet_profile_image: 'https://s3.amazonaws.com/revue/tweet_items/profile_images/000/123/456/thumb/ABCD_normal.png?12345',tweet_handle: 'Ghost',tweet_description: '\u003cspan\u003eHello world\u003c/span\u003e',tweet_lang: 'en'}]);
assert.deepEqual(result, '<figure class="kg-card kg-embed-card">\n' +
' <blockquote class="twitter-tweet"><a href="https://twitter.com/Ghost/status/123456"></a></blockquote>\n' +
' <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>\n' +
' </figure>');
});
it('can handle long youtube video item', function () {
const result = JSONToHTML.itemsToHtml([{title: '', issue_id: 123456, item_type: 'video', url: 'https://www.youtube.com/watch?v=ABCDEF', description: 'Hello World', order: 0, image: 'https://s3.amazonaws.com/revue/items/images/012/345/678/web/maxresdefault.jpg?1667924432'}]);
assert.deepEqual(result, '<figure class="kg-card kg-embed-card kg-card-hascaption"><iframe width="200" height="113" src="https://www.youtube.com/embed/ABCDEF?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe><figcaption>Hello World</figcaption></figure>');
});
it('can handle short youtube video item', function () {
const result = JSONToHTML.itemsToHtml([{title: '', issue_id: 123456, item_type: 'video', url: 'https://youtu.be/ABCDEF', description: 'Hello World', order: 2, image: 'https://s3.amazonaws.com/revue/items/images/006/606/464/web/maxresdefault.jpg?1601883862'}]);
assert.deepEqual(result, '<figure class="kg-card kg-embed-card kg-card-hascaption"><iframe width="200" height="113" src="https://www.youtube.com/embed/ABCDEF?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe><figcaption>Hello World</figcaption></figure>');
});
it('can handle vimeo video item', function () {
const result = JSONToHTML.itemsToHtml([{title: '', issue_id: 123456, item_type: 'video', url: 'https://vimeo.com/789123', description: 'Hello world', order: 2, image: 'https://s3.amazonaws.com/revue/items/images/006/606/464/web/maxresdefault.jpg?1601883862'}]);
assert.deepEqual(result, '<figure class="kg-card kg-embed-card kg-card-hascaption"><iframe src="https://player.vimeo.com/video/789123" width="200" height="113" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe><figcaption>Hello world</figcaption></figure>');
});
});
});
});

View File

@ -4463,7 +4463,7 @@
"@tryghost/root-utils" "^0.3.18"
debug "^4.3.1"
"@tryghost/elasticsearch@^3.0.7":
"@tryghost/elasticsearch@^3.0.2", "@tryghost/elasticsearch@^3.0.7":
version "3.0.7"
resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.7.tgz#eae9f00e89a0066673ca2470c5215de0ae976bea"
integrity sha512-Q9EW6mMqteFuItG3fyJHV7esqi1/3pBDGmsKGe08TaqfryHw4E/zptjn1zDMPK7EMt5/i7TDVFBwY3Co1dN/CA==
@ -4536,7 +4536,7 @@
resolved "https://registry.yarnpkg.com/@tryghost/http-cache-utils/-/http-cache-utils-0.1.5.tgz#bfb9d17c59e9f838073831c530eef9d65454716e"
integrity sha512-q829lqTxQkaPuup9ud7YjTQRQm1i/+bGhxElq0g10GMhEZqzqtDExb8fa/bAOL7CNwvUU7gdFMiT59/cyF9FmA==
"@tryghost/http-stream@^0.1.15":
"@tryghost/http-stream@^0.1.10", "@tryghost/http-stream@^0.1.15":
version "0.1.15"
resolved "https://registry.yarnpkg.com/@tryghost/http-stream/-/http-stream-0.1.15.tgz#48cf656feb7917a6f62a18555abc0e2411eb8c8e"
integrity sha512-O/SZP1HDstFqTYkpqfwEGWsDDvfLYOsNpGNz1Go7xtxo2qz94utzRFueqRDR9X/7pm2yP/1nQraHiVqTBb2YvA==
@ -4580,7 +4580,7 @@
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-atoms/-/kg-default-atoms-3.1.4.tgz#58916cbd350e865246f95143d14ba62a06f7a1a7"
integrity sha512-LBDW1uD70Wh27LiYzpctvIv6MExcgq7KkGy/RWUo0K/1EExtyEjLhNeeBrJ9taPouW2AQVwkqu69RHQ7NJW6eA==
"@tryghost/kg-default-cards@5.18.7":
"@tryghost/kg-default-cards@5.18.7", "@tryghost/kg-default-cards@^5.18.7":
version "5.18.7"
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-cards/-/kg-default-cards-5.18.7.tgz#2e1882cc543818cdbe9913f0b40891541369cdfa"
integrity sha512-Fezi9DnAkuxuBEYErJi2FC3xXoqsdKHTutEvHMVMAFO38Ns3FQtDaoxBhDxBsmQawboS+ZfpaaTOJquEDtmoQg==
@ -4660,7 +4660,24 @@
lodash "^4.17.21"
luxon "^1.26.0"
"@tryghost/logging@2.2.3", "@tryghost/logging@2.3.5", "@tryghost/logging@^2.2.3":
"@tryghost/logging@2.2.3":
version "2.2.3"
resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-2.2.3.tgz#40575a42e18b907a49cee5dfdfa62deb820954aa"
integrity sha512-ACCm84U4HITt3mQhDSpyDLZetxzjYo4ux2MoSVGL3zxPfQBPFoI6hWEiSzYWX/4RGq2l2tR4di+5LWjIe8Ow6A==
dependencies:
"@tryghost/bunyan-rotating-filestream" "^0.0.7"
"@tryghost/elasticsearch" "^3.0.2"
"@tryghost/http-stream" "^0.1.10"
"@tryghost/pretty-stream" "^0.1.11"
"@tryghost/root-utils" "^0.3.15"
bunyan "^1.8.15"
bunyan-loggly "^1.4.2"
fs-extra "^10.0.0"
gelf-stream "^1.1.1"
json-stringify-safe "^5.0.1"
lodash "^4.17.21"
"@tryghost/logging@2.3.5", "@tryghost/logging@^2.2.3":
version "2.3.5"
resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-2.3.5.tgz#76806c21190d43008750dfb3e88cbe3558145511"
integrity sha512-/rZ4CrBG1mi/WZXT86cXUadGOkTbdio4mo1csDft8SUfA/NYC7jm8TlfApeUn/O3CWiEO4vuTFeVlM8i4k4log==
@ -4765,7 +4782,7 @@
chalk "^4.1.0"
sywac "^1.3.0"
"@tryghost/pretty-stream@^0.1.14":
"@tryghost/pretty-stream@^0.1.11", "@tryghost/pretty-stream@^0.1.14":
version "0.1.14"
resolved "https://registry.yarnpkg.com/@tryghost/pretty-stream/-/pretty-stream-0.1.14.tgz#c7e88bf3324c89335a3c52e869c228a76a294eec"
integrity sha512-pzLKwCVZA+Mri1MqJWpWqQ+U0g+g7bTvDqCC5krqpHcgGcUz2Oy5cNLGsSH1XMeWS9afjbLKUBOqXMf6bbi16Q==
@ -19549,18 +19566,52 @@ mock-knex@TryGhost/mock-knex#8ecb8c227bf463c991c3d820d33f59efc3ab9682:
lodash "^4.14.2"
semver "^5.3.0"
moment-timezone@0.5.23, moment-timezone@0.5.34, moment-timezone@^0.5.23, moment-timezone@^0.5.31, moment-timezone@^0.5.33:
moment-timezone@0.5.23, moment-timezone@^0.5.23:
version "0.5.23"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.23.tgz#7cbb00db2c14c71b19303cb47b0fb0a6d8651463"
integrity sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==
dependencies:
moment ">= 2.9.0"
moment@2.24.0, moment@2.27.0, moment@2.29.1, moment@2.29.3, moment@2.29.4, "moment@>= 2.9.0", moment@^2.10.2, moment@^2.18.1, moment@^2.19.3, moment@^2.27.0, moment@^2.29.1:
moment-timezone@0.5.34:
version "0.5.34"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c"
integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg==
dependencies:
moment ">= 2.9.0"
moment-timezone@^0.5.31, moment-timezone@^0.5.33:
version "0.5.40"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.40.tgz#c148f5149fd91dd3e29bf481abc8830ecba16b89"
integrity sha512-tWfmNkRYmBkPJz5mr9GVDn9vRlVZOTe6yqY92rFxiOdWXbjaR0+9LwQnZGGuNR63X456NqmEkbskte8tWL5ePg==
dependencies:
moment ">= 2.9.0"
moment@2.24.0, "moment@>= 2.9.0", moment@^2.10.2, moment@^2.18.1, moment@^2.19.3:
version "2.24.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
moment@2.27.0:
version "2.27.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d"
integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==
moment@2.29.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
moment@2.29.3:
version "2.29.3"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.3.tgz#edd47411c322413999f7a5940d526de183c031f3"
integrity sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==
moment@2.29.4, moment@^2.27.0, moment@^2.29.1:
version "2.29.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
moo@^0.5.0, moo@^0.5.1:
version "0.5.2"
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"
@ -20648,7 +20699,7 @@ pako@~1.0.5:
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
papaparse@5.3.2:
papaparse@5.3.2, papaparse@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.3.2.tgz#d1abed498a0ee299f103130a6109720404fbd467"
integrity sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw==
@ -23997,6 +24048,18 @@ sinon@14.0.2:
nise "^5.1.2"
supports-color "^7.2.0"
sinon@15.0.0:
version "15.0.0"
resolved "https://registry.yarnpkg.com/sinon/-/sinon-15.0.0.tgz#651a641b45c0a57aabc8275343c7108cffc9c062"
integrity sha512-pV97G1GbslaSJoSdy2F2z8uh5F+uPGp3ddOzA4JsBOUBLEQRz2OAqlKGRFTSh2KiqUCmHkzyAeu7R4x1Hx0wwg==
dependencies:
"@sinonjs/commons" "^2.0.0"
"@sinonjs/fake-timers" "^9.1.2"
"@sinonjs/samsam" "^7.0.1"
diff "^5.0.0"
nise "^5.1.2"
supports-color "^7.2.0"
sinon@^9.0.0:
version "9.2.4"
resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.2.4.tgz#e55af4d3b174a4443a8762fa8421c2976683752b"