gopher://gopher.someodd.zip:70/1/catalog/someodd's libraryfiles for your pleasure2026-06-16T00:00:00Zgopher.someodd.zipgopher://gopher.someodd.zip:70/1/someodd/opensource/wmpixelwmpixel: a pixel-art animation studio in your Window Maker dock2026-06-09T00:00:00Z2026-06-16T00:00:00Zan 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_someoddgopher://gopher.someodd.zip:70/0/someodd/services/irc.mdirc.md2026-06-15T00:00:00Z2026-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-mortem2026-06-15T00:00:00Z2026-06-15T00:00:00ZPost-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.txtHibernation Nightmares (Framework 13, Ryzen AI 300)2026-03-13T00:00:00Z2026-06-12T00:00:00ZThe 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.txtwireguard-nm.txt2026-06-10T00:00:00Z2026-06-10T00:00:00ZWireGuard + 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/interlogInterlog: Gopher-style forum.2026-06-09T00:00:00Z2026-06-09T00:00:00ZText BBS/4ch-style forum.Text BBS/4ch-style forum.gopher://gopher.someodd.zip:70/1/tech/workstation/window_maker/rofi-omnirofi-omni: a unified rofi launcher for Window Maker (windows + apps + files)2026-06-09T00:00:00Z2026-06-09T00:00:00ZCompanion 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.tomlMy config for Bartleby2026-06-07T00:00:00Z2026-06-07T00:00:00ZI could hide this, or I could share it!gopher://gopher.someodd.zip:70/1/someodd/letters/2026/2026_05_27_last_letter_lumenaLast Letter to St. Lumena2026-06-03T00:00:00Z2026-06-03T00:00:00ZA farewell.A farewell.gopher://gopher.someodd.zip:70/1/someodd/letters/2026/2026_06_new_rainNew Rain2026-06-03T00:00:00Z2026-06-03T00:00:00ZFirst letter after almost all the sisters left.First letter after almost all the sisters left.gopher://gopher.someodd.zip:70/1/README.gophermapREADME.gophermap2026-05-23T00:00:00Z2026-05-23T00:00:00Zgopher://gopher.someodd.zip:70/1/applets/README.gophermapREADME.gophermap2026-05-23T00:00:00Z2026-05-23T00:00:00Zgopher://gopher.someodd.zip:70/9/applets/girc.lhs.bak-20260615girc.lhs.bak-202606152026-05-23T00:00:00Z2026-05-23T00:00:00Zgopher://gopher.someodd.zip:70/1/applets/webhole.lhswebhole: browse any website as a gopherhole2026-05-23T00:00:00Z2026-05-23T00:00:00Zpaste a web-page URL and walk it as a gopher menu -- prose becomes ...gopher://gopher.someodd.zip:70/0/applets/curl/README.txtREADME.txt2026-05-23T00:00:00Z2026-05-23T00:00:00ZThe idea of these applets is to evangalize Gopher to people who don...gopher://gopher.someodd.zip:70/1/applets/curl/ask.lhsask: question a local LLM over gopher2026-05-14T00:00:00Z2026-05-23T00:00:00Zask a short question, get a short plain-text answer from a local LL...gopher://gopher.someodd.zip:70/0/archives/hacker_manifesto.txthacker_manifesto.txt2026-05-23T00:00:00Z2026-05-23T00:00:00ZThe following was written shortly after my arrest. I am currently g...gopher://gopher.someodd.zip:70/1/applets/whoami.lhswhoami.lhs2026-05-22T00:00:00Z2026-05-22T00:00:00Zgopher://gopher.someodd.zip:70/1/applets/bujo/bujo.lhsbujo: a bullet journal over gopher2026-05-22T00:00:00Z2026-05-22T00:00:00Za dead-simple bullet journal you keep entirely in gopherspace --- o...gopher://gopher.someodd.zip:70/1/applets/curl/atomize.lhsatomize: any web page into an Atom feed2026-05-22T00:00:00Z2026-05-22T00:00:00Zpaste a web-page URL, get back an Atom feed -- deterministically di...gopher://gopher.someodd.zip:70/1/applets/curl/txtify.lhstxtify: any web page into plaintext2026-05-22T00:00:00Z2026-05-22T00:00:00Zpaste a web-page URL, get back the most likely main textual content...gopher://gopher.someodd.zip:70/1/archives/douay_rheims_bibleThe Holy Bible — Douay-Rheims (Challoner Revision)2026-05-22T00:00:00Z2026-05-22T00:00:00ZThe 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_serverA Tor + clearnet Gopher server (Venusia on :70)2026-05-22T00:00:00Z2026-05-22T00:00:00ZHow 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.lhsgirc: chat on IRC over gopher2026-05-20T00:00:00Z2026-05-21T00:00:00ZRead #main's scrollback over gopher (the shared oddbot channel log)...gopher://gopher.someodd.zip:70/1/uploadsUploads2026-05-20T00:00:00Z2026-05-20T00:00:00ZJust my dumping ground for transfers.Just my dumping ground for transfers.gopher://gopher.someodd.zip:70/1/someodd/opensource/window_maker_rofi_window_switcherwindow_maker_rofi_window_switcher: a rofi switcher that shows minimized windows2026-05-20T00:00:00Z2026-05-20T00:00:00ZA 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.lhs4_ch.lhs2026-05-18T00:00:00Z2026-05-18T00:00:00Zgopher://gopher.someodd.zip:70/1/applets/grep.lhsSearch this gopherhole with grep2026-05-18T00:00:00Z2026-05-18T00:00:00Zgopher://gopher.someodd.zip:70/1/applets/search.lhssearch.lhs2026-05-18T00:00:00Z2026-05-18T00:00:00Zgopher://gopher.someodd.zip:70/1/hosted/README.gophermapREADME.gophermap2026-05-18T00:00:00Z2026-05-18T00:00:00Zgopher://gopher.someodd.zip:70/1/hosted/roygbyteroygbyte2026-05-17T00:00:00Z2026-05-17T00:00:00Zroygbyte's gopherhole.roygbyte's gopherhole.gopher://gopher.someodd.zip:70/1/someodd/services/announcements/sftp-gopher-hostingHow I Host My Friends on Gopher2026-05-17T00:00:00Z2026-05-17T00:00:00ZSFTP-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-worldtestuser-hello-world2026-05-16T00:00:00Z2026-05-16T00:00:00Zgopherhole mirrored from https://github.com/octocat/Hello-World.gitgopherhole mirrored from https://github.com/octocat/Hello-World.gitgopher://gopher.someodd.zip:70/1/applets/diggingsdiggings: prospect gopherspace2026-05-14T00:00:00Z2026-05-14T00:00:00Zsurf 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 boardgopher://gopher.someodd.zip:70/1/applets/qdbqdb: a Gopher <-> IRC quote database2026-05-14T00:00:00Z2026-05-14T00:00:00ZIRC 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 stripgopher://gopher.someodd.zip:70/1/lawnGopher Lawn2026-05-13T00:00:00Z2026-05-13T00:00:00ZFavorites in gopherspace & more!Favorites in gopherspace & more!gopher://gopher.someodd.zip:70/1/someodd/opensource/gophervrgophervr: walk around gopherspace in 3D, again2026-05-13T00:00:00Z2026-05-13T00:00:00ZCameron 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_wmcubepeepy_wmcube: peepy for your dockapp2026-05-13T00:00:00Z2026-05-13T00:00:00ZA 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/icecastinternet radio over gopher2026-05-10T00:00:00Z2026-05-12T00:00:00Zstream any http/https internet radio over gopher as audio passthrou...stream any http/https internet radio over gopher as audio passthrough or 144p showcqt visualizationgopher://gopher.someodd.zip:70/1/applets/fewbytes/yt.lhsYouTube at modem speeds2026-05-11T00:00:00Z2026-05-11T00:00:00Zstream youtube over gopher as 6 kbps audio, 144p video, or plain-te...gopher://gopher.someodd.zip:70/0/someodd/letters/README.txtREADME.txt2026-05-11T00:00:00Z2026-05-11T00:00:00ZI like handwriting letters to others.gopher://gopher.someodd.zip:70/I/someodd/letters/2026/2026_05_10_mothers_day_for_father.jpg2026_05_10_mothers_day_for_father.jpg2026-05-11T00:00:00Z2026-05-11T00:00:00Z]]>gopher://gopher.someodd.zip:70/I/someodd/letters/2026/2026_05_andrew.jpg2026_05_andrew.jpg2026-05-11T00:00:00Z2026-05-11T00:00:00Z]]>gopher://gopher.someodd.zip:70/I/someodd/letters/2026/2026_05_for_a_friend.jpg2026_05_for_a_friend.jpg2026-05-11T00:00:00Z2026-05-11T00:00:00Z]]>gopher://gopher.someodd.zip:70/1/applets/figlet.lhsFiglet ASCII font art generator2026-05-10T00:00:00Z2026-05-10T00:00:00Zcreate ASCII art text in gopherspacegopher://gopher.someodd.zip:70/1/someodd/letters/2026/2026_05_10_mothers_day_sistersMother's Day 2026, for the Sisters2026-05-10T00:00:00Z2026-05-10T00:00:00ZMissionaries of Charity DevotionalsMissionaries of Charity Devotionalsgopher://gopher.someodd.zip:70/I/someodd/slice_of_life/2026_05_10_sister_lumena_flower_photo.jpgSister Lumena's Rose Photo2026-05-10T00:00:00Z2026-05-10T00:00:00ZOn 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.jpegpetrarch_tomb_childe_harolds_pilgramage_a_romaunt_george_gordon_byron.jpeg2026-05-09T00:00:00Z2026-05-09T00:00:00Z]]>gopher://gopher.someodd.zip:70/1/someodd/opensource/gopher_applet_specLiterate Haskell with dependencies, speedily in gopherspace.2026-05-07T00:00:00Z2026-05-07T00:00:00Zgopher://gopher.someodd.zip:70/0/applets/digest.hsdigest.hs2026-05-06T00:00:00Z2026-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, _) ]]>