~gpanders/wk

dcecfbcac8f092ba545a9033a47938a9201400d0 — Greg Anders 8 months ago b8951ff
Only commit files that were actually modified

This involves a few significant changes:

- Hash file contents and check file modification times to see if a file
  was changed
- Dynamically create the git commit message based on both the command
  and the number of actually modified files
- Re-write the backlinks function to be more efficient. Now, each file
  will be read at most twice (whereas before each file could be read up
  to N times, where N = total number of zettels!)
- Update backlinks after every `open` or `new` command. This should
  ensure that backlinks are always up to date.
6 files changed, 171 insertions(+), 97 deletions(-)

M src/cmd.zig
M src/cmd/backlinks.zig
M src/cmd/new.zig
M src/main.zig
M src/util.zig
M src/zettel.zig
M src/cmd.zig => src/cmd.zig +51 -39
@@ 94,13 94,8 @@ pub fn handleCommand(allocator: *std.mem.Allocator, command: []const u8, args: ?
            else
                zettels.items;

            const modified = try backlinks.run(allocator, zettels.items, matches);
            if (modified.len > 0) {
                const msg = try std.fmt.allocPrint(allocator, "Updated backlinks in {} files.\n", .{modified.len});
                defer allocator.free(msg);

                try util.commit(allocator, modified, msg);
            }
            try backlinks.run(allocator, zettels.items, matches);
            try commitChanges(allocator, .Backlinks, matches);
        },
        .Show => {
            if (args) |_| {


@@ 120,43 115,17 @@ pub fn handleCommand(allocator: *std.mem.Allocator, command: []const u8, args: ?
                null;

            try open.run(allocator, matches);

            if (matches) |_| {
                var msg_lines = std.ArrayList([]const u8).init(allocator);
                defer {
                    for (msg_lines.items) |line| {
                        allocator.free(line);
                    }
                    msg_lines.deinit();
                }

                try msg_lines.append(try std.fmt.allocPrint(allocator, "Updated {} notes:\n\n", .{matches.?.len}));
                for (matches.?) |match| {
                    try msg_lines.append(try std.fmt.allocPrint(allocator, "- {} {}\n", .{ match.id, match.title }));
                }

                const msg = try std.mem.join(allocator, "\n", msg_lines.items);
                defer allocator.free(msg);

                try util.commit(allocator, matches.?, msg);
            }
            try backlinks.run(allocator, zettels.items, zettels.items);
            try commitChanges(allocator, .Open, zettels.items);
        },
        .New => {
            if (args) |_| {
                const new_zettel = try new.run(allocator, zettels.items, args.?[0]);
                try open.run(allocator, &[_]zettel.Zettel{new_zettel});

                const new_zettel = try new.run(allocator, args.?[0]);
                try zettels.append(new_zettel);

                // Update backlinks in existing zettels
                var modified = std.ArrayList(zettel.Zettel).fromOwnedSlice(
                    allocator,
                    try backlinks.run(allocator, zettels.items, zettels.items[0 .. zettels.items.len - 1]),
                );
                defer modified.deinit();

                try modified.append(new_zettel);
                try util.commit(allocator, modified.items, new_zettel.title);
                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;
            }


@@ 182,6 151,49 @@ pub fn handleCommand(allocator: *std.mem.Allocator, command: []const u8, args: ?
    }
}

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

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

    if (modified.items.len > 0) {
        const msg = switch (cmd) {
            .Backlinks => try std.fmt.allocPrint(
                allocator,
                "Updated backlinks in {} file(s)",
                .{modified.items.len},
            ),
            .New => try std.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);
                defer msg_lines.deinit();

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

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

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

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

        try util.commit(allocator, files.items, msg);
    }
}

fn parseCommand(command: []const u8) !Command {
    if (strEq(command, &[_][]const u8{ "bl", "backlinks" })) {
        return .Backlinks;

M src/cmd/backlinks.zig => src/cmd/backlinks.zig +61 -35
@@ 9,50 9,72 @@ 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: *std.mem.Allocator, all_zettels: []const Zettel, zettels: []const Zettel) ![]Zettel {
    var modified = std.ArrayList(Zettel).init(allocator);
    defer modified.deinit();

    // For each zettel, iterate through every other zettel and look for
    // links of the form `[[ID]]`
    var buf = try allocator.alloc(u8, zettels[0].id.len + "[[]]".len);
    defer allocator.free(buf);
pub fn run(allocator: *std.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);
    defer links.deinit();

    // Search each zettel in the given list for links to other zettels
    for (zettels) |zet| {
        var links = std.ArrayList([]const u8).init(allocator);
        defer links.deinit();

        const link_str = try std.fmt.bufPrint(buf, "[[{}]]", .{zet.id});

        for (all_zettels) |other| {
            if (std.mem.eql(u8, zet.id, other.id)) continue;
        var file = try std.fs.cwd().openFile(zet.fname, .{ .read = true });
        defer file.close();

            var file = try std.fs.cwd().openFile(other.fname, .{ .read = true });
            defer file.close();
        const size = try file.getEndPos();
        const contents = try file.inStream().readAllAlloc(allocator, size);
        defer allocator.free(contents);

            const size = try file.getEndPos();
            const contents = try file.inStream().readAllAlloc(allocator, size);
            defer allocator.free(contents);
        // Ignore any links beneath a preexisting backlinks header
        const end_index = std.mem.indexOf(u8, contents, section_header) orelse size;

            // Ignore any links beneath a preexisting backlinks header
            const end = std.mem.indexOf(u8, contents, section_header) orelse size;
            if (std.mem.indexOf(u8, contents[0..end], link_str) != null) {
                try links.append(other.id);
        var start_index: usize = 0;
        outer: while (std.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 (!std.mem.startsWith(u8, link, "[[") or !std.mem.endsWith(u8, link, "]]")) {
                continue;
            }

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

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

    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 (std.mem.eql(u8, zet.id, entry.kv.key)) {
                    break :blk zet;
                }
            }

            continue :outer;
        };

        if (links.items.len == 0) continue;
        const link_list = entry.kv.value;

        // Format referenced IDs into list items according to `link_template`
        var list_items = try std.ArrayList([]const u8).initCapacity(allocator, links.items.len);
        var list_items = try std.ArrayList([]const u8).initCapacity(allocator, link_list.items.len);
        defer list_items.deinit();

        for (links.items) |link| {
        for (link_list.items) |link| {
            const item = try std.fmt.allocPrint(allocator, link_template, .{link});
            list_items.appendAssumeCapacity(item);
        }

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



@@ 71,7 93,7 @@ pub fn run(allocator: *std.mem.Allocator, all_zettels: []const Zettel, zettels: 
            std.mem.indexOf(u8, contents[metadata_start..], "---") orelse size,
        });

        const end = blk: {
        const end_index = blk: {
            if (std.mem.lastIndexOf(u8, contents, "---")) |i| {
                if (i <= metadata_end) {
                    // If last instance of "---" is in the metadata block, ignore it


@@ 82,26 104,30 @@ pub fn run(allocator: *std.mem.Allocator, all_zettels: []const Zettel, zettels: 
            } else unreachable;
        };

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

        const backlinks = try std.mem.join(allocator, "\n", list_items.items);
        defer allocator.free(backlinks);

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

        if (!std.mem.eql(u8, contents, new_contents)) {
        var new_hash: [20]u8 = undefined;
        std.crypto.Sha1.hash(new_contents, new_hash[0..]);

        if (!std.mem.eql(u8, new_hash[0..], zet.hash[0..])) {
            try file.seekTo(0);
            try file.outStream().writeAll(new_contents);
            try modified.append(zet);
            num_modified += 1;
        }
    }

    try stdout.print("Updated backlinks in {} files.\n", .{modified.items.len});
    return modified.toOwnedSlice();
    if (num_modified > 0) {
        stdout.print("Updated backlinks in {} file(s).\n", .{num_modified}) catch {};
    }
}

M src/cmd/new.zig => src/cmd/new.zig +1 -1
@@ 5,6 5,6 @@ const Zettel = @import("../zettel.zig").Zettel;
pub const usage = "n|new <TITLE>";
pub const desc = "Create a new note with the given title.";

pub fn run(allocator: *std.mem.Allocator, zettels: []const Zettel, title: []const u8) !Zettel {
pub fn run(allocator: *std.mem.Allocator, title: []const u8) !Zettel {
    return try Zettel.new(allocator, title);
}

M src/main.zig => src/main.zig +1 -2
@@ 11,7 11,6 @@ pub fn main() anyerror!void {
    const arglist = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, arglist);

    const exe = arglist[0];
    if (arglist.len == 1) {
        cmd.printUsage(null);
        return;


@@ 59,7 58,7 @@ fn readFromStdin(allocator: *std.mem.Allocator) !?[][]const u8 {
        return null;
    }

    var input = try std.io.getStdIn().inStream().readAllAlloc(allocator, 1 * 1024 * 1024);
    const input = try std.io.getStdIn().inStream().readAllAlloc(allocator, 1 * 1024 * 1024);
    defer allocator.free(input);

    var args = std.ArrayList([]const u8).init(allocator);

M src/util.zig => src/util.zig +2 -11
@@ 1,7 1,5 @@
const std = @import("std");

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

/// Return full path to the executable that would be run if the given `command` was called.
/// Caller owns the returned string.
pub fn which(allocator: *std.mem.Allocator, command: []const u8) ?[]const u8 {


@@ 54,24 52,17 @@ pub fn execAndCheck(allocator: *std.mem.Allocator, args: [][]const u8) !std.Chil
    return result;
}

pub fn commit(allocator: *std.mem.Allocator, zettels: []const Zettel, msg: []const u8) !void {
pub fn commit(allocator: *std.mem.Allocator, files: []const []const u8, msg: []const u8) !void {
    std.fs.cwd().access(".git", .{}) catch return;

    const git = which(allocator, "git") orelse return;
    defer allocator.free(git);

    var fnames = try std.ArrayList([]const u8).initCapacity(allocator, zettels.len);
    defer fnames.deinit();

    for (zettels) |zet| {
        fnames.appendAssumeCapacity(zet.fname);
    }

    var add_args = std.ArrayList([]const u8).init(allocator);
    defer add_args.deinit();

    try add_args.appendSlice(&[_][]const u8{ git, "add" });
    try add_args.appendSlice(fnames.items);
    try add_args.appendSlice(files);

    _ = try execAndCheck(allocator, add_args.items);


M src/zettel.zig => src/zettel.zig +55 -9
@@ 13,6 13,8 @@ pub const Zettel = struct {
    id: []const u8,
    tags: [][]const u8,
    title: []const u8,
    mtime: i64,
    hash: [20]u8,

    /// Create a new Zettel with the given title.
    /// Caller must call `.deinit()` on created Zettel when finished.


@@ 42,14 44,18 @@ pub const Zettel = struct {
        defer allocator.free(contents);
        try file.writeAll(contents);

        return Zettel{
        var zettel = Zettel{
            .allocator = allocator,
            .basename = fname[0 .. fname.len - EXT.len],
            .fname = fname,
            .id = fname[0..ID_LENGTH],
            .tags = &[_][]const u8{},
            .title = try std.mem.dupe(allocator, u8, title),
            .mtime = -1,
            .hash = undefined,
        };

        return zettel;
    }

    pub fn deinit(self: Zettel) void {


@@ 61,6 67,27 @@ pub const Zettel = struct {
    pub fn print(self: Zettel) void {
        stdout.print("{} {}\n", .{ self.id, self.title }) catch return;
    }

    pub fn modified(self: Zettel) !bool {
        var file = try std.fs.cwd().openFile(self.fname, .{ .read = true });
        defer file.close();

        if ((try file.stat()).mtime == self.mtime) {
            return false;
        }

        const contents = try file.inStream().readAllAlloc(self.allocator, 1 * 1024 * 1024);
        defer self.allocator.free(contents);

        var h: [20]u8 = undefined;
        std.crypto.Sha1.hash(contents, h[0..]);

        if (!std.mem.eql(u8, h[0..], self.hash[0..])) {
            return false;
        }

        return true;
    }
};

pub fn getZettels(allocator: *std.mem.Allocator) !std.ArrayList(Zettel) {


@@ 81,11 108,7 @@ pub fn getZettels(allocator: *std.mem.Allocator) !std.ArrayList(Zettel) {
        }
    }

    std.sort.sort(Zettel, zettels.items, struct {
        fn lessThan(lhs: Zettel, rhs: Zettel) bool {
            return std.mem.lessThan(u8, lhs.id, rhs.id);
        }
    }.lessThan);
    sortZettels(zettels.items);

    return zettels;
}


@@ 109,6 132,14 @@ pub fn findZettels(allocator: *std.mem.Allocator, zettels: []const Zettel, keywo
    return found.toOwnedSlice();
}

pub fn sortZettels(zettels: []Zettel) void {
    std.sort.sort(Zettel, zettels, struct {
        fn lessThan(lhs: Zettel, rhs: Zettel) bool {
            return std.mem.lessThan(u8, lhs.id, rhs.id);
        }
    }.lessThan);
}

fn findZettel(zettels: []const Zettel, keyword: []const u8) ?Zettel {
    comptime var fields = .{ "id", "fname", "basename", "title" };
    for (zettels) |zet| {


@@ 173,11 204,18 @@ fn fromEntry(allocator: *std.mem.Allocator, entry: std.fs.Dir.Entry) !?Zettel {
    var buf = try allocator.alloc(u8, 256);
    defer allocator.free(buf);

    if (try file.inStream().readUntilDelimiterOrEof(buf, '\n')) |line| {
    const contents = try file.inStream().readAllAlloc(allocator, 1 * 1024 * 1024);
    defer allocator.free(contents);

    var line_it = std.mem.split(contents, "\n");

    if (line_it.next()) |line| {
        // If first line is not a metadata delimiter, skip this entry
        if (!std.mem.eql(u8, line, "---")) {
            return null;
        }
    } else {
        return null;
    }

    var title: ?[]const u8 = null;


@@ 186,9 224,10 @@ fn fromEntry(allocator: *std.mem.Allocator, entry: std.fs.Dir.Entry) !?Zettel {
    var tags = std.ArrayList([]const u8).init(allocator);
    defer tags.deinit();

    outer: while (try file.inStream().readUntilDelimiterOrEof(buf, '\n')) |line| {
    outer: while (line_it.next()) |line| {
        if (std.mem.eql(u8, line, "---") or std.mem.eql(u8, line, "...")) break;

        // Read title
        if (std.mem.startsWith(u8, line, "title: ")) {
            comptime var k = "title: ".len - 1;
            for (line[k..]) |char, i| {


@@ 202,6 241,7 @@ fn fromEntry(allocator: *std.mem.Allocator, entry: std.fs.Dir.Entry) !?Zettel {
            return null;
        }

        // Read tags
        if (std.mem.startsWith(u8, line, "tags: ")) {
            comptime var k = "tags: ".len - 1;
            for (line[k..]) |char, i| {


@@ 228,14 268,20 @@ fn fromEntry(allocator: *std.mem.Allocator, entry: std.fs.Dir.Entry) !?Zettel {
    const fname = try std.mem.dupe(allocator, u8, entry.name);
    errdefer allocator.free(fname);

    return Zettel{
    var zettel = Zettel{
        .allocator = allocator,
        .basename = fname[0 .. fname.len - EXT.len],
        .fname = fname,
        .id = fname[0..ID_LENGTH],
        .tags = tags.toOwnedSlice(),
        .title = title.?,
        .mtime = (try file.stat()).mtime,
        .hash = undefined,
    };

    std.crypto.Sha1.hash(contents, zettel.hash[0..]);

    return zettel;
}

fn hasId(name: []const u8) bool {