Serializer should preserve created and modified fields

This commit is contained in:
Jeremy Ruston
2026-04-22 10:53:49 +01:00
parent 82bde9bffc
commit 7a80bd4a6f
3 changed files with 110 additions and 12 deletions
@@ -163,10 +163,23 @@ describe("markdown YAML frontmatter", function() {
// Malformed YAML still parses something; we just ensure body is set
expect(result[0].text).toBeDefined();
});
it("ignores created and modified per collision policy", function() {
var result = ds("---\ntitle: T\ncreated: 2026-01-01\nmodified: 2026-02-02\n---\n\nb",{});
it("parses ISO-8601 created and modified into TW native format", function() {
var result = ds("---\ntitle: T\ncreated: 2025-01-02T03:04:05.006Z\nmodified: 2026-02-03T04:05:06Z\n---\n\nb",{});
expect(result[0].created).toBe("20250102030405006");
expect(result[0].modified).toBe("20260203040506000");
});
it("accepts a bare YYYY-MM-DD date for created/modified", function() {
var result = ds("---\ntitle: T\ncreated: 2025-03-15\n---\n\nb",{});
expect(result[0].created).toBe("20250315000000000");
});
it("passes through TW native timestamps for created/modified", function() {
var result = ds("---\ntitle: T\ncreated: \"20250101000000000\"\nmodified: \"20260101000000\"\n---\n\nb",{});
expect(result[0].created).toBe("20250101000000000");
expect(result[0].modified).toBe("20260101000000000");
});
it("drops unparseable created/modified values", function() {
var result = ds("---\ntitle: T\ncreated: not-a-date\n---\n\nb",{});
expect(result[0].created).toBeUndefined();
expect(result[0].modified).toBeUndefined();
});
it("merges existing tags with frontmatter tags", function() {
var result = ds("---\ntags: [b, c]\n---\n\nbody",{tags: "a"});
@@ -204,22 +217,34 @@ describe("markdown YAML frontmatter", function() {
expect(out).toContain("- multi word tag");
expect(out).toContain("- simple");
});
it("skips text, created, modified, bag, revision", function() {
it("skips text, bag, revision", function() {
var t = new $tw.Tiddler({
title: "X",
text: "body",
created: "20260101000000000",
modified: "20260101000000000",
bag: "default",
revision: "1"
});
var out = ser(t);
expect(out).not.toContain("created:");
expect(out).not.toContain("modified:");
expect(out).not.toContain("bag:");
expect(out).not.toContain("revision:");
expect(out).not.toContain("text:");
});
it("emits created and modified as ISO-8601 strings", function() {
var t = new $tw.Tiddler({
title: "X",
text: "b",
created: "20250102030405006",
modified: "20260203040506000"
});
var out = ser(t);
expect(out).toContain('created: "2025-01-02T03:04:05.006Z"');
expect(out).toContain('modified: "2026-02-03T04:05:06.000Z"');
});
it("drops unparseable created/modified values", function() {
var t = new $tw.Tiddler({title: "X", text: "b", created: "garbage"});
var out = ser(t);
expect(out).not.toContain("created:");
});
it("skips type when it equals text/x-markdown", function() {
var t = new $tw.Tiddler({title: "X", type: "text/x-markdown", text: "b"});
expect(ser(t)).not.toContain("type:");
@@ -262,6 +287,15 @@ describe("markdown YAML frontmatter", function() {
expect(reparsed.rating).toBe("7");
expect(reparsed.text).toBe("This is the body.");
});
it("preserves created and modified across deserialize → serialize", function() {
var input = "---\ntitle: T\ncreated: 2025-01-02T03:04:05.006Z\nmodified: 2026-02-03T04:05:06.007Z\n---\n\nbody";
var fields = ds(input,{})[0];
var t = new $tw.Tiddler(fields);
var out = ser(t);
var reparsed = ds(out,{})[0];
expect(reparsed.created).toBe("20250102030405006");
expect(reparsed.modified).toBe("20260203040506007");
});
});
});
@@ -11,6 +11,11 @@ field with a registered `stringify` method) are converted to TiddlyWiki
bracketed lists. Non-string, non-array values are stored as their JSON
representation.
`created` and `modified` in the frontmatter are accepted in either
TiddlyWiki's native `YYYYMMDDHHMMSSmmm` UTC format or any ISO-8601
string that `Date()` can parse; both are normalised to TW's native
format. Values that cannot be parsed are dropped.
\*/
"use strict";
@@ -60,7 +65,10 @@ function deserialize(text,fields) {
value = frontmatter[key];
// Apply field collision policy
if(key === "created" || key === "modified") {
// Defer to TiddlyWiki's own timestamps; ignore YAML values
var normalised = normaliseDate(value);
if(normalised !== null) {
result[key] = normalised;
}
continue;
}
if(key === "tags" && result[key]) {
@@ -113,6 +121,35 @@ function fieldValueToString(key,value) {
return String(value);
}
/*
Normalise a frontmatter date value to TiddlyWiki's YYYYMMDDHHMMSSmmm UTC
format. Accepts TW native strings (14 or 17 digits, optional leading "-"
for negative years) and anything `Date()` can parse (ISO 8601, RFC 2822,
Date objects). Returns null if the value cannot be interpreted as a date.
*/
function normaliseDate(value) {
if(value === null || value === undefined) {
return null;
}
if(typeof value === "string") {
if(/^-?\d{14}$/.test(value)) {
return value + "000";
}
if(/^-?\d{17}$/.test(value)) {
return value;
}
var d = new Date(value);
if(!isNaN(d.getTime())) {
return $tw.utils.stringifyDate(d);
}
return null;
}
if(value instanceof Date && !isNaN(value.getTime())) {
return $tw.utils.stringifyDate(value);
}
return null;
}
/*
Merge incoming tag value with existing tags string.
The incoming value may be a string (TW bracketed list) or an array (from YAML).
@@ -12,7 +12,8 @@ Markdown file body whose first lines are a YAML frontmatter block
Field handling:
- `title` is always emitted (frontmatter wins over filename when reloaded).
- `text` is the body; not emitted in the frontmatter.
- `created`, `modified` are skipped (TiddlyWiki manages timestamps via filesystem).
- `created`, `modified` are emitted as ISO-8601 strings (symmetric with
the deserializer, which accepts either ISO-8601 or TW's native format).
- `type` is skipped when it equals `text/x-markdown` (the default for `.md` files).
- `bag`, `revision` are skipped (sync metadata, not authored content).
- List fields (those with a registered `stringify` method) are emitted as YAML arrays.
@@ -26,8 +27,6 @@ var yaml = require("$:/plugins/tiddlywiki/markdown/yaml.js");
// Field names to skip when emitting frontmatter
var SKIP_FIELDS = {
text: true,
created: true,
modified: true,
bag: true,
revision: true
};
@@ -50,6 +49,13 @@ function serialize(tiddler) {
if(name === "type" && value === "text/x-markdown") {
return;
}
if(name === "created" || name === "modified") {
var iso = toIsoDate(value);
if(iso) {
frontmatter[name] = iso;
}
return;
}
// List fields → YAML arrays
if($tw.Tiddler.fieldModules[name] && $tw.Tiddler.fieldModules[name].stringify) {
var items;
@@ -74,6 +80,27 @@ function serialize(tiddler) {
return "---\n" + yaml.dump(frontmatter) + "\n---\n\n" + body;
}
/*
Convert a TiddlyWiki date field value to an ISO-8601 string. Accepts a
native `YYYYMMDDHHMMSSmmm` string or a Date. Returns null if the value
cannot be parsed.
*/
function toIsoDate(value) {
if(!value) {
return null;
}
var d;
if($tw.utils.isDate(value)) {
d = value;
} else {
d = $tw.utils.parseDate(String(value));
}
if(d && !isNaN(d.getTime())) {
return d.toISOString();
}
return null;
}
// Register under both types — text/markdown is what the "New Markdown" button
// sets; text/x-markdown is what the deserializer uses after content-type
// resolution for .md files loaded from disk.