to-userscript
Version:
Converts simple browser extensions to userscripts
609 lines (532 loc) • 17.1 kB
JavaScript
// --- Abstraction Layer: Vanilla Target
let vanillaStorageDB = null;
const STORAGE_DB_NAME = "VanillaExtensionStorage";
const STORAGE_DB_VERSION = 1;
const STORAGE_STORE_NAME = "storage";
/**
* Initializes the IndexedDB database for storage
* @returns {Promise<IDBDatabase>} The opened database
*/
async function _initStorage() {
if (vanillaStorageDB) {
return vanillaStorageDB;
}
return new Promise((resolve, reject) => {
try {
const request = indexedDB.open(STORAGE_DB_NAME, STORAGE_DB_VERSION);
request.onerror = () => {
const error = new Error(
`Failed to open IndexedDB: ${
request.error?.message || "Unknown error"
}`
);
_error("IndexedDB initialization error:", error);
reject(error);
};
request.onsuccess = () => {
vanillaStorageDB = request.result;
_log("IndexedDB storage initialized successfully");
resolve(vanillaStorageDB);
vanillaStorageDB.onclose = () => {
_warn("IndexedDB connection closed unexpectedly");
vanillaStorageDB = null;
};
vanillaStorageDB.onerror = (event) => {
_error("IndexedDB error:", event.target.error);
};
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
try {
if (!db.objectStoreNames.contains(STORAGE_STORE_NAME)) {
const store = db.createObjectStore(STORAGE_STORE_NAME, {
keyPath: "key",
});
_log("Created IndexedDB object store:", STORAGE_STORE_NAME);
}
} catch (storeError) {
_error("Error creating object store:", storeError);
reject(
new Error(`Failed to create object store: ${storeError.message}`)
);
}
};
request.onblocked = () => {
_warn("IndexedDB upgrade blocked by another connection");
reject(
new Error("IndexedDB upgrade blocked. Please close other tabs.")
);
};
} catch (error) {
const errorMsg = `IndexedDB not supported or initialization failed: ${error.message}`;
_error(errorMsg);
reject(new Error(errorMsg));
}
});
}
/**
* Ensures database is initialized and returns a transaction
* @param {string} mode - Transaction mode ('readonly' or 'readwrite')
* @returns {Promise<IDBObjectStore>} The object store
*/
async function getStorageStore(mode = "readonly") {
try {
const db = await _initStorage();
if (!db) {
throw new Error("Database is not available");
}
const transaction = db.transaction([STORAGE_STORE_NAME], mode);
const store = transaction.objectStore(STORAGE_STORE_NAME);
transaction.onerror = () => {
_error("Transaction error:", transaction.error);
};
return store;
} catch (error) {
throw new Error(`Failed to get storage store: ${error.message}`);
}
}
/**
* Validates storage key
* @param {string} key - The storage key to validate
* @throws {Error} If key is invalid
*/
function validateStorageKey(key) {
if (typeof key !== "string") {
throw new Error(`Storage key must be a string, got ${typeof key}`);
}
if (key.length === 0) {
throw new Error("Storage key cannot be empty");
}
if (key.length > 8192) {
throw new Error(`Storage key too long (${key.length} > 8192 characters)`);
}
}
/**
* Validates storage value
* @param {any} value - The storage value to validate
* @throws {Error} If value is invalid
*/
function validateStorageValue(value) {
if (value === undefined) {
throw new Error("Storage value cannot be undefined (use null instead)");
}
try {
const serialized = JSON.stringify(value);
if (serialized === undefined) {
throw new Error("Storage value is not serializable");
}
const sizeInBytes = new Blob([serialized]).size;
if (sizeInBytes > maxSize) {
throw new Error(
`Storage value too large (${Math.round(
sizeInBytes / 1024 / 1024
)}MB > 10MB)`
);
}
} catch (error) {
if (error.message.includes("circular")) {
throw new Error("Storage value contains circular references");
}
throw new Error(`Storage value validation failed: ${error.message}`);
}
}
async function _storageSet(items) {
try {
if (!items || typeof items !== "object" || Array.isArray(items)) {
throw new Error("Storage set items must be a plain object");
}
const keys = Object.keys(items);
if (keys.length === 0) {
_warn("No items to set in storage");
return;
}
for (const [key, value] of Object.entries(items)) {
validateStorageKey(key);
validateStorageValue(value);
}
const store = await getStorageStore("readwrite");
const promises = [];
for (const [key, value] of Object.entries(items)) {
const promise = new Promise((resolve, reject) => {
const request = store.put({ key, value, timestamp: Date.now() });
request.onsuccess = () => resolve();
request.onerror = () =>
reject(
new Error(
`Failed to store key '${key}': ${
request.error?.message || "Unknown error"
}`
)
);
});
promises.push(promise);
}
await Promise.all(promises);
_log(`Successfully stored ${keys.length} item(s) to IndexedDB:`, keys);
} catch (error) {
const errorMsg = `Storage set operation failed: ${error.message}`;
_error(errorMsg);
throw new Error(errorMsg);
}
}
async function _storageGet(keys) {
try {
const store = await getStorageStore("readonly");
let keyList = [];
let defaults = {};
let requestedKeys = [];
if (keys === null || keys === undefined) {
const getAllRequest = store.getAll();
return new Promise((resolve, reject) => {
getAllRequest.onsuccess = () => {
const result = {};
getAllRequest.result.forEach((item) => {
result[item.key] = item.value;
});
resolve(result);
};
getAllRequest.onerror = () => {
reject(
new Error(
`Failed to get all storage items: ${
getAllRequest.error?.message || "Unknown error"
}`
)
);
};
});
} else if (typeof keys === "string") {
keyList = [keys];
requestedKeys = [keys];
} else if (Array.isArray(keys)) {
keyList = keys;
requestedKeys = [...keys];
} else if (typeof keys === "object" && keys !== null) {
keyList = Object.keys(keys);
requestedKeys = [...keyList];
defaults = keys;
} else {
throw new Error(`Invalid keys format for storage get: ${typeof keys}`);
}
keyList.forEach((key) => {
if (typeof key !== "string") {
throw new Error(
`Storage key must be a string, got ${typeof key} at key: ${key}`
);
}
});
const results = {};
const promises = keyList.map((key) => {
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => {
if (request.result) {
results[key] = request.result.value;
} else if (defaults.hasOwnProperty(key)) {
results[key] = defaults[key];
}
resolve();
};
request.onerror = () => {
reject(
new Error(
`Failed to get storage key '${key}': ${
request.error?.message || "Unknown error"
}`
)
);
};
});
});
await Promise.all(promises);
const finalResult = {};
for (const key of requestedKeys) {
if (results.hasOwnProperty(key)) {
finalResult[key] = results[key];
} else if (defaults.hasOwnProperty(key)) {
finalResult[key] = defaults[key];
}
}
_log(
`Retrieved ${
Object.keys(finalResult).length
} item(s) from IndexedDB storage`
);
return finalResult;
} catch (error) {
const errorMsg = `Storage get operation failed: ${error.message}`;
_error(errorMsg);
throw new Error(errorMsg);
}
}
async function _storageRemove(keysToRemove) {
try {
let keyList = [];
if (typeof keysToRemove === "string") {
keyList = [keysToRemove];
} else if (Array.isArray(keysToRemove)) {
keyList = keysToRemove;
} else {
throw new Error(
`Invalid keys format for storage remove: ${typeof keysToRemove}`
);
}
if (keyList.length === 0) {
_warn("No keys to remove from storage");
return;
}
keyList.forEach((key) => validateStorageKey(key));
const store = await getStorageStore("readwrite");
const promises = keyList.map((key) => {
return new Promise((resolve, reject) => {
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(
new Error(
`Failed to remove storage key '${key}': ${
request.error?.message || "Unknown error"
}`
)
);
};
});
});
await Promise.all(promises);
_log(`Removed ${keyList.length} item(s) from IndexedDB storage:`, keyList);
} catch (error) {
const errorMsg = `Storage remove operation failed: ${error.message}`;
_error(errorMsg);
throw new Error(errorMsg);
}
}
async function _storageClear() {
try {
const store = await getStorageStore("readwrite");
return new Promise((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => {
_log("Cleared all items from IndexedDB storage");
resolve();
};
request.onerror = () => {
reject(
new Error(
`Failed to clear storage: ${
request.error?.message || "Unknown error"
}`
)
);
};
});
} catch (error) {
const errorMsg = `Storage clear operation failed: ${error.message}`;
_error(errorMsg);
throw new Error(errorMsg);
}
}
async function _cookieList(details = {}) {
return new Promise((resolve) => {
const cookies = [];
if (typeof document === "undefined" || !document.cookie) {
return resolve([]);
}
const docCookies = document.cookie.split(";");
const currentDomain = location.hostname;
if (details.domain && !currentDomain.endsWith(details.domain)) {
return resolve([]);
}
for (let i = 0; i < docCookies.length; i++) {
let cookie = docCookies[i];
while (cookie.charAt(0) === " ") {
cookie = cookie.substring(1, cookie.length);
}
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
const value = eqPos > -1 ? cookie.substr(eqPos + 1) : "";
if (!details.name || details.name === name) {
cookies.push({
name: name,
value: decodeURIComponent(value),
domain: currentDomain,
path: "/", // Cannot determine path from document.cookie
secure: location.protocol === "https:",
httpOnly: false, // Cannot determine httpOnly from document.cookie
session: true, // Cannot determine expiration from document.cookie
});
}
}
resolve(cookies);
});
}
async function _cookieSet(details = {}) {
return new Promise((resolve, reject) => {
if (typeof document === "undefined") {
return reject(new Error("document is not available to set cookie."));
}
if (!details.name) {
return reject(new Error("Cookie name is required."));
}
let cookieString = `${details.name}=${encodeURIComponent(
details.value || ""
)}`;
if (details.expirationDate) {
const date = new Date(details.expirationDate * 1000);
cookieString += `; expires=${date.toUTCString()}`;
}
if (details.domain) {
cookieString += `; domain=${details.domain}`;
}
if (details.path) {
cookieString += `; path=${details.path}`;
} else {
cookieString += `; path=/`;
}
if (details.secure) {
cookieString += `; secure`;
}
if (details.sameSite) {
cookieString += `; samesite=${details.sameSite}`;
}
if (details.httpOnly) {
_warn("`httpOnly` flag cannot be set for cookies via JavaScript.");
}
document.cookie = cookieString;
resolve();
});
}
async function _cookieDelete(details = {}) {
return new Promise((resolve, reject) => {
if (typeof document === "undefined") {
return reject(new Error("document is not available to delete cookie."));
}
if (!details.name) {
return reject(new Error("Cookie name is required for deletion."));
}
let cookieString = `${details.name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
if (details.domain) {
cookieString += `; domain=${details.domain}`;
}
if (details.path) {
cookieString += `; path=${details.path}`;
} else {
cookieString += `; path=/`;
}
document.cookie = cookieString;
document.cookie = `${details.name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
resolve();
});
}
async function _fetch(url, options = {}) {
try {
if (!url || typeof url !== "string") {
throw new Error(`Invalid URL for fetch: ${url}`);
}
if (options && typeof options !== "object") {
throw new Error(`Fetch options must be an object, got ${typeof options}`);
}
const response = await fetch(url, options);
const enhancedResponse = {
...response,
ok: response.ok,
status: response.status,
statusText: response.statusText,
url: response.url,
headers: response.headers,
text: () => response.text(),
json: () => response.json(),
blob: () => response.blob(),
arrayBuffer: () => response.arrayBuffer(),
clone: () => response.clone(),
};
return enhancedResponse;
} catch (error) {
if (error.name === "TypeError" && error.message.includes("fetch")) {
throw new Error(
`Network error or invalid URL: ${url} - ${error.message}`
);
} else if (error.name === "AbortError") {
throw new Error(`Fetch request aborted: ${url}`);
} else {
throw new Error(`Fetch failed for ${url}: ${error.message}`);
}
}
}
function _registerMenuCommand(name, func) {
if (!name || typeof name !== "string") {
_warn("Invalid menu command name:", name);
return;
}
if (!func || typeof func !== "function") {
_warn("Invalid menu command function for:", name);
return;
}
_log(`Menu command registered (vanilla mode): ${name}`);
if (!window._vanillaMenuCommands) {
window._vanillaMenuCommands = new Map();
}
window._vanillaMenuCommands.set(name, func);
// Note: In vanilla JS, there's no standard browser equivalent to GM_registerMenuCommand
// Extensions could implement their own UI for this, but for now we just store it
_warn(
`Vanilla menu command registration: "${name}" - No standard browser equivalent available`
);
}
function _openTab(url) {
try {
if (!url || typeof url !== "string") {
throw new Error(`Invalid URL for tab opening: ${url}`);
}
// Validate URL format
try {
new URL(url);
} catch (urlError) {
throw new Error(`Invalid URL format: ${url}`);
}
// Use window.open with security best practices
const newTab = window.open(url, "_blank", "noopener,noreferrer");
if (!newTab) {
_warn(`Tab opening was blocked by popup blocker for: ${url}`);
// Fallback: try to create a clickable link for user action
const fallbackNotification = () => {
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = `Open ${url}`;
link.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
z-index: 999999;
background: #007cba;
color: white;
padding: 8px 12px;
border-radius: 4px;
text-decoration: none;
font-family: sans-serif;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
`;
document.body.appendChild(link);
// Auto-remove after 10 seconds
setTimeout(() => {
if (link.parentNode) {
link.parentNode.removeChild(link);
}
}, 10000);
_log("Created fallback link for blocked popup:", url);
};
// Only create fallback if we're in a document context
if (typeof document !== "undefined" && document.body) {
fallbackNotification();
}
} else {
_log(`Successfully opened tab: ${url}`);
}
} catch (error) {
const errorMsg = `Tab opening failed: ${error.message}`;
_error(errorMsg);
throw new Error(errorMsg);
}
}