~gpanders/wk

ref: 785887cd46b9ff998c33e47d190ba1d97343220e wk/src/zettel.zig -rw-r--r-- 10.1 KiB
785887cd — Greg Anders Refactor backlinks logic 2 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
const std = @import("std");
const ascii = std.ascii;
const fmt = std.fmt;
const fs = std.fs;
const mem = std.mem;
const os = std.os;
const warn = std.debug.warn;

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

const execAndCheck = @import("util.zig").execAndCheck;

const id_len = "20200106121000".len;
const extension = ".md";
const bl_header = "## Backlinks";

pub const Zettel = struct {
    allocator: *mem.Allocator,
    basename: []const u8,
    fname: []const u8,
    id: []const u8,
    tags: [][]const u8,
    title: []const u8,
    mtime: i64,
    contents: []const u8,

    /// 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, "{}-{}" ++ extension, .{ id, title });

        for (fname) |*char| {
            if (ascii.isSpace(char.*)) {
                char.* = '-';
                continue;
            }

            char.* = ascii.toLower(char.*);
        }

        var file = try fs.cwd().createFile(fname, .{});
        defer file.close();

        comptime var template = "---\ntitle: {}\ndate:  {}\ntags:\n...\n";
        const contents = try fmt.allocPrint(allocator, template, .{ title, date });
        try file.writeAll(contents);

        return Zettel{
            .allocator = allocator,
            .basename = fname[0 .. fname.len - extension.len],
            .fname = fname,
            .id = fname[0..id_len],
            .tags = &[_][]const u8{},
            .title = try mem.dupe(allocator, u8, title),
            .mtime = (try file.stat()).mtime,
            .contents = contents,
        };
    }

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

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

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

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

        if (mem.eql(u8, self.contents, try self.read())) {
            return false;
        }

        return true;
    }

    pub fn match(self: Zettel, keywords: []const []const u8) bool {
        for (keywords) |keyword| {
            if (ascii.indexOfIgnoreCase(self.fname, keyword) != null or
                ascii.indexOfIgnoreCase(self.contents, keyword) != null)
            {
                return true;
            }

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

        return false;
    }

    /// 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, bl_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 + 1) {
            // Check for valid link and ID
            comptime var link_len = "[[".len + id_len + "]]".len;
            const link = self.contents[index .. index + link_len];
            if (!mem.startsWith(u8, link, "[[") or !mem.endsWith(u8, link, "]]")) {
                continue;
            }

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

            try links_list.append(id);
        }

        return links_list.toOwnedSlice();
    }
};

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

    // TODO zig 0.7.0
    // argv.appendSliceAssumeCapacity(zettels)
    for (zettels) |zet| argv.appendAssumeCapacity(zet);

    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 });
    defer dir.close();

    var it = dir.iterate();
    while (try it.next()) |entry| {
        if (!hasId(entry.name) or !mem.endsWith(u8, entry.name, extension)) {
            continue;
        }

        const zet = fromEntry(allocator, entry) catch |err| switch (err) {
            error.InvalidFormat => continue,
            else => return err,
        };

        try zettels.append(zet);
    }

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

    return zettels.toOwnedSlice();
}

/// Find all backlinks between notes and write them to file
pub fn updateBacklinks(allocator: *mem.Allocator, zettels: []const Zettel) !void {
    for (zettels) |zet| {
        var backlinks = std.StringHashMap([]const u8).init(allocator);
        for (zettels) |other| {
            if (mem.eql(u8, other.id, zet.id)) continue;
            for (try other.links()) |link| {
                if (mem.eql(u8, link, zet.id)) {
                    _ = try backlinks.put(other.id, other.title);
                }
            }
        }

        if (backlinks.size == 0) continue;

        const start_index = if (mem.indexOf(u8, zet.contents, bl_header)) |s|
            s - 1
        else
            zet.contents.len;

        var bl_list = try std.ArrayList([]const u8).initCapacity(allocator, backlinks.size);
        var it = backlinks.iterator();
        while (it.next()) |kv| {
            comptime var template = "- [[{}]] {}";
            const item = try fmt.allocPrint(allocator, template, .{ kv.key, kv.value });
            bl_list.appendAssumeCapacity(item);
        }

        const new_contents = try mem.join(allocator, "\n", &[_][]const u8{
            zet.contents[0 .. start_index - 1],
            "",
            bl_header,
            "",
            try mem.join(allocator, "\n", bl_list.items),
        });

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

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

pub fn strftime(allocator: *mem.Allocator, format: [:0]const u8) ![]const u8 {
    var t = c.time(0);
    var tmp = c.localtime(&t);

    var buf = try allocator.allocSentinel(u8, 128, 0);
    _ = c.strftime(buf.ptr, buf.len, format.ptr, tmp);
    return allocator.shrink(buf, mem.len(buf.ptr));
}

fn fromEntry(allocator: *mem.Allocator, entry: fs.Dir.Entry) !Zettel {
    var file = try fs.cwd().openFile(entry.name, .{ .read = true });
    defer file.close();

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

    var line_it = mem.split(contents, "\n");
    if (line_it.next()) |line| {
        // If first line is not a front matter fence, skip this entry
        if (!mem.eql(u8, line, "---")) {
            return error.InvalidFormat;
        }
    } else {
        return error.InvalidFormat;
    }

    var title: ?[]const u8 = null;

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

    outer: while (line_it.next()) |line| {
        if (mem.eql(u8, line, "---") or mem.eql(u8, line, "...")) break;

        // Read title
        if (mem.startsWith(u8, line, "title: ")) {
            comptime var k = "title: ".len - 1;
            for (line[k..]) |char, i| {
                if (!fmt.isWhiteSpace(char)) {
                    title = line[k + i ..];
                    continue :outer;
                }
            }

            // No title found
            return error.InvalidFormat;
        }

        // Read tags
        if (mem.startsWith(u8, line, "tags: ")) {
            comptime var k = "tags: ".len - 1;
            for (line[k..]) |char, i| {
                if (!fmt.isWhiteSpace(char)) {
                    var it = mem.split(line[k + i ..], ",");
                    while (it.next()) |tag| {
                        const new_tag = fmt.trim(tag);
                        try tags.append(new_tag);
                    }

                    continue :outer;
                }
            }
        }
    } else {
        // Reached EOF before finding closing front matter fence
        return error.InvalidFormat;
    }

    // If no title found, skip
    _ = title orelse return error.InvalidFormat;

    const fname = try mem.dupe(allocator, u8, entry.name);

    return Zettel{
        .allocator = allocator,
        .basename = fname[0 .. fname.len - extension.len],
        .fname = fname,
        .id = fname[0..id_len],
        .tags = tags.toOwnedSlice(),
        .title = title.?,
        .mtime = (try file.stat()).mtime,
        .contents = contents,
    };
}

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

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

    return true;
}

test "hasId" {
    std.testing.expect(hasId("20200512074730"));
    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" ++ extension));
}