gopher://gopher.someodd.zip:70/1/catalog/ someodd's library files for your pleasure 2026-06-16T00:00:00Z gopher.someodd.zip gopher://gopher.someodd.zip:70/1/someodd/opensource/wmpixel wmpixel: a pixel-art animation studio in your Window Maker dock 2026-06-09T00:00:00Z 2026-06-16T00:00:00Z an indexed-colour pixel-art editor and frame animator that lives as... an indexed-colour pixel-art editor and frame animator that lives as a Window Maker dockapp --- the dock icon plays your animation, click it for a full editor: mouse + keyboard drawing, a 16-colour palette and RGB picker, wrap-around scroll, animation frames at an adjustable frame rate, GIF / indexed-PNG import and export, fullscreen, a viewer window, and a NeXT-style colour-grouped tool panel. it auto-saves your work to ~/.wmpixel-session and reloads it on the next launch. one self-contained literate-Haskell file, doctested with LiquidHaskell refinements on its numeric core. by regarding_someodd gopher://gopher.someodd.zip:70/0/someodd/services/irc.md irc.md 2026-06-15T00:00:00Z 2026-06-15T00:00:00Z =============== IRC.SOMEODD.ZIP =============== gopher://gopher.someodd.zip:70/0/someodd/services/announcements/main-is-now-voice-only.md #main went voice-only: a post-mortem 2026-06-15T00:00:00Z 2026-06-15T00:00:00Z Post-mortem on a persistent ban evader in #main: why IRC bans never... +V `+m` makes it voice-only; `+V` auto-voices that person on every join, so a regular never has to ask twice. (For an unregistered regular you can match a hostmask instead of an account.) Pin the state so it survives restarts, with ChanServ: /msg ChanServ SET #main MLOCK +mnt /msg ChanServ SET #main GUARD ON /msg ChanServ SET #main KEEPTOPIC ON /msg ChanServ SET #main TOPICLOCK ON MLOCK locks the modes so nobody can quietly drop `+m`; GUARD keeps ChanServ sitting in the channel so it never empties out; the topic stays put. Make #main the only room. In ngircd.conf, predefine the channel and set, under [Options]: AllowedChannelTypes = (empty), then rehash. Ordinary users can no longer spin up channels. Point people at registration, so their voice is durable: /msg NickServ REGISTER /msg NickServ IDENTIFY An identified nick gets account-based voice that follows them across addresses -- no hostmask juggling, nothing to re-ask. ## The gopher angle The gopher-to-IRC bridge (the girc applet) kept working through all of this. The trick under `+m`: it connects over loopback, **waits until ChanServ grants it a voice, and only then speaks** -- otherwise its line would be sent in the same instant it joined, before the voice landed, and dropped. It posts under its own throwaway handle, not as the channel bot, so a hello from a passing gopher visitor still lands and is still attributable. ## Impact - Regulars: barely noticed. - Evader: neutralized -- present but silent, with no way to earn a voice. - Tradeoff: a brand-new visitor has to ask for a voice before their first word. ## Lessons I would rather leave a small gap than risk silencing someone who belongs here. The defenses lean permissive on purpose, and the evader gets handled directly when they turn up -- better than a wall so tall it locks out my own people. ## Status Resolved. #main is usable, the regulars are talking, and the evader is shouting into a void. Connection details and rules: /someodd/services/irc.md ]]> gopher://gopher.someodd.zip:70/0/tech/workstation/hibernation-nightmares.txt Hibernation Nightmares (Framework 13, Ryzen AI 300) 2026-03-13T00:00:00Z 2026-06-12T00:00:00Z The full saga, now CLOSED: resume was TWO bugs. The firmware-S4 reb... amdgpu RDNA 3.5 hang; kernel version is the dial. Recover with SysRq, not power-off. (UPDATE 2026-06-12: caught it. Async device-resume race; pm_async=0 kills it. Kernel version was never the dial here either. Smoking-gun chapter below. closed.) * reboot to a fresh LUKS prompt -> firmware S4 flaking out (PM: Image not found, NO S4 wake). Fix is HibernateMode= shutdown PLUS aligning the initramfs resume target to the swapfile; shutdown alone wasn't enough. (closed 2026-06-03.) - Don't force-power-off a blanked resume; it invalidates the swap header. Use SysRq S-U-B. - Lid now does DIRECT hibernate on battery, nothing on AC. suspend-then-hibernate made resume worse, not better. - Bluetooth (MT7925) is dead on kernel 7.0.7, fine on 6.19.13. The hardware (so I stop misdiagnosing it) ----------------------------------------- Ryzen AI 7 350 / Radeon 860M, codename "Krackan", RDNA 3.5, gfx1152, PCI 1002:1114, Display Core 3.5. Wi-Fi and Bluetooth are one MediaTek MT7925 combo: Wi-Fi mt7925e on PCIe, BT btusb/btmtk on USB 0e8d:0717. Framework board FRANMGCP07, BIOS 03.05. It is NOT a Phoenix 7040 -- I assumed that for a while and it sent me chasing the wrong fixes. Also: this platform offers s2idle only, no "deep" S3, so mem_sleep_default=deep in my grub line is inert. The core nightmare: resume -------------------------- The image writes fine (battery barely moves overnight), but RESUME comes up to a black screen -- often with the machine alive underneath -- and sometimes reboots instead. The crash happens before journald persists anything, so there is no log of it. The only trace is on the next cold boot: PM: Image not found (code -22) A part-way resume crash invalidates the swap header (so it won't retry into a wedged state), which is exactly why the retry boot comes up clean and empty-handed. (UPDATE 2026-05-26: that story is right for the black-screen variant. For the REBOOT variant it's backwards -- the image was never restored at all, because the firmware never came back in S4. See "the lever I never pulled" below.) Cause of the black-screen variant: an upstream amdgpu regression on RDNA 3.5. Framework's own AI 300 threads put the failure rate at 20-50% and quote support saying hibernate-to- disk on these "is spotty at best ... wait for the bug to be patched." The lever that moves it is kernel version -- people report specific kernels working where neighbours fail. The setup (still valid) ----------------------- 1. Big swapfile to hold the image: fallocate -l 64G /swapfile-hibernate chmod 600 /swapfile-hibernate mkswap /swapfile-hibernate swapon /swapfile-hibernate /etc/fstab: /swapfile-hibernate none swap defaults 0 0 2. Resume from it. Find the offset: filefrag -v /swapfile-hibernate | grep " 0:" /etc/default/grub: resume=/dev/mapper/framework--vg-root resume_offset= then: update-grub && reboot Swap must be >= RAM or hibernate fails with "Not enough suitable swap space". Gotcha I left myself: the initramfs resume target DISAGREES with the cmdline -- cmdline: resume=/dev/mapper/framework--vg-root + offset initramfs: RESUME=/dev/mapper/framework--vg-swap_1 (1G LV) It works only because the cmdline wins. The 1G LV couldn't hold an image anyway. Should be aligned; left as a TODO. (UPDATE 2026-06-03: NOT harmless under shutdown mode -- this was the bug. See below.) Lid policy: the part that flipped --------------------------------- History: s2idle only -> overnigh]]> gopher://gopher.someodd.zip:70/0/tech/workstation/wireguard-nm.txt wireguard-nm.txt 2026-06-10T00:00:00Z 2026-06-10T00:00:00Z WireGuard + NetworkManager (nm-applet) — setup notes ==============... home server (friend0) over WireGuard on the LAN. DDNS (friend0.crabdance.com) for reaching it from outside. TOPOLOGY -------- server (friend0 @ 192.168.1.210) wg0 10.8.0.1/32 listen 51820 laptop friend0 10.8.0.2/32 Keys cross over: each side's [Peer] PublicKey = the OTHER side's PUBLIC key. A private key NEVER leaves its own box. SERVER /etc/wireguard/wg0.conf [Interface] Address = 10.8.0.1/32 ListenPort = 51820 PrivateKey = [Peer] # laptop PublicKey = AllowedIPs = 10.8.0.2/32 # /32 per peer LAPTOP /etc/wireguard/friend0.conf [Interface] Address = 10.8.0.2/32 PrivateKey = [Peer] # server PublicKey = Endpoint = 192.168.1.210:51820 AllowedIPs = 10.8.0.0/32 PersistentKeepalive = 25 NM-APPLET: SET THE WG IPv4 ADDRESS (the big trap) ------------------------------------------------- NM stores the tunnel IP in IPv4 settings, NOT the WireGuard section. New WG connections default IPv4 to Automatic (DHCP). WG has no DHCP -> you get NO ipv4 addr (only fe80:: link-local). GUI fix: nm-applet > Edit Connections > > IPv4 Settings Method: Manual Add Address 10.8.0.2 Netmask 32 Gateway (blank) Save, toggle the connection off/on. CLI: nmcli connection modify friend0 ipv4.method manual \ ipv4.addresses 10.8.0.2/32 nmcli connection up friend0 Best: import the conf, it sets IPv4 Manual for you: nmcli connection import type wireguard file friend0.conf Split tunnel: IPv4 > Routes > tick "use this connection only for resources on its network". MISTAKES I MADE (the derp log) ------------------------------ 1. Put the LAPTOP's private key into the SERVER [Interface]. -> server's identity became the laptop's; pubkey mismatch; no handshake. Each box uses ITS OWN private key. 2. AllowedIPs = x/24 on a peer. Must be /32 per peer (server side). /24 collides with the interface's own subnet route. 3. No IPv4 on the tunnel (the real blocker): NM "Automatic" left only fe80::. ping had no source -> server couldn't route the reply back. Set IPv4 Manual = 10.8.0.2/24. 4. Fought the wrong interface: laptop's wg0 is a DIFFERENT VPN. The friend0 tunnel is iface "friend0". Don't down/up wg0. 5. wg syncconf does NOT set Address (wg-quick-only directive, stripped by wg-quick strip). Use wg-quick up, or add the IP by hand. TROUBLESHOOTING (in order) -------------------------- Keys pair? on each box: echo "" | wg pubkey -> must equal what the OTHER box lists as [Peer] PublicKey. Server listening? sudo ss -ulnp | grep 51820 Firewall? (no ufw here; check nft/iptables) sudo nft list ruleset sudo iptables -L INPUT -n -v allow: sudo iptables -I INPUT -p udp --dport 51820 -j ACCEPT Handshake + transfer: sudo wg show - "latest handshake" present = crypto OK - X sent / 0 received = reply blocked (firewall, return route, wrong AllowedIPs, or no IP on the iface) - counters are cumulative; old failed retries inflate "sent" Does the iface actually have its IP? ip -br addr show - only fe80:: = NO ipv4 (the NM-Automatic trap) LAN reachable at all? ping Apply a config change: edit file, then sudo wg syncconf <(wg-quick strip ) # peers/keys only (NOT Address) — or a full sudo wg-quick up DDNS note: WG resolves Endpoint ONCE at bring-up. When the home IP changes the tunnel goes stale -> use the reresolve-dns timer (wireguard-tools examples) or just restart the tunnel. ]]> gopher://gopher.someodd.zip:70/1/applets/interlog Interlog: Gopher-style forum. 2026-06-09T00:00:00Z 2026-06-09T00:00:00Z Text BBS/4ch-style forum. Text BBS/4ch-style forum. gopher://gopher.someodd.zip:70/1/tech/workstation/window_maker/rofi-omni rofi-omni: a unified rofi launcher for Window Maker (windows + apps + files) 2026-06-09T00:00:00Z 2026-06-09T00:00:00Z Companion to the rofi-windows switcher: a single rofi prompt that f... Companion to the rofi-windows switcher: a single rofi prompt that fuzzy-filters across open windows (minimized ones included), installed apps, and recently-modified files under $HOME, ranked windows-then-apps-then-files with files newest-first. Bash + wmctrl + xdotool + fd, with background-refreshed caches so it stays instant -- files are mtime-sorted and capped to keep the prompt snappy. Bound to Mod4+s. gopher://gopher.someodd.zip:70/9/venusia.toml My config for Bartleby 2026-06-07T00:00:00Z 2026-06-07T00:00:00Z I could hide this, or I could share it! gopher://gopher.someodd.zip:70/1/someodd/letters/2026/2026_05_27_last_letter_lumena Last Letter to St. Lumena 2026-06-03T00:00:00Z 2026-06-03T00:00:00Z A farewell. A farewell. gopher://gopher.someodd.zip:70/1/someodd/letters/2026/2026_06_new_rain New Rain 2026-06-03T00:00:00Z 2026-06-03T00:00:00Z First letter after almost all the sisters left. First letter after almost all the sisters left. gopher://gopher.someodd.zip:70/1/README.gophermap README.gophermap 2026-05-23T00:00:00Z 2026-05-23T00:00:00Z gopher://gopher.someodd.zip:70/1/applets/README.gophermap README.gophermap 2026-05-23T00:00:00Z 2026-05-23T00:00:00Z gopher://gopher.someodd.zip:70/9/applets/girc.lhs.bak-20260615 girc.lhs.bak-20260615 2026-05-23T00:00:00Z 2026-05-23T00:00:00Z gopher://gopher.someodd.zip:70/1/applets/webhole.lhs webhole: browse any website as a gopherhole 2026-05-23T00:00:00Z 2026-05-23T00:00:00Z paste a web-page URL and walk it as a gopher menu -- prose becomes ... gopher://gopher.someodd.zip:70/0/applets/curl/README.txt README.txt 2026-05-23T00:00:00Z 2026-05-23T00:00:00Z The idea of these applets is to evangalize Gopher to people who don... gopher://gopher.someodd.zip:70/1/applets/curl/ask.lhs ask: question a local LLM over gopher 2026-05-14T00:00:00Z 2026-05-23T00:00:00Z ask a short question, get a short plain-text answer from a local LL... gopher://gopher.someodd.zip:70/0/archives/hacker_manifesto.txt hacker_manifesto.txt 2026-05-23T00:00:00Z 2026-05-23T00:00:00Z The following was written shortly after my arrest. I am currently g... gopher://gopher.someodd.zip:70/1/applets/whoami.lhs whoami.lhs 2026-05-22T00:00:00Z 2026-05-22T00:00:00Z gopher://gopher.someodd.zip:70/1/applets/bujo/bujo.lhs bujo: a bullet journal over gopher 2026-05-22T00:00:00Z 2026-05-22T00:00:00Z a dead-simple bullet journal you keep entirely in gopherspace --- o... gopher://gopher.someodd.zip:70/1/applets/curl/atomize.lhs atomize: any web page into an Atom feed 2026-05-22T00:00:00Z 2026-05-22T00:00:00Z paste a web-page URL, get back an Atom feed -- deterministically di... gopher://gopher.someodd.zip:70/1/applets/curl/txtify.lhs txtify: any web page into plaintext 2026-05-22T00:00:00Z 2026-05-22T00:00:00Z paste a web-page URL, get back the most likely main textual content... gopher://gopher.someodd.zip:70/1/archives/douay_rheims_bible The Holy Bible — Douay-Rheims (Challoner Revision) 2026-05-22T00:00:00Z 2026-05-22T00:00:00Z The complete Douay-Rheims Bible, Challoner revision — the public-do... The complete Douay-Rheims Bible, Challoner revision — the public-domain English Catholic translation from the Latin Vulgate. Full 73-book Catholic canon including the deuterocanonical books. Plain UTF-8 text, verse-only (Challoner's footnotes stripped), organized one file per chapter under NN-book/CCC.txt, with a MANIFEST. Sourced from Project Gutenberg eBook #1581; public domain. gopher://gopher.someodd.zip:70/1/tech/gopher/gopher_tor_server A Tor + clearnet Gopher server (Venusia on :70) 2026-05-22T00:00:00Z 2026-05-22T00:00:00Z How this box serves the same gopherhole over both clearnet and a To... How this box serves the same gopherhole over both clearnet and a Tor hidden service. Venusia's own package unit binds privileged port 70 as an unprivileged user (CAP_NET_BIND_SERVICE), while xinetd plus a tiny rewriting router expose it as an .onion that hands back .onion links. Notes the Tor gotcha that the client IP is always loopback. Descendant of the earlier reverse-proxy routing setup. gopher://gopher.someodd.zip:70/1/applets/girc.lhs girc: chat on IRC over gopher 2026-05-20T00:00:00Z 2026-05-21T00:00:00Z Read #main's scrollback over gopher (the shared oddbot channel log)... gopher://gopher.someodd.zip:70/1/uploads Uploads 2026-05-20T00:00:00Z 2026-05-20T00:00:00Z Just my dumping ground for transfers. Just my dumping ground for transfers. gopher://gopher.someodd.zip:70/1/someodd/opensource/window_maker_rofi_window_switcher window_maker_rofi_window_switcher: a rofi switcher that shows minimized windows 2026-05-20T00:00:00Z 2026-05-20T00:00:00Z A rofi-driven window switcher for Window Maker that actually lists ... A rofi-driven window switcher for Window Maker that actually lists minimized windows -- rofi's native -show window silently drops them under WMaker. Bash + wmctrl + xdotool: MRU order, per-app icons, off-desktop tags, reliable de-iconify, no title-parsing ambiguity. gopher://gopher.someodd.zip:70/1/applets/4_ch.lhs 4_ch.lhs 2026-05-18T00:00:00Z 2026-05-18T00:00:00Z gopher://gopher.someodd.zip:70/1/applets/grep.lhs Search this gopherhole with grep 2026-05-18T00:00:00Z 2026-05-18T00:00:00Z gopher://gopher.someodd.zip:70/1/applets/search.lhs search.lhs 2026-05-18T00:00:00Z 2026-05-18T00:00:00Z gopher://gopher.someodd.zip:70/1/hosted/README.gophermap README.gophermap 2026-05-18T00:00:00Z 2026-05-18T00:00:00Z gopher://gopher.someodd.zip:70/1/hosted/roygbyte roygbyte 2026-05-17T00:00:00Z 2026-05-17T00:00:00Z roygbyte's gopherhole. roygbyte's gopherhole. gopher://gopher.someodd.zip:70/1/someodd/services/announcements/sftp-gopher-hosting How I Host My Friends on Gopher 2026-05-17T00:00:00Z 2026-05-17T00:00:00Z SFTP-based per-friend gopher hosting on someodd.zip. Per-user chroo... SFTP-based per-friend gopher hosting on someodd.zip. Per-user chroot at /srv/sftp/<u>/ bind-mounted from /var/gopher/hosted/<u>/, so the gopher daemon (venusia) keeps reading and writing while friends only ever see their own hole. Bartleby auto-regenerates the friend's catalog at SFTP disconnect via a PAM session_close hook. Includes the bootstrap script. gopher://gopher.someodd.zip:70/1/hosted/testuser-hello-world testuser-hello-world 2026-05-16T00:00:00Z 2026-05-16T00:00:00Z gopherhole mirrored from https://github.com/octocat/Hello-World.git gopherhole mirrored from https://github.com/octocat/Hello-World.git gopher://gopher.someodd.zip:70/1/applets/diggings diggings: prospect gopherspace 2026-05-14T00:00:00Z 2026-05-14T00:00:00Z surf gopherspace through a proxy, strike gold for finding selectors... surf gopherspace through a proxy, strike gold for finding selectors nobody has visited, and leave verified tripcode posts on any page --- the whole protocol as one text board gopher://gopher.someodd.zip:70/1/applets/qdb qdb: a Gopher <-> IRC quote database 2026-05-14T00:00:00Z 2026-05-14T00:00:00Z IRC quotes captured in #main with .qdb, kept forever as plain text,... IRC quotes captured in #main with .qdb, kept forever as plain text, browsable in gopherspace --- read as text or as a Microsoft Comic Chat strip gopher://gopher.someodd.zip:70/1/lawn Gopher Lawn 2026-05-13T00:00:00Z 2026-05-13T00:00:00Z Favorites in gopherspace & more! Favorites in gopherspace & more! gopher://gopher.someodd.zip:70/1/someodd/opensource/gophervr gophervr: walk around gopherspace in 3D, again 2026-05-13T00:00:00Z 2026-05-13T00:00:00Z Cameron Kaiser's 2015 Motif port of UMN's 1995 GopherVR, with a sma... Cameron Kaiser's 2015 Motif port of UMN's 1995 GopherVR, with a small patch to build on Debian forky / gcc 15. One zip, one script, ~5 minutes from clone to clicking on a gopher menu inside a rotating 3D scene. gopher://gopher.someodd.zip:70/1/someodd/opensource/peepy_wmcube peepy_wmcube: peepy for your dockapp 2026-05-13T00:00:00Z 2026-05-13T00:00:00Z A heavily-decimated wireframe of Nam Lemonade's Low poly Peepy, con... A heavily-decimated wireframe of Nam Lemonade's Low poly Peepy, converted to wmcube's .wmc format so the round goose-thing can spin in your Window Maker dock and speed up under CPU load. gopher://gopher.someodd.zip:70/1/applets/icecast internet radio over gopher 2026-05-10T00:00:00Z 2026-05-12T00:00:00Z stream any http/https internet radio over gopher as audio passthrou... stream any http/https internet radio over gopher as audio passthrough or 144p showcqt visualization gopher://gopher.someodd.zip:70/1/applets/fewbytes/yt.lhs YouTube at modem speeds 2026-05-11T00:00:00Z 2026-05-11T00:00:00Z stream youtube over gopher as 6 kbps audio, 144p video, or plain-te... gopher://gopher.someodd.zip:70/0/someodd/letters/README.txt README.txt 2026-05-11T00:00:00Z 2026-05-11T00:00:00Z I like handwriting letters to others. gopher://gopher.someodd.zip:70/I/someodd/letters/2026/2026_05_10_mothers_day_for_father.jpg 2026_05_10_mothers_day_for_father.jpg 2026-05-11T00:00:00Z 2026-05-11T00:00:00Z ]]> gopher://gopher.someodd.zip:70/I/someodd/letters/2026/2026_05_andrew.jpg 2026_05_andrew.jpg 2026-05-11T00:00:00Z 2026-05-11T00:00:00Z ]]> gopher://gopher.someodd.zip:70/I/someodd/letters/2026/2026_05_for_a_friend.jpg 2026_05_for_a_friend.jpg 2026-05-11T00:00:00Z 2026-05-11T00:00:00Z ]]> gopher://gopher.someodd.zip:70/1/applets/figlet.lhs Figlet ASCII font art generator 2026-05-10T00:00:00Z 2026-05-10T00:00:00Z create ASCII art text in gopherspace gopher://gopher.someodd.zip:70/1/someodd/letters/2026/2026_05_10_mothers_day_sisters Mother's Day 2026, for the Sisters 2026-05-10T00:00:00Z 2026-05-10T00:00:00Z Missionaries of Charity Devotionals Missionaries of Charity Devotionals gopher://gopher.someodd.zip:70/I/someodd/slice_of_life/2026_05_10_sister_lumena_flower_photo.jpg Sister Lumena's Rose Photo 2026-05-10T00:00:00Z 2026-05-10T00:00:00Z On Mother's Day, when working on a banner to celebrate perpetual vo... ]]> gopher://gopher.someodd.zip:70/I/archives/petrarch/petrarch_tomb_childe_harolds_pilgramage_a_romaunt_george_gordon_byron.jpeg petrarch_tomb_childe_harolds_pilgramage_a_romaunt_george_gordon_byron.jpeg 2026-05-09T00:00:00Z 2026-05-09T00:00:00Z ]]> gopher://gopher.someodd.zip:70/1/someodd/opensource/gopher_applet_spec Literate Haskell with dependencies, speedily in gopherspace. 2026-05-07T00:00:00Z 2026-05-07T00:00:00Z gopher://gopher.someodd.zip:70/0/applets/digest.hs digest.hs 2026-05-06T00:00:00Z 2026-05-06T00:00:00Z #!/usr/bin/env runghc {-# LANGUAGE OverloadedStrings #-} {-# LANGUA... IO () dbg s = lookupEnv "DEBUG" >>= \d -> case d of Just "1" -> hPutStrLn stderr ("[dbg] " ++ s); _ -> pure () trim :: String -> String trim = dropWhile isSpace . dropWhileEnd isSpace lower :: String -> String lower = map toLower short :: Int -> String -> String short n s = let t = unwords (words s) in if length t > n then take (n-1) t ++ "…" else t section :: String -> IO () section t = putStrLn $ "\n===== " ++ t ++ " =====" -- curl / fetch / jq curlBody :: String -> IO (Maybe String) curlBody u = do ik <- lookupEnv "INSECURE" let k = if ik == Just "1" && ("someodd.zip" `isInfixOf` u) && ("http" `isPrefixOf` u) then ["-k"] else [] a = ["-fsSL","--location","--connect-timeout","3","--max-time","8","-A","someodd-digest/hs","--"] ++ k ++ [u] dbg $ "curl " ++ unwords a readProcessWithExitCode "curl" a "" >>= \case (ExitSuccess, out, _) -> pure (Just out) _ -> pure Nothing fetch :: String -> IO (Maybe String) fetch u | "gopher://" `isPrefixOf` u = curlBody u | "https://" `isPrefixOf` u = curlBody u >>= \case { Just x -> pure (Just x); _ -> curlBody ("http://" ++ drop 8 u) } | otherwise = curlBody u jq :: String -> String -> IO (Maybe String) jq e s = readProcessWithExitCode "jq" ["-r", e] s >>= \case (ExitSuccess, o, _) -> let t = trim o in pure $ if t == "" || t == "null" then Nothing else Just t _ -> pure Nothing -- 1) latest toot toot :: IO () toot = do section "Toot" fetch "https://fosstodon.org/api/v1/accounts/lookup?acct=someodd" >>= \case Nothing -> putStrLn "(couldn't resolve account)" Just js -> jq ".id" js >>= \case Nothing -> putStrLn "(couldn't resolve account)" Just aid -> fetch ("https://fosstodon.org/api/v1/accounts/" ++ aid ++ "/statuses?limit=1&exclude_replies=true&exclude_reblogs=true") >>= \case Nothing -> putStrLn "(unreachable)" Just s -> jq ".[0] | select(.!=null) | [.created_at, (.content//\"\")] | @tsv" s >>= \case Nothing -> putStrLn "(none found)" Just tsv -> let (ts, rest) = span (/= '\t') tsv body = stripHTML (drop 1 rest) in putStrLn (ts ++ " — " ++ short 280 body) where stripHTML = decode . kill kill [] = [] kill ('<':xs) = kill (drop 1 (dropWhile (/= '>') xs)) kill (x:xs) = x : kill xs decode = rep " " " " . rep "&" "&" . rep "<" "<" . rep ">" ">" . rep """ "\"" . rep "'" "'" rep a b = go where go s | a `isPrefixOf` s = b ++ go (drop (length a) s) | otherwise = case s of [] -> []; (c:cs) -> c : go cs -- 2) latest bartleby library accession (via gopher atom feed) bartleby :: IO () bartleby = do section "Bartleby Library" fetch "gopher://gopher.someodd.zip:70/0/library/catalog/feed.xml" >>= \case Nothing -> putStrLn "(unreachable)" Just feed -> do mt <- xpath feed "//a:entry[1]/a:title" ms <- xpath feed "//a:entry[1]/a:summary" let title = stripMd (fromMaybe "" mt) summary = fromMaybe "" ms if null title then putStrLn "(no accessions)" else do putStrLn $ "Latest: " ++ title case summary of "" -> pure () s | s == title -> pure () | otherwise -> putStrLn $ "Summary: " ++ short 280 s where stripMd s = if ".md" `isSuffixOf` s then take (length s - 3) s else s xpath input expr = let args = ["sel", "-N", "a=http://www.w3.org/2005/Atom", "-t", "-v", expr] in readProcessWithExitCode "xmlstarlet" args input >>= \case (ExitSuccess, o, _) ]]>