Scripts by zzeleznick

Low-Fi Finder Image Grid View 🖼️

Low-Fi Finder Image Grid View 🖼️

While list views are nice, sometimes you just want a grid 😄. Here, I played around with displaying images in a grid layout.

I'd even say that grids feel like a natural extension to the layouts in #231 🤓

I started this project a little while back, and am currently using the html panel as opposed to choice.html. To support custom actions on selection (e.g. copy image filepath to clipboard on choice selection) within a grid context, I think that there is at least one missing piece.

Enable setting className / styles of choice button parent container

In my example, I'm doing a self-described css injection to add new styles (e.g. .grid-cols-3 {grid-template-columns: repeat(3, minmax(0, 1fr))}), but the styles probably could be done in-line.

Demo

image_grid

Code

lib/image-grid.js
// lib/image-grid.js
const DEFAULT_LIMIT = 10000; // 1000; // 100;
const DEBUG = { ENABLED: false };
const debug = (...args) => DEBUG.ENABLED && console.log(...args)
const info = (...args) => console.log(...args)
export const enableDebugMode = () => { DEBUG.ENABLED = true }
const getImages = (filepath, maxdepth) => {
// NOTE: options to use "-ctime -90d" / "-atime -90d" to filter more results
const findCommand = `find -E ${filepath} -iregex '.*\.(jpg|jpeg|png|gif)' -maxdepth ${maxdepth}`
const findSortedCommand = `${findCommand} -print0 | xargs -0 ls -at`
debug("findSortedCommand", findSortedCommand)
return exec(findSortedCommand, { silent: true }).toString().split("\n").filter(v => v)
}
const buildImageModal = (payload) => {
let {file} = payload;
const img = `<img src="${file}">`
return `<div class="imgContainer">${img}</div>`
}
const injectCss = (html) => {
// our tailwind build doesn't include grid css
// we add some custom styles as well
const css = `
/* Mimic tailwind grid css */
.grid {display:grid}
.grid-cols-3 {grid-template-columns: repeat(3, minmax(0, 1fr))}
.grid-cols-4 {grid-template-columns: repeat(4, minmax(0, 1fr))}
.grid-cols-5 {grid-template-columns: repeat(5, minmax(0, 1fr))}
/* custom css to center images in grid */
.grid div {place-items: center; padding: clamp(1px, 4%, 25px);}
.imgContainer {display: flex;}
`
const style = `<style type="text/css">${css}</style>`
return `${style}${html}`
}
const buildPage = (imageObjects, limit = DEFAULT_LIMIT) => {
const subset = imageObjects
.slice(0, limit)
.map(file => { return { file } })
const columns = subset.length > 32 ? (subset.length > 64 ? 5 : 4) : 3
const modals = subset.map(buildImageModal).join('\n')
const html = `<div class="grid grid-cols-${columns} pt-1 m-1">${modals}</div>`
const page = injectCss(html)
debug(page);
info('buildPage: Done')
return page
}
export const buildImagesPanel = async (filepath, maxdepth, limit) => {
const images = getImages(filepath, maxdepth);
info(`Found ${images.length} images`);
await arg({
input: " ",
}, buildPage(images, limit));
}
view-desktop.js
// Menu: View Desktop
// Description: View Desktop Attachments
// Author: Zach Zeleznick
// Twitter: @zzxiv
// Shortcut: cmd shift d
const {buildImagesPanel} = await lib("image-grid")
const filepath = "~/Desktop"
const depth = "3"
await buildImagesPanel(filepath, depth)
view-attachments.js
// Menu: View Attachment
// Description: View iMessage Attachments
// Author: Zach Zeleznick
// Twitter: @zzxiv
// Shortcut: cmd shift l
const {buildImagesPanel} = await lib("image-grid")
// NOTE: Need to grant Kit app full disk access in Security and Privacy or find will return 0 results
const filepath = "~/Library/Messages/Attachments"
const depth = "4"
await buildImagesPanel(filepath, depth)
view-downloads.js
// Menu: View Download
// Description: View Download Attachments
// Author: Zach Zeleznick
// Twitter: @zzxiv
// Shortcut: cmd shift 0
const {buildImagesPanel, enableDebugMode} = await lib("image-grid")
const filepath = "~/Downloads"
const depth = "2"
const limit = 42
enableDebugMode()
await buildImagesPanel(filepath, depth, limit)

Reference

Here are a few screenshots of the native Finder window on macOS 10.14 that I intended to recreate in low-fidelity.

Native ViewSorting Options
Example search for `png` files on my DesktopSorting options
Discuss Post

View Scripts Kit Scripts in Script Kit 🤯

View Scripts Kit Scripts in Script Kit 🤯

In today's microsite adventure, I built @johnlindquist's scriptkit.app page in Kit.

Demo

kit-scripts

Bells & Whistles

  • Uses a cache to only fetch files from Github when the local cache files are missing or changed
  • Applies light/dark theme auto-magically
  • Should be fairly easy to extend to support viewing files in other repos / paths

Code

Click for code
// Menu: Kit Scripts
// Description: View + Copy Scripts
// Author: Zach Zeleznick
// Twitter: @zzxiv
const Prism = await npm('prismjs')
const scriptsDB = db("kit-scripts", { scripts: [] });
const scriptsRef = scriptsDB.get("scripts");
const owner = `eggheadio`
const repo = `scriptkit.app`
const branch = `main`
const author = `johnlindquist`
const treepath = `public/scripts/${author}`
const ref = `${branch}:${treepath}`
const githubURL = "https://api.github.com/graphql";
let token = env.GITHUB_ACCESS_TOKEN;
const config = {
headers: {
"Authorization": `Bearer ${token}`,
}
}
if (!token) {
const element = `
<div class="flex flex-col justify-center">
<div>
<a href="https://github.com/settings/tokens/new">Create a token</a> with "public_repo" enabled.
</div>
<br>
<div>
Then, copy + paste the token above or set <code>GITHUB_REPO_TOKEN</code> inside <code>~/.kenv/.env</code>
</div>
</div>`
token = await env("GITHUB_ACCESS_TOKEN", {
info: `Create and enter your personal access token`,
choices: element,
});
}
const repoTreeQuery = `
query {
repository(owner: "${owner}", name: "${repo}") {
object(expression: "${ref}") {
... on Tree {
entries {
name,
oid,
}
}
}
}
}`
const fetchTreeObjects = async () => {
let response;
try {
response = await post(githubURL,
{
query: repoTreeQuery
},
config
);
}
catch (err) {
console.warn("fetchTreeObjects failed:", err);
return
}
const graphqlResponse = response.data;
// console.log(repoTreeQuery, graphqlResponse);
const {
data: {
repository: {
object: {
entries
}
}
}
} = graphqlResponse;
return entries
}
const fetchScript = async (name) => {
const scriptUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${treepath}/${name}`;
// NOTE: https://scriptkit.app/scripts/${author}/${name} should also work by design
const response = await get(scriptUrl);
return response.data;
}
// adapted from kit/cli/info.js
const getByMarker = marker => text => {
const exp = new RegExp(`${marker}(.*)`);
const match = text.match(exp)
if (!match) return
return match[1].trim()
}
const extractMetadata = (text) => {
const mapping = {
menu: 'Menu:',
description: 'Description:',
author: 'Author:',
twitter: 'Twitter:',
}
return Object.entries(mapping)
.reduce((a, [k,v]) => {
return {...a, [k]: getByMarker(v)(text)}
}, {});
}
const loadScriptBundle = async (name, oid) => {
const entry = scriptsRef.find({ name });
const cached = entry.value();
if (cached) {
console.log(`Found cached for: ${name}`);
const localOid = cached.oid;
if (localOid === oid) {
console.log(`No remote changes for: ${name}`);
return cached
}
console.log(`Git object mismatch for: ${name} – local:${localOid} != remote:${oid}`);
}
console.log(`Fetching remote ${name}`);
const text = await fetchScript(name);
const metadata = extractMetadata(text);
console.log(`Fetched remote ${name} with metadata: ${JSON.stringify(metadata)}`);
const payload = { ...metadata, text, name, oid}
// TODO: should probably remove old files
// TODO: should clean up this function – doing too much
if (cached) {
entry.assign(payload).write()
} else {
scriptsRef.insert(payload).write();
}
return payload
}
// MARK: currently unused
const injectCustomClass = async () => {
// Load per suggestion on https://github.com/PrismJS/prism/issues/1171#issuecomment-470929808
// Source: https://github.com/PrismJS/prism/blob/master/plugins/custom-class/prism-custom-class.js
await npm('prismjs/plugins/custom-class/prism-custom-class')
// injects into Prism.plugins (e.g run 'console.log(Object.keys(Prism.plugins))' before + after)
Prism.plugins.customClass.add(({language, type, content}) => {
if (language === 'javascript') {
return 'overflow-scroll';
}
});
}
const buildCodeBlock = (code) => {
const html = Prism.highlight(code, Prism.languages.javascript, 'javascript');
return `<div class="h-full p-1 pt-2 pb-2 text-xs w-screen"><pre><code>${html}</code></pre></div>`
}
const smallTextify = (field) => {
return field ? `<div class="text-xs">${field}</div>` : ''
}
// NOTE: couldn't trigger the app.on('open-url') and would instead get the app in a bad state ...
// const buildUrl = (name) => `kit://${name.split('.')[0]}?url=https://${repo}/scripts/${author}/${name}`
const buildUrl = (name) => `https://${repo}/scripts/${author}/${name.split('.')[0]}`
const buildCodeModal = (payload) => {
let {name, text: code, description, author, twitter} = payload;
const block = buildCodeBlock(code)
const download = `<a class="group font-mono font-bold inline-flex" href="${buildUrl(name)}">Install</a>`
name = name ? `<div class="text-lg font-mono font-bold">${name.split('.')[0]}</div>` : ''
const row = `<div class="flex w-full justify-between">${name}${download}</div>`
const meta = [row].concat([description, author, twitter].map(smallTextify)).join('\n');
// ideally add some fancier styles like 'box-border border-4 bg-white' here
const metaStyle = "border-bottom: 2px solid rgba(0, 0, 0, .025)"
const header = `<div class="h-full p-3" style="${metaStyle}">${meta}</div>`
const style = "border: 2px solid rgba(0, 0, 0, .05); overflow: scroll;"
return `<div class="h-full w-full p-1 pb-2 mb-2 " style="${style}">${header}${block}</div>`
}
const injectCss = (html) => {
// see https://unpkg.com/prism-theme-night-owl@1.4.0/build/light.css
// source: https://github.com/SaraVieira/prism-theme-night-owl
const css = `code[class*=language-],pre[class*=language-]{color:#403f53;font-family:Consolas,Monaco,"Andale Mono","Ubuntu Mono",monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#fbfbfb}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#fbfbfb}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{color:#fff;background:#fbfbfb}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.prolog{color:#989fb1;font-style:italic}.token.punctuation{color:#994cc3}.namespace{color:#0c969b}.token.deleted{color:rgba(239,83,80,.56);font-style:italic}.token.keyword,.token.operator,.token.property,.token.symbol{color:#0c969b}.token.tag{color:#994cc3}.token.boolean{color:#bc5454}.token.number{color:#aa0982}.language-css .token.string,.style .token.string,.token.builtin,.token.char,.token.constant,.token.entity,.token.string,.token.url{color:#4876d6}.token.doctype,.token.function,.token.selector{color:#994cc3;font-style:italic}.token.attr-name,.token.inserted{color:#4876d6;font-style:italic}.token.atrule,.token.attr-value,.token.class-name{color:#111}.token.important,.token.regex,.token.variable{color:#c96765}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}`
const style = `<style type="text/css">${css}</style>`
return `${style}${html}`
}
const createRegEx = (input = '') => {
input = input.trim().toLowerCase()
let matcher = input
try {
matcher = new RegExp(input)
} catch (err) {
console.warn("Invalid expression", input)
}
return matcher
}
const fetchAllFileObjects = async () => {
const entries = await fetchTreeObjects();
const limit = 50; // fake limit
const promises = entries.slice(0,limit).map(({name, oid}) => loadScriptBundle(name, oid));
return await Promise.all(promises);
}
const buildPage = (fileObjects) => (input) => {
const matcher = createRegEx(input)
const modals = fileObjects
.filter(({name}) => name.match(matcher) !== null)
.map(buildCodeModal)
const results = `<div style="overflow: hidden;">${modals.join('\n')}</div>`
const metaPanel = `<div class="text-xl font-semibold font-mono pb-2">Found ${modals.length} hits</div>`
const html = `<div>${metaPanel}${results}</div>`
const page = injectCss(html)
console.log(page);
return page
}
const buildScriptRxPanel = async () => {
const objects = await fetchAllFileObjects();
await arg({
message: "Search for scripts:",
input: "",
}, buildPage(objects));
}
await buildScriptRxPanel()
Discuss Post

Emoji Picker ⚒️

Emoji Picker ⚒️

While you can press ctrl + cmd + space to launch the native emoji keyboard, or select an emoji from newer Mac's touch-bar, sometimes it's fun to build your own!

Here, you can find two demos – the first uses the panel html feature to enable regex filtering of the emojis, whereas the second uses the built in filtering for choices.

While currently the UI's don't match, perhaps in future releases, the same look could be achieved 😄

Panel Demo

Choices Demo

Code

Click for code
// Menu: Kit Emojis
// Description: View + Copy Emojis
// Author: Zach Zeleznick
// Twitter: @zzxiv
// Shortcut: cmd e
const emojisDB = db("emojis", { emojis: {} });
const emojisRef = emojisDB.get("emojis");
// NOTE: Should extract this into a lib since emojis db used in kit-discussions ...
const fetchEmojis = async () => {
// Could install and use as an npm package, but we just need a k-v map ...
const emojiURL = 'https://raw.githubusercontent.com/omnidan/node-emoji/master/lib/emoji.json';
const response = await get(emojiURL);
const emojis = response.data;
emojisDB.set("emojis", emojis).write();
}
const setupEmojis = async () => {
const emojis = emojisRef.value();
if (!emojis || !Object.keys(emojis).length) {
await fetchEmojis()
}
return emojis
}
const createRegEx = (input = '') => {
input = input.trim().toLowerCase()
// NOTE: don't check length here for snappy ux
// input = input.length < 3 ? '' : input
let matcher = input
try {
matcher = new RegExp(input)
} catch (err) {
console.warn("Invalid expression", input)
}
return matcher
}
const showEmojis = (emojis) => (input) => {
const matcher = createRegEx(input)
const inner = Object.entries(emojis)
.filter(([k,v]) => k.match(matcher) !== null)
.map(([k,v]) => `<div class="flex h-10 w-full justify-start items-center">
<div class="text-base font-bold font-sans mr-8"> ${v} </div>
<div class="text-xs font-mono"> ${k} </div>
</div>`).join('\n');
const html = `
<div class="grid grid-cols-1">
${inner}
</div>`
return html
}
const buildEmojisRxPanel = async () => {
const emojis = await setupEmojis();
await arg({
message: "Search for emoji:",
input: "",
}, showEmojis(emojis));
}
const buildEmojisChoices = async () => {
const emojis = await setupEmojis();
const choices = Object.entries(emojis)
.map(([k,v]) => {
return {
name: k,
value: v,
html: `<div> ${v} </div>`
}
});
const emoji = await arg("Search for emoji:", choices);
copy(emoji);
}
const panel = true // would be nice to set based on whether shift is pressed with shortcut cmd
panel ? await buildEmojisRxPanel() : await buildEmojisChoices();
// NOTE: tabs don't play nicely with choices + panels
// onTab("Choices", buildChoicesEmojis);
// onTab("Panel", buildReactivePrompt);

Appendix

I've been a fan of emojipedia.org, but now that I learned the mac keyboard shortcut (ctrl + cmd + space), I might use it more often...

Emoji Keyboard Emoji Keyboard Search
Mac Emoji Keyboard on terminal cursor Mac Emoji Keyboard supports search
Emoji Character Viewer Emoji Viewer Search
Mac Emoji Character Viewer Menu Mac Character Viewer Menu also supports search

PS. For future work, it could be cool to extend the emoji list (see instructions on node-emoji) to support adding custom emojis.

Discuss Post

CSS Named Colors 🎨

CSS Named Colors

I've been an admirer of the microsite colours.neilorangepeel.com that displays the named css colors, which I've used for selecting background colors for reveal.js presentations.

Using the new panel feature (see #95 and #100), I built the colours microsite in Kit.

Demo

kit-colors

Notes

  • I expected the tabs component to scroll on x-overflow instead of increasing the width of the page
  • For each tab, I'd ideally be able to set a custom style (e.g. solid background) or provide an html element instead of displaying the color text
  • Text input currently doesn't do anything – I was hoping that the panel would be displayed on the first render without needing to type a character to show the panel

Code

Click for code
// Menu: Kit Colors
// Description: View CSS Colors
// Author: Zach Zeleznick
// Twitter: @zzxiv
// Inspired by colours.neilorangepeel.com
// Also see colors.commutercreative.com/grid
const colors = {"aliceblue":[240,248,255],"antiquewhite":[250,235,215],"aqua":[0,255,255],"aquamarine":[127,255,212],"azure":[240,255,255],"beige":[245,245,220],"bisque":[255,228,196],"black":[0,0,0],"blanchedalmond":[255,235,205],"blue":[0,0,255],"blueviolet":[138,43,226],"brown":[165,42,42],"burlywood":[222,184,135],"cadetblue":[95,158,160],"chartreuse":[127,255,0],"chocolate":[210,105,30],"coral":[255,127,80],"cornflowerblue":[100,149,237],"cornsilk":[255,248,220],"crimson":[220,20,60],"cyan":[0,255,255],"darkblue":[0,0,139],"darkcyan":[0,139,139],"darkgoldenrod":[184,134,11],"darkgray":[169,169,169],"darkgreen":[0,100,0],"darkgrey":[169,169,169],"darkkhaki":[189,183,107],"darkmagenta":[139,0,139],"darkolivegreen":[85,107,47],"darkorange":[255,140,0],"darkorchid":[153,50,204],"darkred":[139,0,0],"darksalmon":[233,150,122],"darkseagreen":[143,188,143],"darkslateblue":[72,61,139],"darkslategray":[47,79,79],"darkslategrey":[47,79,79],"darkturquoise":[0,206,209],"darkviolet":[148,0,211],"deeppink":[255,20,147],"deepskyblue":[0,191,255],"dimgray":[105,105,105],"dimgrey":[105,105,105],"dodgerblue":[30,144,255],"firebrick":[178,34,34],"floralwhite":[255,250,240],"forestgreen":[34,139,34],"fuchsia":[255,0,255],"gainsboro":[220,220,220],"ghostwhite":[248,248,255],"gold":[255,215,0],"goldenrod":[218,165,32],"gray":[128,128,128],"green":[0,128,0],"greenyellow":[173,255,47],"grey":[128,128,128],"honeydew":[240,255,240],"hotpink":[255,105,180],"indianred":[205,92,92],"indigo":[75,0,130],"ivory":[255,255,240],"khaki":[240,230,140],"lavender":[230,230,250],"lavenderblush":[255,240,245],"lawngreen":[124,252,0],"lemonchiffon":[255,250,205],"lightblue":[173,216,230],"lightcoral":[240,128,128],"lightcyan":[224,255,255],"lightgoldenrodyellow":[250,250,210],"lightgray":[211,211,211],"lightgreen":[144,238,144],"lightgrey":[211,211,211],"lightpink":[255,182,193],"lightsalmon":[255,160,122],"lightseagreen":[32,178,170],"lightskyblue":[135,206,250],"lightslategray":[119,136,153],"lightslategrey":[119,136,153],"lightsteelblue":[176,196,222],"lightyellow":[255,255,224],"lime":[0,255,0],"limegreen":[50,205,50],"linen":[250,240,230],"magenta":[255,0,255],"maroon":[128,0,0],"mediumaquamarine":[102,205,170],"mediumblue":[0,0,205],"mediumorchid":[186,85,211],"mediumpurple":[147,112,219],"mediumseagreen":[60,179,113],"mediumslateblue":[123,104,238],"mediumspringgreen":[0,250,154],"mediumturquoise":[72,209,204],"mediumvioletred":[199,21,133],"midnightblue":[25,25,112],"mintcream":[245,255,250],"mistyrose":[255,228,225],"moccasin":[255,228,181],"navajowhite":[255,222,173],"navy":[0,0,128],"oldlace":[253,245,230],"olive":[128,128,0],"olivedrab":[107,142,35],"orange":[255,165,0],"orangered":[255,69,0],"orchid":[218,112,214],"palegoldenrod":[238,232,170],"palegreen":[152,251,152],"paleturquoise":[175,238,238],"palevioletred":[219,112,147],"papayawhip":[255,239,213],"peachpuff":[255,218,185],"peru":[205,133,63],"pink":[255,192,203],"plum":[221,160,221],"powderblue":[176,224,230],"purple":[128,0,128],"rebeccapurple":[102,51,153],"red":[255,0,0],"rosybrown":[188,143,143],"royalblue":[65,105,225],"saddlebrown":[139,69,19],"salmon":[250,128,114],"sandybrown":[244,164,96],"seagreen":[46,139,87],"seashell":[255,245,238],"sienna":[160,82,45],"silver":[192,192,192],"skyblue":[135,206,235],"slateblue":[106,90,205],"slategray":[112,128,144],"slategrey":[112,128,144],"snow":[255,250,250],"springgreen":[0,255,127],"steelblue":[70,130,180],"tan":[210,180,140],"teal":[0,128,128],"thistle":[216,191,216],"tomato":[255,99,71],"turquoise":[64,224,208],"violet":[238,130,238],"wheat":[245,222,179],"white":[255,255,255],"whitesmoke":[245,245,245],"yellow":[255,255,0],"yellowgreen":[154,205,50]};
const colorGroups = {"pink":["pink","lightpink","hotpink","deeppink","palevioletred","mediumvioletred"],"purple":["lavender","thistle","plum","orchid","violet","fuchsia","magenta","mediumorchid","darkorchid","darkviolet","blueviolet","darkmagenta","purple","mediumpurple","mediumslateblue","slateblue","darkslateblue","rebeccapurple","indigo"],"red":["lightsalmon","salmon","darksalmon","lightcoral","indianred","crimson","red","firebrick","darkred"],"orange":["orange","darkorange","coral","tomato","orangered"],"yellow":["gold","yellow","lightyellow","lemonchiffon","lightgoldenrodyellow","papayawhip","moccasin","peachpuff","palegoldenrod","khaki","darkkhaki"],"green":["greenyellow","chartreuse","lawngreen","lime","limegreen","palegreen","lightgreen","mediumspringgreen","springgreen","mediumseagreen","seagreen","forestgreen","green","darkgreen","yellowgreen","olivedrab","darkolivegreen","mediumaquamarine","darkseagreen","lightseagreen","darkcyan","teal"],"cyan":["aqua","cyan","lightcyan","paleturquoise","aquamarine","turquoise","mediumturquoise","darkturquoise"],"blue":["cadetblue","steelblue","lightsteelblue","lightblue","powderblue","lightskyblue","skyblue","cornflowerblue","deepskyblue","dodgerblue","royalblue","blue","mediumblue","darkblue","navy","midnightblue"],"brown":["cornsilk","blanchedalmond","bisque","navajowhite","wheat","burlywood","tan","rosybrown","sandybrown","goldenrod","darkgoldenrod","peru","chocolate","olive","saddlebrown","sienna","brown","maroon"],"white":["white","snow","honeydew","mintcream","azure","aliceblue","ghostwhite","whitesmoke","seashell","beige","oldlace","floralwhite","ivory","antiquewhite","linen","lavenderblush","mistyrose"],"gray":["gainsboro","lightgray","silver","darkgray","dimgray","gray","lightslategray","slategray","darkslategray","black"]}
const allColors = Object.values(colorGroups).reduce((a,b) => a.concat(b), []);
// Adapted from https://stackoverflow.com/a/3943023
const pickTextColor = ([r, g, b]) => {
const L = r * 0.299 + g * 0.587 + b * 0.114;
return (L > 186) ? "black" : "white";
}
const componentToHex = (v) => v.toString(16).padStart(2, 0)
const rgbToHex = ([r, g, b]) => `#${[r,g,b].map(componentToHex).join('').toUpperCase()}`
const formatRgb = ([r, g, b]) => `rgb(${r},${g},${b})`
const showCategory = (category) => {
const dataset = category ? colorGroups[category] : allColors;
const inner = dataset.map(name => {
const rgb = colors[name]
// console.log(`rgb: ${rgb}, name: ${name}`);
const color = pickTextColor(rgb);
return `<div class="flex flex-col h-20 w-full justify-center items-center" style="background: ${name}">
<div style="color:${color}" class="text-base font-bold font-sans"> ${name.toUpperCase()} </div>
<div style="color:${color}" class="text-xs font-mono"> ${rgbToHex(rgb)} ${formatRgb(rgb)} </div>
</div>`
}).join('\n');
const html = `
<div class="grid grid-cols-1">
${inner}
</div>`
// console.log(`category: ${category}`, html);
return html
}
const buildTabs = () => {
const groups = Object.keys(colorGroups);
let tabs = [ {
name: "All",
method: async () => await arg("all", showCategory())
}];
groups.map(name => {
tabs.push( { name, method: async () => await arg(name, showCategory(name)) } )
});
tabs.map(({name, method}) => {
onTab(name, method);
});
}
// await arg("all", showCategory())
// NOTE: current api requires typing before panel is displayed if we pass in a function like (input) => {}
buildTabs();
Discuss Post

Show Github Discussions of Kit repo

Kit Discussions

I was feeling inspired and riffed on #75 and #96 to render the kit repo's discussions in kit.

Demo

kit-discussions

I've also been meaning to play around (also see procrastinating) with graphql, and since I couldn't find a REST API for Github's discussions, writing this script was a good intro :)

Notes

  • Uses the improved support for html (e.g. links) showcased in #95 to help instruct the user how to create an access token which is set as an environment variable
  • Elected to write my own humanize duration function instead of requiring humanize-duration to hit 0 external dependencies
  • Attempted to match time labels with Github's UI, but noticed that their UI appears to round days based on duration instead of using calendar days (see below)

e.g.

6 days ago actual 7 days ago rounded
March 25th appearing as 6 days ago March 25th appearing as 7 days ago

Quirks

  • Downloads and caches a json object of emojis instead of requiring node-emoji
  • Caches the github discussion categories and does not set a TTL / check for future updates

Code

Click for (not very clean) code
// Menu: Kit Discusssions
// Description: View Kit Discussions
// Author: Zach Zeleznick
// Twitter: @zzxiv
const {focusTab} = await kit('chrome')
// const humanizeDuration = await npm('humanize-duration')
const emojisDB = db("emojis", { emojis: {} });
const emojisRef = emojisDB.get("emojis");
const categoriesDB = db("kit-discussions", { categories: [] });
const categoriesRef = categoriesDB.get("categories");
const githubURL = "https://api.github.com/graphql";
let token = env.GITHUB_ACCESS_TOKEN;
if (!token) {
const element = `
<div class="flex flex-col justify-center">
<div>
<a href="https://github.com/settings/tokens/new">Create a token</a> with "public_repo" enabled.
</div>
<br>
<div>
Then, copy + paste the token above or set <code>GITHUB_REPO_TOKEN</code> inside <code>~/.kenv/.env</code>
</div>
</div>`
token = await env("GITHUB_ACCESS_TOKEN", {
info: `Create and enter your personal access token`,
choices: element,
});
}
const fetchEmojis = async () => {
// Could install and use as an npm package, but we just need a k-v map ...
const emojiURL = 'https://raw.githubusercontent.com/omnidan/node-emoji/master/lib/emoji.json';
const response = await get(emojiURL);
const emojis = response.data;
// console.log(JSON.stringify(emojis, null, 2));
emojisDB.set("emojis", emojis).write();
}
const setupEmojis = async () => {
const emojis = emojisRef.value();
if (!emojis || !Object.keys(emojis).length) {
await fetchEmojis()
}
return emojis
}
const lookupEmoji = (key) => {
const emojis = emojisRef.value();
return emojis[key.slice(1, key.length - 1)]
}
const config = {
headers: {
"Authorization": `Bearer ${token}`,
"GraphQL-Features": "discussions_api",
}
}
const categoriesQuery = `
query {
repository(owner: "johnlindquist", name: "kit") {
discussionCategories(first: 10) {
# type: DiscussionConnection
totalCount # Int!
nodes {
id,
name,
emoji,
# emojiHTML,
description,
}
}
}
}`
const fetchCategories = async () => {
let response;
try {
response = await post(githubURL,
{
query: categoriesQuery
},
config
);
}
catch (err) {
console.warn("fetchCategories failed:", err);
return
}
const graphqlResponse = response.data;
// console.log(JSON.stringify(categories, null, 2));
const {
data: {
repository: {
discussionCategories: {
totalCount,
nodes
}
}
}
} = graphqlResponse;
categoriesDB.set("categories", nodes).write();
}
const setupCategories = async () => {
const categories = categoriesRef.value();
if (!categories || !categories.length) {
await fetchCategories()
}
return categories
}
// NOTE: can use `categoryId` in discussions query
// to limit results or could just fetch all and filter
const discussionInnerQuery = `
# type: DiscussionConnection
totalCount # Int!
nodes {
# type: Discussion
id,
title,
# bodyText,
createdAt,
resourcePath,
category {
id,
name,
emoji,
},
author {
login,
# avatarUrl,
}
}
`
const allDiscussionsQuery = `
query {
repository(owner: "johnlindquist", name: "kit") {
discussions(first: 10, orderBy: {
field: CREATED_AT,
direction: DESC,
}) {
${discussionInnerQuery}
}
}
}`
const buildCategoryQuery = (categoryId) => `
query {
repository(owner: "johnlindquist", name: "kit") {
discussions(first: 10, categoryId: "${categoryId}", orderBy: {
field: CREATED_AT,
direction: DESC,
}) {
${discussionInnerQuery}
}
}
}`
const fetchDiscussions = async (categoryId = "") => {
let response;
const query = categoryId ? buildCategoryQuery(categoryId) : allDiscussionsQuery;
try {
response = await post(githubURL,
{
query,
},
config
);
}
catch (err) {
console.warn("fetchDiscussions failed:", err);
return
}
const {data, errors } = response.data;
if (errors) {
console.warn("fetchDiscussions errors:", errors);
// todo: handle errors
}
const {
repository: {
discussions: {
totalCount,
nodes
}
}
} = data;
// console.log(JSON.stringify(nodes, null, 2));
return nodes;
}
const allDiscussions = async () => await fetchDiscussions();
const buildHtml = ({emoji}) => {
const glyph = lookupEmoji(emoji)
return `<div class="flex justify-center">
<div> ${glyph} </div>
</div>
`
}
const humanizeDuration = (duration) => {
// intend to mirror `humanizeDuration(duration, { round: true, largest: 1 })`
// note that 36 hours (1.5 days) would round to 2 days which isn't always the goal
// e.g.
// '2021-03-30T06:00:00Z' <> '2021-03-31T18:00:00Z' 36 hours, expect 1 vs 2
// '2021-03-30T18:00:00Z' <> '2021-04-01T06:00:00Z' 36 hours, expect 2
const components = {
"seconds": 1000,
"minutes": 60000,
"hours": 3600000,
"days": 86400000,
}
const units = Object.keys(components);
for (let i = units.length - 1; i > -1; i--) {
let unit = units[i];
const divisor = components[unit];
const val = duration / divisor;
const fval = Math.floor(val);
const rval = Math.round(val);
if (fval === 0) {
continue
}
unit = rval === 1 ? unit.slice(0, unit.length -1) : unit;
return `${rval} ${unit}`
}
}
const humanizeTime = (createdAt, fakeTime) => {
const then = new Date(createdAt);
const now = fakeTime ? new Date(fakeTime) : new Date();
let duration = now - then; // implicitly calls getTime();
// NOTE: Github UI rounds (so this interesting)
if (duration > 86400000) { // handle rounding case for days
const loffset = (then.getHours() - 12) * 3600000;
const roffset = (12 - now.getHours()) * 3600000;
duration = duration + loffset + roffset;
}
if (duration < 2592000000) { // within 30 days (in ms)
return `${humanizeDuration(duration)} ago`;
}
const sameYear = now.getYear() === then.getYear();
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const options = { timeZone, year: sameYear ? undefined : 'numeric', month: 'short', day: 'numeric' };
return `on ${then.toLocaleDateString('en-US', options)}`
}
const buildChoice = (node) => {
const {
title,
resourcePath,
createdAt,
category: {
name,
emoji,
},
author: {
login
},
} = node;
const url = `https://github.com${resourcePath}`
const description = `${login} created ${humanizeTime(createdAt)} in ${name}`
const html = buildHtml({emoji})
return {
name: title,
value: url,
description,
html,
}
}
const showCategory = async (categoryId) => {
const nodes = await fetchDiscussions(categoryId);
const choices = nodes.map(buildChoice);
const selectedIssue = await arg("Search discussions:", choices);
focusTab(selectedIssue);
}
const buildTabs = async () => {
const categories = await setupCategories();
let tabs = [ {
name: "All",
method: showCategory
}];
categories.map(({name, id}) => {
tabs.push( { name, method: async () => await showCategory(id) } )
});
tabs.map(({name, method}) => {
onTab(name, method);
});
}
await setupEmojis();
buildTabs();
Discuss Post

Pokémon Finder

Pokémon Finder

This demo showcases use of the img property for choices to show a pretty list of pokemon.

Mac Demo

pokemon-app

Terminal Demo

pokemon-term

Code

Click for code
// Menu: Pokedex
// Description: Display Pokemon
// Author: Zach Zeleznick
// Twitter: @zzxiv
const {focusTab} = await kit('chrome')
const pokemon = [{"name":"bulbasaur","id":"1"},{"name":"charmander","id":"4"},{"name":"squirtle","id":"7"},{"name":"caterpie","id":"10"},{"name":"weedle","id":"13"},{"name":"pidgey","id":"16"},{"name":"rattata","id":"19"},{"name":"spearow","id":"21"},{"name":"ekans","id":"23"},{"name":"sandshrew","id":"27"},{"name":"nidoran♀","id":"29"},{"name":"nidoran♂","id":"32"},{"name":"vulpix","id":"37"},{"name":"zubat","id":"41"},{"name":"oddish","id":"43"},{"name":"paras","id":"46"},{"name":"venonat","id":"48"},{"name":"diglett","id":"50"},{"name":"meowth","id":"52"},{"name":"psyduck","id":"54"},{"name":"mankey","id":"56"},{"name":"growlithe","id":"58"},{"name":"poliwag","id":"60"},{"name":"abra","id":"63"},{"name":"machop","id":"66"},{"name":"bellsprout","id":"69"},{"name":"tentacool","id":"72"},{"name":"geodude","id":"74"},{"name":"venusaur","id":"3"},{"name":"charmeleon","id":"5"},{"name":"charizard","id":"6"},{"name":"wartortle","id":"8"},{"name":"blastoise","id":"9"},{"name":"metapod","id":"11"},{"name":"butterfree","id":"12"},{"name":"kakuna","id":"14"},{"name":"beedrill","id":"15"},{"name":"pidgeotto","id":"17"},{"name":"pidgeot","id":"18"},{"name":"raticate","id":"20"},{"name":"fearow","id":"22"},{"name":"arbok","id":"24"},{"name":"pikachu","id":"25"},{"name":"raichu","id":"26"},{"name":"sandslash","id":"28"},{"name":"nidorina","id":"30"},{"name":"nidoqueen","id":"31"},{"name":"nidorino","id":"33"},{"name":"nidoking","id":"34"},{"name":"clefairy","id":"35"},{"name":"clefable","id":"36"},{"name":"ninetales","id":"38"},{"name":"jigglypuff","id":"39"},{"name":"wigglytuff","id":"40"},{"name":"golbat","id":"42"},{"name":"gloom","id":"44"},{"name":"vileplume","id":"45"},{"name":"parasect","id":"47"},{"name":"venomoth","id":"49"},{"name":"dugtrio","id":"51"},{"name":"persian","id":"53"},{"name":"golduck","id":"55"},{"name":"primeape","id":"57"},{"name":"arcanine","id":"59"},{"name":"poliwhirl","id":"61"},{"name":"poliwrath","id":"62"},{"name":"kadabra","id":"64"},{"name":"alakazam","id":"65"},{"name":"machoke","id":"67"},{"name":"machamp","id":"68"},{"name":"weepinbell","id":"70"},{"name":"victreebel","id":"71"},{"name":"tentacruel","id":"73"},{"name":"graveler","id":"75"},{"name":"ponyta","id":"77"},{"name":"slowpoke","id":"79"},{"name":"magnemite","id":"81"},{"name":"farfetchd","id":"83"},{"name":"doduo","id":"84"},{"name":"seel","id":"86"},{"name":"grimer","id":"88"},{"name":"shellder","id":"90"},{"name":"gastly","id":"92"},{"name":"onix","id":"95"},{"name":"drowzee","id":"96"},{"name":"krabby","id":"98"},{"name":"voltorb","id":"100"},{"name":"exeggcute","id":"102"},{"name":"cubone","id":"104"},{"name":"lickitung","id":"108"},{"name":"koffing","id":"109"},{"name":"rhyhorn","id":"111"},{"name":"tangela","id":"114"},{"name":"kangaskhan","id":"115"},{"name":"horsea","id":"116"},{"name":"goldeen","id":"118"},{"name":"staryu","id":"120"},{"name":"scyther","id":"123"},{"name":"pinsir","id":"127"},{"name":"tauros","id":"128"},{"name":"magikarp","id":"129"},{"name":"lapras","id":"131"},{"name":"ditto","id":"132"},{"name":"eevee","id":"133"},{"name":"porygon","id":"137"},{"name":"omanyte","id":"138"},{"name":"kabuto","id":"140"},{"name":"aerodactyl","id":"142"},{"name":"articuno","id":"144"},{"name":"zapdos","id":"145"},{"name":"moltres","id":"146"},{"name":"dratini","id":"147"},{"name":"mewtwo","id":"150"},{"name":"rapidash","id":"78"},{"name":"slowbro","id":"80"},{"name":"magneton","id":"82"},{"name":"dodrio","id":"85"},{"name":"dewgong","id":"87"},{"name":"muk","id":"89"},{"name":"cloyster","id":"91"},{"name":"haunter","id":"93"},{"name":"gengar","id":"94"},{"name":"hypno","id":"97"},{"name":"kingler","id":"99"},{"name":"electrode","id":"101"},{"name":"exeggutor","id":"103"},{"name":"marowak","id":"105"},{"name":"hitmonlee","id":"106"},{"name":"hitmonchan","id":"107"},{"name":"weezing","id":"110"},{"name":"rhydon","id":"112"},{"name":"chansey","id":"113"},{"name":"seadra","id":"117"},{"name":"seaking","id":"119"},{"name":"starmie","id":"121"},{"name":"mr-mime","id":"122"},{"name":"jynx","id":"124"},{"name":"electabuzz","id":"125"},{"name":"magmar","id":"126"},{"name":"gyarados","id":"130"},{"name":"vaporeon","id":"134"},{"name":"jolteon","id":"135"},{"name":"flareon","id":"136"},{"name":"omastar","id":"139"},{"name":"kabutops","id":"141"},{"name":"snorlax","id":"143"},{"name":"dragonair","id":"148"},{"name":"dragonite","id":"149"},{"name":"mew","id":"151"},{"name":"ivysaur","id":"2"},{"name":"golem","id":"76"}]
const translations = {
"mr-mime": "Mr._Mime",
"farfetchd": "Farfetch'd"
}
const toTitleCase = (str) => {
return str.split(' ').map(s => s.charAt(0).toUpperCase() + s.substr(1).toLowerCase()).join(' ');
}
const buildImageUrl = (id) => {
// e.g. https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/2.png
const baseUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon"
return `${baseUrl}/${id}.png`
}
const buildOpenUrl = (name) => {
// e.g. https://bulbapedia.bulbagarden.net/wiki/Nidoran%E2%99%80_(Pok%C3%A9mon)
const baseUrl = "https://bulbapedia.bulbagarden.net/wiki"
let translated = translations[name] ? translations[name] : toTitleCase(name);
return encodeURI(`${baseUrl}/${translated}_(Pokémon)`);
}
const buildChoices = () => {
return pokemon.map(({name, id}) => {
return {
name: toTitleCase(name),
value: name,
description: `Pokedex entry: ${id.padStart(3, 0)}`,
img: buildImageUrl(id)
}
});
}
const name = await arg("View Gen 1 Pokemon:", buildChoices());
focusTab(buildOpenUrl(name));
Discuss Post

Stock Watchlist

Stock Watchlist

While it's no bloomberg terminal, I'd like to share my first kit script 🥳

Thanks @johnlindquist for building this!

stonks-demo-zz

Code

Click to expand
// Menu: Kit Stocks
// Description: Display Stocks
// Author: Zach Zeleznick
// Twitter: @zzxiv
const {focusTab} = await kit('chrome')
const defaultSymbols = ["GME", "AMC", "SNAP"];
const apiUrl = `https://query1.finance.yahoo.com/v7/finance/quote?lang=en-US&region=US&corsDomain=finance.yahoo.com&symbols=`
const populateFrom = (symbols) => symbols.map((v, i) => {return {symbol: v, id: `id-${i}` }})
const tickersDB = db("tickers", { tickers: populateFrom(defaultSymbols) });
const tickersRef = tickersDB.get("tickers");
// helper in the case all tickers are removed – we should reinit or return empty result
const initDB = () => {
const tickers = populateFrom(defaultSymbols);
tickersDB.set("tickers", tickers).write();
}
const urlToOpen = (ticker) => {
return `https://finance.yahoo.com/quote/${ticker}?p=${ticker}`
}
const getTickers = () => tickersRef.value()
const tickersToSymbols = () => getTickers().map(({symbol}) => symbol)
const tickersToChoices = () => {
return getTickers().map(({symbol, id}) => {
return {
name: symbol,
value: id,
}
});
}
const getStocks = async (stocks) => {
stocks = stocks ? stocks : defaultSymbols;
stocks = Array.isArray(stocks) ? stocks.join(",") : stocks;
const response = await get(`${apiUrl}${stocks}`);
const { quoteResponse: { result, error } } = response.data;
// TODO: handle errors
// console.log(JSON.stringify(result, null, 2));
return result;
}
const buildHtml = ({price, percentChange}) => {
let color = 'gray';
const significance = Math.abs(percentChange) > 0.25; // arbitray 0.25% cutoff
// TODO: should filter on significance based on volatility
const pct = percentChange.toFixed(2);
color = significance ? (Math.sign(percentChange) === -1 ? "red" : "green") : color;
return `<div class="h-full w-full p-1 text-xs flex flex-col justify-center items-center font-bold">
<div>${price}</div>
<div style="color:${color}">${pct}%</div>
</div>`
}
const quoteResponseToChoice = (quoteResponse) => {
const { symbol, displayName, regularMarketPrice,
regularMarketChange, regularMarketChangePercent,
} = quoteResponse;
try {
return {
name: symbol,
value: symbol,
description: displayName,
html: buildHtml({price: regularMarketPrice, percentChange: regularMarketChangePercent}),
}
} catch(e) {
console.error(e);
return null
}
}
const listTickers = async () => {
let symbols = tickersToSymbols();
if (!symbols || !symbols.length) {
await arg("Search stocks:", [{
name: "No Results",
value: "__empty__",
description: "Hit enter to reinit default stocks"
}]);
initDB();
return await listTickers();
}
const stocks = await getStocks(symbols);
const choices = stocks.map(quoteResponseToChoice).filter(x => x);
const selectedTicker = await arg("Search stocks:", choices);
focusTab(urlToOpen(selectedTicker)); // open tab for quote
}
const addTicker = async () => {
const symbol = await arg("Select stock to add:");
tickersRef.insert({ symbol }).write();
return await addTicker();
};
const removeTicker = async () => {
const choices = tickersToChoices();
const id = await arg("Select stock to remove:", choices);
tickersRef.remove({ id }).write();
return await removeTicker();
};
onTab("List", listTickers)
onTab("Add", addTicker)
onTab("Remove", removeTicker)
Discuss Post