Eine Textauswahl im Browser in Obsidian speichern

Strichzeichnung eines Entwicklers vor einem Bildschirm mit etwas Code. Schmuck bild für den Blogartikel

Einleitung

Wenn ich inter­es­san­te Webseiten ent­de­cke, nut­ze ich Bookmarks. Auf die­sem Blog habe ich bereits zwei Methoden vor­ge­stellt, wie ich mit­hil­fe ein­fa­cher Tastenkürzel Bookmarks erstel­le, die dann in Obsidian gespei­chert wer­den. Diese Vorgehensweise ermög­licht es mir, mei­ne Bookmarks unab­hän­gig vom ver­wen­de­ten Browser und der Plattform zen­tral zugäng­lich zu machen.

Die bis­her vor­ge­stell­ten Lösungen waren recht sim­pel: Entweder wur­den die Bookmarks nur mit Datum, Titel und URL gespei­chert oder in einer anspre­chen­de­ren Variante auch mit einem klei­nen Vorschaubild und einem Auszug der Webseite, sofern ver­füg­bar.

Ein Nachteil die­ser Methoden ist jedoch, dass man – selbst in der erwei­ter­ten Variante – leicht den Überblick ver­lie­ren kann, war­um eine Webseite als erin­ne­rungs­wür­dig ein­ge­stuft wur­de. Manchmal sind Titel und Auszug aus­rei­chend, oft jedoch nicht. Eine für mich prak­ti­ka­ble Lösung besteht dar­in, zusätz­lich einen Textauszug zu spei­chern, der mehr Kontext bie­tet als die ande­ren Methoden.

Ich möch­te zwei Lösungen vor­stel­len, mit denen ich nun auf dem Mac sowie auf dem iPhone und iPad ein Bookmark zusam­men mit einer Textauswahl von der Webseite in Obsidian able­gen kann. Das Vorgehen, das ich auf dem Mac mit der Alfred App und dem Powerpack nut­ze, stel­le ich in die­sem Beitrag etwas aus­führ­li­cher vor. Wie ich das für iOS/iPadOS hand­ha­be, wer­de ich dann in einem zwei­ten Artikel vor­stel­len.

Warum zwei unter­schied­li­che Lösungen? Auf dem Mac nut­ze ich ger­ne ver­schie­de­ne Browser. Alfred bie­tet eine Funktion, die es ermög­licht, JavaScript-Skripte in fast allen instal­lier­ten Browsern zu star­ten. In den Apple-Kurzbefehlen ist das Starten von JavaScript-Skripten lei­der nur im Safari-Browser mög­lich. Allerdings benö­tig am Ende das JavaScript nur eine mini­ma­le Anpassung um in einem Kurzbefehl ver­wen­det wer­den kann.

Was soll erreicht werden

Mein Ziel ist es, aus einer sol­chen Markierung im Browser:

Das Bild zeigt den Inhalt eine Browserfenster mit eine Markierung.

auf einer defi­nier­ten Notiz in mei­nem Obsidian Vault einen sol­chen Eintrag hin­zu­zu­fü­gen:

Das Bild zeigt, wie der oben markierte Browser Text als Snippet in einer Obsidian Notiz aussieht.

Wie ich bereits erwähnt habe, basiert der Teil, der die Markierung im Browser ver­ar­bei­tet, auf JavaScript. Bei mei­ner Suche im Web hat sich die Verwendung von JavaScript als die bes­te Lösung her­aus­ge­stellt, da es spe­zi­ell dafür ent­wi­ckelt wur­de, Inhalte auf Webseiten zu mani­pu­lie­ren.

Allerdings muss dafür in den Browsern die Ausführung von JavaScript erlaubt sein. Beim Safari-Browser fin­det man die­se Einstellung im Tab „Sicherheit”. In ande­ren Browsern kann man sie bei­spiels­wei­se im Menü „Anzeigen → Entwickler” akti­vie­ren.

Ansonsten wird für die Umsetzung die Alfred App sowie das Powerpack benö­tigt. Zusätzlich muss für den zwei­ten Teil des Workflows Python auf dem Mac instal­liert sein.

1. Schritt: Das JavaScript

Meine JavaScript-Kenntnisse beschrän­ken sich auf grund­le­gen­de Prinzipien und Anwendungsfälle, also gehen prak­tisch gegen Null. Daher benö­tig­te ich für die Erstellung des not­wen­di­gen Codes, um die Textauswahl in einem Browser zu ver­ar­bei­ten, Hilfe. Wie schon so oft, griff ich dafür auf ChatGPT zurück, und nach eini­gen Runden haben wir einen „Working Code” erstellt:

/**
 * Extracts the currently selected HTML from the browser window.
 * @returns {string} The HTML string of the selected content.
 */
function getSelectionHtml() {
    // console.log("Attempting to get the current selection...");
    let html = "";
    if (window.getSelection) {
        const sel = window.getSelection();
        // console.log(`Selection object obtained: ${sel.toString()}`);
        if (sel.rangeCount) {
            // console.log(`Number of selection ranges: ${sel.rangeCount}`);
            const container = document.createElement("div");
            for (let i = 0, len = sel.rangeCount; i < len; ++i) {
                container.appendChild(sel.getRangeAt(i).cloneContents());
                // console.log(`Contents of range ${i} appended to container.`);
            }
            html = container.innerHTML;
            // console.log("Final HTML content extracted from selection:");
            // console.log(html);
        } else {
            // console.log("No ranges in the selection.");
        }
    } else {
        // console.log("window.getSelection is not supported by this browser.");
    }
    return html;
}

/**
 * Converts HTML content into Markdown.
 * @param {string} html - HTML content to be converted.
 * @returns {string} The converted Markdown.
 */
function convertHtmlToMarkdown(html) {
    // console.log("Starting conversion of HTML to Markdown...");
    const doc = new DOMParser().parseFromString(html, 'text/html');
    let markdown = convertNode(doc.body);
    // console.log("Markdown conversion completed:");
    // console.log(markdown);
    return markdown.trim();
}

/**
 * Converts an HTML node to its Markdown representation.
 * @param {Node} node - The HTML node to convert.
 * @returns {string} Markdown representation of the node.
 */
function convertNode(node) {
    // console.log(`Converting node: ${node.nodeName}`);
    let markdown = '';

    if (node.nodeType === Node.ELEMENT_NODE) {
        // console.log(`Processing element node: ${node.tagName}`);
        switch (node.tagName) {
            case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6':
                markdown += `${'#'.repeat(parseInt(node.tagName[1]))} ${node.textContent.trim()}\n\n`;
                break;
            case 'P':
                markdown += `${node.textContent.trim()}\n\n`;
                break;
            case 'A':
                markdown += `[${node.textContent}](${node.href})`;
                break;
            case 'UL': case 'OL':
                markdown += convertList(node);
                break;
            case 'IMG':
                const alt = node.alt || 'Image';
                const src = node.src || '';
                const title = node.title ? ` "${node.title}"` : '';
                markdown += `![${alt}](${src}${title})\n`;
                break;
            case 'PRE': case 'CODE':
                markdown += `\`\`\`\n${node.textContent}\n\`\`\`\n\n`;
                break;
            case 'TABLE':
                markdown += convertTableToMarkdown(node);
                break;
            default:
                node.childNodes.forEach(child => {
                    markdown += convertNode(child);
                });
                break;
        }
    } else if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
        // console.log(`Adding text node content: ${node.textContent.trim()}`);
        markdown += node.textContent.trim() + ' ';
    }

    return markdown;
}

/**
 * Converts HTML lists to Markdown.
 * @param {HTMLElement} list - The list element.
 * @returns {string} Markdown formatted list.
 */
function convertList(list) {
    // console.log(`Converting ${list.tagName} to Markdown list...`);
    let markdown = '';
    const items = list.children;
    for (let item of items) {
        if (item.tagName === 'LI') {
            const prefix = list.tagName === 'OL' ? (Array.from(list.children).indexOf(item) + 1) + '.' : '-';
            markdown += `${prefix} ${item.textContent.trim()}\n`;
        }
    }
    return markdown + '\n';
}

/**
 * Converts HTML tables to Markdown.
 * @param {HTMLTableElement} table - The table element.
 * @returns {string} Markdown formatted table.
 */
function convertTableToMarkdown(table) {
    // console.log(`Converting table to Markdown...`);
    let markdown = '';
    const rows = table.querySelectorAll('tr');
    rows.forEach((row, index) => {
        let rowMarkdown = '|';
        row.querySelectorAll('th, td').forEach(cell => {
            rowMarkdown += ` ${cell.textContent.trim()} |`;
        });
        markdown += rowMarkdown + '\n';

        // Add header separator for the first row if it contains headers
        if (index === 0 && row.querySelector('th')) {
            markdown += '|' + Array.from(row.children).map(() => ' --- ').join('|') + '|\n';
        }
    });
    return markdown + '\n';
}

/**
 * Main function that orchestrates the conversion from HTML to Markdown.
 * @returns {void}
 */
function main() {
    // console.log("Main function started...");
    const html = getSelectionHtml();
    if (!html) {
        // console.error('No HTML content selected or script failed to access selection.');
        return;
    }

    let markdown = convertHtmlToMarkdown(html);
    const datum = new Date().toLocaleDateString();
    const title = document.title;
    const url = window.location.href;
    const header = `\n\n>[\!snippets] ${datum} [${title}](${url})\n`;
    markdown = markdown.split('\n').map(line => '> ' + line).join('\n');
    markdown = header + markdown;
    // console.log(`Final Markdown output: ${markdown}`);
    return (markdown);
}

main();

ChatGPT war so nett, den Code mit Kommentaren zu ver­se­hen, die die wich­tigs­ten Funktionen des Skripts erläu­tern. Ich hof­fe, es ist in Ordnung, dass die­se Kommentare auf Englisch ver­fasst sind. Zudem wur­den Logging-Befehle inte­griert, die es ermög­li­chen, die Ausführung des Skriptes in der Konsole der Browser-Entwicklertools zu ver­fol­gen. Im obi­gen Code sind die­se jedoch aus­kom­men­tiert. Falls gewünscht, kön­nen sie akti­viert wer­den indem ‘// console.log’ durch ‘console.log’ in einem Text Editor mit der Suchen und Ersetzen Funktion aus­ge­tauscht wird.

Was macht der Code?

Da ich nicht nur ein­fach den Code ver­wen­den möch­te, son­dern die Funktionsweise in JavaScript ver­ste­hen will, bat ich ChatGPT, mir eine detail­lier­te Erklärung der ein­zel­nen Funktionen zu geben. Es bedurf­te zwar auch eini­ger Nachfragen, doch in den fol­gen­den Absätzen möch­te ich auf­zei­gen, was ich letzt­end­lich ver­stan­den habe. Ich hof­fe, die Erklärungen sind eini­ger­ma­ßen kor­rekt und eben­so lehr­reich wie für mich. Es ist aber durch­aus o.k, die­sen Teil zu Überspringen und direkt zum Schritt 2 zu sprin­gen.

1. getSelectionHtml()

function getSelectionHtml() {
    // console.log("Attempting to get the current selection...");
    let html = "";
    if (window.getSelection) {
        const sel = window.getSelection();
        // console.log(`Selection object obtained: ${sel.toString()}`);
        if (sel.rangeCount) {
            // console.log(`Number of selection ranges: ${sel.rangeCount}`);
            const container = document.createElement("div");
            for (let i = 0, len = sel.rangeCount; i < len; ++i) {
                container.appendChild(sel.getRangeAt(i).cloneContents());
                // console.log(`Contents of range ${i} appended to container.`);
            }
            html = container.innerHTML;
            // console.log("Final HTML content extracted from selection:");
            // console.log(html);
        } else {
            // console.log("No ranges in the selection.");
        }
    } else {
        // console.log("window.getSelection is not supported by this browser.");
    }
    return html;
}

Das Skript ver­wen­det die Funktion getSelectionHtml(), um das HTML der aktu­el­len Markierung im Browser zu über­neh­men. Mit dem Statement if(window.getSelection), wird zunächst sicher­ge­stellt, dass der Browser die­se Funktion über­haupt unter­stützt. Sollte das nicht der Fall sein, wird der else-Teil des Codes aus­ge­führt, der in hier im Moment nur einen aus­kom­men­tier­ten Befehl für das Debugging ent­hält. Andernfalls wird das sel-Objekt mit der Browser-Markierung befüllt. Die nach­fol­gen­de Abfrage if (sel.rangeCount) prüft, ob über­haupt etwas aus­ge­wählt wur­de. Der rangeCount ent­hält die Anzahl der zusam­men­hän­gen­den Markierungsbereiche, die dann in der fol­gen­den for-Schleife bear­bei­tet wer­den. Wenn nichts im Browser aus­ge­wählt ist, steht im rangeCount eine 0 und der else-Fall wird aus­ge­führt. In allen ande­ren Fällen wird die for-Schleife durch­lau­fen, obwohl die­se eigent­lich nicht not­wen­dig ist, da ich das Skript nur in moder­nen Browsern ver­wen­de, die zu die­sem Zeitpunkt kei­ne Möglichkeit bie­ten, meh­re­re Bereiche, wie z.B. in Word, zu mar­kie­ren. Also steht im rangeCount immer ent­we­der eine 0 oder eine 1.

Diese bei­den if-Statements machen den Code etwas kom­ple­xer, weil sie eigent­lich nicht not­wen­dig sind, aber ich habe mich ent­schie­den, sie zu behal­ten, um eine gene­rel­le JavaScript Frage zu klä­ren, die mich anfangs etwas ver­wirr­te. Die for-Schleife star­tet mit einem Counter von 0 und wird nur ein­mal durch­lau­fen, da ((let i = 0, len = sel.rangeCount; i < len; ++i) bedeu­tet, star­te mit 0 und ende bei 1–1, also eben­falls 0. Ich hät­te erwar­tet, dass die Schleife bei 1 beginnt, also mit dem Wert des rangeCount, und dann das Abbruchkriterium eher i = len gewe­sen wäre. Warum also wird die Schleife mit dem Zähler i=0 gestar­tet? Die Lösung ist ganz ein­fach: Bei Konstrukten wie Arrays oder Listen hat das ers­te Element den Index 0 und nicht 1. So wird in dem Statement container.appendChild(sel.getRangeAt(i).cloneContents()); dann das ers­te Element dem Objekt container zuge­ord­net, in dem die Markierung steht, da i=0 ist. Das gin­ge zwar auch anders, aber es scheint guter JavaScript-Stil zu sein, die 1 nicht inner­halb der for-Schleife von i abzu­zie­hen.

Das Objekt container wur­de vor der Schleife als Dokument mit einem <div> initia­li­siert. An die­ses <div> wird dann der HTML-Code der Markierung erst ange­hängt und dann wie­der­um mit der Zeile vor der Zuordnung zur Variablen html = container.innerHTML; abge­hängt. Dieser Schritt soll laut ChatGPT hel­fen, aus dem String des HTML-Fragment eine sau­be­re HTML-Struktur zu machen, auch wenn am Ende wie­der ein String als Resultat aus­ge­ge­ben wird.

Der ver­ein­fach­te Funktionscode wür­de übri­gens so aus­se­hen:

function getSelectionHtml() {
    let html = "";
    const sel = window.getSelection();
    if (sel.rangeCount > 0) {
        const container = document.createElement("div");
        container.appendChild(sel.getRangeAt(0).cloneContents());
        html = container.innerHTML;
    }
    return html;
}

Für mich wer­de ich eher den kom­ple­xe­ren Code durch eine bes­se­re Ausnahmebehandlung erwei­tern. — aber nicht an die­ser Stelle.

2. convertHtmlToMarkdown(html)

function convertHtmlToMarkdown(html) {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    let markdown = convertNode(doc.body);
    return markdown.trim();

Mit die­ser klei­ne Funktion wird die Konvertierung vor­be­rei­tet. In der Zeile

const doc = new DOMParser().parseFromString(html, 'text/html');

wird mit dem HTML-Fragment ein neu­es DOM-Objekt erstellt. DOM ist die Abkürzung von „Document Objekt Model und stellt das HTML als Baustruktur zu Verfügung, für das JavaScript ein­fa­che Verarbeitungsfunktionen bie­tet.

Im nächs­ten Schritt wird mit dem Aufruf let markdown = convertNode(doc.body); der Teil des DOM-Objektes an die Konvertierungsfunktion über­ge­ben, wobei nur der body des gesam­ten HTML-Dokuments über­ge­ben wird. Prinzipiell besteht das DOM einer Webseite aus wei­te­ren Teilen, wie dem head-Teil. Dieser Aufruf stellt sicher, dass nur der „sicht­ba­re Teil” über­ge­ben wird. Das Ergebnis, das aus der Konvertierung ent­stan­de­ne Markdown, wird dann der Variablen markdown über­ge­ben und mit der Methode trim() von even­tu­el­len über­flüs­si­gen Leerzeichen befreit, bevor es als Ergebnis aus­ge­ge­ben wird.

3. convertNode(node), convertList(list) und convertTableToMarkdown(table)

function convertNode(node) {
    // console.log(`Converting node: ${node.nodeName}`);
    let markdown = '';

    if (node.nodeType === Node.ELEMENT_NODE) {
        // console.log(`Processing element node: ${node.tagName}`);
        switch (node.tagName) {
            case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6':
                markdown += `${'#'.repeat(parseInt(node.tagName[1]))} ${node.textContent.trim()}\n\n`;
                break;
            case 'P':
                markdown += `${node.textContent.trim()}\n\n`;
                break;
            case 'A':
                markdown += `[${node.textContent}](${node.href})`;
                break;
            case 'UL': case 'OL':
                markdown += convertList(node);
                break;
            case 'IMG':
                const alt = node.alt || 'Image';
                const src = node.src || '';
                const title = node.title ? ` "${node.title}"` : '';
                markdown += `![${alt}](${src}${title})\n`;
                break;
            case 'PRE': case 'CODE':
                markdown += `\`\`\`\n${node.textContent}\n\`\`\`\n\n`;
                break;
            case 'TABLE':
                markdown += convertTableToMarkdown(node);
                break;
            default:
                node.childNodes.forEach(child => {
                    markdown += convertNode(child);
                });
                break;
        }
    } else if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
        // console.log(`Adding text node content: ${node.textContent.trim()}`);
        markdown += node.textContent.trim() + ' ';
    }

    return markdown;
}

Hier pas­siert die eigent­li­che Arbeit. Die Funktion convertNode(node) ruft sich selbst immer wie­der rekur­siv, im default-case des switch, auf und durch­läuft so den Baum, also das DOM, Knoten für Knoten. Dabei fügt sie die ent­spre­chen­de Markdown-Konvertierung zur Variable markdown hin­zu. Ich beschrän­ke mich auf die fol­gen­den HTML-Tags:

  • <H1> bis <H6>
  • <p>
  • <a>
  • <img>
  • <pre> und <code>
  • <ul> und <ol>
  • <table>

Die HTML-Tags <table>, <ul> und <ol> wer­den jedoch nicht direkt inner­halb der Funktion convertNode(node) kon­ver­tiert, son­dern dafür wer­den spe­zia­li­sier­te, etwas kom­ple­xe­re Funktionen auf­ge­ru­fen. Für die Listen ist die ent­spre­chen­de Funktion:

function convertList(list) {
    // console.log(`Converting ${list.tagName} to Markdown list...`);
    let markdown = '';
    const items = list.children;
    for (let item of items) {
        if (item.tagName === 'LI') {
            const prefix = list.tagName === 'OL' ? (Array.from(list.children).indexOf(item) + 1) + '.' : '-';
            markdown += `${prefix} ${item.textContent.trim()}\n`;
        }
    }
    return markdown + '\n';
}

Die Funktion erhält das List-Objekt als Eingabe, wel­ches den zu kon­ver­tie­ren­de HTML-Listen-Teilbau ent­hält. Dies kann sowohl eine geord­ne­te Liste (<ol>) als auch eine unge­ord­ne­te Liste (<ul>) beinhal­ten oder auch getes­te­te Listen. Nach der Initialisierung der Variablen markdown, die nach und nach gefüllt wird, wird eine for-Schleife durch­lau­fen, in der auf die Unterknoten des Listen-Elements zuge­grif­fen wird (list.children). Diese Unterknoten soll­ten die Listeneinträge (<li>) ent­hal­ten.

Abhängig vom Listentyp wird jedes Listenelement ent­we­der mit einer fort­lau­fen­den Nummer und einem Punkt im Falle von <ol> oder mit einem Bindestrich im Falle von <ul> in die Variable markdown ein­ge­fügt.

Nachdem das Array mit den Items durch­lau­fen ist, wird das resul­tie­ren­de Markup als Ergebnis zurück­ge­ge­ben.

Die Abarbeitung eines <table-Tags ist etwas kom­pli­zier­ter:

function convertTableToMarkdown(table) {
    // console.log(`Converting table to Markdown...`);
    let markdown = '';
    const rows = table.querySelectorAll('tr');
    rows.forEach((row, index) => {
        let rowMarkdown = '|';
        row.querySelectorAll('th, td').forEach(cell => {
            rowMarkdown += ` ${cell.textContent.trim()} |`;
        });
        markdown += rowMarkdown + '\n';

        // Add header separator for the first row if it contains headers
        if (index === 0 && row.querySelector('th')) {
            markdown += '|' + Array.from(row.children).map(() => ' --- ').join('|') + '|\n';
        }
    });
    return markdown + '\n';
}

In die­ser Funktion wird das table-Objekt über­ge­ben. Zunächst wird die Variable markdown initia­li­siert, in der das Ergebnis jedes Konvertierungsschrittes gesam­melt und schließ­lich als Resultat zurück­ge­ge­ben wird. Mit dem Aufruf table.querySelectorAll('tr') wer­den alle Zeilen (<tr>-Elemente) der Tabelle abge­ru­fen. Dieser Schritt erfasst alle hori­zon­ta­len Reihen der Tabelle, unab­hän­gig davon, ob sie Kopfzeilen (<th>) oder nor­ma­le Zelleneinträge (<td>) ent­hal­ten, und fügt sie als Array dem Objekt row hin­zu. Anschließend wird jede Zeile der Tabelle ein­zeln durch­lau­fen. Für jede Zeile wird eine neue Variable rowMarkdown initia­li­siert, die mit einem Pipe-Symbol | beginnt, was in Markdown eine Tabellenzelle mar­kiert. Innerhalb jeder Zeile wer­den mit­tels der Funktion row.querySelectorAll('th, td') alle Zellen abge­ru­fen, und für jede Zelle wird der getrimm­te Textinhalt gefolgt von einem abschlie­ßen­den Pipe-Symbol | zu rowMarkdown hin­zu­ge­fügt. Nun wird das Ergebnis jeder Zeilenverarbeitung an die Variable markdown ange­hängt, gefolgt von einem Zeilenumbruch \n, um die nächs­te Zeile der Markdown-Tabelle zu begin­nen. Zeilen, die Kopfzeilen ent­hal­ten (<th>), wer­den spe­zi­ell behan­delt, indem eine Zeile mit der Markdown-Syntax für die Trennlinie unter den Zellen ein­ge­fügt wird.

4. main()

Die Funktion main()ist die Steuerungszentrale. Sie wird als ers­tes Aufgerufen, wenn das Skript gestar­tet wird.

function main() {
    // console.log("Main function started...");
    const html = getSelectionHtml();
    if (!html) {
        // console.error('No HTML content selected or script failed to access selection.');
        return;
    }

    let markdown = convertHtmlToMarkdown(html);
    const datum = new Date().toLocaleDateString();
    const title = document.title;
    const url = window.location.href;
    const header = `\n\n>[\!snippets] ${datum} [${title}](${url})\n`;
    markdown = markdown.split('\n').map(line => '> ' + line).join('\n');
    markdown = header + markdown;
    // console.log(`Final Markdown output: ${markdown}`);
    return (markdown);
}

Zunächst wird die Konstante html mit dem Ergebnis des Aufrufes der Funktion getSelectionHtml() initia­li­siert. Die if-Abfrage soll­te zur Fehlerbehandlung die­nen, wenn nichts aus­ge­wählt wur­de, wor­auf ich im Moment noch ver­zich­te.

Das HTML-Fragment wird dann der Konvertierungsfunktion über­ge­ben, und deren Ergebnis wird in der Variablen markdown gespei­chert.

Da ich als Ergebnis ein Markdown haben möch­te, das in etwa so aus­sieht:

>[!snippets] 24.4.2024 [dit und dat](https://ileif.de/)
> ## Update: Obsidian Vaults synchronisieren.
> 
> In mei­nem Artikel Obsidian Vaults syn­chro­ni­sie­ren, auch ohne iCloud beschrei­be ich, wie man das Obsidian-Erweiterung remo­te­ly-save nut­zen kann, um einen Vault über ver­schie­de­ne Plattformen hin­weg zu syn­chro­ni­sie­ren. Obwohl das Plugin nun schon eini­ge Zeit nicht aktua­li­siert wur­de, funk­tio­nier­te es bis­her gut. Allerdings waren eini­ge Sicherheitsupdates not­wen­dig, sodass es nun einen Fork gibt,der unter dem Namen…

Die ers­te Zeile des Snippets-Markdowns wird in der Zeile

const header = \n\n>[\!snippets] ${datum} [${title}](${url})\n; 

zusam­men­ge­baut und in der Konstanten header gespei­chert, wobei noch die zuvor initia­li­sier­ten Variablen datum, title und url ver­wen­det wer­den. Das [!snippets] ist ein von mir defi­nier­ter Callout-Typ. Wie das funk­tio­niert, beschrei­be ich kurz unten im Text. Es wür­de auch z.B. [\!info] oder ein ähn­li­cher tor­de­fi­nier­ter Callout-Typ gehen. Der Backslash \ vor dem ! war not­wen­dig, weil das ! ohne das Fluchtsymbol Fehler pro­du­zier­te. Die zwei \n\n vor dem >[\!snippets] die­nen zur visu­el­len Trennung der Blöcke in der Obsidian-Notiz.

Bevor nun die Teile zusam­men­ge­fügt wer­den, wird noch das Ergebnis der Funktion getSelection für ein Callout auf­be­rei­tet, indem jeder Zeile ein > vor­an­ge­stellt wird. Was mit der Zeile

markdown = markdown.split('\n').map(line => '> ' + line).join('\n');

pas­siert, zeigt wie­der die Stärke von JavaScript bei der Manipulation von Objekten: Die Methode split erstellt aus jeder Zeile des HTML-Codes ein Item in einem Array, wobei das \n als Trennungszeichen defi­niert ist. Mit map wird dann jeder Zeile ein > vor­an­ge­stellt und am Ende mit join das Ganze wie­der als ein String zusam­men­ge­baut und die Zeilenumbrüche mit \n wie­der ein­ge­fügt.

Zu guter Letzt wer­den die Variablen header und markdown zusam­men­ge­fügt und als Ergebnis aus­ge­ge­ben.

Schritt 2: Das Markdown Snippet in eine Obsidian Datei einfügen

Im ers­ten Teil wur­de aus der Markierung im Browser ein Markdown erstellt, das nun im zwei­ten Schritt in eine vor­han­de­ne Obsidian-Notiz ein­ge­fügt wer­den soll. Dafür ver­wen­de ich ein klei­nes Python-Skript, das die­se Verarbeitung direkt in der ent­spre­chen­den Datei vor­nimmt.

In mei­nem Fall heißt die Datei Snippet.md und ist in mei­nem Vault abge­legt, also bei­spiels­wei­se unter /Users/Documents/ObsidianVault/Snippets.md. Da auch in die­ser Datei am Anfang ein Bereich für die Meta-Daten der Notiz, das soge­nann­te Frontmatter, steht und ich eine Sortierung von „neu” nach „alt” bevor­zu­ge, benö­ti­ge ich einen Marker, der den Ort bezeich­net, an dem das Snippet direkt hin­ter dem Frontmatter ein­ge­fügt wird. Eigentlich bevor­zu­ge ich einen Markdown-Ausdruck wie %%ins%%, der den Marker nur im Editier-Modus anzeigt. Da ich jedoch auch von iOS aus auf die­se Datei zugrei­fe und Snippets hin­zu­fü­ge, sind die Möglichkeiten begrenzt. In mei­nem nächs­ten Beitrag wer­de ich dies aus­führ­li­cher erläu­tern. Die Kurzbefehle auf iOS erlau­ben nur das Einfügen hin­ter einer Überschrift. Deshalb nut­ze ich ###### Snippets als Marker.

Und so sieht der ent­spre­chen­de Code aus:

import sys

def insert_snippet(file_path, insert_marker, snippet):
    with open(file_path, 'r', encoding='utf-8') as file:
        content = file.readlines()

    new_content = []
    insert_done = False

    for line in content:
        new_content.append(line)
        # Check if this line contains the insertion marker and we haven't inserted yet
        if insert_marker in line and not insert_done:
            # Replace escape sequences for newlines and append the snippet
            formatted_snippet = snippet.replace(r'\\n', '\n')
            new_content.append(formatted_snippet + '\n')
            insert_done = True

    # Rewrite the modified content back to the file
    with open(file_path, 'w', encoding='utf-8') as file:
        file.writelines(new_content)

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("Usage: python script.py <snippet>")
        sys.exit(1)

    snippet = sys.argv[1]
    file_path = "/path_to_my/Vault/Snippets.md"
    insert_marker = '###### Snippets'
    
    insert_snippet(file_path, insert_marker, snippet)

Dieses Skript is auch rela­tiv ein­fach auf­ge­baut. Das Skript wird mit dem aus dem ers­ten Teil gene­rier­ten Markdown-String auf­ge­ru­fen und ver­ar­bei­tet die­sen in der Funktion Insertion_snippet. Im untern wird geprüft, ob die Funktion tat­säch­lich mit einem String als Argument auf­ge­ru­fen wird, falls nicht steigt da Skript ein­fach aus. Ausser wird hier der Pfad zu der Notizdatein in den Variablen file_path und der Marker in der Variablen insert_marker fest­ge­legt. Mit die­sen drei Parameter, wird dann die eigent­lich Routine auf­ge­ru­fen, die das Snippet ein­fügt.

insert_snippet(file_path, insert_marker, snippet)

def insert_snippet(file_path, insert_marker, snippet):
    with open(file_path, 'r', encoding='utf-8') as file:
        content = file.readlines()

    new_content = []
    insert_done = False

    for line in content:
        new_content.append(line)
        # Check if this line contains the insertion marker and we haven't inserted yet
        if insert_marker in line and not insert_done:
            # Replace escape sequences for newlines and append the snippet
            formatted_snippet = snippet.replace(r'\\n', '\n')
            new_content.append(formatted_snippet + '\n')
            insert_done = True

    # Rewrite the modified content back to the file
    insert_done

Zuerst wird die Datei geöff­net und jede Zeile als sepa­ra­tes Element in der Liste content abge­spei­chert. Eine neue Liste new_content wird initia­li­siert, die den durch das Snippet ergänz­ten Dateiinhalt auf­nimmt. Zusätzlich wird insert_done mit False initia­li­siert, um sicher­zu­stel­len, dass das Snippet nur hin­ter dem ers­ten Auftreten des Markers ein­ge­fügt wird.

In der for-Schleife wird jede Zeile von content ite­riert und in die neue Liste new_content ein­ge­fügt. Mit der Bedingung if insert_marker in line and not insert_done: wird geprüft, ob in der aktu­el­len Zeile der Marker vor­han­den ist und ob insert_done noch immer False ist. Wenn die­se Bedingungen erfüllt sind, wird das Snippet in die new_conten ein­ge­fügt.

Das Skript trifft dabei Vorsichtsmaßnahmen wie das Ersetzen von \\n durch ech­te Zeilenumbrüche \n. Nach dem Einfügen des Snippets wird insert_done auf True gesetzt, um wei­te­re Einfügungen zu ver­hin­dern. Die ver­blei­ben­den Zeilen von content wer­den dann wei­ter­hin zu new_content hin­zu­ge­fügt.

Am Ende wird mit dem die Datei Snippets.md erneut geöff­net, der vor­han­de­ne Inhalt gelöscht und mit file.writelines(new_content) wird der aktua­li­sier­te Inhalt, ein­schließ­lich des ein­ge­füg­ten Snippets, zurück­ge­schrie­ben:

with open(file_path, 'w', encoding='utf-8') as file:
    file.writelines(new_content)

Damit sind die bei­den Skripte defi­niert und in dem Schritt 3 wer­den sie als Alfred Workflow zusam­men gebaut.

Schritt 3: Alfred Workflow

Die Definition des Alfred-Workflows erfolgt in drei ein­fa­chen Schritten:

  1. Als Trigger „Hotkey” zu der Aktion ver­wen­den.
  2. Den Automation Task -”Run JavaScript in Front Browser Tab“ aus­wäh­len und das JavaScript ein­fü­gen
  3. Eine “Run Script”-Aktion hin­zu­fü­gen und in die­se das Python-Skript ein­fü­gen.

Das war dann im Prinzip alles. Falls irgend­et­was nicht funk­tio­niert, kön­nen die // console.log-Anweisungen im JavaScript-Teil wie­der aus­kom­men­tiert wer­den. Dadurch lässt sich in der Konsole der Entwicklertools im Browser nach­voll­zie­hen, wo mög­li­cher­wei­se Probleme auf­tre­ten.

Defineren eigener Callout Typen

Wie ver­spro­chen folgt am Ende ein klei­ner Exkurs, wie man eige­ne Callout-Typen in Obsidian defi­nie­ren kann. Dazu wird ein wenig CSS benö­tigt:

.callout[data-callout="snippets"] {
    --callout-color: 61, 118, 218;
    --callout-icon: puzzle;
}

Dieser CSS-Code muss in Obsidian in einem CSS-Snippet gespei­chert sein, was ganz ein­fach umzu­set­zen ist. Gehen Sie dazu wie folgt vor:

  1. Unter den „Darstellung“-Einstellungen -> CSS-Bausteine auf das Ordnersymbol kli­cken.
  2. In dem sich öff­nen­den Ordner eine Datei mit einem aus­sa­ge­kräf­ti­gen Namen, z.B. callout.css, erstel­len.
  3. Die oben beschrie­be­ne CSS-Definition in die­se Datei ein­fü­gen und spei­chern.
  4. Anschließend auf das „Refresh“-Icon kli­cken, um die neu erstell­te Datei zu akti­vie­ren.

Für die Icons emp­fiehlt es sich, Lucide Icons zu nut­zen. In das Callout-CSS kann ein­fach der Name des gewünsch­ten Icons ein­ge­tra­gen wer­den. Ich ver­wen­de hier das Puzzle-Icon.

Schlusswort

Damit wäre die Lösung unter Alfred kom­plett dar­ge­stellt. Ich hof­fe, es war lehr­reich, denn ich habe – auch für mich selbst – den von ChatGPT und mir erar­bei­te­ten Code nicht nur über­flo­gen, son­dern jede Zeile genau betrach­tet und erklä­ren las­sen. Die Erklärungen von ChatGPT habe ich nicht ein­fach über­nom­men, son­dern – um auch selbst zu ler­nen – in mei­ne eige­nen Worte gefasst. Ich ent­schul­di­ge mich bei allen Informatikern und Fachleuten, falls ich eini­ge Begriffe viel­leicht nicht ganz kor­rekt ver­wen­det habe. Ich freue mich auf Korrekturen, Kritik und wei­te­re Kommentare.

In den nächs­ten Tagen folgt dann auch eine Lösung für das iPhone und das iPad, die etwas kür­zer aus­fal­len wird, da das JavaScript fast ohne Änderung über­nom­men wer­den kann. Mich wür­de auch inter­es­sie­ren, ob die­ses Vorgehen auch in Raycast imple­men­tiert wer­den kann. Falls jemand dazu Informationen oder Tipps hat, freue ich mich über Kommentare dazu.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert