chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 0 additions and 0 deletions

View file

@ -1,170 +0,0 @@
#!/usr/bin/env python3
"""Builds the Anki packages the app's import tests read, with Anki itself.
The `anki` Python package is Anki's own backend, so the packages are exactly
what Anki exports: one collection, exported in the latest format, in the
legacy format ("Support older Anki versions"), and repacked the way Anki
before 2.1.50 wrote packages (only `collection.anki2`, no `meta`).
The collection holds, on purpose:
- a Basic note with bold text, a line break, an image, and two tags, one of
them hierarchical (deck "Japanese");
- a "Basic (and reversed card)" note with a sound, both its cards answered,
one of them first wrong (deck "Japanese::Animals");
- a Cloze note with two deletions, never reviewed;
- a Basic note with a list and HTML entities ("Japanese::Animals");
- a manual reschedule in the review log (no answer).
A second deck (`anki_furigana*.apkg`) has a note type that shows its
Reading through Anki's furigana filter, and the same text in its Expression.
Run from the repository root, in a virtual environment with Anki's package:
python3 -m venv /tmp/anki && /tmp/anki/bin/pip install anki==26.9.3
/tmp/anki/bin/python tool/anki/make_fixtures.py
It rewrites halkyon_learn_flutter/test/feature/anki/fixtures/*.apkg. Ids and
times differ on every run; the tests don't depend on them.
"""
import io
import os
import tempfile
import zipfile
from pathlib import Path
from anki.collection import Collection, ExportAnkiPackageOptions
from anki.decks import DeckId
OUT = Path("halkyon_learn_flutter/test/feature/anki/fixtures")
# Valid files, so the packages also serve to try the import by hand: a 1x1
# PNG (one orange pixel), and 0.26 s of silence as an MP3 (ten MPEG-1 Layer
# III frames, 128 kbit/s, 44.1 kHz, mono, whose zeroed side information
# decodes to silence).
PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d49444154789c63f8dfc0f01f000680027f104c1be10000000049454e44ae426082"
)
MP3 = (bytes.fromhex("fffb90c4") + bytes(413)) * 10
AGAIN, GOOD, EASY = 0, 2, 3
def main():
OUT.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as work:
col = Collection(os.path.join(work, "collection.anki2"))
col.media.write_data("cat.png", PNG)
col.media.write_data("neko.mp3", MP3)
japanese = col.decks.id("Japanese")
animals = col.decks.id("Japanese::Animals")
def add(model, deck, fields, tags):
note = col.new_note(col.models.by_name(model))
for i, field in enumerate(fields):
note.fields[i] = field
note.tags = tags
col.add_note(note, DeckId(deck))
return note
def answer(card_id, rating):
card = col.get_card(card_id)
card.start_timer()
states = col._backend.get_scheduling_states(card.id)
col.sched.answer_card(
col.sched.build_answer(card=card, states=states, rating=rating)
)
cat = add(
"Basic",
japanese,
["<b>猫</b>", 'cat<br>a pet<img src="cat.png">'],
["animal", "JLPT::N5"],
)
dog = add(
"Basic (and reversed card)",
animals,
["犬 [sound:neko.mp3]", "dog"],
["animal"],
)
add("Cloze", japanese, ["{{c1::Tokyo}} is the capital of {{c2::Japan::country}}", "extra"], [])
add(
"Basic",
animals,
["<ul><li>one</li><li>two</li></ul>", "&lt;tag&gt; &amp; more"],
[],
)
answer(cat.card_ids()[0], EASY)
forward, reverse = sorted(dog.card_ids(), key=lambda c: col.get_card(c).ord)
answer(forward, AGAIN)
answer(forward, GOOD)
answer(reverse, EASY)
col.sched.set_due_date([cat.card_ids()[0]], "5")
for legacy, name in [(False, "anki_latest.apkg"), (True, "anki_legacy.apkg")]:
col.export_anki_package(
out_path=str(OUT / name),
options=ExportAnkiPackageOptions(
with_scheduling=True,
with_deck_configs=False,
with_media=True,
legacy=legacy,
),
limit=None,
)
col.close()
# Before 2.1.50: the collection as collection.anki2, no meta, no stub.
with zipfile.ZipFile(OUT / "anki_legacy.apkg") as legacy:
out = io.BytesIO()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as old:
for info in legacy.infolist():
if info.filename in ("meta", "collection.anki2"):
continue
name = "collection.anki2" if info.filename == "collection.anki21" else info.filename
old.writestr(name, legacy.read(info.filename))
(OUT / "anki_before_2_1_50.apkg").write_bytes(out.getvalue())
def furigana():
"""A deck whose note type shows a field through Anki's furigana filter,
in the latest format and the legacy one: the import turns that field's
漢字[かんじ] into readings, and leaves the same text in another field."""
with tempfile.TemporaryDirectory() as work:
col = Collection(os.path.join(work, "collection.anki2"))
models = col.models
model = models.new("Japanese (furigana)")
for name in ("Expression", "Reading", "Meaning"):
models.add_field(model, models.new_field(name))
template = models.new_template("Recognition")
template["qfmt"] = "{{Expression}}"
template["afmt"] = "{{FrontSide}}<hr id=answer>{{furigana:Reading}}<br>{{Meaning}}"
models.add_template(model, template)
models.add(model)
note = col.new_note(col.models.by_name("Japanese (furigana)"))
note.fields[0] = "日本語[にほんご]"
note.fields[1] = "日本語[にほんご]を 勉強[べんきょう]する"
note.fields[2] = "to study Japanese"
col.add_note(note, DeckId(col.decks.id("Furigana")))
for legacy, name in [
(False, "anki_furigana.apkg"),
(True, "anki_furigana_legacy.apkg"),
]:
col.export_anki_package(
out_path=str(OUT / name),
options=ExportAnkiPackageOptions(
with_scheduling=False,
with_deck_configs=False,
with_media=False,
legacy=legacy,
),
limit=None,
)
col.close()
if __name__ == "__main__":
main()
furigana()

View file

@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""Makes the app's icons for every platform from its own, the kingfisher on white
(halkyon_learn_flutter/assets/icon/halkyon_icon.png).
- The snap's, snap/gui/halkyon-learn.png (512 x 512 pixels, the Snap Store's size),
which the Store and the desktop file show as they are: the picture with rounded
corners, as other apps' white tiles have (radius 142.222 of 512, about 28 %).
- Through flutter_launcher_icons, configured in the app's pubspec: Android's adaptive
icon and its older square one, iOS's, macOS's (the rounded tile on Apple's grid,
824 of 1024 pixels) and Windows' (the rounded tile).
- Windows' .ico once more, with every size Windows shows (16 to 256 pixels): the tool
writes only the 256 one and leaves Windows to scale it down for its small views.
- The iOS project file as it was: the tool sets every build setting with ASSETCATALOG
in its name to the icon set's name, Xcode's yes-or-no
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS too, which breaks the
project (flutter_launcher_icons issue 634); the project names its icon set already.
The derived images it hands to the tool go to halkyon_learn_flutter/build/icons/,
which git ignores. The rounded corners' mask is drawn at four times the size and
scaled down, so their edges are smooth.
Run from the repository root with Python 3 and Pillow (python3-pil on Ubuntu), the
app's dependencies fetched:
python3 tool/app_icons.py
"""
import subprocess
from pathlib import Path
from PIL import Image, ImageDraw
APP = Path("halkyon_learn_flutter")
SOURCE = APP / "assets/icon/halkyon_icon.png"
DERIVED = APP / "build/icons"
SNAP_ICON = Path("snap/gui/halkyon-learn.png")
WINDOWS_ICON = APP / "windows/runner/resources/app_icon.ico"
IOS_PROJECT = APP / "ios/Runner.xcodeproj/project.pbxproj"
RADIUS = 142.222 / 512
SUPERSAMPLING = 4
# Apple's grid: the tile is 824 of the icon's 1024 pixels, centred.
MACOS_CANVAS = 1024
MACOS_TILE = 824
WINDOWS_SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256]
def rounded(size: int) -> Image.Image:
"""The icon at [size] pixels, its corners rounded off to transparency."""
icon = Image.open(SOURCE).convert("RGBA").resize((size, size), Image.LANCZOS)
large = size * SUPERSAMPLING
mask = Image.new("L", (large, large), 0)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, large - 1, large - 1), radius=RADIUS * large, fill=255
)
mask = mask.resize((size, size), Image.LANCZOS)
# Keeps what the icon itself makes transparent, and takes the corners away.
transparent = Image.new("L", (size, size), 0)
icon.putalpha(Image.composite(icon.getchannel("A"), transparent, mask))
return icon
def main() -> None:
rounded(512).save(SNAP_ICON, optimize=True)
print(f"Wrote {SNAP_ICON}.")
DERIVED.mkdir(parents=True, exist_ok=True)
rounded(1024).save(DERIVED / "rounded.png")
macos = Image.new("RGBA", (MACOS_CANVAS, MACOS_CANVAS), (0, 0, 0, 0))
margin = (MACOS_CANVAS - MACOS_TILE) // 2
macos.alpha_composite(rounded(MACOS_TILE), (margin, margin))
macos.save(DERIVED / "macos.png")
ios_project = IOS_PROJECT.read_bytes()
subprocess.run(["dart", "run", "flutter_launcher_icons"], cwd=APP, check=True)
IOS_PROJECT.write_bytes(ios_project)
rounded(256).save(
WINDOWS_ICON, format="ICO", sizes=[(size, size) for size in WINDOWS_SIZES]
)
print(f"Wrote {WINDOWS_ICON} with {len(WINDOWS_SIZES)} sizes.")
if __name__ == "__main__":
main()

View file

@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Builds the app's map of Japan's prefectures from Natural Earth.
Natural Earth's vector data is in the public domain. This script downloads
its Admin 1 (states, provinces) layer at 1:10m, release v5.1.2, checks the
download against the hash recorded below, keeps Japan's 47 prefectures, and
reduces them to a compact outline for display (outlines.py): Douglas-Peucker
simplification (0.006 degrees), islands too small to see dropped (each
prefecture keeps its largest), coordinates rounded to 4 decimals. The result
is an approximation, not survey-accurate data.
Each region is named by its ISO 3166-2 code (JP-01 ... JP-47), with Natural
Earth's English and Japanese names.
Run from the repository root with Python 3 and no other packages:
python3 tool/maps/japan_prefectures.py
It rewrites halkyon_learn_flutter/assets/maps/japan_prefectures.geojson.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from outlines import download, polygons_of, reduce # noqa: E402
SOURCE = (
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/"
"v5.1.2/geojson/ne_10m_admin_1_states_provinces.geojson"
)
SOURCE_SHA256 = "22d0e3ad85eb3e27f17cabf8ba2d50e554fbc27a87796ff891d958185da62fb5"
TARGET = Path("halkyon_learn_flutter/assets/maps/japan_prefectures.geojson")
TOLERANCE = 0.006 # degrees
MIN_AREA = 0.0005 # square degrees; smaller islands are dropped
DECIMALS = 4
def main():
source = json.loads(download(SOURCE, SOURCE_SHA256))
features = []
for feature in source["features"]:
properties = feature["properties"]
if properties.get("adm0_a3") != "JPN":
continue
features.append({
"type": "Feature",
"properties": {
"code": properties["iso_3166_2"],
"name": properties["name"],
"name_ja": properties["name_ja"],
},
"geometry": reduce(polygons_of(feature["geometry"]), TOLERANCE,
MIN_AREA, DECIMALS),
})
features.sort(key=lambda feature: feature["properties"]["code"])
codes = [feature["properties"]["code"] for feature in features]
if codes != [f"JP-{n:02d}" for n in range(1, 48)]:
sys.exit(f"Expected the 47 prefectures JP-01 ... JP-47, got {codes}")
TARGET.write_text(json.dumps(
{"type": "FeatureCollection", "features": features},
ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(f"Wrote {TARGET} ({TARGET.stat().st_size} bytes)", file=sys.stderr)
if __name__ == "__main__":
main()

View file

@ -1,102 +0,0 @@
"""Outlines reduced for display, shared by the map scripts beside this file.
Douglas-Peucker simplification, islands too small to see dropped (a region
always keeps its largest), coordinates rounded. The result is an
approximation, not survey-accurate data.
"""
import hashlib
import math
import sys
import urllib.request
def download(url, sha256):
"""The bytes at url, checked against their recorded SHA-256."""
print(f"Downloading {url}", file=sys.stderr)
data = urllib.request.urlopen(url).read()
digest = hashlib.sha256(data).hexdigest()
if digest != sha256:
sys.exit(f"The SHA-256 of {url} is {digest}, not {sha256}.")
return data
def simplify(points, tolerance):
"""Douglas-Peucker on an open polyline of (x, y) pairs."""
if len(points) < 3:
return points
first, last = points[0], points[-1]
dx, dy = last[0] - first[0], last[1] - first[1]
length = math.hypot(dx, dy)
farthest, index = -1.0, 0
for i in range(1, len(points) - 1):
px, py = points[i]
if length == 0:
distance = math.hypot(px - first[0], py - first[1])
else:
distance = abs(dy * px - dx * py + last[0] * first[1]
- last[1] * first[0]) / length
if distance > farthest:
farthest, index = distance, i
if farthest <= tolerance:
return [first, last]
left = simplify(points[: index + 1], tolerance)
right = simplify(points[index:], tolerance)
return left[:-1] + right
def simplify_ring(ring, tolerance, decimals):
"""A closed ring simplified, rounded, without repeated points; None when
it collapses below a triangle."""
# Split at the point farthest from the start, so a closed ring (first
# point = last point) simplifies as two open halves.
start = ring[0]
far = max(range(len(ring)), key=lambda i: math.hypot(
ring[i][0] - start[0], ring[i][1] - start[1]))
halves = simplify(ring[: far + 1], tolerance)[:-1] + simplify(
ring[far:], tolerance)
rounded = []
for x, y in halves:
point = [round(x, decimals), round(y, decimals)]
if not rounded or rounded[-1] != point:
rounded.append(point)
if rounded[0] != rounded[-1]:
rounded.append(rounded[0])
return rounded if len(rounded) >= 4 else None
def area(ring):
"""The absolute area of a ring, in square degrees (shoelace)."""
return abs(sum(x1 * y2 - x2 * y1
for (x1, y1), (x2, y2) in zip(ring, ring[1:]))) / 2
def polygons_of(geometry):
if geometry["type"] == "Polygon":
return [geometry["coordinates"]]
if geometry["type"] == "MultiPolygon":
return geometry["coordinates"]
raise ValueError(f"Unexpected geometry {geometry['type']}")
def reduce(polygons, tolerance, min_area, decimals):
"""polygons simplified: those smaller than min_area dropped, except the
largest; a polygon that collapses entirely kept as its outline alone,
unsimplified but rounded, so even the smallest island stays."""
largest = max(range(len(polygons)), key=lambda i: area(polygons[i][0]))
kept = []
for i, polygon in enumerate(polygons):
if i != largest and area(polygon[0]) < min_area:
continue
exterior = simplify_ring(polygon[0], tolerance, decimals)
if exterior is None:
if i != largest:
continue
exterior = simplify_ring(polygon[0], 0, decimals)
if exterior is None:
continue
holes = [hole for hole in (simplify_ring(ring, tolerance, decimals)
for ring in polygon[1:])
if hole is not None]
kept.append([exterior, *holes])
return {"type": "MultiPolygon", "coordinates": kept}

View file

@ -1,171 +0,0 @@
#!/usr/bin/env python3
"""Builds the app's world map of countries and territories.
The outlines come from Natural Earth, whose vector data is in the public
domain: its Admin 0 (countries) layer at 1:50m, release v5.1.2, which draws
borders as they are on the ground, generalised for maps of this scale with
neighbours' borders kept identical; Gibraltar and the US Outlying Islands,
which that layer leaves out, come from its 1:10m layer. The names come from
the Unicode CLDR,
release 48.2.3 (Unicode License v3), in every language a studyset can be in:
the names phones and computers give countries. Each download is checked
against the hash recorded below.
Each region is a country or territory with its own ISO 3166-1 code (CY, GL,
XK for Kosovo as the EU and CLDR use it), 239 in all. Areas without a code
of their own count where ISO counts them: Northern Cyprus as Cyprus,
Somaliland as Somalia, and the outlying parts Natural Earth draws apart
(Australia's islands in the Indian Ocean) as their country. Siachen Glacier,
which belongs to no code, stays as land that can't be picked.
The outlines are kept as Natural Earth generalised them, so neighbours'
borders still meet; only islands under 0.001 square degrees are dropped
(unless they are all a region has), and coordinates rounded to 3 decimals,
about 100 m. Each region keeps Natural Earth's label point, where the app
marks a region too small to tap.
Run from the repository root with Python 3 and no other packages:
python3 tool/maps/world_countries.py
It rewrites halkyon_learn_flutter/assets/maps/world_countries.geojson.
"""
import json
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from outlines import area, download, polygons_of, reduce # noqa: E402
SOURCE = (
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/"
"v5.1.2/geojson/ne_50m_admin_0_countries.geojson"
)
SOURCE_SHA256 = "3e458fc036ad0a66411f2c1e6cac49c5d7bfb81cb1123bc513b22511a2b7fdeb"
# The finer layer, for what the coarser one leaves out.
DETAILED = (
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/"
"v5.1.2/geojson/ne_10m_admin_0_countries.geojson"
)
DETAILED_SHA256 = (
"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255")
FROM_DETAILED = {"GI", "UM"}
NAMES = (
"https://raw.githubusercontent.com/unicode-org/cldr-json/48.2.3/"
"cldr-json/cldr-localenames-full/main/{}/territories.json"
)
# The languages a studyset's cards can be in (content_languages.dart), as
# CLDR names them: zh is Chinese in simplified characters.
NAMES_SHA256 = {
"ar": "b78c4a11a9ad8358048998bcda2eb49e205d5ab281f85991ed097af7b972c3d7",
"de": "e5b7a831cd70afeaad10a56360c08e2d970e9fe79f254ee8d937a7dfa13686b7",
"el": "489974d90d8ccc4647b267467e70cbe9abad3c5995e878a6238a853a3315975c",
"en": "158c1d575308f7e46912edbeda435c8fe2ef5dad280798231f3a432e406b1807",
"es": "027b9c91d4e923d506b404415648691ef41ebde721aa454cee4d9f7f66b3ea62",
"fr": "42d67b78cb74ffe87380db1d702d06be995c3082d7f711ffd6e75a721133c5e0",
"he": "f487b312ed883f3840ea9d3d2b7754023b6897cbd986f77e81d319104531128d",
"hi": "f77f9c0c4f8bb7062dfe40edca416beae34e17b89714dabedca2f2b41ed77121",
"it": "d41462485baabf9b0b70de0092a173a45a7af8c415ace686e6cbe1909baf390f",
"ja": "2e9976a1d5e6a02e48b5a5c18987bd6246b0a21369513386bb5874a6887056d2",
"ko": "47b2f98d9cbf823068b0594074fe5db7fde80f9baf6a8038bd24ed81aefe7829",
"nl": "51f5d8ffc56cee8b610b2857eb491d151ad2866da49eccbd7b9ff0d6461f9b3c",
"pl": "ae91e6cb2c761bc158f08ae3bf9a6d495b05914ac945cc6eea1bee2b8b532aab",
"pt": "89604c7cd2eecc83c7a7f65129e7e6fe70a04f2d52263345530d714146f99ee8",
"ru": "305ba1f9eecb1b0151ab081a111aaa8a5898d38c9d57bfbfa125734e508a2389",
"sv": "8f11c17116f5740ca6f0ccbe404542b65c678033594d1f0f027942131bd981cd",
"th": "e4c6fd21ec75686f51e9bf986856cac2d6a4cddefe9065373282a7510bcefcd0",
"tr": "4b9b52e756c79f8b4c81e7c2c5bd2c2ba9338f0c80f2dceeee53ba4561d872b5",
"uk": "be0b7a34fb070cb4e7749a69eed93e62af6acf7a298437d4eb436ef0163284af",
"vi": "1bf0ad4f7286b12b326f9d4387af6d4bcc4e1099a269c2ec9a6c9e5d6db20476",
"zh": "18f1426f1e8981a671517a4857a8d4e56060909906c4cfd9b9a43a8a18b9ab6f",
"zh-Hant": "2a316ff8da8e54295d4d13ec76e1b6e9d07f3a1a11c76af2ac85ec7d6a7960c6",
}
# The tags the app gives these languages.
TAGS = {"zh": "zh-Hans"}
# CLDR's short names where its usual one says more than a map label needs
# ("Hong Kong SAR China", "Myanmar (Burma)").
SHORT = {"HK", "MO", "MM", "PS"}
TARGET = Path("halkyon_learn_flutter/assets/maps/world_countries.geojson")
TOLERANCE = 0 # degrees: only points on a straight line go
MIN_AREA = 0.001 # square degrees; smaller islands are dropped
DECIMALS = 3
# Areas Natural Earth gives no code of their own, counted where ISO counts
# them; the rest of those stays as land without a code.
JOINED = {
"N. Cyprus": "CY",
"Somaliland": "SO",
}
def names_by_code():
"""Every territory's name in each language, by ISO code."""
names = defaultdict(dict)
for language, sha256 in NAMES_SHA256.items():
data = json.loads(download(NAMES.format(language), sha256))
territories = data["main"][language]["localeDisplayNames"][
"territories"]
for code, name in territories.items():
if len(code) == 2 and code.isalpha():
short = territories.get(f"{code}-alt-short")
names[code][TAGS.get(language, language)] = (
short if code in SHORT and short else name)
return names
def main():
source = json.loads(download(SOURCE, SOURCE_SHA256))["features"]
detailed = json.loads(download(DETAILED, DETAILED_SHA256))["features"]
source += [feature for feature in detailed
if feature["properties"]["ISO_A2_EH"] in FROM_DETAILED]
names = names_by_code()
parts = defaultdict(list)
# Each code's label: its largest area's (France's, not Clipperton
# Island's), with that area's size.
labels = {}
land = []
for feature in source:
properties = feature["properties"]
code = JOINED.get(properties["NAME"], properties["ISO_A2_EH"])
polygons = polygons_of(feature["geometry"])
if code == "-99":
land.extend(polygons)
continue
parts[code].extend(polygons)
size = sum(area(polygon[0]) for polygon in polygons)
if code not in labels or labels[code][1] < size:
labels[code] = ([round(properties["LABEL_X"], DECIMALS),
round(properties["LABEL_Y"], DECIMALS)], size)
features = []
for code in sorted(parts):
if "en" not in names[code]:
sys.exit(f"CLDR has no English name for {code}.")
features.append({
"type": "Feature",
"properties": {
"code": code,
"names": dict(sorted(names[code].items())),
"label": labels[code][0],
},
"geometry": reduce(parts[code], TOLERANCE, MIN_AREA, DECIMALS),
})
features.append({
"type": "Feature",
"properties": {},
"geometry": reduce(land, TOLERANCE, MIN_AREA, DECIMALS),
})
if len(features) - 1 != 239:
sys.exit(f"Expected 239 regions, got {len(features) - 1}.")
TARGET.write_text(json.dumps(
{"type": "FeatureCollection", "features": features},
ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(f"Wrote {TARGET} ({TARGET.stat().st_size} bytes)", file=sys.stderr)
if __name__ == "__main__":
main()