TornCast

Raycast style page launcher

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         TornCast
// @description  Raycast style page launcher
// @namespace    http://tampermonkey.net/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @version      1.1
// @author       Upsilon [3212478]
// @match        https://www.torn.com/*
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    const HOTKEYS = [
        { alt: true, ctrl: false, shift: false, key: "k" },
        { alt: false, ctrl: true, shift: false, key: "k" },
    ];

    const COMMANDS = [
        // --- CITY ---
        { type: "nav", title: "Education", url: "https://www.torn.com/education.php#/step=main" },
        { type: "nav", title: "Gym", url: "https://www.torn.com/gym.php", aliases: ["g"] },
        { type: "nav", title: "Travel Agency", url: "https://www.torn.com/travelagency.php" },
        { type: "nav", title: "Casino", url: "https://www.torn.com/casino.php" },
        { type: "nav", title: "Dump", url: "https://www.torn.com/dump.php" },
        { type: "nav", title: "Loan Shark", url: "https://www.torn.com/loan.php" },
        { type: "nav", title: "Missions", url: "https://www.torn.com/loader.php?sid=missions" },
        { type: "nav", title: "Raceway", url: "https://www.torn.com/page.php?sid=racing" },
        { type: "nav", title: "Auction House", url: "https://www.torn.com/amarket.php#itemtab=weapons&start=0", aliases: ["ah", "auction", "auctions"] },
        { type: "nav", title: "Bazaar Directory", url: "https://www.torn.com/page.php?sid=bazaar" },
        { type: "nav", title: "Church", url: "https://www.torn.com/church.php" },
        { type: "nav", title: "Item Market", url: "https://www.torn.com/page.php?sid=ItemMarket" },
        { type: "nav", title: "Points Building", url: "https://www.torn.com/points.php" },
        { type: "nav", title: "Points Market", url: "https://www.torn.com/pmarket.php" },
        { type: "nav", title: "Estate Agents", url: "https://www.torn.com/estateagents.php" },
        { type: "nav", title: "Chronicle Archives", url: "https://www.torn.com/archives.php" },
        { type: "nav", title: "City Hall", url: "https://www.torn.com/citystats.php" },
        { type: "nav", title: "Community Center", url: "https://www.torn.com/fans.php" },
        { type: "nav", title: "Hospital", url: "https://www.torn.com/hospitalview.php" },
        { type: "nav", title: "Jail", url: "https://www.torn.com/jailview.php" },
        { type: "nav", title: "Player Committee", url: "https://www.torn.com/committee.php#/step=main" },
        { type: "nav", title: "Staff", url: "https://www.torn.com/staff.php" },
        { type: "nav", title: "Visitor Center", url: "https://www.torn.com/wiki" },
        { type: "nav", title: "Bank", url: "https://www.torn.com/bank.php" },
        { type: "nav", title: "Donator House", url: "https://www.torn.com/donator.php" },
        { type: "nav", title: "Messaging Inc", url: "https://www.torn.com/messageinc.php" },
        { type: "nav", title: "Stock Market", url: "https://www.torn.com/page.php?sid=stocks" },
        { type: "nav", title: "Big Al's Gun Shop", url: "https://www.torn.com/bigalgunshop.php" },
        { type: "nav", title: "Bits 'n' Bobs", url: "https://www.torn.com/shops.php?step=bitsnbobs" },
        { type: "nav", title: "Cyber Force", url: "https://www.torn.com/shops.php?step=cyberforce" },
        { type: "nav", title: "Docks", url: "https://www.torn.com/shops.php?step=docks" },
        { type: "nav", title: "Jewelry Store", url: "https://www.torn.com/shops.php?step=jewelry" },
        { type: "nav", title: "Nikeh Sports", url: "https://www.torn.com/shops.php?step=nikeh" },
        { type: "nav", title: "Pawn Shop", url: "https://www.torn.com/shops.php?step=pawnshop" },
        { type: "nav", title: "Pharmacy", url: "https://www.torn.com/shops.php?step=pharmacy" },
        { type: "nav", title: "Post Office", url: "https://www.torn.com/shops.php?step=postoffice" },
        { type: "nav", title: "Print Store", url: "https://www.torn.com/shops.php?step=printstore" },
        { type: "nav", title: "Recycling Center", url: "https://www.torn.com/shops.php?step=recyclingcenter" },
        { type: "nav", title: "Super Store", url: "https://www.torn.com/shops.php?step=super" },
        { type: "nav", title: "Sweet Shop", url: "https://www.torn.com/shops.php?step=candy" },
        { type: "nav", title: "TC Clothing", url: "https://www.torn.com/shops.php?step=clothes" },

        // --- PERSONAL ---
        { type: "nav", title: "Home", url: "https://www.torn.com/home.php"},
        { type: "nav", title: "Inventory", url: "https://www.torn.com/item.php"},
        { type: "nav", title: "Trade", url: "https://www.torn.com/trade.php"},
        { type: "nav", title: "Properties", url: "https://www.torn.com/properties.php"},
        { type: "nav", title: "Job", url: "https://www.torn.com/jobs.php"},
        { type: "nav", title: "Message", url: "https://www.torn.com/messages.php"},
        { type: "nav", title: "Event", url: "https://www.torn.com/page.php?sid=events"},
        { type: "nav", title: "Log", url: "https://www.torn.com/page.php?sid=log"},
        { type: "nav", title: "Friends", url: "https://www.torn.com/page.php?sid=list&type=friends"},
        { type: "nav", title: "Enemies", url: "https://www.torn.com/page.php?sid=list&type=enemies"},
        { type: "nav", title: "Targets", url: "https://www.torn.com/page.php?sid=list&type=targets"},

        // --- OTHER ---
        { type: "nav", title: "News", url: "https://www.torn.com/newspaper.php"},
        { type: "nav", title: "Bounties", url: "https://www.torn.com/bounties.php#!p=main"},
        { type: "nav", title: "Calendar", url: "https://www.torn.com/calendar.php"},
        { type: "nav", title: "Hall Of Fame", url: "https://www.torn.com/page.php?sid=hof"},

        // --- CATEGORY ---
        {
            title: "Crimes",
            items: [
                { type: "nav", title: "Search For Cash", url: "https://www.torn.com/page.php?sid=crimes#/searchforcash" },
                { type: "nav", title: "Bootlegging", url: "https://www.torn.com/page.php?sid=crimes#/bootlegging" },
                { type: "nav", title: "Graffiti", url: "https://www.torn.com/page.php?sid=crimes#/graffiti" },
                { type: "nav", title: "Shoplifting", url: "https://www.torn.com/page.php?sid=crimes#/shoplifting" },
                { type: "nav", title: "Pickpocketing", url: "https://www.torn.com/page.php?sid=crimes#/pickpocketing" },
                { type: "nav", title: "Card Skimming", url: "https://www.torn.com/page.php?sid=crimes#/cardskimming" },
                { type: "nav", title: "Burglary", url: "https://www.torn.com/page.php?sid=crimes#/burglary" },
                { type: "nav", title: "Hustling", url: "https://www.torn.com/page.php?sid=crimes#/hustling" },
                { type: "nav", title: "Disposal", url: "https://www.torn.com/page.php?sid=crimes#/disposal" },
                { type: "nav", title: "Cracking", url: "https://www.torn.com/page.php?sid=crimes#/cracking" },
                { type: "nav", title: "Forgery", url: "https://www.torn.com/page.php?sid=crimes#/forgery" },
                { type: "nav", title: "Scamming", url: "https://www.torn.com/page.php?sid=crimes#/scamming" },
                { type: "nav", title: "Arson", url: "https://www.torn.com/page.php?sid=crimes#/arson" }
            ],
        },
        {
            title: "Company",
            items: [
                { type: "nav", title: "Income Chart", url: "https://www.torn.com/companies.php#/option=income-chart" },
                { type: "nav", title: "Employees", url: "https://www.torn.com/companies.php#/option=employees" },
                { type: "nav", title: "Company Positions", url: "https://www.torn.com/companies.php#/option=company-positions" },
                { type: "nav", title: "Applications", url: "https://www.torn.com/companies.php#/option=applications" },
                { type: "nav", title: "Pricing", url: "https://www.torn.com/companies.php#/option=pricing" },
                { type: "nav", title: "Stock", url: "https://www.torn.com/companies.php#/option=stock" },
                { type: "nav", title: "Advertising", url: "https://www.torn.com/companies.php#/option=advertising" },
                { type: "nav", title: "Funds", url: "https://www.torn.com/companies.php#/option=funds" },
                { type: "nav", title: "Upgrades", url: "https://www.torn.com/companies.php#/option=upgrades" },
                { type: "nav", title: "Edit Profile", url: "https://www.torn.com/companies.php#/option=edit-profile" },
                { type: "nav", title: "Change Director", url: "https://www.torn.com/companies.php#/option=change-director" },
                { type: "nav", title: "Sell Company", url: "https://www.torn.com/companies.php#/option=sell-company" },
            ]
        },
        {
            title: "Faction",
            items: [
                { type: "nav", title: "Home", url: "https://www.torn.com/factions.php?step=your&type=1" },
                { type: "nav", title: "Info", url: "https://www.torn.com/factions.php?step=your&type=1#/tab=info" },
                { type: "nav", title: "Territory", url: "https://www.torn.com/factions.php?step=your&type=5#/tab=territory" },
                { type: "nav", title: "Rank", url: "https://www.torn.com/factions.php?step=your&type=12#/tab=rank" },
                { type: "nav", title: "Crimes", url: "https://www.torn.com/factions.php?step=your&type=12#/tab=crimes" },
                { type: "nav", title: "Upgrades", url: "https://www.torn.com/factions.php?step=your&type=12#/tab=upgrades" },
                { type: "nav", title: "Armory", url: "https://www.torn.com/factions.php?step=your&type=12#/tab=armoury" },
                { type: "nav", title: "Controls", url: "https://www.torn.com/factions.php?step=your&type=12#/tab=controls&option=members" },
            ]
        },


        // --- SUB CATEGORY EXAMPLE ---
        {
            title: "Utilities",
            items: [
                {
                    type: "group",
                    title: "Scroll",
                    items: [
                        { type: "action", title: "Top", run: () => window.scrollTo({ top: 0, behavior: "smooth" }) },
                        { type: "action", title: "Bottom", run: () => window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }) },
                    ],
                },
                { type: "action", title: "Copy current URL", run: () => navigator.clipboard.writeText(location.href) },
                { type: "action", title: "Reload", run: () => location.reload() },
            ],
        },
    ];

    let isOpen = false;
    let root, input, list, header;
    let filtered = [];
    let searchIndex = [];
    let selectedIndex = 0;
    let usingKeyboard = true;

    const pathStack = [{ title: "Root", items: buildRootItems(COMMANDS) }];

    function resetTree() {
        pathStack.length = 1;
        pathStack[0].title = "Root";
        pathStack[0].items = buildRootItems(COMMANDS);
    }

    function buildRootItems(commands) {
        const items = [];

        for (const x of commands) {
            const isCategory = x && typeof x === "object" && !x.type && Array.isArray(x.items) && typeof x.title === "string";
            if (isCategory) {
                items.push({ type: "group", title: x.title, items: x.items });
            } else {
                items.push(x);
            }
        }

        return items.filter(Boolean);
    }

    const normalize = (s) => (s || "").toLowerCase().trim();

    function toast(msg) {
        const t = document.createElement("div");
        t.textContent = msg;
        t.style.cssText =
            "position:fixed;bottom:16px;left:16px;z-index:2147483647;" +
            "background:rgba(0,0,0,.75);color:white;padding:10px 12px;border-radius:10px;" +
            "font:13px -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif";
        document.documentElement.appendChild(t);
        setTimeout(() => t.remove(), 1200);
    }

    function goTo(url) {
        if (!url) return;
        const finalUrl = url.startsWith("http") ? url : new URL(url, location.origin).toString();
        location.assign(finalUrl);
    }

    function looksLikeMath(q) {
        const s = (q || "").replace(/^=\s*/, "").trim();
        if (!s) return false;

        if (!/[0-9]/.test(s)) return false;
        return /[+\-*/()%]/.test(s);
    }

    function safeCalc(q) {
        const raw = (q || "").replace(/^=\s*/, "").trim();
        const parsed = parseAndEvalExpression(raw);
        return parsed;
    }

    function parseAndEvalExpression(expr) {
        const { tokens, format } = tokenize(expr);
        if (!tokens.length) throw new Error("Empty expression");

        const rpn = toRPN(tokens);
        const value = evalRPN(rpn);

        if (typeof value !== "number" || !isFinite(value)) throw new Error("Bad result");

        return {
            value,
            formatted: formatResult(value, format),
        };
    }

    function tokenize(expr) {
        const s = (expr || "").trim();
        let i = 0;

        const tokens = [];
        const format = {
            currency: null,
            maxDecimalsSeen: 0,
            anyCommaFormatting: false,
        };

        const isWS = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
        const isDigit = (c) => c >= "0" && c <= "9";

        const prevTokenType = () => (tokens.length ? tokens[tokens.length - 1].type : "start");

        const isUnaryMinusPosition = () => {
            const t = prevTokenType();
            return t === "start" || t === "op" || t === "(";
        };

        function readNumber(startIndex, signed) {
            let j = startIndex;

            let sign = 1;
            if (signed) {
                sign = -1;
                j++;
                while (j < s.length && isWS(s[j])) j++;
            }

            let currency = null;
            if (s[j] === "$") {
                currency = "$";
                j++;
                while (j < s.length && isWS(s[j])) j++;
            }

            let hasDigits = false;
            let hasDot = false;
            let sawComma = false;
            let numStr = "";

            if (s[j] === ".") {
                hasDot = true;
                numStr += ".";
                j++;
            }

            while (j < s.length) {
                const c = s[j];
                if (isDigit(c)) {
                    hasDigits = true;
                    numStr += c;
                    j++;
                    continue;
                }
                if (c === ",") {
                    sawComma = true;
                    numStr += ",";
                    j++;
                    continue;
                }
                if (c === ".") {
                    if (hasDot) break;
                    hasDot = true;
                    numStr += ".";
                    j++;
                    continue;
                }
                break;
            }

            if (!hasDigits) return null;

            if (currency) format.currency = format.currency || currency;
            if (sawComma) format.anyCommaFormatting = true;

            const decimals = (() => {
                const idx = numStr.indexOf(".");
                return idx >= 0 ? (numStr.length - idx - 1) : 0;
            })();
            format.maxDecimalsSeen = Math.max(format.maxDecimalsSeen, decimals);

            const normalized = numStr.replace(/,/g, "");
            const n = Number(normalized);
            if (!isFinite(n)) throw new Error("Invalid number");

            return { token: { type: "num", value: sign * n }, nextIndex: j };
        }

        while (i < s.length) {
            const c = s[i];

            if (isWS(c)) { i++; continue; }

            if (c === "(") { tokens.push({ type: "(" }); i++; continue; }
            if (c === ")") { tokens.push({ type: ")" }); i++; continue; }

            if (c === "+" || c === "*" || c === "/") {
                tokens.push({ type: "op", op: c });
                i++;
                continue;
            }

            if (c === "-") {
                if (isUnaryMinusPosition()) {
                    const attempt = readNumber(i, true);
                    if (attempt) {
                        tokens.push(attempt.token);
                        i = attempt.nextIndex;
                        continue;
                    }
                    tokens.push({ type: "num", value: 0 });
                    tokens.push({ type: "op", op: "-" });
                    i++;
                    continue;
                } else {
                    tokens.push({ type: "op", op: "-" });
                    i++;
                    continue;
                }
            }

            if (c === "%") {
                tokens.push({ type: "pct" });
                i++;
                continue;
            }

            if (c === "$" || c === "." || isDigit(c)) {
                const attempt = readNumber(i, false);
                if (!attempt) throw new Error("Invalid number format");
                tokens.push(attempt.token);
                i = attempt.nextIndex;
                continue;
            }

            throw new Error(`Invalid character: ${c}`);
        }

        return { tokens, format };
    }

    function toRPN(tokens) {
        const output = [];
        const stack = [];

        const precedence = (t) => {
            if (t.type === "pct") return 3;
            if (t.type === "op" && (t.op === "*" || t.op === "/")) return 2;
            if (t.type === "op" && (t.op === "+" || t.op === "-")) return 1;
            return 0;
        };

        for (const t of tokens) {
            if (t.type === "num") {
                output.push(t);
                continue;
            }

            if (t.type === "pct") {
                output.push(t);
                continue;
            }

            if (t.type === "op") {
                while (stack.length) {
                    const top = stack[stack.length - 1];
                    if (top.type === "op" && precedence(top) >= precedence(t)) {
                        output.push(stack.pop());
                    } else {
                        break;
                    }
                }
                stack.push(t);
                continue;
            }

            if (t.type === "(") { stack.push(t); continue; }

            if (t.type === ")") {
                while (stack.length && stack[stack.length - 1].type !== "(") {
                    output.push(stack.pop());
                }
                if (!stack.length) throw new Error("Mismatched parentheses");
                stack.pop();
                continue;
            }

            throw new Error("Unknown token");
        }

        while (stack.length) {
            const top = stack.pop();
            if (top.type === "(" || top.type === ")") throw new Error("Mismatched parentheses");
            output.push(top);
        }

        return output;
    }

    function evalRPN(rpn) {
        const st = [];

        for (const t of rpn) {
            if (t.type === "num") {
                st.push(t.value);
                continue;
            }

            if (t.type === "pct") {
                if (st.length < 1) throw new Error("Bad %");
                const a = st.pop();
                st.push(a / 100);
                continue;
            }

            if (t.type === "op") {
                if (st.length < 2) throw new Error("Bad operator usage");
                const b = st.pop();
                const a = st.pop();
                let r;

                if (t.op === "+") r = a + b;
                else if (t.op === "-") r = a - b;
                else if (t.op === "*") r = a * b;
                else if (t.op === "/") r = a / b;
                else throw new Error("Unknown operator");

                st.push(r);
                continue;
            }

            throw new Error("Unknown RPN token");
        }

        if (st.length !== 1) throw new Error("Bad expression");
        return st[0];
    }

    function formatResult(value, format) {
        const currency = format.currency;
        const decimals = format.maxDecimalsSeen > 0 ? format.maxDecimalsSeen : 0;
        const abs = Math.abs(value);
        const sign = value < 0 ? "-" : "";

        const formattedNumber = abs.toLocaleString("en-US", {
            minimumFractionDigits: decimals,
            maximumFractionDigits: decimals,
        });

        return `${sign}${formattedNumber}`;
    }


    function ensureUI() {
        if (root) return;

        root = document.createElement("div");
        root.id = "ups-raycast-root";
        root.innerHTML = `
      <div class="ups-backdrop"></div>
      <div class="ups-panel" role="dialog" aria-modal="true">
        <div class="ups-header"><div class="ups-breadcrumb"></div></div>
        <input class="ups-input" type="text" placeholder="Search… (math like 23*42 works too)" />
        <div class="ups-hint">Alt+K / Ctrl+K • ↑↓ • Enter • Esc • Backspace(empty)=back</div>
        <div class="ups-list" role="listbox"></div>
      </div>
    `;

        const style = document.createElement("style");
        style.textContent = `
      #ups-raycast-root{position:fixed;inset:0;z-index:2147483647;display:none}
      #ups-raycast-root .ups-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.35);backdrop-filter:blur(4px)}
      #ups-raycast-root .ups-panel{position:absolute;top:12vh;left:50%;transform:translateX(-50%);
        width:min(760px,92vw);background:rgba(20,20,22,.98);border:1px solid rgba(255,255,255,.08);
        border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.45);color:#fff;overflow:hidden;
        font:14px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;}
      #ups-raycast-root .ups-header{padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}
      #ups-raycast-root .ups-breadcrumb{opacity:.9;font-size:12px}
      #ups-raycast-root .ups-input{width:100%;box-sizing:border-box;padding:14px;border:none;outline:none;
        background:rgba(255,255,255,.06);color:#fff;font-size:15px}
      #ups-raycast-root .ups-hint{padding:8px 14px;opacity:.65;font-size:12px;border-bottom:1px solid rgba(255,255,255,.06)}
      #ups-raycast-root .ups-list{max-height:52vh;overflow:auto}
      #ups-raycast-root .ups-item{padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06);cursor:pointer}
      #ups-raycast-root .ups-item .title{font-weight:600}
      #ups-raycast-root .ups-item .subtitle{opacity:.7;font-size:12px;margin-top:2px}
      #ups-raycast-root .ups-item.selected{background:rgba(255,255,255,.10)}
    `;
        document.documentElement.appendChild(style);
        document.documentElement.appendChild(root);

        input = root.querySelector(".ups-input");
        list = root.querySelector(".ups-list");
        header = root.querySelector(".ups-breadcrumb");

        root.addEventListener("mousemove", () => {
            usingKeyboard = false;
        });

        root.querySelector(".ups-backdrop").addEventListener("click", close);

        input.addEventListener("input", () => {
            selectedIndex = 0;
            render();
        });

        input.addEventListener("keydown", (e) => {
            if (e.key === "Escape") { e.preventDefault(); close(); return; }

            if (e.key === "Backspace" && input.value.length === 0) {
                if (pathStack.length > 1) {
                    e.preventDefault();
                    pathStack.pop();
                    selectedIndex = 0;
                    render();
                }
                return;
            }

            if (e.key === "ArrowDown") { e.preventDefault(); selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1); render(); return; }
            if (e.key === "ArrowUp") { e.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); render(); return; }
            if (e.key === "Enter") { e.preventDefault(); runSelected(); return; }
        });
    }

    function breadcrumbText() {
        return pathStack.map(p => p.title).join("  ›  ");
    }

    function open() {
        ensureUI();
        isOpen = true;
        root.style.display = "block";
        input.value = "";
        selectedIndex = 0;
        render();
        setTimeout(() => input.focus(), 0);
    }

    function close() {
        isOpen = false;
        if (root) root.style.display = "none";
    }

    function currentItems() {
        return pathStack[pathStack.length - 1].items || [];
    }

    function norm(s) {
        return (s || "")
            .toLowerCase()
            .normalize("NFKD")
            .replace(/[\u0300-\u036f]/g, "")
            .trim();
    }

    function initialsOf(title) {
        return norm(title)
            .split(/[\s\-_/]+/)
            .filter(Boolean)
            .map(w => w[0])
            .join("");
    }

    function buildSearchIndex() {
        const rootItems = buildRootItems(COMMANDS);
        const out = [];

        function visit(items, breadcrumb) {
            for (const it of items || []) {
                if (!it) continue;

                const isGroup = it.type === "group" && Array.isArray(it.items);
                const title = it.title || "";
                const url = it.url || "";
                const aliases = Array.isArray(it.aliases) ? it.aliases : [];
                const bc = [...breadcrumb, title].filter(Boolean);
                const bcText = bc.join(" › ");

                const hayParts = [
                    title,
                    url,
                    bcText,
                    ...aliases
                ].map(norm);

                out.push({
                    item: it,
                    breadcrumb: bc,
                    breadcrumbText: bcText,
                    hay: hayParts.join(" "),
                    initials: initialsOf(title),
                    aliasInitials: aliases.map(initialsOf),
                });

                if (isGroup) {
                    visit(it.items, bc);
                }
            }
        }

        visit(rootItems, []);
        searchIndex = out;
    }

    function scoreMatch(q, entry) {
        const t = norm(entry.item.title);
        const hay = entry.hay;
        const init = entry.initials;

        if (!q) return -Infinity;

        let score = -Infinity;

        if (t === q) score = Math.max(score, 1000);
        if (t.startsWith(q)) score = Math.max(score, 850);
        if (hay.includes(q)) score = Math.max(score, 500);
        if (init && init === q) score = Math.max(score, 900);
        if (init && init.startsWith(q)) score = Math.max(score, 820);

        const aliases = Array.isArray(entry.item.aliases) ? entry.item.aliases.map(norm) : [];
        if (aliases.includes(q)) score = Math.max(score, 920);
        if (aliases.some(a => a.startsWith(q))) score = Math.max(score, 830);
        if (aliases.some(a => a.includes(q))) score = Math.max(score, 620);

        const bc = norm(entry.breadcrumbText);
        if (bc.includes(q)) score = Math.max(score, score + 20);
        if (!t.includes(q) && !aliases.some(a => a.includes(q)) && hay.includes(q)) {
            score -= 30;
        }

        return score;
    }

    function globalSearch(qRaw) {
        const q = norm(qRaw);
        if (!q) return [];

        const results = [];

        for (const entry of searchIndex) {
            const sc = scoreMatch(q, entry);
            if (sc > 0) {
                results.push({ entry, score: sc });
            }
        }

        results.sort((a, b) => b.score - a.score);

        return results.slice(0, 80).map(r => ({
            ...r.entry.item,
            _breadcrumb: r.entry.breadcrumbText,
            _score: r.score,
        }));
    }

    function render() {
        header.textContent = breadcrumbText();
        const q = normalize(input.value);

        let calcItem = null;
        if (q && looksLikeMath(q)) {
            try {
                const { value, formatted } = safeCalc(q);
                calcItem = {
                    type: "action",
                    title: `Result: ${formatted}`,
                    subtitle: "Enter to copy result",
                    run: () => navigator.clipboard.writeText(String(formatted)),
                };
            } catch (e) {
                calcItem = {
                    type: "noop",
                    title: "Calculator: invalid expression",
                    subtitle: "Allowed: 0-9 + - * / ( ) . %  (you can type '23*42')",
                    run: () => {},
                };
            }
        }

        let items;
        if (!q) {
            items = currentItems();
            filtered = items.filter((it) => {
                if (!q) return true;
                const url = it.url || "";
                const subtitle = it.subtitle || (it.type === "group" ? "Open category" : "");
                const hay = normalize(it.title + " " + url + " " + subtitle);
                return hay.includes(q);
            });
        } else {
            filtered = globalSearch(q);
        }

        if (calcItem) filtered = [calcItem, ...filtered];

        list.innerHTML = "";
        if (!filtered.length) {
            list.innerHTML = `<div class="ups-item"><div class="title">No results</div><div class="subtitle">Try another query</div></div>`;
            return;
        }

        filtered.forEach((a, idx) => {
            const item = document.createElement("div");
            item.className = "ups-item" + (idx === selectedIndex ? " selected" : "");
            const breadcrumb = a._breadcrumb || ""; // ajouté par globalSearch
            const subtitle =
                q
                    ? (breadcrumb || (a.type === "nav" ? a.url : (a.subtitle || (a.type === "group" ? "Open category" : ""))))
                    : (a.type === "nav" ? a.url : (a.subtitle || (a.type === "group" ? "Open category" : "")));
            item.innerHTML = `<div class="title"></div><div class="subtitle"></div>`;
            item.querySelector(".title").textContent = a.title;
            item.querySelector(".subtitle").textContent = subtitle || "";
            item.addEventListener("mouseenter", () => {
                if (usingKeyboard) return;
                selectedIndex = idx;
                render();
            });
            item.addEventListener("click", () => {
                usingKeyboard = false;
                selectedIndex = idx;
                runSelected();
            });
            list.appendChild(item);
        });
    }

    function runSelected() {
        const a = filtered[selectedIndex];
        if (!a) return;

        if (a.type === "group") {
            pathStack.push({ title: a.title, items: a.items || [] });
            input.value = "";
            selectedIndex = 0;
            render();
            return;
        }

        resetTree();
        close();

        try {
            if (a.type === "nav") goTo(a.url);
            else if (a.type === "action") a.run?.();
            else a.run?.();
        } catch (e) {
            console.error(e);
            toast("Error: check console");
        }
    }

    function isTypingInField(target) {
        const el = target || document.activeElement;
        if (!el) return false;

        const tag = (el.tagName || "").toLowerCase();
        if (tag === "input" || tag === "textarea" || tag === "select") return true;
        if (el.isContentEditable) return true;

        return false;
    }

    function matchesHotkey(e) {
        const key = (e.key || "").toLowerCase();
        return HOTKEYS.some((h) =>
            key === h.key &&
            e.altKey === !!h.alt &&
            e.ctrlKey === !!h.ctrl &&
            e.shiftKey === !!h.shift
        );
    }

    function onKeydown(e) {
        if (!matchesHotkey(e)) return;
        if (!isOpen && isTypingInField(e.target)) return;

        e.preventDefault();
        e.stopPropagation();

        isOpen ? close() : open();
    }

    buildSearchIndex();
    window.addEventListener("keydown", onKeydown, true);
})();