diff --git a/instapaper-widget.js b/instapaper-widget.js new file mode 100644 index 0000000..3f7ff18 --- /dev/null +++ b/instapaper-widget.js @@ -0,0 +1,711 @@ +// 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 + * + * 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 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 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 filenames. +const CACHE_FILE = "instapaper_cache.json"; +const FEATURED_IMG = "instapaper_featured.png"; + +// ──────────────────────────────────────────────────── +// 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; + + 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(); + + if (json && json.bookmarks) return json.bookmarks; + if (Array.isArray(json)) + return json.filter(function (o) { + return o.type === "bookmark"; + }); + 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() { + 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 +// ──────────────────────────────────────────────────── + +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 +// ──────────────────────────────────────────────────── + +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)]; +} + +function createWidget(bookmarks, featured, featuredImg) { + var count = bookmarks.length; + var totalMin = (count * AVG_WORDS_PER_ARTICLE) / WORDS_PER_MINUTE; + + var bg = new Color("#1a1a2e"); + var accent = new Color("#64ffda"); + var muted = new Color("#8892b0"); + var warm = new Color("#e6c07b"); + + var w = new ListWidget(); + w.backgroundColor = bg; + 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; + } + + // ── 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; + } + + 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; + } + + w.addSpacer(null); + + // ── Separator ── + var sepEl = w.addImage(createSeparatorImage()); + sepEl.imageSize = new Size(0, 1); + sepEl.imageOpacity = 1; + + w.addSpacer(6); + + // ── Stats bar ── + var statsStack = w.addStack(); + statsStack.layoutHorizontally(); + statsStack.centerAlignContent(); + + var countStr = String(count) + (count === 1 ? " article" : " articles"); + var cEl = statsStack.addText(countStr); + cEl.font = Font.mediumSystemFont(11); + cEl.textColor = muted; + + statsStack.addSpacer(null); + + var tEl = statsStack.addText("\u{23F1} ~" + formatTime(totalMin)); + tEl.font = Font.mediumSystemFont(11); + tEl.textColor = warm; + + // ── Tap action → open Instapaper app ── + w.url = "instapaper://"; + + 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 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) { + 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; + } + } + + 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 --- + + if (!hasCredentials()) { + Script.setWidget(createErrorWidget("Open Scriptable to log in")); + Script.complete(); + return; + } + + var cred = getCredentials(); + var bookmarks; + var cachedFeaturedId = null; + + 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 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(); +} + +await main();