# Read a bundle

Reading a `.chit` takes four steps.

1. **Unzip it** and parse `manifest.json`. Check `format` is `chitmonk-bundle` and `version` is one you know (`1`).
2. **For each entry in `meetings`**, read the file at its `path` and compare its SHA-256 with the manifest's.
3. **Replay its `events`** in order, skipping any that an `EventUndone` names. [The rules](/chit/events/#replaying-a-log).
4. **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.

```python
"""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](/sample.chit):

```text
$ 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](/exports/) is the same content already laid out.
