Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,12 +414,18 @@ gctools delete-empty-tags <apiURL> <adminAPIKey> --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 <apiURL> <adminAPIKey> --find 'Old text'

# Dry run with detailed per-post, per-field match report
gctools find-replace <apiURL> <adminAPIKey> --find 'Old text' --where all -V

# Replace a string but only in the `mobiledoc` and `title`
gctools find-replace <apiURL> <adminAPIKey> --find 'Old text' --replace 'New text' --where mobiledoc,title

Expand All @@ -433,6 +439,8 @@ gctools find-replace <apiURL> <adminAPIKey> --tag world-news --find 'Old text' -
gctools find-replace <apiURL> <adminAPIKey> --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`
Expand Down
15 changes: 12 additions & 3 deletions commands/find-replace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
}
Comment on lines +55 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalid --replace usage should fail the command, not silently return.

Right now Line 59 returns after logging, which can still produce a successful process exit code in automation.

Suggested fix
 if (argv.replace !== null && typeof argv.replace !== 'string') {
-    ui.log.error(`--replace requires an argument. Provide '' to replace text with an empty string.`);
-    return;
+    const message = `--replace requires an argument. Provide '' to replace text with an empty string.`;
+    ui.log.error(message);
+    throw new TypeError(message);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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;
}
// 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') {
const message = `--replace requires an argument. Provide '' to replace text with an empty string.`;
ui.log.error(message);
throw new TypeError(message);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@commands/find-replace.js` around lines 55 - 60, The validation branch that
checks argv.replace currently logs an error via ui.log.error and then returns,
which can leave the process exiting with success; in the argv.replace !== null
&& typeof argv.replace !== 'string' block (where argv.replace and ui.log.error
are used) change the control flow so the command fails with a non-zero exit —
for example after logging call process.exit(1) (or alternatively throw a
descriptive Error) so automation sees a failing exit code instead of a silent
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'];
}
Expand All @@ -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.`);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export default {
Expand Down
41 changes: 40 additions & 1 deletion tasks/find-replace.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -36,6 +37,8 @@ const initialise = (options) => {
};

const getFullTaskList = (options) => {
const dryRun = options.replace === null;

return [
initialise(options),
{
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

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 '<string>' to replace them.`;
}
},
{
title: 'Replacing text',
enabled: () => !dryRun,
task: async (ctx) => {
let tasks = [];

Expand All @@ -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);
Expand Down