Tri Clipper: een webclipper voor Safari en Trilium

In augustus ben ik met mijn notities van Obsidian naar Trilium overgestapt, en daarmee raakte ik het ene ding kwijt dat ik elke dag gebruikte: een knop die de pagina bewaart die ik aan het lezen ben. Trilium heeft een officiële clipper voor Chrome en Firefox. Ik gebruik Safari. Dus heb ik er zelf een geschreven.

Een artikelpagina in Safari met de popup van Tri Clipper open, waarin titel, bestemming en labels al zijn ingevuld.
De popup vult zichzelf in vanuit de pagina. Alles erin kun je aanpassen voordat je op Clip drukt.

Markdown is een omweg

Bijna elke webclipper zet de pagina onderweg om naar Markdown. Dat klinkt netjes, en voor een gewoon essay is het dat meestal ook. Het gedoe begint bij alles wat geen alinea is: tabellen raken hun kolommen kwijt, bijschriften van afbeeldingen raken los en schuiven naar het einde van de notitie, voetnoten worden losse links.

Wat het erger maakt, is waar de notitie naartoe gaat. Trilium bewaart tekstnotities als HTML. De Markdown die je zo zorgvuldig hebt gemaakt, wordt door de editor dus meteen weer naar HTML omgezet. Je hebt betaald voor een retourtje en komt uit waar je begon, min de stukken die de reis niet hebben overleefd.

Tri Clipper houdt de HTML. Het gebruikt Readability van Mozilla om het artikel te vinden, en kleedt wat overblijft daarna uit tot de set tags die de editor van Trilium echt bewaart: alinea’s, koppen, lijsten, tabellen, figuren, links, afbeeldingen, nadruk. Al het andere gaat eruit, ook de scripts, de stijlen en de classnamen die Trilium later toch zou hebben weggegooid.

Het saaie deel van dat werk blijkt het belangrijkste: lazy-loaded afbeeldingen herleiden naar hun echte bron in plaats van de placeholder van 1×1 in src, relatieve links absoluut maken zodat ze over een jaar nog werken, en de breedste kandidaat uit een srcset kiezen in plaats van de kandidaat die toevallig in het venster paste toen je klikte.

Een sjabloon bepaalt vier dingen

Het instellingenscherm met de sjablooneditor open, met bestemming, modus, siteregels, titel en labels.
Geef een sjabloon de sites die het moet claimen, en het kiest zichzelf.

Waar de notitie terechtkomt, hoe hij heet, welke labels hij krijgt en hoe de inhoud eruitziet. Dat is het hele model. Titels en labels schrijf je met variabelen: de paginatitel, de auteur, de publicatiedatum, elke metatag, elk JSON-LD-veld, of de tekst van elk element dat je met een CSS-selector aanwijst. Filters vormen ze onderweg:

{{title|safe}}
{{author|split:","|first}}
{{published|date:"YYYY-MM-DD"}}
{{meta:og:site_name}}
{{selector:.byline|trim}}

Waar de clipper van Obsidian YAML-frontmatter schrijft, schrijft deze Trilium-labels. Hetzelfde idee, maar eigen aan de bestemming: #author, #published, #site, en een #clipUrl met het adres waar de notitie vandaan kwam.

Dat laatste label doet meer dan je zou denken. Geef een sjabloon een lijst sites en het claimt ze, zodat clippen van een bepaalde blog vanzelf het sjabloon pakt dat je voor die blog hebt geschreven, zonder dat je het hoeft te kiezen.

Markeringen, en dezelfde pagina twee keer clippen

Een artikel met twee gemarkeerde passages en de popup in de modus Markeringen.
Markeermodus aan, selecteer de passages die het bewaren waard zijn, en clip die.

Zet de markeermodus aan, markeer de passages die je wilt, en de notitie bevat alleen die. Kom je een week later terug op dezelfde pagina en markeer je iets anders, dan komen de nieuwe passages bij de notitie die je al had. Daar is #clipUrl voor: de extensie zoekt een notitie met het adres van deze pagina en voegt daaraan toe, in plaats van een tweede notitie te beginnen.

Je ziet de notitie voordat hij wordt geschreven

De popup met het voorbeeldpaneel open, waarin de opgemaakte inhoud van de notitie te zien is.
De uitgewerkte titel, de uitgewerkte labels, de uitgewerkte inhoud.

De popup laat de notitie zien die zo meteen wordt aangemaakt, met elke variabele al ingevuld. Verander de titel, haal een label weg, kies een andere bestemming, en dat is precies wat Trilium ontvangt. Ik heb dit gebouwd nadat ik voor de derde keer twee dagen later een notitie met een slechte titel tegenkwam.

De extensie praat niet met de server

Dit is het deel waar ik niet verwachtte een avond aan kwijt te zijn.

Het voor de hand liggende ontwerp is dat de extensie de ETAPI van Trilium rechtstreeks aanroept. Twee regels van WebKit zitten in de weg. Trilium stuurt helemaal geen CORS-headers mee, dus een cross-origin-verzoek vanuit de extensie wordt geweigerd. En een Trilium op je eigen netwerk bereik je via gewone http, en dat telt als mixed content vanuit de beveiligde origin van de extensie zelf.

Geen van beide beperkingen geldt voor URLSession in een native app. Dus doet de JavaScript-kant het verzoek niet zelf: die geeft het via native messaging door aan een kleine Swift-handler die in de extensie meekomt, en die handler voert het uit. De relay accepteert alleen paden onder /etapi/, opgelost tegen het adres in je instellingen. Zo wordt het geen proxy voor algemeen gebruik voor alles wat de extensie bereikt.

De verbindingsinstellingen, met een serveradres, een ETAPI-token en een geslaagde verbindingstest.
Jouw server, jouw token. Er is geen account en geen dienst ertussen.

Er is een schakelaar voor de directe route, voor wie Trilium al achter https heeft staan met CORS opengezet. De relay is de standaard, omdat die werkt zonder iets van je server te vragen.

Eerst gebouwd zonder Xcode

Toen ik begon stond er geen Xcode op deze Mac, alleen de Command Line Tools. Ik ging ervan uit dat een Safari-extensie daarmee afviel, omdat Safari alleen extensies laadt die in een app-bundle zitten, en safari-web-extension-converter een tool van Xcode is.

Een .appex blijkt een gewone bundle te zijn die je met de hand in elkaar kunt zetten. Het enige wat Xcode doet dat lastig te raden is, is één linker-flag: app-extensies starten via NSExtensionMain in Foundation en niet via main().

swiftc -O -module-name TriClipperExtension -parse-as-library \
  -Xlinker -e -Xlinker _NSExtensionMain \
  -framework SafariServices \
  -o "$APPEX/Contents/MacOS/TriClipperExtension" \
  Sources/Handler/SafariWebExtensionHandler.swift

Voeg een Info.plist toe die com.apple.Safari.web-extension als extension point noemt, zet de bestanden van de webextensie in de Resources van de appex, onderteken beide bundles, en macOS registreert hem. Eén detail kost je een uur als je het mist: de app eromheen moet één keer gestart zijn voordat pluginkit de extensie oppikt. Registreren zonder de app te starten is niet genoeg.

Het addertje zit in het ondertekenen. Met een ad-hocsignatuur laadt Safari de extensie alleen zolang Allow unsigned extensions in het Develop-menu aangevinkt staat, en die instelling zet zichzelf uit elke keer dat Safari stopt. Een Developer ID-certificaat maakt daar een eind aan, en dat is de reden dat dit stuk er überhaupt is: de app is op weg naar de App Store.

Uiteindelijk heb ik Xcode toch geïnstalleerd. De iOS SDK en de simulator komen alleen daarmee, en een build voor iPhone en iPad heeft ze allebei nodig. Het regelt ook het ondertekenen: xcodebuild -allowProvisioningUpdates maakt de bundle identifiers en provisioning profiles voor alle vier de targets aan, werk dat anders met de hand in het developer portal gebeurt.

Het project komt uit safari-web-extension-converter, gedraaid zonder --copy-resources, zodat het naar dezelfde extensiemap wijst als de met de hand gebouwde versie. Er is één kopie van de webextensie en niets om gelijk te houden. De converter liet wel fouten achter. De ergste: hij stond uitgaande verbindingen toe voor de app en vergat de extensie, en daar draait de relay juist. De app zou er gezond hebben uitgezien terwijl elke clip mislukte. Een script dat op beide manieren bouwt en de resultaten vergelijkt, heeft het gevonden.

De met de hand gebouwde versie bestaat nog, maar releases komen uit Xcode, omdat die ook voor Intel-Macs bouwt.

Waar je het krijgt

Tri Clipper staat gratis in de App Store, voor Mac, iPhone en iPad. Je hebt een Trilium-server nodig met ETAPI aan, en een token dat je aanmaakt onder Options → ETAPI. Een server op je eigen netwerk mag gewoon http zijn; een server die je van buitenaf bereikt moet https zijn.

Het heeft niets te maken met het Trilium-project. Het is iets wat ik op mijn eigen machine wilde hebben, en het bleek de moeite waard om het goed af te maken.

← Alle berichten