Projektsider for ubl-tools, ahfail og pi3-smart-workspace

Hvert værktøj jeg udgiver på AUR får sin egen side under /projekter/<navn>,
så pakkernes Upstream URL kan pege på en side her i stedet for direkte på
Gitea. Siderne ligger som markdown i en ny projekter-samling og bærer
SoftwareSourceCode-data med kode-repo og AUR-pakker.

Kortene på /projekter linker til projektsiden når der findes en, de tre står
øverst, og de er med i ⌘K-søgningen. /ubl linker videre til ubl-tools.

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
Asger Geel Weirsøe
2026-10-04 07:21:48 +02:00
parent 32b85cc973
commit 61d3d0584e
11 changed files with 349 additions and 51 deletions
+1 -1
View File
@@ -29,5 +29,5 @@ export const SOCIALS = [
// hide: repo-navne der skjules helt. pinned: vises først (i den rækkefølge).
export const PROJECTS = {
hide: [] as string[],
pinned: [] as string[],
pinned: ['ubl-tools', 'gtk-ahfail', 'pi3-smart-workspace'] as string[],
};
+21 -1
View File
@@ -14,4 +14,24 @@ const blog = defineCollection({
}),
});
export const collections = { blog };
// Projektsider (/projekter/<slug>): én markdown-fil pr. værktøj jeg udgiver.
// Frontmatter bærer de faste links, så siden og søgeindekset kan vise dem ens.
const projekter = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/projekter' }),
schema: () =>
z.object({
title: z.string(),
description: z.string(),
repo: z.string(), // repo-navn på Gitea under SITE.giteaUser
sprog: z.string(), // fx Rust, Python
licens: z.string(),
platforme: z.array(z.string()).default([]),
aur: z.array(z.string()).default([]), // AUR-pakkenavne
tags: z.array(z.string()).default([]),
date: z.coerce.date(), // første udgivelse
updated: z.coerce.date().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog, projekter };
+55
View File
@@ -0,0 +1,55 @@
---
title: "ahfail"
description: "En skærmlås til Linux og macOS. Skriver du forkert kodeord, dukker Dennis Nedry op og siger \"ah ah ah, you didn't say the magic word\"."
repo: gtk-ahfail
sprog: Rust
licens: AGPL-3.0-only
platforme: [Linux (Wayland), Linux (X11), macOS]
aur: [ahfail, ahfail-bin]
tags: [linux, wayland, x11, macos, pam, rust]
date: 2026-09-30
updated: 2026-09-30
---
Skriver du forkert kodeord, kører en animation i loop af mit eget ansigt photoshoppet ind på Dennis Nedry fra Jurassic Park, mens klippet med "ah ah ah, you didn't say the magic word" spiller. Lyden skrues helt op, så den der prøvede at snige sig ind på din maskine også får det hele med (`--no-max-volume` hvis du hellere vil være diskret). Under joken er det en rigtig skærmlås.
```bash
ahfail # lås skærmen
ahfail test # se effekten uden at låse
ahfail doctor # hvad mangler maskinen?
```
Den er ét program på Linux, og den finder selv ud af om du kører Wayland eller X11. På Wayland låser den gennem `ext-session-lock-v1`, så det virker i sway, Hyprland, niri, river, labwc og KDE Plasma 6. GNOME lader ikke andre programmer låse skærmen, så der er den ude. På X11 gør den som i3lock: fuldskærmsvinduer på alle skærme og et greb om tastatur og mus, og kan den ikke få grebet, nægter den at låse i stedet for at vise en lås dine tastetryk går lige igennem.
Kodeordet tjekkes gennem dens egen PAM-tjeneste, `/etc/pam.d/ahfail`, som pakken installerer selv. Den rører ikke andre pakkers PAM-opsætning, på samme måde som swaylock og gtklock gør det. Den inkluderer `login`, så systemets `pam_faillock` gælder også her, og på Arch betyder det at tre forkerte kodeord på et kvarter låser kontoen i ti minutter. Det er værd at vide inden man viser den frem til fredagsbaren.
En skærmlås sidder direkte i login-vejen, så en fejl i den kan i værste fald svække låsen. Der er ingen garantier, og du installerer den på eget ansvar. Det står også i pakken.
## Installation
På Arch Linux, som kildepakke eller færdigbygget:
```bash
yay -S ahfail
yay -S ahfail-bin
```
Kør `ahfail doctor` bagefter. Den fortæller om sessionstypen er understøttet, om PAM-filen ligger rigtigt, om GStreamer kan afspille klippet og om lydstyrken kan styres, med den præcise rettelse hvis noget mangler.
Vil du have den til at låse af sig selv, så peg din idle-dæmon på den:
```bash
swayidle -w timeout 300 'ahfail' before-sleep 'ahfail' # Wayland
xss-lock -- ahfail # X11
```
## macOS
På Mac er det systemets egen låseskærm der låser, og effekten kommer fra en PAM-hook der installeres via Homebrew:
```bash
brew tap asger-weirsoee/tap
brew install ahfail
```
Homebrew skriver bagefter den ene linje ud som skal tilføjes i `/etc/pam.d/screensaverui`. Det er det eneste manuelle trin.
@@ -0,0 +1,47 @@
---
title: "pi3-smart-workspace"
description: "Et lille værktøj til i3 der gør $mod+1 til den første workspace på den skærm musen står på, i stedet for workspace nummer 1 et helt andet sted."
repo: pi3-smart-workspace
sprog: Python
licens: Apache-2.0
platforme: [Linux (i3)]
aur: [pi3-smart-workspace]
tags: [i3, linux, python]
date: 2020-09-04
updated: 2026-09-30
---
Med flere skærme i i3 er workspaces nummereret globalt. Trykker du `$mod+1`, hopper du til workspace 1, også selvom den ligger på skærmen til venstre og du sidder og arbejder på den til højre. `pi3-smart-workspace` vender det om: tasten vælger den N'te workspace på den skærm musen står på. `$mod+1` er altid den første workspace der hvor musen er, og et setup med flere skærme føles meget mere sammenhængende.
Den læser din kørende i3-konfiguration og bruger tre ting fra den: navne på skærmene (`set $<var> <output>`), navne på workspaces og linjerne der binder en workspace til en skærm. Rækkefølgen af de linjer bestemmer hvilken workspace der er nummer 1, 2 og 3 på hver skærm. Den kan også flytte det aktive vindue derhen, og hvis du vil, følge med.
Den første udgave er fra 2020.
## Installation
Fra AUR eller PyPI:
```bash
yay -S pi3-smart-workspace
pip install pi3-smart-workspace
```
Den kræver Python 3.14 og en kørende i3-session.
## Opsætning
Den nemmeste vej er at lade den skrive konfigurationen selv:
```bash
pi3-smart-workspace init
```
Den finder alle aktive skærme gennem i3 og skriver en mappe med en `workspaces.conf` pr. skærm og en `bindings.conf` med `$mod+N`, `$mod+Shift+N` og `$mod+Ctrl+N`. Hver skærm får sit eget sammenhængende interval af globale numre, og indekset efter kolon (fx `9:1`) betyder "første workspace på den her skærm". Tilføj de include-linjer `init` skriver ud til din i3-config og genindlæs med `$mod+Shift+r`.
Vil du selv binde tasterne, ser det sådan ud:
```
bindsym $mod+1 exec --no-startup-id pi3-smart-workspace -i 1
bindsym $mod+Shift+1 exec --no-startup-id pi3-smart-workspace -i 1 -s
bindsym $mod+Ctrl+1 exec --no-startup-id pi3-smart-workspace -i 1 -s -k
```
+59
View File
@@ -0,0 +1,59 @@
---
title: "ubl-tools"
description: "Læs e-fakturaer i UBL (Peppol BIS 3.0 og OIOUBL) i terminalen eller som en side du kan printe, og træk den vedhæftede PDF ud. Alt foregår på din egen maskine."
repo: ubl-tools
sprog: Rust
licens: MIT OR Apache-2.0
platforme: [Linux, macOS, Windows]
aur: [ubl-tools, ubl-tools-bin]
tags: [peppol, oioubl, nemhandel, e-faktura, rust]
date: 2026-09-30
updated: 2026-10-01
---
En e-faktura er en XML-fil. Vil man læse en, skal man enten scrolle gennem et par hundrede linjer `cbc:` og `cac:`, eller uploade den til en online viewer. Viewerne virker fint, men de vil have hele fakturaen, og den indeholder navne, CVR-numre, bankoplysninger og hvad kunden har købt. Det har jeg det lidt stramt med at sende til en fremmed server bare for at kunne læse den.
Så jeg skrev `ubl`. Den læser filen lokalt og viser den som en almindelig faktura: sælger og køber med deres identifikatorer navngivet ordentligt (CVR, GLN, SE-nr. og Peppol-endpoints i stedet for skemakoder som `0184`), linjerne med antal, enhed, pris og moms, momsopgørelsen pr. kategori og totalerne. Betalingsoplysningerne kommer med, også danske FI-kort i den velkendte `+71<…+…<`-notation. Er fakturaen pakket ind i en Peppol-kuvert (SBDH), pakker den den selv ud.
Den forstår Peppol BIS Billing 3.0, OIOUBL 2.1 og andre EN 16931-fakturaer og kreditnotaer. OIOUBL har en lille finte, hvor `TaxExclusiveAmount` faktisk er momsbeløbet og ikke beløbet før moms.. det har den lært at tage højde for, så tallene passer.
## Installation
På Arch Linux ligger den på AUR, både som kildepakke og som færdigbygget binær:
```bash
yay -S ubl-tools # bygges fra kildekoden
yay -S ubl-tools-bin # færdigbygget binær
```
På macOS via min Homebrew-tap:
```bash
brew tap asger-weirsoee/tap
brew install ubl-tools
```
På Windows 10/11 med Scoop:
```powershell
scoop install https://asger.weirsoe.dk/tarballz/ubl-tools.json
```
Du kan også hente zip-filen fra [tarballz](/tarballz/) og pakke den ud. Binæren er ikke kodesigneret, så Windows spørger måske første gang den køres.
## Brug
```bash
ubl view faktura.xml # i terminalen
ubl view faktura.xml --open # som side i browseren
ubl view faktura.xml --lang da # danske labels
ubl extract faktura.xml anden.xml # gem PDF'en ved hver fil
ubl extract *.xml -o ./bilag # eller alle i én mappe
ubl open faktura.xml # gem bilag og åbn i browser
```
`ubl extract` overskriver aldrig noget. Ligger der allerede en identisk fil, genbruges den, og er den anderledes, får den nye et andet navn. På Windows kan du trække en eller flere XML-filer direkte over på `ubl.exe` i Stifinder, så gemmes PDF'erne og fakturaerne åbnes, og vinduet bliver stående til du har læst resultatet.
HTML-siden er én selvstændig fil uden eksterne forespørgsler, sat op til A4, så den printer pænt.
Skal du bare have afkodet en CustomizationID eller se hvilken profil en faktura følger, kan [UBL-tjekket](/ubl) her på siden gøre det direkte i browseren.
-49
View File
@@ -1,49 +0,0 @@
---
import Base from '../layouts/Base.astro';
import { SITE, PROJECTS } from '../consts';
type Repo = { name: string; description: string | null; html_url: string; language: string | null; stars_count: number; updated_at: string; fork: boolean; archived: boolean; private: boolean; };
let repos: Repo[] = [];
let error = false;
try {
const res = await fetch(`${SITE.giteaBase}/api/v1/users/${SITE.giteaUser}/repos?limit=50&sort=updated`, {
headers: { 'Accept': 'application/json' },
});
if (res.ok) {
const hide = new Set(PROJECTS.hide.map((s) => s.toLowerCase()));
const data = (await res.json()) as Repo[];
repos = data
.filter((r) => !r.fork && !r.archived && !r.private && !hide.has(r.name.toLowerCase()));
// pinned først (i opsat rækkefølge), resten efter senest opdateret
const order = (n: string) => {
const i = PROJECTS.pinned.findIndex((p) => p.toLowerCase() === n.toLowerCase());
return i === -1 ? Infinity : i;
};
repos.sort((a, b) => {
const oa = order(a.name), ob = order(b.name);
if (oa !== ob) return oa - ob;
return +new Date(b.updated_at) - +new Date(a.updated_at);
});
} else { error = true; }
} catch { error = true; }
---
<Base title="Projekter" description="Udvalgte offentlige projekter fra min Gitea.">
<h1 class="text-4xl font-bold tracking-tight">Projekter</h1>
<p class="mt-3 text-muted">Hentet live fra min <a class="text-accent hover:underline" href={`${SITE.giteaBase}/${SITE.giteaUser}`}>Gitea</a>.</p>
{error && <p class="mt-8 rounded-xl border border-border bg-surface/60 p-5 text-muted">Kunne ikke hente repositories lige nu.</p>}
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{repos.map((r) => (
<a href={r.html_url} target="_blank" rel="noopener"
class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
<div class="flex items-center justify-between">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{r.name}</h3>
{r.stars_count > 0 && <span class="text-xs text-muted">★ {r.stars_count}</span>}
</div>
<p class="mt-2 line-clamp-3 text-sm text-muted">{r.description || 'Ingen beskrivelse.'}</p>
{r.language && <div class="mt-3 text-xs text-accent">{r.language}</div>}
</a>
))}
</div>
</Base>
+59
View File
@@ -0,0 +1,59 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { SITE } from '../../consts';
import { softwareSourceCode, breadcrumb } from '../../seo';
export async function getStaticPaths() {
const sider = await getCollection('projekter', ({ data }) => !data.draft);
return sider.map((side) => ({ params: { slug: side.id }, props: { side } }));
}
const { side } = Astro.props;
const { Content } = await render(side);
const d = side.data;
const url = new URL(`/projekter/${side.id}`, SITE.url).href;
const repo = `${SITE.giteaBase}/${SITE.giteaUser}/${d.repo}`;
const aur = d.aur.map((n) => ({ navn: n, url: `https://aur.archlinux.org/packages/${n}` }));
const published = d.date.toISOString();
const modified = (d.updated ?? d.date).toISOString();
const ld = softwareSourceCode({
url, name: d.title, description: d.description, codeRepository: repo,
language: d.sprog, license: d.licens, platforms: d.platforme,
sameAs: aur.map((a) => a.url), dateCreated: published, dateModified: modified, keywords: d.tags,
});
const crumbs = breadcrumb([
{ name: 'Forside', url: `${SITE.url}/` },
{ name: 'Projekter', url: `${SITE.url}/projekter` },
{ name: d.title, url },
]);
---
<Base title={d.title} description={d.description} type="article"
publishedTime={published} modifiedTime={modified} tags={d.tags} jsonLd={[ld, crumbs]}>
<nav aria-label="Brødkrumme" class="text-sm text-muted">
<a href="/" class="hover:text-accent">Forside</a>
<span class="px-1.5 text-muted/60">/</span>
<a href="/projekter" class="hover:text-accent">Projekter</a>
</nav>
<article class="mt-4">
<header class="border-b border-border pb-6">
<h1 class="text-4xl font-bold tracking-tight">{d.title}</h1>
<p class="mt-3 max-w-2xl text-lg text-muted">{d.description}</p>
<dl class="mt-5 flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div><dt class="inline text-muted">Sprog </dt><dd class="inline">{d.sprog}</dd></div>
<div><dt class="inline text-muted">Licens </dt><dd class="inline">{d.licens}</dd></div>
{d.platforme.length > 0 && <div><dt class="inline text-muted">Kører på </dt><dd class="inline">{d.platforme.join(', ')}</dd></div>}
</dl>
<div class="mt-5 flex flex-wrap gap-2">
<a href={repo} class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">Kildekode på Gitea</a>
{aur.map((a) => (
<a href={a.url} class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">AUR: {a.navn}</a>
))}
{d.aur.some((n) => n.endsWith('-bin')) && (
<a href="/tarballz/" class="rounded-full border border-border px-3 py-1 text-sm hover:border-accent hover:text-accent">Færdigbyggede pakker</a>
)}
</div>
</header>
<div class="prose mt-8"><Content /></div>
</article>
</Base>
+68
View File
@@ -0,0 +1,68 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { SITE, PROJECTS } from '../../consts';
// Repos med en projektside her på sitet linker dertil; resten går direkte til Gitea.
const sider = new Map(
(await getCollection('projekter', ({ data }) => !data.draft)).map((p) => [p.data.repo.toLowerCase(), p]),
);
type Repo = { name: string; description: string | null; html_url: string; language: string | null; stars_count: number; updated_at: string; fork: boolean; archived: boolean; private: boolean; };
let repos: Repo[] = [];
let error = false;
try {
const res = await fetch(`${SITE.giteaBase}/api/v1/users/${SITE.giteaUser}/repos?limit=50&sort=updated`, {
headers: { 'Accept': 'application/json' },
});
if (res.ok) {
const hide = new Set(PROJECTS.hide.map((s) => s.toLowerCase()));
const data = (await res.json()) as Repo[];
repos = data
.filter((r) => !r.fork && !r.archived && !r.private && !hide.has(r.name.toLowerCase()));
// pinned først (i opsat rækkefølge), resten efter senest opdateret
const order = (n: string) => {
const i = PROJECTS.pinned.findIndex((p) => p.toLowerCase() === n.toLowerCase());
return i === -1 ? Infinity : i;
};
repos.sort((a, b) => {
const oa = order(a.name), ob = order(b.name);
if (oa !== ob) return oa - ob;
return +new Date(b.updated_at) - +new Date(a.updated_at);
});
} else { error = true; }
} catch { error = true; }
---
<Base title="Projekter" description="Udvalgte offentlige projekter fra min Gitea.">
<h1 class="text-4xl font-bold tracking-tight">Projekter</h1>
<p class="mt-3 text-muted">Hentet live fra min <a class="text-accent hover:underline" href={`${SITE.giteaBase}/${SITE.giteaUser}`}>Gitea</a>.</p>
{error && <p class="mt-8 rounded-xl border border-border bg-surface/60 p-5 text-muted">Kunne ikke hente repositories lige nu.</p>}
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{repos.map((r) => {
const side = sider.get(r.name.toLowerCase());
return side ? (
<a href={`/projekter/${side.id}`}
class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
<div class="flex items-center justify-between">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{side.data.title}</h3>
{side.data.aur.length > 0 && <span class="text-xs text-muted">AUR</span>}
</div>
<p class="mt-2 line-clamp-3 text-sm text-muted">{side.data.description}</p>
<div class="mt-3 text-xs text-accent">{side.data.sprog} · Læs mere →</div>
</a>
) : (
<a href={r.html_url} target="_blank" rel="noopener"
class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
<div class="flex items-center justify-between">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{r.name}</h3>
{r.stars_count > 0 && <span class="text-xs text-muted">★ {r.stars_count}</span>}
</div>
<p class="mt-2 line-clamp-3 text-sm text-muted">{r.description || 'Ingen beskrivelse.'}</p>
{r.language && <div class="mt-3 text-xs text-accent">{r.language}</div>}
</a>
);
})}
</div>
</Base>
+6
View File
@@ -3,10 +3,16 @@ import { getCollection } from 'astro:content';
export async function GET() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const projekter = await getCollection('projekter', ({ data }) => !data.draft);
const items = [
{ type: 'page', title: 'Forside', url: '/' },
{ type: 'page', title: 'CV', url: '/cv' },
{ type: 'page', title: 'Projekter', url: '/projekter' },
...projekter.map((p) => ({
type: 'page', title: `Projekt: ${p.data.title}`, url: `/projekter/${p.id}`,
extra: [p.data.sprog, ...p.data.aur, ...p.data.tags].join(' '),
})),
{ type: 'page', title: 'Færdigbyggede pakker (tarballz)', url: '/tarballz/' },
{ type: 'page', title: 'Blog', url: '/blog' },
{ type: 'page', title: 'Tags', url: '/tags' },
{ type: 'page', title: 'NemHandel-opslag', url: '/nemhandel' },
+5
View File
@@ -22,6 +22,11 @@ const idx = profiles.map((p) => ({
EN 16931-status og kobler til <a href="/profiler" class="text-accent">profil-opslaget</a>.
Alt sker <strong class="text-text">lokalt i din browser</strong>; intet sendes nogen steder.
</p>
<p class="mt-3 max-w-2xl text-muted">
Vil du læse hele fakturaen eller have den vedhæftede PDF ud, så har jeg skrevet
<a href="/projekter/ubl-tools" class="text-accent hover:underline">ubl-tools</a> til terminalen,
også på Windows og macOS.
</p>
<div id="drop" class="mt-8 rounded-2xl border border-dashed border-border bg-surface/40 p-8 text-center transition-colors">
<p class="text-muted">Træk en <code class="rounded bg-surface-2 px-1.5 py-0.5">.xml</code>-fil hertil, eller
+28
View File
@@ -159,3 +159,31 @@ export function collectionPage(opts: { url: string; name: string; description: s
about: { '@id': personId },
};
}
// SoftwareSourceCode — a project page for a tool the Person wrote and maintains.
// codeRepository points at Gitea; sameAs lists the package pages (AUR) for the same tool.
export function softwareSourceCode(opts: {
url: string; name: string; description: string; codeRepository: string;
language: string; license: string; platforms: string[]; sameAs: string[];
dateCreated: string; dateModified: string; keywords: string[];
}) {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareSourceCode',
'@id': `${opts.url}#software`,
url: opts.url,
name: opts.name,
description: opts.description,
codeRepository: opts.codeRepository,
programmingLanguage: opts.language,
license: opts.license,
runtimePlatform: opts.platforms,
sameAs: opts.sameAs,
dateCreated: opts.dateCreated,
dateModified: opts.dateModified,
keywords: opts.keywords,
inLanguage: 'da-DK',
author: { '@id': personId },
maintainer: { '@id': personId },
};
}