├── Import-Script ├── Import-Script.js ├── Import-Script.scriptable └── readme.md ├── LICENSE ├── OAuth2 ├── Save OAuth Client Info.js ├── spotify-api.js ├── spotify-app.js └── spotify-auth.js ├── README.md ├── Shortcuts Restore.js ├── docs ├── buttons-widget.md ├── img │ ├── butons-widget-crop.png │ ├── butons-widget.png │ ├── peanuts-widget-crop.png │ ├── peanuts-widget.png │ ├── simple-weather-crop.png │ ├── simple-weather.png │ ├── text-file-widget-crop.png │ ├── text-file-widget.png │ ├── tfw-lockscreen-cropped.png │ ├── tfw-lockscreen.png │ ├── tfw-with-custom.png │ ├── tfw-with-opts.png │ ├── xkcd-widget-crop.png │ └── xkcd-widget.png ├── importing.md ├── openweathermap.md ├── peanuts-widget.md ├── text-file-widget.md ├── widgets.md └── xkcd-widget.md ├── misc ├── preview-uspolls.jpg └── us-elections.js ├── no-background └── readme.md ├── routinehub-widgets ├── preview-rhp-sml.jpg ├── readme.md ├── rh-profile-widget.js └── rh-profile-widget.png ├── source ├── buttons-widget-sample.js ├── buttons-widget.js ├── lib-text-file-widget.js ├── openweathermap.js ├── peanuts-widget.js ├── simple-weather-widget.js ├── text-file-widget-w-custom.js ├── text-file-widget-w-opts.js ├── text-file-widget.js └── xkcd.js └── utilities ├── basic-ui.js └── json-util.js /Import-Script/Import-Script.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: blue; icon-glyph: download; share-sheet-inputs: plain-text, url; 4 | 5 | /* ----------------------------------------------- 6 | Script : Import-Script.js 7 | Author : me@supermamon.com 8 | Version : 1.5.2 9 | Description : 10 | A script to download and import files into the 11 | Scriptable folder. Includes a mini repo file 12 | browser for github repos. 13 | 14 | Supported Sites 15 | * github.com 16 | * gist.github.com 17 | * pastebin.com 18 | * hastebin.com 19 | * raw code from the clipboard 20 | 21 | Changelog: 22 | v1.5.2 - (fix) detection errors on last version 23 | v1.5.1 - (fix) unrecognized github file urls 24 | v1.5.0 - (new) ability to accept urls via the 25 | queryString argument. This is to allow 26 | creating scriptable:/// links on webpages 27 | to download scripts 28 | v1.4.0 - (new) option to use local storage instead 29 | of iCloud for users who don't have 30 | iCloud enabled 31 | v1.3.0 - (new) hastebin.com support 32 | v1.2.0 - (update)renamed to Import-Script.js 33 | - (fix) script names with spaces are saved 34 | with URL Encoding 35 | v1.1.1 - fix gist error introduced in v1.1 36 | v1.1.0 - support for gists with multiple files 37 | v1.0.0 - Initial releast 38 | ----------------------------------------------- */ 39 | 40 | // detect is icloud is used 41 | const USE_ICLOUD = module.filename.includes('Documents/iCloud~') 42 | 43 | let url; 44 | let data; 45 | 46 | // if there are no urls passed via the share sheet 47 | // get text from the clipboard 48 | if (args.urls.length > 0) { 49 | input = args.urls[0] 50 | } else if (args.queryParameters.url) { 51 | input = args.queryParameters.url 52 | } else { 53 | input = Pasteboard.paste() 54 | } 55 | 56 | 57 | //await presentAlert(`[${input}]`) 58 | 59 | // exit if there's no input 60 | if (!input) { 61 | log('nothing to work with') 62 | return 63 | } 64 | 65 | log(`input: ${input}`) 66 | 67 | // identify if the input is one of the supported 68 | // websites. if not, then it might be raw code. 69 | // ask the user about it 70 | var urlType = getUrlType(input) 71 | log(urlType) 72 | //await presentAlert(JSON.stringify(urlType)) 73 | if (!urlType) { 74 | let resp = await presentAlert('Unable to identify urls from the input. Is it already the actual code?', ["Yes","No"]) 75 | if (resp==0) { 76 | urlType = {name:'code'} 77 | } else { 78 | await presentAlert('Unsupported input.') 79 | return 80 | } 81 | } 82 | 83 | // store the information into a common structure 84 | switch (urlType.type) { 85 | case 'repo': 86 | data = await pickFileFromRepo(input, '') 87 | break; 88 | case 'repo-folder': 89 | data = await pickFileFromRepo(urlType.slices[urlType.repoIndex], urlType.slices[urlType.pathIndex]) 90 | break; 91 | case 'repo-file': 92 | data = await getRepoFileDetails(urlType.slices[urlType.repoIndex],urlType.slices[urlType.pathIndex]) 93 | break; 94 | case 'raw': 95 | var slices = input.match(urlType.regex) 96 | data = { 97 | source: 'rawurl', 98 | name: decodeURIComponent(`${urlType.slices[urlType.nameIndex]}${urlType.extension}`), 99 | download_url: input 100 | } 101 | break; 102 | case 'gist': 103 | data = await pickFileFromGist(urlType.slices[urlType.idIndex]) 104 | break; 105 | case 'pastebin': 106 | data = { 107 | source: 'pastebin', 108 | name: `${urlType.slices[urlType.nameIndex]}${urlType.extension}`, 109 | download_url: input.replace('.com','.com/raw') 110 | } 111 | break; 112 | case 'code': 113 | data = { 114 | source: 'raw', 115 | name: 'Untitled.js', 116 | code: input 117 | } 118 | break; 119 | default: 120 | } 121 | 122 | log('data') 123 | log(data) 124 | 125 | if (data) { 126 | let importedFile = await importScript(data) 127 | if (importedFile) { 128 | await presentAlert(`Imported ${importedFile}`,["OK"]) 129 | } 130 | return 131 | } 132 | 133 | //------------------------------------------------ 134 | function getUrlType(url) { 135 | const typeMatchers = [ 136 | {name: 'gh-repo', 137 | regex: /^https:\/\/github.com\/[^\s\/]+\/[^\s\/]+\/?$/, 138 | type: 'repo', 139 | repoIndex: 0 140 | }, 141 | {name: 'gh-repo-folder', 142 | regex: /^(https:\/\/github.com\/[^\s\/]+\/[^\s\/]+)\/tree\/[^\s\/]+(\/[^\s]+\/?)$/ , 143 | type: 'repo-folder', 144 | repoIndex: 1, 145 | pathIndex: 2 146 | }, 147 | {name: 'gh-repo-file-noblob', 148 | regex: /^(https:\/\/github.com\/[^\s\/]+\/[^\s\/]+)\/(?!blob)([^\s]+\.[a-zA-Z\d]+)$/, 149 | type: 'repo-file', 150 | repoIndex: 1, 151 | pathIndex: 2 152 | }, 153 | {name: 'gh-repo-file', 154 | regex: /^(https:\/\/github.com\/[^\s\/]+\/[^\s\/]+)\/blob\/[^\s\/]+(\/[^\s]+)$/, 155 | type: 'repo-file', 156 | repoIndex: 1, 157 | pathIndex: 2 158 | }, 159 | {name: 'gh-repo-raw', 160 | regex: /^https:\/\/raw\.githubusercontent\.com(\/[^\/\s]+)+\/([^\s]+)/, 161 | type: 'raw', 162 | nameIndex: 2 163 | }, 164 | {name: 'gh-gist', 165 | regex: /^(https:\/\/gist\.github.com\/)([^\/]+)\/([a-z0-9]+)$/, 166 | type: 'gist', 167 | idIndex: 3 168 | }, 169 | {name: 'gh-gist-raw', 170 | regex: /^https:\/\/gist\.githubusercontent\.com\/[^\/]+\/[^\/]+\/raw\/[^\/]+\/(.+)$/, 171 | type: 'raw', 172 | nameIndex: 1 173 | }, 174 | {name: 'pastebin-raw', 175 | regex: /^https:\/\/pastebin\.com\/raw\/([a-zA-Z\d]+)$/, 176 | type: 'raw', 177 | nameIndex: 1, 178 | extension: '.js' 179 | }, 180 | {name: 'pastebin', 181 | regex: /^https:\/\/pastebin\.com\/(?!raw)([a-zA-Z\d]+)/, 182 | type: 'pastebin', 183 | nameIndex: 1, 184 | extension: '.js' 185 | }, 186 | {name: 'hastebin', 187 | regex: /^https:\/\/hastebin\.com\/([a-z]+\.[a-z]+)$/, 188 | type: 'pastebin', 189 | nameIndex: 1 190 | }, 191 | {name: 'hastebin-raw', 192 | regex: /^https:\/\/hastebin\.com\/raw\/([a-z]+\.[a-z]+)$/, 193 | type: 'raw', 194 | nameIndex: 1 195 | } 196 | ] 197 | let types = typeMatchers.filter( matcher => { 198 | return matcher.regex.test(url) 199 | }) 200 | 201 | var type; 202 | if (types.length) { 203 | type = types[0] 204 | type['slices'] = url.match(type.regex) 205 | if (!type.hasOwnProperty('extension')) { 206 | type.extension = '' 207 | } 208 | } 209 | 210 | return type 211 | } 212 | //------------------------------------------------ 213 | async function pickFileFromRepo(url, path) { 214 | 215 | log('fn:pickFileFromRepo') 216 | log(`url = ${url}`) 217 | log(`path = ${path}`) 218 | 219 | url = url.replace(/\/$/,'') 220 | const apiUrl = url.replace('/github.com/', 221 | 'api.github.com/repos/') 222 | 223 | log(`apiURL=${apiUrl}`) 224 | 225 | let req = new Request(apiUrl) 226 | try { 227 | var data = await req.loadJSON() 228 | } catch (e) { 229 | await presentAlert("Unable to fetch repo information. Likely due to api limits", ["OK"]) 230 | return null 231 | } 232 | 233 | let contents_url = data.contents_url 234 | log(`contents_url = ${contents_url}`) 235 | 236 | // get contents 237 | contents_url = contents_url.replace('{+path}',path) 238 | req = new Request(contents_url) 239 | try { 240 | var contents = await req.loadJSON() 241 | } catch (e) { 242 | await presentAlert("Unable to fetch repo information. Likely due to api limits", ["OK"]) 243 | return null 244 | } 245 | 246 | log(contents.map(c=>c.name).join("\n")) 247 | 248 | let table = new UITable() 249 | let list = [] 250 | 251 | // add a .. entry if path is passed 252 | if (path) { 253 | list.push({ 254 | name: '..', 255 | type: 'dir', 256 | path: '..' 257 | }) 258 | } 259 | 260 | list.push(contents) 261 | list = list.flat().sort( (a,b) => { 262 | if (a.type==b.type) { 263 | if (a.name.toLowerCase() < b.name.toLowerCase()) { 264 | return -1 265 | } else if (a.name.toLowerCase() > b.name.toLowerCase()) { 266 | return 1 267 | } 268 | } else { 269 | if (a.type == 'dir' ) { 270 | return -1 271 | } else if (b.type == 'dir' ) { 272 | return 1 273 | } 274 | } 275 | 276 | return 0 277 | }) 278 | 279 | let selected; 280 | list.forEach( content => { 281 | const row = new UITableRow() 282 | 283 | let name = content.name 284 | let display_name = content.type == 'dir' ? `${name}/` : name 285 | if (name=='..') display_name = name 286 | 287 | let icon = content.type=='dir'?(name=='..'?'arrow.left':'folder'):'doc' 288 | let sfIcon = SFSymbol.named(`${icon}.circle`) 289 | sfIcon.applyFont(Font.systemFont(25)) 290 | let img = sfIcon.image 291 | let iconCell = row.addImage(img) 292 | iconCell.widthWeight = 10 293 | iconCell.centerAligned() 294 | 295 | let nameCell = row.addText(display_name) 296 | nameCell.widthWeight = 90 297 | 298 | row.onSelect = (index) => { 299 | selected = list[index] 300 | } 301 | 302 | table.addRow(row) 303 | }) 304 | 305 | let resp = await table.present() 306 | 307 | if (!selected) return null 308 | 309 | log(selected.name) 310 | 311 | if (selected.type == 'dir') { 312 | if (selected.name == '..') { 313 | const lastPath = path.split('/').reverse().slice(1).reverse().join('/') 314 | selected = await pickFileFromRepo(url, lastPath) 315 | } else { 316 | selected = await pickFileFromRepo(url, selected.path) 317 | } 318 | } 319 | 320 | if (selected) { 321 | return { 322 | source: 'repo', 323 | name: selected.name, 324 | download_url: selected.download_url 325 | } 326 | } 327 | return null 328 | } 329 | //------------------------------------------------ 330 | async function getRepoFileDetails(repoUrl, path) { 331 | 332 | repoUrl = repoUrl.replace(/\/$/,'') 333 | path = path.replace(/^\//,'') 334 | 335 | log(`repo ${repoUrl}`) 336 | log(`path ${path}`) 337 | path = path.replace(/blob\/[^\/]+/,'') 338 | log(`path ${path}`) 339 | 340 | let apiUrl = repoUrl.replace('/github.com/',`api.github.com/repos/`) 341 | apiUrl = `${apiUrl}/contents/${path}` 342 | const req = new Request(apiUrl) 343 | try { 344 | var resp = await req.loadJSON() 345 | log(resp) 346 | if (resp.message) { 347 | await presentAlert(resp.message) 348 | return null 349 | } 350 | 351 | } catch(e) { 352 | log(e.message) 353 | await presentAlert(`Unable to fetch repo information - ${e.message}`, ["OK"]) 354 | return null 355 | } 356 | 357 | const data = { 358 | source: 'repo', 359 | name: resp.name, 360 | path: resp.path, 361 | download_url: resp.download_url 362 | } 363 | return data 364 | 365 | } 366 | //------------------------------------------------ 367 | async function pickFileFromGist(gistId) { 368 | let apiUrl = `https://api.github.com/gists/${gistId}` 369 | log(apiUrl) 370 | const req = new Request(apiUrl) 371 | 372 | try { 373 | var gist = await req.loadJSON() 374 | } catch(e) { 375 | await presentAlert("Unable to fetch repo information. Likely due to api limits", ["OK"]) 376 | return null 377 | } 378 | 379 | let filenames = Object.keys(gist.files) 380 | log(filenames) 381 | // don't show browser if just one file 382 | if (filenames.length == 1) { 383 | let file = gist.files[filenames[0]] 384 | log(file) 385 | return { 386 | source: 'gist', 387 | name: file.filename, 388 | download_url: file.raw_url 389 | } 390 | } 391 | 392 | let selected; 393 | 394 | let table = new UITable() 395 | filenames = filenames.sort() 396 | filenames.forEach( filename => { 397 | const row = new UITableRow() 398 | 399 | let sfIcon = SFSymbol.named(`doc.circle`) 400 | sfIcon.applyFont(Font.systemFont(25)) 401 | let img = sfIcon.image 402 | let iconCell = row.addImage(img) 403 | iconCell.widthWeight = 10 404 | iconCell.centerAligned() 405 | 406 | let nameCell = row.addText(filename) 407 | nameCell.widthWeight = 90 408 | 409 | row.onSelect = (index) => { 410 | selected = filenames[index] 411 | } 412 | 413 | table.addRow(row) 414 | }) 415 | 416 | await table.present() 417 | 418 | if (!selected) return null 419 | 420 | if (selected) { 421 | let file = gist.files[selected] 422 | return { 423 | source: 'gist', 424 | name: file.filename, 425 | download_url: file.raw_url 426 | } 427 | } 428 | 429 | 430 | } 431 | //------------------------------------------------ 432 | async function importScript(data) { 433 | 434 | var fm = USE_ICLOUD ? FileManager.iCloud() : 435 | FileManager.local() 436 | 437 | log(`fn:importScript`) 438 | log(data.source) 439 | log(data.name) 440 | 441 | var code; 442 | var name = data.name 443 | 444 | if (data.source == 'raw' ) { 445 | code = data.code 446 | } else { 447 | let url = data.download_url 448 | let resp = await presentAlert(`Download ${name}?`,["Yes","No"]) 449 | if (resp==0) { 450 | log(`downloading from ${url}`) 451 | const req = new Request(url) 452 | code = await req.loadString() 453 | } 454 | } 455 | 456 | if (code) { 457 | 458 | let filename = name 459 | let fileExists = true 460 | 461 | while(fileExists) { 462 | filename = await presentPrompt("Save as", filename) 463 | log(filename) 464 | 465 | if (filename) { 466 | let savePath = fm.joinPath(fm.documentsDirectory(), filename) 467 | fileExists = fm.fileExists(savePath) 468 | log(fileExists) 469 | 470 | if (fileExists) { 471 | let resp = await presentAlert('File exists. Overwrite?',["Yes","No","Cancel"]) 472 | if (resp==2) { 473 | fileExists = false 474 | filename = null 475 | } else { 476 | fileExists = resp == 1 477 | } 478 | } 479 | } else { 480 | fileExists = false 481 | } 482 | } 483 | 484 | if (filename) { 485 | log(`saving ${filename}`) 486 | const path = fm.joinPath(fm.documentsDirectory(), filename) 487 | fm.writeString(path, code) 488 | return filename 489 | } 490 | } 491 | return null 492 | } 493 | //------------------------------------------------ 494 | async function presentAlert(prompt,items = ["OK"],asSheet) 495 | { 496 | 497 | let alert = new Alert() 498 | alert.message = prompt 499 | 500 | for (const item of items) { 501 | alert.addAction(item) 502 | } 503 | let resp = asSheet ? 504 | await alert.presentSheet() : 505 | await alert.presentAlert() 506 | log(resp) 507 | return resp 508 | } 509 | //------------------------------------------------ 510 | async function presentPrompt(prompt,defaultText) 511 | { 512 | let alert = new Alert() 513 | alert.message = prompt 514 | 515 | alert.addTextField("",defaultText) 516 | 517 | var buttons = ["OK", "Cancel"] 518 | for (const button of buttons) { 519 | alert.addAction(button) 520 | } 521 | let resp = await alert.presentAlert() 522 | if (resp==0) { 523 | return alert.textFieldValue(0) 524 | } 525 | return null 526 | } 527 | -------------------------------------------------------------------------------- /Import-Script/Import-Script.scriptable: -------------------------------------------------------------------------------- 1 | { 2 | "always_run_in_app" : false, 3 | "icon" : { 4 | "color" : "blue", 5 | "glyph" : "download" 6 | }, 7 | "name" : "Import-Script", 8 | "script" : "\n\/* -----------------------------------------------\nScript : Import-Script.js\nAuthor : me@supermamon.com\nVersion : 1.5.2\nDescription :\n A script to download and import files into the\n Scriptable folder. Includes a mini repo file \n browser for github repos.\n\nSupported Sites\n* github.com\n* gist.github.com\n* pastebin.com\n* hastebin.com\n* raw code from the clipboard\n\nChangelog:\nv1.5.2 - (fix) detection errors on last version\nv1.5.1 - (fix) unrecognized github file urls\nv1.5.0 - (new) ability to accept urls via the \n queryString argument. This is to allow\n creating scriptable:\/\/\/ links on webpages\n to download scripts\nv1.4.0 - (new) option to use local storage instead\n of iCloud for users who don't have \n iCloud enabled\nv1.3.0 - (new) hastebin.com support\nv1.2.0 - (update)renamed to Import-Script.js\n - (fix) script names with spaces are saved \n with URL Encoding\nv1.1.1 - fix gist error introduced in v1.1\nv1.1.0 - support for gists with multiple files\nv1.0.0 - Initial releast\n----------------------------------------------- *\/\n\n\/\/ detect is icloud is used\nconst USE_ICLOUD = module.filename.includes('Documents\/iCloud~')\n\nlet url;\nlet data;\n\n\/\/ if there are no urls passed via the share sheet\n\/\/ get text from the clipboard\nif (args.urls.length > 0) {\n input = args.urls[0]\n} else if (args.queryParameters.url) {\n input = args.queryParameters.url\n} else {\n input = Pasteboard.paste()\n}\n\n\n\/\/await presentAlert(`[${input}]`)\n\n\/\/ exit if there's no input\nif (!input) {\n log('nothing to work with')\n return\n}\n\nlog(`input: ${input}`)\n\n\/\/ identify if the input is one of the supported\n\/\/ websites. if not, then it might be raw code.\n\/\/ ask the user about it\nvar urlType = getUrlType(input)\nlog(urlType)\n\/\/await presentAlert(JSON.stringify(urlType))\nif (!urlType) {\n let resp = await presentAlert('Unable to identify urls from the input. Is it already the actual code?', [\"Yes\",\"No\"])\n if (resp==0) {\n urlType = {name:'code'}\n } else {\n await presentAlert('Unsupported input.')\n return\n }\n}\n\n\/\/ store the information into a common structure\nswitch (urlType.type) {\n case 'repo': \n data = await pickFileFromRepo(input, '')\n break;\n case 'repo-folder':\n data = await pickFileFromRepo(urlType.slices[urlType.repoIndex], urlType.slices[urlType.pathIndex])\n break;\n case 'repo-file': \n data = await getRepoFileDetails(urlType.slices[urlType.repoIndex],urlType.slices[urlType.pathIndex])\n break;\n case 'raw': \n var slices = input.match(urlType.regex)\n data = {\n source: 'rawurl',\n name: decodeURIComponent(`${urlType.slices[urlType.nameIndex]}${urlType.extension}`),\n download_url: input\n }\n break;\n case 'gist': \n data = await pickFileFromGist(urlType.slices[urlType.idIndex])\n break;\n case 'pastebin': \n data = {\n source: 'pastebin',\n name: `${urlType.slices[urlType.nameIndex]}${urlType.extension}`,\n download_url: input.replace('.com','.com\/raw')\n }\n break;\n case 'code':\n data = {\n source: 'raw',\n name: 'Untitled.js',\n code: input\n } \n break;\n default:\n}\n\nlog('data')\nlog(data)\n\nif (data) {\n let importedFile = await importScript(data)\n if (importedFile) {\n await presentAlert(`Imported ${importedFile}`,[\"OK\"])\n }\n return\n}\n\n\/\/------------------------------------------------\nfunction getUrlType(url) {\n const typeMatchers = [\n {name: 'gh-repo', \n regex: \/^https:\\\/\\\/github.com\\\/[^\\s\\\/]+\\\/[^\\s\\\/]+\\\/?$\/,\n type: 'repo',\n repoIndex: 0\n },\n {name: 'gh-repo-folder', \n regex: \/^(https:\\\/\\\/github.com\\\/[^\\s\\\/]+\\\/[^\\s\\\/]+)\\\/tree\\\/[^\\s\\\/]+(\\\/[^\\s]+\\\/?)$\/ ,\n type: 'repo-folder',\n repoIndex: 1,\n pathIndex: 2\n },\n {name: 'gh-repo-file-noblob', \n regex: \/^(https:\\\/\\\/github.com\\\/[^\\s\\\/]+\\\/[^\\s\\\/]+)\\\/(?!blob)([^\\s]+\\.[a-zA-Z\\d]+)$\/,\n type: 'repo-file',\n repoIndex: 1,\n pathIndex: 2\n },\n {name: 'gh-repo-file', \n regex: \/^(https:\\\/\\\/github.com\\\/[^\\s\\\/]+\\\/[^\\s\\\/]+)\\\/blob\\\/[^\\s\\\/]+(\\\/[^\\s]+)$\/,\n type: 'repo-file',\n repoIndex: 1,\n pathIndex: 2\n },\n {name: 'gh-repo-raw', \n regex: \/^https:\\\/\\\/raw\\.githubusercontent\\.com(\\\/[^\\\/\\s]+)+\\\/([^\\s]+)\/,\n type: 'raw',\n nameIndex: 2\n },\n {name: 'gh-gist', \n regex: \/^(https:\\\/\\\/gist\\.github.com\\\/)([^\\\/]+)\\\/([a-z0-9]+)$\/,\n type: 'gist',\n idIndex: 3\n },\n {name: 'gh-gist-raw', \n regex: \/^https:\\\/\\\/gist\\.githubusercontent\\.com\\\/[^\\\/]+\\\/[^\\\/]+\\\/raw\\\/[^\\\/]+\\\/(.+)$\/,\n type: 'raw',\n nameIndex: 1\n },\n {name: 'pastebin-raw', \n regex: \/^https:\\\/\\\/pastebin\\.com\\\/raw\\\/([a-zA-Z\\d]+)$\/,\n type: 'raw',\n nameIndex: 1,\n extension: '.js'\n },\n {name: 'pastebin', \n regex: \/^https:\\\/\\\/pastebin\\.com\\\/(?!raw)([a-zA-Z\\d]+)\/,\n type: 'pastebin',\n nameIndex: 1,\n extension: '.js'\n },\n {name: 'hastebin', \n regex: \/^https:\\\/\\\/hastebin\\.com\\\/([a-z]+\\.[a-z]+)$\/,\n type: 'pastebin',\n nameIndex: 1\n },\n {name: 'hastebin-raw', \n regex: \/^https:\\\/\\\/hastebin\\.com\\\/raw\\\/([a-z]+\\.[a-z]+)$\/,\n type: 'raw',\n nameIndex: 1\n }\n ]\n let types = typeMatchers.filter( matcher => {\n return matcher.regex.test(url)\n })\n\n var type;\n if (types.length) {\n type = types[0]\n type['slices'] = url.match(type.regex)\n if (!type.hasOwnProperty('extension')) {\n type.extension = ''\n }\n }\n\n return type\n}\n\/\/------------------------------------------------\nasync function pickFileFromRepo(url, path) {\n\n log('fn:pickFileFromRepo')\n log(`url = ${url}`)\n log(`path = ${path}`)\n\n url = url.replace(\/\\\/$\/,'')\n const apiUrl = url.replace('\/github.com\/',\n 'api.github.com\/repos\/')\n\n log(`apiURL=${apiUrl}`)\n\n let req = new Request(apiUrl)\n try {\n var data = await req.loadJSON()\n } catch (e) {\n await presentAlert(\"Unable to fetch repo information. Likely due to api limits\", [\"OK\"])\n return null\n }\n \n let contents_url = data.contents_url\n log(`contents_url = ${contents_url}`)\n\n \/\/ get contents\n contents_url = contents_url.replace('{+path}',path)\n req = new Request(contents_url)\n try {\n var contents = await req.loadJSON()\n } catch (e) {\n await presentAlert(\"Unable to fetch repo information. Likely due to api limits\", [\"OK\"])\n return null\n }\n \n log(contents.map(c=>c.name).join(\"\\n\"))\n\n let table = new UITable()\n let list = []\n\n \/\/ add a .. entry if path is passed\n if (path) {\n list.push({\n name: '..',\n type: 'dir',\n path: '..'\n })\n }\n\n list.push(contents)\n list = list.flat().sort( (a,b) => {\n if (a.type==b.type) {\n if (a.name.toLowerCase() < b.name.toLowerCase()) {\n return -1\n } else if (a.name.toLowerCase() > b.name.toLowerCase()) {\n return 1\n }\n } else {\n if (a.type == 'dir' ) {\n return -1\n } else if (b.type == 'dir' ) {\n return 1\n }\n }\n\n return 0\n })\n \n let selected;\n list.forEach( content => {\n const row = new UITableRow()\n\n let name = content.name\n let display_name = content.type == 'dir' ? `${name}\/` : name\n if (name=='..') display_name = name\n \n let icon = content.type=='dir'?(name=='..'?'arrow.left':'folder'):'doc'\n let sfIcon = SFSymbol.named(`${icon}.circle`)\n sfIcon.applyFont(Font.systemFont(25))\n let img = sfIcon.image\n let iconCell = row.addImage(img)\n iconCell.widthWeight = 10\n iconCell.centerAligned()\n\n let nameCell = row.addText(display_name)\n nameCell.widthWeight = 90\n\n row.onSelect = (index) => {\n selected = list[index]\n }\n\n table.addRow(row)\n })\n\n let resp = await table.present()\n\n if (!selected) return null\n\n log(selected.name)\n \n if (selected.type == 'dir') {\n if (selected.name == '..') {\n const lastPath = path.split('\/').reverse().slice(1).reverse().join('\/')\n selected = await pickFileFromRepo(url, lastPath)\n } else {\n selected = await pickFileFromRepo(url, selected.path)\n }\n }\n\n if (selected) {\n return {\n source: 'repo',\n name: selected.name,\n download_url: selected.download_url\n }\n } \n return null\n}\n\/\/------------------------------------------------\nasync function getRepoFileDetails(repoUrl, path) {\n\n repoUrl = repoUrl.replace(\/\\\/$\/,'')\n path = path.replace(\/^\\\/\/,'')\n\n log(`repo ${repoUrl}`)\n log(`path ${path}`)\n path = path.replace(\/blob\\\/[^\\\/]+\/,'')\n log(`path ${path}`)\n\n let apiUrl = repoUrl.replace('\/github.com\/',`api.github.com\/repos\/`)\n apiUrl = `${apiUrl}\/contents\/${path}`\n const req = new Request(apiUrl)\n try {\n var resp = await req.loadJSON()\n log(resp)\n if (resp.message) {\n await presentAlert(resp.message)\n return null\n }\n \n } catch(e) {\n log(e.message)\n await presentAlert(`Unable to fetch repo information - ${e.message}`, [\"OK\"])\n return null\n }\n\n const data = {\n source: 'repo',\n name: resp.name,\n path: resp.path,\n download_url: resp.download_url\n }\n return data\n\n}\n\/\/------------------------------------------------\nasync function pickFileFromGist(gistId) {\n let apiUrl = `https:\/\/api.github.com\/gists\/${gistId}` \n log(apiUrl)\n const req = new Request(apiUrl)\n\n try {\n var gist = await req.loadJSON()\n } catch(e) {\n await presentAlert(\"Unable to fetch repo information. Likely due to api limits\", [\"OK\"])\n return null\n }\n\n let filenames = Object.keys(gist.files)\n log(filenames)\n \/\/ don't show browser if just one file\n if (filenames.length == 1) {\n let file = gist.files[filenames[0]]\n log(file)\n return {\n source: 'gist',\n name: file.filename,\n download_url: file.raw_url\n } \n }\n\n let selected;\n\n let table = new UITable()\n filenames = filenames.sort()\n filenames.forEach( filename => {\n const row = new UITableRow()\n\n let sfIcon = SFSymbol.named(`doc.circle`)\n sfIcon.applyFont(Font.systemFont(25))\n let img = sfIcon.image\n let iconCell = row.addImage(img)\n iconCell.widthWeight = 10\n iconCell.centerAligned()\n\n let nameCell = row.addText(filename)\n nameCell.widthWeight = 90\n\n row.onSelect = (index) => {\n selected = filenames[index]\n }\n\n table.addRow(row)\n })\n\n await table.present()\n\n if (!selected) return null \n\n if (selected) {\n let file = gist.files[selected]\n return {\n source: 'gist',\n name: file.filename,\n download_url: file.raw_url\n }\n } \n\n\n}\n\/\/------------------------------------------------\nasync function importScript(data) {\n \n var fm = USE_ICLOUD ? FileManager.iCloud() :\n FileManager.local()\n \n log(`fn:importScript`)\n log(data.source)\n log(data.name)\n\n var code;\n var name = data.name\n\n if (data.source == 'raw' ) {\n code = data.code\n } else {\n let url = data.download_url\n let resp = await presentAlert(`Download ${name}?`,[\"Yes\",\"No\"])\n if (resp==0) {\n log(`downloading from ${url}`)\n const req = new Request(url)\n code = await req.loadString()\n } \n }\n\n if (code) {\n\n let filename = name\n let fileExists = true\n\n while(fileExists) {\n filename = await presentPrompt(\"Save as\", filename)\n log(filename)\n\n if (filename) {\n let savePath = fm.joinPath(fm.documentsDirectory(), filename)\n fileExists = fm.fileExists(savePath)\n log(fileExists)\n\n if (fileExists) {\n let resp = await presentAlert('File exists. Overwrite?',[\"Yes\",\"No\",\"Cancel\"])\n if (resp==2) {\n fileExists = false\n filename = null\n } else {\n fileExists = resp == 1\n }\n }\n } else {\n fileExists = false\n }\n }\n\n if (filename) {\n log(`saving ${filename}`)\n const path = fm.joinPath(fm.documentsDirectory(), filename)\n fm.writeString(path, code)\n return filename\n }\n } \n return null\n}\n\/\/------------------------------------------------\nasync function presentAlert(prompt,items = [\"OK\"],asSheet) \n{\n\n let alert = new Alert()\n alert.message = prompt\n \n for (const item of items) {\n alert.addAction(item)\n }\n let resp = asSheet ? \n await alert.presentSheet() : \n await alert.presentAlert()\n log(resp)\n return resp\n}\n\/\/------------------------------------------------\nasync function presentPrompt(prompt,defaultText) \n{\n let alert = new Alert()\n alert.message = prompt\n\n alert.addTextField(\"\",defaultText)\n \n var buttons = [\"OK\", \"Cancel\"]\n for (const button of buttons) {\n alert.addAction(button)\n }\n let resp = await alert.presentAlert()\n if (resp==0) {\n return alert.textFieldValue(0)\n }\n return null\n}\n", 9 | "share_sheet_inputs" : [ 10 | "plain-text", 11 | "url" 12 | ] 13 | } -------------------------------------------------------------------------------- /Import-Script/readme.md: -------------------------------------------------------------------------------- 1 | # Import-Script.js 2 | 3 | A script importer with a built-in repo/gist browser to choose the script to added to the Scriptable.app library. 4 | 5 | [Download Import-Script](Import-Script.js). 6 | 7 | ### Supported Websites 8 | 9 | * github.com repos and raw urls 10 | * gist.github.com 11 | * pastebin.com 12 | * hastebin.com 13 | * raw code from the clipboard 14 | 15 | ### How To Use 16 | 17 | Share the URL via the share sheet or copy the url or actual code in the clipboard the run the script. 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Raymond Velasquez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OAuth2/Save OAuth Client Info.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: purple; icon-glyph: save; 4 | /* 5 | Script : Save OAuth Client Info.js 6 | Author : @supermamon 7 | Version: 1.0.0 8 | 9 | More Info: https://github.com/supermamon/oauth-proxy 10 | 11 | */ 12 | const FM = FileManager.iCloud(); 13 | const HOME = FM.documentsDirectory(); 14 | const jsonutil = importModule('./json-util.js') 15 | const UI = importModule('./basic-ui.js') 16 | 17 | async function askForText(caption, defaultValue) { 18 | let prompt = new UI.Prompt(caption, defaultValue) 19 | return await prompt.show() 20 | } 21 | async function alert(message) { 22 | let msg = new Alert() 23 | msg.message = message 24 | await msg.present() 25 | } 26 | let app_name = await askForText('Application Name') 27 | let client_id = await askForText('client_id') 28 | let client_secret = await askForText('client_secret') 29 | let redirect_uri = await askForText('redirect_uri') 30 | 31 | let storage_dir = FM.joinPath(HOME, app_name) 32 | 33 | if (!FM.fileExists(storage_dir)) { 34 | FM.createDirectory(storage_dir) 35 | } 36 | 37 | let auth = `${client_id}:${client_secret}` 38 | auth = Data.fromString(auth).toBase64String() 39 | auth = `Basic ${auth}` 40 | 41 | let client = { 42 | client_id: client_id, 43 | client_secret: client_secret, 44 | authorization: auth, 45 | redirect_uri: redirect_uri 46 | } 47 | 48 | jsonutil.writeToFile(client, FM.joinPath(storage_dir,'client.json')) 49 | 50 | await alert(`Client Info saved to \n /Scriptable/${app_name}/client.json.`) -------------------------------------------------------------------------------- /OAuth2/spotify-api.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: deep-green; icon-glyph: rss-square; 4 | /* 5 | Script : spotify-api.js 6 | Author : @supermamon 7 | Version: 1.0.0 8 | 9 | More Info: https://github.com/supermamon/oauth-proxy 10 | 11 | */ 12 | const FM = FileManager.iCloud(); 13 | const jsonutil = importModule('./json-util') 14 | 15 | function Spotify(storage_dir) { 16 | 17 | this.storage_dir = storage_dir 18 | this.client_file = FM.joinPath(storage_dir,'client.json') 19 | 20 | log(`client_file: ${this.client_file}`) 21 | 22 | if (!FM.fileExists(this.client_file)) { 23 | FM.downloadFileFromiCloud(this.client_file) 24 | } 25 | 26 | this.client = jsonutil.loadFromFile(this.client_file) 27 | log(`client: ${JSON.stringify(this.client)}`) 28 | 29 | this.current_user_file = FM.joinPath(storage_dir,'current_user.access_token.json') 30 | 31 | if (!FM.fileExists(this.current_user_file)) { 32 | FM.downloadFileFromiCloud(this.current_user_file) 33 | } 34 | 35 | log(`current_user_file: ${this.current_user_file}`) 36 | 37 | if (FM.fileExists(this.current_user_file)) { 38 | this.token = jsonutil.loadFromFile(this.current_user_file) 39 | } else { 40 | this.token = {} 41 | } 42 | 43 | log(`token: ${JSON.stringify(this.token)}`) 44 | 45 | this.BASE_ENDPOINT = 'https://api.spotify.com/v1' 46 | this.AUTH_URL = 'https://accounts.spotify.com/authorize' 47 | this.TOKEN_URL = 'https://accounts.spotify.com/api/token' 48 | this.REFRESH_URL = this.TOKEN_URL 49 | 50 | } 51 | 52 | Spotify.prototype.isAuthenticated = function() { 53 | return (!!this.token.access_token) 54 | } 55 | 56 | Spotify.prototype.launchAuthentication = function(scope, state) 57 | { 58 | let auth_url = this.AUTH_URL 59 | auth_url += `?client_id=${this.client.client_id}` 60 | auth_url += `&response_type=code` 61 | auth_url += `&scope=${encodeURIComponent(scope)}` 62 | auth_url += `&state=${encodeURIComponent(state)}` 63 | auth_url += `&redirect_uri=${encodeURIComponent(this.client.redirect_uri)}` 64 | console.log('auth_url', auth_url) 65 | 66 | Safari.open(auth_url) 67 | } 68 | 69 | Spotify.prototype.getAccessToken = async function(authorization) { 70 | 71 | var req = new Request(this.TOKEN_URL) 72 | req.headers = { 73 | 'Authorization' : `${this.client.authorization}`, 74 | 'Content-Type': 'application/x-www-form-urlencoded' 75 | } 76 | req.method = 'POST' 77 | let body = `grant_type=authorization_code` + 78 | `&code=${encodeURIComponent(authorization.code)}` + 79 | `&redirect_uri=${encodeURIComponent(this.client.redirect_uri)}` 80 | 81 | req.body = body 82 | 83 | this.token = await req.loadJSON(); 84 | 85 | let end_date = new Date(); 86 | end_date.setSeconds(end_date.getSeconds() + this.token.expires_in) 87 | 88 | this.token['expires_on'] = end_date 89 | 90 | jsonutil.writeToFile(this.token, FM.joinPath(this.storage_dir, 'current_user.access_token.json')) 91 | 92 | return this.token 93 | } 94 | 95 | Spotify.prototype.refreshToken = async function() { 96 | 97 | if (!this.isAuthenticated()) { 98 | throw 'Not yet authenticated.' 99 | } 100 | 101 | let now = new Date() 102 | let expiry = new Date(this.token.expires_on) 103 | 104 | log('checking token validity') 105 | if (now < expiry) { 106 | log('token is still valid. not refreshing') 107 | return this.token 108 | } else { 109 | log('token expired. refreshing.') 110 | } 111 | 112 | var req = new Request(this.REFRESH_URL) 113 | req.headers = { 114 | 'Authorization' : `${this.client.authorization}`, 115 | 'Content-Type': 'application/x-www-form-urlencoded' 116 | } 117 | req.method = 'POST' 118 | let body = `grant_type=refresh_token` + 119 | `&refresh_token=${encodeURIComponent(this.token.refresh_token)}` 120 | 121 | req.body = body 122 | 123 | this.token = await req.loadJSON(); 124 | 125 | let end_date = new Date(); 126 | end_date.setSeconds(end_date.getSeconds() + this.token.expires_in) 127 | 128 | this.token['expires_on'] = end_date 129 | 130 | jsonutil.writeToFile(this.token, FM.joinPath(this.storage_dir, 'current_user.access_token.json')) 131 | 132 | return this.token 133 | 134 | } 135 | Spotify.prototype.getUser = async function() { 136 | if (!this.isAuthenticated()) { 137 | throw 'Not yet authenticated.' 138 | } 139 | 140 | await this.refreshToken() 141 | 142 | let url = `${this.BASE_ENDPOINT}/me` 143 | var req = new Request(url) 144 | req.headers = { 145 | 'Authorization' : `Bearer ${this.token.access_token}` 146 | } 147 | req.method = 'GET' 148 | this.user = await req.loadJSON(); 149 | return this.user 150 | 151 | } 152 | Spotify.prototype.getLatestPlayed = async function(limit) { 153 | limit = limit?limit:20 154 | var url = `${this.BASE_ENDPOINT}/me/player/recently-played?limit=${limit}` 155 | 156 | let req = new Request(url) 157 | req.headers = { 158 | 'Authorization' : `Bearer ${this.token.access_token}` 159 | } 160 | req.method = 'GET' 161 | 162 | let resp = await req.loadJSON() 163 | 164 | let items = resp.items.map(item => { 165 | return { 166 | name: item.track.name, 167 | played_at: item.played_at 168 | } 169 | }) 170 | return items; 171 | 172 | } 173 | 174 | 175 | module.exports = Spotify -------------------------------------------------------------------------------- /OAuth2/spotify-app.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: deep-green; icon-glyph: rss-square; 4 | /* 5 | Script : spotify-app.js 6 | Author : @supermamon 7 | Version: 1.0.0 8 | 9 | More Info: https://github.com/supermamon/oauth-proxy 10 | 11 | */ 12 | const FM = FileManager.iCloud(); 13 | const HOME = FM.documentsDirectory(); 14 | const Spotify = importModule('./spotify-api.js') 15 | 16 | // new instance of the Spotify client 17 | const app = new Spotify(FM.joinPath(HOME, 'spotify')) 18 | 19 | // refresh token if needed 20 | await app.refreshToken() 21 | 22 | // get the latest played songs 23 | let songs = await app.getLatestPlayed(25) 24 | 25 | // display the list 26 | let table = new UITable() 27 | await songs.forEach( song => { 28 | let row = new UITableRow() 29 | 30 | let titleCell = UITableCell.text(song.name); 31 | row.addCell(titleCell); 32 | 33 | let countCell = UITableCell.text(song.played_at) 34 | row.addCell(countCell); 35 | 36 | row.height = 30 37 | row.cellSpacing = 5 38 | table.addRow(row) 39 | }) 40 | 41 | QuickLook.present(table) -------------------------------------------------------------------------------- /OAuth2/spotify-auth.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: deep-green; icon-glyph: rss-square; 4 | /* 5 | Script : spotify-auth.js 6 | Author : @supermamon 7 | Version: 1.0.0 8 | 9 | More Info: https://github.com/supermamon/oauth-proxy 10 | 11 | Assumptions: 12 | * a `spotify` directory under the Scriptable 13 | that contains `client.json`. Use the 14 | `Save OAuth Client Info` script to generate 15 | `client.json 16 | 17 | */ 18 | 19 | const FM = FileManager.iCloud(); 20 | const HOME = FM.documentsDirectory(); 21 | const Spotify = importModule('./spotify-api.js') 22 | 23 | // new instance of the Spotify client 24 | const app = new Spotify(FM.joinPath(HOME, 'spotify')) 25 | 26 | async function alert(message) { 27 | let msg = new Alert() 28 | msg.message = message 29 | await msg.present() 30 | } 31 | 32 | // if not `code` argument is passed, start the 33 | // authentiation 34 | if (!args.queryParameters['code']) { 35 | let scope = 'user-read-recently-played' 36 | let state = 'sriptable-for-iphone' 37 | app.launchAuthentication(scope, state) 38 | } else { 39 | // else get the access token 40 | let token = await app.getAccessToken({ 41 | code: args.queryParameters['code'], 42 | state: args.queryParameters['state'] 43 | }) 44 | 45 | await app.getUser() 46 | 47 | await alert(`${app.user.display_name} authenticated. Access expires on ${app.token.expires_on.toString()}.`) 48 | 49 | } 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Scriptable.app](https://scriptable.app) scripts 2 | 3 | This repository consists of scripts built for [Scriptable.app](https://scriptable.app). 4 | 5 | * [How to Download](docs/importing.md) 6 | 7 | --- 8 | ## Scripts 9 | * [Import-Script.js](Import-Script/Import-Script.js) - a script to download and import other scripts into your Scriptable library. [Read more](Import-Script). 10 | After you downloaded Import-Script, tap on the **Import** links below to import them into your library. 11 | * [OAuth2](OAuth2) - see [Automators.fm topic](https://talk.automators.fm/t/building-a-general-purpose-oauth-redirect-proxy-for-shortcuts-and-scriptable/4420). 12 | * [no-background](http://github.com/supermamon/scriptable-no-background) - a module to simulate transparent background for widgets. 13 | * [openweathermap](openweathermap) - A module to encapsulate OpenWeatherMap's [One Call API](https://openweathermap.org/api/one-call-api) and more 14 | * [basic-ui](utilities/basic-ui.js) - a helper moduel for user interactions 15 | * [json-utils](utilities/json-utils.js) - a helper module for reading, storing, and converting JSON. 16 | * [Widgets](docs/widgets.md) 17 | -------------------------------------------------------------------------------- /Shortcuts Restore.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: cyan; icon-glyph: magic; 4 | /* 5 | Name : Shortcuts Restore 6 | Author : @supermamon 7 | Version : 2.0.0 8 | Changes : 9 | v2.0.0 | 2018-10-30 10 | - Removed the Tier-ring. Shortcuts restores 11 | the shortcut names in Run Shortcut ones the 12 | called shortcut is restored 13 | - Grant access to the Shortcuts folder using 14 | the document picker. Moving the backup 15 | folder manually is no longer needed 16 | v1.0.0 | 2018-09-29 17 | - Initial release 18 | Requires: 19 | Backup & Restore Shortcut 20 | - https://routinehub.co/shortcut/613 21 | Parameters 22 | * restore_dir : the folder inside the Shortcuts 23 | folder where the backups are stored 24 | * info_file : filename of the backup information 25 | (.json) file 26 | 27 | Configure 28 | * DEBUG = false : full import. 29 | * DEBUG = true : limited import 30 | : append "(restore)" to names 31 | 32 | */ 33 | const DEBUG = true; 34 | const DEBUG_TEST_IMPORT_QTY = 2; 35 | 36 | 37 | const FM = FileManager.iCloud(); 38 | const params = URLScheme.allParameters(); 39 | 40 | // simulate parameters 41 | // params['restore_dir']='Restore' 42 | // params['info_file'] ='BackupInfo.json' 43 | 44 | // let not run this without the required params 45 | console.log('* Validating parameters') 46 | if (isParamMissing('restore_dir')) { 47 | console.log('* Aborted.') 48 | return 49 | } 50 | if (isParamMissing('info_file')) { 51 | console.log('Aborted.') 52 | return 53 | } 54 | 55 | // how? 56 | // 1. Navigate to the iCloud Drive root folder 57 | // 2. Tap Select 58 | // 3. Choose the Shortcuts folder 59 | // 4. Tap Open 60 | console.log('* Acquiring Shortcuts folder access') 61 | let prompt = 'Please navigate to the iCloud ' + 62 | 'Drive and open the Shortcuts folder.' 63 | const shortcutsDirs = await promptForFolders(prompt); 64 | const shortcutsDir = shortcutsDirs[0]; 65 | 66 | // append the restore path to the Shortcuts path 67 | console.log('* Acquiring restore path') 68 | const restore_dir = FM.joinPath( 69 | shortcutsDir, 70 | params['restore_dir'] 71 | ); 72 | console.log(' --> ' + restore_dir) 73 | 74 | // load the .json file 75 | console.log('* Getting backup information') 76 | let info_file = params['info_file']; 77 | info_file = 'BackupInfo.json'; 78 | const backup_info = loadBackupInfo( 79 | restore_dir, 80 | info_file 81 | ) 82 | console.log(' --> ' + backup_info.name) 83 | 84 | // prepare the number of items and the list 85 | console.log('* Getting the list of shortcuts') 86 | const restore_list = backup_info.list_order; 87 | const max_items = DEBUG?DEBUG_TEST_IMPORT_QTY:restore_list.length 88 | 89 | console.log('* Ready to restore') 90 | prompt = (DEBUG ? 'DEBUG MODE\n' : '' ) + 91 | `This will restore ${max_items} ` + 92 | 'shortcuts! Focus will switch between ' + 93 | 'Shortcuts and Scriptable until the ' + 94 | 'restoration is completed.' 95 | 96 | if ( await confirm('Restore', prompt) == -1 ) { 97 | console.log('* Restore aborted by user.'); 98 | return 99 | } 100 | 101 | console.log('* Restoring'); 102 | for (var i=0; i ${scName}`) 163 | 164 | // read the file and convert it into an 165 | // encoded URL 166 | let d = Data.fromFile(shortcutPath); 167 | let file = d.toBase64String(); 168 | let durl = "data:text/shortcut;base64,"+file; 169 | let encodedUri = encodeURI(durl); 170 | console.log(` > ${scName} loaded`) 171 | 172 | // callback to import it to the Shortcuts app 173 | const baseURL = "shortcuts://import-shortcut"; 174 | const url = new CallbackURL(baseURL); 175 | url.addParameter("name",scName+(DEBUG?' (restored)':'')); 176 | url.addParameter("url",encodedUri); 177 | await url.open(); 178 | console.log(` > ${scName} sent for import`) 179 | 180 | } 181 | -------------------------------------------------------------------------------- /docs/buttons-widget.md: -------------------------------------------------------------------------------- 1 | # ButtonsWidget 2 | 3 | ![](img/butons-widget-crop.png) 4 | 5 | A customizable launcher widget. 6 | 7 | [Source](../source/buttons-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/buttons-widget.js) 8 | 9 | --- 10 | 11 | * [Syntax](#syntax) 12 | * [Options](#options) 13 | * [Grid Sizes](#grid-sizes) 14 | * [Button](#button) 15 | * [Examples](#examples) 16 | * [FAQ](#faq) 17 | 18 | --- 19 | 20 | 21 | ## Syntax 22 | 23 | ```js 24 | const { ButtonsWidget } = importModule('buttons-widget') 25 | const buttons = [...] 26 | const widget = new ButtonsWidget(buttons, options) 27 | ``` 28 | 29 | | Parameter | Description | 30 | | --------- | ---------------------------------------------------------------- | 31 | | `buttons` | a JSON array containing a list of [button](#button) definitions. | 32 | | `options` | customization [options](#options). | 33 | 34 | [[Top]](#buttonswidget) 35 | 36 | --- 37 | 38 | 39 | ## Options 40 | 41 | All options are, well, optional. Defaults defined where needed. 42 | 43 | | Option | Default | Compact Default | Description | 44 | | ------------------ | ----------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | 45 | | `backgroundImage` | none | none | Image to use as a background of the widget | 46 | | `widgetFamily` | config.widgetFamily | config.widgetFamily | The size of the widget as defined by the default | 47 | | `compact` | false | N/A | `true` increases button capacity.1 See [Grid Sizes](#grid-sizes). | 48 | | `padding` | 3 | 3 | Space around the whole widget | 49 | | `rows` | See [Grid Sizes](#grid-sizes) | | Number of icon rows | 50 | | `cols` | See [Grid Sizes](#grid-sizes) | | Number of icon columns | 51 | | `emptyIconColor` | Color.darkGray() | Color.darkGray() | Background color of an empty icon | 52 | | `iconWidth` | _calculated_2 | _calculated_ | The width of each icon. Will be also used as height. | 53 | | `iconCornerRadius` | 18 | 18 | Corner radius of each icon. Higher = more circular | 54 | | `iconColor` | Color.blue() | Color.blue() | Default icon color for the whole widget | 55 | | `iconFontSize` | 18 | 10 | Font size of the initials that appear on the icon when both `symbol` and `icon` are not provided. | 56 | | `iconTintColor` | Color.white() | Color.white() | The color of the SFSymbols or text on the icon | 57 | | `labelFont` | Font.caption2() | Font.caption2() | Font of the label that appear under the icon | 58 | | `labelColor` | none | none | Color of the label that appear under the icon | 59 | 60 | 1While it will increase capacity, labels are not shown on `compact` mode. 61 | 62 | 2Math.floor(screenSize.width * 0.15). Compact icon is 25% smaller. On the iPad, it's set to arbitrary 56. Still figuring out the right calculation. 63 | 64 | [[Top]](#buttonswidget) 65 | 66 | --- 67 | 68 | 69 | ### Grid Sizes 70 | 71 | The number of default icon spaces available depends on the widget size and the value of the `compact` property. 72 | The values on the table below are presented in row x columns. 73 | The grid can be modified by values to the `rows` and `cols` properties. 74 | 75 | | Widget Size | Regular | Compact | 76 | | ----------- |:-------:|:-------:| 77 | | Small | 2 x 2 | 3 x 3 | 78 | | Medium | 2 x 4 | 3 x 6 | 79 | | Large | 4 x 4 | 6 x 6 | 80 | | Extra Large | 4 x 8 | 6 x 12 | 81 | 82 | [[Top]](#buttonswidget) 83 | 84 | --- 85 | 86 | 87 | ## Button 88 | 89 | The table below lists the properties applicable to buttons. For a button to have a tap target, an `action` needs to be assigned. 90 | An `action` can be a url to a website or a url-scheme to execute actions in an app - like shortcuts or scripts from Scriptable. 91 | 92 | A button can show either an image, and SFSymbol, or a text initials. The initials are automatically derived from the `label` property. 93 | The script will prioritize `icon`, then `symbol`, then `label` - whichever the first one that has value. 94 | 95 | | Property | Description | 96 | | ----------- | ----------------------------------------------------------------------------------------------- | 97 | | `action` | Default: _none_. A url to call when a button is tapped. url-schemes allowed | 98 | | `label` | Default: _none_. The text to appear at the bottom of the icon. | 99 | | `symbol` | Default: _none_. An SFSymbol name to use as an icon for the button. | 100 | | `icon` | Default: _none_. An image to use an as icon for the button. | 101 | | `iconColor` | Default: _blue_. The background color used for the icon. Overrides the main `iconColor` option. | 102 | 103 | [[Top]](#buttonswidget) 104 | 105 | --- 106 | 107 | ## Examples 108 | 109 | Below is a 2x2 icon widget showing different types of buttons. 110 | 111 | ```js 112 | const { ButtonsWidget } = importModule('buttons-widget') 113 | 114 | let buttons = [ 115 | // image icons 116 | { icon: (await getAutomatorsIcon()), iconColor: new Color('#094563'), label: 'Automate', action: 'https://talk.automators.fm' }, 117 | 118 | // SFSymbol icon 119 | { symbol: 'rectangle.stack.person.crop', action: 'http://www.savethechildren.org/', label: "Save" }, 120 | 121 | // SFSymbol icon with custom color 122 | { symbol: 'bandage', action: 'https://www.directrelief.org/', iconColor: Color.red(), label: 'Bandage' }, 123 | 124 | // just label, no icon/symbol 125 | { label: "Tap Here", action: "http://www.doctorswithoutborders.org/" }, 126 | ] 127 | 128 | const widget = new ButtonsWidget(buttons) 129 | Script.setWidget(widget) 130 | 131 | async function getAutomatorsIcon() { 132 | const req = new Request('https://talk.automators.fm/user_avatar/talk.automators.fm/automatorbot/90/16_2.png') 133 | return (await req.loadImage()) 134 | } 135 | ``` 136 | 137 | **More Examples** 138 | 139 | * [buttons-widget-sample.js](../source/buttons-widget-sample.js) 140 | 141 | [[Top]](#buttonswidget) 142 | 143 | --- 144 | 145 | ## FAQ 146 | 147 | **How do I assign a URL-Scheme from an app as an action?** 148 | 149 | Simply set the `action` property of a button the the url-scheme. Examples: 150 | 151 | * Shortcuts: `{ action="shortcuts://run-shortcut?name=name_of_shortcut", ... }` 152 | * Scriptable: `{ action="scriptable:///run/name_of_script", ... }` 153 | * Apollo: `{ action="apollo://www.reddit.com/r/ApolloApp", ... }` 154 | 155 | 156 | **How do I make the widget background transparent?** 157 | 158 | See [no-background](https://github.com/supermamon/scriptable-no-background). 159 | 160 | [[Top]](#buttonswidget) -------------------------------------------------------------------------------- /docs/img/butons-widget-crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/butons-widget-crop.png -------------------------------------------------------------------------------- /docs/img/butons-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/butons-widget.png -------------------------------------------------------------------------------- /docs/img/peanuts-widget-crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/peanuts-widget-crop.png -------------------------------------------------------------------------------- /docs/img/peanuts-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/peanuts-widget.png -------------------------------------------------------------------------------- /docs/img/simple-weather-crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/simple-weather-crop.png -------------------------------------------------------------------------------- /docs/img/simple-weather.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/simple-weather.png -------------------------------------------------------------------------------- /docs/img/text-file-widget-crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/text-file-widget-crop.png -------------------------------------------------------------------------------- /docs/img/text-file-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/text-file-widget.png -------------------------------------------------------------------------------- /docs/img/tfw-lockscreen-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/tfw-lockscreen-cropped.png -------------------------------------------------------------------------------- /docs/img/tfw-lockscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/tfw-lockscreen.png -------------------------------------------------------------------------------- /docs/img/tfw-with-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/tfw-with-custom.png -------------------------------------------------------------------------------- /docs/img/tfw-with-opts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/tfw-with-opts.png -------------------------------------------------------------------------------- /docs/img/xkcd-widget-crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/xkcd-widget-crop.png -------------------------------------------------------------------------------- /docs/img/xkcd-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/docs/img/xkcd-widget.png -------------------------------------------------------------------------------- /docs/importing.md: -------------------------------------------------------------------------------- 1 | # Importing Scripts 2 | 3 | The app's way of importing scripts is that it accepts files `.scriptable` format. This is in essence a JSON file containing the script's code and metadata. If you receive a file in this format, you can simply share the file to Scriptable. 4 | 5 | Most of the scripts shared by developers are in `.js` format, usually containing the raw code of the script. This is how the Scriptable app stores scripts in its folder. 6 | 7 | Scriptable can be used with or without iCloud. With iCloud, you will be able to see your script files in the Files app under the Scriptable folder. 8 | Without iCloud, you can only see your script in the application. 9 | 10 | ## Importing with iCloud enabled 11 | 12 | 1. Download the `.js` file into the Scriptable folder in the Files app. The script will appear in the app after a few seconds. 13 | 14 | ## Importing without iCloud enabled 15 | 16 | 1. Copy the whole code into the clipboard. 17 | 2. Open the Scriptable app, tap the `+` icon at the upper-left corner of the screen. 18 | 3. Paste the code inside the blank area. 19 | 4. Tap the name at the top, normally called `Untitled Script`, then change it to the appropriate name. There's no need to include the `.js` extension. 20 | 21 | ## Automated Imports 22 | 23 | * See [Import-Script](import-script.md) 24 | 25 | 26 | -------------------------------------------------------------------------------- /docs/openweathermap.md: -------------------------------------------------------------------------------- 1 | # openweathermap.js 2 | 3 | ![](img/simple-weather-crop.png) 4 | 5 | A module to encapsulate OpenWeatherMap's [One Call API](https://openweathermap.org/api/one-call-api) and additional information shared by developers over the Automators.fm community. 6 | 7 | [Source](../source/openweathermap.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/openweathermap.js) 8 | 9 | --- 10 | 11 | ## Features 12 | 13 | * Automatic location detection or custom coordinates 14 | * Night time detection 15 | * SFSymbol names for weather conditions 16 | * Units information 17 | * OpenWeatherMap icon urls 18 | 19 | ## Excluded 20 | 21 | * minutely forcast 22 | 23 | --- 24 | 25 | ## Syntax 26 | 27 | Below are some example usage of the module. 28 | 29 | **Automatic location detection** 30 | 31 | By not providing coordinates (`lat`, `lon`), location will be automatically detected. 32 | 33 | ```javascript 34 | const API_KEY = 'your-api-key' 35 | const owm = importModule('openweathermap.js') 36 | const weatherData = await owm({appid: API_KEY}) 37 | ``` 38 | 39 | **Automatic location detection & reverse geocoding** 40 | 41 | `revgeocode` will search for the address information based on the detected location. 42 | 43 | ```javascript 44 | const API_KEY = 'your-api-key' 45 | const owm = importModule('openweathermap.js') 46 | const weatherData = await owm({appid: API_KEY, revgeocode:true}) 47 | ``` 48 | 49 | **Specified coordinates** 50 | 51 | Provide coordinates to disable location detection. 52 | 53 | ```javascript 54 | const API_KEY = 'your-api-key' 55 | const owm = importModule('openweathermap.js') 56 | const weatherData = await owm({appid: API_KEY, lat: 37.32, lon: -122.03}) 57 | ``` 58 | 59 | **Localization** 60 | 61 | You can pass the pass `lang` and `units` as options to receive alternative results. 62 | Defaults are `en` and `metric` respectively. 63 | 64 | ```javascript 65 | const API_KEY = 'your-api-key' 66 | const owm = importModule('openweathermap.js') 67 | const weatherData = await owm({appid: API_KEY, lang: 'fr', units: 'metric'}) 68 | ``` 69 | **Other Parameters** 70 | 71 | * `location_name` - automatic location name search (`revgeocode`) can be turned of the location name may be manually provided via this parameter 72 | * `exclude` - comma-separate values of forecasts to exclude. Accepted options are `daily` and `hourly`. `minutely` and `alerts` is automatically always excluded. 73 | * `api_version` - This module was initially created at the time where version 1.0 (a.k.a 2.5) of the [One Call API](https://openweathermap.org/api/one-call-api) is current. With the release of [One Call API 3.0](https://openweathermap.org/api/one-call-3) this parameter is provided so users of this module have the flexibility to change. 74 | 75 | --- 76 | 77 | ## Additional Data Returned 78 | 79 | The properties below are not part of the API but are returned by this module. 80 | 81 | These values depend on the `units` parameter. Information is based from the [Units of Measurement](https://openweathermap.org/api/one-call-api#data) documentation. 82 | ```javascript 83 | .units: { 84 | temp 85 | pressure 86 | visibility 87 | wind_speed 88 | wind_gust 89 | rain 90 | snow 91 | } 92 | ``` 93 | 94 | Any of the arguments passed when the function is called are available in the `.args` method. This also include arguments with default values. 95 | 96 | ```javascript 97 | .args: { 98 | appid 99 | api_version 100 | units 101 | lang 102 | lat 103 | lon 104 | revgeocode 105 | location_name 106 | exclude 107 | } 108 | ``` 109 | 110 | Nighttime and SFSymbol information fields under the `current`, `daily` and `hourly` properties 111 | 112 | * `.is_night` - boolean to signify whether the current condition is during night time 113 | * `.weather[0].sfsymbol` - SFSymbol name to represent the weather conditions. This is an alternative to `.weather[0].icon`. 114 | * `.icon_url` - the [icon url](https://openweathermap.org/weather-conditions#How-to-get-icon-URL) provided by OpenWeatherMap 115 | 116 | 117 | --- 118 | 119 | ## Example 120 | 121 | * [simple-weather-widget.js](../source/simple-weather-widget.js) - a weather widget that uses this module. 122 | 123 | -------------------------------------------------------------------------------- /docs/peanuts-widget.md: -------------------------------------------------------------------------------- 1 | # Peanuts™ Widget 2 | --- 3 | 4 | ![](img/peanuts-widget-crop.png) 5 | 6 | A widget to show current/random Peanuts™ comic. 7 | 8 | [Source](../source/peanuts-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/peanuts-widget.js) 9 | 10 | --- 11 | 12 | ## Options 13 | 14 | | Option | Default | Description | 15 | | ---------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | 16 | | `BACKGROUND_DARK_MODE` | system | Set to light or dark mode. Either set to `yes`, `no` or `system` to follow the system setting | 17 | | `TRANSPARENT` | false | Set to `true` simulate transparent background using [no-barckground](https://github.com/supermamon/scriptable-no-background). | 18 | | `RANDOM` | false | Set to `true` to show random comic instead of today's one. | 19 | | `SHOW_TITLE` | true | Show the "Peanuts" title on top | 20 | | `SHOW_DATE` | true | Show the publication date of the comic | 21 | 22 | `BACKGROUND_DARK_MODE` is ignored if `TRANSPARENT` is `true`. 23 | 24 | The `RANDOM` option can also be overriden by setting the widget parameter to `random`. 25 | -------------------------------------------------------------------------------- /docs/text-file-widget.md: -------------------------------------------------------------------------------- 1 | # Text File Widget 2 | 3 | 4 | Display the contents of a text file on a widget. Works best for short texts but also flexible to allow displaying longer text files. 5 | 6 | This is Scriptable **module** that extends the `ListWidget` class so widget. So styling, additional content may be added if needed. 7 | 8 | This can be used as a lock screen or a home screen widget. 9 | 10 | [Source](../source/lib-text-file-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/lib-text-file-widget.js) 11 | 12 | --- 13 | 14 | ## How to use? 15 | 16 | Here's a four-liner to load a file into a widget. 17 | 18 | ```js 19 | const { TextFileWidget } = importModule('lib-text-file-widget') 20 | const widget = new TextFileWidget('notes/reminders.txt') 21 | await widget.waitForLoad() 22 | Script.setWidget(widget) 23 | ``` 24 | 25 | The rectangular widget below shows how the file will be rendered. 26 | The two circular widget are other example for the same widget using other files. 27 | 28 | ![Screenshot of lock screen showing widget](img/tfw-lockscreen.png) 29 | 30 | 31 | ## Options 32 | 33 | ```js 34 | const { TextFileWidget } = importModule('lib-text-file-widget') 35 | 36 | const options = { 37 | 38 | // padding 39 | // default: 10, forced 0 for lock screen widgets 40 | padding: 8, 41 | 42 | // text scaling 43 | // default 0.65 44 | minimumScale: 0.8, 45 | 46 | // define where the filename parameter is an 47 | // absolute or relative path 48 | // default: false 49 | absolute: false, 50 | 51 | // Font of the content 52 | // default: Font.body() 53 | font: Font.regularSystemFont(12), 54 | 55 | // display the filename on the widget 56 | // default: false 57 | showFilename: true, 58 | 59 | // horizontally center the file contents 60 | // default: false. forced true for circular widgets 61 | centerContent: true 62 | 63 | } 64 | 65 | const widget = new TextFileWidget('notes/notes.txt', options) 66 | await widget.waitForLoad() 67 | Script.setWidget(widget) 68 | ``` 69 | ![Screenshot of home screen showing widget with replaced default options](img/tfw-with-opts.png) 70 | 71 | 72 | ## Other Customizations 73 | 74 | Since `TextFileWidget` is an extension of `ListWidget` you can further style it and add more content. 75 | 76 | This example, uses the [no-background](https://github.com/supermamon/scriptable-no-background) module to simulate transparency and adds the current date at the bottom. 77 | 78 | ```js 79 | const { TextFileWidget } = importModule('lib-text-file-widget') 80 | 81 | const widget = new TextFileWidget('notes/reminders.txt') 82 | await widget.waitForLoad() 83 | 84 | // custom background and padding 85 | widget.setPadding(15,15,15,15) 86 | const nobg = importModule('no-background') 87 | widget.backgroundImage = await nobg.getSlice('medium-top') 88 | 89 | // additional content 90 | const dateLine = widget.addDate(new Date()) 91 | dateLine.applyDateStyle() 92 | dateLine.font = Font.footnote() 93 | dateLine.rightAlignText() 94 | 95 | Script.setWidget(widget) 96 | ``` 97 | 98 | ![Screenshot of home screen showing widget with transparent background and date](img/tfw-with-custom.png) 99 | 100 | 101 | ## Example Scripts 102 | 103 | * [text-file-widget.js](../source/text-file-widget.js) - a widget that accepts a file name as widget parameter and displays its content 104 | * [text-file-widget-w-opts.js](../source/text-file-widget-w-opts.js) - example code for the **Options** section above 105 | * [text-file-widget-w-custom.js](../source/text-file-widget-w-custom.js) - example code for the **Other Customizations** section above 106 | -------------------------------------------------------------------------------- /docs/widgets.md: -------------------------------------------------------------------------------- 1 | # Widgets 2 | 3 | The _Import_ links uses [Import-Script](../Import-Script/readme.md) to import. 4 | 5 | 6 | * [xkcd-widget](#xkcd-widget) 7 | * [Peanuts™ Widget](#peanuts-widget) 8 | * [Simple Weather Widget](#simple-weather-widget) 9 | * [Transparent Widgets](#transparent-widgets) 10 | 11 | * [RoutineHub Profile Widget](#routinehub-profile-widget) 12 | * [Text File Widget](#text-file-widget) 13 | * [ButtonsWidget](#buttons-widget) 14 | 15 | **Archived / Not Working** 16 | 17 | * [US Elections Widget](#us-elections-widget) - data source not available 18 | * [Instagram Latest Posts](#instagram-latest-posts) - too many variations on Instgram API 19 | 20 | 21 | --- 22 | 23 | ## xkcd Widget 24 | 25 | A widget to show current/random xkcd comic. 26 | 27 | [Source](../source/xkcd.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/xkcd.js) | [Docs](xkcd-widget.md) 28 | 29 | ![](img/xkcd-widget-crop.png) 30 | 31 | [[top]](#widgets) 32 | 33 | --- 34 | ### Peanuts Widget 35 | 36 | A widget to show current/random Peanuts™ comic. 37 | 38 | [Source](../source/peanuts-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/peanuts-widget.js) | [Docs](peanuts-widget.md) 39 | 40 | ![](img/peanuts-widget-crop.png) 41 | 42 | [[top]](#widgets) 43 | 44 | --- 45 | ### Simple Weather Widget 46 | 47 | Example widget that uses the [openweathermap](openweathermap.md) module. 48 | 49 | [Source](../source/simple-weather-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/simple-weather-widget.js) | [Docs](openweathermap.md) 50 | 51 | ![](img/simple-weather-crop.png) 52 | 53 | [[top]](#widgets) 54 | 55 | --- 56 | 57 | ### RoutineHub Profile Widget 58 | A widget to show the current shortcuts and download counts of a [routinehub.co](https://routinehub.co) profile. 59 | 60 | [Source](routinehub-widgets/rh-profile-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/routinehub-widgets/rh-profile-widget.js) 61 | 62 | ![](../routinehub-widgets/preview-rhp-sml.jpg) 63 | 64 | [[top]](#widgets) 65 | 66 | --- 67 | 68 | ### Text File Widget 69 | 70 | Display the contents of a text file on a widget. Works best for short texts but also flexible to allow displaying longer text files. 71 | This can be used as a lock screen or a home screen widget. 72 | 73 | [Source](../source/lib-text-file-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/lib-text-file-widget.js) | [Docs](text-file-widget.md) 74 | 75 | ![three-way screenshot of widget](img/text-file-widget-crop.png) 76 | 77 | [[top]](#widgets) 78 | 79 | --- 80 | 81 | ### Buttons Widget 82 | 83 | A customizable launcher widget. See the [documentation](buttons-widget.md). 84 | 85 | ![](img/butons-widget-crop.png) 86 | 87 | * [Source](../source/buttons-widget.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/buttons-widget.js) 88 | * [Example Source](../source/buttons-widget-sample.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/buttons-widget-sample.js) 89 | 90 | [[top]](#widgets) 91 | 92 | --- 93 | 94 | ## Archived / Not Working 95 | 96 | ### US Elections Widget 97 | Show the latest electoral votes for all candidates. 98 | 99 | [Source](misc/us-elections.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/misc/us-elections.js) 100 | 101 | ![](../misc/preview-uspolls.jpg) 102 | 103 | [[top]](#widgets) 104 | 105 | --- 106 | ### Instagram Latest Posts 107 | Randomly show between the 12 of the most recent post from a user or users. 108 | 109 | [Source](https://github.com/supermamon/scriptable-instagram-widgets/blob/master/ig-latest-post.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-instagram-widgets/ig-latest-post.js) 110 | 111 | 112 | ![](https://raw.githubusercontent.com/supermamon/scriptable-instagram-widgets/master/preview-igl.jpg) 113 | 114 | [[top]](#widgets) 115 | -------------------------------------------------------------------------------- /docs/xkcd-widget.md: -------------------------------------------------------------------------------- 1 | # xkcd widget 2 | --- 3 | 4 | ![](img/xkcd-widget-crop.png) 5 | 6 | A widget to show current/random xkcd comic. 7 | 8 | [Source](../source/xkcd.js) | [Import](https://open.scriptable.app/run/Import-Script?url=https://github.com/supermamon/scriptable-scripts/source/xkcd.js) 9 | 10 | --- 11 | 12 | ## Options 13 | 14 | 15 | | Option | Default | Description | 16 | | ---------------------- | ------- | --------------------------------------------------------------------------------------------- | 17 | | `BACKGROUND_DARK_MODE` | system | Set to light or dark mode. Either set to `yes`, `no` or `system` to follow the system setting | 18 | | `RANDOM` | false | Set to `true` to show random comic instead of today's one. | 19 | | `SHOW_ALT` | true | Show the alt text as caption | 20 | 21 | The `RANDOM` option can also be overriden by setting the widget parameter to `random`. 22 | -------------------------------------------------------------------------------- /misc/preview-uspolls.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/misc/preview-uspolls.jpg -------------------------------------------------------------------------------- /misc/us-elections.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: receipt; 4 | 5 | const SHOW_LASTUPDATE_TIMER = false 6 | const url = 'https://election.krit.me/results.json' 7 | const req = new Request(url) 8 | const data = await req.loadJSON() 9 | const widget = new ListWidget() 10 | widget.setPadding(14, 5, 14, 5) 11 | 12 | var refreshDate = Date.now() + 1000*60*5 // 5 minutes 13 | widget.refreshAfterDate = new Date(refreshDate) 14 | 15 | 16 | // transparent 17 | //const nobg = importModule('no-background') 18 | //const RESET_BACKGROUND = !config.runsInWidget 19 | //widget.backgroundImage = await nobg.getSliceForWidget(Script.name()) 20 | 21 | const flag = widget.addText('🇺🇸 Elections') 22 | flag.font = Font.systemFont(16) 23 | 24 | widget.addSpacer(10) 25 | 26 | is_bidden = data[0].candidate = 'Joe Biden' 27 | const colors = [ 28 | is_bidden?Color.blue():Color.red(), 29 | is_bidden?Color.red():Color.blue(), 30 | Color.gray() 31 | ] 32 | 33 | data.forEach( (rec,i) => { 34 | 35 | // name and electoral stack 36 | const h1 = widget.addStack() 37 | h1.setPadding(5, 5, 5, 5) 38 | h1.cornerRadius = 5 39 | h1.layoutHorizontally() 40 | h1.backgroundColor = colors[i] 41 | 42 | // name 43 | const n1 = h1.addText(name(rec.candidate, config.widgetFamily)) 44 | h1.addSpacer() 45 | // electoral votes 46 | h1.addText(rec.electoral) 47 | 48 | // subs 49 | widget.addSpacer(2) 50 | const sh1 = widget.addStack() 51 | sh1.layoutHorizontally() 52 | sh1.centerAlignContent() 53 | // percentage 54 | const p1 = sh1.addText(rec.percentage) 55 | p1.font = Font.systemFont(8) 56 | 57 | sh1.addSpacer() 58 | // count 59 | const c1 = sh1.addText(`${rec.count}`) 60 | c1.font = Font.systemFont(8) 61 | c1.rightAlignText() 62 | 63 | widget.addSpacer(8) 64 | 65 | }) 66 | 67 | if (SHOW_LASTUPDATE_TIMER) { 68 | const lastUp = widget.addDate(new Date()) 69 | lastUp.font = Font.systemFont(9) 70 | lastUp.rightAlignText() 71 | lastUp.applyRelativeStyle() 72 | } 73 | 74 | widget.addSpacer() 75 | 76 | Script.setWidget(widget) 77 | widget.presentSmall() 78 | 79 | function name(n, f='small') { 80 | return f=='small' ? n.split(' ')[1] : n 81 | } 82 | -------------------------------------------------------------------------------- /no-background/readme.md: -------------------------------------------------------------------------------- 1 | # no-background.js 2 | 3 | This folder has been split into the [scriptable-no-background](https://github.com/supermamon/scriptable-no-background) repository. 4 | -------------------------------------------------------------------------------- /routinehub-widgets/preview-rhp-sml.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/routinehub-widgets/preview-rhp-sml.jpg -------------------------------------------------------------------------------- /routinehub-widgets/readme.md: -------------------------------------------------------------------------------- 1 | # rh-profile-widget.js 2 | 3 | A widget to show the current shortcuts and download counts of a routinehub.co profile. 4 | 5 | 6 | Features: 7 | * Displays profile picture 8 | * Adaptive layout to widget size 9 | * Accept username as parameter 10 | * Show downloads delta from the last 24 hrs. 11 | * Dynamic background color base on system 12 | dark/light appearance 13 | * Support for transparent background using the 14 | no-background module 15 | 16 | Download [rh-profile-widget.js](rh-profile-widget.js). 17 | 18 | ![](rh-profile-widget.png) -------------------------------------------------------------------------------- /routinehub-widgets/rh-profile-widget.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: blue; icon-glyph: user-astronaut; 4 | 5 | /* ----------------------------------------------- 6 | Script : rh-profile-widget.js 7 | Author : dev@supermamon.com 8 | Version : 1.0.0 9 | Description : 10 | A widget to show the current shortcuts and 11 | download counts of a routinehub.co profile 12 | 13 | Features: 14 | * Displays profile picture 15 | * Adaptive layout to widget size 16 | * Accept username as parameter 17 | * Show downloads delta from the last 24 hrs. 18 | * Dynamic background color base on system 19 | dark/light appearance 20 | * Support for transparent background using the 21 | no-background module 22 | ----------------------------------------------- */ 23 | 24 | var PREFER_TRANSPARENT_BG = true 25 | var DARK_MODE_BGCOLOR = '#262335' 26 | var LIGHT_MODE_BGCOLOR = '#4f6a8f' 27 | 28 | // be careful if want to edit anything after this 29 | // point 30 | 31 | // accept username as parameter 32 | var username = args.widgetParameter || 'supermamon' 33 | 34 | // if PREFER_TRANSPARENT_BG is true, load the 35 | // no-background module if it exists. if it does 36 | // not exist, script will use the specified colors 37 | var nobg = !PREFER_TRANSPARENT_BG ? null 38 | : await importModuleOptional('no-background') 39 | username = username.replace(/^\s+|\s+$|@/,'') 40 | const widgetID = `rh-profile-${username}` 41 | //------------------------------------------------ 42 | async function createWidget(widgetFamily='small') 43 | { 44 | // get user information 45 | const rhu = new RoutineHubUser(username) 46 | await rhu.load() 47 | 48 | // setup widget 49 | const widget = new ListWidget() 50 | widget.setPadding(0,0,0,0) 51 | 52 | widget.backgroundColor = Color.dynamic( 53 | new Color(LIGHT_MODE_BGCOLOR), 54 | new Color(DARK_MODE_BGCOLOR)) 55 | 56 | // if the no-background module is loaded, 57 | // get the appropriate background 58 | if (nobg) { 59 | widget.backgroundImage = 60 | await nobg.getSliceForWidget(widgetID) 61 | } 62 | 63 | // size identifiction to help with layout 64 | const scale = Device.screenScale() 65 | const dh = Device.screenResolution().height 66 | 67 | const sizeRef = phoneSizes[dh][widgetFamily] 68 | const size = {...sizeRef} 69 | 70 | size.w = Math.floor(size.w / scale) 71 | size.h = Math.floor(size.h / scale) 72 | 73 | const smallRef = phoneSizes[dh]['small'] 74 | const small = {...smallRef} 75 | small.w = Math.floor(small.w / scale) 76 | small.h = Math.floor(small.h / scale) 77 | 78 | const continerHeight = widgetFamily=='small' 79 | ? Math.floor(small.h/2) 80 | : small.h 81 | const foreColor = Color.white() 82 | 83 | // setup main container 84 | const mainStack = widget.addStack() 85 | mainStack.setPadding(0,0,0,0) 86 | mainStack[`layout${widgetFamily=='small' ? 'Vertically' : 'Horizontally'}`]() 87 | mainStack.size = new Size(size.w, size.h) 88 | mainStack.centerAlignContent() 89 | 90 | // profile photo and name section 91 | const idStack = mainStack.addStack() 92 | idStack.size = new Size(small.w,continerHeight) 93 | idStack.layoutVertically() 94 | idStack.topAlignContent() 95 | 96 | idStack.addSpacer(10) // top padding 97 | 98 | // profile photo image 99 | const pfpStack = idStack.addStack() 100 | pfpStack.layoutHorizontally() 101 | // space | photo | space 102 | pfpStack.addSpacer() 103 | 104 | var profileImg = rhu.has_profile_photo 105 | ? await downloadImage(rhu.profile_photo_url) 106 | : sfIcon('person.crop.rectangle.fill', Font.systemFont(96)) 107 | 108 | const pfp = pfpStack.addImage(profileImg) 109 | if (!rhu.has_profile_photo) { 110 | pfp.tintColor = Color.white() 111 | } 112 | pfp.cornerRadius = widgetFamily=='small'?18:55 113 | pfp.url = rhu.profile_page 114 | pfpStack.addSpacer() 115 | 116 | idStack.addSpacer(8) // space between image and name 117 | 118 | // username 119 | // space | username | space 120 | const nameStack = idStack.addStack() 121 | nameStack.layoutHorizontally() 122 | nameStack.addSpacer() 123 | const eUser = nameStack.addText(rhu.username) 124 | eUser.textColor = foreColor 125 | eUser.centerAlignText() 126 | nameStack.addSpacer() 127 | 128 | if(widgetFamily!='small') { 129 | idStack.addSpacer(10) // bottom padding 130 | } 131 | 132 | // statistics section 133 | const statsFont = new Font('Menlo-Regular',14) 134 | 135 | // stats container 136 | const statsStack = mainStack.addStack() 137 | statsStack.size = new Size(small.w,continerHeight) 138 | statsStack.layoutVertically() 139 | statsStack.topAlignContent() 140 | 141 | // shortcuts line 142 | const shortcutsStack = statsStack.addStack() 143 | shortcutsStack.layoutHorizontally() 144 | // space | icon | value | space 145 | shortcutsStack.addSpacer() 146 | //icon 147 | const scIcon = shortcutsStack.addImage(sfIcon('f.cursive.circle.fill', statsFont)) 148 | scIcon.tintColor = foreColor 149 | scIcon.resizable = false 150 | //value 151 | const scCount = shortcutsStack.addText(` ${formatNumber(rhu.shortcuts)}`.substr(-8)) 152 | scCount.font = statsFont 153 | scCount.textColor = foreColor 154 | shortcutsStack.addSpacer() 155 | 156 | statsStack.addSpacer(5) 157 | 158 | // downloads line 159 | const dlStack = statsStack.addStack() 160 | dlStack.layoutHorizontally() 161 | // space | icon | value | space 162 | dlStack.addSpacer() 163 | //icon 164 | const dlIcon = dlStack.addImage(sfIcon('icloud.and.arrow.down.fill', statsFont)) 165 | dlIcon.tintColor = foreColor 166 | dlIcon.resizable = false 167 | //value 168 | const dlCount = dlStack.addText(` ${formatNumber(rhu.downloads)}`.substr(-8)) 169 | dlCount.font = statsFont 170 | dlCount.textColor = foreColor 171 | dlStack.addSpacer() 172 | 173 | // delta line 174 | if (rhu.newDownloads > 0) { 175 | 176 | statsStack.addSpacer(3) 177 | 178 | const dlDeltaStack = statsStack.addStack() 179 | dlDeltaStack.layoutHorizontally() 180 | // space || icon | count | space 181 | dlDeltaStack.addSpacer() 182 | 183 | //icon 184 | const deltaIcon = dlDeltaStack.addImage(sfIcon('chevron.right.circle', statsFont)) 185 | deltaIcon.tintColor = foreColor 186 | deltaIcon.resizable = false 187 | // count 188 | const dlDelta = dlDeltaStack.addText(` +${formatNumber(rhu.newDownloads)}`.substr(-8)) 189 | dlDelta.textColor = Color.green() 190 | dlDelta.font = statsFont 191 | 192 | dlDeltaStack.addSpacer() 193 | } 194 | 195 | return widget 196 | } 197 | 198 | //------------------------------------------------ 199 | class RoutineHubUser { 200 | 201 | constructor(username) { 202 | this.cacheDir = 'cache/rh' 203 | this.cacheFile = `${username}.json` 204 | this.username = username 205 | this.exists = false 206 | this.downloads = 0 207 | this.shortcuts = 0 208 | this.has_profile_photo = false 209 | this.profile_photo_url = null 210 | this.profile_page = `https://routinehub.co/user/${username}` 211 | } 212 | 213 | async load() { 214 | 215 | let req = new Request(this.profile_page) 216 | let page = await req.loadString() 217 | 218 | let downloads = page.match(/Downloads:\s(\d+)/)[1] 219 | 220 | if (!downloads) { 221 | this.exists = false 222 | return this 223 | } 224 | 225 | this.exists = true 226 | this.downloads = parseInt(downloads) 227 | this.shortcuts = parseInt(page.match(/Shortcuts:\s(\d+)/)[1]) 228 | 229 | this.previousStats = await this.getPreviousStats() 230 | log('previous') 231 | log(this.previousStats) 232 | 233 | const now = new Date() 234 | const yesterday = new Date(now - (24 * 60 * 60 * 1000)) 235 | const lastUpdate = this.previousStats.lastUpdate 236 | 237 | if (lastUpdate.getTime() < yesterday.getTime()) { 238 | log('been more that 24 hours. updating stats') 239 | this.previousStats = await this.saveNewStats() 240 | } 241 | 242 | this.newDownloads = this.downloads - this.previousStats.downloads 243 | //log(this.newDownloads) 244 | 245 | let pfpm = page.match(/class="is-rounded"\ssrc="([^"]+)/) 246 | if (pfpm) { 247 | this.profile_photo_url = pfpm[1] 248 | this.has_profile_photo = true 249 | } 250 | 251 | return this 252 | 253 | } 254 | 255 | async saveNewStats(input) { 256 | const ICLOUD = module.filename 257 | .includes('Documents/iCloud~') 258 | const fm = FileManager[ICLOUD 259 | ? 'iCloud' 260 | : 'local']() 261 | const cachePath = fm.joinPath( 262 | fm.documentsDirectory(), 263 | this.cacheDir) 264 | fm.createDirectory(cachePath, true) 265 | const cacheFilePath = fm.joinPath( 266 | cachePath, 267 | this.cacheFile) 268 | const newStats = input || { 269 | lastUpdate: new Date(), 270 | downloads: this.downloads, 271 | shortcuts: this.shortcuts 272 | } 273 | log('saving updated stats') 274 | log(newStats) 275 | if (fm.fileExists(this.cacheFile)) { 276 | if (ICLOUD) await fm.downloadFileFromiCloud(cacheFilePath) 277 | } 278 | fm.writeString(cacheFilePath, JSON.stringify(newStats)) 279 | return newStats 280 | 281 | } 282 | async getPreviousStats() { 283 | log('getting previous') 284 | const ICLOUD = module.filename 285 | .includes('Documents/iCloud~') 286 | const fm = FileManager[ICLOUD 287 | ? 'iCloud' 288 | : 'local']() 289 | const cachePath = fm.joinPath( 290 | fm.documentsDirectory(), 291 | this.cacheDir) 292 | fm.createDirectory(cachePath, true) 293 | const cacheFilePath = fm.joinPath( 294 | cachePath, 295 | this.cacheFile) 296 | log(cacheFilePath) 297 | if (!fm.fileExists(cacheFilePath)) { 298 | log('local stats missing. saving') 299 | //let yest = (new Date).getTime - (24 * 60 * 60 * 1000) 300 | let stats = { 301 | lastUpdate: new Date(), 302 | downloads: this.downloads, 303 | shortcuts: this.shortcuts 304 | } 305 | await this.saveNewStats(stats) 306 | } 307 | let stats = JSON.parse( 308 | fm.readString(cacheFilePath)) 309 | stats.lastUpdate = new Date(stats.lastUpdate) 310 | 311 | return stats 312 | } 313 | } 314 | 315 | //------------------------------------------------ 316 | function sfIcon(name, font) { 317 | const sf = SFSymbol.named(name) 318 | sf.applyFont(font) 319 | return sf.image 320 | } 321 | //------------------------------------------------ 322 | function formatNumber(n) { 323 | return new Intl.NumberFormat().format(n) 324 | } 325 | 326 | 327 | //------------------------------------------------ 328 | async function previewWidget() { 329 | let widget; 330 | let resp = await presentAlert('Preview Widget', 331 | ['Small','Medium','Large','Cancel']) 332 | switch (resp) { 333 | case 0: 334 | widget = await createWidget('small') 335 | await widget.presentSmall() 336 | break; 337 | case 1: 338 | widget = await createWidget('medium') 339 | await widget.presentMedium() 340 | break; 341 | case 2: 342 | widget = await createWidget('large') 343 | await widget.presentLarge() 344 | break; 345 | default: 346 | } 347 | } 348 | //------------------------------------------------ 349 | async function presentAlert(prompt,items,asSheet) 350 | { 351 | let alert = new Alert() 352 | alert.message = prompt 353 | 354 | for (const item of items) { 355 | alert.addAction(item) 356 | } 357 | let resp = asSheet ? 358 | await alert.presentSheet() : 359 | await alert.presentAlert() 360 | return resp 361 | } 362 | //------------------------------------------------ 363 | async function downloadImage(url) { 364 | const req = new Request(url) 365 | const img = await req.loadImage() 366 | return img 367 | } 368 | //------------------------------------------------ 369 | const phoneSizes = { 370 | "2532": { 371 | "models" : ["12", "12 Pro"], 372 | "small" : {"w": 474, "h": 474 }, 373 | "medium" : {"w": 1014, "h": 474 }, 374 | "large" : {"w": 1014, "h": 1062 }, 375 | "left" : 78, 376 | "right" : 618, 377 | "top" : 231, 378 | "middle" : 819, 379 | "bottom" : 1407 380 | }, 381 | 382 | "2688": { 383 | "models" : ["Xs Max", "11 Pro Max"], 384 | "small" : {"w": 507, "h": 507}, 385 | "medium" : {"w": 1080, "h": 507}, 386 | "large" : {"w": 1080, "h": 1137}, 387 | "left" : 81, 388 | "right" : 654, 389 | "top" : 228, 390 | "middle" : 858, 391 | "bottom" : 1488 392 | }, 393 | 394 | "1792": { 395 | "models" : ["11", "Xr"], 396 | "small" : {"w": 338, "h": 338}, 397 | "medium" : {"w": 720, "h": 338}, 398 | "large" : {"w": 720, "h": 758}, 399 | "left" : 54, 400 | "right" : 436, 401 | "top" : 160, 402 | "middle" : 580, 403 | "bottom" : 1000 404 | }, 405 | 406 | "2436": { 407 | "models" : ["X", "Xs", "11 Pro"], 408 | "small" : {"w": 465, "h": 465}, 409 | "medium" : {"w": 987, "h": 465}, 410 | "large" : {"w": 987, "h": 1035}, 411 | "left" : 69, 412 | "right" : 591, 413 | "top" : 213, 414 | "middle" : 783, 415 | "bottom" : 1353 416 | }, 417 | 418 | "2208": { 419 | "models" : ["6+", "6s+", "7+", "8+"], 420 | "small" : {"w": 471, "h": 471}, 421 | "medium" : {"w": 1044, "h": 471}, 422 | "large" : {"w": 1044, "h": 1071}, 423 | "left" : 99, 424 | "right" : 672, 425 | "top" : 114, 426 | "middle" : 696, 427 | "bottom" : 1278 428 | }, 429 | 430 | "1334": { 431 | "models" : ["6","6s","7","8"], 432 | "small" : {"w": 296, "h": 296}, 433 | "medium" : {"w": 642, "h": 296}, 434 | "large" : {"w": 642, "h": 648}, 435 | "left" : 54, 436 | "right" : 400, 437 | "top" : 60, 438 | "middle" : 412, 439 | "bottom" : 764 440 | }, 441 | 442 | "1136": { 443 | "models" : ["5","5s","5c","SE"], 444 | "small" : {"w": 282, "h": 282}, 445 | "medium" : {"w": 584, "h": 282}, 446 | "large" : {"w": 584, "h": 622}, 447 | "left" : 30, 448 | "right" : 332, 449 | "top" : 59, 450 | "middle" : 399, 451 | "bottom" : 399 452 | } 453 | } 454 | //------------------------------------------------ 455 | async function importModuleOptional(module_name) { 456 | const ICLOUD = module.filename 457 | .includes('Documents/iCloud~') 458 | const fm = FileManager[ICLOUD 459 | ? 'iCloud' 460 | : 'local']() 461 | if (!/\.js$/.test(module_name)) { 462 | module_name = module_name + '.js' 463 | } 464 | const module_path = fm.joinPath 465 | (fm.documentsDirectory(), 466 | module_name) 467 | if (!fm.fileExists(module_path)) { 468 | log(`module ${module_name} does not exist`) 469 | return null 470 | } 471 | if (ICLOUD) { 472 | await fm.downloadFileFromiCloud(module_path) 473 | } 474 | const mod = importModule(module_name) 475 | return mod 476 | } 477 | //---[ main ]------------------------------------- 478 | if (config.runsInWidget) { 479 | let widget = await createWidget(config.widgetFamily) 480 | Script.setWidget(widget) 481 | Script.complete() 482 | } else { 483 | // show options 484 | const options = ['Preview Widget'] 485 | if (nobg) { 486 | options.push('Set Background') 487 | } 488 | options.push('Cancel') 489 | let response = await presentAlert( 490 | 'Options', options) 491 | let sel = options[response] 492 | switch(sel) { 493 | case 'Preview Widget': 494 | await previewWidget() 495 | break; 496 | case 'Set Background': 497 | await nobg.chooseBackgroundSlice(widgetID) 498 | break; 499 | default: 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /routinehub-widgets/rh-profile-widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supermamon/scriptable-scripts/12b70f794f1d6e5a8118f414590d50a52331d817/routinehub-widgets/rh-profile-widget.png -------------------------------------------------------------------------------- /source/buttons-widget-sample.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: grip-horizontal; 4 | 5 | /* ********************************************** 6 | Name : buttons-widget-sample.js 7 | Author : @supermamon 8 | Version : 1.0.1 9 | Desc : A example widget that uses the 10 | button-widget library 11 | 12 | See https://github.com/supermamon/scriptable-scripts/tree/master/docs/buttons-widget.md 13 | 14 | Changelog: 15 | ------------------------------------------------- 16 | v1.0.0 | 2022-09-19 17 | * Initial release 18 | ------------------------------------------------- 19 | v1.0.1 | 2022-09-20 20 | * Fix: incorrect variable name 21 | ********************************************** */ 22 | 23 | const { ButtonsWidget } = importModule('buttons-widget') 24 | 25 | // fill the widget parameter with 'compact' to get smaller icons 26 | const is_compact = (args.widgetParameter || '').includes('compact') 27 | 28 | const buttons = [ 29 | 30 | // icon using SF Symbol 31 | { symbol: 'rectangle.stack.person.crop', action: 'http://www.savethechildren.org/', label: "Save" }, 32 | { symbol: 'shield.lefthalf.fill', action: 'http://www.rescue.org/', label: 'Rescue' }, 33 | { symbol: 'hand.draw', action: 'http://www.pih.org/', label: 'Hand' }, 34 | 35 | // empty button 36 | null, 37 | 38 | // text caption 39 | { label: "Tap Here", action: "http://www.oxfamamerica.org/" }, 40 | 41 | // button custom colors 42 | { symbol: 'bandage', action: 'https://www.directrelief.org/', iconColor: Color.red(), label: 'Bandage' }, 43 | { symbol: 'waveform.path', action: 'http://www.doctorswithoutborders.org/', iconColor: Color.green(), label: 'Wave' }, 44 | 45 | // icon using image 46 | { icon: (await getIcon()), iconColor: new Color('#094563'), label: 'Automate', action: 'https://talk.automators.fm' }, 47 | 48 | 49 | ] 50 | 51 | // create the widget 52 | const widget = new ButtonsWidget(buttons, { 53 | widgetFamily: config.widgetFamily || 'medium', 54 | compact: is_compact 55 | }) 56 | 57 | // #OPTIONAL 58 | // add background if needed 59 | const transparent = (args.widgetParameter || '').includes('transparent') 60 | if (transparent) { 61 | const fm = FileManager.iCloud() 62 | const nobgscript = fm.joinPath(fm.documentsDirectory(), 'no-background.js') 63 | if (fm.fileExists(nobgscript)) { 64 | const nobg = importModule('no-background') 65 | widget.backgroundImage = await nobg.getSlice('medium-top') 66 | } 67 | } 68 | // END #OPTIONAL 69 | 70 | 71 | Script.setWidget(widget) 72 | 73 | if (config.runsInApp) { 74 | await widget.presentMedium() 75 | } 76 | 77 | async function getIcon() { 78 | const req = new Request('https://talk.automators.fm/user_avatar/talk.automators.fm/automatorbot/90/16_2.png') 79 | return (await req.loadImage()) 80 | } 81 | 82 | -------------------------------------------------------------------------------- /source/buttons-widget.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: purple; icon-glyph: grip-horizontal; 4 | 5 | /* ********************************************** 6 | Name : buttons-widget.js 7 | Author : @supermamon 8 | Version : 1.0.0 9 | Desc : A widget library for creating widgets 10 | as app/action launchers 11 | 12 | Changelog: 13 | ------------------------------------------------- 14 | v1.0.0 | 2022-09-19 15 | * Initial release 16 | ------------------------------------------------- 17 | v1.0.1 | 2022-09-20 18 | * (fix) icons too large on smaller devices 19 | ------------------------------------------------- 20 | v1.0.2 | 2022-09-20 21 | * (update) simplified icon size formula 22 | * (fix) removed unwanted code 23 | -------------------------------------------------h 24 | v1.0.3 | 2022-09-20 25 | * (fix) custom iconWidth not being applied 26 | * (temp-fix) icons too large on iPad. 27 | ********************************************** */ 28 | 29 | 30 | // FLAGS 31 | // lock screen widgets don't support tap targets 32 | // leaving this in in case it happens in the 33 | // future 34 | const FEAT_LS_TAP_TARGETS_SUPPORTED = false 35 | // give stack background color for debugging 36 | const DBG_COLOR_STACKS = false 37 | 38 | //=============================================== 39 | class ButtonsWidget extends ListWidget { 40 | constructor(buttons = [], { 41 | 42 | // styles 43 | backgroundImage, 44 | compact = false, 45 | widgetFamily = config.widgetFamily, 46 | padding = 3, 47 | 48 | // grid 49 | rows, 50 | cols, 51 | 52 | // icon 53 | emptyIconColor = Color.darkGray(), 54 | 55 | iconWidth, 56 | iconCornerRadius = 18, 57 | iconColor = Color.blue(), 58 | iconFontSize, 59 | iconTintColor = Color.white(), 60 | 61 | // labels 62 | labelFont = Font.caption2(), 63 | labelColor, 64 | 65 | } = {}) { 66 | super() 67 | 68 | // initialize 69 | Object.assign(this, { 70 | buttons, 71 | 72 | // styles 73 | compact, 74 | widgetFamily, 75 | padding, 76 | 77 | rows, 78 | cols, 79 | 80 | iconWidth, 81 | iconCornerRadius, 82 | iconColor, 83 | emptyIconColor, 84 | iconFontSize, 85 | iconTintColor, 86 | 87 | labelFont, 88 | labelColor, 89 | }) 90 | 91 | if (backgroundImage) { 92 | log('background image provided') 93 | this.backgroundImage = backgroundImage 94 | } 95 | 96 | // grid size 97 | if (FEAT_LS_TAP_TARGETS_SUPPORTED && widgetFamily == 'accessoryRectangular') { 98 | this.cols = 3 99 | this.rows = 1 100 | this.iconWidth = 46 101 | this.buttonColor = Color.black() 102 | this.padding = 0 103 | this.addAccessoryWidgetBackground = false 104 | } else if (FEAT_LS_TAP_TARGETS_SUPPORTED && widgetFamily == 'accessoryCircular') { 105 | this.cols = 1 106 | this.rows = 1 107 | this.iconWidth = 46 108 | this.buttonColor = Color.black() 109 | this.padding = 0 110 | this.addAccessoryWidgetBackground = true 111 | } else { 112 | 113 | if (!this.rows) { 114 | this.rows = widgetFamily == 'extraLarge' ? (compact ? 6 : 4) : widgetFamily == 'large' ? (compact ? 6 : 4) : (compact ? 3 : 2) 115 | } 116 | if (!this.cols) { 117 | this.cols = widgetFamily == 'small' ? (compact ? 3 : 2) : widgetFamily == 'extraLarge' ? (compact ? 12 : 8) : (compact ? 6 : 4) 118 | } 119 | 120 | const screenSize = Device.screenSize() 121 | 122 | const reference = screenSize.width > screenSize.height ? screenSize.height : screenSize.width 123 | //const reference = screenSize.width 124 | 125 | if (!this.iconWidth) { 126 | // must find calculation for iPad 127 | const iw = Device.isPad() ? 56 : Math.floor(reference * 0.15) 128 | this.iconWidth = compact ? Math.floor(iw * 0.75) : iw 129 | //log(`iconWidth = ${this.iconWidth}`) 130 | } 131 | 132 | } 133 | 134 | this.sidePadding = 3 // 135 | this.iconFontSize = compact ? 10 : 18 136 | this.iconSize = new Size(this.iconWidth, this.iconWidth) 137 | this.labelSize = new Size(this.iconWidth, 14) 138 | 139 | this.spacing = 2 140 | this.setPadding(this.padding, this.sidePadding, this.padding, this.sidePadding) 141 | 142 | if (DBG_COLOR_STACKS) this.backgroundColor = Color.yellow() 143 | 144 | // main stack 145 | const vstack = this.addStack() 146 | vstack.layoutVertically() 147 | if (DBG_COLOR_STACKS) vstack.backgroundColor = Color.brown() 148 | 149 | let idx = 0; // to track current button 150 | // this nested loop will render the buttons 151 | // if there are lesser number of buttons 152 | // than the capacity of the grid, it will 153 | // create an empty button. That's where the 154 | // emptyColor property comes in. 155 | 156 | // loop through rows 157 | for (var r = 0; r < this.rows; r++) { 158 | const row = vstack.addStack() 159 | row.layoutHorizontally() 160 | 161 | // loop through columns 162 | for (var c = 0; c < this.cols; c++) { 163 | 164 | // spacer conditions 165 | const has_left_spacer = c > 0 || compact 166 | const has_right_spacer = c < this.cols - 1 || compact 167 | 168 | // container stack. the box that will 169 | // contain the icon, label and internal 170 | // spacers for margin 171 | const container = row.addStack() 172 | container.setPadding(0, 0, 0, 0) 173 | if (DBG_COLOR_STACKS) container.backgroundColor = Color.green() 174 | 175 | if (has_left_spacer) { 176 | container.addSpacer(2) 177 | } 178 | 179 | // content area 180 | // this stack will contain the icon and 181 | // the label 182 | const content = container.addStack() 183 | content.layoutVertically() 184 | content.setPadding(0, 0, 0, 0) 185 | if (DBG_COLOR_STACKS) content.backgroundColor = Color.gray() 186 | 187 | // the button itself 188 | const b = content.addStack() 189 | b.layoutVertically() 190 | 191 | b.size = this.iconSize 192 | b.cornerRadius = this.iconCornerRadius 193 | 194 | // get the item, if exists 195 | const item = idx < this.buttons.length ? this.buttons[idx] : null 196 | 197 | // default empty color for button 198 | b.backgroundColor = this.emptyIconColor 199 | 200 | // vertical alignment 201 | b.addSpacer() 202 | 203 | // button content 204 | const tx = b.addStack() 205 | tx.layoutHorizontally() 206 | 207 | // center the icon/caption horizontally 208 | // by putting spacer on each end 209 | tx.addSpacer() 210 | if (item) { 211 | 212 | // tap target 213 | // this can be a website or a url-scheme 214 | if (item.action) { 215 | b.url = item.action 216 | } 217 | 218 | // if button has custom color, use it, else use default 219 | b.backgroundColor = item?.iconColor ?? this.iconColor 220 | 221 | // priority 222 | // icon (image), symbol, label 223 | if (item.icon) { 224 | const img = tx.addImage(item.icon) 225 | } else if (item.symbol) { 226 | const img = tx.addImage(imageFromSymbol(item.symbol)) 227 | img.tintColor = this.iconTintColor 228 | } else if (item.label) { 229 | 230 | let initials = item.label.split(' ') 231 | if (initials.length > 1) { 232 | // grab the first 2 initials if multi-word 233 | initials = initials.slice(0, 2) 234 | .map(w => w.substring(0, 1)) 235 | .join('') 236 | .toUpperCase() 237 | } else { 238 | // use the whole label. 239 | initials = item.label.toUpperCase() 240 | } 241 | 242 | const cap = tx.addText(initials) 243 | cap.textColor = this.iconTintColor 244 | cap.font = Font.regularSystemFont(this.iconFontSize) 245 | } else { 246 | // no lable, icon, or symbol provided 247 | const img = tx.addImage(imageFromSymbol('questionmark.diamond')) 248 | img.tintColor = this.iconTintColor 249 | } 250 | } 251 | tx.addSpacer() 252 | 253 | // vertical alignment 254 | b.addSpacer() 255 | 256 | // it not compact, hide the label 257 | if (!this.compact) { 258 | // space between icon and label 259 | content.addSpacer(2) 260 | 261 | const labelStack = content.addStack() 262 | labelStack.layoutHorizontally() 263 | labelStack.setPadding(0, 0, 0, 0) 264 | 265 | labelStack.size = this.labelSize 266 | 267 | if (DBG_COLOR_STACKS) labelStack.backgroundColor = Color.red() 268 | 269 | labelStack.addSpacer(2) 270 | let label; 271 | if (item?.label) { 272 | label = labelStack.addText(item.label) 273 | } else { 274 | label = labelStack.addText(' ') 275 | } 276 | label.font = this.labelFont 277 | label.centerAlignText() 278 | if (this.labelColor) { 279 | label.textColor = this.labelColor 280 | } 281 | label.shadowColor = Color.gray() 282 | label.shadowOffset = new Point(0, 0) 283 | label.shadowRadius = 5 284 | 285 | labelStack.addSpacer(2) 286 | } 287 | 288 | if (has_right_spacer) { 289 | container.addSpacer(2) 290 | } 291 | 292 | idx++; 293 | // only add a spacer if not the last column 294 | if (c < this.cols - 1) row.addSpacer() 295 | } 296 | 297 | // only add a spacer if not the last row 298 | if (r < this.rows - 1) vstack.addSpacer() 299 | 300 | } 301 | 302 | } 303 | 304 | } 305 | 306 | function imageFromSymbol(name) { 307 | const sym = SFSymbol.named(name) 308 | sym.applyRegularWeight() 309 | const img = sym.image 310 | return sym.image 311 | } 312 | 313 | module.exports = { ButtonsWidget } 314 | -------------------------------------------------------------------------------- /source/lib-text-file-widget.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: purple; icon-glyph: file-alt; 4 | 5 | /* ********************************************** 6 | Name : lib-text-file-widget.js 7 | Author : @supermamon 8 | Version : 1.1.0 9 | Desc : A widget to display the contents of a 10 | text file. 11 | Dependencies : 12 | * iCloud enabled for Scriptable 13 | 14 | Changelog: 15 | ------------------------------------------------- 16 | v1.1.0 | 2022-09-16 17 | * Center text in circular widgets. 18 | 19 | v1.0.0 | 2022-09-16 20 | * Initial release 21 | ********************************************** */ 22 | 23 | 24 | class TextFileWidget extends ListWidget { 25 | 26 | constructor(filename, { minimumScale = 0.65, absolute = false, font = Font.body(), padding = 10, showFilename = false, centerContent = false } = {}) { 27 | super() 28 | 29 | if (config.runsInAccessoryWidget) { 30 | // override padding when in home screen 31 | padding = 0 32 | if (config.widgetFamily == 'accessoryCircular') { 33 | centerContent = true 34 | font = Font.headline() 35 | this.addAccessoryWidgetBackground = true 36 | } 37 | } 38 | 39 | 40 | this.loading = true 41 | this.minimumScale = minimumScale 42 | this.absolute = absolute 43 | this.filename = filename 44 | this.padding = padding 45 | this.setPadding(padding, padding, padding, padding) 46 | this.font = font 47 | this.showFilename = showFilename 48 | this.centerContent = centerContent 49 | 50 | 51 | if (!filename) { 52 | this.addText('filename not provided') 53 | this.loading = false 54 | return 55 | } 56 | 57 | const Files = FileManager.iCloud() 58 | this.filepath = this.absolute ? filename : Files.joinPath(Files.documentsDirectory(), filename) 59 | 60 | if (this.showFilename) { 61 | log('displaying filename') 62 | const fileNameStack = this.addStack() 63 | fileNameStack.layoutHorizontally() 64 | fileNameStack.addSpacer() 65 | const fnText = fileNameStack.addText(Files.fileName(this.filepath)) 66 | fnText.font = Font.regularSystemFont(8) 67 | fileNameStack.addSpacer() 68 | this.addSpacer(2) 69 | } 70 | 71 | if (!Files.fileExists(this.filepath)) { 72 | log('file does not exists') 73 | const text = this.addText("File doesn't exists.") 74 | text.minimumScaleFactor = this.minimumScale 75 | text.font = this.font 76 | this.loading = false 77 | } else { 78 | Files.downloadFileFromiCloud(this.filepath) 79 | .then(() => { 80 | log('loading file contents') 81 | 82 | try { 83 | const content = Files.readString(this.filepath) 84 | log(content) 85 | const text = this.addText(content) 86 | if (centerContent) { 87 | text.centerAlignText() 88 | } 89 | 90 | text.minimumScaleFactor = this.minimumScale 91 | text.font = this.font 92 | log('file loaded and displayed') 93 | 94 | } catch (e) { 95 | log(e.message) 96 | const text = this.addText(e.message) 97 | 98 | text.minimumScaleFactor = this.minimumScale 99 | text.font = this.font 100 | log('encountered error') 101 | } 102 | 103 | this.loading = false 104 | 105 | }) 106 | } 107 | 108 | // test refresh every minute 109 | const nextRefresh = new Date() 110 | nextRefresh.setTime(nextRefresh.getTime() + 1000 * 60) 111 | this.refreshAfterDate = nextRefresh 112 | 113 | } 114 | 115 | waitForLoad() { 116 | return new Promise((resolve, reject) => { 117 | if (!this.loading) { 118 | log('not waiting because not loading') 119 | resolve(0) 120 | return 121 | } 122 | const timeout = 1000 * 3 // seconds 123 | let iterations = 0 124 | 125 | const t = new Timer() 126 | t.timeInterval = 200 127 | t.schedule(() => { 128 | iterations += 200 129 | if (!this.loading) { 130 | log('stop waiting because file is loaded') 131 | t.invalidate() 132 | resolve(0) 133 | return 134 | } else if (iterations >= timeout) { 135 | log('stop waiting because timeout reached') 136 | this.loading = false 137 | t.invalidate() 138 | resolve(1) 139 | return 140 | } 141 | log('still loading...') 142 | }) 143 | }) 144 | } 145 | } 146 | 147 | 148 | 149 | module.exports = { TextFileWidget } -------------------------------------------------------------------------------- /source/openweathermap.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: purple; icon-glyph: sun; 4 | 5 | /* ----------------------------------------------- 6 | Script : openweathermap.js 7 | Author : dev@supermamon.com 8 | Version : 1.2.0 9 | Description : 10 | A Scriptable module that encapsulate the 11 | One Call API from OpenWeatherMap and additional 12 | information shared by developers over the 13 | Automators.fm community. 14 | 15 | Features 16 | * Auto location detection or custom coordinates 17 | * Night time detection 18 | * SFSymbol names for weather conditions 19 | * Units information 20 | * OpenWeatherMap icon urls 21 | 22 | References: 23 | https://openweathermap.org/api/one-call-api 24 | https://talk.automators.fm/t/widget-examples/7994/412 25 | https://talk.automators.fm/t/widget-examples/7994/414 26 | 27 | Changelog : 28 | v1.0.0 29 | - Initial release 30 | ------------------------------------------------- 31 | v1.1.0 32 | - (new) Add sfsymbol and icon to daily forecast 33 | - (fix) Does not allow changing units 34 | ------------------------------------------------- 35 | v1.2.0 | 20 Sep 2022 36 | - (fix) lang option does not do anything 37 | - (fix) night detection is incorrect sometimes 38 | - (fix) minutely forecast not being excluded 39 | - (fix) error when hourly or daily is excluded 40 | - (new) api_version parameter 41 | ----------------------------------------------- */ 42 | 43 | async function getOpenWeatherData({ 44 | appid = '', 45 | api_version = '2.5', 46 | units = 'metric', 47 | lang = 'en', 48 | exclude = 'minutely,alerts', 49 | revgeocode = false, 50 | lat, 51 | lon, 52 | location_name, 53 | } = {}) { 54 | 55 | // auto-exclude minutely and alerts 56 | if (!exclude.includes('minutely')) { 57 | exclude = `${exclude},minutely` 58 | } 59 | if (!exclude.includes('alerts')) { 60 | exclude = `${exclude},alerts` 61 | } 62 | 63 | const opts = { appid, api_version, units, lang, exclude, revgeocode, lat, lon, location_name } 64 | 65 | // validate units 66 | if (!(/metric|imperial|standard/.test(opts.units))) { 67 | opts.units = 'metric' 68 | } 69 | 70 | // if coordinates are not provided, attempt to 71 | // automatically find them 72 | if (!opts.lat || !opts.lon) { 73 | log('coordinates not provided. detecting...') 74 | try { 75 | var loc = await Location.current() 76 | log('successfully detected') 77 | } catch (e) { 78 | log('unable to detect') 79 | throw new Error('Unable to find your location.') 80 | } 81 | opts.lat = loc.latitude 82 | opts.lon = loc.longitude 83 | log(`located lat: ${opts.lat}, lon: ${opts.lon}`) 84 | } 85 | 86 | // ready to fetch the weather data 87 | let url = `https://api.openweathermap.org/data/${opts.api_version}/onecall?lat=${opts.lat}&lon=${opts.lon}&exclude=${opts.exclude}&units=${opts.units}&lang=${opts.lang}&appid=${opts.appid}` 88 | let req = new Request(url) 89 | let wttr = await req.loadJSON() 90 | if (wttr.cod) { 91 | throw new Error(wttr.message) 92 | } 93 | 94 | // add some information not provided by OWM 95 | // UNITS 96 | const currUnits = { 97 | standard: { 98 | temp: "K", 99 | pressure: "hPa", 100 | visibility: "m", 101 | wind_speed: "m/s", 102 | wind_gust: "m/s", 103 | rain: "mm", 104 | snow: "mm" 105 | }, 106 | metric: { 107 | temp: "C", 108 | pressure: "hPa", 109 | visibility: "m", 110 | wind_speed: "m/s", 111 | wind_gust: "m/s", 112 | rain: "mm", 113 | snow: "mm" 114 | }, 115 | imperial: { 116 | temp: "F", 117 | pressure: "hPa", 118 | visibility: "m", 119 | wind_speed: "mi/h", 120 | wind_gust: "mi/h", 121 | rain: "mm", 122 | snow: "mm" 123 | } 124 | } 125 | wttr.units = currUnits[opts.units] 126 | 127 | // Reverse Geocoding 128 | if (opts.revgeocode) { 129 | log('reverse geocoding...') 130 | const geo = await Location.reverseGeocode(opts.lat, opts.lon) 131 | if (geo.length) { 132 | wttr.geo = geo[0] 133 | } 134 | } 135 | 136 | // Night Detections, Weather Symbols and Icons 137 | 138 | //---------------------------------------------- 139 | // SFSymbol function 140 | // Credits to @eqsOne | https://talk.automators.fm/t/widget-examples/7994/414 141 | // Reference: https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2 142 | const symbolForCondition = function (cond, night = false) { 143 | let symbols = { 144 | // Thunderstorm 145 | "2": function () { 146 | return "cloud.bolt.rain.fill" 147 | }, 148 | // Drizzle 149 | "3": function () { 150 | return "cloud.drizzle.fill" 151 | }, 152 | // Rain 153 | "5": function () { 154 | return (cond == 511) ? "cloud.sleet.fill" : "cloud.rain.fill" 155 | }, 156 | // Snow 157 | "6": function () { 158 | return (cond >= 611 && cond <= 613) ? "cloud.snow.fill" : "snow" 159 | }, 160 | // Atmosphere 161 | "7": function () { 162 | if (cond == 781) { return "tornado" } 163 | if (cond == 701 || cond == 741) { return "cloud.fog.fill" } 164 | return night ? "cloud.fog.fill" : "sun.haze.fill" 165 | }, 166 | // Clear and clouds 167 | "8": function () { 168 | if (cond == 800) { return night ? "moon.stars.fill" : "sun.max.fill" } 169 | if (cond == 802 || cond == 803) { return night ? "cloud.moon.fill" : "cloud.sun.fill" } 170 | return "cloud.fill" 171 | } 172 | } 173 | // Get first condition digit. 174 | let conditionDigit = Math.floor(cond / 100) 175 | return symbols[conditionDigit]() 176 | 177 | } 178 | 179 | // find the day that matched the epoch `dt` 180 | const findDay = function (dt) { 181 | const hDate = new Date(1000 * (dt)) 182 | 183 | const tzOffset = (new Date()).getTimezoneOffset() * 60 184 | const match = wttr.daily.filter(daily => { 185 | // somehow the daily timestamps are at UTC but inputs are local 186 | // just trying this out if it works long term 187 | const dDate = new Date(1000 * (daily.dt + tzOffset)) 188 | 189 | return ( 190 | hDate.getYear() == dDate.getYear() && 191 | hDate.getMonth() == dDate.getMonth() && 192 | hDate.getDate() == dDate.getDate()) 193 | })[0] 194 | return match 195 | } 196 | 197 | wttr.current.is_night = ( 198 | wttr.current.dt > (wttr.current.sunset) || 199 | wttr.current.dt < (wttr.current.sunrise)) 200 | 201 | wttr.current.weather[0].sfsymbol = 202 | symbolForCondition( 203 | wttr.current.weather[0].id, 204 | wttr.current.is_night) 205 | 206 | let wicon = wttr.current.weather[0].icon 207 | wttr.current.weather[0].icon_url = 208 | `http://openweathermap.org/img/wn/@2x.png${wicon}` 209 | 210 | // hourly data 211 | if (wttr.hourly) { 212 | wttr.hourly.map(hourly => { 213 | 214 | var day = findDay(hourly.dt) 215 | hourly.is_night = ( 216 | hourly.dt > day.sunset || 217 | hourly.dt < day.sunrise) 218 | 219 | hourly.weather[0].sfsymbol = 220 | symbolForCondition( 221 | hourly.weather[0].id, 222 | hourly.is_night) 223 | 224 | let wicon = hourly.weather[0].icon 225 | hourly.weather[0].icon_url = 226 | `http://openweathermap.org/img/wn/@2x.png${wicon}` 227 | 228 | return hourly 229 | }) 230 | } 231 | 232 | if (wttr.daily) { 233 | wttr.daily.map(daily => { 234 | 235 | daily.weather[0].sfsymbol = 236 | symbolForCondition( 237 | daily.weather[0].id, 238 | false) 239 | 240 | let wicon = daily.weather[0].icon 241 | daily.weather[0].icon_url = 242 | `http://openweathermap.org/img/wn/@2x.png${wicon}` 243 | 244 | return daily 245 | }) 246 | } 247 | 248 | 249 | 250 | 251 | 252 | // also return the arguments provided 253 | wttr.args = opts 254 | 255 | //log(wttr) 256 | return wttr 257 | 258 | } 259 | 260 | module.exports = getOpenWeatherData -------------------------------------------------------------------------------- /source/peanuts-widget.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: tablets; 4 | /* ********************************************** 5 | Name : peanuts-widget.js 6 | Author : @supermamon 7 | Version : 1.0.1 8 | Desc : shows a random or today's Peanuts 9 | comic strip from www.gocomics.com/peanuts/ 10 | 11 | Changelog: 12 | ------------------------------------------------- 13 | v1.0.0 | 2020-10-28 14 | * Initial release 15 | ------------------------------------------------- 16 | v1.0.1 | 2022-09-20 17 | * (fix) regex matching 18 | * (update) merged no-background option 19 | * (update) implemented Color.dynamic() for gradient 20 | ********************************************** */ 21 | 22 | const BACKGROUND_DARK_MODE = "system" 23 | // options: "yes", "no", "system" 24 | const TRANSPARENT = false 25 | 26 | const SHOW_TITLE = true 27 | const SHOW_DATE = true 28 | 29 | let RANDOM = false 30 | // default is current comic 31 | // set the Parameter value to "random" in the 32 | // Edit Widget screen to use a random comic 33 | if (args.widgetParameter == 'random') { 34 | RANDOM = true 35 | } 36 | 37 | 38 | 39 | let data = await loadData(RANDOM) 40 | let widget = await createWidget(data) 41 | 42 | if (!config.runsInWidget) { 43 | await widget.presentMedium() 44 | } else { 45 | Script.setWidget(widget) 46 | } 47 | Script.complete() 48 | // ----------------------------------------------- 49 | async function createWidget(data) { 50 | const w = new ListWidget(); 51 | w.setPadding(15, 0, 15, 0) 52 | 53 | let fromLightColor = 'b00a0fe6' 54 | let fromDarkColor = '#010c1ee6' 55 | let toLightColor = 'b00a0fb3' 56 | let toDarkColor = '#010c1ee6' 57 | if (BACKGROUND_DARK_MODE == 'no') { 58 | fromDarkColor = fromLightColor 59 | toDarkColor = fromDarkColor 60 | } 61 | if (BACKGROUND_DARK_MODE == 'yes') { 62 | fromLightColor = fromDarkColor 63 | toLightColor = toDarkColor 64 | } 65 | 66 | 67 | if (!TRANSPARENT) { 68 | let gradient = new LinearGradient() 69 | gradient.locations = [0, 1] 70 | gradient.colors = [ 71 | Color.dynamic(new Color(fromLightColor), new Color(fromDarkColor)), 72 | Color.dynamic(new Color(toLightColor), new Color(toDarkColor)), 73 | ] 74 | w.backgroundGradient = gradient 75 | } else { 76 | const fm = FileManager.iCloud() 77 | const nobgscript = fm.joinPath(fm.documentsDirectory(), 'no-background.js') 78 | if (fm.fileExists(nobgscript)) { 79 | const nobg = importModule('no-background') 80 | w.backgroundImage = await nobg.getSliceForWidget('peanuts-widget') 81 | } 82 | } 83 | 84 | w.addSpacer() 85 | 86 | if (SHOW_TITLE) { 87 | let titleTxt = w.addText('Peanuts') 88 | titleTxt.font = Font.boldSystemFont(14) 89 | titleTxt.centerAlignText() 90 | titleTxt.textColor = Color.white() 91 | w.addSpacer(2) 92 | } 93 | 94 | let img = await downloadImage(data.img); 95 | let pic = w.addImage(img) 96 | pic.centerAlignImage() 97 | 98 | if (SHOW_DATE) { 99 | w.addSpacer(2) 100 | let dateTxt = w.addText(data.formattedDate) 101 | dateTxt.centerAlignText() 102 | dateTxt.textColor = Color.white() 103 | } 104 | 105 | w.addSpacer() 106 | return w 107 | } 108 | 109 | // ----------------------------------------------- 110 | async function downloadImage(imgurl) { 111 | let imgReq = new Request(imgurl) 112 | let img = await imgReq.loadImage() 113 | return img 114 | } 115 | // ----------------------------------------------- 116 | async function loadData(random) { 117 | var comicDate = new Date() 118 | if (random) { 119 | var start = new Date(1950, 9, 2) 120 | var end = new Date() 121 | comicDate = new Date(+start + Math.random() * (end - start)); 122 | } 123 | log(comicDate) 124 | var df = new DateFormatter() 125 | df.dateFormat = 'YYYY/MM/dd' 126 | var dfDate = df.string(comicDate) 127 | var url = `https://www.gocomics.com/peanuts/${dfDate}` 128 | var req = new Request(url) 129 | var src = await req.loadString() 130 | log(src) 131 | var m = src.match(/ new Color(color)) 158 | return gradient 159 | } -------------------------------------------------------------------------------- /source/text-file-widget-w-custom.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: file-signature; 4 | 5 | /** 6 | * Name : text-file-widget-w-opts.js 7 | * Author : @supermamon 8 | * Docs : https://github.com/supermamon/scriptable-scripts/docs/text-file-widget.md 9 | */ 10 | 11 | const { TextFileWidget } = importModule('lib-text-file-widget') 12 | 13 | const widget = new TextFileWidget('notes/notes.txt') 14 | await widget.waitForLoad() 15 | 16 | // custom background and padding 17 | widget.setPadding(15, 15, 15, 15) 18 | const nobg = importModule('no-background') 19 | widget.backgroundImage = await nobg.getSlice('medium-top') 20 | 21 | // additional content 22 | const dateLine = widget.addDate(new Date()) 23 | dateLine.applyDateStyle() 24 | dateLine.font = Font.footnote() 25 | dateLine.rightAlignText() 26 | 27 | Script.setWidget(widget) 28 | -------------------------------------------------------------------------------- /source/text-file-widget-w-opts.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: file-signature; 4 | 5 | /** 6 | * Name : text-file-widget-w-opts.js 7 | * Author : @supermamon 8 | * Docs : https://github.com/supermamon/scriptable-scripts/docs/text-file-widget.md 9 | */ 10 | 11 | const { TextFileWidget } = importModule('lib-text-file-widget') 12 | 13 | const options = { 14 | 15 | // padding 16 | // default: 10, forced 0 for lock screen widgets 17 | padding: 8, 18 | 19 | // text scaling 20 | // default 0.65 21 | minimumScale: 0.8, 22 | 23 | // define where the filename parameter is an 24 | // absolute or relative path 25 | // default: false 26 | absolute: false, 27 | 28 | // Font of the content 29 | // default: Font.body() 30 | font: Font.regularSystemFont(12), 31 | 32 | // display the filename on the widget 33 | // default: false 34 | showFilename: true, 35 | 36 | // horizontally center the file contents 37 | // default: false. forced true for circular widgets 38 | centerContent: true 39 | 40 | } 41 | 42 | const widget = new TextFileWidget('notes/notes.txt', options) 43 | await widget.waitForLoad() 44 | Script.setWidget(widget) 45 | -------------------------------------------------------------------------------- /source/text-file-widget.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: file-signature; 4 | 5 | /* ********************************************** 6 | Name : text-file-widget.js 7 | Author : @supermamon 8 | Version : 1.0 9 | Desc : A widget to display the contents of a 10 | text file. Pass the file path (relative 11 | to the Scriptable folder) as widget 12 | parameter 13 | Dependencies : 14 | * module: lib-text-file-widget.js 15 | * iCloud enabled for Scriptable 16 | 17 | Changelog: 18 | ------------------------------------------------- 19 | v1.0.0 | 2022-09-16 20 | * Initial release 21 | ********************************************** */ 22 | 23 | const { TextFileWidget } = importModule('lib-text-file-widget') 24 | const widget = new TextFileWidget(args.widgetParameter) 25 | await widget.waitForLoad() 26 | Script.setWidget(widget) 27 | 28 | if (config.runsInApp) { 29 | await widget.presentLarge() 30 | } -------------------------------------------------------------------------------- /source/xkcd.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: gray; icon-glyph: angle-right; 4 | 5 | // script : xkcdWidget.js 6 | // version : 1.0.0 7 | // description: xkcd widget for Scriptable.app 8 | // author : @supermamon 9 | // date : 2020-09-17 10 | 11 | 12 | const BACKGROUND_DARK_MODE = "system" 13 | // options: "yes", "no", "system" 14 | 15 | let RANDOM = false 16 | // default is current comic 17 | // set the Parameter value to "random" in the 18 | // Edit Widget screen to use a random comic 19 | if (args.widgetParameter == 'random') { 20 | RANDOM = true 21 | } 22 | 23 | // show the alt text at the bottom of the image. 24 | let SHOW_ALT = true 25 | 26 | // load data and create widget 27 | let data = await loadData() 28 | let widget = await createWidget(data) 29 | 30 | if (!config.runsInWidget) { 31 | await widget.presentLarge() 32 | } 33 | 34 | // Tell the system to show the widget. 35 | Script.setWidget(widget) 36 | Script.complete() 37 | 38 | async function createWidget(data) { 39 | const w = new ListWidget(); 40 | 41 | let isDarkMode = 42 | BACKGROUND_DARK_MODE=="system" ? 43 | await isUsingDarkAppearance() : 44 | BACKGROUND_DARK_MODE=="yes" 45 | 46 | if (isDarkMode) { 47 | w.backgroundGradient = newLinearGradient('#010c1ee6','#001e38b3') 48 | } else { 49 | w.backgroundGradient = newLinearGradient('#b00a0fe6','#b00a0fb3') 50 | } 51 | 52 | let titleTxt = w.addText(data.safe_title) 53 | titleTxt.font = Font.boldSystemFont(14) 54 | titleTxt.centerAlignText() 55 | titleTxt.textColor = Color.white() 56 | 57 | w.addSpacer(2) 58 | 59 | let img = await downloadImage(data.img); 60 | let pic = w.addImage(img) 61 | pic.centerAlignImage() 62 | 63 | w.addSpacer() 64 | 65 | if (SHOW_ALT) { 66 | let subTxt = w.addText(`${data.num}: ${data.alt}`) 67 | subTxt.font = Font.mediumSystemFont(10) 68 | subTxt.textColor = Color.white() 69 | subTxt.textOpacity = 0.9 70 | subTxt.centerAlignText() 71 | } 72 | 73 | return w 74 | } 75 | async function loadData() { 76 | return (await xkcd(RANDOM)) 77 | } 78 | function newLinearGradient(from, to) { 79 | let gradient = new LinearGradient() 80 | gradient.locations = [0, 1] 81 | gradient.colors = [ 82 | new Color(from), 83 | new Color(to) 84 | ] 85 | return gradient 86 | } 87 | async function downloadImage(imgurl) { 88 | let imgReq = new Request(imgurl) 89 | let img = await imgReq.loadImage() 90 | return img 91 | } 92 | async function xkcd(random) { 93 | let url = "https://xkcd.com/info.0.json" 94 | let req = new Request(url) 95 | let json = await req.loadJSON() 96 | 97 | if (random) { 98 | let rnd = Math.floor(Math.random() * (json.num - 1 + 1) ) + 1 99 | url = `https://xkcd.com/${rnd}/info.0.json` 100 | req = new Request(url) 101 | json = await req.loadJSON() 102 | } 103 | 104 | return json 105 | } 106 | 107 | async function isUsingDarkAppearance() { 108 | // yes there's a Device.isUsingDarkAppearance() method 109 | // but I find it unreliable 110 | const wv = new WebView() 111 | let js ="(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)" 112 | let r = await wv.evaluateJavaScript(js) 113 | return r 114 | } -------------------------------------------------------------------------------- /utilities/basic-ui.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: deep-brown; icon-glyph: drafting-compass; 4 | 5 | function Picker(data, idProperty, captionProperty, header, returnIDOnly) { 6 | this.data = data 7 | this.idProperty = idProperty 8 | this.captionProperty = captionProperty 9 | this.header = header 10 | this.dataOffset = header?1:0 11 | this.hasHeader = !!header 12 | this.returnIDOnly = returnIDOnly?true:false 13 | } 14 | 15 | Picker.prototype.show = async function() { 16 | let table = new UITable() 17 | table.showSeparators = true 18 | 19 | if (this.hasHeader) { 20 | let headerRow = new UITableRow() 21 | headerRow.isHeader = true; 22 | headerRow.addCell( UITableCell.text(this.header)) 23 | table.addRow(headerRow) 24 | } 25 | 26 | let selectedItem; 27 | 28 | this.data.forEach( item => { 29 | let row = new UITableRow() 30 | row.cellSpacing = 10 31 | row.dismissOnSelect = true 32 | let cell = UITableCell.text(item[this.captionProperty]) 33 | cell.leftAligned() 34 | row.addCell(cell) 35 | table.addRow(row) 36 | row.onSelect = (index) => { 37 | var item = this.data[index-this.dataOffset] 38 | selectedItem = this.returnIDOnly ? item[this.idProperty] : item 39 | } 40 | }) 41 | 42 | await QuickLook.present(table)// table.present() 43 | return selectedItem 44 | } 45 | 46 | function Prompt(caption, defaultValue) { 47 | var prompt = new Alert() 48 | prompt.title = caption 49 | prompt.addAction('OK') 50 | prompt.addCancelAction('Cancel') 51 | prompt.addTextField(caption, defaultValue) 52 | this.prompt = prompt 53 | } 54 | Prompt.prototype.show = async function() { 55 | await this.prompt.presentAlert() 56 | return this.prompt.textFieldValue(0) 57 | } 58 | 59 | module.exports = { 60 | Picker: Picker, 61 | Prompt: Prompt 62 | } -------------------------------------------------------------------------------- /utilities/json-util.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: orange; icon-glyph: star-of-life; 4 | 5 | const FM = FileManager.iCloud(); 6 | 7 | var jsonUtil = {} 8 | 9 | jsonUtil.encodeAsQueryString = function(json) 10 | { 11 | var result = '' 12 | for (var key in json) { 13 | let val = json[key] 14 | val = encodeURIComponent(val) 15 | result += result?'&':'' 16 | result += `${key}=${val}` 17 | } 18 | return result 19 | } 20 | 21 | jsonUtil.loadFromFile = function(path) { 22 | let contents = FM.readString(path) 23 | return JSON.parse(contents) 24 | } 25 | 26 | jsonUtil.writeToFile = function(json, path) { 27 | FM.writeString(path,JSON.stringify(json)) 28 | } 29 | 30 | module.exports = jsonUtil --------------------------------------------------------------------------------