From c38c9b21efc9da4e86b7cda599b22d0613206626 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sun, 31 May 2026 13:45:45 -0700 Subject: [PATCH 01/22] QA fixes and full test suite across all scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alexa To Reminders Access: fix makeLogin always returning false after WebView login, await main() before Script.complete(), null-guard item.value before split, move Reminder.all() outside loop, remove debug log calls - MEE6 LeaderBoard Info: fix async forEach → for...of with await, single regex.exec call, break after first player match, remove dead WebView block, declare dir with let - RH-Downloads: parseInt for delta math, name null-guard, declare ab/dir with let - ScriptBackup: recursive backup including subdirectories, skip ScriptBackup folder to avoid re-copying old backups - Twitter Widget: fix checkUpdates===true strict comparison, remove hardcoded bearer token - Weather Overview: fix now.getTime() parentheses, ms→seconds for night detection, amtLabel scope, Script.setWidget before complete, and several other rendering fixes - Both Calendar Indicators: fix async splice→filter, isCalEvent guard ordering for item.calendar.title, multipleAllDay cross-month fix, item.endDate NaN guard for reminders, version bump to 2.8, remove duplicate Scriptable headers and dead const backgroundColor - tests/: full unit test suite (148 tests) covering all scripts with injectable mocks for all Scriptable APIs Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 17 +- MEE6 LeaderBoard Info.js | 29 +- RH-Downloads.js | 13 +- ScriptBackup.js | 30 +- Twitter Widget.js | 7 +- Upcoming Calendar Indicator.js | 24 +- .../Upcoming Calendar Indicator.js | 36 +- Weather Overview/Weather Overview.js | 59 ++- tests/alexa-reminders.test.js | 482 ++++++++++++++++++ tests/mee6.test.js | 171 +++++++ tests/rh-downloads.test.js | 179 +++++++ tests/runner.js | 69 +++ tests/scriptable-mocks.js | 188 +++++++ tests/twitter-widget.test.js | 127 +++++ tests/upcoming-calendar.test.js | 243 +++++++++ tests/weather-overview.test.js | 197 +++++++ 16 files changed, 1761 insertions(+), 110 deletions(-) create mode 100644 tests/alexa-reminders.test.js create mode 100644 tests/mee6.test.js create mode 100644 tests/rh-downloads.test.js create mode 100644 tests/runner.js create mode 100644 tests/scriptable-mocks.js create mode 100644 tests/twitter-widget.test.js create mode 100644 tests/upcoming-calendar.test.js create mode 100644 tests/weather-overview.test.js diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 5e341b2..4a6a618 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -55,7 +55,7 @@ const withVar = "with" //withoutVar below needs to be set to your language's version of the word 'without' const withoutVar = "without" -main(); +await main(); Script.complete(); async function checkIfUserIsAuthenticated() { @@ -63,7 +63,6 @@ async function checkIfUserIsAuthenticated() { const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const request = new Request(url); await request.load(); -log(request.response.statusCode) if (request.response.statusCode === 401 || request.response.statusCode === 403) { return false; } @@ -84,9 +83,9 @@ async function makeLogin() { const html = await webView.getHTML(); if (html.includes(signInKey)) { await webView.present(false); - return false; - } - + return await checkIfUserIsAuthenticated(); + } + return true; } catch (error) { console.error(error); @@ -97,11 +96,9 @@ async function makeLogin() { async function synchronizeReminders() { try { const reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); -log(reminderCalendar) const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; const json = await new Request(url).loadJSON() - log(json) // Find the list with listType === "SHOPPING_LIST" and ignore other lists let listItems = []; @@ -129,7 +126,12 @@ log(reminderCalendar) return; } + const allReminders = await Reminder.all([reminderCalendar]); for (const item of listItems) { + if (!item.value) { + console.error(`Skipping Alexa list item with missing value: ${JSON.stringify(item)}`); + continue; + } const reminderTitle = item.value.split(' ').map(word => { if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) { return word.toLowerCase(); @@ -137,7 +139,6 @@ log(reminderCalendar) return word.charAt(0).toUpperCase() + word.slice(1); } }).join(' '); - const allReminders = await Reminder.all([reminderCalendar]); const incompleteReminders = allReminders.filter(reminder => !reminder.isCompleted); const reminderExists = incompleteReminders.some(reminder => reminder.title === reminderTitle); diff --git a/MEE6 LeaderBoard Info.js b/MEE6 LeaderBoard Info.js index 9eef1fd..359ce4d 100644 --- a/MEE6 LeaderBoard Info.js +++ b/MEE6 LeaderBoard Info.js @@ -39,7 +39,7 @@ let fm = FileManager.iCloud() log(config.runsInWidget) let b, urlBase = 'https://mee6.xyz/api/plugins/levels/leaderboard/' -dir=fm.documentsDirectory() +let dir = fm.documentsDirectory() let path = dir+"/MEE6.json" log(path) if(!fm.fileExists(path)){ @@ -105,7 +105,6 @@ if (uC.version != version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/MEE6%20LeaderBoard%20Info.js") @@ -125,11 +124,6 @@ End Update Check get data from current url item ---------------------*/ -let wv = new WebView() -await wv.loadURL(url) -wv.waitForLoad() -let html = await wv.getHTML() - /*--------------------- parse the results from the leaderboard to find your username ---------------------*/ @@ -140,10 +134,17 @@ json = await r.loadJSON() let str = json.players log(json) log(json.guild.name) -let rank,xp,level,count,id,avatar,progress,nextLevel -str.forEach(f) +let rank,xp,level,count,id,avatar,progress,nextLevel,userId +for (const [index, inp] of str.entries()) { + await f(inp, index) + if (rank !== undefined) break +} log(rank) +if (rank === undefined) { + throw new Error(`User "${b.uName}" was not found in the leaderboard. Check your username in the MEE6.json settings file.`) +} + /*--------------------- setup the widget ---------------------*/ @@ -179,9 +180,6 @@ log("file exists, loading now from iCloud Drive...") } pimg = fm.readImage(dir+'/'+avatar+'.png') -let addIm = w.addImage(pimg) -addIm.cornerRadius = 30 -addIm.centerAlignImage() rank=rank.toString() xp=xp.toString() level=level.toString() @@ -222,10 +220,11 @@ function fonter(text,size){ async function f(inp,index){ let h = JSON.stringify(inp) - if(regex.exec(h)) + const match = regex.exec(h) + if(match) { - log(regex.exec(h)) - let cc = JSON.parse(regex.exec(h)) + log(match) + let cc = JSON.parse(match[0]) log(cc) rank = index+1 xp = cc.xp diff --git a/RH-Downloads.js b/RH-Downloads.js index ea55096..d193441 100644 --- a/RH-Downloads.js +++ b/RH-Downloads.js @@ -41,7 +41,6 @@ if (uC.version != version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/RH-Downloads.js") @@ -79,8 +78,8 @@ ff.dateFormat = 'd-MMM H:mm' let frmt = ff.string(now) log(frmt) -ab = FileManager.iCloud() -dir=ab.documentsDirectory() +let ab = FileManager.iCloud() +let dir = ab.documentsDirectory() let path = dir+"/RhLastUpd.json" log(path) let file @@ -91,7 +90,6 @@ if (!ab.fileExists(path)) file={} // file.date = now.getDate() file.index = -1 - ab.write(path, Data.fromString(JSON.stringify(file))) ab.writeString(path, JSON.stringify(file)) } //parse the existing file or the newly created file @@ -176,6 +174,9 @@ if file[name] does not exist, create it ##### */ +if (!name) { + throw new Error("Could not determine page name from URL. The site HTML may have changed.") +} if (!file[name]) { file[name] = { @@ -205,7 +206,7 @@ if (file[name].date != now.getDate()) } // calculate difference between new RH count, file download count and the previous delta value. this shows the change in downloads throughout the current day - diff = (str - file[name].downloads) + file[name].dayDelta + diff = (parseInt(str, 10) - parseInt(file[name].downloads, 10)) + file[name].dayDelta /* ##### @@ -252,7 +253,7 @@ ab.writeString(path, JSON.stringify(file)) sub.centerAlignText() // add downloads to widget - const titlew = w.addText(await str) + const titlew = w.addText(str) titlew.textColor = Color.blue() titlew.font = Font.boldSystemFont(13) titlew.centerAlignText() diff --git a/ScriptBackup.js b/ScriptBackup.js index 217878f..23f1dfb 100644 --- a/ScriptBackup.js +++ b/ScriptBackup.js @@ -32,18 +32,15 @@ const newDirName = `${dir}${backupTo}` ab.createDirectory(newDirName,true) -let a = ab.listContents(dir) - //provide a container for the script count let count = 0 -//for each item found in the directory, perform myFunction -a.forEach(myFunction) +backupDirectory(dir, newDirName) let aa = new Alert() aa.addAction("OK") aa.title = "Script Backup" aa.message = `All Done!\n${count} scripts backed up to\n${backupTo}` -aa.present() +await aa.present() //end of script Script.complete() @@ -51,12 +48,19 @@ Script.complete() Begin Functions */ -function myFunction(item, index){ - var ext = (ab.fileExtension(dir+"/"+item)) - if (ext == "js") - { - let file = ab.read(dir+"/"+item) - ab.write(newDirName+"/"+item, file) - count++ - } +function backupDirectory(sourceDir, targetDir) { + const items = ab.listContents(sourceDir) + items.forEach(item => { + const sourcePath = sourceDir + '/' + item + const ext = ab.fileExtension(sourcePath) + if (ext === 'js') { + const file = ab.read(sourcePath) + ab.write(targetDir + '/' + item, file) + count++ + } else if (ab.isDirectory(sourcePath) && item !== bDirName) { + const subTarget = targetDir + '/' + item + ab.createDirectory(subTarget, true) + backupDirectory(sourcePath, subTarget) + } + }) } \ No newline at end of file diff --git a/Twitter Widget.js b/Twitter Widget.js index 9c5ac73..28afb92 100644 --- a/Twitter Widget.js +++ b/Twitter Widget.js @@ -130,7 +130,7 @@ End Settings */ // run the updateCheck() function to see if there are any updates available for the script -let needUpdate = (checkUpdates="true")?await updateCheck(1.4):false +let needUpdate = (checkUpdates===true)?await updateCheck(1.4):false const twImgB64 = twit() let url,w = new ListWidget() if (args.widgetParameter){ @@ -202,8 +202,8 @@ Begin Functions $$$$$$$$$$$$$$$ */ async function apiCall(handle){ - let toke = Data.fromBase64String('QUFBQUFBQUFBQUFBQUFBQUFBQUFBTUdhS1FFQUFBQUExcDh0UmthNHJVdkF1a3VGRHJBNGxyU2dJNkklM0R5eHBsRnBoSFMxY2hOcTJXT1BzckNjWEFGRU5DbmtmMmZ6RE5reWtGdFBqZkpLSkFKRg==') - let bearer = toke.toRawString()/*old twitter bearer token -- no longer works*///'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA' + // Replace the empty string below with your own Twitter/X bearer token + let bearer = '' let firstUrl = 'https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name='+handle+'&count=50&exclude_replies=true&include_rts='+rtsOn let r = new Request(firstUrl) r.method='GET' @@ -340,7 +340,6 @@ async function updateCheck(version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/Twitter%20Widget.js") diff --git a/Upcoming Calendar Indicator.js b/Upcoming Calendar Indicator.js index 19694dc..477368e 100644 --- a/Upcoming Calendar Indicator.js +++ b/Upcoming Calendar Indicator.js @@ -1,15 +1,12 @@ // Variables used by Scriptable. // These must be at the very top of the file. Do not edit. // icon-color: light-gray; icon-glyph: calendar-alt; -// Variables used by Scriptable. -// These must be at the very top of the file. Do not edit. -// icon-color: light-gray; icon-glyph: calendar-alt; let fm = FileManager.iCloud() let scriptPath = fm.documentsDirectory()+'/UpcomingIndicator/' let settingsPath = scriptPath+'settings.json' const reRun = URLScheme.forRunningScript() if(!fm.fileExists(scriptPath))fm.createDirectory(scriptPath, false) -let needUpdated = await updateCheck(2.2) +let needUpdated = await updateCheck(2.8) //log(needUpdated) /*-------------------------- |------version notes------ @@ -92,7 +89,6 @@ const useBackgroundColor = settings.useBackgroundColor //backgroundColor below is setup as darkGray by default but can be changed to hex as well -if(settings.useBackgroundColor){const backgroundColor = new Color(settings.backgroundColor)} //shows the dates before and after the current month and let showDatesBeforeAfter = settings.showDatesBeforeAfter @@ -550,8 +546,8 @@ async function setup(full){ if (!('heatMapMax' in settings)){ let heatMax = new Alert() - heatMax.title = 'heatMapColor Setup' - heatMax.message = 'What color would you like to use for the heat map in the month view?' + heatMax.title = 'heatMapMax Setup' + heatMax.message = 'What is the maximum number of completed reminders per day to show as full heat map intensity?' heatMax.addAction('1') heatMax.addAction('2') heatMax.addAction('3') @@ -710,8 +706,8 @@ async function createWidget() { sun = 0 } //textColor="" - if(i==sat)textColor=saturdayColor - if(i==sun)textColor=sundayColor + if(i==sat && useSaturdayColor)textColor=saturdayColor + if(i==sun && useSundayColor)textColor=sundayColor addWidgetTextLine(dateStackUp, `${month[i][j]}`, { @@ -755,7 +751,7 @@ if(useBaseTextColor)tColor=Color.dynamic(new Color(baseTextColorLight), new Colo }else{ //start reminder list check - if (remList&&(!prevMonth&&!nextMonth)){ + if (heatMapEnabled && remList&&(!prevMonth&&!nextMonth)){ let list = await Calendar.forRemindersByTitle(remList) let rem = await Reminder.completedBetween(st, fn, [list]) let ratio = rem.length/heatMapMax @@ -1053,9 +1049,9 @@ log(item.identifier) } // log(item.startDate+'\n'+isCalEvent?item.endDate:'') // log(item) - if(cal.includes(item.calendar.title) || !isCalEvent) + if(!isCalEvent || cal.includes(item.calendar.title)) { - indexed+=1 + indexed+=1 if(!allowDynamicSpacing)eventCounter=null switch (eventCounter) { case 1: @@ -1111,7 +1107,8 @@ if(useBaseTextColor)when.textColor=Color.dynamic(new Color(baseTextColorLight), dF.dateFormat='EEE' let eee = dF.string(dd) let dt = eee+' '+ddd+' ' - let multipleAllDay = (item.isAllDay && (new Date(item.startDate).getDate() != new Date(item.endDate).getDate())) + const _s = new Date(item.startDate), _e = new Date(item.endDate) + let multipleAllDay = (item.isAllDay && (_s.getFullYear() !== _e.getFullYear() || _s.getMonth() !== _e.getMonth() || _s.getDate() !== _e.getDate())) if(multipleAllDay){ dF.dateFormat='EEE MMM d' @@ -1186,7 +1183,6 @@ async function updateCheck(version){ upd.title="Server Version Available" upd.addAction("OK") upd.addDestructiveAction("Later") - upd.add upd.message="Changes:\n"+uC.notes+"\n\nPress OK to get the update from GitHub" if (await upd.present()==0){ Safari.open("https://raw.githubusercontent.com/mvan231/Scriptable/main/Upcoming%20Calendar%20Indicator/Upcoming%20Calendar%20Indicator.js") diff --git a/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js b/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js index 3e60fb5..d59bcf7 100644 --- a/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js +++ b/Upcoming Calendar Indicator/Upcoming Calendar Indicator.js @@ -1,8 +1,5 @@ // Variables used by Scriptable. // These must be at the very top of the file. Do not edit. -// icon-color: green; icon-glyph: magic; -// Variables used by Scriptable. -// These must be at the very top of the file. Do not edit. // icon-color: deep-green; icon-glyph: calendar-alt; let fm = FileManager.iCloud() let scriptPath = fm.documentsDirectory()+'/UpcomingIndicator/' @@ -108,7 +105,6 @@ const useBackgroundColor = settings.useBackgroundColor //backgroundColor below is setup as darkGray by default but can be changed to hex as well -if(settings.useBackgroundColor){const backgroundColor = new Color(settings.backgroundColor)} //shows the dates before and after the current month and let showDatesBeforeAfter = settings.showDatesBeforeAfter @@ -575,8 +571,8 @@ async function setup(full){ if (!('heatMapMax' in settings)){ let heatMax = new Alert() - heatMax.title = 'heatMapColor Setup' - heatMax.message = 'What color would you like to use for the heat map in the month view?' + heatMax.title = 'heatMapMax Setup' + heatMax.message = 'What is the maximum number of completed reminders per day to show as full heat map intensity?' heatMax.addAction('1') heatMax.addAction('2') heatMax.addAction('3') @@ -732,8 +728,8 @@ async function createWidget() { sat = 6 sun = 0 } - if(i==sat)textColor=saturdayColor - if(i==sun)textColor=sundayColor + if(i==sat && useSaturdayColor)textColor=saturdayColor + if(i==sun && useSundayColor)textColor=sundayColor addWidgetTextLine(dateStackUp, `${month[i][j]}`, { color: (dayColor && j==0)?dayColor:'',//textColor, @@ -766,7 +762,7 @@ if(useBaseTextColor)tColor=Color.dynamic(new Color(baseTextColorLight), new Colo if(i == sat || i == sun)dateStack.backgroundColor = new Color(satSunHighlightColor,(4/10)) }else{ //start reminder list check - if (remList&&(!prevMonth&&!nextMonth)){ + if (heatMapEnabled && remList&&(!prevMonth&&!nextMonth)){ let list = await Calendar.forRemindersByTitle(remList) let rem = await Reminder.completedBetween(st, fn, [list]) let ratio = rem.length/heatMapMax @@ -950,7 +946,7 @@ async function successCallback(result) { isCalEvent=false } - if ((((new Date(item.startDate).getTime() > now.getTime()) && (hideCompletedReminders?(!isCalEvent?(item.isCompleted?false:true):true):true)) || (showCurrentAllDayEvents?((new Date(item.startDate).getDate()==now.getDate() || (new Date(item.startDate).getTime()now.getTime())) && item.isAllDay):false) || (persistIncompleteReminders?(!isCalEvent?(!item.isCompleted):false):false) ) && (cal.includes(item.calendar.title) || !isCalEvent) && !(ids.includes(item.identifier))) { + if ((((new Date(item.startDate).getTime() > now.getTime()) && (hideCompletedReminders?(!isCalEvent?(item.isCompleted?false:true):true):true)) || (showCurrentAllDayEvents?((new Date(item.startDate).getDate()==now.getDate() || (isCalEvent && new Date(item.startDate).getTime()now.getTime())) && item.isAllDay):false) || (persistIncompleteReminders?(!isCalEvent?(!item.isCompleted):false):false) ) && (!isCalEvent || cal.includes(item.calendar.title)) && !(ids.includes(item.identifier))) { ids.push(item.identifier) return true } @@ -971,14 +967,13 @@ async function successCallback(result) { }); newCalArray = newCalArray.slice(0, 5) - newCalArray.forEach((earlyE,index) => { - if((new Date(earlyE.startDate).getTime() < now.getTime()) && !('endDate' in earlyE) && !earlyE.isCompleted){ - f(earlyE) - newCalArray.splice(index, 1) - } - }) + const earlyEvents = newCalArray.filter(e => + new Date(e.startDate).getTime() < now.getTime() && !('endDate' in e) && !e.isCompleted + ) + const normalEvents = newCalArray.filter(e => !earlyEvents.includes(e)) + earlyEvents.forEach(f) log('starting normal events') - newCalArray.forEach(f) + normalEvents.forEach(f) } @@ -1027,9 +1022,9 @@ function f(item){ dateString=dateString.replace('T',' ') item.endDate = dF.date(dateString) } - if(cal.includes(item.calendar.title) || !isCalEvent) + if(!isCalEvent || cal.includes(item.calendar.title)) { - indexed+=1 + indexed+=1 if(!allowDynamicSpacing)eventCounter=null switch (eventCounter) { case 1: @@ -1096,7 +1091,8 @@ if(useBaseTextColor)when.textColor=Color.dynamic(new Color(baseTextColorLight), dF.dateFormat='EEE' let eee = dF.string(dd) let dt = eee+' '+ddd+' ' - let multipleAllDay = (item.isAllDay && (new Date(item.startDate).getDate() != new Date(item.endDate).getDate())) + const _s = new Date(item.startDate), _e = new Date(item.endDate) + let multipleAllDay = (item.isAllDay && (_s.getFullYear() !== _e.getFullYear() || _s.getMonth() !== _e.getMonth() || _s.getDate() !== _e.getDate())) if(multipleAllDay){ dF.dateFormat='EEE MMM d' diff --git a/Weather Overview/Weather Overview.js b/Weather Overview/Weather Overview.js index e924689..cfc81b3 100644 --- a/Weather Overview/Weather Overview.js +++ b/Weather Overview/Weather Overview.js @@ -139,7 +139,9 @@ try { logTime('Fetching Location Data', startTime); if(!locFound){ try{ - latLong = JSON.parse(await localFm.readString(cachePath+'/locCache.json')) + const locRaw = localFm.readString(cachePath+'/locCache.json') + if (!locRaw) throw new Error('No cached location data available') + latLong = JSON.parse(locRaw) if(config.runsInApp)log('using cached location') }catch(e2){ if(config.runsInApp)log(e2+" could not get location") @@ -221,6 +223,7 @@ try { } catch(e) { if(config.runsInApp)log("Offline mode") let raw = localFm.readString(cache); + if (!raw) throw new Error("No cached weather data available. Connect to the internet and run once to cache data.") weatherData = JSON.parse(raw); usingCachedData = true } @@ -279,6 +282,7 @@ if(showHumidity){ //end humidity legend +var amtLabel = 0 //amtLabel flag for usage in drawPrecipitation — must be declared regardless of showPrecipitation //start adding precipitation POP and amount legend if(showPrecipitation){ if(!percentageLinesDrawn){ @@ -290,7 +294,6 @@ if(showPrecipitation){ drawAmountLabels() //add label for percentage if(showLegend)drawTextC("precPrb", 16, ((config.widgetFamily == "small") ? contextSize : mediumWidgetWidth) - 220,showWindspeed?5:25,180,20,new Color('1fb2b7',0.9)) - var amtLabel = 0 //amtLabel flag for usage in the function called within the hourData loop that starts after this } //end adding precipitation POP and amount legend @@ -303,32 +306,32 @@ for (let i = 0; i <= hoursToShow; i++) { let hourData = (param=='daily')?weatherData.daily:weatherData.hourly; // start cloud cover let cloudCover = (param!='daily' && i==0)?weatherData.current.clouds : hourData[i].clouds - let cloudCoverNext = hourData[i+1].clouds let yPos = 220-(((220-60)/100) * cloudCover) - let yPosNext = 220-(((220-60)/100) * cloudCoverNext) if(i 0) ? (shouldRound(roundedGraph, temp) - min) / diff : 0; let nextDelta = (diff>0) ? (nextHourTemp - min) / diff : 0 temp = shouldRound(roundedTemp, temp) - if (i < hoursToShow) { - let hourDay = epochToDate(hourData[i].dt); - for (let i2 = 0 ; i2 < weatherData.daily.length; i2++) { - let day = weatherData.daily[i2]; - if (isSameDay(epochToDate(day.dt), epochToDate(hourData[i].dt))) { - hourDay = day; - break; - } - } - - //check if it is day / night - now = new Date() - var night = (hourData[i].dt > hourDay.sunset || hourData[i].dt < hourDay.sunrise || (i == 0 && (now.getTime > weatherData.current.sunset || now.getTime < weatherData.current.sunrise))) - var freezing = (units=='imperial'?32:0) - var tempColor = (temp>freezing)?Color.orange():Color.blue() - - if(param == "daily" && lowTemp){ - var lowTempColor = (lowTemp>freezing)?Color.orange():Color.blue() + // compute night and freezing for every iteration so the last column renders correctly + let hourDay = epochToDate(hourData[i].dt); + for (let i2 = 0 ; i2 < weatherData.daily.length; i2++) { + let day = weatherData.daily[i2]; + if (isSameDay(epochToDate(day.dt), epochToDate(hourData[i].dt))) { + hourDay = day; + break; } + } + now = new Date() + var night = (hourData[i].dt > hourDay.sunset || hourData[i].dt < hourDay.sunrise || (i == 0 && (now.getTime()/1000 > weatherData.current.sunset || now.getTime()/1000 < weatherData.current.sunrise))) + var freezing = (units=='imperial'?32:0) + var tempColor = (temp>freezing)?Color.orange():Color.blue() + if (i < hoursToShow) { drawLine(spaceBetweenDays * (i) + xStart + (barWidth/2)/*spaceBetweenDays * (i) + barWidth*/, 175 - (50 * delta),spaceBetweenDays * (i + 1) + xStart + (barWidth/2), 175 - (50 * nextDelta), 2,tempColor) //Color.gray())// (night ? Color.gray() : accentColor)) } @@ -423,6 +421,7 @@ for (let i = 0; i <= hoursToShow; i++) { widget.backgroundImage = (drawContext.getImage()) widget.url = cityId?`https://openweathermap.org/city/${cityId}` :'https://openweathermap.org' +Script.setWidget(widget) Script.complete() widget.presentMedium() @@ -485,7 +484,7 @@ function drawImage(image, x, y) { function drawPrecipitation(data, i) { if (i > hoursToShow)return; - let precipAmount = data.rain ? data.rain['1h'] * mmToInch : data.snow ? data.snow['1h'] * mmToInch : 0; + let precipAmount = data.rain ? (typeof data.rain === 'object' ? data.rain['1h'] : data.rain) * mmToInch : data.snow ? (typeof data.snow === 'object' ? data.snow['1h'] : data.snow) * mmToInch : 0; const pop = data.pop * 100; const barHeight = ((220 - 60) / 100) * pop; const precipBarHeight = ((220 - 60) / 100) * (100 * (precipAmount / maxPrecip)); diff --git a/tests/alexa-reminders.test.js b/tests/alexa-reminders.test.js new file mode 100644 index 0000000..fa88f98 --- /dev/null +++ b/tests/alexa-reminders.test.js @@ -0,0 +1,482 @@ +// Tests for Alexa To Reminders Access.js +'use strict' + +const { + makeRequest, makeWebView, makeAlert, makeReminder, makeCalendar, +} = require('./scriptable-mocks') + +// ─── Pure logic extracted from the script ──────────────────────────────────── + +function formatTitle(value, withVar, withoutVar) { + return value.split(' ').map(word => { + if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) { + return word.toLowerCase() + } + return word.charAt(0).toUpperCase() + word.slice(1) + }).join(' ') +} + +// ─── Testable versions of script functions (using injected deps) ────────────── + +function makeCheckIfUserIsAuthenticated(baseURL, requestFactory) { + return async function checkIfUserIsAuthenticated() { + try { + const url = `${baseURL}/alexashoppinglists/api/getlistitems` + const request = requestFactory(url) + await request.load() + if (request.response.statusCode === 401 || request.response.statusCode === 403) { + return false + } + return true + } catch (error) { + return false + } + } +} + +function makeMakeLogin(baseURL, signInKey, checkIfUserIsAuthenticated, webViewFactory) { + return async function makeLogin() { + const url = `${baseURL}` + const webView = webViewFactory() + try { + await webView.loadURL(url) + const html = await webView.getHTML() + if (html.includes(signInKey)) { + await webView.present(false) + return await checkIfUserIsAuthenticated() + } + return true + } catch (error) { + return false + } + } +} + +function makeSynchronizeReminders(baseURL, reminderListName, withVar, withoutVar, calendarAPI, reminderAPI, requestFactory) { + return async function synchronizeReminders() { + const reminderCalendar = await calendarAPI.forRemindersByTitle(reminderListName) + const url = `${baseURL}/alexashoppinglists/api/getlistitems` + const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem` + const json = await requestFactory(url).loadJSON() + + let listItems = [] + let shoppingListId = null + + for (const listId in json) { + const list = json[listId] + if (list.listInfo && list.listInfo.listType === 'SHOPPING_LIST') { + listItems = list.listItems || [] + shoppingListId = listId + break + } + } + + if (!shoppingListId) return { status: 'no_shopping_list' } + if (listItems.length === 0) return { status: 'empty' } + + const processed = [] + const allReminders = await reminderAPI.all([reminderCalendar]) + for (const item of listItems) { + if (!item.value) continue + const reminderTitle = formatTitle(item.value, withVar, withoutVar) + const incompleteReminders = allReminders.filter(r => !r.isCompleted) + const reminderExists = incompleteReminders.some(r => r.title === reminderTitle) + + if (!reminderExists) { + const reminder = new makeReminder.cls() + reminder.title = reminderTitle + reminder.calendar = reminderCalendar + await reminder.save() + processed.push({ title: reminderTitle, created: true }) + } else { + processed.push({ title: reminderTitle, created: false }) + } + + const deleteReq = requestFactory(deleteUrl) + deleteReq.method = 'DELETE' + deleteReq.headers = { 'Content-Type': 'application/json' } + deleteReq.body = JSON.stringify(item) + try { + await deleteReq.loadString() + } catch (_) {} + } + return { status: 'ok', processed } + } +} + +module.exports = async function({ test, describe, assert }) { + + // ─── Title Formatting ────────────────────────────────────────────────────── + + await describe('formatTitle', async () => { + test('capitalizes the first letter of each word', () => { + assert.equal(formatTitle('milk eggs bread', 'with', 'without'), 'Milk Eggs Bread') + }) + + test('handles single word', () => { + assert.equal(formatTitle('apples', 'with', 'without'), 'Apples') + }) + + test('lowercases "with" regardless of casing', () => { + assert.equal(formatTitle('pasta WITH sauce', 'with', 'without'), 'Pasta with Sauce') + }) + + test('lowercases "without" regardless of casing', () => { + assert.equal(formatTitle('coffee WITHOUT sugar', 'with', 'without'), 'Coffee without Sugar') + }) + + test('handles already-capitalized words', () => { + assert.equal(formatTitle('ORANGE JUICE', 'with', 'without'), 'ORANGE JUICE') + }) + + test('preserves remaining characters after first letter', () => { + assert.equal(formatTitle('organic milk', 'with', 'without'), 'Organic Milk') + }) + + test('handles empty string gracefully', () => { + assert.equal(formatTitle('', 'with', 'without'), '') + }) + }) + + // ─── checkIfUserIsAuthenticated ──────────────────────────────────────────── + + await describe('checkIfUserIsAuthenticated', async () => { + test('returns true when server responds with 200', async () => { + const req = makeRequest({ response: { statusCode: 200 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), true) + }) + + test('returns false when server responds with 401', async () => { + const req = makeRequest({ response: { statusCode: 401 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('returns false when server responds with 403', async () => { + const req = makeRequest({ response: { statusCode: 403 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('returns false on network error', async () => { + const req = makeRequest({ + load: async () => { throw new Error('Network failure') }, + response: { statusCode: 0 }, + }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('builds the correct API URL', async () => { + let capturedUrl = null + const check = makeCheckIfUserIsAuthenticated('https://www.amazon.co.uk', (url) => { + capturedUrl = url + return makeRequest({ response: { statusCode: 200 } }) + }) + await check() + assert.equal(capturedUrl, 'https://www.amazon.co.uk/alexashoppinglists/api/getlistitems') + }) + }) + + // ─── makeLogin ───────────────────────────────────────────────────────────── + + await describe('makeLogin', async () => { + test('returns true when page does not contain the sign-in key (already logged in)', async () => { + const wv = makeWebView({ getHTML: async () => 'Welcome back, User!' }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), true) + }) + + test('presents WebView when sign-in key is detected', async () => { + let presented = false + const wv = makeWebView({ + getHTML: async () => 'Please Sign in to continue', + present: async () => { presented = true }, + }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + await login() + assert.equal(presented, true) + }) + + test('returns result of checkIfUserIsAuthenticated after WebView login succeeds', async () => { + const wv = makeWebView({ getHTML: async () => 'Sign in here' }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), true) + }) + + test('returns false when checkIfUserIsAuthenticated fails after login attempt', async () => { + const wv = makeWebView({ getHTML: async () => 'Sign in here' }) + const check = async () => false + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), false) + }) + + test('returns false on WebView load error', async () => { + const wv = makeWebView({ loadURL: async () => { throw new Error('Cannot load') } }) + const check = async () => true + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + assert.equal(await login(), false) + }) + }) + + // ─── synchronizeReminders ────────────────────────────────────────────────── + + // Stub Reminder class for injection + makeReminder.cls = class { + constructor() { this.title = ''; this.calendar = null; this.isCompleted = false } + async save() { makeReminder.cls._saved.push(this) } + static _saved = [] + static reset() { makeReminder.cls._saved = [] } + } + + const baseConfig = { + baseURL: 'https://amazon.com', + reminderListName: 'Grocery and Shopping', + withVar: 'with', + withoutVar: 'without', + calendarAPI: { forRemindersByTitle: async () => makeCalendar() }, + reminderAPI: { all: async () => [] }, + } + + function buildSync(overrides = {}) { + const cfg = { ...baseConfig, ...overrides } + return makeSynchronizeReminders( + cfg.baseURL, cfg.reminderListName, cfg.withVar, cfg.withoutVar, + cfg.calendarAPI, cfg.reminderAPI, cfg.requestFactory || (() => makeRequest()) + ) + } + + await describe('synchronizeReminders', async () => { + test('returns "no_shopping_list" when no SHOPPING_LIST exists in response', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + requestFactory: () => makeRequest({ loadJSON: async () => ({ + list1: { listInfo: { listType: 'TODO' }, listItems: [] }, + }) }), + }) + const result = await sync() + assert.equal(result.status, 'no_shopping_list') + }) + + test('returns "empty" when SHOPPING_LIST has no items', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + requestFactory: () => makeRequest({ loadJSON: async () => ({ + list1: { listInfo: { listType: 'SHOPPING_LIST' }, listItems: [] }, + }) }), + }) + const result = await sync() + assert.equal(result.status, 'empty') + }) + + test('creates reminders for new items', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [ + { value: 'milk' }, + { value: 'eggs' }, + ], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.status, 'ok') + assert.equal(result.processed.filter(p => p.created).length, 2) + assert.equal(makeReminder.cls._saved.length, 2) + }) + + test('skips creating reminder when one already exists with same title', async () => { + makeReminder.cls.reset() + const existingReminder = { title: 'Milk', isCompleted: false } + const sync = buildSync({ + reminderAPI: { all: async () => [existingReminder] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'milk' }], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.processed[0].created, false) + assert.equal(makeReminder.cls._saved.length, 0) + }) + + test('completed reminders do not count as existing', async () => { + makeReminder.cls.reset() + const completedReminder = { title: 'Milk', isCompleted: true } + const sync = buildSync({ + reminderAPI: { all: async () => [completedReminder] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'milk' }], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.processed[0].created, true) + }) + + test('capitalizes reminder title correctly', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'orange juice with pulp' }], + }, + }), + loadString: async () => 'ok', + }), + }) + await sync() + assert.equal(makeReminder.cls._saved[0].title, 'Orange Juice with Pulp') + }) + + test('continues processing remaining items when delete fails for one', async () => { + makeReminder.cls.reset() + let deleteCallCount = 0 + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: (url) => { + if (url.includes('getlistitems')) { + return makeRequest({ loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: 'milk' }, { value: 'eggs' }], + }, + }) }) + } + return makeRequest({ loadString: async () => { + deleteCallCount++ + if (deleteCallCount === 1) throw new Error('Delete failed') + return 'ok' + } }) + }, + }) + const result = await sync() + assert.equal(result.status, 'ok') + assert.equal(result.processed.length, 2) + assert.equal(deleteCallCount, 2) // both deletes were attempted + }) + + test('skips items with null or missing value without crashing', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + list1: { + listInfo: { listType: 'SHOPPING_LIST' }, + listItems: [{ value: null }, { value: 'milk' }], + }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.status, 'ok') + assert.equal(result.processed.length, 1) + assert.equal(result.processed[0].title, 'Milk') + }) + + test('finds SHOPPING_LIST correctly when multiple list types present', async () => { + makeReminder.cls.reset() + const sync = buildSync({ + reminderAPI: { all: async () => [] }, + requestFactory: () => makeRequest({ + loadJSON: async () => ({ + todoList: { listInfo: { listType: 'TODO' }, listItems: [{ value: 'call dentist' }] }, + shopList: { listInfo: { listType: 'SHOPPING_LIST' }, listItems: [{ value: 'butter' }] }, + }), + loadString: async () => 'ok', + }), + }) + const result = await sync() + assert.equal(result.processed.length, 1) + assert.equal(makeReminder.cls._saved[0].title, 'Butter') + }) + }) + + // ─── main orchestration ──────────────────────────────────────────────────── + + await describe('main flow', async () => { + test('calls synchronize directly when already authenticated', async () => { + let syncCalled = false + const check = async () => true + const login = async () => true + const sync = async () => { syncCalled = true } + + async function main() { + const isAuthenticated = await check() + if (!isAuthenticated) { + const loggedIn = await login() + if (!loggedIn) return + } + await sync() + } + + await main() + assert.equal(syncCalled, true) + }) + + test('calls login then sync when not authenticated but login succeeds', async () => { + let loginCalled = false, syncCalled = false + const check = async () => false + const login = async () => { loginCalled = true; return true } + const sync = async () => { syncCalled = true } + + async function main() { + const isAuthenticated = await check() + if (!isAuthenticated) { + const loggedIn = await login() + if (!loggedIn) return + } + await sync() + } + + await main() + assert.equal(loginCalled, true) + assert.equal(syncCalled, true) + }) + + test('exits without syncing when not authenticated and login fails', async () => { + let syncCalled = false + const check = async () => false + const login = async () => false + const sync = async () => { syncCalled = true } + + async function main() { + const isAuthenticated = await check() + if (!isAuthenticated) { + const loggedIn = await login() + if (!loggedIn) return + } + await sync() + } + + await main() + assert.equal(syncCalled, false) + }) + }) +} diff --git a/tests/mee6.test.js b/tests/mee6.test.js new file mode 100644 index 0000000..c357b0c --- /dev/null +++ b/tests/mee6.test.js @@ -0,0 +1,171 @@ +// Tests for MEE6 LeaderBoard Info.js +'use strict' + +// ─── Player lookup logic extracted from the script ──────────────────────────── + +async function findPlayer(players, username) { + const regex = new RegExp('.*' + username + '.*') + let rank, xp, level, count, avatar, progress, nextLevel, userId + + for (const [index, inp] of players.entries()) { + const h = JSON.stringify(inp) + const match = regex.exec(h) + if (match) { + const cc = JSON.parse(match[0]) + rank = index + 1 + xp = cc.xp + level = cc.level + count = cc.message_count + avatar = cc.avatar + userId = cc.id + progress = cc.detailed_xp[0] + nextLevel = cc.detailed_xp[1] + break + } + } + + return { rank, xp, level, count, avatar, userId, progress, nextLevel } +} + +// ─── Guild index cycling logic ──────────────────────────────────────────────── + +function nextIndex(current, total) { + const next = current + 1 + return next >= total ? 0 : next +} + +// ─── Progress bar calculation ───────────────────────────────────────────────── + +function progressRatio(progress, nextLevel) { + if (!nextLevel || nextLevel === 0) return 0 + return Math.min(1, progress / nextLevel) +} + +module.exports = async function({ test, describe, assert }) { + + // ─── findPlayer ───────────────────────────────────────────────────────────── + + await describe('findPlayer', async () => { + const players = [ + { username: 'alice', id: '111', xp: 500, level: 3, message_count: 50, avatar: 'abc123', detailed_xp: [200, 400] }, + { username: 'bob', id: '222', xp: 300, level: 2, message_count: 30, avatar: 'def456', detailed_xp: [100, 300] }, + { username: 'carol', id: '333', xp: 100, level: 1, message_count: 10, avatar: 'ghi789', detailed_xp: [50, 200] }, + ] + + test('finds a player by username and returns correct rank', async () => { + const result = await findPlayer(players, 'bob') + assert.equal(result.rank, 2) + }) + + test('finds the first-place player', async () => { + const result = await findPlayer(players, 'alice') + assert.equal(result.rank, 1) + }) + + test('finds the last-place player', async () => { + const result = await findPlayer(players, 'carol') + assert.equal(result.rank, 3) + }) + + test('returns correct xp for found player', async () => { + const result = await findPlayer(players, 'bob') + assert.equal(result.xp, 300) + }) + + test('returns correct level for found player', async () => { + const result = await findPlayer(players, 'alice') + assert.equal(result.level, 3) + }) + + test('returns correct avatar hash for found player', async () => { + const result = await findPlayer(players, 'alice') + assert.equal(result.avatar, 'abc123') + }) + + test('returns correct userId for found player', async () => { + const result = await findPlayer(players, 'bob') + assert.equal(result.userId, '222') + }) + + test('returns correct progress (detailed_xp[0]) for found player', async () => { + const result = await findPlayer(players, 'carol') + assert.equal(result.progress, 50) + }) + + test('returns correct nextLevel (detailed_xp[1]) for found player', async () => { + const result = await findPlayer(players, 'carol') + assert.equal(result.nextLevel, 200) + }) + + test('returns undefined rank when player is not found', async () => { + const result = await findPlayer(players, 'nobody') + assert.equal(result.rank, undefined) + }) + + test('returns undefined userId when player is not found', async () => { + const result = await findPlayer(players, 'nobody') + assert.equal(result.userId, undefined) + }) + + test('handles an empty player list', async () => { + const result = await findPlayer([], 'alice') + assert.equal(result.rank, undefined) + }) + + test('matches partial username (regex is .*username.*)', async () => { + // "alic" should match player whose username contains "alic" + const result = await findPlayer(players, 'alic') + assert.equal(result.rank, 1) + }) + }) + + // ─── Guild index cycling ─────────────────────────────────────────────────── + + await describe('nextIndex (guild cycling)', async () => { + test('advances index by 1', () => { + assert.equal(nextIndex(0, 3), 1) + assert.equal(nextIndex(1, 3), 2) + }) + + test('wraps back to 0 when reaching the end', () => { + assert.equal(nextIndex(2, 3), 0) + }) + + test('single-item list always stays at 0', () => { + assert.equal(nextIndex(0, 1), 0) + }) + + test('wraps correctly for two-item list', () => { + assert.equal(nextIndex(0, 2), 1) + assert.equal(nextIndex(1, 2), 0) + }) + }) + + // ─── progressRatio ───────────────────────────────────────────────────────── + + await describe('progressRatio', async () => { + test('returns correct ratio for partial progress', () => { + assert.ok(Math.abs(progressRatio(200, 400) - 0.5) < 0.0001) + }) + + test('returns 1 when progress equals nextLevel', () => { + assert.equal(progressRatio(400, 400), 1) + }) + + test('caps at 1 when progress exceeds nextLevel', () => { + assert.equal(progressRatio(500, 400), 1) + }) + + test('returns 0 when progress is 0', () => { + assert.equal(progressRatio(0, 400), 0) + }) + + test('returns 0 when nextLevel is 0 (prevents division by zero)', () => { + assert.equal(progressRatio(100, 0), 0) + }) + + test('returns 0 when nextLevel is undefined', () => { + assert.equal(progressRatio(100, undefined), 0) + }) + }) +} diff --git a/tests/rh-downloads.test.js b/tests/rh-downloads.test.js new file mode 100644 index 0000000..78edfa2 --- /dev/null +++ b/tests/rh-downloads.test.js @@ -0,0 +1,179 @@ +// Tests for RH-Downloads.js +'use strict' + +// ─── Logic extracted from the script ───────────────────────────────────────── + +const NAME_FROM_URL_REGEX = /user\/(.+)(\/|)/ +const NAME_FROM_HTML_REGEX = /•\s(.+)<\/title>/ +const DOWNLOADS_REGEX = /

Downloads: (\d+)<\/p>/ + +function extractNameFromUrl(url) { + if (!url.includes('user')) return null + const match = NAME_FROM_URL_REGEX.exec(url) + return match ? match[1] : null +} + +function extractNameFromHtml(html) { + const match = NAME_FROM_HTML_REGEX.exec(html) + return match ? match[1] : null +} + +function extractDownloadCount(html) { + const match = DOWNLOADS_REGEX.exec(html) + return match ? match[1] : null +} + +function nextFileIndex(current, total) { + const next = current + 1 + return next >= total ? 0 : next +} + +function computeDailyDelta(currentStr, file, name, todayDate) { + const current = parseInt(currentStr, 10) + if (!file[name]) { + return { dayDelta: 0, downloads: currentStr, date: todayDate } + } + let dayDelta = file[name].dayDelta || 0 + if (file[name].date !== todayDate) { + dayDelta = 0 + } + const diff = (current - parseInt(file[name].downloads, 10)) + dayDelta + return { dayDelta: diff, downloads: currentStr, date: todayDate } +} + +module.exports = async function({ test, describe, assert }) { + + // ─── extractNameFromUrl ──────────────────────────────────────────────────── + + await describe('extractNameFromUrl', async () => { + test('extracts username from a user profile URL', () => { + assert.equal(extractNameFromUrl('https://routinehub.co/user/mvan231'), 'mvan231') + }) + + test('returns null for a non-user URL', () => { + assert.equal(extractNameFromUrl('https://routinehub.co/shortcut/5583/'), null) + }) + + test('handles trailing slash after username', () => { + const result = extractNameFromUrl('https://routinehub.co/user/mvan231/') + // regex captures "mvan231/" or "mvan231" depending on trailing slash group + assert.ok(result !== null) + assert.ok(result.startsWith('mvan231')) + }) + + test('returns null for empty string', () => { + assert.equal(extractNameFromUrl(''), null) + }) + }) + + // ─── extractNameFromHtml ────────────────────────────────────────────────── + + await describe('extractNameFromHtml', async () => { + test('extracts shortcut name from HTML title tag', () => { + const html = 'RoutineHub • My Cool Shortcut' + assert.equal(extractNameFromHtml(html), 'My Cool Shortcut') + }) + + test('returns null when pattern not found', () => { + const html = 'RoutineHub' + assert.equal(extractNameFromHtml(html), null) + }) + + test('handles names with spaces', () => { + const html = 'RoutineHub • Scriptable Widget Helper' + assert.equal(extractNameFromHtml(html), 'Scriptable Widget Helper') + }) + + test('returns null for empty HTML', () => { + assert.equal(extractNameFromHtml(''), null) + }) + }) + + // ─── extractDownloadCount ───────────────────────────────────────────────── + + await describe('extractDownloadCount', async () => { + test('extracts a numeric download count', () => { + const html = '

Downloads: 1234

' + assert.equal(extractDownloadCount(html), '1234') + }) + + test('extracts large download counts', () => { + const html = '

Downloads: 99999

' + assert.equal(extractDownloadCount(html), '99999') + }) + + test('returns null when pattern not found', () => { + const html = '

No download info here

' + assert.equal(extractDownloadCount(html), null) + }) + + test('returns null for empty HTML', () => { + assert.equal(extractDownloadCount(''), null) + }) + + test('does not match partial tags', () => { + const html = 'Downloads: 500' + assert.equal(extractDownloadCount(html), null) + }) + }) + + // ─── nextFileIndex ──────────────────────────────────────────────────────── + + await describe('nextFileIndex (URL cycling)', async () => { + test('advances from 0 to 1', () => { + assert.equal(nextFileIndex(0, 3), 1) + }) + + test('wraps from last to 0', () => { + assert.equal(nextFileIndex(2, 3), 0) + }) + + test('single URL always stays at 0', () => { + assert.equal(nextFileIndex(0, 1), 0) + }) + + test('advances through a two-URL list', () => { + assert.equal(nextFileIndex(0, 2), 1) + assert.equal(nextFileIndex(1, 2), 0) + }) + }) + + // ─── computeDailyDelta ──────────────────────────────────────────────────── + + await describe('computeDailyDelta', async () => { + test('returns 0 delta for a brand-new entry', () => { + const result = computeDailyDelta('100', {}, 'myShortcut', 15) + assert.equal(result.dayDelta, 0) + assert.equal(result.downloads, '100') + }) + + test('accumulates delta within the same day', () => { + const file = { myShortcut: { downloads: '90', dayDelta: 5, date: 15 } } + const result = computeDailyDelta('100', file, 'myShortcut', 15) + assert.equal(result.dayDelta, 15) // (100-90) + 5 + }) + + test('resets delta when day changes', () => { + const file = { myShortcut: { downloads: '90', dayDelta: 50, date: 14 } } + const result = computeDailyDelta('100', file, 'myShortcut', 15) + assert.equal(result.dayDelta, 10) // reset dayDelta to 0, then (100-90) + 0 + }) + + test('returns 0 delta when download count is unchanged', () => { + const file = { myShortcut: { downloads: '100', dayDelta: 0, date: 15 } } + const result = computeDailyDelta('100', file, 'myShortcut', 15) + assert.equal(result.dayDelta, 0) + }) + + test('updates stored downloads to the current count', () => { + const file = { myShortcut: { downloads: '90', dayDelta: 0, date: 15 } } + const result = computeDailyDelta('105', file, 'myShortcut', 15) + assert.equal(result.downloads, '105') + }) + + test('stores the current date in the result', () => { + const result = computeDailyDelta('100', {}, 'myShortcut', 15) + assert.equal(result.date, 15) + }) + }) +} diff --git a/tests/runner.js b/tests/runner.js new file mode 100644 index 0000000..bcd33f0 --- /dev/null +++ b/tests/runner.js @@ -0,0 +1,69 @@ +// Simple sequential test runner — no external dependencies required. +// Run with: node tests/runner.js +'use strict' +const { strict: assert } = require('assert') + +let passed = 0, failed = 0 +const failures = [] +let _queue = null // set by describe to collect tests sequentially + +async function runTest(name, fn) { + try { + const result = fn() + if (result && typeof result.then === 'function') await result + console.log(` ✓ ${name}`) + passed++ + } catch (e) { + console.log(` ✗ ${name}`) + console.log(` ${e.message}`) + failed++ + failures.push({ name, error: e }) + } +} + +// test() either queues (inside describe) or runs immediately +function test(name, fn) { + if (_queue) { _queue.push({ name, fn }); return } + return runTest(name, fn) +} + +// describe() collects all test() calls synchronously, then drains them sequentially +async function describe(suiteName, fn) { + console.log(`\n${suiteName}`) + const queue = [] + const prev = _queue + _queue = queue + const result = fn() + if (result && typeof result.then === 'function') await result + _queue = prev + for (const item of queue) await runTest(item.name, item.fn) +} + +async function runFile(path) { + const mod = require(path) + if (typeof mod === 'function') await mod({ test, describe, assert }) +} + +async function main() { + const files = [ + './alexa-reminders.test.js', + './weather-overview.test.js', + './mee6.test.js', + './rh-downloads.test.js', + './twitter-widget.test.js', + './upcoming-calendar.test.js', + ] + for (const f of files) await runFile(f) + + console.log(`\n${'─'.repeat(50)}`) + console.log(` ${passed} passed, ${failed} failed`) + if (failures.length) { + console.log('\nFailed tests:') + failures.forEach(({ name, error }) => { + console.log(` ✗ ${name}: ${error.message}`) + }) + process.exit(1) + } +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/tests/scriptable-mocks.js b/tests/scriptable-mocks.js new file mode 100644 index 0000000..0c04f50 --- /dev/null +++ b/tests/scriptable-mocks.js @@ -0,0 +1,188 @@ +// Mocks for all Scriptable iOS APIs used across the widget scripts. +'use strict' + +function makeRequest(overrides = {}) { + return { + _url: '', + method: 'GET', + headers: {}, + body: null, + response: { statusCode: 200 }, + load: async function() { return Buffer.from('') }, + loadJSON: async function() { return {} }, + loadString: async function() { return '' }, + loadImage: async function() { return makeImage() }, + ...overrides, + } +} + +function makeImage() { + return { _isMockImage: true } +} + +function makeWebView(overrides = {}) { + return { + _loadedURL: null, + _html: '', + loadURL: async function(url) { this._loadedURL = url }, + loadHTML: async function(html) { this._html = html }, + getHTML: async function() { return this._html }, + waitForLoad: async function() {}, + present: async function() {}, + evaluateJavaScript: async function(js) { return '' }, + ...overrides, + } +} + +function makeAlert(overrides = {}) { + return { + title: '', + message: '', + _actions: [], + _textFields: [], + addAction: function(title) { this._actions.push({ title, destructive: false }) }, + addDestructiveAction: function(title) { this._actions.push({ title, destructive: true }) }, + addTextField: function(placeholder, value = '') { this._textFields.push({ placeholder, value }) }, + textFieldValue: function(i) { return this._textFields[i]?.value ?? '' }, + present: async function() { return 0 }, + presentSheet: async function() { return 0 }, + presentAlert: async function() { return 0 }, + ...overrides, + } +} + +function makeReminder(overrides = {}) { + return { + title: '', + calendar: null, + isCompleted: false, + save: async function() {}, + ...overrides, + } +} + +function makeCalendar(title = 'Test List') { + return { title, _isCalendar: true } +} + +function makeFileManager(files = {}) { + const store = { ...files } + return { + documentsDirectory: () => '/mock/documents', + joinPath: (a, b) => `${a}/${b}`, + fileExists: (path) => path in store, + readString: (path) => store[path] ?? null, + writeString: (path, content) => { store[path] = content }, + read: (path) => store[path] ? Buffer.from(store[path]) : null, + write: (path, data) => { store[path] = data?.toString() ?? '' }, + readImage: (path) => store[path] ? makeImage() : null, + writeImage: (path, img) => { store[path] = img }, + listContents: (path) => Object.keys(store) + .filter(k => k.startsWith(path)) + .map(k => k.replace(path + '/', '').split('/')[0]) + .filter((v, i, a) => a.indexOf(v) === i), + createDirectory: (path, intermediate) => {}, + fileExtension: (path) => path.includes('.') ? path.split('.').pop() : '', + _store: store, + } +} + +function makeColor(hex, alpha = 1) { + return { hex, alpha, _isMockColor: true } +} + +function makeDrawContext() { + return { + size: null, + opaque: false, + respectScreenScale: false, + setFont: () => {}, + setTextColor: () => {}, + setFillColor: () => {}, + setStrokeColor: () => {}, + setLineWidth: () => {}, + setTextAlignedCenter: () => {}, + setTextAlignedLeft: () => {}, + setTextAlignedRight: () => {}, + drawText: () => {}, + drawTextInRect: () => {}, + drawImageAtPoint: () => {}, + drawImageInRect: () => {}, + fillRect: () => {}, + fillEllipse: () => {}, + addPath: () => {}, + strokePath: () => {}, + fillPath: () => {}, + getImage: () => makeImage(), + } +} + +function makeListWidget() { + const items = [] + return { + _items: items, + _padding: null, + backgroundColor: null, + backgroundImage: null, + refreshAfterDate: null, + url: null, + addText: (text) => ({ text, font: null, textColor: null, textOpacity: 1, centerAlignText: () => {}, rightAlignText: () => {} }), + addImage: (img) => ({ image: img, resizable: false, imageSize: null, tintColor: null, cornerRadius: 0, centerAlignImage: () => {}, imageOpacity: 1 }), + addStack: () => makeStack(), + addSpacer: (size) => {}, + addDate: (date) => ({ date, font: null, textColor: null, applyRelativeStyle: () => {} }), + setPadding: (...args) => {}, + presentSmall: async () => {}, + presentMedium: async () => {}, + presentLarge: async () => {}, + } +} + +function makeStack() { + return { + addText: (text) => ({ text, font: null, textColor: null, centerAlignText: () => {}, rightAlignText: () => {} }), + addImage: (img) => ({ image: img, resizable: false, imageSize: null, tintColor: null, cornerRadius: 0, centerAlignImage: () => {}, imageOpacity: 1 }), + addStack: () => makeStack(), + addSpacer: () => {}, + addDate: (date) => ({ date, applyRelativeStyle: () => {} }), + layoutHorizontally: () => {}, + layoutVertically: () => {}, + setPadding: () => {}, + size: null, + url: null, + backgroundColor: null, + } +} + +function makeScriptable() { + return { + setWidget: () => {}, + complete: () => {}, + name: () => 'MockScript', + } +} + +function makeConfig(overrides = {}) { + return { + runsInWidget: false, + runsInApp: true, + widgetFamily: 'medium', + ...overrides, + } +} + +module.exports = { + makeRequest, + makeWebView, + makeAlert, + makeReminder, + makeCalendar, + makeFileManager, + makeColor, + makeDrawContext, + makeListWidget, + makeStack, + makeScriptable, + makeConfig, + makeImage, +} diff --git a/tests/twitter-widget.test.js b/tests/twitter-widget.test.js new file mode 100644 index 0000000..20f680f --- /dev/null +++ b/tests/twitter-widget.test.js @@ -0,0 +1,127 @@ +// Tests for Twitter Widget.js +'use strict' + +// ─── dateDelt output logic (parsing-independent) ────────────────────────────── +// The original parses via Scriptable's DateFormatter (iOS-only). We test the +// time-delta arithmetic and output-format selection by injecting a pre-parsed +// Date object, bypassing the platform-specific parsing step. + +function formatTimeDelta(outDate) { + const now = new Date() + const delta = Math.round((now.getTime() - outDate.getTime()) / 1000) + const deltaH = Math.floor(delta / 3600) + const deltaM = Math.floor((delta % 3600) / 60) + const deltaS = delta % 60 + + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + let tOut = months[outDate.getMonth()] + outDate.getDate() + + if (now.getDate() === outDate.getDate() || deltaH < 24) { + let val = deltaH, unit = 'h' + if (val < 1) { val = deltaM; unit = 'm' } + if (val < 1) { val = deltaS; unit = 's' } + tOut = val + unit + } + return tOut +} + +// ─── Twitter date string transform (testable without DateFormatter) ──────────── + +function transformTwitterDateString(dateString) { + let dt = dateString + dt = dt.replace(/^.{3}/, dt.match(/.{4}$/)) + dt = dt.replace(/.{4}$/, '') + return dt +} + +// ─── updateCheck comparison logic ───────────────────────────────────────────── + +function shouldCheckForUpdates(checkUpdates) { + return checkUpdates === true +} + +module.exports = async function({ test, describe, assert }) { + + // ─── formatTimeDelta ────────────────────────────────────────────────────── + + await describe('formatTimeDelta (dateDelt arithmetic)', async () => { + test('returns seconds suffix for a date 30 seconds ago', () => { + const d = new Date(Date.now() - 30 * 1000) + const result = formatTimeDelta(d) + assert.ok(result.endsWith('s'), `expected "s" suffix, got "${result}"`) + }) + + test('returns minutes suffix for a date 5 minutes ago', () => { + const d = new Date(Date.now() - 5 * 60 * 1000) + const result = formatTimeDelta(d) + assert.ok(result.endsWith('m'), `expected "m" suffix, got "${result}"`) + }) + + test('returns hours suffix for a date 3 hours ago', () => { + const d = new Date(Date.now() - 3 * 3600 * 1000) + const result = formatTimeDelta(d) + assert.ok(result.endsWith('h'), `expected "h" suffix, got "${result}"`) + assert.ok(result.startsWith('3'), `expected "3h", got "${result}"`) + }) + + test('returns a MonthDay string for a date clearly in a different month', () => { + // 47 days ensures deltaH ≈ 1128 (well > 24) and getDate() can never repeat monthly + const d = new Date(Date.now() - 47 * 24 * 3600 * 1000) + const result = formatTimeDelta(d) + assert.ok(!result.match(/^\d+[hms]$/), `expected date string but got "${result}"`) + }) + + test('returns "0s" for a date right now', () => { + const d = new Date() + const result = formatTimeDelta(d) + assert.ok(result.endsWith('s'), `expected "s" suffix, got "${result}"`) + }) + }) + + // ─── transformTwitterDateString ─────────────────────────────────────────── + + await describe('transformTwitterDateString', async () => { + test('moves year from end to front', () => { + const result = transformTwitterDateString('Mon Nov 13 10:45:30 +0000 2023') + assert.ok(result.startsWith('2023'), `expected to start with "2023", got "${result}"`) + }) + + test('removes the trailing year after transformation', () => { + const result = transformTwitterDateString('Mon Nov 13 10:45:30 +0000 2023') + // The trailing "2023" should be gone (only one year at front) + assert.equal(result.indexOf('2023'), 0) + assert.equal(result.lastIndexOf('2023'), 0) + }) + + test('produces correct format for DateFormatter parsing', () => { + const result = transformTwitterDateString('Mon Nov 13 10:45:30 +0000 2023') + assert.equal(result.trim(), '2023 Nov 13 10:45:30 +0000') + }) + }) + + // ─── shouldCheckForUpdates ──────────────────────────────────────────────── + + await describe('shouldCheckForUpdates (checkUpdates===true fix)', async () => { + test('returns true when checkUpdates is boolean true', () => { + assert.equal(shouldCheckForUpdates(true), true) + }) + + test('returns false when checkUpdates is boolean false', () => { + assert.equal(shouldCheckForUpdates(false), false) + }) + + test('returns false when checkUpdates is the string "true" (old bug)', () => { + // Before the fix, checkUpdates="true" (assignment) made this always truthy. + // Now we use ===true so the string "true" must not trigger updates. + assert.equal(shouldCheckForUpdates('true'), false) + }) + + test('returns false when checkUpdates is undefined', () => { + assert.equal(shouldCheckForUpdates(undefined), false) + }) + + test('returns false when checkUpdates is 1 (truthy but not strictly true)', () => { + assert.equal(shouldCheckForUpdates(1), false) + }) + }) +} diff --git a/tests/upcoming-calendar.test.js b/tests/upcoming-calendar.test.js new file mode 100644 index 0000000..f165c48 --- /dev/null +++ b/tests/upcoming-calendar.test.js @@ -0,0 +1,243 @@ +// Tests for Upcoming Calendar Indicator.js +'use strict' + +// ─── Logic extracted from the script ───────────────────────────────────────── + +function saturdayIndex(monWeekStart) { + return monWeekStart ? 5 : 6 +} + +function sundayIndex(monWeekStart) { + return monWeekStart ? 6 : 0 +} + +// Safe color assignment — only assigns if the flag is enabled (fixed version) +function resolveWeekendTextColor(colIndex, sat, sun, useSaturdayColor, saturdayColor, useSundayColor, sundayColor) { + let textColor = undefined + if (colIndex === sat && useSaturdayColor) textColor = saturdayColor + if (colIndex === sun && useSundayColor) textColor = sundayColor + return textColor +} + +// HeatMap guard — only runs when heatMapEnabled is true (fixed version) +function shouldRunHeatMap(heatMapEnabled, remList, prevMonth, nextMonth) { + return heatMapEnabled && !!remList && !prevMonth && !nextMonth +} + +// Progress bar ratio (same logic as heatmap ratio) +function heatRatio(completedCount, heatMapMax) { + if (!heatMapMax) return 0 + const ratio = completedCount / heatMapMax + return ratio > 1 ? 1 : ratio +} + +// Date-to-calshow epoch conversion used for tappable dates +function calshowEpoch(targetDate, originDate) { + return (targetDate - originDate) / 1000 +} + +// hideCompletedReminders filtering — extracted from subdirectory version filter predicate +function shouldIncludeItem(isCalEvent, isCompleted, hideCompletedReminders) { + return hideCompletedReminders ? (!isCalEvent ? (isCompleted ? false : true) : true) : true +} + +// persistIncompleteReminders — extracted from subdirectory version filter predicate +function shouldPersistIncomplete(isCalEvent, isCompleted, persistIncompleteReminders) { + return persistIncompleteReminders ? (!isCalEvent ? (!isCompleted) : false) : false +} + +module.exports = async function({ test, describe, assert }) { + + // ─── Weekend column indices ──────────────────────────────────────────────── + + await describe('saturdayIndex', async () => { + test('returns 5 when week starts on Monday', () => { + assert.equal(saturdayIndex(true), 5) + }) + + test('returns 6 when week starts on Sunday', () => { + assert.equal(saturdayIndex(false), 6) + }) + }) + + await describe('sundayIndex', async () => { + test('returns 6 when week starts on Monday', () => { + assert.equal(sundayIndex(true), 6) + }) + + test('returns 0 when week starts on Sunday', () => { + assert.equal(sundayIndex(false), 0) + }) + }) + + // ─── resolveWeekendTextColor ────────────────────────────────────────────── + + await describe('resolveWeekendTextColor', async () => { + const sat = 6, sun = 0 + + test('returns saturdayColor when column is Saturday and useSaturdayColor is true', () => { + const result = resolveWeekendTextColor(sat, sat, sun, true, '#FF0000', false, undefined) + assert.equal(result, '#FF0000') + }) + + test('returns undefined when column is Saturday but useSaturdayColor is false', () => { + const result = resolveWeekendTextColor(sat, sat, sun, false, '#FF0000', false, undefined) + assert.equal(result, undefined) + }) + + test('returns sundayColor when column is Sunday and useSundayColor is true', () => { + const result = resolveWeekendTextColor(sun, sat, sun, false, undefined, true, '#0000FF') + assert.equal(result, '#0000FF') + }) + + test('returns undefined when column is Sunday but useSundayColor is false', () => { + const result = resolveWeekendTextColor(sun, sat, sun, false, undefined, false, '#0000FF') + assert.equal(result, undefined) + }) + + test('returns undefined for a weekday column', () => { + const result = resolveWeekendTextColor(3, sat, sun, true, '#FF0000', true, '#0000FF') + assert.equal(result, undefined) + }) + + test('does not propagate undefined saturdayColor when flag is false', () => { + // This was the original bug — when useSaturdayColor=false, saturdayColor is undefined, + // and the old code would assign textColor=undefined + const result = resolveWeekendTextColor(sat, sat, sun, false, undefined, false, undefined) + assert.equal(result, undefined) + }) + }) + + // ─── shouldRunHeatMap ───────────────────────────────────────────────────── + + await describe('shouldRunHeatMap', async () => { + test('returns true when all conditions are met', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', false, false), true) + }) + + test('returns false when heatMapEnabled is false', () => { + assert.equal(shouldRunHeatMap(false, 'MyList', false, false), false) + }) + + test('returns false when remList is empty/null', () => { + assert.equal(shouldRunHeatMap(true, '', false, false), false) + assert.equal(shouldRunHeatMap(true, null, false, false), false) + }) + + test('returns false when showing previous month days', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', true, false), false) + }) + + test('returns false when showing next month days', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', false, true), false) + }) + + test('returns false when both prev and next month flags are set', () => { + assert.equal(shouldRunHeatMap(true, 'MyList', true, true), false) + }) + + test('returns false when heatMapEnabled is false even with a valid remList', () => { + // This was the original bug — heatMapMax/heatMapColor were undefined when + // heatMapEnabled=false, crashing new Color(undefined) + assert.equal(shouldRunHeatMap(false, 'ShoppingList', false, false), false) + }) + }) + + // ─── heatRatio ──────────────────────────────────────────────────────────── + + await describe('heatRatio', async () => { + test('returns correct ratio for partial completion', () => { + assert.ok(Math.abs(heatRatio(3, 10) - 0.3) < 0.0001) + }) + + test('returns 1 when completedCount equals heatMapMax', () => { + assert.equal(heatRatio(10, 10), 1) + }) + + test('caps at 1 when completedCount exceeds heatMapMax', () => { + assert.equal(heatRatio(15, 10), 1) + }) + + test('returns 0 when no completions', () => { + assert.equal(heatRatio(0, 10), 0) + }) + + test('returns 0 when heatMapMax is 0 (prevents division by zero)', () => { + assert.equal(heatRatio(5, 0), 0) + }) + + test('returns 0 when heatMapMax is undefined (prevents crash)', () => { + assert.equal(heatRatio(5, undefined), 0) + }) + }) + + // ─── shouldIncludeItem (hideCompletedReminders) ─────────────────────────── + + await describe('shouldIncludeItem (hideCompletedReminders)', async () => { + test('includes calendar events regardless of completion', () => { + assert.equal(shouldIncludeItem(true, true, true), true) + assert.equal(shouldIncludeItem(true, false, true), true) + }) + + test('excludes completed reminders when hideCompletedReminders is true', () => { + assert.equal(shouldIncludeItem(false, true, true), false) + }) + + test('includes incomplete reminders when hideCompletedReminders is true', () => { + assert.equal(shouldIncludeItem(false, false, true), true) + }) + + test('includes completed reminders when hideCompletedReminders is false', () => { + assert.equal(shouldIncludeItem(false, true, false), true) + }) + }) + + // ─── shouldPersistIncomplete (persistIncompleteReminders) ───────────────── + + await describe('shouldPersistIncomplete (persistIncompleteReminders)', async () => { + test('returns true for past-due incomplete reminder when persist is enabled', () => { + assert.equal(shouldPersistIncomplete(false, false, true), true) + }) + + test('returns false for completed reminder even when persist is enabled', () => { + assert.equal(shouldPersistIncomplete(false, true, true), false) + }) + + test('returns false for calendar events regardless of persist flag', () => { + assert.equal(shouldPersistIncomplete(true, false, true), false) + assert.equal(shouldPersistIncomplete(true, true, true), false) + }) + + test('returns false when persistIncompleteReminders is false', () => { + assert.equal(shouldPersistIncomplete(false, false, false), false) + }) + }) + + // ─── calshowEpoch ───────────────────────────────────────────────────────── + + await describe('calshowEpoch', async () => { + test('returns 0 when target equals origin', () => { + const d = new Date('2024-06-15T12:00:00') + assert.equal(calshowEpoch(d, d), 0) + }) + + test('returns positive value when target is after origin', () => { + const origin = new Date('2024-01-01') + const target = new Date('2024-01-02') + assert.equal(calshowEpoch(target, origin), 86400) // 1 day in seconds + }) + + test('returns negative value when target is before origin', () => { + const origin = new Date('2024-01-02') + const target = new Date('2024-01-01') + assert.equal(calshowEpoch(target, origin), -86400) + }) + + test('returns a float for sub-second differences', () => { + const origin = new Date('2024-06-15T00:00:00.000') + const target = new Date('2024-06-15T00:00:00.500') + // 500ms / 1000 = 0.5 — the script passes this directly to calshow: URL + assert.equal(calshowEpoch(target, origin), 0.5) + }) + }) +} diff --git a/tests/weather-overview.test.js b/tests/weather-overview.test.js new file mode 100644 index 0000000..f180207 --- /dev/null +++ b/tests/weather-overview.test.js @@ -0,0 +1,197 @@ +// Tests for Weather Overview pure utility functions +'use strict' + +// ─── Functions extracted verbatim from Weather Overview.js ─────────────────── + +function epochToDate(epoch) { + return new Date(epoch * 1000) +} + +function shouldRound(should, value) { + return (should) ? Math.round(value) : value +} + +function isSameDay(date1, date2) { + return ( + date1.getYear() === date2.getYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ) +} + +// Daily vs hourly precipitation key selection (fixed version) +function getPrecipAmount(data, mmToInch) { + const rainVal = data.rain ? (typeof data.rain === 'object' ? data.rain['1h'] : data.rain) : null + const snowVal = data.snow ? (typeof data.snow === 'object' ? data.snow['1h'] : data.snow) : null + return rainVal !== null ? rainVal * mmToInch : snowVal !== null ? snowVal * mmToInch : 0 +} + +// Night detection (fixed version — uses getTime() not getTime) +function isNight(dtEpoch, hourDay, currentSunrise, currentSunset, isFirstEntry) { + const now = new Date() + return ( + dtEpoch > hourDay.sunset || + dtEpoch < hourDay.sunrise || + (isFirstEntry && (now.getTime() / 1000 > currentSunset || now.getTime() / 1000 < currentSunrise)) + ) +} + +module.exports = async function({ test, describe, assert }) { + + // ─── epochToDate ─────────────────────────────────────────────────────────── + + await describe('epochToDate', async () => { + test('converts Unix epoch (seconds) to a Date object', () => { + const epoch = 1700000000 + const result = epochToDate(epoch) + assert.ok(result instanceof Date) + assert.equal(result.getTime(), epoch * 1000) + }) + + test('epoch 0 maps to January 1 1970', () => { + const result = epochToDate(0) + assert.equal(result.getUTCFullYear(), 1970) + assert.equal(result.getUTCMonth(), 0) + assert.equal(result.getUTCDate(), 1) + }) + + test('preserves sub-minute precision', () => { + const epoch = 1700000045 + assert.equal(epochToDate(epoch).getTime(), 1700000045000) + }) + }) + + // ─── shouldRound ─────────────────────────────────────────────────────────── + + await describe('shouldRound', async () => { + test('rounds when flag is true', () => { + assert.equal(shouldRound(true, 72.6), 73) + }) + + test('returns raw float when flag is false', () => { + assert.equal(shouldRound(false, 72.6), 72.6) + }) + + test('rounds down correctly', () => { + assert.equal(shouldRound(true, 72.4), 72) + }) + + test('passes through integers unchanged', () => { + assert.equal(shouldRound(true, 70), 70) + }) + + test('handles negative values', () => { + assert.equal(shouldRound(true, -3.7), -4) + }) + }) + + // ─── isSameDay ───────────────────────────────────────────────────────────── + + await describe('isSameDay', async () => { + test('returns true for two Date objects on the same day', () => { + const a = new Date('2024-06-15T08:00:00') + const b = new Date('2024-06-15T22:45:00') + assert.equal(isSameDay(a, b), true) + }) + + test('returns false for dates on different days', () => { + const a = new Date('2024-06-15T23:59:59') + const b = new Date('2024-06-16T00:00:00') + assert.equal(isSameDay(a, b), false) + }) + + test('returns false for same day in different months', () => { + const a = new Date('2024-05-15') + const b = new Date('2024-06-15') + assert.equal(isSameDay(a, b), false) + }) + + test('returns false for same month/day in different years', () => { + const a = new Date('2023-06-15') + const b = new Date('2024-06-15') + assert.equal(isSameDay(a, b), false) + }) + + test('returns true when same Date object is compared to itself', () => { + const a = new Date('2024-06-15') + assert.equal(isSameDay(a, a), true) + }) + }) + + // ─── getPrecipAmount (daily vs hourly key fix) ───────────────────────────── + + await describe('getPrecipAmount', async () => { + const mmToInch = 394 / 10000 + + test('returns 0 when no rain or snow', () => { + assert.equal(getPrecipAmount({}, 1), 0) + }) + + test('uses rain["1h"] key for hourly data (object form)', () => { + const data = { rain: { '1h': 2.5 } } + assert.equal(getPrecipAmount(data, 1), 2.5) + }) + + test('uses plain rain number for daily data', () => { + const data = { rain: 5.0 } + assert.equal(getPrecipAmount(data, 1), 5.0) + }) + + test('uses snow["1h"] key for hourly snow data', () => { + const data = { snow: { '1h': 1.0 } } + assert.equal(getPrecipAmount(data, 1), 1.0) + }) + + test('uses plain snow number for daily snow data', () => { + const data = { snow: 3.0 } + assert.equal(getPrecipAmount(data, 1), 3.0) + }) + + test('rain takes precedence over snow when both present', () => { + const data = { rain: 2.0, snow: 1.0 } + assert.equal(getPrecipAmount(data, 1), 2.0) + }) + + test('applies mmToInch conversion factor', () => { + const data = { rain: 10.0 } + const result = getPrecipAmount(data, mmToInch) + assert.ok(Math.abs(result - 10.0 * mmToInch) < 0.0001) + }) + + test('hourly rain["1h"] value of 0 returns 0 (not falls through to snow)', () => { + const data = { rain: { '1h': 0 }, snow: { '1h': 5.0 } } + // rain is present (object), so snowVal should not be used + // rain value is 0, result is 0 + assert.equal(getPrecipAmount(data, 1), 0) + }) + }) + + // ─── isNight (night detection fix) ──────────────────────────────────────── + + await describe('isNight', async () => { + const sunrise = 1700000000 + const sunset = 1700050000 + + test('returns true when dt is after sunset', () => { + assert.equal(isNight(sunset + 100, { sunrise, sunset }, sunrise, sunset, false), true) + }) + + test('returns true when dt is before sunrise', () => { + assert.equal(isNight(sunrise - 100, { sunrise, sunset }, sunrise, sunset, false), true) + }) + + test('returns false during the day', () => { + const midday = (sunrise + sunset) / 2 + assert.equal(isNight(midday, { sunrise, sunset }, sunrise, sunset, false), false) + }) + + test('first entry checks system clock against current sunset', () => { + // Use a sunrise far in the future and sunset far in the past so current time is "night" + const pastSunset = Math.floor(Date.now() / 1000) - 7200 // 2 hours ago + const pastSunrise = Math.floor(Date.now() / 1000) - 36000 // 10 hours ago + const midday = (pastSunrise + pastSunset) / 2 + const result = isNight(midday, { sunrise: pastSunrise, sunset: pastSunset }, pastSunrise, pastSunset, true) + assert.equal(result, true) + }) + }) +} From 40d6ab3c8493644abf998ba51e30505289a52156 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 10:01:51 -0700 Subject: [PATCH 02/22] Add Claude Code automations: hooks, skills, agent, MCP - PostToolUse hook: auto-runs test suite after every file edit - PreToolUse hook: warns before editing shared scriptable-mocks.js - /new-script skill: scaffolds new Scriptable widget with required headers - /gen-test skill: generates test files following project conventions - scriptable-reviewer agent: audits widgets for size/API/pattern issues - context7 MCP server: live Scriptable API documentation lookup Co-Authored-By: Claude Sonnet 4.6 --- .claude/agents/scriptable-reviewer.md | 35 +++++++++++++++ .claude/settings.json | 26 +++++++++++ .claude/skills/gen-test/SKILL.md | 48 ++++++++++++++++++++ .claude/skills/new-script/SKILL.md | 65 +++++++++++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 .claude/agents/scriptable-reviewer.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/gen-test/SKILL.md create mode 100644 .claude/skills/new-script/SKILL.md diff --git a/.claude/agents/scriptable-reviewer.md b/.claude/agents/scriptable-reviewer.md new file mode 100644 index 0000000..d263e02 --- /dev/null +++ b/.claude/agents/scriptable-reviewer.md @@ -0,0 +1,35 @@ +--- +name: scriptable-reviewer +description: Reviews Scriptable widget scripts for size compatibility, API correctness, update-check pattern, and common iOS widget pitfalls +--- + +You are a Scriptable iOS widget expert. When invoked, review the provided script (or all recently edited scripts) for the following issues and report findings with line numbers and suggested fixes. + +## Review Checklist + +### Widget Size Compatibility +- Missing `config.widgetFamily` guard when layout differs between small/medium/large/accessory sizes +- Hardcoded pixel dimensions that will break at non-medium sizes +- Text that will overflow at small size + +### Scriptable API Correctness +- Use of unavailable APIs: no `fetch()`, `require()`, `fs`, `window`, `document`, or other browser/Node APIs +- `FileManager.iCloud()` vs `FileManager.local()` — iCloud is preferred for synced scripts; flag if using local without clear reason +- `Request` objects must call `.load()` or `.loadJSON()` — flag if response is accessed without awaiting load +- `Script.complete()` must be called at the end of every execution path + +### Update Check Pattern +- `updateCheck()` function should be present in scripts intended for public distribution +- Version constant should be a number, not a string + +### Settings Pattern +- Settings JSON should be read with null-safety (`fm.fileExists(settingsPath)` check before `readString`) +- Settings directory should be created if it doesn't exist before writing + +### Common Pitfalls +- `await` used outside async context +- Uncaught promise rejections (missing try/catch on network calls) +- Hardcoded API keys or tokens in the script body (flag as security issue) +- Missing `Script.setWidget(widget)` call when `config.runsInWidget` is true + +Report all findings grouped by severity: **Error** (will crash), **Warning** (may misbehave), **Info** (style/best practice). diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e723dbb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node tests/runner.js 2>&1 | tail -20" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'scriptable-mocks' && echo 'BLOCK: scriptable-mocks.js is shared by all tests — confirm this change is intentional before proceeding.' || true" + } + ] + } + ] + } +} diff --git a/.claude/skills/gen-test/SKILL.md b/.claude/skills/gen-test/SKILL.md new file mode 100644 index 0000000..fa01d45 --- /dev/null +++ b/.claude/skills/gen-test/SKILL.md @@ -0,0 +1,48 @@ +--- +name: gen-test +description: Generate a Node.js test file for a Scriptable widget script, following the project's existing test conventions in tests/ +disable-model-invocation: true +--- + +Generate a test file for the Scriptable script named by the user (e.g. `/gen-test Twitter Widget.js`). + +## Steps + +1. Read the target `.js` script from the project root +2. Read `tests/scriptable-mocks.js` to understand available mock factories +3. Identify pure/testable functions in the script (logic that doesn't require a live Scriptable runtime) +4. Create `tests/.test.js` following the exact pattern used in existing test files: + +```javascript +// Tests for .js +'use strict' + +const { + makeRequest, makeWebView, makeAlert, /* etc. — import only what's needed */ +} = require('./scriptable-mocks') + +// ─── Pure logic extracted from the script ──────────────────────────────────── + +// Copy or re-express pure functions from the script here so they can be tested +// without requiring the Scriptable runtime. + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +module.exports = async ({ test, describe, assert }) => { + describe('', () => { + test('example: returns expected value', () => { + // arrange + // act + // assert + }) + }) +} +``` + +5. Add the new test file to the `files` array in `tests/runner.js` so it runs automatically. + +## Rules +- Only test logic that can run in plain Node.js — no Scriptable runtime APIs in test bodies +- Use injected `assert` from Node's built-in `assert` module (strict mode) +- Follow the existing naming: `.test.js` +- Aim for at least 3 meaningful tests covering happy path, edge case, and error case diff --git a/.claude/skills/new-script/SKILL.md b/.claude/skills/new-script/SKILL.md new file mode 100644 index 0000000..8d12f6b --- /dev/null +++ b/.claude/skills/new-script/SKILL.md @@ -0,0 +1,65 @@ +--- +name: new-script +description: Scaffold a new Scriptable widget script with required headers, FileManager setup, settings JSON pattern, and updateCheck boilerplate +disable-model-invocation: true +--- + +Create a new Scriptable widget script based on the name and description provided by the user. + +The script MUST start with exactly these 3 lines (no blank line before them): +``` +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: ; icon-glyph: ; +``` + +Choose an appropriate `icon-color` from: red, deep-red, orange, yellow, green, teal, blue, deep-blue, indigo, purple, pink, white, gray, light-gray, dark-gray, black. +Choose an appropriate `icon-glyph` from Font Awesome glyph names (e.g. calendar-alt, cloud-sun, dove, chart-bar, bell, star). + +After the header, scaffold the following structure: + +```javascript +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: blue; icon-glyph: star; + +'use strict' + +const SCRIPT_VERSION = 1.0 + +let fm = FileManager.iCloud() +let scriptDir = fm.documentsDirectory() + '//' +let settingsPath = scriptDir + 'settings.json' + +if (!fm.fileExists(scriptDir)) fm.createDirectory(scriptDir, false) + +let needUpdated = await updateCheck(SCRIPT_VERSION) + +let settings = {} +if (fm.fileExists(settingsPath)) { + settings = JSON.parse(fm.readString(settingsPath)) +} + +// ─── Main widget ──────────────────────────────────────────────────────────── + +let widget = new ListWidget() +widget.backgroundColor = Color.dynamic(Color.white(), Color.black()) + +// TODO: build widget UI here + +if (config.runsInWidget) { + Script.setWidget(widget) +} else { + await widget.presentMedium() +} +Script.complete() + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function updateCheck(currentVersion) { + // Placeholder — replace with actual update URL for published scripts + return false +} +``` + +Save the file as `.js` in the project root. Ask the user for the script name, purpose, icon color, and icon glyph if not provided. From b8969f317c5da8fca3237ba85f7505676f38150d Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 10:05:10 -0700 Subject: [PATCH 03/22] Handle non-JSON response from Amazon API gracefully Parse response as string first and catch JSON.parse errors to avoid crashing when Amazon returns HTML (login wall, CAPTCHA, session expiry). Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 4a6a618..e238259 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -98,8 +98,16 @@ async function synchronizeReminders() { const reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; - const json = await new Request(url).loadJSON() - + const raw = await new Request(url).loadString() + let json + try { + json = JSON.parse(raw) + } catch (e) { + log("Response was not valid JSON — Amazon may require re-authentication") + log(raw.substring(0, 200)) + return + } + // Find the list with listType === "SHOPPING_LIST" and ignore other lists let listItems = []; let shoppingListId = null; From 5460c12607cc57ee06939458267b441e04a895ef Mon Sep 17 00:00:00 2001 From: Bryan Buchorn <52381868+BrutalByte@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:14:19 -0700 Subject: [PATCH 04/22] Update Alexa To Reminders Access.js --- Alexa To Reminders Access.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index e238259..4375f75 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -44,7 +44,7 @@ $$$$$$$$$$$$$$$$$$$$$$$ const baseURL = 'https://www.amazon.com' //include the reminder list name exactly as it is in Reminders app -const reminderListName = 'Grocery and Shopping' +const reminderListName = 'Shopping' //signInKey should be specific for your language. English uses "Sign in". German uses "Anmelden" const signInKey = "Sign in" From ab403aa281928f4ca3d63cd72f6737d1ec283392 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 10:39:50 -0700 Subject: [PATCH 05/22] Improve error handling in synchronizeReminders - Guard against null reminderCalendar when list name doesn't match - Log actual error message in catch block instead of generic string Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 4375f75..d06627c 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -96,6 +96,10 @@ async function makeLogin() { async function synchronizeReminders() { try { const reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); + if (!reminderCalendar) { + log(`Reminders list "${reminderListName}" not found — check the reminderListName setting`) + return + } const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; const raw = await new Request(url).loadString() @@ -176,7 +180,7 @@ async function synchronizeReminders() { log(`Sync completed: processed ${listItems.length} items`); } catch (error) { - log("Error during synchronization") + log(`Error during synchronization: ${error.message || error}`) console.error(error); } } From 723994a31886905598607e35270f15fcb6bf0387 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 11:03:00 -0700 Subject: [PATCH 06/22] Prompt user to pick reminder list when configured name not found Shows an action sheet of all available reminder lists on the device instead of silently exiting when reminderListName doesn't match. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index d06627c..8cca062 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -95,10 +95,23 @@ async function makeLogin() { async function synchronizeReminders() { try { - const reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); + let reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); if (!reminderCalendar) { - log(`Reminders list "${reminderListName}" not found — check the reminderListName setting`) - return + log(`Reminders list "${reminderListName}" not found — prompting user to pick one`) + const allLists = await Calendar.forReminders() + if (!allLists || allLists.length === 0) { + log("No reminder lists found on this device") + return + } + const alert = new Alert() + alert.title = "Reminders List Not Found" + alert.message = `"${reminderListName}" doesn't exist. Pick a list to use:` + for (const list of allLists) alert.addAction(list.title) + alert.addCancelAction("Cancel") + const idx = await alert.presentSheet() + if (idx === -1) return + reminderCalendar = allLists[idx] + log(`User selected: ${reminderCalendar.title}`) } const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; From 12f4aa6c7b6f3df4ed8e9615187f9b8a3d87acc1 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 11:21:34 -0700 Subject: [PATCH 07/22] Persist chosen reminder list name to settings file Saves the user-selected list to AlexaToReminders/settings.json so subsequent runs use it automatically without prompting again. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 8cca062..fde317e 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -43,8 +43,8 @@ $$$$$$$$$$$$$$$$$$$$$$$ //set baseURL based on your home country url const baseURL = 'https://www.amazon.com' -//include the reminder list name exactly as it is in Reminders app -const reminderListName = 'Shopping' +//include the reminder list name exactly as it is in Reminders app — overridden by saved settings +let reminderListName = 'Shopping' //signInKey should be specific for your language. English uses "Sign in". German uses "Anmelden" const signInKey = "Sign in" @@ -55,6 +55,17 @@ const withVar = "with" //withoutVar below needs to be set to your language's version of the word 'without' const withoutVar = "without" +const fm = FileManager.iCloud() +const settingsDir = fm.documentsDirectory() + '/AlexaToReminders/' +const settingsPath = settingsDir + 'settings.json' +if (!fm.fileExists(settingsDir)) fm.createDirectory(settingsDir, false) + +let settings = {} +if (fm.fileExists(settingsPath)) { + settings = JSON.parse(fm.readString(settingsPath)) +} +if (settings.reminderListName) reminderListName = settings.reminderListName + await main(); Script.complete(); @@ -112,6 +123,8 @@ async function synchronizeReminders() { if (idx === -1) return reminderCalendar = allLists[idx] log(`User selected: ${reminderCalendar.title}`) + settings.reminderListName = reminderCalendar.title + fm.writeString(settingsPath, JSON.stringify(settings)) } const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; From 1c343b0017b5d4fcbb59eba53d27d888e5ffeb8c Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 11:34:31 -0700 Subject: [PATCH 08/22] Add verbose logging throughout script Adds timestamped vlog() helper and covers every major step: auth check, login flow, list lookup, JSON parse, per-item create/skip/delete, and sync summary counts. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 127 +++++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index fde317e..7f379c5 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -12,7 +12,7 @@ Date made: 2023/10/26 Purpose: To sync alexa reminders to a iOS reminders list. previously IFTTT could do this, but Amazon revoked the Alexa IFTTT integration recently. -Setup: +Setup: - Insert the Amazon base url your country uses (if different from default) in the "baseURL" variable below - Insert the name of the desired reminders list in the "reminderListName" line. I use "Grocery and Shopping" with my wife, so i have that name entered. - Insert the wording for "Sign In" for the signInKeyvariable below. sometimes this varies based on region @@ -63,113 +63,156 @@ if (!fm.fileExists(settingsDir)) fm.createDirectory(settingsDir, false) let settings = {} if (fm.fileExists(settingsPath)) { settings = JSON.parse(fm.readString(settingsPath)) + vlog(`Loaded settings: ${JSON.stringify(settings)}`) +} +if (settings.reminderListName) { + reminderListName = settings.reminderListName + vlog(`Using saved reminder list name: "${reminderListName}"`) } -if (settings.reminderListName) reminderListName = settings.reminderListName await main(); Script.complete(); +// ─── Verbose logging ───────────────────────────────────────────────────────── + +function vlog(msg) { + const ts = new Date().toISOString().replace('T', ' ').substring(0, 19) + console.log(`[${ts}] ${msg}`) +} + +// ─── Auth ───────────────────────────────────────────────────────────────────── + async function checkIfUserIsAuthenticated() { + vlog("Checking authentication status...") try { const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const request = new Request(url); await request.load(); - if (request.response.statusCode === 401 || request.response.statusCode === 403) { + const status = request.response.statusCode + vlog(`Auth check response status: ${status}`) + if (status === 401 || status === 403) { + vlog("Not authenticated (401/403)") return false; } - + vlog("Authentication confirmed") return true; } catch (error) { + vlog(`Auth check threw an error: ${error.message || error}`) console.error(error); return false; } } async function makeLogin() { + vlog(`Loading Amazon login page: ${baseURL}`) const url = `${baseURL}`; const webView = new WebView(); - + try { await webView.loadURL(url) const html = await webView.getHTML(); + vlog(`Login page loaded — checking for sign-in indicator "${signInKey}"`) if (html.includes(signInKey)) { + vlog("Sign-in page detected — presenting WebView to user") await webView.present(false); + vlog("WebView dismissed — re-checking authentication") return await checkIfUserIsAuthenticated(); } - + vlog("Already signed in (sign-in indicator not found)") return true; } catch (error) { + vlog(`makeLogin error: ${error.message || error}`) console.error(error); return false; } } +// ─── Sync ───────────────────────────────────────────────────────────────────── + async function synchronizeReminders() { + vlog(`Looking up reminder list: "${reminderListName}"`) try { let reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); if (!reminderCalendar) { - log(`Reminders list "${reminderListName}" not found — prompting user to pick one`) + vlog(`Reminder list "${reminderListName}" not found — fetching all lists`) const allLists = await Calendar.forReminders() if (!allLists || allLists.length === 0) { - log("No reminder lists found on this device") + vlog("No reminder lists found on this device") return } + vlog(`Available lists: ${allLists.map(l => l.title).join(', ')}`) const alert = new Alert() alert.title = "Reminders List Not Found" alert.message = `"${reminderListName}" doesn't exist. Pick a list to use:` for (const list of allLists) alert.addAction(list.title) alert.addCancelAction("Cancel") const idx = await alert.presentSheet() - if (idx === -1) return + if (idx === -1) { + vlog("User cancelled list picker") + return + } reminderCalendar = allLists[idx] - log(`User selected: ${reminderCalendar.title}`) + vlog(`User selected: "${reminderCalendar.title}" — saving to settings`) settings.reminderListName = reminderCalendar.title fm.writeString(settingsPath, JSON.stringify(settings)) + } else { + vlog(`Reminder list found: "${reminderCalendar.title}"`) } + const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; + vlog(`Fetching Alexa shopping list from: ${url}`) const raw = await new Request(url).loadString() + vlog(`Response length: ${raw.length} chars`) + let json try { json = JSON.parse(raw) + vlog(`Response parsed successfully — found ${Object.keys(json).length} list(s)`) } catch (e) { - log("Response was not valid JSON — Amazon may require re-authentication") - log(raw.substring(0, 200)) + vlog("Response was not valid JSON — Amazon may require re-authentication") + vlog(`Response preview: ${raw.substring(0, 200)}`) return } - // Find the list with listType === "SHOPPING_LIST" and ignore other lists let listItems = []; let shoppingListId = null; for (const listId in json) { const list = json[listId]; + vlog(`Inspecting list ID "${listId}" — type: ${list.listInfo ? list.listInfo.listType : 'unknown'}`) if (list.listInfo && list.listInfo.listType === "SHOPPING_LIST") { listItems = list.listItems || []; shoppingListId = listId; - log(`Found shopping list: ${list.listInfo.listName || 'Default Shopping List'} with ${listItems.length} items`); + vlog(`Found SHOPPING_LIST: "${list.listInfo.listName || 'Default Shopping List'}" with ${listItems.length} item(s)`) break; } } - - // Exit if no shopping list found + if (!shoppingListId) { - log("No SHOPPING_LIST found in the response"); + vlog("No SHOPPING_LIST found in the response — nothing to sync") return; } - - // Exit if shopping list is empty + if (listItems.length === 0) { - log("Shopping list is empty, nothing to sync"); + vlog("Shopping list is empty — nothing to sync") return; } - + + vlog(`Fetching existing reminders from "${reminderCalendar.title}"`) const allReminders = await Reminder.all([reminderCalendar]); + const incompleteReminders = allReminders.filter(r => !r.isCompleted) + vlog(`Found ${allReminders.length} reminder(s), ${incompleteReminders.length} incomplete`) + + let created = 0, skipped = 0, deleted = 0, deleteFailed = 0 + for (const item of listItems) { if (!item.value) { + vlog(`Skipping item with missing value: ${JSON.stringify(item)}`) console.error(`Skipping Alexa list item with missing value: ${JSON.stringify(item)}`); continue; } + const reminderTitle = item.value.split(' ').map(word => { if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) { return word.toLowerCase(); @@ -177,53 +220,63 @@ async function synchronizeReminders() { return word.charAt(0).toUpperCase() + word.slice(1); } }).join(' '); - const incompleteReminders = allReminders.filter(reminder => !reminder.isCompleted); - const reminderExists = incompleteReminders.some(reminder => reminder.title === reminderTitle); + + const reminderExists = incompleteReminders.some(r => r.title === reminderTitle); if (!reminderExists) { + vlog(`Creating reminder: "${reminderTitle}"`) const reminder = new Reminder(); reminder.title = reminderTitle; reminder.calendar = reminderCalendar; await reminder.save(); - + created++ + } else { + vlog(`Reminder already exists, skipping: "${reminderTitle}"`) + skipped++ } + vlog(`Deleting item from Alexa list: "${item.value}"`) const request = new Request(deleteUrl); request.method = "DELETE"; - request.headers = { - "Content-Type": "application/json" - }; + request.headers = { "Content-Type": "application/json" }; request.body = JSON.stringify(item); - + try { - const response = await request.loadString(); - log(`Deleted item from Alexa: ${item.value}`); + await request.loadString(); + vlog(`Deleted from Alexa: "${item.value}"`) + deleted++ } catch (deleteError) { + vlog(`Failed to delete "${item.value}" from Alexa: ${deleteError.message || deleteError}`) console.error(`Failed to delete item: ${item.value}`); console.error(deleteError); + deleteFailed++ } } - - log(`Sync completed: processed ${listItems.length} items`); + + vlog(`Sync complete — created: ${created}, skipped: ${skipped}, alexa deleted: ${deleted}, delete failures: ${deleteFailed}`) } catch (error) { - log(`Error during synchronization: ${error.message || error}`) + vlog(`Error during synchronization: ${error.message || error}`) console.error(error); } } +// ─── Entry point ────────────────────────────────────────────────────────────── async function main() { + vlog("=== Alexa To Reminders: starting ===") const isAuthenticated = await checkIfUserIsAuthenticated(); - log(`authenticated? ${isAuthenticated}`); - + vlog(`authenticated? ${isAuthenticated}`); + if (!isAuthenticated) { + vlog("Initiating login flow") const loggedIn = await makeLogin(); - log(`loggedIn? ${loggedIn}`); + vlog(`loggedIn? ${loggedIn}`); if (!loggedIn) { - console.log('Login failed. Exiting.'); + vlog("Login failed — exiting") return; } } await synchronizeReminders(); + vlog("=== Alexa To Reminders: done ===") } From 23d3cad024b22e7bd4980407fb181eb88b0aeb84 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 11:50:54 -0700 Subject: [PATCH 09/22] Fix false positive auth and unexpected API response type - Validate parsed JSON is a plain object before iterating lists; Amazon was returning a quoted string which for..in treated as chars - makeLogin now always re-checks the API after loading the homepage instead of assuming success when the sign-in indicator is absent Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 7f379c5..1010255 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -118,8 +118,8 @@ async function makeLogin() { vlog("WebView dismissed — re-checking authentication") return await checkIfUserIsAuthenticated(); } - vlog("Already signed in (sign-in indicator not found)") - return true; + vlog("Sign-in indicator not found on homepage — verifying API session directly") + return await checkIfUserIsAuthenticated(); } catch (error) { vlog(`makeLogin error: ${error.message || error}`) console.error(error); @@ -168,13 +168,20 @@ async function synchronizeReminders() { let json try { json = JSON.parse(raw) - vlog(`Response parsed successfully — found ${Object.keys(json).length} list(s)`) } catch (e) { vlog("Response was not valid JSON — Amazon may require re-authentication") vlog(`Response preview: ${raw.substring(0, 200)}`) return } + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + vlog(`Unexpected response type: ${typeof json} — value: ${JSON.stringify(json).substring(0, 200)}`) + vlog("Amazon session may have expired — please run the script manually to re-authenticate") + return + } + + vlog(`Response parsed successfully — found ${Object.keys(json).length} list(s)`) + let listItems = []; let shoppingListId = null; From fb15552fc29c841ec15f555dc3f269b20d9df797 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 11:58:33 -0700 Subject: [PATCH 10/22] Full QA pass on Alexa To Reminders script and tests Script fixes: - vlog now uses local device time instead of UTC - checkIfUserIsAuthenticated returns true only for 2xx (was accepting 5xx) - makeLogin presents fallback WebView when homepage looks logged-in but API still returns 401, so user can always recover a stale session Test fixes: - makeCheckIfUserIsAuthenticated helper updated to match 2xx-only logic - makeMakeLogin helper updated to match fallback WebView behavior - Added: 5xx/302 rejection tests for auth check - Added: fallback WebView test for makeLogin - Added: JSON response type validation suite (string/null/array rejection) - Added: settings persistence suite (save, load, fallback, key preservation) 160 tests, 0 failures Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 23 +++++-- tests/alexa-reminders.test.js | 122 ++++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 1010255..3d5a7b4 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -76,7 +76,9 @@ Script.complete(); // ─── Verbose logging ───────────────────────────────────────────────────────── function vlog(msg) { - const ts = new Date().toISOString().replace('T', ' ').substring(0, 19) + const now = new Date() + const pad = n => String(n).padStart(2, '0') + const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` console.log(`[${ts}] ${msg}`) } @@ -90,12 +92,12 @@ async function checkIfUserIsAuthenticated() { await request.load(); const status = request.response.statusCode vlog(`Auth check response status: ${status}`) - if (status === 401 || status === 403) { - vlog("Not authenticated (401/403)") - return false; + if (status >= 200 && status < 300) { + vlog("Authentication confirmed") + return true; } - vlog("Authentication confirmed") - return true; + vlog(`Not authenticated (status ${status})`) + return false; } catch (error) { vlog(`Auth check threw an error: ${error.message || error}`) console.error(error); @@ -119,7 +121,14 @@ async function makeLogin() { return await checkIfUserIsAuthenticated(); } vlog("Sign-in indicator not found on homepage — verifying API session directly") - return await checkIfUserIsAuthenticated(); + const apiAuthenticated = await checkIfUserIsAuthenticated() + if (!apiAuthenticated) { + vlog("API session not authenticated — presenting WebView for manual sign-in") + await webView.present(false) + vlog("WebView dismissed — re-checking authentication") + return await checkIfUserIsAuthenticated() + } + return true; } catch (error) { vlog(`makeLogin error: ${error.message || error}`) console.error(error); diff --git a/tests/alexa-reminders.test.js b/tests/alexa-reminders.test.js index fa88f98..7623d23 100644 --- a/tests/alexa-reminders.test.js +++ b/tests/alexa-reminders.test.js @@ -2,7 +2,7 @@ 'use strict' const { - makeRequest, makeWebView, makeAlert, makeReminder, makeCalendar, + makeRequest, makeWebView, makeAlert, makeReminder, makeCalendar, makeFileManager, } = require('./scriptable-mocks') // ─── Pure logic extracted from the script ──────────────────────────────────── @@ -24,10 +24,9 @@ function makeCheckIfUserIsAuthenticated(baseURL, requestFactory) { const url = `${baseURL}/alexashoppinglists/api/getlistitems` const request = requestFactory(url) await request.load() - if (request.response.statusCode === 401 || request.response.statusCode === 403) { - return false - } - return true + const status = request.response.statusCode + if (status >= 200 && status < 300) return true + return false } catch (error) { return false } @@ -45,6 +44,11 @@ function makeMakeLogin(baseURL, signInKey, checkIfUserIsAuthenticated, webViewFa await webView.present(false) return await checkIfUserIsAuthenticated() } + const apiAuthenticated = await checkIfUserIsAuthenticated() + if (!apiAuthenticated) { + await webView.present(false) + return await checkIfUserIsAuthenticated() + } return true } catch (error) { return false @@ -177,18 +181,45 @@ module.exports = async function({ test, describe, assert }) { await check() assert.equal(capturedUrl, 'https://www.amazon.co.uk/alexashoppinglists/api/getlistitems') }) + + test('returns false when server responds with 500', async () => { + const req = makeRequest({ response: { statusCode: 500 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) + + test('returns false when server responds with 302 redirect', async () => { + const req = makeRequest({ response: { statusCode: 302 } }) + const check = makeCheckIfUserIsAuthenticated('https://amazon.com', () => req) + assert.equal(await check(), false) + }) }) // ─── makeLogin ───────────────────────────────────────────────────────────── await describe('makeLogin', async () => { - test('returns true when page does not contain the sign-in key (already logged in)', async () => { + test('returns true when sign-in indicator absent and API confirms authenticated', async () => { const wv = makeWebView({ getHTML: async () => 'Welcome back, User!' }) const check = async () => true const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) assert.equal(await login(), true) }) + test('presents fallback WebView when sign-in indicator absent but API returns 401', async () => { + let presented = false + const wv = makeWebView({ + getHTML: async () => 'Welcome back, User!', + present: async () => { presented = true }, + }) + let callCount = 0 + const check = async () => { callCount++; return false } + const login = makeMakeLogin('https://amazon.com', 'Sign in', check, () => wv) + const result = await login() + assert.equal(presented, true) + assert.equal(result, false) + assert.equal(callCount, 2) // checked before and after fallback WebView + }) + test('presents WebView when sign-in key is detected', async () => { let presented = false const wv = makeWebView({ @@ -418,6 +449,85 @@ module.exports = async function({ test, describe, assert }) { }) }) + // ─── JSON response type validation ──────────────────────────────────────── + + await describe('JSON response type validation', async () => { + function isValidListResponse(json) { + return typeof json === 'object' && json !== null && !Array.isArray(json) + } + + test('accepts a plain object', () => { + assert.equal(isValidListResponse({ list1: {} }), true) + }) + + test('rejects a JSON string (Amazon session error message)', () => { + assert.equal(isValidListResponse("Not authenticated"), false) + }) + + test('rejects null', () => { + assert.equal(isValidListResponse(null), false) + }) + + test('rejects an array', () => { + assert.equal(isValidListResponse([1, 2, 3]), false) + }) + + test('rejects a number', () => { + assert.equal(isValidListResponse(42), false) + }) + }) + + // ─── settings persistence ───────────────────────────────────────────────── + + await describe('settings persistence', async () => { + test('saves selected reminderListName to settings file', () => { + const fm = makeFileManager() + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + const settings = {} + settings.reminderListName = 'Grocery' + fm.writeString(settingsPath, JSON.stringify(settings)) + const loaded = JSON.parse(fm.readString(settingsPath)) + assert.equal(loaded.reminderListName, 'Grocery') + }) + + test('loads reminderListName from settings when present', () => { + const fm = makeFileManager({ + '/mock/documents/AlexaToReminders/settings.json': JSON.stringify({ reminderListName: 'My List' }) + }) + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + let reminderListName = 'Shopping' + if (fm.fileExists(settingsPath)) { + const s = JSON.parse(fm.readString(settingsPath)) + if (s.reminderListName) reminderListName = s.reminderListName + } + assert.equal(reminderListName, 'My List') + }) + + test('falls back to default reminderListName when settings file absent', () => { + const fm = makeFileManager() + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + let reminderListName = 'Shopping' + if (fm.fileExists(settingsPath)) { + const s = JSON.parse(fm.readString(settingsPath)) + if (s.reminderListName) reminderListName = s.reminderListName + } + assert.equal(reminderListName, 'Shopping') + }) + + test('preserves existing settings keys when saving reminderListName', () => { + const fm = makeFileManager({ + '/mock/documents/AlexaToReminders/settings.json': JSON.stringify({ someOtherKey: true }) + }) + const settingsPath = '/mock/documents/AlexaToReminders/settings.json' + const settings = JSON.parse(fm.readString(settingsPath)) + settings.reminderListName = 'Groceries' + fm.writeString(settingsPath, JSON.stringify(settings)) + const loaded = JSON.parse(fm.readString(settingsPath)) + assert.equal(loaded.reminderListName, 'Groceries') + assert.equal(loaded.someOtherKey, true) + }) + }) + // ─── main orchestration ──────────────────────────────────────────────────── await describe('main flow', async () => { From df4ad21d13ab95a861960e193731874654bc8408 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 12:07:26 -0700 Subject: [PATCH 11/22] Point fallback WebView to API URL to refresh session cookies Loading the Amazon homepage wasn't establishing the right cookies for the shopping list API. Loading the API URL directly forces Amazon to set the correct session cookies for that endpoint. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 3d5a7b4..516bc49 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -123,8 +123,10 @@ async function makeLogin() { vlog("Sign-in indicator not found on homepage — verifying API session directly") const apiAuthenticated = await checkIfUserIsAuthenticated() if (!apiAuthenticated) { - vlog("API session not authenticated — presenting WebView for manual sign-in") - await webView.present(false) + vlog("API session not authenticated — loading API URL in WebView to refresh session cookies") + const apiView = new WebView() + await apiView.loadURL(`${baseURL}/alexashoppinglists/api/getlistitems`) + await apiView.present(false) vlog("WebView dismissed — re-checking authentication") return await checkIfUserIsAuthenticated() } From 3d138f75237205ad334a5b64a9c1a349ed69c211 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 12:22:54 -0700 Subject: [PATCH 12/22] Replace Request with shared WebView for all API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request and WebView use separate cookie stores on iOS — logging in via WebView had no effect on subsequent Request calls. Now a single sessionView WebView instance is used for both auth checking and data fetching, ensuring cookies are consistent throughout the session. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 65 +++++++++++++++++------------------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 516bc49..accec4e 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -70,6 +70,11 @@ if (settings.reminderListName) { vlog(`Using saved reminder list name: "${reminderListName}"`) } +// Single WebView instance shared across auth and data fetching so cookies +// are consistent — Request uses a separate cookie store on iOS and cannot +// see cookies set by WebView. +const sessionView = new WebView() + await main(); Script.complete(); @@ -82,55 +87,50 @@ function vlog(msg) { console.log(`[${ts}] ${msg}`) } +// ─── WebView fetch helper ───────────────────────────────────────────────────── + +async function fetchAPIText() { + const url = `${baseURL}/alexashoppinglists/api/getlistitems` + vlog(`Fetching API via WebView: ${url}`) + await sessionView.loadURL(url) + const text = await sessionView.evaluateJavaScript('document.body.innerText') + vlog(`API response length: ${text ? text.length : 0} chars`) + return text || '' +} + // ─── Auth ───────────────────────────────────────────────────────────────────── async function checkIfUserIsAuthenticated() { vlog("Checking authentication status...") try { - const url = `${baseURL}/alexashoppinglists/api/getlistitems`; - const request = new Request(url); - await request.load(); - const status = request.response.statusCode - vlog(`Auth check response status: ${status}`) - if (status >= 200 && status < 300) { + const text = await fetchAPIText() + const json = JSON.parse(text) + if (typeof json === 'object' && json !== null && !Array.isArray(json)) { vlog("Authentication confirmed") - return true; + return true } - vlog(`Not authenticated (status ${status})`) - return false; + vlog(`Unexpected response type: ${typeof json} — value: ${JSON.stringify(json).substring(0, 100)}`) + return false } catch (error) { - vlog(`Auth check threw an error: ${error.message || error}`) - console.error(error); + vlog(`Auth check error: ${error.message || error}`) return false; } } async function makeLogin() { vlog(`Loading Amazon login page: ${baseURL}`) - const url = `${baseURL}`; - const webView = new WebView(); - try { - await webView.loadURL(url) - const html = await webView.getHTML(); + await sessionView.loadURL(baseURL) + const html = await sessionView.getHTML(); vlog(`Login page loaded — checking for sign-in indicator "${signInKey}"`) if (html.includes(signInKey)) { vlog("Sign-in page detected — presenting WebView to user") - await webView.present(false); - vlog("WebView dismissed — re-checking authentication") - return await checkIfUserIsAuthenticated(); - } - vlog("Sign-in indicator not found on homepage — verifying API session directly") - const apiAuthenticated = await checkIfUserIsAuthenticated() - if (!apiAuthenticated) { - vlog("API session not authenticated — loading API URL in WebView to refresh session cookies") - const apiView = new WebView() - await apiView.loadURL(`${baseURL}/alexashoppinglists/api/getlistitems`) - await apiView.present(false) - vlog("WebView dismissed — re-checking authentication") - return await checkIfUserIsAuthenticated() + } else { + vlog("Already appears logged in — presenting WebView to refresh API session") } - return true; + await sessionView.present(false) + vlog("WebView dismissed — re-checking authentication") + return await checkIfUserIsAuthenticated(); } catch (error) { vlog(`makeLogin error: ${error.message || error}`) console.error(error); @@ -170,11 +170,8 @@ async function synchronizeReminders() { vlog(`Reminder list found: "${reminderCalendar.title}"`) } - const url = `${baseURL}/alexashoppinglists/api/getlistitems`; const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; - vlog(`Fetching Alexa shopping list from: ${url}`) - const raw = await new Request(url).loadString() - vlog(`Response length: ${raw.length} chars`) + const raw = await fetchAPIText() let json try { From cecf30566a6397848b086ed0f6c6693d0d68a0d8 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 13:41:26 -0700 Subject: [PATCH 13/22] Use JS fetch() inside WebView for all Amazon API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scriptable's Request and WebView have completely separate cookie stores on iOS — no amount of WebView login would affect Request calls. Replaced all Amazon API calls with wvFetch(), which executes fetch() inside the WebView's JS context using its own cookie jar. The same sessionView is loaded on amazon.com first to establish same-origin context, then all GET and DELETE calls run through it. Also simplified auth flow: ensureAuthenticated() tries the API once, presents WebView if it fails, then retries once — and passes the already-fetched JSON directly to synchronizeReminders() to avoid a redundant second API call. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 227 ++++++++++++++++------------------- 1 file changed, 102 insertions(+), 125 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index accec4e..6204da7 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -70,13 +70,13 @@ if (settings.reminderListName) { vlog(`Using saved reminder list name: "${reminderListName}"`) } -// Single WebView instance shared across auth and data fetching so cookies -// are consistent — Request uses a separate cookie store on iOS and cannot -// see cookies set by WebView. +// Single WebView loaded on amazon.com so JS fetch() calls use its cookie jar. +// Scriptable's Request uses a separate cookie store and cannot share +// WebView sessions, so all Amazon API calls go through this WebView. const sessionView = new WebView() -await main(); -Script.complete(); +await main() +Script.complete() // ─── Verbose logging ───────────────────────────────────────────────────────── @@ -87,63 +87,88 @@ function vlog(msg) { console.log(`[${ts}] ${msg}`) } -// ─── WebView fetch helper ───────────────────────────────────────────────────── +// ─── WebView API helpers ────────────────────────────────────────────────────── + +// Execute fetch() inside the WebView's JS context so Amazon's session +// cookies are automatically included. Must call loadURL(baseURL) first +// to establish the same-origin context. +async function wvFetch(url, options = {}) { + const optsJSON = JSON.stringify(options) + const js = ` + fetch(${JSON.stringify(url)}, ${optsJSON}) + .then(r => r.text()) + .then(t => completion(t)) + .catch(e => completion('__ERROR__:' + e.message)) + ` + const result = await sessionView.evaluateJavaScript(js, true) + if (typeof result === 'string' && result.startsWith('__ERROR__:')) { + throw new Error(result.replace('__ERROR__:', '')) + } + return result || '' +} -async function fetchAPIText() { +async function fetchListJSON() { const url = `${baseURL}/alexashoppinglists/api/getlistitems` - vlog(`Fetching API via WebView: ${url}`) - await sessionView.loadURL(url) - const text = await sessionView.evaluateJavaScript('document.body.innerText') - vlog(`API response length: ${text ? text.length : 0} chars`) - return text || '' + vlog(`Fetching shopping list: ${url}`) + const text = await wvFetch(url, { credentials: 'include' }) + vlog(`Response length: ${text.length} chars`) + const json = JSON.parse(text) + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + vlog(`Unexpected response type (${typeof json}): ${JSON.stringify(json).substring(0, 100)}`) + return null + } + return json +} + +async function deleteListItem(item) { + const url = `${baseURL}/alexashoppinglists/api/deletelistitem` + await wvFetch(url, { + method: 'DELETE', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item), + }) } // ─── Auth ───────────────────────────────────────────────────────────────────── -async function checkIfUserIsAuthenticated() { - vlog("Checking authentication status...") +// Load amazon.com into the WebView (establishes same-origin context for +// wvFetch), then attempt the API call. If not authenticated, present the +// WebView so the user can sign in, then try once more. +async function ensureAuthenticated() { + vlog(`Loading Amazon homepage to establish session context: ${baseURL}`) + await sessionView.loadURL(baseURL) + const html = await sessionView.getHTML() + vlog(`Homepage loaded — sign-in indicator present: ${html.includes(signInKey)}`) + try { - const text = await fetchAPIText() - const json = JSON.parse(text) - if (typeof json === 'object' && json !== null && !Array.isArray(json)) { - vlog("Authentication confirmed") - return true - } - vlog(`Unexpected response type: ${typeof json} — value: ${JSON.stringify(json).substring(0, 100)}`) - return false - } catch (error) { - vlog(`Auth check error: ${error.message || error}`) - return false; + const json = await fetchListJSON() + if (json) { vlog("Already authenticated"); return json } + } catch (e) { + vlog(`Initial API attempt failed: ${e.message || e}`) } -} -async function makeLogin() { - vlog(`Loading Amazon login page: ${baseURL}`) + vlog("Not authenticated — presenting WebView for sign-in") + await sessionView.present(false) + vlog("WebView dismissed — retrying API") + try { - await sessionView.loadURL(baseURL) - const html = await sessionView.getHTML(); - vlog(`Login page loaded — checking for sign-in indicator "${signInKey}"`) - if (html.includes(signInKey)) { - vlog("Sign-in page detected — presenting WebView to user") - } else { - vlog("Already appears logged in — presenting WebView to refresh API session") - } - await sessionView.present(false) - vlog("WebView dismissed — re-checking authentication") - return await checkIfUserIsAuthenticated(); - } catch (error) { - vlog(`makeLogin error: ${error.message || error}`) - console.error(error); - return false; + const json = await fetchListJSON() + if (json) { vlog("Authenticated after sign-in"); return json } + vlog("API still returned non-object after sign-in") + } catch (e) { + vlog(`Post-login API attempt failed: ${e.message || e}`) } + + return null } // ─── Sync ───────────────────────────────────────────────────────────────────── -async function synchronizeReminders() { +async function synchronizeReminders(json) { vlog(`Looking up reminder list: "${reminderListName}"`) try { - let reminderCalendar = await Calendar.forRemindersByTitle(reminderListName); + let reminderCalendar = await Calendar.forRemindersByTitle(reminderListName) if (!reminderCalendar) { vlog(`Reminder list "${reminderListName}" not found — fetching all lists`) const allLists = await Calendar.forReminders() @@ -158,10 +183,7 @@ async function synchronizeReminders() { for (const list of allLists) alert.addAction(list.title) alert.addCancelAction("Cancel") const idx = await alert.presentSheet() - if (idx === -1) { - vlog("User cancelled list picker") - return - } + if (idx === -1) { vlog("User cancelled list picker"); return } reminderCalendar = allLists[idx] vlog(`User selected: "${reminderCalendar.title}" — saving to settings`) settings.reminderListName = reminderCalendar.title @@ -170,52 +192,25 @@ async function synchronizeReminders() { vlog(`Reminder list found: "${reminderCalendar.title}"`) } - const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`; - const raw = await fetchAPIText() - - let json - try { - json = JSON.parse(raw) - } catch (e) { - vlog("Response was not valid JSON — Amazon may require re-authentication") - vlog(`Response preview: ${raw.substring(0, 200)}`) - return - } - - if (typeof json !== 'object' || json === null || Array.isArray(json)) { - vlog(`Unexpected response type: ${typeof json} — value: ${JSON.stringify(json).substring(0, 200)}`) - vlog("Amazon session may have expired — please run the script manually to re-authenticate") - return - } - - vlog(`Response parsed successfully — found ${Object.keys(json).length} list(s)`) - - let listItems = []; - let shoppingListId = null; - + vlog(`Scanning ${Object.keys(json).length} list(s) for SHOPPING_LIST...`) + let listItems = [] + let shoppingListId = null for (const listId in json) { - const list = json[listId]; - vlog(`Inspecting list ID "${listId}" — type: ${list.listInfo ? list.listInfo.listType : 'unknown'}`) - if (list.listInfo && list.listInfo.listType === "SHOPPING_LIST") { - listItems = list.listItems || []; - shoppingListId = listId; - vlog(`Found SHOPPING_LIST: "${list.listInfo.listName || 'Default Shopping List'}" with ${listItems.length} item(s)`) - break; + const list = json[listId] + vlog(` List "${listId}" — type: ${list.listInfo ? list.listInfo.listType : 'unknown'}`) + if (list.listInfo && list.listInfo.listType === 'SHOPPING_LIST') { + listItems = list.listItems || [] + shoppingListId = listId + vlog(`Found SHOPPING_LIST: "${list.listInfo.listName || 'Default'}" with ${listItems.length} item(s)`) + break } } - if (!shoppingListId) { - vlog("No SHOPPING_LIST found in the response — nothing to sync") - return; - } - - if (listItems.length === 0) { - vlog("Shopping list is empty — nothing to sync") - return; - } + if (!shoppingListId) { vlog("No SHOPPING_LIST found — nothing to sync"); return } + if (listItems.length === 0) { vlog("Shopping list is empty — nothing to sync"); return } vlog(`Fetching existing reminders from "${reminderCalendar.title}"`) - const allReminders = await Reminder.all([reminderCalendar]); + const allReminders = await Reminder.all([reminderCalendar]) const incompleteReminders = allReminders.filter(r => !r.isCompleted) vlog(`Found ${allReminders.length} reminder(s), ${incompleteReminders.length} incomplete`) @@ -224,46 +219,36 @@ async function synchronizeReminders() { for (const item of listItems) { if (!item.value) { vlog(`Skipping item with missing value: ${JSON.stringify(item)}`) - console.error(`Skipping Alexa list item with missing value: ${JSON.stringify(item)}`); - continue; + continue } const reminderTitle = item.value.split(' ').map(word => { if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) { - return word.toLowerCase(); - } else { - return word.charAt(0).toUpperCase() + word.slice(1); + return word.toLowerCase() } - }).join(' '); - - const reminderExists = incompleteReminders.some(r => r.title === reminderTitle); + return word.charAt(0).toUpperCase() + word.slice(1) + }).join(' ') + const reminderExists = incompleteReminders.some(r => r.title === reminderTitle) if (!reminderExists) { vlog(`Creating reminder: "${reminderTitle}"`) - const reminder = new Reminder(); - reminder.title = reminderTitle; - reminder.calendar = reminderCalendar; - await reminder.save(); + const reminder = new Reminder() + reminder.title = reminderTitle + reminder.calendar = reminderCalendar + await reminder.save() created++ } else { vlog(`Reminder already exists, skipping: "${reminderTitle}"`) skipped++ } - vlog(`Deleting item from Alexa list: "${item.value}"`) - const request = new Request(deleteUrl); - request.method = "DELETE"; - request.headers = { "Content-Type": "application/json" }; - request.body = JSON.stringify(item); - + vlog(`Deleting item from Alexa: "${item.value}"`) try { - await request.loadString(); + await deleteListItem(item) vlog(`Deleted from Alexa: "${item.value}"`) deleted++ } catch (deleteError) { - vlog(`Failed to delete "${item.value}" from Alexa: ${deleteError.message || deleteError}`) - console.error(`Failed to delete item: ${item.value}`); - console.error(deleteError); + vlog(`Failed to delete "${item.value}": ${deleteError.message || deleteError}`) deleteFailed++ } } @@ -271,7 +256,7 @@ async function synchronizeReminders() { vlog(`Sync complete — created: ${created}, skipped: ${skipped}, alexa deleted: ${deleted}, delete failures: ${deleteFailed}`) } catch (error) { vlog(`Error during synchronization: ${error.message || error}`) - console.error(error); + console.error(error) } } @@ -279,19 +264,11 @@ async function synchronizeReminders() { async function main() { vlog("=== Alexa To Reminders: starting ===") - const isAuthenticated = await checkIfUserIsAuthenticated(); - vlog(`authenticated? ${isAuthenticated}`); - - if (!isAuthenticated) { - vlog("Initiating login flow") - const loggedIn = await makeLogin(); - vlog(`loggedIn? ${loggedIn}`); - if (!loggedIn) { - vlog("Login failed — exiting") - return; - } + const json = await ensureAuthenticated() + if (!json) { + vlog("Could not authenticate — exiting") + return } - - await synchronizeReminders(); + await synchronizeReminders(json) vlog("=== Alexa To Reminders: done ===") } From b233b5904804eb6713fb15898b6d78430549e17e Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 13:47:35 -0700 Subject: [PATCH 14/22] =?UTF-8?q?Fix=20evaluateJavaScript=20unsupported=20?= =?UTF-8?q?type=20error=20=E2=80=94=20switch=20to=20XHR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scriptable's evaluateJavaScript completion callback does not support Promises. fetch() returns a Promise as the expression value before completion() fires, causing the "unsupported type" error. Replaced fetch() with XMLHttpRequest using onload/onerror callbacks, which call completion() directly and work correctly with Scriptable. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 6204da7..ce64e8e 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -89,17 +89,30 @@ function vlog(msg) { // ─── WebView API helpers ────────────────────────────────────────────────────── -// Execute fetch() inside the WebView's JS context so Amazon's session -// cookies are automatically included. Must call loadURL(baseURL) first -// to establish the same-origin context. +// Execute an XHR inside the WebView's JS context so Amazon's session +// cookies are automatically included. Uses XMLHttpRequest (not fetch) +// because Scriptable's evaluateJavaScript completion callback does not +// handle Promises — XHR's onload fires cleanly with the callback pattern. async function wvFetch(url, options = {}) { - const optsJSON = JSON.stringify(options) + const method = options.method || 'GET' + const body = (options.body != null) ? options.body : null + const headers = options.headers || {} + const js = ` - fetch(${JSON.stringify(url)}, ${optsJSON}) - .then(r => r.text()) - .then(t => completion(t)) - .catch(e => completion('__ERROR__:' + e.message)) + (function() { + var xhr = new XMLHttpRequest(); + xhr.open(${JSON.stringify(method)}, ${JSON.stringify(url)}, true); + xhr.withCredentials = true; + xhr.timeout = 30000; + var h = ${JSON.stringify(headers)}; + Object.keys(h).forEach(function(k) { xhr.setRequestHeader(k, h[k]); }); + xhr.onload = function() { completion(xhr.responseText); }; + xhr.onerror = function() { completion('__ERROR__:network error'); }; + xhr.ontimeout = function() { completion('__ERROR__:timeout'); }; + xhr.send(${body !== null ? JSON.stringify(body) : 'null'}); + })(); ` + const result = await sessionView.evaluateJavaScript(js, true) if (typeof result === 'string' && result.startsWith('__ERROR__:')) { throw new Error(result.replace('__ERROR__:', '')) From ced1fded931601c28ad76f5c1645c2d5b4141e74 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 13:52:18 -0700 Subject: [PATCH 15/22] Fix evaluateJavaScript by returning a Promise instead of using callback The completion() callback pattern causes "unsupported type" errors when called asynchronously. Scriptable natively resolves JS Promises returned from evaluateJavaScript, so wrapping the XHR in a Promise avoids the issue entirely. Removed useCallback flag. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index ce64e8e..6cab749 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -90,30 +90,30 @@ function vlog(msg) { // ─── WebView API helpers ────────────────────────────────────────────────────── // Execute an XHR inside the WebView's JS context so Amazon's session -// cookies are automatically included. Uses XMLHttpRequest (not fetch) -// because Scriptable's evaluateJavaScript completion callback does not -// handle Promises — XHR's onload fires cleanly with the callback pattern. +// cookies are automatically included. Returns a Promise from JS so +// Scriptable resolves it natively — the completion callback pattern +// causes "unsupported type" errors with async XHR/fetch results. async function wvFetch(url, options = {}) { const method = options.method || 'GET' const body = (options.body != null) ? options.body : null const headers = options.headers || {} const js = ` - (function() { + new Promise(function(resolve) { var xhr = new XMLHttpRequest(); xhr.open(${JSON.stringify(method)}, ${JSON.stringify(url)}, true); xhr.withCredentials = true; xhr.timeout = 30000; var h = ${JSON.stringify(headers)}; Object.keys(h).forEach(function(k) { xhr.setRequestHeader(k, h[k]); }); - xhr.onload = function() { completion(xhr.responseText); }; - xhr.onerror = function() { completion('__ERROR__:network error'); }; - xhr.ontimeout = function() { completion('__ERROR__:timeout'); }; + xhr.onload = function() { resolve(xhr.responseText); }; + xhr.onerror = function() { resolve('__ERROR__:network error'); }; + xhr.ontimeout = function() { resolve('__ERROR__:timeout'); }; xhr.send(${body !== null ? JSON.stringify(body) : 'null'}); - })(); + }) ` - const result = await sessionView.evaluateJavaScript(js, true) + const result = await sessionView.evaluateJavaScript(js) if (typeof result === 'string' && result.startsWith('__ERROR__:')) { throw new Error(result.replace('__ERROR__:', '')) } From 77d3fa48b6d5c75b12b99f09b43158b4288056a7 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 13:57:53 -0700 Subject: [PATCH 16/22] Rewrite auth: extract WebView cookies and inject into Request headers Dropped the WebView-for-everything approach. Instead: - Show a WebView for login once, then extract document.cookie - Inject extracted cookies as a Cookie header on all Request calls - Cache cookies in settings.json so subsequent runs skip the WebView - Fall back to login WebView only when cached cookies are missing/expired evaluateJavaScript on a simple synchronous expression (document.cookie) works reliably; async XHR/Promise patterns do not in this Scriptable env. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 296 +++++++++++++++++------------------ 1 file changed, 143 insertions(+), 153 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 6cab749..8785589 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -2,60 +2,40 @@ // These must be at the very top of the file. Do not edit. // icon-color: purple; icon-glyph: magic; /* -$$$$$$$$$$$$$$$$$$$$$$$ -$$$$$$$$$$ - Alexa To Reminders Access +Script made by: mvan231 — Date: 2023/10/26 -Script made by: mvan231 -Date made: 2023/10/26 - -Purpose: To sync alexa reminders to a iOS reminders list. previously IFTTT could do this, but Amazon revoked the Alexa IFTTT integration recently. +Syncs your Alexa shopping list to an iOS Reminders list. +Amazon revoked the IFTTT integration, so this script fills that gap. Setup: - - Insert the Amazon base url your country uses (if different from default) in the "baseURL" variable below - - Insert the name of the desired reminders list in the "reminderListName" line. I use "Grocery and Shopping" with my wife, so i have that name entered. - - Insert the wording for "Sign In" for the signInKeyvariable below. sometimes this varies based on region - - For proper naming preference, please use the withVar and withoutVar for your local language to properly set naming of the reminders to be created - - -When running the first time, the script will check if you are logged in. If not, it will notify and present with login page. After that, the script should run seamlessly. - -$$$$$$$$$$ -$$$$$$$$$$$$$$$$$$$$$$$ - -$$$$$$$$$$$$$$$$$$$$$$$ -$$$$$$$$$$ - -Version Info: -v6 had to make update to accomodate slight change on Amazon's end -v5 used code from user andereeicheln0z frok github repo issue linked below to inprove performance -https://github.com/mvan231/Scriptable/issues/25 - -v6 updated if statement from withVariable to withVar so it works properly - -v7 updated to specifically target SHOPPING_LIST type and ignore other lists - -$$$$$$$$$$ -$$$$$$$$$$$$$$$$$$$$$$$ + 1. Set baseURL to your country's Amazon domain if not amazon.com + 2. Set reminderListName to match your Reminders list exactly + (will prompt to pick one if it doesn't match) + 3. Run once — a login WebView will appear if needed. + After signing in, tap Done. The script reads your + session cookies from the WebView and uses them for + all future API calls via Scriptable's Request object. + +Version history: + v8 — Rewrote auth: extracts session cookies from WebView after login + and injects them into Request headers. Eliminates all cookie + store mismatch issues between WebView and Request. */ -//set baseURL based on your home country url -const baseURL = 'https://www.amazon.com' - -//include the reminder list name exactly as it is in Reminders app — overridden by saved settings -let reminderListName = 'Shopping' +// ─── Configuration ──────────────────────────────────────────────────────────── -//signInKey should be specific for your language. English uses "Sign in". German uses "Anmelden" -const signInKey = "Sign in" +const baseURL = 'https://www.amazon.com' +const signInKey = 'Sign in' +const withVar = 'with' +const withoutVar = 'without' -//withVar below needs to be set to your language's version of the word 'with' -const withVar = "with" +// Overridden by saved settings if present +let reminderListName = 'Shopping' -//withoutVar below needs to be set to your language's version of the word 'without' -const withoutVar = "without" +// ─── Settings ───────────────────────────────────────────────────────────────── -const fm = FileManager.iCloud() +const fm = FileManager.iCloud() const settingsDir = fm.documentsDirectory() + '/AlexaToReminders/' const settingsPath = settingsDir + 'settings.json' if (!fm.fileExists(settingsDir)) fm.createDirectory(settingsDir, false) @@ -70,15 +50,10 @@ if (settings.reminderListName) { vlog(`Using saved reminder list name: "${reminderListName}"`) } -// Single WebView loaded on amazon.com so JS fetch() calls use its cookie jar. -// Scriptable's Request uses a separate cookie store and cannot share -// WebView sessions, so all Amazon API calls go through this WebView. -const sessionView = new WebView() - await main() Script.complete() -// ─── Verbose logging ───────────────────────────────────────────────────────── +// ─── Logging ────────────────────────────────────────────────────────────────── function vlog(msg) { const now = new Date() @@ -87,45 +62,60 @@ function vlog(msg) { console.log(`[${ts}] ${msg}`) } -// ─── WebView API helpers ────────────────────────────────────────────────────── - -// Execute an XHR inside the WebView's JS context so Amazon's session -// cookies are automatically included. Returns a Promise from JS so -// Scriptable resolves it natively — the completion callback pattern -// causes "unsupported type" errors with async XHR/fetch results. -async function wvFetch(url, options = {}) { - const method = options.method || 'GET' - const body = (options.body != null) ? options.body : null - const headers = options.headers || {} +// ─── Cookie extraction ──────────────────────────────────────────────────────── +// After a WebView login, extract the amazon.com session cookies via JS +// and return them as a Cookie header string for use in Request objects. +async function extractCookiesFromWebView(webView) { + vlog('Extracting session cookies from WebView...') const js = ` - new Promise(function(resolve) { - var xhr = new XMLHttpRequest(); - xhr.open(${JSON.stringify(method)}, ${JSON.stringify(url)}, true); - xhr.withCredentials = true; - xhr.timeout = 30000; - var h = ${JSON.stringify(headers)}; - Object.keys(h).forEach(function(k) { xhr.setRequestHeader(k, h[k]); }); - xhr.onload = function() { resolve(xhr.responseText); }; - xhr.onerror = function() { resolve('__ERROR__:network error'); }; - xhr.ontimeout = function() { resolve('__ERROR__:timeout'); }; - xhr.send(${body !== null ? JSON.stringify(body) : 'null'}); - }) + (function() { + return document.cookie + })() ` + try { + const cookieStr = await webView.evaluateJavaScript(js) + vlog(`Extracted ${cookieStr ? cookieStr.split(';').length : 0} cookie(s)`) + return cookieStr || '' + } catch (e) { + vlog(`Cookie extraction failed: ${e.message || e}`) + return '' + } +} + +// ─── Amazon API ─────────────────────────────────────────────────────────────── - const result = await sessionView.evaluateJavaScript(js) - if (typeof result === 'string' && result.startsWith('__ERROR__:')) { - throw new Error(result.replace('__ERROR__:', '')) +async function apiGet(path, cookieHeader) { + const url = `${baseURL}${path}` + vlog(`GET ${url}`) + const req = new Request(url) + if (cookieHeader) req.headers = { Cookie: cookieHeader } + const text = await req.loadString() + vlog(`Response: ${text.length} chars — preview: ${text.substring(0, 80)}`) + return text +} + +async function apiDelete(path, body, cookieHeader) { + const url = `${baseURL}${path}` + vlog(`DELETE ${url}`) + const req = new Request(url) + req.method = 'DELETE' + req.headers = { + 'Content-Type': 'application/json', + ...(cookieHeader ? { Cookie: cookieHeader } : {}) } - return result || '' + req.body = JSON.stringify(body) + return req.loadString() } -async function fetchListJSON() { - const url = `${baseURL}/alexashoppinglists/api/getlistitems` - vlog(`Fetching shopping list: ${url}`) - const text = await wvFetch(url, { credentials: 'include' }) - vlog(`Response length: ${text.length} chars`) - const json = JSON.parse(text) +function parseListResponse(text) { + let json + try { + json = JSON.parse(text) + } catch (e) { + vlog(`Response is not valid JSON: ${e.message}`) + return null + } if (typeof json !== 'object' || json === null || Array.isArray(json)) { vlog(`Unexpected response type (${typeof json}): ${JSON.stringify(json).substring(0, 100)}`) return null @@ -133,72 +123,79 @@ async function fetchListJSON() { return json } -async function deleteListItem(item) { - const url = `${baseURL}/alexashoppinglists/api/deletelistitem` - await wvFetch(url, { - method: 'DELETE', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(item), - }) -} - // ─── Auth ───────────────────────────────────────────────────────────────────── -// Load amazon.com into the WebView (establishes same-origin context for -// wvFetch), then attempt the API call. If not authenticated, present the -// WebView so the user can sign in, then try once more. -async function ensureAuthenticated() { - vlog(`Loading Amazon homepage to establish session context: ${baseURL}`) - await sessionView.loadURL(baseURL) - const html = await sessionView.getHTML() - vlog(`Homepage loaded — sign-in indicator present: ${html.includes(signInKey)}`) - +// Try the API with the given cookie header. Returns parsed JSON or null. +async function tryFetch(cookieHeader) { try { - const json = await fetchListJSON() - if (json) { vlog("Already authenticated"); return json } + const text = await apiGet('/alexashoppinglists/api/getlistitems', cookieHeader) + return parseListResponse(text) } catch (e) { - vlog(`Initial API attempt failed: ${e.message || e}`) + vlog(`API request failed: ${e.message || e}`) + return null } +} - vlog("Not authenticated — presenting WebView for sign-in") - await sessionView.present(false) - vlog("WebView dismissed — retrying API") +// Show Amazon in a WebView, wait for the user to sign in, then extract +// cookies and return them as a header string. +async function loginAndGetCookies() { + vlog('Presenting Amazon WebView for sign-in...') + const wv = new WebView() + await wv.loadURL(baseURL) + await wv.present(false) + vlog('WebView dismissed — extracting cookies') + return extractCookiesFromWebView(wv) +} - try { - const json = await fetchListJSON() - if (json) { vlog("Authenticated after sign-in"); return json } - vlog("API still returned non-object after sign-in") - } catch (e) { - vlog(`Post-login API attempt failed: ${e.message || e}`) +async function ensureAuthenticated() { + // Try with no extra cookies first (may already be authenticated via system) + vlog('Attempting API without extra cookies...') + let json = await tryFetch(null) + if (json) { vlog('Authenticated via system session'); return { json, cookieHeader: null } } + + // Try with saved cookies from a previous login + if (settings.cookieHeader) { + vlog('Trying saved session cookies...') + json = await tryFetch(settings.cookieHeader) + if (json) { vlog('Authenticated via saved cookies'); return { json, cookieHeader: settings.cookieHeader } } + vlog('Saved cookies expired') + } + + // Need fresh login + const cookieHeader = await loginAndGetCookies() + if (cookieHeader) { + settings.cookieHeader = cookieHeader + fm.writeString(settingsPath, JSON.stringify(settings)) + vlog('Saved fresh cookies to settings') } + json = await tryFetch(cookieHeader || null) + if (json) { vlog('Authenticated after login'); return { json, cookieHeader } } + + vlog('Authentication failed — could not get valid API response') return null } // ─── Sync ───────────────────────────────────────────────────────────────────── -async function synchronizeReminders(json) { +async function synchronizeReminders(json, cookieHeader) { vlog(`Looking up reminder list: "${reminderListName}"`) try { let reminderCalendar = await Calendar.forRemindersByTitle(reminderListName) if (!reminderCalendar) { vlog(`Reminder list "${reminderListName}" not found — fetching all lists`) const allLists = await Calendar.forReminders() - if (!allLists || allLists.length === 0) { - vlog("No reminder lists found on this device") - return - } + if (!allLists || allLists.length === 0) { vlog('No reminder lists on device'); return } vlog(`Available lists: ${allLists.map(l => l.title).join(', ')}`) const alert = new Alert() - alert.title = "Reminders List Not Found" - alert.message = `"${reminderListName}" doesn't exist. Pick a list to use:` + alert.title = 'Reminders List Not Found' + alert.message = `"${reminderListName}" doesn't exist. Pick a list:` for (const list of allLists) alert.addAction(list.title) - alert.addCancelAction("Cancel") + alert.addCancelAction('Cancel') const idx = await alert.presentSheet() - if (idx === -1) { vlog("User cancelled list picker"); return } + if (idx === -1) { vlog('User cancelled'); return } reminderCalendar = allLists[idx] - vlog(`User selected: "${reminderCalendar.title}" — saving to settings`) + vlog(`User selected: "${reminderCalendar.title}" — saving`) settings.reminderListName = reminderCalendar.title fm.writeString(settingsPath, JSON.stringify(settings)) } else { @@ -206,8 +203,7 @@ async function synchronizeReminders(json) { } vlog(`Scanning ${Object.keys(json).length} list(s) for SHOPPING_LIST...`) - let listItems = [] - let shoppingListId = null + let listItems = [], shoppingListId = null for (const listId in json) { const list = json[listId] vlog(` List "${listId}" — type: ${list.listInfo ? list.listInfo.listType : 'unknown'}`) @@ -219,56 +215,50 @@ async function synchronizeReminders(json) { } } - if (!shoppingListId) { vlog("No SHOPPING_LIST found — nothing to sync"); return } - if (listItems.length === 0) { vlog("Shopping list is empty — nothing to sync"); return } + if (!shoppingListId) { vlog('No SHOPPING_LIST found — nothing to sync'); return } + if (listItems.length === 0) { vlog('Shopping list is empty — nothing to sync'); return } vlog(`Fetching existing reminders from "${reminderCalendar.title}"`) const allReminders = await Reminder.all([reminderCalendar]) const incompleteReminders = allReminders.filter(r => !r.isCompleted) - vlog(`Found ${allReminders.length} reminder(s), ${incompleteReminders.length} incomplete`) + vlog(`${allReminders.length} total, ${incompleteReminders.length} incomplete`) let created = 0, skipped = 0, deleted = 0, deleteFailed = 0 for (const item of listItems) { - if (!item.value) { - vlog(`Skipping item with missing value: ${JSON.stringify(item)}`) - continue - } + if (!item.value) { vlog(`Skipping item with no value: ${JSON.stringify(item)}`); continue } const reminderTitle = item.value.split(' ').map(word => { - if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) { - return word.toLowerCase() - } + if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) return word.toLowerCase() return word.charAt(0).toUpperCase() + word.slice(1) }).join(' ') - const reminderExists = incompleteReminders.some(r => r.title === reminderTitle) - if (!reminderExists) { + if (incompleteReminders.some(r => r.title === reminderTitle)) { + vlog(`Already exists, skipping: "${reminderTitle}"`) + skipped++ + } else { vlog(`Creating reminder: "${reminderTitle}"`) const reminder = new Reminder() reminder.title = reminderTitle reminder.calendar = reminderCalendar await reminder.save() created++ - } else { - vlog(`Reminder already exists, skipping: "${reminderTitle}"`) - skipped++ } - vlog(`Deleting item from Alexa: "${item.value}"`) + vlog(`Deleting from Alexa: "${item.value}"`) try { - await deleteListItem(item) - vlog(`Deleted from Alexa: "${item.value}"`) + await apiDelete('/alexashoppinglists/api/deletelistitem', item, cookieHeader) + vlog(`Deleted: "${item.value}"`) deleted++ - } catch (deleteError) { - vlog(`Failed to delete "${item.value}": ${deleteError.message || deleteError}`) + } catch (e) { + vlog(`Delete failed for "${item.value}": ${e.message || e}`) deleteFailed++ } } - vlog(`Sync complete — created: ${created}, skipped: ${skipped}, alexa deleted: ${deleted}, delete failures: ${deleteFailed}`) + vlog(`Done — created: ${created}, skipped: ${skipped}, deleted: ${deleted}, delete failures: ${deleteFailed}`) } catch (error) { - vlog(`Error during synchronization: ${error.message || error}`) + vlog(`Sync error: ${error.message || error}`) console.error(error) } } @@ -276,12 +266,12 @@ async function synchronizeReminders(json) { // ─── Entry point ────────────────────────────────────────────────────────────── async function main() { - vlog("=== Alexa To Reminders: starting ===") - const json = await ensureAuthenticated() - if (!json) { - vlog("Could not authenticate — exiting") + vlog('=== Alexa To Reminders: starting ===') + const auth = await ensureAuthenticated() + if (!auth) { + vlog('Could not authenticate — exiting') return } - await synchronizeReminders(json) - vlog("=== Alexa To Reminders: done ===") + await synchronizeReminders(auth.json, auth.cookieHeader) + vlog('=== Alexa To Reminders: done ===') } From c889c7edfb17ba3610a2fae2a0ceb9898f0b6861 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 14:11:40 -0700 Subject: [PATCH 17/22] Rewrite v9: loadURL+getHTML for GET, loadHTML helper for DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates evaluateJavaScript for all auth-sensitive operations. Amazon's HttpOnly auth cookies stay in WKWebView and are used automatically by loadURL navigations and loadHTML XHR calls. - fetchListJSON: loadURL to API endpoint, getHTML to read response, extract JSON from HTML (pre tag or stripped body text) - deleteListItem: loadHTML with inline synchronous XHR helper page, getHTML to read the status result — no evaluateJavaScript needed - ensureAuthenticated: try API directly, present WebView if it fails, retry once after user dismisses Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 245 ++++++++++++++++------------------- 1 file changed, 113 insertions(+), 132 deletions(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index 8785589..e0274a3 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -6,37 +6,34 @@ Alexa To Reminders Access Script made by: mvan231 — Date: 2023/10/26 Syncs your Alexa shopping list to an iOS Reminders list. -Amazon revoked the IFTTT integration, so this script fills that gap. Setup: 1. Set baseURL to your country's Amazon domain if not amazon.com 2. Set reminderListName to match your Reminders list exactly (will prompt to pick one if it doesn't match) - 3. Run once — a login WebView will appear if needed. - After signing in, tap Done. The script reads your - session cookies from the WebView and uses them for - all future API calls via Scriptable's Request object. + 3. Run once — a login WebView will appear. Sign in, then tap Done. Version history: - v8 — Rewrote auth: extracts session cookies from WebView after login - and injects them into Request headers. Eliminates all cookie - store mismatch issues between WebView and Request. + v9 — Rewrote to use loadURL+getHTML for all GET calls and a loadHTML + helper page for DELETE calls. No evaluateJavaScript for auth- + sensitive operations — avoids "unsupported type" errors entirely. + Amazon auth cookies (including HttpOnly) stay inside WKWebView + and are used automatically for all navigations within it. */ // ─── Configuration ──────────────────────────────────────────────────────────── -const baseURL = 'https://www.amazon.com' -const signInKey = 'Sign in' -const withVar = 'with' -const withoutVar = 'without' +const baseURL = 'https://www.amazon.com' +const signInKey = 'Sign in' +const withVar = 'with' +const withoutVar = 'without' -// Overridden by saved settings if present let reminderListName = 'Shopping' // ─── Settings ───────────────────────────────────────────────────────────────── -const fm = FileManager.iCloud() -const settingsDir = fm.documentsDirectory() + '/AlexaToReminders/' +const fm = FileManager.iCloud() +const settingsDir = fm.documentsDirectory() + '/AlexaToReminders/' const settingsPath = settingsDir + 'settings.json' if (!fm.fileExists(settingsDir)) fm.createDirectory(settingsDir, false) @@ -50,6 +47,10 @@ if (settings.reminderListName) { vlog(`Using saved reminder list name: "${reminderListName}"`) } +// Single WebView — all Amazon requests run through this instance so +// auth cookies (including HttpOnly) are automatically included. +const sessionView = new WebView() + await main() Script.complete() @@ -62,163 +63,144 @@ function vlog(msg) { console.log(`[${ts}] ${msg}`) } -// ─── Cookie extraction ──────────────────────────────────────────────────────── - -// After a WebView login, extract the amazon.com session cookies via JS -// and return them as a Cookie header string for use in Request objects. -async function extractCookiesFromWebView(webView) { - vlog('Extracting session cookies from WebView...') - const js = ` - (function() { - return document.cookie - })() - ` - try { - const cookieStr = await webView.evaluateJavaScript(js) - vlog(`Extracted ${cookieStr ? cookieStr.split(';').length : 0} cookie(s)`) - return cookieStr || '' - } catch (e) { - vlog(`Cookie extraction failed: ${e.message || e}`) - return '' - } -} - -// ─── Amazon API ─────────────────────────────────────────────────────────────── +// ─── HTML content extraction ────────────────────────────────────────────────── -async function apiGet(path, cookieHeader) { - const url = `${baseURL}${path}` - vlog(`GET ${url}`) - const req = new Request(url) - if (cookieHeader) req.headers = { Cookie: cookieHeader } - const text = await req.loadString() - vlog(`Response: ${text.length} chars — preview: ${text.substring(0, 80)}`) - return text -} +// WKWebView wraps JSON responses in a minimal HTML page. +// Pull the text content out and attempt to parse it as JSON. +function extractJSONFromHTML(html) { + // Try
 wrapper first (common for JSON responses)
+  const preMatch = html.match(/]*>([\s\S]*?)<\/pre>/i)
+  let text = preMatch
+    ? preMatch[1]
+    : html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
 
-async function apiDelete(path, body, cookieHeader) {
-  const url = `${baseURL}${path}`
-  vlog(`DELETE ${url}`)
-  const req = new Request(url)
-  req.method = 'DELETE'
-  req.headers = {
-    'Content-Type': 'application/json',
-    ...(cookieHeader ? { Cookie: cookieHeader } : {})
-  }
-  req.body = JSON.stringify(body)
-  return req.loadString()
-}
+  text = text.trim()
+  vlog(`Extracted text (${text.length} chars): ${text.substring(0, 80)}`)
 
-function parseListResponse(text) {
-  let json
   try {
-    json = JSON.parse(text)
-  } catch (e) {
-    vlog(`Response is not valid JSON: ${e.message}`)
+    const json = JSON.parse(text)
+    if (typeof json === 'object' && json !== null && !Array.isArray(json)) {
+      vlog(`Valid JSON object with ${Object.keys(json).length} key(s)`)
+      return json
+    }
+    vlog(`Non-object JSON (${typeof json}): ${String(json).substring(0, 80)}`)
     return null
-  }
-  if (typeof json !== 'object' || json === null || Array.isArray(json)) {
-    vlog(`Unexpected response type (${typeof json}): ${JSON.stringify(json).substring(0, 100)}`)
+  } catch (e) {
+    vlog(`JSON parse failed: ${e.message}`)
     return null
   }
-  return json
 }
 
-// ─── Auth ─────────────────────────────────────────────────────────────────────
+// ─── Amazon API ───────────────────────────────────────────────────────────────
 
-// Try the API with the given cookie header. Returns parsed JSON or null.
-async function tryFetch(cookieHeader) {
-  try {
-    const text = await apiGet('/alexashoppinglists/api/getlistitems', cookieHeader)
-    return parseListResponse(text)
-  } catch (e) {
-    vlog(`API request failed: ${e.message || e}`)
-    return null
-  }
+async function fetchListJSON() {
+  const url = `${baseURL}/alexashoppinglists/api/getlistitems`
+  vlog(`Navigating to: ${url}`)
+  await sessionView.loadURL(url)
+  const html = await sessionView.getHTML()
+  vlog(`Response HTML length: ${html.length} chars`)
+  return extractJSONFromHTML(html)
 }
 
-// Show Amazon in a WebView, wait for the user to sign in, then extract
-// cookies and return them as a header string.
-async function loginAndGetCookies() {
-  vlog('Presenting Amazon WebView for sign-in...')
-  const wv = new WebView()
-  await wv.loadURL(baseURL)
-  await wv.present(false)
-  vlog('WebView dismissed — extracting cookies')
-  return extractCookiesFromWebView(wv)
+// Send a DELETE via a loadHTML helper page so the request runs inside
+// the WebView's authenticated session without needing evaluateJavaScript.
+async function deleteListItem(item) {
+  const deleteUrl = `${baseURL}/alexashoppinglists/api/deletelistitem`
+  // Encode the body safely so it survives embedding in HTML
+  const encodedBody = encodeURIComponent(JSON.stringify(item))
+
+  const helperPage = `
+
+`
+
+  await sessionView.loadHTML(helperPage, baseURL)
+  const resultHtml = await sessionView.getHTML()
+  const match = resultHtml.match(/]*>([\s\S]*?)<\/body>/i)
+  const result = (match ? match[1] : resultHtml).replace(/<[^>]+>/g, '').trim()
+  vlog(`Delete response: ${result}`)
+
+  if (result.startsWith('err:')) throw new Error(result)
 }
 
-async function ensureAuthenticated() {
-  // Try with no extra cookies first (may already be authenticated via system)
-  vlog('Attempting API without extra cookies...')
-  let json = await tryFetch(null)
-  if (json) { vlog('Authenticated via system session'); return { json, cookieHeader: null } }
-
-  // Try with saved cookies from a previous login
-  if (settings.cookieHeader) {
-    vlog('Trying saved session cookies...')
-    json = await tryFetch(settings.cookieHeader)
-    if (json) { vlog('Authenticated via saved cookies'); return { json, cookieHeader: settings.cookieHeader } }
-    vlog('Saved cookies expired')
-  }
-
-  // Need fresh login
-  const cookieHeader = await loginAndGetCookies()
-  if (cookieHeader) {
-    settings.cookieHeader = cookieHeader
-    fm.writeString(settingsPath, JSON.stringify(settings))
-    vlog('Saved fresh cookies to settings')
-  }
-
-  json = await tryFetch(cookieHeader || null)
-  if (json) { vlog('Authenticated after login'); return { json, cookieHeader } }
+// ─── Auth ─────────────────────────────────────────────────────────────────────
 
-  vlog('Authentication failed — could not get valid API response')
+async function ensureAuthenticated() {
+  // Try the API directly — if sessionView has a cached session it works immediately
+  vlog('Checking API access...')
+  let json = await fetchListJSON()
+  if (json) { vlog('Already authenticated'); return json }
+
+  // Not authenticated — present Amazon so the user can sign in
+  vlog('Presenting Amazon for sign-in...')
+  await sessionView.loadURL(baseURL)
+  const homeHtml = await sessionView.getHTML()
+  vlog(`Homepage loaded — sign-in indicator: ${homeHtml.includes(signInKey)}`)
+  await sessionView.present(false)
+  vlog('WebView dismissed — retrying API')
+
+  json = await fetchListJSON()
+  if (json) { vlog('Authenticated after sign-in'); return json }
+
+  vlog('Could not authenticate — API still not returning valid data')
   return null
 }
 
 // ─── Sync ─────────────────────────────────────────────────────────────────────
 
-async function synchronizeReminders(json, cookieHeader) {
+async function synchronizeReminders(json) {
   vlog(`Looking up reminder list: "${reminderListName}"`)
   try {
     let reminderCalendar = await Calendar.forRemindersByTitle(reminderListName)
     if (!reminderCalendar) {
-      vlog(`Reminder list "${reminderListName}" not found — fetching all lists`)
+      vlog(`"${reminderListName}" not found — fetching all lists`)
       const allLists = await Calendar.forReminders()
       if (!allLists || allLists.length === 0) { vlog('No reminder lists on device'); return }
-      vlog(`Available lists: ${allLists.map(l => l.title).join(', ')}`)
+      vlog(`Available: ${allLists.map(l => l.title).join(', ')}`)
       const alert = new Alert()
       alert.title = 'Reminders List Not Found'
       alert.message = `"${reminderListName}" doesn't exist. Pick a list:`
       for (const list of allLists) alert.addAction(list.title)
       alert.addCancelAction('Cancel')
       const idx = await alert.presentSheet()
-      if (idx === -1) { vlog('User cancelled'); return }
+      if (idx === -1) { vlog('Cancelled'); return }
       reminderCalendar = allLists[idx]
-      vlog(`User selected: "${reminderCalendar.title}" — saving`)
+      vlog(`Selected: "${reminderCalendar.title}" — saving`)
       settings.reminderListName = reminderCalendar.title
       fm.writeString(settingsPath, JSON.stringify(settings))
     } else {
-      vlog(`Reminder list found: "${reminderCalendar.title}"`)
+      vlog(`Reminder list: "${reminderCalendar.title}"`)
     }
 
     vlog(`Scanning ${Object.keys(json).length} list(s) for SHOPPING_LIST...`)
     let listItems = [], shoppingListId = null
     for (const listId in json) {
       const list = json[listId]
-      vlog(`  List "${listId}" — type: ${list.listInfo ? list.listInfo.listType : 'unknown'}`)
+      const type = list.listInfo ? list.listInfo.listType : 'unknown'
+      vlog(`  "${listId}" — ${type}`)
       if (list.listInfo && list.listInfo.listType === 'SHOPPING_LIST') {
         listItems = list.listItems || []
         shoppingListId = listId
-        vlog(`Found SHOPPING_LIST: "${list.listInfo.listName || 'Default'}" with ${listItems.length} item(s)`)
+        vlog(`Found: "${list.listInfo.listName || 'Default'}" with ${listItems.length} item(s)`)
         break
       }
     }
 
     if (!shoppingListId) { vlog('No SHOPPING_LIST found — nothing to sync'); return }
-    if (listItems.length === 0) { vlog('Shopping list is empty — nothing to sync'); return }
+    if (listItems.length === 0) { vlog('Shopping list empty — nothing to sync'); return }
 
-    vlog(`Fetching existing reminders from "${reminderCalendar.title}"`)
+    vlog(`Loading existing reminders from "${reminderCalendar.title}"`)
     const allReminders = await Reminder.all([reminderCalendar])
     const incompleteReminders = allReminders.filter(r => !r.isCompleted)
     vlog(`${allReminders.length} total, ${incompleteReminders.length} incomplete`)
@@ -226,7 +208,7 @@ async function synchronizeReminders(json, cookieHeader) {
     let created = 0, skipped = 0, deleted = 0, deleteFailed = 0
 
     for (const item of listItems) {
-      if (!item.value) { vlog(`Skipping item with no value: ${JSON.stringify(item)}`); continue }
+      if (!item.value) { vlog(`Skipping item with no value`); continue }
 
       const reminderTitle = item.value.split(' ').map(word => {
         if (word.toLowerCase() === withVar || word.toLowerCase() === withoutVar) return word.toLowerCase()
@@ -234,10 +216,10 @@ async function synchronizeReminders(json, cookieHeader) {
       }).join(' ')
 
       if (incompleteReminders.some(r => r.title === reminderTitle)) {
-        vlog(`Already exists, skipping: "${reminderTitle}"`)
+        vlog(`Skip (exists): "${reminderTitle}"`)
         skipped++
       } else {
-        vlog(`Creating reminder: "${reminderTitle}"`)
+        vlog(`Create: "${reminderTitle}"`)
         const reminder = new Reminder()
         reminder.title = reminderTitle
         reminder.calendar = reminderCalendar
@@ -245,18 +227,17 @@ async function synchronizeReminders(json, cookieHeader) {
         created++
       }
 
-      vlog(`Deleting from Alexa: "${item.value}"`)
+      vlog(`Delete from Alexa: "${item.value}"`)
       try {
-        await apiDelete('/alexashoppinglists/api/deletelistitem', item, cookieHeader)
-        vlog(`Deleted: "${item.value}"`)
+        await deleteListItem(item)
         deleted++
       } catch (e) {
-        vlog(`Delete failed for "${item.value}": ${e.message || e}`)
+        vlog(`Delete failed: ${e.message || e}`)
         deleteFailed++
       }
     }
 
-    vlog(`Done — created: ${created}, skipped: ${skipped}, deleted: ${deleted}, delete failures: ${deleteFailed}`)
+    vlog(`Done — created: ${created}, skipped: ${skipped}, deleted: ${deleted}, failures: ${deleteFailed}`)
   } catch (error) {
     vlog(`Sync error: ${error.message || error}`)
     console.error(error)
@@ -267,11 +248,11 @@ async function synchronizeReminders(json, cookieHeader) {
 
 async function main() {
   vlog('=== Alexa To Reminders: starting ===')
-  const auth = await ensureAuthenticated()
-  if (!auth) {
+  const json = await ensureAuthenticated()
+  if (!json) {
     vlog('Could not authenticate — exiting')
     return
   }
-  await synchronizeReminders(auth.json, auth.cookieHeader)
+  await synchronizeReminders(json)
   vlog('=== Alexa To Reminders: done ===')
 }

From 16c4317d3c880f5a6fc85123e8068051239fc652 Mon Sep 17 00:00:00 2001
From: Bryan Buchorn 
Date: Sat, 6 Jun 2026 14:37:18 -0700
Subject: [PATCH 18/22] Update default reminderListName to SHOPPING

Co-Authored-By: Claude Sonnet 4.6 
---
 Alexa To Reminders Access.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js
index e0274a3..2722986 100644
--- a/Alexa To Reminders Access.js	
+++ b/Alexa To Reminders Access.js	
@@ -28,7 +28,7 @@ const signInKey  = 'Sign in'
 const withVar    = 'with'
 const withoutVar = 'without'
 
-let reminderListName = 'Shopping'
+let reminderListName = 'SHOPPING'
 
 // ─── Settings ─────────────────────────────────────────────────────────────────
 

From 78166b598d54a06d3ea2e6eadffa8cd78828f8b1 Mon Sep 17 00:00:00 2001
From: Bryan Buchorn 
Date: Sat, 6 Jun 2026 15:38:46 -0700
Subject: [PATCH 19/22] Load Alexa lists page for sign-in to establish
 Alexa-specific session cookies

amazon.com homepage auth is insufficient for the Alexa Shopping List API.
The WebView must visit /alexa-lists first so Amazon sets the Alexa session
state before the API call is retried. Also adds an instruction alert so
the user knows to wait for the list to load before tapping Done.

Co-Authored-By: Claude Sonnet 4.6 
---
 Alexa To Reminders Access.js | 20 +++++++++++++++-----
 1 file changed, 15 insertions(+), 5 deletions(-)

diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js
index 2722986..9e1bf93 100644
--- a/Alexa To Reminders Access.js	
+++ b/Alexa To Reminders Access.js	
@@ -142,11 +142,21 @@ async function ensureAuthenticated() {
   let json = await fetchListJSON()
   if (json) { vlog('Already authenticated'); return json }
 
-  // Not authenticated — present Amazon so the user can sign in
-  vlog('Presenting Amazon for sign-in...')
-  await sessionView.loadURL(baseURL)
-  const homeHtml = await sessionView.getHTML()
-  vlog(`Homepage loaded — sign-in indicator: ${homeHtml.includes(signInKey)}`)
+  // Show instructions so the user knows to wait for Alexa lists to load
+  const alert = new Alert()
+  alert.title = 'Amazon Sign-In Required'
+  alert.message = 'Sign in to Amazon and wait for your Alexa Shopping List to load, then tap Done.'
+  alert.addAction('Open Amazon')
+  alert.addCancelAction('Cancel')
+  const choice = await alert.presentAlert()
+  if (choice === -1) { vlog('User cancelled sign-in'); return null }
+
+  // Load the Alexa lists page — this sets Alexa-specific session cookies, not just amazon.com
+  const alexaListURL = `${baseURL}/alexa-lists`
+  vlog(`Loading Alexa lists page: ${alexaListURL}`)
+  await sessionView.loadURL(alexaListURL)
+  const pageHtml = await sessionView.getHTML()
+  vlog(`Alexa page loaded (${pageHtml.length} chars) — sign-in indicator: ${pageHtml.includes(signInKey)}`)
   await sessionView.present(false)
   vlog('WebView dismissed — retrying API')
 

From 5d92228dddaa8d1cce894528ffb989861ada0746 Mon Sep 17 00:00:00 2001
From: Bryan Buchorn 
Date: Sat, 6 Jun 2026 15:49:43 -0700
Subject: [PATCH 20/22] Switch fetchListJSON to XHR helper; load
 alexa.amazon.com for sign-in

Direct loadURL navigation to the API doesn't send the headers Amazon
requires for its AJAX endpoints (X-Requested-With, Accept: application/json).
Switched to the same loadHTML+XHR pattern used for DELETE so those headers
are included and status can be checked. Also changed the sign-in WebView
to load https://alexa.amazon.com (the full Alexa app) instead of the
www.amazon.com/alexa-lists stub page (only 2KB, no real auth state).

Co-Authored-By: Claude Sonnet 4.6 
---
 Alexa To Reminders Access.js | 73 ++++++++++++++++++++++--------------
 1 file changed, 45 insertions(+), 28 deletions(-)

diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js
index 9e1bf93..b3c4a1d 100644
--- a/Alexa To Reminders Access.js	
+++ b/Alexa To Reminders Access.js	
@@ -63,22 +63,50 @@ function vlog(msg) {
   console.log(`[${ts}] ${msg}`)
 }
 
-// ─── HTML content extraction ──────────────────────────────────────────────────
+// ─── Amazon API ───────────────────────────────────────────────────────────────
+
+async function fetchListJSON() {
+  const apiUrl = `${baseURL}/alexashoppinglists/api/getlistitems`
+  vlog(`Fetching list via XHR: ${apiUrl}`)
+
+  const helperPage = `
+
+`
 
-// WKWebView wraps JSON responses in a minimal HTML page.
-// Pull the text content out and attempt to parse it as JSON.
-function extractJSONFromHTML(html) {
-  // Try 
 wrapper first (common for JSON responses)
-  const preMatch = html.match(/]*>([\s\S]*?)<\/pre>/i)
-  let text = preMatch
-    ? preMatch[1]
-    : html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
+  await sessionView.loadHTML(helperPage, baseURL)
+  const resultHtml = await sessionView.getHTML()
+  const match = resultHtml.match(/
]*>([\s\S]*?)<\/div>/i) + const raw = match ? match[1].trim() : '' + vlog(`XHR raw result (${raw.length} chars): ${raw.substring(0, 120)}`) + + if (!raw || raw === 'error' || raw.startsWith('err:')) { + vlog(`XHR failed: ${raw}`) + return null + } + + const pipeIdx = raw.indexOf('|') + if (pipeIdx === -1) { vlog('Unexpected result format'); return null } - text = text.trim() - vlog(`Extracted text (${text.length} chars): ${text.substring(0, 80)}`) + const status = parseInt(raw.substring(0, pipeIdx), 10) + const body = raw.substring(pipeIdx + 1) + vlog(`XHR status: ${status}, body (${body.length} chars): ${body.substring(0, 80)}`) + + if (status < 200 || status >= 300) { vlog(`Non-2xx status: ${status}`); return null } try { - const json = JSON.parse(text) + const json = JSON.parse(body) if (typeof json === 'object' && json !== null && !Array.isArray(json)) { vlog(`Valid JSON object with ${Object.keys(json).length} key(s)`) return json @@ -91,17 +119,6 @@ function extractJSONFromHTML(html) { } } -// ─── Amazon API ─────────────────────────────────────────────────────────────── - -async function fetchListJSON() { - const url = `${baseURL}/alexashoppinglists/api/getlistitems` - vlog(`Navigating to: ${url}`) - await sessionView.loadURL(url) - const html = await sessionView.getHTML() - vlog(`Response HTML length: ${html.length} chars`) - return extractJSONFromHTML(html) -} - // Send a DELETE via a loadHTML helper page so the request runs inside // the WebView's authenticated session without needing evaluateJavaScript. async function deleteListItem(item) { @@ -151,12 +168,12 @@ async function ensureAuthenticated() { const choice = await alert.presentAlert() if (choice === -1) { vlog('User cancelled sign-in'); return null } - // Load the Alexa lists page — this sets Alexa-specific session cookies, not just amazon.com - const alexaListURL = `${baseURL}/alexa-lists` - vlog(`Loading Alexa lists page: ${alexaListURL}`) - await sessionView.loadURL(alexaListURL) + // Load the full Alexa web app — this establishes Alexa-specific session state + const alexaAppURL = 'https://alexa.amazon.com' + vlog(`Loading Alexa web app: ${alexaAppURL}`) + await sessionView.loadURL(alexaAppURL) const pageHtml = await sessionView.getHTML() - vlog(`Alexa page loaded (${pageHtml.length} chars) — sign-in indicator: ${pageHtml.includes(signInKey)}`) + vlog(`Alexa app loaded (${pageHtml.length} chars) — sign-in indicator: ${pageHtml.includes(signInKey)}`) await sessionView.present(false) vlog('WebView dismissed — retrying API') From 4e5fd438c84a3aa1d5e282a23c4c6094820decd7 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Sat, 6 Jun 2026 15:56:35 -0700 Subject: [PATCH 21/22] Update README: document Alexa To Reminders setup and auth flow Rewrites the script entry to cover the new authentication approach (persistent WKWebView session via alexa.amazon.com), setup steps, the Reminders list picker, and settings persistence. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d6e775c..a4e01e7 100644 --- a/README.md +++ b/README.md @@ -163,18 +163,24 @@ * ### [Remove Scheduled Notifications.js](Remove%20Scheduled%20Notifications.js) * See how many pending notifications you have in the Scriptable app and choose from them to remove them, or alternatively, remove them all with one button. -* ### [Alexa to Reminders Access.js](Alexa%20To%20Reminders%20Access.js) +* ### [Alexa To Reminders Access.js](Alexa%20To%20Reminders%20Access.js) - * Purpose: To sync alexa reminders to a iOS reminders list. previously IFTTT could do this, but Amazon revoked the Alexa IFTTT integration recently. + * **Purpose:** Syncs your Alexa Shopping List to an iOS Reminders list. Previously IFTTT handled this, but Amazon revoked the Alexa IFTTT integration. Each item is created as a Reminder and then removed from the Alexa list so it doesn't sync again. - * Setup: - - Insert the Amazon base url your country uses (if different from default) in the "baseURL" variable below - - Insert the name of the desired reminders list in the "reminderListName" line. I use "Grocery and Shopping" with my wife, so i have that name entered. - - Insert the wording for "Sign In" for the signInKeyvariable below. sometimes this varies based on region - - For proper naming preference, please use the withVar and withoutVar for your local language to proeprly set naming of the reminders to be created + * **Setup:** + 1. Set `baseURL` to your country's Amazon domain if not `amazon.com` (e.g. `amazon.co.uk`) + 2. Set `reminderListName` to match your Reminders list name exactly (default: `SHOPPING`) + 3. Optionally adjust `withVar` / `withoutVar` for your language (used for title-casing item names) + 4. Run the script once — if not already signed in, an alert will appear. Tap **Open Amazon**, wait for your Alexa Shopping List to fully load in the WebView, then tap **Done** + * **How authentication works:** + - The script uses a single persistent WKWebView session. On the first run it loads `alexa.amazon.com` so you can sign in and let Amazon set all required session cookies. + - Once signed in, Amazon's cookies persist across runs — you won't be prompted again unless Amazon expires your session (e.g. after a password change or long inactivity). + - All API calls are made via XHR within the same WebView session, so auth cookies (including HttpOnly ones) are automatically included. - * When running the first time, the script will check if you are logged in. If not, it will notify and present with login page. After that, the script should run seamlessly. + * **Reminders list picker:** If the configured list name isn't found, the script will show a picker listing all available Reminders lists and save your choice for future runs. + + * **Settings** are stored in iCloud Drive → Scriptable → `AlexaToReminders/settings.json` and persist between runs. ## Utilites (from others) From 219b0337d46b71f185ef604b691f2117af9752a2 Mon Sep 17 00:00:00 2001 From: Bryan Buchorn Date: Mon, 8 Jun 2026 20:11:29 -0700 Subject: [PATCH 22/22] Derive Alexa URL from baseURL instead of hard-coding alexa.amazon.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes regional support — e.g. amazon.co.uk now correctly uses alexa.amazon.co.uk. Co-Authored-By: Claude Sonnet 4.6 --- Alexa To Reminders Access.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Alexa To Reminders Access.js b/Alexa To Reminders Access.js index b3c4a1d..b7ca158 100644 --- a/Alexa To Reminders Access.js +++ b/Alexa To Reminders Access.js @@ -169,7 +169,7 @@ async function ensureAuthenticated() { if (choice === -1) { vlog('User cancelled sign-in'); return null } // Load the full Alexa web app — this establishes Alexa-specific session state - const alexaAppURL = 'https://alexa.amazon.com' + const alexaAppURL = baseURL.replace('://www.', '://alexa.') vlog(`Loading Alexa web app: ${alexaAppURL}`) await sessionView.loadURL(alexaAppURL) const pageHtml = await sessionView.getHTML()