#target photoshop

/*
<javascriptresource>
<name>Mark Duplicate Layer Names...</name>
<category>layers</category>
<about>Counts the layers in the active document, lists layers that share a name and gives the extra copies a red color label.</about>
<enableinfo>true</enableinfo>
</javascriptresource>
*/

/* ============================================================================
   Mark Duplicate Layer Names  v1.0
   ----------------------------------------------------------------------------
   Run it with a document open (File > Scripts > Browse...). It will:

     1. Count every layer and group in the document.
     2. Find layers that share the same name anywhere in the document.
     3. Leave the top-most one (as seen in the Layers panel) as it is and give
        every other copy a RED color label. If a label can't be applied, that
        copy is renamed to <name>_DUPLICATE instead (_DUPLICATE_2, _3 ... when
        the name is already taken, so the new names never clash).
     4. Show a report with the counts and where each duplicate sits.

   Good to know
     - All changes are one history step, so a single Undo reverts the run.
     - Photoshop only recolors or renames layers that are selected, so the
       script selects them while it works and puts your own selection back
       at the end. Groups that hold duplicates may open up in the Layers panel.
     - The color a layer had before it was turned red is stored in the
       document's metadata. Fix the names, run the script again, and the
       layers that are no longer duplicates get their old color back.
     - The Background layer is never changed. If it shares its name with
       another layer, the other layer is the one that gets marked.

   To have it in the File > Scripts menu, copy this file into Photoshop's
   Presets/Scripts folder and restart Photoshop.
   ============================================================================ */

// ---------------------------------------------------------------- settings --
var MDL_SETTINGS = {
    includeGroups: true,        // also compare group names (groups and layers share one name list)
    sameGroupOnly: false,       // true = only flag duplicates that sit directly in the same group
    ignoreCase: false,          // true = "Hair_11" and "hair_11" count as duplicates
    ignoreSpaces: true,         // true = spaces at the start/end don't count ("Hair_11 " = "Hair_11")
    keepTopMost: true,          // true = the top-most copy stays as is, false = the bottom-most one
    alsoRename: false,          // true = rename the copies to <name>_DUPLICATE as well as coloring them
    renameSuffix: '_DUPLICATE',
    showReport: true            // false = no dialog at the end (for running inside actions)
};
// ----------------------------------------------------------------------------

var MDL_TITLE = 'Mark Duplicate Layer Names';
var MDL_XMP_NS = 'http://ns.mark-duplicate-layer-names.jsx/1.0/';
var MDL_XMP_PREFIX = 'mdlnames';
var MDL_XMP_PROP = 'markedLayers';
var MDL_RED = charIDToTypeID('Rd  ');
var MDL_STATE = null;   // hands the plan to mdlApplyChanges(), which runs inside suspendHistory

// Photoshop's layerKind numbers -> labels for the report
var MDL_KIND_NAMES = {
    '$1': 'Pixel', '$2': 'Adjustment', '$3': 'Text', '$4': 'Shape', '$5': 'Smart Object',
    '$6': 'Video', '$8': '3D', '$9': 'Fill', '$10': 'Fill', '$11': 'Fill', '$12': 'Pixel'
};
var MDL_KIND_ORDER = ['Pixel', 'Text', 'Shape', 'Smart Object', 'Adjustment', 'Fill', 'Video', '3D', 'Other'];
var MDL_COLOR_NAMES = {
    'none': 'No Color', 'red': 'Red', 'orange': 'Orange', 'yellowColor': 'Yellow',
    'grain': 'Green', 'blue': 'Blue', 'violet': 'Violet', 'gray': 'Gray',
    'magenta': 'Magenta', 'seafoam': 'Seafoam', 'indigo': 'Indigo', 'fuchsia': 'Fuchsia'
};

// ======================================================================= main

function mdlMain() {
    var S = mdlSettings();
    if (!app.documents.length) {
        if (S.showReport) alert('Open a document first.', MDL_TITLE);
        return;
    }
    var doc = app.activeDocument;
    var st = mdlPlan(doc, mdlScan(), S);

    if (st.toColor.length || st.toRestore.length || st.toRename.length) {
        MDL_STATE = st;
        doc.suspendHistory(MDL_TITLE, 'mdlApplyChanges()');
        MDL_STATE = null;
    }
    if (st.recordDirty) mdlWriteRecord(doc, st.record);

    $.global.MDL_LAST_RESULT = st;   // lets other scripts read the result
    if (S.showReport) mdlShowReport(doc, st);
    if (st.error) throw st.error;
}

function mdlSettings() {
    var S = {}, k;
    for (k in MDL_SETTINGS) {
        if (MDL_SETTINGS.hasOwnProperty(k)) S[k] = MDL_SETTINGS[k];
    }
    // Another script can change settings without editing this file:
    //   var MDL_OVERRIDES = { ignoreCase: true };  $.evalFile(thisScript);
    if (typeof MDL_OVERRIDES === 'object' && MDL_OVERRIDES !== null) {
        for (k in MDL_OVERRIDES) {
            if (MDL_OVERRIDES.hasOwnProperty(k)) S[k] = MDL_OVERRIDES[k];
        }
    }
    return S;
}

// ================================================================== scanning

// Walks the layer stack from the top of the Layers panel down, using Action
// Manager (much faster than the DOM on big files). Each group shows up as a
// start entry and a hidden end entry; the end entries are skipped.
function mdlScan() {
    var K_ID = mdlS('layerID'), K_SECTION = mdlS('layerSection'), K_NAME = mdlC('Nm  ');
    var K_KIND = mdlS('layerKind'), K_ARTBOARD = mdlS('artboardEnabled');
    var START = mdlS('layerSectionStart'), END = mdlS('layerSectionEnd');
    var scan = { items: [], layers: 0, groups: 0, artboards: 0, kinds: {}, kindsKnown: true };
    var bgId = mdlBackgroundId();
    var seen = {}, stack = [], i, s, id, section, parent, path, it, kind, label;

    // Indexes start at 0 or 1 depending on whether there is a Background layer.
    // Trying one extra index at each end and skipping ids already seen covers both.
    for (i = mdlLayerCount() + 1; i >= 0; i--) {
        try { id = mdlGetByIndex(i, K_ID).getInteger(K_ID); } catch (e1) { continue; }
        if (seen['$' + id]) continue;
        seen['$' + id] = true;

        section = null;
        try { section = mdlGetByIndex(i, K_SECTION).getEnumerationValue(K_SECTION); } catch (e2) {}
        if (section == END) {
            if (stack.length) stack.pop();
            continue;
        }

        parent = stack.length ? stack[stack.length - 1] : null;
        path = [];
        for (s = 0; s < stack.length; s++) path.push(stack[s].name);

        it = {
            id: id,
            name: '',
            isGroup: section == START,
            isArtboard: false,
            isBackground: id == bgId,
            parentId: parent ? parent.id : 0,
            path: path
        };
        try { it.name = mdlGetByIndex(i, K_NAME).getString(K_NAME); } catch (e3) {}

        if (it.isGroup) {
            scan.groups++;
            try { it.isArtboard = mdlGetByIndex(i, K_ARTBOARD).getBoolean(K_ARTBOARD) === true; } catch (e4) {}
            if (it.isArtboard) scan.artboards++;
            stack.push(it);
        } else {
            scan.layers++;
            if (scan.kindsKnown) {
                try {
                    kind = mdlGetByIndex(i, K_KIND).getInteger(K_KIND);
                    label = MDL_KIND_NAMES['$' + kind] || 'Other';
                    scan.kinds[label] = (scan.kinds[label] || 0) + 1;
                } catch (e5) {
                    scan.kindsKnown = false;   // older Photoshop: skip the breakdown
                }
            }
        }
        scan.items.push(it);
    }
    return scan;
}

function mdlNameKey(it, S) {
    var n = String(it.name);
    if (S.ignoreSpaces) n = mdlTrim(n);
    if (S.ignoreCase) n = n.toLowerCase();
    // '$' keeps names like "constructor" or "toString" from hitting built-in properties
    return '$' + (S.sameGroupOnly ? it.parentId + '/' : '') + n;
}

// Groups items by name (in Layers panel order) and returns the names used more than once.
function mdlFindDuplicates(items, S) {
    var buckets = {}, order = [], sets = [], i, j, key, list, keep, set;
    for (i = 0; i < items.length; i++) {
        if (items[i].isGroup && !S.includeGroups) continue;
        key = mdlNameKey(items[i], S);
        if (!buckets.hasOwnProperty(key)) {
            buckets[key] = [];
            order.push(key);
        }
        buckets[key].push(items[i]);
    }
    for (i = 0; i < order.length; i++) {
        list = buckets[order[i]];
        if (list.length < 2) continue;
        keep = S.keepTopMost ? 0 : list.length - 1;
        for (j = 0; j < list.length; j++) {
            if (list[j].isBackground) keep = j;   // the Background layer is never changed
        }
        set = { keeper: list[keep], copies: [], all: list };
        for (j = 0; j < list.length; j++) {
            if (j !== keep) set.copies.push(list[j]);
        }
        sets.push(set);
    }
    return { sets: sets, uniqueNames: order.length };
}

// Works out what has to change before anything is touched.
function mdlPlan(doc, scan, S) {
    var st = {
        settings: S, scan: scan, docName: doc.name,
        sets: [], uniqueNames: 0, copies: [],
        record: mdlReadRecord(doc), recordDirty: false,
        toColor: [], toRestore: [], toRename: [], usedNames: {}, error: null
    };
    var byId = {}, toMark = {}, keys = [], i, j, it, key, found;

    for (i = 0; i < scan.items.length; i++) {
        it = scan.items[i];
        byId['$' + it.id] = it;
        st.usedNames['$' + mdlTrim(it.name).toLowerCase()] = true;
    }

    found = mdlFindDuplicates(scan.items, S);
    st.sets = found.sets;
    st.uniqueNames = found.uniqueNames;

    for (i = 0; i < st.sets.length; i++) {
        for (j = 0; j < st.sets[i].copies.length; j++) {
            it = st.sets[i].copies[j];
            st.copies.push(it);
            toMark['$' + it.id] = true;
            it.colorBefore = mdlTry(mdlGetColor, it.id);
            if (it.colorBefore !== null && it.colorBefore == MDL_RED) it.result = 'alreadyRed';
            else st.toColor.push(it);
            if (S.alsoRename) st.toRename.push(it);
        }
    }

    // Layers this script turned red on an earlier run that aren't duplicates any
    // more get their old color back. Entries for layers that were deleted, or
    // recolored by hand since, are just dropped.
    for (key in st.record) {
        if (st.record.hasOwnProperty(key)) keys.push(key);
    }
    for (i = 0; i < keys.length; i++) {
        key = keys[i];
        if (toMark[key] === true) continue;
        it = byId[key];
        if (it && !it.isBackground && mdlTry(mdlGetColor, it.id) == MDL_RED) {
            it.restoreTo = st.record[key];
            st.toRestore.push(it);
        } else {
            delete st.record[key];
        }
    }
    return st;
}

// ================================================================== changing
// Photoshop applies a color label or a new name to the *selected* layers (a
// layer id in the "set" command isn't reliable), so layers get selected first
// and the original selection is put back at the end.

// Runs inside suspendHistory, so the whole run is one history step.
function mdlApplyChanges() {
    var st = MDL_STATE;
    var selection = mdlTry(mdlGetSelectedIds);
    try {
        mdlRestoreColors(st);
        mdlMarkRed(st);
        mdlRenameCopies(st);
    } catch (e) {
        st.error = e;
    }
    if (selection !== null) {
        try { mdlSelectIds(selection); } catch (e2) {}
    }
}

function mdlRestoreColors(st) {
    var groups = {}, order = [], i, j, it, key, list, color;
    for (i = 0; i < st.toRestore.length; i++) {
        it = st.toRestore[i];
        key = '$' + it.restoreTo;
        if (!groups.hasOwnProperty(key)) {
            groups[key] = [];
            order.push(key);
        }
        groups[key].push(it);
        delete st.record['$' + it.id];
        st.recordDirty = true;
    }
    for (i = 0; i < order.length; i++) {
        list = groups[order[i]];
        color = mdlS(order[i].substring(1));
        mdlColorItems(list, color);
        for (j = 0; j < list.length; j++) list[j].restoreOk = mdlHasColor(list[j].id, color);
    }
}

function mdlMarkRed(st) {
    var i, it;
    if (!st.toColor.length) return;
    mdlColorItems(st.toColor, MDL_RED);
    for (i = 0; i < st.toColor.length; i++) {
        it = st.toColor[i];
        if (mdlHasColor(it.id, MDL_RED)) {
            it.result = 'red';
            if (it.colorBefore !== null) {
                st.record['$' + it.id] = typeIDToStringID(it.colorBefore);
                st.recordDirty = true;
            }
        } else {
            it.result = 'failed';
            if (!st.settings.alsoRename) st.toRename.push(it);   // label didn't stick: rename instead
        }
    }
}

function mdlRenameCopies(st) {
    var i, it, newName;
    for (i = 0; i < st.toRename.length; i++) {
        it = st.toRename[i];
        newName = mdlUniqueName(it.name, st);
        try {
            mdlSelectIds([it.id]);
            mdlSetSelectedName(newName);
        } catch (e) {}
        if (mdlTry(mdlGetName, it.id) === newName) {
            it.newName = newName;
            if (it.result === 'failed') it.result = 'renamed';
        }
    }
}

// Selects the layers and sets the color on all of them in one go. Any layer
// that didn't take the color is then retried on its own.
function mdlColorItems(items, colorId) {
    var ids = [], i;
    for (i = 0; i < items.length; i++) ids.push(items[i].id);
    try {
        mdlSelectIds(ids);
        mdlSetSelectedColor(colorId);
    } catch (e) {}
    for (i = 0; i < items.length; i++) {
        if (mdlHasColor(items[i].id, colorId)) continue;
        try {
            mdlSelectIds([items[i].id]);
            mdlSetSelectedColor(colorId);
        } catch (e2) {}
    }
}

// True when the layer has that color (or when its color can't be read back).
function mdlHasColor(id, colorId) {
    var c = mdlTry(mdlGetColor, id);
    return c === null || c == colorId;
}

// "<name>_DUPLICATE", or _DUPLICATE_2, _3 ... when that name is already used anywhere.
function mdlUniqueName(name, st) {
    var suffix = st.settings.renameSuffix;
    var base = mdlTrim(name), candidate, n = 2;
    if (suffix) base = base.replace(new RegExp(mdlEscapeRegExp(suffix) + '(_\\d+)?$', 'i'), '');
    if (!base) base = 'Layer';
    candidate = base + suffix;
    while (st.usedNames.hasOwnProperty('$' + candidate.toLowerCase())) {
        candidate = base + suffix + '_' + n;
        n++;
    }
    st.usedNames['$' + candidate.toLowerCase()] = true;
    return candidate;
}

// ============================================================ Action Manager

function mdlC(s) { return charIDToTypeID(s); }
function mdlS(s) { return stringIDToTypeID(s); }

function mdlGetByIndex(index, key) {
    var ref = new ActionReference();
    ref.putProperty(mdlC('Prpr'), key);
    ref.putIndex(mdlC('Lyr '), index);
    return executeActionGet(ref);
}

function mdlGetById(id, key) {
    var ref = new ActionReference();
    ref.putProperty(mdlC('Prpr'), key);
    ref.putIdentifier(mdlC('Lyr '), id);
    return executeActionGet(ref);
}

function mdlGetColor(id) {
    return mdlGetById(id, mdlC('Clr ')).getEnumerationValue(mdlC('Clr '));
}

function mdlGetName(id) {
    return mdlGetById(id, mdlC('Nm  ')).getString(mdlC('Nm  '));
}

function mdlLayerCount() {
    var ref = new ActionReference();
    ref.putProperty(mdlC('Prpr'), mdlC('NmbL'));
    ref.putEnumerated(mdlC('Dcmn'), mdlC('Ordn'), mdlC('Trgt'));
    return executeActionGet(ref).getInteger(mdlC('NmbL'));
}

function mdlBackgroundId() {
    try {
        var ref = new ActionReference();
        ref.putProperty(mdlC('Prpr'), mdlC('Bckg'));
        ref.putEnumerated(mdlC('Lyr '), mdlC('Ordn'), mdlC('Back'));
        if (executeActionGet(ref).getBoolean(mdlC('Bckg')) !== true) return -1;
        ref = new ActionReference();
        ref.putProperty(mdlC('Prpr'), mdlS('layerID'));
        ref.putEnumerated(mdlC('Lyr '), mdlC('Ordn'), mdlC('Back'));
        return executeActionGet(ref).getInteger(mdlS('layerID'));
    } catch (e) {
        return -1;
    }
}

function mdlGetSelectedIds() {
    var ref = new ActionReference(), list, ids = [], i;
    ref.putProperty(mdlC('Prpr'), mdlS('targetLayersIDs'));
    ref.putEnumerated(mdlC('Dcmn'), mdlC('Ordn'), mdlC('Trgt'));
    list = executeActionGet(ref).getList(mdlS('targetLayersIDs'));
    for (i = 0; i < list.count; i++) ids.push(list.getReference(i).getIdentifier());
    return ids;
}

// Selects exactly these layers (none when the list is empty), without
// changing their visibility.
function mdlSelectIds(ids) {
    var i, desc, ref;
    if (!ids.length) {
        desc = new ActionDescriptor();
        ref = new ActionReference();
        ref.putEnumerated(mdlC('Lyr '), mdlC('Ordn'), mdlC('Trgt'));
        desc.putReference(mdlC('null'), ref);
        executeAction(mdlS('selectNoLayers'), desc, DialogModes.NO);
        return;
    }
    for (i = 0; i < ids.length; i++) {
        desc = new ActionDescriptor();
        ref = new ActionReference();
        ref.putIdentifier(mdlC('Lyr '), ids[i]);
        desc.putReference(mdlC('null'), ref);
        if (i > 0) desc.putEnumerated(mdlS('selectionModifier'), mdlS('selectionModifierType'), mdlS('addToSelection'));
        desc.putBoolean(mdlC('MkVs'), false);
        executeAction(mdlC('slct'), desc, DialogModes.NO);
    }
}

function mdlSetSelected(fill) {
    var desc = new ActionDescriptor(), ref = new ActionReference(), props = new ActionDescriptor();
    ref.putEnumerated(mdlC('Lyr '), mdlC('Ordn'), mdlC('Trgt'));
    desc.putReference(mdlC('null'), ref);
    fill(props);
    desc.putObject(mdlC('T   '), mdlC('Lyr '), props);
    executeAction(mdlC('setd'), desc, DialogModes.NO);
}

function mdlSetSelectedColor(colorId) {
    mdlSetSelected(function (d) { d.putEnumerated(mdlC('Clr '), mdlC('Clr '), colorId); });
}

function mdlSetSelectedName(name) {
    mdlSetSelected(function (d) { d.putString(mdlC('Nm  '), name); });
}

// ======================================================= memory of old colors
// Stored in the document's XMP as "layerID:color;layerID:color".

function mdlXmpReady() {
    try {
        if (!ExternalObject.AdobeXMPScript) ExternalObject.AdobeXMPScript = new ExternalObject('lib:AdobeXMPScript');
        XMPMeta.registerNamespace(MDL_XMP_NS, MDL_XMP_PREFIX);
        return true;
    } catch (e) {
        return false;
    }
}

function mdlReadRecord(doc) {
    var rec = {}, raw, prop, pairs, kv, i;
    if (!mdlXmpReady()) return rec;
    try {
        raw = doc.xmpMetadata.rawData;
        if (!raw) return rec;
        prop = new XMPMeta(raw).getProperty(MDL_XMP_NS, MDL_XMP_PROP);
        if (!prop) return rec;
        pairs = String(prop.value).split(';');
        for (i = 0; i < pairs.length; i++) {
            kv = pairs[i].split(':');
            if (kv.length === 2 && /^\d+$/.test(kv[0]) && /^\w+$/.test(kv[1])) rec['$' + kv[0]] = kv[1];
        }
    } catch (e) {}
    return rec;
}

function mdlWriteRecord(doc, rec) {
    var pairs = [], key, raw, xmp;
    if (!mdlXmpReady()) return false;
    try {
        for (key in rec) {
            if (rec.hasOwnProperty(key)) pairs.push(key.substring(1) + ':' + rec[key]);
        }
        raw = doc.xmpMetadata.rawData;
        xmp = raw ? new XMPMeta(raw) : new XMPMeta();   // unreadable metadata throws, and is left alone
        if (pairs.length) xmp.setProperty(MDL_XMP_NS, MDL_XMP_PROP, pairs.join(';'));
        else xmp.deleteProperty(MDL_XMP_NS, MDL_XMP_PROP);
        doc.xmpMetadata.rawData = xmp.serialize(XMPConst.SERIALIZE_OMIT_PACKET_WRAPPER | XMPConst.SERIALIZE_USE_COMPACT_FORMAT);
        return true;
    } catch (e) {
        return false;
    }
}

// ==================================================================== report

function mdlBuildReport(st) {
    var S = st.settings, scan = st.scan, R = {}, lines = [], parts = [], kinds = [], full;
    var marked = 0, renamed = 0, failed = 0, restored = 0, i, j, set, it, label, note;

    for (i = 0; i < st.copies.length; i++) {
        it = st.copies[i];
        if (it.result === 'red' || it.result === 'alreadyRed') marked++;
        else if (it.result !== 'renamed') failed++;
        if (it.newName) renamed++;
    }
    for (i = 0; i < st.toRestore.length; i++) {
        if (st.toRestore[i].restoreOk) restored++;
    }

    if (st.sets.length) {
        if (marked) parts.push(mdlPlural(marked, 'layer', 'layers') + ' marked red');
        if (renamed) parts.push(renamed + ' renamed');
        if (failed) parts.push(failed + ' could not be changed');
        R.headline = 'Found ' + mdlPlural(st.sets.length, 'duplicate name', 'duplicate names') +
            ' \u2014 ' + parts.join(', ');
    } else {
        R.headline = 'No duplicate names \u2014 every layer name is unique.';
    }

    R.countLine = 'Total: ' + (scan.layers + scan.groups) + '   (' +
        mdlPlural(scan.layers, 'layer', 'layers') + ' + ' + mdlPlural(scan.groups, 'group', 'groups') +
        (scan.artboards ? ', ' + scan.artboards + ' of them ' + (scan.artboards === 1 ? 'an artboard' : 'artboards') : '') +
        ')';

    R.kindLine = '';
    if (scan.kindsKnown && scan.layers) {
        for (i = 0; i < MDL_KIND_ORDER.length; i++) {
            if (scan.kinds[MDL_KIND_ORDER[i]]) kinds.push(MDL_KIND_ORDER[i] + ' ' + scan.kinds[MDL_KIND_ORDER[i]]);
        }
        R.kindLine = kinds.join('   \u00B7   ');
    }

    R.restoreLine = '';
    if (restored === 1) R.restoreLine = '1 layer is no longer a duplicate and got its previous color back.';
    else if (restored > 1) R.restoreLine = restored + ' layers are no longer duplicates and got their previous color back.';

    R.modeLine = 'Checked ' + (S.includeGroups ? 'layer and group names' : 'layer names (groups skipped)') +
        (S.sameGroupOnly ? ' within each group' : ' across the whole document') +
        (S.ignoreCase ? ', ignoring case' : ', case-sensitive') +
        (S.ignoreSpaces ? ', ignoring spaces at the ends' : '') + '.';
    R.keepLine = 'The ' + (S.keepTopMost ? 'top-most' : 'bottom-most') +
        ' layer of each name is kept as the original; the others are marked.';

    for (i = 0; i < st.sets.length; i++) {
        set = st.sets[i];
        lines.push('"' + set.keeper.name + '"   \u00D7' + set.all.length);
        for (j = 0; j < set.all.length; j++) {
            it = set.all[j];
            note = '';
            if (it === set.keeper) label = 'keep';
            else if (it.result === 'red' || it.result === 'alreadyRed') label = 'red';
            else if (it.result === 'renamed') label = 'renamed';
            else label = 'FAILED';
            if (it.result === 'alreadyRed') note += '   (was already red)';
            if (it.newName) note += '   ->  ' + it.newName;
            if (label === 'FAILED') note += '   (could not change its label or its name)';
            lines.push('    ' + mdlPad(label, 10) + mdlDisplayPath(it) + note);
        }
        lines.push('');
    }
    if (st.toRestore.length) {
        lines.push('No longer duplicates \u2014 previous color put back:');
        for (i = 0; i < st.toRestore.length; i++) {
            it = st.toRestore[i];
            lines.push('    ' + mdlDisplayPath(it) + '   ->  ' + mdlColorName(it.restoreTo) +
                (it.restoreOk ? '' : '   (failed)'));
        }
        lines.push('');
    }
    while (lines.length && lines[lines.length - 1] === '') lines.pop();
    R.details = lines.join('\n');
    R.detailLines = lines.length;

    full = [R.headline, '', 'Document: ' + st.docName, R.countLine];
    if (R.kindLine) full.push(R.kindLine);
    if (R.restoreLine) full.push(R.restoreLine);
    full.push(R.modeLine);
    if (st.sets.length) full.push(R.keepLine);
    if (R.details) full.push('', R.details);
    R.full = full.join('\n');
    return R;
}

function mdlShowReport(doc, st) {
    var R = mdlBuildReport(st);
    var changed = st.toColor.length || st.toRestore.length || st.toRename.length;
    var w = new Window('dialog', MDL_TITLE);
    w.orientation = 'column';
    w.alignChildren = ['fill', 'top'];
    w.margins = 16;
    w.spacing = 12;

    var head = w.add('statictext', undefined, R.headline);
    try { head.graphics.font = ScriptUI.newFont('dialog', 'BOLD', 14); } catch (e) {}

    var counts = w.add('panel', undefined, 'Layers in ' + st.docName);
    counts.orientation = 'column';
    counts.alignChildren = ['left', 'top'];
    counts.margins = [12, 16, 12, 10];
    counts.spacing = 4;
    counts.add('statictext', undefined, R.countLine);
    if (R.kindLine) counts.add('statictext', undefined, R.kindLine);
    if (R.restoreLine) counts.add('statictext', undefined, R.restoreLine);

    if (R.details) {
        var box = w.add('edittext', undefined, R.details, { multiline: true, readonly: true, scrolling: true });
        box.preferredSize = [640, Math.max(120, Math.min(380, R.detailLines * 17 + 12))];
    }

    var notes = w.add('group');
    notes.orientation = 'column';
    notes.alignChildren = ['left', 'top'];
    notes.spacing = 2;
    notes.add('statictext', undefined, R.modeLine);
    if (st.sets.length) notes.add('statictext', undefined, R.keepLine);
    if (changed) notes.add('statictext', undefined, 'All changes are one history step: one Undo (' +
        (mdlIsWindows() ? 'Ctrl+Z' : 'Cmd+Z') + ') reverts them.');

    var buttons = w.add('group');
    buttons.alignment = ['right', 'top'];
    if (R.details) {
        var save = buttons.add('button', undefined, 'Save Report...');
        save.onClick = function () { mdlSaveReport(doc, R.full); };
    }
    buttons.add('button', undefined, 'OK', { name: 'ok' });
    w.show();
}

function mdlSaveReport(doc, text) {
    var win = mdlIsWindows(), folder = Folder.desktop, target, f, lines, i;
    try { folder = doc.path; } catch (e) {}   // documents that were never saved have no folder
    target = new File(folder.fsName + '/' + String(doc.name).replace(/\.[^.]*$/, '') + ' - duplicate layer names.txt');
    f = win ? target.saveDlg('Save the report', 'Text files:*.txt') : target.saveDlg('Save the report');
    if (!f) return;
    f.encoding = 'UTF-8';
    f.lineFeed = win ? 'Windows' : 'Unix';
    if (!f.open('w')) {
        alert('Could not write to ' + f.fsName, MDL_TITLE, true);
        return;
    }
    f.write('\uFEFF');
    lines = text.split('\n');
    for (i = 0; i < lines.length; i++) f.writeln(lines[i]);
    f.close();
}

// =================================================================== helpers

function mdlDisplayPath(it) {
    var s = it.path.concat([it.name]).join(' / ');
    if (it.isArtboard) s += '   [artboard]';
    else if (it.isGroup) s += '   [group]';
    else if (it.isBackground) s += '   [Background layer]';
    return s;
}

function mdlColorName(stringId) {
    return MDL_COLOR_NAMES.hasOwnProperty(stringId) ? MDL_COLOR_NAMES[stringId] : stringId;
}

function mdlTrim(s) { return String(s).replace(/^\s+|\s+$/g, ''); }

function mdlPad(s, n) {
    while (s.length < n) s += ' ';
    return s;
}

function mdlPlural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }

function mdlEscapeRegExp(s) { return String(s).replace(/([.*+?^${}()|\[\]\\\/])/g, '\\$1'); }

function mdlTry(fn, arg) {
    try { return fn(arg); } catch (e) { return null; }
}

function mdlIsWindows() { return $.os.indexOf('Windows') !== -1; }

// ===================================================================== run

try {
    mdlMain();
} catch (err) {
    if (mdlSettings().showReport) {
        alert(MDL_TITLE + ' stopped with an error:\n' + err + (err && err.line ? '  (line ' + err.line + ')' : ''), MDL_TITLE, true);
    } else {
        throw err;
    }
}
