Pixiv Bookmark Tag Summary

Count illustrations per tag in bookmarks

// ==UserScript==
// @name         Pixiv Bookmark Tag Summary
// @namespace    http://tampermonkey.net/
// @version      0.6.3
// @description  Count illustrations per tag in bookmarks
// @match        https://www.pixiv.net/*/bookmarks*
// @grant        unsafeWindow
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const turboMode = false;
    const bookmarkBatchSize = 100;
    const BANNER = ".sc-x1dm5r-0";
    let uid, lang, token;
    let pageInfo = {};
    let userTags = [];
    // Function to count bookmarks by tag
    let tags = {};
    let sortedTags = [];
    let illustrations = {};
    let bookmarks = {};
    let translations = {};
    let debounceTimer = null;
    let fetchedAll = false;
    let tagTiles = {};

    let globalObjects = {
        userTags: userTags,
        tags: tags,
        sortedTags: sortedTags,
        illustrations: illustrations,
        bookmarks: bookmarks,
        translations: translations,
        uid: uid,
        lang: lang,
        token: token,
        bookmarkBatchSize: bookmarkBatchSize,
        tagTiles: tagTiles
    }

    let unsafeWindow_ = unsafeWindow;
    
    const delay = (ms) => {
        return new Promise((res) => setTimeout(res, ms));
    }
    const splitIntoBatches = (array, batchSize) => {
        const batches = [];
        for (let i = 0; i < array.length; i += batchSize) {
            batches.push(array.slice(i, i + batchSize));
        }
        return batches;
    }

    const reverseObject = (obj) => {
        const reversed = {};
    
        for (let [key, value] of Object.entries(obj)) {
            if (reversed[value]) {
                reversed[value].push(key);
            } else {
                reversed[value] = [key];
            }
        }
    
        return reversed;
    }
    const downloadObject = (obj, name="object.json") => {
        // Convert the transformed dictionary to a JSON string
        let jsonStr = JSON.stringify(obj, null, 4);

        // Create a Blob from the JSON string
        let blob = new Blob([jsonStr], { type: 'application/json' });

        let url = URL.createObjectURL(blob);
        let a = document.createElement('a');
        a.href = url;
        a.download = name;
        document.body.appendChild(a);  // Append the link to the body
        a.click();  // Trigger the download
        document.body.removeChild(a);  // Remove the link after the download
        URL.revokeObjectURL(url); 
    }

    const fixTagName = (str) => {
        return str.replace(/ /g, "_").slice(0, 20).trim();
    }

    const getTag = (tag) => {
        if (typeof tag == "string"){
            return tags[tag];
        }
        return tag;
    }

    const getTagName = (tag) => {
        if (typeof tag !== "string"){
            return tag.name;
        }
        return tag;
    }
    
    const remove = async (tagNames, bookmarkIds, isBatch=false) => {
        if (!tagNames || !bookmarkIds || tagNames.length == 0 || bookmarkIds.length == 0){
            console.log(`Skip removing tag ${tagNames} from ${bookmarkIds.length} illustrations`);
            return;
        }
        tagNames = tagNames.map(getTagName);
        if (tagNames.length > 1){
            for (const tagName of tagNames) {
                await remove([tagName], bookmarkIds);
            }
            return;
    }
        bookmarkIds = filterBookmarks(tagNames[0], bookmarkIds, false);
        bookmarkIds = filterBookmarks2(tagNames[0], bookmarkIds, false);
        if (!bookmarkIds || bookmarkIds.length == 0){
            console.log(`Skip removing tag ${tagNames} because it has no illustrations`);
            return;
        }
        tagNames = tagNames.map(fixTagName);
        if (bookmarkIds.length > bookmarkBatchSize){
            const batches = splitIntoBatches(bookmarkIds, bookmarkBatchSize);
            for (const batch of batches) {
                await remove(tagNames, batch, true);
            }
            return;
        }
        const payload = { 
            removeTags: tagNames,
            bookmarkIds: bookmarkIds,
        };
        const response = await fetch("https://www.pixiv.net/ajax/illusts/bookmarks/remove_tags", {
            headers: {
                accept: "application/json",
                "content-type": "application/json; charset=utf-8",
                //"content-type": "application/x-www-form-urlencoded; charset=utf-8",
                "x-csrf-token": token,
            },
            body: JSON.stringify(payload),
            //body: new URLSearchParams(payload).toString(),
            method: "POST",
        });
        if (response.ok && !response.error) {
            tagNames.forEach((tagName) => {
                const bookmarks = bookmarkIds.map(id => globalObjects.bookmarks[id]);
                bookmarks.forEach(b => {
                    removeIllust(tagName, b);
                });
            })
            console.log(`Removed ${tagNames} from ${bookmarkIds}`);
        }else{
            throw Error(`Remove of ${tagNames} failed for ${bookmarkIds} with status ${response.status} and error ${response.error}: ${response.message}`);
        }
        await delay(500);
    }
    const includes = (source, negate=false) => {
        let f = x => true;
        if (typeof source === 'object' && !Array.isArray(source) && !(source instanceof Set)) {
            f = key => (key in source);
        }
        if (Array.isArray(source)) {
            f = key => source.includes(key);
        }
        if (source instanceof Set) {
            f = key => source.has(key);
        }
        let f2 = f;
        if (negate){
            f2 = key => !f(key);
        }
        return f2;
    }
    const filterBookmarks = (source, keys, exclusion) => {
        if(typeof source == "string"){
            source = globalObjects.tags[source];
            if (!source) return keys;
            source = source.bookmarks;
            if (!source) return keys;
        }
        let f2 = includes(source, exclusion);
        return keys.filter(f2);
    }
    const filterBookmarks2 = (tagName, bookmarkIds, exclusion) => {
        let bookmarks = bookmarkIds.map(id => globalObjects.bookmarks[id]);
        bookmarks = bookmarks.filter(b => includes(b.associatedTags, exclusion)(tagName));
        return bookmarks.map(b => b.bookmarkId);
    }
    const add = async (tagNames, bookmarkIds, isBatch=false) => {
        if (!tagNames || !bookmarkIds || tagNames.length == 0 || bookmarkIds.length == 0){
            console.log(`Skip adding tag ${tagNames} to ${bookmarkIds.length} illustrations`);
            return tagNames;
        }
        tagNames = tagNames.map(getTagName);
        if (tagNames.length > 1){
            for (const tagName of tagNames) {
                await add([tagName], bookmarkIds);
            }
            return tagNames;
        }
        bookmarkIds = filterBookmarks(tagNames[0], bookmarkIds, true);
        bookmarkIds = filterBookmarks2(tagNames[0], bookmarkIds, true);
        if (!bookmarkIds || bookmarkIds.length == 0){
            console.log(`Skip adding tag ${tagNames} because the illustrations already have it`);
            return tagNames;
        }
        tagNames = tagNames.map(fixTagName);
        if (bookmarkIds.length > bookmarkBatchSize){
            const batches = splitIntoBatches(bookmarkIds, bookmarkBatchSize);
            for (const batch of batches) {
                await add(tagNames, batch, true);
            }
            return tagNames;
        }
        const restrict = window.location.href.includes("rest=hide") ? 1 : 0;
        const payload = { 
            tags: tagNames,
            bookmarkIds: bookmarkIds,
        };
        const response = await fetch("https://www.pixiv.net/ajax/illusts/bookmarks/add_tags", {
            headers: {
                accept: "application/json",
                "content-type": "application/json; charset=utf-8",
                //"content-type": "application/x-www-form-urlencoded; charset=utf-8",
                "x-csrf-token": token,
            },
            body: JSON.stringify(payload),
            //body: new URLSearchParams(payload).toString(),
            method: "POST",
        });
        if (response.ok && !response.error) {
            tagNames.forEach((tagName) => {
                const tag = saveTag(tagName);
                const bookmarks = bookmarkIds.map(id => globalObjects.bookmarks[id]);
                bookmarks.forEach(b => {
                    saveIllust(tag, b);
                });
            })
            console.log(`Added ${tagNames} to ${bookmarkIds}`);
        }else{
            throw Error(`Add of ${tagNames} failed for ${bookmarkIds} with status ${response.status} and error ${response.error}: ${response.message}`);
        }
        await delay(500);
        return tagNames;
    }

    const getTagTranslation = async (tag) => {
        const apiBaseUrl = "https://www.pixiv.net/ajax/search/tags/";
        const apiUrl = `${apiBaseUrl}${encodeURIComponent(tag)}?lang=${lang}`;
        let translation = null;

        if (includes(globalObjects.translations)(tag)){
            translation = globalObjects.translations[tag];
            if (translation){
                console.log(`Cached translation: ${tag} -> ${translation}`);
                return translation;
            }
        }

        try {
            // Fetch the tag translation from the API
            if (!translation){
                const response = await fetch(apiUrl, { credentials: 'same-origin' });
                if (!response.ok) {
                    console.error(`Failed to fetch translation for tag: ${tag}`);
                    return null;
                }
    
                // Parse the JSON response
                const json = await response.json();
                if (json.error) {
                    console.error(`API error for tag: ${tag}`, json.message || "Unknown error");
                    return null;
                }
    
                // Extract translation based on the order of preference
                translation =
                    json.body?.tagTranslation?.[tag]?.[lang] ||
                    json.body?.breadcrumbs?.successor?.pop()?.translation?.[lang] ||
                    json.body?.tagTranslation?.[tag]?.en ||
                    json.body?.breadcrumbs?.successor?.pop()?.translation?.en ||
                    json.body?.tagTranslation?.[tag]?.romaji ||
                    json.body?.pixpedia?.tag ||
                    null; // No translation available
            }

            if (translation) {
                translation = fixTagName(translation);
                console.log(`Translated: ${tag} -> ${translation}`);
                translations[tag] = translation;
                return translation;
            } else {
                console.warn(`No translation found for tag: ${tag}`);
                return null;
            }

            // Delay to avoid overloading the server
        } catch (err) {
            console.error(`Error fetching translation for tag: ${tag}`, err);
            return null;
        }finally {
            await delay(500);
        }
        return translation;
    }

    const renameTag = async (tag, newTagName, publicationType=null) => {
        if (!publicationType){
            publicationType = window.location.href.includes("rest=hide") ? "hide" : "show";
        }
        tag = getTag(tag);
        const tagTile = globalObjects.tagTiles[tag.name];
        
        let bookmarkIds = Object.values(tag.bookmarks).map(illust => illust.bookmarkId);

        if (!bookmarkIds || bookmarkIds.length == 0){
            console.log(`Skip renaming tag ${tag.name} -> ${newTagName} because it has no illustrations`);
            return;
        }
            
        newTagName = fixTagName(newTagName);
        if (includes(tagTiles)(newTagName)){
            if (confirm(`Tag ${newTagName} already exists. Do you want to move ${tag.name} into ${newTagName}?`)){
                moveTag(tag.name, newTagName, true);
            }
            return;
        }
        // Update server-side: Add the new tag and remove the old tag
        newTagName = (await add([newTagName], bookmarkIds))[0];
        await remove([tag.name], bookmarkIds);

        // Update the `tags` object
        const oldTagName = tag.name;
        tag.name = newTagName;

        // Update the DOM: Modify the tag tile
        if (tagTile){
            const tagLink = tagTile.querySelector('a'); // Tag link element
            const tagCount = tagTile.querySelector('div:last-child'); // Tag count element
    
            if (tagLink) tagLink.innerText = newTagName; // Update tag name
            if (tagLink) tagLink.href = `https://www.pixiv.net/en/users/${uid}/bookmarks/artworks/${newTagName}?rest=${publicationType}`; // Update URL
            if (tagCount) tagCount.innerText = `(${Object.keys(tag.illustrations).length})`; // Count remains unchanged

            tagTiles[newTagName] = tagTile;
        }

        removeTagLocal(oldTagName, true);
        console.log(`Renamed tag "${oldTagName}" to "${newTagName}".`);
    }


    const deleteArrayElement = (arr, el) => {
        let index = arr.indexOf(el);
        if (index !== -1) {
            arr.splice(index, 1);
        }
    }

    const removeTagLocal = (tagName, isRename=false) => {
        deleteArrayElement(userTags, tagName);
        if (!isRename){
            deleteArrayElement(sortedTags, tags[tagName]);
        } 
        delete tags[tagName];
        if (tagTiles[tagName]){
            if (!isRename){
                tagTiles[tagName].remove();
            } 
            delete tagTiles[tagName];
        }
    }

    const removeTags = async (tags) => {
        for (let tag of tags) {
            tag = getTag(tag);
            const illusts = Object.values(tag.illustrations).map((illust) => illust.bookmarkId);
            await remove([tag.name], illusts);
            removeTagLocal(tag.name);
        }
    }

    const sortByParody = (array) => {
        const sortFunc = (a, b) => {
          let reg = /^[a-zA-Z0-9]/;
          if (reg.test(a) && !reg.test(b)) return -1;
          else if (!reg.test(a) && reg.test(b)) return 1;
          else return a.localeCompare(b, "zh");
        };
        const withParody = array.filter((key) => key.includes("("));
        const withoutParody = array.filter((key) => !key.includes("("));
        withoutParody.sort(sortFunc);
        withParody.sort(sortFunc);
        withParody.sort((a, b) => sortFunc(a.split("(")[1], b.split("(")[1]));
        return withoutParody.concat(withParody);
    }

    const fetchUserTags = async () => {
        const tagsRaw = await fetch(
        `/ajax/user/${uid}/illusts/bookmark/tags?lang=${lang}`
        );
        const tagsObj = await tagsRaw.json();
        if (tagsObj.error === true)
        return alert(
            `获取tags失败
        Fail to fetch user tags` +
            "\n" +
            decodeURI(tagsObj.message)
        );
        let userTagDict = tagsObj.body;
        const userTagsSet = new Set();
        const addTag2Set = (tag) => {
            try {
                userTagsSet.add(decodeURI(tag));
            } catch (err) {
                userTagsSet.add(tag);
                if (err.message !== "URI malformed") {
                    console.log("[Label Pixiv] Error!");
                    console.log(err.name, err.message);
                    console.log(err.stack);
                }
            }
        };
        for (let obj of userTagDict.public) {
            addTag2Set(obj.tag);
        }
        for (let obj of userTagDict["private"]) {
            addTag2Set(obj.tag);
        }
        userTagsSet.delete("未分類");
        return sortByParody(Array.from(userTagsSet));
    }
    
    // Function to bulk remove or remove tags based on minimum bookmark count
    const bulkRemove = async (minBookmarks) => {
        // Filter tags based on the bookmark count
        const selectedTags = sortedTags.filter((tag) => {
            const bookmarkCount = countIllusts(tag);
            return bookmarkCount < minBookmarks;
        });

        if (selectedTags.length === 0) {
            alert('No tag meet the criteria.');
            return;
        }

        // Show confirmation dialog
        const confirmation = confirm(`Are you sure you want to remove ${selectedTags.length} tags?`);
        if (!confirmation) return;

        await removeTags(selectedTags);
        alert(`Removed ${selectedTags.length} tags`);
    }
    
    const fetchTokenPolyfill = async () => {
        // get token
        const userRaw = await fetch(
            "/bookmark_add.php?type=illust&illust_id=83540927"
        );
        if (!userRaw.ok) {
            console.log(`获取身份信息失败
            Fail to fetch user information`);
            throw new Error();
        }
        const userRes = await userRaw.text();
        const tokenPos = userRes.indexOf("pixiv.context.token");
        const tokenEnd = userRes.indexOf(";", tokenPos);
        return userRes.slice(tokenPos, tokenEnd).split('"')[1];
    }
    const initializeVariables = async () => {
        const polyfill = async () => {
            try {
                const dataLayer = unsafeWindow_["dataLayer"][0];
                uid = dataLayer["user_id"];
                lang = dataLayer["lang"];
                token = await fetchTokenPolyfill();
                pageInfo.userId = window.location.href.match(/users\/(\d+)/)?.[1];
                pageInfo.client = { userId: uid, lang, token };
            } catch (err) {
                console.log(err);
                console.log("[Label Bookmarks] Initializing Failed");
            }
        }

        try {
            pageInfo = Object.values(document.querySelector(BANNER))[0]["return"][
                "return"
            ]["memoizedProps"];
            uid = pageInfo["client"]["userId"];
            token = pageInfo["client"]["token"];
            lang = pageInfo["client"]["lang"];
            if (!uid || !token || !lang) await polyfill();
        } catch (err) {
            console.log(err);
            await polyfill();
        }

    }
    const fetchBookmarks = async (uid, tagToQuery='', offset=0, publicationType=null) => {
        if (!publicationType){
            publicationType = window.location.href.includes("rest=hide") ? "hide" : "show";
        }
        const bookmarksRaw = await fetch(
            `/ajax/user/${uid}` +
            `/illusts/bookmarks?tag=${tagToQuery}` +
            `&offset=${offset}&limit=${bookmarkBatchSize}&rest=${publicationType}`
        );
        if (!turboMode) await delay(500);
        const bookmarksRes = await bookmarksRaw.json();
        if (!bookmarksRaw.ok || bookmarksRes.error === true) {
            return alert(
            `获取用户收藏夹列表失败\nFail to fetch user bookmarks\n` +
                decodeURI(bookmarksRes.message)
            );
        }
        const bookmarks = bookmarksRes.body;
        bookmarks.count = bookmarks["works"].length;
        const works = bookmarks["works"]
        .map((work) => {
            if (work.title === "-----") return null;
            work.bookmarkId = work["bookmarkData"]["id"];
            work.associatedTags = bookmarks["bookmarkTags"][work.bookmarkId] || []; 
            work.associatedTags = work.associatedTags.filter(
                (tag) => tag != "未分類"
            );
            return work;
        })
        .filter((work) => work && work.associatedTags.length); 
        bookmarks["works"] = works;
        return bookmarks;
    }

    const fetchAllBookmarks = async (uid, tagToQuery='', publicationType=null) => {
        let total, // total bookmarks of specific tag
            index = 0; // counter of do-while loop
        let finalBookmarks = null;
        let allWorks = [];
        let allTags = {}
        do {
            const bookmarks = await fetchBookmarks(
                uid,
                tagToQuery,
                index,
                publicationType
            );
            if (!total) total = bookmarks.total;
            const works = bookmarks["works"];
            allWorks = allWorks.concat(works);
            allTags = updateObject(allTags, bookmarks["bookmarkTags"]);
            index += bookmarks.count || bookmarks["works"].length;
            finalBookmarks = updateObject(finalBookmarks, bookmarks);
            console.log(`Fetching bookmarks... ${index}/${total}`)
        } while (index < total);
        finalBookmarks["works"] = allWorks;
        finalBookmarks["bookmarkTags"] = allTags;
        return finalBookmarks;
    }


    // Function to check if the bookmarks list has changed
    const countIllusts = (tag) => Object.keys(tag.illustrations).length;
    const countBookmarks = (tag) => Object.keys(tag.bookmarks).length;
    const illustComparator = (a, b) => countIllusts(b) - countIllusts(a);

    const updateObject = (target, source) => {
        if (!target) return source;
        //target = {...target, ...source};
        Object.assign(target, source);
        return target;
    }

    const saveTag = (tag) => {
        tag = getTagName(tag);
        if (!tags[tag]){
            userTags.push(tag);
            tags[tag] = {
                name: tag,
                illustrations: {},
                bookmarks: {},
            };
        }
        return tags[tag];
    }
    const saveIllust = (tag, illust) => {
        tag = saveTag(tag);
        let illustId = illust.id;
        if (tag.illustrations[illustId]) {
            tag.illustrations[illustId] = updateObject(tag.illustrations[illustId], illust);
            illust = tag.illustrations[illustId];
        }else{
            tag.illustrations[illustId] = illust;
        }
        illustrations[illustId] = illust;
        let bookmarkId = illust.bookmarkId;
        if (tag.bookmarks[bookmarkId]) {
            tag.bookmarks[bookmarkId] = updateObject(tag.bookmarks[bookmarkId], illust);
            illust = tag.bookmarks[bookmarkId];
        }else{
            tag.bookmarks[bookmarkId] = illust;
        }
        bookmarks[bookmarkId] = illust;
        return illust;
    }

    const removeIllust = (tag, illust) => {
        tag = getTag(tag);
        if (!tag) return;
        delete tag.illustrations[illust.id];
        delete tag.bookmarks[illust.bookmarkId];
        if (countIllusts(tag) == 0){
            removeTags([tag]);
        }
    }

    const summarizeAllBookmarks = async () => {
        
        userTags = await fetchUserTags();
        userTags.forEach((tag) => {
            saveTag(tag);
        });

        const bookmarks = await fetchAllBookmarks(uid);
        console.log(`Fetched ${bookmarks.works.length} bookmarks`);
        
        let total = 0;
        bookmarks["works"].forEach((work) => {
            let illust = {
                id: work.id,
                title: work.title,
                alt: work.alt,
                img: work.url,
            };
            illust = updateObject(illust, work);
            illust["url"] = `https://www.pixiv.net/${lang}/artworks/${work.id}`;
            work.associatedTags.forEach((tag) => {
                saveIllust(tag, illust);
            });
            total += 1;
        });
        console.log(`Processed ${total} illusts with ${Object.keys(tags).length} tags`);
        sortTags();
        fetchedAll = true;
        requestAnimationFrame(renderSummary);
        //renderSummary();
    }

    const sortTags = () =>{
        sortedTags = Object.values(tags).sort(illustComparator);
        globalObjects.sortedTags = sortedTags;
    }

    const moveTag = async (tag, targetTagName, forceRemove=false) => {
        // Add the dragged tag (A) to all illustrations in the target tag (B)
        tag = getTag(tag);
        const targetTag = tags[targetTagName];
        const bookmarkIds = Object.values(tag.illustrations).map(illust => illust.bookmarkId);
        await add([targetTagName], bookmarkIds);
        populateTagTile(targetTag);
        console.log(`Tag "${targetTagName}" added to all illustrations in "${tag.name}".`);

        if (forceRemove || confirm(`Do you want to remove the "${tag.name}" tag?`)) {
            await removeTags([tag.name]);
            if (!forceRemove) alert(`Tag "${tag.name}" removed.`);
        }
        sortTags();
    }

    //populate tag tile
    const populateTagTile = (tag) => {
        tag = getTag(tag);

        const tagTile = tagTiles[tag.name];
        if (!tagTile) return;

        const count = countIllusts(tag);
        const tagCount = tagTile.querySelector(".tag-count");
        tagCount.innerText = `(${count})`;

        const illustContainer = tagTile.querySelector(".illust-container")
        illustContainer.innerHTML = "";
        // Populate illustrations
        Object.values(tag.illustrations).forEach((illust) => {
            const illustItem = document.createElement('li');
            illustItem.style.marginBottom = '5px';
            illustItem.innerHTML = `<a href="${illust.url}" target="_blank">${illust.alt || illust.title}</a>`;
            illustContainer.appendChild(illustItem);
        });
    }

    // Function to render the summary UI
    const renderSummary = () => {
        let publicationType = window.location.href.includes("rest=hide") ? "hide" : "show";
        // Clear previous summary if exists
        const existingSummary = document.getElementById('tag-summary');
        if (existingSummary) {
            existingSummary.remove();
        }
        // Create a summary element
        const summaryDiv = document.createElement('div');
        summaryDiv.id = 'tag-summary'; // Set an ID for easy removal
        summaryDiv.style.position = 'fixed';
        summaryDiv.style.bottom = '10px';
        summaryDiv.style.right = '10px';
        summaryDiv.style.backgroundColor = '#fff';
        summaryDiv.style.padding = '10px';
        summaryDiv.style.border = '1px solid #ccc';
        summaryDiv.style.zIndex = '9999';
        summaryDiv.style.color = 'black';

        const title = document.createElement('h3');
        title.innerText = 'Tags';
        title.style.cursor = 'pointer'; // Change cursor to pointer
        title.style.margin = '0'; // Remove default margin

        // Create a container for tag data
        const summaryContent = document.createElement('div');
        summaryContent.style.display = 'none'; // Initially hidden
        summaryContent.style.padding = '10px';
        summaryContent.style.maxHeight = '400px'; // Set a maximum height
        summaryContent.style.minWidth = '400px';
        summaryContent.style.overflowY = 'auto'; // Enable vertical scrolling

        // Toggle visibility of the tag container when the title is clicked
        title.addEventListener('click', () => {
            if (summaryContent.style.display === 'none') {
                summaryContent.style.display = 'block';
                title.innerText = 'Tags'; // Change title when expanded
            } else {
                summaryContent.style.display = 'none';
                title.innerText = 'Tags'; // Reset title when collapsed
            }
        });

        let totalCount = 0;
        const tagContainer = document.createElement('div');
        tagContainer.style.display = 'grid';
        tagContainer.style.gridTemplateColumns = 'repeat(auto-fill, minmax(100px, 1fr))';
        tagContainer.style.gap = '10px';
    
        // Keep track of the currently expanded tile
        let expandedTile = null;
        // Generate each tag as a tile with clickable illustration list

        Object.keys(tagTiles).forEach(key => delete tagTiles[key]);
        Object.values(sortedTags).forEach((tag) => {
            const count = countIllusts(tag);
            if (!count) return;
    
            const tagTile = document.createElement('div');
            tagTile.style.backgroundColor = '#f0f0f0';
            tagTile.style.padding = '5px';
            tagTile.style.borderRadius = '5px';
            tagTile.style.textAlign = 'center';
            tagTile.style.cursor = 'pointer';
            tagTile.style.transition = 'all 0.3s ease';
            tagTile.style.position = "relative"; /*add this*/
            tagTile.style.color = 'black';
            tagTile.draggable = true;  // Enable dragging

            // Store the tag name in the element's dataset for easy reference during drag and drop
            tagTile.dataset.tagName = tag.name;

            tagTiles[tag.name] = tagTile;
            tagTile.tag = tag;

            // Dragstart is triggered on the dragged element
            tagTile.addEventListener('dragstart', (e) => {
                e.dataTransfer.setData('text/plain', tag.name);
            });

            tagTile.addEventListener('dragover', (e) => {
                e.preventDefault(); 
            });

            // drop and dragover are triggered on the below element
            tagTile.addEventListener('drop', async (e) => {
                e.preventDefault();
                const targetTagName = tag.name;
                const draggedTagName = e.dataTransfer.getData('text/plain');
                console.log(draggedTagName, targetTagName);
                if (draggedTagName === targetTagName) return;
                // Confirm addition of the dragged tag to target tag's illustrations
                if (!confirm(`Do you want to move "${draggedTagName}" tag to "${targetTagName}" tag?\nThis will add "${targetTagName}" tag to all illustrations in "${draggedTagName}"`)) {
                    return;
                }
                
                moveTag(draggedTagName, targetTagName);
            });

            const buttonContainer = document.createElement('div');
            buttonContainer.classList.add("button-container");
            buttonContainer.style.position = 'absolute';
            buttonContainer.style.top = '5px';
            buttonContainer.style.right = '5px';
            buttonContainer.style.display = 'flex';
            buttonContainer.style.gap = '5px'; // Space between buttons
            buttonContainer.style.display = 'none';  // Initially hidden
            tagTile.appendChild(buttonContainer);

            // Create a delete button for each tile
            const deleteButton = document.createElement('button');
            deleteButton.innerText = '🗑';
            deleteButton.style.backgroundColor = 'red';
            deleteButton.style.color = 'white';
            deleteButton.style.border = 'none';
            deleteButton.style.cursor = 'pointer';

            // Add click event for delete button
            deleteButton.addEventListener('click', async (e) => {
                e.stopPropagation(); // Prevent triggering tile click events
                const confirmDelete = confirm(`Are you sure you want to delete the "${tag.name}" tag?`);
                if (confirmDelete) {
                    await removeTags([tag.name]);
                    alert(`Tag "${tag.name}" has been deleted.`);
                }
            });


            const copyButton = document.createElement('button');
            copyButton.innerText = '🗎';
            copyButton.style.backgroundColor = 'gray';
            copyButton.style.color = 'white';
            copyButton.style.border = 'none';
            copyButton.style.cursor = 'pointer';

            copyButton.addEventListener('click', (e) => {
                e.stopPropagation();
                navigator.clipboard.writeText(tag.name).then(() => {
                    console.log(`Copied "${tag.name}" to clipboard.`);
                }).catch((err) => {
                    console.error(`Failed to copy tag: ${err}`);
                });
            });

            // Edit button
            const editButton = document.createElement('button');
            editButton.innerText = '✏️'; // Unicode for edit icon
            editButton.style.border = 'none';
            editButton.style.background = 'yellow';
            editButton.style.color = 'white';
            editButton.style.cursor = 'pointer';

            editButton.addEventListener('click', async (e) => {
                e.stopPropagation();
                let newTagName = prompt(`Edit tag "${tag.name}". Enter a new name:`, tag.name);
                if (!newTagName || newTagName.trim() === tag.name) return;
            
                const oldTagName = tag.name;
                newTagName = newTagName.trim();

                await renameTag(tag, newTagName);
                alert(`Tag "${oldTagName}" has been renamed to "${newTagName}".`);
            });

            const translateButton = document.createElement('button');
            translateButton.innerText = '🌐'; // Unicode globe icon for translation
            translateButton.style.border = 'none';
            translateButton.style.background = 'blue';
            translateButton.style.color = 'white';
            translateButton.style.cursor = 'pointer';

            translateButton.addEventListener('click', async (e) => {
                e.stopPropagation();

                // Fetch translation for the current tag
                const translatedTag = await getTagTranslation(tag.name);
                if (!translatedTag || translatedTag == tag.name) {
                    alert(`No translation found for tag: ${tag.name}`);
                    return;
                }

                // Confirm translation
                if (confirm(`Translate "${tag.name}" to "${translatedTag}"?`)) {
                    const oldTagName = tag.name;
                    await renameTag(tag, translatedTag);
                    alert(`Tag "${oldTagName}" has been renamed to "${translatedTag}".`);
                }
            });
    
            // Add buttons to the container
            buttonContainer.appendChild(translateButton);
            buttonContainer.appendChild(editButton);
            buttonContainer.appendChild(copyButton);
            buttonContainer.appendChild(deleteButton);
    
            const tagText = document.createElement('div');
            tagTile.appendChild(tagText);

            const tagLink = document.createElement('a');
            tagLink.href = `https://www.pixiv.net/en/users/${uid}/bookmarks/artworks/${tag.name}?rest=${publicationType}`;
            tagLink.innerText = `${tag.name}`;
            tagLink.classList.add("tag-link");
            tagLink.style.textDecoration = 'none';
            tagLink.style.display = 'block';
            tagText.appendChild(tagLink);
            tagTile.tagLink = tagLink;
    
            const tagCount = document.createElement('div');
            tagCount.classList.add("tag-count");
            tagText.appendChild(tagCount);
            tagTile.tagCount = tagCount;
    
            // Create a container for illustrations (initially hidden)
            let illustContainer = document.createElement('ol');
            illustContainer.classList.add("illust-container");
            illustContainer.style.display = 'none';
            illustContainer.style.paddingTop = '5px';
            illustContainer.style.textAlign = 'left';

            tagTile.appendChild(illustContainer);
            tagTile.illustContainer = illustContainer;

            populateTagTile(tag);
    
            // Toggle illustration visibility when tile is clicked
            tagTile.addEventListener('click', () => {
                if (expandedTile && expandedTile !== tagTile) {
                    // Collapse the previously expanded tile
                    expandedTile.style.gridColumn = '';
                    expandedTile.querySelector('.illust-container').style.display = 'none';
                    expandedTile.querySelector('.button-container').style.display = 'none';
                }
                if (illustContainer.style.display === 'none') {
                    // Expand this tile
                    tagTile.style.gridColumn = '1 / -1'; // Full width in grid
                    illustContainer.style.display = 'block';
                    buttonContainer.style.display = 'block'; // Show button
                    expandedTile = tagTile;
                } else {
                    // Collapse this tile if already expanded
                    tagTile.style.gridColumn = '';
                    illustContainer.style.display = 'none';
                    buttonContainer.style.display = 'none'; // Hide button
                    expandedTile = null;
                }
            });

    
            tagContainer.appendChild(tagTile);
            totalCount += count;
        });
        const totalContainer = document.createElement('p');
        totalContainer.innerHTML = `<span>Total: ${totalCount}</span>`;
        const logButton = document.createElement('button');
        logButton.innerHTML = `Log Items`;
        logButton.style.marginRight = '5px';
        logButton.addEventListener('click', () => {
            console.log(JSON.stringify(sortedTags));
            console.log(sortedTags);
        });
        const fetchButton = document.createElement('button');
        fetchButton.innerHTML = `Fetch All`;
        fetchButton.style.marginRight = '5px';
        fetchButton.addEventListener('click', () => {
            setTimeout(summarizeAllBookmarks, 100);
        });

        const loadTranslationsButton = document.createElement('button');
        loadTranslationsButton.innerHTML = `Load Translations`;
        loadTranslationsButton.style.marginRight = '5px';

        loadTranslationsButton.addEventListener('click', async () => {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = 'application/json';
            input.addEventListener('change', async (event) => {
                const file = event.target.files[0];
                if (!file) return;

                try {
                    const fileContent = await file.text();
                    const loadedTranslations = JSON.parse(fileContent);

                    if (typeof loadedTranslations !== 'object') {
                        alert('Invalid JSON file. Expected an object.');
                        return;
                    }

                    // Update the translations dictionary
                    Object.assign(globalObjects.translations, loadedTranslations);

                    console.log('Loaded translations:', loadedTranslations);
                    alert(`Loaded ${Object.keys(loadedTranslations).length} translations.`);
                } catch (error) {
                    console.error('Error loading translations:', error);
                    alert('Failed to load translations. Please ensure the file is valid JSON.');
                } finally {
                    document.body.removeChild(input);
                    //input.remove(); 
                }
            });

            document.body.appendChild(input);
            input.click(); 
        });

        const bulkActionDiv = document.createElement('div');
        if (fetchedAll){
            // Create UI for bulk add/remove
            bulkActionDiv.style.marginTop = '10px';
            const minCountInput = document.createElement('input');
            minCountInput.type = 'number';
            minCountInput.placeholder = 'Min bookmarks';
            minCountInput.style.width = '100px';
            minCountInput.style.marginRight = '5px';
    
            // Remove button
            const removeButton = document.createElement('button');
            removeButton.innerText = 'Remove';
            removeButton.onclick = () => {
                const minBookmarks = parseInt(minCountInput.value, 10);
                if (isNaN(minBookmarks)) {
                    alert('Please enter a valid number for minimum bookmarks.');
                    return;
                }
                bulkRemove(minBookmarks);
            };
            

            const translateTags = async () => {
                const translations = {}; // Dictionary to store tag translations
            
                for (const tagName in tags) {
                    if (!tags[tagName].bookmarks || countIllusts(tags[tagName]) == 0){
                        continue;
                    }
                    // Skip tags that are purely alphanumeric, whitespace, or common symbols
                    if (/^[a-zA-Z0-9\s~!@#\$%\^&*\(\)\-_=\+\[\]{}\\|;:'",\.\/<>\?]+$/.test(tagName)) {
                        console.log(`Skipping tag (no translation needed): ${tagName}`);
                        continue;
                    }
                    const translation = await getTagTranslation(tagName);
                    
                    if (translation){
                        translations[tagName] = translation;
                    }
                }
            
                console.log(translations);
                console.log(JSON.stringify(translations));

                let synonyms = reverseObject(translations);
                console.log(synonyms);
                console.log(JSON.stringify(synonyms));

                if (confirm(`Save translations?`)) {
                    downloadObject(translations, "translations.json");
                }
                if (confirm(`Save translations as synonyms?`)) {
                    downloadObject(synonyms, "synonyms.json");
                }

                // Show an alert asking if the user wants to download the file
                const toTranslateCount = Object.keys(translations).length;
                let translatedCount = 0;
                if (confirm(`Are you sure to translate ${toTranslateCount} tags?`)) {
                    // Trigger the download by clicking the link
                    for (let tagName in translations) {
                        let tag = tags[tagName];
                        try{
                            await renameTag(tag, translations[tag.name]);
                            translatedCount += 1;
                        }catch(ex){
                            continue;
                        }
                    }
                }
                alert(`Translated ${translatedCount}/${toTranslateCount} tags`);
            }

            const translateButton = document.createElement('button');
            translateButton.innerHTML = `Translate Tags`;
            translateButton.style.marginRight = '5px';
            translateButton.addEventListener('click', async () => {
                await translateTags();
            });
    
            // Append elements to the bulk action div
            bulkActionDiv.appendChild(translateButton);
            bulkActionDiv.appendChild(minCountInput);
            bulkActionDiv.appendChild(removeButton);
        }

        summaryContent.appendChild(tagContainer);
        summaryContent.appendChild(totalContainer);
        summaryContent.appendChild(logButton);
        summaryContent.appendChild(fetchButton);
        summaryContent.appendChild(loadTranslationsButton);
        // Add the bulk action div to the summary UI
        summaryContent.appendChild(bulkActionDiv);
        summaryDiv.appendChild(title);
        summaryDiv.appendChild(summaryContent);
        document.body.appendChild(summaryDiv);
    }

    // Initial summary calculation when the page loads
    window.addEventListener('load', () => {
        setTimeout(async () => {
            await initializeVariables();
            renderSummary();
        }, 1000);
    });

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址