Postmarks (Gmail Quick Links Port)

Postmarks is a userscript port of kevinwucodes/gmail-quick-links with a few QoL improvements. Adds Import/Export feature and Bootrap Icons.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Postmarks (Gmail Quick Links Port)
// @namespace    https://github.com/appel/userscripts
// @version      0.2
// @description  Postmarks is a userscript port of kevinwucodes/gmail-quick-links with a few QoL improvements. Adds Import/Export feature and Bootrap Icons.
// @author       Ap
// @match        https://mail.google.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_download
// @run-at       document-end
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    const ICONS = {
        add: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2"/></svg>`,
        delete: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16"><path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/></svg>`,
        edit: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/></svg>`,
        globe: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-globe" viewBox="0 0 16 16"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/></svg>`,
        upload: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/><path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/></svg>`,
        download: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/></svg>`,
        up: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-up" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5"/></svg>`,
        down: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-down" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1"/></svg>`
    };

    const css = `
        #postmarksContainer { padding-top: 1rem; color: #222; }
        #postmarksHeader, .pm-action-group, .pm-item, .pm-controls,
        .pm-header-btn, .pm-icon-btn {
            display: flex;
            align-items: center;
        }
        #postmarksHeader { justify-content: space-between; padding-left: 1.5rem; margin-bottom: 1rem; }
        #postmarksHeader h2 { font-size: 16px; font-weight: 500; color: #202124; margin: 0; }
        .pm-action-group, .pm-controls { gap: 2px; }
        .pm-header-btn, .pm-icon-btn {
            justify-content: center;
            cursor: pointer;
            color: #5f6368;
            width: 16px;
            height: 16px;
        }
        .pm-header-btn:hover, .pm-icon-btn:hover { color: #000; }
        .pm-header-btn svg { width: 14px; height: 14px; }
        .pm-icon-btn svg { width: 12px; height: 12px; }
        .pm-list { padding-left: 1.5rem; }
        .pm-item { justify-content: space-between; }
        .pm-item a {
            text-decoration: none;
            color: #777;
            flex-grow: 1;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            cursor: pointer;
        }
        .pm-item:hover a, .pm-item a:hover { color: #000; }
        .pm-header-btn, .pm-controls { opacity: 0; transition: opacity 0.2s; }
        #postmarksContainer:hover .pm-header-btn, .pm-item:hover .pm-controls { opacity: 1; }
        .pm-is-global { color: #1a73e8 !important; }
        .pm-is-local { color: #dadce0 !important; }
        .pm-is-local:hover { color: #5f6368 !important; }
        .pm-disabled { opacity: 0.3; cursor: default !important; }
        .pm-disabled:hover { color: #5f6368 !important; background-color: transparent !important; }
    `;
    GM_addStyle(css);

    const STORAGE_KEY = 'postmarks_data';

    const getStore = () => GM_getValue(STORAGE_KEY, { linkList: {}, accountList: {} });
    const setStore = (data) => GM_setValue(STORAGE_KEY, data);

    const selectors = {
        labelControlsContainer: () => document.getElementsByClassName('ajl aib aZ6')[0],
        sidebar: () => document.querySelector('div.wT'),
        accountName: () => {
            try {
                const node = Array.from(document.querySelectorAll('a[aria-label]'))
                    .map(n => n.attributes['aria-label'].nodeValue)
                    .find(t => /\(.+@.+\)/.test(t));
                if (node) return node.match(/\((.+@.+)\)/)[1];
            } catch (e) { console.error("Postmarks: Could not find account name", e); }
            return "unknown_account";
        }
    };

    // Helper to safely parse SVG strings (Bypasses TrustedHTML issues)
    function parseSVG(svgString) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(svgString, "image/svg+xml");
        return document.importNode(doc.documentElement, true);
    }

    function getCurrentHash() { return window.location.hash; }

    function addLink() {
        const account = selectors.accountName();
        const urlHash = getCurrentHash();
        const name = prompt(`Enter title for current link [${urlHash.substring(1)}]`, urlHash.substring(1));

        if (!name) return;

        const data = getStore();
        if (!data.accountList) data.accountList = {};
        if (!data.accountList[account]) data.accountList[account] = {};

        data.accountList[account][name] = { urlHash };
        setStore(data);
        renderLinks();
    }

    function removeLink(type, name) {
        if (!confirm(`Delete link "${name}"?`)) return;
        const data = getStore();
        const account = selectors.accountName();

        if (type === 'global') delete data.linkList[name];
        else delete data.accountList[account][name];

        setStore(data);
        renderLinks();
    }

    function renameLink(type, oldName) {
        const newName = prompt(`Rename link "${oldName}"?`, oldName);
        if (!newName || newName === oldName) return;

        const data = getStore();
        const account = selectors.accountName();
        let sourceObj = (type === 'global') ? data.linkList : data.accountList[account];

        // Retain order during rename
        const newObj = {};
        Object.keys(sourceObj).forEach(key => {
            if (key === oldName) newObj[newName] = sourceObj[oldName];
            else newObj[key] = sourceObj[key];
        });

        if (type === 'global') data.linkList = newObj;
        else data.accountList[account] = newObj;

        setStore(data);
        renderLinks();
    }

    function toggleGlobal(type, name) {
        const data = getStore();
        const account = selectors.accountName();

        if (type === 'global') {
            const item = data.linkList[name];
            delete data.linkList[name];
            if (!data.accountList[account]) data.accountList[account] = {};
            data.accountList[account][name] = item;
        } else {
            const item = data.accountList[account][name];
            delete data.accountList[account][name];
            if (!data.linkList) data.linkList = {};
            data.linkList[name] = item;
        }

        setStore(data);
        renderLinks();
    }

    function moveLink(type, name, direction) {
        const data = getStore();
        const account = selectors.accountName();
        let list = (type === 'global') ? data.linkList : data.accountList[account];

        if (!list) return;

        const keys = Object.keys(list);
        const idx = keys.indexOf(name);

        if (idx === -1) return;
        if (direction === -1 && idx === 0) return;
        if (direction === 1 && idx === keys.length - 1) return;

        const swapIdx = idx + direction;
        [keys[idx], keys[swapIdx]] = [keys[swapIdx], keys[idx]];

        const newObj = {};
        keys.forEach(key => {
            newObj[key] = list[key];
        });

        if (type === 'global') data.linkList = newObj;
        else data.accountList[account] = newObj;

        setStore(data);
        renderLinks();
    }

    function handleExport() {
        const data = getStore();
        const jsonStr = JSON.stringify(data, null, 2);
        const fileName = `postmarks_${new Date().toISOString().slice(0, 10)}.json`;

        if (typeof GM_download !== 'undefined') {
            GM_download({
                url: 'data:application/json;charset=utf-8,' + encodeURIComponent(jsonStr),
                name: fileName,
                saveAs: true
            });
        } else {
            alert("Error: GM_download is not supported by your userscript manager.");
        }
    }

    function handleImport() {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.json';
        input.style.display = 'none';

        input.onchange = (e) => {
            const file = e.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = (event) => {
                try {
                    const parsedData = JSON.parse(event.target.result);
                    if (!parsedData || (typeof parsedData.linkList === 'undefined' && typeof parsedData.accountList === 'undefined')) {
                        alert("Invalid backup file.");
                        return;
                    }
                    if (confirm("Importing will REPLACE all existing links. Continue?")) {
                        setStore(parsedData);
                        renderLinks();
                        alert("Import successful.");
                    }
                } catch (err) {
                    alert("Error parsing JSON file.");
                }
            };
            reader.readAsText(file);
        };
        document.body.appendChild(input);
        input.click();
        document.body.removeChild(input);
    }

    function createIconBtn(svgHtml, title, action, extraClass = '') {
        const btn = document.createElement('div');
        btn.className = `pm-icon-btn ${extraClass}`;
        btn.title = title;
        btn.appendChild(parseSVG(svgHtml));

        if (action) {
            btn.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                action();
            };
        }
        return btn;
    }

    function createLinkRow(type, name, urlHash, index, totalCount) {
        const div = document.createElement('div');
        div.className = 'pm-item';

        const a = document.createElement('a');
        a.href = urlHash;
        a.textContent = name;
        a.title = urlHash;

        const controls = document.createElement('div');
        controls.className = 'pm-controls';

        const globeClass = type === 'global' ? 'pm-is-global' : 'pm-is-local';
        const globeTitle = type === 'global' ? 'Make local' : 'Make global';

        controls.appendChild(createIconBtn(ICONS.globe, globeTitle, () => toggleGlobal(type, name), globeClass));

        const isFirst = index === 0;
        const upAction = isFirst ? null : () => moveLink(type, name, -1);
        const upClass = isFirst ? 'pm-disabled' : '';
        controls.appendChild(createIconBtn(ICONS.up, 'Move up', upAction, upClass));

        const isLast = index === totalCount - 1;
        const downAction = isLast ? null : () => moveLink(type, name, 1);
        const downClass = isLast ? 'pm-disabled' : '';
        controls.appendChild(createIconBtn(ICONS.down, 'Move down', downAction, downClass));

        controls.appendChild(createIconBtn(ICONS.edit, 'Rename', () => renameLink(type, name)));
        controls.appendChild(createIconBtn(ICONS.delete, 'Delete', () => removeLink(type, name)));

        div.appendChild(a);
        div.appendChild(controls);
        return div;
    }

    function renderLinks() {
        const container = document.getElementById('postmarksList');
        if (!container) return;

        container.replaceChildren();

        const data = getStore();
        const account = selectors.accountName();

        if (data.linkList) {
            const keys = Object.keys(data.linkList);
            keys.forEach((name, index) => {
                container.appendChild(createLinkRow('global', name, data.linkList[name].urlHash, index, keys.length));
            });
        }

        if (data.accountList && data.accountList[account]) {
            const keys = Object.keys(data.accountList[account]);
            keys.forEach((name, index) => {
                container.appendChild(createLinkRow('account', name, data.accountList[account][name].urlHash, index, keys.length));
            });
        }

        if (container.children.length === 0) {
            const emptyMsg = document.createElement('div');
            emptyMsg.style.padding = '5px';
            emptyMsg.style.fontSize = '12px';
            emptyMsg.style.color = '#777';
            emptyMsg.textContent = 'No links found.';
            container.appendChild(emptyMsg);
        }
    }

    function injectUI() {
        if (document.getElementById('postmarksContainer')) return;

        const targetNode = selectors.labelControlsContainer();
        if (!targetNode) return;

        const mainDiv = document.createElement('div');
        mainDiv.id = 'postmarksContainer';

        const header = document.createElement('div');
        header.id = 'postmarksHeader';

        const title = document.createElement('h2');
        title.textContent = "Postmarks";

        const actionGroup = document.createElement('div');
        actionGroup.className = 'pm-action-group';

        const createHeaderBtn = (icon, title, action) => {
            const s = document.createElement('div');
            s.className = 'pm-header-btn';
            s.title = title;
            s.appendChild(parseSVG(icon));
            s.onclick = action;
            return s;
        };

        actionGroup.appendChild(createHeaderBtn(ICONS.upload, "Back up", handleImport));
        actionGroup.appendChild(createHeaderBtn(ICONS.download, "Restore", handleExport));
        actionGroup.appendChild(createHeaderBtn(ICONS.add, "Add new link", addLink));

        header.appendChild(title);
        header.appendChild(actionGroup);

        const listDiv = document.createElement('div');
        listDiv.id = 'postmarksList';
        listDiv.className = 'pm-list';

        mainDiv.appendChild(header);
        mainDiv.appendChild(listDiv);

        targetNode.insertAdjacentElement('afterend', mainDiv);
        renderLinks();
    }

    const init = setInterval(() => {
        if (selectors.sidebar() && selectors.labelControlsContainer()) {
            clearInterval(init);
            injectUI();

            const observer = new MutationObserver(() => {
                if (!document.getElementById('postmarksContainer')) {
                    injectUI();
                }
            });

            const parent = selectors.labelControlsContainer().parentElement;
            if (parent) observer.observe(parent, { childList: true, subtree: true });
        }
    }, 500);
})();