From 20bfda25f88d01bc36ef25a9fcb3d2d41902522f Mon Sep 17 00:00:00 2001 From: Graham Kelly Date: Tue, 25 Nov 2025 14:32:58 -0500 Subject: [PATCH] Switch to ESM --- index.html | 2 +- main.js | 126 +---------------------------- sw.js | 225 +--------------------------------------------------- util.js | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 349 deletions(-) create mode 100644 util.js diff --git a/index.html b/index.html index 80e651a..11146ad 100644 --- a/index.html +++ b/index.html @@ -125,7 +125,7 @@ } - + diff --git a/main.js b/main.js index 1088a94..87630ad 100644 --- a/main.js +++ b/main.js @@ -1,30 +1,7 @@ -const DBN = "FileCacheDB" -const FOLDERS_SN = "Folders" -const FILES_SN = "Files" -const META_SN = "Metadata" -const RULES_SN = "Rules" -const DB_VERSION = 11 // Version 1.1 - -const CHUNK_SIZE = 4 * 1024 * 1024 // 4MB chunks -const BATCH_SIZE = 50 // Batch of 50 +import {CACHE_NAME,clientSessionStore,DBN,DB_VERSION,dbPromise,FILES_SN,FOLDERS_SN,FULL_APP_SHELL_URLS,promisifyRequest,promisifyTransaction,getDb,getMimeType,escapeRegex,applyRegexRules} from './util' // Fetches all folder names from IndexedDB and displays them in the UI. let isListingFolders = false -// Helper function to wrap IndexedDB requests in a Promise -function promisifyRequest(request) { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) -} - -function promisifyTransaction(transaction) { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve() - transaction.onerror = () => reject(transaction.error) - transaction.onabort = () => reject(transaction.error || new DOMException("Transaction aborted")) - }) -} let currentlyBusy = false function setUiBusy(isBusy) { @@ -41,58 +18,6 @@ navigator.storage.persist().then(persistent => { } }) -let dbPromise = null -function getDb() { - if (!dbPromise) { - dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DBN, DB_VERSION) - - request.onupgradeneeded = function (e) { - const db = e.target.result - const transaction = e.target.transaction - - // Create standard stores if they don't exist - if (!db.objectStoreNames.contains(FOLDERS_SN)) { - db.createObjectStore(FOLDERS_SN, { keyPath: "id" }) - } - if (!db.objectStoreNames.contains(RULES_SN)) { - db.createObjectStore(RULES_SN, { keyPath: "id" }) - } - - let fileStore - if (!db.objectStoreNames.contains(FILES_SN)) { - fileStore = db.createObjectStore(FILES_SN, { keyPath: "id", autoIncrement: true }) - } else { - fileStore = transaction.objectStore(FILES_SN) - } - - if (!fileStore.indexNames.contains("lookup")) { - fileStore.createIndex("lookup", "lookupPath", { unique: true }) - } - - if (!db.objectStoreNames.contains("FileChunks")) { - const chunkStore = db.createObjectStore("FileChunks", { keyPath: "id", autoIncrement: true }) - chunkStore.createIndex("by_file", "fileId", { unique: false }) - } - } - - request.onsuccess = e => { - db = e.target.result - db.onversionchange = () => { - console.warn("Database version change detected, closing connection.") - if (db) { - db.close() - } - db = null - dbPromise = null - } - resolve(db) - } - request.onerror = e => reject(e.target.errorCode) - }) - } - return dbPromise -} // Global variables to hold state for the currently managed folder. let folderName, dirHandle, observer @@ -594,54 +519,7 @@ function invalidateCacheAndWait(folderName) { }) } -/** - * Determines the MIME type of a file based on its extension. - * @param {string} filePath The path to the file. - * @returns {string | undefined} The MIME type or undefined if not found. - */ -function getMimeType(filePath) { - const ext = filePath.split(".").pop().toLowerCase() - const mimeTypes = { - // Web Text/Markup - "html": "text/html", "htm": "text/html", "css": "text/css", - "js": "application/javascript", "mjs": "application/javascript", - "json": "application/json", "xml": "application/xml", - "txt": "text/plain", "md": "text/markdown", "csv": "text/csv", - "php": "text/html", "appcache": "text/cache-manifest", - "xhtml": "application/xhtml+xml", - - // Images - "ico": "image/x-icon", "bmp": "image/bmp", "gif": "image/gif", - "jpeg": "image/jpeg", "jpg": "image/jpeg", "png": "image/png", - "svg": "image/svg+xml", "tif": "image/tiff", "tiff": "image/tiff", - "webp": "image/webp", "avif": "image/avif", - - // Audio - "mp3": "audio/mpeg", "wav": "audio/wav", "ogg": "audio/ogg", - "weba": "audio/webm", "mid": "audio/midi", - - // Video - "mp4": "video/mp4", "webm": "video/webm", "mpeg": "video/mpeg", - "ogv": "video/ogg", "3gp": "video/3gpp", "avi": "video/x-msvideo", - - // Documents & Other Apps - "pdf": "application/pdf", "rtf": "application/rtf", - "ogg": "application/ogg", // Generic OGG container - - // Archives/Compressed - "zip": "application/zip", "gz": "application/gzip", - "rar": "application/vnd.rar", "tar": "application/x-tar", - "7z": "application/x-7z-compressed", - - // Fonts - "woff": "font/woff", "woff2": "font/woff2", "ttf": "font/ttf", - "otf": "font/otf", "eot": "application/vnd.ms-fontobject", - - // WebAssembly - "wasm": "application/wasm" - } - return mimeTypes[ext] -} + // Retrieves a file or directory handle from a given root directory and a relative path. async function getHandleFromPath(rootDirHandle, path) { diff --git a/sw.js b/sw.js index 141b38f..d0c04b6 100644 --- a/sw.js +++ b/sw.js @@ -1,27 +1,6 @@ +import {CACHE_NAME,clientSessionStore,DBN,DB_VERSION,dbPromise,FILES_SN,FOLDERS_SN,FULL_APP_SHELL_URLS,promisifyRequest,promisifyTransaction,getDb,getMimeType,escapeRegex,applyRegexRules} from './util' // A single variable to hold data for the very next navigation request. let pendingNavData = null -// A single map to store session data (rules, keys) for each client tab. -const clientSessionStore = new Map() - -const DBN = "FileCacheDB" -const FOLDERS_SN = "Folders" -const FILES_SN = "Files" -const RULES_SN = "Rules" -const DB_VERSION = 11 // Version 1.1 - -const CACHE_NAME = "fc" -const APP_SHELL_FILES = ["./", "./index.html", "./main.js", "./cbor-x.js"] // core files - -const FULL_APP_SHELL_URLS = APP_SHELL_FILES.map(file => new URL(file, self.location.href).href) - -// Promise for the IndexedDB connection. -let dbPromise = null - -const STORE_ENTRY_TTL = 30000 // 30 seconds - -const basePath = new URL("./", self.location).pathname -const virtualPathPrefix = basePath + "n/" - function cleanupExpiredStores() { const now = Date.now() for (const [clientId, sessionData] of clientSessionStore.entries()) { @@ -34,208 +13,6 @@ function cleanupExpiredStores() { cleanupExpiredStores() -/** - * Promisifies an IndexedDB request. - * @param {IDBRequest} req The IndexedDB request. - * @returns {Promise} A promise that resolves with the request result or rejects on error. - */ -function promisifyRequest(req) { - return new Promise((resolve, reject) => { - req.onsuccess = () => resolve(req.result) - req.onerror = () => reject(req.error) - }) -} - -/** - * Promisifies an IndexedDB transaction completion. - * @param {IDBTransaction} transaction The IndexedDB transaction. - * @returns {Promise} A promise that resolves when the transaction completes or rejects on error/abort. - */ -function promisifyTransaction(transaction) { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve() - transaction.onerror = () => reject(transaction.error) - transaction.onabort = () => reject(transaction.error || new DOMException("Transaction aborted")) - }) -} - -/** - * Determines the MIME type of a file based on its extension. - * @param {string} filePath The path to the file. - * @returns {string | undefined} The MIME type or undefined if not found. - */ -function getMimeType(filePath) { - const ext = filePath.split(".").pop().toLowerCase() - const mimeTypes = { - // Web Text/Markup - "html": "text/html", "htm": "text/html", "css": "text/css", - "js": "application/javascript", "mjs": "application/javascript", - "json": "application/json", "xml": "application/xml", - "txt": "text/plain", "md": "text/markdown", "csv": "text/csv", - "php": "text/html", "appcache": "text/cache-manifest", - "xhtml": "application/xhtml+xml", - - // Images - "ico": "image/x-icon", "bmp": "image/bmp", "gif": "image/gif", - "jpeg": "image/jpeg", "jpg": "image/jpeg", "png": "image/png", - "svg": "image/svg+xml", "tif": "image/tiff", "tiff": "image/tiff", - "webp": "image/webp", "avif": "image/avif", - - // Audio - "mp3": "audio/mpeg", "wav": "audio/wav", "ogg": "audio/ogg", - "weba": "audio/webm", "mid": "audio/midi", - - // Video - "mp4": "video/mp4", "webm": "video/webm", "mpeg": "video/mpeg", - "ogv": "video/ogg", "3gp": "video/3gpp", "avi": "video/x-msvideo", - - // Documents & Other Apps - "pdf": "application/pdf", "rtf": "application/rtf", - "ogg": "application/ogg", // Generic OGG container - - // Archives/Compressed - "zip": "application/zip", "gz": "application/gzip", - "rar": "application/vnd.rar", "tar": "application/x-tar", - "7z": "application/x-7z-compressed", - - // Fonts - "woff": "font/woff", "woff2": "font/woff2", "ttf": "font/ttf", - "otf": "font/otf", "eot": "application/vnd.ms-fontobject", - - // WebAssembly - "wasm": "application/wasm" - } - return mimeTypes[ext] -} - -/** - * Escapes special characters in a string for safe use in a regular expression. - * @param {string} string The string to escape. - * @returns {string} The escaped string. - */ -function escapeRegex(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -/** - * Applies regex search and replace rules to file content if it's text-based. - * @param {string} filePath The path of the file being processed. - * @param {ArrayBuffer} fileBuffer The file's content. - * @param {string} fileType The file's MIME type. - * @param {string | null} regexRules The rules string. - * @returns {ArrayBuffer} The potentially modified file content. - */ -function applyRegexRules(filePath, fileBuffer, fileType, regexRules) { - if (!regexRules || !regexRules.trim()) return fileBuffer - - // A more inclusive check to ensure JS, JSON, and other text files are processed - if (!/^(text\/|application\/(javascript|json|xml|x-javascript))/.test(fileType)) return fileBuffer - - try { - let content = new TextDecoder().decode(fileBuffer) - const rules = regexRules.trim().split("\n") - - for (const line of rules) { - const parts = line.split("->") - if (parts.length < 2) continue - - const matchPart = parts[0].trim() - // This correctly handles cases where "->" might exist in the replacement string - const replacePart = parts.slice(1).join("->").trim() - - // Use a robust regex that correctly parses the file match, operator, and search pattern - const operatorMatch = matchPart.match(/^(.*?)\s+(\$|\$\$|\|\||\|)\s+(.*)$/s) - if (!operatorMatch) continue - - const [, fileMatch, operator, searchPattern] = operatorMatch - const fileRegex = new RegExp(fileMatch.trim() === "*" ? ".*" : fileMatch.trim()) - - // If the rule's file path doesn't match, skip to the next rule - if (!fileRegex.test(filePath)) continue - - let searchRegex - switch (operator) { - case "|": // User provides a full regex pattern - searchRegex = new RegExp(searchPattern, "g") - break - case "$": // User provides plain text to be searched - searchRegex = new RegExp(escapeRegex(searchPattern), "g") - break - case "||": // Single-match version of regex - searchRegex = new RegExp(searchPattern) - break - case "$$": // Single-match version of plain text - searchRegex = new RegExp(escapeRegex(searchPattern)) - break - } - - if (searchRegex) { - content = content.replace(searchRegex, replacePart) - } - } - return new TextEncoder().encode(content).buffer - } catch (e) { - console.error(`Error applying regex rules to ${filePath}:`, e) - return fileBuffer - } -} - -/** - * Gets the IndexedDB connection promise, creating it if it doesn't exist. - * @returns {Promise} A promise that resolves with the DB instance. - */ -function getDb() { - if (!dbPromise) { - dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DBN, DB_VERSION) - - request.onupgradeneeded = function (e) { - const db = e.target.result - const transaction = e.target.transaction - - // Create standard stores if they don't exist - if (!db.objectStoreNames.contains(FOLDERS_SN)) { - db.createObjectStore(FOLDERS_SN, { keyPath: "id" }) - } - if (!db.objectStoreNames.contains(RULES_SN)) { - db.createObjectStore(RULES_SN, { keyPath: "id" }) - } - - let fileStore - if (!db.objectStoreNames.contains(FILES_SN)) { - fileStore = db.createObjectStore(FILES_SN, { keyPath: "id", autoIncrement: true }) - } else { - fileStore = transaction.objectStore(FILES_SN) - } - - if (!fileStore.indexNames.contains("lookup")) { - fileStore.createIndex("lookup", "lookupPath", { unique: true }) - } - - if (!db.objectStoreNames.contains("FileChunks")) { - const chunkStore = db.createObjectStore("FileChunks", { keyPath: "id", autoIncrement: true }) - chunkStore.createIndex("by_file", "fileId", { unique: false }) - } - } - - request.onsuccess = e => { - db = e.target.result - db.onversionchange = () => { - console.warn("Database version change detected, closing connection.") - if (db) { - db.close() - } - db = null - dbPromise = null - } - resolve(db) - } - request.onerror = e => reject(e.target.errorCode) - }) - } - return dbPromise -} - // Service worker installation. Caches the application shell. self.addEventListener("install", e => { e.waitUntil((async () => { diff --git a/util.js b/util.js new file mode 100644 index 0000000..5027881 --- /dev/null +++ b/util.js @@ -0,0 +1,226 @@ + +// A single map to store session data (rules, keys) for each client tab. +export const clientSessionStore = new Map() + +export const DBN = "FileCacheDB" +export const FOLDERS_SN = "Folders" +export const FILES_SN = "Files" +export const RULES_SN = "Rules" +export const DB_VERSION = 11 // Version 1.1 + +export const CACHE_NAME = "fc" +export const APP_SHELL_FILES = ["./", "./index.html", "./main.js", "./cbor-x.js"] // core files + +export const FULL_APP_SHELL_URLS = APP_SHELL_FILES.map(file => new URL(file, self.location.href).href) + +// Promise for the IndexedDB connection. +export let dbPromise = null + +export const STORE_ENTRY_TTL = 30000 // 30 seconds + +export const basePath = new URL("./", self.location).pathname +export const virtualPathPrefix = basePath + "n/" + + + +/** + * Promisifies an IndexedDB request. + * @param {IDBRequest} req The IndexedDB request. + * @returns {Promise} A promise that resolves with the request result or rejects on error. + */ +export function promisifyRequest(req) { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +/** + * Promisifies an IndexedDB transaction completion. + * @param {IDBTransaction} transaction The IndexedDB transaction. + * @returns {Promise} A promise that resolves when the transaction completes or rejects on error/abort. + */ +export function promisifyTransaction(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error || new DOMException("Transaction aborted")) + }) +} + +/** + * Determines the MIME type of a file based on its extension. + * @param {string} filePath The path to the file. + * @returns {string | undefined} The MIME type or undefined if not found. + */ +export function getMimeType(filePath) { + const ext = filePath.split(".").pop().toLowerCase() + const mimeTypes = { + // Web Text/Markup + "html": "text/html", "htm": "text/html", "css": "text/css", + "js": "application/javascript", "mjs": "application/javascript", + "json": "application/json", "xml": "application/xml", + "txt": "text/plain", "md": "text/markdown", "csv": "text/csv", + "php": "text/html", "appcache": "text/cache-manifest", + "xhtml": "application/xhtml+xml", + + // Images + "ico": "image/x-icon", "bmp": "image/bmp", "gif": "image/gif", + "jpeg": "image/jpeg", "jpg": "image/jpeg", "png": "image/png", + "svg": "image/svg+xml", "tif": "image/tiff", "tiff": "image/tiff", + "webp": "image/webp", "avif": "image/avif", + + // Audio + "mp3": "audio/mpeg", "wav": "audio/wav", "ogg": "audio/ogg", + "weba": "audio/webm", "mid": "audio/midi", + + // Video + "mp4": "video/mp4", "webm": "video/webm", "mpeg": "video/mpeg", + "ogv": "video/ogg", "3gp": "video/3gpp", "avi": "video/x-msvideo", + + // Documents & Other Apps + "pdf": "application/pdf", "rtf": "application/rtf", + "ogg": "application/ogg", // Generic OGG container + + // Archives/Compressed + "zip": "application/zip", "gz": "application/gzip", + "rar": "application/vnd.rar", "tar": "application/x-tar", + "7z": "application/x-7z-compressed", + + // Fonts + "woff": "font/woff", "woff2": "font/woff2", "ttf": "font/ttf", + "otf": "font/otf", "eot": "application/vnd.ms-fontobject", + + // WebAssembly + "wasm": "application/wasm" + } + return mimeTypes[ext] +} + +/** + * Escapes special characters in a string for safe use in a regular expression. + * @param {string} string The string to escape. + * @returns {string} The escaped string. + */ +export function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** + * Applies regex search and replace rules to file content if it's text-based. + * @param {string} filePath The path of the file being processed. + * @param {ArrayBuffer} fileBuffer The file's content. + * @param {string} fileType The file's MIME type. + * @param {string | null} regexRules The rules string. + * @returns {ArrayBuffer} The potentially modified file content. + */ +export function applyRegexRules(filePath, fileBuffer, fileType, regexRules) { + if (!regexRules || !regexRules.trim()) return fileBuffer + + // A more inclusive check to ensure JS, JSON, and other text files are processed + if (!/^(text\/|application\/(javascript|json|xml|x-javascript))/.test(fileType)) return fileBuffer + + try { + let content = new TextDecoder().decode(fileBuffer) + const rules = regexRules.trim().split("\n") + + for (const line of rules) { + const parts = line.split("->") + if (parts.length < 2) continue + + const matchPart = parts[0].trim() + // This correctly handles cases where "->" might exist in the replacement string + const replacePart = parts.slice(1).join("->").trim() + + // Use a robust regex that correctly parses the file match, operator, and search pattern + const operatorMatch = matchPart.match(/^(.*?)\s+(\$|\$\$|\|\||\|)\s+(.*)$/s) + if (!operatorMatch) continue + + const [, fileMatch, operator, searchPattern] = operatorMatch + const fileRegex = new RegExp(fileMatch.trim() === "*" ? ".*" : fileMatch.trim()) + + // If the rule's file path doesn't match, skip to the next rule + if (!fileRegex.test(filePath)) continue + + let searchRegex + switch (operator) { + case "|": // User provides a full regex pattern + searchRegex = new RegExp(searchPattern, "g") + break + case "$": // User provides plain text to be searched + searchRegex = new RegExp(escapeRegex(searchPattern), "g") + break + case "||": // Single-match version of regex + searchRegex = new RegExp(searchPattern) + break + case "$$": // Single-match version of plain text + searchRegex = new RegExp(escapeRegex(searchPattern)) + break + } + + if (searchRegex) { + content = content.replace(searchRegex, replacePart) + } + } + return new TextEncoder().encode(content).buffer + } catch (e) { + console.error(`Error applying regex rules to ${filePath}:`, e) + return fileBuffer + } +} + +/** + * Gets the IndexedDB connection promise, creating it if it doesn't exist. + * @returns {Promise} A promise that resolves with the DB instance. + */ +export function getDb() { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DBN, DB_VERSION) + + request.onupgradeneeded = function (e) { + const db = e.target.result + const transaction = e.target.transaction + + // Create standard stores if they don't exist + if (!db.objectStoreNames.contains(FOLDERS_SN)) { + db.createObjectStore(FOLDERS_SN, { keyPath: "id" }) + } + if (!db.objectStoreNames.contains(RULES_SN)) { + db.createObjectStore(RULES_SN, { keyPath: "id" }) + } + + let fileStore + if (!db.objectStoreNames.contains(FILES_SN)) { + fileStore = db.createObjectStore(FILES_SN, { keyPath: "id", autoIncrement: true }) + } else { + fileStore = transaction.objectStore(FILES_SN) + } + + if (!fileStore.indexNames.contains("lookup")) { + fileStore.createIndex("lookup", "lookupPath", { unique: true }) + } + + if (!db.objectStoreNames.contains("FileChunks")) { + const chunkStore = db.createObjectStore("FileChunks", { keyPath: "id", autoIncrement: true }) + chunkStore.createIndex("by_file", "fileId", { unique: false }) + } + } + + request.onsuccess = e => { + db = e.target.result + db.onversionchange = () => { + console.warn("Database version change detected, closing connection.") + if (db) { + db.close() + } + db = null + dbPromise = null + } + resolve(db) + } + request.onerror = e => reject(e.target.errorCode) + }) + } + return dbPromise +} \ No newline at end of file