"Flashcards" – spaced-repeat images, managed just by filesystem

I have tried all the "flashcards" apps. Nothing has stuck with me. This is my final attemp which seems to finally work (for now :D). I just take screenshots of things I find interesting, move them to "flashcards" folder (using Keyboard maestro macro), and use Scriptkit to "spaced-repeat" them.

https://github.com/user-attachments/assets/515132a8-a039-4e49-b8f6-e4fda71a5846

Open flashcards in Script Kit

// Name: Flashcards
// Description: Spaced-repeat images, managed just by filesystem
// Shortcut: shift+ctrl+option+cmd+o
import '@johnlindquist/kit'
const flashcardsDir = kenvPath('db', 'flashcards')
await ensureDir(flashcardsDir)
const today = new Date()
let files = await readdir(flashcardsDir)
let renamed = 0
for (const file of files) {
if (!file.endsWith('.png')) continue
const parsed = parseFilename(file)
if (!parsed) {
renamed++
const max = currentMaxForDate(formatDate(today))
const newName = formatFilename(formatDate(today), max + 1, 0)
await rename(path.join(flashcardsDir, file), path.join(flashcardsDir, newName))
}
}
if (renamed > 0) {
notify(`Flashcards: Renamed ${renamed} files to match the flashcard format`)
files = await readdir(flashcardsDir) // refresh files after rename
}
let cards = files
.filter(file => file.endsWith('.png'))
.map(file => {
const parsed = parseFilename(file)!
return {
filename: file,
...parsed,
fullPath: path.join(flashcardsDir, file),
}
})
if (cards.length === 0) {
open(`file://${flashcardsDir}`)
await div(md(`
## No flashcards found in ${flashcardsDir}
Just add images (must be pngs!) there (I've opened the folder for you).
Don't worry about the filename, it will be renamed automatically on the next run.
After you are done, just rerun this script
`))
exit()
}
cards.sort((a, b) => {
if (a.nextReviewDate < formatDate(today) && b.nextReviewDate >= formatDate(today)) return -1
if (a.nextReviewDate >= formatDate(today) && b.nextReviewDate < formatDate(today)) return 1
return a.orderInBucket - b.orderInBucket
})
const reviewDays = [1, 2, 4, 7, 14, 30]
const shortcuts = {
"remember": 'r',
"forgot": 'f',
"delete": 'd',
}
const cardsToReview = cards.filter(card => {
const reviewDate = new Date(card.nextReviewDate)
return reviewDate <= today
})
const { size } = await getActiveScreen()
for (const card of cardsToReview) {
const days = reviewDays[Math.min(card.numberOfReviewsDone, reviewDays.length - 1)]
const nextReview = new Date()
nextReview.setDate(today.getDate() + days)
await div({
x: 50,
y: 50,
height: size.height - 100,
width: size.width - 100,
enter: "Close",
hint: `${cardsToReview.length} cards to review`,
html: `<img
src="file://${card.fullPath}"
style="width: 100%; height: 100%; object-fit: contain"
/>`,
className: "h-full w-full", // Otherwise the image could grow too much and cause y scroll
shortcuts: [
// Right bar
{
name: "Remember",
key: "Space",
bar: "right",
onPress: async () => {
const newFilename = formatFilename(
formatDate(nextReview),
card.orderInBucket,
card.numberOfReviewsDone + 1
)
await rename(
card.fullPath,
path.join(flashcardsDir, newFilename)
)
submit(null)
},
},
// Left bar
{
name: "Forgot",
key: shortcuts.forgot,
bar: "left",
onPress: async () => {
const lastCard = cards.reduce((max, card) => Math.max(max, card.orderInBucket), 0)
const newFilename = formatFilename(formatDate(today), lastCard + 1, 0)
await rename(card.fullPath, path.join(flashcardsDir, newFilename))
submit(null)
},
},
{
name: "Delete",
key: shortcuts.delete,
bar: "left",
onPress: async () => {
await trash(card.fullPath)
submit(null)
},
},
{
name: "Reveal in Finder",
key: "r",
bar: "left",
onPress: async () => {
// -R reveals the file in Finder instead of opening it
await exec(`open -R "${card.fullPath}"`)
},
},
],
})
}
say(`Time flows like water,
Knowledge grows with each review,
Mind blooms day by day`)
await div(
md(`
# That's all folks!
All flashcards have been reviewed.
See ya next time!
`)
)
// Helpers
// ===
function parseFilename(filename: string) {
const match = filename.match(/^CARD_(\d{4}-\d{2}-\d{2})_(\d+)_(\d+)\.png$/)
if (!match) return null
return {
nextReviewDate: match[1],
orderInBucket: parseInt(match[2], 10),
numberOfReviewsDone: parseInt(match[3], 10),
}
}
function formatFilename(nextReviewDate: string,
orderInBucket: number,
numberOfReviewsDone: number) {
return `CARD_${nextReviewDate}_${orderInBucket}_${numberOfReviewsDone}.png`
}
function currentMaxForDate(date: string) {
const files = readdirSync(flashcardsDir)
return files.filter(file => file.startsWith(`CARD_${date}_`)).map(file => parseFilename(file)!.orderInBucket).reduce((max, order) => Math.max(max, order), 0)
}
function formatDate(date: Date) {
return date.toISOString().slice(0, 10)
}