Robert Birming

Bear Blog media tools

A dashboard plugin that adds two handy tools to the Bear media page: tag several files at once, and copy selected images as a ready-to-paste gallery.

Both buttons sit next to Bear's own "Delete selected", and wake up as soon as you check a file. Bulk tagging uses Bear's own tag forms, so filters and tag chips stay in sync. Copied galleries use the Photo gallery format, so they're one paste away from a neat photo grid.1


Preview

Bear Blog media page with the Tag selected and Copy as gallery buttons

How to use

Installation

  1. Copy the script below.
  2. Go to Customise dashboard.
  3. Paste it under "Dashboard footer content".
  4. Save. Enjoy.

Tagging several files

  1. On the media page, check the files you want to tag.
  2. Click Tag selected. The field shows the tags all selected files already share.
  3. Add tags, separated by commas, or remove shared ones you no longer want.
  4. Click Save, or press Enter.

Each file keeps its other tags. Only the tags you add or remove change. Press Escape or click Cancel to close without saving.

  1. Check the images you want in your gallery. Filtering by a tag first makes this quick.
  2. Click Copy as gallery.
  3. Paste into a post or page.

You get one linked image per line, ready for the Photo gallery styles:

[![Example](example-1.webp#gallery)](example-1.webp)
[![Example](example-2.webp#gallery)](example-2.webp)
[![Example](example-3.webp#gallery)](example-3.webp)

The alt text comes from each image's tags. Without tags, a descriptive file name is used instead, like "Harbour at dawn". Camera names like img_6090 get an empty alt, so they're easy to spot and fill in by hand.

Plugin

<script>
/*
 Plugin name: Media tools
 Description: Tag several media files at once, and copy selected images as a ready-to-paste gallery.
 Author: Robert Birming
 Author URI: https://robertbirming.com
*/

(function () {
    'use strict';

    const $controls = document.querySelector('.media-controls');
    const $list     = document.querySelector('.media-list');

    if (!$controls || !$list) return;

    const $delete = document.getElementById('deleteSelected');
    const $before = $controls.querySelector('.media-raw');
    const tools = [];

    /* Shared
       ========================================================================== */

    // Buttons are enabled and disabled together with Bear's delete button, unless busy
    function sync() {
        const off = !$delete || $delete.disabled;
        tools.forEach(tool => {
            if (!tool.locked) tool.button.disabled = off;
        });
    }

    function addTool(id, label) {
        const button = document.createElement('button');
        button.type = 'button';
        button.id = id;
        button.textContent = label;
        $controls.insertBefore(button, $before);

        const tool = { button, label, locked: false, timer: 0 };
        tools.push(tool);
        return tool;
    }

    function lock(tool, state) {
        tool.locked = state;
        tool.button.disabled = state;
        if (!state) sync();
    }

    // Short message on the button, then back to its label
    function flash(tool, message) {
        clearTimeout(tool.timer);
        tool.button.textContent = message;
        tool.timer = setTimeout(() => tool.button.textContent = tool.label, 2000);
    }

    function plural(count, word) {
        return `${count} ${word}${count === 1 ? '' : 's'}`;
    }

    // Checked rows among the files the filters show, optionally of one type
    function selectedRows(type) {
        const selector = type ? `.media-row[data-type="${type}"]` : '.media-row';
        return [...$list.querySelectorAll(selector)].filter(row =>
            !row.hidden && row.querySelector('input[name="selected_media"]').checked
        );
    }

    // Trimmed tags, duplicates removed case-insensitively
    function parseTags(value) {
        const seen = new Set();
        return value.split(',')
            .map(tag => tag.trim())
            .filter(tag => {
                const key = tag.toLowerCase();
                if (!tag || seen.has(key)) return false;
                seen.add(key);
                return true;
            });
    }

    function tagsOf(row) {
        const field = row.querySelector('.media-tag-form')?.elements.tags;
        return field ? parseTags(field.defaultValue) : [];
    }

    // Each tool runs on its own, so one failing never stops the other
    function run(name, setup) {
        try {
            setup();
        } catch (error) {
            console.error(`Media tools: ${name} failed`, error);
        }
    }

    /* Bulk tags
       ========================================================================== */

    run('Bulk tags', () => {
        const tool = addTool('bulkTags', 'Tag selected');
        const $button = tool.button;
        $button.setAttribute('aria-expanded', 'false');
        $button.setAttribute('aria-controls', 'bulkTagsPanel');

        // Panel, reusing the look of the built-in tag form
        const $panel = document.createElement('div');
        $panel.id = 'bulkTagsPanel';
        $panel.className = 'media-tag-form';
        $panel.hidden = true;
        $panel.innerHTML = `
            <label for="bulkTagsInput">Tags for selected files</label>
            <input id="bulkTagsInput" type="text" autocomplete="off" aria-describedby="bulkTagsHelp">
            <small id="bulkTagsHelp">Separate tags with commas. Shows tags all selected files share. Other tags are kept.</small>
            <div>
                <button type="button" data-mode="save">Save</button>
                <button type="button" data-mode="cancel">Cancel</button>
                <span class="tag-status" role="status"></span>
            </div>
        `;
        $controls.after($panel);

        const $input   = $panel.querySelector('#bulkTagsInput');
        const $status  = $panel.querySelector('.tag-status');
        const $actions = [...$panel.querySelectorAll('button')];

        let shared = [];

        // Tags every selected file has, compared case-insensitively
        function sharedTags(rows) {
            if (!rows.length) return [];
            const others = rows.slice(1).map(row => tagsOf(row).map(tag => tag.toLowerCase()));
            return tagsOf(rows[0]).filter(tag => others.every(list => list.includes(tag.toLowerCase())));
        }

        function setOpen(open) {
            $panel.hidden = !open;
            $button.setAttribute('aria-expanded', String(open));
            if (!open) return;
            shared = sharedTags(selectedRows());
            $input.value = shared.join(', ');
            $status.textContent = '';
            $input.focus();
        }

        function close() {
            setOpen(false);
            $button.focus();
        }

        function setBusy(state) {
            $input.disabled = state;
            $actions.forEach(btn => btn.disabled = state);
            lock(tool, state);
        }

        // Bear's own tag form does the saving, so filters and chips stay in sync.
        // Its save button is disabled while the request runs.
        function waitForSave(form) {
            const $save = form.querySelector('button[type="submit"]');
            const started = Date.now();
            return new Promise(resolve => {
                (function check() {
                    if (!$save.disabled || Date.now() - started > 15000) {
                        resolve(form.querySelector('.tag-status').textContent === '');
                        return;
                    }
                    setTimeout(check, 100);
                })();
            });
        }

        async function apply() {
            const tags = parseTags($input.value);
            const lower = tags.map(tag => tag.toLowerCase());

            // Shared tags taken out of the field are removed from every file
            const removed = shared
                .map(tag => tag.toLowerCase())
                .filter(tag => !lower.includes(tag));

            // Collect first, Bear clears the checkboxes after each save
            const rows = selectedRows();
            if (!rows.length) {
                $status.textContent = 'No files selected.';
                return;
            }

            let saved = 0, skipped = 0, failed = 0;

            setBusy(true);

            for (const [i, row] of rows.entries()) {
                $status.textContent = `Saving ${i + 1} of ${rows.length}…`;

                const form = row.querySelector('.media-tag-form');
                const current = tagsOf(row);
                const kept = current.filter(tag => !removed.includes(tag.toLowerCase()));
                const value = parseTags(kept.concat(tags).join(',')).join(', ');

                if (value === current.join(', ')) {
                    skipped++;
                    continue;
                }

                if (value.length > 500) {
                    failed++;
                    continue;
                }

                form.elements.tags.value = value;
                form.requestSubmit();
                (await waitForSave(form)) ? saved++ : failed++;
            }

            setBusy(false);

            const parts = [`${plural(saved, 'file')} updated`];
            if (skipped) parts.push(`${skipped} unchanged`);
            if (failed) parts.push(`${failed} failed`);

            if (failed) {
                $status.textContent = parts.join(', ') + '.';
                return;
            }

            $input.value = '';
            close();
            flash(tool, parts.join(', '));
        }

        $button.addEventListener('click', () => setOpen($panel.hidden));

        $panel.addEventListener('click', event => {
            const mode = event.target.closest('button')?.dataset.mode;
            if (mode === 'save') apply();
            if (mode === 'cancel') close();
        });

        $input.addEventListener('keydown', event => {
            if (event.key === 'Enter') {
                event.preventDefault();
                apply();
            }
            if (event.key === 'Escape') close();
        });
    });

    /* Copy as gallery
       ========================================================================== */

    run('Copy as gallery', () => {
        const tool = addTool('copyGallery', 'Copy as gallery');

        function sentenceCase(text) {
            return text.charAt(0).toUpperCase() + text.slice(1);
        }

        // The file's tags, if it has any
        function altFromTags(row) {
            return sentenceCase(tagsOf(row)
                .map(tag => tag.replace(/[\[\]]/g, '').trim())
                .filter(Boolean)
                .join(', '));
        }

        // Camera-style names get an empty alt so they stand out for writing by hand
        function altFromName(name) {
            const base = name.replace(/\.[^.]+$/, '');
            const cameraName = /^(img|dsc|dscn|pxl|photo)[_-]?\d+/i.test(base)
                || /^\d{6,}/.test(base)
                || /^\d+(-\d+)?$/.test(base)
                || /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(base);
            if (cameraName) return '';
            return sentenceCase(base.replace(/[-_]+/g, ' ').replace(/[\[\]]/g, '').trim());
        }

        // One linked image per line, in the theme's #gallery format
        function buildGallery(rows) {
            return rows.map(row => {
                const url = row.querySelector('input[name="selected_media"]').value;
                const alt = altFromTags(row) || altFromName(row.dataset.name);
                return `[![${alt}](${url}#gallery)](${url})`;
            }).join('\n');
        }

        tool.button.addEventListener('click', async () => {
            const rows = selectedRows('images');

            if (!rows.length) {
                flash(tool, 'No images selected');
                return;
            }

            try {
                await navigator.clipboard.writeText(buildGallery(rows));
                flash(tool, `Copied ${plural(rows.length, 'image')}`);
            } catch (error) {
                flash(tool, 'Copy failed');
            }
        });
    });

    if ($delete) {
        new MutationObserver(sync).observe($delete, { attributes: true, attributeFilter: ['disabled'] });
    }

    sync();
})();
</script>

Want more? Check out the full Bear Blog library.

  1. Requires JavaScript, available with Bear Blog subscription.↩