Crawls a section of your site to find images matching a specific source URL. Move those images into DA and either rewrite the image URLs or create draft copies of pages with updated references.
+
+
+
+
+
Pages with matching images -
+
Time - 0.00s
+
+
+
No matching images found.
+
+
+
+
Images to be imported - 0
+
Time - 0.00s
+ Toggle origin view
+
+
+
No matching images found.
+
+
+
+
+
Remaining
+
0
+
+
+
Errors
+
0
+
+
+
Success
+
0
+
+
+
Total
+
0
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/image-migration/image-migration.js b/tools/image-migration/image-migration.js
new file mode 100644
index 00000000..c204032b
--- /dev/null
+++ b/tools/image-migration/image-migration.js
@@ -0,0 +1,800 @@
+/* eslint-disable import/no-unresolved */
+import DA_SDK from 'https://da.live/nx/utils/sdk.js';
+import { crawl } from 'https://da.live/nx/public/utils/tree.js';
+
+/* utilities */
+
+/**
+ * Formats elapsed time since a start timestamp as a seconds string.
+ * @param {number} startTime - Timestamp from Date.now() at the start of the operation
+ * @returns {string} Elapsed seconds with two decimal places, e.g. "1.23s"
+ */
+function elapsed(startTime) {
+ return `${((Date.now() - startTime) / 1000).toFixed(2)}s`;
+}
+
+/**
+ * Returns the path without the .html extension, or null if the path is not an HTML file.
+ * @param {string} path - File path to check
+ * @returns {string|null} Path without .html, or null
+ */
+function stripHtml(path) {
+ return path.endsWith('.html') ? path.slice(0, -5) : null;
+}
+
+/**
+ * Sanitizes a single name segment for use in a URL path.
+ * @param {string} name - Raw name to sanitize
+ * @param {boolean} preserveDots - Whether to preserve dots (e.g. for file extensions)
+ * @param {boolean} allowUnderscores - Whether to allow underscores in the output
+ * @returns {string|null} Sanitized segment, or `null` if the input is falsy
+ */
+function sanitizeName(name, preserveDots = true, allowUnderscores = false) {
+ if (!name) return null;
+
+ if (preserveDots && name.indexOf('.') !== -1) {
+ return name
+ .split('.')
+ .map((part) => sanitizeName(part, true, allowUnderscores))
+ .join('.');
+ }
+
+ const pattern = allowUnderscores ? /[^a-z0-9_]+/g : /[^a-z0-9]+/g;
+
+ return name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(pattern, '-')
+ .replace(/-$/g, '');
+}
+
+/**
+ * Sanitizes every segment of a URL path, removing path traversal parts.
+ * @param {string} path - Raw path to sanitize (must start with '/')
+ * @returns {string} Sanitized path, without the leading slash
+ */
+function sanitizePathParts(path) {
+ const parts = path.slice(1).toLowerCase().split('/');
+ return parts
+ .map((name, i) => (name ? sanitizeName(name, true, i < parts.length - 1) : ''))
+ // Remove path traversal segments (./ and ../); keep trailing empty string.
+ .filter((name, i, arr) => !/^[.]{1,2}$/.test(name) && (name !== '' || i === arr.length - 1))
+ .join('/');
+}
+
+/**
+ * Computes the DA path for an image by sanitizing its pathname and replacing the path prefix.
+ * @param {string} src - Full image URL
+ * @param {string} pathPrefix - Pathname segment to replace, e.g. '/content/dam/vitamix'
+ * @param {string} destPath - DA path that replaces pathPrefix, e.g. '/media'
+ * @returns {string} The computed DA path, e.g. '/media/hero.jpg'
+ */
+function buildDaImagePath(src, pathPrefix, destPath) {
+ const { pathname } = new URL(src);
+ return `/${sanitizePathParts(pathname)}`.replace(pathPrefix, destPath);
+}
+
+/* dom utilities */
+
+/**
+ * Creates an anchor element that opens in a new tab.
+ * @param {string} href - URL for the link
+ * @param {string} text - Text content for the link
+ * @returns {HTMLAnchorElement} Anchor element with target="_blank" and rel="noopener noreferrer"
+ */
+function createLink(href, text) {
+ const a = document.createElement('a');
+ a.href = href;
+ a.textContent = text;
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ return a;
+}
+
+/**
+ * Sets the disabled state on all form action buttons.
+ * @param {boolean} disabled - Whether to disable the buttons
+ */
+function setButtonsDisabled(disabled) {
+ document.querySelectorAll('.button-wrapper sl-button').forEach((btn) => {
+ btn.disabled = disabled;
+ });
+}
+
+/**
+ * Shows one result panel and hides the others.
+ * @param {string} active - Class name of the panel to show: 'scan', 'preview', or 'run'
+ */
+function showResults(active) {
+ document.querySelectorAll('.result').forEach((el) => {
+ el.setAttribute('aria-hidden', !el.classList.contains(active));
+ });
+}
+
+/* form state */
+
+/**
+ * Reads the current form values and returns the tool configuration.
+ * @returns {{ crawlPath: string, srcFilter: string, pathPrefix: string,
+ * destPath: string, draftMode: boolean, draftFolder: string }}
+ */
+function getConfig() {
+ return {
+ crawlPath: document.getElementById('crawl-path').value,
+ srcFilter: document.getElementById('url-filter').value,
+ pathPrefix: document.getElementById('path-prefix').value,
+ destPath: document.getElementById('dest-path').value,
+ draftMode: document.querySelector('input[name="output-mode"]:checked').value === 'draft',
+ draftFolder: document.getElementById('draft-folder').value,
+ };
+}
+
+/**
+ * Loads entry history for a form field from localStorage.
+ * @param {string} fieldId - The form field's element ID
+ * @returns {string[]} Previously entered values, newest first
+ */
+function loadHistory(fieldId) {
+ try {
+ return JSON.parse(localStorage.getItem(`image-migration:${fieldId}`)) || [];
+ } catch (e) {
+ return [];
+ }
+}
+
+/**
+ * Prepends a value to the stored history for a form field, deduplicating and capping at 3.
+ * @param {string} fieldId - The form field's element ID
+ * @param {string} value - The value to record
+ */
+function saveToHistory(fieldId, value) {
+ if (!value) return;
+ const history = loadHistory(fieldId).filter((v) => v !== value);
+ history.unshift(value);
+ localStorage.setItem(`image-migration:${fieldId}`, JSON.stringify(history.slice(0, 3)));
+}
+
+/**
+ * Persists the current form state to localStorage.
+ */
+function saveFormState() {
+ ['crawl-path', 'url-filter', 'path-prefix', 'dest-path', 'draft-folder'].forEach((id) => {
+ saveToHistory(id, document.getElementById(id).value);
+ });
+ const checkedMode = document.querySelector('input[name="output-mode"]:checked');
+ if (checkedMode) {
+ localStorage.setItem('image-migration:output-mode', checkedMode.value);
+ }
+}
+
+/* da */
+
+/**
+ * Resolves the DA SDK token and derives repo context for API calls.
+ * @returns {Promise<{ org: string, repo: string, repoPath: string, authOpts: object }>}
+ */
+async function getAuth() {
+ const { context, token } = await DA_SDK;
+ const { org, repo } = context;
+ return {
+ org,
+ repo,
+ repoPath: `/${org}/${repo}`,
+ authOpts: { headers: { Authorization: `Bearer ${token}` } },
+ };
+}
+
+/**
+ * Fetches a DA document and returns the src of any images matching srcFilter.
+ * @param {object} item - Crawl result item; must have a `path` string property
+ * @param {string} srcFilter - Substring to match against img src
+ * @param {object} authOpts - Fetch options containing the Authorization header
+ * @returns {Promise} Matching image src values, or null if none found or fetch fails
+ */
+async function scanDocument(item, srcFilter, authOpts) {
+ if (!stripHtml(item.path)) return null;
+
+ const docResp = await fetch(`https://admin.da.live/source${item.path}`, authOpts);
+ if (!docResp.ok) return null;
+
+ const dom = new DOMParser().parseFromString(await docResp.text(), 'text/html');
+ try {
+ const images = [...dom.querySelectorAll(`img[src*="${srcFilter}"]`)].map((img) => img.src);
+ return images.length ? images : null;
+ } catch (e) {
+ return null;
+ }
+}
+
+/**
+ * Fetches an image from its current URL, uploads it to DA, and updates img.src.
+ * @param {HTMLImageElement} img - The img element to migrate
+ * @param {string} repoPath - DA repo path, e.g. '/org/repo'
+ * @param {string} pathPrefix - Pathname segment to replace
+ * @param {string} destPath - DA path that replaces pathPrefix
+ * @param {object} authOpts - Fetch options containing the Authorization header
+ */
+async function migrateImage(img, repoPath, pathPrefix, destPath, authOpts) {
+ const imageResp = await fetch(img.src);
+ if (!imageResp.ok) throw new Error('Could not fetch image');
+
+ const daPath = buildDaImagePath(img.src, pathPrefix, destPath);
+
+ const imageBlob = await imageResp.blob();
+ const imageForm = new FormData();
+ imageForm.append('data', imageBlob);
+
+ const uploadResp = await fetch(`https://admin.da.live/source${repoPath}${daPath}`, {
+ ...authOpts,
+ method: 'POST',
+ body: imageForm,
+ });
+ if (!uploadResp.ok) throw new Error('Could not upload image');
+
+ img.src = `https://content.da.live${repoPath}${daPath}`;
+}
+
+/**
+ * Fetches a DA document, migrates matching images, and saves the result.
+ * In draft mode, saves to the draft folder; in overwrite mode, versions the original first.
+ * @param {object} item - Crawl result item; must have a `path` string property
+ * @param {string} repoPath - DA repo path, e.g. '/org/repo'
+ * @param {string} srcFilter - Substring to match against img src
+ * @param {string} pathPrefix - Pathname segment to replace when building the DA path
+ * @param {string} destPath - DA path that replaces pathPrefix, e.g. '/media'
+ * @param {boolean} draftMode - When true, saves to draftFolder instead of the original path
+ * @param {string} draftFolder - Draft folder path segment, e.g. '/drafts/fkakatie'
+ * @param {object} authOpts - Fetch options containing the Authorization header
+ * @returns {Promise