wplace-infinite-bookmarks

WPlace Unlimited Favorites⭐ - Unlimited favorites storage on [WPlace.live], with backup & restore functionality, full Blue Marble support, integrated UI type

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        wplace-infinite-bookmarks
// @name:ja         WPlace Unlimited Favorites⭐ - 無制限保存 & エクスポート機能
// @namespace   https://greasyfork.org/users/1508363
// @version     1.0.1
// @description WPlace Unlimited Favorites⭐ - Unlimited favorites storage on [WPlace.live], with backup & restore functionality, full Blue Marble support, integrated UI type
// @description:ja  WPlace.liveで無制限のお気に入り保存、バックアップ・復元機能付き、Blue Marble完全対応、UI統合型
// @author      Defaulter,gissehel
// @homepage    https://github.com/gissehel/userscripts
// @supportURL  https://github.com/gissehel/userscripts/issues
// @match       https://wplace.live/*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=wplace.live
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM.setValue
// @grant       GM.getValue
// @license     MIT
// ==/UserScript==

const script_name = GM_info?.script?.name || 'no-name'
const script_version = GM_info?.script?.version || 'no-version'
const script_id = `${script_name} ${script_version}`
console.log(`Begin - ${script_id}`)


// @main_begin{wplace-infinite-bookmarks}

const i18n = {
    ja: {
        "save_button": "保存",
        "modal_title": "お気に入り",
        "export": "エクスポート",
        "import": "インポート",
        "no_favorites_to_export": "エクスポートするお気に入りがありません",
        "favorites_exported": "件のお気に入りをエクスポートしました",
        "export_error": "エクスポートエラー",
        "import_error": "インポートエラー",
        "export_failed": "エクスポートに失敗しました",
        "import_failed": "インポートに失敗しました",
        "imported": "件のお気に入りをインポートしました",
        "invalid_file_format": "無効なファイル形式です",
        "confirm_import_1": "",
        "confirm_import_2": "件のお気に入りをインポートしますか?\n既存のデータは保持されます。",
        "unable_to_retrieve_location": "位置情報を取得できませんでした。マップをクリックしてから保存してください。",
        "enter_favorite_name": "お気に入り名を入力してください",
        "location": "地点",
        "location_retrieval_error": "位置取得エラー",
        "favorite_saved": "を保存しました",
        "favorite_retrieval_error": "お気に入り取得エラー",
        "confirm_delete": "このお気に入りを削除しますか?",
        "favorite_deleted": "削除しました",
        "favorite_count": "保存済み",
        "items": "件",
        "no_favorites": "お気に入りがありません",
        "add_favorites": "下の「保存」ボタンから追加してください",
    },
    en: {
        "save_button": "Save",
        "modal_title": "Favorites",
        "export": "Export",
        "import": "Import",
        "no_favorites_to_export": "No favorites to export",
        "favorites_exported": " favorites exported",
        "export_error": "Export error",
        "import_error": "Import error",
        "export_failed": "Export failed",
        "import_failed": "Import failed",
        "imported": " favorites imported",
        "invalid_file_format": "Invalid file format",
        "confirm_import_1": "Do you want to import",
        "confirm_import_2": "favorites?\nExisting data will be preserved.",
        "unable_to_retrieve_location": "Unable to retrieve location information. Please click on the map and then save.",
        "enter_favorite_name": "Please enter a name for the favorite",
        "location": "Location",
        "location_retrieval_error": "Location retrieval error",
        "favorite_saved": "Favorite saved",
        "favorite_retrieval_error": "Favorite retrieval error",
        "confirm_delete": "Are you sure you want to delete this favorite?",
        "favorite_deleted": "Deleted",
        "favorite_count": "Saved",
        "items": "items",
        "no_favorites": "No favorites",
        "add_favorites": "Please add from the 'Save' button below",
    },
    fr: {
        "save_button": "Enregistrer",
        "modal_title": "Favoris",
        "export": "Exporter",
        "import": "Importer",
        "no_favorites_to_export": "Aucun favori à exporter",
        "favorites_exported": " favoris exportés",
        "export_error": "Erreur d'exportation",
        "import_error": "Erreur d'importation",
        "export_failed": "Échec de l'exportation",
        "import_failed": "Échec de l'importation",
        "imported": " favoris importés",
        "invalid_file_format": "Format de fichier invalide",
        "confirm_import_1": "Voulez-vous importer",
        "confirm_import_2": "favoris ?\nLes données existantes seront préservées.",
        "unable_to_retrieve_location": "Impossible de récupérer les informations de localisation. Veuillez cliquer sur la carte puis enregistrer.",
        "enter_favorite_name": "Veuillez entrer un nom pour le favori",
        "location": "Emplacement",
        "location_retrieval_error": "Erreur de récupération de la localisation",
        "favorite_saved": "Favori enregistré",
        "favorite_retrieval_error": "Erreur de récupération des favoris",
        "confirm_delete": "Êtes-vous sûr de vouloir supprimer ce favori ?",
        "favorite_deleted": "Supprimé",
        "favorite_count": "Enregistré",
        "items": "éléments",
        "no_favorites": "Aucun favori",
        "add_favorites": "Veuillez ajouter depuis le bouton 'Enregistrer' ci-dessous",
    },
    es: {
        "save_button": "Guardar",
        "modal_title": "Favoritos",
        "export": "Exportar",
        "import": "Importar",
        "no_favorites_to_export": "No hay favoritos para exportar",
        "favorites_exported": " favoritos exportados",
        "export_error": "Error de exportación",
        "import_error": "Error de importación",
        "export_failed": "Error al exportar",
        "import_failed": "Error al importar",
        "imported": " favoritos importados",
        "invalid_file_format": "Formato de archivo inválido",
        "confirm_import_1": "¿Desea importar",
        "confirm_import_2": "favoritos?\nLos datos existentes se conservarán.",
        "unable_to_retrieve_location": "No se puede recuperar la información de ubicación. Haga clic en el mapa y luego guarde.",
        "enter_favorite_name": "Por favor, ingrese un nombre para el favorito",
        "location": "Ubicación",
        "location_retrieval_error": "Error al recuperar la ubicación",
        "favorite_saved": "Favorito guardado",
        "favorite_retrieval_error": "Error al recuperar los favoritos",
        "confirm_delete": "¿Está seguro de que desea eliminar este favorito?",
        "favorite_deleted": "Eliminado",
        "favorite_count": "Guardado",
        "items": "elementos",
        "no_favorites": "No hay favoritos",
        "add_favorites": "Por favor, agregue desde el botón 'Guardar' a continuación",
    }
};

const getLang = (i18n) => {
    const userLang = (navigator.languages && navigator.languages.length)
        ? navigator.languages[0]
        : navigator.language;
    const langKey = userLang.split("-")[0];

    return i18n[langKey] || i18n['en'];
}

const _ = getLang(i18n);

class WPlaceExtendedFavorites {
    constructor() {
        this.STORAGE_KEY = 'wplace_extended_favorites';
        this.init();
    }

    init() {
        this.observeAndInit();
    }

    observeAndInit() {
        // Button configurations
        const buttonConfigs = [
            {
                id: 'favorite-btn',
                selector: `[title="${_.modal_title}"]`,
                containerSelector: 'button[title="Toggle art opacity"]',
                create: this.createFavoriteButton.bind(this)
            },
            {
                id: 'save-btn',
                selector: '[data-wplace-save="true"]',
                containerSelector: '.hide-scrollbar.flex.max-w-full.gap-1\\.5.overflow-x-auto',
                create: this.createSaveButton.bind(this)
            }
        ];

        // Start generic button observer
        this.startButtonObserver(buttonConfigs);

        // Create modal
        setTimeout(() => this.createModal(), 2000);
    }

    // Generic Button Observer System
    startButtonObserver(configs) {
        const ensureButtons = () => {
            configs.forEach(config => {
                if (!document.querySelector(config.selector)) {
                    const container = document.querySelector(config.containerSelector);
                    if (container) {
                        config.create(container);
                    }
                }
            });
        };

        // Observe DOM changes
        const observer = new MutationObserver(() => {
            setTimeout(ensureButtons, 100);
        });

        observer.observe(document.body, { childList: true, subtree: true });

        // Initial placement & periodic check
        setTimeout(ensureButtons, 1000);
        setInterval(ensureButtons, 5000);
    }

    // Create Favorite Button
    createFavoriteButton(toggleButton) {
        const container = toggleButton.parentElement;
        if (!container) return;

        const button = document.createElement('button');
        button.className = 'btn btn-lg sm:btn-xl btn-square shadow-md text-base-content/80 ml-2 z-30';
        button.title = _.modal_title;
        button.innerHTML = `
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-5">
            <path d="m354-287 126-76 126 77-33-144 111-96-146-13-58-136-58 135-146 13 111 97-33 143ZM233-120l65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Zm247-350Z"/>
        </svg>
    `;
        button.addEventListener('click', () => this.openModal());
        container.appendChild(button);
        console.log('⭐ Favorite button added');
    }

    // Create Save Button
    createSaveButton(container) {
        const button = document.createElement('button');
        button.className = 'btn btn-primary btn-soft';
        button.setAttribute('data-wplace-save', 'true');
        button.innerHTML = `
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-4.5">
            <path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/>
        </svg>
        ${_.save_button}
    `;
        button.addEventListener('click', () => this.addFavorite());
        container.appendChild(button);
        console.log('⭐ Save button added');
    }

    // Create Modal
    createModal() {
        const modal = document.createElement('dialog');
        modal.id = 'favorite-modal';
        modal.className = 'modal';

        modal.innerHTML = `
        <div class="modal-box max-w-4xl">
            <form method="dialog">
                <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
            </form>

            <div class="flex items-center gap-1.5 mb-4">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-5">
                    <path d="m354-287 126-76 126 77-33-144 111-96-146-13-58-136-58 135-146 13 111 97-33 143ZM233-120l65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Zm247-350Z"/>
                </svg>
                <h3 class="text-lg font-bold">${_.modal_title}</h3>
            </div>

            <!-- en: Export/Import Buttons -->
            <div class="flex gap-2 mb-4">
                <button id="export-btn" class="btn btn-outline btn-sm">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-4">
                        <path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/>
                    </svg>
                    ${_.export}
                </button>
                <button id="import-btn" class="btn btn-outline btn-sm">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-4">
                        <path d="M260-160q-91 0-155.5-63T40-377q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 75-52.5 127.5T740-160H520q-33 0-56.5-23.5T440-240v-206l-64 62-56-56 160-160 160 160-56 56-64-62v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-83 0-141.5 58.5T280-520h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h100v80H260Z"/>
                    </svg>
                    ${_.import}
                </button>
                <input type="file" id="import-file" accept=".json" style="display: none;">
            </div>

            <div id="favorites-grid" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 max-h-96 overflow-y-auto">
                <!-- Favorites will be displayed here -->
            </div>

            <div id="favorites-count" class="text-center text-sm text-base-content/80 mt-4">
                <!-- Favorites count will be displayed here -->
            </div>
        </div>

        <form method="dialog" class="modal-backdrop">
            <button>close</button>
        </form>
    `;

        document.body.appendChild(modal);

        // Event listener for existing grid clicks
        modal.querySelector('#favorites-grid').addEventListener('click', (e) => {
            const card = e.target.closest('.favorite-card');
            const deleteBtn = e.target.closest('.delete-btn');

            if (deleteBtn) {
                const id = parseInt(deleteBtn.dataset.id);
                this.deleteFavorite(id);
            } else if (card) {
                const lat = parseFloat(card.dataset.lat);
                const lng = parseFloat(card.dataset.lng);
                const zoom = parseFloat(card.dataset.zoom);
                this.goTo(lat, lng, zoom);
                modal.close();
            }
        });

        // Export & Import event listeners
        modal.querySelector('#export-btn').addEventListener('click', () => this.exportFavorites());
        modal.querySelector('#import-btn').addEventListener('click', () => this.importFavorites());
    }

    // Export Function
    async exportFavorites() {
        try {
            const favorites = await this.getFavorites();

            if (favorites.length === 0) {
                this.showToast(_.no_favorites_to_export);
                return;
            }

            const exportData = {
                version: "1.0",
                exportDate: new Date().toISOString(),
                count: favorites.length,
                favorites: favorites
            };

            const dataStr = JSON.stringify(exportData, null, 2);
            const dataBlob = new Blob([dataStr], { type: 'application/json' });

            const link = document.createElement('a');
            link.href = URL.createObjectURL(dataBlob);
            link.download = `wplace-favorites-${new Date().toISOString().split('T')[0]}.json`;
            link.click();

            this.showToast(`${favorites.length}${_.favorites_exported}`);

        } catch (error) {
            console.error(`${_.export_error}:`, error);
            this.showToast(_.export_failed);
        }
    }

    // Import Function
    importFavorites() {
        const fileInput = document.getElementById('import-file');
        fileInput.click();

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

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

                // Data format check
                if (!importData.favorites || !Array.isArray(importData.favorites)) {
                    throw new Error(_.invalid_file_format);
                }

                const currentFavorites = await this.getFavorites();
                const importCount = importData.favorites.length;

                if (!confirm(`${_.confirm_import_1} ${importCount} ${_.confirm_import_2}`)) {
                    return;
                }

                // Duplicate check (exclude those with the same coordinates)
                const newFavorites = importData.favorites.filter(importFav => {
                    return !currentFavorites.some(existing =>
                        Math.abs(existing.lat - importFav.lat) < 0.001 &&
                        Math.abs(existing.lng - importFav.lng) < 0.001
                    );
                });

                // Reassign new IDs (integers)
                newFavorites.forEach((fav, index) => {
                    fav.id = Date.now() + index;
                });

                // Merge and save
                const mergedFavorites = [...currentFavorites, ...newFavorites];
                await GM.setValue(this.STORAGE_KEY, JSON.stringify(mergedFavorites));

                this.renderFavorites();
                this.showToast(`${newFavorites.length}${_.imported}`);

            } catch (error) {
                console.error(`${_.import_error}:`, error);
                this.showToast(`${_.import_failed}: ` + error.message);
            }

            // Clear file input
            fileInput.value = '';
        };
    }

    // Open Modal
    openModal() {
        this.renderFavorites();
        document.getElementById('favorite-modal').showModal();
    }

    // Get Current Position
    getCurrentPosition() {
        try {
            const locationStr = localStorage.getItem('location');
            if (locationStr) {
                const location = JSON.parse(locationStr);
                return {
                    lat: location.lat,
                    lng: location.lng,
                    zoom: location.zoom
                };
            }
        } catch (error) {
            console.error(`${_.location_retrieval_error}:`, error);
        }
        return null;
    }

    // Add Favorite
    async addFavorite() {
        const position = this.getCurrentPosition();
        if (!position) {
            alert(_.unable_to_retrieve_location);
            return;
        }

        const name = prompt(`${_.enter_favorite_name}:`, `${_.location} (${position.lat.toFixed(3)}, ${position.lng.toFixed(3)})`);
        if (!name) return;

        const favorite = {
            id: Date.now(),
            name: name,
            lat: position.lat,
            lng: position.lng,
            zoom: position.zoom || 14,
            date: new Date().toLocaleDateString('ja-JP')
        };

        const favorites = await this.getFavorites();
        favorites.push(favorite);
        await GM.setValue(this.STORAGE_KEY, JSON.stringify(favorites));

        // Notification
        this.showToast(`"${name}" ${_.favorite_saved}`);
    }

    // Get Favorites List
    async getFavorites() {
        try {
            const stored = await GM.getValue(this.STORAGE_KEY, '[]');
            return JSON.parse(stored);
        } catch (error) {
            console.error(`${_.favorite_retrieval_error}:`, error);
            return [];
        }
    }

    // Render Favorites List
    async renderFavorites() {
        const favorites = await this.getFavorites();
        const grid = document.getElementById('favorites-grid');
        const count = document.getElementById('favorites-count');

        if (!grid || !count) return;

        count.textContent = `${_.favorite_count}: ${favorites.length} ${_.items}`;

        if (favorites.length === 0) {
            grid.innerHTML = `
                <div class="col-span-full text-center py-12">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-12 mx-auto mb-4 text-base-content/50">
                        <path d="m354-287 126-76 126 77-33-144 111-96-146-13-58-136-58 135-146 13 111 97-33 143ZM233-120l65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Zm247-350Z"/>
                    </svg>
                    <p class="text-base-content/80">${_.no_favorites}</p>
                    <p class="text-sm text-base-content/60">${_.add_favorites}</p>
                </div>
            `;
            return;
        }

        // Sort by newest first
        favorites.sort((a, b) => b.id - a.id);

        grid.innerHTML = favorites.map(fav => `
            <div class="favorite-card card bg-base-200 shadow-sm hover:shadow-md cursor-pointer transition-all relative"
                 data-lat="${fav.lat}" data-lng="${fav.lng}" data-zoom="${fav.zoom}">
                <button class="delete-btn btn btn-ghost btn-xs btn-circle absolute right-1 top-1 z-10"
                        data-id="${fav.id}">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-3">
                        <path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/>
                    </svg>
                </button>
                <div class="card-body p-3">
                    <h4 class="card-title text-sm line-clamp-2">${fav.name}</h4>
                    <div class="text-xs text-base-content/70 space-y-1">
                        <div>📍 ${fav.lat.toFixed(3)}, ${fav.lng.toFixed(3)}</div>
                        <div>📅 ${fav.date}</div>
                    </div>
                </div>
            </div>
        `).join('');
    }

    // Go To Location
    goTo(lat, lng, zoom) {
        const url = new URL(window.location);
        url.searchParams.set('lat', lat);
        url.searchParams.set('lng', lng);
        url.searchParams.set('zoom', zoom);
        window.location.href = url.toString();
    }

    // Delete Favorite
    async deleteFavorite(id) {
        if (!confirm(`${_.confirm_delete}`)) return;

        const favorites = await this.getFavorites();
        const filtered = favorites.filter(fav => fav.id !== id);
        await GM.setValue(this.STORAGE_KEY, JSON.stringify(filtered));

        this.renderFavorites();
        this.showToast(`${_.favorite_deleted}`);
    }

    // Show Toast Notification
    showToast(message) {
        const toast = document.createElement('div');
        toast.className = 'toast toast-top toast-end z-50';
        toast.innerHTML = `
            <div class="alert alert-success">
                <span>${message}</span>
            </div>
        `;

        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), 3000);
    }
}

// Initialize
new WPlaceExtendedFavorites();
// @main_end{wplace-infinite-bookmarks}

console.log(`End - ${script_id}`)