Easy Storage Manager is a handy script that allows you to easily export and import local storage data for WME.
// ==UserScript==
// @name WME Easy Storage Manager
// @namespace https://greasyfork.org/en/scripts/466806-easy-storage-manager
// @author DevlinDelFuego, Hiwi234
// @version 2026.04.13
// @description Easy Storage Manager is a handy script that allows you to easily export and import local storage data for WME.
// @match *://*.waze.com/*editor*
// @exclude *://*.waze.com/user/editor*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @connect api.dropboxapi.com
// @connect content.dropboxapi.com
// @connect www.googleapis.com
// @connect oauth2.googleapis.com
// @connect accounts.google.com
// @connect esm-token-proxy.devlinlab.com
// @require https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @license GPLv3
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
const ESM_DIAG = {
log: (...args) => console.log('[ESM]', ...args),
warn: (...args) => console.warn('[ESM]', ...args),
error: (...args) => console.error('[ESM]', ...args)
};
window.addEventListener('error', (e) => {
try { sessionStorage.setItem('ESM_DIAG_LAST_ERROR', `${e.message} at ${e.filename}:${e.lineno}`); } catch (err) {}
ESM_DIAG.error('Unhandled error:', e.message);
});
window.addEventListener('unhandledrejection', (e) => {
try { sessionStorage.setItem('ESM_DIAG_LAST_REJECTION', String(e.reason)); } catch (err) {}
ESM_DIAG.error('Unhandled rejection:', e.reason);
});
// Compact UI styles to avoid blowing up the layout
(function injectCompactStyles(){
const css = `
/* Compact buttons across all ESM variants */
#esm-import, #esm-export,
#esm-cloud-backup, #esm-cloud-restore,
#esm-gdrive-backup, #esm-gdrive-restore,
#esm-import-btn, #esm-export-btn,
#esm-drive-backup-btn, #esm-drive-restore-btn,
#esm-gdrive-backup-btn, #esm-gdrive-restore-btn {
padding: 6px 10px !important;
font-size: 12px !important;
width: 100% !important;
flex: 1 1 0 !important;
min-width: 0 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
box-sizing: border-box !important;
margin: 0 !important;
}
/* Row containers: use CSS Grid for perfect equality with 2 equal columns */
#esm-tab > div, #easy-storage-manager-tab > div {
display: grid !important;
width: 100% !important;
grid-template-columns: 1fr 1fr !important;
gap: 8px !important;
align-items: stretch !important;
box-sizing: border-box !important;
}
/* Fallback panel button container - also 3 equal columns */
#esm-fallback-panel .btnRow,
#esm-fallback-panel > div[style*="display: flex"] {
display: grid !important;
width: 100% !important;
grid-template-columns: 1fr 1fr 1fr !important;
gap: 8px !important;
align-items: stretch !important;
box-sizing: border-box !important;
margin: 0 !important;
}
@media (max-width: 768px) {
#esm-tab > div, #easy-storage-manager-tab > div,
#esm-fallback-panel .btnRow,
#esm-fallback-panel > div[style*="display: flex"] {
grid-template-columns: 1fr 1fr !important;
}
}
@media (max-width: 480px) {
#esm-tab > div, #easy-storage-manager-tab > div,
#esm-fallback-panel .btnRow,
#esm-fallback-panel > div[style*="display: flex"] {
grid-template-columns: 1fr !important;
}
}
`;
try {
const style = document.createElement('style');
style.id = 'esm-compact-styles';
style.textContent = css;
if (document.head) {
document.head.appendChild(style);
} else {
document.addEventListener('DOMContentLoaded', function(){
try { document.head.appendChild(style); } catch (_) {}
});
}
} catch (_) {}
})();
let importedData; // Imported JSON data
let applyButton; // Apply button element
let autoSaveInterval = null;
let autoSaveEnabled = false;
const AUTO_SAVE_ENABLED_KEY = 'ESM_AUTO_SAVE_ENABLED';
const AUTO_SAVE_INTERVAL_KEY = 'ESM_AUTO_SAVE_INTERVAL';
const AUTO_SAVE_TARGET_KEY = 'ESM_AUTO_SAVE_TARGET';
const AUTO_SAVE_MAX_BACKUPS_KEY = 'ESM_AUTO_SAVE_MAX_BACKUPS';
let scriptVersion = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) ? GM_info.script.version : 'dev-local';
const updateMessage = "<b>Changelog</b><br><br> - Added Dropbox cloud backup and restore functionality. <br> - Added Google Drive cloud backup and restore functionality. <br> - Improved backend security when connecting to cloud backup servers. <br><br>";
const REAPPLY_STASH_KEY = 'ESM_POST_RELOAD';
// Sprachunterstützung (DE/EN) für UI-Texte
const ESM_LANG = ((navigator.language || 'en').toLowerCase().startsWith('de')) ? 'de' : 'en';
const ESM_I18N = {
de: {
panelTitle: 'Cloud-Backup (Dropbox) – Anleitung',
show: 'Anzeigen',
hide: 'Ausblenden',
howTo: 'So aktivierst du die Online-Cloud:',
step1: 'Bei Dropbox anmelden.',
step2: 'App erstellen (kostenlos):',
step3: 'In deiner App einen Generated access token erzeugen.',
step4: 'Token unten eingeben und speichern.',
genTokenLabel: 'Generated access token:',
saveTokenBtn: 'Token speichern',
clearTokenBtn: 'Abmelden',
statusEnterToken: 'Bitte Token eingeben.',
statusSavedValidated: 'Token gespeichert und validiert. Dropbox ist bereit.',
statusSignedOut: 'Abgemeldet. Bitte neuen Token eingeben.',
cloudBackup: '☁️ Dropbox Sichern',
cloudRestore: '☁️ Dropbox Laden',
cloudBackupTitle: '☁️ Backup in Dropbox sichern',
cloudRestoreTitle: '☁️ Aus Dropbox wiederherstellen',
gdriveBackup: '📁 Google Sichern',
gdriveRestore: '📁 Google Laden',
gdriveBackupTitle: '📁 Backup in Google Drive sichern',
gdriveRestoreTitle: '📁 Aus Google Drive wiederherstellen',
gdrivePanelTitle: 'Google Drive – Verbindung',
gdriveHowTo: 'So aktivierst du Google Drive:',
gdriveStep1: 'Google Cloud Console öffnen.',
gdriveStep2: 'Projekt erstellen und Google Drive API aktivieren.',
gdriveStep3: 'OAuth 2.0 Client-ID erstellen (Web-App).',
gdriveStep4: 'API-Key unten eingeben und speichern.',
gdriveTokenLabel: 'Google Drive API Key / Access Token:',
gdriveSaveTokenBtn: 'Token speichern',
gdriveClearTokenBtn: 'Abmelden',
gdriveStatusEnterToken: 'Bitte Google Drive Token eingeben.',
gdriveStatusSaved: 'Token gespeichert. Google Drive ist bereit.',
gdriveStatusSignedOut: 'Abgemeldet. Bitte neuen Token eingeben.',
gdriveSaveSuccess: '✅ Backup erfolgreich in Google Drive gespeichert!',
gdriveSaveFailed: '❌ Google Drive Backup fehlgeschlagen:',
gdriveNoBackups: '❌ Keine Backup-Dateien in Google Drive gefunden.',
gdriveLoadSuccess: '✅ Google Drive Backup erfolgreich geladen!',
gdriveRestoreFailed: '❌ Google Drive Wiederherstellung fehlgeschlagen:',
gdriveExportUnavailable: 'Google Drive Backup-Funktion nicht verfügbar',
gdriveImportUnavailable: 'Google Drive Wiederherstellungs-Funktion nicht verfügbar'
},
en: {
panelTitle: 'Cloud Backup (Dropbox) – Guide',
show: 'Show',
hide: 'Hide',
howTo: 'How to enable cloud backup:',
step1: 'Sign in to Dropbox.',
step2: 'Create an app (free):',
step3: 'Generate a personal access token in your app.',
step4: 'Enter the token below and save it.',
genTokenLabel: 'Generated access token:',
saveTokenBtn: 'Save Token',
clearTokenBtn: 'Sign out',
statusEnterToken: 'Please enter a token.',
statusSavedValidated: 'Token saved and validated. Dropbox is ready.',
statusSignedOut: 'Signed out. Please enter a new token.',
cloudBackup: '☁️ Dropbox Backup',
cloudRestore: '☁️ Dropbox Restore',
cloudBackupTitle: '☁️ Save backup to Dropbox',
cloudRestoreTitle: '☁️ Restore from Dropbox',
gdriveBackup: '📁 Google Backup',
gdriveRestore: '📁 Google Restore',
gdriveBackupTitle: '📁 Save backup to Google Drive',
gdriveRestoreTitle: '📁 Restore from Google Drive',
gdrivePanelTitle: 'Google Drive – Connection',
gdriveHowTo: 'How to enable Google Drive backup:',
gdriveStep1: 'Open Google Cloud Console.',
gdriveStep2: 'Create a project and enable Google Drive API.',
gdriveStep3: 'Create an OAuth 2.0 Client ID (Web App).',
gdriveStep4: 'Enter the token below and save it.',
gdriveTokenLabel: 'Google Drive API Key / Access Token:',
gdriveSaveTokenBtn: 'Save Token',
gdriveClearTokenBtn: 'Sign out',
gdriveStatusEnterToken: 'Please enter a Google Drive token.',
gdriveStatusSaved: 'Token saved. Google Drive is ready.',
gdriveStatusSignedOut: 'Signed out. Please enter a new token.',
gdriveSaveSuccess: '✅ Backup saved to Google Drive successfully!',
gdriveSaveFailed: '❌ Google Drive backup failed:',
gdriveNoBackups: '❌ No backup files found in Google Drive.',
gdriveLoadSuccess: '✅ Google Drive backup loaded successfully!',
gdriveRestoreFailed: '❌ Google Drive restore failed:',
gdriveExportUnavailable: 'Google Drive backup function not available',
gdriveImportUnavailable: 'Google Drive restore function not available'
}
};
function t(key) {
const langVal = (ESM_I18N[ESM_LANG] && ESM_I18N[ESM_LANG][key]) || null;
const enVal = (ESM_I18N.en && ESM_I18N.en[key]) || null;
const deVal = (ESM_I18N.de && ESM_I18N.de[key]) || null;
return langVal || enVal || deVal || key;
}
// Extend language maps with additional UI texts for Import/Export & Drive
try {
Object.assign(ESM_I18N.de, {
importExportDesc: 'Importiere eine Backup-JSON-Datei oder exportiere ein vollständiges Backup (localStorage, sessionStorage, Cookies, IndexedDB).',
importBackup: '♻️ Wiederherstellen',
exportBackup: '💾 Lokal Speichern',
importBackupTitle: '♻️ Backup importieren',
exportBackupTitle: '💾 Backup exportieren',
driveBackupTitle: '☁️ Backup in Dropbox sichern',
driveRestoreTitle: '☁️ Aus Dropbox wiederherstellen',
importFunctionUnavailable: 'Import-Funktion nicht verfügbar',
exportFunctionUnavailable: 'Export-Funktion nicht verfügbar',
dropboxExportUnavailable: 'Dropbox Backup-Funktion nicht verfügbar',
dropboxImportUnavailable: 'Dropbox Wiederherstellungs-Funktion nicht verfügbar',
driveExportUnavailable: 'Dropbox Backup-Funktion nicht verfügbar',
driveImportUnavailable: 'Dropbox Wiederherstellungs-Funktion nicht verfügbar',
selectAll: 'Alle auswählen',
deselectAll: 'Auswahl aufheben',
apply: 'Anwenden',
scriptTitle: 'Easy Storage Manager',
fallbackDesc: 'Fallback-Panel aktiv. Importiere/Exportiere Backups und wähle Schlüssel zur Wiederherstellung.',
autoSaveLabel: '⏱️ Auto Save',
autoSaveEnable: 'Auto Save aktivieren',
autoSaveInterval: 'Intervall:',
autoSaveTarget: 'Ziel:',
autoSaveTargetLocal: 'Lokal (Download)',
autoSaveTargetDropbox: 'Dropbox',
autoSaveTargetGdrive: 'Google Drive',
autoSaveRunning: 'Auto Save läuft...',
autoSaveDone: '✅ Auto Save abgeschlossen.',
autoSaveFailed: '❌ Auto Save fehlgeschlagen:',
autoSaveMaxBackups: 'Backups:',
autoSaveMaxBackupsHint1: '1 = überschreiben',
autoSaveMaxBackupsHint10: '10 = rotierend'
});
Object.assign(ESM_I18N.en, {
importExportDesc: 'Import a backup JSON file or export a full backup (localStorage, sessionStorage, cookies, IndexedDB).',
importBackup: '♻️ Restore',
exportBackup: '💾 Save Local',
importBackupTitle: '♻️ Import Backup',
exportBackupTitle: '💾 Export Backup',
driveBackupTitle: '☁️ Save backup to Dropbox',
driveRestoreTitle: '☁️ Restore from Dropbox',
importFunctionUnavailable: 'Import function not available',
exportFunctionUnavailable: 'Export function not available',
dropboxExportUnavailable: 'Dropbox export function not available',
dropboxImportUnavailable: 'Dropbox restore function not available',
driveExportUnavailable: 'Dropbox export function not available',
driveImportUnavailable: 'Dropbox import function not available',
selectAll: 'Select All',
deselectAll: 'Deselect All',
apply: 'Apply',
scriptTitle: 'Easy Storage Manager',
fallbackDesc: 'Fallback panel active. Import/export backups and choose keys to restore.',
autoSaveLabel: '⏱️ Auto Save',
autoSaveEnable: 'Enable Auto Save',
autoSaveInterval: 'Interval:',
autoSaveTarget: 'Target:',
autoSaveTargetLocal: 'Local (Download)',
autoSaveTargetDropbox: 'Dropbox',
autoSaveTargetGdrive: 'Google Drive',
autoSaveRunning: 'Auto Save running...',
autoSaveDone: '✅ Auto Save completed.',
autoSaveFailed: '❌ Auto Save failed:',
autoSaveMaxBackups: 'Backups:',
autoSaveMaxBackupsHint1: '1 = overwrite',
autoSaveMaxBackupsHint10: '10 = rotating'
});
} catch (_) {}
// Zusätzliche Texte für vollständige UI-Übersetzung (Alerts/Labels)
try {
Object.assign(ESM_I18N.de, {
dropboxSaveSuccessPrefix: '✅ Backup erfolgreich in Dropbox gespeichert!',
dropboxSaveFailedPrefix: '❌ Dropbox Backup fehlgeschlagen:',
dropboxNoBackups: '❌ Keine Backup-Dateien in Dropbox gefunden.',
invalidSelection: '❌ Ungültige Auswahl.',
foreignBackupHint: 'Hinweis: Das Backup stammt von einer anderen Quelle. Aus Sicherheitsgründen werden Cookies und Session Storage standardmäßig nicht importiert.',
dropboxLoadSuccessPrefix: '✅ Dropbox Backup erfolgreich geladen!',
foundEntriesLabel: 'Gefundene Einträge:',
invalidJson: '❌ Backup-Datei konnte nicht gelesen werden: Ungültiges JSON-Format.',
dropboxRestoreFailedPrefix: '❌ Dropbox Wiederherstellung fehlgeschlagen:',
fileReadSuccess: 'Datei erfolgreich gelesen',
fileReadError: 'Fehler beim Lesen der Datei. Bitte erneut versuchen.',
noKeysSelected: 'Keine Schlüssel ausgewählt. Nichts zu importieren.',
fileLabel: 'Datei:',
pathLabel: 'Pfad:',
sizeLabel: 'Größe:',
kb: 'KB'
});
Object.assign(ESM_I18N.en, {
dropboxSaveSuccessPrefix: '✅ Backup saved to Dropbox successfully!',
dropboxSaveFailedPrefix: '❌ Dropbox backup failed:',
dropboxNoBackups: '❌ No backup files found in Dropbox.',
invalidSelection: '❌ Invalid selection.',
foreignBackupHint: 'Note: The backup originates from a different source. For security reasons, cookies and session storage are not imported by default.',
dropboxLoadSuccessPrefix: '✅ Dropbox backup loaded successfully!',
foundEntriesLabel: 'Found entries:',
invalidJson: '❌ Could not read backup file: Invalid JSON format.',
dropboxRestoreFailedPrefix: '❌ Dropbox restore failed:',
fileReadSuccess: 'File read successfully',
fileReadError: 'Error occurred while reading the file. Please try again.',
noKeysSelected: 'No keys selected. Nothing to import.',
fileLabel: 'File:',
pathLabel: 'Path:',
sizeLabel: 'Size:',
kb: 'KB'
});
} catch (_) {}
try {
Object.assign(ESM_I18N.de, {
restorePrompt: 'Welche Datei möchten Sie wiederherstellen?'
});
Object.assign(ESM_I18N.en, {
restorePrompt: 'Which file would you like to restore?'
});
} catch (_) {}
// Dropbox API Configuration
const DROPBOX_CONFIG = {
APP_KEY: '9fxl4soww5di6qt',
ACCESS_TOKEN: null,
// Use same-origin Waze Dropbox endpoint to satisfy CSP
PROXY_BASE_URL: '/dropbox',
API_BASE_URL: 'https://api.dropboxapi.com/2',
CONTENT_API_URL: 'https://content.dropboxapi.com/2'
};
let dropboxAuth = null;
const DROPBOX_TOKEN_KEY = 'ESM_DROPBOX_TOKEN';
const DROPBOX_ACCOUNT_CACHE_KEY = 'ESM_DROPBOX_ACCOUNT';
const DROPBOX_REFRESH_TOKEN_KEY = 'ESM_DROPBOX_REFRESH_TOKEN';
const DROPBOX_TOKEN_EXPIRES_KEY = 'ESM_DROPBOX_TOKEN_EXPIRES';
// Sandboxed storage for OAuth refresh tokens (GM_setValue when available, localStorage fallback).
// Access tokens use sessionStorage — cleared on tab close.
const gmStorage = {
get(key) {
try { if (typeof GM_getValue === 'function') return GM_getValue(key, null); } catch (_) {}
return localStorage.getItem(key);
},
set(key, value) {
try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } } catch (_) {}
localStorage.setItem(key, value);
},
remove(key) {
try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(key); return; } } catch (_) {}
localStorage.removeItem(key);
}
};
// Hook alerts to display bilingual messages for known keys
(function(){
const origAlert = window.alert;
const map = {
'Import function not available': t('importFunctionUnavailable'),
'Export function not available': t('exportFunctionUnavailable'),
'Dropbox Backup-Funktion nicht verfügbar': t('dropboxExportUnavailable'),
'Dropbox Wiederherstellungs-Funktion nicht verfügbar': t('dropboxImportUnavailable'),
};
window.alert = function(msg){
const key = String(msg);
const repl = map[key] || key;
return origAlert(repl);
};
})();
function setDropboxToken(token) {
try {
sessionStorage.setItem(DROPBOX_TOKEN_KEY, token);
dropboxAuth = token;
ESM_DIAG.log('Dropbox token gespeichert.');
} catch (e) {
ESM_DIAG.warn('Konnte Dropbox-Token nicht speichern:', e);
}
}
function clearDropboxToken() {
try {
sessionStorage.removeItem(DROPBOX_TOKEN_KEY);
sessionStorage.removeItem(DROPBOX_TOKEN_EXPIRES_KEY);
gmStorage.remove(DROPBOX_REFRESH_TOKEN_KEY);
dropboxAuth = null;
sessionStorage.removeItem(DROPBOX_ACCOUNT_CACHE_KEY);
ESM_DIAG.log('Dropbox-Token gelöscht.');
} catch (e) {
ESM_DIAG.warn('Konnte Dropbox-Token nicht löschen:', e);
}
}
async function promptForDropboxToken() {
const hint = 'Bitte persönliches Dropbox Access Token eingeben (Bearer Token).\n' +
'Anleitung: Öffne https://www.dropbox.com/developers/apps, wähle deine App,\n' +
'erzeuge ein Access Token und füge es hier ein.';
const token = prompt(hint);
if (!token) throw new Error('Kein Dropbox-Token eingegeben');
setDropboxToken(token.trim());
// Validierung
await getDropboxAccount(token.trim());
return token.trim();
}
async function getDropboxAccount(accessToken) {
// Cache lesen
try {
const cached = sessionStorage.getItem(DROPBOX_ACCOUNT_CACHE_KEY);
if (cached) return JSON.parse(cached);
} catch (_) {}
if (typeof GM_xmlhttpRequest !== 'function') {
// Ohne GM können wir den Account nicht bequem validieren; Return minimal
return { account_id: 'unknown', email: 'unknown' };
}
const res = await gmFetch(`${DROPBOX_CONFIG.API_BASE_URL}/users/get_current_account`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` },
// Body muss leer sein
});
if (res.status < 200 || res.status >= 300) {
throw new Error(`Dropbox-Token ungültig (${res.status})`);
}
let info;
try { info = JSON.parse(res.responseText); } catch (_) { info = { account_id: 'unknown', email: 'unknown' }; }
try { sessionStorage.setItem(DROPBOX_ACCOUNT_CACHE_KEY, JSON.stringify(info)); } catch (_) {}
return info;
}
// ===== OAuth 2.0 (PKCE) Hilfsfunktionen =====
function base64urlFromBytes(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function generateCodeVerifier() {
const bytes = new Uint8Array(64);
crypto.getRandomValues(bytes);
return base64urlFromBytes(bytes);
}
async function sha256Base64Url(input) {
const data = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64urlFromBytes(new Uint8Array(hash));
}
// Wrapper around GM_xmlhttpRequest to bypass page CSP when available
function gmFetch(url, opts = {}) {
const method = opts.method || 'GET';
const headers = opts.headers || {};
// Only include a request body if the caller provided one.
// This avoids sending the literal string "null" to endpoints (e.g. Dropbox files/download)
// that require an entirely empty request body.
const hasBody = Object.prototype.hasOwnProperty.call(opts, 'body');
let body = null;
if (hasBody) {
if (typeof opts.body === 'string') body = opts.body;
else if (opts.body != null) body = JSON.stringify(opts.body);
}
const responseType = opts.responseType || 'text';
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('GM_xmlhttpRequest not available'));
return;
}
try {
const req = {
url,
method,
headers,
responseType,
onload: (res) => resolve(res),
onerror: (err) => reject(new Error(err && err.error ? err.error : 'GM request failed')),
ontimeout: () => reject(new Error('GM request timeout'))
};
if (body != null) {
req.data = body;
}
GM_xmlhttpRequest(req);
} catch (e) {
reject(e);
}
});
}
// Get safe, preferably native storage methods from a fresh iframe context (in case other scripts patch the prototype)
function captureNativeStorage() {
try {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'about:blank';
document.documentElement.appendChild(iframe);
const win = iframe.contentWindow;
const methods = {
getItem: win.Storage.prototype.getItem,
setItem: win.Storage.prototype.setItem,
removeItem: win.Storage.prototype.removeItem,
key: win.Storage.prototype.key
};
iframe.parentNode.removeChild(iframe);
return methods;
} catch (e) {
// Fallback to current (possibly patched) prototype
return {
getItem: Storage.prototype.getItem,
setItem: Storage.prototype.setItem,
removeItem: Storage.prototype.removeItem,
key: Storage.prototype.key
};
}
}
const NativeStorage = captureNativeStorage();
// ===== Einfache Anleitung & Token-Input (UI) für Dropbox Cloud =====
// parent: optional Container; wenn gesetzt, wird das Panel inline im Skripte-Tab gerendert
function injectDropboxHelpPanel(parent) {
try {
if (document.getElementById('esm-dropbox-help-panel')) return;
const inline = !!parent;
const hiddenKey = inline ? 'ESM_DROPBOX_HELP_TAB_HIDDEN' : 'ESM_DROPBOX_HELP_HIDDEN';
const hidden = localStorage.getItem(hiddenKey) === '1';
const wrapper = document.createElement('div');
wrapper.id = 'esm-dropbox-help-panel';
wrapper.style.fontFamily = "system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif";
wrapper.style.color = '#111827';
if (!inline) {
wrapper.style.position = 'fixed';
wrapper.style.bottom = '16px';
wrapper.style.right = '16px';
wrapper.style.zIndex = '2147483646';
} else {
wrapper.style.marginTop = '10px';
wrapper.style.width = '100%';
}
const card = document.createElement('div');
card.style.background = '#ffffff';
card.style.border = '1px solid #e5e7eb';
card.style.borderRadius = '12px';
card.style.overflow = 'hidden';
if (!inline) {
card.style.boxShadow = '0 6px 18px rgba(0,0,0,0.12)';
card.style.width = '340px';
card.style.maxWidth = '90vw';
} else {
card.style.boxShadow = 'none';
card.style.width = '100%';
card.style.maxWidth = '100%';
}
const header = document.createElement('div');
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.justifyContent = 'space-between';
header.style.padding = '10px 12px';
header.style.background = '#f9fafb';
header.style.borderBottom = '1px solid #e5e7eb';
const title = document.createElement('div');
title.textContent = t('panelTitle');
title.style.fontSize = '13px';
title.style.fontWeight = '600';
const controls = document.createElement('div');
const btnHide = document.createElement('button');
btnHide.textContent = hidden ? t('show') : t('hide');
btnHide.style.fontSize = '12px';
btnHide.style.border = '1px solid #d1d5db';
btnHide.style.background = '#ffffff';
btnHide.style.borderRadius = '8px';
btnHide.style.padding = '4px 8px';
btnHide.style.cursor = 'pointer';
btnHide.addEventListener('click', () => {
const contentVisible = content.style.display !== 'none';
content.style.display = contentVisible ? 'none' : 'block';
btnHide.textContent = contentVisible ? t('show') : t('hide');
try { localStorage.setItem(hiddenKey, contentVisible ? '1' : '0'); } catch (_) {}
});
controls.appendChild(btnHide);
header.appendChild(title);
header.appendChild(controls);
const content = document.createElement('div');
content.style.padding = '12px';
content.style.display = hidden ? 'none' : 'block';
const inner = document.createElement('div');
inner.style.fontSize = '12px';
inner.style.lineHeight = '1.5';
const howToP = document.createElement('p');
howToP.style.margin = '0 0 8px';
const howToBold = document.createElement('b');
howToBold.textContent = t('howTo');
howToP.appendChild(howToBold);
inner.appendChild(howToP);
const ol = document.createElement('ol');
ol.style.margin = '0 0 10px 18px';
ol.style.padding = '0';
const steps = [t('step1'), null, t('step3'), t('step4')];
steps.forEach((stepText, i) => {
const li = document.createElement('li');
if (i === 1) {
li.textContent = t('step2') + ' ';
const a = document.createElement('a');
a.href = 'https://www.dropbox.com/developers/apps';
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'https://www.dropbox.com/developers/apps';
li.appendChild(a);
} else {
li.textContent = stepText;
}
ol.appendChild(li);
});
inner.appendChild(ol);
const tokenLabel = document.createElement('div');
tokenLabel.style.margin = '8px 0 4px';
tokenLabel.textContent = t('genTokenLabel');
inner.appendChild(tokenLabel);
const input = document.createElement('input');
input.id = 'esm-dropbox-token-input';
input.type = 'text';
input.placeholder = 'Dropbox Access Token';
input.style.width = '100%';
input.style.boxSizing = 'border-box';
input.style.fontSize = '12px';
input.style.padding = '6px 8px';
input.style.border = '1px solid #d1d5db';
input.style.borderRadius = '8px';
inner.appendChild(input);
const btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.gap = '8px';
btnRow.style.marginTop = '8px';
const saveBtn = document.createElement('button');
saveBtn.id = 'esm-dropbox-token-save';
saveBtn.textContent = t('saveTokenBtn');
saveBtn.style.flex = '1';
saveBtn.style.fontSize = '12px';
saveBtn.style.border = '1px solid #10b981';
saveBtn.style.background = '#10b981';
saveBtn.style.color = '#fff';
saveBtn.style.borderRadius = '8px';
saveBtn.style.padding = '6px 8px';
saveBtn.style.cursor = 'pointer';
btnRow.appendChild(saveBtn);
const clearBtn = document.createElement('button');
clearBtn.id = 'esm-dropbox-token-clear';
clearBtn.textContent = t('clearTokenBtn');
clearBtn.style.flex = '1';
clearBtn.style.fontSize = '12px';
clearBtn.style.border = '1px solid #ef4444';
clearBtn.style.background = '#ef4444';
clearBtn.style.color = '#fff';
clearBtn.style.borderRadius = '8px';
clearBtn.style.padding = '6px 8px';
clearBtn.style.cursor = 'pointer';
btnRow.appendChild(clearBtn);
inner.appendChild(btnRow);
const statusEl = document.createElement('div');
statusEl.id = 'esm-dropbox-token-status';
statusEl.style.marginTop = '8px';
statusEl.style.fontSize = '12px';
statusEl.style.color = '#374151';
inner.appendChild(statusEl);
content.appendChild(inner);
card.appendChild(header);
card.appendChild(content);
wrapper.appendChild(card);
if (inline && parent) {
parent.appendChild(wrapper);
} else {
document.documentElement.appendChild(wrapper);
}
try {
const stored = sessionStorage.getItem(DROPBOX_TOKEN_KEY);
if (stored) input.value = stored;
} catch (_) {}
async function setStatus(msg, type) {
statusEl.textContent = msg || '';
statusEl.style.color = type === 'error' ? '#b91c1c' : (type === 'success' ? '#065f46' : '#374151');
}
saveBtn.addEventListener('click', async () => {
const token = String(input.value || '').trim();
if (!token) { setStatus(t('statusEnterToken'), 'error'); return; }
try {
setDropboxToken(token);
// optional: validate account
await getDropboxAccount(token);
setStatus(t('statusSavedValidated'), 'success');
} catch (e) {
setStatus(`Fehler beim Speichern/Validieren: ${e && e.message ? e.message : e}`, 'error');
}
});
clearBtn.addEventListener('click', async () => {
try {
clearDropboxToken();
input.value = '';
setStatus(t('statusSignedOut'), '');
} catch (e) {
setStatus(`Fehler beim Abmelden: ${e && e.message ? e.message : e}`, 'error');
}
});
} catch (e) {
ESM_DIAG.warn('Konnte Dropbox-Hilfspanel nicht einfügen:', e);
}
}
// Hinweis: Die Hilfe wird jetzt im Skripte-Tab inline eingefügt (siehe addScriptTab)
// Authenticate with Dropbox (ohne OAuth, nur gespeicherter Access-Token)
async function authenticateDropbox() {
try {
ESM_DIAG.log('Dropbox-Authentifizierung (nur Token) starten...');
// Direkt gespeicherten Token verwenden
const token = (dropboxAuth && String(dropboxAuth).trim()) || sessionStorage.getItem(DROPBOX_TOKEN_KEY);
if (token && String(token).trim()) {
try { await getDropboxAccount(token); } catch (_) {}
dropboxAuth = String(token).trim();
return dropboxAuth;
}
// Kein Token vorhanden -> Nutzer um persönlichen Access-Token bitten
const manual = await promptForDropboxToken();
dropboxAuth = manual;
return manual;
} catch (error) {
ESM_DIAG.error('Dropbox-Authentifizierung (Token) fehlgeschlagen:', error);
throw error;
}
}
// Attempt to attach CSRF headers expected by waze.com endpoints
function getCsrfHeaders() {
try {
const cookies = document.cookie.split(';').map(s => s.trim()).reduce((acc, cur) => {
const idx = cur.indexOf('=');
if (idx > -1) {
try { acc[cur.slice(0, idx)] = decodeURIComponent(cur.slice(idx + 1)); } catch (_) {}
}
return acc;
}, {});
const rawToken = cookies['XSRF-TOKEN'] || cookies['xsrf-token'] || cookies['_csrf'] || cookies['csrf'] || cookies['csrf_token'] || cookies['X-CSRF-TOKEN'];
// Validate token contains only safe characters before putting it in headers
const token = (rawToken && /^[a-zA-Z0-9\-_.~+/=]{8,}$/.test(rawToken)) ? rawToken : null;
if (!token) {
ESM_DIAG.warn('No CSRF token cookie found for waze.com Dropbox endpoint');
return {};
}
const headers = {
'X-XSRF-TOKEN': token,
'X-CSRF-TOKEN': token,
'X-CSRF-Token': token
};
ESM_DIAG.log('Attached CSRF headers for waze.com Dropbox endpoint');
return headers;
} catch (e) {
ESM_DIAG.warn('Failed to build CSRF headers:', e);
return {};
}
}
// Export backup to Dropbox
const COOKIE_WARNING_KEY = 'ESM_COOKIE_WARNING_SHOWN';
function warnCookiesOnce() {
try { if (sessionStorage.getItem(COOKIE_WARNING_KEY)) return true; } catch (_) {}
const msg = ESM_LANG === 'de'
? 'Dieses Backup enthält Cookies (inkl. Sitzungs- und Authentifizierungs-Cookies).\n\nBewahre die Datei sicher auf – wer sie besitzt, kann deine Sitzung übernehmen.\n\nFortfahren?'
: 'This backup includes cookies (including session and authentication cookies).\n\nKeep the file secure — anyone who has it can replay your session.\n\nProceed?';
const ok = confirm(msg);
if (ok) { try { sessionStorage.setItem(COOKIE_WARNING_KEY, '1'); } catch (_) {} }
return ok;
}
async function exportToDropbox() {
if (!warnCookiesOnce()) return;
try {
ESM_DIAG.log('Starting Dropbox backup...');
// Token ermitteln (kein OAuth)
const accessToken = await authenticateDropbox();
// Generate backup data (same as local export)
const backup = {
meta: {
exportedAt: new Date().toISOString(),
origin: location.origin,
scriptVersion,
backupType: 'dropbox'
},
localStorage: (() => {
const out = {};
try {
const len = window.localStorage.length;
for (let i = 0; i < len; i++) {
const k = NativeStorage.key.call(window.localStorage, i);
if (k != null) {
out[k] = NativeStorage.getItem.call(window.localStorage, k);
}
}
} catch (e) {
Object.keys(window.localStorage).forEach(k => {
try { out[k] = window.localStorage.getItem(k); } catch (_) { out[k] = null; }
});
}
return out;
})(),
sessionStorage: (() => {
const out = {};
try {
const len = window.sessionStorage.length;
for (let i = 0; i < len; i++) {
const k = NativeStorage.key.call(window.sessionStorage, i);
if (k != null) {
out[k] = NativeStorage.getItem.call(window.sessionStorage, k);
}
}
} catch (e) {
Object.keys(window.sessionStorage).forEach(k => {
try { out[k] = window.sessionStorage.getItem(k); } catch (_) { out[k] = null; }
});
}
return out;
})(),
cookies: document.cookie
.split(';')
.map(c => {
const [name, ...rest] = c.trim().split('=');
return { name, value: rest.join('=') };
})
.filter(c => c.name),
indexedDB: await backupIndexedDB()
};
const backupData = JSON.stringify(backup, null, 2);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `wme_settings_backup_${timestamp}.json`;
const account = await getDropboxAccount(accessToken);
const userFolder = `/WME_Backups/${account && account.account_id ? account.account_id : 'unknown'}`;
// Upload to Dropbox
let result;
if (typeof GM_xmlhttpRequest === 'function') {
// Use GM_xmlhttpRequest to bypass CSP and talk to Dropbox directly
ESM_DIAG.log('Uploading backup to Dropbox via GM_xmlhttpRequest...');
const gmRes = await gmFetch(`${DROPBOX_CONFIG.CONTENT_API_URL}/files/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': `Bearer ${accessToken}`,
'Dropbox-API-Arg': JSON.stringify({
path: `${userFolder}/${fileName}`,
mode: 'add',
autorename: true
})
},
body: backupData
}).catch(err => {
ESM_DIAG.error('Dropbox upload (GM) failed:', err);
throw new Error(`Network error during Dropbox upload (GM): ${err.message}`);
});
if (gmRes.status < 200 || gmRes.status >= 300) {
const errorText = gmRes.responseText || 'Unknown error';
ESM_DIAG.error('Dropbox upload (GM) failed:', gmRes.status, errorText);
throw new Error(`Upload failed: ${gmRes.status} - ${errorText}`);
}
try {
result = JSON.parse(gmRes.responseText);
} catch (err) {
ESM_DIAG.error('Failed to parse upload response (GM):', err);
throw new Error(`Invalid response format from Dropbox upload (GM): ${err.message}`);
}
} else {
// Fallback to same-origin proxy (requires CSP + CSRF compliance in WME)
ESM_DIAG.log('Uploading backup to Dropbox via same-origin proxy...');
const response = await fetch(`${DROPBOX_CONFIG.PROXY_BASE_URL}/files/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': JSON.stringify({
path: `${userFolder}/${fileName}`,
mode: 'add',
autorename: true
}),
...getCsrfHeaders()
},
credentials: 'include',
body: backupData
}).catch(err => {
ESM_DIAG.error('Dropbox upload fetch failed:', err);
throw new Error(`Network error during Dropbox upload: ${err.message}`);
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
ESM_DIAG.error('Dropbox upload failed:', response.status, response.statusText, errorText);
throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText}`);
}
result = await response.json().catch(err => {
ESM_DIAG.error('Failed to parse upload response:', err);
throw new Error(`Invalid response format from Dropbox upload: ${err.message}`);
});
}
ESM_DIAG.log('Backup uploaded to Dropbox:', result);
alert(`${t('dropboxSaveSuccessPrefix')}\n\n${t('fileLabel')} ${fileName}\n${t('pathLabel')} /WME_Backups/${fileName}\n${t('sizeLabel')} ${Math.round(backupData.length / 1024)} ${t('kb')}`);
} catch (error) {
ESM_DIAG.error('Dropbox backup failed:', error);
alert(`${t('dropboxSaveFailedPrefix')}\n\n${error.message}`);
}
}
// Import backup from Dropbox
async function importFromDropbox() {
try {
ESM_DIAG.log('Starting Dropbox restore...');
// Token ermitteln (kein OAuth)
let listJson;
const accessToken = await authenticateDropbox();
const account = await getDropboxAccount(accessToken);
const userFolder = `/WME_Backups/${account && account.account_id ? account.account_id : 'unknown'}`;
if (typeof GM_xmlhttpRequest === 'function') {
ESM_DIAG.log('Listing backup files from Dropbox via GM_xmlhttpRequest...');
const gmRes = await gmFetch(`${DROPBOX_CONFIG.API_BASE_URL}/files/list_folder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: { path: userFolder, recursive: false }
}).catch(err => {
ESM_DIAG.error('Dropbox list (GM) failed:', err);
throw new Error(`Network error during Dropbox file listing (GM): ${err.message}`);
});
if (gmRes.status < 200 || gmRes.status >= 300) {
const errorText = gmRes.responseText || 'Unknown error';
ESM_DIAG.error('Dropbox list (GM) failed:', gmRes.status, errorText);
throw new Error(`List request failed: ${gmRes.status} - ${errorText}`);
}
try {
listJson = JSON.parse(gmRes.responseText);
} catch (err) {
ESM_DIAG.error('Failed to parse list response (GM):', err);
throw new Error(`Invalid response format from Dropbox list (GM): ${err.message}`);
}
} else {
ESM_DIAG.log('Listing backup files from Dropbox via same-origin proxy...');
const listUrl = `${DROPBOX_CONFIG.PROXY_BASE_URL}/files/list_folder`;
const listResponse = await fetch(listUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getCsrfHeaders()
},
credentials: 'include',
body: JSON.stringify({
path: userFolder,
recursive: false
})
}).catch(err => {
ESM_DIAG.error('Dropbox list fetch failed:', err);
throw new Error(`Network error during Dropbox file listing: ${err.message}`);
});
if (!listResponse.ok) {
const errorText = await listResponse.text().catch(() => 'Unknown error');
ESM_DIAG.error('Dropbox list request failed:', listResponse.status, listResponse.statusText, errorText);
throw new Error(`List request failed: ${listResponse.status} ${listResponse.statusText} - ${errorText}`);
}
listJson = await listResponse.json().catch(err => {
ESM_DIAG.error('Failed to parse list response:', err);
throw new Error(`Invalid response format from Dropbox list: ${err.message}`);
});
}
const files = listJson.entries ? listJson.entries.filter(entry =>
entry['.tag'] === 'file' && entry.name.includes('wme_settings_backup')
) : [];
if (!files || files.length === 0) {
alert(t('dropboxNoBackups'));
return;
}
// Show file selection dialog
let fileList = 'Verfügbare Backups in Dropbox:\n\n';
files.forEach((file, index) => {
const date = new Date(file.client_modified).toLocaleString('de-DE');
const size = file.size ? `${Math.round(file.size / 1024)} KB` : 'Unbekannt';
fileList += `${index + 1}. ${file.name}\n Erstellt: ${date}\n Größe: ${size}\n\n`;
});
const selection = prompt(`${fileList}${t('restorePrompt')} (1-${files.length})`);
if (!selection) return;
const fileIndex = parseInt(selection, 10) - 1;
if (fileIndex < 0 || fileIndex >= files.length) {
alert(t('invalidSelection'));
return;
}
const selectedFile = files[fileIndex];
// Download the selected file from Dropbox
let backupData;
if (typeof GM_xmlhttpRequest === 'function') {
ESM_DIAG.log('Downloading backup file from Dropbox via GM_xmlhttpRequest:', selectedFile.name);
const gmRes = await gmFetch(`${DROPBOX_CONFIG.CONTENT_API_URL}/files/download`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Dropbox-API-Arg': JSON.stringify({ path: selectedFile.path_lower })
},
responseType: 'text'
}).catch(err => {
ESM_DIAG.error('Dropbox download (GM) failed:', err);
throw new Error(`Network error during Dropbox download (GM): ${err.message}`);
});
if (gmRes.status < 200 || gmRes.status >= 300) {
const errorText = gmRes.responseText || 'Unknown error';
ESM_DIAG.error('Dropbox download (GM) failed:', gmRes.status, errorText);
throw new Error(`Download failed: ${gmRes.status} - ${errorText}`);
}
backupData = gmRes.responseText;
} else {
ESM_DIAG.log('Downloading backup file from Dropbox via same-origin proxy:', selectedFile.name);
const downloadUrl = `${DROPBOX_CONFIG.PROXY_BASE_URL}/files/download`;
const downloadResponse = await fetch(downloadUrl, {
method: 'POST',
headers: {
'Dropbox-API-Arg': JSON.stringify({ path: selectedFile.path_lower }),
...getCsrfHeaders()
},
credentials: 'include'
}).catch(err => {
ESM_DIAG.error('Dropbox download fetch failed:', err);
throw new Error(`Network error during Dropbox download: ${err.message}`);
});
if (!downloadResponse.ok) {
const errorText = await downloadResponse.text().catch(() => 'Unknown error');
ESM_DIAG.error('Dropbox download failed:', downloadResponse.status, downloadResponse.statusText, errorText);
throw new Error(`Download failed: ${downloadResponse.status} ${downloadResponse.statusText} - ${errorText}`);
}
backupData = await downloadResponse.text().catch(err => {
ESM_DIAG.error('Failed to read download response:', err);
throw new Error(`Failed to read backup data: ${err.message}`);
});
}
try {
const parsed = JSON.parse(backupData);
importedData = parsed;
let keyValuePairs = [];
const originOk = !(parsed && parsed.meta && parsed.meta.origin) || parsed.meta.origin === location.origin;
if (parsed && typeof parsed === 'object' && (parsed.localStorage || parsed.sessionStorage || parsed.cookies || parsed.indexedDB)) {
if (parsed.localStorage) {
for (const [k, v] of Object.entries(parsed.localStorage)) {
keyValuePairs.push([`localStorage:${k}`, v]);
}
}
if (parsed.sessionStorage && originOk) {
for (const [k, v] of Object.entries(parsed.sessionStorage)) {
keyValuePairs.push([`sessionStorage:${k}`, v]);
}
}
if (Array.isArray(parsed.cookies) && originOk) {
for (const c of parsed.cookies) {
if (c && c.name != null) {
keyValuePairs.push([`cookie:${c.name}`, (c && typeof c.value !== 'undefined' && c.value !== null) ? c.value : '']);
}
}
}
if (Array.isArray(parsed.indexedDB)) {
for (const dbBackup of parsed.indexedDB) {
const dbName = (dbBackup && dbBackup.name) ? dbBackup.name : undefined;
if (!dbName || !Array.isArray(dbBackup.stores)) continue;
for (const storeBackup of dbBackup.stores) {
const storeName = (storeBackup && storeBackup.name) ? storeBackup.name : undefined;
if (!storeName || !Array.isArray(storeBackup.entries)) continue;
for (const entry of storeBackup.entries) {
let keyStr;
try { keyStr = JSON.stringify(entry.key); } catch (e) { ESM_DIAG.warn('Skipping IDB entry with non-serializable key:', e); continue; }
const keyLabel = `indexedDB:${dbName}/${storeName}:${keyStr}`;
const valueObj = {
db: dbName,
store: storeName,
key: entry.key,
value: entry.value,
keyPath: (storeBackup && typeof storeBackup.keyPath !== 'undefined') ? storeBackup.keyPath : null,
autoIncrement: !!storeBackup.autoIncrement,
indexes: Array.isArray(storeBackup.indexes) ? storeBackup.indexes : []
};
keyValuePairs.push([keyLabel, valueObj]);
}
}
}
}
} else {
keyValuePairs = Object.entries(parsed);
}
displayKeyList(keyValuePairs);
if (applyButton) applyButton.style.display = 'block';
if (!originOk) {
alert(t('foreignBackupHint'));
} else {
alert(`${t('dropboxLoadSuccessPrefix')}\n\n${t('fileLabel')} ${selectedFile.name}\n${t('foundEntriesLabel')} ${keyValuePairs.length}`);
}
} catch (parseError) {
ESM_DIAG.error('Failed to parse backup data:', parseError);
alert(t('invalidJson'));
}
} catch (error) {
ESM_DIAG.error('Dropbox restore failed:', error);
alert(`${t('dropboxRestoreFailedPrefix')}\n\n${error.message}`);
}
}
// ===== Google Drive API Configuration & Functions =====
const GDRIVE_CONFIG = {
// OAuth Client ID (public — visible in redirect URLs anyway)
// Create at https://console.cloud.google.com/apis/credentials
// Type: Web application
// Authorized JavaScript Origins: https://www.waze.com
// Authorized Redirect URI: https://www.waze.com/oauth2callback
CLIENT_ID: '43613994100-ku10bn3ajmnfs1p7162mahraq402q6j7.apps.googleusercontent.com',
// CLIENT_SECRET is NOT stored here — it lives in the Cloudflare Worker (token-proxy/).
// Set TOKEN_PROXY_URL to your deployed worker URL.
TOKEN_PROXY_URL: 'https://esm-token-proxy.devlinlab.com',
SCOPES: 'https://www.googleapis.com/auth/drive.file',
UPLOAD_URL: 'https://www.googleapis.com/upload/drive/v3/files',
FILES_URL: 'https://www.googleapis.com/drive/v3/files'
};
const GDRIVE_TOKEN_KEY = 'ESM_GDRIVE_TOKEN';
const GDRIVE_TOKEN_EXPIRES_KEY = 'ESM_GDRIVE_TOKEN_EXPIRES';
const GDRIVE_TOKEN_SCOPE_KEY = 'ESM_GDRIVE_TOKEN_SCOPE';
const GDRIVE_REFRESH_TOKEN_KEY = 'ESM_GDRIVE_REFRESH_TOKEN';
function loadGdriveToken() {
try {
const token = sessionStorage.getItem(GDRIVE_TOKEN_KEY);
const exp = parseInt(sessionStorage.getItem(GDRIVE_TOKEN_EXPIRES_KEY) || '0', 10);
const savedScope = sessionStorage.getItem(GDRIVE_TOKEN_SCOPE_KEY) || '';
if (token && exp && Date.now() < (exp - 60000) && savedScope === GDRIVE_CONFIG.SCOPES) { return token; }
if (savedScope && savedScope !== GDRIVE_CONFIG.SCOPES) {
ESM_DIAG.log('Google Drive Scope geändert, alter Token wird gelöscht.');
clearGdriveToken();
}
} catch (_) {}
return null;
}
function saveGdriveToken(token, expiresIn, refreshToken) {
try {
sessionStorage.setItem(GDRIVE_TOKEN_KEY, token);
const expiresAt = expiresIn ? (Date.now() + (expiresIn * 1000)) : (Date.now() + 3600000);
sessionStorage.setItem(GDRIVE_TOKEN_EXPIRES_KEY, String(expiresAt));
sessionStorage.setItem(GDRIVE_TOKEN_SCOPE_KEY, GDRIVE_CONFIG.SCOPES);
if (refreshToken) gmStorage.set(GDRIVE_REFRESH_TOKEN_KEY, refreshToken);
ESM_DIAG.log('Google Drive Token gespeichert.');
} catch (e) { ESM_DIAG.warn('Konnte Google Drive Token nicht speichern:', e); }
}
function clearGdriveToken() {
try {
sessionStorage.removeItem(GDRIVE_TOKEN_KEY);
sessionStorage.removeItem(GDRIVE_TOKEN_EXPIRES_KEY);
sessionStorage.removeItem(GDRIVE_TOKEN_SCOPE_KEY);
gmStorage.remove(GDRIVE_REFRESH_TOKEN_KEY);
gdriveFolderIdCache = null;
ESM_DIAG.log('Google Drive Token gelöscht.');
} catch (e) { ESM_DIAG.warn('Konnte Google Drive Token nicht löschen:', e); }
}
async function refreshGdriveToken() {
const refreshToken = gmStorage.get(GDRIVE_REFRESH_TOKEN_KEY);
if (!refreshToken) throw new Error('No Google refresh token available');
const res = await gmFetch(`${GDRIVE_CONFIG.TOKEN_PROXY_URL}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Origin': location.origin },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (res.status < 200 || res.status >= 300) {
throw new Error(`Google token refresh failed (${res.status}): ${res.responseText || ''}`);
}
const json = JSON.parse(res.responseText);
if (!json.access_token || typeof json.access_token !== 'string') {
throw new Error('Google token refresh returned no access_token');
}
// Refresh response does not include a new refresh_token — keep the existing one
saveGdriveToken(json.access_token, json.expires_in);
return json.access_token;
}
// OAuth 2.0 Authorization Code + PKCE via Popup
function oauthGooglePKCE() {
return new Promise(async (resolve, reject) => {
try {
const verifier = generateCodeVerifier();
const challenge = await sha256Base64Url(verifier);
const state = base64urlFromBytes(crypto.getRandomValues(new Uint8Array(32)));
try {
sessionStorage.setItem('ESM_GOOGLE_OAUTH_STATE', state);
sessionStorage.setItem('ESM_GOOGLE_OAUTH_VERIFIER', verifier);
} catch (_) {}
const redirectUri = location.origin + '/oauth2callback';
const params = new URLSearchParams({
client_id: GDRIVE_CONFIG.CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: GDRIVE_CONFIG.SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
access_type: 'offline',
prompt: 'consent'
}).toString();
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
const popup = window.open(authUrl, 'esm_google_oauth', 'width=500,height=600');
if (!popup) { reject(new Error(ESM_LANG === 'de' ? 'Popup blockiert – bitte Popup-Blocker deaktivieren' : 'Popup blocked – please disable popup blocker')); return; }
const timer = setTimeout(() => { clearInterval(pollInterval); reject(new Error('Google OAuth timeout (3 min)')); }, 180000);
const pollInterval = setInterval(async () => {
try {
if (!popup || popup.closed) { clearInterval(pollInterval); clearTimeout(timer); reject(new Error(ESM_LANG === 'de' ? 'OAuth-Fenster wurde geschlossen' : 'OAuth window was closed')); return; }
const popupUrl = popup.location.href;
if (popupUrl && popupUrl.startsWith(location.origin)) {
clearInterval(pollInterval); clearTimeout(timer); popup.close();
const urlObj = new URL(popupUrl);
const code = urlObj.searchParams.get('code');
const returnedState = urlObj.searchParams.get('state');
const error = urlObj.searchParams.get('error');
if (error) { reject(new Error(`Google OAuth: ${error}`)); return; }
const savedState = sessionStorage.getItem('ESM_GOOGLE_OAUTH_STATE');
if (!savedState || !returnedState || savedState !== returnedState) { reject(new Error('Invalid OAuth state')); return; }
if (!code) { reject(new Error('No authorization code in response')); return; }
// Exchange code for tokens
const ver = sessionStorage.getItem('ESM_GOOGLE_OAUTH_VERIFIER') || verifier;
try {
const tokenRes = await gmFetch(`${GDRIVE_CONFIG.TOKEN_PROXY_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Origin': location.origin },
body: JSON.stringify({
code,
code_verifier: ver,
redirect_uri: redirectUri
})
});
if (tokenRes.status < 200 || tokenRes.status >= 300) {
reject(new Error(`Token exchange failed (${tokenRes.status}): ${tokenRes.responseText || ''}`)); return;
}
const json = JSON.parse(tokenRes.responseText);
if (!json.access_token || typeof json.access_token !== 'string') {
reject(new Error('Token exchange returned no access_token')); return;
}
saveGdriveToken(json.access_token, json.expires_in, json.refresh_token);
resolve(json.access_token);
} catch (err) { reject(err); }
}
} catch (e) { /* cross-origin – popup still on Google, keep polling */ }
}, 500);
} catch (err) { reject(err); }
});
}
async function authenticateGdrive() {
// 1. Valid cached access token
const existing = loadGdriveToken();
if (existing) return existing;
// 2. Refresh token available — get a new access token silently
if (gmStorage.get(GDRIVE_REFRESH_TOKEN_KEY)) {
try { return await refreshGdriveToken(); } catch (e) {
ESM_DIAG.warn('Google token refresh failed, falling back to login:', e);
clearGdriveToken();
}
}
// 3. Full PKCE login flow
return await oauthGooglePKCE();
}
// Find or create the WME_Backups folder in Google Drive (visible in Drive UI)
const GDRIVE_FOLDER_NAME = 'WME_Backups';
let gdriveFolderIdCache = null;
async function getOrCreateGdriveFolder(accessToken) {
if (gdriveFolderIdCache) return gdriveFolderIdCache;
// Search for existing folder
const searchUrl = `${GDRIVE_CONFIG.FILES_URL}?q=${encodeURIComponent(`name='${GDRIVE_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`)}&fields=files(id,name)&spaces=drive`;
const searchRes = await gmFetch(searchUrl, {
method: 'GET',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (searchRes.status >= 200 && searchRes.status < 300) {
const data = JSON.parse(searchRes.responseText);
if (data.files && data.files.length > 0) {
gdriveFolderIdCache = data.files[0].id;
return gdriveFolderIdCache;
}
}
// Create the folder
const createRes = await gmFetch(GDRIVE_CONFIG.FILES_URL, {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: GDRIVE_FOLDER_NAME, mimeType: 'application/vnd.google-apps.folder' })
});
if (createRes.status < 200 || createRes.status >= 300) {
throw new Error(`Failed to create WME_Backups folder: ${createRes.status}`);
}
const folder = JSON.parse(createRes.responseText);
gdriveFolderIdCache = folder.id;
return gdriveFolderIdCache;
}
// Export backup to Google Drive
async function exportToGoogleDrive() {
if (!warnCookiesOnce()) return;
try {
ESM_DIAG.log('Starting Google Drive backup...');
if (typeof GM_xmlhttpRequest !== 'function') {
throw new Error('GM_xmlhttpRequest not available – Google Drive requires Userscript manager');
}
const accessToken = await authenticateGdrive();
// Generate backup data
const backup = {
meta: {
exportedAt: new Date().toISOString(),
origin: location.origin,
scriptVersion,
backupType: 'googledrive'
},
localStorage: (() => {
const out = {};
try {
const len = window.localStorage.length;
for (let i = 0; i < len; i++) {
const k = NativeStorage.key.call(window.localStorage, i);
if (k != null) out[k] = NativeStorage.getItem.call(window.localStorage, k);
}
} catch (e) {
Object.keys(window.localStorage).forEach(k => { try { out[k] = window.localStorage.getItem(k); } catch (_) { out[k] = null; } });
}
return out;
})(),
sessionStorage: (() => {
const out = {};
try {
const len = window.sessionStorage.length;
for (let i = 0; i < len; i++) {
const k = NativeStorage.key.call(window.sessionStorage, i);
if (k != null) out[k] = NativeStorage.getItem.call(window.sessionStorage, k);
}
} catch (e) {
Object.keys(window.sessionStorage).forEach(k => { try { out[k] = window.sessionStorage.getItem(k); } catch (_) { out[k] = null; } });
}
return out;
})(),
cookies: document.cookie.split(';').map(c => { const [name, ...rest] = c.trim().split('='); return { name, value: rest.join('=') }; }).filter(c => c.name),
indexedDB: await backupIndexedDB()
};
const backupData = JSON.stringify(backup, null, 2);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `wme_settings_backup_${timestamp}.json`;
// Multipart upload to Google Drive
const folderId = await getOrCreateGdriveFolder(accessToken);
const boundary = 'ESM' + Array.from(crypto.getRandomValues(new Uint8Array(12))).map(b => b.toString(16).padStart(2, '0')).join('');
const metadata = JSON.stringify({ name: fileName, parents: [folderId], mimeType: 'application/json' });
const multipartBody =
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n` +
`--${boundary}\r\nContent-Type: application/json\r\n\r\n${backupData}\r\n` +
`--${boundary}--`;
const uploadRes = await gmFetch(`${GDRIVE_CONFIG.UPLOAD_URL}?uploadType=multipart`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`
},
body: multipartBody
});
if (uploadRes.status < 200 || uploadRes.status >= 300) {
const errorText = uploadRes.responseText || 'Unknown error';
throw new Error(`Upload failed: ${uploadRes.status} - ${errorText}`);
}
const result = JSON.parse(uploadRes.responseText);
ESM_DIAG.log('Backup uploaded to Google Drive:', result);
alert(`${t('gdriveSaveSuccess')}\n\n${t('fileLabel')} ${fileName}\n${t('sizeLabel')} ${Math.round(backupData.length / 1024)} ${t('kb')}`);
} catch (error) {
ESM_DIAG.error('Google Drive backup failed:', error);
alert(`${t('gdriveSaveFailed')}\n\n${error.message}`);
}
}
// Import backup from Google Drive
async function importFromGoogleDrive() {
try {
ESM_DIAG.log('Starting Google Drive restore...');
if (typeof GM_xmlhttpRequest !== 'function') {
throw new Error('GM_xmlhttpRequest not available – Google Drive requires Userscript manager');
}
const accessToken = await authenticateGdrive();
// List backup files in WME_Backups folder
const folderId = await getOrCreateGdriveFolder(accessToken);
const listUrl = `${GDRIVE_CONFIG.FILES_URL}?spaces=drive&q=${encodeURIComponent(`'${folderId}' in parents and name contains 'wme_settings_backup' and trashed=false`)}&fields=files(id,name,size,modifiedTime)&orderBy=modifiedTime%20desc`;
ESM_DIAG.log('Google Drive list URL:', listUrl);
const listRes = await gmFetch(listUrl, {
method: 'GET',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (listRes.status < 200 || listRes.status >= 300) {
throw new Error(`List request failed: ${listRes.status} - ${listRes.responseText || ''}`);
}
const listData = JSON.parse(listRes.responseText);
const files = (listData.files || []).filter(f => f.name.includes('wme_settings_backup'));
if (!files.length) {
alert(t('gdriveNoBackups'));
return;
}
let fileList = (ESM_LANG === 'de' ? 'Verfügbare Backups in Google Drive:\n\n' : 'Available backups in Google Drive:\n\n');
files.forEach((file, index) => {
const date = new Date(file.modifiedTime).toLocaleString(ESM_LANG === 'de' ? 'de-DE' : 'en-US');
const size = file.size ? `${Math.round(parseInt(file.size) / 1024)} KB` : '?';
fileList += `${index + 1}. ${file.name}\n ${ESM_LANG === 'de' ? 'Geändert' : 'Modified'}: ${date}\n ${t('sizeLabel')} ${size}\n\n`;
});
const selection = prompt(`${fileList}${t('restorePrompt')} (1-${files.length})`);
if (!selection) return;
const fileIndex = parseInt(selection, 10) - 1;
if (fileIndex < 0 || fileIndex >= files.length) {
alert(t('invalidSelection'));
return;
}
const selectedFile = files[fileIndex];
// Download file content
const downloadRes = await gmFetch(`${GDRIVE_CONFIG.FILES_URL}/${selectedFile.id}?alt=media`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${accessToken}` },
responseType: 'text'
});
if (downloadRes.status < 200 || downloadRes.status >= 300) {
throw new Error(`Download failed: ${downloadRes.status} - ${downloadRes.responseText || ''}`);
}
const backupData = downloadRes.responseText;
try {
const parsed = JSON.parse(backupData);
importedData = parsed;
let keyValuePairs = [];
const originOk = !(parsed && parsed.meta && parsed.meta.origin) || parsed.meta.origin === location.origin;
if (parsed && typeof parsed === 'object' && (parsed.localStorage || parsed.sessionStorage || parsed.cookies || parsed.indexedDB)) {
if (parsed.localStorage) {
for (const [k, v] of Object.entries(parsed.localStorage)) keyValuePairs.push([`localStorage:${k}`, v]);
}
if (parsed.sessionStorage && originOk) {
for (const [k, v] of Object.entries(parsed.sessionStorage)) keyValuePairs.push([`sessionStorage:${k}`, v]);
}
if (Array.isArray(parsed.cookies) && originOk) {
for (const c of parsed.cookies) {
if (c && c.name != null) keyValuePairs.push([`cookie:${c.name}`, (c && typeof c.value !== 'undefined' && c.value !== null) ? c.value : '']);
}
}
if (Array.isArray(parsed.indexedDB)) {
for (const dbBackup of parsed.indexedDB) {
const dbName = (dbBackup && dbBackup.name) ? dbBackup.name : undefined;
if (!dbName || !Array.isArray(dbBackup.stores)) continue;
for (const storeBackup of dbBackup.stores) {
const storeName = (storeBackup && storeBackup.name) ? storeBackup.name : undefined;
if (!storeName || !Array.isArray(storeBackup.entries)) continue;
for (const entry of storeBackup.entries) {
let keyStr;
try { keyStr = JSON.stringify(entry.key); } catch (e) { ESM_DIAG.warn('Skipping IDB entry with non-serializable key:', e); continue; }
const keyLabel = `indexedDB:${dbName}/${storeName}:${keyStr}`;
keyValuePairs.push([keyLabel, { db: dbName, store: storeName, key: entry.key, value: entry.value, keyPath: storeBackup.keyPath || null, autoIncrement: !!storeBackup.autoIncrement, indexes: Array.isArray(storeBackup.indexes) ? storeBackup.indexes : [] }]);
}
}
}
}
} else {
keyValuePairs = Object.entries(parsed);
}
displayKeyList(keyValuePairs);
if (applyButton) applyButton.style.display = 'block';
if (!originOk) {
alert(t('foreignBackupHint'));
} else {
alert(`${t('gdriveLoadSuccess')}\n\n${t('fileLabel')} ${selectedFile.name}\n${t('foundEntriesLabel')} ${keyValuePairs.length}`);
}
} catch (parseError) {
ESM_DIAG.error('Failed to parse Google Drive backup data:', parseError);
alert(t('invalidJson'));
}
} catch (error) {
ESM_DIAG.error('Google Drive restore failed:', error);
alert(`${t('gdriveRestoreFailed')}\n\n${error.message}`);
}
}
// ===== Google Drive Help Panel (UI) – mit Login-Button statt Token-Eingabe =====
function injectGdriveHelpPanel(parent) {
try {
if (document.getElementById('esm-gdrive-help-panel')) return;
const inline = !!parent;
const hiddenKey = inline ? 'ESM_GDRIVE_HELP_TAB_HIDDEN' : 'ESM_GDRIVE_HELP_HIDDEN';
const hidden = localStorage.getItem(hiddenKey) === '1';
const wrapper = document.createElement('div');
wrapper.id = 'esm-gdrive-help-panel';
wrapper.style.fontFamily = "system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif";
wrapper.style.color = '#111827';
if (!inline) {
wrapper.style.position = 'fixed'; wrapper.style.bottom = '16px'; wrapper.style.right = '16px'; wrapper.style.zIndex = '2147483645';
} else {
wrapper.style.marginTop = '10px'; wrapper.style.width = '100%';
}
const card = document.createElement('div');
card.style.background = '#ffffff'; card.style.border = '1px solid #e5e7eb'; card.style.borderRadius = '12px'; card.style.overflow = 'hidden';
if (!inline) { card.style.boxShadow = '0 6px 18px rgba(0,0,0,0.12)'; card.style.width = '340px'; card.style.maxWidth = '90vw'; }
else { card.style.boxShadow = 'none'; card.style.width = '100%'; card.style.maxWidth = '100%'; }
const header = document.createElement('div');
header.style.display = 'flex'; header.style.alignItems = 'center'; header.style.justifyContent = 'space-between';
header.style.padding = '10px 12px'; header.style.background = '#f9fafb'; header.style.borderBottom = '1px solid #e5e7eb';
const title = document.createElement('div');
title.textContent = t('gdrivePanelTitle'); title.style.fontSize = '13px'; title.style.fontWeight = '600';
const btnHide = document.createElement('button');
btnHide.textContent = hidden ? t('show') : t('hide');
btnHide.style.fontSize = '12px'; btnHide.style.border = '1px solid #d1d5db'; btnHide.style.background = '#ffffff';
btnHide.style.borderRadius = '8px'; btnHide.style.padding = '4px 8px'; btnHide.style.cursor = 'pointer';
btnHide.addEventListener('click', () => {
const vis = content.style.display !== 'none';
content.style.display = vis ? 'none' : 'block';
btnHide.textContent = vis ? t('show') : t('hide');
try { localStorage.setItem(hiddenKey, vis ? '1' : '0'); } catch (_) {}
});
header.appendChild(title); header.appendChild(btnHide);
const content = document.createElement('div');
content.style.padding = '12px'; content.style.display = hidden ? 'none' : 'block';
const statusEl = document.createElement('div');
statusEl.id = 'esm-gdrive-status';
statusEl.style.fontSize = '12px'; statusEl.style.marginBottom = '8px'; statusEl.style.color = '#374151';
// Google-Style Login Button
const loginBtn = document.createElement('button');
loginBtn.style.display = 'flex'; loginBtn.style.alignItems = 'center'; loginBtn.style.gap = '8px';
loginBtn.style.width = '100%'; loginBtn.style.padding = '8px 12px';
loginBtn.style.fontSize = '13px'; loginBtn.style.fontWeight = '500';
loginBtn.style.border = '1px solid #dadce0'; loginBtn.style.borderRadius = '8px';
loginBtn.style.background = '#fff'; loginBtn.style.cursor = 'pointer';
loginBtn.style.transition = 'background 150ms ease, box-shadow 150ms ease';
loginBtn.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
loginBtn.addEventListener('mouseenter', () => { loginBtn.style.background = '#f8f9fa'; loginBtn.style.boxShadow = '0 2px 6px rgba(0,0,0,0.12)'; });
loginBtn.addEventListener('mouseleave', () => { loginBtn.style.background = '#fff'; loginBtn.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)'; });
const gLogo = document.createElement('span');
(function() {
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('width', '18'); svg.setAttribute('height', '18'); svg.setAttribute('viewBox', '0 0 48 48');
const paths = [
['#EA4335', 'M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z'],
['#4285F4', 'M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z'],
['#FBBC05', 'M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z'],
['#34A853', 'M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z']
];
paths.forEach(([fill, d]) => {
const p = document.createElementNS(NS, 'path');
p.setAttribute('fill', fill); p.setAttribute('d', d);
svg.appendChild(p);
});
gLogo.appendChild(svg);
})();
loginBtn.appendChild(gLogo);
const loginText = document.createElement('span');
loginBtn.appendChild(loginText);
// Logout Button
const logoutBtn = document.createElement('button');
logoutBtn.style.width = '100%'; logoutBtn.style.marginTop = '6px';
logoutBtn.style.fontSize = '12px'; logoutBtn.style.padding = '6px 8px';
logoutBtn.style.border = '1px solid #ef4444'; logoutBtn.style.background = '#ef4444'; logoutBtn.style.color = '#fff';
logoutBtn.style.borderRadius = '8px'; logoutBtn.style.cursor = 'pointer';
function updateGdriveUI() {
const token = loadGdriveToken();
if (token) {
statusEl.textContent = ESM_LANG === 'de' ? '✅ Mit Google Drive verbunden.' : '✅ Connected to Google Drive.';
statusEl.style.color = '#065f46';
loginBtn.style.display = 'none';
logoutBtn.style.display = 'block';
logoutBtn.textContent = ESM_LANG === 'de' ? 'Abmelden' : 'Sign out';
} else {
statusEl.textContent = ESM_LANG === 'de' ? 'Nicht angemeldet.' : 'Not signed in.';
statusEl.style.color = '#374151';
loginBtn.style.display = 'flex';
loginText.textContent = ESM_LANG === 'de' ? 'Mit Google anmelden' : 'Sign in with Google';
logoutBtn.style.display = 'none';
}
}
loginBtn.addEventListener('click', async () => {
try {
statusEl.textContent = ESM_LANG === 'de' ? 'Anmeldung läuft...' : 'Signing in...';
statusEl.style.color = '#374151';
await oauthGooglePKCE();
updateGdriveUI();
} catch (e) {
statusEl.textContent = `${ESM_LANG === 'de' ? 'Fehler' : 'Error'}: ${e.message}`;
statusEl.style.color = '#b91c1c';
}
});
logoutBtn.addEventListener('click', () => { clearGdriveToken(); updateGdriveUI(); });
content.appendChild(statusEl);
content.appendChild(loginBtn);
content.appendChild(logoutBtn);
updateGdriveUI();
card.appendChild(header); card.appendChild(content); wrapper.appendChild(card);
if (inline && parent) parent.appendChild(wrapper);
else document.documentElement.appendChild(wrapper);
} catch (e) {
ESM_DIAG.warn('Konnte Google Drive Hilfspanel nicht einfügen:', e);
}
}
// Export local storage data to a JSON file
async function backupIndexedDB() {
const result = [];
let dbs = [];
try {
if (indexedDB.databases) {
dbs = await indexedDB.databases();
}
} catch (e) {
dbs = [];
}
for (const info of dbs) {
if (!info || !info.name) continue;
const name = info.name;
const metaVersion = info.version;
const backupForDb = await new Promise((resolve) => {
const req = indexedDB.open(name);
req.onerror = () => resolve(null);
req.onupgradeneeded = () => {
// No schema changes on read
};
req.onsuccess = () => {
const db = req.result;
const storeBackups = [];
const stores = Array.from(db.objectStoreNames);
const perStorePromises = stores.map((storeName) => new Promise((res) => {
try {
const tx = db.transaction([storeName], 'readonly');
const store = tx.objectStore(storeName);
const keyPath = store.keyPath || null;
const autoIncrement = store.autoIncrement || false;
const indexes = Array.from(store.indexNames).map((indexName) => {
const idx = store.index(indexName);
return { name: indexName, keyPath: idx.keyPath, unique: !!idx.unique, multiEntry: !!idx.multiEntry };
});
const out = { name: storeName, keyPath, autoIncrement, indexes, entries: [] };
if (store.getAll && store.getAllKeys) {
const keysReq = store.getAllKeys();
const valsReq = store.getAll();
keysReq.onsuccess = () => {
const keys = keysReq.result || [];
valsReq.onsuccess = () => {
const vals = valsReq.result || [];
const len = Math.max(keys.length, vals.length);
for (let i = 0; i < len; i++) {
out.entries.push({ key: keys[i], value: vals[i] });
}
storeBackups.push(out);
res(true);
};
valsReq.onerror = () => { storeBackups.push(out); res(true); };
};
keysReq.onerror = () => { storeBackups.push(out); res(true); };
} else {
const request = store.openCursor();
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
out.entries.push({ key: cursor.key, value: cursor.value });
cursor.continue();
} else {
storeBackups.push(out);
res(true);
}
};
request.onerror = () => { storeBackups.push(out); res(true); };
}
} catch (err) {
res(true);
}
}));
Promise.all(perStorePromises).then(() => {
const backupObj = { name, version: db.version || metaVersion || null, stores: storeBackups };
db.close();
resolve(backupObj);
});
};
});
if (backupForDb) result.push(backupForDb);
}
return result;
}
// ===== Toast Notification =====
function esmToast(message, type) {
// type: 'success', 'error', 'info'
try {
// Altes Toast entfernen falls vorhanden
var old = document.getElementById('esm-toast');
if (old) old.remove();
var colors = {
success: { bg: '#1b5e20', border: '#4caf50', icon: '✅' },
error: { bg: '#b71c1c', border: '#f44336', icon: '❌' },
info: { bg: '#0d47a1', border: '#2196f3', icon: 'ℹ️' }
};
var c = colors[type] || colors.info;
var toast = document.createElement('div');
toast.id = 'esm-toast';
toast.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:2147483647;'
+ 'background:' + c.bg + ';color:#fff;border:1px solid ' + c.border + ';'
+ 'border-radius:10px;padding:10px 16px;font-size:13px;font-family:system-ui,sans-serif;'
+ 'box-shadow:0 4px 16px rgba(0,0,0,0.35);display:flex;align-items:center;gap:8px;'
+ 'max-width:360px;opacity:0;transform:translateY(20px);'
+ 'transition:opacity 300ms ease,transform 300ms ease;pointer-events:auto;';
var icon = document.createElement('span');
icon.textContent = c.icon;
icon.style.fontSize = '16px';
toast.appendChild(icon);
var text = document.createElement('span');
text.textContent = message;
text.style.flex = '1';
toast.appendChild(text);
var closeBtn = document.createElement('span');
closeBtn.textContent = '✕';
closeBtn.style.cssText = 'cursor:pointer;opacity:0.7;font-size:14px;margin-left:8px;';
closeBtn.addEventListener('click', function() { toast.remove(); });
toast.appendChild(closeBtn);
document.body.appendChild(toast);
// Einblenden
requestAnimationFrame(function() {
requestAnimationFrame(function() {
toast.style.opacity = '1';
toast.style.transform = 'translateY(0)';
});
});
// Auto-Ausblenden nach 4s (Fehler 6s)
var duration = type === 'error' ? 6000 : 4000;
setTimeout(function() {
if (toast.parentNode) {
toast.style.opacity = '0';
toast.style.transform = 'translateY(20px)';
setTimeout(function() { if (toast.parentNode) toast.remove(); }, 350);
}
}, duration);
} catch (_) {
// Fallback: console
ESM_DIAG.log('Toast (' + type + '):', message);
}
}
// ===== Auto Save Functions =====
function toggleAutoSave() {
var cb = document.getElementById('esm-autosave-cb');
var sel = document.getElementById('esm-autosave-interval');
if (!cb) return;
autoSaveEnabled = cb.checked;
try { localStorage.setItem(AUTO_SAVE_ENABLED_KEY, autoSaveEnabled ? '1' : '0'); } catch (_) {}
if (sel) sel.disabled = !autoSaveEnabled;
if (autoSaveInterval) { clearInterval(autoSaveInterval); autoSaveInterval = null; }
if (autoSaveEnabled) {
var ms = parseInt(sel ? sel.value : '300000');
try { localStorage.setItem(AUTO_SAVE_INTERVAL_KEY, String(ms)); } catch (_) {}
ESM_DIAG.log('Auto Save enabled (' + (ms / 60000) + ' min)');
esmToast('⏱️ Auto Save ' + (ESM_LANG === 'de' ? 'aktiviert' : 'enabled') + ' (' + (ms / 60000) + ' min)', 'success');
autoSaveInterval = setInterval(runAutoSave, ms);
} else {
ESM_DIAG.log('Auto Save disabled');
esmToast('⏱️ Auto Save ' + (ESM_LANG === 'de' ? 'deaktiviert' : 'disabled'), 'info');
}
}
function onAutoSaveIntervalChange() {
var sel = document.getElementById('esm-autosave-interval');
if (sel) { try { localStorage.setItem(AUTO_SAVE_INTERVAL_KEY, sel.value); } catch (_) {} }
if (autoSaveEnabled) toggleAutoSave();
}
function onAutoSaveTargetChange() {
var sel = document.getElementById('esm-autosave-target');
if (sel) { try { localStorage.setItem(AUTO_SAVE_TARGET_KEY, sel.value); } catch (_) {} }
}
function getAutoSaveMaxBackups() {
try {
var v = parseInt(localStorage.getItem(AUTO_SAVE_MAX_BACKUPS_KEY) || '1', 10);
return (!isNaN(v) && v >= 1 && v <= 10) ? v : 1;
} catch (_) { return 1; }
}
// Dropbox: alte Auto-Save Backups auflisten und älteste löschen
async function cleanupOldDropboxBackups(accessToken, userFolder, maxBackups) {
try {
var listRes = await gmFetch(DROPBOX_CONFIG.API_BASE_URL + '/files/list_folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: { path: userFolder, recursive: false }
});
if (listRes.status < 200 || listRes.status >= 300) return;
var listData = JSON.parse(listRes.responseText);
var autoFiles = (listData.entries || []).filter(function(e) {
return e['.tag'] === 'file' && e.name.indexOf('wme_autosave_') === 0;
});
autoFiles.sort(function(a, b) { return (a.client_modified || '').localeCompare(b.client_modified || ''); });
var toDelete = autoFiles.length - maxBackups;
for (var i = 0; i < toDelete; i++) {
await gmFetch(DROPBOX_CONFIG.API_BASE_URL + '/files/delete_v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: { path: autoFiles[i].path_lower }
});
ESM_DIAG.log('Deleted old Dropbox backup:', autoFiles[i].name);
}
} catch (e) { ESM_DIAG.warn('Cleanup old Dropbox backups failed:', e); }
}
// Google Drive: alte Auto-Save Backups auflisten und älteste löschen
async function cleanupOldGdriveBackups(accessToken, maxBackups) {
try {
var folderId = await getOrCreateGdriveFolder(accessToken);
var listUrl = GDRIVE_CONFIG.FILES_URL + '?spaces=drive&q=' + encodeURIComponent("'" + folderId + "' in parents and name contains 'wme_autosave_' and trashed=false") + '&fields=files(id,name,modifiedTime)&orderBy=modifiedTime%20asc';
var listRes = await gmFetch(listUrl, {
method: 'GET',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
if (listRes.status < 200 || listRes.status >= 300) return;
var listData = JSON.parse(listRes.responseText);
var files = listData.files || [];
var toDelete = files.length - maxBackups;
for (var i = 0; i < toDelete; i++) {
await gmFetch(GDRIVE_CONFIG.FILES_URL + '/' + files[i].id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
ESM_DIAG.log('Deleted old GDrive backup:', files[i].name);
}
} catch (e) { ESM_DIAG.warn('Cleanup old GDrive backups failed:', e); }
}
async function runAutoSave() {
var targetSel = document.getElementById('esm-autosave-target');
var target = (targetSel ? targetSel.value : 'local');
var maxBackups = getAutoSaveMaxBackups();
var targetNames = { local: t('autoSaveTargetLocal'), dropbox: t('autoSaveTargetDropbox'), gdrive: t('autoSaveTargetGdrive') };
var targetName = targetNames[target] || target;
ESM_DIAG.log('Auto Save running, target:', target, 'maxBackups:', maxBackups);
esmToast(t('autoSaveRunning') + ' → ' + targetName, 'info');
try {
if (target === 'local') {
await exportLocalStorage();
} else if (target === 'dropbox') {
await autoSaveToDropbox(maxBackups);
} else if (target === 'gdrive') {
await autoSaveToGdrive(maxBackups);
}
ESM_DIAG.log('Auto Save completed.');
var time = new Date().toLocaleTimeString();
esmToast(t('autoSaveDone') + ' (' + targetName + ', ' + time + ')', 'success');
} catch (e) {
ESM_DIAG.error('Auto Save failed:', e);
esmToast(t('autoSaveFailed') + ' ' + (e.message || e), 'error');
}
}
async function autoSaveToDropbox(maxBackups) {
var accessToken = await authenticateDropbox();
var account = await getDropboxAccount(accessToken);
var userFolder = '/WME_Backups/' + (account && account.account_id ? account.account_id : 'unknown');
var backup = await buildBackupData('dropbox');
var backupData = JSON.stringify(backup, null, 2);
var fileName;
var uploadMode;
if (maxBackups <= 1) {
fileName = 'wme_autosave_latest.json';
uploadMode = { '.tag': 'overwrite' };
} else {
var timestamp = new Date().toISOString().replace(/[:.]/g, '-');
fileName = 'wme_autosave_' + timestamp + '.json';
uploadMode = 'add';
}
if (typeof GM_xmlhttpRequest === 'function') {
var gmRes = await gmFetch(DROPBOX_CONFIG.CONTENT_API_URL + '/files/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': 'Bearer ' + accessToken,
'Dropbox-API-Arg': JSON.stringify({ path: userFolder + '/' + fileName, mode: uploadMode, autorename: true })
},
body: backupData
});
if (gmRes.status < 200 || gmRes.status >= 300) throw new Error('Dropbox upload failed: ' + gmRes.status);
} else {
var response = await fetch(DROPBOX_CONFIG.PROXY_BASE_URL + '/files/upload', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': JSON.stringify({ path: userFolder + '/' + fileName, mode: uploadMode, autorename: true }),
...getCsrfHeaders()
},
credentials: 'include',
body: backupData
});
if (!response.ok) throw new Error('Dropbox upload failed: ' + response.status);
}
if (maxBackups > 1) {
await cleanupOldDropboxBackups(accessToken, userFolder, maxBackups);
}
ESM_DIAG.log('Auto Save Dropbox done:', fileName);
}
async function autoSaveToGdrive(maxBackups) {
if (typeof GM_xmlhttpRequest !== 'function') throw new Error('GM_xmlhttpRequest not available');
var accessToken = await authenticateGdrive();
var backup = await buildBackupData('googledrive');
var backupData = JSON.stringify(backup, null, 2);
var fileName;
if (maxBackups <= 1) {
// Bei Überschreiben: bestehende Datei suchen und updaten
var folderId = await getOrCreateGdriveFolder(accessToken);
var listUrl = GDRIVE_CONFIG.FILES_URL + '?spaces=drive&q=' + encodeURIComponent("'" + folderId + "' in parents and name = 'wme_autosave_latest.json' and trashed=false") + '&fields=files(id,name)';
var listRes = await gmFetch(listUrl, { method: 'GET', headers: { 'Authorization': 'Bearer ' + accessToken } });
var existingId = null;
if (listRes.status >= 200 && listRes.status < 300) {
var listData = JSON.parse(listRes.responseText);
if (listData.files && listData.files.length > 0) existingId = listData.files[0].id;
}
if (existingId) {
// Update existing file
var updateRes = await gmFetch(GDRIVE_CONFIG.UPLOAD_URL + '/' + existingId + '?uploadType=media', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
body: backupData
});
if (updateRes.status < 200 || updateRes.status >= 300) throw new Error('GDrive update failed: ' + updateRes.status);
} else {
fileName = 'wme_autosave_latest.json';
var boundary = 'ESM' + Array.from(crypto.getRandomValues(new Uint8Array(12))).map(function(b){return b.toString(16).padStart(2,'0');}).join('');
var metadata = JSON.stringify({ name: fileName, parents: [folderId], mimeType: 'application/json' });
var multipartBody = '--' + boundary + '\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n' + metadata + '\r\n--' + boundary + '\r\nContent-Type: application/json\r\n\r\n' + backupData + '\r\n--' + boundary + '--';
var uploadRes = await gmFetch(GDRIVE_CONFIG.UPLOAD_URL + '?uploadType=multipart', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'multipart/related; boundary=' + boundary },
body: multipartBody
});
if (uploadRes.status < 200 || uploadRes.status >= 300) throw new Error('GDrive upload failed: ' + uploadRes.status);
}
} else {
var timestamp = new Date().toISOString().replace(/[:.]/g, '-');
fileName = 'wme_autosave_' + timestamp + '.json';
var boundary2 = 'ESM' + Array.from(crypto.getRandomValues(new Uint8Array(12))).map(function(b){return b.toString(16).padStart(2,'0');}).join('');
var folderId2 = await getOrCreateGdriveFolder(accessToken);
var metadata2 = JSON.stringify({ name: fileName, parents: [folderId2], mimeType: 'application/json' });
var multipartBody2 = '--' + boundary2 + '\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n' + metadata2 + '\r\n--' + boundary2 + '\r\nContent-Type: application/json\r\n\r\n' + backupData + '\r\n--' + boundary2 + '--';
var uploadRes2 = await gmFetch(GDRIVE_CONFIG.UPLOAD_URL + '?uploadType=multipart', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'multipart/related; boundary=' + boundary2 },
body: multipartBody2
});
if (uploadRes2.status < 200 || uploadRes2.status >= 300) throw new Error('GDrive upload failed: ' + uploadRes2.status);
await cleanupOldGdriveBackups(accessToken, maxBackups);
}
ESM_DIAG.log('Auto Save GDrive done.');
}
// Shared backup data builder for auto-save
async function buildBackupData(backupType) {
return {
meta: {
exportedAt: new Date().toISOString(),
origin: location.origin,
scriptVersion: scriptVersion,
backupType: backupType || 'autosave'
},
localStorage: (function() {
var out = {};
try {
var len = window.localStorage.length;
for (var i = 0; i < len; i++) {
var k = NativeStorage.key.call(window.localStorage, i);
if (k != null) out[k] = NativeStorage.getItem.call(window.localStorage, k);
}
} catch (e) {
Object.keys(window.localStorage).forEach(function(k) { try { out[k] = window.localStorage.getItem(k); } catch (_) { out[k] = null; } });
}
return out;
})(),
sessionStorage: (function() {
var out = {};
try {
var len = window.sessionStorage.length;
for (var i = 0; i < len; i++) {
var k = NativeStorage.key.call(window.sessionStorage, i);
if (k != null) out[k] = NativeStorage.getItem.call(window.sessionStorage, k);
}
} catch (e) {
Object.keys(window.sessionStorage).forEach(function(k) { try { out[k] = window.sessionStorage.getItem(k); } catch (_) { out[k] = null; } });
}
return out;
})(),
cookies: document.cookie.split(';').map(function(c) { var parts = c.trim().split('='); return { name: parts[0], value: parts.slice(1).join('=') }; }).filter(function(c) { return c.name; }),
indexedDB: await backupIndexedDB()
};
}
function loadAutoSaveSettings() {
var cb = document.getElementById('esm-autosave-cb');
var sel = document.getElementById('esm-autosave-interval');
var targetSel = document.getElementById('esm-autosave-target');
var slider = document.getElementById('esm-autosave-maxbackups');
var sliderVal = document.getElementById('esm-autosave-maxbackups-val');
try {
var savedInterval = localStorage.getItem(AUTO_SAVE_INTERVAL_KEY) || '300000';
if (sel) sel.value = savedInterval;
var savedTarget = localStorage.getItem(AUTO_SAVE_TARGET_KEY) || 'local';
if (targetSel) targetSel.value = savedTarget;
var savedMax = localStorage.getItem(AUTO_SAVE_MAX_BACKUPS_KEY) || '1';
if (slider) { slider.value = savedMax; }
if (sliderVal) { sliderVal.textContent = savedMax; }
var savedEnabled = localStorage.getItem(AUTO_SAVE_ENABLED_KEY) === '1';
if (cb) {
cb.checked = savedEnabled;
if (savedEnabled) toggleAutoSave();
}
} catch (_) {}
}
function createAutoSaveUI() {
var wrapper = document.createElement('div');
wrapper.id = 'esm-autosave-section';
wrapper.style.marginTop = '12px';
wrapper.style.padding = '10px 12px';
wrapper.style.background = '#f0f4ff';
wrapper.style.border = '1px solid #c5cae9';
wrapper.style.borderRadius = '10px';
var title = document.createElement('div');
title.textContent = t('autoSaveLabel');
title.style.fontWeight = '700';
title.style.fontSize = '13px';
title.style.marginBottom = '8px';
wrapper.appendChild(title);
var cbLabel = document.createElement('label');
cbLabel.style.display = 'flex';
cbLabel.style.alignItems = 'center';
cbLabel.style.gap = '6px';
cbLabel.style.fontSize = '12px';
cbLabel.style.cursor = 'pointer';
cbLabel.style.marginBottom = '8px';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = 'esm-autosave-cb';
cb.addEventListener('change', toggleAutoSave);
cbLabel.appendChild(cb);
cbLabel.appendChild(document.createTextNode(t('autoSaveEnable')));
wrapper.appendChild(cbLabel);
var row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '10px';
row.style.alignItems = 'center';
row.style.flexWrap = 'wrap';
var intLabel = document.createElement('label');
intLabel.textContent = t('autoSaveInterval');
intLabel.style.fontSize = '12px';
intLabel.style.fontWeight = '500';
row.appendChild(intLabel);
var sel = document.createElement('select');
sel.id = 'esm-autosave-interval';
sel.disabled = true;
sel.style.padding = '4px 6px';
sel.style.border = '1px solid #bbb';
sel.style.borderRadius = '6px';
sel.style.fontSize = '12px';
var intervals = [['300000','5 min'],['600000','10 min'],['900000','15 min'],['1800000','30 min'],['3600000','1 h']];
intervals.forEach(function(iv) {
var opt = document.createElement('option');
opt.value = iv[0];
opt.textContent = iv[1];
sel.appendChild(opt);
});
sel.addEventListener('change', onAutoSaveIntervalChange);
row.appendChild(sel);
var tgtLabel = document.createElement('label');
tgtLabel.textContent = t('autoSaveTarget');
tgtLabel.style.fontSize = '12px';
tgtLabel.style.fontWeight = '500';
row.appendChild(tgtLabel);
var tgtSel = document.createElement('select');
tgtSel.id = 'esm-autosave-target';
tgtSel.style.padding = '4px 6px';
tgtSel.style.border = '1px solid #bbb';
tgtSel.style.borderRadius = '6px';
tgtSel.style.fontSize = '12px';
var targets = [['local', t('autoSaveTargetLocal')],['dropbox', t('autoSaveTargetDropbox')],['gdrive', t('autoSaveTargetGdrive')]];
targets.forEach(function(tg) {
var opt = document.createElement('option');
opt.value = tg[0];
opt.textContent = tg[1];
tgtSel.appendChild(opt);
});
tgtSel.addEventListener('change', onAutoSaveTargetChange);
row.appendChild(tgtSel);
wrapper.appendChild(row);
// Slider für max. Backups
var sliderRow = document.createElement('div');
sliderRow.style.display = 'flex';
sliderRow.style.gap = '8px';
sliderRow.style.alignItems = 'center';
sliderRow.style.marginTop = '10px';
var sliderLabel = document.createElement('label');
sliderLabel.textContent = t('autoSaveMaxBackups');
sliderLabel.style.fontSize = '12px';
sliderLabel.style.fontWeight = '500';
sliderLabel.style.minWidth = '55px';
sliderRow.appendChild(sliderLabel);
var hintLeft = document.createElement('span');
hintLeft.textContent = t('autoSaveMaxBackupsHint1');
hintLeft.style.fontSize = '10px';
hintLeft.style.color = '#888';
hintLeft.style.whiteSpace = 'nowrap';
sliderRow.appendChild(hintLeft);
var slider = document.createElement('input');
slider.type = 'range';
slider.id = 'esm-autosave-maxbackups';
slider.min = '1';
slider.max = '10';
slider.value = '1';
slider.style.flex = '1';
slider.style.cursor = 'pointer';
slider.style.accentColor = '#3f51b5';
sliderRow.appendChild(slider);
var hintRight = document.createElement('span');
hintRight.textContent = t('autoSaveMaxBackupsHint10');
hintRight.style.fontSize = '10px';
hintRight.style.color = '#888';
hintRight.style.whiteSpace = 'nowrap';
sliderRow.appendChild(hintRight);
var sliderVal = document.createElement('span');
sliderVal.id = 'esm-autosave-maxbackups-val';
sliderVal.textContent = '1';
sliderVal.style.fontSize = '13px';
sliderVal.style.fontWeight = '700';
sliderVal.style.minWidth = '20px';
sliderVal.style.textAlign = 'center';
sliderRow.appendChild(sliderVal);
slider.addEventListener('input', function() {
sliderVal.textContent = slider.value;
try { localStorage.setItem(AUTO_SAVE_MAX_BACKUPS_KEY, slider.value); } catch (_) {}
});
wrapper.appendChild(sliderRow);
return wrapper;
}
async function exportLocalStorage() {
if (!warnCookiesOnce()) return;
const backup = {
meta: {
exportedAt: new Date().toISOString(),
origin: location.origin,
scriptVersion
},
// Robust: Determine keys and read values via the native storage API
localStorage: (() => {
const out = {};
try {
const len = window.localStorage.length;
for (let i = 0; i < len; i++) {
const k = NativeStorage.key.call(window.localStorage, i);
if (k != null) {
out[k] = NativeStorage.getItem.call(window.localStorage, k);
}
}
} catch (e) {
// Fallback if another script has patched key()/getItem
Object.keys(window.localStorage).forEach(k => {
try { out[k] = window.localStorage.getItem(k); } catch (_) { out[k] = null; }
});
}
return out;
})(),
sessionStorage: (() => {
const out = {};
try {
const len = window.sessionStorage.length;
for (let i = 0; i < len; i++) {
const k = NativeStorage.key.call(window.sessionStorage, i);
if (k != null) {
out[k] = NativeStorage.getItem.call(window.sessionStorage, k);
}
}
} catch (e) {
Object.keys(window.sessionStorage).forEach(k => {
try { out[k] = window.sessionStorage.getItem(k); } catch (_) { out[k] = null; }
});
}
return out;
})(),
cookies: document.cookie
.split(';')
.map(c => {
const [name, ...rest] = c.trim().split('=');
return { name, value: rest.join('=') };
})
.filter(c => c.name),
indexedDB: await backupIndexedDB()
};
const data = JSON.stringify(backup, null, 2);
const file = new Blob([data], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(file);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
a.download = `wme_settings_backup_${ts}.json`;
a.click();
}
// Import local storage data from a JSON file
function importLocalStorage() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = function (event) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = function () {
try {
const parsed = JSON.parse(reader.result);
importedData = parsed;
let keyValuePairs = [];
const originOk = !(parsed && parsed.meta && parsed.meta.origin) || parsed.meta.origin === location.origin;
if (parsed && typeof parsed === 'object' && (parsed.localStorage || parsed.sessionStorage || parsed.cookies || parsed.indexedDB)) {
if (parsed.localStorage) {
for (const [k, v] of Object.entries(parsed.localStorage)) {
keyValuePairs.push([`localStorage:${k}`, v]);
}
}
if (parsed.sessionStorage && originOk) {
for (const [k, v] of Object.entries(parsed.sessionStorage)) {
keyValuePairs.push([`sessionStorage:${k}`, v]);
}
}
if (Array.isArray(parsed.cookies) && originOk) {
for (const c of parsed.cookies) {
if (c && c.name != null) {
keyValuePairs.push([`cookie:${c.name}`, (c && typeof c.value !== 'undefined' && c.value !== null) ? c.value : '']);
}
}
}
if (Array.isArray(parsed.indexedDB)) {
for (const dbBackup of parsed.indexedDB) {
const dbName = (dbBackup && dbBackup.name) ? dbBackup.name : undefined;
if (!dbName || !Array.isArray(dbBackup.stores)) continue;
for (const storeBackup of dbBackup.stores) {
const storeName = (storeBackup && storeBackup.name) ? storeBackup.name : undefined;
if (!storeName || !Array.isArray(storeBackup.entries)) continue;
for (const entry of storeBackup.entries) {
let keyStr;
try { keyStr = JSON.stringify(entry.key); } catch (e) { ESM_DIAG.warn('Skipping IDB entry with non-serializable key:', e); continue; }
const keyLabel = `indexedDB:${dbName}/${storeName}:${keyStr}`;
const valueObj = {
db: dbName,
store: storeName,
key: entry.key,
value: entry.value,
keyPath: (storeBackup && typeof storeBackup.keyPath !== 'undefined') ? storeBackup.keyPath : null,
autoIncrement: !!storeBackup.autoIncrement,
indexes: Array.isArray(storeBackup.indexes) ? storeBackup.indexes : []
};
keyValuePairs.push([keyLabel, valueObj]);
}
}
}
}
} else {
keyValuePairs = Object.entries(parsed);
}
displayKeyList(keyValuePairs);
if (applyButton) applyButton.style.display = 'block';
if (!originOk) {
alert(t('foreignBackupHint'));
} else {
alert(t('fileReadSuccess'));
}
} catch (error) {
// Only display the error message if the import fails
console.error(error);
alert(t('invalidJson'));
};
};
reader.onerror = function () {
alert(t('fileReadError'));
};
reader.readAsText(file);
};
input.click();
}
// Display the list of keys for selection
function displayKeyList(keyValuePairs) {
const container = document.getElementById('key-list-container');
container.innerHTML = ''; // Clear existing list
// Select All button
const selectAllButton = document.createElement('button');
selectAllButton.textContent = t('selectAll');
selectAllButton.addEventListener('click', function () {
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach((checkbox) => {
checkbox.checked = true;
});
});
container.appendChild(selectAllButton);
// Deselect All button
const deselectAllButton = document.createElement('button');
deselectAllButton.textContent = t('deselectAll');
deselectAllButton.addEventListener('click', function () {
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach((checkbox) => {
checkbox.checked = false;
});
});
container.appendChild(deselectAllButton);
container.appendChild(document.createElement('br'));
// Key checkboxes
keyValuePairs.forEach(([key, value]) => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = key;
checkbox.value = key;
checkbox.checked = true;
container.appendChild(checkbox);
const label = document.createElement('label');
label.htmlFor = key;
label.textContent = key;
container.appendChild(label);
const hiddenValue = document.createElement('input');
hiddenValue.type = 'hidden';
hiddenValue.value = typeof value === 'string' ? value : JSON.stringify(value);
container.appendChild(hiddenValue);
container.appendChild(document.createElement('br'));
});
// Apply button
applyButton = document.createElement('button');
applyButton.textContent = t('apply');
applyButton.addEventListener('click', applyImport);
container.appendChild(applyButton);
}
// Apply the selected key-value pairs from the JSON file
async function applyImport() {
const selectedPairs = getSelectedPairs();
if (selectedPairs.length === 0) {
alert('No keys selected. Nothing to import.');
return;
}
const { counts, failures } = await applyPairs(selectedPairs);
importedData = null; // Clear sensitive backup data from memory after apply
const summary = `Import Completed\n- localStorage: ${counts.local}\n- sessionStorage: ${counts.session}\n- Cookies: ${counts.cookie}\n- IndexedDB: ${counts.idb}` + (failures.length ? `\n\nError (first ${Math.min(5, failures.length)}):\n${failures.slice(0,5).join('\n')}${failures.length > 5 ? `\n... (${failures.length - 5} more)` : ''}` : '');
alert(summary);
// Prompt to refresh the page
if (confirm('The import was successful. Press ok to refresh page.')) {
try {
// Stash for post-reload reapply to prevent other scripts from overwriting restored values during startup
sessionStorage.setItem(REAPPLY_STASH_KEY, JSON.stringify({ origin: location.origin, items: selectedPairs }));
} catch (e) {
// ignore stash errors
}
location.reload();
}
}
// Get the selected key-value pairs
function getSelectedPairs() {
const checkboxes = document.querySelectorAll('#key-list-container input[type="checkbox"]');
const selectedPairs = [];
checkboxes.forEach((checkbox) => {
if (checkbox.checked) {
const key = checkbox.value;
const valueStr = checkbox.nextElementSibling.nextElementSibling.value;
// For IndexedDB entries, the value is JSON-encoded; for all other storages, the string must remain unchanged
if (key.startsWith('indexedDB:')) {
let parsedValue;
try {
parsedValue = JSON.parse(valueStr);
} catch (e) {
parsedValue = valueStr; // Fallback in case something unexpected happens
}
selectedPairs.push([key, parsedValue]);
} else {
selectedPairs.push([key, valueStr]);
}
}
});
return selectedPairs;
}
// Helper to apply selected pairs across storage types and IndexedDB (inside IIFE)
const KEY_MAX_LEN = 512;
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function isValidStorageKey(key) {
return typeof key === 'string' && key.length > 0 && key.length <= KEY_MAX_LEN && !DANGEROUS_KEYS.has(key);
}
async function applyPairs(selectedPairs) {
const counts = { local: 0, session: 0, cookie: 0, idb: 0 };
const failures = [];
const sourceOrigin = (importedData && importedData.meta && importedData.meta.origin) ? importedData.meta.origin : undefined;
const sameOrigin = !sourceOrigin || sourceOrigin === location.origin;
for (const [fullKey, value] of selectedPairs) {
try {
const colonIdx = fullKey.indexOf(':');
if (colonIdx < 0) {
// Fallback: without type prefix we treat it as localStorage
if (!isValidStorageKey(fullKey)) {
failures.push(`${fullKey} skipped (invalid key)`);
ESM_DIAG.warn('Skipping invalid key:', fullKey);
continue;
}
localStorage.setItem(fullKey, value);
counts.local++;
continue;
}
const type = fullKey.slice(0, colonIdx);
const rest = fullKey.slice(colonIdx + 1);
if (type !== 'indexedDB' && !isValidStorageKey(rest)) {
failures.push(`${fullKey} skipped (invalid key)`);
ESM_DIAG.warn('Skipping invalid key:', fullKey);
continue;
}
if (type === 'localStorage') {
try {
NativeStorage.setItem.call(window.localStorage, rest, value);
} catch (_) {
window.localStorage.setItem(rest, value);
}
counts.local++;
} else if (type === 'sessionStorage') {
if (!sameOrigin) {
failures.push(`sessionStorage:${rest} skipped (Origin differs)`);
} else {
try {
NativeStorage.setItem.call(window.sessionStorage, rest, value);
} catch (_) {
window.sessionStorage.setItem(rest, value);
}
counts.session++;
}
} else if (type === 'cookie') {
if (!sameOrigin) {
failures.push(`cookie:${rest} skipped (Origin differs)`);
} else {
// Sanitize the cookie name and validate it contains only legal cookie-name chars.
const safeCookieName = String(rest).split(';')[0].trim();
if (!safeCookieName || !/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(safeCookieName)) {
failures.push(`cookie:${rest} skipped (invalid cookie name characters)`);
ESM_DIAG.warn('Skipping cookie with invalid name:', rest);
continue;
}
const safeCookieValue = encodeURIComponent(String(value));
const secureFlag = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${safeCookieName}=${safeCookieValue}; path=/; SameSite=Strict${secureFlag}`;
counts.cookie++;
}
} else if (type === 'indexedDB') {
try {
// The value is already a complete record object (db, store, key, value, keyPath, autoIncrement, indexes)
await writeIndexedDBRecord(value);
counts.idb++;
} catch (e) {
failures.push(`indexedDB:${rest} -> ${e && e.message ? e.message : e}`);
}
} else {
// Unknown type: treat as localStorage
localStorage.setItem(rest, value);
counts.local++;
}
} catch (err) {
failures.push(`${fullKey} -> ${err && err.message ? err.message : err}`);
}
}
return { counts, failures };
}
// Create and add the tab for the script (robust multi-path)
function addScriptTab() {
if (typeof window !== 'undefined' && window.uiMounted) {
ESM_DIAG.log('UI already mounted, skipping addScriptTab');
return;
}
const haveWUserscripts = typeof W !== 'undefined' && W && W.userscripts && typeof W.userscripts.registerSidebarTab === 'function';
const haveWazeWrapTab = typeof WazeWrap !== 'undefined' && WazeWrap && WazeWrap.Interface && typeof WazeWrap.Interface.Tab === 'function';
if (haveWUserscripts) {
try {
const scriptId = 'easy-storage-manager-tab';
const { tabLabel, tabPane } = W.userscripts.registerSidebarTab(scriptId);
tabLabel.innerText = '💾';
tabLabel.title = t('scriptTitle');
const description = document.createElement('p');
description.style.fontWeight = 'bold';
description.textContent = t('scriptTitle');
tabPane.appendChild(description);
const text = document.createElement('p');
text.textContent = t('importExportDesc');
tabPane.appendChild(text);
const importButton = document.createElement('button');
importButton.textContent = t('importBackup');
importButton.title = t('importBackupTitle');
importButton.addEventListener('click', function() {
if (typeof window.importLocalStorage === 'function') {
window.importLocalStorage();
} else if (typeof importLocalStorage === 'function') {
importLocalStorage();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Import function not available'); } catch (_) {}
alert('Import function not available');
}
});
// keep title already set
importButton.style.backgroundImage = 'linear-gradient(180deg, #2196f3, #1976d2)';
importButton.style.color = '#fff';
importButton.style.border = 'none';
importButton.style.borderRadius = '10px';
importButton.style.padding = '8px 12px';
importButton.style.fontWeight = '600';
importButton.style.cursor = 'pointer';
importButton.style.boxShadow = '0 3px 8px rgba(25, 118, 210, 0.35)';
importButton.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
importButton.style.whiteSpace = 'nowrap';
importButton.style.display = 'inline-flex';
importButton.style.alignItems = 'center';
importButton.style.flex = '0 0 auto';
importButton.style.gap = '4px';
importButton.style.fontSize = '13px';
importButton.style.lineHeight = '18px';
importButton.style.width = 'auto';
importButton.addEventListener('mouseenter', () => { importButton.style.filter = 'brightness(1.08)'; importButton.style.boxShadow = '0 6px 14px rgba(25,118,210,0.45)'; });
importButton.addEventListener('mouseleave', () => { importButton.style.filter = ''; importButton.style.boxShadow = '0 3px 8px rgba(25,118,210,0.35)'; });
const exportButton = document.createElement('button');
exportButton.textContent = t('exportBackup');
exportButton.title = t('exportBackupTitle');
exportButton.addEventListener('click', function() {
if (typeof window.exportLocalStorage === 'function') {
window.exportLocalStorage();
} else if (typeof exportLocalStorage === 'function') {
exportLocalStorage();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Export function not available'); } catch (_) {}
alert('Export function not available');
}
});
// keep title already set
exportButton.style.backgroundImage = 'linear-gradient(180deg, #43a047, #2e7d32)';
exportButton.style.color = '#fff';
exportButton.style.border = 'none';
exportButton.style.borderRadius = '10px';
exportButton.style.padding = '8px 12px';
exportButton.style.fontWeight = '600';
exportButton.style.cursor = 'pointer';
exportButton.style.boxShadow = '0 3px 8px rgba(46, 125, 50, 0.35)';
exportButton.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
exportButton.style.whiteSpace = 'nowrap';
exportButton.style.display = 'inline-flex';
exportButton.style.alignItems = 'center';
exportButton.style.flex = '0 0 auto';
exportButton.style.gap = '4px';
exportButton.style.fontSize = '13px';
exportButton.style.lineHeight = '18px';
exportButton.style.width = 'auto';
exportButton.addEventListener('mouseenter', () => { exportButton.style.filter = 'brightness(1.08)'; exportButton.style.boxShadow = '0 6px 14px rgba(46,125,50,0.45)'; });
exportButton.addEventListener('mouseleave', () => { exportButton.style.filter = ''; exportButton.style.boxShadow = '0 3px 8px rgba(46,125,50,0.35)'; });
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = '8px';
buttonContainer.style.marginTop = '8px';
buttonContainer.style.justifyContent = 'center';
buttonContainer.style.alignItems = 'center';
buttonContainer.style.width = '100%';
// Reihenfolge: zuerst Lokal Speichern (Export), dann Wiederherstellen (Import)
exportButton.style.order = '1';
importButton.style.order = '2';
buttonContainer.appendChild(exportButton);
buttonContainer.appendChild(importButton);
tabPane.appendChild(buttonContainer);
// Dropbox Cloud Backup Buttons
const cloudBackupButton = document.createElement('button');
cloudBackupButton.textContent = t('cloudBackup');
cloudBackupButton.title = t('cloudBackupTitle');
cloudBackupButton.addEventListener('click', function() {
if (typeof exportToDropbox === 'function') {
exportToDropbox();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Dropbox export function not available'); } catch (_) {}
alert(t('dropboxExportUnavailable'));
}
});
// title already set
cloudBackupButton.style.backgroundImage = 'linear-gradient(180deg, #ff9800, #f57c00)';
cloudBackupButton.style.color = '#fff';
cloudBackupButton.style.border = 'none';
cloudBackupButton.style.borderRadius = '10px';
cloudBackupButton.style.padding = '8px 12px';
cloudBackupButton.style.fontWeight = '600';
cloudBackupButton.style.cursor = 'pointer';
cloudBackupButton.style.boxShadow = '0 3px 8px rgba(245, 124, 0, 0.35)';
cloudBackupButton.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
cloudBackupButton.style.whiteSpace = 'nowrap';
cloudBackupButton.style.display = 'inline-flex';
cloudBackupButton.style.alignItems = 'center';
cloudBackupButton.style.flex = '0 0 auto';
cloudBackupButton.style.gap = '4px';
cloudBackupButton.style.fontSize = '13px';
cloudBackupButton.style.lineHeight = '18px';
cloudBackupButton.style.width = 'auto';
cloudBackupButton.addEventListener('mouseenter', () => { cloudBackupButton.style.filter = 'brightness(1.08)'; cloudBackupButton.style.boxShadow = '0 6px 14px rgba(245,124,0,0.45)'; });
cloudBackupButton.addEventListener('mouseleave', () => { cloudBackupButton.style.filter = ''; cloudBackupButton.style.boxShadow = '0 3px 8px rgba(245,124,0,0.35)'; });
const cloudRestoreButton = document.createElement('button');
cloudRestoreButton.textContent = t('cloudRestore');
cloudRestoreButton.title = t('cloudRestoreTitle');
cloudRestoreButton.addEventListener('click', function() {
if (typeof importFromDropbox === 'function') {
importFromDropbox();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Dropbox restore function not available'); } catch (_) {}
alert(t('dropboxImportUnavailable'));
}
});
// title already set
cloudRestoreButton.style.backgroundImage = 'linear-gradient(180deg, #9c27b0, #7b1fa2)';
cloudRestoreButton.style.color = '#fff';
cloudRestoreButton.style.border = 'none';
cloudRestoreButton.style.borderRadius = '10px';
cloudRestoreButton.style.padding = '8px 12px';
cloudRestoreButton.style.fontWeight = '600';
cloudRestoreButton.style.cursor = 'pointer';
cloudRestoreButton.style.boxShadow = '0 3px 8px rgba(123, 31, 162, 0.35)';
cloudRestoreButton.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
cloudRestoreButton.style.whiteSpace = 'nowrap';
cloudRestoreButton.style.display = 'inline-flex';
cloudRestoreButton.style.alignItems = 'center';
cloudRestoreButton.style.flex = '0 0 auto';
cloudRestoreButton.style.gap = '4px';
cloudRestoreButton.style.fontSize = '13px';
cloudRestoreButton.style.lineHeight = '18px';
cloudRestoreButton.style.width = 'auto';
cloudRestoreButton.addEventListener('mouseenter', () => { cloudRestoreButton.style.filter = 'brightness(1.08)'; cloudRestoreButton.style.boxShadow = '0 6px 14px rgba(123,31,162,0.45)'; });
cloudRestoreButton.addEventListener('mouseleave', () => { cloudRestoreButton.style.filter = ''; cloudRestoreButton.style.boxShadow = '0 3px 8px rgba(123,31,162,0.35)'; });
const cloudButtonContainer = document.createElement('div');
cloudButtonContainer.style.display = 'flex';
cloudButtonContainer.style.gap = '8px';
cloudButtonContainer.style.marginTop = '8px';
cloudButtonContainer.style.justifyContent = 'center';
cloudButtonContainer.style.alignItems = 'center';
cloudButtonContainer.style.width = '100%';
cloudButtonContainer.appendChild(cloudBackupButton);
cloudButtonContainer.appendChild(cloudRestoreButton);
tabPane.appendChild(cloudButtonContainer);
// Google Drive Backup Buttons
const gdriveBackupButton = document.createElement('button');
gdriveBackupButton.textContent = t('gdriveBackup');
gdriveBackupButton.title = t('gdriveBackupTitle');
gdriveBackupButton.addEventListener('click', function() {
if (typeof exportToGoogleDrive === 'function') { exportToGoogleDrive(); }
else { alert(t('gdriveExportUnavailable')); }
});
gdriveBackupButton.style.backgroundImage = 'linear-gradient(180deg, #4285f4, #1a73e8)';
gdriveBackupButton.style.color = '#fff'; gdriveBackupButton.style.border = 'none'; gdriveBackupButton.style.borderRadius = '10px';
gdriveBackupButton.style.padding = '8px 12px'; gdriveBackupButton.style.fontWeight = '600'; gdriveBackupButton.style.cursor = 'pointer';
gdriveBackupButton.style.boxShadow = '0 3px 8px rgba(26, 115, 232, 0.35)';
gdriveBackupButton.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
gdriveBackupButton.style.whiteSpace = 'nowrap'; gdriveBackupButton.style.display = 'inline-flex'; gdriveBackupButton.style.alignItems = 'center';
gdriveBackupButton.style.flex = '0 0 auto'; gdriveBackupButton.style.gap = '4px'; gdriveBackupButton.style.fontSize = '13px';
gdriveBackupButton.style.lineHeight = '18px'; gdriveBackupButton.style.width = 'auto';
gdriveBackupButton.addEventListener('mouseenter', () => { gdriveBackupButton.style.filter = 'brightness(1.08)'; gdriveBackupButton.style.boxShadow = '0 6px 14px rgba(26,115,232,0.45)'; });
gdriveBackupButton.addEventListener('mouseleave', () => { gdriveBackupButton.style.filter = ''; gdriveBackupButton.style.boxShadow = '0 3px 8px rgba(26,115,232,0.35)'; });
const gdriveRestoreButton = document.createElement('button');
gdriveRestoreButton.textContent = t('gdriveRestore');
gdriveRestoreButton.title = t('gdriveRestoreTitle');
gdriveRestoreButton.addEventListener('click', function() {
if (typeof importFromGoogleDrive === 'function') { importFromGoogleDrive(); }
else { alert(t('gdriveImportUnavailable')); }
});
gdriveRestoreButton.style.backgroundImage = 'linear-gradient(180deg, #34a853, #137333)';
gdriveRestoreButton.style.color = '#fff'; gdriveRestoreButton.style.border = 'none'; gdriveRestoreButton.style.borderRadius = '10px';
gdriveRestoreButton.style.padding = '8px 12px'; gdriveRestoreButton.style.fontWeight = '600'; gdriveRestoreButton.style.cursor = 'pointer';
gdriveRestoreButton.style.boxShadow = '0 3px 8px rgba(19, 115, 51, 0.35)';
gdriveRestoreButton.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
gdriveRestoreButton.style.whiteSpace = 'nowrap'; gdriveRestoreButton.style.display = 'inline-flex'; gdriveRestoreButton.style.alignItems = 'center';
gdriveRestoreButton.style.flex = '0 0 auto'; gdriveRestoreButton.style.gap = '4px'; gdriveRestoreButton.style.fontSize = '13px';
gdriveRestoreButton.style.lineHeight = '18px'; gdriveRestoreButton.style.width = 'auto';
gdriveRestoreButton.addEventListener('mouseenter', () => { gdriveRestoreButton.style.filter = 'brightness(1.08)'; gdriveRestoreButton.style.boxShadow = '0 6px 14px rgba(19,115,51,0.45)'; });
gdriveRestoreButton.addEventListener('mouseleave', () => { gdriveRestoreButton.style.filter = ''; gdriveRestoreButton.style.boxShadow = '0 3px 8px rgba(19,115,51,0.35)'; });
const gdriveButtonContainer = document.createElement('div');
gdriveButtonContainer.style.display = 'flex'; gdriveButtonContainer.style.gap = '8px'; gdriveButtonContainer.style.marginTop = '8px';
gdriveButtonContainer.style.justifyContent = 'center'; gdriveButtonContainer.style.alignItems = 'center'; gdriveButtonContainer.style.width = '100%';
gdriveButtonContainer.appendChild(gdriveBackupButton);
gdriveButtonContainer.appendChild(gdriveRestoreButton);
tabPane.appendChild(gdriveButtonContainer);
// Dropbox-Hilfepanel direkt im Skripte-Tab anzeigen
try { injectDropboxHelpPanel(tabPane); } catch (_) {}
// Google Drive Hilfepanel im Skripte-Tab anzeigen
try { injectGdriveHelpPanel(tabPane); } catch (_) {}
// Auto Save UI
try {
var autoSaveUI = createAutoSaveUI();
tabPane.appendChild(autoSaveUI);
loadAutoSaveSettings();
} catch (_) {}
const keyListContainer = document.createElement('div');
keyListContainer.id = 'key-list-container';
keyListContainer.style.marginTop = '10px';
tabPane.appendChild(keyListContainer);
if (typeof window !== 'undefined') window.uiMounted = true;
ESM_DIAG.log('UI mounted via W.userscripts.registerSidebarTab');
return;
} catch (e) {
ESM_DIAG.error('Failed to mount via W.userscripts, falling back', e);
}
}
if (haveWazeWrapTab) {
try {
const html = `
<div id="esm-tab">
<p id="esm-title" style="font-weight:700;margin:0 0 6px 0;">${t('scriptTitle')}</p>
<p style="margin:0 0 8px 0;">${t('importExportDesc')}</p>
<div style="display:flex;gap:8px;margin-bottom:8px;justify-content:center;align-items:center;width:100%;">
<button id="esm-export">${t('exportBackup')}</button>
<button id="esm-import">${t('importBackup')}</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:8px;justify-content:center;align-items:center;width:100%;">
<button id="esm-cloud-backup">${t('cloudBackup')}</button>
<button id="esm-cloud-restore">${t('cloudRestore')}</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:8px;justify-content:center;align-items:center;width:100%;">
<button id="esm-gdrive-backup">${t('gdriveBackup')}</button>
<button id="esm-gdrive-restore">${t('gdriveRestore')}</button>
</div>
<div id="key-list-container" style="border:1px solid rgba(0,0,0,0.1);border-radius:8px;padding:8px;max-height:320px;overflow:auto;"></div>
</div>`;
new WazeWrap.Interface.Tab(t('scriptTitle'), html, () => {
const importBtn = document.getElementById('esm-import');
if (importBtn) { importBtn.textContent = t('importBackup'); importBtn.title = t('importBackupTitle'); }
const exportBtn = document.getElementById('esm-export');
if (exportBtn) { exportBtn.textContent = t('exportBackup'); exportBtn.title = t('exportBackupTitle'); }
const cloudBackupBtn = document.getElementById('esm-cloud-backup');
if (cloudBackupBtn) { cloudBackupBtn.textContent = t('cloudBackup'); cloudBackupBtn.title = t('cloudBackupTitle'); }
const cloudRestoreBtn = document.getElementById('esm-cloud-restore');
if (cloudRestoreBtn) { cloudRestoreBtn.textContent = t('cloudRestore'); cloudRestoreBtn.title = t('cloudRestoreTitle'); }
if (cloudBackupBtn) cloudBackupBtn.textContent = t('cloudBackup');
if (cloudRestoreBtn) cloudRestoreBtn.textContent = t('cloudRestore');
if (importBtn) importBtn.addEventListener('click', function() { if (typeof window.importLocalStorage === 'function') { window.importLocalStorage(); } else { try { if (window.ESM_DIAG) window.ESM_DIAG.error('Import function not available'); } catch (_) {} alert(t('importFunctionUnavailable')); } });
if (exportBtn) exportBtn.addEventListener('click', function() { if (typeof window.exportLocalStorage === 'function') { window.exportLocalStorage(); } else { try { if (window.ESM_DIAG) window.ESM_DIAG.error('Export function not available'); } catch (_) {} alert(t('exportFunctionUnavailable')); } });
if (cloudBackupBtn) cloudBackupBtn.addEventListener('click', function() { if (typeof exportToDropbox === 'function') { exportToDropbox(); } else { try { if (window.ESM_DIAG) window.ESM_DIAG.error('Dropbox export function not available'); } catch (_) {} alert(t('dropboxExportUnavailable')); } });
if (cloudRestoreBtn) cloudRestoreBtn.addEventListener('click', function() { if (typeof importFromDropbox === 'function') { importFromDropbox(); } else { try { if (window.ESM_DIAG) window.ESM_DIAG.error('Dropbox import function not available'); } catch (_) {} alert(t('dropboxImportUnavailable')); } });
// Reihenfolge im Flex-Container: zuerst Lokal Speichern (Export), dann Wiederherstellen (Import)
if (typeof exportBtn !== 'undefined' && exportBtn) exportBtn.style.order = '1';
if (typeof importBtn !== 'undefined' && importBtn) importBtn.style.order = '2';
// Apply compact styles for WazeWrap tab buttons
if (importBtn) {
importBtn.style.backgroundImage = 'linear-gradient(180deg, #2196f3, #1976d2)';
importBtn.style.color = '#fff';
importBtn.style.border = 'none';
importBtn.style.borderRadius = '10px';
importBtn.style.padding = '8px 12px';
importBtn.style.fontWeight = '600';
importBtn.style.cursor = 'pointer';
importBtn.style.boxShadow = '0 3px 8px rgba(25, 118, 210, 0.35)';
importBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
importBtn.style.whiteSpace = 'nowrap';
importBtn.style.display = 'inline-flex';
importBtn.style.alignItems = 'center';
importBtn.style.gap = '4px';
importBtn.style.fontSize = '13px';
importBtn.style.lineHeight = '18px';
}
if (exportBtn) {
exportBtn.style.backgroundImage = 'linear-gradient(180deg, #43a047, #2e7d32)';
exportBtn.style.color = '#fff';
exportBtn.style.border = 'none';
exportBtn.style.borderRadius = '10px';
exportBtn.style.padding = '8px 12px';
exportBtn.style.fontWeight = '600';
exportBtn.style.cursor = 'pointer';
exportBtn.style.boxShadow = '0 3px 8px rgba(46, 125, 50, 0.35)';
exportBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
exportBtn.style.whiteSpace = 'nowrap';
exportBtn.style.display = 'inline-flex';
exportBtn.style.alignItems = 'center';
exportBtn.style.gap = '4px';
exportBtn.style.fontSize = '13px';
exportBtn.style.lineHeight = '18px';
}
if (cloudBackupBtn) {
cloudBackupBtn.style.backgroundImage = 'linear-gradient(180deg, #ff9800, #f57c00)';
cloudBackupBtn.style.color = '#fff';
cloudBackupBtn.style.border = 'none';
cloudBackupBtn.style.borderRadius = '10px';
cloudBackupBtn.style.padding = '8px 12px';
cloudBackupBtn.style.fontWeight = '600';
cloudBackupBtn.style.cursor = 'pointer';
cloudBackupBtn.style.boxShadow = '0 3px 8px rgba(245, 124, 0, 0.35)';
cloudBackupBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
cloudBackupBtn.style.whiteSpace = 'nowrap';
cloudBackupBtn.style.display = 'inline-flex';
cloudBackupBtn.style.alignItems = 'center';
cloudBackupBtn.style.gap = '4px';
cloudBackupBtn.style.fontSize = '13px';
cloudBackupBtn.style.lineHeight = '18px';
cloudBackupBtn.title = t('cloudBackupTitle');
}
if (cloudRestoreBtn) {
cloudRestoreBtn.style.backgroundImage = 'linear-gradient(180deg, #9c27b0, #7b1fa2)';
cloudRestoreBtn.style.color = '#fff';
cloudRestoreBtn.style.border = 'none';
cloudRestoreBtn.style.borderRadius = '10px';
cloudRestoreBtn.style.padding = '8px 12px';
cloudRestoreBtn.style.fontWeight = '600';
cloudRestoreBtn.style.cursor = 'pointer';
cloudRestoreBtn.style.boxShadow = '0 3px 8px rgba(123, 31, 162, 0.35)';
cloudRestoreBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
cloudRestoreBtn.style.whiteSpace = 'nowrap';
cloudRestoreBtn.style.display = 'inline-flex';
cloudRestoreBtn.style.alignItems = 'center';
cloudRestoreBtn.style.gap = '4px';
cloudRestoreBtn.style.fontSize = '13px';
cloudRestoreBtn.style.lineHeight = '18px';
cloudRestoreBtn.title = t('cloudRestoreTitle');
}
// Google Drive buttons in WazeWrap tab
const gdriveBackupBtn = document.getElementById('esm-gdrive-backup');
const gdriveRestoreBtn = document.getElementById('esm-gdrive-restore');
if (gdriveBackupBtn) { gdriveBackupBtn.textContent = t('gdriveBackup'); gdriveBackupBtn.title = t('gdriveBackupTitle'); }
if (gdriveRestoreBtn) { gdriveRestoreBtn.textContent = t('gdriveRestore'); gdriveRestoreBtn.title = t('gdriveRestoreTitle'); }
if (gdriveBackupBtn) gdriveBackupBtn.addEventListener('click', function() { if (typeof exportToGoogleDrive === 'function') { exportToGoogleDrive(); } else { alert(t('gdriveExportUnavailable')); } });
if (gdriveRestoreBtn) gdriveRestoreBtn.addEventListener('click', function() { if (typeof importFromGoogleDrive === 'function') { importFromGoogleDrive(); } else { alert(t('gdriveImportUnavailable')); } });
if (gdriveBackupBtn) {
gdriveBackupBtn.style.backgroundImage = 'linear-gradient(180deg, #4285f4, #1a73e8)';
gdriveBackupBtn.style.color = '#fff'; gdriveBackupBtn.style.border = 'none'; gdriveBackupBtn.style.borderRadius = '10px';
gdriveBackupBtn.style.padding = '8px 12px'; gdriveBackupBtn.style.fontWeight = '600'; gdriveBackupBtn.style.cursor = 'pointer';
gdriveBackupBtn.style.boxShadow = '0 3px 8px rgba(26, 115, 232, 0.35)';
gdriveBackupBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
gdriveBackupBtn.style.whiteSpace = 'nowrap'; gdriveBackupBtn.style.display = 'inline-flex'; gdriveBackupBtn.style.alignItems = 'center';
gdriveBackupBtn.style.gap = '4px'; gdriveBackupBtn.style.fontSize = '13px'; gdriveBackupBtn.style.lineHeight = '18px';
}
if (gdriveRestoreBtn) {
gdriveRestoreBtn.style.backgroundImage = 'linear-gradient(180deg, #34a853, #137333)';
gdriveRestoreBtn.style.color = '#fff'; gdriveRestoreBtn.style.border = 'none'; gdriveRestoreBtn.style.borderRadius = '10px';
gdriveRestoreBtn.style.padding = '8px 12px'; gdriveRestoreBtn.style.fontWeight = '600'; gdriveRestoreBtn.style.cursor = 'pointer';
gdriveRestoreBtn.style.boxShadow = '0 3px 8px rgba(19, 115, 51, 0.35)';
gdriveRestoreBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
gdriveRestoreBtn.style.whiteSpace = 'nowrap'; gdriveRestoreBtn.style.display = 'inline-flex'; gdriveRestoreBtn.style.alignItems = 'center';
gdriveRestoreBtn.style.gap = '4px'; gdriveRestoreBtn.style.fontSize = '13px'; gdriveRestoreBtn.style.lineHeight = '18px';
}
if (typeof window !== 'undefined') window.uiMounted = true;
ESM_DIAG.log('UI mounted via WazeWrap.Interface.Tab');
// Dropbox-Hilfepanel im WazeWrap-Tab platzieren
try { const tabRoot = document.getElementById('esm-tab') || document.body; injectDropboxHelpPanel(tabRoot); } catch (_) {}
// Google Drive Hilfepanel im WazeWrap-Tab platzieren
try { const tabRoot2 = document.getElementById('esm-tab') || document.body; injectGdriveHelpPanel(tabRoot2); } catch (_) {}
// Auto Save UI im WazeWrap-Tab
try {
const tabRoot3 = document.getElementById('esm-tab') || document.body;
const autoSaveUI = createAutoSaveUI();
tabRoot3.appendChild(autoSaveUI);
loadAutoSaveSettings();
} catch (_) {}
});
return;
} catch (e) {
ESM_DIAG.error('Failed to mount via WazeWrap.Interface.Tab, falling back', e);
}
}
// Final fallback: floating panel
createFallbackPanel();
}
// Initialize the script
function initialize() {
ESM_DIAG.log('initialize() called. document.readyState=', document.readyState);
// Check if we're in a Waze environment
const isWazeEnvironment = window.location.hostname.includes('waze.com');
if (isWazeEnvironment && typeof W !== 'undefined' && W && W.userscripts && W.userscripts.state && W.userscripts.state.isReady) {
ESM_DIAG.log('W.userscripts.state.isReady is true. Initializing UI.');
addScriptTab();
showScriptUpdate();
reapplyAfterReload();
} else if (isWazeEnvironment) {
ESM_DIAG.log('Waiting for wme-ready event...');
document.addEventListener('wme-ready', function () {
ESM_DIAG.log('wme-ready event received. Initializing UI.');
addScriptTab();
showScriptUpdate();
reapplyAfterReload();
}, { once: true });
} else {
// Non-Waze environment - use fallback panel directly
ESM_DIAG.log('Non-Waze environment detected. Using fallback panel.');
createFallbackPanel();
reapplyAfterReload();
}
}
// Call the initialize function
initialize();
// Show script update notification
function showScriptUpdate() {
if (typeof WazeWrap !== 'undefined' && WazeWrap && WazeWrap.Interface && typeof WazeWrap.Interface.ShowScriptUpdate === 'function') {
WazeWrap.Interface.ShowScriptUpdate(
t('scriptTitle'),
(typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) ? GM_info.script.version : scriptVersion,
updateMessage,
'https://greasyfork.org/en/scripts/466806-easy-storage-manager',
'https://www.waze.com/forum/viewtopic.php?t=382966'
);
} else {
ESM_DIAG.warn('ShowScriptUpdate skipped: WazeWrap.Interface.ShowScriptUpdate not available yet.');
}
}
// Reapply after reload if a stash exists (inside IIFE)
function reapplyAfterReload() {
let stashStr = null;
try {
stashStr = sessionStorage.getItem(REAPPLY_STASH_KEY);
} catch (e) {
stashStr = null;
}
if (!stashStr) { ESM_DIAG.log('No reapply stash found; skipping.'); return; }
sessionStorage.removeItem(REAPPLY_STASH_KEY);
ESM_DIAG.log('Reapply stash found. Parsing...');
try {
const stash = JSON.parse(stashStr);
ESM_DIAG.log('Parsed stash items count:', Array.isArray(stash.items) ? stash.items.length : 0);
if (!(stash && stash.origin === location.origin && Array.isArray(stash.items) && stash.items.length)) { ESM_DIAG.warn('Stash invalid or empty; skipping.'); return; }
applyPairs(stash.items).then(({ counts, failures }) => {
ESM_DIAG.log('Initial applyPairs after reload complete.', counts, failures.slice(0, 3));
const summary = `Restored after reload executed.\n- localStorage: ${counts.local}\n- sessionStorage: ${counts.session}\n- Cookies: ${counts.cookie}\n- IndexedDB: ${counts.idb}` + (failures.length ? `\n\nError (first ${Math.min(5, failures.length)}):\n${failures.slice(0,5).join('\n')}${failures.length > 5 ? `\n... (${failures.length - 5} more)` : ''}` : '');
try { alert(summary); } catch (e) {}
}).catch((ex) => { ESM_DIAG.error('applyPairs after reload failed:', ex); });
const desiredLocal = new Map();
for (const [fullKey, value] of stash.items) {
const idx = fullKey.indexOf(':');
const type = idx < 0 ? 'localStorage' : fullKey.slice(0, idx);
const name = idx < 0 ? fullKey : fullKey.slice(idx + 1);
if (type === 'localStorage') {
desiredLocal.set(name, value);
}
}
ESM_DIAG.log('Desired localStorage keys:', Array.from(desiredLocal.keys()));
const scheduleRepairs = (delaysMs) => {
ESM_DIAG.log('Scheduling repairs with delays:', delaysMs);
delaysMs.forEach(ms => {
setTimeout(() => {
const repairs = [];
desiredLocal.forEach((desired, name) => {
try {
const current = NativeStorage.getItem.call(window.localStorage, name);
if (current !== desired) {
ESM_DIAG.log('Repair needed for key:', name, 'current=', current, 'desired=', desired);
repairs.push([`localStorage:${name}`, desired]);
} else {
ESM_DIAG.log('Key already correct:', name);
}
} catch (e) {
ESM_DIAG.warn('Error reading key during repair check:', name, e);
}
});
if (repairs.length) {
ESM_DIAG.log('Applying repairs count:', repairs.length);
applyPairs(repairs).catch((ex) => { ESM_DIAG.error('applyPairs repairs failed:', ex); });
}
}, ms);
});
};
const originalSetItem = localStorage.setItem.bind(localStorage);
const originalRemoveItem = localStorage.removeItem.bind(localStorage);
const originalClear = localStorage.clear.bind(localStorage);
const protectMs = 120000; // 120s protection
const protectUntil = Date.now() + protectMs;
const protectedKeys = new Set(Array.from(desiredLocal.keys()));
try {
localStorage.setItem = function (key, value) {
if (protectedKeys.has(key) && Date.now() < protectUntil) {
ESM_DIAG.log('Protected setItem intercepted for key:', key);
try { return NativeStorage.setItem.call(window.localStorage, key, desiredLocal.get(key)); } catch (e) { return; }
}
return originalSetItem(key, value);
};
localStorage.getItem = function (key) {
if (protectedKeys.has(key) && Date.now() < protectUntil) {
const desired = desiredLocal.get(key);
ESM_DIAG.log('Protected getItem intercepted for key:', key);
return desired != null ? String(desired) : null;
}
return NativeStorage.getItem.call(window.localStorage, key);
};
localStorage.removeItem = function (key) {
if (protectedKeys.has(key) && Date.now() < protectUntil) {
ESM_DIAG.log('Protected removeItem blocked for key:', key);
try { return NativeStorage.setItem.call(window.localStorage, key, desiredLocal.get(key)); } catch (_) { return; }
}
return originalRemoveItem(key);
};
localStorage.clear = function () {
if (Date.now() < protectUntil && protectedKeys.size) {
ESM_DIAG.log('Protected clear intercepted; preserving keys:', Array.from(protectedKeys));
try {
const len = window.localStorage.length;
const toRemove = [];
for (let i = 0; i < len; i++) {
const k = NativeStorage.key.call(window.localStorage, i);
if (k != null && !protectedKeys.has(k)) toRemove.push(k);
}
toRemove.forEach(k => { try { originalRemoveItem(k); } catch (_) {} });
return;
} catch (_) { /* fallback */ }
}
return originalClear();
};
ESM_DIAG.log('Protection overrides applied for keys:', Array.from(protectedKeys), 'until', new Date(protectUntil).toISOString());
} catch (ex) {
ESM_DIAG.error('Error applying protection overrides:', ex);
}
scheduleRepairs([500, 2000, 5000, 10000, 20000, 30000, 45000, 60000, 90000, 120000]);
} catch (ex) {
ESM_DIAG.error('Error parsing stash or scheduling repairs:', ex);
}
}
// Export key functions to window for fallback usage
if (typeof window !== 'undefined') {
window.exportLocalStorage = exportLocalStorage;
window.importLocalStorage = importLocalStorage;
window.exportToGoogleDrive = exportToGoogleDrive;
window.importFromGoogleDrive = importFromGoogleDrive;
}
async function writeIndexedDBRecord(record) {
return new Promise((resolve, reject) => {
if (typeof record.db !== 'string' || record.db.length === 0 || record.db.length > 255) {
reject(new Error(`Invalid IndexedDB name: ${record.db}`)); return;
}
if (typeof record.store !== 'string' || record.store.length === 0 || record.store.length > 255) {
reject(new Error(`Invalid store name: ${record.store}`)); return;
}
const openReq = indexedDB.open(record.db);
openReq.onerror = () => reject(openReq.error);
openReq.onupgradeneeded = () => { /* no-op */ };
openReq.onsuccess = () => {
const db = openReq.result;
const proceedWrite = () => {
try {
const tx = db.transaction([record.store], 'readwrite');
const st = tx.objectStore(record.store);
let req;
if (st.keyPath) {
req = st.put(record.value);
} else {
req = st.put(record.value, record.key);
}
req.onerror = () => reject(req.error);
tx.oncomplete = () => { db.close(); resolve(true); };
tx.onerror = () => reject(tx.error);
} catch (err) {
reject(err);
}
};
if (!db.objectStoreNames.contains(record.store)) {
db.close();
const bump = indexedDB.open(record.db, (db.version || 1) + 1);
bump.onerror = () => reject(bump.error);
bump.onupgradeneeded = (evt) => {
const db2 = evt.target.result;
if (!db2.objectStoreNames.contains(record.store)) {
const opts = {};
if (record.keyPath) opts.keyPath = record.keyPath;
if (record.autoIncrement) opts.autoIncrement = true;
const newStore = db2.createObjectStore(record.store, opts);
if (Array.isArray(record.indexes)) {
record.indexes.forEach(ix => {
try {
newStore.createIndex(ix.name, ix.keyPath, { unique: !!ix.unique, multiEntry: !!ix.multiEntry });
} catch (e) { /* ignore invalid index definitions */ }
});
}
}
};
bump.onsuccess = () => {
const db2 = bump.result;
try {
const tx = db2.transaction([record.store], 'readwrite');
const st = tx.objectStore(record.store);
let req;
if (st.keyPath) {
req = st.put(record.value);
} else {
req = st.put(record.value, record.key);
}
req.onerror = () => reject(req.error);
tx.oncomplete = () => { db2.close(); resolve(true); };
tx.onerror = () => reject(tx.error);
} catch (err) {
reject(err);
}
};
} else {
proceedWrite();
}
};
});
}
// Properly close IIFE
})();
// Duplicate applyPairs removed; using the in-IIFE implementation.
let uiMounted = false;
function ensureDOMReady(cb) {
if (document.readyState === 'interactive' || document.readyState === 'complete') {
try { cb(); } catch (e) { if (window.ESM_DIAG) window.ESM_DIAG.error('ensureDOMReady callback error', e); }
} else {
document.addEventListener('DOMContentLoaded', () => {
try { cb(); } catch (e) { if (window.ESM_DIAG) window.ESM_DIAG.error('ensureDOMReady DOMContentLoaded error', e); }
}, { once: true });
}
}
function createFallbackPanel() {
ensureDOMReady(() => {
if (window.uiMounted) return;
const panel = document.createElement('div');
panel.id = 'esm-fallback-panel';
panel.style.position = 'fixed';
panel.style.top = '72px';
panel.style.right = '12px';
panel.style.zIndex = '999999';
panel.style.background = 'rgba(30, 41, 59, 0.93)';
panel.style.backdropFilter = 'blur(4px)';
panel.style.border = '1px solid rgba(255,255,255,0.12)';
panel.style.borderRadius = '12px';
panel.style.padding = '12px 14px';
panel.style.boxShadow = '0 6px 18px rgba(0,0,0,0.35)';
panel.style.color = '#eaeef5';
panel.style.maxWidth = '420px';
panel.style.fontFamily = 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif';
const title = document.createElement('div');
title.textContent = t('scriptTitle');
title.style.fontWeight = '700';
title.style.marginBottom = '6px';
title.style.letterSpacing = '0.2px';
panel.appendChild(title);
const desc = document.createElement('div');
desc.textContent = t('fallbackDesc');
desc.style.fontSize = '12px';
desc.style.opacity = '0.9';
desc.style.marginBottom = '8px';
panel.appendChild(desc);
const btnRow = document.createElement('div');
btnRow.className = 'btnRow';
btnRow.style.display = 'grid';
btnRow.style.gridTemplateColumns = '1fr 1fr 1fr';
btnRow.style.gap = '8px';
btnRow.style.marginBottom = '10px';
btnRow.style.alignItems = 'stretch';
btnRow.style.width = '100%';
btnRow.style.boxSizing = 'border-box';
const importBtn = document.createElement('button');
importBtn.id = 'esm-import-btn';
importBtn.textContent = t('importBackup');
importBtn.title = t('importBackupTitle');
importBtn.style.backgroundImage = 'linear-gradient(180deg, #2196f3, #1976d2)';
importBtn.style.color = '#fff';
importBtn.style.border = 'none';
importBtn.style.borderRadius = '10px';
importBtn.style.padding = '8px 12px';
importBtn.style.fontWeight = '600';
importBtn.style.cursor = 'pointer';
importBtn.style.boxShadow = '0 3px 8px rgba(25, 118, 210, 0.35)';
importBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
importBtn.style.whiteSpace = 'nowrap';
importBtn.style.display = 'inline-flex';
importBtn.style.alignItems = 'center';
importBtn.style.gap = '4px';
importBtn.style.fontSize = '13px';
importBtn.style.lineHeight = '18px';
importBtn.style.width = '100%';
importBtn.style.flex = '1 1 0';
importBtn.style.minWidth = '0';
importBtn.style.margin = '0';
importBtn.addEventListener('mouseenter', () => { importBtn.style.filter = 'brightness(1.08)'; importBtn.style.boxShadow = '0 6px 14px rgba(25,118,210,0.45)'; });
importBtn.addEventListener('mouseleave', () => { importBtn.style.filter = ''; importBtn.style.boxShadow = '0 3px 8px rgba(25,118,210,0.35)'; });
importBtn.addEventListener('click', function() {
if (typeof window.importLocalStorage === 'function') {
window.importLocalStorage();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Import function not available'); } catch (_) {}
alert(t('importFunctionUnavailable'));
}
});
const exportBtn = document.createElement('button');
exportBtn.id = 'esm-export-btn';
exportBtn.textContent = t('exportBackup');
exportBtn.title = t('exportBackupTitle');
exportBtn.style.backgroundImage = 'linear-gradient(180deg, #43a047, #2e7d32)';
exportBtn.style.color = '#fff';
exportBtn.style.border = 'none';
exportBtn.style.borderRadius = '10px';
exportBtn.style.padding = '8px 12px';
exportBtn.style.fontWeight = '600';
exportBtn.style.cursor = 'pointer';
exportBtn.style.boxShadow = '0 3px 8px rgba(46, 125, 50, 0.35)';
exportBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
exportBtn.style.whiteSpace = 'nowrap';
exportBtn.style.display = 'inline-flex';
exportBtn.style.alignItems = 'center';
exportBtn.style.gap = '4px';
exportBtn.style.fontSize = '13px';
exportBtn.style.lineHeight = '18px';
exportBtn.style.width = '100%';
exportBtn.style.flex = '1 1 0';
exportBtn.style.minWidth = '0';
exportBtn.style.margin = '0';
exportBtn.addEventListener('mouseenter', () => { exportBtn.style.filter = 'brightness(1.08)'; exportBtn.style.boxShadow = '0 6px 14px rgba(46,125,50,0.45)'; });
exportBtn.addEventListener('mouseleave', () => { exportBtn.style.filter = ''; exportBtn.style.boxShadow = '0 3px 8px rgba(46,125,50,0.35)'; });
exportBtn.addEventListener('click', function() {
if (typeof window.exportLocalStorage === 'function') {
window.exportLocalStorage();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Export function not available'); } catch (_) {}
alert(t('exportFunctionUnavailable'));
}
});
// Reihenfolge im Grid-Container: alle Buttons gleichberechtigt
exportBtn.style.order = '1';
importBtn.style.order = '2';
// Dropbox Backup Button
const driveBackupBtn = document.createElement('button');
driveBackupBtn.id = 'esm-drive-backup-btn';
driveBackupBtn.textContent = t('cloudBackup');
driveBackupBtn.title = t('cloudBackupTitle');
driveBackupBtn.style.backgroundImage = 'linear-gradient(180deg, #4285f4, #1a73e8)';
driveBackupBtn.style.color = '#fff';
driveBackupBtn.style.border = 'none';
driveBackupBtn.style.borderRadius = '10px';
driveBackupBtn.style.padding = '8px 12px';
driveBackupBtn.style.fontWeight = '600';
driveBackupBtn.style.cursor = 'pointer';
driveBackupBtn.style.boxShadow = '0 3px 8px rgba(26, 115, 232, 0.35)';
driveBackupBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
driveBackupBtn.style.whiteSpace = 'nowrap';
driveBackupBtn.style.display = 'inline-flex';
driveBackupBtn.style.alignItems = 'center';
driveBackupBtn.style.gap = '4px';
driveBackupBtn.style.fontSize = '13px';
driveBackupBtn.style.lineHeight = '18px';
driveBackupBtn.style.width = '100%';
driveBackupBtn.style.flex = '1 1 0';
driveBackupBtn.style.minWidth = '0';
driveBackupBtn.style.margin = '0';
driveBackupBtn.addEventListener('mouseenter', () => { driveBackupBtn.style.filter = 'brightness(1.08)'; driveBackupBtn.style.boxShadow = '0 6px 14px rgba(26,115,232,0.45)'; });
driveBackupBtn.addEventListener('mouseleave', () => { driveBackupBtn.style.filter = ''; driveBackupBtn.style.boxShadow = '0 3px 8px rgba(26,115,232,0.35)'; });
driveBackupBtn.addEventListener('click', function() {
if (typeof window.exportToDropbox === 'function') {
window.exportToDropbox();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Dropbox backup function not available'); } catch (_) {}
alert(t('dropboxExportUnavailable'));
}
});
// Dropbox Restore Button
const driveRestoreBtn = document.createElement('button');
driveRestoreBtn.id = 'esm-drive-restore-btn';
driveRestoreBtn.textContent = t('cloudRestore');
driveRestoreBtn.title = t('cloudRestoreTitle');
driveRestoreBtn.style.backgroundImage = 'linear-gradient(180deg, #34a853, #137333)';
driveRestoreBtn.style.color = '#fff';
driveRestoreBtn.style.border = 'none';
driveRestoreBtn.style.borderRadius = '10px';
driveRestoreBtn.style.padding = '8px 12px';
driveRestoreBtn.style.fontWeight = '600';
driveRestoreBtn.style.cursor = 'pointer';
driveRestoreBtn.style.boxShadow = '0 3px 8px rgba(19, 115, 51, 0.35)';
driveRestoreBtn.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
driveRestoreBtn.style.whiteSpace = 'nowrap';
driveRestoreBtn.style.display = 'inline-flex';
driveRestoreBtn.style.alignItems = 'center';
driveRestoreBtn.style.gap = '4px';
driveRestoreBtn.style.fontSize = '13px';
driveRestoreBtn.style.lineHeight = '18px';
driveRestoreBtn.style.width = '100%';
driveRestoreBtn.style.flex = '1 1 0';
driveRestoreBtn.style.minWidth = '0';
driveRestoreBtn.style.margin = '0';
driveRestoreBtn.addEventListener('mouseenter', () => { driveRestoreBtn.style.filter = 'brightness(1.08)'; driveRestoreBtn.style.boxShadow = '0 6px 14px rgba(19,115,51,0.45)'; });
driveRestoreBtn.addEventListener('mouseleave', () => { driveRestoreBtn.style.filter = ''; driveRestoreBtn.style.boxShadow = '0 3px 8px rgba(19,115,51,0.35)'; });
driveRestoreBtn.addEventListener('click', function() {
if (typeof window.importFromDropbox === 'function') {
window.importFromDropbox();
} else {
try { if (window.ESM_DIAG) window.ESM_DIAG.error('Dropbox restore function not available'); } catch (_) {}
alert(t('dropboxImportUnavailable'));
}
});
btnRow.appendChild(exportBtn);
btnRow.appendChild(importBtn);
btnRow.appendChild(driveBackupBtn);
btnRow.appendChild(driveRestoreBtn);
// Google Drive Backup Button (Fallback)
const gdriveBackupFb = document.createElement('button');
gdriveBackupFb.id = 'esm-gdrive-backup-btn';
gdriveBackupFb.textContent = t('gdriveBackup');
gdriveBackupFb.title = t('gdriveBackupTitle');
gdriveBackupFb.style.backgroundImage = 'linear-gradient(180deg, #4285f4, #1a73e8)';
gdriveBackupFb.style.color = '#fff'; gdriveBackupFb.style.border = 'none'; gdriveBackupFb.style.borderRadius = '10px';
gdriveBackupFb.style.padding = '8px 12px'; gdriveBackupFb.style.fontWeight = '600'; gdriveBackupFb.style.cursor = 'pointer';
gdriveBackupFb.style.boxShadow = '0 3px 8px rgba(26, 115, 232, 0.35)';
gdriveBackupFb.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
gdriveBackupFb.style.whiteSpace = 'nowrap'; gdriveBackupFb.style.display = 'inline-flex'; gdriveBackupFb.style.alignItems = 'center';
gdriveBackupFb.style.gap = '4px'; gdriveBackupFb.style.fontSize = '13px'; gdriveBackupFb.style.lineHeight = '18px';
gdriveBackupFb.style.width = '100%'; gdriveBackupFb.style.flex = '1 1 0'; gdriveBackupFb.style.minWidth = '0'; gdriveBackupFb.style.margin = '0';
gdriveBackupFb.addEventListener('mouseenter', () => { gdriveBackupFb.style.filter = 'brightness(1.08)'; gdriveBackupFb.style.boxShadow = '0 6px 14px rgba(26,115,232,0.45)'; });
gdriveBackupFb.addEventListener('mouseleave', () => { gdriveBackupFb.style.filter = ''; gdriveBackupFb.style.boxShadow = '0 3px 8px rgba(26,115,232,0.35)'; });
gdriveBackupFb.addEventListener('click', function() {
if (typeof window.exportToGoogleDrive === 'function') { window.exportToGoogleDrive(); }
else { alert(t('gdriveExportUnavailable')); }
});
// Google Drive Restore Button (Fallback)
const gdriveRestoreFb = document.createElement('button');
gdriveRestoreFb.id = 'esm-gdrive-restore-btn';
gdriveRestoreFb.textContent = t('gdriveRestore');
gdriveRestoreFb.title = t('gdriveRestoreTitle');
gdriveRestoreFb.style.backgroundImage = 'linear-gradient(180deg, #34a853, #137333)';
gdriveRestoreFb.style.color = '#fff'; gdriveRestoreFb.style.border = 'none'; gdriveRestoreFb.style.borderRadius = '10px';
gdriveRestoreFb.style.padding = '8px 12px'; gdriveRestoreFb.style.fontWeight = '600'; gdriveRestoreFb.style.cursor = 'pointer';
gdriveRestoreFb.style.boxShadow = '0 3px 8px rgba(19, 115, 51, 0.35)';
gdriveRestoreFb.style.transition = 'transform 80ms ease, box-shadow 200ms ease, filter 200ms ease';
gdriveRestoreFb.style.whiteSpace = 'nowrap'; gdriveRestoreFb.style.display = 'inline-flex'; gdriveRestoreFb.style.alignItems = 'center';
gdriveRestoreFb.style.gap = '4px'; gdriveRestoreFb.style.fontSize = '13px'; gdriveRestoreFb.style.lineHeight = '18px';
gdriveRestoreFb.style.width = '100%'; gdriveRestoreFb.style.flex = '1 1 0'; gdriveRestoreFb.style.minWidth = '0'; gdriveRestoreFb.style.margin = '0';
gdriveRestoreFb.addEventListener('mouseenter', () => { gdriveRestoreFb.style.filter = 'brightness(1.08)'; gdriveRestoreFb.style.boxShadow = '0 6px 14px rgba(19,115,51,0.45)'; });
gdriveRestoreFb.addEventListener('mouseleave', () => { gdriveRestoreFb.style.filter = ''; gdriveRestoreFb.style.boxShadow = '0 3px 8px rgba(19,115,51,0.35)'; });
gdriveRestoreFb.addEventListener('click', function() {
if (typeof window.importFromGoogleDrive === 'function') { window.importFromGoogleDrive(); }
else { alert(t('gdriveImportUnavailable')); }
});
btnRow.appendChild(gdriveBackupFb);
btnRow.appendChild(gdriveRestoreFb);
panel.appendChild(btnRow);
// Auto Save UI im Fallback-Panel
try {
const autoSaveUI = createAutoSaveUI();
autoSaveUI.style.background = 'rgba(255,255,255,0.08)';
autoSaveUI.style.border = '1px solid rgba(255,255,255,0.15)';
autoSaveUI.style.color = '#eaeef5';
panel.appendChild(autoSaveUI);
loadAutoSaveSettings();
} catch (_) {}
const container = document.createElement('div');
container.id = 'key-list-container';
container.style.background = 'rgba(255,255,255,0.05)';
container.style.border = '1px solid rgba(255,255,255,0.10)';
container.style.borderRadius = '10px';
container.style.padding = '10px';
container.style.maxHeight = '320px';
container.style.overflow = 'auto';
panel.appendChild(container);
document.body.appendChild(panel);
window.uiMounted = true;
if (typeof window !== 'undefined' && window.ESM_DIAG) window.ESM_DIAG.log('UI mounted via floating fallback panel');
});
}
// Duplicate global addScriptTab removed; using the IIFE-scoped implementation exclusively.
// UI mounting is managed inside the IIFE via initialize(); removing duplicate global function to avoid scope issues.
if (typeof window !== 'undefined') {
if (typeof exportLocalStorage === 'function') {
window.exportLocalStorage = exportLocalStorage;
}
if (typeof importLocalStorage === 'function') {
window.importLocalStorage = importLocalStorage;
}
if (typeof exportToDropbox === 'function') {
window.exportToDropbox = exportToDropbox;
}
if (typeof importFromDropbox === 'function') {
window.importFromDropbox = importFromDropbox;
}
if (typeof exportToGoogleDrive === 'function') {
window.exportToGoogleDrive = exportToGoogleDrive;
}
if (typeof importFromGoogleDrive === 'function') {
window.importFromGoogleDrive = importFromGoogleDrive;
}
}