Read a bundle
Reading a .chit takes four steps.
- Unzip it and parse
manifest.json. Checkformatischitmonk-bundleandversionis one you know (1). - For each entry in
meetings, read the file at itspathand compare its SHA-256 with the manifest's. - Replay its
eventsin order, skipping any that anEventUndonenames. The rules. - Label speakers honestly. A confirmed name is a name. A guess is a guess.
How Chitmonk labels a line
Every export Chitmonk writes uses the same four labels, and a reader should too:
| The line | Label |
|---|---|
| A person confirmed who spoke | Maya |
| A person marked it as nobody on the list | Unassigned |
| Not confirmed, but the app has a guess | Maya? |
| Not confirmed, no guess | Unknown speaker |
A reader in Python
No packages to install. project() is the whole of the replay rules.
"""Read a Chitmonk .chit bundle: who said what, what is owed, what was decided.
Standard library only. python3 read_chit.py meetings.chit
"""
import hashlib
import json
import sys
import zipfile
def project(events):
"""Replay one meeting's event log into the meeting as it stands now."""
undone = {e["targetId"] for e in events if e["type"] == "EventUndone"}
m = {"title": "", "people": {}, "order": [], "lines": [], "actions": [], "decisions": [], "notes": []}
for e in events:
if e["id"] in undone:
continue
t = e["type"]
items = m["actions"] if t.startswith("Action") else m["decisions"]
if t in ("MeetingCreated", "MeetingRenamed"):
m["title"] = e["title"]
elif t == "ParticipantAdded":
m["people"][e["participantId"]] = {"name": e["name"], "pronouns": e.get("pronouns"), "removed": False}
m["order"].append(e["participantId"])
elif t == "ParticipantUpdated":
p = m["people"][e["participantId"]]
p["name"] = e.get("name") or p["name"]
if "pronouns" in e:
p["pronouns"] = e["pronouns"]
elif t == "ParticipantReordered":
rank = {pid: i for i, pid in enumerate(e["order"])}
m["order"].sort(key=lambda pid: rank.get(pid, 10**9))
elif t == "ParticipantRemoved":
m["people"][e["participantId"]]["removed"] = True
elif t == "SegmentCommitted":
who = e.get("speakerId")
m["lines"].append({
"id": e["lineId"], "startMs": e["startMs"], "text": e["text"],
"speakerId": who, "confirmed": who is not None,
"suggestedSpeakerId": None if who is not None else e.get("suggestedSpeakerId"),
})
elif t == "SpeakerAssigned":
ids = set(e["lineIds"])
for line in m["lines"]:
if line["id"] in ids:
line.update(speakerId=e["participantId"], confirmed=True, suggestedSpeakerId=None)
for item in m["actions"] + m["decisions"]:
if item["ownerFromSpeaker"] and item["lineId"] in ids:
item["ownerId"] = e["participantId"]
elif t == "TranscriptLineEdited":
for line in m["lines"]:
if line["id"] == e["lineId"]:
line["text"] = e["text"]
elif t == "TranscriptLineDeleted":
m["lines"] = [line for line in m["lines"] if line["id"] != e["lineId"]]
elif t in ("ActionSuggested", "DecisionSuggested"):
items.append({
"id": e["itemId"], "lineId": e.get("lineId"), "text": e["text"], "ownerId": e.get("ownerId"),
"ownerFromSpeaker": bool(e.get("ownerFromSpeaker")), "dueText": e.get("dueText"), "dueDate": e.get("dueDate"), "accepted": False,
})
elif t in ("ActionEdited", "DecisionEdited"):
for item in items:
if item["id"] == e["itemId"]:
if e.get("text") is not None:
item["text"] = e["text"]
if "ownerId" in e: # a person chose the owner, so it stops following the speaker
item["ownerId"], item["ownerFromSpeaker"] = e["ownerId"], False
for key in ("dueText", "dueDate"):
if key in e:
item[key] = e[key]
elif t in ("ActionAccepted", "DecisionAccepted"):
for item in items:
if item["id"] == e["itemId"]:
item["accepted"] = True
elif t in ("ActionDeleted", "DecisionDeleted"):
items[:] = [item for item in items if item["id"] != e["itemId"]]
elif t == "NoteAdded":
m["notes"].append({"id": e["noteId"], "lineId": e.get("lineId"), "text": e["text"]})
elif t == "NoteEdited":
for note in m["notes"]:
if note["id"] == e["noteId"]:
note["text"] = e["text"]
elif t == "NoteDeleted":
m["notes"] = [note for note in m["notes"] if note["id"] != e["noteId"]]
return m
def clock(ms):
s = int(ms / 1000 + 0.5)
return f"{s // 3600}:{s % 3600 // 60:02}:{s % 60:02}" if s >= 3600 else f"{s // 60}:{s % 60:02}"
def name(m, pid):
return m["people"][pid]["name"] if pid in m["people"] else None
def speaker(m, line):
"""A confirmed name, or a guess marked with "?". A guess is never shown as a fact."""
if line["confirmed"]:
return name(m, line["speakerId"]) or "Unassigned"
guess = name(m, line["suggestedSpeakerId"])
return f"{guess}?" if guess else "Unknown speaker"
def item_line(m, item):
due = item["dueDate"] or item["dueText"]
about = [name(m, item["ownerId"]), f"due {due}" if due else None, None if item["accepted"] else "suggested"]
about = [a for a in about if a]
return f"{item['text']} ({', '.join(about)})" if about else item["text"]
def read(path):
"""Every meeting in the bundle, checked against the manifest's SHA-256 and replayed."""
with zipfile.ZipFile(path) as z:
manifest = json.loads(z.read("manifest.json"))
if manifest.get("format") != "chitmonk-bundle" or manifest.get("version", 0) > 1:
raise ValueError("not a .chit bundle this script can read")
for entry in manifest["meetings"]:
data = z.read(entry["path"])
if hashlib.sha256(data).hexdigest() != entry["sha256"]:
raise ValueError(f"{entry['path']} does not match its checksum")
yield project(json.loads(data)["events"])
if __name__ == "__main__":
for m in read(sys.argv[1]):
print(m["title"])
people = [m["people"][pid] for pid in m["order"] if not m["people"][pid]["removed"]]
print("People: " + ", ".join(f"{p['name']} ({p['pronouns']})" if p["pronouns"] else p["name"] for p in people))
print()
for line in m["lines"]:
print(f"[{clock(line['startMs'])}] {speaker(m, line)}: {line['text']}")
for title, items in (("Action items", m["actions"]), ("Decisions", m["decisions"])):
if items:
print(f"\n{title}")
for item in items:
print(f"- {item_line(m, item)}")
if m["notes"]:
starts = {line["id"]: line["startMs"] for line in m["lines"]}
print("\nNotes")
for note in m["notes"]:
at = f"[{clock(starts[note['lineId']])}] " if note["lineId"] in starts else ""
print(f"- {at}{note['text']}")
Run it on the sample bundle:
$ python3 read_chit.py sample.chit
Launch review
People: Maya, Leo, Sam (they/them)
[0:01] Maya: Okay, let us get started. Thanks everyone for joining the launch review.
[0:07] Leo: Sounds good. The checkout tests passed last night, so the build is ready.
[0:13] Maya: Great. I will send the revised deck by Friday.
[0:17] Sam: We decided to launch on Tuesday.
[0:21] Leo?: Sam, can you update the release notes before the launch?
[0:26] Unknown speaker: Yes, that works for me.
Action items
- I will send the revised deck by Friday. (Maya, due by Friday)
- Sam, can you update the release notes before the launch? (Sam, suggested)
Decisions
- We decided to launch on Tuesday. (suggested)
Notes
- [0:13] Ask Leo for the old deck
Line three shows the edit (the friday first heard is gone), line four shows the undo (it is Sam's, not Leo's), and line five is still a guess because nobody confirmed it.
If you only need to read it
A .chit is for tools. If a person or a model just needs to read the meeting, the Markdown or plain text export is the same content already laid out.