From 568e9aa652e55e244aeede6b39e6bc5532567946 Mon Sep 17 00:00:00 2001 From: RKBoss6 Date: Fri, 28 Nov 2025 17:11:14 -0600 Subject: [PATCH 01/13] Track favorited apps in session for pseudo-instant favorite updates Added session tracking for favorited apps and removed unused function. --- js/index.js | 89 +++++++++++++++++------------------------------------ 1 file changed, 28 insertions(+), 61 deletions(-) diff --git a/js/index.js b/js/index.js index 6566bcd..ed9b9a9 100644 --- a/js/index.js +++ b/js/index.js @@ -2,6 +2,7 @@ let appJSON = []; // List of apps and info from apps.json let appSortInfo = {}; // list of data to sort by, from appdates.csv { created, modified } let appCounts = {}; let files = []; // list of files on the Espruimo Device +let appsFavoritedInSession = []; // list of app IDs favourited during this session const DEFAULTSETTINGS = { pretokenise : true, minify : false, // disabled by default due to https://github.com/espruino/BangleApps/pull/355#issuecomment-620124162 @@ -99,45 +100,6 @@ function appJSONLoadedHandler() { }); } -/** - * Extract an app name from a /apps/appname path - * - assumes app names cannot contain periods - * - assumes apps cannot be named "apps" - * - assumes we're in or including the "apps" folder in the href - * Returns the app name string or null if not an app folder. - */ -function extractAppNameFromHref(href) { - if (!href) return null; - - try { - const u = new URL(href); - href = u.pathname; - } catch (e) { - // ignore - just use href as-is - } - // very unlikely, but get rid of query/hash - href = href.split('?')[0].split('#')[0].trim(); - // remove leading/trailing slashes - href = href.replace(/^\/+|\/+$/g, ''); - if (!href) return null; // was just /, throw it out - - const parts = href.split('/').filter(p=>p!=""); - // allow './' prefixes by dropping leading '.' segments - while (parts.length && parts[0] === '.') parts.shift(); - if (parts.length === 0) return null; // skip if it was current dir only - // reject any parent-directory references anywhere - if (parts.some(p => p === '..')) return null; - // prefer an 'apps' segment anywhere in the path; otherwise use first folder - const appsIdx = parts.findIndex(p => p.toLowerCase() === 'apps'); - let candidate; - if (appsIdx >= 0 && appsIdx + 1 < parts.length) candidate = parts[appsIdx + 1]; - else candidate = parts[0]; - if (!candidate) return null; - // if the only thing we found is 'apps', ignore it - if (candidate.toLowerCase() === 'apps') return null; - return candidate; -} - httpGet(Const.APPS_JSON_FILE).then(apps=>{ if (apps.startsWith("---")) { showToast(Const.APPS_JSON_FILE+" still contains Jekyll markup","warning"); @@ -164,16 +126,10 @@ httpGet(Const.APPS_JSON_FILE).then(apps=>{ let xmlDoc = parser.parseFromString(htmlText,"text/html"); appJSON = []; let promises = []; - let appsLoaded = []; htmlToArray(xmlDoc.querySelectorAll("a")).forEach(a=>{ let href = a.getAttribute("href"); - const appName = extractAppNameFromHref(href); - // Skip anything that doesn't look like an app or is an _example_app - if (!appName || appName.startsWith("_") || ["lint_exemptions.js","unknown.png"].includes(appName)) - return; - if (appsLoaded.includes(appName)) return; // avoid duplicates - appsLoaded.push(appName); - let metadataURL = appsURL+appName+"/metadata.json"; + if (!href || href.startsWith("/") || href.startsWith("_") || !href.endsWith("/")) return; + let metadataURL = appsURL+"/"+href+"metadata.json"; console.log(" - Loading "+metadataURL); promises.push(httpGet(metadataURL).then(metadataText=>{ try { @@ -440,7 +396,7 @@ function handleCustomApp(appTemplate) { iframe : iframe, modal : modal, jsFile : "customize.js", - onClose: () => reject(""), // reject to ensure the promise isn't left dangling + onClose: reject, messageHandler : function(event) { let msg = event.data; if (msg.type=="app") { @@ -491,7 +447,7 @@ function handleAppInterface(app) { iframe : iframe, modal : modal, jsFile : "interface.js", - onClose: () => reject(""), // reject to ensure the promise isn't left dangling + // onClose: reject, // we don't need to reject when the window is closed messageHandler : function(event) { // nothing custom needed in here } @@ -503,10 +459,16 @@ function handleAppInterface(app) { function changeAppFavourite(favourite, app) { if (favourite) { SETTINGS.favourites = SETTINGS.favourites.concat([app.id]); + if( appsFavoritedInSession.indexOf(app.id)!=-1) { + // app was favourited earlier in this session + + } } else { SETTINGS.favourites = SETTINGS.favourites.filter(e => e != app.id); } + saveSettings(); + console.log(SETTINGS); refreshLibrary(); refreshMyApps(); } @@ -554,13 +516,19 @@ function getAppHTML(app, appInstalled, forInterface) { let percent=(info.installs / appCounts.installs * 100).toFixed(0); let percentText=percent<1?"Less than 1% of all users":percent+"% of all Bangle.js users"; infoTxt.push(`${info.installs} reported installs (${percentText})`); + } if (info.favourites) { + let percent=(info.favourites / info.installs * 100).toFixed(0); let percentText=percent>100?"More than 100% of installs":percent+"% of installs"; if(!info.installs||info.installs<1) {infoTxt.push(`${info.favourites} users favourited`)} else {infoTxt.push(`${info.favourites} users favourited (${percentText})`)} + appFavourites = info.favourites; + if(appsFavoritedInSession.includes(app.id)) appFavourites += 1; //add one to give the illusion of immediate database changes + + } if (infoTxt.length) versionTitle = `title="${infoTxt.join("\n")}"`; @@ -628,7 +596,7 @@ let chips = Array.from(document.querySelectorAll('.filter-nav .chip')).map(chip let activeSort = ''; -let libraryShowAll = false; // perist whether user chose to view all apps + // Update the sort state to match the current sort value function refreshSort(){ let sortContainer = document.querySelector("#librarycontainer .sort-nav"); @@ -641,7 +609,6 @@ function refreshLibrary(options) { options = options||{}; // options.dontChangeSearchBox : bool -> don't update the value in the search box // options.showAll : bool -> don't restrict the numbers of apps that are shown - if (options.showAll) libraryShowAll = true; // remember expansion choice let panelbody = document.querySelector("#librarycontainer .panel-body"); // Work out what we should be filtering, based on the URL let searchType = ""; // possible values: hash, chip, full, id @@ -763,7 +730,7 @@ function refreshLibrary(options) { } let viewMoreText = ""; - if (!libraryShowAll && visibleApps.length > Const.MAX_APPS_SHOWN) { + if (!options.showAll && visibleApps.length > Const.MAX_APPS_SHOWN) { viewMoreText = `
@@ -825,11 +792,15 @@ function refreshLibrary(options) { icon.classList.add("loading"); updateApp(app); } else if (icon.classList.contains("icon-interface")) { - handleAppInterface(app).catch( err => { - if (err != "") showToast("Failed, "+err, "error"); - }); + handleAppInterface(app); } else if ( button.classList.contains("btn-favourite")) { + //clicked let favourite = SETTINGS.favourites.find(e => e == app.id); + if(!favourite){ + appsFavoritedInSession = appsFavoritedInSession.concat([app.id]); + }else{ + appsFavoritedInSession = appsFavoritedInSession.filter(e => e != app.id); + } changeAppFavourite(!favourite, app); } }); @@ -947,8 +918,7 @@ function customApp(app) { refreshMyApps(); refreshLibrary(); }).catch(err => { - if (err != "") - showToast("Customise failed, "+err, "error"); + showToast("Customise failed, "+err, "error"); refreshMyApps(); refreshLibrary(); }); @@ -1103,10 +1073,7 @@ function refreshMyApps() { // check icon to figure out what we should do if (icon.classList.contains("icon-delete")) removeApp(app); if (icon.classList.contains("icon-refresh")) updateApp(app); - if (icon.classList.contains("icon-interface")) - handleAppInterface(app).catch( err => { - if (err != "") showToast("Failed, "+err, "error"); - }); + if (icon.classList.contains("icon-interface")) handleAppInterface(app); if (icon.classList.contains("icon-favourite")) { let favourite = SETTINGS.favourites.find(e => e == app.id); changeAppFavourite(!favourite, app); From 9bec8efc8208bb29e6a7abf89981eb3cbbb90348 Mon Sep 17 00:00:00 2001 From: RKBoss6 Date: Fri, 28 Nov 2025 17:15:57 -0600 Subject: [PATCH 02/13] Update for latest commits to repo --- js/index.js | 82 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/js/index.js b/js/index.js index ed9b9a9..ae87b8b 100644 --- a/js/index.js +++ b/js/index.js @@ -2,7 +2,7 @@ let appJSON = []; // List of apps and info from apps.json let appSortInfo = {}; // list of data to sort by, from appdates.csv { created, modified } let appCounts = {}; let files = []; // list of files on the Espruimo Device -let appsFavoritedInSession = []; // list of app IDs favourited during this session +let appsFavoritedInSession = []; // list of app IDs favourited during this web session const DEFAULTSETTINGS = { pretokenise : true, minify : false, // disabled by default due to https://github.com/espruino/BangleApps/pull/355#issuecomment-620124162 @@ -100,6 +100,45 @@ function appJSONLoadedHandler() { }); } +/** + * Extract an app name from a /apps/appname path + * - assumes app names cannot contain periods + * - assumes apps cannot be named "apps" + * - assumes we're in or including the "apps" folder in the href + * Returns the app name string or null if not an app folder. + */ +function extractAppNameFromHref(href) { + if (!href) return null; + + try { + const u = new URL(href); + href = u.pathname; + } catch (e) { + // ignore - just use href as-is + } + // very unlikely, but get rid of query/hash + href = href.split('?')[0].split('#')[0].trim(); + // remove leading/trailing slashes + href = href.replace(/^\/+|\/+$/g, ''); + if (!href) return null; // was just /, throw it out + + const parts = href.split('/').filter(p=>p!=""); + // allow './' prefixes by dropping leading '.' segments + while (parts.length && parts[0] === '.') parts.shift(); + if (parts.length === 0) return null; // skip if it was current dir only + // reject any parent-directory references anywhere + if (parts.some(p => p === '..')) return null; + // prefer an 'apps' segment anywhere in the path; otherwise use first folder + const appsIdx = parts.findIndex(p => p.toLowerCase() === 'apps'); + let candidate; + if (appsIdx >= 0 && appsIdx + 1 < parts.length) candidate = parts[appsIdx + 1]; + else candidate = parts[0]; + if (!candidate) return null; + // if the only thing we found is 'apps', ignore it + if (candidate.toLowerCase() === 'apps') return null; + return candidate; +} + httpGet(Const.APPS_JSON_FILE).then(apps=>{ if (apps.startsWith("---")) { showToast(Const.APPS_JSON_FILE+" still contains Jekyll markup","warning"); @@ -126,10 +165,16 @@ httpGet(Const.APPS_JSON_FILE).then(apps=>{ let xmlDoc = parser.parseFromString(htmlText,"text/html"); appJSON = []; let promises = []; + let appsLoaded = []; htmlToArray(xmlDoc.querySelectorAll("a")).forEach(a=>{ let href = a.getAttribute("href"); - if (!href || href.startsWith("/") || href.startsWith("_") || !href.endsWith("/")) return; - let metadataURL = appsURL+"/"+href+"metadata.json"; + const appName = extractAppNameFromHref(href); + // Skip anything that doesn't look like an app or is an _example_app + if (!appName || appName.startsWith("_") || ["lint_exemptions.js","unknown.png"].includes(appName)) + return; + if (appsLoaded.includes(appName)) return; // avoid duplicates + appsLoaded.push(appName); + let metadataURL = appsURL+appName+"/metadata.json"; console.log(" - Loading "+metadataURL); promises.push(httpGet(metadataURL).then(metadataText=>{ try { @@ -396,7 +441,7 @@ function handleCustomApp(appTemplate) { iframe : iframe, modal : modal, jsFile : "customize.js", - onClose: reject, + onClose: () => reject(""), // reject to ensure the promise isn't left dangling messageHandler : function(event) { let msg = event.data; if (msg.type=="app") { @@ -447,7 +492,7 @@ function handleAppInterface(app) { iframe : iframe, modal : modal, jsFile : "interface.js", - // onClose: reject, // we don't need to reject when the window is closed + onClose: () => reject(""), // reject to ensure the promise isn't left dangling messageHandler : function(event) { // nothing custom needed in here } @@ -459,16 +504,10 @@ function handleAppInterface(app) { function changeAppFavourite(favourite, app) { if (favourite) { SETTINGS.favourites = SETTINGS.favourites.concat([app.id]); - if( appsFavoritedInSession.indexOf(app.id)!=-1) { - // app was favourited earlier in this session - - } } else { SETTINGS.favourites = SETTINGS.favourites.filter(e => e != app.id); } - saveSettings(); - console.log(SETTINGS); refreshLibrary(); refreshMyApps(); } @@ -516,19 +555,15 @@ function getAppHTML(app, appInstalled, forInterface) { let percent=(info.installs / appCounts.installs * 100).toFixed(0); let percentText=percent<1?"Less than 1% of all users":percent+"% of all Bangle.js users"; infoTxt.push(`${info.installs} reported installs (${percentText})`); - } if (info.favourites) { - let percent=(info.favourites / info.installs * 100).toFixed(0); let percentText=percent>100?"More than 100% of installs":percent+"% of installs"; if(!info.installs||info.installs<1) {infoTxt.push(`${info.favourites} users favourited`)} else {infoTxt.push(`${info.favourites} users favourited (${percentText})`)} - appFavourites = info.favourites; if(appsFavoritedInSession.includes(app.id)) appFavourites += 1; //add one to give the illusion of immediate database changes - } if (infoTxt.length) versionTitle = `title="${infoTxt.join("\n")}"`; @@ -596,7 +631,7 @@ let chips = Array.from(document.querySelectorAll('.filter-nav .chip')).map(chip let activeSort = ''; - +let libraryShowAll = false; // perist whether user chose to view all apps // Update the sort state to match the current sort value function refreshSort(){ let sortContainer = document.querySelector("#librarycontainer .sort-nav"); @@ -609,6 +644,7 @@ function refreshLibrary(options) { options = options||{}; // options.dontChangeSearchBox : bool -> don't update the value in the search box // options.showAll : bool -> don't restrict the numbers of apps that are shown + if (options.showAll) libraryShowAll = true; // remember expansion choice let panelbody = document.querySelector("#librarycontainer .panel-body"); // Work out what we should be filtering, based on the URL let searchType = ""; // possible values: hash, chip, full, id @@ -730,7 +766,7 @@ function refreshLibrary(options) { } let viewMoreText = ""; - if (!options.showAll && visibleApps.length > Const.MAX_APPS_SHOWN) { + if (!libraryShowAll && visibleApps.length > Const.MAX_APPS_SHOWN) { viewMoreText = `
@@ -792,7 +828,9 @@ function refreshLibrary(options) { icon.classList.add("loading"); updateApp(app); } else if (icon.classList.contains("icon-interface")) { - handleAppInterface(app); + handleAppInterface(app).catch( err => { + if (err != "") showToast("Failed, "+err, "error"); + }); } else if ( button.classList.contains("btn-favourite")) { //clicked let favourite = SETTINGS.favourites.find(e => e == app.id); @@ -918,7 +956,8 @@ function customApp(app) { refreshMyApps(); refreshLibrary(); }).catch(err => { - showToast("Customise failed, "+err, "error"); + if (err != "") + showToast("Customise failed, "+err, "error"); refreshMyApps(); refreshLibrary(); }); @@ -1073,7 +1112,10 @@ function refreshMyApps() { // check icon to figure out what we should do if (icon.classList.contains("icon-delete")) removeApp(app); if (icon.classList.contains("icon-refresh")) updateApp(app); - if (icon.classList.contains("icon-interface")) handleAppInterface(app); + if (icon.classList.contains("icon-interface")) + handleAppInterface(app).catch( err => { + if (err != "") showToast("Failed, "+err, "error"); + }); if (icon.classList.contains("icon-favourite")) { let favourite = SETTINGS.favourites.find(e => e == app.id); changeAppFavourite(!favourite, app); From c37286d56e030a064ff61adb4d12f1b33a17a195 Mon Sep 17 00:00:00 2001 From: RKBoss6 Date: Fri, 28 Nov 2025 17:21:53 -0600 Subject: [PATCH 03/13] remove unnecessary line --- js/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/index.js b/js/index.js index ae87b8b..a656e60 100644 --- a/js/index.js +++ b/js/index.js @@ -563,7 +563,6 @@ function getAppHTML(app, appInstalled, forInterface) { else {infoTxt.push(`${info.favourites} users favourited (${percentText})`)} appFavourites = info.favourites; if(appsFavoritedInSession.includes(app.id)) appFavourites += 1; //add one to give the illusion of immediate database changes - } if (infoTxt.length) versionTitle = `title="${infoTxt.join("\n")}"`; From 4fe384ad90607a651bff88f82ae9589f32f67111 Mon Sep 17 00:00:00 2001 From: RKBoss6 Date: Sun, 30 Nov 2025 21:31:42 -0500 Subject: [PATCH 04/13] Use local storage to remember apps favorited this session, when database updates, remove them from storage --- js/index.js | 2618 ++++++++++++++++++++++++++------------------------- 1 file changed, 1345 insertions(+), 1273 deletions(-) diff --git a/js/index.js b/js/index.js index a656e60..997ad8b 100644 --- a/js/index.js +++ b/js/index.js @@ -2,1381 +2,1444 @@ let appJSON = []; // List of apps and info from apps.json let appSortInfo = {}; // list of data to sort by, from appdates.csv { created, modified } let appCounts = {}; let files = []; // list of files on the Espruimo Device -let appsFavoritedInSession = []; // list of app IDs favourited during this web session const DEFAULTSETTINGS = { - pretokenise : true, - minify : false, // disabled by default due to https://github.com/espruino/BangleApps/pull/355#issuecomment-620124162 - settime : false, // Always update time when we connect - favourites : ["launch"], - language : "", - bleCompat: false, // 20 byte MTU BLE Compatibility mode - sendUsageStats: true, // send usage stats to banglejs.com - alwaysAllowUpdate : false, // Always show "reinstall app" buttonregardless of the version - autoReload: false, // Automatically reload watch after app App Loader actions (removes "Hold button" prompt) - noPackets: false, // Enable File Upload Compatibility mode (disables binary packet upload) + pretokenise : true, + minify : false, // disabled by default due to https://github.com/espruino/BangleApps/pull/355#issuecomment-620124162 + settime : false, // Always update time when we connect + favourites : ["launch"], + appsFavoritedInSession : [], + language : "", + bleCompat: false, // 20 byte MTU BLE Compatibility mode + sendUsageStats: true, // send usage stats to banglejs.com + alwaysAllowUpdate : false, // Always show "reinstall app" button regardless of the version + autoReload: false, // Automatically reload watch after app App Loader actions (removes "Hold button" prompt) + noPackets: false, // Enable File Upload Compatibility mode (disables binary packet upload) }; let SETTINGS = JSON.parse(JSON.stringify(DEFAULTSETTINGS)); // clone + let device = { - id : undefined, // The Espruino device ID of this device, eg. BANGLEJS - version : undefined,// The Espruino firmware version, eg 2v08 - info : undefined, // An entry from DEVICEINFO with information about this device - connected : false, // are we connected via BLE right now? - appsInstalled : [] // list of app {id,version} of installed apps + id : undefined, // The Espruino device ID of this device, eg. BANGLEJS + version : undefined,// The Espruino firmware version, eg 2v08 + info : undefined, // An entry from DEVICEINFO with information about this device + connected : false, // are we connected via BLE right now? + appsInstalled : [] // list of app {id,version} of installed apps }; + + // FOR TESTING ONLY /*let LANGUAGE = { - "//":"German language translations", - "GLOBAL": { - "//":"Translations that apply for all apps", - "Alarm" : "Wecker", - "Hours" : "Stunden", - "Minutes" : "Minuten", - "Enabled" : "Aktiviert", - "Settings" : "Einstellungen" - }, - "alarm": { - "//":"App-specific overrides", - "Alarm" : "Alarm" - } + "//":"German language translations", + "GLOBAL": { + "//":"Translations that apply for all apps", + "Alarm" : "Wecker", + "Hours" : "Stunden", + "Minutes" : "Minuten", + "Enabled" : "Aktiviert", + "Settings" : "Einstellungen" + }, + "alarm": { + "//":"App-specific overrides", + "Alarm" : "Alarm" + } };*/ let LANGUAGE = undefined; + /** Ensure we run transfers one after the other rather that potentially having them overlap if the user clicks around https://github.com/espruino/EspruinoAppLoaderCore/issues/67 */ let currentOperation = Promise.resolve(); + /// Start an operation - calls back function startOperation(options, callback) { - options = options||{}; - if (!options.name) throw new Error("Expecting a name"); - console.log(`=========== Queued Operation ${options.name}`); - return new Promise(resolve => { - currentOperation = currentOperation.then(() => { - console.log(`=========== Starting Operation ${options.name}`); - let promise = callback(); - if (!(promise instanceof Promise)) - throw new Error(`Operation ${options.name} didn't return a promise!`); - return promise; - }).then((result) => { - console.log(`=========== Operation ${options.name} Complete`); - Progress.hide({sticky:true}); - refreshMyApps(); - refreshLibrary(); - resolve(result); - }, (err) => { - console.error(`=========== ERROR during Operation ${options.name}`); - showToast(`${options.name} failed, ${err}`,"error"); - Progress.hide({sticky:true}); - // remove loading indicator - refreshMyApps(); - refreshLibrary(); - resolve(); - }); - }); + options = options||{}; + if (!options.name) throw new Error("Expecting a name"); + console.log(`=========== Queued Operation ${options.name}`); + return new Promise(resolve => { + currentOperation = currentOperation.then(() => { + console.log(`=========== Starting Operation ${options.name}`); + let promise = callback(); + if (!(promise instanceof Promise)) + throw new Error(`Operation ${options.name} didn't return a promise!`); + return promise; + }).then((result) => { + console.log(`=========== Operation ${options.name} Complete`); + Progress.hide({sticky:true}); + refreshMyApps(); + refreshLibrary(); + resolve(result); + }, (err) => { + console.error(`=========== ERROR during Operation ${options.name}`); + showToast(`${options.name} failed, ${err}`,"error"); + Progress.hide({sticky:true}); + // remove loading indicator + refreshMyApps(); + refreshLibrary(); + resolve(); + }); + }); } + function appJSONLoadedHandler() { - appJSON.forEach(app => { - if (app.screenshots) - app.screenshots.forEach(s => { - if (s.url) s.url = "apps/"+app.id+"/"+s.url; - }); - }); - let promise = Promise.resolve(); - if ("undefined" != typeof onAppJSONLoaded) /*global onAppJSONLoaded*/ - promise = promise.then(onAppJSONLoaded); - // finally update what we're showing - promise.then(function() { - refreshLibrary(); - // if ?id=...&readme is in URL, show it - if (window.location.search) { - let searchParams = new URLSearchParams(window.location.search); - if (searchParams.has("id") && searchParams.has("readme")) { - let id = searchParams.get("id").toLowerCase(); - showReadme(null, id); - } - } - }); + appJSON.forEach(app => { + if (app.screenshots) + app.screenshots.forEach(s => { + if (s.url) s.url = "apps/"+app.id+"/"+s.url; + }); + }); + let promise = Promise.resolve(); + if ("undefined" != typeof onAppJSONLoaded) /*global onAppJSONLoaded*/ + promise = promise.then(onAppJSONLoaded); + // finally update what we're showing + promise.then(function() { + refreshLibrary(); + // if ?id=...&readme is in URL, show it + if (window.location.search) { + let searchParams = new URLSearchParams(window.location.search); + if (searchParams.has("id") && searchParams.has("readme")) { + let id = searchParams.get("id").toLowerCase(); + showReadme(null, id); + } + } + }); } + /** - * Extract an app name from a /apps/appname path - * - assumes app names cannot contain periods - * - assumes apps cannot be named "apps" - * - assumes we're in or including the "apps" folder in the href - * Returns the app name string or null if not an app folder. - */ +* Extract an app name from a /apps/appname path +* - assumes app names cannot contain periods +* - assumes apps cannot be named "apps" +* - assumes we're in or including the "apps" folder in the href +* Returns the app name string or null if not an app folder. +*/ function extractAppNameFromHref(href) { - if (!href) return null; - + if (!href) return null; try { - const u = new URL(href); - href = u.pathname; - } catch (e) { - // ignore - just use href as-is - } - // very unlikely, but get rid of query/hash - href = href.split('?')[0].split('#')[0].trim(); - // remove leading/trailing slashes - href = href.replace(/^\/+|\/+$/g, ''); - if (!href) return null; // was just /, throw it out - + const u = new URL(href); + href = u.pathname; + } catch (e) { + // ignore - just use href as-is + } + // very unlikely, but get rid of query/hash + href = href.split('?')[0].split('#')[0].trim(); + // remove leading/trailing slashes + href = href.replace(/^\/+|\/+$/g, ''); + if (!href) return null; // was just /, throw it out const parts = href.split('/').filter(p=>p!=""); - // allow './' prefixes by dropping leading '.' segments - while (parts.length && parts[0] === '.') parts.shift(); - if (parts.length === 0) return null; // skip if it was current dir only - // reject any parent-directory references anywhere - if (parts.some(p => p === '..')) return null; - // prefer an 'apps' segment anywhere in the path; otherwise use first folder - const appsIdx = parts.findIndex(p => p.toLowerCase() === 'apps'); - let candidate; - if (appsIdx >= 0 && appsIdx + 1 < parts.length) candidate = parts[appsIdx + 1]; - else candidate = parts[0]; - if (!candidate) return null; - // if the only thing we found is 'apps', ignore it - if (candidate.toLowerCase() === 'apps') return null; - return candidate; + // allow './' prefixes by dropping leading '.' segments + while (parts.length && parts[0] === '.') parts.shift(); + if (parts.length === 0) return null; // skip if it was current dir only + // reject any parent-directory references anywhere + if (parts.some(p => p === '..')) return null; + // prefer an 'apps' segment anywhere in the path; otherwise use first folder + const appsIdx = parts.findIndex(p => p.toLowerCase() === 'apps'); + let candidate; + if (appsIdx >= 0 && appsIdx + 1 < parts.length) candidate = parts[appsIdx + 1]; + else candidate = parts[0]; + if (!candidate) return null; + // if the only thing we found is 'apps', ignore it + if (candidate.toLowerCase() === 'apps') return null; + return candidate; } + httpGet(Const.APPS_JSON_FILE).then(apps=>{ - if (apps.startsWith("---")) { - showToast(Const.APPS_JSON_FILE+" still contains Jekyll markup","warning"); - throw new Error("Not JSON"); - } - try { - appJSON = JSON.parse(apps); - } catch(e) { - console.log(e); - showToast("App List Corrupted","error"); - } - // fix up the JSON - if (appJSON.length && appJSON[appJSON.length-1]===null) - appJSON.pop(); // remove trailing null added to make auto-generation of apps.json easier - appJSONLoadedHandler(); + if (apps.startsWith("---")) { + showToast(Const.APPS_JSON_FILE+" still contains Jekyll markup","warning"); + throw new Error("Not JSON"); + } + try { + appJSON = JSON.parse(apps); + } catch(e) { + console.log(e); + showToast("App List Corrupted","error"); + } + // fix up the JSON + if (appJSON.length && appJSON[appJSON.length-1]===null) + appJSON.pop(); // remove trailing null added to make auto-generation of apps.json easier + appJSONLoadedHandler(); }).catch(error=>{ - console.warn("APPS FILE NOT FOUND "+Const.APPS_JSON_FILE); - console.log("Attempting search - SLOW"); - let baseurl = window.location.href.replace(/\/[^/]*$/,"/"); - let appsURL = baseurl+"apps/"; - httpGet(appsURL).then(htmlText=>{ - showToast(Const.APPS_JSON_FILE+" can't be read, scanning 'apps' folder for apps","warning"); - let parser = new DOMParser(); - let xmlDoc = parser.parseFromString(htmlText,"text/html"); - appJSON = []; - let promises = []; - let appsLoaded = []; - htmlToArray(xmlDoc.querySelectorAll("a")).forEach(a=>{ - let href = a.getAttribute("href"); - const appName = extractAppNameFromHref(href); - // Skip anything that doesn't look like an app or is an _example_app - if (!appName || appName.startsWith("_") || ["lint_exemptions.js","unknown.png"].includes(appName)) - return; - if (appsLoaded.includes(appName)) return; // avoid duplicates - appsLoaded.push(appName); - let metadataURL = appsURL+appName+"/metadata.json"; - console.log(" - Loading "+metadataURL); - promises.push(httpGet(metadataURL).then(metadataText=>{ - try { - appJSON.push(JSON.parse(metadataText)); - } catch(e) { - console.log(e); - showToast("App "+href+" metadata.json Corrupted","error"); - } - }).catch(err=>{ - console.warn("App folder "+href+" has no metadata"); - })); - }); - Promise.all(promises).then(appJSONLoadedHandler); - }).catch(err=>{ - showToast(Const.APPS_JSON_FILE+" doesn't exist and cannot do directory listing on this server","error"); - }); + console.warn("APPS FILE NOT FOUND "+Const.APPS_JSON_FILE); + console.log("Attempting search - SLOW"); + let baseurl = window.location.href.replace(/\/[^/]*$/,"/"); + let appsURL = baseurl+"apps/"; + httpGet(appsURL).then(htmlText=>{ + showToast(Const.APPS_JSON_FILE+" can't be read, scanning 'apps' folder for apps","warning"); + let parser = new DOMParser(); + let xmlDoc = parser.parseFromString(htmlText,"text/html"); + appJSON = []; + let promises = []; + let appsLoaded = []; + htmlToArray(xmlDoc.querySelectorAll("a")).forEach(a=>{ + let href = a.getAttribute("href"); + const appName = extractAppNameFromHref(href); + // Skip anything that doesn't look like an app or is an _example_app + if (!appName || appName.startsWith("_") || ["lint_exemptions.js","unknown.png"].includes(appName)) + return; + if (appsLoaded.includes(appName)) return; // avoid duplicates + appsLoaded.push(appName); + let metadataURL = appsURL+appName+"/metadata.json"; + console.log(" - Loading "+metadataURL); + promises.push(httpGet(metadataURL).then(metadataText=>{ + try { + appJSON.push(JSON.parse(metadataText)); + } catch(e) { + console.log(e); + showToast("App "+href+" metadata.json Corrupted","error"); + } + }).catch(err=>{ + console.warn("App folder "+href+" has no metadata"); + })); + }); + Promise.all(promises).then(appJSONLoadedHandler); + }).catch(err=>{ + showToast(Const.APPS_JSON_FILE+" doesn't exist and cannot do directory listing on this server","error"); + }); }); + if (Const.APP_DATES_CSV) httpGet(Const.APP_DATES_CSV).then(csv=>{ - // Firefox Date.parse doesn't understand our appdates.csv format - function parseDate(datestamp) { - // example: "2022-01-13 09:21:33 +0000" - const [date, time, tz] = datestamp.split(" "), - [year, month, day] = date.split("-"), - [hours, minutes, seconds] = time.split(":"); - return new Date(year, month-1, day, hours, minutes, seconds); - } - csv.split("\n").forEach(line=>{ - let l = line.split(","); - if (l.length<3) return; - let key = l[0]; - if (appSortInfo[key]==undefined) - appSortInfo[key] = {}; - appSortInfo[key].created = parseDate(l[1]); - appSortInfo[key].modified = parseDate(l[2]); - }); - document.querySelector(".sort-nav").classList.remove("hidden"); - document.querySelector(".sort-nav label[sortid='created']").classList.remove("hidden"); - document.querySelector(".sort-nav label[sortid='modified']").classList.remove("hidden"); + // Firefox Date.parse doesn't understand our appdates.csv format + function parseDate(datestamp) { + // example: "2022-01-13 09:21:33 +0000" + const [date, time, tz] = datestamp.split(" "), + [year, month, day] = date.split("-"), + [hours, minutes, seconds] = time.split(":"); + return new Date(year, month-1, day, hours, minutes, seconds); + } + csv.split("\n").forEach(line=>{ + let l = line.split(","); + if (l.length<3) return; + let key = l[0]; + if (appSortInfo[key]==undefined) + appSortInfo[key] = {}; + appSortInfo[key].created = parseDate(l[1]); + appSortInfo[key].modified = parseDate(l[2]); + }); + document.querySelector(".sort-nav").classList.remove("hidden"); + document.querySelector(".sort-nav label[sortid='created']").classList.remove("hidden"); + document.querySelector(".sort-nav label[sortid='modified']").classList.remove("hidden"); }).catch(err=>{ - console.log("No recent.csv - app sort disabled"); + console.log("No recent.csv - app sort disabled"); }); + if (Const.APP_USAGE_JSON) httpGet(Const.APP_USAGE_JSON).then(jsonTxt=>{ - let json; - try { - json = JSON.parse(jsonTxt); - } catch (e) { - console.warn("App usage JSON at "+Const.APP_USAGE_JSON+" couldn't be parsed"); - return; - } - appCounts.favs = 0; - Object.keys(json.fav).forEach(key =>{ - if (appSortInfo[key]==undefined) - appSortInfo[key] = {}; - if (json.fav[key] > appCounts.favs) appCounts.favs = json.fav[key]; - appSortInfo[key].favourites = json.fav[key]; - }); - appCounts.installs = 0; - Object.keys(json.app).forEach(key =>{ - if (appSortInfo[key]==undefined) - appSortInfo[key] = {}; - if (json.app[key] > appCounts.installs) appCounts.installs = json.app[key]; - appSortInfo[key].installs = json.app[key]; - }); - document.querySelector(".sort-nav").classList.remove("hidden"); - document.querySelector(".sort-nav label[sortid='installs']").classList.remove("hidden"); - document.querySelector(".sort-nav label[sortid='favourites']").classList.remove("hidden"); - // actually set to sort on favourites - if (activeSort != "favourites") { - activeSort = "favourites"; - refreshSort(); - refreshLibrary(); - } + let json; + try { + json = JSON.parse(jsonTxt); + } catch (e) { + console.warn("App usage JSON at "+Const.APP_USAGE_JSON+" couldn't be parsed"); + return; + } + appCounts.favs = 0; + Object.keys(json.fav).forEach(key =>{ + if (appSortInfo[key]==undefined) + appSortInfo[key] = {}; + if (json.fav[key] > appCounts.favs) appCounts.favs = json.fav[key]; + appSortInfo[key].favourites = json.fav[key]; + }); + appCounts.installs = 0; + Object.keys(json.app).forEach(key =>{ + if (appSortInfo[key]==undefined) + appSortInfo[key] = {}; + if (json.app[key] > appCounts.installs) appCounts.installs = json.app[key]; + appSortInfo[key].installs = json.app[key]; + }); + document.querySelector(".sort-nav").classList.remove("hidden"); + document.querySelector(".sort-nav label[sortid='installs']").classList.remove("hidden"); + document.querySelector(".sort-nav label[sortid='favourites']").classList.remove("hidden"); + // actually set to sort on favourites + if (activeSort != "favourites") { + activeSort = "favourites"; + refreshSort(); + refreshLibrary(); + } }).catch(err=>{ - console.log("No recent.csv - app sort disabled"); + console.log("No recent.csv - app sort disabled"); }); + // =========================================== Top Navigation function showChangeLog(appid, installedVersion) { - let app = appNameToApp(appid); - function show(contents) { - let shouldEscapeHtml = true; - if (contents && installedVersion) { - let lines = contents.split("\n"); - for(let i = 0; i < lines.length; i++) { - let line = lines[i]; - if (line.startsWith(installedVersion)) { - line = '' + line; - lines[i] = line; - } - } - contents = lines.join("
"); - shouldEscapeHtml = false; - } - showPrompt(app.name+" ChangeLog",contents,{ok:true}, shouldEscapeHtml).catch(()=>{}); - if (installedVersion) { - let elem = document.getElementById(installedVersion); - if (elem) elem.scrollIntoView(); - } - } - httpGet(`apps/${appid}/ChangeLog`). - then(show).catch(()=>show("No Change Log available")); + let app = appNameToApp(appid); + function show(contents) { + let shouldEscapeHtml = true; + if (contents && installedVersion) { + let lines = contents.split("\n"); + for(let i = 0; i < lines.length; i++) { + let line = lines[i]; + if (line.startsWith(installedVersion)) { + line = '' + line; + lines[i] = line; + } + } + contents = lines.join("
"); + shouldEscapeHtml = false; + } + showPrompt(app.name+" ChangeLog",contents,{ok:true}, shouldEscapeHtml).catch(()=>{}); + if (installedVersion) { + let elem = document.getElementById(installedVersion); + if (elem) elem.scrollIntoView(); + } + } + httpGet(`apps/${appid}/ChangeLog`). + then(show).catch(()=>show("No Change Log available")); } function showReadme(event, appid) { - if (event) event.preventDefault(); - let app = appNameToApp(appid); - let appPath = `apps/${appid}/`; - let markedOptions = { baseUrl : appPath }; - function show(contents) { - if (!contents) return; - let footerText = `(Link)`; - showPrompt(app.name + " Documentation", marked(contents, markedOptions), {ok: true, footer: footerText}, false).catch(() => {}); - } - httpGet(appPath+app.readme).then(show).catch(()=>show("Failed to load README.")); + if (event) event.preventDefault(); + let app = appNameToApp(appid); + let appPath = `apps/${appid}/`; + let markedOptions = { baseUrl : appPath }; + function show(contents) { + if (!contents) return; + let footerText = `(Link)`; + showPrompt(app.name + " Documentation", marked(contents, markedOptions), {ok: true, footer: footerText}, false).catch(() => {}); + } + httpGet(appPath+app.readme).then(show).catch(()=>show("Failed to load README.")); } function getAppDescription(app) { - let appPath = `apps/${app.id}/`; - let markedOptions = { baseUrl : appPath }; - return marked(app.description, markedOptions); + let appPath = `apps/${app.id}/`; + let markedOptions = { baseUrl : appPath }; + return marked(app.description, markedOptions); } + /** Setup IFRAME callbacks for handleCustomApp and handleInterface */ function iframeSetup(options) { - let iframe = options.iframe; - let modal = options.modal; - document.body.append(modal); - htmlToArray(modal.getElementsByTagName("a")).forEach(button => { - button.addEventListener("click",event => { - event.preventDefault(); - modal.remove(); - if (options.onClose) options.onClose("Window closed"); - }); - }); - // when iframe is loaded, call 'onInit' with info about the device - iframe.addEventListener("load", function() { - console.log("IFRAME loaded"); - /* if we get a message from the iframe (eg asking to send data to Puck), handle it - otherwise pass to messageHandler because handleCustomApp may want to handle it */ - iframe.contentWindow.addEventListener("message",function(event) { - let msg = event.data; - if (msg.type=="close") { - modal.remove(); - if (options.onClose) options.onClose("Window closed"); - } else if (msg.type=="eval") { - Comms.eval(msg.data).then(function(result) { - iframe.contentWindow.postMessage({ - type : "evalrsp", - data : result, - id : msg.id - }); - }, function(err) { - showToast("Eval from app loader failed:\n"+err,"error"); - console.warn(err); - }); - } else if (msg.type=="write") { - Comms.write(msg.data).then(function(result) { - iframe.contentWindow.postMessage({ - type : "writersp", - data : result, - id : msg.id - }); - }, function(err) { - showToast("File Write from app loader failed:\n"+err,"error"); - console.warn(err); - }); - } else if (msg.type=="readstoragefile") { - Comms.readStorageFile(msg.filename).then(function(result) { - iframe.contentWindow.postMessage({ - type : "readstoragefilersp", - data : result, - id : msg.id - }); - }, function(err) { - showToast("StorageFile Read from app loader failed:\n"+err,"error"); - console.warn(err); - }); - } else if (msg.type=="readstorage") { - Comms.readFile(msg.filename).then(function(result) { - iframe.contentWindow.postMessage({ - type : "readstoragersp", - data : result, - id : msg.id - }); - }, function(err) { - showToast("File Read from app loader failed:\n"+err,"error"); - console.warn(err); - }); - } else if (msg.type=="readstoragejson") { - Comms.readFile(msg.filename).then(function(result) { - iframe.contentWindow.postMessage({ - type : "readstoragejsonrsp", - data : Utils.parseRJSON(result), - id : msg.id - }); - }, function(err) { - showToast("JSON File Read from app loader failed:\n"+err,"error"); - console.warn(err); - }); - } else if (msg.type=="writestorage") { - Progress.show({title:`Uploading ${JSON.stringify(msg.filename)}`,sticky:true}); - Comms.writeFile(msg.filename, msg.data).then(function() { - Progress.hide({sticky:true}); - iframe.contentWindow.postMessage({ - type : "writestoragersp", - id : msg.id - }); - }, function(err) { - showToast("StorageFile Write from app loader failed:\n"+err,"error"); - console.warn(err); - }); - } else if (options.messageHandler) options.messageHandler(event); - }, false); - // send the 'init' message - iframe.contentWindow.postMessage({ - type: "init", - expectedInterface: options.jsFile, - data: device - },"*"); - // Push any data received back through to IFRAME - if (Comms.isConnected()) { - console.log("Adding Comms.on('data') handler for iframe"); - Comms.on("data", data => { - if (!iframe.contentWindow) { - // if no frame, disable - console.log("Removing Comms.on('data') handler"); - Comms.on("data"); - return; - } - iframe.contentWindow.postMessage({ - type : "recvdata", - data : data - }); - }); - } - }, false); + let iframe = options.iframe; + let modal = options.modal; + document.body.append(modal); + htmlToArray(modal.getElementsByTagName("a")).forEach(button => { + button.addEventListener("click",event => { + event.preventDefault(); + modal.remove(); + if (options.onClose) options.onClose("Window closed"); + }); + }); + // when iframe is loaded, call 'onInit' with info about the device + iframe.addEventListener("load", function() { + console.log("IFRAME loaded"); + /* if we get a message from the iframe (eg asking to send data to Puck), handle it + otherwise pass to messageHandler because handleCustomApp may want to handle it */ + iframe.contentWindow.addEventListener("message",function(event) { + let msg = event.data; + if (msg.type=="close") { + modal.remove(); + if (options.onClose) options.onClose("Window closed"); + } else if (msg.type=="eval") { + Comms.eval(msg.data).then(function(result) { + iframe.contentWindow.postMessage({ + type : "evalrsp", + data : result, + id : msg.id + }); + }, function(err) { + showToast("Eval from app loader failed:\n"+err,"error"); + console.warn(err); + }); + } else if (msg.type=="write") { + Comms.write(msg.data).then(function(result) { + iframe.contentWindow.postMessage({ + type : "writersp", + data : result, + id : msg.id + }); + }, function(err) { + showToast("File Write from app loader failed:\n"+err,"error"); + console.warn(err); + }); + } else if (msg.type=="readstoragefile") { + Comms.readStorageFile(msg.filename).then(function(result) { + iframe.contentWindow.postMessage({ + type : "readstoragefilersp", + data : result, + id : msg.id + }); + }, function(err) { + showToast("StorageFile Read from app loader failed:\n"+err,"error"); + console.warn(err); + }); + } else if (msg.type=="readstorage") { + Comms.readFile(msg.filename).then(function(result) { + iframe.contentWindow.postMessage({ + type : "readstoragersp", + data : result, + id : msg.id + }); + }, function(err) { + showToast("File Read from app loader failed:\n"+err,"error"); + console.warn(err); + }); + } else if (msg.type=="readstoragejson") { + Comms.readFile(msg.filename).then(function(result) { + iframe.contentWindow.postMessage({ + type : "readstoragejsonrsp", + data : Utils.parseRJSON(result), + id : msg.id + }); + }, function(err) { + showToast("JSON File Read from app loader failed:\n"+err,"error"); + console.warn(err); + }); + } else if (msg.type=="writestorage") { + Progress.show({title:`Uploading ${JSON.stringify(msg.filename)}`,sticky:true}); + Comms.writeFile(msg.filename, msg.data).then(function() { + Progress.hide({sticky:true}); + iframe.contentWindow.postMessage({ + type : "writestoragersp", + id : msg.id + }); + }, function(err) { + showToast("StorageFile Write from app loader failed:\n"+err,"error"); + console.warn(err); + }); + } else if (options.messageHandler) options.messageHandler(event); + }, false); + // send the 'init' message + iframe.contentWindow.postMessage({ + type: "init", + expectedInterface: options.jsFile, + data: device + },"*"); + // Push any data received back through to IFRAME + if (Comms.isConnected()) { + console.log("Adding Comms.on('data') handler for iframe"); + Comms.on("data", data => { + if (!iframe.contentWindow) { + // if no frame, disable + console.log("Removing Comms.on('data') handler"); + Comms.on("data"); + return; + } + iframe.contentWindow.postMessage({ + type : "recvdata", + data : data + }); + }); + } + }, false); } + /** Create window for app customiser */ function handleCustomApp(appTemplate) { - // Pops up an IFRAME that allows an app to be customised - if (!appTemplate.custom) throw new Error("App doesn't have custom HTML"); - // if it needs a connection, do that first - if (appTemplate.customConnect && !device.connected) - return getInstalledApps().then(() => handleCustomApp(appTemplate)); - // otherwise continue - return new Promise((resolve,reject) => { - let modal = htmlElement(`