// ==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}`)