From 5a4b116ca447aa282f5fb841984c88c6931e4ee9 Mon Sep 17 00:00:00 2001 From: linyows Date: Tue, 20 Jan 2026 17:30:49 +0900 Subject: [PATCH] Add index table sorting feature - Add sorting support for index tables with customizable sort order - Default sort: by @id (asc), @date (desc), or mtime (desc) - Custom sort syntax: {{@index{@col1|@col2,asc:@field}}} - Add DocumentMeta.mtime field for file modification time sorting - Bump version to 0.3.0 - Update README with sorting documentation Co-Authored-By: Claude Opus 4.5 --- README.md | 17 ++++ src/index.zig | 266 +++++++++++++++++++++++++++++++++++++++++++++++++- src/main.zig | 34 +++++-- 3 files changed, 302 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 61ef8cc..a3eddad 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,26 @@ With this configuration: |----------|-------------| | `{{@index}}` | Document list table (default columns: Title, Date, Author) | | `{{@index{@id\|@title\|@status}}}` | Custom format table with specified columns | +| `{{@index{@id\|@title,asc:@id}}}` | Custom format with sort specification | +| `{{@index{@id\|@title\|@date,desc:@date}}}` | Sort by date descending | Available columns: `@id`, `@title`, `@date`, `@name`, `@status` +### Index Sorting + +You can specify a sort order using the `,asc:@field` or `,desc:@field` syntax after the column specification. + +**Default sort behavior** (when no sort is specified): +- If documents have `@id`: sort by `@id` ascending +- Else if documents have `@date`: sort by `@date` descending +- Else: sort by file modification time descending + +**Examples:** +```markdown +{{@index{@id|@title|@author,asc:@id}}} +{{@index{@title|@date,desc:@date}}} +``` + ## Custom Templates Create your own templates in `.draft/templates/`: diff --git a/src/index.zig b/src/index.zig index 68020e3..9595ba1 100644 --- a/src/index.zig +++ b/src/index.zig @@ -8,9 +8,20 @@ pub const DocumentMeta = struct { date: []const u8, name: []const u8, status: []const u8, + mtime: i128, // File modification time in nanoseconds }; -pub fn extractDocumentMeta(allocator: mem.Allocator, filename: []const u8, content: []const u8) !DocumentMeta { +pub const SortOrder = enum { + asc, + desc, +}; + +pub const SortConfig = struct { + field: []const u8, + order: SortOrder, +}; + +pub fn extractDocumentMeta(allocator: mem.Allocator, filename: []const u8, content: []const u8, mtime: i128) !DocumentMeta { var meta = DocumentMeta{ .filename = try allocator.dupe(u8, filename), .id = try allocator.dupe(u8, ""), @@ -18,6 +29,7 @@ pub fn extractDocumentMeta(allocator: mem.Allocator, filename: []const u8, conte .date = try allocator.dupe(u8, ""), .name = try allocator.dupe(u8, ""), .status = try allocator.dupe(u8, ""), + .mtime = mtime, }; // Extract ID from filename (first 3 chars if numeric) @@ -60,6 +72,27 @@ pub fn extractDocumentMeta(allocator: mem.Allocator, filename: []const u8, conte return meta; } +/// Extract sort configuration from template +/// Returns null if no sort specification found in template +pub fn extractSortConfigFromTemplate(template: []const u8) ?SortConfig { + const index_start = "{{@index"; + if (mem.indexOf(u8, template, index_start)) |start_idx| { + const after_start = template[start_idx + index_start.len ..]; + + if (after_start.len > 0 and after_start[0] == '{') { + // Custom format: {{@index{@id|@title|@status,asc:@id}}} + if (mem.indexOf(u8, after_start, "}}}")) |close_idx| { + const spec = after_start[1..close_idx]; + const parsed = parseIndexSpec(spec); + if (parsed.sort_spec) |sort_spec| { + return parseSortSpec(sort_spec); + } + } + } + } + return null; +} + pub fn expandIndex(allocator: mem.Allocator, template: []const u8, docs: []const DocumentMeta) ![]const u8 { var result = try allocator.dupe(u8, template); @@ -72,9 +105,11 @@ pub fn expandIndex(allocator: mem.Allocator, template: []const u8, docs: []const var end_idx: usize = 0; if (after_start.len > 0 and after_start[0] == '{') { - // Custom format: {{@index{@id|@title|@status}}} + // Custom format: {{@index{@id|@title|@status}}} or {{@index{@id|@title,asc:@id}}} if (mem.indexOf(u8, after_start, "}}}")) |close_idx| { - format = after_start[1..close_idx]; + const spec = after_start[1..close_idx]; + const parsed = parseIndexSpec(spec); + format = parsed.format; end_idx = start_idx + index_start.len + close_idx + 3; } } else if (mem.indexOf(u8, after_start, "}}")) |close_idx| { @@ -172,6 +207,87 @@ pub fn getColumnValue(doc: DocumentMeta, col: []const u8) []const u8 { return ""; } +/// Parse sort specification from format string (e.g., "asc:@id" or "desc:@date") +pub fn parseSortSpec(sort_spec: []const u8) ?SortConfig { + if (mem.startsWith(u8, sort_spec, "asc:")) { + return SortConfig{ + .field = sort_spec[4..], + .order = .asc, + }; + } else if (mem.startsWith(u8, sort_spec, "desc:")) { + return SortConfig{ + .field = sort_spec[5..], + .order = .desc, + }; + } + return null; +} + +/// Determine default sort configuration based on document metadata +/// - If documents have id: sort by id ascending +/// - Else if documents have date: sort by date descending +/// - Else: sort by mtime descending +pub fn getDefaultSortConfig(docs: []const DocumentMeta) SortConfig { + // Check if any document has a non-empty id + var has_id = false; + var has_date = false; + + for (docs) |doc| { + if (doc.id.len > 0) has_id = true; + if (doc.date.len > 0) has_date = true; + } + + if (has_id) { + return SortConfig{ .field = "@id", .order = .asc }; + } else if (has_date) { + return SortConfig{ .field = "@date", .order = .desc }; + } else { + return SortConfig{ .field = "@mtime", .order = .desc }; + } +} + +/// Sort documents by the given configuration +pub fn sortDocuments(docs: []DocumentMeta, sort_config: SortConfig) void { + const Context = struct { + config: SortConfig, + + fn compare(ctx: @This(), a: DocumentMeta, b: DocumentMeta) bool { + const order_result = if (mem.eql(u8, ctx.config.field, "@mtime")) + compareMtime(a, b) + else + compareStrings(getColumnValue(a, ctx.config.field), getColumnValue(b, ctx.config.field)); + + return if (ctx.config.order == .asc) order_result == .lt else order_result == .gt; + } + + fn compareMtime(a: DocumentMeta, b: DocumentMeta) std.math.Order { + return std.math.order(a.mtime, b.mtime); + } + + fn compareStrings(a_val: []const u8, b_val: []const u8) std.math.Order { + return mem.order(u8, a_val, b_val); + } + }; + + std.mem.sort(DocumentMeta, docs, Context{ .config = sort_config }, Context.compare); +} + +/// Parse format and sort specification from index tag +/// Format: "@col1|@col2|@col3,asc:@field" or "@col1|@col2|@col3" +/// Returns (format, sort_spec) +pub fn parseIndexSpec(spec: []const u8) struct { format: []const u8, sort_spec: ?[]const u8 } { + if (mem.lastIndexOfScalar(u8, spec, ',')) |comma_idx| { + return .{ + .format = spec[0..comma_idx], + .sort_spec = spec[comma_idx + 1 ..], + }; + } + return .{ + .format = spec, + .sort_spec = null, + }; +} + // ============================================================================= // Tests // ============================================================================= @@ -191,7 +307,7 @@ test "extractDocumentMeta: basic extraction" { \\## Context ; - const meta = try extractDocumentMeta(allocator, "001-auth.md", content); + const meta = try extractDocumentMeta(allocator, "001-auth.md", content, 1000000); defer { allocator.free(meta.filename); allocator.free(meta.id); @@ -207,13 +323,14 @@ test "extractDocumentMeta: basic extraction" { try testing.expectEqualStrings("2026-01-18", meta.date); try testing.expectEqualStrings("linyows", meta.name); try testing.expectEqualStrings("Accepted", meta.status); + try testing.expectEqual(@as(i128, 1000000), meta.mtime); } test "extractDocumentMeta: id from filename" { const allocator = testing.allocator; const content = "# My Title\n\nSome content"; - const meta = try extractDocumentMeta(allocator, "042-my-doc.md", content); + const meta = try extractDocumentMeta(allocator, "042-my-doc.md", content, 2000000); defer { allocator.free(meta.filename); allocator.free(meta.id); @@ -244,6 +361,7 @@ test "getColumnValue: returns correct values" { .date = "2026-01-18", .name = "linyows", .status = "Proposed", + .mtime = 1000000, }; try testing.expectEqualStrings("001", getColumnValue(doc, "@id")); @@ -264,6 +382,7 @@ test "buildIndexTable: default format" { .date = "2026-01-18", .name = "linyows", .status = "Accepted", + .mtime = 1000000, }, }; @@ -286,6 +405,7 @@ test "buildIndexTable: custom format with id and status" { .date = "2026-01-18", .name = "linyows", .status = "Accepted", + .mtime = 1000000, }, }; @@ -308,6 +428,7 @@ test "expandIndex: default format" { .date = "2026-01-18", .name = "linyows", .status = "Proposed", + .mtime = 1000000, }, }; @@ -331,6 +452,7 @@ test "expandIndex: custom format" { .date = "2026-01-18", .name = "linyows", .status = "Accepted", + .mtime = 1000000, }, }; @@ -341,3 +463,137 @@ test "expandIndex: custom format" { try testing.expect(mem.indexOf(u8, result, "[001](./001-test.md)") != null); try testing.expect(mem.indexOf(u8, result, "Accepted") != null); } + +test "expandIndex: custom format with sort spec" { + const allocator = testing.allocator; + const template = "# ADR\n\n{{@index{@id|@title,asc:@id}}}\n"; + var docs = [_]DocumentMeta{ + .{ + .filename = "001-test.md", + .id = "001", + .title = "Test", + .date = "2026-01-18", + .name = "linyows", + .status = "Accepted", + .mtime = 1000000, + }, + }; + + const result = try expandIndex(allocator, template, &docs); + defer allocator.free(result); + + try testing.expect(mem.indexOf(u8, result, "| ID | Title |") != null); + try testing.expect(mem.indexOf(u8, result, "[001](./001-test.md)") != null); +} + +test "parseSortSpec: parses asc" { + const config = parseSortSpec("asc:@id"); + try testing.expect(config != null); + try testing.expectEqualStrings("@id", config.?.field); + try testing.expectEqual(SortOrder.asc, config.?.order); +} + +test "parseSortSpec: parses desc" { + const config = parseSortSpec("desc:@date"); + try testing.expect(config != null); + try testing.expectEqualStrings("@date", config.?.field); + try testing.expectEqual(SortOrder.desc, config.?.order); +} + +test "parseSortSpec: returns null for invalid" { + try testing.expect(parseSortSpec("invalid") == null); + try testing.expect(parseSortSpec("@id") == null); +} + +test "parseIndexSpec: with sort spec" { + const result = parseIndexSpec("@id|@title|@date,asc:@id"); + try testing.expectEqualStrings("@id|@title|@date", result.format); + try testing.expect(result.sort_spec != null); + try testing.expectEqualStrings("asc:@id", result.sort_spec.?); +} + +test "parseIndexSpec: without sort spec" { + const result = parseIndexSpec("@id|@title|@date"); + try testing.expectEqualStrings("@id|@title|@date", result.format); + try testing.expect(result.sort_spec == null); +} + +test "getDefaultSortConfig: with id" { + var docs = [_]DocumentMeta{ + .{ .filename = "a.md", .id = "001", .title = "", .date = "", .name = "", .status = "", .mtime = 0 }, + }; + const config = getDefaultSortConfig(&docs); + try testing.expectEqualStrings("@id", config.field); + try testing.expectEqual(SortOrder.asc, config.order); +} + +test "getDefaultSortConfig: with date only" { + var docs = [_]DocumentMeta{ + .{ .filename = "a.md", .id = "", .title = "", .date = "2026-01-18", .name = "", .status = "", .mtime = 0 }, + }; + const config = getDefaultSortConfig(&docs); + try testing.expectEqualStrings("@date", config.field); + try testing.expectEqual(SortOrder.desc, config.order); +} + +test "getDefaultSortConfig: no id or date" { + var docs = [_]DocumentMeta{ + .{ .filename = "a.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 0 }, + }; + const config = getDefaultSortConfig(&docs); + try testing.expectEqualStrings("@mtime", config.field); + try testing.expectEqual(SortOrder.desc, config.order); +} + +test "sortDocuments: by id ascending" { + var docs = [_]DocumentMeta{ + .{ .filename = "b.md", .id = "002", .title = "B", .date = "", .name = "", .status = "", .mtime = 0 }, + .{ .filename = "a.md", .id = "001", .title = "A", .date = "", .name = "", .status = "", .mtime = 0 }, + .{ .filename = "c.md", .id = "003", .title = "C", .date = "", .name = "", .status = "", .mtime = 0 }, + }; + sortDocuments(&docs, .{ .field = "@id", .order = .asc }); + try testing.expectEqualStrings("001", docs[0].id); + try testing.expectEqualStrings("002", docs[1].id); + try testing.expectEqualStrings("003", docs[2].id); +} + +test "sortDocuments: by date descending" { + var docs = [_]DocumentMeta{ + .{ .filename = "a.md", .id = "", .title = "", .date = "2026-01-15", .name = "", .status = "", .mtime = 0 }, + .{ .filename = "b.md", .id = "", .title = "", .date = "2026-01-20", .name = "", .status = "", .mtime = 0 }, + .{ .filename = "c.md", .id = "", .title = "", .date = "2026-01-10", .name = "", .status = "", .mtime = 0 }, + }; + sortDocuments(&docs, .{ .field = "@date", .order = .desc }); + try testing.expectEqualStrings("2026-01-20", docs[0].date); + try testing.expectEqualStrings("2026-01-15", docs[1].date); + try testing.expectEqualStrings("2026-01-10", docs[2].date); +} + +test "sortDocuments: by mtime descending" { + var docs = [_]DocumentMeta{ + .{ .filename = "a.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 1000 }, + .{ .filename = "b.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 3000 }, + .{ .filename = "c.md", .id = "", .title = "", .date = "", .name = "", .status = "", .mtime = 2000 }, + }; + sortDocuments(&docs, .{ .field = "@mtime", .order = .desc }); + try testing.expectEqual(@as(i128, 3000), docs[0].mtime); + try testing.expectEqual(@as(i128, 2000), docs[1].mtime); + try testing.expectEqual(@as(i128, 1000), docs[2].mtime); +} + +test "extractSortConfigFromTemplate: with sort spec" { + const config = extractSortConfigFromTemplate("# Index\n\n{{@index{@id|@title,asc:@id}}}\n"); + try testing.expect(config != null); + try testing.expectEqualStrings("@id", config.?.field); + try testing.expectEqual(SortOrder.asc, config.?.order); +} + +test "extractSortConfigFromTemplate: without sort spec" { + const config = extractSortConfigFromTemplate("# Index\n\n{{@index{@id|@title}}}\n"); + try testing.expect(config == null); +} + +test "extractSortConfigFromTemplate: default format" { + const config = extractSortConfigFromTemplate("# Index\n\n{{@index}}\n"); + try testing.expect(config == null); +} diff --git a/src/main.zig b/src/main.zig index 84a7ed1..1274315 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,6 +16,9 @@ const replaceVariables = template.replaceVariables; const DocumentMeta = index.DocumentMeta; const extractDocumentMeta = index.extractDocumentMeta; const expandIndex = index.expandIndex; +const extractSortConfigFromTemplate = index.extractSortConfigFromTemplate; +const getDefaultSortConfig = index.getDefaultSortConfig; +const sortDocuments = index.sortDocuments; const getToday = utils.getToday; const getUsername = utils.getUsername; const getNextId = utils.getNextId; @@ -97,15 +100,22 @@ fn printUsage() void { \\ {{@id{N}}} - Incremental ID with N digits (e.g., {{@id{4}}} -> 0001) \\ \\Index Variables: - \\ {{@index}} - Document list table (default: @title|@date|@name) - \\ {{@index{@id|@title|@status}}} - Custom format table + \\ {{@index}} - Document list table (default: @title|@date|@name) + \\ {{@index{@id|@title|@status}}} - Custom format table + \\ {{@index{@id|@title,asc:@id}}} - Custom format with sort specification + \\ {{@index{@id|@title|@date,desc:@date}}} - Sort by date descending + \\ + \\Index Sort (default behavior): + \\ - If documents have @id: sort by @id ascending + \\ - Else if documents have @date: sort by @date descending + \\ - Else: sort by file modification time descending \\ ; std.debug.print("{s}", .{usage}); } fn printVersion() void { - std.debug.print("draft version 0.1.0\n", .{}); + std.debug.print("draft version 0.3.0\n", .{}); } fn runInit(allocator: mem.Allocator) !void { @@ -302,16 +312,20 @@ fn runIndex(allocator: mem.Allocator, template_name: []const u8) !void { const content = cwd.readFileAlloc(allocator, file_path, 1024 * 1024) catch continue; defer allocator.free(content); - const meta = try extractDocumentMeta(allocator, entry.name, content); + // Get file modification time + const stat = dir.statFile(entry.name) catch continue; + const mtime = stat.mtime; + + const meta = try extractDocumentMeta(allocator, entry.name, content, mtime); try docs.append(allocator, meta); } - // Sort by ID - std.mem.sort(DocumentMeta, docs.items, {}, struct { - fn lessThan(_: void, a: DocumentMeta, b: DocumentMeta) bool { - return std.mem.order(u8, a.id, b.id) == .lt; - } - }.lessThan); + // Determine sort configuration + const sort_config = extractSortConfigFromTemplate(template_content) orelse + getDefaultSortConfig(docs.items); + + // Sort documents + sortDocuments(docs.items, sort_config); // Expand @index variable const output_content = try expandIndex(allocator, template_content, docs.items);