Topic - Workflows
Workflows described elsewhere:
- Captioning videos
- Emacsconf
- Editing videos with Emacs and subed-record.el (also, Subed)
- Hyperlinking SVGs
Most of the functions are in my Emacs configuration.
On this page:
Capture and process thoughts
On the go:
- I add the thought as a note in my Inbox.org using Orgzly Revived.
- Syncthing synchronizes it with my laptop.
From within Emacs, I use org-capture to save the note to my Inbox.org.
From my web browser, I select the text and use org-capture-extension to save it.
Eventually, I review my Inbox.org:
- I flesh out some thoughts or delete them.
- Sometimes I use
org-refileto move a thought somewhere. - Sometimes I turn something into a blog post right in my inbox.
Read books
The Toronto Public Library has sooooo many books. I rarely buy books since my backlog of borrowable books is pretty much infinite.
Reading e-books:
- I like using Libby to read books on my iPad. I highlight interesting quotes and key concepts as I read.
- Then I export the book highlights as JSON and format them as Org Mode so that I can add them to my books.org file. This organizes them by chapter.
When I read paper books, I type or handwrite short quotes. If I want to save a long quote and I don't feel like typing it in:
- I use the camera on my Android phone to focus on the text.
- I use Google Lens to recognize the text.
- I select the text and share it with Orgzly Revived.
- Syncthing synchronizes my phone with my computer.
- When I'm back in Org Mode in Emacs, I copy it to my books.org file.
Once I have the raw notes in books.org:
- I select quotes to share in Mastodon toots or blog posts.
- I often make a book sketchnote. Sometimes I draw these as I read the book, and sometimes I do them afterwards.
I have a script to help me manage the three library cards we have. It renews library books and lists the ones I need to return.
NodeJS+Puppeteer script for renewing Toronto Public Library books
const puppeteer = require('puppeteer');
const moment = require('moment');
const process = require('process');
const fetch = require('node-fetch');
const cheerio = require('cheerio');
const readline = require('readline');
require('dotenv').config();
const cards = [{id: '27131039151693', abbrev: 'S', pin: '2669', limit: 50},
{id: '27131014320362', abbrev: 'W', pin: '2669', limit: 25},
{id: '27131041984909', abbrev: 'A', pin: '2669', limit: 50}];
var command = process.argv[2];
var threshold = process.env['LIBRARY_THRESHOLD'];
var debug = process.env['DEBUG'];
var today = moment().format('YYYY-MM-DD');
const fs = require('fs');
if (!threshold) { threshold = '2'; }
if (threshold.match(/^[0-9]+$/)) {
threshold = moment().add(threshold, 'days').format('YYYY-MM-DD');
}
exports.login = async function(page, card) {
await page.goto('https://account.torontopubliclibrary.ca/signin?redirect=%2Fcheckouts');
await page.waitForSelector('#userID');
try {
await page.click('#userID');
await page.keyboard.type(card.id);
await page.click('#password');
await page.keyboard.type(card.pin);
await page.click('#form_signin .button');
await page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(err => {});
if (await page.$('#sign-out-link').catch(err => {})) {
return true;
}
} catch (err) {
console.log(err);
}
};
exports.renew = async function(page, itemIDs) {
if (itemIDs.length == 0) return [];
let list = [];
for (let i = 0; i < itemIDs.length; i++) {
let selector = 'label[for=item_' + itemIDs[i] + ']';
await page.waitForSelector(selector);
await page.evaluate((selector) => {
document.querySelector(selector).closest('tr').querySelector('button').click();
}, selector);
await page.waitForResponse((resp) => resp.url().match(/renewals/)).then(async (res) => {
let data = await res.json();
list.push(data);
});
}
return list;
};
function sortByDue(a, b) {
if (a.dueDate < b.dueDate) return -1;
if (a.dueDate > b.dueDate) return 1;
return 0;
}
function summarizeData(card) {
if (!card.data) { card.data = {}; }
if (card.data.holds) {
card.data.readyForPickup = card.data.holds.filter((o) => o.status == 'READY');
} else {
card.data.readyForPickup = [];
}
if (card.data.checkouts) {
card.data.checkouts = card.data.checkouts.map((o) => {
o.abbrev = card.abbrev;
o.toReturn = (o.dueDate <= ((o.item.format.type == 'DVD') ? today : threshold));
return o;
});
card.data.returns = card.data.checkouts.filter(o => o.toReturn).sort(sortByDue);
} else {
card.data.returns = [];
}
return card;
}
async function logInAndGetData(page, card) {
let userData = {};
const getUserData = new Promise((resolve, reject) => {
page.on('response', async (resp) => {
if (!resp.url().match(/rest/)) {
return false;
}
const url = resp.url();
if (url.match(/charges$/)) {
let m = url.match(/users\/([0-9]+)\//);
userData.systemID = m[1];
userData.charges = await resp.json();
} else if (url.match(/notifications$/)) {
userData.notifications = await resp.json();
} else if (url.match(/holds\/current$/)) {
userData.holds = await resp.json();
} else if (url.match(/checkouts$/)) {
userData.checkouts = await resp.json();
} else if (url.match(/renewals$/)) {
let data = await resp.json();
if (!data?.errorCode) {
userData.checkouts = userData.checkouts.map((o) => {
if (o.id == data.id) {
return data;
} else {
return o;
}
});
}
}
if (userData.charges && userData.notifications && userData.holds && userData.checkouts) {
card.data = userData;
resolve(card);
}
return true;
});
});
await exports.login(page, card);
return await getUserData;
}
async function processUser(page, card) {
const data = logInAndGetData(page, card);
card = summarizeData(card);
if (card.data.returns?.length > 0) {
let renewals = await exports.renew(page, card.data.returns.filter((o) => o.renewalsRemaining > 0).map((o) => o.id));
console.log(renewals);
}
return summarizeData(card);
}
const checkoutLimit = 50;
function url(item) {
return 'https://torontopubliclibrary.ca' + (item.url || item.item.url);
}
function formatReport(cards) {
cards = cards.map(summarizeData);
let totalPickups = cards.reduce((prev, o) => prev + o.data.readyForPickup.length, 0);
let totalReturns = cards.reduce((prev, o) => prev + o.data.returns.length, 0);
let earliestPickup = cards.reduce((prev, o) => {
return o.data.readyForPickup.reduce((p2, hold) => {
if (!p2 || hold.readyDateExpiration < p2) {
return hold.readyDateExpiration;
} else {
return p2;
}
}, prev);
}, undefined);
let otherReturns = cards.reduce((prev, c) => {
return prev.concat(c.data.checkouts?.filter((o) => !o.toReturn));
}, []).sort(sortByDue).map(formatCheckout).join("\n");
let allCheckouts = cards.reduce((prev, o) => prev + o.data?.checkouts?.length, 0);
let inTransit = cards.reduce((prev, card) => prev.concat(card.data?.holds?.filter((h) => h.intransit).map((o) => { o.abbrev = card.abbrev; return o; })), [])
.map((o) => `${o.abbrev} ${o.item?.title} ${url(o)}`).join("\n");
let holds = cards.reduce((prev, o) => prev.concat(o.data?.holds.map((h) => { h.abbrev = o.abbrev; return h; })), []);
let pickupByUser = cards.map((c) => {
let space = c.limit - c.data.checkouts?.length || 0;
let spaceReport = (c.data.checkouts ? ((space > 0) ? space : ' NO SPACE') : 'UNKNOWN');
let holds = c.data.readyForPickup?.map((hold) => `${moment(hold.readyDateExpiration).diff(today, 'days')} ${hold.item.title} ${url(hold)}`).join("\n") || '';
let returns = c.data.returns?.map(formatCheckout).join("\n") || '';
let activeHolds = c.data?.holds?.filter((h) => h.status == 'PENDING');
return `* ${c.abbrev}: ${c.data.readyForPickup.length} / ${space} - transit ${c.data?.holds?.filter((h) => h.intransit).length} - active ${activeHolds.length} - return ${c.data.returns.length} - checked out ${c.data.checkouts.length}${holds ? "\n** Pick up\n" + holds : ''}${returns ? "\n** Returns\n" + returns : ''}`;
}).join("\n\n");
return `* ${today} Total for pickup: ${totalPickups}${earliestPickup ? ' in ' + moment(earliestPickup).diff(today, 'days') : ''} - return ${totalReturns} - checked out: ${allCheckouts}
${pickupByUser}${inTransit ? `\n* In transit\n${inTransit}` : ''}
* Other returns
${otherReturns}
* Active holds
${holds.filter((o) => o.status == 'PENDING').map((o) => `${o.abbrev} ${o.item.title} ${o.queuePosition}/${o.queueLength}x${o.item.circulatingCopies} ${url(o)}`).join("\n")}
`;
}
function formatCheckout(o) {
return `${o.abbrev} ${moment(o.dueDate).diff(today, 'days')}-${o.renewalsRemaining || 'X'} ${o.item.title} ${o.id} https://torontopubliclibrary.ca${o?.item?.url}`;
}
async function scrapeData() {
const browser = await puppeteer.launch({ headless: process.env.DEBUG != 'chrome',
args: ['--no-sandbox']});
for (let card of cards) {
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
await processUser(page, card);
}
browser.close();
fs.writeFileSync('data.json', JSON.stringify(cards));
return cards;
}
async function search(text) {
const body = await fetch('https://www.torontopubliclibrary.ca/search.jsp?N=37918+20206&Ntt=' + encodeURIComponent(text)).then((res) => res.text());
const $ = cheerio.load(body);
console.log($('.title a').map(function() {
return $(this).attr('href') + "\t" + $(this).text().trim().replace(' : ', ': ');
}).toArray().join('\n'));
}
function readTitleIDs() {
return [...fs.readFileSync(process.stdin.fd, 'utf-8').matchAll(/R=([0-9]+)/g)].map((m) => m[1]);
}
async function requestItems(card) {
const titleIDs = readTitleIDs();
const browser = await puppeteer.launch({ headless: process.env.DEBUG != 'chrome',
args: ['--no-sandbox']});
const page = await browser.newPage();
const data = await logInAndGetData(page, card);
await titleIDs.reduce(async (prev, val) => {
await prev;
await page.goto('https://www.torontopubliclibrary.ca/placehold?titleId=' + val);
await page.waitForSelector('#hold-button input');
await page.click('#hold-button input');
return await page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(err => {});
}, Promise.resolve());
await browser.close();
}
if (require.main === module) {
(async () => {
if (command == 'org') {
writeOrg();
return;
}
if (command == 'scrape') {
await scrapeData();
}
if (command == 'search') {
await search(process.argv.slice(3).join(' '));
}
else if (command == 'request') {
const abbrev = process.argv[3] || 'S';
await requestItems(cards.find((o) => o.abbrev == abbrev));
}
else {
let cards = require('./data.json');
let report = formatReport(cards);
if (process.env['ORG_FILE']) {
fs.writeFileSync(process.env['ORG_FILE'], report);
}
console.log(report);
}
})();
}
Read and mark up PDFs, extract images and text into Org Mode
Read blogs
I've been using NetNewsWire on my iPad. It's easy to add new blogs to it with the share command. When I come across an interesting quote, I:
- Use the Share menu to open the post in Google Chrome.
- Select the text and use "Copy Link with Highlight".
- I share the selected text to Ice Cubes for Mastodon.
- I format it as a blockquote with
>and add quotation marks. - I paste in the URL to the text fragment.
- I post the toot.
I set up NetNewsWire to synchronize with FreshRSS in a Docker container. Sometimes I use FreshRSS's web interface on my Linux laptop. I haven't yet figured out how to get elfeed and elfeed-protocol to properly synchronize with FreshRSS via the Fever protocol.
I like using minifeed.net and indieblog.page/all to find new blogs.
Draw my sketches, process them, and write about them
Lately, I've been drawing my sketches using Noteful on my iPad. I use this coloured dot grid I made for the SuperNote A5X as the background, and I have a landscape version as well.
I use a sketch ID of the form YYYY-MM-DD-NN. The number comes from my journaling system and allows me to uniquely identify the images.
I usually export the current page as an image and save it to Dropbox so that it gets synchronized with my laptop. Sometimes if I want to be able to resize, recolour, or animate elements, I'll export it as a PDF so that I can convert it to PNG.
Once I have the image file, I process it using
Emacs Lisp. The code is generally in the Supernote section of my configuration.
I can open the latest sketch with the
my-latest-sketch function that checks my
~/Dropbox/sketches and
~/Dropbox/Supernote/EXPORT directories for the
latest sketch, or I can just process it directly
with my-supernote-process-latest. Here's how the
code processes the sketch:
my-image-recognize: Use Google Cloud Vision to recognize the handwriting and convert it to text. Save the results in a .txt with the same base name as the image file.my-sketch-rename: If the file has an ID, rename the file based on the journal entry with that ID.- Is it an SVG or PDF? (Convert PDFs to SVG using pdftocairo)
my-sketch-svg-prepare:- Remove backgrounds
- Change colour references to hex
- Add a solid white background
- Change fill attributes to style
- Replace colours (useful for converting grayscale sketches from the Supernote)
- Is it a PNG or JPG?
- Replace colours (useful for converting grayscale sketches from the Supernote)
- Rotate and crop as needed.
my-image-store: If the file is properly renamed and tagged, store the fileset in my~/sync/private-sketchesdirectory if it has the private tag, or in my~/sync/sketchesdirectory otherwise. Syncthing copies the public directory to the server.- Recreate the JSON for sketches.sachachua.com based on the synchronized sketches so that it's available from my sketch viewer.
Once the JSON has been recreated, sketches.sachachua.com/YYYY-MM-DD-NN can redirect to sketches based on their IDs (ex: https://sachachua.com/2025-02-26-06).
my-supernote-process-latest also opens the
associated .txt file with the converted text. I
edit that. Useful shortcuts:
M-<up>andM-<down>: move paragraphs aroundM-S-<up>andM-S-<down>: move lines aroundM-O: I've mapped this tojoin-line.
After I correct the text, I usually draft a blog
post using my-write-about-sketch. This creates
an Org Mode subtree in my posts.org file with a
reference to the image and a details block that
includes the text. I use the custom my_details
block I defined using org-special-block-extras so
that the text is inside a
<details><summary>...</summary>...</details>
element.
From there, it's just the usual blog post workflow. After I publish the blog, sachachua.com/YYYY-MM-DD-NN redirects to the blog post instead. (ex: https://sachachua.com/2025-03-26-01)
Write a blog post
I draft blog posts using Org Mode in Emacs. I usually start writing them in either my posts.org or my inbox.org.
I like to add lots of links. I have custom Org
Mode link types to make it easy to link to blog
posts, sections of my Emacs configuration, project
files, sketches, Emacs Lisp functions, and more.
Then I can use C-c l (which I've bound to
org-store-link) to store the link and C-c C-l
to insert it.
When I'm ready to post, I use C-c e s 1 1 to export using my-org-11ty-export, which wraps around ox-11ty.el.
I use the 11ty static site generator to turn those HTML and JSON files into a blog with reverse-chronological archives, category pages, feeds, and so on.
Here's my rough workflow:
- Keep
npx eleventy --serverunning in the background, using.eleventyignoreto make rebuilds reasonably fast. (servein this Makefile) - Export the subtree with
C-c e s 1 1, which usesorg-export-dispatchto callmy-org-11ty-exportwith the subtree. - After about 10 seconds, use
my-org-11ty-copy-just-this-postand verify. - Use
my-mastodon-11ty-toot-postto compose a toot. Edit the toot and post it. - Check that the
EXPORT_MASTODONproperty has been set. - Export the subtree again, this time with the front matter.
- Publish my whole blog: (
generate-allin this Makefile)- Replace the contents of
.eleventyignorefile with a shorter one. - Generate the blog.
- Update the index using Pagefind.
- Rsync to the server.
- Restart the Nginx server just in case.
- Restore the longer
.eleventyignorefile so that dev builds are faster.
- Replace the contents of
Adding a comment from e-mail: my-message-add-blog-comment
Compile Emacs News
For Emacs News, my workflow goes like this:
- Upvote posts on Reddit.
- Add YouTube videos to a playlist. I search for
emacs | "org mode" | orgmode | eshell | "org roam"and sort by upload date, but I don't think the results are comprehensive, and I have to wade through a number of low-effort or irrelevant videos, so I might miss some good ones. - Update the Emacs Calendar.
- Generate the Emacs News list for the week by evaluating the block after "Actually generate the section".
- Announce GNU ELPA packages on info-gnu-emacs using
my-announce-elpa-package. - Collect links from Mastodon.
- Collect links from other sites.
- Check for duplicates with
my-emacs-news-check-duplicates. - Categorize links with
my-org-categorize-emacs-news/body. - Review and incorporate some of the emacs-devel links e-mailed by Andrés Ramiréz.
- Publish the Emacs News blog post.
- Publish the news as plain text, HTML, and attached Org file using
my-org-share-news. - Use
my-tweet-emacs-newsto post on Mastodon. I usually post on Bluesky as well. I'm probably going to phase out posting on X.
Emacs News functions are generally defined in sachac/emacs-news: Weekly Emacs news (index.org).
Livestream Emacs stuff - spontaneous
- Clean up my windows and browser tabs.
- In Emacs, create a task with the
:stream:tag and clock into it. - Go to https://studio.youtube.com.
- Confirm that I'm on the @sachactube channel.
- Click on Go live.
- Click on Edit. Change the title and confirm the privacy setting.
- Click on the copy icon in the thumbnail to copy the stream key.
- Open OBS.
- Switch to "Virtual with current task". Confirm that the task is displayed.
- Open Settings - Stream. Paste in the stream key.
Process a two-speaker recording
Setup: Configure OBS to record audio to different tracks.
- Audio source - Advanced Audio Properties - Tracks
- Mic: track 1 and track 2
- System audio: track 1 and track 3
- Settings - Output
- Output Mode: Advanced
- Recording settings - Audio track: check 1, 2, 3
After recording:
- Split the audio into separate WAVs and transcribe them separately with file:///home/sacha/bin/split-audio.
- Edit the VTT files for each track:
- Use file:///home/sacha/bin/subseg to resplit the TXT.
- Regenerate timestamps with
subed-align. - Correct transcript timing with
subed-word-data-fix-subtitle-timing. - Trim the timestamps as needed, using
subed-waveform-show-allas reference. - Edit the text and add
NOTEcomments to mark chapters. - Use
subed-word-data-add-word-timestampsif desired.
- Use
subed-vtt-combine-separate-speaker-filesto combine the two VTT files into one file sorted by timestamps. - Use
subed-section-comments-as-chaptersto copy the chapters to the kill ring. - To make a clean copy without word timestamps, save the combined VTT to another file and then use
subed-word-data-remove-word-timestamps. - Format the other speaker in a different way with
my-subed-format-second-speaker. - Use
my-subed-as-org-list-with-timesto insert the transcript into the Org Mode notes.
Update emacs.tv
- Select the subtree for the latest Emacs News.
- Call
M-x emacstv-add-from-org. - Open videos.org.
- Add tags if I feel like organizing things.
- Use
M-x emacstv-buildto sort the file and update various feeds. npm run buildgit commit -m "update" -a; git push
Routines
- Daily
- Get the kiddo through the day
- Play the piano
- Go for a long walk or bike ride
- Write or draw
- Draw moment of the day using Noteful on my iPad
- Update my web-based journal
- Check on my mom
- Weekly
- Mondays:
- Saturdays:
- Make sure my time records look sensible (no open-ended records, etc.)
my-org-prepare-weekly-review
- Other time during the week
- Check in with my consulting client
- Monthly
my-org-prepare-monthly-review- Update now page
- Add blog posts to my index
- Prepare invoice for consulting
- Check out IndieWeb Carnival for writing ideas
- Yearly
- August:
- Write and draw an annual review
- February:
- Write and draw an annual review for A+
- Celebrate A+'s birthday
- August:
Life
- I like using a Load 75 cargo bike to get around with the kiddo. I usually bring a Bakkie Bag so that I can tow A+'s bike when she gets tired.
- I'm slowly working on an Org Mode inventory of various things in the house.
- W- and I use OurGroceries to coordinate the grocery list.