timeline_builder
Turn dated events (years, months or days, BCE/BC and CE/AD, points or ranges) into a chronologically sorted Markdown list, a Markdown table with durations and an ASCII timeline scaled exactly to a chosen width, and report duplicates, events on the same date, overlapping or nested ranges and points that fall within a range. Uses the historical convention with no year 0.
Install
/tools install timeline_builder /tools install timeline_builder@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · timeline_builder 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool timeline_builder; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
events | array of object | yes | items ≥ 1; items ≤ 500 | |
width | integer | no | 60 | ≥ 20; ≤ 200 |
era | string | no | "BCE" | BCE/CE or BC/AD labels. one of "BCE", "BC" |
title | string | no | length ≤ 200 |
Input schema (JSON)
{
"type": "object",
"required": [
"events"
],
"additionalProperties": false,
"properties": {
"events": {
"type": "array",
"minItems": 1,
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"label"
],
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"date": {
"type": [
"integer",
"string"
],
"description": "Year (negative = BCE) or YYYY, YYYY-MM, YYYY-MM-DD, '44 BC', '500 BCE', 'AD 79', '79 CE'"
},
"year": {
"type": [
"integer",
"string"
],
"description": "Same as date"
},
"start": {
"type": [
"integer",
"string"
]
},
"end": {
"type": [
"integer",
"string"
]
}
}
}
},
"width": {
"type": "integer",
"minimum": 20,
"maximum": 200,
"default": 60
},
"era": {
"type": "string",
"enum": [
"BCE",
"BC"
],
"default": "BCE",
"description": "BCE/CE or BC/AD labels"
},
"title": {
"type": "string",
"maxLength": 200
}
}
}
Example
Input
{
"events": [
{
"label": "Augustus reigns",
"start": "27 BC",
"end": "AD 14"
},
{
"label": "Rome founded",
"date": -753
}
],
"era": "BC"
}
Output (stdout)
{
"markdown_list": "- **753 BC** — Rome founded\n- **27 BC – AD 14** — Augustus reigns\n"
}
Files
The command the install card registers is python3 timeline_builder.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
timeline_builder.py · sha256 f651be1bd7ff84971fee9920b45c6b68dd0e958ea63099abc79ed6d58b2928e9
#!/usr/bin/env python3
"""Events to a sorted Markdown list, a Markdown table and an ASCII timeline scaled to a width,
with duplicate, same-date and overlap detection. Years BCE are supported.
Input (JSON on stdin):
events [{"label": "...", "date": "1066-10-14"} | {"label": "...", "start": "1337", "end": "1453"}]
a date is an integer year (negative = BCE) or a string: "YYYY", "YYYY-MM", "YYYY-MM-DD",
"-YYYY", "44 BC", "500 BCE", "AD 79", "79 CE"; there is no year 0 (1 BCE is followed by 1 CE)
width ASCII chart width in columns, default 60 (20-200)
era "BCE" | "BC" labels, default BCE
title optional heading
"""
import json
import math
import re
import sys
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
class When:
"""A date of year, month or day precision; year is historical (no year 0), astro = 1 - BCE year."""
def __init__(self, year, bce, month=None, day=None):
self.year, self.bce, self.month, self.day = year, bce, month, day
self.astro = 1 - year if bce else year
def key_start(self):
return (self.astro, self.month or 1, self.day or 1)
def key_end(self):
month = self.month or 12
return (self.astro, month, self.day or 31)
def pos_start(self):
p = Fraction(self.astro)
if self.month:
p += Fraction(self.month - 1, 12)
if self.day:
p += Fraction(self.day - 1, 12 * 31)
return p
def pos_end(self):
"""Exclusive end of the period: the start of the next year, month or day."""
if self.day:
return self.pos_start() + Fraction(1, 12 * 31)
if self.month:
return self.pos_start() + Fraction(1, 12)
return Fraction(self.astro + 1)
def label(self, era, any_bce):
if self.bce:
if self.month:
return f"{self.year}-{self.month:02d}" + (f"-{self.day:02d}" if self.day else "") + f" {era}"
return f"{self.year} {era}"
text = f"{self.year:04d}" if self.year >= 1000 else str(self.year)
if self.month:
text = f"{self.year:04d}-{self.month:02d}" + (f"-{self.day:02d}" if self.day else "")
if any_bce and not self.month:
text += " CE" if era == "BCE" else ""
if era == "BC":
text = "AD " + text
return text
def parse(value, field):
if isinstance(value, bool):
fail(f"{field} must be a year or a date string")
if isinstance(value, int):
if value == 0:
fail(f"{field}: there is no year 0 (1 BCE is followed by 1 CE)")
return When(abs(value), value < 0)
if not isinstance(value, str):
fail(f"{field} must be a year or a date string")
s = value.strip()
m = re.fullmatch(r"(?i)(\d{1,6})\s*(BCE|BC|B\.C\.E?\.?)", s)
if m:
year = int(m.group(1))
if year == 0:
fail(f"{field}: there is no year 0")
return When(year, True)
m = re.fullmatch(r"(?i)(?:AD|A\.D\.)\s*(\d{1,6})|(\d{1,6})\s*(?:CE|AD|C\.E\.)", s)
if m:
year = int(m.group(1) or m.group(2))
if year == 0:
fail(f"{field}: there is no year 0")
return When(year, False)
m = re.fullmatch(r"(-?)(\d{1,6})(?:-(\d{2})(?:-(\d{2}))?)?", s)
if not m:
fail(f"{field} {value!r} is not a year or YYYY[-MM[-DD]] date")
year, month, day = int(m.group(2)), m.group(3), m.group(4)
if year == 0:
fail(f"{field}: there is no year 0 (1 BCE is followed by 1 CE)")
bce = m.group(1) == "-"
month = int(month) if month else None
day = int(day) if day else None
if month is not None and not 1 <= month <= 12:
fail(f"{field} has month {month}")
if day is not None:
astro = 1 - year if bce else year
leap = astro % 4 == 0 and (astro % 100 != 0 or astro % 400 == 0) # proleptic Gregorian
days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
if not 1 <= day <= days:
fail(f"{field} has day {day} outside the month")
return When(year, bce, month, day)
def col(pos, low, span, width):
"""Column of a position, scaled exactly and rounded half up."""
return math.floor((pos - low) / span * (width - 1) + Fraction(1, 2))
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = sorted(set(request) - {"events", "width", "era", "title"})
if extra:
fail(f"unknown field(s): {', '.join(extra)}")
raw = request.get("events")
if not isinstance(raw, list) or not 1 <= len(raw) <= 500:
fail("events must be a list of 1 to 500 events")
width = request.get("width", 60)
if isinstance(width, bool) or not isinstance(width, int) or not 20 <= width <= 200:
fail("width must be an integer from 20 to 200")
era = request.get("era", "BCE")
if era not in ("BCE", "BC"):
fail("era must be BCE or BC")
title = request.get("title")
if title is not None and (not isinstance(title, str) or len(title) > 200):
fail("title must be a string of at most 200 characters")
events = []
for i, e in enumerate(raw):
if not isinstance(e, dict) or set(e) - {"label", "date", "year", "start", "end"}:
fail(f"events[{i}] must have label and date (or year), or start and end")
label = e.get("label")
if not isinstance(label, str) or not label.strip() or len(label) > 200 or "\n" in label:
fail(f"events[{i}].label must be a one-line string of at most 200 characters")
point = e.get("date", e.get("year"))
if point is not None and ("start" in e or "end" in e):
fail(f"events[{i}]: give date or start/end, not both")
if point is not None:
start = end = parse(point, f"events[{i}].date")
is_range = False
else:
if "start" not in e or "end" not in e:
fail(f"events[{i}] needs date (or year), or both start and end")
start, end = parse(e["start"], f"events[{i}].start"), parse(e["end"], f"events[{i}].end")
if end.key_end() < start.key_start():
fail(f"events[{i}] ends before it starts")
is_range = True
events.append({"index": i, "label": label.strip(), "start": start, "end": end, "range": is_range})
events.sort(key=lambda ev: (ev["start"].key_start(), ev["end"].key_end(), ev["label"].casefold()))
any_bce = any(ev["start"].bce or ev["end"].bce for ev in events)
def when_text(ev):
s = ev["start"].label(era, any_bce)
return f"{s} – {ev['end'].label(era, any_bce)}" if ev["range"] else s
duplicates, same_date, overlaps, during = [], [], [], []
for a in range(len(events)):
for b in range(a + 1, len(events)):
x, y = events[a], events[b]
same = x["start"].key_start() == y["start"].key_start() and x["end"].key_end() == y["end"].key_end()
if same and x["label"].casefold() == y["label"].casefold():
duplicates.append({"events": [x["index"] + 1, y["index"] + 1], "label": x["label"], "when": when_text(x)})
continue
if same and not x["range"] and not y["range"]:
same_date.append({"events": [x["index"] + 1, y["index"] + 1], "when": when_text(x),
"labels": [x["label"], y["label"]]})
xs, xe, ys, ye = x["start"].pos_start(), x["end"].pos_end(), y["start"].pos_start(), y["end"].pos_end()
if x["range"] and y["range"] and xs < ye and ys < xe:
kind = "contains" if (xs <= ys and ye <= xe) or (ys <= xs and xe <= ye) else "overlap"
overlaps.append({"events": [x["index"] + 1, y["index"] + 1], "kind": kind,
"labels": [x["label"], y["label"]]})
for r, p in ((x, y), (y, x)):
if r["range"] and not p["range"] and r["start"].pos_start() <= p["start"].pos_start() < r["end"].pos_end():
during.append({"event": p["index"] + 1, "label": p["label"], "during": r["label"]})
lines_md = ([f"## {title}", ""] if title else []) + [f"- **{when_text(ev)}** — {ev['label']}" for ev in events]
table = ["| Start | End | Event | Years |", "|:--|:--|:--|--:|"]
for ev in events:
years = ""
if ev["range"]:
length = ev["end"].pos_start() - ev["start"].pos_start()
years = str(length) if length.denominator == 1 else str(
(Decimal(length.numerator) / Decimal(length.denominator)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
end = ev["end"].label(era, any_bce) if ev["range"] else ""
table.append(f"| {ev['start'].label(era, any_bce)} | {end} | {ev['label'].replace('|', '/')} | {years} |")
low = min(ev["start"].pos_start() for ev in events)
high = max(ev["end"].pos_end() if ev["range"] else ev["start"].pos_start() for ev in events)
span = high - low if high > low else Fraction(1)
first, last = events[0]["start"].label(era, any_bce), max(
events, key=lambda ev: (ev["end"] if ev["range"] else ev["start"]).pos_end())
last_label = (last["end"] if last["range"] else last["start"]).label(era, any_bce)
gap = width - len(first) - len(last_label)
ascii_lines = [first + " " * max(gap, 1) + last_label, "+" + "-" * (width - 2) + "+"]
for ev in events:
row = [" "] * width
c1 = col(ev["start"].pos_start(), low, span, width)
if ev["range"]:
c2 = max(c1, col(ev["end"].pos_end(), low, span, width))
if c2 == c1:
row[c1] = "#"
else:
for c in range(c1 + 1, c2):
row[c] = "="
row[c1], row[c2] = "[", "]"
else:
row[c1] = "*"
ascii_lines.append("".join(row) + " " + when_text(ev) + " " + ev["label"])
print(json.dumps({
"events": [{"n": k + 1, "input_index": ev["index"] + 1, "label": ev["label"], "when": when_text(ev),
"start": ev["start"].label(era, any_bce), "end": ev["end"].label(era, any_bce) if ev["range"] else None,
"astronomical_year": ev["start"].astro} for k, ev in enumerate(events)],
"markdown_list": "\n".join(lines_md) + "\n", "markdown_table": "\n".join(table) + "\n",
"ascii": "\n".join(ascii_lines) + "\n",
"duplicates": duplicates, "same_date": same_date, "overlaps": overlaps, "during": during,
"scale": {"from": first, "to": last_label, "width": width,
"years_per_column": str((Decimal(span.numerator) / Decimal(span.denominator * (width - 1))).quantize(
Decimal("0.001"), rounding=ROUND_HALF_UP))},
}, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error: # last resort: a usage error, never a traceback
fail(f"invalid input ({type(error).__name__})")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/timeline_builder/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/timeline_builder/1.0.0/timeline_builder.py