Pavel 'Strajk' Dolecek

Pavel 'Strajk' Dolecek

// 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)
}
// Name: Speed Up Video
// Description: Speed up a video, optionally keeping the first and last X seconds at original speed
// Author: Strajk
import '@johnlindquist/kit';
// Get the video file path, either from selection or prompt
import { Choice } from '@johnlindquist/kit';
const videoPath = await getSelectedFile() || await path({ placeholder: 'Select a video file' });
const { stdout: videoDurationRaw } = await $`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${videoPath}`
const videoDuration = parseFloat(videoDurationRaw)
// Not sure if best way of handling error states
if (!videoDuration || videoDuration < 1) {
await div(`${videoPath} does not look like a video file, exiting`)
exit()
}
const { dir, name, ext } = path.parse(videoPath);
const outputPath = path.join(dir, `${name}-adjusted${ext}`);
const speedOptions: Choice[] = [
{ name: `2x (${Math.round(videoDuration / 2)}s)`, value: 2 },
{ name: `3x (${Math.round(videoDuration / 3)}s)`, value: 3 },
{ name: `4x (${Math.round(videoDuration / 4)}s)`, value: 4 },
{ name: `5x (${Math.round(videoDuration / 5)}s)`, value: 5 },
];
// Prompt user to select a speed
const speedRaw = await arg({
placeholder: `Select new playback speed, or enter a custom value`,
hint: `Any number or ";5;3;6" for 5x speed-up, but first 3 and last 6s are kept at original speed`,
choices: speedOptions,
strict: false, // allow arbitrary input
});
let mode: 'simple' | 'complex' = speedRaw.includes?.(';') ? 'complex' : 'simple'
if (mode === 'simple') {
let speed: number = parseFloat(speedRaw)
// -filter:v means video filter
// -filter:a means audio filter
// for uploading to twitter, i'm setting fps to 30
await term(`ffmpeg \
-i "${videoPath}" \
-filter:v "setpts=${1 / speed}*PTS,fps=30" \
-filter:a "atempo=${speed}" \
-y "${outputPath}"
`)
} else {
let [speedStr, introStr, outroStr] = speedRaw.split(';')
let speed = parseFloat(speedStr)
let intro = parseInt(introStr)
let outro = parseInt(outroStr)
// Note: ffmpeg is savage
await term(`ffmpeg -i "${videoPath}" -filter_complex "
[0:v]split=3[v1][v2][v3];
[v1]trim=0:${intro},setpts=PTS-STARTPTS,fps=30[first];
[v2]trim=${intro}:${videoDuration - outro},setpts=PTS-STARTPTS,setpts=${1 / speed}*PTS,fps=30[middle];
[v3]trim=${videoDuration - outro},setpts=PTS-STARTPTS,fps=30[last];
[first][middle][last]concat=n=3:v=1:a=0[outv]
" -y -map "[outv]" "${outputPath}"
`)
}
// Reveal output file in the system's file explorer
await revealFile(outputPath);
// Name: Chrome Profiles
// Description: List Chrome profiles and copy their path to clipboard/open in Finder
// Author: Strajk
import '@johnlindquist/kit'
import { Choice } from '@johnlindquist/kit/types';
const chromeAppSupportPaths = [
home('Library/Application Support/Google/Chrome/'),
home('Library/Application Support/Google/Chrome Canary/'),
home('Library/Application Support/Google/Chrome Dev/'),
]
let choices: Choice[] = []
for (const chromeAppSupportPath of chromeAppSupportPaths) {
if (!await pathExists(chromeAppSupportPath)) continue
const subdirs = await readdir(chromeAppSupportPath)
for (const subdir of subdirs) {
const profilePath = path.join(chromeAppSupportPath, subdir)
const profilePreferencesPath = path.join(profilePath, 'Preferences')
if (!await pathExists(profilePreferencesPath)) continue
try {
const preferencesRaw = await readFile(profilePreferencesPath, 'utf-8')
const preferencesJson = JSON.parse(preferencesRaw)
const useful = pickUsefulFromPreferences(preferencesJson)
let title = useful.accountEmail || useful.profileName
title += ` (created ${useful.profileCreationTime ? useful.profileCreationTime.toISOString().split('T')[0] : ''})`
let description = profilePath
let tag = ''
if (chromeAppSupportPath.includes('Google/Chrome Canary')) {
tag = 'Canary'
} else if (chromeAppSupportPath.includes('Google/Chrome Beta')) {
tag = 'Beta'
} else if (chromeAppSupportPath.includes('Google/Chrome Dev')) {
tag = 'Dev'
} else if (chromeAppSupportPath.includes('Google/Chrome')) {
tag = 'Stable'
}
let className = ''
if (isUnnamedProfile(title)) {
className = 'text-gray-400'
}
choices.push({
value: profilePath, // full path always as a value
img: useful.pictureUrl || useful.avatarUrl,
name: title,
description,
tag,
nameClassName: className,
})
} catch (e) {
console.warn(`Error parsing profile at ${profilePreferencesPath}`, e)
}
}
}
if (choices.length === 0) {
await div("No Chrome profiles found")
exit()
}
// Sort choices: regular profiles alphabetically first, then "Person X" and "Guest" profiles
choices.sort((a, b) => {
const aIsUnnamed = isUnnamedProfile(a.name)
const bIsUnnamed = isUnnamedProfile(b.name)
// If both are Unnamed profiles or both are not, sort alphabetically
if ((aIsUnnamed && bIsUnnamed) || (!aIsUnnamed && !bIsUnnamed)) {
return a.name.localeCompare(b.name)
}
// Put Unnamed profiles at the end
return aIsUnnamed ? 1 : -1
})
let selectedProfile = await arg({
placeholder: 'Select a profile',
enter: 'Copy Path',
actions: [
{
name: 'Open in Finder',
onAction: async (input, { focused }) => {
await revealFile(focused.value)
},
},
]
}, choices)
if (selectedProfile) {
await clipboard.writeText(selectedProfile)
notify(`Copied Profile path to clipboard`)
}
// HELPERS
// ===
function isUnnamedProfile(title: string) {
return title.match(/Person \d+|Guest/)
}
// Converts Windows FILETIME timestamp (100-nanosecond intervals since January 1, 1601)
// to Unix timestamp (seconds since January 1, 1970)
// See: https://learn.microsoft.com/en-us/windows/win32/sysinfo/file-times
function windowsTimestampToDate(windowsTime: number): Date {
try {
const n = Number(windowsTime) / 1e6 - 11644473600;
return new Date(n * 1000);
} catch (e) {
console.warn(`Error converting Windows timestamp ${windowsTime} to Date`, e);
return undefined
}
}
function pickUsefulFromPreferences(preferencesJson: any) {
return {
accountEmail: preferencesJson.account_info?.[0]?.email,
accountName: preferencesJson.account_info?.[0]?.full_name,
pictureUrl: preferencesJson.account_info?.[0]?.picture_url,
avatarUrl: avatarIdToUrl(preferencesJson.profile?.avatar_index),
profileName: preferencesJson.profile?.name,
profileCreationTime: windowsTimestampToDate(preferencesJson.profile?.creation_time),
lastEngagementTime: windowsTimestampToDate(preferencesJson.profile?.last_engagement_time),
extensionsCount: Object.keys(preferencesJson.extensions.install_signature?.ids || {}).length,
}
}
function chromeImageUrl(name: "stable" | "canary" | "dev" | "beta") {
let url = `https://raw.githubusercontent.com/chromium/chromium/refs/heads/main/chrome/app/theme/default_100_percent/common/`
if (name === "stable") {
url += "product_logo_16.png"
} else if (name === "canary") {
url += "product_logo_32_canary.png"
} else if (name === "dev") {
url += "product_logo_32_dev.png"
} else {
url += "product_logo_32_beta.png"
}
return url
}
function avatarIdToUrl(avatarId: number) {
// from https://gitlab.developers.cam.ac.uk/jz448/browser-android-tabs/-/blob/base-75.0.3770.67-brave-ads/chrome/app/theme/theme_resources.grd
const mapIdToFile = {
0: 'profile_avatar_generic.png',
1: 'profile_avatar_generic_aqua.png',
2: 'profile_avatar_generic_blue.png',
3: 'profile_avatar_generic_green.png',
4: 'profile_avatar_generic_orange.png',
5: 'profile_avatar_generic_purple.png',
6: 'profile_avatar_generic_red.png',
7: 'profile_avatar_generic_yellow.png',
8: 'profile_avatar_secret_agent.png',
9: 'profile_avatar_superhero.png',
10: 'profile_avatar_volley_ball.png',
11: 'profile_avatar_businessman.png',
12: 'profile_avatar_ninja.png',
13: 'profile_avatar_alien.png',
14: 'profile_avatar_awesome.png',
15: 'profile_avatar_flower.png',
16: 'profile_avatar_pizza.png',
17: 'profile_avatar_soccer.png',
18: 'profile_avatar_burger.png',
19: 'profile_avatar_cat.png',
20: 'profile_avatar_cupcake.png',
21: 'profile_avatar_dog.png',
22: 'profile_avatar_horse.png',
23: 'profile_avatar_margarita.png',
24: 'profile_avatar_note.png',
25: 'profile_avatar_sun_cloud.png',
26: 'profile_avatar_placeholder.png',
27: 'modern_avatars/origami/avatar_cat.png',
28: 'modern_avatars/origami/avatar_corgi.png',
29: 'modern_avatars/origami/avatar_dragon.png',
30: 'modern_avatars/origami/avatar_elephant.png',
31: 'modern_avatars/origami/avatar_fox.png',
32: 'modern_avatars/origami/avatar_monkey.png',
33: 'modern_avatars/origami/avatar_panda.png',
34: 'modern_avatars/origami/avatar_penguin.png',
35: 'modern_avatars/origami/avatar_pinkbutterfly.png',
36: 'modern_avatars/origami/avatar_rabbit.png',
37: 'modern_avatars/origami/avatar_unicorn.png',
38: 'modern_avatars/illustration/avatar_basketball.png',
39: 'modern_avatars/illustration/avatar_bike.png',
40: 'modern_avatars/illustration/avatar_bird.png',
41: 'modern_avatars/illustration/avatar_cheese.png',
42: 'modern_avatars/illustration/avatar_football.png',
43: 'modern_avatars/illustration/avatar_ramen.png',
44: 'modern_avatars/illustration/avatar_sunglasses.png',
45: 'modern_avatars/illustration/avatar_sushi.png',
46: 'modern_avatars/illustration/avatar_tamagotchi.png',
47: 'modern_avatars/illustration/avatar_vinyl.png',
48: 'modern_avatars/abstract/avatar_avocado.png',
49: 'modern_avatars/abstract/avatar_cappuccino.png',
50: 'modern_avatars/abstract/avatar_icecream.png',
51: 'modern_avatars/abstract/avatar_icewater.png',
52: 'modern_avatars/abstract/avatar_melon.png',
53: 'modern_avatars/abstract/avatar_onigiri.png',
54: 'modern_avatars/abstract/avatar_pizza.png',
55: 'modern_avatars/abstract/avatar_sandwich.png'
}
const file = mapIdToFile[avatarId]
return `https://raw.githubusercontent.com/chromium/chromium/refs/heads/main/chrome/app/theme/default_100_percent/common/${file}`
}
// Name: Night Shift
// Description: Control Night Shift on macOS
// Author: Pavel 'Strajk' Dolecek
// Acknowledgements:
// - https://github.com/smudge/nightlight
// - https://github.com/shmulvad/alfred-nightshift/
// Notes:
// nightlight CLI usage:
// on/off: `nightlight on|off|toggle`, status will show current state
// temp: `nightlight temp [0-100]`, no argument will show current temperature
// schedule: `nightlight schedule [start|off|HH:MM HH:MM]`
import "@johnlindquist/kit"
import {Choice} from "@johnlindquist/kit";
async function main() {
await execa('which', ['nightlight'])
.catch(async () => {
let choice = await arg({
placeholder: `Nightlight CLI required, but not installed, install?`,
choices: ['Yes', 'No']
})
if (choice !== 'Yes') {
await notify(`OK, not installing Nightlight CLI & exiting`)
exit() // exit whole script
}
console.log(`Installing Nightlight CLI... on Apple Silicon it might take a while because precompiled binaries are not available yet, see https://github.com/smudge/nightlight/issues/22`)
await terminal(`brew install smudge/smudge/nightlight`)
await arg("Press enter when installing in terminal finishes...")
console.clear() // Clear previous console.log
return main()
})
let status = execaSync('nightlight', ['status']).stdout
let temp = execaSync('nightlight', ['temp']).stdout
let choices: Choice[] = [{
name: 'Toggle',
value: 'toggle'
}, {
name: 'Off',
value: 'off'
}, {
name: 'On',
value: 'on'
}, {
name: 'On & 25%',
value: 'temp 25'
}, {
name: 'On & 50%',
value: 'temp 50'
}, {
name: 'On & 75%',
value: 'temp 75'
}, {
name: 'On & 100%',
value: 'temp 100'
}]
let choice = await arg({
placeholder: `Either select or type exact amount from 0 to 100`,
hint: `Current status: ${uppercaseFirst(status)} • Current temperature: ${temp}`,
choices,
strict: false // allow arbitrary input
})
let args = choice.split(' ')
let isArg0Number = !isNaN(parseInt(args[0]))
if (isArg0Number) {
args = ['temp', args[0]]
}
if (args[0] === 'temp') {
// if setting temp, turn on first
await execa('nightlight', ['on'])
}
await execa('nightlight', args)
.then(() => notify(`Nightlight set to: ${choice}`))
.catch(() => notify(`Failed to set Nightlight to: ${choice}`))
}
void main()
function uppercaseFirst(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// Name: Faker
// Description: Generate fake data with faker.js
// Author: Pavel 'Strajk' Dolecek
// Twitter: @straaajk
import "@johnlindquist/kit"
import { faker } from '@faker-js/faker';
import { Choice } from "@johnlindquist/kit";
const CACHE_EXPIRATION = 1000 * 60 * 60 * 24 * 30 // 1 month
// const CACHE_EXPIRATION = 1000 * 15 // 15 seconds // uncomment for debugging
// @ts-ignore kitCommand is defined, but it's not in types :(
const keyv = await store(kitCommand, {cachedChoices: null, cachedChoicesExpires: null})
const maybeCachedChoices = await keyv.get('choices') as Choice[] | null
const maybeCachedChoicesExpires = await keyv.get('cachedChoicesExpires') as number | null
const EXAMPLES_MAX_COUNT = 5
const EXAMPLES_MAX_CHARS = 120
const modulesToIgnore = [
"_defaultRefDate",
"_randomizer",
"datatype", // just boolean
"helpers",
"rawDefinitions",
"definitions",
]
const methodsToIgnore = [
"faker",
]
const pairsToIgnore = [
"internet.userName", // deprecated in favour of internet.username
"image.avatarLegacy", // deprecated in favour of image.avatar
// requires params, which would require special handling
"string.fromCharacters",
"date.between",
"date.betweens",
]
let choices = []
if (
maybeCachedChoices?.length > 0
&& maybeCachedChoicesExpires > Date.now()
) {
// console.log("Using cached choices")
choices = maybeCachedChoices as Choice[]
} else {
// console.log("Generating choices")
for (const module in faker) { // date, number, string, finance, ...
if (modulesToIgnore.includes(module)) {
continue;
}
for (const method in faker[module]) {
if (methodsToIgnore.includes(method)) {
continue;
}
const pair = `${module}.${method}`;
if (pairsToIgnore.includes(pair)) {
continue;
}
let examplesCount = 0;
let examplesText = "";
while (examplesText.length < EXAMPLES_MAX_CHARS && examplesCount < EXAMPLES_MAX_COUNT) {
const newExample = callFaker(module, method);
if (examplesText.length > 0) {
examplesText += " • "
}
examplesText += toString(newExample);
examplesCount++;
}
examplesText = examplesText.trim().substring(0, EXAMPLES_MAX_CHARS);
choices.push({
name: `${module}: ${method}`,
value: pair,
description: examplesText,
})
}
}
await keyv.set('choices', choices)
await keyv.set('cachedChoicesExpires', Date.now() + CACHE_EXPIRATION)
}
const selected = await arg({
placeholder: "Select a Faker module and method",
choices: choices,
enter: "Copy to clipboard",
})
let [module, method] = selected.split(".")
let value = toString(callFaker(module, method))
await copy(value)
await notify(`Copied: ${value}`)
// Helpers
// ===
function callFaker(module: string, method: string) {
try {
return faker[module][method]()
} catch (error) {
console.error(`Error calling faker method: ${module}.${method}`, error);
return
}
}
function toString(value: any): string {
if (typeof value === "string") {
return value
} else if (typeof value === "object") {
return JSON.stringify(value)
} else {
return value?.toString() || "❌"
}
}
// Name: Karabiner Elements Profile Switcher
// Acknowledgements:
// - https://www.alfredforum.com/topic/9927-karabiner-elements-profile-switcher/
// Notes:
// - Probably could also work with cli https://github.com/raycast/extensions/blob/be03024b4c4f4f1ad0af7f4d20ea4630d7f0ee20/extensions/karabiner-profile-switcher/src/model/KarabinerManager.ts
import "@johnlindquist/kit"
import fs from 'fs'
const CONFIG_PATH = home('.config/karabiner/karabiner.json')
const configJson = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
const profileChoices = configJson.profiles.map((profile: any) => ({
name: profile.name,
value: profile.name,
tag: profile.selected ? '🟢' : '', // Note: Not sure if there's a better way to highlight currenly active
}))
const selectedProfile = await arg({
placeholder: 'Select a profile',
choices: profileChoices,
})
for (const profile of configJson.profiles) {
profile.selected = profile.name === selectedProfile // intentional mutation
}
fs.writeFileSync(CONFIG_PATH, JSON.stringify(configJson, null, 2))
notify(`Karabiner Elements profile switched to "${selectedProfile}"`)
// Name: Disposable email
// Description: Generate a disposable email address, open the inbox in the browser, and copy the email address to the clipboard
// Acknowledgments:
// - https://www.alfredforum.com/topic/4643-temporary-email-%E2%80%94-generate-disposable-email-inboxes/
import "@johnlindquist/kit"
import { uniqueNamesGenerator, adjectives, animals, colors } from 'unique-names-generator';
// The ones with auto: true will copy the email address to clipboard, the ones with auto: false will not
const providers = {
"maildrop.cc": { name: "Maildrop", auto: true },
"harakirimail.com": { name: "Harakirimail", auto: true },
"incognitomail.co": { name: "Incognitomail", auto: false },
"temporarymail.com": { name: "Temporarymail", auto: false },
"mail.tm": { name: "Mail.tm", auto: false },
"dropmail.me": { name: "Dropmail", auto: false },
"guerrillamail.com": { name: "Guerrillamail", auto: false }
};
let provider = await arg({
placeholder: `Select Provider`,
choices: Object.entries(providers).map(([name, def]) => ({
name: def.name,
description: def.auto ? `🤖 Auto-copies email to clipboard` : `🫵 Manually copy email from website`,
value: name
}))
});
let def = providers[provider];
if (!def) throw new Error(`Invalid provider: ${provider}`);
if (def.auto) {
const desiredEmailName = uniqueNamesGenerator({
dictionaries: [adjectives, animals],
length: 2,
separator: '-',
});
const email = `${desiredEmailName}@${provider}`;
let url: string;
if (provider === 'maildrop.cc') {
url = `https://${provider}/inbox/?mailbox=${desiredEmailName}`;
} else {
url = `https://${provider}/inbox/${desiredEmailName}`;
}
await clipboard.writeText(email);
await open(url);
await notify(`Email: ${email} copied to clipboard`);
} else {
await open(`https://${provider}`);
await notify(`Get the email address from the website`);
}
// Name: Clear MacOS notifications
// Description: Only visible notifications – clearing not visible notifications is not possible
import "@johnlindquist/kit"
await jxa(`
Application("System Events")
.applicationProcesses.byName("NotificationCenter")
.windows[0]
.groups[0]
.scrollAreas[0]
.uiElements[0]
.groups()
.map(banner => banner.actions().slice(-1)[0])
.forEach(banner => banner.perform())
`)
// Inspired by Kit's "applescript" function
async function jxa(script: string) {
await writeFile(kenvTmpPath("clear-macos-notifications.jxa"), script);
await execa("osascript", ["-l", "JavaScript", kenvTmpPath("clear-macos-notifications.jxa")]);
}
// Name: Lyrics on Genius
// Description: Look up Lyrics of Current Song on Rap Genius
// Acknowledgments:
// - Ryan Rudzitis: Look up Lyrics of Current Song on Rap Genius
import "@johnlindquist/kit"
let appsToTry = [
"Music",
"Spotify"
]
let qs: string
for (let app of appsToTry) {
qs = await applescript(/* applescript */ `
tell application "${app}"
if player state is playing then
set aTrack to the current track
set aName to name of aTrack
set aArtist to artist of aTrack
return quoted form of (aArtist & " - " & aName)
else
return ""
end if
end tell
`)
if (qs) break
}
if (!qs) {
notify(`No music is playing in: ` + appsToTry.join(", "))
} else {
open(`http://genius.com/search?q=${qs}`)
}
// Name: Messages 2FA codes
// Description: Search for 2FA codes in your Messages, within the last 30 minutes
// Ackowledgements:
// - https://github.com/squatto/alfred-imessage-2fa/
// - https://github.com/raycast/extensions/tree/main/extensions/imessage-2fa
import "@johnlindquist/kit"
import Database from 'better-sqlite3';
let preferences = {
lookBackMinutes: 30,
ignoreRead: false,
}
export type TMessage = {
guid: string;
message_date: string; // 2024-11-26 06:11:18
sender: string; // e.g. amazon.de or +49123456789
service: string; // e.g. SMS
text: string;
}
const db = new Database(home("Library/Messages/chat.db"));
let output = await arg({
placeholder: "Select a message or start typing to search",
choices: async (input) => {
let stmt = db.prepare(dbQuery(input));
let messages = stmt.all() as TMessage[];
return messages.map((m) => ({
name: m.text,
tag: extractCode(m.text) ?? "no code",
description: `${m.message_date}${m.sender}${m.service}`,
value: m.text,
preview: `<div class="p-2 text-sm">${m.text}</div>`,
}));
},
actions: [
{
name: "Copy Whole Message",
flag: "copyWholeMessage",
visible: true,
shortcut: `${cmd}+c`,
},
]
})
if (flag.copyWholeMessage) {
clipboard.writeText(output)
notify("Whole message copied to clipboard")
} else {
let code = extractCode(output)
if (code) {
clipboard.writeText(code)
notify(`Code: ${code} copied to clipboard`)
} else {
clipboard.writeText(output)
notify("No code found. Copied whole message to clipboard instead")
}
}
// Helpers
// ===
function dbQuery(qs: string = "") {
let baseQuery = /* sql */`
select
message.guid,
message.rowid,
ifnull(handle.uncanonicalized_id, chat.chat_identifier) AS sender,
message.service,
datetime(message.date / 1000000000 + 978307200, 'unixepoch', 'localtime') AS message_date,
message.text
from message
left join chat_message_join on chat_message_join.message_id = message.ROWID
left join chat on chat.ROWID = chat_message_join.chat_id
left join handle on message.handle_id = handle.ROWID
where message.is_from_me = 0
and message.text is not null
and length(message.text) > 0
and
datetime(message.date / 1000000000 + strftime('%s', '2001-01-01'), 'unixepoch', 'localtime')
>=
datetime('now', '-${preferences.lookBackMinutes} minutes', 'localtime')
`;
if (preferences.ignoreRead) baseQuery += " and message.is_read = 0";
if (!qs) { // search for code
baseQuery = /* sql */`${baseQuery} and (
-- Matches 3 alphanumeric (e.g., 'ABC')
message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z]*'
-- Matches 4 alphanumeric (e.g., 'ABCD')
or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'
-- Matches 5 alphanumeric (e.g., 'ABCDE')
or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'
-- Matches 6 alphanumeric (e.g., 'ABCDEF')
or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'
-- Matches format '123-456'
or message.text glob '*[0-9][0-9][0-9]-[0-9][0-9][0-9]*'
-- Matches 7 alphanumeric (e.g., 'ABCDEFG')
or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'
-- Matches 8 alphanumeric (e.g., 'ABCDEFGH')
or message.text glob '*[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]*'
)`;
} else { // Search for text
baseQuery = /* sql */`${baseQuery} and message.text like '%${qs}%'`;
}
return `${baseQuery} \norder by message.date desc limit 100`.trim();
}
export function extractCode(original: string) {
// remove URLs
const urlRegex = new RegExp(
"\\b((https?|ftp|file):\\/\\/|www\\.)[-A-Z0-9+&@#\\/%?=~_|$!:,.;]*[A-Z0-9+&@#\\/%=~_|$]",
"ig"
);
let message = original.replaceAll(urlRegex, "");
if (message.trim() === "") return "";
let m;
let code;
// Look for specific patterns first
if ((m = /^(\d{4,8})(\sis your.*code)/.exec(message)) !== null) {
// 4-8 digits followed by "is your [...] code"
// examples:
// "2773 is your Microsoft account verification code"
code = m[1];
} else if (
(m = /(code\s*:|is\s*:|码|use code|autoriza(?:ca|çã)o\s*:|c(?:o|ó)digo\s*:)\s*(\w{4,8})($|\s|\\R|\t|\b|\.|,)/i.exec(
message
)) !== null
) {
// "code:" OR "is:" OR "use code", optional whitespace, then 4-8 consecutive alphanumeric characters
// examples:
// "Your Airbnb verification code is: 1234."
// "Your verification code is: 1234, use it to log in"
// "Here is your authorization code:9384"
// "【抖音】验证码9316,用于手机验证"
// "Your healow verification code is : 7579."
// "TRUSTED LOCATION PASSCODE: mifsuc"
// "Código de Autorização: 12345678"
code = m[2];
} else {
// more generic, brute force patterns
// remove phone numbers
// we couldn't do this before, because some auth codes resemble text shortcodes, which would be filtered by this regex
const phoneRegex = new RegExp(
// https://stackoverflow.com/a/123666
/(?:(?:\+?1\s*(?:[.-]\s*)?)?(?:\(\s*([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\s*\)|([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\s*(?:[.-]\s*)?)?([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\s*(?:[.-]\s*)?([0-9]{4})(?:\s*(?:#|x\.?|ext\.?|extension)\s*(\d+))?/,
"ig"
);
const originalMessage = message;
message = message.replaceAll(phoneRegex, "");
if ((m = /(^|\s|\\R|\t|\b|G-|:)(\d{5,8})($|\s|\\R|\t|\b|\.|,)/.exec(message)) !== null) {
// 5-8 consecutive digits
// examples:
// "您的验证码是 199035,10分钟内有效,请勿泄露"
// "登录验证码:627823,您正在尝试【登录】,10分钟内有效"
// "【赛验】验证码 54538"
// "Enter this code to log in:59678."
// "G-315643 is your Google verification code"
// "Enter the code 765432, and then click the button to log in."
// "Your code is 45678!"
// "Your code is:98765!"
code = m[2];
} else if ((m = /\b(?=[A-Z]*[0-9])(?=[0-9]*[A-Z])[0-9A-Z]{3,8}\b/.exec(message)) !== null) {
// 3-8 character uppercase alphanumeric string, containing at least one letter and one number
// examples:
// "5WGU8G"
// "Your code is: 5WGU8G"
// "CWGUG8"
// "CWGUG8 is your code"
// "7645W453"
code = m[0];
} else if ((m = /(^|code:|is:|\b)\s*(\d{3})-(\d{3})($|\s|\\R|\t|\b|\.|,)/i.exec(message)) !== null) {
// line beginning OR "code:" OR "is:" OR word boundary, optional whitespace, 3 consecutive digits, a hyphen, then 3 consecutive digits
// but NOT a phone number (###-###-####)
// examples:
// "123-456"
// "Your Stripe verification code is: 719-839."
// and make sure it isn't a phone number
// doesn't match: <first digits>-<second digits>-<4 consecutive digits>
const first = m[2];
const second = m[3];
code = `${first}${second}`;
} else if ((m = /(code|is):?\s*(\d{3,8})($|\s|\\R|\t|\b|\.|,)/i.exec(originalMessage)) !== null) {
// "code" OR "is" followed by an optional ":" + optional whitespace, then 3-8 consecutive digits
// examples:
// "Please enter code 548 on Zocdoc."
code = m[2];
} else {
// console.log("no code found in message");
}
}
return code;
}
// Name: Keyboard Maestro
// Description: Run or edit a Keyboard Maestro macro
// Author: Pavel 'Strajk' Dolecek
// Twitter: @straaajk
// Cache: true
import "@johnlindquist/kit"
import plist from 'plist'
import dedent from "dedent"
export type TMacro = Partial<{
name: string
uid: string
active: boolean
created: number
used: number
enabled: boolean
lastused: number
modified: number
saved: number
sort: string
triggers: Array<Partial<{ description: string, short: string, type: string }>>
}>
export type TMacroGroup = Partial<{
uid: string
enabled: boolean
name: string
sort: string
macros: TMacro[]
}>
let kmMacrosRaw = await applescript(dedent`
tell application "Keyboard Maestro Engine"
getmacros with asstring
end tell
`)
let kmMacrosParsed = plist.parse(kmMacrosRaw) as TMacroGroup[]
const choices = kmMacrosParsed.flatMap(group => group.macros.map(macro => ({
name: `${group.name} - ${macro.name}`,
value: macro.uid
})))
let chosen = await arg({
placeholder: "Choose a macro",
choices: choices,
actions: [
{
shortcut: `${cmd}+e`,
name: "Edit Macro",
visible: true,
flag: "edit"
}
]
})
if (flag.edit) { // Note that flag is a global
await applescript(dedent`
tell application "Keyboard
Maestro"
editMacro "${chosen}"
activate
end tell
`)
} else {
await applescript(dedent`
tell application "Keyboard Maestro Engine"
do script "${chosen}"
end tell
`)
}
// Name: Rename script files to match their name
// Description: Renames script file names to match their name (// Name: Foo Bar -> foo-bar.ts)
import "@johnlindquist/kit"
import { stripName } from "@johnlindquist/kit"
import * as fs from "fs/promises"
import dedent from "dedent"
// just for documentation purposes
let exampleScript = {
"command": "rename-script-files-to-match-their-name",
"filePath": "/Users/strajk/.kenv/scripts/rename-script-files-to-match-their-name.ts",
"id": "/Users/strajk/.kenv/scripts/rename-script-files-to-match-their-name.ts",
"name": "Rename script files to match their name",
"timestamp": 1732375034783,
"type": "Prompt"
// ...
}
let scripts = await getScripts()
for (let script of scripts) {
let normalizedName = stripName(script.name)
let extname = path.extname(script.filePath)
let basename = path.basename(script.filePath, extname)
if (basename !== script.command) {
console.log(dedent`
This should never happen, basename and command should be the same:
basename: ${basename}
command: ${script.command}
`)
notify(`basename: ${basename} command: ${script.command}`)
exit()
}
if (normalizedName !== basename) {
console.log(dedent`
"${script.name}":
current: ${basename}
normalized: ${normalizedName}
`)
let confirmed = await arg({
placeholder: `Rename "${basename}" to "${normalizedName}"`,
choices: [
{ name: "Yes", value: true },
{ name: "No", value: false }
]
})
if (!confirmed) {
continue
}
console.log(`Renaming "${basename}" to: ${normalizedName}`)
let newPath = path.join(path.dirname(script.filePath), normalizedName + extname)
await fs.rename(script.filePath, newPath)
}
}
// Name: Install app from dmg in Downloads
/// Implementation notes:
/// - dmg file name can be different from mounted volume name, e.g. UIBrowser302.dmg -> /Volumes/UI Browser 3.0.2.0
/// - dmg might contain License agreement that needs to be accepted, e.g. UIBrowser302.dmg
/// - dmg might contain other files than just the app, e.g. Extras folder and README.rtf, see UIBrowser302.dmg
import "@johnlindquist/kit"
import fs, {statSync, unlinkSync} from "fs";
import {join} from "path";
import * as luxon from "luxon"
import {execa} from "execa";
import {execSync} from "child_process"
let downloadsDir = home("Downloads") // Feel free to change
let dmgPaths = await globby("*.dmg", { cwd: downloadsDir })
let dmgObjs = dmgPaths.map(path => ({
fullPath: join(downloadsDir, path),
baseName: path.split("/").pop()?.replace(".dmg", ""),
createdAt: statSync(join(downloadsDir, path)).ctime.getTime(),
sizeInMb: statSync(join(downloadsDir, path)).size / 1024 / 1024
})).sort((a, b) => b.createdAt - a.createdAt)
if (dmgObjs.length === 0) {
setPlaceholder("No DMG files found in Downloads directory")
} else {
let selectedDmgPath = await arg({
placeholder: "Which dmg?",
choices: dmgObjs.map(dmg => ({
value: dmg.fullPath,
name: dmg.baseName,
description: `${luxon.DateTime.fromMillis(dmg.createdAt).toFormat('yyyy-MM-dd HH:mm')}${dmg.sizeInMb.toFixed(2)} MB`
}))
})
console.log(`Mounting ${selectedDmgPath}`)
let volumeName = await attachDmg(selectedDmgPath)
let mountPath = `/Volumes/${volumeName}`;
console.log(`Mounted to ${mountPath}`)
// Note: Globby did not work for me for mounted volumes
let apps = fs.readdirSync(mountPath).filter(f => f.endsWith(".app"))
if (apps.length === 0) {
setPlaceholder("No apps found in the mounted volume")
// TODO: Find a better way to do early returns/exits
} else {
let confirmed = await arg({
placeholder: `Found ${apps.length} apps: ${apps.join(", ")}, install?`,
choices: ["yes", "no"]
})
if (confirmed !== "yes") {
notify("Aborted")
process.exit(0)
}
for (let app of apps) {
console.log(`Copying ${app} to /Applications folder`);
await execa(`cp`, [
'-a', `${mountPath}/${app}`,
'/Applications/'
]);
}
console.log(`Detaching ${mountPath}`)
await detachDmg(mountPath)
let confirmDeletion = await arg({
placeholder: `Delete ${selectedDmgPath}?`,
choices: ["yes", "no"]
})
if (confirmDeletion === "yes") {
console.log(`Deleting ${selectedDmgPath}`)
await trash(selectedDmgPath)
}
}
}
// Helpers
// ===
async function attachDmg(dmgPath: string): Promise<string> {
// https://superuser.com/questions/221136/bypass-a-licence-agreement-when-mounting-a-dmg-on-the-command-line
let out = execSync(`yes | PAGER=cat hdiutil attach "${dmgPath}"`).toString()
let lines = out.split("\n").reverse()
// from the end, find line with volume name
// /dev/disk6s2 Apple_HFS /Volumes/UI Browser 3.0.2.0
let lineWithVolume = lines.find(line => line.includes("/Volumes/"))
if (!lineWithVolume) {
throw new Error(`Failed to find volume name in output: ${out}`)
}
let volumeName = lineWithVolume.split(`/Volumes/`)[1]
return volumeName
}
async function detachDmg(mountPoint: string) {
await execa('hdiutil', ['detach', mountPoint])
}

// Name: Log Past X Minutes to Calendar
// Description: Asks for duration in minutes, event title, and (on first use) calendar name
// Author: Pavel 'Strajk' Dolecek <www.strajk.me>
// Twitter: @straaajk
// me.strajk:status SHARED
const minutes = await arg("Enter the duration (in minutes) to log retroactively")
const title = await arg("Describe the activity you want to log")
let calendar = await env("SCRIPTKIT_LOG_CALENDAR_NAME", {
hint: `Enter the exact name of an existing calendar in your Calendar app`,
})
await applescript(`
tell application "Calendar"
switch view to week view
tell calendar "${calendar}"
set theCurrentDate to current date
make new event at end with properties {summary:"${title}", start date:theCurrentDate - ${minutes} * minutes, end date:theCurrentDate}
end tell
end tell
`)
await notify(`Logged ${minutes} minutes of ${title} to ${calendar}`)
// Menu: Super Search across multiple websites
// Description: Search multiple websites, in bulk, in your browser
// Author: Pavel 'Strajk' Dolecek <www.strajk.me>
// Twitter: @straaajk
//// Shortcut: command option ;
// me.strajk:status WIP
// BEWARE: Discord search requires my userscript:
// https://github.com/Strajk/setup/blob/master/user-scripts/discord-search-from-q-url-param.user.js
let templates = {
discord: `https://discord.com/channels/{slug}/?q={query}`,
githubRepo: `https://github.com/{slug}/issues?q={query}`,
githubDiscussion: `https://github.com/{slug}/discussions?discussions_q={query}`,
twitter: `https://x.com/search?q=from%3A{slug}+{query}`,
reddit: `https://www.reddit.com/r/{slug}/search?q={query}`,
// TODO:
// Slack: But even harder than Discord https://stackoverflow.com/questions/51541986/a-way-to-open-up-slack-search-ui-in-a-browser-from-a-url
}
// Each topic can only use search templates defined above (discord, githubRepo, githubDiscussion)
// but doesn't need to include all of them - just some are enough
// ⬇⬇⬇ EDIT TO YOUR LIKING ⬇⬇⬇
let topics: Record<string, Partial<{[K in keyof typeof templates]: string[]}>> = {
kit: {
discord: [`804053880266686464`],
githubRepo: [`johnlindquist/kit`],
githubDiscussion: [`johnlindquist/kit`],
twitter: [`scriptkitapp`],
},
litellm: {
discord: [`1123360753068540065`],
githubRepo: [`BerriAI/litellm`],
githubDiscussion: [`BerriAI/litellm`],
twitter: [`LiteLLM`],
},
wxt: {
discord: [`1212416027611365476`],
githubRepo: [`wxt-dev/wxt`],
githubDiscussion: [`wxt-dev/wxt`]
},
coolify: {
discord: [`459365938081431553`],
githubRepo: [`coollabsio/coolify`],
githubDiscussion: [`coollabsio/coolify`],
twitter: [`coolify`],
},
crawlee: {
discord: [`801163717915574323`],
githubRepo: [`apify/crawlee`],
githubDiscussion: [`apify/crawlee`]
},
mantine: {
discord: [`854810300876062770`],
githubRepo: [`mantinedev/mantine`],
githubDiscussion: [`orgs/mantinedev`], // note it's different from repo above
twitter: [`mantinedev`],
},
supabase: {
discord: [`839993398554656828`],
githubDiscussion: [`orgs/supabase`],
reddit: [`Supabase`],
githubRepo: [
`supabase/supabase`,
`supabase/supabase-js`,
`supabase/cli`,
`supabase/postgrest-js`,
`supabase/supabase-py`,
],
},
pnpm: {
discord: [`731599538665553971`],
githubRepo: [`pnpm/pnpm`],
githubDiscussion: [`pnpm/pnpm`],
},
ai: {
discord: [
`1110910277110743103`, // superagent
`1153072414184452236`, // autogen
`822583790773862470`, // latentspace
`1122748573000409160`, // ai stack devs
`877056448956346408`, // lablablab
],
},
nextjs: {
discord: [
`752553802359505017`, // nextjs
`966627436387266600`, // theo typesafe cult
],
},
plasmo: {
githubDiscussion: [`PlasmoHQ/plasmo`],
githubRepo: [`PlasmoHQ/plasmo`],
discord: [`946290204443025438`],
},
scraping: {
discord: [
`646150246094602263`, // scraping in prod
`851364676688543744`, // scrapy
`737009125862408274`, // scraping enthusiasts
],
}
}
let hint = `${isMac ? `` : `Control`}+o to edit`
let type = await arg({
placeholder: `What topic? ${hint}`,
choices: Object.keys(topics),
})
let query = await arg('Query?')
let topicObj = topics[type] // e.g. { discord: ['123', '345'], github: ['foo'] }
for (const [key, slugs] of Object.entries(topicObj)) {
// key is e.g. discord, githubIssues, githubDiscussions, ...
// slugs are e.g. ['12356', 'facebook/react']
for (const slug of slugs) {
// slug e.g. '12356', 'facebook/react'
let urlTemplate = templates[key] // e.g. 'github.com/{id}/issues?q={query}'
let url = urlTemplate
.replace('{slug}', slug) // e.g. 'facebook/react'
.replace('{query}', encodeURI(query)) // e.g. 'foo'
exec(`open ${url}`)
}
}