#!/usr/bin/env python3 """ Reference reader for the five-year-diary/3 sealed format, specified in five-year-diary-3.md, published beside it. Python 3.8+ standard library only. AES-256 and GCM are implemented here in full so that this file proves the format is not a lock-in: anything that runs Python can read the diary, with nothing to install. It proves the format; it is not a tool for a large archive. Its cipher is pure Python and reads about 100 KiB a second, so 100 MB of photographs takes about 17 minutes. Usage: python3 five_year_diary_open.py prints every entry; the passphrase is read from the terminal, never from an argument python3 five_year_diary_open.py --vectors [golden-vectors.json] opens every vector in the corpus and checks its records, or the refusal it names A file that cannot be opened is refused as `: `. The codes are the format's own (the specification's *Refusals*), so every reader refuses a given file with the same code. To the extent possible under law, Lustig Fairchild Ltd. has waived all copyright and related or neighbouring rights to this reader, under CC0 1.0 (https://creativecommons.org/publicdomain/zero/1.0/). Published from Bulgaria. """ import base64 import binascii import getpass import hashlib import json import re import sys import unicodedata import zlib from datetime import date from pathlib import Path # ---------------------------------------------------------------- AES-256 --- _SBOX = bytes.fromhex( "637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0" "b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275" "09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf" "d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2" "cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb" "e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08" "ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e" "e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16") _RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36] def _xtime(a): a <<= 1 return (a ^ 0x1B) & 0xFF if a & 0x100 else a def _expand_key_256(key): w = [list(key[i:i + 4]) for i in range(0, 32, 4)] for i in range(8, 60): t = list(w[i - 1]) if i % 8 == 0: t = [_SBOX[b] for b in t[1:] + t[:1]] t[0] ^= _RCON[i // 8 - 1] elif i % 8 == 4: t = [_SBOX[b] for b in t] w.append([w[i - 8][j] ^ t[j] for j in range(4)]) return [bytes(b for c in range(4) for b in w[4 * r + c]) for r in range(15)] def _encrypt_block(rk, block): s = [block[i] ^ rk[0][i] for i in range(16)] for rnd in range(1, 15): s = [_SBOX[b] for b in s] # ShiftRows. The state is column-major: byte index = 4 * column + row. s = [s[4 * ((c + r) % 4) + r] for c in range(4) for r in range(4)] if rnd != 14: t = [0] * 16 for c in range(4): a = s[4 * c:4 * c + 4] t[4 * c + 0] = _xtime(a[0]) ^ (_xtime(a[1]) ^ a[1]) ^ a[2] ^ a[3] t[4 * c + 1] = a[0] ^ _xtime(a[1]) ^ (_xtime(a[2]) ^ a[2]) ^ a[3] t[4 * c + 2] = a[0] ^ a[1] ^ _xtime(a[2]) ^ (_xtime(a[3]) ^ a[3]) t[4 * c + 3] = (_xtime(a[0]) ^ a[0]) ^ a[1] ^ a[2] ^ _xtime(a[3]) s = t s = [s[i] ^ rk[rnd][i] for i in range(16)] return bytes(s) # -------------------------------------------------------------------- GCM --- def _ghash_mul(x, y): z, v = 0, y for i in range(127, -1, -1): if (x >> i) & 1: z ^= v v = (v >> 1) ^ (0xE1 << 120) if v & 1 else v >> 1 return z def _pad(data): return data + bytes(-len(data) % 16) class Gcm: """AES-256-GCM with a 12-byte nonce and a 16-byte tag, opened one message at a time.""" def __init__(self, key): self._rk = _expand_key_256(key) self._h = int.from_bytes(_encrypt_block(self._rk, bytes(16)), "big") def open(self, nonce, sealed, aad): """The plaintext, or None when the tag does not match.""" ciphertext, tag = sealed[:-16], sealed[-16:] j0 = nonce + b"\x00\x00\x00\x01" blocks = (_pad(aad) + _pad(ciphertext) + (8 * len(aad)).to_bytes(8, "big") + (8 * len(ciphertext)).to_bytes(8, "big")) s = 0 for i in range(0, len(blocks), 16): s = _ghash_mul(s ^ int.from_bytes(blocks[i:i + 16], "big"), self._h) expected = bytes(a ^ b for a, b in zip(s.to_bytes(16, "big"), _encrypt_block(self._rk, j0))) if not _same(expected, tag): return None out, counter = bytearray(), int.from_bytes(j0, "big") for i in range(0, len(ciphertext), 16): counter = (counter & ~0xFFFFFFFF) | ((counter + 1) & 0xFFFFFFFF) key_stream = _encrypt_block(self._rk, counter.to_bytes(16, "big")) out += bytes(a ^ b for a, b in zip(ciphertext[i:i + 16], key_stream)) return bytes(out) def _same(a, b): diff = len(a) ^ len(b) for x, y in zip(a, b): diff |= x ^ y return diff == 0 # ----------------------------------------------------------------- format --- FORMAT = "five-year-diary/3" VERSION = 3 HEADER_KEYS = ["format", "version", "kdf", "iterations", "salt", "cipher", "nonce", "segment", "compression", "created"] RECORD_KEYS = { "diary": ["kind", "id", "name", "created"], "entry": ["kind", "diary", "date", "text", "created", "updated", "photo", "asked", "question"], "photo": ["kind", "id", "mime", "bytes"], } OPTIONAL = {"photo", "asked", "question"} PHOTO_TYPES = ("image/jpeg", "image/png", "image/webp") MAX_ITERATIONS = 4_000_000 # a hostile header may not make a reader derive for ever SEGMENT_SIZES = (1024, 1 << 20) # plaintext bytes per segment; the app writes 65,536 TAG = 16 MAX_LINE = 16 << 20 # one record line, so memory is bounded by a line, not by the library MAX_BOOKS = 1000 MAX_ENTRIES = 36_600 # per book: a century of days INFLATION = (16, 64 << 20) # the payload may inflate to 16 times the sealed bytes read, plus 64 MiB MAX_DEPTH = 16 # no value more than 16 levels inside the header's object or a record's _ID = re.compile(r"[a-z0-9][a-z0-9-]{0,35}") _DEVICE_NAMES = {"con", "prn", "aux", "nul"} | {f"{n}{i}" for n in ("com", "lpt") for i in range(1, 10)} _DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})") _STAMP = re.compile(r"([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z") _BLANK = re.compile("[ \t\n\r]+") _LATER = re.compile(r"five-year-diary/([1-9][0-9]*)") class Refused(Exception): def __init__(self, code, reason): super().__init__(f"{code}: {reason}") self.code = code def canonical_json(members): """The one serialisation: members in the order given, no whitespace, UTF-8 with non-ASCII literal, and exactly the escapes json.dumps makes.""" return json.dumps(dict(members), separators=(",", ":"), ensure_ascii=False) def canonical_header(header): """The AAD, re-serialised rather than lifted from the file: the canonical keys in their order, then every other key in code-point order of its name — which is Python's string order, and not UTF-16's.""" extra = sorted(k for k in header if k not in HEADER_KEYS) return canonical_json([(k, header[k]) for k in HEADER_KEYS + extra]).encode("utf-8") def passphrase_bytes(passphrase): """NFKD; each run of space, tab, CR or LF becomes one space, with none at either end; UTF-8. Case is kept. A phrase that confirms at sealing is byte for byte the phrase that opens.""" return _BLANK.sub(" ", unicodedata.normalize("NFKD", passphrase)).strip(" ").encode("utf-8") def segment_nonce(base, index, final): """The header's nonce XOR (seven zero bytes, the index as four big-endian bytes, 1 on the last).""" mask = bytes(7) + index.to_bytes(4, "big") + (b"\x01" if final else b"\x00") return bytes(a ^ b for a, b in zip(base, mask)) # ------------------------------------------------------------------ reading --- def _strict(code): def pairs(members): seen = {} for key, value in members: if key in seen: raise Refused(code, f"the key {key!r} appears twice") seen[key] = value return seen return pairs def _no_constant(name): raise ValueError(f"{name} is not JSON") _DIGITS = 4000 # past this many digits an integer is only ever out of every bound def _whole(token): """A JSON integer of any length, as the format allows. Python 3.11 and later refuse to convert more than 4,300 digits, and the conversion grows with the square of the length; no bound in the format comes near 4,000 digits, so a longer integer is read as one just past them, on its own side of zero.""" digits = token.lstrip("-") if len(digits) <= _DIGITS: return int(token) return -(10**_DIGITS) if token.startswith("-") else 10**_DIGITS def read_file(text): """The container of a sealed file's text. A file cut short says so rather than looking malformed.""" try: container = json.loads( text, object_pairs_hook=_strict("container"), parse_constant=_no_constant, parse_int=_whole ) except json.JSONDecodeError as e: if e.pos >= len(text.rstrip()) or e.msg.startswith("Unterminated string"): raise Refused("incomplete", "the file ends before its container closes") from None raise Refused("container", f"not JSON: {e.msg} at character {e.pos}") from None except (ValueError, RecursionError) as e: raise Refused("container", f"not one JSON object: {e}") from None return container def _too_deep(value): """Whether any value sits more than MAX_DEPTH levels inside `value`, which is level 0: in {"a":[1]} the list is at level 1 and the 1 at level 2. Walked with a stack, so no depth can exhaust this reader.""" stack = [(value, 0)] while stack: v, level = stack.pop() if level > MAX_DEPTH: return True if isinstance(v, dict): stack.extend((x, level + 1) for x in v.values()) elif isinstance(v, list): stack.extend((x, level + 1) for x in v) return False def _later(value): """`five-year-diary/N` for a whole N above this version's: a newer file, told as one rather than as a stranger. Compared as digits, so no length of N can overflow a reader.""" found = _LATER.fullmatch(value) if isinstance(value, str) else None if not found: return False n, this = found.group(1), str(VERSION) return len(n) > len(this) or (len(n) == len(this) and n > this) def _integer(value): return isinstance(value, int) and not isinstance(value, bool) def _lone_surrogate(text): """JSON can escape half a surrogate pair; no Unicode text holds one.""" return any("\ud800" <= c <= "\udfff" for c in text) def _base64(text, what): """Strict and canonical: the standard alphabet, padding, nothing else, and zero bits under the padding — so exactly one text stands for any bytes.""" if isinstance(text, str): try: data = base64.b64decode(text, validate=True) if base64.b64encode(data).decode("ascii") == text: return data except (binascii.Error, ValueError): pass raise Refused("base64", f"{what} is not canonical standard base64") def _timestamp(value, what): found = _STAMP.fullmatch(value) if isinstance(value, str) else None if not (found and _real_date(found.group(1)) and int(found.group(2)) < 24 and int(found.group(3)) < 60 and int(found.group(4)) < 60): raise Refused("timestamp", f"{what} {value!r} is not a whole-second UTC timestamp") def _real_date(text): found = _DATE.fullmatch(text) if not found: return False try: date(*(int(part) for part in found.groups())) return True except ValueError: return False def _id(value, what): if not (_ID.fullmatch(value) and value not in _DEVICE_NAMES): raise Refused("id", f"{what} {value[:40]!r} is outside the id grammar") _QUESTION = re.compile(r"d(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])|r[0-9]{4}") def _question_id(value, what): """A deck question's id: d and a month and day of a leap year, or r and four digits from 0001.""" known = _QUESTION.fullmatch(value) and value != "r0000" if known and value[0] == "d": known = _real_date(f"2028-{value[1:3]}-{value[3:]}") if not known: raise Refused("id", f"{what} {value[:40]!r} is outside the question grammar") def _photograph(mime, data): if mime == "image/jpeg": return data[:3] == b"\xff\xd8\xff" if mime == "image/png": return data[:8] == b"\x89PNG\r\n\x1a\n" return data[:4] == b"RIFF" and data[8:12] == b"WEBP" def _check_header(container): if not (isinstance(container, dict) and list(container)[:1] == ["header"] and isinstance(container["header"], dict)): raise Refused("container", "not an object that opens with its header") h = container["header"] if _too_deep(h): raise Refused("container", f"the header nests deeper than {MAX_DEPTH} levels") if h.get("format") != FORMAT: if _later(h.get("format")): raise Refused("version", f"sealed by a later version: {h['format']!r}") raise Refused("format", f"not a {FORMAT} file: its format is {h.get('format')!r}") segments = container.get("segments") if (list(container) != ["header", "segments"] or not isinstance(segments, list) or not segments or not all(isinstance(s, str) for s in segments)): raise Refused("container", "a container holds its header, then a non-empty list of segments, and nothing else") for key in HEADER_KEYS: if key not in h: raise Refused("header", f"the header has no {key}") for key in ("version", "iterations", "segment"): if not _integer(h[key]): raise Refused("integer", f"{key} is not an integer: {h[key]!r}") if h["version"] != VERSION: raise Refused("version", f"version {h['version']} is not one this reader knows") for key, known in (("kdf", "PBKDF2-HMAC-SHA256"), ("cipher", "AES-256-GCM"), ("compression", "zlib none")): if not (isinstance(h[key], str) and h[key] in known.split()): raise Refused("algorithm", f"{key} {h[key]!r} is not supported") if not 1 <= h["iterations"] <= MAX_ITERATIONS: raise Refused("iterations", f"iteration count out of range: {h['iterations']}") if not SEGMENT_SIZES[0] <= h["segment"] <= SEGMENT_SIZES[1]: raise Refused("segment-size", f"segment size out of range: {h['segment']}") if len(_base64(h["salt"], "the salt")) != 16 or len(_base64(h["nonce"], "the nonce")) != 12: raise Refused("header", "the salt is 16 bytes and the nonce 12") _timestamp(h["created"], "the header's created") for key, value in h.items(): if key in HEADER_KEYS: continue scalar = (isinstance(value, bool) or (isinstance(value, str) and not _lone_surrogate(value)) or (_integer(value) and -(1 << 63) <= value < 1 << 63)) if _lone_surrogate(key) or not scalar: raise Refused("header", f"the header's {key!r} is not a string, a 64-bit integer or a boolean") return h, segments class _Tally: """Sealed bytes read and payload bytes inflated from them, for the inflation bound.""" def __init__(self): self.sealed = self.inflated = 0 def grew(self, size): self.inflated += size if self.inflated > INFLATION[0] * self.sealed + INFLATION[1]: raise Refused("bounds", f"the payload inflates past {INFLATION[0]} times its sealed size and 64 MiB") def _plaintexts(segments, key, header, tally): """Each segment in turn: its text, its length, then its tag.""" gcm, base, size = Gcm(key), base64.b64decode(header["nonce"]), header["segment"] aad, last = canonical_header(header), len(segments) - 1 for index, text in enumerate(segments): sealed, final = _base64(text, f"segment {index}"), index == last held = len(sealed) - TAG if held < 0 or (held != size if not final else not (1 if index else 0) <= held <= size): raise Refused("segment-length", f"segment {index} holds {max(held, 0)} bytes; " f"{'one to ' if final else 'exactly '}{size} belong there") plain = gcm.open(segment_nonce(base, index, final), sealed, aad) if plain is None: if index: raise Refused("altered", f"the passphrase is right, but segment {index} has been altered, " "or the file cut short or extended") if gcm.open(segment_nonce(base, 0, not final), sealed, aad) is not None: raise Refused("altered", "the passphrase is right, but the file has been cut short or extended " "after its first segment") raise Refused("passphrase", "wrong passphrase, or the file has been altered") tally.sealed += len(sealed) yield plain def _inflated(chunks, tally): """zlib (RFC 1950), its checksum included, undone in bounded steps.""" stream = zlib.decompressobj(zlib.MAX_WBITS) for chunk in chunks: if stream.eof and chunk: raise Refused("compression", "data follows the end of the zlib stream") for out in _drain(stream, chunk): tally.grew(len(out)) yield out if stream.unused_data: raise Refused("compression", "data follows the end of the zlib stream") yield from _drain(stream, b"") if not stream.eof: raise Refused("compression", "the zlib stream is unfinished when the last segment ends") def _drain(stream, data, step=1 << 20): while True: try: out = stream.decompress(data, step) except zlib.error: raise Refused("compression", "the payload is not a zlib (RFC 1950) stream this format allows") from None if out: yield out data = stream.unconsumed_tail if not data and len(out) < step: return def _lines(chunks): """The payload is zero or more records, each followed by LF.""" pending = b"" for chunk in chunks: *complete, pending = (pending + chunk).split(b"\n") yield from complete if len(pending) > MAX_LINE: raise Refused("bounds", "a payload line is longer than 16 MiB") if pending: raise Refused("record", "the payload does not end with LF") class _Payload: """The record rules, applied line by line as the payload streams past, in the order the specification lists.""" RANK = {"diary": 0, "entry": 1, "photo": 2} IDS = {"diary": ("id",), "entry": ("diary", "photo"), "photo": ("id",)} STAMPS = {"diary": ("created",), "entry": ("created", "updated"), "photo": ()} def __init__(self): self.books, self.referenced, self.last = {}, set(), None def read(self, raw, number): if len(raw) > MAX_LINE: raise Refused("bounds", f"line {number} is longer than 16 MiB") try: line = raw.decode("utf-8") except UnicodeDecodeError: raise Refused("utf8", f"line {number} is not UTF-8") from None try: record = json.loads( line, object_pairs_hook=_strict("record"), parse_constant=_no_constant, parse_int=_whole ) except (ValueError, RecursionError): record = None if not isinstance(record, dict): raise Refused("record", f"line {number} is not a JSON object") if _too_deep(record): raise Refused("record", f"line {number} nests deeper than {MAX_DEPTH} levels") if "kind" not in record: raise Refused("missing-key", f"line {number} has no kind") kind = record["kind"] if not isinstance(kind, str): raise Refused("record", f"line {number}: its kind is not a string") if kind not in RECORD_KEYS: raise Refused("kind", f"line {number} is a record of an unknown kind: {kind!r}") keys = [k for k in RECORD_KEYS[kind] if k in record] for key in RECORD_KEYS[kind]: if key not in record and key not in OPTIONAL: raise Refused("missing-key", f"line {number}, a {kind}, has no {key}") for key in keys: if not isinstance(record[key], str) or _lone_surrogate(record[key]): raise Refused("record", f"line {number}: its {key} is not a string of Unicode text") if kind == "entry" and "asked" in record and "question" in record: raise Refused("record", f"line {number}: an entry keeps one question, not two") for key in self.IDS[kind]: if key in record: _id(record[key], f"line {number}: the id") if kind == "entry" and "asked" in record: _question_id(record["asked"], f"line {number}: the question") if kind == "entry" and not _real_date(record["date"]): raise Refused("date", f"line {number}: {record['date']!r} is not a date that exists") for key in self.STAMPS[kind]: _timestamp(record[key], f"line {number}: {key}") if kind == "entry" and not record["text"]: raise Refused("empty", f"line {number}: an entry with no text") if kind == "entry" and "question" in record and not record["question"].strip(" \t\n\r"): raise Refused("empty", f"line {number}: a question with no words") if kind == "diary" and not record["name"].strip(" \t\n\r"): raise Refused("empty", f"line {number}: a book with no name") if kind == "photo": if record["mime"] not in PHOTO_TYPES: raise Refused("photo-type", f"line {number}: {record['mime']!r} is not {', '.join(PHOTO_TYPES)}") data = _base64(record["bytes"], f"the photograph on line {number}") if not data: raise Refused("empty", f"line {number}: a photograph with no bytes") if not _photograph(record["mime"], data): raise Refused("photo-type", f"line {number}: the bytes are not {record['mime']}") self._place(kind, record, number) return record def _place(self, kind, r, number): place = (self.RANK[kind],) + ((r["diary"], r["date"]) if kind == "entry" else (r["id"],)) if self.last is not None and place <= self.last: if place == self.last: code = "duplicate-date" if kind == "entry" else "duplicate-id" raise Refused(code, f"line {number} repeats {' '.join(place[1:])}") raise Refused("order", f"line {number} is out of order: books by id, then entries by book and " "date, then photographs by id") self.last = place if kind == "diary": self.books[r["id"]] = 0 if len(self.books) > MAX_BOOKS: raise Refused("bounds", f"more than {MAX_BOOKS} books") elif kind == "entry": if r["diary"] not in self.books: raise Refused("no-diary", f"line {number} belongs to book {r['diary']!r}, which has no diary record") self.books[r["diary"]] += 1 if self.books[r["diary"]] > MAX_ENTRIES: raise Refused("bounds", f"book {r['diary']!r} holds more than {MAX_ENTRIES} entries") if "photo" in r: self.referenced.add(r["photo"]) else: self.referenced.discard(r["id"]) def finish(self): if self.referenced: raise Refused("no-photo", f"no photo record for {', '.join(sorted(self.referenced))}") def open_archive(container, passphrase): """Every record of a sealed file, or Refused. Nothing counts until the last segment has opened: a reader that stores as it goes keeps what it stored provisional until then.""" header, segments = _check_header(container) key = hashlib.pbkdf2_hmac("sha256", passphrase_bytes(passphrase), base64.b64decode(header["salt"]), header["iterations"], dklen=32) tally = _Tally() plain = _plaintexts(segments, key, header, tally) payload = _Payload() stream = _inflated(plain, tally) if header["compression"] == "zlib" else plain records = [payload.read(raw, number) for number, raw in enumerate(_lines(stream), start=1)] payload.finish() return records # --------------------------------------------------------------------- main --- def run_vectors(path): corpus = json.loads(Path(path).read_text(encoding="utf-8")) if corpus.get("format") != FORMAT: print(f"{path} is not a {FORMAT} corpus") return 1 failed = 0 for vector in corpus["vectors"]: name, phrases = vector["name"], vector.get("passphrases", [vector.get("passphrase", corpus["passphrase"])]) wrong = None for phrase in phrases: try: container = vector["container"] if "container" in vector else read_file(vector["file"]) records = open_archive(container, phrase) if vector.get("must_fail"): wrong = f"opened; it must be refused as {vector['refusal']}" elif [list(r.items()) for r in records] != [list(r.items()) for r in vector["expected_records"]]: wrong = f"opened with {len(records)} records that differ from the expected ones" except Refused as refused: if not vector.get("must_fail"): wrong = f"refused — {refused}" elif refused.code != vector["refusal"]: wrong = f"refused as {refused.code}, not {vector['refusal']} — {refused}" if wrong: break failed += bool(wrong) print(f"{'FAIL' if wrong else 'PASS'} {name}" + (f" — {wrong}" if wrong else "")) total = len(corpus["vectors"]) print(f"\n{total - failed} of {total} vectors as the corpus says.") return 1 if failed else 0 _UNSAFE = re.compile("[\x00-\x09\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]") def _shown(text, one_line=False): """Text for a terminal. Control characters and bidirectional overrides are shown as escapes rather than obeyed: a sealed file may have been written to attack whoever reads it.""" text = _UNSAFE.sub(lambda m: f"\\u{ord(m.group()):04x}", text) return text.replace("\n", "\\n") if one_line else text.replace("\n", "\n" + " " * 37) def main(argv): if len(argv) >= 2 and argv[1] == "--vectors" and len(argv) <= 3: return run_vectors(argv[2] if len(argv) == 3 else Path(__file__).with_name("golden-vectors.json")) if len(argv) != 2 or argv[1].startswith("-"): print(__doc__.strip()) return 2 try: text = Path(argv[1]).read_text(encoding="utf-8") except UnicodeDecodeError: print("refused — container: the file is not UTF-8 text", file=sys.stderr) return 1 try: records = open_archive(read_file(text), getpass.getpass("Passphrase: ")) except Refused as refused: print(f"refused — {refused}", file=sys.stderr) return 1 names = {r["id"]: _shown(r["name"], one_line=True) for r in records if r["kind"] == "diary"} entries = [r for r in records if r["kind"] == "entry"] for e in entries: mark = " [photograph]" if "photo" in e else "" # A deck question is kept by id — its words are the app's — and the person's own by its words. asked = f" [asked {e['asked']}]" if "asked" in e else "" if "question" in e: asked = f" [asked: {_shown(e['question'], one_line=True)}]" print(f"{names[e['diary']][:24]:<24} {e['date']} {_shown(e['text'])}{mark}{asked}") photos = sum(r["kind"] == "photo" for r in records) print(f"\n{len(entries)} entries in {len(names)} books, {photos} photographs.", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))