/*
    DuplicateKit for EffectCraft, version 1.0

    Duplicates the compositions selected in the Project panel together with the compositions
    nested in them, so the copy can be changed without touching the original. A nested
    composition used several times is duplicated once per copy and every layer that used it uses
    the new copy. Names, folders, labels, expressions, footage and solids follow the panel's
    options; Collect Dependencies gathers a hierarchy into one folder instead.

    Written for EffectCraft 0.10 from a behaviour description of a comp-duplicating panel for
    After Effects (DuplicateKit_spec.md); none of that panel's code is used. NAME below renames
    the tool everywhere it shows.

    Install: File > Scripts > Install ScriptUI Panel..., then open it from the Window menu.
    Or run it once as a floating window with File > Scripts > Run Script File...

    How it works in EffectCraft 0.10 (scripts there can't switch a layer to another source):
    - The selected compositions are copied with Duplicate. A nested composition is copied through
      the first layer that uses it: Pre-compose with "leave all attributes" gives that layer a new
      composition (the layer keeps its transform, effects, masks, timing and switches), which then
      gets the nested composition's layers (pasted, with every property, keyframe, expression,
      parent and matte) and its settings. Essential Graphics controls and guides of nested
      compositions are not copied; the result notes name the compositions that had controls.
    - Other layers that use the same nested composition get a pass-through composition holding
      the one copy ("<copy> (use 2)"), so editing the copy still changes every instance.
    - Duplicate Footage: the footage item is duplicated once per copy and each layer that showed
      it gets a pass-through composition holding the new item ("<item> (use 1)").
    - Duplicate Solids: each solid layer gets its own new solid (Layer Settings with "affect all
      layers" off).
    - When an EffectCraft version makes layer.replaceSource() work, the script uses it instead:
      nested compositions, footage and solids are duplicated and the layers switched to them, with
      no pass-through compositions.
    - Effects that pick a layer (Set Matte, Displacement Map, an environment light's source...)
      are pointed at the copy's own layer: EffectCraft's Duplicate and Paste leave them on the
      source composition's layers.

    A run is one undo step. It uses EffectCraft's layer clipboard (Copy / Paste), so it replaces
    what was copied. EffectCraft records every command of a script's undo group against Levels of
    Undo; when a run is long enough to push the oldest steps out, the script adds throwaway edits
    until the group is longer than the undo levels, which makes EffectCraft keep the history from
    before the run.

    Differences from the After Effects panel it follows:
    - Settings are saved as they change, every control included.
    - A name without a number gets one when Increment is on (Logo > Logo 2, Logo 3 for the next
      copy), as that panel's help describes; its script left such names unchanged.
    - Search / Replace takes both texts literally, and Replace may be empty (removes the text).
    - A composition that is both selected and nested in another selected one is duplicated once.
    - Select duplicates selects the copies of every copy run, not only the last one.
    - Expressions: single- and double-quoted names, every reference on a line, footage("...")
      references, no chained renames, line breaks kept, and expressions that are off or have
      errors are updated too (and stay as they were).
    - Collect Dependencies works (the reference stopped on an error with Select duplicates on and
      moved compositions only): it also collects footage with Duplicate Footage on and solids with
      Duplicate Solids on, and keeps folder names as they are.
    - New names that other items already have are listed, because expressions find compositions
      by name.
*/

(function (thisObj) {
    var NAME = "DuplicateKit";
    var VERSION = "1.0";
    var SECTION = "DuplicateKit"; // app.settings section for the panel's settings

    // ------------------------------------------------------------------ engine access

    function cmd(id, params) { return app.run(id, params || {}); }
    function itemInfo(id) { return __get("item", { id: id }); }
    function layerInfo(comp, layer) { return __get("layer", { comp: comp, layer: layer }); }
    function hasOwn(o, k) { return Object.prototype.hasOwnProperty.call(o, k); }
    function errText(e) {
        var s = (e && e.message !== undefined) ? e.message : String(e);
        return s.replace(/^Error:\s*/, "");
    }

    // Whether layer.replaceSource() works in this EffectCraft (null: not tried yet). EffectCraft
    // 0.10 throws "not supported yet"; the first retarget of a session finds out.
    var NATIVE = null;

    // ------------------------------------------------------------------ names

    function escapeRegExp(s) { return String(s).replace(/[\\^$.*+?()[\]{}|\/-]/g, "\\$&"); }

    function padNumber(v, width) {
        var s = String(v);
        while (s.length < width) s = "0" + s;
        return s;
    }

    // "Shot_009" -> "Shot_010" (+ copy index); a name without digits gets " 2", or "2" after a
    // separator ("Logo_" -> "Logo_2").
    function incrementName(name, mode, copyIndex, noAdd) {
        var re = mode === "First" ? /\d+/ : /\d+(?=\D*$)/;
        var m = re.exec(name);
        if (!m) {
            if (noAdd) return name;
            var n = 2 + copyIndex;
            return /[\s_\-.~#+]$/.test(name) || name === "" ? name + n : name + " " + n;
        }
        var digits = m[0];
        var value = parseInt(digits, 10) + 1 + copyIndex;
        return name.slice(0, m.index) + padNumber(value, digits.length) + name.slice(m.index + digits.length);
    }

    function makeNamer(o) {
        var search = o.replace.on && o.replace.search !== "" ? new RegExp(escapeRegExp(o.replace.search), "gi") : null;
        var rep = o.replace.replace;
        // `folder`: recreated folders only get an existing number increased (they sit in their
        // own copy's group folder, so they don't need one to be told apart).
        return function (name, copyIndex, folder) {
            var n = String(name);
            if (o.affix.on) n = o.affix.mode === "Prefix" ? o.affix.text + n : n + o.affix.text;
            if (search) {
                search.lastIndex = 0;
                n = n.replace(search, function () { return rep; });
            }
            if (o.increment.on) n = incrementName(n, o.increment.mode, copyIndex, !!folder);
            if (n.replace(/\s+/g, "") === "") n = String(name); // EffectCraft refuses empty names
            return n;
        };
    }

    // The group folder of copy run c: the typed name for the first, then its last number + c
    // (keeping zeros), or c added when it has none: "Dups", "Dups1", "Dups2".
    function groupFolderName(base, c) {
        if (c === 0) return base;
        var m = /\d+(?=\D*$)/.exec(base);
        if (!m) return base + c;
        return base.slice(0, m.index) + padNumber(parseInt(m[0], 10) + c, m[0].length) + base.slice(m.index + m[0].length);
    }

    function makeExclude(o) {
        if (!o.exclude.on) return function () { return false; };
        var t = o.exclude.text;
        if (o.exclude.mode === "Suffix") return function (n) { return n.length >= t.length && n.slice(n.length - t.length) === t; };
        if (o.exclude.mode === "Matching Regex") {
            var re = new RegExp(t);
            return function (n) { re.lastIndex = 0; return re.test(n); };
        }
        return function (n) { return n.indexOf(t) === 0; };
    }

    // ------------------------------------------------------------------ expressions

    // Rewrites name references in an expression's text: comp("A"), footage("F"),
    // thisComp.layer("L") and comp("A").layer("L"), with either quote. ctx.comp(name) /
    // ctx.footage(name) give the new name or null; ctx.layer(owner, name) does the same for a layer,
    // owner being null for thisComp or the comp's original name. Comments and other text are left
    // as they are, line breaks included.
    function rewriteExpression(text, ctx) {
        var n = text.length, i = 0, last = 0, out = "", changed = false;
        var lastComp = null; // the comp("X") call just read: {end (after its ")"), name}
        function isId(ch) { return /[A-Za-z0-9_$]/.test(ch); }
        function skipSpaceBack(j) { while (j >= 0 && /\s/.test(text.charAt(j))) j--; return j; }
        function skipSpace(j) { while (j < n && /\s/.test(text.charAt(j))) j++; return j; }
        // The call a string literal at `at` is the argument of: {name, dot, dotAt} or null.
        function callBefore(at) {
            var j = skipSpaceBack(at - 1);
            if (text.charAt(j) !== "(") return null;
            j = skipSpaceBack(j - 1);
            var end = j;
            while (j >= 0 && isId(text.charAt(j))) j--;
            if (j === end) return null;
            var name = text.slice(j + 1, end + 1);
            var k = skipSpaceBack(j);
            var dot = text.charAt(k) === ".";
            if (!dot && k >= 0 && isId(text.charAt(k))) return null;
            return { name: name, dot: dot, dotAt: k };
        }
        // Who a ".layer(" call belongs to: null for thisComp, a comp name for comp("X"), undefined
        // when it can't be told.
        function ownerBefore(dotAt) {
            var j = skipSpaceBack(dotAt - 1);
            if (j >= 7 && text.slice(j - 7, j + 1) === "thisComp" && (j - 8 < 0 || !isId(text.charAt(j - 8)))) return null;
            if (text.charAt(j) === ")" && lastComp && lastComp.end === j + 1) return lastComp.name;
            return undefined;
        }
        function readString(at) {
            var q = text.charAt(at), j = at + 1, val = "";
            while (j < n) {
                var ch = text.charAt(j);
                if (ch === "\\") {
                    var nx = text.charAt(j + 1);
                    val += nx === "n" ? "\n" : nx === "t" ? "\t" : nx === "r" ? "\r" : nx;
                    j += 2;
                    continue;
                }
                if (ch === q) return { end: j + 1, value: val, quote: q };
                if (ch === "\n") return null;
                val += ch;
                j++;
            }
            return null;
        }
        function quote(s, q) {
            return q + s.replace(/\\/g, "\\\\").replace(q === "'" ? /'/g : /"/g, "\\" + q).replace(/\n/g, "\\n").replace(/\r/g, "\\r") + q;
        }
        while (i < n) {
            var ch = text.charAt(i);
            if (ch === "/" && text.charAt(i + 1) === "/") {
                while (i < n && text.charAt(i) !== "\n" && text.charAt(i) !== "\r") i++;
                continue;
            }
            if (ch === "/" && text.charAt(i + 1) === "*") {
                var close = text.indexOf("*/", i + 2);
                i = close < 0 ? n : close + 2;
                continue;
            }
            if (ch === "`") {
                var tq = text.indexOf("`", i + 1);
                i = tq < 0 ? n : tq + 1;
                continue;
            }
            if (ch === '"' || ch === "'") {
                var str = readString(i);
                if (!str) { i++; continue; }
                var call = callBefore(i);
                var after = skipSpace(str.end);
                if (call && text.charAt(after) === ")") {
                    var repl = null;
                    if (call.name === "comp" && !call.dot) {
                        repl = ctx.comp(str.value);
                        lastComp = { end: after + 1, name: str.value };
                    } else if (call.name === "footage" && !call.dot) {
                        repl = ctx.footage(str.value);
                    } else if (call.name === "layer" && call.dot) {
                        var owner = ownerBefore(call.dotAt);
                        if (owner !== undefined) repl = ctx.layer(owner, str.value);
                    }
                    if (repl !== null && repl !== undefined && repl !== str.value) {
                        out += text.slice(last, i) + quote(repl, str.quote);
                        last = str.end;
                        changed = true;
                    }
                }
                i = str.end;
                continue;
            }
            i++;
        }
        return changed ? out + text.slice(last) : text;
    }

    // ------------------------------------------------------------------ reading the project

    function makeCache() { return { items: {}, comps: {} }; }

    function itemOf(id, cache) {
        if (hasOwn(cache.items, id)) return cache.items[id];
        var i = itemInfo(id);
        var it = { id: id, kind: i.kind, name: i.name, parent: i.parent || 0, label: i.label, comment: i.comment || "", file: i.file || "", info: i };
        cache.items[id] = it;
        return it;
    }

    function readComp(id, cache) {
        if (hasOwn(cache.comps, id)) return cache.comps[id];
        var it = itemOf(id, cache);
        var layers = [];
        for (var k = 0; k < it.info.layers.length; k++) {
            var L = layerInfo(id, it.info.layers[k]);
            layers.push({ id: L.id, name: L.name, source: (L.source === undefined || L.source === null) ? null : L.source, collapse: !!L.collapse });
        }
        var c = { id: id, name: it.name, layers: layers };
        cache.comps[id] = c;
        return c;
    }

    // What one copy run does, read from the project before anything changes: the compositions to
    // duplicate (the selected ones and those nested within the depth limit, not excluded), with
    // their depth; for each, the layers that get a new source; the selected compositions that no
    // other duplicated composition uses (copied with Duplicate); the footage and solids used.
    function makePlan(selIds, o, cache) {
        var depthOf = {}, order = [], queue = [], i;
        for (i = 0; i < selIds.length; i++) {
            if (!hasOwn(depthOf, selIds[i])) {
                depthOf[selIds[i]] = 0;
                order.push(selIds[i]);
                queue.push(selIds[i]);
            }
        }
        var limit = o.depthLimit;
        while (queue.length) {
            var id = queue.shift();
            var d = depthOf[id];
            if (limit >= 0 && d >= limit) continue;
            var c = readComp(id, cache);
            for (var k = 0; k < c.layers.length; k++) {
                var src = c.layers[k].source;
                if (src === null || hasOwn(depthOf, src)) continue;
                var it = itemOf(src, cache);
                if (it.kind === "comp" && !o.excluded(it.name)) {
                    depthOf[src] = d + 1;
                    order.push(src);
                    queue.push(src);
                }
            }
        }
        var actions = {}, targeted = {}, footage = [], solids = [], seenF = {}, seenS = {};
        for (i = 0; i < order.length; i++) {
            var cid = order[i], dd = depthOf[cid], cc = readComp(cid, cache), acts = [];
            for (var j = 0; j < cc.layers.length; j++) {
                var s = cc.layers[j].source;
                if (s === null) continue;
                var si = itemOf(s, cache);
                if (si.kind === "comp") {
                    if ((limit < 0 || dd < limit) && hasOwn(depthOf, s) && !o.excluded(si.name)) {
                        acts.push({ k: j, type: "comp", src: s });
                        targeted[s] = true;
                    }
                } else if (si.kind === "footage") {
                    if (o.footage && si.file && !o.excluded(si.name)) {
                        acts.push({ k: j, type: "footage", src: s });
                        if (!seenF[s]) { seenF[s] = true; footage.push(s); }
                    }
                } else if (si.kind === "solid") {
                    if (o.solids && !o.excluded(si.name)) {
                        acts.push({ k: j, type: "solid", src: s });
                        if (!seenS[s]) { seenS[s] = true; solids.push(s); }
                    }
                }
            }
            actions[cid] = acts;
        }
        var roots = [];
        for (i = 0; i < selIds.length; i++) if (!targeted[selIds[i]]) roots.push(selIds[i]);
        return { order: order, depthOf: depthOf, actions: actions, roots: roots, footage: footage, solids: solids, selected: selIds, cache: cache };
    }

    // ------------------------------------------------------------------ building one copy

    var SETTING_KEYS = [
        ["frameRate", "frameRate"], ["pixelAspect", "pixelAspect"], ["startTime", "displayStartTime"], ["background", "bgColor"],
        ["shutterAngle", "shutterAngle"], ["shutterPhase", "shutterPhase"], ["motionBlurSamples", "motionBlurSamplesPerFrame"],
        ["adaptiveSampleLimit", "motionBlurAdaptiveSampleLimit"], ["preserveFrameRate", "preserveNestedFrameRate"],
        ["preserveResolution", "preserveNestedResolution"], ["width", "width"], ["height", "height"], ["duration", "duration"]
    ];
    var SWITCH_KEYS = [["hideShy", "hideShyLayers"], ["motionBlur", "motionBlur"], ["frameBlending", "frameBlending"], ["draft3d", "draft3d"]];

    function sameValue(a, b) {
        if (a instanceof Array && b instanceof Array) {
            if (a.length !== b.length) return false;
            for (var i = 0; i < a.length; i++) if (Math.abs(a[i] - b[i]) > 1e-6) return false;
            return true;
        }
        if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) < 1e-6;
        return a === b;
    }

    // Give composition `to` the settings, switches (and, with `full`, the work area and markers)
    // of composition `from` (an item info).
    function matchCompSettings(to, from, full) {
        var ti = itemInfo(to), p = { comp: to }, any = false, i;
        for (i = 0; i < SETTING_KEYS.length; i++) {
            var v = from[SETTING_KEYS[i][1]];
            if (v !== undefined && !sameValue(v, ti[SETTING_KEYS[i][1]])) { p[SETTING_KEYS[i][0]] = v; any = true; }
        }
        if (from.renderer !== ti.renderer) { p.renderer = /advanced/i.test(from.renderer) ? "advanced3D" : "classic3D"; any = true; }
        if (any) cmd("comp.settings", p);
        for (i = 0; i < SWITCH_KEYS.length; i++) {
            var key = SWITCH_KEYS[i][1];
            if (!!from[key] !== !!ti[key]) cmd("comp.setSwitch", { comp: to, "switch": SWITCH_KEYS[i][0], value: !!from[key] });
        }
        if (!full) return;
        var now = itemInfo(to);
        if (!sameValue(from.workAreaStart, now.workAreaStart) || !sameValue(from.workAreaDuration, now.workAreaDuration)) {
            cmd("comp.workArea", { comp: to, start: from.workAreaStart, end: from.workAreaStart + from.workAreaDuration });
        }
        var ms = cmd("markers.list", { comp: from.id }) || [];
        for (i = 0; i < ms.length; i++) {
            var m = ms[i];
            cmd("markers.set", {
                comp: to, "new": true, time: m.time, duration: m.duration, comment: m.comment, chapter: m.chapter, url: m.url,
                frameTarget: m.frameTarget, cuePoint: m.cuePoint, "protected": m["protected"], label: m.label
            });
        }
    }

    // A layer's name after its source changed: a layer named after its old source takes the new
    // source's name (After Effects shows such a layer under its source's name); a layer with a
    // name of its own keeps it. Renames are noted for the expression pass.
    function settleLayerName(comp, layer, oldName, oldSourceName, newSourceName, X) {
        var want = oldName === oldSourceName ? newSourceName : oldName;
        var now = layerInfo(comp, layer).name;
        if (now !== want) cmd("layer.rename", { comp: comp, layer: layer, name: want });
        if (want !== oldName) {
            if (!hasOwn(X.renames, comp)) X.renames[comp] = {};
            if (!hasOwn(X.renames[comp], oldName)) X.renames[comp][oldName] = want;
        }
    }

    // Composition `copy` (made by Pre-compose, holding one helper layer) becomes a copy of `src`:
    // its settings, then its layers through the layer clipboard, with the names Paste made unique
    // set back.
    function fillCompCopy(copy, src, R) {
        var ci = itemInfo(copy), si = itemInfo(src), i;
        if (ci.layers.length) cmd("edit.clear", { comp: copy, layers: ci.layers });
        matchCompSettings(copy, si, true);
        if (si.layers.length) {
            cmd("edit.copy", { comp: src, layers: si.layers });
            var pasted = cmd("edit.paste", { comp: copy });
            for (i = 0; i < si.layers.length && i < pasted.length; i++) {
                var want = layerInfo(src, si.layers[i]).name;
                if (layerInfo(copy, pasted[i]).name !== want) cmd("layer.rename", { comp: copy, layer: pasted[i], name: want });
            }
        }
        if (si.comment) cmd("project.setComment", { items: [copy], comment: si.comment });
        try {
            var eg = cmd("essential.list", { comp: src });
            if (eg && eg.controls && eg.controls.length) R.notes.push("Essential Graphics controls of \"" + si.name + "\" were not copied.");
        } catch (e) {}
    }

    // Composition `wrap` (made by Pre-compose) becomes a pass-through: one layer of `target` at its
    // own size and time. `like` (an item info) gives it a composition's settings.
    function fillPassThrough(wrap, target, like, collapse) {
        var wi = itemInfo(wrap);
        if (wi.layers.length) cmd("edit.clear", { comp: wrap, layers: wi.layers });
        if (like) matchCompSettings(wrap, like, false);
        var ti = itemInfo(target);
        var p = { comp: wrap, item: target, time: 0 };
        if (ti.kind !== "comp" && ti.isStill) p.duration = itemInfo(wrap).duration;
        var added = cmd("layer.addItem", p);
        if (collapse && ti.kind === "comp") cmd("layer.setSwitch", { comp: wrap, layers: [added.layer], "switch": "collapse", value: true });
    }

    // The first change made to a layer: when EffectCraft refuses it, nothing changed and the layer
    // can be skipped.
    function firstStep(id, params) {
        try {
            return cmd(id, params);
        } catch (e) {
            throw { skip: true, message: errText(e) };
        }
    }
    function nativeSwitch(comp, layer, item) {
        try {
            __layer(comp, layer).replaceSource(__item(item), false);
        } catch (e) {
            throw { skip: true, message: errText(e) };
        }
    }

    function duplicateItems(ids) {
        if (!ids.length) return [];
        return cmd("project.duplicate", { items: ids }).items;
    }

    // Try layer.replaceSource once: `copy` is the item it should switch to. Returns whether it
    // works (EffectCraft 0.10 throws "not supported yet").
    function probeNative(comp, layer, copy) {
        try {
            __layer(comp, layer).replaceSource(__item(copy), false);
            NATIVE = true;
        } catch (e) {
            NATIVE = false;
        }
        return NATIVE;
    }

    // One copy run (copy index c). X collects what it made.
    function buildCopy(P, c, o, R) {
        var X = { comp: {}, foot: {}, solid: {}, uses: {}, renames: {}, made: [], top: [] };
        var cache = P.cache, i;
        function srcName(id) { return itemOf(id, cache).name; }

        // The selected compositions no other one uses: Duplicate, renamed by the rules.
        var roots = duplicateItems(P.roots);
        for (i = 0; i < roots.length; i++) {
            X.comp[P.roots[i]] = roots[i];
            X.made.push({ id: roots[i], kind: "comp", src: P.roots[i] });
            cmd("project.rename", { item: roots[i], name: o.namer(srcName(P.roots[i]), c) });
            if (!R.padItem) R.padItem = roots[i];
        }
        // Footage to duplicate: all at once.
        var fdup = duplicateItems(P.footage);
        for (i = 0; i < fdup.length; i++) {
            X.foot[P.footage[i]] = fdup[i];
            X.made.push({ id: fdup[i], kind: "footage", src: P.footage[i] });
            cmd("project.rename", { item: fdup[i], name: o.namer(srcName(P.footage[i]), c) });
        }

        var queue = [];
        for (i = 0; i < P.roots.length; i++) queue.push({ src: P.roots[i], copy: X.comp[P.roots[i]] });
        while (queue.length) {
            var e = queue.shift();
            var acts = P.actions[e.src];
            if (!acts || !acts.length) continue;
            var lids = itemInfo(e.copy).layers;
            var srcComp = readComp(e.src, cache);
            for (var a = 0; a < acts.length; a++) {
                var act = acts[a], lid = lids[act.k], orig = srcComp.layers[act.k];
                if (lid === undefined || !orig) continue;
                try {
                    if (act.type === "comp") retargetComp(e, lid, orig, act.src);
                    else if (act.type === "footage") retargetFootage(e, lid, orig, act.src);
                    else retargetSolid(e, lid, orig, act.src);
                } catch (err) {
                    // A layer EffectCraft refuses to change is skipped (nothing was changed for it);
                    // any later failure stops the run, which is then undone.
                    if (!(err && err.skip)) throw err;
                    R.notes.push("Layer \"" + orig.name + "\" in \"" + itemInfo(e.copy).name + "\" kept its source: " + err.message);
                }
            }
        }

        // Where layer.replaceSource works, copies are made with Duplicate and the layers switched
        // to them; the first switch of a session finds out whether it works (its copy is removed
        // again when it doesn't). Otherwise Pre-compose does it (see the header).
        function retargetComp(e, lid, orig, S) {
            var sname = srcName(S), switched = false;
            if (NATIVE !== false && !hasOwn(X.comp, S)) {
                var dup = duplicateItems([S])[0];
                cmd("project.rename", { item: dup, name: o.namer(sname, c) });
                if (NATIVE === null) switched = probeNative(e.copy, lid, dup);
                if (NATIVE === true) {
                    X.comp[S] = dup;
                    X.made.push({ id: dup, kind: "comp", src: S });
                    queue.push({ src: S, copy: dup });
                } else {
                    cmd("project.delete", { items: [dup] });
                }
            }
            if (NATIVE === true) {
                if (!switched) nativeSwitch(e.copy, lid, X.comp[S]);
            } else if (!hasOwn(X.comp, S)) {
                // The first layer using S: its new composition becomes S's copy.
                var made = firstStep("layer.precompose", { comp: e.copy, layers: [lid], name: o.namer(sname, c), mode: "leave" });
                fillCompCopy(made.comp, S, R);
                X.comp[S] = made.comp;
                X.made.push({ id: made.comp, kind: "comp", src: S, label: true });
                queue.push({ src: S, copy: made.comp });
            } else {
                // Another layer using S: a pass-through holding the copy.
                X.uses[S] = (X.uses[S] || 1) + 1;
                var wname = itemInfo(X.comp[S]).name + " (use " + X.uses[S] + ")";
                var w = firstStep("layer.precompose", { comp: e.copy, layers: [lid], name: wname, mode: "leave" });
                fillPassThrough(w.comp, X.comp[S], itemInfo(S), orig.collapse);
                X.made.push({ id: w.comp, kind: "pass", src: S, label: true });
            }
            settleLayerName(e.copy, lid, orig.name, sname, itemInfo(X.comp[S]).name, X);
        }

        function retargetFootage(e, lid, orig, F) {
            var copy = X.foot[F], fname = srcName(F), switched = false;
            if (NATIVE === null) switched = probeNative(e.copy, lid, copy);
            if (NATIVE === true) {
                if (!switched) nativeSwitch(e.copy, lid, copy);
            } else {
                X.uses[F] = (X.uses[F] || 0) + 1;
                var wname = itemInfo(copy).name + " (use " + X.uses[F] + ")";
                var w = firstStep("layer.precompose", { comp: e.copy, layers: [lid], name: wname, mode: "leave" });
                fillPassThrough(w.comp, copy, null, false);
                X.made.push({ id: w.comp, kind: "pass", src: F, label: true });
            }
            settleLayerName(e.copy, lid, orig.name, fname, itemInfo(copy).name, X);
        }

        function retargetSolid(e, lid, orig, Sd) {
            var sname = srcName(Sd), newName = o.namer(sname, c), switched = false;
            if (NATIVE !== false && !hasOwn(X.solid, Sd)) {
                var dup = duplicateItems([Sd])[0];
                cmd("project.rename", { item: dup, name: newName });
                if (NATIVE === null) switched = probeNative(e.copy, lid, dup);
                if (NATIVE === true) {
                    X.solid[Sd] = dup;
                    X.made.push({ id: dup, kind: "solid", src: Sd });
                } else {
                    cmd("project.delete", { items: [dup] });
                }
            }
            if (NATIVE === true) {
                if (!switched) nativeSwitch(e.copy, lid, X.solid[Sd]);
            } else {
                // Layer Settings with "affect all layers" off gives the layer its own solid.
                var r = firstStep("layer.settings", { comp: e.copy, layer: lid, affectAll: false });
                if (r && r.solid !== undefined && r.solid !== Sd) {
                    cmd("project.rename", { item: r.solid, name: newName });
                    X.made.push({ id: r.solid, kind: "solid", src: Sd });
                }
            }
            settleLayerName(e.copy, lid, orig.name, sname, newName, X);
        }

        // The copies of the selected compositions (for Select duplicates).
        for (i = 0; i < P.selected.length; i++) if (hasOwn(X.comp, P.selected[i])) X.top.push(X.comp[P.selected[i]]);
        return X;
    }

    // ------------------------------------------------------------------ folders and labels

    function newFolder(name, parent) {
        var p = { name: name };
        if (parent) p.parent = parent;
        var r = cmd("project.newFolder", p);
        return (r && typeof r === "object") ? (r.item !== undefined ? r.item : r.folder) : r;
    }

    // Where items from folder f go: f itself without grouping; with grouping a new folder next to
    // the first selected composition's folder takes that folder's items (and the top level's), and
    // other folders are recreated inside it (named by `nameFolder`).
    function makeFolderMap(group, groupName, firstFolder, nameFolder, R) {
        if (!group) return function (f) { return f; };
        var gParent = firstFolder ? (itemInfo(firstFolder).parent || 0) : 0;
        var G = newFolder(groupName, gParent);
        R.folders.push(G);
        var map = {};
        map[firstFolder] = G;
        map[0] = G;
        function dest(f) {
            if (hasOwn(map, f)) return map[f];
            var fi = itemInfo(f);
            var parent = dest(fi.parent || 0);
            var nf = newFolder(nameFolder(fi.name), parent);
            R.folders.push(nf);
            map[f] = nf;
            return nf;
        }
        dest.group = G;
        return dest;
    }

    // Move each item to its folder: one Move per destination.
    function moveAll(pairs) {
        var by = {}, keys = [], i;
        for (i = 0; i < pairs.length; i++) {
            var d = pairs[i].to, k = String(d);
            if ((itemInfo(pairs[i].id).parent || 0) === d) continue;
            if (!hasOwn(by, k)) { by[k] = { folder: d, items: [] }; keys.push(k); }
            by[k].items.push(pairs[i].id);
        }
        for (i = 0; i < keys.length; i++) cmd("project.move", { items: by[keys[i]].items, folder: by[keys[i]].folder ? by[keys[i]].folder : null });
    }

    function setLabels(pairs) {
        var by = {}, keys = [], i;
        for (i = 0; i < pairs.length; i++) {
            if (itemInfo(pairs[i].id).label === pairs[i].label) continue;
            var k = String(pairs[i].label);
            if (!hasOwn(by, k)) { by[k] = []; keys.push(k); }
            by[k].push(pairs[i].id);
        }
        for (i = 0; i < keys.length; i++) cmd("project.setLabel", { items: by[keys[i]], label: parseInt(keys[i], 10) });
    }

    // ------------------------------------------------------------------ fixing up the copies

    // After a copy run, in every new composition: effect parameters that pick a layer (Set Matte,
    // Displacement Map, an environment light's source...) point at the copy's own layer
    // (EffectCraft's Duplicate and Paste leave them on the source composition's layers), and,
    // with Update Expressions, expressions point at the new names.
    function fixCopies(X, P, R, o) {
        var cache = P.cache, compMap = {}, footMap = {}, copyByName = {}, s;
        for (s in X.comp) if (hasOwn(X.comp, s)) {
            var on = itemOf(Number(s), cache).name;
            compMap[on] = itemInfo(X.comp[s]).name;
            copyByName[on] = X.comp[s];
        }
        for (s in X.foot) if (hasOwn(X.foot, s)) footMap[itemOf(Number(s), cache).name] = itemInfo(X.foot[s]).name;
        for (s in X.comp) if (hasOwn(X.comp, s)) scanComp(X.comp[s], Number(s));

        function scanComp(C, S) {
            var ctx = {
                comp: function (n) { return hasOwn(compMap, n) ? compMap[n] : null; },
                footage: function (n) { return hasOwn(footMap, n) ? footMap[n] : null; },
                layer: function (owner, n) {
                    var id = owner === null ? C : (hasOwn(copyByName, owner) ? copyByName[owner] : null);
                    if (id === null || !hasOwn(X.renames, id)) return null;
                    return hasOwn(X.renames[id], n) ? X.renames[id][n] : null;
                }
            };
            var info = itemInfo(C), src = readComp(S, cache).layers, idMap = {}, k;
            for (k = 0; k < src.length && k < info.layers.length; k++) idMap[src[k].id] = info.layers[k];
            for (k = 0; k < info.layers.length; k++) {
                var lid = info.layers[k], tree;
                try { tree = cmd("layer.tree", { comp: C, layer: lid }); } catch (e) { continue; }
                walk(tree.properties, lid);
            }
            function walk(node, lid) {
                if (!node) return;
                if (node.type === "layer" && typeof node.value === "number" && hasOwn(idMap, node.value) && idMap[node.value] !== node.value) {
                    try {
                        cmd("prop.set", { comp: C, layer: lid, prop: node.uid, value: idMap[node.value] });
                        R.layerRefs++;
                    } catch (e) {
                        R.notes.push("\"" + info.name + "\" > \"" + layerInfo(C, lid).name + "\" > " + node.name + " still picks a layer of \"" + itemOf(S, cache).name + "\": " + errText(e));
                    }
                }
                if (o.expressions && typeof node.expression === "string" && node.expression !== "") {
                    var nt = rewriteExpression(node.expression, ctx);
                    if (nt !== node.expression) setExpression(C, lid, node, nt, info.name);
                }
                if (node.children) for (var j = 0; j < node.children.length; j++) walk(node.children[j], lid);
            }
        }

        function setExpression(C, lid, node, text, compName) {
            var where = "\"" + compName + "\" > \"" + layerInfo(C, lid).name + "\" > " + node.name;
            try {
                var on = !!__get("node", { comp: C, layer: lid, uid: node.uid }).expressionEnabled;
                var r = cmd("prop.setExpression", { comp: C, layer: lid, prop: node.uid, expression: text, enabled: on });
                R.expressions++;
                if (r && r.error) R.errors.push(where + ": " + r.error);
            } catch (e) {
                R.errors.push(where + ": " + errText(e));
            }
        }
    }

    // ------------------------------------------------------------------ undo

    // Run fn inside one undo group named `label`. EffectCraft 0.10 records every command of the
    // group against Levels of Undo, so a long group pushes the user's oldest steps out; when the
    // history is full by the end, throwaway edits (an item comment set and set back) make the
    // group longer than the undo levels, and EffectCraft then restores the history from before
    // the group and records the group as one step. A failed run is undone.
    function inUndoGroup(label, fn, R) {
        var levels = 32;
        try { levels = Math.max(1, parseInt(cmd("prefs.get", { key: "general.undoLevels" }), 10) || 32); } catch (e) {}
        var before = cmd("edit.history", {}).undo;
        var rev0 = __get("project").revision;
        var result, failure = null;
        app.beginUndoGroup(label);
        try {
            result = fn();
        } catch (e) {
            failure = e;
        }
        try {
            if (__get("project").revision !== rev0 && cmd("edit.history", {}).undo.length >= levels && R.padItem) {
                var base = itemInfo(R.padItem).comment;
                for (var k = 0; k <= levels; k++) cmd("project.setComment", { items: [R.padItem], comment: k % 2 === 0 ? base + "​" : base });
                if (itemInfo(R.padItem).comment !== base) cmd("project.setComment", { items: [R.padItem], comment: base });
            }
        } catch (e) {}
        app.endUndoGroup();
        if (failure) {
            var back = __get("project").revision === rev0;
            try {
                var h = cmd("edit.history", {}).undo;
                if (!back && h.length && h[0] === label && h.length <= before.length + 1) {
                    cmd("edit.history", { steps: 1 });
                    back = true;
                }
            } catch (e) {}
            if (!failure || typeof failure !== "object") failure = new Error(String(failure));
            try { failure.rolledBack = back; } catch (e) {}
            throw failure;
        }
        return result;
    }

    // ------------------------------------------------------------------ the two tools

    function defaults() {
        return {
            affix: { on: false, mode: "Suffix", text: "" },
            replace: { on: false, search: "", replace: "" },
            increment: { on: true, mode: "Last" },
            label: { on: false, index: 1 },
            group: { on: false, name: "" },
            exclude: { on: false, mode: "Prefix", text: "_" },
            depth: { on: false, value: "1" },
            expressions: true,
            footage: false,
            solids: false,
            select: false,
            copies: "1",
            items: null
        };
    }

    function selectedComps(o, problems) {
        var ids = o.items ? o.items.slice() : __get("project").selection.slice();
        if (!ids.length) { problems.push("Select the compositions to duplicate in the Project panel."); return ids; }
        for (var i = 0; i < ids.length; i++) {
            if (itemInfo(ids[i]).kind !== "comp") { problems.push("Select compositions only (top-level ones) in the Project panel."); break; }
        }
        return ids;
    }

    function checkCommon(o, problems) {
        if (o.exclude.on) {
            if (o.exclude.text === "") problems.push("Type what to exclude (" + o.exclude.mode + ").");
            else if (o.exclude.mode === "Matching Regex") {
                try { new RegExp(o.exclude.text); } catch (e) { problems.push("Exclude: invalid regular expression \"" + o.exclude.text + "\"."); }
            }
        }
        if (o.depth.on && !/^\d+$/.test(String(o.depth.value))) problems.push("Depth Limit must be a whole number.");
    }

    function prepare(o) {
        o.excluded = makeExclude(o);
        o.depthLimit = o.depth.on ? parseInt(o.depth.value, 10) : -1;
        o.namer = makeNamer(o);
    }

    function restoreLayerSelection(active) {
        if (!active) return;
        try {
            var info = itemInfo(active.id);
            var now = info.selectedLayers || [];
            var want = [];
            for (var i = 0; i < active.layers.length; i++) if (info.layers.indexOf(active.layers[i]) >= 0) want.push(active.layers[i]);
            if (now.join(",") !== want.join(",")) cmd("layer.select", { comp: active.id, layers: want });
        } catch (e) {}
    }

    function activeComp() {
        var a = __get("project").active;
        if (a === null || a === undefined) return null;
        try {
            var i = itemInfo(a);
            if (i.kind === "comp" && i.active) return { id: a, layers: (i.selectedLayers || []).slice() };
        } catch (e) {}
        return null;
    }

    function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }
    function unique(list) {
        var out = [], seen = {};
        for (var i = 0; i < list.length; i++) if (!hasOwn(seen, list[i])) { seen[list[i]] = true; out.push(list[i]); }
        return out;
    }
    function stoppedMessage(e) {
        return e && e.rolledBack ? "Stopped; nothing was changed: " + errText(e) : "Stopped: " + errText(e) + " (Edit > Undo takes back what was done).";
    }

    // Duplicate Selected. Returns {ok, message, errors, notes}.
    function duplicate(o) {
        var problems = [];
        if (o.affix.on && o.affix.text === "") problems.push("Type the " + o.affix.mode.toLowerCase() + " to add.");
        if (o.replace.on && o.replace.search === "") problems.push("Type the text to search for.");
        checkCommon(o, problems);
        if (o.group.on && o.group.name.replace(/\s+/g, "") === "") problems.push("Type the name of the folder to group the items into.");
        var copies = /^\d+$/.test(String(o.copies)) ? parseInt(o.copies, 10) : NaN;
        if (!(copies >= 1)) problems.push("Copies must be 1 or more.");
        var sel = selectedComps(o, problems);
        if (problems.length) return { ok: false, message: problems.join(" "), errors: [], notes: [] };
        prepare(o);

        var R = { notes: [], errors: [], expressions: 0, layerRefs: 0, folders: [], padItem: null };
        var selBefore = __get("project").selection.slice();
        var active = activeComp();
        var cache = makeCache();
        var P = makePlan(sel, o, cache);
        var firstFolder = itemOf(sel[0], cache).parent;
        var preNames = {};
        var all = __get("project").items;
        for (var pi = 0; pi < all.length; pi++) preNames[itemOf(all[pi], cache).name] = true;
        var runs = [];

        function run() {
            for (var c = 0; c < copies; c++) {
                var X = buildCopy(P, c, o, R);
                runs.push(X);
                var dest = makeFolderMap(o.group.on, groupFolderName(o.group.name, c), firstFolder, function (n) { return o.namer(n, c, true); }, R);
                var moves = [], labels = [], i;
                for (i = 0; i < X.made.length; i++) {
                    var m = X.made[i];
                    moves.push({ id: m.id, to: dest(itemOf(m.src, cache).parent) });
                    if (!o.label.on && m.label) labels.push({ id: m.id, label: itemOf(m.src, cache).label });
                }
                moveAll(moves);
                if (o.label.on) {
                    var ids = [];
                    for (i = 0; i < X.made.length; i++) ids.push(X.made[i].id);
                    for (i = 0; i < R.folders.length; i++) ids.push(R.folders[i]);
                    if (ids.length) cmd("project.setLabel", { items: ids, label: o.label.index });
                } else {
                    setLabels(labels);
                }
                fixCopies(X, P, R, o);
            }
            // The Project panel selection: as it was, or the new copies.
            var want = selBefore.slice();
            if (o.select) {
                want = [];
                for (var s = 0; s < selBefore.length; s++) if (sel.indexOf(selBefore[s]) < 0) want.push(selBefore[s]);
                for (var r = 0; r < runs.length; r++) for (var t = 0; t < runs[r].top.length; t++) want.push(runs[r].top[t]);
            }
            cmd("project.select", { items: want });
            restoreLayerSelection(active);
        }
        try {
            inUndoGroup("Duplicate Comps", run, R);
        } catch (e) {
            try { cmd("project.select", { items: selBefore }); } catch (e2) {}
            restoreLayerSelection(active);
            return { ok: false, message: stoppedMessage(e), errors: [], notes: [] };
        }

        // Counts and notes.
        var comps = 0, passes = 0, footage = 0, solids = 0, taken = [], seenTaken = {};
        for (var a = 0; a < runs.length; a++) {
            for (var b = 0; b < runs[a].made.length; b++) {
                var mm = runs[a].made[b];
                if (mm.kind === "comp") comps++;
                else if (mm.kind === "pass") passes++;
                else if (mm.kind === "footage") footage++;
                else solids++;
                if (mm.kind !== "pass") {
                    var nn = itemInfo(mm.id).name;
                    if (preNames[nn] && !seenTaken[nn]) { seenTaken[nn] = true; taken.push(nn); }
                }
            }
        }
        if (taken.length) {
            var shown = taken.slice(0, 5).join(", ") + (taken.length > 5 ? " and " + (taken.length - 5) + " more" : "");
            R.notes.push("New names other items already have (expressions find compositions by name): " + shown + ".");
        }
        if (passes) R.notes.push(plural(passes, "pass-through composition was", "pass-through compositions were") + " added (EffectCraft 0.10 can't switch layers to another source from a script).");
        var parts = [plural(comps, "comp", "comps")];
        if (footage) parts.push(plural(footage, "footage item", "footage items"));
        if (solids) parts.push(plural(solids, "solid", "solids"));
        if (R.folders.length) parts.push(plural(R.folders.length, "folder", "folders"));
        var msg = "Duplicated " + parts.join(", ") + (copies > 1 ? " (" + copies + " copies)" : "") + ".";
        if (o.expressions) msg += " " + plural(R.expressions, "expression", "expressions") + " updated.";
        if (R.errors.length) msg += " " + plural(R.errors.length, "expression error", "expression errors") + ".";
        return { ok: true, message: msg, errors: R.errors, notes: unique(R.notes), runs: runs, native: NATIVE };
    }

    // Collect Dependencies. Returns {ok, message, notes}.
    function collect(o) {
        var problems = [];
        if (!o.group.on || o.group.name.replace(/\s+/g, "") === "") problems.push("Turn on Group Items Into Folder and type the folder's name to collect into.");
        checkCommon(o, problems);
        var sel = selectedComps(o, problems);
        if (problems.length) return { ok: false, message: problems.join(" "), errors: [], notes: [] };
        prepare(o);
        var R = { notes: [], errors: [], expressions: 0, folders: [], padItem: null };
        var cache = makeCache();
        var P = makePlan(sel, o, cache);
        var items = P.order.concat(P.footage, P.solids);
        var firstFolder = itemOf(sel[0], cache).parent;
        var selBefore = __get("project").selection.slice();
        var moved = 0;
        function run() {
            var dest = makeFolderMap(true, o.group.name, firstFolder, function (n) { return n; }, R);
            R.padItem = dest.group;
            var pairs = [];
            for (var i = 0; i < items.length; i++) {
                var to = dest(itemOf(items[i], cache).parent);
                if ((itemInfo(items[i]).parent || 0) !== to) moved++;
                pairs.push({ id: items[i], to: to });
            }
            moveAll(pairs);
            if (o.label.on && R.folders.length) cmd("project.setLabel", { items: R.folders, label: o.label.index });
            cmd("project.select", { items: selBefore });
        }
        try {
            inUndoGroup("Collect Dependencies", run, R);
        } catch (e) {
            try { cmd("project.select", { items: selBefore }); } catch (e2) {}
            return { ok: false, message: stoppedMessage(e), errors: [], notes: [] };
        }
        return { ok: true, message: "Collected " + plural(moved, "item", "items") + " into \"" + o.group.name + "\".", errors: [], notes: R.notes };
    }

    var api = {
        NAME: NAME, VERSION: VERSION, defaults: defaults, duplicate: duplicate, collect: collect,
        incrementName: incrementName, makeNamer: makeNamer, groupFolderName: groupFolderName,
        rewriteExpression: rewriteExpression, makePlan: makePlan, makeCache: makeCache, prepare: prepare,
        resetNative: function () { NATIVE = null; }, native: function () { return NATIVE; }
    };
    var G = (typeof globalThis !== "undefined") ? globalThis : (function () { return this; })();
    G.DuplicateKit = api;

    // ------------------------------------------------------------------ panel

    var HELP = [
        NAME + " duplicates the compositions selected in the Project panel together with the compositions nested in them. A nested composition used several times is duplicated once, and every layer that used it uses the new copy.",
        "",
        "New Item Naming",
        "  Prefix / Suffix: adds the text before or after every new name.",
        "  Search / Replace: replaces the search text, in any case, in every new name (leave Replace empty to remove it).",
        "  Increment First / Last Number: the first or last number in a name goes up by one for each copy, keeping its zeros (Shot_009 > Shot_010, Shot_011). A name without a number gets one (Logo > Logo 2, Logo 3).",
        "  Label Color: gives every new item this label (otherwise they keep their source's label).",
        "",
        "Options",
        "  Group Items Into Folder: each copy goes into a new folder with this name, next to the folder of the first selected composition. Items from that folder go straight in; items from other folders keep their folder path inside it. More copies number the folder (Dups, Dups1, Dups2).",
        "  Exclude Items With: nested compositions and footage whose names start with, end with or match this text are not duplicated; the copies keep using them.",
        "  Depth Limit: how many levels of nested compositions are duplicated (0: the selected compositions only).",
        "  Update Expressions: expressions in the new compositions point at the new compositions, footage and layer names (comp(\"...\"), footage(\"...\"), thisComp.layer(\"...\") and comp(\"...\").layer(\"...\")).",
        "  Duplicate Footage: footage used in the compositions is duplicated too, so it can be replaced in the copy only.",
        "  Duplicate Solids: solid layers get their own new solids.",
        "  Select duplicates: selects the new copies when done.",
        "",
        "Copies: how many separate copies to make.",
        "",
        "Collect Dependencies moves the selected compositions and the compositions they use (and their footage and solids when Duplicate Footage or Duplicate Solids is on) into a new folder named in Group Items Into Folder, keeping their folder paths inside it. Exclude Items With and Depth Limit apply.",
        "",
        "A layer named after its source takes the new source's name; a layer you named keeps its name.",
        "",
        "In EffectCraft 0.10",
        "  Scripts can't switch a layer to another source yet, so a nested composition is copied through Pre-compose: the layer keeps all its own settings, and the new composition gets the nested composition's layers and settings. Essential Graphics controls and guides of nested compositions are not copied.",
        "  Another layer using the same nested composition, and each duplicated footage layer, gets a pass-through composition holding the copy, named \"(use 2)\" and so on.",
        "  A run uses EffectCraft's layer clipboard, replacing what was copied, and is one undo step."
    ];

    if (G.DUPLICATEKIT_NO_UI) return;
    buildUI(thisObj, api);

    // The panel, laid out at the After Effects panel's positions (one group with every control at
    // its bounds; the frames, labels and checkboxes are drawn).
    function buildUI(thisObj, api) {
        // Inter's advance widths (per 1000 em) for " " to "~": EffectCraft draws text in Inter.
        var INTER = [281,288,466,633,642,982,644,300,365,365,501,662,288,460,288,360,631,407,610,618,646,593,620,566,619,620,288,302,662,662,662,511,966,690,654,730,722,601,590,746,743,269,571,672,565,903,753,765,639,765,644,642,646,744,690,985,682,679,629,365,360,365,471,456,323,562,612,571,612,583,370,613,591,242,242,549,242,876,591,600,612,612,376,528,327,591,562,818,546,562,552,426,333,426,662];
        function textWidth(s, size) {
            var w = 0;
            for (var i = 0; i < s.length; i++) {
                var k = s.charCodeAt(i) - 32;
                w += (k >= 0 && k < INTER.length) ? INTER[k] : 620;
            }
            return w * size / 1000;
        }
        // ScriptUI colours are taken as linear light in EffectCraft: convert sRGB.
        function lin(v) { return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }
        var FONT = ScriptUI.newFont("dialog", "REGULAR", 11);
        var LINE = [lin(0.424), lin(0.424), lin(0.424), 0.25]; // After Effects' frame line on either theme
        var TEXT_DY = 5.6; // an 11 px line drawn this far above a row's centre has its capitals where After Effects has them
        var W = 440, H = 513;

        var docked = typeof Panel !== "undefined" && thisObj instanceof Panel;
        var win = docked ? thisObj : new Window("palette", NAME, undefined, { resizeable: false });
        win.orientation = "column";
        win.alignChildren = ["left", "top"];
        win.spacing = 0;
        win.margins = [0, 0, 0, 0];

        var root = win.add("group");
        root.alignment = ["left", "top"];
        root.preferredSize = [W, H];
        var frames = [
            { y1: 14.5, y2: 157.5, title: "New Item Naming", tx: 14 },
            { y1: 176.5, y2: 405.5, title: "Options", tx: 13 },
            { y1: 424.5, y2: 473.5, title: "Tools", tx: 13 }
        ];
        root.onDraw = function () {
            var g = this.graphics, pen = g.newPen(g.PenType.SOLID_COLOR, LINE, 1);
            for (var i = 0; i < frames.length; i++) {
                var f = frames[i], x1 = 1.5, x2 = 434.5, y1 = f.y1, y2 = f.y2, r = 4, k = r * 0.2929;
                var gap0 = f.tx - 4, gap1 = f.tx + textWidth(f.title, 11) + 4;
                g.newPath();
                g.moveTo(gap1, y1);
                g.lineTo(x2 - r, y1); g.lineTo(x2 - k, y1 + k); g.lineTo(x2, y1 + r);
                g.lineTo(x2, y2 - r); g.lineTo(x2 - k, y2 - k); g.lineTo(x2 - r, y2);
                g.lineTo(x1 + r, y2); g.lineTo(x1 + k, y2 - k); g.lineTo(x1, y2 - r);
                g.lineTo(x1, y1 + r); g.lineTo(x1 + k, y1 + k); g.lineTo(x1 + r, y1);
                g.lineTo(gap0, y1);
                g.strokePath(pen);
                g.drawString(f.title, null, f.tx, y1 - TEXT_DY, FONT);
            }
        };

        // ---------------------------------------------------------------- controls

        function drawText() { this.graphics.drawString(this.text, null, 0, this.textY, FONT); }
        // A label: static text drawn at 11 px with its capitals centred on row centre c.
        function addLabel(text, x, c) {
            var top = c - 8.5;
            var st = root.add("statictext", [x, top, x + Math.ceil(textWidth(text, 11)) + 4, top + 18], text);
            st.textY = 8.5 - TEXT_DY;
            st.onDraw = drawText;
            return st;
        }
        // A checkbox drawn as After Effects draws it, with its label (clicking either toggles it).
        function drawCheck() {
            var g = this.graphics, x = 2, y = 3;
            var pen2 = g.newPen(g.PenType.SOLID_COLOR, null, 2);
            if (this.value) {
                // A filled box (a wide stroke in the text colour) with the tick in the field colour.
                g.newPath();
                g.strokePath(g.newPen(g.PenType.SOLID_COLOR, null, 6), g.rectPath(x + 3, y + 3, 6, 6));
                g.newPath();
                tick(g, x, y);
                g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, null));
            } else {
                g.newPath();
                var box = g.rectPath(x + 1, y + 1, 10, 10);
                g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, null), box);
                g.strokePath(pen2, box);
            }
            if (this.labelText) g.drawString(this.labelText, null, 40, this.textY, FONT);
        }
        // The tick: two thick strokes as filled quads, in box coordinates.
        function tick(g, x, y) {
            function seg(ax, ay, bx, by, w) {
                var dx = bx - ax, dy = by - ay, l = Math.sqrt(dx * dx + dy * dy), nx = -dy / l * w / 2, ny = dx / l * w / 2;
                var ex = dx / l * w / 4, ey = dy / l * w / 4;
                g.moveTo(x + ax - ex + nx, y + ay - ey + ny); g.lineTo(x + bx + ex + nx, y + by + ey + ny);
                g.lineTo(x + bx + ex - nx, y + by + ey - ny); g.lineTo(x + ax - ex - nx, y + ay - ey - ny);
                g.closePath();
            }
            seg(2.6, 5.9, 4.8, 8.4, 2.1);
            seg(4.8, 8.4, 9.6, 3.1, 2.1);
        }
        function addCheck(text, c) {
            var top = c - 8.5, w = text ? 40 + Math.ceil(textWidth(text, 11)) + 4 : 20;
            var cb = root.add("checkbox", [16, top, 16 + w, top + 18], "");
            cb.labelText = text;
            cb.textY = 8.5 - TEXT_DY;
            cb.onDraw = drawCheck;
            return cb;
        }
        // Edit texts and dropdowns are EffectCraft's own, 22 px high, centred on the row.
        function addField(x1, c, x2) {
            var top = c - 10.5;
            var t = root.add("edittext", [x1, top, x2, top + 22], "");
            t.onChanging = function () {}; // sends every keystroke, so a button reads what's typed
            return t;
        }
        // EffectCraft draws a dropdown max(width - 8, label + 34) wide: as wide as its longest item
        // + 42 keeps every item inside. `minWidth`: After Effects' width.
        function addDrop(items, x, c, minWidth) {
            var longest = 0, i;
            for (i = 0; i < items.length; i++) longest = Math.max(longest, textWidth(items[i], 12));
            var w = Math.max(minWidth, Math.ceil(longest) + 42), top = c - 10.5;
            var d = root.add("dropdownlist", [x, top, x + w, top + 22], items);
            d.drawnRight = x + Math.max(w - 8, Math.ceil(longest) + 34);
            return d;
        }
        function addButton(text, x, top, w, h) { return root.add("button", [x, top, x + w, top + h], text); }

        // New Item Naming
        var affixChk = addCheck("", 36.5);
        var affixDrp = addDrop(["Prefix", "Suffix"], 56, 36.5, 62);
        var affixTxt = addField(affixDrp.drawnRight + 10, 36.5, 418);
        var replChk = addCheck("Search", 68.5);
        var srchTxt = addField(99, 68.5, 199);
        addLabel("Replace", 210, 68.5);
        var replTxt = addField(258, 68.5, 418);
        var incChk = addCheck("Increment", 100.5);
        var incDrp = addDrop(["First", "Last"], 116, 100.5, 56);
        addLabel("Number In Names", incDrp.drawnRight + 13, 100.5);
        var colChk = addCheck("Label Color", 134.5);
        var labelNames = [];
        try {
            var lp = cmd("prefs.get", { key: "labels" });
            for (var li = 0; li < lp.length && li < 16; li++) labelNames.push(String(lp[li].name));
        } catch (e) {}
        if (!labelNames.length) labelNames = __get("labels").slice(1);
        var colDrp = addDrop(labelNames, 122, 134.5, 91);

        // Options
        var grpChk = addCheck("Group Items Into Folder", 198.5);
        var grpTxt = addField(186, 198.5, 418);
        var exclChk = addCheck("Exclude Items With", 232.5);
        var exclDrp = addDrop(["Prefix", "Suffix", "Matching Regex"], 162, 232.5, 113);
        var exclTxt = addField(exclDrp.drawnRight + 10, 232.5, 418);
        var depChk = addCheck("Depth Limit", 264.5);
        var depTxt = addField(124, 264.5, 154);
        var exprChk = addCheck("Update Expressions", 294.5);
        var footChk = addCheck("Duplicate Footage (Slower)", 324.5);
        var solidChk = addCheck("Duplicate Solids", 354.5);
        var selChk = addCheck("Select duplicates", 384.5);

        // Tools and the bottom row
        var collectBtn = addButton("Collect Dependencies", 18, 435, Math.max(133, Math.ceil(textWidth("Collect Dependencies", 12)) + 17), 28);
        var helpBtn = addButton("?", 1, 484, 30, 20);
        var dupW = Math.max(118, Math.ceil(textWidth("Duplicate Selected", 12)) + 17);
        var dupBtn = addButton("Duplicate Selected", 434 - dupW, 484, dupW, 28);
        var copiesTxt = addField(434 - dupW - 9 - 30, 494.5, 434 - dupW - 9);
        addLabel("Copies", 434 - dupW - 9 - 30 - 6 - Math.ceil(textWidth("Copies", 11)), 498.5);

        // Names for automation (EffectCraft's control channel finds controls by name).
        var names = {
            affixOn: affixChk, affixMode: affixDrp, affixText: affixTxt, replaceOn: replChk, search: srchTxt, replace: replTxt,
            incrementOn: incChk, incrementMode: incDrp, labelOn: colChk, label: colDrp, groupOn: grpChk, groupName: grpTxt,
            excludeOn: exclChk, excludeMode: exclDrp, excludeText: exclTxt, depthOn: depChk, depth: depTxt, expressions: exprChk,
            footage: footChk, solids: solidChk, select: selChk, collect: collectBtn, help: helpBtn, duplicate: dupBtn, copies: copiesTxt
        };
        for (var nk in names) if (hasOwn(names, nk)) names[nk].name = nk;

        helpBtn.helpTip = "Help";
        collectBtn.helpTip = "Move the selected compositions and what they use into the Group Items Into Folder folder.";
        dupBtn.helpTip = "Duplicate the compositions selected in the Project panel with the compositions nested in them.";

        // The status line under the panel (shown when there is something to say).
        var status = win.add("statictext", undefined, "");
        status.name = "status";
        status.visible = false;
        status.onDraw = function () {
            var lines = this.text.split("\n");
            for (var i = 0; i < lines.length; i++) this.graphics.drawString(lines[i], null, 6, 3 + i * 14, FONT);
        };

        // ---------------------------------------------------------------- settings

        var saved = [
            [affixChk, "affixOn", "false"], [affixDrp, "affixMode", "Suffix"], [affixTxt, "affixText", ""],
            [replChk, "replaceOn", "false"], [srchTxt, "search", ""], [replTxt, "replace", ""],
            [incChk, "incrementOn", "true"], [incDrp, "incrementMode", "Last"],
            [colChk, "labelOn", "false"], [colDrp, "labelName", labelNames[0] || "Red"],
            [grpChk, "groupOn", "false"], [grpTxt, "groupName", ""],
            [exclChk, "excludeOn", "false"], [exclDrp, "excludeMode", "Prefix"], [exclTxt, "excludeText", "_"],
            [depChk, "depthOn", "false"], [depTxt, "depth", "1"],
            [exprChk, "expressions", "true"], [footChk, "footage", "false"], [solidChk, "solids", "false"],
            [selChk, "select", "false"], [copiesTxt, "copies", "1"]
        ];
        function getSetting(key, def) {
            try { if (app.settings.haveSetting(SECTION, key)) return String(app.settings.getSetting(SECTION, key)); } catch (e) {}
            return def;
        }
        function saveSetting(key, value) {
            try { app.settings.saveSetting(SECTION, key, String(value)); } catch (e) {}
        }
        function kind(ctrl) { return ctrl.type || ""; }
        function valueOf(ctrl) {
            var k = kind(ctrl);
            if (k === "checkbox") return ctrl.value ? "true" : "false";
            if (k === "dropdownlist") return ctrl.selection ? ctrl.selection.text : "";
            return ctrl.text;
        }
        function apply(ctrl, v) {
            var k = kind(ctrl);
            if (k === "checkbox") ctrl.value = v === "true";
            else if (k === "dropdownlist") {
                var pick = 0;
                for (var i = 0; i < ctrl.items.length; i++) if (ctrl.items[i].text === v) pick = i;
                ctrl.selection = pick;
            } else ctrl.text = v;
        }
        function save(ctrl) {
            for (var i = 0; i < saved.length; i++) if (saved[i][0] === ctrl) saveSetting(saved[i][1], valueOf(ctrl));
        }
        // The colour label default is Red, the first of EffectCraft's labels unless renamed.
        if (labelNames.length && labelNames[0] !== "Red") saved[10][2] = labelNames[0];
        for (var si = 0; si < saved.length; si++) apply(saved[si][0], getSetting(saved[si][1], saved[si][2]));

        // ---------------------------------------------------------------- behaviour

        function updateEnabled() {
            affixDrp.enabled = affixTxt.enabled = affixChk.value;
            srchTxt.enabled = replTxt.enabled = replChk.value;
            incDrp.enabled = incChk.value;
            colDrp.enabled = colChk.value;
            grpTxt.enabled = grpChk.value;
            exclDrp.enabled = exclTxt.enabled = exclChk.value;
            depTxt.enabled = depChk.value;
        }
        updateEnabled();
        for (var ci = 0; ci < saved.length; ci++) {
            (function (ctrl) {
                if (kind(ctrl) === "checkbox") ctrl.onClick = function () { updateEnabled(); save(ctrl); };
                else if (kind(ctrl) !== "edittext") ctrl.onChange = function () { save(ctrl); };
            })(saved[ci][0]);
        }
        function wholeNumber(field, what) {
            field.onChange = function () {
                if (!/^\d+$/.test(field.text)) {
                    say(what + " must be a whole number" + (field === copiesTxt ? " of 1 or more." : "."));
                    field.text = "1";
                }
                save(field);
            };
        }
        wholeNumber(depTxt, "Depth Limit");
        wholeNumber(copiesTxt, "Copies");
        affixTxt.onChange = srchTxt.onChange = replTxt.onChange = grpTxt.onChange = exclTxt.onChange = function () { save(this); };

        function options() {
            var o = api.defaults();
            o.affix = { on: affixChk.value, mode: affixDrp.selection ? affixDrp.selection.text : "Suffix", text: affixTxt.text };
            o.replace = { on: replChk.value, search: srchTxt.text, replace: replTxt.text };
            o.increment = { on: incChk.value, mode: incDrp.selection ? incDrp.selection.text : "Last" };
            o.label = { on: colChk.value, index: colDrp.selection ? colDrp.selection.index + 1 : 1 };
            o.group = { on: grpChk.value, name: grpTxt.text };
            o.exclude = { on: exclChk.value, mode: exclDrp.selection ? exclDrp.selection.text : "Prefix", text: exclTxt.text };
            o.depth = { on: depChk.value, value: depTxt.text };
            o.expressions = exprChk.value;
            o.footage = footChk.value;
            o.solids = solidChk.value;
            o.select = selChk.value;
            o.copies = copiesTxt.text;
            return o;
        }

        // Window height: EffectCraft makes a floating window 4 px taller for every control it draws
        // itself (fields, dropdowns, buttons): the bottom margin takes it back.
        function relayout() {
            if (!docked) win.margins = [0, 0, 0, 6 - 4 * 14];
            try { win.layout.layout(true); } catch (e) {}
        }
        function wrap(text, width, size) {
            var out = [], words = String(text).split(" "), line = "";
            for (var i = 0; i < words.length; i++) {
                var t = line ? line + " " + words[i] : words[i];
                if (line && textWidth(t, size || 11) > width) { out.push(line); line = words[i]; } else line = t;
            }
            if (line) out.push(line);
            return out;
        }
        function say(msg, extra) {
            var lines = msg ? wrap(msg, 420) : [];
            if (extra) for (var i = 0; i < extra.length && lines.length < 9; i++) lines = lines.concat(wrap(extra[i], 420));
            status.text = lines.join("\n");
            status.preferredSize = [W, lines.length ? lines.length * 14 + 8 : 0];
            status.visible = lines.length > 0;
            relayout();
            if (msg) try { alert(msg); } catch (e) {}
        }
        function report(r) {
            say(r.message, r.notes);
            if (r.errors && r.errors.length) listWindow(NAME + ": expression errors", ["These expressions could not be updated:", ""].concat(r.errors));
        }
        function busy(text) { return function () { say(text); }; }

        dupBtn.addEventListener("mousedown", busy("Duplicating..."));
        collectBtn.addEventListener("mousedown", busy("Collecting..."));
        dupBtn.onClick = function () {
            try { report(api.duplicate(options())); } catch (e) { say("Stopped: " + errText(e)); }
        };
        collectBtn.onClick = function () {
            try { report(api.collect(options())); } catch (e) { say("Stopped: " + errText(e)); }
        };
        helpBtn.onClick = function () { listWindow(NAME + " " + VERSION, HELP); };

        // A window listing lines (multiline fields don't scroll in EffectCraft; a list does).
        var open = {};
        function listWindow(title, lines) {
            if (open[title]) { try { open[title].close(); } catch (e) {} }
            var rows = [];
            for (var i = 0; i < lines.length; i++) {
                if (lines[i] === "") { rows.push(" "); continue; }
                var lead = /^\s*/.exec(lines[i])[0];
                // List rows are drawn at about 13 px and wrap by themselves when too long.
                var parts = wrap(lines[i].replace(/^\s+/, ""), 400 - textWidth(lead, 13) * 2, 13);
                for (var j = 0; j < parts.length; j++) rows.push((j ? lead + "  " : lead) + parts[j]);
            }
            var w = new Window("palette", title, undefined, { resizeable: false });
            w.orientation = "column";
            w.alignChildren = ["fill", "top"];
            w.margins = [10, 10, 10, 10 - 8];
            var list = w.add("listbox", undefined, rows);
            list.preferredSize = [460, 320];
            var ok = w.add("button", undefined, "Close");
            ok.alignment = ["right", "top"];
            ok.preferredSize = [80, 24];
            ok.onClick = function () { w.close(); };
            open[title] = w;
            w.show();
        }

        relayout();
        if (!docked) { win.center(); win.show(); }
        return win;
    }

})(this);
