diff --git a/README.md b/README.md index cb436987..e6a3e4fb 100644 --- a/README.md +++ b/README.md @@ -414,12 +414,18 @@ gctools delete-empty-tags --maxPostCount 5 --delayBetween ### find-replace -Find & replace strings of text within Ghost posts +Find & replace strings of text within Ghost posts. If `--replace` is omitted, the command runs in dry-run mode and reports the number of matches without making any changes. ```sh # See all available options gctools find-replace --help +# Dry run: find matches without replacing anything +gctools find-replace --find 'Old text' + +# Dry run with detailed per-post, per-field match report +gctools find-replace --find 'Old text' --where all -V + # Replace a string but only in the `mobiledoc` and `title` gctools find-replace --find 'Old text' --replace 'New text' --where mobiledoc,title @@ -433,6 +439,8 @@ gctools find-replace --tag world-news --find 'Old text' - gctools find-replace --find 'Old text' --replace 'New text' --delayBetweenCalls 100 ``` +Use `-V` (`--verbose`) for detailed output showing which fields matched or were replaced in each post. + Available `where` fields are: * `all` diff --git a/commands/find-replace.js b/commands/find-replace.js index d4012d7b..ba94c3da 100644 --- a/commands/find-replace.js +++ b/commands/find-replace.js @@ -30,7 +30,7 @@ const setup = (sywac) => { }); sywac.string('--replace', { defaultValue: null, - desc: 'Replace with' + desc: 'Replace with (omit to do a dry run)' }); sywac.array('--where', { defaultValue: 'mobiledoc', @@ -52,6 +52,13 @@ const run = async (argv) => { let timer = Date.now(); let context = {errors: []}; + // Validate --replace: if the flag is present but has no argument, + // sywac may coerce it to a non-string value (e.g. boolean true). + if (argv.replace !== null && typeof argv.replace !== 'string') { + ui.log.error(`--replace requires an argument. Provide '' to replace text with an empty string.`); + return; + } + if (argv.where.includes('all')) { argv.where = ['mobiledoc', 'html', 'lexical', 'title', 'slug', 'custom_excerpt', 'meta_title', 'meta_description', 'twitter_title', 'twitter_description', 'og_title', 'og_description', 'feature_image', 'codeinjection_head', 'codeinjection_foot']; } @@ -66,8 +73,10 @@ const run = async (argv) => { ui.log.error('Done with errors', context.errors); } - // Report success - ui.log.ok(`Successfully updated ${context.updated.length} strings in ${Date.now() - timer}ms.`); + if (argv.replace !== null) { + // Report success for replace mode + ui.log.ok(`Successfully updated ${context.updated.length} strings in ${Date.now() - timer}ms.`); + } }; export default { diff --git a/tasks/find-replace.js b/tasks/find-replace.js index 45cdaa03..bdde0506 100644 --- a/tasks/find-replace.js +++ b/tasks/find-replace.js @@ -1,6 +1,7 @@ import Promise from 'bluebird'; import GhostAdminAPI from '@tryghost/admin-api'; import {makeTaskRunner} from '@tryghost/listr-smart-renderer'; +import {ui} from '@tryghost/pretty-cli'; import _ from 'lodash'; import {transformToCommaString} from '../lib/utils.js'; import {discover} from '../lib/batch-ghost-discover.js'; @@ -36,6 +37,8 @@ const initialise = (options) => { }; const getFullTaskList = (options) => { + const dryRun = options.replace === null; + return [ initialise(options), { @@ -66,25 +69,60 @@ const getFullTaskList = (options) => { task: async (ctx) => { await Promise.mapSeries(ctx.posts, async (post) => { let matches = []; + let matchesByField = {}; ctx.args.where.forEach((key) => { if (post[key]) { let match = post[key].match(ctx.regex); if (match) { matches.push(...match); + matchesByField[key] = match.length; } } }); if (matches.length > 0) { post.matches = matches; + post.matchesByField = matchesByField; ctx.toUpdate.push(post); } }); } }, + { + title: 'Reporting matches', + enabled: () => dryRun, + task: async (ctx, task) => { + if (ctx.toUpdate.length === 0) { + task.title = 'No matches found'; + return; + } + + let totalMatches = 0; + + for (const post of ctx.toUpdate) { + for (const [, count] of Object.entries(post.matchesByField)) { + totalMatches += count; + } + } + + if (ctx.args.verbose) { + ui.log.info(''); + for (const post of ctx.toUpdate) { + for (const [field, count] of Object.entries(post.matchesByField)) { + ui.log.info(` ${count}:${field}:${post.title}`); + } + } + ui.log.info(''); + } + + task.title = `Found ${totalMatches} matches across ${ctx.toUpdate.length} posts`; + task.output = `Add --replace '' to replace them.`; + } + }, { title: 'Replacing text', + enabled: () => !dryRun, task: async (ctx) => { let tasks = []; @@ -98,8 +136,9 @@ const getFullTaskList = (options) => { } }); - // Delete the matches object or else the request gets denied + // Delete the matches objects or else the request gets denied delete post.matches; + delete post.matchesByField; try { let result = await ctx.api.posts.edit(post);