From 18427928e3d250f92ddbc74119cc8eed53fcb546 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 08:06:25 +0000 Subject: [PATCH 1/5] Add Instapaper reading queue widget for Scriptable Self-contained iOS widget that shows unread article count and estimated reading time using the Instapaper Full API (OAuth 1.0a / xAuth). Tapping the widget opens a random unread article. Includes a pure-JS SHA-1/HMAC-SHA1 implementation so no external dependencies are needed inside the Scriptable runtime. https://claude.ai/code/session_01QsCcdu4bFzd1xy4NQ1oWYb --- instapaper-widget.js | 560 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 instapaper-widget.js diff --git a/instapaper-widget.js b/instapaper-widget.js new file mode 100644 index 0000000..d40ce63 --- /dev/null +++ b/instapaper-widget.js @@ -0,0 +1,560 @@ +// Variables used by Scriptable. +// These must be at the very top of the file. Do not edit. +// icon-color: orange; icon-glyph: book-open; + +/** + * Instapaper Reading Queue — Scriptable Widget + * + * Shows your unread article count and estimated reading time. + * Tapping the widget opens a random unread article in Instapaper. + * + * SETUP + * ----- + * 1. Request an API consumer key at: + * https://www.instapaper.com/main/request_oauth_consumer_token + * 2. Once approved, paste your key and secret into the CONFIG section below. + * 3. Run this script inside the Scriptable app to log in with your + * Instapaper email & password (credentials are exchanged for an OAuth + * token via xAuth and are never stored). + * 4. Add a Small or Medium Scriptable widget to your home screen and + * pick this script. + */ + +// ====================== CONFIG ====================== +const CONSUMER_KEY = "YOUR_CONSUMER_KEY"; +const CONSUMER_SECRET = "YOUR_CONSUMER_SECRET"; + +// Average word count assumed per article (used for time estimate +// since the Instapaper API does not return word counts). +const AVG_WORDS_PER_ARTICLE = 1200; + +// Average adult reading speed in words per minute. +const WORDS_PER_MINUTE = 238; + +// Minutes before the local bookmark cache is considered stale. +const CACHE_MINUTES = 15; +// ==================================================== + +// Keychain keys for the stored OAuth token pair. +const KC_TOKEN = "instapaper_oauth_token"; +const KC_SECRET = "instapaper_oauth_secret"; + +// Local cache filename. +const CACHE_FILE = "instapaper_cache.json"; + +// ──────────────────────────────────────────────────── +// SHA-1 (pure JavaScript — no native crypto needed) +// ──────────────────────────────────────────────────── + +function sha1Bytes(msg) { + function rotl(n, s) { + return (n << s) | (n >>> (32 - s)); + } + + var words = []; + for (var i = 0; i < msg.length; i++) { + words[i >> 2] = (words[i >> 2] || 0) | (msg[i] << (24 - (i % 4) * 8)); + } + + var bitLen = msg.length * 8; + var pi = msg.length >> 2; + words[pi] = (words[pi] || 0) | (0x80 << (24 - (msg.length % 4) * 8)); + var total = ((msg.length + 8) >> 6 << 4) + 16; + while (words.length < total) words.push(0); + words[total - 1] = bitLen; + + var H = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; + + for (var b = 0; b < words.length; b += 16) { + var W = []; + for (var t = 0; t < 16; t++) W[t] = words[b + t] || 0; + for (var t = 16; t < 80; t++) + W[t] = rotl(W[t - 3] ^ W[t - 8] ^ W[t - 14] ^ W[t - 16], 1); + + var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4]; + + for (var t = 0; t < 80; t++) { + var f, k; + if (t < 20) { + f = (bb & c) | (~bb & d); + k = 0x5a827999; + } else if (t < 40) { + f = bb ^ c ^ d; + k = 0x6ed9eba1; + } else if (t < 60) { + f = (bb & c) | (bb & d) | (c & d); + k = 0x8f1bbcdc; + } else { + f = bb ^ c ^ d; + k = 0xca62c1d6; + } + var tmp = (rotl(a, 5) + f + e + k + W[t]) & 0xffffffff; + e = d; + d = c; + c = rotl(bb, 30); + bb = a; + a = tmp; + } + + H[0] = (H[0] + a) & 0xffffffff; + H[1] = (H[1] + bb) & 0xffffffff; + H[2] = (H[2] + c) & 0xffffffff; + H[3] = (H[3] + d) & 0xffffffff; + H[4] = (H[4] + e) & 0xffffffff; + } + + var out = []; + for (var i = 0; i < 5; i++) { + out.push((H[i] >>> 24) & 0xff); + out.push((H[i] >>> 16) & 0xff); + out.push((H[i] >>> 8) & 0xff); + out.push(H[i] & 0xff); + } + return out; +} + +// ──────────────────────────────────────────────────── +// HMAC-SHA1 +// ──────────────────────────────────────────────────── + +function strToBytes(s) { + var b = []; + for (var i = 0; i < s.length; i++) b.push(s.charCodeAt(i) & 0xff); + return b; +} + +function bytesToBase64(bytes) { + var T = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var r = ""; + for (var i = 0; i < bytes.length; i += 3) { + var b1 = bytes[i], + b2 = bytes[i + 1] || 0, + b3 = bytes[i + 2] || 0; + r += T[(b1 >> 2) & 0x3f]; + r += T[((b1 & 3) << 4) | ((b2 >> 4) & 0xf)]; + r += i + 1 < bytes.length ? T[((b2 & 0xf) << 2) | ((b3 >> 6) & 3)] : "="; + r += i + 2 < bytes.length ? T[b3 & 0x3f] : "="; + } + return r; +} + +function hmacSha1(key, message) { + var kb = strToBytes(key); + var mb = strToBytes(message); + if (kb.length > 64) kb = sha1Bytes(kb); + while (kb.length < 64) kb.push(0); + + var ipad = [], + opad = []; + for (var i = 0; i < 64; i++) { + ipad.push(kb[i] ^ 0x36); + opad.push(kb[i] ^ 0x5c); + } + + return bytesToBase64(sha1Bytes(opad.concat(sha1Bytes(ipad.concat(mb))))); +} + +// ──────────────────────────────────────────────────── +// OAuth 1.0a helpers +// ──────────────────────────────────────────────────── + +function pctEnc(s) { + return encodeURIComponent(s).replace(/[!'()*]/g, function (c) { + return "%" + c.charCodeAt(0).toString(16).toUpperCase(); + }); +} + +function oauthNonce() { + var c = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var n = ""; + for (var i = 0; i < 32; i++) + n += c[Math.floor(Math.random() * c.length)]; + return n; +} + +function oauthSign(method, url, params, consumerSecret, tokenSecret) { + var sorted = Object.keys(params) + .sort() + .map(function (k) { + return pctEnc(k) + "=" + pctEnc(params[k]); + }) + .join("&"); + + var base = method + "&" + pctEnc(url) + "&" + pctEnc(sorted); + var key = pctEnc(consumerSecret) + "&" + pctEnc(tokenSecret || ""); + return hmacSha1(key, base); +} + +function oauthHeader(params) { + return ( + "OAuth " + + Object.keys(params) + .sort() + .map(function (k) { + return pctEnc(k) + '="' + pctEnc(params[k]) + '"'; + }) + .join(", ") + ); +} + +// ──────────────────────────────────────────────────── +// Instapaper API transport +// ──────────────────────────────────────────────────── + +function buildRequest(endpoint, bodyParams, token, tokenSecret) { + var url = "https://www.instapaper.com" + endpoint; + + var oa = { + oauth_consumer_key: CONSUMER_KEY, + oauth_nonce: oauthNonce(), + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: String(Math.floor(Date.now() / 1000)), + oauth_version: "1.0", + }; + if (token) oa.oauth_token = token; + + // Signature is computed over *all* params (OAuth + body). + var all = Object.assign({}, oa, bodyParams || {}); + oa.oauth_signature = oauthSign( + "POST", + url, + all, + CONSUMER_SECRET, + tokenSecret || "" + ); + + var req = new Request(url); + req.method = "POST"; + req.headers = { + Authorization: oauthHeader(oa), + "Content-Type": "application/x-www-form-urlencoded", + }; + + if (bodyParams && Object.keys(bodyParams).length) { + req.body = Object.keys(bodyParams) + .map(function (k) { + return encodeURIComponent(k) + "=" + encodeURIComponent(bodyParams[k]); + }) + .join("&"); + } + + return req; +} + +// ──────────────────────────────────────────────────── +// Authentication (xAuth → OAuth token) +// ──────────────────────────────────────────────────── + +async function authenticate(username, password) { + var req = buildRequest("/api/1/oauth/access_token", { + x_auth_username: username, + x_auth_password: password, + x_auth_mode: "client_auth", + }); + + var text = await req.loadString(); + var parts = {}; + text.split("&").forEach(function (pair) { + var kv = pair.split("="); + parts[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]); + }); + + if (!parts.oauth_token) { + throw new Error("Authentication failed — check your credentials."); + } + + return { token: parts.oauth_token, secret: parts.oauth_token_secret }; +} + +// ──────────────────────────────────────────────────── +// Bookmarks +// ──────────────────────────────────────────────────── + +async function fetchUnreadBookmarks(token, tokenSecret) { + var req = buildRequest( + "/api/1/bookmarks/list", + { folder_id: "unread", limit: "500" }, + token, + tokenSecret + ); + + var json = await req.loadJSON(); + + // The bookmarks/list endpoint returns { bookmarks: [...] }. + // Fall back to filtering an array response by type, just in case. + if (json && json.bookmarks) return json.bookmarks; + if (Array.isArray(json)) + return json.filter(function (o) { + return o.type === "bookmark"; + }); + return []; +} + +// ──────────────────────────────────────────────────── +// Local cache (FileManager) +// ──────────────────────────────────────────────────── + +function cachePath() { + var fm = FileManager.local(); + return fm.joinPath(fm.documentsDirectory(), CACHE_FILE); +} + +function readCache() { + var fm = FileManager.local(); + var p = cachePath(); + if (!fm.fileExists(p)) return null; + try { + var data = JSON.parse(fm.readString(p)); + if (Date.now() - data.ts < CACHE_MINUTES * 60 * 1000) return data; + } catch (e) { + /* ignore corrupt cache */ + } + return null; +} + +function readStaleCache() { + var fm = FileManager.local(); + var p = cachePath(); + if (!fm.fileExists(p)) return null; + try { + return JSON.parse(fm.readString(p)); + } catch (e) { + return null; + } +} + +function writeCache(bookmarks) { + var fm = FileManager.local(); + fm.writeString( + cachePath(), + JSON.stringify({ ts: Date.now(), bookmarks: bookmarks }) + ); +} + +// ──────────────────────────────────────────────────── +// Credential helpers (Keychain) +// ──────────────────────────────────────────────────── + +function hasCredentials() { + return Keychain.contains(KC_TOKEN) && Keychain.contains(KC_SECRET); +} + +function getCredentials() { + return { token: Keychain.get(KC_TOKEN), secret: Keychain.get(KC_SECRET) }; +} + +function saveCredentials(token, secret) { + Keychain.set(KC_TOKEN, token); + Keychain.set(KC_SECRET, secret); +} + +// ──────────────────────────────────────────────────── +// Widget rendering +// ──────────────────────────────────────────────────── + +function formatTime(minutes) { + var h = Math.floor(minutes / 60); + var m = Math.round(minutes % 60); + if (h > 0) return h + "h " + m + "m"; + return m + " min"; +} + +function pickRandom(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +async function createWidget(bookmarks) { + var count = bookmarks.length; + var totalMin = (count * AVG_WORDS_PER_ARTICLE) / WORDS_PER_MINUTE; + + var w = new ListWidget(); + + // ── Colours ── + var bg = new Color("#1a1a2e"); + var accent = new Color("#64ffda"); + var muted = new Color("#8892b0"); + var warm = new Color("#e6c07b"); + + w.backgroundColor = bg; + w.setPadding(14, 14, 14, 14); + + // ── Header ── + var hdr = w.addText("INSTAPAPER"); + hdr.font = Font.boldSystemFont(11); + hdr.textColor = accent; + + w.addSpacer(4); + + // ── Article count ── + var num = w.addText(String(count)); + num.font = Font.boldSystemFont(42); + num.textColor = Color.white(); + num.minimumScaleFactor = 0.5; + + var lbl = w.addText(count === 1 ? "unread article" : "unread articles"); + lbl.font = Font.regularSystemFont(13); + lbl.textColor = muted; + + w.addSpacer(6); + + // ── Reading-time estimate ── + var timeText = w.addText("\u{23F1} ~" + formatTime(totalMin)); + timeText.font = Font.mediumSystemFont(14); + timeText.textColor = warm; + + w.addSpacer(null); // push remaining content to bottom + + // ── Random-article prompt ── + if (count > 0) { + var random = pickRandom(bookmarks); + + // Show title preview on medium / large widgets. + var family = config.widgetFamily; + if (family === "medium" || family === "large") { + var title = random.title || "Untitled"; + if (title.length > 60) title = title.substring(0, 57) + "..."; + var preview = w.addText(title); + preview.font = Font.regularSystemFont(11); + preview.textColor = Color.white(); + preview.lineLimit = 2; + w.addSpacer(2); + } + + var hint = w.addText("Tap to open a random article"); + hint.font = Font.italicSystemFont(10); + hint.textColor = muted; + hint.textOpacity = 0.7; + + // Universal link — opens in the Instapaper app when installed. + w.url = "https://www.instapaper.com/read/" + random.bookmark_id; + } else { + var empty = w.addText("Queue is empty!"); + empty.font = Font.italicSystemFont(11); + empty.textColor = accent; + w.url = "https://www.instapaper.com"; + } + + return w; +} + +function createErrorWidget(message) { + var w = new ListWidget(); + w.backgroundColor = new Color("#1a1a2e"); + w.setPadding(14, 14, 14, 14); + + var t = w.addText("INSTAPAPER"); + t.font = Font.boldSystemFont(11); + t.textColor = new Color("#ff6b6b"); + + w.addSpacer(8); + + var e = w.addText(message); + e.font = Font.regularSystemFont(12); + e.textColor = Color.white(); + + w.url = "https://www.instapaper.com"; + return w; +} + +// ──────────────────────────────────────────────────── +// Entry point +// ──────────────────────────────────────────────────── + +async function main() { + // --- Running inside the Scriptable app (not as a widget) --- + if (!config.runsInWidget) { + // Prompt for login if we have no stored token yet. + if (!hasCredentials()) { + var alert = new Alert(); + alert.title = "Instapaper Login"; + alert.message = + "Enter your Instapaper credentials.\n" + + "They are exchanged for an OAuth token and never stored."; + alert.addTextField("Email"); + alert.addSecureTextField("Password"); + alert.addAction("Log In"); + alert.addCancelAction("Cancel"); + + var idx = await alert.presentAlert(); + if (idx === -1) return; + + try { + var creds = await authenticate( + alert.textFieldValue(0), + alert.textFieldValue(1) + ); + saveCredentials(creds.token, creds.secret); + + var ok = new Alert(); + ok.title = "Authenticated"; + ok.message = + "You're all set. Add a Scriptable widget to your " + + "home screen and select this script."; + ok.addAction("OK"); + await ok.presentAlert(); + } catch (e) { + var fail = new Alert(); + fail.title = "Login Failed"; + fail.message = String(e.message || e); + fail.addAction("OK"); + await fail.presentAlert(); + return; + } + } + + // Show a preview of the widget. + if (hasCredentials()) { + var cred = getCredentials(); + var bookmarks; + try { + bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); + writeCache(bookmarks); + } catch (e) { + var stale = readStaleCache(); + bookmarks = stale ? stale.bookmarks : []; + } + var preview = await createWidget(bookmarks); + preview.presentSmall(); + } + return; + } + + // --- Running as a home-screen widget --- + + if (!hasCredentials()) { + Script.setWidget(createErrorWidget("Open Scriptable to log in")); + Script.complete(); + return; + } + + var cred = getCredentials(); + var bookmarks; + + // Prefer a fresh cache to avoid unnecessary API calls. + var cached = readCache(); + if (cached) { + bookmarks = cached.bookmarks; + } else { + try { + bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); + writeCache(bookmarks); + } catch (e) { + var stale = readStaleCache(); + if (stale) { + bookmarks = stale.bookmarks; + } else { + Script.setWidget(createErrorWidget("Network error")); + Script.complete(); + return; + } + } + } + + var widget = await createWidget(bookmarks); + Script.setWidget(widget); + Script.complete(); +} + +await main(); From 72dc66b3941e934eb1f3a76f0b9004f3e08c1c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 08:10:43 +0000 Subject: [PATCH 2/5] Redesign widget: featured article card with image + stats bar The widget now shows a random unread article with its Open Graph image and title. A horizontal separator at the bottom divides the article card from a stats bar showing total unread count and estimated reading time. Supports small (vertical) and medium/large (horizontal) widget layouts. Article images are fetched from og:image meta tags and cached locally alongside the featured article selection. https://claude.ai/code/session_01QsCcdu4bFzd1xy4NQ1oWYb --- instapaper-widget.js | 319 +++++++++++++++++++++++++++++++------------ 1 file changed, 235 insertions(+), 84 deletions(-) diff --git a/instapaper-widget.js b/instapaper-widget.js index d40ce63..628e4e2 100644 --- a/instapaper-widget.js +++ b/instapaper-widget.js @@ -5,15 +5,17 @@ /** * Instapaper Reading Queue — Scriptable Widget * - * Shows your unread article count and estimated reading time. - * Tapping the widget opens a random unread article in Instapaper. + * Displays a random unread article (image + title). Tapping the + * widget opens that article in Instapaper. A separator at the + * bottom shows how many unread articles remain and the estimated + * total reading time. * * SETUP * ----- * 1. Request an API consumer key at: * https://www.instapaper.com/main/request_oauth_consumer_token * 2. Once approved, paste your key and secret into the CONFIG section below. - * 3. Run this script inside the Scriptable app to log in with your + * 3. Run this script once inside the Scriptable app to log in with your * Instapaper email & password (credentials are exchanged for an OAuth * token via xAuth and are never stored). * 4. Add a Small or Medium Scriptable widget to your home screen and @@ -39,8 +41,9 @@ const CACHE_MINUTES = 15; const KC_TOKEN = "instapaper_oauth_token"; const KC_SECRET = "instapaper_oauth_secret"; -// Local cache filename. -const CACHE_FILE = "instapaper_cache.json"; +// Local cache filenames. +const CACHE_FILE = "instapaper_cache.json"; +const FEATURED_IMG = "instapaper_featured.png"; // ──────────────────────────────────────────────────── // SHA-1 (pure JavaScript — no native crypto needed) @@ -215,7 +218,6 @@ function buildRequest(endpoint, bodyParams, token, tokenSecret) { }; if (token) oa.oauth_token = token; - // Signature is computed over *all* params (OAuth + body). var all = Object.assign({}, oa, bodyParams || {}); oa.oauth_signature = oauthSign( "POST", @@ -282,8 +284,6 @@ async function fetchUnreadBookmarks(token, tokenSecret) { var json = await req.loadJSON(); - // The bookmarks/list endpoint returns { bookmarks: [...] }. - // Fall back to filtering an array response by type, just in case. if (json && json.bookmarks) return json.bookmarks; if (Array.isArray(json)) return json.filter(function (o) { @@ -292,47 +292,94 @@ async function fetchUnreadBookmarks(token, tokenSecret) { return []; } +// ──────────────────────────────────────────────────── +// Article image (Open Graph) +// ──────────────────────────────────────────────────── + +async function fetchArticleImage(articleUrl) { + try { + var req = new Request(articleUrl); + req.timeoutInterval = 8; + var html = await req.loadString(); + + // Try property-first, then content-first ordering of the og:image tag. + var match = html.match( + /]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i + ); + if (!match) { + match = html.match( + /]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i + ); + } + + if (match && match[1]) { + var imgUrl = match[1]; + if (imgUrl.startsWith("//")) imgUrl = "https:" + imgUrl; + var imgReq = new Request(imgUrl); + imgReq.timeoutInterval = 8; + return await imgReq.loadImage(); + } + } catch (e) { + // Image fetch is best-effort; widget works without it. + } + return null; +} + // ──────────────────────────────────────────────────── // Local cache (FileManager) // ──────────────────────────────────────────────────── +function fm() { + return FileManager.local(); +} + function cachePath() { - var fm = FileManager.local(); - return fm.joinPath(fm.documentsDirectory(), CACHE_FILE); + return fm().joinPath(fm().documentsDirectory(), CACHE_FILE); +} + +function featuredImagePath() { + return fm().joinPath(fm().documentsDirectory(), FEATURED_IMG); } function readCache() { - var fm = FileManager.local(); var p = cachePath(); - if (!fm.fileExists(p)) return null; + if (!fm().fileExists(p)) return null; try { - var data = JSON.parse(fm.readString(p)); + var data = JSON.parse(fm().readString(p)); if (Date.now() - data.ts < CACHE_MINUTES * 60 * 1000) return data; } catch (e) { - /* ignore corrupt cache */ + /* corrupt */ } return null; } function readStaleCache() { - var fm = FileManager.local(); var p = cachePath(); - if (!fm.fileExists(p)) return null; + if (!fm().fileExists(p)) return null; try { - return JSON.parse(fm.readString(p)); + return JSON.parse(fm().readString(p)); } catch (e) { return null; } } -function writeCache(bookmarks) { - var fm = FileManager.local(); - fm.writeString( +function writeCache(bookmarks, featuredId) { + fm().writeString( cachePath(), - JSON.stringify({ ts: Date.now(), bookmarks: bookmarks }) + JSON.stringify({ ts: Date.now(), bookmarks: bookmarks, featuredId: featuredId }) ); } +function saveFeaturedImage(img) { + if (img) fm().writeImage(featuredImagePath(), img); +} + +function loadFeaturedImage() { + var p = featuredImagePath(); + if (fm().fileExists(p)) return fm().readImage(p); + return null; +} + // ──────────────────────────────────────────────────── // Credential helpers (Keychain) // ──────────────────────────────────────────────────── @@ -350,6 +397,36 @@ function saveCredentials(token, secret) { Keychain.set(KC_SECRET, secret); } +// ──────────────────────────────────────────────────── +// Drawing helpers +// ──────────────────────────────────────────────────── + +function createSeparatorImage() { + var ctx = new DrawContext(); + ctx.size = new Size(500, 1); + ctx.opaque = false; + ctx.setFillColor(new Color("#ffffff", 0.2)); + ctx.fillRect(new Rect(0, 0, 500, 1)); + return ctx.getImage(); +} + +function createPlaceholderImage() { + var ctx = new DrawContext(); + ctx.size = new Size(200, 120); + ctx.opaque = false; + + // Gradient-ish solid placeholder. + ctx.setFillColor(new Color("#2d2d5e")); + ctx.fillRect(new Rect(0, 0, 200, 120)); + + // Book icon in the centre. + ctx.setFont(Font.regularSystemFont(36)); + ctx.setTextColor(new Color("#64ffda", 0.4)); + ctx.drawTextInRect("\u{1F4D6}", new Rect(72, 36, 60, 48)); + + return ctx.getImage(); +} + // ──────────────────────────────────────────────────── // Widget rendering // ──────────────────────────────────────────────────── @@ -365,76 +442,113 @@ function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; } -async function createWidget(bookmarks) { +function createWidget(bookmarks, featured, featuredImg) { var count = bookmarks.length; var totalMin = (count * AVG_WORDS_PER_ARTICLE) / WORDS_PER_MINUTE; - var w = new ListWidget(); - - // ── Colours ── - var bg = new Color("#1a1a2e"); + var bg = new Color("#1a1a2e"); var accent = new Color("#64ffda"); - var muted = new Color("#8892b0"); - var warm = new Color("#e6c07b"); + var muted = new Color("#8892b0"); + var warm = new Color("#e6c07b"); + var w = new ListWidget(); w.backgroundColor = bg; - w.setPadding(14, 14, 14, 14); + w.setPadding(12, 12, 10, 12); + + // ── Empty state ── + if (count === 0 || !featured) { + var hdr = w.addText("INSTAPAPER"); + hdr.font = Font.boldSystemFont(11); + hdr.textColor = accent; + w.addSpacer(null); + var empty = w.addText("No unread articles"); + empty.font = Font.italicSystemFont(13); + empty.textColor = Color.white(); + w.addSpacer(null); + w.url = "https://www.instapaper.com"; + return w; + } - // ── Header ── - var hdr = w.addText("INSTAPAPER"); - hdr.font = Font.boldSystemFont(11); - hdr.textColor = accent; + // ── Determine layout by widget size ── + var family = config.widgetFamily || "small"; + var img = featuredImg || createPlaceholderImage(); + var title = featured.title || "Untitled"; + + if (family === "medium" || family === "large") { + // ── MEDIUM / LARGE: image on left, text on right ── + var topStack = w.addStack(); + topStack.layoutHorizontally(); + topStack.spacing = 10; + + var imgEl = topStack.addImage(img); + imgEl.imageSize = new Size(85, 85); + imgEl.cornerRadius = 8; + imgEl.applyFillingContentMode(); + + var textStack = topStack.addStack(); + textStack.layoutVertically(); + textStack.spacing = 4; + + var brandEl = textStack.addText("INSTAPAPER"); + brandEl.font = Font.boldSystemFont(9); + brandEl.textColor = accent; + + var titleEl = textStack.addText(title); + titleEl.font = Font.semiboldSystemFont(14); + titleEl.textColor = Color.white(); + titleEl.lineLimit = 3; + + if (featured.description) { + var descEl = textStack.addText(featured.description); + descEl.font = Font.regularSystemFont(11); + descEl.textColor = muted; + descEl.lineLimit = 2; + } - w.addSpacer(4); + textStack.addSpacer(null); + } else { + // ── SMALL: image on top, title below ── + var imgEl = w.addImage(img); + imgEl.cornerRadius = 8; + imgEl.applyFillingContentMode(); + imgEl.imageSize = new Size(0, 72); + + w.addSpacer(6); + + var titleEl = w.addText(title); + titleEl.font = Font.semiboldSystemFont(12); + titleEl.textColor = Color.white(); + titleEl.lineLimit = 2; + titleEl.minimumScaleFactor = 0.8; + } - // ── Article count ── - var num = w.addText(String(count)); - num.font = Font.boldSystemFont(42); - num.textColor = Color.white(); - num.minimumScaleFactor = 0.5; + w.addSpacer(null); - var lbl = w.addText(count === 1 ? "unread article" : "unread articles"); - lbl.font = Font.regularSystemFont(13); - lbl.textColor = muted; + // ── Separator ── + var sepEl = w.addImage(createSeparatorImage()); + sepEl.imageSize = new Size(0, 1); + sepEl.imageOpacity = 1; w.addSpacer(6); - // ── Reading-time estimate ── - var timeText = w.addText("\u{23F1} ~" + formatTime(totalMin)); - timeText.font = Font.mediumSystemFont(14); - timeText.textColor = warm; - - w.addSpacer(null); // push remaining content to bottom - - // ── Random-article prompt ── - if (count > 0) { - var random = pickRandom(bookmarks); - - // Show title preview on medium / large widgets. - var family = config.widgetFamily; - if (family === "medium" || family === "large") { - var title = random.title || "Untitled"; - if (title.length > 60) title = title.substring(0, 57) + "..."; - var preview = w.addText(title); - preview.font = Font.regularSystemFont(11); - preview.textColor = Color.white(); - preview.lineLimit = 2; - w.addSpacer(2); - } + // ── Stats bar ── + var statsStack = w.addStack(); + statsStack.layoutHorizontally(); + statsStack.centerAlignContent(); - var hint = w.addText("Tap to open a random article"); - hint.font = Font.italicSystemFont(10); - hint.textColor = muted; - hint.textOpacity = 0.7; + var countStr = String(count) + (count === 1 ? " article" : " articles"); + var cEl = statsStack.addText(countStr); + cEl.font = Font.mediumSystemFont(11); + cEl.textColor = muted; - // Universal link — opens in the Instapaper app when installed. - w.url = "https://www.instapaper.com/read/" + random.bookmark_id; - } else { - var empty = w.addText("Queue is empty!"); - empty.font = Font.italicSystemFont(11); - empty.textColor = accent; - w.url = "https://www.instapaper.com"; - } + statsStack.addSpacer(null); + + var tEl = statsStack.addText("\u{23F1} ~" + formatTime(totalMin)); + tEl.font = Font.mediumSystemFont(11); + tEl.textColor = warm; + + // ── Tap action → open the featured article ── + w.url = "https://www.instapaper.com/read/" + featured.bookmark_id; return w; } @@ -462,10 +576,32 @@ function createErrorWidget(message) { // Entry point // ──────────────────────────────────────────────────── +async function loadFeaturedData(bookmarks, cachedFeaturedId) { + if (bookmarks.length === 0) return { article: null, image: null }; + + // Re-use the cached featured article when it is still in the list. + var article; + if (cachedFeaturedId) { + article = bookmarks.find(function (b) { + return String(b.bookmark_id) === String(cachedFeaturedId); + }); + } + + if (article) { + // Cache hit — reuse locally-stored image. + return { article: article, image: loadFeaturedImage() }; + } + + // Pick a new random article and fetch its image. + article = pickRandom(bookmarks); + var img = await fetchArticleImage(article.url); + saveFeaturedImage(img); + return { article: article, image: img }; +} + async function main() { // --- Running inside the Scriptable app (not as a widget) --- if (!config.runsInWidget) { - // Prompt for login if we have no stored token yet. if (!hasCredentials()) { var alert = new Alert(); alert.title = "Instapaper Login"; @@ -504,19 +640,22 @@ async function main() { } } - // Show a preview of the widget. if (hasCredentials()) { var cred = getCredentials(); var bookmarks; try { bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); - writeCache(bookmarks); } catch (e) { var stale = readStaleCache(); bookmarks = stale ? stale.bookmarks : []; } - var preview = await createWidget(bookmarks); - preview.presentSmall(); + + var feat = await loadFeaturedData(bookmarks, null); + if (feat.article) writeCache(bookmarks, feat.article.bookmark_id); + else writeCache(bookmarks, null); + + var preview = createWidget(bookmarks, feat.article, feat.image); + preview.presentMedium(); } return; } @@ -531,19 +670,20 @@ async function main() { var cred = getCredentials(); var bookmarks; + var cachedFeaturedId = null; - // Prefer a fresh cache to avoid unnecessary API calls. var cached = readCache(); if (cached) { bookmarks = cached.bookmarks; + cachedFeaturedId = cached.featuredId; } else { try { bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); - writeCache(bookmarks); } catch (e) { var stale = readStaleCache(); if (stale) { bookmarks = stale.bookmarks; + cachedFeaturedId = stale.featuredId; } else { Script.setWidget(createErrorWidget("Network error")); Script.complete(); @@ -552,7 +692,18 @@ async function main() { } } - var widget = await createWidget(bookmarks); + var feat = await loadFeaturedData(bookmarks, cachedFeaturedId); + + // Persist cache only when we fetched fresh data (no cachedFeaturedId from + // a fresh cache hit means we already have valid cache). + if (!cached) { + writeCache( + bookmarks, + feat.article ? feat.article.bookmark_id : null + ); + } + + var widget = createWidget(bookmarks, feat.article, feat.image); Script.setWidget(widget); Script.complete(); } From 44a64f09540b7bd8a5f480dc5f50ff5a6cd59af8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 08:20:46 +0000 Subject: [PATCH 3/5] Rewrite widget to use iOS Shortcuts instead of the Instapaper API Replaces the full OAuth 1.0a / HMAC-SHA1 implementation with a much simpler Shortcuts-based data pipeline. An iOS Shortcut uses Instapaper's native "Get Instapaper Bookmarks" action to fetch articles, then passes the data to this script via Scriptable's "Run Script" Shortcuts action. The script caches the data locally for the widget to render. This eliminates the need for API consumer keys, OAuth credentials, and ~400 lines of crypto code. Requires Instapaper Premium for the Shortcuts action. https://claude.ai/code/session_01QsCcdu4bFzd1xy4NQ1oWYb --- instapaper-widget.js | 616 +++++++++++-------------------------------- 1 file changed, 151 insertions(+), 465 deletions(-) diff --git a/instapaper-widget.js b/instapaper-widget.js index 628e4e2..02dea80 100644 --- a/instapaper-widget.js +++ b/instapaper-widget.js @@ -4,292 +4,117 @@ /** * Instapaper Reading Queue — Scriptable Widget + * (Shortcuts Edition) * * Displays a random unread article (image + title). Tapping the - * widget opens that article in Instapaper. A separator at the - * bottom shows how many unread articles remain and the estimated - * total reading time. + * widget opens that article. A separator at the bottom shows the + * remaining unread count and estimated total reading time. * - * SETUP - * ----- - * 1. Request an API consumer key at: - * https://www.instapaper.com/main/request_oauth_consumer_token - * 2. Once approved, paste your key and secret into the CONFIG section below. - * 3. Run this script once inside the Scriptable app to log in with your - * Instapaper email & password (credentials are exchanged for an OAuth - * token via xAuth and are never stored). - * 4. Add a Small or Medium Scriptable widget to your home screen and - * pick this script. + * Data is fetched by an iOS Shortcut using Instapaper's native + * Shortcuts actions — no API keys, no OAuth, no credentials. + * + * ─── SETUP ─────────────────────────────────────────── + * + * 1. Copy this script into the Scriptable app and name it + * "Instapaper Widget". + * + * 2. Create the companion Shortcut in the iOS Shortcuts app + * with the following actions (in order): + * + * ┌─────────────────────────────────────────────────┐ + * │ 1. Get Instapaper Bookmarks │ + * │ Folder: Unread │ + * │ │ + * │ 2. Count │ + * │ Input: Instapaper Bookmarks │ + * │ → Save to variable "articleCount" │ + * │ │ + * │ 3. Get Item from List │ + * │ Get: Random Item │ + * │ from: Instapaper Bookmarks │ + * │ → Save to variable "randomArticle" │ + * │ │ + * │ 4. Get Name of │ + * │ Input: randomArticle │ + * │ → Save to variable "articleTitle" │ + * │ │ + * │ 5. Get URLs from │ + * │ Input: randomArticle │ + * │ → Save to variable "articleURL" │ + * │ │ + * │ 6. Dictionary │ + * │ count → articleCount │ + * │ title → articleTitle │ + * │ url → articleURL │ + * │ │ + * │ 7. Run Script (Scriptable) │ + * │ Script: Instapaper Widget │ + * │ Input : Dictionary │ + * └─────────────────────────────────────────────────┘ + * + * 3. (Optional) Automate it: Shortcuts → Automation → + * Time of Day → pick an interval → run the shortcut. + * This keeps the widget data fresh automatically. + * + * 4. Add a Small or Medium Scriptable widget to your + * home screen and select "Instapaper Widget". + * + * NOTE: "Get Instapaper Bookmarks" requires Instapaper Premium. + * ───────────────────────────────────────────────────── */ // ====================== CONFIG ====================== -const CONSUMER_KEY = "YOUR_CONSUMER_KEY"; -const CONSUMER_SECRET = "YOUR_CONSUMER_SECRET"; - // Average word count assumed per article (used for time estimate -// since the Instapaper API does not return word counts). +// since Instapaper does not expose word counts). const AVG_WORDS_PER_ARTICLE = 1200; // Average adult reading speed in words per minute. const WORDS_PER_MINUTE = 238; - -// Minutes before the local bookmark cache is considered stale. -const CACHE_MINUTES = 15; // ==================================================== -// Keychain keys for the stored OAuth token pair. -const KC_TOKEN = "instapaper_oauth_token"; -const KC_SECRET = "instapaper_oauth_secret"; - -// Local cache filenames. -const CACHE_FILE = "instapaper_cache.json"; -const FEATURED_IMG = "instapaper_featured.png"; +const CACHE_FILE = "instapaper_data.json"; +const IMAGE_FILE = "instapaper_featured.png"; // ──────────────────────────────────────────────────── -// SHA-1 (pure JavaScript — no native crypto needed) +// Local cache (FileManager.local — always available, +// no iCloud download required) // ──────────────────────────────────────────────────── -function sha1Bytes(msg) { - function rotl(n, s) { - return (n << s) | (n >>> (32 - s)); - } - - var words = []; - for (var i = 0; i < msg.length; i++) { - words[i >> 2] = (words[i >> 2] || 0) | (msg[i] << (24 - (i % 4) * 8)); - } - - var bitLen = msg.length * 8; - var pi = msg.length >> 2; - words[pi] = (words[pi] || 0) | (0x80 << (24 - (msg.length % 4) * 8)); - var total = ((msg.length + 8) >> 6 << 4) + 16; - while (words.length < total) words.push(0); - words[total - 1] = bitLen; - - var H = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; - - for (var b = 0; b < words.length; b += 16) { - var W = []; - for (var t = 0; t < 16; t++) W[t] = words[b + t] || 0; - for (var t = 16; t < 80; t++) - W[t] = rotl(W[t - 3] ^ W[t - 8] ^ W[t - 14] ^ W[t - 16], 1); - - var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4]; - - for (var t = 0; t < 80; t++) { - var f, k; - if (t < 20) { - f = (bb & c) | (~bb & d); - k = 0x5a827999; - } else if (t < 40) { - f = bb ^ c ^ d; - k = 0x6ed9eba1; - } else if (t < 60) { - f = (bb & c) | (bb & d) | (c & d); - k = 0x8f1bbcdc; - } else { - f = bb ^ c ^ d; - k = 0xca62c1d6; - } - var tmp = (rotl(a, 5) + f + e + k + W[t]) & 0xffffffff; - e = d; - d = c; - c = rotl(bb, 30); - bb = a; - a = tmp; - } - - H[0] = (H[0] + a) & 0xffffffff; - H[1] = (H[1] + bb) & 0xffffffff; - H[2] = (H[2] + c) & 0xffffffff; - H[3] = (H[3] + d) & 0xffffffff; - H[4] = (H[4] + e) & 0xffffffff; - } - - var out = []; - for (var i = 0; i < 5; i++) { - out.push((H[i] >>> 24) & 0xff); - out.push((H[i] >>> 16) & 0xff); - out.push((H[i] >>> 8) & 0xff); - out.push(H[i] & 0xff); - } - return out; -} - -// ──────────────────────────────────────────────────── -// HMAC-SHA1 -// ──────────────────────────────────────────────────── - -function strToBytes(s) { - var b = []; - for (var i = 0; i < s.length; i++) b.push(s.charCodeAt(i) & 0xff); - return b; -} - -function bytesToBase64(bytes) { - var T = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - var r = ""; - for (var i = 0; i < bytes.length; i += 3) { - var b1 = bytes[i], - b2 = bytes[i + 1] || 0, - b3 = bytes[i + 2] || 0; - r += T[(b1 >> 2) & 0x3f]; - r += T[((b1 & 3) << 4) | ((b2 >> 4) & 0xf)]; - r += i + 1 < bytes.length ? T[((b2 & 0xf) << 2) | ((b3 >> 6) & 3)] : "="; - r += i + 2 < bytes.length ? T[b3 & 0x3f] : "="; - } - return r; -} - -function hmacSha1(key, message) { - var kb = strToBytes(key); - var mb = strToBytes(message); - if (kb.length > 64) kb = sha1Bytes(kb); - while (kb.length < 64) kb.push(0); - - var ipad = [], - opad = []; - for (var i = 0; i < 64; i++) { - ipad.push(kb[i] ^ 0x36); - opad.push(kb[i] ^ 0x5c); - } - - return bytesToBase64(sha1Bytes(opad.concat(sha1Bytes(ipad.concat(mb))))); -} - -// ──────────────────────────────────────────────────── -// OAuth 1.0a helpers -// ──────────────────────────────────────────────────── - -function pctEnc(s) { - return encodeURIComponent(s).replace(/[!'()*]/g, function (c) { - return "%" + c.charCodeAt(0).toString(16).toUpperCase(); - }); +function lfm() { + return FileManager.local(); } -function oauthNonce() { - var c = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - var n = ""; - for (var i = 0; i < 32; i++) - n += c[Math.floor(Math.random() * c.length)]; - return n; +function cachePath() { + return lfm().joinPath(lfm().documentsDirectory(), CACHE_FILE); } -function oauthSign(method, url, params, consumerSecret, tokenSecret) { - var sorted = Object.keys(params) - .sort() - .map(function (k) { - return pctEnc(k) + "=" + pctEnc(params[k]); - }) - .join("&"); - - var base = method + "&" + pctEnc(url) + "&" + pctEnc(sorted); - var key = pctEnc(consumerSecret) + "&" + pctEnc(tokenSecret || ""); - return hmacSha1(key, base); +function imagePath() { + return lfm().joinPath(lfm().documentsDirectory(), IMAGE_FILE); } -function oauthHeader(params) { - return ( - "OAuth " + - Object.keys(params) - .sort() - .map(function (k) { - return pctEnc(k) + '="' + pctEnc(params[k]) + '"'; - }) - .join(", ") - ); +function writeCache(data) { + data.ts = Date.now(); + lfm().writeString(cachePath(), JSON.stringify(data)); } -// ──────────────────────────────────────────────────── -// Instapaper API transport -// ──────────────────────────────────────────────────── - -function buildRequest(endpoint, bodyParams, token, tokenSecret) { - var url = "https://www.instapaper.com" + endpoint; - - var oa = { - oauth_consumer_key: CONSUMER_KEY, - oauth_nonce: oauthNonce(), - oauth_signature_method: "HMAC-SHA1", - oauth_timestamp: String(Math.floor(Date.now() / 1000)), - oauth_version: "1.0", - }; - if (token) oa.oauth_token = token; - - var all = Object.assign({}, oa, bodyParams || {}); - oa.oauth_signature = oauthSign( - "POST", - url, - all, - CONSUMER_SECRET, - tokenSecret || "" - ); - - var req = new Request(url); - req.method = "POST"; - req.headers = { - Authorization: oauthHeader(oa), - "Content-Type": "application/x-www-form-urlencoded", - }; - - if (bodyParams && Object.keys(bodyParams).length) { - req.body = Object.keys(bodyParams) - .map(function (k) { - return encodeURIComponent(k) + "=" + encodeURIComponent(bodyParams[k]); - }) - .join("&"); +function readCache() { + var p = cachePath(); + if (!lfm().fileExists(p)) return null; + try { + return JSON.parse(lfm().readString(p)); + } catch (e) { + return null; } - - return req; } -// ──────────────────────────────────────────────────── -// Authentication (xAuth → OAuth token) -// ──────────────────────────────────────────────────── - -async function authenticate(username, password) { - var req = buildRequest("/api/1/oauth/access_token", { - x_auth_username: username, - x_auth_password: password, - x_auth_mode: "client_auth", - }); - - var text = await req.loadString(); - var parts = {}; - text.split("&").forEach(function (pair) { - var kv = pair.split("="); - parts[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]); - }); - - if (!parts.oauth_token) { - throw new Error("Authentication failed — check your credentials."); - } - - return { token: parts.oauth_token, secret: parts.oauth_token_secret }; +function saveFeaturedImage(img) { + if (img) lfm().writeImage(imagePath(), img); } -// ──────────────────────────────────────────────────── -// Bookmarks -// ──────────────────────────────────────────────────── - -async function fetchUnreadBookmarks(token, tokenSecret) { - var req = buildRequest( - "/api/1/bookmarks/list", - { folder_id: "unread", limit: "500" }, - token, - tokenSecret - ); - - var json = await req.loadJSON(); - - if (json && json.bookmarks) return json.bookmarks; - if (Array.isArray(json)) - return json.filter(function (o) { - return o.type === "bookmark"; - }); - return []; +function loadFeaturedImage() { + var p = imagePath(); + if (lfm().fileExists(p)) return lfm().readImage(p); + return null; } // ──────────────────────────────────────────────────── @@ -302,7 +127,6 @@ async function fetchArticleImage(articleUrl) { req.timeoutInterval = 8; var html = await req.loadString(); - // Try property-first, then content-first ordering of the og:image tag. var match = html.match( /]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i ); @@ -320,83 +144,11 @@ async function fetchArticleImage(articleUrl) { return await imgReq.loadImage(); } } catch (e) { - // Image fetch is best-effort; widget works without it. - } - return null; -} - -// ──────────────────────────────────────────────────── -// Local cache (FileManager) -// ──────────────────────────────────────────────────── - -function fm() { - return FileManager.local(); -} - -function cachePath() { - return fm().joinPath(fm().documentsDirectory(), CACHE_FILE); -} - -function featuredImagePath() { - return fm().joinPath(fm().documentsDirectory(), FEATURED_IMG); -} - -function readCache() { - var p = cachePath(); - if (!fm().fileExists(p)) return null; - try { - var data = JSON.parse(fm().readString(p)); - if (Date.now() - data.ts < CACHE_MINUTES * 60 * 1000) return data; - } catch (e) { - /* corrupt */ + // Best-effort; widget works fine without an image. } return null; } -function readStaleCache() { - var p = cachePath(); - if (!fm().fileExists(p)) return null; - try { - return JSON.parse(fm().readString(p)); - } catch (e) { - return null; - } -} - -function writeCache(bookmarks, featuredId) { - fm().writeString( - cachePath(), - JSON.stringify({ ts: Date.now(), bookmarks: bookmarks, featuredId: featuredId }) - ); -} - -function saveFeaturedImage(img) { - if (img) fm().writeImage(featuredImagePath(), img); -} - -function loadFeaturedImage() { - var p = featuredImagePath(); - if (fm().fileExists(p)) return fm().readImage(p); - return null; -} - -// ──────────────────────────────────────────────────── -// Credential helpers (Keychain) -// ──────────────────────────────────────────────────── - -function hasCredentials() { - return Keychain.contains(KC_TOKEN) && Keychain.contains(KC_SECRET); -} - -function getCredentials() { - return { token: Keychain.get(KC_TOKEN), secret: Keychain.get(KC_SECRET) }; -} - -function saveCredentials(token, secret) { - Keychain.set(KC_TOKEN, token); - Keychain.set(KC_SECRET, secret); -} - // ──────────────────────────────────────────────────── // Drawing helpers // ──────────────────────────────────────────────────── @@ -414,16 +166,11 @@ function createPlaceholderImage() { var ctx = new DrawContext(); ctx.size = new Size(200, 120); ctx.opaque = false; - - // Gradient-ish solid placeholder. ctx.setFillColor(new Color("#2d2d5e")); ctx.fillRect(new Rect(0, 0, 200, 120)); - - // Book icon in the centre. ctx.setFont(Font.regularSystemFont(36)); ctx.setTextColor(new Color("#64ffda", 0.4)); ctx.drawTextInRect("\u{1F4D6}", new Rect(72, 36, 60, 48)); - return ctx.getImage(); } @@ -438,12 +185,8 @@ function formatTime(minutes) { return m + " min"; } -function pickRandom(arr) { - return arr[Math.floor(Math.random() * arr.length)]; -} - -function createWidget(bookmarks, featured, featuredImg) { - var count = bookmarks.length; +function createWidget(data, featuredImg) { + var count = Number(data.count) || 0; var totalMin = (count * AVG_WORDS_PER_ARTICLE) / WORDS_PER_MINUTE; var bg = new Color("#1a1a2e"); @@ -456,7 +199,7 @@ function createWidget(bookmarks, featured, featuredImg) { w.setPadding(12, 12, 10, 12); // ── Empty state ── - if (count === 0 || !featured) { + if (count === 0) { var hdr = w.addText("INSTAPAPER"); hdr.font = Font.boldSystemFont(11); hdr.textColor = accent; @@ -469,13 +212,14 @@ function createWidget(bookmarks, featured, featuredImg) { return w; } + var img = featuredImg || createPlaceholderImage(); + var title = data.title || "Untitled"; + // ── Determine layout by widget size ── var family = config.widgetFamily || "small"; - var img = featuredImg || createPlaceholderImage(); - var title = featured.title || "Untitled"; if (family === "medium" || family === "large") { - // ── MEDIUM / LARGE: image on left, text on right ── + // ── MEDIUM / LARGE: image left, text right ── var topStack = w.addStack(); topStack.layoutHorizontally(); topStack.spacing = 10; @@ -498,13 +242,6 @@ function createWidget(bookmarks, featured, featuredImg) { titleEl.textColor = Color.white(); titleEl.lineLimit = 3; - if (featured.description) { - var descEl = textStack.addText(featured.description); - descEl.font = Font.regularSystemFont(11); - descEl.textColor = muted; - descEl.lineLimit = 2; - } - textStack.addSpacer(null); } else { // ── SMALL: image on top, title below ── @@ -548,7 +285,7 @@ function createWidget(bookmarks, featured, featuredImg) { tEl.textColor = warm; // ── Tap action → open the featured article ── - w.url = "https://www.instapaper.com/read/" + featured.bookmark_id; + if (data.url) w.url = data.url; return w; } @@ -573,139 +310,88 @@ function createErrorWidget(message) { } // ──────────────────────────────────────────────────── -// Entry point +// Image management // ──────────────────────────────────────────────────── -async function loadFeaturedData(bookmarks, cachedFeaturedId) { - if (bookmarks.length === 0) return { article: null, image: null }; - - // Re-use the cached featured article when it is still in the list. - var article; - if (cachedFeaturedId) { - article = bookmarks.find(function (b) { - return String(b.bookmark_id) === String(cachedFeaturedId); - }); - } - - if (article) { - // Cache hit — reuse locally-stored image. - return { article: article, image: loadFeaturedImage() }; +async function getImage(data) { + // Reuse the cached image if the article URL hasn't changed. + var cached = readCache(); + if (cached && cached.url === data.url) { + var img = loadFeaturedImage(); + if (img) return img; } - // Pick a new random article and fetch its image. - article = pickRandom(bookmarks); - var img = await fetchArticleImage(article.url); + // Fetch and cache the new image. + var img = await fetchArticleImage(data.url); saveFeaturedImage(img); - return { article: article, image: img }; + return img; } +// ──────────────────────────────────────────────────── +// Entry point +// ──────────────────────────────────────────────────── + async function main() { - // --- Running inside the Scriptable app (not as a widget) --- - if (!config.runsInWidget) { - if (!hasCredentials()) { - var alert = new Alert(); - alert.title = "Instapaper Login"; - alert.message = - "Enter your Instapaper credentials.\n" + - "They are exchanged for an OAuth token and never stored."; - alert.addTextField("Email"); - alert.addSecureTextField("Password"); - alert.addAction("Log In"); - alert.addCancelAction("Cancel"); - - var idx = await alert.presentAlert(); - if (idx === -1) return; - - try { - var creds = await authenticate( - alert.textFieldValue(0), - alert.textFieldValue(1) - ); - saveCredentials(creds.token, creds.secret); - - var ok = new Alert(); - ok.title = "Authenticated"; - ok.message = - "You're all set. Add a Scriptable widget to your " + - "home screen and select this script."; - ok.addAction("OK"); - await ok.presentAlert(); - } catch (e) { - var fail = new Alert(); - fail.title = "Login Failed"; - fail.message = String(e.message || e); - fail.addAction("OK"); - await fail.presentAlert(); - return; - } - } + // ── Called from the Shortcut → cache the data and exit ── + var input = args.shortcutParameter; + if (input && typeof input === "object") { + writeCache({ + count: input.count, + title: input.title, + url: String(input.url || ""), + }); - if (hasCredentials()) { - var cred = getCredentials(); - var bookmarks; - try { - bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); - } catch (e) { - var stale = readStaleCache(); - bookmarks = stale ? stale.bookmarks : []; - } - - var feat = await loadFeaturedData(bookmarks, null); - if (feat.article) writeCache(bookmarks, feat.article.bookmark_id); - else writeCache(bookmarks, null); - - var preview = createWidget(bookmarks, feat.article, feat.image); - preview.presentMedium(); + // Fetch the article image while we have full runtime access. + if (input.url) { + var img = await fetchArticleImage(String(input.url)); + saveFeaturedImage(img); } - return; - } - - // --- Running as a home-screen widget --- - if (!hasCredentials()) { - Script.setWidget(createErrorWidget("Open Scriptable to log in")); + Script.setShortcutOutput("OK"); Script.complete(); return; } - var cred = getCredentials(); - var bookmarks; - var cachedFeaturedId = null; + // ── Running as a home-screen widget ── + if (config.runsInWidget) { + var data = readCache(); + if (!data) { + Script.setWidget( + createErrorWidget("Run the companion\nShortcut first") + ); + Script.complete(); + return; + } - var cached = readCache(); - if (cached) { - bookmarks = cached.bookmarks; - cachedFeaturedId = cached.featuredId; - } else { - try { - bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); - } catch (e) { - var stale = readStaleCache(); - if (stale) { - bookmarks = stale.bookmarks; - cachedFeaturedId = stale.featuredId; - } else { - Script.setWidget(createErrorWidget("Network error")); - Script.complete(); - return; - } + var img = loadFeaturedImage(); + // If no cached image exists, try fetching (best effort within + // the widget's limited execution time). + if (!img && data.url) { + img = await fetchArticleImage(data.url); + saveFeaturedImage(img); } - } - var feat = await loadFeaturedData(bookmarks, cachedFeaturedId); + Script.setWidget(createWidget(data, img)); + Script.complete(); + return; + } - // Persist cache only when we fetched fresh data (no cachedFeaturedId from - // a fresh cache hit means we already have valid cache). - if (!cached) { - writeCache( - bookmarks, - feat.article ? feat.article.bookmark_id : null - ); + // ── Running inside the Scriptable app → show a preview ── + var data = readCache(); + if (!data) { + var a = new Alert(); + a.title = "No Data Yet"; + a.message = + "Run the companion Shortcut first so the widget " + + "has article data to display."; + a.addAction("OK"); + await a.presentAlert(); + return; } - var widget = createWidget(bookmarks, feat.article, feat.image); - Script.setWidget(widget); - Script.complete(); + var img = await getImage(data); + var widget = createWidget(data, img); + widget.presentMedium(); } await main(); From 05a8fa00ebe4f939617b7f1f5866dab283bb58b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 08:31:39 +0000 Subject: [PATCH 4/5] Revert "Rewrite widget to use iOS Shortcuts instead of the Instapaper API" This reverts commit 44a64f09540b7bd8a5f480dc5f50ff5a6cd59af8. --- instapaper-widget.js | 616 ++++++++++++++++++++++++++++++++----------- 1 file changed, 465 insertions(+), 151 deletions(-) diff --git a/instapaper-widget.js b/instapaper-widget.js index 02dea80..628e4e2 100644 --- a/instapaper-widget.js +++ b/instapaper-widget.js @@ -4,117 +4,292 @@ /** * Instapaper Reading Queue — Scriptable Widget - * (Shortcuts Edition) * * Displays a random unread article (image + title). Tapping the - * widget opens that article. A separator at the bottom shows the - * remaining unread count and estimated total reading time. + * widget opens that article in Instapaper. A separator at the + * bottom shows how many unread articles remain and the estimated + * total reading time. * - * Data is fetched by an iOS Shortcut using Instapaper's native - * Shortcuts actions — no API keys, no OAuth, no credentials. - * - * ─── SETUP ─────────────────────────────────────────── - * - * 1. Copy this script into the Scriptable app and name it - * "Instapaper Widget". - * - * 2. Create the companion Shortcut in the iOS Shortcuts app - * with the following actions (in order): - * - * ┌─────────────────────────────────────────────────┐ - * │ 1. Get Instapaper Bookmarks │ - * │ Folder: Unread │ - * │ │ - * │ 2. Count │ - * │ Input: Instapaper Bookmarks │ - * │ → Save to variable "articleCount" │ - * │ │ - * │ 3. Get Item from List │ - * │ Get: Random Item │ - * │ from: Instapaper Bookmarks │ - * │ → Save to variable "randomArticle" │ - * │ │ - * │ 4. Get Name of │ - * │ Input: randomArticle │ - * │ → Save to variable "articleTitle" │ - * │ │ - * │ 5. Get URLs from │ - * │ Input: randomArticle │ - * │ → Save to variable "articleURL" │ - * │ │ - * │ 6. Dictionary │ - * │ count → articleCount │ - * │ title → articleTitle │ - * │ url → articleURL │ - * │ │ - * │ 7. Run Script (Scriptable) │ - * │ Script: Instapaper Widget │ - * │ Input : Dictionary │ - * └─────────────────────────────────────────────────┘ - * - * 3. (Optional) Automate it: Shortcuts → Automation → - * Time of Day → pick an interval → run the shortcut. - * This keeps the widget data fresh automatically. - * - * 4. Add a Small or Medium Scriptable widget to your - * home screen and select "Instapaper Widget". - * - * NOTE: "Get Instapaper Bookmarks" requires Instapaper Premium. - * ───────────────────────────────────────────────────── + * SETUP + * ----- + * 1. Request an API consumer key at: + * https://www.instapaper.com/main/request_oauth_consumer_token + * 2. Once approved, paste your key and secret into the CONFIG section below. + * 3. Run this script once inside the Scriptable app to log in with your + * Instapaper email & password (credentials are exchanged for an OAuth + * token via xAuth and are never stored). + * 4. Add a Small or Medium Scriptable widget to your home screen and + * pick this script. */ // ====================== CONFIG ====================== +const CONSUMER_KEY = "YOUR_CONSUMER_KEY"; +const CONSUMER_SECRET = "YOUR_CONSUMER_SECRET"; + // Average word count assumed per article (used for time estimate -// since Instapaper does not expose word counts). +// since the Instapaper API does not return word counts). const AVG_WORDS_PER_ARTICLE = 1200; // Average adult reading speed in words per minute. const WORDS_PER_MINUTE = 238; + +// Minutes before the local bookmark cache is considered stale. +const CACHE_MINUTES = 15; // ==================================================== -const CACHE_FILE = "instapaper_data.json"; -const IMAGE_FILE = "instapaper_featured.png"; +// Keychain keys for the stored OAuth token pair. +const KC_TOKEN = "instapaper_oauth_token"; +const KC_SECRET = "instapaper_oauth_secret"; + +// Local cache filenames. +const CACHE_FILE = "instapaper_cache.json"; +const FEATURED_IMG = "instapaper_featured.png"; // ──────────────────────────────────────────────────── -// Local cache (FileManager.local — always available, -// no iCloud download required) +// SHA-1 (pure JavaScript — no native crypto needed) // ──────────────────────────────────────────────────── -function lfm() { - return FileManager.local(); +function sha1Bytes(msg) { + function rotl(n, s) { + return (n << s) | (n >>> (32 - s)); + } + + var words = []; + for (var i = 0; i < msg.length; i++) { + words[i >> 2] = (words[i >> 2] || 0) | (msg[i] << (24 - (i % 4) * 8)); + } + + var bitLen = msg.length * 8; + var pi = msg.length >> 2; + words[pi] = (words[pi] || 0) | (0x80 << (24 - (msg.length % 4) * 8)); + var total = ((msg.length + 8) >> 6 << 4) + 16; + while (words.length < total) words.push(0); + words[total - 1] = bitLen; + + var H = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; + + for (var b = 0; b < words.length; b += 16) { + var W = []; + for (var t = 0; t < 16; t++) W[t] = words[b + t] || 0; + for (var t = 16; t < 80; t++) + W[t] = rotl(W[t - 3] ^ W[t - 8] ^ W[t - 14] ^ W[t - 16], 1); + + var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4]; + + for (var t = 0; t < 80; t++) { + var f, k; + if (t < 20) { + f = (bb & c) | (~bb & d); + k = 0x5a827999; + } else if (t < 40) { + f = bb ^ c ^ d; + k = 0x6ed9eba1; + } else if (t < 60) { + f = (bb & c) | (bb & d) | (c & d); + k = 0x8f1bbcdc; + } else { + f = bb ^ c ^ d; + k = 0xca62c1d6; + } + var tmp = (rotl(a, 5) + f + e + k + W[t]) & 0xffffffff; + e = d; + d = c; + c = rotl(bb, 30); + bb = a; + a = tmp; + } + + H[0] = (H[0] + a) & 0xffffffff; + H[1] = (H[1] + bb) & 0xffffffff; + H[2] = (H[2] + c) & 0xffffffff; + H[3] = (H[3] + d) & 0xffffffff; + H[4] = (H[4] + e) & 0xffffffff; + } + + var out = []; + for (var i = 0; i < 5; i++) { + out.push((H[i] >>> 24) & 0xff); + out.push((H[i] >>> 16) & 0xff); + out.push((H[i] >>> 8) & 0xff); + out.push(H[i] & 0xff); + } + return out; } -function cachePath() { - return lfm().joinPath(lfm().documentsDirectory(), CACHE_FILE); +// ──────────────────────────────────────────────────── +// HMAC-SHA1 +// ──────────────────────────────────────────────────── + +function strToBytes(s) { + var b = []; + for (var i = 0; i < s.length; i++) b.push(s.charCodeAt(i) & 0xff); + return b; } -function imagePath() { - return lfm().joinPath(lfm().documentsDirectory(), IMAGE_FILE); +function bytesToBase64(bytes) { + var T = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var r = ""; + for (var i = 0; i < bytes.length; i += 3) { + var b1 = bytes[i], + b2 = bytes[i + 1] || 0, + b3 = bytes[i + 2] || 0; + r += T[(b1 >> 2) & 0x3f]; + r += T[((b1 & 3) << 4) | ((b2 >> 4) & 0xf)]; + r += i + 1 < bytes.length ? T[((b2 & 0xf) << 2) | ((b3 >> 6) & 3)] : "="; + r += i + 2 < bytes.length ? T[b3 & 0x3f] : "="; + } + return r; } -function writeCache(data) { - data.ts = Date.now(); - lfm().writeString(cachePath(), JSON.stringify(data)); +function hmacSha1(key, message) { + var kb = strToBytes(key); + var mb = strToBytes(message); + if (kb.length > 64) kb = sha1Bytes(kb); + while (kb.length < 64) kb.push(0); + + var ipad = [], + opad = []; + for (var i = 0; i < 64; i++) { + ipad.push(kb[i] ^ 0x36); + opad.push(kb[i] ^ 0x5c); + } + + return bytesToBase64(sha1Bytes(opad.concat(sha1Bytes(ipad.concat(mb))))); } -function readCache() { - var p = cachePath(); - if (!lfm().fileExists(p)) return null; - try { - return JSON.parse(lfm().readString(p)); - } catch (e) { - return null; +// ──────────────────────────────────────────────────── +// OAuth 1.0a helpers +// ──────────────────────────────────────────────────── + +function pctEnc(s) { + return encodeURIComponent(s).replace(/[!'()*]/g, function (c) { + return "%" + c.charCodeAt(0).toString(16).toUpperCase(); + }); +} + +function oauthNonce() { + var c = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var n = ""; + for (var i = 0; i < 32; i++) + n += c[Math.floor(Math.random() * c.length)]; + return n; +} + +function oauthSign(method, url, params, consumerSecret, tokenSecret) { + var sorted = Object.keys(params) + .sort() + .map(function (k) { + return pctEnc(k) + "=" + pctEnc(params[k]); + }) + .join("&"); + + var base = method + "&" + pctEnc(url) + "&" + pctEnc(sorted); + var key = pctEnc(consumerSecret) + "&" + pctEnc(tokenSecret || ""); + return hmacSha1(key, base); +} + +function oauthHeader(params) { + return ( + "OAuth " + + Object.keys(params) + .sort() + .map(function (k) { + return pctEnc(k) + '="' + pctEnc(params[k]) + '"'; + }) + .join(", ") + ); +} + +// ──────────────────────────────────────────────────── +// Instapaper API transport +// ──────────────────────────────────────────────────── + +function buildRequest(endpoint, bodyParams, token, tokenSecret) { + var url = "https://www.instapaper.com" + endpoint; + + var oa = { + oauth_consumer_key: CONSUMER_KEY, + oauth_nonce: oauthNonce(), + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: String(Math.floor(Date.now() / 1000)), + oauth_version: "1.0", + }; + if (token) oa.oauth_token = token; + + var all = Object.assign({}, oa, bodyParams || {}); + oa.oauth_signature = oauthSign( + "POST", + url, + all, + CONSUMER_SECRET, + tokenSecret || "" + ); + + var req = new Request(url); + req.method = "POST"; + req.headers = { + Authorization: oauthHeader(oa), + "Content-Type": "application/x-www-form-urlencoded", + }; + + if (bodyParams && Object.keys(bodyParams).length) { + req.body = Object.keys(bodyParams) + .map(function (k) { + return encodeURIComponent(k) + "=" + encodeURIComponent(bodyParams[k]); + }) + .join("&"); } + + return req; } -function saveFeaturedImage(img) { - if (img) lfm().writeImage(imagePath(), img); +// ──────────────────────────────────────────────────── +// Authentication (xAuth → OAuth token) +// ──────────────────────────────────────────────────── + +async function authenticate(username, password) { + var req = buildRequest("/api/1/oauth/access_token", { + x_auth_username: username, + x_auth_password: password, + x_auth_mode: "client_auth", + }); + + var text = await req.loadString(); + var parts = {}; + text.split("&").forEach(function (pair) { + var kv = pair.split("="); + parts[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]); + }); + + if (!parts.oauth_token) { + throw new Error("Authentication failed — check your credentials."); + } + + return { token: parts.oauth_token, secret: parts.oauth_token_secret }; } -function loadFeaturedImage() { - var p = imagePath(); - if (lfm().fileExists(p)) return lfm().readImage(p); - return null; +// ──────────────────────────────────────────────────── +// Bookmarks +// ──────────────────────────────────────────────────── + +async function fetchUnreadBookmarks(token, tokenSecret) { + var req = buildRequest( + "/api/1/bookmarks/list", + { folder_id: "unread", limit: "500" }, + token, + tokenSecret + ); + + var json = await req.loadJSON(); + + if (json && json.bookmarks) return json.bookmarks; + if (Array.isArray(json)) + return json.filter(function (o) { + return o.type === "bookmark"; + }); + return []; } // ──────────────────────────────────────────────────── @@ -127,6 +302,7 @@ async function fetchArticleImage(articleUrl) { req.timeoutInterval = 8; var html = await req.loadString(); + // Try property-first, then content-first ordering of the og:image tag. var match = html.match( /]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i ); @@ -144,11 +320,83 @@ async function fetchArticleImage(articleUrl) { return await imgReq.loadImage(); } } catch (e) { - // Best-effort; widget works fine without an image. + // Image fetch is best-effort; widget works without it. + } + return null; +} + +// ──────────────────────────────────────────────────── +// Local cache (FileManager) +// ──────────────────────────────────────────────────── + +function fm() { + return FileManager.local(); +} + +function cachePath() { + return fm().joinPath(fm().documentsDirectory(), CACHE_FILE); +} + +function featuredImagePath() { + return fm().joinPath(fm().documentsDirectory(), FEATURED_IMG); +} + +function readCache() { + var p = cachePath(); + if (!fm().fileExists(p)) return null; + try { + var data = JSON.parse(fm().readString(p)); + if (Date.now() - data.ts < CACHE_MINUTES * 60 * 1000) return data; + } catch (e) { + /* corrupt */ } return null; } +function readStaleCache() { + var p = cachePath(); + if (!fm().fileExists(p)) return null; + try { + return JSON.parse(fm().readString(p)); + } catch (e) { + return null; + } +} + +function writeCache(bookmarks, featuredId) { + fm().writeString( + cachePath(), + JSON.stringify({ ts: Date.now(), bookmarks: bookmarks, featuredId: featuredId }) + ); +} + +function saveFeaturedImage(img) { + if (img) fm().writeImage(featuredImagePath(), img); +} + +function loadFeaturedImage() { + var p = featuredImagePath(); + if (fm().fileExists(p)) return fm().readImage(p); + return null; +} + +// ──────────────────────────────────────────────────── +// Credential helpers (Keychain) +// ──────────────────────────────────────────────────── + +function hasCredentials() { + return Keychain.contains(KC_TOKEN) && Keychain.contains(KC_SECRET); +} + +function getCredentials() { + return { token: Keychain.get(KC_TOKEN), secret: Keychain.get(KC_SECRET) }; +} + +function saveCredentials(token, secret) { + Keychain.set(KC_TOKEN, token); + Keychain.set(KC_SECRET, secret); +} + // ──────────────────────────────────────────────────── // Drawing helpers // ──────────────────────────────────────────────────── @@ -166,11 +414,16 @@ function createPlaceholderImage() { var ctx = new DrawContext(); ctx.size = new Size(200, 120); ctx.opaque = false; + + // Gradient-ish solid placeholder. ctx.setFillColor(new Color("#2d2d5e")); ctx.fillRect(new Rect(0, 0, 200, 120)); + + // Book icon in the centre. ctx.setFont(Font.regularSystemFont(36)); ctx.setTextColor(new Color("#64ffda", 0.4)); ctx.drawTextInRect("\u{1F4D6}", new Rect(72, 36, 60, 48)); + return ctx.getImage(); } @@ -185,8 +438,12 @@ function formatTime(minutes) { return m + " min"; } -function createWidget(data, featuredImg) { - var count = Number(data.count) || 0; +function pickRandom(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function createWidget(bookmarks, featured, featuredImg) { + var count = bookmarks.length; var totalMin = (count * AVG_WORDS_PER_ARTICLE) / WORDS_PER_MINUTE; var bg = new Color("#1a1a2e"); @@ -199,7 +456,7 @@ function createWidget(data, featuredImg) { w.setPadding(12, 12, 10, 12); // ── Empty state ── - if (count === 0) { + if (count === 0 || !featured) { var hdr = w.addText("INSTAPAPER"); hdr.font = Font.boldSystemFont(11); hdr.textColor = accent; @@ -212,14 +469,13 @@ function createWidget(data, featuredImg) { return w; } - var img = featuredImg || createPlaceholderImage(); - var title = data.title || "Untitled"; - // ── Determine layout by widget size ── var family = config.widgetFamily || "small"; + var img = featuredImg || createPlaceholderImage(); + var title = featured.title || "Untitled"; if (family === "medium" || family === "large") { - // ── MEDIUM / LARGE: image left, text right ── + // ── MEDIUM / LARGE: image on left, text on right ── var topStack = w.addStack(); topStack.layoutHorizontally(); topStack.spacing = 10; @@ -242,6 +498,13 @@ function createWidget(data, featuredImg) { titleEl.textColor = Color.white(); titleEl.lineLimit = 3; + if (featured.description) { + var descEl = textStack.addText(featured.description); + descEl.font = Font.regularSystemFont(11); + descEl.textColor = muted; + descEl.lineLimit = 2; + } + textStack.addSpacer(null); } else { // ── SMALL: image on top, title below ── @@ -285,7 +548,7 @@ function createWidget(data, featuredImg) { tEl.textColor = warm; // ── Tap action → open the featured article ── - if (data.url) w.url = data.url; + w.url = "https://www.instapaper.com/read/" + featured.bookmark_id; return w; } @@ -310,88 +573,139 @@ function createErrorWidget(message) { } // ──────────────────────────────────────────────────── -// Image management +// Entry point // ──────────────────────────────────────────────────── -async function getImage(data) { - // Reuse the cached image if the article URL hasn't changed. - var cached = readCache(); - if (cached && cached.url === data.url) { - var img = loadFeaturedImage(); - if (img) return img; +async function loadFeaturedData(bookmarks, cachedFeaturedId) { + if (bookmarks.length === 0) return { article: null, image: null }; + + // Re-use the cached featured article when it is still in the list. + var article; + if (cachedFeaturedId) { + article = bookmarks.find(function (b) { + return String(b.bookmark_id) === String(cachedFeaturedId); + }); } - // Fetch and cache the new image. - var img = await fetchArticleImage(data.url); + if (article) { + // Cache hit — reuse locally-stored image. + return { article: article, image: loadFeaturedImage() }; + } + + // Pick a new random article and fetch its image. + article = pickRandom(bookmarks); + var img = await fetchArticleImage(article.url); saveFeaturedImage(img); - return img; + return { article: article, image: img }; } -// ──────────────────────────────────────────────────── -// Entry point -// ──────────────────────────────────────────────────── - async function main() { - // ── Called from the Shortcut → cache the data and exit ── - var input = args.shortcutParameter; - if (input && typeof input === "object") { - writeCache({ - count: input.count, - title: input.title, - url: String(input.url || ""), - }); + // --- Running inside the Scriptable app (not as a widget) --- + if (!config.runsInWidget) { + if (!hasCredentials()) { + var alert = new Alert(); + alert.title = "Instapaper Login"; + alert.message = + "Enter your Instapaper credentials.\n" + + "They are exchanged for an OAuth token and never stored."; + alert.addTextField("Email"); + alert.addSecureTextField("Password"); + alert.addAction("Log In"); + alert.addCancelAction("Cancel"); + + var idx = await alert.presentAlert(); + if (idx === -1) return; + + try { + var creds = await authenticate( + alert.textFieldValue(0), + alert.textFieldValue(1) + ); + saveCredentials(creds.token, creds.secret); + + var ok = new Alert(); + ok.title = "Authenticated"; + ok.message = + "You're all set. Add a Scriptable widget to your " + + "home screen and select this script."; + ok.addAction("OK"); + await ok.presentAlert(); + } catch (e) { + var fail = new Alert(); + fail.title = "Login Failed"; + fail.message = String(e.message || e); + fail.addAction("OK"); + await fail.presentAlert(); + return; + } + } - // Fetch the article image while we have full runtime access. - if (input.url) { - var img = await fetchArticleImage(String(input.url)); - saveFeaturedImage(img); + if (hasCredentials()) { + var cred = getCredentials(); + var bookmarks; + try { + bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); + } catch (e) { + var stale = readStaleCache(); + bookmarks = stale ? stale.bookmarks : []; + } + + var feat = await loadFeaturedData(bookmarks, null); + if (feat.article) writeCache(bookmarks, feat.article.bookmark_id); + else writeCache(bookmarks, null); + + var preview = createWidget(bookmarks, feat.article, feat.image); + preview.presentMedium(); } + return; + } + + // --- Running as a home-screen widget --- - Script.setShortcutOutput("OK"); + if (!hasCredentials()) { + Script.setWidget(createErrorWidget("Open Scriptable to log in")); Script.complete(); return; } - // ── Running as a home-screen widget ── - if (config.runsInWidget) { - var data = readCache(); - if (!data) { - Script.setWidget( - createErrorWidget("Run the companion\nShortcut first") - ); - Script.complete(); - return; - } + var cred = getCredentials(); + var bookmarks; + var cachedFeaturedId = null; - var img = loadFeaturedImage(); - // If no cached image exists, try fetching (best effort within - // the widget's limited execution time). - if (!img && data.url) { - img = await fetchArticleImage(data.url); - saveFeaturedImage(img); + var cached = readCache(); + if (cached) { + bookmarks = cached.bookmarks; + cachedFeaturedId = cached.featuredId; + } else { + try { + bookmarks = await fetchUnreadBookmarks(cred.token, cred.secret); + } catch (e) { + var stale = readStaleCache(); + if (stale) { + bookmarks = stale.bookmarks; + cachedFeaturedId = stale.featuredId; + } else { + Script.setWidget(createErrorWidget("Network error")); + Script.complete(); + return; + } } - - Script.setWidget(createWidget(data, img)); - Script.complete(); - return; } - // ── Running inside the Scriptable app → show a preview ── - var data = readCache(); - if (!data) { - var a = new Alert(); - a.title = "No Data Yet"; - a.message = - "Run the companion Shortcut first so the widget " + - "has article data to display."; - a.addAction("OK"); - await a.presentAlert(); - return; + var feat = await loadFeaturedData(bookmarks, cachedFeaturedId); + + // Persist cache only when we fetched fresh data (no cachedFeaturedId from + // a fresh cache hit means we already have valid cache). + if (!cached) { + writeCache( + bookmarks, + feat.article ? feat.article.bookmark_id : null + ); } - var img = await getImage(data); - var widget = createWidget(data, img); - widget.presentMedium(); + var widget = createWidget(bookmarks, feat.article, feat.image); + Script.setWidget(widget); + Script.complete(); } await main(); From 2789e8342f87e6ebf4180161b8c1cafff3a1f8cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 13:09:05 +0000 Subject: [PATCH 5/5] Use instapaper:// URL scheme so widget tap opens the app Instapaper has no universal links (their AASA only declares webcredentials) and no documented deep link to a specific article, so the best we can do is open the app directly. https://claude.ai/code/session_01QsCcdu4bFzd1xy4NQ1oWYb --- instapaper-widget.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/instapaper-widget.js b/instapaper-widget.js index 628e4e2..3f7ff18 100644 --- a/instapaper-widget.js +++ b/instapaper-widget.js @@ -547,8 +547,8 @@ function createWidget(bookmarks, featured, featuredImg) { tEl.font = Font.mediumSystemFont(11); tEl.textColor = warm; - // ── Tap action → open the featured article ── - w.url = "https://www.instapaper.com/read/" + featured.bookmark_id; + // ── Tap action → open Instapaper app ── + w.url = "instapaper://"; return w; }