add excalidraw normalizer script and test git hook

This commit is contained in:
Alain Brenzikofer
2026-09-29 09:55:59 +02:00
parent 5b67eaf618
commit a0578803ee
2 changed files with 77 additions and 7 deletions
@@ -16,11 +16,11 @@
{"id":"te231930574466","type":"text","x":148.70184295573705,"y":446.294092339979,"width":32.26,"height":11.05,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"Cancel","fontSize":9,"fontFamily":2,"index":"aP","strokeWidth":2,"lineHeight":1.25},
{"id":"te146535658481","type":"text","x":-17.678157044262946,"y":511.844092339979,"width":115.33,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"1a. From search","fontSize":13,"fontFamily":2,"index":"aW","strokeWidth":2,"lineHeight":1.25},
{"id":"te9023925185","type":"text","x":-17.678157044262946,"y":528.484092339979,"width":271.44,"height":68.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"Typing filters to the chats found by this name,\nand Connect to resolves it, both as today. A name\nyou have a chat for is resolved at most once a\nday; any other name on every tap, so an owner\ncan check a change.","fontSize":11,"fontFamily":2,"index":"aX","strokeWidth":2,"lineHeight":1.25},
{"id":"re137734344677","type":"rectangle","x":338.28393341265075,"y":302.0538844800932,"width":150.8570599529494,"height":107.10851256659409,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"index":"b03","strokeWidth":2,"boundElements":[{"id":"ar625193548486","type":"arrow"},{"id":"ar909442594990","type":"arrow"}]},
{"id":"te719661998578","type":"text","x":380.9107231737149,"y":315.34091014517986,"width":65.60348043082108,"height":11.942850579608494,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"Name expired","fontSize":9.670324355958295,"fontFamily":2,"index":"b04","strokeWidth":2,"lineHeight":1.25},
{"id":"te79947822704","type":"text","x":347.76085128148986,"y":334.08199874702706,"width":133.25706962510532,"height":18.857132494118677,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"bakery.simplex expired on 24 June\n2027. Its owner can renew it until 22","fontSize":7.736259484766636,"fontFamily":2,"index":"b05","strokeWidth":2,"lineHeight":1.25,"boundElements":[{"id":"ar909442594990","type":"arrow"}]},
{"id":"te73085632245","type":"text","x":381.73270074397135,"y":355.20198714043994,"width":63.96919561466413,"height":9.428566247059338,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"September 2027.","fontSize":7.736259484766636,"fontFamily":2,"index":"b07","strokeWidth":2,"lineHeight":1.25},
{"id":"te272497230298","type":"text","x":406.9529066643106,"y":384.46438864156977,"width":13.528783773985655,"height":10.057137330196626,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"OK","fontSize":7.736259484766636,"fontFamily":2,"index":"b08","strokeWidth":2,"lineHeight":1.25},
{"id":"re137734344677","type":"rectangle","x":316.13016684172646,"y":300.7900393691602,"width":175.26957589025088,"height":121.93603568633762,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"index":"b03","strokeWidth":2,"boundElements":[{"id":"ar625193548486","type":"arrow"},{"id":"ar909442594990","type":"arrow"}]},
{"id":"te719661998578","type":"text","x":368.18665936239535,"y":317.09831096098713,"width":74.68527140629419,"height":13.596154213852198,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"Name expired","fontSize":11.009031752107044,"fontFamily":2,"index":"b04","strokeWidth":2,"lineHeight":1.25},
{"id":"te79947822704","type":"text","x":330.4476985161724,"y":338.4338144965705,"width":151.70445754403508,"height":21.467611916608732,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"bakery.simplex expired on 24 June\n2027. Its owner can renew it until 22","fontSize":8.807225401685637,"fontFamily":2,"index":"b05","strokeWidth":2,"lineHeight":1.25,"boundElements":[{"id":"ar909442594990","type":"arrow"},{"id":"ar625193548486","type":"arrow"}]},
{"id":"te73085632245","type":"text","x":369.1224270613244,"y":362.4775398431723,"width":72.8247450401881,"height":10.733805958304366,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"September 2027.","fontSize":8.807225401685635,"fontFamily":2,"index":"b07","strokeWidth":2,"lineHeight":1.25},
{"id":"te272497230298","type":"text","x":397.8339818708196,"y":395.79086992504824,"width":15.401635421197755,"height":11.449393022191323,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"OK","fontSize":8.807225401685635,"fontFamily":2,"index":"b08","strokeWidth":2,"lineHeight":1.25},
{"id":"te309994649535","type":"text","x":306.2232070690634,"y":518.14,"width":236.12,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over"],"text":"1c. One alert over the found chats","fontSize":13,"fontFamily":2,"index":"b09","strokeWidth":2,"lineHeight":1.25},
{"id":"te1071904305641","type":"text","x":306.2232070690634,"y":534.78,"width":268.15,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over"],"text":"An actionable answer shows as one alert over the\nfiltered list. Dismissing it keeps the filter, so the\nchats found stay reachable.","fontSize":11,"fontFamily":2,"index":"b0A","strokeWidth":2,"lineHeight":1.25},
{"id":"re649142848260","type":"rectangle","x":-19.776792930936608,"y":690,"width":209.04,"height":223.6,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"index":"b0E","strokeWidth":2,"boundElements":[{"id":"ar507222825509","type":"arrow"},{"id":"ar11920695389","type":"arrow"}]},
@@ -215,11 +215,11 @@
{"id":"te166687152361","type":"text","x":780,"y":1645.16,"width":197.96,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"your address or channel claims it","fontSize":12,"fontFamily":2,"index":"b49","strokeWidth":2,"lineHeight":1.25},
{"id":"te921930439761","type":"text","x":60,"y":2112.98,"width":1923.76,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"Tags compare with today's app: today, changed, new. The two tinted bands split the answers by whether a chat for the name is on the device. Greyed: needs the wallet (simplex-chat#7475), which has not landed. Design document, not screenshots; alerts are drawn in the app's iOS idiom. A lookup returns the chats found on the device and, on Connect to or a\ntapped name, one resolution from the registry: always for a name with no local chat, and for a name with one only when its cached answer is older than a day or past its expiry; registration will always resolve. The API is in simplex-name-lookup-alert-diff.md. Registering, owning, recovering and giving names are separate flows.","fontSize":11,"fontFamily":2,"index":"b4A","strokeWidth":2,"lineHeight":1.25},
{"id":"te719096292165","type":"text","x":357.85,"y":362.4,"width":53.20068359375,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"Connect to","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar909442594990","index":"b4C","strokeWidth":2,"lineHeight":1.25},
{"id":"ar909442594990","type":"arrow","x":196.36184295573696,"y":364.00409233997897,"width":134.02710699216095,"height":0.3171881280713933,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te719096292165","type":"text"}],"points":[[0,0],[134.02710699216095,0.3171881280713933]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b4D","startBinding":{"elementId":"re698762997543","fixedPoint":[1.023918867202449,0.49964387464387444],"focus":0,"gap":0},"endBinding":{"elementId":"re137734344677","fixedPoint":[-0.05233419945486937,0.5813487135230525],"focus":0,"gap":0}},
{"id":"ar909442594990","type":"arrow","x":196.361842955737,"y":364.00409233997897,"width":114.66821606441519,"height":2.359878586740649,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te719096292165","type":"text"}],"points":[[0,0],[57.38416194299478,0],[57.38416194299478,-2.359878586740649],[114.66821606441519,-2.359878586740649]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b4D","startBinding":{"elementId":"re698762997543","fixedPoint":[1.023918867202449,0.49964387464387444],"focus":0,"gap":0},"endBinding":{"elementId":"re137734344677","fixedPoint":[-0.029098648728218802,0.499066367391314],"focus":0,"gap":1}},
{"id":"te597337641794","type":"text","x":300.05,"y":791,"width":56.87451171875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"chats found","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar507222825509","index":"b4E","strokeWidth":2,"lineHeight":1.25},
{"id":"ar507222825509","type":"arrow","x":199.2231269090634,"y":813.7999412,"width":123.3252558673932,"height":1.3543268028437296,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te597337641794","type":"text"}],"points":[[0,0],[56.00008015999998,0],[56.00008015999998,-1.3543268028437296],[123.3252558673932,-1.3543268028437296]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":[{"index":2,"start":[56.00008015999998,0],"end":[56.00008015999998,-1.3543268028437296]}],"endBinding":{"elementId":"re984646293193","focus":0,"gap":10,"fixedPoint":[-0.047621,0.499684]},"index":"b4F","startBinding":{"elementId":"re649142848260","focus":0,"gap":10,"fixedPoint":[1.047646,0.553667]}},
{"id":"te509749597660","type":"text","x":698.55,"y":362.4,"width":53.8076171875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"the answer","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar625193548486","index":"b4G","strokeWidth":2,"lineHeight":1.25},
{"id":"ar625193548486","type":"arrow","x":486.0179209065952,"y":352.93913124114573,"width":152.3484204462074,"height":0.4306126831448296,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te509749597660","type":"text"}],"points":[[0,0],[152.3484204462074,0.4306126831448296]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b4H","startBinding":{"elementId":"re137734344677","focus":0,"gap":5,"fixedPoint":[1.032051,0.499097]},"endBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[-26.187111437291986,0.07311428340218563],"focus":0,"gap":1}},
{"id":"ar625193548486","type":"arrow","x":498.6622717253598,"y":391.2664916007193,"width":137.97755227766993,"height":0.09643745591267816,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te509749597660","type":"text"}],"points":[[0,0],[137.97755227766993,0.09643745591267816]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b4H","startBinding":{"elementId":"re137734344677","fixedPoint":[1.0414363357501935,0.7419992926807656],"focus":0,"gap":0},"endBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[-26.187111437291986,0.07311428340218563],"focus":0,"gap":1}},
{"id":"te1001643326240","type":"text","x":306.75,"y":997.1,"width":83.76220703125,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"the same answer","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar11920695389","index":"b4I","strokeWidth":2,"lineHeight":1.25},
{"id":"ar11920695389","type":"arrow","x":199.2231269090634,"y":889.599894,"width":432.13147298422797,"height":147.83927408611544,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te1001643326240","type":"text"}],"points":[[0,0],[60.12854667662532,0],[60.12854667662532,147.83927408611544],[432.13147298422797,147.83927408611544]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":[{"index":2,"start":[60.12854667662532,0],"end":[60.12854667662532,147.83927408611544]}],"index":"b4J","startBinding":{"elementId":"re649142848260","focus":0,"gap":10,"fixedPoint":[1.047646,0.892665]},"endBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[-54.06464610022175,0.44492907483548705],"focus":0,"gap":0},"startIsSpecial":false,"endIsSpecial":false},
{"id":"te723091252074","type":"text","x":846.55,"y":296,"width":66.0322265625,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"new resource","fontSize":11,"fontFamily":2,"textAlign":"center","verticalAlign":"middle","containerId":"ar967582753031","index":"b4M","strokeWidth":2,"lineHeight":1.25},
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Shrink an .excalidraw file without changing what it renders.
Excalidraw writes every element with its full schema, pretty-printed, so a
354-element sketch costs 14k lines and ~90k tokens to read, and a one-field
edit shows up as a 27-line diff block. This drops the fields Excalidraw
re-derives on load and puts one element per line, so a changed element is
one changed line.
Kept on purpose: roughness, fillStyle, fontFamily and strokeColor, which
this drawing sets away from the Excalidraw default; strokeWidth, whose
default is 2, so dropping a 1 doubles the stroke; and lineHeight, which
Excalidraw recomputes from font metrics when it is absent.
Usage: normalize-excalidraw.py FILE... rewrites each file in place
Runs from .git/hooks/pre-commit on every staged .excalidraw file.
"""
import json
import sys
# Rewritten by Excalidraw on every save; carries no meaning for the drawing.
DROP = {"version", "versionNonce", "updated", "seed", "baseline"}
# Dropped only where the element already holds the default value.
DEFAULT = {
"isDeleted": False, "locked": False, "link": None, "frameId": None,
"angle": 0, "opacity": 100, "strokeStyle": "solid",
"groupIds": [], "boundElements": [], "roundness": None,
"backgroundColor": "transparent", "autoResize": True,
"textAlign": "left", "verticalAlign": "top", "containerId": None,
"startBinding": None, "endBinding": None, "startArrowhead": None,
"endArrowhead": None, "lastCommittedPoint": None,
"startIsSpecial": None, "endIsSpecial": None,
}
def lean(element):
return {k: v for k, v in element.items()
if k not in DROP
and not (k in DEFAULT and v == DEFAULT[k])
and not (k == "originalText" and v == element.get("text"))}
def dump(scene):
head = [f"{json.dumps(k)}: {json.dumps(v)}" for k, v in scene.items()
if k not in ("elements", "appState", "files")]
body = ",\n".join(" " + json.dumps(lean(e), separators=(",", ":"))
for e in scene["elements"])
tail = [f"{json.dumps(k)}: {json.dumps(scene[k])}"
for k in ("appState", "files") if k in scene]
return ("{\n " + ",\n ".join(head) + ',\n "elements": [\n' + body
+ "\n ],\n " + ",\n ".join(tail) + "\n}\n")
def main(paths):
for path in paths:
with open(path) as f:
before = f.read()
text = dump(json.loads(before))
with open(path, "w") as f:
f.write(text)
print(f"{path}: {len(before)} -> {len(text)} bytes, "
f"{before.count(chr(10))} -> {text.count(chr(10))} lines")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
main(sys.argv[1:])