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 Page = struct {
allocator: *mem.Allocator,
basename: []const u8,
fname: []const u8,
id: []const u8,
tags: [][]const u8,
title: []const u8,
contents: []const u8,
/// Create a new Page with the given title.
pub fn new(allocator: *mem.Allocator, title: []const u8) !Page {
const fname = try createFilename(allocator, title);
var file = try fs.cwd().createFile(fname, .{});
defer file.close();
const date = try strftime(allocator, "%B %d, %Y");
comptime var template = "---\ntitle: {}\ndate: {}\ntags:\n...\n";
const contents = try fmt.allocPrint(allocator, template, .{ title, date });
try file.writeAll(contents);
return Page{
.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),
.contents = contents,
};
}
pub fn read(self: Page) ![]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 match(self: Page, 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 pages linked to in the given page
pub fn links(self: Page) ![]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 openPages(allocator: *mem.Allocator, pages: []const []const u8) !void {
var argv = try std.ArrayList([]const u8).initCapacity(allocator, pages.len + 1);
argv.appendAssumeCapacity(os.getenv("EDITOR") orelse "vi");
argv.appendSliceAssumeCapacity(pages);
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 getPages(allocator: *mem.Allocator) ![]Page {
var pages = std.ArrayList(Page).init(allocator);
var dir = try fs.cwd().openDir(".", .{ .iterate = true });
defer dir.close();
var it = dir.iterate();
while (try it.next()) |file| {
if (!hasId(file.name) or !mem.endsWith(u8, file.name, extension)) {
continue;
}
const page = fromFile(allocator, file) catch |err| switch (err) {
error.InvalidFormat => continue,
else => return err,
};
try pages.append(page);
}
std.sort.sort(Page, pages.items, {}, struct {
fn lessThan(context: void, lhs: Page, rhs: Page) bool {
return mem.lessThan(u8, lhs.id, rhs.id);
}
}.lessThan);
return pages.toOwnedSlice();
}
/// Find all backlinks between notes and write them to file
pub fn updateBacklinks(allocator: *mem.Allocator, pages: []const Page) !void {
for (pages) |page| {
var backlinks = std.StringHashMap([]const u8).init(allocator);
for (pages) |other| {
if (mem.eql(u8, other.id, page.id)) continue;
for (try other.links()) |link| {
if (mem.eql(u8, link, page.id)) {
_ = try backlinks.put(other.id, other.title);
}
}
}
if (backlinks.count() == 0) continue;
const start_index = if (mem.indexOf(u8, page.contents, bl_header)) |s|
s - 1
else
page.contents.len;
var bl_list = try std.ArrayList([]const u8).initCapacity(allocator, backlinks.count());
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{
page.contents[0 .. start_index - 1],
"",
bl_header,
"",
try mem.join(allocator, "\n", bl_list.items),
});
if (!mem.eql(u8, new_contents, page.contents)) {
var file = try fs.cwd().openFile(page.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 fromFile(allocator: *mem.Allocator, page: fs.Dir.Entry) !Page {
var file = try fs.cwd().openFile(page.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 page
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 (!ascii.isSpace(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 (!ascii.isSpace(char)) {
var it = mem.split(line[k + i ..], ",");
while (it.next()) |tag| {
const new_tag = mem.trim(u8, tag, &ascii.spaces);
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, page.name);
return Page{
.allocator = allocator,
.basename = fname[0 .. fname.len - extension.len],
.fname = fname,
.id = fname[0..id_len],
.tags = tags.toOwnedSlice(),
.title = title.?,
.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));
}
fn createFilename(allocator: *mem.Allocator, title: []const u8) ![]const u8 {
const id = try strftime(allocator, "%Y%m%d%H%M%S");
defer allocator.free(id);
var fname = try fmt.allocPrint(allocator, "{}-{}" ++ extension, .{ id, title });
comptime var not_allowed = [_]u8{ '?', '*', '"' };
var i: usize = 0;
for (fname) |ch| {
if (mem.indexOfScalar(u8, ¬_allowed, ch)) |_| {
continue;
}
if (ascii.isSpace(ch)) {
fname[i] = '_';
} else {
fname[i] = ch;
}
i += 1;
}
return allocator.shrink(fname, i);
}
test "createFilename" {
const allocator = std.testing.allocator;
const id = try strftime(allocator, "%Y%m%d%H%M%S");
defer allocator.free(id);
{
const actual = try createFilename(allocator, "Hello, World!");
defer allocator.free(actual);
const expected = try fmt.allocPrint(allocator, "{}-{}" ++ extension, .{
id,
"Hello,_World!",
});
defer allocator.free(expected);
std.testing.expect(mem.eql(u8, actual, expected));
}
{
const actual = try createFilename(allocator, "A perfectly safe title");
defer allocator.free(actual);
const expected = try fmt.allocPrint(allocator, "{}-{}" ++ extension, .{
id,
"A_perfectly_safe_title",
});
defer allocator.free(expected);
std.testing.expect(mem.eql(u8, actual, expected));
}
{
const actual = try createFilename(allocator, "Dashes, commas, and apostrophes are ok, but *'s, ?'s, and \"'s aren't");
defer allocator.free(actual);
const expected = try fmt.allocPrint(allocator, "{}-{}" ++ extension, .{
id,
"Dashes,_commas,_and_apostrophes_are_ok,_but_'s,_'s,_and_'s_aren't",
});
defer allocator.free(expected);
std.testing.expect(mem.eql(u8, actual, expected));
}
}