~gpanders/wk

aa076091c763352f5af7e109b90828fc984f20c8 — Greg Anders 4 months ago 84a6a0c
Refactor common functions

Most of the heavy lifting now happens in the zettel.zig file itself and
most of the cmd/*.zig files just act as front-ends to these functions
(e.g. backlinks, reading, opening, etc.). This allows most of the
complexity to exist in a single place and the command functions
themselves to be nicely isolated.
M src/cmd.zig => src/cmd.zig +28 -121
@@ 5,20 5,17 @@ const mem = std.mem;
const stdout = std.io.getStdOut().outStream();
const warn = std.debug.warn;

const commit = @import("util.zig").commit;
const Zettel = @import("zettel.zig").Zettel;
const getZettels = @import("zettel.zig").getZettels;
const findZettels = @import("zettel.zig").findZettels;

pub const backlinks = @import("cmd/backlinks.zig");
pub const list = @import("cmd/list.zig");
pub const new = @import("cmd/new.zig");
pub const open = @import("cmd/open.zig");
pub const preview = @import("cmd/preview.zig");
pub const sync = @import("cmd/sync.zig");
pub const search = @import("cmd/search.zig");
pub const show = @import("cmd/show.zig");
pub const tags = @import("cmd/tags.zig");

const backlinks = @import("cmd/backlinks.zig");
const list = @import("cmd/list.zig");
const new = @import("cmd/new.zig");
const open = @import("cmd/open.zig");
const preview = @import("cmd/preview.zig");
const sync = @import("cmd/sync.zig");
const search = @import("cmd/search.zig");
const show = @import("cmd/show.zig");
const tags = @import("cmd/tags.zig");

pub const Command = enum {
    Backlinks,


@@ 58,6 55,21 @@ pub const Command = enum {
            .Tags => tags.desc,
        };
    }

    pub fn run(self: Command, allocator: *mem.Allocator, args: ?[]const []const u8) !void {
        var zettels = try getZettels(allocator);
        return switch (self) {
            .Backlinks => try backlinks.run(allocator, zettels),
            .List => try list.run(zettels, args),
            .New => try new.run(allocator, zettels, args),
            .Open => try open.run(allocator, zettels, args),
            .Preview => try preview.run(allocator, zettels, args),
            .Sync => try sync.run(allocator),
            .Search => try search.run(zettels, args),
            .Show => try show.run(allocator, zettels, args),
            .Tags => try tags.run(allocator, zettels, args),
        };
    }
};

pub fn printUsage(cmd: ?Command) void {


@@ 82,120 94,15 @@ pub fn printUsage(cmd: ?Command) void {
    }
}

pub fn handleCommand(allocator: *mem.Allocator, command: []const u8, args: ?[][]const u8) !void {
pub fn handleCommand(allocator: *mem.Allocator, command: []const u8, args: ?[]const []const u8) !void {
    if (mem.eql(u8, command, "help")) {
        const subcmd = if (args != null) try parseCommand(args.?[0]) else null;
        printUsage(subcmd);
        return;
    }

    var zettels = try getZettels(allocator);

    switch (try parseCommand(command)) {
        .Backlinks => {
            const matches = if (args) |_|
                try findZettels(allocator, zettels.items, args.?)
            else
                zettels.items;

            try backlinks.run(allocator, zettels.items, matches);
            try commitChanges(allocator, .Backlinks, matches);
        },
        .Show => {
            if (args) |_| {
                const matches = try findZettels(allocator, zettels.items, args.?);
                try show.run(allocator, matches);
            } else {
                return error.MissingRequiredArgument;
            }
        },
        .List => {
            list.run(allocator, args, zettels.items);
        },
        .Open => {
            const matches = if (args) |_|
                try findZettels(allocator, zettels.items, args.?)
            else
                null;

            try open.run(allocator, matches);
            try backlinks.run(allocator, zettels.items, zettels.items);
            try commitChanges(allocator, .Open, zettels.items);
        },
        .Sync => {
            try sync.run(allocator);
        },
        .New => {
            if (args) |_| {
                const new_zettel = try new.run(allocator, args.?[0]);
                try zettels.append(new_zettel);

                try open.run(allocator, zettels.items[zettels.items.len - 1 ..]);
                try backlinks.run(allocator, zettels.items, zettels.items);
                try commitChanges(allocator, .New, zettels.items);
            } else {
                return error.MissingRequiredArgument;
            }
        },
        .Search => {
            if (args) |_| {
                try search.run(args.?, zettels.items);
            } else {
                return error.MissingRequiredArgument;
            }
        },
        .Preview => {
            if (args) |_| {
                const matches = try findZettels(allocator, zettels.items, args.?);
                try preview.run(allocator, matches);
            } else {
                return error.MissingRequiredArgument;
            }
        },
        .Tags => {
            try tags.run(allocator, args, zettels.items);
        },
    }
}

fn commitChanges(allocator: *mem.Allocator, cmd: Command, zettels: []const Zettel) !void {
    var modified = try std.ArrayList(Zettel).initCapacity(allocator, zettels.len);

    for (zettels) |*zet| if (try zet.modified()) modified.appendAssumeCapacity(zet.*);

    if (modified.items.len > 0) {
        const msg = switch (cmd) {
            .Backlinks => try fmt.allocPrint(
                allocator,
                "Updated backlinks in {} file(s)",
                .{modified.items.len},
            ),
            .New => try fmt.allocPrint(
                allocator,
                "New note: {}",
                .{zettels[zettels.len - 1].title},
            ),
            .Open => blk: {
                var msg_lines = try std.ArrayList([]const u8).initCapacity(allocator, modified.items.len + 1);

                var line = try fmt.allocPrint(allocator, "Updated {} note(s):\n", .{modified.items.len});
                msg_lines.appendAssumeCapacity(line);
                for (modified.items) |item| {
                    line = try fmt.allocPrint(allocator, "- {} {}", .{ item.id, item.title });
                    msg_lines.appendAssumeCapacity(line);
                }

                break :blk try mem.join(allocator, "\n", msg_lines.items);
            },
            else => unreachable,
        };

        var files = try std.ArrayList([]const u8).initCapacity(allocator, modified.items.len);

        for (modified.items) |item| files.appendAssumeCapacity(item.fname);

        try commit(allocator, files.items, msg);
    }
    const cmd = try parseCommand(command);
    try cmd.run(allocator, args);
}

fn parseCommand(command: []const u8) !Command {

M src/cmd/backlinks.zig => src/cmd/backlinks.zig +10 -107
@@ 1,121 1,24 @@
const std = @import("std");
const ascii = std.ascii;
const fmt = std.fmt;
const fs = std.fs;
const mem = std.mem;
const stdout = std.io.getStdOut().outStream();

const Zettel = @import("../zettel.zig").Zettel;
const commit = @import("../util.zig").commit;

pub const usage = "bl|backlinks [NOTE [NOTE ..]]";
pub const desc = "Add backlinks to given notes (all notes if no argument given).";

const section_header = "## Backlinks";
const link_template = "- [[{}]] {}";

pub fn run(allocator: *mem.Allocator, all_zettels: []const Zettel, zettels: []const Zettel) !void {
    // Map from zettel ID to list of IDs that link to this zettel
    var links = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);

    // Search each zettel in the given list for links to other zettels
    for (zettels) |zet| {
        var file = try fs.cwd().openFile(zet.fname, .{ .read = true });
        defer file.close();

        const size = try file.getEndPos();
        const contents = try file.inStream().readAllAlloc(allocator, size);

        // Ignore any links beneath a preexisting backlinks header
        const end_index = mem.indexOf(u8, contents, section_header) orelse size;

        var start_index: usize = 0;
        outer: while (mem.indexOfPos(u8, contents[0..end_index], start_index, "[[")) |index| : (start_index = index + "[[".len) {
            // Check for valid link and ID
            const link = contents[index .. index + "[[".len + zet.id.len + "]]".len];
            if (!mem.startsWith(u8, link, "[[") or !mem.endsWith(u8, link, "]]")) {
                continue;
            }

            const id = link["[[".len .. link.len - "]]".len];
            for (id) |c| {
                if (!ascii.isDigit(c)) {
                    continue :outer;
                }
            }

            const key = try mem.dupe(allocator, u8, id);
            const links_list = try links.getOrPutValue(key, std.ArrayList([]const u8).init(allocator));
            const link_item = try fmt.allocPrint(allocator, link_template, .{ zet.id, zet.title });
            try links_list.value.append(link_item);
        }
    }

    if (links.size == 0) return;

    var num_modified: u32 = 0;

    outer: for (links.entries) |entry| {
        // Find Zettel corresponding to entry
        const zet = blk: {
            for (all_zettels) |zet| {
                if (mem.eql(u8, zet.id, entry.kv.key)) {
                    break :blk zet;
                }
            }

            continue :outer;
        };

        const links_list = entry.kv.value;

        // Read the contents of the file and find where to insert the backlinks
        var file = try fs.cwd().openFile(zet.fname, .{ .read = true, .write = true });
        defer file.close();

        const size = try file.getEndPos();
        const contents = try file.inStream().readAllAlloc(allocator, size);

        // Find the index of the end of the YAML metadata block, since that
        // interferes with detection of horizontal rules ("---") in the actual
        // note contents.
        // Note that both "---" and "..." are valid closing delimiters of the
        // metadata block.
        const metadata_start = "---".len + 1;
        const metadata_end = mem.min(usize, &[_]usize{
            mem.indexOf(u8, contents[metadata_start..], "...") orelse size,
            mem.indexOf(u8, contents[metadata_start..], "---") orelse size,
        });

        const end_index = blk: {
            if (mem.lastIndexOf(u8, contents, "---")) |i| {
                if (i <= metadata_end) {
                    // If last instance of "---" is in the metadata block, ignore it
                    break :blk size;
                } else {
                    break :blk i - 1;
                }
            } else unreachable;
        };

        const start_index = if (mem.indexOf(u8, contents, section_header)) |s| s - 1 else end_index;

        const backlinks = try mem.join(allocator, "\n", links_list.items);

        const new_contents = try fmt.allocPrint(allocator, "{}\n\n{}\n\n{}\n{}", .{
            contents[0 .. start_index - 1],
            section_header,
            backlinks,
            contents[end_index..],
        });

        if (!mem.eql(u8, new_contents, zet.contents)) {
            try file.seekTo(0);
            try file.outStream().writeAll(new_contents);
            num_modified += 1;
        }
pub fn run(allocator: *mem.Allocator, zettels: []Zettel) !void {
    var modified = std.ArrayList([]const u8).init(allocator);
    for (zettels) |*zet| {
        try zet.writeBacklinks();
        if (try zet.modified()) try modified.append(zet.fname);
    }

    if (num_modified > 0) {
        stdout.print("Updated backlinks in {} file(s).\n", .{num_modified}) catch {};
    if (modified.items.len > 0) {
        const msg = try fmt.allocPrint(allocator, "Updating backlinks in {} file(s)", .{modified.items.len});
        stdout.print("{}.\n", .{msg}) catch {};
        try commit(allocator, modified.items, msg);
    }
}

M src/cmd/list.zig => src/cmd/list.zig +3 -3
@@ 7,10 7,10 @@ const Zettel = @import("../zettel.zig").Zettel;
pub const usage = "l|list [PATTERN [PATTERN ...]]";
pub const desc = "With no argument, list all notes. Otherwise list notes matching any of the given patterns.";

pub fn run(allocator: *mem.Allocator, patterns: ?[][]const u8, zettels: []const Zettel) void {
pub fn run(zettels: []const Zettel, args: ?[]const []const u8) !void {
    for (zettels) |zet| {
        if (patterns) |pats| {
            for (pats) |pat| {
        if (args) |patterns| {
            for (patterns) |pat| {
                if (mem.indexOf(u8, zet.id, pat) != null or
                    ascii.indexOfIgnoreCase(zet.fname, pat) != null or
                    ascii.indexOfIgnoreCase(zet.title, pat) != null)

M src/cmd/new.zig => src/cmd/new.zig +32 -2
@@ 1,11 1,41 @@
const std = @import("std");
const fmt = std.fmt;
const mem = std.mem;

const Zettel = @import("../zettel.zig").Zettel;
const openZettels = @import("../zettel.zig").openZettels;
const commit = @import("../util.zig").commit;

pub const usage = "n|new <TITLE>";
pub const desc = "Create a new note with the given title.";

pub fn run(allocator: *mem.Allocator, title: []const u8) !Zettel {
    return try Zettel.new(allocator, title);
pub fn run(allocator: *mem.Allocator, zettels: []Zettel, args: ?[]const []const u8) !void {
    if (args) |_| {
        const title = args.?[0];
        var new_zettel = try Zettel.new(allocator, title);

        try openZettels(allocator, &[_]Zettel{new_zettel});

        new_zettel.contents = try new_zettel.read();

        const links = try new_zettel.links();
        for (links) |link| {
            for (zettels) |*zet| {
                if (mem.eql(u8, link, zet.id)) try zet.backlinks.append(&new_zettel);
            }
        }

        var modified = std.ArrayList([]const u8).init(allocator);
        try modified.append(new_zettel.fname);

        for (zettels) |*zet| {
            try zet.writeBacklinks();
            if (try zet.modified()) try modified.append(zet.fname);
        }

        const msg = try fmt.allocPrint(allocator, "New note: {}", .{title});
        try commit(allocator, modified.items, msg);
    } else {
        return error.MissingRequiredArgument;
    }
}

M src/cmd/open.zig => src/cmd/open.zig +14 -9
@@ 4,20 4,25 @@ const os = std.os;
const warn = std.debug.warn;

const Zettel = @import("../zettel.zig").Zettel;
const findZettels = @import("../zettel.zig").findZettels;

pub const usage = "o|open [NOTE [NOTE ...]]";
pub const desc = "Open the given notes in your $EDITOR.";

pub fn run(allocator: *mem.Allocator, zettels: ?[]const Zettel) !void {
    var args = std.ArrayList([]const u8).init(allocator);
    try args.append(os.getenv("EDITOR") orelse "vi");
    if (zettels) |_| {
        for (zettels.?) |zet| {
            try args.append(zet.fname);
        }
pub fn run(allocator: *mem.Allocator, zettels: []const Zettel, args: ?[]const []const u8) !void {
    const matches = if (args) |_|
        try findZettels(allocator, zettels, args.?)
    else
        null;

    var argv = std.ArrayList([]const u8).init(allocator);
    try argv.append(os.getenv("EDITOR") orelse "vi");

    if (matches) |_| {
        for (matches.?) |zet| try argv.append(zet.fname);
    }

    var proc = try std.ChildProcess.init(args.items, allocator);
    var proc = try std.ChildProcess.init(argv.items, allocator);
    defer proc.deinit();

    const term = try proc.spawnAndWait();


@@ 25,7 30,7 @@ pub fn run(allocator: *mem.Allocator, zettels: ?[]const Zettel) !void {
        .Exited => {},
        else => {
            warn("The following command terminated unexpectedly:\n", .{});
            for (args.items) |arg| warn("{} ", .{arg});
            for (argv.items) |arg| warn("{} ", .{arg});
            return error.CommandFailed;
        },
    }

M src/cmd/preview.zig => src/cmd/preview.zig +8 -3
@@ 7,17 7,22 @@ const os = std.os;
const warn = std.debug.warn;

const Zettel = @import("../zettel.zig").Zettel;
const which = @import("../util.zig").which;
const findZettels = @import("../zettel.zig").findZettels;
const execAndCheck = @import("../util.zig").execAndCheck;

pub const usage = "p|preview <NOTE> [NOTE ...]";
pub const desc = "View notes as HTML.";

pub fn run(allocator: *mem.Allocator, zettels: []const Zettel) !void {
pub fn run(allocator: *mem.Allocator, zettels: []const Zettel, args: ?[]const []const u8) !void {
    const matches = if (args) |_|
        try findZettels(allocator, zettels, args.?)
    else
        return error.MissingRequiredArgument;

    var output_files = std.ArrayList([]const u8).init(allocator);
    defer for (output_files.items) |file| fs.cwd().deleteFile(file) catch {};

    for (zettels) |zet| {
    for (matches) |zet| {
        const html_file = try convert(allocator, zet);
        try output_files.append(html_file);


M src/cmd/search.zig => src/cmd/search.zig +10 -6
@@ 6,13 6,17 @@ const Zettel = @import("../zettel.zig").Zettel;
pub const usage = "s|search <PATTERN> [PATTERN ...]";
pub const desc = "Search for notes whose contents match any of the given patterns.";

pub fn run(patterns: [][]const u8, zettels: []const Zettel) !void {
    for (zettels) |zet| {
        for (patterns) |pat| {
            if (ascii.indexOfIgnoreCase(zet.contents, pat)) |_| {
                zet.print();
                break;
pub fn run(zettels: []const Zettel, args: ?[]const []const u8) !void {
    if (args) |patterns| {
        for (zettels) |zet| {
            for (patterns) |pat| {
                if (ascii.indexOfIgnoreCase(zet.contents, pat)) |_| {
                    zet.print();
                    break;
                }
            }
        }
    } else {
        return error.MissingRequiredArgument;
    }
}

M src/cmd/show.zig => src/cmd/show.zig +7 -9
@@ 4,18 4,16 @@ const mem = std.mem;
const stdout = std.io.getStdOut().outStream();

const Zettel = @import("../zettel.zig").Zettel;
const findZettels = @import("../zettel.zig").findZettels;

pub const usage = "sh|show <NOTE> [NOTE ...]";
pub const desc = "Display contents of notes to stdout.";

pub fn run(allocator: *mem.Allocator, zettels: []const Zettel) !void {
    for (zettels) |zet| {
        var file = try fs.cwd().openFile(zet.fname, .{ .read = true });
        defer file.close();

        const size = try file.getEndPos();
        const contents = try file.inStream().readAllAlloc(allocator, size);

        try stdout.print("{}", .{contents});
pub fn run(allocator: *mem.Allocator, zettels: []const Zettel, args: ?[]const []const u8) !void {
    if (args) |_| {
        const matches = try findZettels(allocator, zettels, args.?);
        for (matches) |zet| stdout.print("{}", .{try zet.read()}) catch return;
    } else {
        return error.MissingRequiredArgument;
    }
}

M src/cmd/sync.zig => src/cmd/sync.zig +2 -2
@@ 17,9 17,9 @@ pub fn run(allocator: *mem.Allocator) !void {
        else => return err,
    };

    try stdout.print("{}", .{result.stderr});
    stdout.print("{}", .{result.stderr}) catch {};

    result = try execAndCheck(allocator, &[_][]const u8{ "git", "push" });

    try stdout.print("{}", .{result.stderr});
    stdout.print("{}", .{result.stderr}) catch {};
}

M src/cmd/tags.zig => src/cmd/tags.zig +8 -10
@@ 8,11 8,11 @@ const Zettel = @import("../zettel.zig").Zettel;
pub const usage = "t|tags [TAG [TAG ...]]";
pub const desc = "With no argument, list all tags found in notes. Otherwise list all notes containing any of the given tags.";

pub fn run(allocator: *mem.Allocator, tags: ?[][]const u8, zettels: []const Zettel) !void {
    if (tags) |_| {
pub fn run(allocator: *mem.Allocator, zettels: []const Zettel, args: ?[]const []const u8) !void {
    if (args) |_| {
        outer: for (zettels) |zet| {
            for (zet.tags) |t| {
                for (tags.?) |tag| {
                for (args.?) |tag| {
                    if (ascii.eqlIgnoreCase(tag, t)) {
                        zet.print();
                        continue :outer;


@@ 22,20 22,18 @@ pub fn run(allocator: *mem.Allocator, tags: ?[][]const u8, zettels: []const Zett
        }
    } else {
        // List tags
        var existing_tags = std.ArrayList([]const u8).init(allocator);
        var tags = std.ArrayList([]const u8).init(allocator);

        for (zettels) |zet| {
            try existing_tags.appendSlice(zet.tags);
        }
        for (zettels) |zet| try tags.appendSlice(zet.tags);

        std.sort.sort([]const u8, existing_tags.items, struct {
        std.sort.sort([]const u8, tags.items, struct {
            fn lessThan(lhs: []const u8, rhs: []const u8) bool {
                return mem.lessThan(u8, lhs, rhs);
            }
        }.lessThan);

        for (existing_tags.items) |t, i| {
            if (i == 0 or !mem.eql(u8, t, existing_tags.items[i - 1])) {
        for (tags.items) |t, i| {
            if (i == 0 or !mem.eql(u8, t, tags.items[i - 1])) {
                stdout.print("{}\n", .{t}) catch continue;
            }
        }

M src/main.zig => src/main.zig +2 -2
@@ 33,7 33,7 @@ pub fn main() anyerror!void {
    try std.process.changeCurDir(dir);

    const command = arglist[1];
    var args: ?[][]const u8 = if (arglist.len > 2 and !mem.eql(u8, arglist[2], "-"))
    var args: ?[]const []const u8 = if (arglist.len > 2 and !mem.eql(u8, arglist[2], "-"))
        arglist[2..]
    else
        try readFromStdin(allocator);


@@ 55,7 55,7 @@ pub fn main() anyerror!void {
    };
}

fn readFromStdin(allocator: *mem.Allocator) !?[][]const u8 {
fn readFromStdin(allocator: *mem.Allocator) !?[]const []const u8 {
    if (std.io.getStdIn().isTty()) {
        return null;
    }

M src/zettel.zig => src/zettel.zig +166 -42
@@ 3,14 3,17 @@ const ascii = std.ascii;
const fmt = std.fmt;
const fs = std.fs;
const mem = std.mem;
const os = std.os;
const stdout = std.io.getStdOut().outStream();
const warn = std.debug.warn;

const c = @cImport(@cInclude("time.h"));

const ID_LENGTH = "20200106121000".len;
const EXT = ".md";
const TEMPLATE = "---\ntitle: {}\ndate:  {}\ntags:\n...\n";
const id_length = "20200106121000".len;
const extension = ".md";
const new_template = "---\ntitle: {}\ndate:  {}\ntags:\n...\n";
const backlinks_template = "- [[{}]] {}";
const backlinks_header = "## Backlinks";

pub const Zettel = struct {
    allocator: *mem.Allocator,


@@ 21,12 24,13 @@ pub const Zettel = struct {
    title: []const u8,
    mtime: i64,
    contents: []const u8,
    backlinks: std.ArrayList(*const Zettel),

    /// Create a new Zettel with the given title.
    pub fn new(allocator: *mem.Allocator, title: []const u8) !Zettel {
        const date = try strftime(allocator, "%B %d, %Y");
        const id = try strftime(allocator, "%Y%m%d%H%M%S");
        var fname = try fmt.allocPrint(allocator, "{}-{}" ++ EXT, .{ id, title });
        var fname = try fmt.allocPrint(allocator, "{}-{}" ++ extension, .{ id, title });

        for (fname) |*char| {
            if (ascii.isSpace(char.*)) {


@@ 40,23 44,31 @@ pub const Zettel = struct {
        var file = try fs.cwd().createFile(fname, .{});
        defer file.close();

        const contents = try fmt.allocPrint(allocator, TEMPLATE, .{ title, date });
        const contents = try fmt.allocPrint(allocator, new_template, .{ title, date });
        try file.writeAll(contents);

        var zettel = Zettel{
            .allocator = allocator,
            .basename = fname[0 .. fname.len - EXT.len],
            .basename = fname[0 .. fname.len - extension.len],
            .fname = fname,
            .id = fname[0..ID_LENGTH],
            .id = fname[0..id_length],
            .tags = &[_][]const u8{},
            .title = try mem.dupe(allocator, u8, title),
            .mtime = -1,
            .contents = &[_]u8{},
            .mtime = (try file.stat()).mtime,
            .contents = contents,
            .backlinks = std.ArrayList(*const Zettel).init(allocator),
        };

        return zettel;
    }

    pub fn read(self: Zettel) ![]const u8 {
        var file = try fs.cwd().openFile(self.fname, .{ .read = true });
        defer file.close();

        return file.inStream().readAllAlloc(self.allocator, 1 * 1024 * 1024);
    }

    pub fn print(self: Zettel) void {
        stdout.print("{} {}\n", .{ self.id, self.title }) catch return;
    }


@@ 69,17 81,112 @@ pub const Zettel = struct {
            return false;
        }

        const contents = try file.inStream().readAllAlloc(self.allocator, 1 * 1024 * 1024);
        if (mem.eql(u8, self.contents, try self.read())) {
            return false;
        }

        return true;
    }

    /// Return a list of zettels linked to in the given zettel
    pub fn links(self: Zettel) ![]const []const u8 {
        var links_list = std.ArrayList([]const u8).init(self.allocator);

        // Ignore any links beneath a preexisting backlinks header
        const end_index = mem.indexOf(u8, self.contents, backlinks_header) orelse self.contents.len;

        var start_index: usize = 0;
        outer: while (mem.indexOfPos(u8, self.contents[0..end_index], start_index, "[[")) |index| : (start_index = index + "[[".len) {
            // Check for valid link and ID
            const link = self.contents[index .. index + "[[".len + self.id.len + "]]".len];
            if (!mem.startsWith(u8, link, "[[") or !mem.endsWith(u8, link, "]]")) {
                continue;
            }

        if (!mem.eql(u8, self.contents, contents)) {
            return true;
            const id = link["[[".len .. link.len - "]]".len];
            for (id) |char| {
                if (!ascii.isDigit(char)) {
                    continue :outer;
                }
            }

            try links_list.append(id);
        }

        return false;
        return links_list.toOwnedSlice();
    }

    /// Write backlinks to file
    pub fn writeBacklinks(self: *Zettel) !void {
        if (self.backlinks.items.len == 0) return;

        // Find the index of the end of the YAML metadata block, since that
        // interferes with detection of horizontal rules ("---") in the actual
        // note contents.
        // Note that both "---" and "..." are valid closing delimiters of the
        // metadata block.
        const metadata_start = "---".len + 1;
        const metadata_end = mem.min(usize, &[_]usize{
            mem.indexOf(u8, self.contents[metadata_start..], "...") orelse self.contents.len,
            mem.indexOf(u8, self.contents[metadata_start..], "---") orelse self.contents.len,
        });

        const end_index = blk: {
            if (mem.lastIndexOf(u8, self.contents, "---")) |i| {
                if (i <= metadata_end) {
                    // If last instance of "---" is in the metadata block, ignore it
                    break :blk self.contents.len;
                } else {
                    break :blk i - 1;
                }
            } else unreachable;
        };

        const start_index = if (mem.indexOf(u8, self.contents, backlinks_header)) |s| s - 1 else end_index;

        var backlinks = try std.ArrayList([]const u8).initCapacity(self.allocator, self.backlinks.items.len);
        for (self.backlinks.items) |backlink| {
            const item = try fmt.allocPrint(self.allocator, backlinks_template, .{ backlink.id, backlink.title });
            backlinks.appendAssumeCapacity(item);
        }

        const new_contents = try fmt.allocPrint(self.allocator, "{}\n\n{}\n\n{}\n{}", .{
            self.contents[0 .. start_index - 1],
            backlinks_header,
            try mem.join(self.allocator, "\n", backlinks.items),
            self.contents[end_index..],
        });

        if (!mem.eql(u8, new_contents, self.contents)) {
            var file = try fs.cwd().openFile(self.fname, .{ .write = true });
            defer file.close();

            try file.outStream().writeAll(new_contents);
        }
    }
};

pub fn getZettels(allocator: *mem.Allocator) !std.ArrayList(Zettel) {
pub fn openZettels(allocator: *mem.Allocator, zettels: []const Zettel) !void {
    var argv = try std.ArrayList([]const u8).initCapacity(allocator, zettels.len + 1);
    argv.appendAssumeCapacity(os.getenv("EDITOR") orelse "vi");

    for (zettels) |zet| argv.appendAssumeCapacity(zet.fname);

    var proc = try std.ChildProcess.init(argv.items, allocator);
    defer proc.deinit();

    const term = try proc.spawnAndWait();
    switch (term) {
        .Exited => {},
        else => {
            warn("The following command terminated unexpectedly:\n", .{});
            for (argv.items) |arg| warn("{} ", .{arg});
            return error.CommandFailed;
        },
    }
}

pub fn getZettels(allocator: *mem.Allocator) ![]Zettel {
    var zettels = std.ArrayList(Zettel).init(allocator);

    var dir = try fs.cwd().openDir(".", .{ .iterate = true });


@@ 105,10 212,22 @@ pub fn getZettels(allocator: *mem.Allocator) !std.ArrayList(Zettel) {
        }
    }.lessThan);

    return zettels;
    // Sorting *must* be done before setting backlinks since we are using
    // pointers in the backlinks list. If sorting happens after then the
    // pointers end up pointing to the wrong object.
    for (zettels.items) |*zet| {
        const links = try zet.links();
        for (links) |link| {
            for (zettels.items) |*other| {
                if (mem.eql(u8, link, other.id)) try other.backlinks.append(zet);
            }
        }
    }

    return zettels.toOwnedSlice();
}

pub fn findZettels(allocator: *mem.Allocator, zettels: []const Zettel, keywords: [][]const u8) ![]const Zettel {
pub fn findZettels(allocator: *mem.Allocator, zettels: []const Zettel, keywords: []const []const u8) ![]const Zettel {
    var found = std.ArrayList(Zettel).init(allocator);

    for (keywords) |keyword| {


@@ 137,8 256,8 @@ fn findZettel(zettels: []const Zettel, keyword: []const u8) ?Zettel {

        // Also try id + title
        if (mem.startsWith(u8, keyword, zet.id) and
            keyword.len > ID_LENGTH + 1 and keyword[ID_LENGTH] == ' ' and
            ascii.eqlIgnoreCase(keyword[ID_LENGTH + 1 ..], zet.title))
            keyword.len > id_length + 1 and keyword[id_length] == ' ' and
            ascii.eqlIgnoreCase(keyword[id_length + 1 ..], zet.title))
        {
            return zet;
        }


@@ 148,43 267,47 @@ fn findZettel(zettels: []const Zettel, keyword: []const u8) ?Zettel {
}

test "findZettel" {
    const expectEqual = std.testing.expectEqual;

    const zettels = &[_]Zettel{
        .{
            .allocator = undefined,
            .basename = "12345678900000-test-one",
            .fname = "12345678900000-test-one" ++ EXT,
            .fname = "12345678900000-test-one" ++ extension,
            .id = "12345678900000",
            .tags = &[_][]const u8{},
            .title = "Test One",
            .mtime = -1,
            .contents = &[_]u8{},
            .backlinks = undefined,
        },
        .{
            .allocator = undefined,
            .basename = "12345678900001-test-two",
            .fname = "12345678900001-test-two" ++ EXT,
            .fname = "12345678900001-test-two" ++ extension,
            .id = "12345678900001",
            .tags = &[_][]const u8{},
            .title = "Test Two",
            .mtime = -1,
            .contents = &[_]u8{},
            .backlinks = undefined,
        },
    };

    std.testing.expectEqual(zettels[0], findZettel(zettels, "12345678900000").?);
    std.testing.expectEqual(zettels[0], findZettel(zettels, "12345678900000-test-one" ++ EXT).?);
    std.testing.expectEqual(zettels[0], findZettel(zettels, "12345678900000-test-one").?);
    std.testing.expectEqual(zettels[0], findZettel(zettels, "Test One").?);
    expectEqual(zettels[0], findZettel(zettels, "12345678900000").?);
    expectEqual(zettels[0], findZettel(zettels, "12345678900000-test-one" ++ extension).?);
    expectEqual(zettels[0], findZettel(zettels, "12345678900000-test-one").?);
    expectEqual(zettels[0], findZettel(zettels, "Test One").?);

    std.testing.expectEqual(zettels[1], findZettel(zettels, "12345678900001").?);
    std.testing.expectEqual(zettels[1], findZettel(zettels, "12345678900001-test-two" ++ EXT).?);
    std.testing.expectEqual(zettels[1], findZettel(zettels, "12345678900001-test-two").?);
    std.testing.expectEqual(zettels[1], findZettel(zettels, "Test Two").?);
    expectEqual(zettels[1], findZettel(zettels, "12345678900001").?);
    expectEqual(zettels[1], findZettel(zettels, "12345678900001-test-two" ++ extension).?);
    expectEqual(zettels[1], findZettel(zettels, "12345678900001-test-two").?);
    expectEqual(zettels[1], findZettel(zettels, "Test Two").?);

    std.testing.expectEqual(@as(?Zettel, null), findZettel(zettels, "12345678900002"));
    std.testing.expectEqual(@as(?Zettel, null), findZettel(zettels, "12345678900002-test-three" ++ EXT));
    std.testing.expectEqual(@as(?Zettel, null), findZettel(zettels, "12345678900002-test-three"));
    std.testing.expectEqual(@as(?Zettel, null), findZettel(zettels, "Test Three"));
    expectEqual(@as(?Zettel, null), findZettel(zettels, "12345678900002"));
    expectEqual(@as(?Zettel, null), findZettel(zettels, "12345678900002-test-three" ++ extension));
    expectEqual(@as(?Zettel, null), findZettel(zettels, "12345678900002-test-three"));
    expectEqual(@as(?Zettel, null), findZettel(zettels, "Test Three"));
}

fn fromEntry(allocator: *mem.Allocator, entry: fs.Dir.Entry) !Zettel {


@@ 251,22 374,23 @@ fn fromEntry(allocator: *mem.Allocator, entry: fs.Dir.Entry) !Zettel {

    var zettel = Zettel{
        .allocator = allocator,
        .basename = fname[0 .. fname.len - EXT.len],
        .basename = fname[0 .. fname.len - extension.len],
        .fname = fname,
        .id = fname[0..ID_LENGTH],
        .id = fname[0..id_length],
        .tags = tags.toOwnedSlice(),
        .title = title.?,
        .mtime = (try file.stat()).mtime,
        .contents = contents,
        .backlinks = std.ArrayList(*const Zettel).init(allocator),
    };

    return zettel;
}

fn hasId(name: []const u8) bool {
    if (name.len < ID_LENGTH) return false;
    if (name.len < id_length) return false;

    for (name[0..ID_LENGTH]) |char| {
    for (name[0..id_length]) |char| {
        if (!ascii.isDigit(char)) return false;
    }



@@ 275,19 399,19 @@ fn hasId(name: []const u8) bool {

test "hasId" {
    std.testing.expect(hasId("20200512074730"));
    std.testing.expect(hasId("20200512074730-test" ++ EXT));
    std.testing.expect(!hasId("2020051207470-test" ++ EXT));
    std.testing.expect(hasId("20200512074730-test" ++ extension));
    std.testing.expect(!hasId("2020051207470-test" ++ extension));
    std.testing.expect(!hasId("test"));
    std.testing.expect(!hasId(" 20200512074730-test" ++ EXT));
    std.testing.expect(!hasId(" 20200512074730-test" ++ extension));
}

fn hasExtension(name: []const u8) bool {
    if (name.len < EXT.len) return false;
    return mem.eql(u8, name[name.len - EXT.len ..], EXT);
    if (name.len < extension.len) return false;
    return mem.eql(u8, name[name.len - extension.len ..], extension);
}

test "hasExtension" {
    std.testing.expect(hasExtension("test" ++ EXT));
    std.testing.expect(hasExtension("test" ++ extension));
    std.testing.expect(!hasExtension("test"));
}