├── LICENSE ├── README.md ├── data ├── apps.json └── lunch.json ├── service statuses ├── cloudflare.js ├── garmin.js ├── github.js ├── ifttt.js ├── netflix.js ├── strava.js └── underground.js ├── widgets ├── Widget: Coronavirus 19.js ├── Widget: Quote of the day.js ├── Widget: Random Exercise.js ├── Widget: UK Threat Level.js ├── Widget: Word of the day.js ├── Widget: tocfcws latest.js ├── widget-background.js ├── widget-macstories.js └── widget-weather.js └── work-in-progress ├── appleSubscriptions.js ├── dropbox.js ├── jsonReader.js ├── message_many.js ├── scratch.js └── tableApps.js /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scriptable 2 | 3 | A repository for scriptable (iOS app) JavaScript files that provide little snippets of information that can be used via Siri or shortcuts. 4 | 5 | ## Services 6 | 7 | As a subscriber to Strava and a user of a Garmin watch, their status pages are included to provide an easy way to see if the services are down. Likewise CloudFlare to ensure my website is online. More services will be added in the future. 8 | 9 | 10 | [Scriptable App Docs](https://docs.scriptable.app) 11 | -------------------------------------------------------------------------------- /data/apps.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Apps": "Tweetbot" 4 | }, 5 | { 6 | "Apps": "Scriptable" 7 | }, 8 | { 9 | "Apps": "Shortcuts" 10 | }, 11 | { 12 | "Apps": "Dropbox" 13 | }, 14 | { 15 | "Apps": "Statszone" 16 | }, 17 | { 18 | "Apps": "Castro" 19 | }, 20 | { 21 | "Apps": "Feedly" 22 | }, 23 | { 24 | "Apps": "Drafts" 25 | }, 26 | { 27 | "Apps": "Strava" 28 | }, 29 | { 30 | "Apps": "Bobby" 31 | }, 32 | { 33 | "Apps": "Working Copy" 34 | }, 35 | { 36 | "Apps": "1Password" 37 | }, 38 | { 39 | "Apps": "Garmin Connect" 40 | }, 41 | { 42 | "Apps": "G-Suite" 43 | }, 44 | { 45 | "Apps": "Ifttt" 46 | }, 47 | { 48 | "Apps": "Reddit" 49 | }, 50 | { 51 | "Apps": "Stack Exchange" 52 | }, 53 | { 54 | "Apps": "Lire" 55 | }, 56 | { 57 | "Apps": "Launch center Pro" 58 | }, 59 | { 60 | "Apps": "Amount" 61 | }, 62 | { 63 | "Apps": "Sky-Service" 64 | }, 65 | { 66 | "Apps": "Three" 67 | }, 68 | { 69 | "Apps": "Amazon" 70 | }, 71 | { 72 | "Apps": "Paypal" 73 | }, 74 | { 75 | "Apps": "Hsbc" 76 | }, 77 | { 78 | "Apps": "Barclaycard" 79 | }, 80 | { 81 | "Apps": "Tube-Map" 82 | }, 83 | { 84 | "Apps": "Cross-country Trains" 85 | }, 86 | { 87 | "Apps": "National Rail" 88 | }, 89 | { 90 | "Apps": "Google-Maps" 91 | }, 92 | { 93 | "Apps": "Kindle" 94 | }, 95 | { 96 | "Apps": "Whatsapp" 97 | }, 98 | { 99 | "Apps": "BBC Iplayer" 100 | }, 101 | { 102 | "Apps": "BT Sport" 103 | }, 104 | { 105 | "Apps": "Amazon Video" 106 | }, 107 | { 108 | "Apps": "Amazon Alexa" 109 | }, 110 | { 111 | "Apps": "Speedtest" 112 | }, 113 | { 114 | "Apps": "Pluralsight" 115 | } 116 | ] -------------------------------------------------------------------------------- /data/lunch.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sandwich Box" : "", 3 | "Wolfies" : "", 4 | "Lime Tree" : "", 5 | "Munchies" : "", 6 | "Bar and Wok" : "", 7 | "KFC" : "", 8 | "Yo Sushi" : "", 9 | "Greggs" : "", 10 | "Burger Star" : "", 11 | "Farmhouse kitchen" : "", 12 | "Waitrose" : "", 13 | "Fresh Crust" : "", 14 | "Boots" : "", 15 | "Papparito's" : "", 16 | "Charlies chippy" : "", 17 | "Prezzo" : "", 18 | "Pizza Express" : "", 19 | "Swallow Bakery" : "", 20 | "Bayshill" : "", 21 | "Gridiron" : "", 22 | "Sainsbury's" : "", 23 | "Tesco" : "", 24 | "McDonald's" : "", 25 | "Patisserie Valerie" : "", 26 | "Chelsea Brasserie" : "", 27 | "Hotel du vin" : "", 28 | "Malmaison" : "", 29 | "Nando's" : "" 30 | } 31 | -------------------------------------------------------------------------------- /service statuses/cloudflare.js: -------------------------------------------------------------------------------- 1 | let url = "https://www.cloudflarestatus.com" 2 | let r = new Request(url) 3 | let body = await r.loadString() 4 | if (config.runsWithSiri) { 5 | let needle = "All Systems Operational" 6 | if (body.includes(needle)) { 7 | Speech.speak("All Systems Operational") 8 | } else { 9 | Speech.speak("uh oh") 10 | } 11 | } 12 | Safari.openInApp(url) 13 | -------------------------------------------------------------------------------- /service statuses/garmin.js: -------------------------------------------------------------------------------- 1 | let url = "https://connect.garmin.com/status/" 2 | let r = new Request(url) 3 | let body = await r.loadString() 4 | if (config.runsWithSiri) { 5 | let needle = "offline" 6 | if (body.includes(needle)) { 7 | Speech.speak("uh oh") 8 | Safari.openInApp(url) 9 | } else { 10 | Speech.speak("All Systems Operational") 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /service statuses/github.js: -------------------------------------------------------------------------------- 1 | let url = "https://status.github.com" 2 | let r = new Request(url) 3 | let body = await r.loadString() 4 | if (config.runsWithSiri) { 5 | let needle = "All Systems Operational" 6 | if (body.includes(needle)) { 7 | Speech.speak("All Systems Operational") 8 | } else { 9 | Speech.speak("uh oh") 10 | } 11 | } 12 | Safari.openInApp(url) 13 | -------------------------------------------------------------------------------- /service statuses/ifttt.js: -------------------------------------------------------------------------------- 1 | let url = "https://www.cloudflarestatus.com" 2 | let r = new Request(url) 3 | let body = await r.loadString() 4 | if (config.runsWithSiri) { 5 | let needle = "All Systems Operational" 6 | if (body.includes(needle)) { 7 | Speech.speak("All Systems Operational") 8 | } else { 9 | Speech.speak("uh oh") 10 | } 11 | } 12 | Safari.openInApp(url) 13 | -------------------------------------------------------------------------------- /service statuses/netflix.js: -------------------------------------------------------------------------------- 1 | let url = "https://www.cloudflarestatus.com" 2 | let r = new Request(url) 3 | let body = await r.loadString() 4 | if (config.runsWithSiri) { 5 | let needle = "All Systems Operational" 6 | if (body.includes(needle)) { 7 | Speech.speak("All Systems Operational") 8 | } else { 9 | Speech.speak("uh oh") 10 | } 11 | } 12 | Safari.openInApp(url) 13 | -------------------------------------------------------------------------------- /service statuses/strava.js: -------------------------------------------------------------------------------- 1 | let url = "https://status.strava.com" 2 | let r = new Request(url) 3 | let body = await r.loadString() 4 | if (config.runsWithSiri) { 5 | let needle = "All Systems Operational" 6 | if (body.includes(needle)) { 7 | Speech.speak("All Systems Operational") 8 | } else { 9 | Speech.speak("uh oh") 10 | } 11 | } 12 | Safari.openInApp(url) 13 | -------------------------------------------------------------------------------- /service statuses/underground.js: -------------------------------------------------------------------------------- 1 | const url = "https://api.tfl.gov.uk/Line/Mode/tube/Status" 2 | let req = new Request(url) 3 | let json = await req.loadJSON() 4 | 5 | let table = new UITable() 6 | for (line of json) { 7 | let row = new UITableRow() 8 | let lineName = line.name 9 | let status = line.lineStatuses.map(s => s.statusSeverityDescription) 10 | 11 | let count = status.length 12 | let str = status.reduce((t,s,i) => { 13 | t = t + s 14 | if (i < (count- 1) && count > 0) { t = t + " &\n "} 15 | return t 16 | }, "") 17 | 18 | let height = 16 + Math.max(count,2) * 22 19 | row.height = height 20 | 21 | const cW = 80 22 | let c = new DrawContext() 23 | c.size = new Size(cW, height * 4) 24 | c.setFillColor(getLineColor(lineName)) 25 | c.fill(new Rect(0, 0 , cW, height * 4)) 26 | 27 | // ... populate the row with data ... 28 | let imageCell = row.addImage(c.getImage()) 29 | let titleCell = row.addText(lineName) 30 | let statusCell = row.addText(str) 31 | 32 | // ... format columns and rows ... 33 | row.cellSpacing = 10 34 | imageCell.widthWeight = 5 35 | titleCell.widthWeight = 35 36 | statusCell.widthWeight = 60 37 | 38 | // ... add row to table ... 39 | table.addRow(row) 40 | } 41 | 42 | // ... and present table 43 | QuickLook.present(table) 44 | 45 | function getLineColor(line) { 46 | switch(line) { 47 | case "Bakerloo": 48 | return new Color("#B26300") 49 | break 50 | case "Central": 51 | return new Color("#DC241F") 52 | break 53 | case "Circle": 54 | return new Color("#FFD329") 55 | break 56 | case "District": 57 | return new Color("#007D32") 58 | break 59 | case "Hammersmith & City": 60 | return new Color("#F4A9BE") 61 | break 62 | case "Jubilee": 63 | return new Color("#A1A5A7") 64 | break 65 | case "Metropolitan": 66 | return new Color("#9B0058") 67 | break 68 | case "Northern": 69 | return new Color("#000") 70 | break 71 | case "Piccadilly": 72 | return new Color("#0019A8") 73 | break 74 | case "Victoria": 75 | return new Color("#0098D8") 76 | break 77 | case "Waterloo & City": 78 | return new Color("#93CEBA") 79 | break 80 | default: 81 | return new Color("CCC") 82 | break 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /widgets/Widget: Coronavirus 19.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: red; icon-glyph: magic; 4 | // Variables used by Scriptable. 5 | // These must be at the very top of the file. Do not edit. 6 | // icon-color: deep-green; icon-glyph: user-md; 7 | // change "country" to a value from https://coronavirus-19-api.herokuapp.com/countries/ 8 | const country = "UK" 9 | const url = `https://coronavirus-19-api.herokuapp.com/countries/${country}` 10 | const req = new Request(url) 11 | const res = await req.loadJSON() 12 | 13 | if (config.runsInWidget) { 14 | // create and show widget 15 | let widget = createWidget("Coronavirus", `${res.todayCases} Today`, `${res.cases} Total`, "#8DA1B9") 16 | Script.setWidget(widget) 17 | Script.complete() 18 | } else { 19 | // make table 20 | let table = new UITable() 21 | 22 | // add header 23 | let row = new UITableRow() 24 | row.isHeader = true 25 | row.addText(`Coronavirus Stats in ${country}`) 26 | table.addRow(row) 27 | 28 | // fill data 29 | table.addRow(createRow("Cases", res.cases)) 30 | table.addRow(createRow("Today", res.todayCases)) 31 | table.addRow(createRow("Deaths", res.deaths)) 32 | table.addRow(createRow("Recovered", res.recovered)) 33 | table.addRow(createRow("Critical", res.critical)) 34 | 35 | if (config.runsWithSiri) 36 | Speech.speak(`There are ${res.cases} cases in ${country}, and ${res.todayCases} cases today.`) 37 | 38 | // present table 39 | table.present() 40 | } 41 | 42 | function createRow(title, number) { 43 | let row = new UITableRow() 44 | row.addText(title) 45 | row.addText(number.toString()).rightAligned() 46 | return row 47 | } 48 | 49 | function createWidget(pretitle, title, subtitle, color) { 50 | let w = new ListWidget() 51 | w.backgroundColor = new Color(color) 52 | let preTxt = w.addText(pretitle) 53 | preTxt.textColor = Color.white() 54 | preTxt.textOpacity = 0.8 55 | preTxt.font = Font.systemFont(16) 56 | w.addSpacer(5) 57 | let titleTxt = w.addText(title) 58 | titleTxt.textColor = Color.white() 59 | titleTxt.font = Font.systemFont(22) 60 | w.addSpacer(5) 61 | let subTxt = w.addText(subtitle) 62 | subTxt.textColor = Color.white() 63 | subTxt.textOpacity = 0.8 64 | subTxt.font = Font.systemFont(18) 65 | return w 66 | } -------------------------------------------------------------------------------- /widgets/Widget: Quote of the day.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: red; icon-glyph: quote-right; 4 | const url = "https://raw.githubusercontent.com/Mat-0/morning.thechels.uk/main/config/quotes.json" 5 | let req = new Request(url) 6 | let json = await req.loadJSON() 7 | 8 | out_s = json[ Math.floor(Math.random()*json.length) ]; 9 | 10 | console.log(out_s) 11 | 12 | 13 | function createWidget(pretitle, out_s, color) { 14 | let w = new ListWidget() 15 | w.backgroundColor = new Color(color) 16 | let preTxt = w.addText(pretitle) 17 | preTxt.textColor = Color.white() 18 | preTxt.textOpacity = 0.9 19 | preTxt.font = Font.systemFont(16) 20 | w.addSpacer(2) 21 | let titleTxt = w.addText(out_s) 22 | titleTxt.textColor = Color.white() 23 | titleTxt.font = Font.systemFont(10) 24 | w.addSpacer(2) 25 | return w 26 | } 27 | 28 | let widget = createWidget("Agile Quotes", `${out_s}`, "#0B4F6C") 29 | 30 | // the widget 31 | if (config.runsInWidget) { 32 | // create and show widget 33 | Script.setWidget(widget); 34 | } 35 | 36 | // preview the widget if in app 37 | if (!config.runsInWidget) { 38 | await widget.presentSmall(); 39 | } 40 | 41 | Script.complete(); 42 | -------------------------------------------------------------------------------- /widgets/Widget: Random Exercise.js: -------------------------------------------------------------------------------- 1 | 2 | let list = [ 3 | "Bodyweight squats", 4 | "Kettlebell swings", 5 | "Plank", 6 | "Kettlebell rows", 7 | "Walking jacks", 8 | "Dead bugs", 9 | "Bench dips", 10 | "Calf raises", 11 | "Shadow boxing", 12 | ]; 13 | 14 | 15 | // get random item from list and return the string 16 | function getRandomItem(list) { 17 | let randomIndex = Math.floor(Math.random() * list.length); 18 | 19 | return list[randomIndex]; 20 | } 21 | 22 | let output = getRandomItem(list); 23 | 24 | console.log(output) 25 | 26 | if (config.runsWithSiri) { 27 | Speech.speak(output); 28 | } 29 | 30 | if (config.runsInWidget) { 31 | // create and show widget 32 | let widget = createWidget("Exercise of the day", `${output}`, "#F26419"); 33 | Script.setWidget(widget); 34 | Script.complete(); 35 | } 36 | 37 | function createWidget(pre_title, output_string, color) { 38 | let w = new ListWidget(); 39 | w.backgroundColor = new Color(color); 40 | let preTxt = w.addText(pre_title); 41 | preTxt.textColor = Color.white(); 42 | preTxt.textOpacity = 0.9; 43 | preTxt.font = Font.systemFont(16); 44 | w.addSpacer(3); 45 | let titleTxt = w.addText(output_string); 46 | titleTxt.textColor = Color.white(); 47 | titleTxt.font = Font.systemFont(10); 48 | w.addSpacer(3); 49 | return w; 50 | } 51 | -------------------------------------------------------------------------------- /widgets/Widget: UK Threat Level.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: red; icon-glyph: user-secret; 4 | // Variables used by Scriptable. 5 | // These must be at the very top of the file. Do not edit. 6 | // icon-color: red; icon-glyph: user-md; 7 | let url_feed = "https://www.mi5.gov.uk/UKThreatLevel/UKThreatLevel.xml"; 8 | let r = new Request(url_feed); 9 | let xml = await r.loadString(); 10 | 11 | let start = xml.split(""); 12 | let content = start[2]; 13 | let end = content.split(""); 14 | let output = end[0]; 15 | 16 | if (config.runsWithSiri) { 17 | Speech.speak(output); 18 | } 19 | console.log(output); 20 | 21 | if (config.runsInWidget) { 22 | // create and show widget 23 | let widget = createWidget("Threat level", `${output}`, "#B22222"); 24 | Script.setWidget(widget); 25 | Script.complete(); 26 | } 27 | 28 | function createWidget(pre_title, output_string, color) { 29 | let w = new ListWidget(); 30 | w.backgroundColor = new Color(color); 31 | let preTxt = w.addText(pre_title); 32 | preTxt.textColor = Color.white(); 33 | preTxt.textOpacity = 0.9; 34 | preTxt.font = Font.systemFont(16); 35 | w.addSpacer(3); 36 | let titleTxt = w.addText(output_string); 37 | titleTxt.textColor = Color.white(); 38 | titleTxt.font = Font.systemFont(10); 39 | w.addSpacer(3); 40 | return w; 41 | } 42 | -------------------------------------------------------------------------------- /widgets/Widget: Word of the day.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: red; icon-glyph: graduation-cap; 4 | // Variables used by Scriptable. 5 | // These must be at the very top of the file. Do not edit. 6 | // icon-color: red; icon-glyph: user-md; 7 | let url_feed = "https://wordsmith.org/awad/rss1.xml"; 8 | let r = new Request(url_feed); 9 | let xml = await r.loadString(); 10 | 11 | function getOutput(xml) { 12 | xml.items.forEach((item) => { 13 | let title = clean(item.title); 14 | let desc = clean(item.description); 15 | output = title + desc; 16 | }); 17 | return output; 18 | } 19 | 20 | if (config.runsWithSiri) { 21 | Speech.speak(output); 22 | } 23 | 24 | if (config.runsInWidget) { 25 | // create and show widget 26 | let widget = createWidget("Word of the day", `${output}`, "#F26419"); 27 | Script.setWidget(widget); 28 | Script.complete(); 29 | } 30 | 31 | function createWidget(pre_title, output_string, color) { 32 | let w = new ListWidget(); 33 | w.backgroundColor = new Color(color); 34 | let preTxt = w.addText(pre_title); 35 | preTxt.textColor = Color.white(); 36 | preTxt.textOpacity = 0.9; 37 | preTxt.font = Font.systemFont(16); 38 | w.addSpacer(3); 39 | let titleTxt = w.addText(output_string); 40 | titleTxt.textColor = Color.white(); 41 | titleTxt.font = Font.systemFont(10); 42 | w.addSpacer(3); 43 | return w; 44 | } 45 | 46 | function clean(str) { 47 | str = str.replace("&#8221;", "'"); 48 | str = str.replace("&#8220;", "'"); 49 | str = unescapeHTML(str); 50 | str = str.replace(/<[^>]*>?/gm, ""); 51 | str = str.replace(/]*>/g, ""); 52 | let regex = /&#(\d+);/g; 53 | str = str.replace(regex, (match, dec) => { 54 | String.fromCharCode(dec); 55 | }); 56 | 57 | return str; 58 | } 59 | 60 | function unescapeHTML(escapedHTML) { 61 | return escapedHTML 62 | .replace(/</g, "<") 63 | .replace(/>/g, ">") 64 | .replace(/&/g, "&"); 65 | } 66 | -------------------------------------------------------------------------------- /widgets/Widget: tocfcws latest.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: red; icon-glyph: user-md; 4 | let url_feed = "http://app.thechels.uk/tocfcws.xml"; 5 | let r = new Request(url_feed); 6 | let xml = await r.loadString(); 7 | 8 | n1 = getDesc(xml, 0); 9 | n2 = getDesc(xml, 1); 10 | n3 = getDesc(xml, 2); 11 | n4 = getDesc(xml, 3); 12 | 13 | let o = n1 + "\n" + n2 + "\n" + n3 + "\n" + n4; 14 | 15 | if (config.runsWithSiri) { 16 | Speech.speak(o); 17 | } 18 | 19 | console.log(o); 20 | 21 | if (config.runsInWidget) { 22 | // create and show widget 23 | let widget = createWidget("ToCFCws latest", `${o}`, "#034694"); 24 | Script.setWidget(widget); 25 | Script.complete(); 26 | } 27 | 28 | function createWidget(pretitle, output_string, color) { 29 | let w = new ListWidget(); 30 | w.backgroundColor = new Color(color); 31 | let preTxt = w.addText(pretitle); 32 | preTxt.textColor = Color.white(); 33 | preTxt.textOpacity = 0.9; 34 | preTxt.font = Font.systemFont(16); 35 | w.addSpacer(3); 36 | let titleTxt = w.addText(output_string); 37 | titleTxt.textColor = Color.white(); 38 | titleTxt.font = Font.systemFont(12); 39 | w.addSpacer(3); 40 | return w; 41 | } 42 | 43 | function getDesc(string, count) { 44 | let a1 = string.split(""); 45 | a1 = a1[count + 1]; 46 | a1 = a1.split("")[0]; 47 | 48 | a1 = a1.split(""); 49 | a1 = a1[1]; 50 | a1 = a1.split("")[0]; 51 | 52 | let o = "• " + a1; 53 | return o; 54 | } 55 | -------------------------------------------------------------------------------- /widgets/widget-background.js: -------------------------------------------------------------------------------- 1 | // This widget was created by Max Zeryck @mzeryck 2 | 3 | if (config.runsInWidget) { 4 | let widget = new ListWidget() 5 | widget.backgroundImage = files.readImage(path) 6 | 7 | // You can your own code here to add additional items to the "invisible" background of the widget. 8 | 9 | Script.setWidget(widget) 10 | Script.complete() 11 | 12 | /* 13 | * The code below this comment is used to set up the invisible widget. 14 | * =================================================================== 15 | */ 16 | } else { 17 | 18 | // Determine if user has taken the screenshot. 19 | var message 20 | message = "Before you start, go to your home screen and enter wiggle mode. Scroll to the empty page on the far right and take a screenshot." 21 | let exitOptions = ["Continue","Exit to Take Screenshot"] 22 | let shouldExit = await generateAlert(message,exitOptions) 23 | if (shouldExit) return 24 | 25 | // Get screenshot and determine phone size. 26 | let img = await Photos.fromLibrary() 27 | let height = img.size.height 28 | let phone = phoneSizes()[height] 29 | if (!phone) { 30 | message = "It looks like you selected an image that isn't an iPhone screenshot, or your iPhone is not supported. Try again with a different image." 31 | await generateAlert(message,["OK"]) 32 | return 33 | } 34 | 35 | // Prompt for widget size and position. 36 | message = "What size of widget are you creating?" 37 | let sizes = ["Small","Medium","Large"] 38 | let size = await generateAlert(message,sizes) 39 | let widgetSize = sizes[size] 40 | 41 | message = "What position will it be in?" 42 | message += (height == 1136 ? " (Note that your device only supports two rows of widgets, so the middle and bottom options are the same.)" : "") 43 | 44 | // Determine image crop based on phone size. 45 | let crop = { w: "", h: "", x: "", y: "" } 46 | if (widgetSize == "Small") { 47 | crop.w = phone.small 48 | crop.h = phone.small 49 | let positions = ["Top left","Top right","Middle left","Middle right","Bottom left","Bottom right"] 50 | let position = await generateAlert(message,positions) 51 | 52 | // Convert the two words into two keys for the phone size dictionary. 53 | let keys = positions[position].toLowerCase().split(' ') 54 | crop.y = phone[keys[0]] 55 | crop.x = phone[keys[1]] 56 | 57 | } else if (widgetSize == "Medium") { 58 | crop.w = phone.medium 59 | crop.h = phone.small 60 | 61 | // Medium and large widgets have a fixed x-value. 62 | crop.x = phone.left 63 | let positions = ["Top","Middle","Bottom"] 64 | let position = await generateAlert(message,positions) 65 | let key = positions[position].toLowerCase() 66 | crop.y = phone[key] 67 | 68 | } else if(widgetSize == "Large") { 69 | crop.w = phone.medium 70 | crop.h = phone.large 71 | crop.x = phone.left 72 | let positions = ["Top","Bottom"] 73 | let position = await generateAlert(message,positions) 74 | 75 | // Large widgets at the bottom have the "middle" y-value. 76 | crop.y = position ? phone.middle : phone.top 77 | } 78 | 79 | // Crop image and finalize the widget. 80 | let imgCrop = cropImage(img, new Rect(crop.x,crop.y,crop.w,crop.h)) 81 | 82 | message = "Your widget background is ready. Would you like to use it in a Scriptable widget or export the image?" 83 | const exportPhotoOptions = ["Export to Files","Export to Photos"] 84 | const exportPhoto = await generateAlert(message,exportPhotoOptions) 85 | 86 | if (exportPhoto) { 87 | Photos.save(imgCrop) 88 | } else { 89 | await DocumentPicker.exportImage(imgCrop) 90 | } 91 | 92 | Script.complete() 93 | } 94 | 95 | // Generate an alert with the provided array of options. 96 | async function generateAlert(message,options) { 97 | 98 | let alert = new Alert() 99 | alert.message = message 100 | 101 | for (const option of options) { 102 | alert.addAction(option) 103 | } 104 | 105 | let response = await alert.presentAlert() 106 | return response 107 | } 108 | 109 | // Crop an image into the specified rect. 110 | function cropImage(img,rect) { 111 | 112 | let draw = new DrawContext() 113 | draw.size = new Size(rect.width, rect.height) 114 | 115 | draw.drawImageAtPoint(img,new Point(-rect.x, -rect.y)) 116 | return draw.getImage() 117 | } 118 | 119 | // Pixel sizes and positions for widgets on all supported phones. 120 | function phoneSizes() { 121 | let phones = { 122 | "2688": { 123 | "small": 507, 124 | "medium": 1080, 125 | "large": 1137, 126 | "left": 81, 127 | "right": 654, 128 | "top": 228, 129 | "middle": 858, 130 | "bottom": 1488 131 | }, 132 | 133 | "1792": { 134 | "small": 338, 135 | "medium": 720, 136 | "large": 758, 137 | "left": 54, 138 | "right": 436, 139 | "top": 160, 140 | "middle": 580, 141 | "bottom": 1000 142 | }, 143 | 144 | "2436": { 145 | "small": 465, 146 | "medium": 987, 147 | "large": 1035, 148 | "left": 69, 149 | "right": 591, 150 | "top": 213, 151 | "middle": 783, 152 | "bottom": 1353 153 | }, 154 | 155 | "2208": { 156 | "small": 471, 157 | "medium": 1044, 158 | "large": 1071, 159 | "left": 99, 160 | "right": 672, 161 | "top": 114, 162 | "middle": 696, 163 | "bottom": 1278 164 | }, 165 | 166 | "1334": { 167 | "small": 296, 168 | "medium": 642, 169 | "large": 648, 170 | "left": 54, 171 | "right": 400, 172 | "top": 60, 173 | "middle": 412, 174 | "bottom": 764 175 | }, 176 | 177 | "1136": { 178 | "small": 282, 179 | "medium": 584, 180 | "large": 622, 181 | "left": 30, 182 | "right": 332, 183 | "top": 59, 184 | "middle": 399, 185 | "bottom": 399 186 | } 187 | } 188 | return phones 189 | } 190 | -------------------------------------------------------------------------------- /widgets/widget-macstories.js: -------------------------------------------------------------------------------- 1 | let items = await loadItems() 2 | 3 | if (config.runsInWidget) { 4 | let widget = createWidget(items) 5 | Script.setWidget(widget) 6 | Script.complete() 7 | } else { 8 | let item = items[0] 9 | Safari.open(item.url) 10 | } 11 | 12 | function createWidget(items) { 13 | let item = items[0] 14 | let authors = item.authors.map(a => { 15 | return a.name 16 | }).join(", ") 17 | let rawDate = item["date_published"] 18 | let date = new Date(Date.parse(rawDate)) 19 | let df = new DateFormatter() 20 | df.useFullDateStyle() 21 | df.useShortTimeStyle() 22 | let strDate = df.string(date) 23 | let w = new ListWidget() 24 | w.backgroundColor = new Color("#b00a0f") 25 | w.centerAlignContent() 26 | let titleTxt = w.addText(item.title) 27 | titleTxt.applyHeadlineTextStyling() 28 | titleTxt.textColor = Color.white() 29 | let authorsTxt = w.addText("by " + authors) 30 | authorsTxt.applyBodyTextStyling() 31 | authorsTxt.textColor = Color.white() 32 | authorsTxt.textOpacity = 0.8 33 | let dateTxt = w.addText(strDate) 34 | dateTxt.applyBodyTextStyling() 35 | dateTxt.textColor = Color.white() 36 | dateTxt.textOpacity = 0.8 37 | return w 38 | } 39 | 40 | async function loadItems() { 41 | let url = "https://macstories.net/feed/json" 42 | let req = new Request(url) 43 | let json = await req.loadJSON() 44 | return json.items 45 | } 46 | 47 | function extractImageURL(item) { 48 | let regex = //
49 |   let html = item[= 2) { 52 | return matches[1] 53 | } else { 54 | return null 55 | } 56 | } 57 | 58 | function decode(str) { 59 | let regex = /&#(\d+);/g 60 | return str.replace(regex, (match, dec) => { 61 | return String.fromCharCode(dec) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /widgets/widget-weather.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: yellow; icon-glyph: cloud; 4 | 5 | // Widget Params 6 | // Don't edit this, those are default values for debugging (location for Cupertino). 7 | // You need to give your locations parameters through the widget params, more info below. 8 | const widgetParams = JSON.parse((args.widgetParameter != null) ? args.widgetParameter : '{ "LAT" : "37.32" , "LON" : "-122.03" , "LOC_NAME" : "Cupertino, US" }') 9 | 10 | // WEATHER API PARAMETERS !important 11 | // API KEY, you need an Open Weather API Key 12 | // You can get one for free at: https://home.openweathermap.org/api_keys (account needed). 13 | const API_KEY = "[key]" 14 | 15 | // Latitude and Longitude of the location where you get the weather of. 16 | // You can get those from the Open Weather website while searching for a city, etc. 17 | // This values are getted from the widget parameters, the widget parameters is a JSON string that looks like this: 18 | // { "LAT" : "" , "LON" : "" , "LOC_NAME" : "" } 19 | // This to allow multiple instances of the widget with different locations, if you will only use one instance (1 widget), you can "hardcode" the values here. 20 | // Note: To debug the widget you need to place the values here, because when playing the script in-app the widget parameters are null (= crash). 21 | const LAT = widgetParams.LAT 22 | const LON = widgetParams.LON 23 | const LOCATION_NAME = widgetParams.LOC_NAME 24 | 25 | // Looking settings 26 | // This are settings to customize the looking of the widgets, because this was made an iPhone SE (2016) screen, I can't test for bigger screens. 27 | // So feel free to modify this to your taste. 28 | 29 | // units : string > Defines the unit used to measure the temps, for temperatures in Fahrenheit use "imperial", "metric" for Celcius and "standard" for Kelvin (Default: "metric"). 30 | const units = "metric" 31 | // roundedGraph : true|false > true (Use rounded values to draw the graph) | false (Draws the graph using decimal values, this can be used to draw an smoother line). 32 | const roundedGraph = true 33 | // roundedTemp : true|false > true (Displays the temps rounding the values (29.8 = 30 | 29.3 = 29). 34 | const roundedTemp = true 35 | // hoursToShow : number > Number of predicted hours to show, Eg: 3 = a total of 4 hours in the widget (Default: 3 for the small widget and 11 for the medium one). 36 | const hoursToShow = (config.widgetFamily == "small") ? 3 : 11; 37 | // spaceBetweenDays : number > Size of the space between the temps in the graph in pixels. (Default: 60 for the small widget and 44 for the medium one). 38 | const spaceBetweenDays = (config.widgetFamily == "small") ? 60 : 44; 39 | 40 | // Widget Size !important. 41 | // Since the widget works "making" an image and displaying it as the widget background, you need to specify the exact size of the widget to 42 | // get an 1:1 display ratio, if you specify an smaller size than the widget itself it will be displayed blurry. 43 | // You can get the size simply taking an screenshot of your widgets on the home screen and measuring them in an image-proccessing software. 44 | // contextSize : number > Height of the widget in screen pixels, this depends on you screen size (for an 4 inch display the small widget is 282 * 282 pixels on the home screen) 45 | const contextSize = 282 46 | // mediumWidgetWidth : number > Width of the medium widget in pixels, this depends on you screen size (for an 4 inch display the medium widget is 584 pixels long on the home screen) 47 | const mediumWidgetWidth = 584 48 | 49 | // accentColor : Color > Accent color of some elements (Graph lines and the location label). 50 | const accentColor = new Color("#EB6E4E", 1) 51 | // backgroundColor : Color > Background color of the widgets. 52 | const backgroundColor = new Color("#1C1C1E", 1) 53 | 54 | // Position and size of the elements on the widget. 55 | // All coordinates make reference to the top-left of the element. 56 | // locationNameCoords : Point > Define the position in pixels of the location label. 57 | const locationNameCoords = new Point(30, 30) 58 | // locationNameFontSize : number > Size in pixels of the font of the location label. 59 | const locationNameFontSize = 24 60 | // weatherDescriptionCoords : Point > Position of the weather description label in pixels. 61 | const weatherDescriptionCoords = new Point(30, 52) 62 | // weatherDescriptionFontSize : number > Font size of the weather description label. 63 | const weatherDescriptionFontSize = 18 64 | //footerFontSize : number > Font size of the footer labels (feels like... and last update time). 65 | const footerFontSize = 20 66 | //feelsLikeCoords : Point > Coordinates of the "feels like" label. 67 | const feelsLikeCoords = new Point(30, 230) 68 | //lastUpdateTimePosAndSize : Rect > Defines the coordinates and size of the last updated time label. 69 | const lastUpdateTimePosAndSize = new Rect((config.widgetFamily == "small") ? 150 : 450, 230, 100, footerFontSize+1) 70 | 71 | // Prepare for the SFSymbol request by getting sunset/sunrise times. 72 | const date = new Date() 73 | const sunData = await new Request("https://api.sunrise-sunset.org/json?lat=" + LAT + "&lng=" + LON + "&formatted=0&date=" + date.getFullYear() + "-" + (date.getMonth()+1) + "-" + date.getDate()).loadJSON(); 74 | 75 | //From here proceed with caution. 76 | let fm = FileManager.iCloud(); 77 | let cachePath = fm.joinPath(fm.documentsDirectory(), "weatherCache"); 78 | if(!fm.fileExists(cachePath)){ 79 | fm.createDirectory(cachePath) 80 | } 81 | 82 | let weatherData; 83 | let usingCachedData = false; 84 | let drawContext = new DrawContext(); 85 | 86 | drawContext.size = new Size((config.widgetFamily == "small") ? contextSize : mediumWidgetWidth, contextSize) 87 | drawContext.opaque = false 88 | drawContext.setTextAlignedCenter() 89 | 90 | try { 91 | weatherData = await new Request("https://api.openweathermap.org/data/2.5/onecall?lat=" + LAT + "&lon=" + LON + "&exclude=daily,minutely,alerts&units=" + units + "&lang=en&appid=" + API_KEY).loadJSON(); 92 | fm.writeString(fm.joinPath(cachePath, "lastread"), JSON.stringify(weatherData)); 93 | }catch(e){ 94 | console.log("Offline mode") 95 | try{ 96 | let raw = fm.readString(fm.joinPath(cachePath, "lastread")); 97 | weatherData = JSON.parse(raw); 98 | usingCachedData = true; 99 | }catch(e2){ 100 | console.log("Error: No offline data cached") 101 | } 102 | } 103 | 104 | let widget = new ListWidget(); 105 | widget.setPadding(0, 0, 0, 0); 106 | widget.backgroundColor = backgroundColor; 107 | 108 | drawText(LOCATION_NAME, locationNameFontSize, locationNameCoords.x, locationNameCoords.y, accentColor); 109 | drawText(weatherData.current.weather[0].description, weatherDescriptionFontSize, weatherDescriptionCoords.x, weatherDescriptionCoords.y, Color.white()) 110 | 111 | let min, max, diff; 112 | for(let i = 0; i<=hoursToShow ;i++){ 113 | let temp = shouldRound(roundedGraph, weatherData.hourly[i].temp); 114 | min = (temp < min || min == undefined ? temp : min) 115 | max = (temp > max || max == undefined ? temp : max) 116 | } 117 | diff = max -min; 118 | 119 | for(let i = 0; i<=hoursToShow ;i++){ 120 | let hourData = weatherData.hourly[i]; 121 | let nextHourTemp = shouldRound(roundedGraph, weatherData.hourly[i+1].temp); 122 | let hour = epochToDate(hourData.dt).getHours(); 123 | hour = (hour > 12 ? hour - 12 : (hour == 0 ? "12a" : ((hour == 12) ? "12p" : hour))) 124 | let temp = i==0?weatherData.current.temp : hourData.temp 125 | let delta = (diff>0)?(shouldRound(roundedGraph, temp) - min) / diff:0.5; 126 | let nextDelta = (diff>0)?(nextHourTemp - min) / diff:0.5 127 | 128 | if(i < hoursToShow) 129 | drawLine(spaceBetweenDays * (i) + 50, 175 - (50 * delta),spaceBetweenDays * (i+1) + 50 , 175 - (50 * nextDelta), 4, (hourData.dt > weatherData.current.sunset? Color.gray():accentColor)) 130 | 131 | drawTextC(shouldRound(roundedTemp, temp)+"°", 20, spaceBetweenDays*i+30, 135 - (50*delta), 50, 21, Color.white()) 132 | 133 | // The next three lines were modified for SFSymbol support. 134 | const condition = i==0?weatherData.current.weather[0].id:hourData.weather[0].id 135 | const condDate = i==0?weatherData.current.dt:hourData.dt 136 | drawImage(symbolForCondition(condition,condDate), spaceBetweenDays * i + 40, 165 - (50*delta)); 137 | 138 | drawTextC((i==0?"Now":hour), 18, spaceBetweenDays*i+25, 200,50, 21, Color.gray()) 139 | 140 | previousDelta = delta; 141 | } 142 | 143 | drawText("feels like " + Math.round(weatherData.current.feels_like) + "°", footerFontSize, feelsLikeCoords.x, feelsLikeCoords.y, Color.gray()) 144 | 145 | drawContext.setTextAlignedRight(); 146 | drawTextC(epochToDate(weatherData.current.dt).toLocaleTimeString(), footerFontSize, lastUpdateTimePosAndSize.x, lastUpdateTimePosAndSize.y, lastUpdateTimePosAndSize.width, lastUpdateTimePosAndSize.height, Color.gray()) 147 | 148 | widget.backgroundImage = (drawContext.getImage()) 149 | widget.presentMedium() 150 | 151 | async function loadImage(imgName){ 152 | if(fm.fileExists(fm.joinPath(cachePath, imgName))){ 153 | return Image.fromData(Data.fromFile(fm.joinPath(cachePath, imgName))) 154 | }else{ 155 | let imgdata = await new Request("https://openweathermap.org/img/wn/"+imgName+".png").load(); 156 | let img = Image.fromData(imgdata); 157 | fm.write(fm.joinPath(cachePath, imgName), imgdata); 158 | return img; 159 | } 160 | } 161 | 162 | function epochToDate(epoch){ 163 | return new Date(epoch * 1000) 164 | } 165 | 166 | function drawText(text, fontSize, x, y, color = Color.black()){ 167 | drawContext.setFont(Font.boldSystemFont(fontSize)) 168 | drawContext.setTextColor(color) 169 | drawContext.drawText(new String(text).toString(), new Point(x, y)) 170 | } 171 | 172 | function drawImage(image, x, y){ 173 | drawContext.drawImageAtPoint(image, new Point(x, y)) 174 | } 175 | 176 | function drawTextC(text, fontSize, x, y, w, h, color = Color.black()){ 177 | drawContext.setFont(Font.boldSystemFont(fontSize)) 178 | drawContext.setTextColor(color) 179 | drawContext.drawTextInRect(new String(text).toString(), new Rect(x, y, w, h)) 180 | } 181 | 182 | function drawLine(x1, y1, x2, y2, width, color){ 183 | const path = new Path() 184 | path.move(new Point(x1, y1)) 185 | path.addLine(new Point(x2, y2)) 186 | drawContext.addPath(path) 187 | drawContext.setStrokeColor(color) 188 | drawContext.setLineWidth(width) 189 | drawContext.strokePath() 190 | } 191 | 192 | function shouldRound(should, value){ 193 | return ((should) ? Math.round(value) : value) 194 | } 195 | 196 | // This function returns an SFSymbol image for a weather condition. 197 | function symbolForCondition(cond,condDate) { 198 | 199 | const sunrise = new Date(sunData.results.sunrise).getTime() 200 | const sunset = new Date(sunData.results.sunset).getTime() 201 | const timeValue = condDate * 1000 202 | 203 | // Is it night at the provided date? 204 | const night = (timeValue < sunrise) || (timeValue > sunset) 205 | 206 | // Define our symbol equivalencies. 207 | let symbols = { 208 | 209 | // Thunderstorm 210 | "2": function() { 211 | return "cloud.bolt.rain.fill" 212 | }, 213 | 214 | // Drizzle 215 | "3": function() { 216 | return "cloud.drizzle.fill" 217 | }, 218 | 219 | // Rain 220 | "5": function() { 221 | return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" 222 | }, 223 | 224 | // Snow 225 | "6": function() { 226 | return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" 227 | }, 228 | 229 | // Atmosphere 230 | "7": function() { 231 | if (cond == 781) { return "tornado" } 232 | if (cond == 701 || cond == 741) { return "cloud.fog.fill" } 233 | return night ? "cloud.fog.fill" : "sun.haze.fill" 234 | }, 235 | 236 | // Clear and clouds 237 | "8": function() { 238 | if (cond == 800) { return night ? "moon.stars.fill" : "sun.max.fill" } 239 | if (cond == 802 || cond == 803) { return night ? "cloud.moon.fill" : "cloud.sun.fill" } 240 | return "cloud.fill" 241 | } 242 | } 243 | 244 | // Find out the first digit. 245 | let conditionDigit = Math.floor(cond / 100) 246 | 247 | // Get the symbol. 248 | return SFSymbol.named(symbols[conditionDigit]()).image 249 | 250 | } 251 | 252 | Script.complete() 253 | -------------------------------------------------------------------------------- /work-in-progress/appleSubscriptions.js: -------------------------------------------------------------------------------- 1 | let url = "Https://Buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/DirectAction/manageSubscriptions" 2 | Safari.openInApp(url) -------------------------------------------------------------------------------- /work-in-progress/dropbox.js: -------------------------------------------------------------------------------- 1 | var folder = draft.processTemplate("[[safe_title]]"); 2 | 3 | // create Dropbox object and vars 4 | let db = Dropbox.create(); 5 | let endpoint = "https://api.dropboxapi.com/2/files/list_folder"; 6 | let args = { 7 | "path": "/Shared Folders/Drafts/" + folder + "/", 8 | "recursive": false, 9 | "include_media_info": false, 10 | "include_deleted": false, 11 | "include_has_explicit_shared_members": false, 12 | "include_mounted_folders": true 13 | }; 14 | 15 | // make API request 16 | let response = db.rpcRequest({ 17 | "url": endpoint, 18 | "method": "POST", 19 | "data": args 20 | }); 21 | if (response.statusCode != 200) { 22 | console.log("Dropbox Error: " + response.statusCode + ", " + response.error); 23 | context.fail(); 24 | } else { 25 | let p = Prompt.create(); 26 | p.title = "Pick a file"; 27 | let fileList = response.responseData.entries; 28 | Object.keys(fileList).forEach(function(key) { 29 | if (!fileList[key].name.endsWith('.md') && !fileList[key].name.endsWith('.html')) { 30 | p.addButton(fileList[key].name); 31 | } 32 | }); 33 | let didSelect = p.show(); 34 | if (didSelect) { 35 | editor.setSelectedText("![](" + p.buttonPressed + ")"); 36 | let selectionRange = editor.getSelectedRange(); 37 | editor.setSelectedRange(editor.getSelectedRange()[0]+2, 0); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /work-in-progress/jsonReader.js: -------------------------------------------------------------------------------- 1 | let url = "[json_url]]" 2 | let req = new Request(url) 3 | let json = await req.loadJSON() 4 | let output = Object.keys(json)[Math.floor(Math.random()*Object.keys(json).length)]; 5 | console.log(output) 6 | -------------------------------------------------------------------------------- /work-in-progress/message_many.js: -------------------------------------------------------------------------------- 1 | 2 | // Variables used by Scriptable. 3 | // These must be at the very top of the file. Do not edit. 4 | // icon-color: red; icon-glyph: envelope; 5 | // Message Many 6 | 7 | /** 8 | * The script is peculiar in that it presents the Messages UI before with the contents and reci- 9 | * pients filled in but doesn't send it right away – the user must manually press the send button. 10 | * 11 | * It can be modified to attach images/files as per the Message object documentation. 12 | */ 13 | 14 | const RECIPIENTS = [ 15 | // strings with phone numbers 16 | ]; 17 | const CONTENT = ''; 18 | 19 | const msg = new Message(); 20 | msg.recipients = RECIPIENTS; 21 | msg.body = CONTENT; 22 | msg.send(); 23 | -------------------------------------------------------------------------------- /work-in-progress/scratch.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mat-0/app_scriptable/a4ef512045ea2ca0247fb47587741bd64f6b1ec7/work-in-progress/scratch.js -------------------------------------------------------------------------------- /work-in-progress/tableApps.js: -------------------------------------------------------------------------------- 1 | const url = 2 | "https://raw.githubusercontent.com/Mat-0/app_scriptable/data/apps.json"; 3 | let req = new Request(url); 4 | let json = await req.loadJSON(); 5 | 6 | let table = new UITable(); 7 | 8 | for (line of json) { 9 | let row = new UITableRow(); 10 | row.cellSpacing = 10; 11 | 12 | let dateCell = row.addText(line.Apps); 13 | dateCell.widthWeight = 100; 14 | dateCell.centerAligned(); 15 | 16 | table.addRow(row); 17 | } 18 | 19 | QuickLook.present(table); 20 | --------------------------------------------------------------------------------