
[{"content":"\rNeko7 to keep your ADHD cute and pettable\r#\rYou can use Neko7 safely as it saves your list and text to your browser cache locally, so only you can see what you\u0026rsquo;re adding. The only thing it asks (optional!) is your location so it can pull the local weather to match.\nI wanted a way to force myself to finish one list of tasks and not get distracted by adding on more and more so this is basically a 7-item task list that cannot be added to until all 7 slots are cleared.\nThis is Claude Code, plus some mucking about with css and ffmpeg to get it how I wanted, so YMMV.\nNeko7 started as a blobby monster that wobbled violently whenever a task bubble was popped but after some forays into animating wireframes, I went back to the classic (and much better drawn!) Neko kitty cat. I grabbed Pixel Cat Animation Pack from itchio\u0026rsquo;s Last tick which came with two other colour variations, black and white, plus the background from an SVG by FreePix. The tiles are a little bit parallex so Neko7 can walk in front of the trees but behind the grass and flowers.\nThe side typing (meant to have a vintage typewriter animation but I am absolutely not yet able to rig that so v3.0 maybe) notepad is just to scratch little notes down. The pomodoro timer on the far right is the same - barely functional but enough for what I need.\nMostly, it\u0026rsquo;s watching the pastel bubbles drift about, Neko7 hissing and jumping and playing with them (yes, the frames are wonky, I\u0026rsquo;m still working on that!) and when popped, confetti raining down on a happy Neko7. Without tasks, she\u0026rsquo;ll curl up and sleep.\nI also had the sky do dawn-dusk with night stars etc tied to my local timezone. There are two special bubbles that don\u0026rsquo;t count on the 7-task list but are generated every hour to remind me to go for a walk or get a drink.\nVersion 2.0 added more animations. The index.html was getting huge with the bit64 strings for the sprites embedded in it, so I pulled them out to an /assets/ directory so it doesn\u0026rsquo;t hog memory. I added pigeons that fly in occasionally to dive at Neko7, and weather (called via an API for local weather) to have clouds, rain and sunshine appropriately.\nThe bubbles got fixed to 7 pride colours and made a little more 2D to match the feel, and I tweaked the grass and trees for better cover.\nThe hardest part was figuring out why Neko7 would get stuck and just spin around in place. When you\u0026rsquo;re working with Claude Code, you have to put in some kind of logging so that you can grab the exact moments something goes wrong or the AI also spins in place trying to figure out from your description/screenshot of the error what happened. It feels like overkill to do logging for a simple web app, but I promise it saves so much time later.\nThe issue was that some animations used the L-R sit and stand pose to transition but wouldn\u0026rsquo;t fully trigger, so she\u0026rsquo;d end up flipping around until one did trigger, so a check on the trigger state had to be cleaned up to make sure that the L-R poses always went to a full action sequence and didn\u0026rsquo;t end up looping. It\u0026rsquo;s still not great which means\u0026hellip;\nVersion 3.0 is to figure out how to do more pixel animations so she can jump and meow and bat at the bubbles, and poke at wireframing which is intriguing and terrifying equally.\n","date":"30 March 2026","externalUrl":null,"permalink":"/projects/neko7/neko7/","section":"Projects","summary":"","title":"Neko7: cute and short tasks","type":"projects"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","date":"17 March 2026","externalUrl":null,"permalink":"/projects/neko7/","section":"Projects","summary":"","title":"Neko7","type":"projects"},{"content":"\rSending AO3 updates directly to your Kobo\r#\rThis is how I get my AO3 fic subscription alerts converted into stories on my Kobo automatically. It takes some set-up and requires you have a Gmail and Dropbox. The free tier for all of this works fine for me at about 20-30 updates daily.\nWhat this does:\nLooks for any complete story alerts in your gmail folder Opens the AO3 link to grab the epub file Moves the epub file into the dropbox folder for your Kobo If it didn\u0026rsquo;t work (story isn\u0026rsquo;t complete, error), stars the email and doesn\u0026rsquo;t move it to the finished folder Setting up your Gmail\r#\rYour AO3 subscriptions need to go to a folder. In Gmail, this is called a label, and you set it up via Settings:Labels. You\u0026rsquo;ll need at least two labels, one to hold all the fresh incoming subscription emails from AO3 and one to move them over once they\u0026rsquo;ve been uploaded to Kobo. I keep them so I can follow up with comments later on.\nSearch for all emails coming from \u0026ldquo;do-not-reply@archiveofourown.org\u0026rdquo; and with \u0026ldquo;posted\u0026rdquo; in the subject line. Create a filter to move them to a folder. Mine is called \u0026ldquo;label:60-69-media-62.02-ao3\u0026rdquo;. Note that if you are nesting your folders, the label name will use - instead of / or \\ for subfolders.\nMy finished folder is called \u0026ldquo;label:60-69-media-62.02-ao3-kobo\u0026rdquo;.\nI\u0026rsquo;ve also got folders set up for Comments and Kudos (same search but adding in the subject line matches) but if you don\u0026rsquo;t get these often, you can go ahead with just two folders.\nSetting up your Dropbox\r#\rKobo has a built-in Dropbox sync. Follow the official instructions to set yours up. You can do this with Google too, but my Google space is used for other projects and so I\u0026rsquo;m using Dropbox. It should be pretty easy to switch this part over to Google Drive on your own.\nOnce set up, your Dropbox account will have a folder inside the local Dropbox folder on your computer called Apps\u0026gt;Rakuten Kobo. You can\u0026rsquo;t change the name of this folder or move it anywhere else.\nSet up the script in script.google.com\r#\rSign in with your google account. Set up a new project and call it \u0026ldquo;AO3toKobo\u0026rdquo; or whatever. You\u0026rsquo;re going to need to create two files and add the OAuth library (so you can login to your dropbox account) and set up some script properties and schedule this to run regularly.\nProject Settings.\r#\rFirst go to Project Settings, the little cog on the lefthand menu. Set your timezone and also make sure all three boxes are checked.\nYou\u0026rsquo;ll need to set up Google Cloud Platform for a project to make the authorisations all work. You can log in directly at GCP or you can just click through to set up a default project. On an individual level, this script uses almost no resources so nothing needs to be paid or set up beyond a default project ID. Ignore all the rest of the technical stuff on that side past getting the ID.\nYou do need to make sure you\u0026rsquo;ve got your name and email address (does not have to be your legal name, but must be a working email address) on the project as the owner.\nNow on Script Properties, you\u0026rsquo;ll need to set up three specific properties for your accounts:\nAO3_SESSION_COOKIE DROPBOX_CLIENT_ID DROPBOX_CLIENT_SECRET Do not share these codes with anyone else.\rCreate the three pairs and give them the value dummy for now.\nHow to get your AO3 Session Cookie\r#\rOpen an AO3 page where you are logged in. Right-click to inspect the page and you\u0026rsquo;ll see a console split your browser to show you all the code of the page.\nClick on the Storage tab and then cookies in the lefthand list. You\u0026rsquo;ll see several values listed and the one you want to carefully copy is _cfuvid. Click on the righthand Data box and you\u0026rsquo;ll be able to copy the whole string.\nGo back to Google Scripts and save that string to AO3_SESSION_COOKIE as the value.\nThis should last a pretty long while - AO3 allows for multiple session cookies.\nHow to get your Dropbox codes\r#\rNow you need to set up a Dropbox developer app on Dropbox Developers. This will stay in Development so it\u0026rsquo;s just used by you and thus free.\nCreate an app and choose:\nScoped access Full Dropbox (so you can access the Rabukuten Kobo folder to upload) Name your app - I called mine Ao3Kobo Then you\u0026rsquo;ll have a development app which only you can access with an App key and App secret. Copy these over to the Google Scripts.\nYou\u0026rsquo;ll need to do some more setup for Dropbox a few steps further on.\nSetting up OAuth\r#\rOkay so Dropbox will not keep your access running for long due to security concerns. So! You need to set up proper logging in. This is surprisingly easy with the OAuth2 library - Github for the official app. Click that link to copy the current script ID.\nThen back in Google Scripts, go to the editor \u0026lt; \u0026gt; and click + Libraries and look up that script ID. Once you see it, add it to your project. I\u0026rsquo;m using version 43 currently.\nGo to your project settings page now and copy the script ID.\nBack to Dropbox to add OAuth.\r#\rGo back to your Dropbox app\u0026rsquo;s Settings tab. OAuth 2 should show Redirect URI as a blank field. You\u0026rsquo;ll need to update this with:\nhttps://script.google.com/macros/d/REPLACE WITH SCRIPT ID/usercallback Where REPLACE WITH SCRIPT ID is actually the script ID from the Project Settings page you just copied.\nMake sure \u0026ldquo;Allow public clients (Implicit Grant \u0026amp; PKCE)\u0026rdquo; is checked, and then go to the Permissions tab.\nThese are the editable fields that should be checked:\nfiles.metadata.write files.content.write files.content.read Then go back to Settings and now you can click Generate for Generate access token.\nYou\u0026rsquo;ll finish setting up the OAuth stuff later, but this is fine now.\nAdd the script file Code.gs\r#\rOkay, here is the code to copy \u0026amp; paste. We\u0026rsquo;ll go through the parts you need to uncomment, run, then comment next.\ncode.gs\rconst CONFIG = { // change to your incoming AO3 emails gmail label gmailLabel: '60-69 Media/62.02 ao3', // change to your done AO3 emails gmail label successLabel: '60-69 Media/62.02 ao3/kobo', senderEmail: 'do-not-reply@archiveofourown.org', dropboxFolderPath: '/Apps/Rakuten Kobo', //dropboxFolderPath: '/Ao3Kobo', maxPerRun: 7, minDelayMsBetweenDownloads: 1200, // Recommended: treat gmailLabel as a true “queue” // If true: once processed, message/thread is removed from the queue label. removeQueueLabelOnSuccess: false }; const DROPBOX_SCOPES = [ 'files.content.write', 'files.metadata.read' ]; const DEBUG = true; // ← turn logs on/off here // function setupDropboxAuth() { // return dropboxGetRedirectUri_(); // } // function authorizeDropbox() { // return dropboxAuthorize_(); // } function runPoller() { debugLog_('▶ runPoller start'); Logger.log('typeof OAuth2 = ' + typeof OAuth2); const queueLabel = GmailApp.getUserLabelByName(CONFIG.gmailLabel); if (!queueLabel) { Logger.log(`✖ Gmail label not found: ${CONFIG.gmailLabel}`); throw new Error(`Missing Gmail label: ${CONFIG.gmailLabel}`); } Logger.log(`✔ Found label: ${CONFIG.gmailLabel}`); let successLabel = GmailApp.getUserLabelByName(CONFIG.successLabel); if (!successLabel) { successLabel = GmailApp.createLabel(CONFIG.successLabel); Logger.log(`✔ Created label: ${CONFIG.successLabel}`); } else { Logger.log(`✔ Found success label: ${CONFIG.successLabel}`); } const threads = queueLabel.getThreads(0, 50); Logger.log(`• Found ${threads.length} threads under label`); let processed = 0; for (const thread of threads) { Logger.log(`→ Thread ${thread.getId()}`); const messages = thread.getMessages(); Logger.log(` → ${messages.length} message(s) in thread`); for (const msg of messages) { if (processed \u0026gt;= CONFIG.maxPerRun) { Logger.log('⏹ Reached maxPerRun, stopping'); return; } const msgId = msg.getId(); Logger.log(`→ Checking message ${msgId}`); // Only process unread messages: read == done if (!msg.isUnread()) { Logger.log(' ⏭ Message already read (treated as processed), skipping'); continue; } // Only process AO3 sender const from = msg.getFrom() || ''; if (!from.includes(CONFIG.senderEmail)) { Logger.log(` ⏭ Sender does not match AO3 (${from}), skipping`); continue; } Logger.log(' ✓ Sender matches AO3'); try { const text = msg.getPlainBody() || ''; // ✅ Chapters gate BEFORE downloading const ch = isAo3EmailComplete_(text); if (ch.status !== 'complete') { Logger.log(` ⏭ Skipping: chapters not complete (${ch.status}${ch.current ? ` ${ch.current}/${ch.total ?? '?'}` : ''})`); // SKIP state (what you asked for): // - star it (visible) // - mark read (so it doesn't retry forever) msg.star(); msg.markRead(); // Optional: also remove from the queue label so it stops clogging the label view // (This does NOT delete the email.) // queueLabel.removeFromThread(thread); continue; } const workId = extractAo3WorkId_(text); if (!workId) { throw new Error('AO3 work ID not found in email body'); } Logger.log(` ✓ Extracted workId: ${workId}`); Logger.log(' → Fetching EPUB from AO3'); const { blob, filename } = downloadEpubAndName_(workId); Logger.log(` ✓ EPUB downloaded: ${filename} (${blob.getBytes().length} bytes)`); const dropboxPath = `${CONFIG.dropboxFolderPath}/${sanitizeDropboxFilename_(filename)}`; Logger.log(` → Uploading to Dropbox: ${dropboxPath}`); uploadToDropbox_(dropboxPath, blob); Logger.log(' ✓ Dropbox upload successful'); // SUCCESS state: // - mark read (so we never do it again) // - unstar (clear any prior error) msg.markRead(); msg.unstar(); // Move thread from queue label → kobo label try { queueLabel.removeFromThread(thread); Logger.log(` ✓ Removed queue label: ${CONFIG.gmailLabel}`); } catch (e) { Logger.log(` ⚠ Could not remove queue label: ${e}`); } try { successLabel.addToThread(thread); Logger.log(` ✓ Added success label: ${CONFIG.successLabel}`); } catch (e) { Logger.log(` ⚠ Could not add success label: ${e}`); } processed += 1; Logger.log(` ✓ Message ${msgId} processed successfully`); Utilities.sleep(CONFIG.minDelayMsBetweenDownloads); } catch (err) { Logger.log(`✖ ERROR processing message ${msgId}: ${err \u0026amp;\u0026amp; err.message ? err.message : err}`); // ERROR state: // - star it (visible) // - leave unread so it retries next run try { msg.star(); } catch (e) {} Logger.log(' → Message starred and left unread for retry'); } } } Logger.log('▶ runPoller finished'); } function debugLog_(msg) { if (DEBUG) Logger.log(msg); } /** --- AO3 download --- */ function downloadEpubAndName_(workId) { const cookieVal = PropertiesService.getScriptProperties().getProperty('AO3_SESSION_COOKIE'); if (!cookieVal) throw new Error('AO3_SESSION_COOKIE missing'); const url = `https://archiveofourown.org/downloads/${workId}/a.epub`; Logger.log(` → AO3 URL: ${url}`); // Follow redirects normally (AO3 often redirects to download.archiveofourown.org) const resp = UrlFetchApp.fetch(url, { followRedirects: true, muteHttpExceptions: true, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/120.0.0.0 Safari/537.36', ...(cookieVal ? { 'Cookie': `_otwarchive_session=${cookieVal};` } : {}) } }); const code = resp.getResponseCode(); Logger.log(` ← AO3 final response code: ${code}`); // If auth fails, the \u0026quot;final\u0026quot; response often becomes an HTML page (not an epub) const ct = (resp.getHeaders()['Content-Type'] || resp.getHeaders()['content-type'] || '').toLowerCase(); Logger.log(` ← AO3 content-type: ${ct}`); if (code === 401 || code === 403) { throw new Error(`AO3 denied access (${code})`); } if (code \u0026lt; 200 || code \u0026gt;= 400) { const snippet = safeSnippet_(resp.getContentText(), 300); throw new Error(`AO3 download failed (${code}). Snippet: ${snippet}`); } // Extra hardening: ensure we actually got an epub-ish response // (Some failures return HTML with 200) if (ct.includes('text/html')) { const html = resp.getContentText() || ''; // Log a bit from later in the page too (reason text is often in the body) Logger.log(` ← AO3 HTML diagnosis: ${diagnoseAo3Html_(html)}`); Logger.log(` ← AO3 HTML head snippet: ${safeSnippet_(html, 300)}`); // Try to find a more meaningful snippet from the body const bodyish = html.replace(/[\\s\\S]*?\u0026lt;body[^\u0026gt;]*\u0026gt;/i, '').slice(0, 1200); Logger.log(` ← AO3 HTML body snippet: ${safeSnippet_(bodyish, 400)}`); throw new Error('AO3 returned HTML instead of EPUB (see diagnosis above).'); } const blob = resp.getBlob(); // Filename from Content-Disposition if present const cd = resp.getHeaders()['Content-Disposition'] || resp.getHeaders()['content-disposition'] || ''; const filename = extractFilenameFromContentDisposition_(cd) || `ao3_${workId}.epub`; return { blob, filename }; } /** --- Dropbox upload --- */ function uploadToDropbox_(path, blob) { // Tripwire to force library resolution const __oauth2_loaded = typeof OAuth2; const service = getDropboxService_(); if (!service.hasAccess()) { throw new Error('Dropbox not authorized. Run dropboxAuthorize_() and complete the auth flow.'); } const token = service.getAccessToken(); const apiArg = { path, mode: 'add', autorename: true, mute: false }; Logger.log(` → Dropbox API arg: ${JSON.stringify(apiArg)}`); const resp = UrlFetchApp.fetch('https://content.dropboxapi.com/2/files/upload', { method: 'post', contentType: 'application/octet-stream', payload: blob.getBytes(), headers: { Authorization: `Bearer ${token}`, 'Dropbox-API-Arg': JSON.stringify(apiArg) }, muteHttpExceptions: true }); const code = resp.getResponseCode(); const body = resp.getContentText() || ''; Logger.log(` ← Dropbox response code: ${code}`); Logger.log(` ← Dropbox response body: ${safeSnippet_(body, 2000)}`); if (code \u0026lt; 200 || code \u0026gt;= 400) { throw new Error(`Dropbox upload failed (${code}). Body: ${safeSnippet_(body, 800)}`); } } /** --- Helpers (unchanged) --- */ function extractAo3WorkId_(text) { const m = (text || '').match(/https?:\\/\\/(?:www\\.)?archiveofourown\\.org\\/works\\/(\\d+)/i); return m ? m[1] : null; } function sanitizeDropboxFilename_(name) { return (name || 'ao3.epub') .replace(/[\\/\\\\]/g, '_') .replace(/[\\u0000-\\u001F\\u007F]/g, '') .trim() .slice(0, 180); } function extractFilenameFromContentDisposition_(cd) { if (!cd) return null; let m = cd.match(/filename\\*\\s*=\\s*UTF-8''([^;]+)/i); if (m) return decodeURIComponent(m[1]); m = cd.match(/filename\\s*=\\s*\u0026quot;?([^\u0026quot;;]+)\u0026quot;?/i); return m ? m[1] : null; } function safeSnippet_(s, maxLen) { if (!s) return ''; const clean = String(s).replace(/\\s+/g, ' ').trim(); return clean.length \u0026gt; maxLen ? clean.slice(0, maxLen) + '…' : clean; } function diagnoseAo3Html_(html) { const lower = (html || '').toLowerCase(); // Common AO3 cases if (lower.includes('login') \u0026amp;\u0026amp; (lower.includes('password') || lower.includes('log in'))) { return 'Looks like AO3 login page (session not authenticated).'; } if (lower.includes('this work is only available to registered users')) { return 'Work restricted to registered users (should work when logged in).'; } if (lower.includes('you do not have permission') || lower.includes('you are not allowed')) { return 'Permission/collection restriction (may be inaccessible even when logged in).'; } if (lower.includes('adult content') \u0026amp;\u0026amp; lower.includes('proceed')) { return 'Adult-content confirmation interstitial (needs preference/consent).'; } if (lower.includes('retry later') || lower.includes('error') \u0026amp;\u0026amp; lower.includes('back')) { return 'AO3 error page / temporary issue.'; } return 'Unknown HTML page type.'; } function getDropboxService_() { const props = PropertiesService.getScriptProperties(); const clientId = props.getProperty('DROPBOX_CLIENT_ID'); const clientSecret = props.getProperty('DROPBOX_CLIENT_SECRET'); if (!clientId || !clientSecret) { throw new Error('Missing DROPBOX_CLIENT_ID or DROPBOX_CLIENT_SECRET in Script Properties.'); } return OAuth2.createService('dropbox') .setAuthorizationBaseUrl('https://www.dropbox.com/oauth2/authorize') .setTokenUrl('https://api.dropboxapi.com/oauth2/token') .setClientId(clientId) .setClientSecret(clientSecret) .setCallbackFunction('dropboxAuthCallback_') // Store tokens here: .setPropertyStore(PropertiesService.getUserProperties()) // Get refresh token .setParam('token_access_type', 'offline') // Ask for scopes .setScope(DROPBOX_SCOPES.join(' ')) } function dropboxGetRedirectUri_() { const service = getDropboxService_(); Logger.log(service.getRedirectUri()); return service.getRedirectUri(); } function dropboxAuthorize_() { const service = getDropboxService_(); const url = service.getAuthorizationUrl(); Logger.log(url); return url; } function dropboxAuthCallback_(request) { const service = getDropboxService_(); const ok = service.handleCallback(request); return HtmlService.createHtmlOutput(ok ? 'Dropbox auth OK. You can close this.' : 'Dropbox auth denied.'); } function dropboxResetAuth_() { getDropboxService_().reset(); } function isAo3EmailComplete_(text) { const complete = /Chapters:\\s*(\\d+)\\s*\\/\\s*\\1\\b/i.test(text || ''); return complete ? { status: 'complete' } : { status: 'not-complete' }; } Add or edit appsscript.JSON\r#\rThis file is auto-generated usually, but just in case:\n{ // Set to your timezone \u0026quot;timeZone\u0026quot;: \u0026quot;Asia/Singapore\u0026quot;, \u0026quot;dependencies\u0026quot;: { \u0026quot;libraries\u0026quot;: [ { \u0026quot;userSymbol\u0026quot;: \u0026quot;OAuth2\u0026quot;, \u0026quot;version\u0026quot;: \u0026quot;43\u0026quot;, \u0026quot;libraryId\u0026quot;: \u0026quot;1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF\u0026quot; } ] }, \u0026quot;exceptionLogging\u0026quot;: \u0026quot;STACKDRIVER\u0026quot;, \u0026quot;runtimeVersion\u0026quot;: \u0026quot;V8\u0026quot;, \u0026quot;oauthScopes\u0026quot;: [ \u0026quot;https://www.googleapis.com/auth/gmail.modify\u0026quot;, \u0026quot;https://www.googleapis.com/auth/script.external_request\u0026quot; ] } Now to uncomment and run the first time\r#\rYou need to run setupDropboxAuth() and authorizeDropbox()functions as part of setup. Ctrl+F to find setupDropboxAuth() in code.gs and then carefully delete the // comments on the lines here:\nfunction setupDropboxAuth() { return dropboxGetRedirectUri_(); } //\tfunction authorizeDropbox() { //\treturn dropboxAuthorize_(); //\t} Save the files and click Run (next to Debug in \u0026lt;\u0026gt; Editor). You\u0026rsquo;ll see an execution log pop-up and it\u0026rsquo;ll generate a redirect URI. This should be the same redirect URI you copied into the Dropbox program already, but double-check.\nNow go back to run the authoriseDropbox() function. Same section of code, but change the commenting to:\n//\tfunction setupDropboxAuth() { //\treturn dropboxGetRedirectUri_(); //\t} function authorizeDropbox() { return dropboxAuthorize_(); } Save the files and click Run again. This will show a new long link in the log. Copy and open this in a new browser window and click through to confirm the authorisation of your dropbox.\nGo back to the files and now comment them all out like this:\n//\tfunction setupDropboxAuth() { //\treturn dropboxGetRedirectUri_(); //\t} //\tfunction authorizeDropbox() { //\treturn dropboxAuthorize_(); //\t} Don\u0026rsquo;t delete them in case you need to re-run them for a new dropbox token in a year. Save the files.\nRun your script to check\r#\rCheck in Gmail that you have at least a couple of recent AO3 update emails in the folder that are for complete stories. This script checks the most recent 50 emails to see if it should download the epub in them.\nRun the whole script again and then go to your gmail and see if the matching emails were moved to the finished folder. Check if any non-matching (Part 2/?, part 3/4 etc) update emails were starred and left behind. Go over to your Rabukuten Kobo folder and see if the matching epubs were downloaded there.\nAuthorising gmail\r#\rI had issues authorising Gmail for my script. I kept getting caught in a security warning. Turned out I was using Firefox instead of Google Chrome. Ran everything again one time in Chrome, and since then it\u0026rsquo;s been fine in Firefox.\nUpdates for multiple new stories\r#\rThis script also only grabs the very first story in a multi-story update notification. It should fail (star and leave in the incoming folder) for those so you can then manually decide what to download for multiple updates, which are relatively rarer.\nSet it up to run regularly\r#\rIn Google Script, go to Triggers and Add a new trigger. You want it to run runPoller function as time-driven and hourly. You can change it to longer if you seldom get updates, but do not set it shorter!\nWhy? AO3 servers delightfully allow scripted projects like this if they are at reasonable rates. This means spacing out the times a script hits their servers. At my rate, it\u0026rsquo;s probably a lower hit than me actually clicking open the links manually and then going to download the pages. Don\u0026rsquo;t be an asshole.\nOperational clean-ups\r#\rIf you start with a lot of email notifications (I had over 3K of saved AO3 email updates), this will slowly work through your backlog and fill up your Kobo. I ended up cleaning up the starred emails every day and did get a lot of error notes initially when I hit the first 50 starred emails so the script couldn\u0026rsquo;t run further. Now, I almost never get an error note and I clean out my folder once a week.\nI also ended up moving older epubs into Calibre so my Rabukuten Kobo dropbox has the most recent epubs to make it faster to sync.\nI\u0026rsquo;m using Calibre to sort by tags and auto-create covers for AO3 fic because Kobo doesn\u0026rsquo;t allow you to create Collections for epubs uploaded via Dropbox from all my research so far. Calibre can do the sorting with some Kobo add ons and wifi syncing, but it\u0026rsquo;s a PITA. There is very helpful advice on r/Kobo about getting Calibre to sort Kobo collections.\nNext up\r#\rNext up is to figure out how to extract my annotations on epubs and create draft comments for AO3 so that I can highlight and comment as I go and leave a comment to the amazing authors immediately from inside Kobo.\nI have zero intention of making this more user-friendly as a script because it uses dropbox, gmail and AO3 logins and I think you should be careful before linking them all with someone else\u0026rsquo;s code. Do it yourself so you know roughly what you\u0026rsquo;re doing and you\u0026rsquo;re not giving anyone else access.\nEmail me at dale@oggham.com.\n","date":"17 February 2026","externalUrl":null,"permalink":"/projects/ao3scripts/gmail_kobo/","section":"Projects","summary":"","title":"From Gmail to your Kobo for AO3 subscriptions","type":"projects"},{"content":"\rKeyboard smashing for toddlers local HTML page\r#\rYou can save Typing Page to your local computer or just use it directly at the link.\nMy tiniest people love to \u0026rsquo;type\u0026rsquo; on my computer. I\u0026rsquo;ve looked in vain for a decent children\u0026rsquo;s typing toy but they\u0026rsquo;re all flimsy plastic with way too many distracting bells-and-whistles or not in the actual QWERTY format. Definitely a gap in the market for a simple keyboard with a LED display that just shows the keys pressed and can stand up to the enthusiasm of a toddler.\nThis is a simple script that blocks all the keyboard presses except for A-Z and 0-9. There\u0026rsquo;s a (visibly shown in right-hand corner) escape Alt+Shift+X to return to the starting page. Kids can type in up to 9 characters a line and pressing space will clear the entered characters. The most recently pressed is the brightest and the background gently changes colour as they type. The app uses the default computer reading voices.\nYou can edit the html file to add your child\u0026rsquo;s name, change the font size etc - the code is basic and clear.\nTested by multiple small people successfully.\n","date":"17 February 2026","externalUrl":null,"permalink":"/projects/sprogs/alphabet_bashing/","section":"Projects","summary":"","title":"Alphabet Bashing page","type":"projects"},{"content":"\n","date":"17 February 2026","externalUrl":null,"permalink":"/projects/sprogs/","section":"Projects","summary":"","title":"Sprogs","type":"projects"},{"content":"\n","date":"17 February 2026","externalUrl":null,"permalink":"/projects/ao3scripts/","section":"Projects","summary":"","title":"AO3scripts","type":"projects"},{"content":"\rQuickly grabbing epub downloads from AO3\r#\rGo to your usual browser and create a bookmark (right-click on the browser toolbar or choose manage bookmarks in the menu). I named mine epub and saved it to my browser toolbar for easy access. Copy+paste this code as the link:\njavascript:(function() { const a = [...document.querySelectorAll('a')] .find(el =\u0026gt; el.textContent.trim().toLowerCase() === 'epub'); if(!a) { alert(\u0026quot;Couldn't find the EPUB link (by label) on this page.\u0026quot;); return; } a.click(); })(); That\u0026rsquo;s literally it. All it does is look inside the AO3 page to find the first epub link and open it for you to save to downloads.\nYou can also set up an automatic watch of your download folder to grab any epub-type files and transfer them to your dropbox folder for your kobo. There are a bunch of ways to do that from batch jobs to open source apps to paid apps.\n","date":"17 February 2026","externalUrl":null,"permalink":"/projects/ao3scripts/ao3_epub/","section":"Projects","summary":"","title":"AO3 epub bookmark","type":"projects"},{"content":" Yadda, yadda. This is where I keep public-facing articles and guides. My day job is at GovTech, figuring out how to get systems working faster and better. I've previously worked in technical editing, magazines, book design and coding. I also founded and worked on Riverkids, helping families at risk of child trafficking in Cambodia with grassroots services and support to keep their kids safe and loved. My only social media these days is Tumblr.\n","date":"17 February 2026","externalUrl":null,"permalink":"/","section":"","summary":"","title":"","type":"page"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"}]