Hi @Luke29383164kjl6, your situation intrigued me, as it seemed like a fairly common problem, and I wondered if I could make a script that would solve it, but also be a bit flexible to handle variations. Here's my attempt.
I've tried to put documentation into the script if you want to check it out, and I've pre-configured it to work the way you describe for your situation. Every time you run it, you choose a folder to search in. This way, if you receive another batch of updated versions later, just run it again and choose that new folder.
I think it will be useful. Let me know how it goes for you and please report bugs—there are bound to be some! Feel free to post bugs here or send me message directly.
- Mark
UPDATE: I've added a UI, with the option to save the settings in the document. This will make it easier to use I hope.

Here's a visualisation of the matching process, showing that both link name and file name must be matched:

Here's the script:
/**
* Re-link Indesign linked files by matching link names
* to files in a target folder.
*
* Example use case:
* We have to a folder of links, and some links have version
* number, eg. _V1. A third party updates the links and
* increments the version numbers, returning an updated
* folder of links. We want our indesign file to be linked
* to the updated files, but this won't happen automatically
* because they have different file names.
*
* We can achieve this with the following parameters:
* linkMatcher: /(^.*_V)\d/
* fileMatcher: '^$1\\d'
*
* This will match a linked file 'foo_V1.jpg' and, when
* pointed to the updated folder, replace with, for
* example, 'foo_V2.jpg'.
*
* Note, that you should be able to go back and forth,
* choosing either the old or new folder. Try it!
*
* See further details in the function documentation below.
*
* @file Relink Matching Links.js
* @author m1b
* @discussion https://community.adobe.com/t5/indesign-discussions/how-to-batch-relink-asset-to-same-file-name-but-with-different-version-number-different-ending/m-p/14358429
*/
function main() {
var settings = {
doc: app.activeDocument,
/*
* (A) linkMatcher - regex to match a LINK NAME
* In this example, we match any link whose name includes
* _V with a digit after it, and we capture the name and
* the _V for matching the file later in (B).
*/
linkMatcher: /^(.*_V)\d/i,
/*
* (B) fileMatcher - string to match a FILE NAME
* In this example, we match a filename starting with
* the contents of capture group 1 from (A) and a digit
* (backslash must be escaped in a string).
*/
fileMatcher: '^$1\\d',
/*
* whether to search for links in the current link's folder
* if this is false, will ask user to select folder
*/
lookInSameFolder: false,
/*
* whether to only update only matched links that are MISSING
* if this is false, will attempt to match *all* links
*/
onlyMissingLinks: false,
// whether to show results after running script
showResults: true,
// whether to show the UI
showUI: true,
};
if (settings.showUI) {
var result = ui(settings);
if (2 === result)
// user cancelled
return;
if (undefined != settings.cleanup) {
settings.cleanup();
delete settings.cleanup;
}
}
// do the relinking
relinkByMatchingLinkAndFile(settings);
};
app.doScript(main, ScriptLanguage.JAVASCRIPT, undefined, UndoModes.ENTIRE_SCRIPT, "Relink Matching Links");
/**
* User interface for this script.
* Will update the `settings` object and,
* optionally, save settings in document.
* @param {Object} settings - the script settings.
* @returns {1|2}
*/
function ui(settings) {
var key = 'relinkMatchingLinks',
savedValues = settings.doc.extractLabel(key).split('\n'),
// controls
w = new Window("dialog { text:'Relink Matching Links' }"),
columns = w.add("group {orientation:'row', margins:[0,0,0,0] }"),
leftColumn = columns.add("group {orientation:'column', margins:[0,0,0,0] }"),
matchLinkGroup = leftColumn.add("group {orientation:'row', margins:[0,0,0,0] }"),
matchLinkLabel = matchLinkGroup.add('statictext', undefined, 'Match Link Name Grep:'),
matchLinkField = matchLinkGroup.add('edittext {preferredSize: [260,30]}'),
matchFileGroup = leftColumn.add("group {orientation:'row', margins:[0,0,0,0] }"),
matchFileLabel = matchFileGroup.add('statictext', undefined, 'Match File Name Grep:'),
matchFileField = matchFileGroup.add('edittext {preferredSize: [260,30]}'),
rightColumn = columns.add("group {orientation:'column', margins:[0,0,0,0] }"),
sideControls = rightColumn.add("group {orientation:'column', alignChildren: ['left','top'] alignment:['fill','top'] }"),
ignoreCaseCheckBox = sideControls.add("CheckBox { text: 'Ignore case' }"),
lookInSameFolderCheckBox = sideControls.add("CheckBox { text: 'Look in same folder as link' }"),
onlyMissingLinksCheckBox = sideControls.add("CheckBox { text: 'Only missing links' }"),
bottomUI = w.add("group {orientation:'row', alignment:['fill','top'], margins: [0,20,0,0] }"),
extraControls = bottomUI.add("group {orientation:'column', alignChildren: ['left','top'] alignment:['fill','top'] }"),
saveInDocCheckBox = extraControls.add("CheckBox { text: 'Save these settings in document' }"),
buttons = bottomUI.add("group {orientation:'row', alignment:['right','top'], alignChildren:'right' }"),
cancelButton = buttons.add('button', undefined, 'Cancel', { name: 'cancel' }),
relinkButton = buttons.add('button', undefined, 'Re-link', { name: 'ok' });
// event handling
relinkButton.onClick = relinkButtonClicked;
if (savedValues.length > 1) {
saveInDocCheckBox.value = true;
matchLinkField.text = regexSource(savedValues[0]);
matchFileField.text = savedValues[1];
ignoreCaseCheckBox.value = 1 === Number(savedValues[2]);
lookInSameFolderCheckBox.value = 1 === Number(savedValues[3]);
onlyMissingLinksCheckBox.value = 1 === Number(savedValues[4]);
}
else {
saveInDocCheckBox.value = false;
matchLinkField.text = settings.linkMatcher.source;
matchFileField.text = settings.fileMatcher;
ignoreCaseCheckBox.value = settings.linkMatcher.ignoreCase;
lookInSameFolderCheckBox.value = settings.lookInSameFolder;
onlyMissingLinksCheckBox.value = 1 === Number(savedValues[4]);
}
w.center();
return w.show();
function relinkButtonClicked() {
try {
var matchLinkRegex = new RegExp(matchLinkField.text, ignoreCaseCheckBox.value ? 'i' : '');
} catch (error) {
return alert('Could not make RegExp "' + matchLinkField.text + '".');
}
// update settings
settings.linkMatcher = matchLinkRegex;
settings.fileMatcher = matchFileField.text;
settings.lookInSameFolder = lookInSameFolderCheckBox.value;
settings.onlyMissingLinks = onlyMissingLinksCheckBox.value;
if (true == saveInDocCheckBox.value) {
settings.cleanup = function () {
// save values in document
settings.doc.insertLabel(key, [
String(settings.linkMatcher),
matchFileField.text,
ignoreCaseCheckBox.value ? 1 : 0,
lookInSameFolderCheckBox.value ? 1 : 0,
onlyMissingLinksCheckBox.value ? 1 : 0,
].join('\n'));
};
}
// finished with dialog
w.close(1);
};
};
/**
* Returns source string from a stringified RegExp.
* eg, '/^Z.*$/gi' -> '^Z.*$'
* @param {String/RegExp} obj - the object to parse.
* @returns {?String}
*/
function regexSource(obj) {
var parts = String(obj).match(/^\/(.*)\/([gimsuy]*)$/) || {};
if (parts[1])
return parts[1];
}
/**
* Relinks a documents links where `linkMatcher` (A)
* matches a link's name, and where a file is found
* in the target folder matching `fileMatcher` (B).
*
* Explanation of parameters:
*
* (A) linkMatcher (RegExp)
* Create a regex that will match the link names you
* are interested in. Include one or more capture groups
* if you wish to reference them in (B).
*
* (B) fileMatcher (String for regex source)
* Create a string that will, as a RegExp, match a file
* in the target folder. If the string includes any capture
* group references (eg. '$1') they will be replaced with
* the string matched using linkMatcher (A).
*
* @author m1b
* @version 2024-01-18
* @param {Object} options
* @param {Document} options.doc - an Indesign Document.
* @param {RegExp} options.linkMatcher - RegExp to match link name.
* @param {String} options.fileMatcher - RegExp source string to match file name.
* @param {Boolean} [options.lookInSameFolder] - whether to search in the current link's folder (default: false, will ask user for folder).
* @param {Boolean} [options.onlyMissingLinks] - whether to update only missing links (default: false, update all links).
* @param {Boolean} [options.showResults] - whether to display a count of the changes (default: true).
*/
function relinkByMatchingLinkAndFile(options) {
// options
options = options || {};
var doc = options.doc || app.activeDocument,
linkMatcher = options.linkMatcher,
fileMatcher = options.fileMatcher,
lookInSameFolder = true === options.lookInSameFolder,
onlyMissingLinks = true === options.onlyMissingLinks,
showResults = false !== options.showResults;
var links = doc.links,
matchedLinkCounter = 0,
matchedFileCounter = 0,
folder,
searchFileOptions,
file,
matchLink,
matchFile;
linksLoop:
for (var i = 0; i < links.length; i++) {
if (
LinkStatus.LINK_MISSING !== links[i].status
&& onlyMissingLinks
)
continue;
// choose a folder
if (
lookInSameFolder
&& folder !== File(links[i].filePath).parent
) {
// folder to search in
folder = File(links[i].filePath).parent;
if (!folder.exists)
folder = undefined;
}
matchLink = decodeURI(links[i].name).match(linkMatcher);
if (
null == matchLink
|| matchLink.length < 2
)
// this link doesn't match with the regex
continue linksLoop;
matchedLinkCounter++;
// this is to match any file according to `matchFileName` with capture groups expanded
matchFile = new RegExp(fileMatcher.replace(/\$(\d)+/g, expandFileMatcher));
// settings for getFilesOfFolder
searchFileOptions = {
folder: folder,
filterRegex: matchFile,
maxDepth: 1,
returnFirstMatch: true,
};
file = getFilesOfFolder(searchFileOptions);
if (undefined == folder)
folder = searchFileOptions.folder;
if (undefined == folder)
return;
if (
undefined == file
|| File(links[i].filePath).absoluteURI === file.absoluteURI
)
continue linksLoop;
links[i].relink(file);
matchedFileCounter++
}
if (showResults)
return alert('Relinked ' + matchedFileCounter + ' links.\n (' + matchedLinkCounter + ' matched links of ' + links.length + ' total)');
/**
* Return matched index of `matchLink`, for use
* with String.replace() method.
* @params arguments provided by String.replace.
* @returns {?String}
*/
function expandFileMatcher() {
if (arguments.length > 1)
return matchLink[Number(arguments[1])];
};
};
/**
* Collects all files inside a Folder, recusively searching in sub-folders.
* If no folder is supplied, will ask user. Search can be filtered using
* `filterRegex`, `fileFilter` or `folderFilter`, and also limited to `maxDepth`.
* @author m1b
* @version 2023-10-02
* @param {Object} options - parameters
* @param {Folder} [options.folder] - the folder to look in (default: ask).
* @param {RegExp} [options.filterRegex] - regex to match file/folder's name (default: no filter).
* @param {Function} [options.fileFilter] - function, given a File, must return true (default: no filter).
* @param {Function} [options.folderFilter] - function, given a Folder, must return true (default: no filter).
* @param {Boolean} [options.returnFirstMatch] - whether to return only the first found file (default: false).
* @param {Number} [options.maxDepth] - deepest folder level (recursion depth limit) (default: 99).
* @param {Boolean} [options.includeFiles] - whether to include files (default: true).
* @param {Boolean} [options.includeFolders] - whether to include folders (default: false).
* @param {Boolean} [options.includeHiddenItems] - whether to include hidden items (default: false).
* @param {Number} [options.depth] - the current depth private param.
* @returns {Array|File} - all the found files, or the first found file if `returnFirstMatch`.
*/
function getFilesOfFolder(options) {
// defaults
options = options || {};
var found = [],
folder = options.folder;
if ((options.depth || 0) === 0)
// once-off initialization
// to keep the object clean
if (!(options = initializeOptions()))
return [];
// get files from this level
var files = folder.getFiles(),
hiddenFile = /^[\.\~]/;
filesLoop:
for (var i = 0; i < files.length; i++) {
if (excludeFilter(files[i]))
// file/folder excluded!
continue filesLoop;
if (includeFilter(files[i]))
// file/folder matched!
found.push(files[i]);
if (
!(files[i] instanceof Folder)
|| options.depth >= options.maxDepth
)
// we only want folders
continue filesLoop;
// set up for the next depth
options.folder = files[i];
options.depth++;
// look inside the folder
found = found.concat(getFilesOfFolder(options));
}
// this level done
if (true == options.returnFirstMatch)
return found[0];
else
return found;
/**
* Returns true when the file/folder should be excluded.
* @param {File|Folder} file
* @returns {Boolean}
*/
function excludeFilter(file) {
return (
// is hidden
(
false == options.includeHiddenItems
&& hiddenFile.test(file.name)
)
// an ignored folder
|| (
file instanceof Folder
&& !(
undefined == options.folderFilter
|| options.folderFilter(file)
)
)
);
};
/**
* Returns true when the file/folder should be included.
* @param {File|Folder} file
* @returns {Boolean}
*/
function includeFilter(file) {
return (
(
// file passes filter
(
options.includeFiles
&& file instanceof File
)
// folder passes filter
|| (
options.includeFolders
&& file instanceof Folder
)
)
// match regex
&& (
undefined == options.filterRegex
|| options.filterRegex.test(decodeURI(file.name))
)
// pass filter
&& (
undefined == options.fileFilter
|| options.fileFilter(file)
)
);
};
/**
* Returns the initialised `options` object.
* @returns {Object}
*/
function initializeOptions() {
// make a new object, so we don't pollute the original
var newOptions = {
depth: 0,
filterRegex: options.filterRegex,
fileFilter: options.fileFilter,
folderFilter: options.folderFilter,
returnFirstMatch: options.returnFirstMatch,
maxDepth: options.maxDepth,
includeFiles: options.includeFiles !== false,
includeFolders: true == options.includeFolders,
includeHiddenItems: true == options.includeHiddenItems,
};
if (
undefined == options.maxDepth
|| !options.maxDepth instanceof Number
)
newOptions.maxDepth = 99;
if (
undefined != folder
&& 'String' == folder.constructor.name
&& File(folder).exists
)
folder = File(folder);
else if (
undefined != folder
&& 'String' == folder.constructor.name
)
folder = undefined;
if (undefined == folder)
// ask user for folder
folder = Folder.selectDialog("Select a folder:");
if (
null === folder
|| !(folder instanceof Folder)
)
// folder bad or user cancelled
return;
// update folder in the *original* object
options.folder = folder;
return newOptions;
};
};
Edit 2024-01-17: fixed bug triggered when cancelling folder select dialog.
Edit 2024-01-18: added better result alert to show how many links were matched as well as how many links were re-linked. Also adjusted example configuration because some of lukes files use a lowercase _v.
Edit 2024-01-18: added optional UI, tweaked some of the variable names to make more sense.