TechWorkRamblings

by Mike Kalvas

Adding incoming and outgoing links to my site

A quick write-up of another feature

Today I walk through adding incoming and outgoing links to my site. It wasn't particularly challenging, so this should be a quick one, but I think it really adds something nice to the site.

#blog #tech #ramblings

Backlinks are really popular in the "knowledge management tech" scene. I use them in my Obsidian Zettelkasten all the time. I find that it adds organic navigation and context to notes and helps me stay in the flow. It also promotes surprise connections. Since I publish all my notes online I thought it would be cool to add that functionality so that I (and others who read my site) can have a similar experience. It turns out it was relatively simple. Let's take a look.

Implementation

Given that this is a homebrew static(ish) site generator, my first step was to add some code to the CLI that would read and generate all the note links at build time.

let linked_note_ids = parse_links(&content)?;
if linked_note_ids.len() > 0 {
    Link::insecure_create_all(note_id, linked_note_ids)?;
}

Which first parses all the links out of the body of the note,

fn parse_links(text: &str) -> anyhow::Result<Vec<u64>> {
    let mut linked_note_ids = Vec::new();
    for capture in WIKILINK_REGEX.captures_iter(text) {
        let target = capture.name("link").unwrap().as_str().to_string();
        let target = RENAME_REGEX.replace(&target, "").to_string();

        if RENAME_REGEX.is_match(&target) {
            tracing::debug!("extracting {target}");
        }

        let target_id: u64 = target.parse()?;
        linked_note_ids.push(target_id);
    }
    Ok(linked_note_ids)
}

Then saves them all to the database.

#[derive(Clone, Debug)]
pub struct Link {
    pub source: u64,
    pub target: u64,
}

// ... other plumbing ellided for the blog post

impl Link {
    /// SAFETY: This should never be used in the online server. This is a CLI
    /// only function because it is vulnerable to sql injection. I assume that I
    /// will not SQL inject myself with my note links.
    pub fn insecure_create_all(note_id: u64, linked_notes: Vec<u64>) -> anyhow::Result<()> {
        let mut links = "".to_string();
        for target in linked_notes {
            links.push_str(&format!("({note_id}, {target}),"));
        }
        links.pop(); // remove trailing comma
    
        let conn = connect()?;
        let tbl = Link::table_name();
        let sql = format!("INSERT OR IGNORE INTO {tbl} (source, target) VALUES {links}");
        let mut stmt = conn.prepare(&sql)?;
        stmt.execute(())?;
    
        Ok(())
    }
}

Which... yikes. Do as I say kids and not as I do. But here we are, and I feel relatively certain that I won't SQL inject myself in my notes. The other piece of "SaFeTy" here is that the regexes WIKILINK_REGEX and RENAME_REGEX pulls out a \d capture group into a Vec<u64>, so that makes it even less likely that something could get into that Vec<u64> for an injection attack.

Whatever, moving on.

Now we have all the links in the graph at build time so it's simple to query them and render them on the page.

impl Note {
    // ... other junk

    pub async fn linked_notes(&self) -> anyhow::Result<NoteLinks> {
        let conn = connect()?;
        let tbl = Note::table_name();      // compile time const
        let link_tbl = Link::table_name(); // compile time const

        let outgoing = conn
            .prepare(&format!(
                "SELECT * FROM {tbl} WHERE ID IN (SELECT target FROM {link_tbl} WHERE source = ?1)"
            ))?
            .query_map([self.id], |row| Note::try_from(row))?
            .collect::<Result<Vec<_>, _>>()?;

        let incoming = conn
            .prepare(&format!(
                "SELECT * FROM {tbl} WHERE ID IN (SELECT source FROM {link_tbl} WHERE target = ?1)"
            ))?
            .query_map([self.id], |row| Note::try_from(row))?
            .collect::<Result<Vec<_>, _>>()?;

        Ok(NoteLinks { outgoing, incoming })
    }
}

And the askama template has a html/jinja-like syntax

<div class="note-layout">
  <article>
    {{ note.content|md }}
    <section class="note-links">
      {% if links.incoming.len() > 0 %}
        <div class="link-header">
          <span class="icon inline invert">{%- include "icons/incoming.svg" -%}</span>Links to this note
        </div>
        <ul>
          {% for link in links.incoming %}
          <li>
            <a href="/notes/{{link.id}}"><span class="mono">{{link.id}}</span> {{link.title}}</a>
          </li>
          {% endfor %}
        </ul>
      {% endif %}
      {% if links.outgoing.len() > 0 %}
        <p class="link-header"><span class="icon inline invert">{%- include "icons/outgoing.svg" -%}</span>Linked from this note</p>
        <ul>
          {% for link in links.outgoing %}
          <li>
            <a href="/notes/{{link.id}}"><span class="mono">{{link.id}}</span> {{link.title}}</a>
          </li>
          {% endfor %}
        </ul>
      {% endif %}
    </section>
  </article>
</div>

The hardest part for me in most of my website projects is design. I try to maintain a consistent look and feel on my site. It might not be your taste or style, but that's ok, it's my site and I like it. However, this means I want things to feel like they really belong and were thought through. Hopefully this feature meets that bar.

On large screens the links get put in their own column to take advantage of the real estate.

Larger screen screenshot of the links section to the right of the main article

On smaller screens they wrap down below the main article. This works well on phones.

Smaller screenshot of the links section below the main article