diff options
| -rw-r--r-- | README.md | 22 | ||||
| -rw-r--r-- | cli/LICENSE | 21 | ||||
| -rw-r--r-- | cli/README.md | 118 | ||||
| -rwxr-xr-x | cli/test.sh | 200 | ||||
| -rwxr-xr-x | cli/ugi | 660 | ||||
| -rw-r--r-- | fonts/KawkabMono-Bold.woff2 | bin | 0 -> 102704 bytes | |||
| -rw-r--r-- | fonts/KawkabMono-Regular.woff2 | bin | 0 -> 106884 bytes | |||
| -rw-r--r-- | spec/LICENSE | 21 | ||||
| -rw-r--r-- | spec/README.md | 894 | ||||
| -rw-r--r-- | spec/grammar/UGI_GRAMMAR_v0.abnf | 148 | ||||
| -rw-r--r-- | spec/grammar/UGI_GRAMMAR_v0.ebnf | 167 | ||||
| -rw-r--r-- | spec/scripts/gen_abnf.py | 265 | ||||
| -rw-r--r-- | spec/scripts/gen_ebnf.py | 215 | ||||
| -rw-r--r-- | spec/scripts/gen_spec.py | 1053 | ||||
| -rw-r--r-- | spec/tests/cases.json | 780 | ||||
| -rw-r--r-- | spec/ugi_registry_v0.json | 134 | ||||
| -rw-r--r-- | web/LICENSE | 21 | ||||
| -rw-r--r-- | web/README.md | 46 | ||||
| -rw-r--r-- | web/scripts/gen_tool.py | 1388 |
19 files changed, 6153 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..63b0f8a --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# ugi + +Universal Geek Identifier. Spec, CLI, and web tool. + +## Contents + +- `spec/`: UGI specification, registry JSON, grammar files, generation scripts +- `cli/`: `ugi` shell script for encoding and decoding from the command line +- `web/`: web tool generated from the registry; run `gen_tool.py` to rebuild +- `fonts/`: Kawkab Mono typeface used by the web tool + +## Build web tool + +```sh +python3 web/scripts/gen_tool.py spec/ugi_registry_v0.json web/index.html +``` + +## Links + +- Live tool: <https://ugi.gumx.cc> +- Spec: `spec/README.md` +- CLI: `cli/README.md` diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..dd35f66 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmed Mohamed Alaa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..478c178 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,118 @@ +# ugi-cli + +Command-line tool for encoding, decoding, and converting UGI (Unified Geek Identifier) strings. + +## Dependencies + +- **bash** 4.0+ +- **jq** — [jqlang.github.io/jq](https://jqlang.github.io/jq/) +- **ugi_registry_v0.json** — from [ugi-spec](https://git.sr.ht/~gumxcc/ugi-spec) + +## Setup + +```bash +git clone https://git.sr.ht/~gumxcc/ugi-cli +cd ugi-cli + +# Point to the registry (pick one): +export UGI_REGISTRY=/path/to/ugi_registry_v0.json +# or place/symlink it next to the script: +ln -s /path/to/ugi-spec/ugi_registry_v0.json . +``` + +Optionally add to `$PATH`: + +```bash +ln -s "$PWD/ugi" ~/.local/bin/ugi +``` + +## Usage + +### Decode + +Parse a UGI string into a human-readable table: + +``` +$ ugi decode "ugi:0@jdoe:gcs$/dev,a4,b5/4,ppy6$js6ts5,i5" + +Version: 0 +Handle: @jdoe + +Field Code Decoded +---------------------------------------------------------------------- +Geek Specialization g Computer Science ($), dev +Age a4 25-34 +Build b5/4 height: Above average; width: Average +Programming Languages p Python=Advanced / daily [$], JavaScript=Advanced / daily, TypeScript=Competent +AI Stance i5 Positive +``` + +Reads from stdin if no argument given: + +```bash +echo "ugi:0@jdoe:gcs,a4" | ugi decode +``` + +### Convert + +Convert between URI and block format: + +```bash +# URI → Block +ugi convert "ugi:0@jdoe:gcs/dev,a4,ppy6rs5" + +# Block → URI +ugi convert <<'EOF' +------- BEGIN UGI BLOCK ------- +v:0 @jdoe Gcs/dev +A4 +Ppy6 Prs5 +-------- END UGI BLOCK -------- +EOF +``` + +### Encode + +Interactive guided encoder: + +```bash +ugi encode +``` + +### Fields + +List all field codes: + +```bash +ugi fields +``` + +### Short aliases + +```bash +ugi d "ugi:0@jdoe:gcs,a4" # decode +ugi c "ugi:0@jdoe:gcs,a4" # convert +ugi e # encode +ugi f # fields +``` + +## Testing + +Run the test suite against the shared test cases from [ugi-spec](https://git.sr.ht/~gumxcc/ugi-spec): + +```bash +./test.sh /path/to/ugi_registry_v0.json /path/to/ugi-spec/tests/cases.json +``` + +The test runner validates decode output (version, handle, field labels) and convert roundtrip (URI → Block → URI) for each case. + +## Environment + +| Variable | Description | +|---|---| +| `UGI_REGISTRY` | Path to `ugi_registry_v0.json`. Falls back to `./ugi_registry_v0.json` or `../ugi-spec/ugi_registry_v0.json`. | + + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/cli/test.sh b/cli/test.sh new file mode 100755 index 0000000..357beeb --- /dev/null +++ b/cli/test.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# Test runner for ugi-cli against cases.json +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UGI="$SCRIPT_DIR/ugi" + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 <ugi_registry_v0.json> <cases.json>" >&2 + exit 1 +fi + +REGISTRY="$1" +CASES="$2" + +if [[ ! -f "$REGISTRY" ]]; then echo "error: registry not found: $REGISTRY" >&2; exit 1; fi +if [[ ! -f "$CASES" ]]; then echo "error: cases not found: $CASES" >&2; exit 1; fi +if [[ ! -x "$UGI" ]]; then echo "error: ugi not found at $UGI" >&2; exit 1; fi + +export UGI_REGISTRY="$REGISTRY" + +pass=0 fail=0 skip=0 +failures=() + +# ── Helpers ────────────────────────────────────────────────────────────────── + +expect_field_label() { + local case_id="$1" field_code="$2" + jq -r --arg id "$case_id" --arg fc "$field_code" ' + .cases[] | select(.id == $id) | .decoded.fields[$fc] //empty | + if .type == "direct" then .label + elif .type == "special" and .dimensions then + [.dimensions | to_entries[] | "\(.key): \(.value.label)"] | join("; ") + elif .type == "special" and .domains then + [.domains[] | .name + (if .paid then " ($)" else "" end)] | join(", ") + elif .entries then + [.entries[] | + .name + (if .value != null then "=\(.label)" else "" end) + + (if .paid then " [$]" else "" end) + + (if .aspire then " [+\(.aspire)]" else "" end) + ] | join(", ") + else empty end + ' "$CASES" +} + +run_decode_test() { + local case_id="$1" uri="$2" + local output + output=$("$UGI" decode "$uri" 2>&1) || true + + local exp_ver + exp_ver=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.version' "$CASES") + if ! echo "$output" | grep -qF "Version: $exp_ver"; then + failures+=("$case_id/decode/version: expected '$exp_ver'") + return 1 + fi + + local exp_handle + exp_handle=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.handle' "$CASES") + if ! echo "$output" | grep -qF "Handle: @$exp_handle"; then + failures+=("$case_id/decode/handle: expected '@$exp_handle'") + return 1 + fi + + local field_codes + field_codes=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.fields | keys[]' "$CASES" 2>/dev/null) || return 0 + + for fc in $field_codes; do + local exp_label + exp_label=$(expect_field_label "$case_id" "$fc") + [[ -z "$exp_label" ]] && continue + + local entry_count + entry_count=$(jq -r --arg id "$case_id" --arg fc "$fc" ' + .cases[] | select(.id == $id) | .decoded.fields[$fc].entries // [] | length + ' "$CASES") + + if [[ "$entry_count" -gt 0 ]]; then + local entry_names + entry_names=$(jq -r --arg id "$case_id" --arg fc "$fc" ' + .cases[] | select(.id == $id) | .decoded.fields[$fc].entries[]? | .name + ' "$CASES") + for ename in $entry_names; do + if ! echo "$output" | grep -qF "$ename"; then + failures+=("$case_id/decode/$fc: missing entry name '$ename' in output") + return 1 + fi + done + elif [[ -n "$exp_label" ]]; then + IFS=';' read -ra label_parts <<< "$exp_label" + for lp in "${label_parts[@]}"; do + lp="${lp## }" + if ! echo "$output" | grep -qF "$lp"; then + failures+=("$case_id/decode/$fc: missing '$lp' in output") + return 1 + fi + done + fi + done + + return 0 +} + +run_convert_test() { + local case_id="$1" uri="$2" expected_block="$3" + + local block_output + block_output=$("$UGI" convert "$uri" 2>&1) || true + + if ! echo "$block_output" | grep -qF "BEGIN UGI BLOCK"; then + failures+=("$case_id/convert-uri2block: missing BEGIN header") + return 1 + fi + if ! echo "$block_output" | grep -qF "END UGI BLOCK"; then + failures+=("$case_id/convert-uri2block: missing END footer") + return 1 + fi + + local exp_handle + exp_handle=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.handle' "$CASES") + if ! echo "$block_output" | grep -qF "@$exp_handle"; then + failures+=("$case_id/convert-uri2block: missing handle '@$exp_handle'") + return 1 + fi + + local uri_output + uri_output=$("$UGI" convert "$block_output" 2>&1) || true + + if [[ "${uri_output,,}" != "ugi:"* ]]; then + failures+=("$case_id/convert-block2uri: output doesn't start with 'ugi:' got '$uri_output'") + return 1 + fi + + if ! echo "$uri_output" | grep -qF "@$exp_handle"; then + failures+=("$case_id/convert-block2uri: missing handle in roundtrip") + return 1 + fi + + return 0 +} + +# ── Run tests ──────────────────────────────────────────────────────────────── + +echo "=== UGI CLI Test Suite ===" +echo "Registry: $REGISTRY" +echo "Cases: $CASES" +echo "CLI: $UGI" +echo + +case_ids=$(jq -r '.cases[].id' "$CASES") + +for cid in $case_ids; do + uri=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .uri // empty' "$CASES") + has_decoded=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .decoded // empty | if . then "yes" else "no" end' "$CASES") + + if [[ -z "$uri" ]]; then + printf " SKIP %-35s (no single URI)\n" "$cid" + skip=$((skip + 1)) + continue + fi + + if [[ "$has_decoded" == "no" || -z "$has_decoded" ]]; then + printf " SKIP %-35s (no decoded expectations)\n" "$cid" + skip=$((skip + 1)) + continue + fi + + if run_decode_test "$cid" "$uri"; then + printf " PASS %-35s decode\n" "$cid" + pass=$((pass + 1)) + else + printf " FAIL %-35s decode\n" "$cid" + fail=$((fail + 1)) + fi + + block=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .block // empty' "$CASES") + if [[ -n "$block" ]]; then + if run_convert_test "$cid" "$uri" "$block"; then + printf " PASS %-35s convert roundtrip\n" "$cid" + pass=$((pass + 1)) + else + printf " FAIL %-35s convert roundtrip\n" "$cid" + fail=$((fail + 1)) + fi + fi +done + +echo +echo "=== Results: $pass passed, $fail failed, $skip skipped ===" + +if [[ ${#failures[@]} -gt 0 ]]; then + echo + echo "Failures:" + for f in "${failures[@]}"; do + echo " - $f" + done + exit 1 +fi + +exit 0 @@ -0,0 +1,660 @@ +#!/usr/bin/env bash +# ugi — encode, decode, and convert UGI strings +set -euo pipefail + +VERSION="0.1.0" + +# ── Registry path ──────────────────────────────────────────────────────────── + +REGISTRY="${UGI_REGISTRY:-}" +if [[ -z "$REGISTRY" ]]; then + # Try common locations relative to script + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + for candidate in \ + "$SCRIPT_DIR/ugi_registry_v0.json" \ + "$SCRIPT_DIR/../ugi-spec/ugi_registry_v0.json" \ + "$SCRIPT_DIR/ugi-spec/ugi_registry_v0.json"; do + if [[ -f "$candidate" ]]; then + REGISTRY="$candidate" + break + fi + done +fi + +require_registry() { + if [[ -z "$REGISTRY" || ! -f "$REGISTRY" ]]; then + echo "error: registry not found. set UGI_REGISTRY or place ugi_registry_v0.json nearby." >&2 + exit 1 + fi +} + +# ── Helpers ────────────────────────────────────────────────────────────────── + +jq_check() { + if ! command -v jq &>/dev/null; then + echo "error: jq is required. install it: https://jqlang.github.io/jq/" >&2 + exit 1 + fi +} + +# ── Decode ─────────────────────────────────────────────────────────────────── + +decode_uri() { + local uri="$1" + require_registry + jq_check + + # Strip ugi: prefix, split header and fields + local rest="${uri#ugi:}" + local version="${rest%%@*}" + rest="${rest#*@}" + local handle="${rest%%:*}" + local fields_str="${rest#*:}" + + printf "Version: %s\nHandle: @%s\n\n" "$version" "$handle" + printf "%-25s %-8s %s\n" "Field" "Code" "Decoded" + printf "%s\n" "----------------------------------------------------------------------" + + IFS=',' read -ra field_parts <<< "$fields_str" + for part in "${field_parts[@]}"; do + [[ -z "$part" ]] && continue + local code="${part:0:1}" + local rest_field="${part:1}" + decode_single_field "$code" "$rest_field" "$part" + done +} + +decode_single_field() { + local code="$1" rest="$2" raw="$3" + local name type + + name=$(jq -r --arg c "$code" '.fields[$c].name // "Unknown"' "$REGISTRY") + type=$(jq -r --arg c "$code" '.fields[$c].type // "unknown"' "$REGISTRY") + + case "$type" in + direct) + decode_direct "$code" "$rest" "$name" + ;; + special) + decode_special "$code" "$rest" "$name" + ;; + multi) + decode_multi "$code" "$rest" "$name" + ;; + single) + decode_multi "$code" "$rest" "$name" + ;; + *) + printf "%-25s %-8s %s\n" "$name" "$code" "$rest" + ;; + esac +} + +decode_direct() { + local code="$1" rest="$2" name="$3" + [[ -z "$rest" ]] && return + + local digit="${rest:0:1}" + local mods="${rest:1}" + local label + + # Try custom values first, then scale_labels, then global scale + label=$(jq -r --arg c "$code" --arg d "$digit" ' + .fields[$c].values[$d].label // + .fields[$c].scale_labels[$d] // + empty + ' "$REGISTRY" 2>/dev/null || true) + + if [[ -z "$label" ]]; then + local scale_type + scale_type=$(jq -r --arg c "$code" '.fields[$c].scale_type // ""' "$REGISTRY") + if [[ -n "$scale_type" && "$scale_type" != "custom" ]]; then + label=$(jq -r --arg d "$digit" --arg st "$scale_type" ' + .scale.values[] | select(.value == ($d | tonumber)) | .[$st] // empty + ' "$REGISTRY" 2>/dev/null || true) + fi + fi + [[ -z "$label" ]] && label="$digit" + + # Parse modifiers + local mod_str="" + if [[ "$mods" == *"/"* ]]; then + local alt_digit="${mods#*/}" + alt_digit="${alt_digit:0:1}" + local alt_label + alt_label=$(jq -r --arg c "$code" --arg d "$alt_digit" ' + .fields[$c].values[$d].label // $d + ' "$REGISTRY" 2>/dev/null || true) + label="$label / $alt_label" + mods="${mods//\/$alt_digit/}" + fi + [[ "$mods" == *'$'* ]] && mod_str+=" [\$]" + if [[ "$mods" == *"+"* ]]; then + local asp="${mods#*+}" + asp="${asp:0:1}" + mod_str+=" [+$asp]" + fi + + printf "%-25s %-8s %s%s\n" "$name" "${code}${digit}" "$label" "$mod_str" +} + +decode_special() { + local code="$1" rest="$2" name="$3" + + case "$code" in + g) + # Geek type: domain/domain/$ + local decoded="" + IFS='/' read -ra domains <<< "$rest" + for d in "${domains[@]}"; do + local paid="" + local dname="$d" + [[ "$d" == *'$' ]] && { paid=" (\$)"; dname="${d%\$}"; } + + local resolved + if [[ "$dname" == "~"* ]]; then + resolved="$dname" + else + resolved=$(jq -r --arg sid "$dname" ' + [.fields.g.sub_id_groups[].sub_ids[$sid] // empty] | first // $sid + ' "$REGISTRY" 2>/dev/null || echo "$dname") + # Handle string vs object + if [[ "$resolved" == "{"* ]]; then + resolved=$(echo "$resolved" | jq -r '.name // empty' 2>/dev/null || echo "$dname") + fi + fi + [[ -n "$decoded" ]] && decoded+=", " + decoded+="${resolved}${paid}" + done + printf "%-25s %-8s %s\n" "$name" "$code" "$decoded" + ;; + b|v) + IFS='/' read -ra dims <<< "$rest" + local dim_names dim_labels="" + dim_names=$(jq -r --arg c "$code" '.fields[$c].dimensions | keys_unsorted[]' "$REGISTRY") + local i=0 + while IFS= read -r dim_name; do + local val="${dims[$i]:-}" + if [[ -n "$val" ]]; then + local dim_label + dim_label=$(jq -r --arg c "$code" --arg dn "$dim_name" --arg v "$val" ' + .fields[$c].dimensions[$dn].values[$v] // $v + ' "$REGISTRY") + [[ -n "$dim_labels" ]] && dim_labels+="; " + dim_labels+="${dim_name}: ${dim_label}" + fi + i=$((i + 1)) || true + done <<< "$dim_names" + printf "%-25s %-8s %s\n" "$name" "${code}${rest}" "$dim_labels" + ;; + l) + # Languages: 2-char code + digit + mods, repeating + decode_multi_inner "$code" "$rest" "$name" 2 + ;; + esac +} + +decode_multi() { + local code="$1" rest="$2" name="$3" + decode_multi_inner "$code" "$rest" "$name" 0 +} + +decode_multi_inner() { + local code="$1" rest="$2" name="$3" fixed_len="$4" + local entries="" + local i=0 len=${#rest} + + while (( i < len )); do + local ch="${rest:$i:1}" + local sub_id="" + + if [[ "$ch" == "~" ]]; then + # Custom sub-ID + local j=$((i + 1)) + while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do + j=$((j + 1)) + done + sub_id="${rest:$i:$((j - i))}" + i=$j + elif [[ "$ch" =~ [a-zA-Z] ]]; then + if (( fixed_len > 0 )); then + sub_id="${rest:$i:$fixed_len}" + i=$((i + fixed_len)) + else + local j=$((i)) + while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do + j=$((j + 1)) + done + sub_id="${rest:$i:$((j - i))}" + i=$j + fi + else + i=$((i + 1)) + continue + fi + + # Resolve name + local resolved + if [[ "$sub_id" == "~"* ]]; then + resolved="$sub_id" + else + resolved=$(jq -r --arg c "$code" --arg sid "$sub_id" ' + ((.fields[$c].sub_ids[$sid] // null) as $flat | + if $flat then + if ($flat | type) == "object" then $flat.name else $flat end + else null end) // + (if .fields[$c].sub_id_groups then + [.fields[$c].sub_id_groups[].sub_ids[$sid] // empty] | first | + if type == "object" then .name else . end + else null end) // + (.fields[$c].common_codes[$sid] // null) // + $sid + ' "$REGISTRY" 2>/dev/null || echo "$sub_id") + fi + + # Read digit + local digit="" + if (( i < len )) && [[ "${rest:$i:1}" =~ [0-7] ]]; then + digit="${rest:$i:1}" + i=$((i + 1)) + fi + + # Read modifiers + local mod_str="" + if (( i < len )) && [[ "${rest:$i:1}" == '$' ]]; then + mod_str+=" [\$]" + i=$((i + 1)) + fi + if (( i < len )) && [[ "${rest:$i:1}" == "+" ]]; then + i=$((i + 1)) + if (( i < len )) && [[ "${rest:$i:1}" =~ [0-7] ]]; then + mod_str+=" [+${rest:$i:1}]" + i=$((i + 1)) + fi + fi + + # Get label for digit + local label="" + if [[ -n "$digit" ]]; then + label=$(jq -r --arg c "$code" --arg d "$digit" ' + .fields[$c].values[$d].label // + .fields[$c].scale_labels[$d] // + empty + ' "$REGISTRY" 2>/dev/null || true) + if [[ -z "$label" ]]; then + local st + st=$(jq -r --arg c "$code" '.fields[$c].scale_type // ""' "$REGISTRY") + if [[ -n "$st" && "$st" != "custom" ]]; then + label=$(jq -r --arg d "$digit" --arg st "$st" ' + .scale.values[] | select(.value == ($d | tonumber)) | .[$st] // empty + ' "$REGISTRY" 2>/dev/null || true) + fi + fi + [[ -z "$label" ]] && label="$digit" + fi + + [[ -n "$entries" ]] && entries+=", " + if [[ -n "$digit" ]]; then + entries+="${resolved}=${label}${mod_str}" + else + entries+="${resolved}${mod_str}" + fi + done + + printf "%-25s %-8s %s\n" "$name" "$code" "$entries" +} + +# ── Convert ────────────────────────────────────────────────────────────────── + +convert_uri_to_block() { + local uri="$1" + require_registry + jq_check + + local rest="${uri#ugi:}" + local version="${rest%%@*}" + rest="${rest#*@}" + local handle="${rest%%:*}" + local fields_str="${rest#*:}" + + echo "------- BEGIN UGI BLOCK -------" + + local first_line="v:${version} @${handle}" + local -a remaining=() + + IFS=',' read -ra parts <<< "$fields_str" + for part in "${parts[@]}"; do + [[ -z "$part" ]] && continue + local code="${part:0:1}" + if [[ "$code" == "g" ]]; then + first_line+=" G${part:1}" + else + remaining+=("$part") + fi + done + echo "$first_line" + + # Group by category + local -A cat_fields + local -a cat_order=(identity appearance tech stance entertainment lifestyle) + for cat in "${cat_order[@]}"; do + cat_fields[$cat]="" + done + + for part in "${remaining[@]}"; do + local code="${part:0:1}" + local cat + cat=$(jq -r --arg c "$code" '.fields[$c].category // "tech"' "$REGISTRY") + local ftype + ftype=$(jq -r --arg c "$code" '.fields[$c].type // "direct"' "$REGISTRY") + local uc="${code^^}" + + if [[ "$ftype" == "multi" || "$ftype" == "single" ]]; then + # Expand merged entries + local expanded + expanded=$(expand_multi_block "$uc" "${part:1}" "$code") + cat_fields[$cat]+="${cat_fields[$cat]:+ }${expanded}" + else + cat_fields[$cat]+="${cat_fields[$cat]:+ }${uc}${part:1}" + fi + done + + for cat in "${cat_order[@]}"; do + [[ -n "${cat_fields[$cat]:-}" ]] && echo "${cat_fields[$cat]}" + done + + echo "-------- END UGI BLOCK --------" +} + +expand_multi_block() { + local uc="$1" rest="$2" code="$3" + local result="" i=0 len=${#rest} + local fixed_len=0 + [[ "$code" == "l" ]] && fixed_len=2 + + while (( i < len )); do + local ch="${rest:$i:1}" + local sub_id="" tail="" + + if [[ "$ch" == "~" ]]; then + local j=$((i + 1)) + while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do j=$((j + 1)); done + sub_id="${rest:$i:$((j - i))}" + i=$j + elif [[ "$ch" =~ [a-zA-Z] ]]; then + if (( fixed_len > 0 )); then + sub_id="${rest:$i:$fixed_len}" + i=$((i + fixed_len)) + else + local j=$i + while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do j=$((j + 1)); done + sub_id="${rest:$i:$((j - i))}" + i=$j + fi + else + i=$((i + 1)); continue + fi + + # digit + modifiers + while (( i < len )) && [[ "${rest:$i:1}" =~ [0-9\$+] ]]; do + tail+="${rest:$i:1}" + i=$((i + 1)) + done + + result+="${result:+ }${uc}${sub_id}${tail}" + done + + echo "$result" +} + +convert_block_to_uri() { + local block="$1" + require_registry + jq_check + + local version="" handle="" + local -a field_parts=() + + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace + [[ "$line" == "---"*"BEGIN"* ]] && continue + [[ "$line" == "---"*"END"* ]] && continue + [[ -z "$line" ]] && continue + + for token in $line; do + if [[ "$token" == "v:"* ]]; then + version="${token#v:}" + elif [[ "$token" == "@"* ]]; then + handle="${token#@}" + elif [[ "${token:0:1}" =~ [A-Za-z] ]]; then + field_parts+=("$token") + fi + done + done <<< "$block" + + # Merge repeated field codes + local -A merged + local -a order=() + for part in "${field_parts[@]}"; do + local code="${part:0:1}" + code="${code,,}" # lowercase + if [[ -z "${merged[$code]+x}" ]]; then + merged[$code]="${part:1}" + order+=("$code") + else + merged[$code]+="${part:1}" + fi + done + + local fields_str="" + for code in "${order[@]}"; do + [[ -n "$fields_str" ]] && fields_str+="," + fields_str+="${code}${merged[$code]}" + done + + echo "ugi:${version}@${handle}:${fields_str}" +} + +# ── Fields ─────────────────────────────────────────────────────────────────── + +list_fields() { + require_registry + jq_check + + printf "%-6s %-25s %-15s %s\n" "Code" "Field" "Type" "Category" + printf "%s\n" "------------------------------------------------------------" + + jq -r ' + .fields | to_entries | sort_by(.key)[] | + "\(.key)\t\(.value.name)\t\(.value.type)\t\(.value.category)" + ' "$REGISTRY" | while IFS=$'\t' read -r code fname ftype fcat; do + printf "%-6s %-25s %-15s %s\n" "$code" "$fname" "$ftype" "$fcat" + done + + # Reserved codes + jq -r '.format.reserved_codes[]' "$REGISTRY" 2>/dev/null | sort | while read -r code; do + printf "%-6s %-25s %-15s %s\n" "$code" "(reserved)" "—" "—" + done +} + +# ── Encode (interactive) ──────────────────────────────────────────────────── + +encode_interactive() { + require_registry + jq_check + + echo "=== UGI Encoder ===" + echo + + # Handle + read -rp "Handle (username): " handle + [[ -z "$handle" ]] && { echo "Handle is required." >&2; exit 1; } + + local -a parts=() + + # Geek type (mandatory) + echo + echo "--- Geek Specialization (g) --- [mandatory]" + echo "Enter domain codes separated by /. Use ~ for custom, \$ after domain for paid." + echo "Example: cs\$/ai/wr~fermenting" + echo + echo "Available domains:" + jq -r ' + .fields.g.sub_id_groups | to_entries[] | + " \(.value.label): " + ([.value.sub_ids | to_entries[:6][] | "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)"] | join(", ")) + "..." + ' "$REGISTRY" + echo + read -rp "Geek domains: " geek + [[ -z "$geek" ]] && { echo "Geek type is required." >&2; exit 1; } + parts+=("g${geek}") + + # Other fields + local field_codes + field_codes=$(jq -r '.fields | keys[] | select(. != "g")' "$REGISTRY" | sort) + + for code in $field_codes; do + local name type + name=$(jq -r --arg c "$code" '.fields[$c].name' "$REGISTRY") + type=$(jq -r --arg c "$code" '.fields[$c].type' "$REGISTRY") + + echo + echo "--- ${name} (${code}) --- [s=skip]" + + case "$type" in + direct) + # Show scale + jq -r --arg c "$code" ' + .fields[$c].values // {} | to_entries | sort_by(.key)[] | + " \(.key): \(.value.label // .value)" + ' "$REGISTRY" 2>/dev/null || true + + read -rp "Value (0-7, s=skip): " val + [[ "$val" == "s" || -z "$val" ]] && continue + [[ "$val" =~ ^[0-7]$ ]] || { echo "Invalid, skipping."; continue; } + parts+=("${code}${val}") + ;; + special) + case "$code" in + b) + echo " Height and width, each 0-7" + read -rp " Height (0-7, s=skip): " h + [[ "$h" == "s" || -z "$h" ]] && continue + read -rp " Width (0-7): " w + [[ -z "$w" ]] && continue + parts+=("b${h}/${w}") + ;; + v) + echo " Social: 0=far-right auth → 7=far-left lib" + echo " Economic: 0=unreg capitalism → 7=full socialism" + read -rp " Social (0-7, s=skip): " s + [[ "$s" == "s" || -z "$s" ]] && continue + read -rp " Economic (0-7): " e + [[ -z "$e" ]] && continue + parts+=("v${s}/${e}") + ;; + l) + echo " Enter ISO 639-1 codes with ratings, no spaces." + echo " Example: en7ar6de3" + read -rp " Languages (s=skip): " langs + [[ "$langs" == "s" || -z "$langs" ]] && continue + parts+=("l${langs}") + ;; + esac + ;; + multi|single) + jq -r --arg c "$code" ' + ((.fields[$c].sub_ids // {}) | to_entries[:8][] | + "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)"), + ((.fields[$c].sub_id_groups // {}) | to_entries[] | .value.sub_ids | to_entries[:4][] | + "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)") + ' "$REGISTRY" 2>/dev/null | head -12 | sed 's/^/ /' + echo " ..." + + if [[ "$type" == "single" ]]; then + echo " Enter one sub-ID + rating. Example: is6" + else + echo " Enter sub-ID+rating pairs. Example: py6rs5js4" + fi + read -rp " ${name} (s=skip): " val + [[ "$val" == "s" || -z "$val" ]] && continue + parts+=("${code}${val}") + ;; + esac + done + + local uri + uri="ugi:0@${handle}:$(IFS=,; echo "${parts[*]}")" + + echo + echo "=== Result ===" + echo + echo "$uri" + echo + convert_uri_to_block "$uri" +} + +# ── Main ───────────────────────────────────────────────────────────────────── + +usage() { + cat <<'EOF' +ugi — UGI (Unified Geek Identifier) CLI tool + +Usage: + ugi decode <ugi-string> Decode a UGI string (URI or block) + ugi convert <ugi-string> Convert between URI and block format + ugi encode Interactive encoder + ugi fields List all field codes + + Aliases: d=decode, c=convert, e=encode, f=fields + +Environment: + UGI_REGISTRY Path to ugi_registry_v0.json + +Options: + -h, --help Show this help + -v, --version Show version + +Reads from stdin if no string argument given (decode/convert). +EOF +} + +main() { + [[ $# -eq 0 ]] && { usage; exit 0; } + + case "$1" in + -h|--help) usage; exit 0 ;; + -v|--version) echo "ugi $VERSION"; exit 0 ;; + decode|d) + shift + local input="${1:-}" + [[ -z "$input" ]] && input="$(cat)" + if [[ "${input,,}" == "ugi:"* ]]; then + decode_uri "$input" + elif [[ "$input" == *"BEGIN UGI"* || "$input" == *"v:"* ]]; then + # Parse block to URI first, then decode + local uri + uri=$(convert_block_to_uri "$input") + decode_uri "$uri" + else + echo "error: unrecognized format" >&2; exit 1 + fi + ;; + convert|c) + shift + local input="${1:-}" + [[ -z "$input" ]] && input="$(cat)" + if [[ "${input,,}" == "ugi:"* ]]; then + convert_uri_to_block "$input" + elif [[ "$input" == *"BEGIN UGI"* || "$input" == *"v:"* ]]; then + convert_block_to_uri "$input" + else + echo "error: unrecognized format" >&2; exit 1 + fi + ;; + encode|e) encode_interactive ;; + fields|f) list_fields ;; + *) echo "error: unknown command '$1'" >&2; usage; exit 1 ;; + esac +} + +main "$@" diff --git a/fonts/KawkabMono-Bold.woff2 b/fonts/KawkabMono-Bold.woff2 Binary files differnew file mode 100644 index 0000000..f8f61f7 --- /dev/null +++ b/fonts/KawkabMono-Bold.woff2 diff --git a/fonts/KawkabMono-Regular.woff2 b/fonts/KawkabMono-Regular.woff2 Binary files differnew file mode 100644 index 0000000..92d4d15 --- /dev/null +++ b/fonts/KawkabMono-Regular.woff2 diff --git a/spec/LICENSE b/spec/LICENSE new file mode 100644 index 0000000..dd35f66 --- /dev/null +++ b/spec/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmed Mohamed Alaa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..3cedcbb --- /dev/null +++ b/spec/README.md @@ -0,0 +1,894 @@ +# UGI — Unified Geek Identifier + +**Version: 0 (Draft) · 2026-03-28 · MIT License** + +--- +## Overview + +A compact, URI-safe self-description code for geeks. Encodes identity, skills, interests, and opinions into a single string suitable for bios, email signatures, and URIs. + +It descends from two predecessors: + +- [Geek Code](https://sig.codes/geek/) by Robert A. Hayden (1993-1996), with its [Geek Code 4.0+](https://sig.codes/geek-4.0/) by exarobibliologist (2019-2026) +- [Hacker Key](https://sig.codes/hacker-key/) by Chris Allegretta (2003-2006) + +UGI takes the cultural breadth of the Geek Code, the numeric compactness of the Hacker Key, and adds URI safety, modern fields, and extensibility. + +### Design goals + +1. Single-letter field codes — 24 active, 2 reserved +2. Octal (0–7) rating scale +3. URI-safe characters only — no percent-encoding needed +4. Dual format — URI for machines, block for humans +5. Case-insensitive +6. Extensible via `~` custom sub-IDs + +--- +## Formats + +### URI + +``` +ugi:<version>[/<revision>]@<handle>:<field>,<field>,... +``` + +### Block + +``` +------- BEGIN UGI BLOCK ------- +v:<version>[/<revision>] @<handle> [G<geek_types>] +<field> <field> ... +-------- END UGI BLOCK -------- +``` + +In block format, field codes are repeated per sub-ID for readability: `Ppy6 Prs5` instead of `ppy6rs5`. Long-form sub-ID aliases (noted in registries) are permitted in block format. + +### Conversion + +Block → URI: remove header/footer, merge repeated field codes, replace spaces with commas, prepend `ugi:`. + +URI → Block: split on commas, expand merged sub-IDs by repeating field codes, add header/footer. + +No modifier or character changes are needed — both formats use identical symbols. + +### Mandatory fields + +`@handle` and `g` (geek type) are required. All others are optional. + +--- +## Grammar + +``` +<field_code><sub_id><digit>[<modifier>]... +``` + +- **Field codes** — single letter (a–z), case-insensitive +- **Sub-IDs** — 1–4 lowercase alpha chars +- **Ratings** — single octal digit (0–7), required on every sub-ID +- **Custom sub-IDs** — `~` prefix: `~fermenting5` +- **Modifiers** — after the digit: + - `$` — paid + - `+` followed by digit — aspiring toward that level + - `$+` combined — paid and aspiring +- **Alternatives** — `/` between two digits on direct-value fields: `c3/6` + +### Special formats + +| Field | Format | Example | +|---|---|---| +| `g` geek type | `/` separates domains, `~` for custom, `$` for paid, no ratings | `gcs$/ai/wr~fermenting` | +| `b` build | `<height>/<width>` | `b6/3` | +| `l` languages | ISO 639-1 code + rating | `lar7en6` | +| `v` politics | `<social>/<economic>` | `v5/3` | + +### Parser algorithm + +After each comma or space, read a single letter (field code). Then: + +- `g` → read alpha tokens separated by `/` and `~`-prefixed tokens until delimiter +- `b` → digit, `/`, digit +- `v` → digit, `/`, digit +- `l` → repeat: 2 alpha + digit + optional modifier +- `r` → one sub-id + digit (single value only) +- direct fields → digit + optional modifier +- direct-alt fields → digit + optional `/` + digit + optional modifier +- multi fields → repeat: alpha (sub-id) + digit + optional modifier + +--- +## Rating scale (0–7) + +| Value | General | Proficiency | Enthusiasm | Stance | +|---|---|---|---|---| +| 0 | Hostile / opposed | Can't, won't | Despise | Strongly against | +| 1 | Strong dislike | Aware it exists | Avoid actively | Against | +| 2 | Dislike / minimal | Dabbled briefly | Not interested | Lean against | +| 3 | Slight negative | Beginner | Cold | Slightly against | +| 4 | Baseline / neutral | Average | Take or leave | No opinion | +| 5 | Moderate positive | Competent | Enjoy | Lean toward | +| 6 | Strong positive | Advanced / daily | Enthusiast | Support | +| 7 | Obsessed / defining | Master / creator | Life-defining | Evangelist | +### Modifier permissions + +| Modifiers | Fields | +|---|---| +| `$` and `+` | `f` `j` `l` `o` `p` `q` `w` `y` `z` | +| `$` only | `g` | +| `+` only | `b` `c` `d` `e` `h` `k` `s` | +| Neither | `a` `i` `m` `r` `t` `v` `x` | + +--- +## Field list + +| Code | Field | Type | Category | +|---|---|---|---| +| `a` | Age | Direct | Identity | +| `b` | Build | Special (h/w) | Appearance | +| `c` | Clothing | Direct, `/` ok | Appearance | +| `d` | Editor / IDE | Multi | Tech | +| `e` | Education | Direct | Identity | +| `f` | Food / Cooking | Direct | Lifestyle | +| `g` | Geek Specialization | Special | Identity | +| `h` | Hair | Multi (1-letter) | Appearance | +| `i` | AI Stance | Direct | Stance | +| `j` | Gaming | Multi | Entertainment | +| `k` | Books | Direct | Entertainment | +| `l` | Spoken Languages | Multi (ISO) | Identity | +| `m` | Music | Multi | Entertainment | +| `n` | *(reserved)* | — | — | +| `o` | Operating System | Multi | Tech | +| `p` | Programming Languages | Multi | Tech | +| `q` | Security / Hacking | Direct, `/` ok | Tech | +| `r` | Religion | Single | Stance | +| `s` | Sex / Relationships | Direct, `/` ok | Lifestyle | +| `t` | TV / Film | Multi | Entertainment | +| `u` | *(reserved)* | — | — | +| `v` | Politics | Special (s/e) | Stance | +| `w` | Hardware | Direct | Tech | +| `x` | Comics / Manga | Multi | Entertainment | +| `y` | Fitness | Direct | Lifestyle | +| `z` | Maker / DIY | Direct | Lifestyle | + +--- +## Field definitions + +--- + +### a — Age + +0. **Under 10** — Still loading. Baby Shark has more life experience. +1. **10-14** — Old enough for Linux opinions, too young to drive to the meetup. +2. **15-19** — Convinced you know everything. Might be right about the tech stuff. +3. **20-24** — Student loans arrived but so has 3am pizza freedom. +4. **25-34** — Peak earning-to-caring ratio. GitHub green, back complaining. +5. **35-49** — You remember dial-up noises. Kids ask what a floppy disk is. +6. **50-64** — You were there when it happened. All of it. +7. **65+** — Living legend. Your software runs on OSes born decades after you. + +--- + +### b — Build + +Format: `b<height>/<width>`. Each 0–7. 0 = extremely small, 4 = average, 7 = extremely large. `+` modifier permitted. + +**Height:** 0 = Extremely short · 1 = Very short · 2 = Short · 3 = Below average · 4 = Average · 5 = Above average · 6 = Tall · 7 = Extremely tall + +**Width:** 0 = Extremely thin · 1 = Very thin · 2 = Slim · 3 = Below average · 4 = Average · 5 = Above average · 6 = Broad · 7 = Extremely broad + +> `b6/3` — tall and slim. +> `b4/4` — average. Chairs were designed for you. + +--- + +### c — Clothing + +0. **Nudist** — Quite a fashion statement. +1. **Punk** — Safety pins are load-bearing. +2. **Political tees** — Your shirts have opinions so you don't have to. +3. **Jeans & tee** — Universal geek uniform. You own seven identical shirts. +4. **Average** — Inoffensive. Forgettable. Exactly as intended. +5. **Smart casual** — You've mastered the blazer-over-tee. +6. **Formal** — You iron things. On purpose. +7. **Suit daily** — CEO, lawyer, or cosplaying as one. + +--- + +### d — Editor / IDE + +The editor holy war, now quantified. + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `vi` | `(vim)` | Vim / Neovim | `em` | `(emacs)` | Emacs | +| `vs` | `(vscode)` | VS Code | `ij` | `(idea)` | IntelliJ / JetBrains | +| `xc` | `(xcode)` | Xcode | `vt` | `(vstudio)` | Visual Studio | +| `ed` | — | ed | `na` | `(nano)` | nano / pico | +| `su` | `(sublime)` | Sublime Text | `ec` | `(eclipse)` | Eclipse | +| `np` | `(npp)` | Notepad++ | `hx` | `(helix)` | Helix | +| `ze` | `(zed)` | Zed | `ka` | `(kakoune)` | Kakoune | +| `lp` | `(lapce)` | Lapce | `at` | `(atom)` | Atom | +| `nv` | `(nvchad)` | NvChad / LazyVim | | | | + +Scale: **proficiency** (see global scale table). + +> 0: Opened it by accident, couldn't close it. +> 7: The editor is an extension of my nervous system. + +--- + +### e — Education + +0. **None / primary** — School of life. And YouTube. +1. **Middle school** — Survived. Emotionally, jury's still out. +2. **High school** — They said best years. They lied. +3. **Trade / associates** — You can actually do things, unlike philosophy majors. +4. **Bachelors** — Four years and paper that cost more than your car. +5. **Masters** — First degree wasn't enough to convince anyone. +6. **PhD** — Call me Doctor. Especially at parties. +7. **Post-doc** — Forgotten what sunlight looks like. + +--- + +### f — Food / Cooking + +0. **Microwave only** — Smoke detector is your kitchen timer. +1. **Boils water** — Sometimes remembers to turn off stove. +2. **Basic meals** — Eggs and pasta. The two food groups. +3. **Follows recipes** — To the letter. Substitution is heresy. +4. **Home cook** — Friends don't dread your dinner invitations. +5. **Experiments** — Sometimes works. You pretend you planned it. +6. **Hosts dinners** — Spice rack has subdivisions. +7. **Chef level** — Own a mandoline, all fingers intact. + +--- + +### g — Geek Specialization + +Mandatory. `/` separates domains. `~` prefixes custom domains. `$` after a domain = paid. No ratings. + +#### Computing & Tech + +| Code | Domain | Code | Domain | +|---|---|---|---| +| `cs` | Computer Science | `ai` | Artificial Intelligence | +| `ml` | Machine Learning | `cy` | Cybersecurity | +| `ds` | Data Science | `bc` | Blockchain | +| `io` | Internet of Things | `qc` | Quantum Computing | +| `vr` | Virtual Reality | `os` | Operating Systems | +| `dv` | DevOps | `ap` | API Development | +| `cl` | Cloud Infrastructure | `eg` | Edge Computing | +| `sc` | Ethical Hacking | `ux` | User Experience | +| `wp` | Web Performance | `db` | Databases | +| `em` | Embedded Systems | `fp` | Functional Programming | +| `nx` | Networking | `cv` | Computer Vision | +| `nl` | NLP / Comp. Linguistics | `cr` | Cryptography | + +#### Science & Medicine + +| Code | Domain | Code | Domain | +|---|---|---|---| +| `bi` | Biology | `cm` | Chemistry | +| `ph` | Physics | `md` | Medicine | +| `nr` | Neuroscience | `ev` | Environmental | +| `fs` | Forensics | `fd` | Food Science | +| `as` | Astronomy | `gl` | Geology | +| `bt` | Biotech | `gn` | Genetics | + +#### Engineering & Making + +| Code | Domain | Code | Domain | +|---|---|---|---| +| `ee` | Electrical Eng. | `me` | Mechanical Eng. | +| `rb` | Robotics | `dy` | DIY / Maker | +| `tp` | 3D Printing | `ar` | Arduino | +| `ce` | Civil Eng. | `ae` | Aerospace Eng. | +| `am` | Additive Mfg. | `ct` | Control Systems | + +#### Academic & Humanities + +| Code | Domain | Code | Domain | +|---|---|---|---| +| `mt` | Mathematics | `pl` | Philosophy | +| `ed` | Education | `lb` | Library Science | +| `lw` | Law | `ss` | Social Science | +| `py` | Psychology | `cg` | Cognitive Science | +| `li` | Linguistics | `hs` | History | +| `an` | Anthropology | `ec` | Economics | + +#### Art & Design + +| Code | Domain | Code | Domain | +|---|---|---|---| +| `gd` | Graphic Design | `ad` | Animation | +| `fa` | Fine Arts | `mu` | Music | +| `pa` | Performing Arts | `sr` | Screenwriting | +| `tw` | Tech Writing | `wr` | Writing | +| `vd` | Game Design | `pg` | Photography | +| `tx` | Textile Arts | `ty` | Typography | + +#### Business & Comms + +`bz` Business · `mk` Marketing · `gv` Government · `lo` Logistics · `qa` QA · `pm` Project Mgmt · `hr` HR + +#### Culture & Society + +`mm` Memes · `fn` Fandom · `pp` Pop Culture · `gn` Gender Studies · `al` Alt Lifestyles + +#### Games & Media + +`rp` RPG · `sm` Streaming · `pd` Podcasting · `cw` Cosplay + +#### Futurism + +`ax` Aerospace · `ft` Futurism · `ali` Alien Theories · `tr` Transhumanism + +#### Custom + +`~fermenting` `~beekeeping` `~lockpicking` `~origami` — anything goes. + +> `gcs$/ai/wr~fermenting` — paid CS geek, also into AI, writing, and fermenting. + +--- + +### h — Hair + +| Short | Name | +|---|---| +| `h` | Head | +| `f` | Face / beard | +| `b` | Brows | +| `m` | Mustache | +| `s` | Sideburns | + +0. **Alopecia** +1. **Shaved** +2. **Stubble** +3. **Below average** +4. **Average** +5. **Above average** +6. **Impressive** +7. **Sasquatch** + +--- + +### i — AI Stance + +0. **Butlerian Jihadist** — Would unplug every GPU on earth. +1. **Strongly opposed** — 'No AI' in bio, code, and dating profile. +2. **Skeptical** — Prefer human-authored everything. +3. **Cautious** — Ethics papers keep you up at night. +4. **Neutral tool** — Like a hammer. No opinions about hammers. +5. **Positive** — Copilot saved 20 minutes today. +6. **Enthusiast** — Cyborg partnership workflow. +7. **Singularity evangelist** — Grocery list is AI-generated. + +--- + +### j — Gaming + +**Genre:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `rp` | `(rpg)` | RPG | `fp` | `(fps)` | Shooter | +| `st` | `(str)` | Strategy | `pz` | `(pzl)` | Puzzle | +| `si` | `(sim)` | Simulation | `mm` | `(mmo)` | MMO | +| `ad` | `(adv)` | Adventure | `sp` | `(spo)` | Sports | +| `mb` | `(mba)` | Metroidvania | `ro` | `(rog)` | Roguelike | +| `su` | `(surv)` | Survival | `sa` | `(sand)` | Sandbox | +| `rr` | `(race)` | Racing | `fi` | `(fight)` | Fighting | +| `ho` | `(horror)` | Horror | | | | + +**Platform:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `pc` | — | PC | `ps` | — | PlayStation | +| `xb` | `(xbox)` | Xbox | `ns` | `(switch)` | Switch | +| `mo` | `(mobile)` | Mobile | `re` | `(retro)` | Retro | +| `vr` | — | VR | `sd` | `(steamdeck)` | Steam Deck | + +**Tabletop:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `dn` | `(dnd)` | D&D | `pf` | `(path)` | Pathfinder | +| `cc` | `(coc)` | Call of Cthulhu | `sr` | — | Shadowrun | +| `wo` | `(wod)` | World of Darkness | `ft` | `(fate)` | Fate | +| `gu` | `(gurps)` | GURPS | `bg` | — | Board games | +| `mt` | `(mtg)` | Magic: TG | `wh` | — | Warhammer | + +Scale: **enthusiasm** (see global scale table). + +> 0: Video games are a waste of time. +> 7: People disconnect when they see your name. + +--- + +### k — Books + +0. **Illiterate by choice** — Words are tiny drawings that haven't tried hard enough. +1. **Not since school** — Didn't finish that one either. +2. **Web only** — You call this 'reading.' +3. **Occasional** — Mostly when trapped on a plane. +4. **Few/year** — Goodreads updated sporadically. +5. **Regular** — To-read pile doubles as furniture. +6. **Avid** — Library cards from multiple jurisdictions. +7. **Book/week** — Shelves have shelves. Read this spec for fun. + +--- + +### l — Spoken Languages + +ISO 639-1 codes + proficiency 0–7. `$` and `+` permitted. + +0. **None** +1. **Few words** +2. **Tourist** +3. **Beginner** +4. **Conversational** +5. **Proficient** +6. **Fluent** +7. **Native** + +Common codes: `en` English · `es` Spanish · `fr` French · `de` German · `zh` Chinese · `ar` Arabic · `ja` Japanese · `ko` Korean · `pt` Portuguese · `ru` Russian · `hi` Hindi · `it` Italian · `nl` Dutch · `sv` Swedish · `tr` Turkish · `pl` Polish · `uk` Ukrainian · `fa` Farsi · `he` Hebrew · `th` Thai · `vi` Vietnamese · `id` Indonesian · `ms` Malay · `sw` Swahili · `bn` Bengali · `ta` Tamil + +> `lar7$en6de3+5` — native Arabic (paid translator), fluent English, learning German (beginner, aspiring proficient). + +--- + +### m — Music + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `rk` | `(rock)` | Rock | `mt` | `(metal)` | Metal | +| `pk` | `(punk)` | Punk | `jz` | `(jazz)` | Jazz | +| `bl` | `(blues)` | Blues | `fk` | `(folk)` | Folk | +| `cl` | `(clas)` | Classical | `el` | `(elec)` | Electronic | +| `hp` | `(hip)` | Hip-hop | `pp` | `(pop)` | Pop | +| `rb` | `(rnb)` | R&B | `cn` | `(coun)` | Country | +| `ab` | `(amb)` | Ambient | `pg` | `(prog)` | Progressive | +| `rg` | `(reg)` | Reggae | `in` | `(ind)` | Indie | +| `sl` | `(soul)` | Soul | `lt` | `(lat)` | Latin | +| `wr` | `(wrld)` | World | `dk` | `(dnb)` | Drum & Bass | +| `tr` | `(trap)` | Trap | `lo` | `(lofi)` | Lo-fi | +| `sy` | `(synth)` | Synthwave | `go` | `(gospel)` | Gospel | +| `op` | `(opera)` | Opera | `ga` | `(garage)` | Garage | + +Scale: **enthusiasm** (see global scale table). + +> 0: This genre is an assault on sound. +> 7: This genre IS your personality. + +--- + +### o — Operating System + +**Linux (x prefix):** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `xa` | `(xarch)` | Arch | `xd` | `(xdeb)` | Debian | +| `xu` | `(xubu)` | Ubuntu | `xm` | `(xmint)` | Mint | +| `xp` | `(xpop)` | Pop!_OS | `xf` | `(xfed)` | Fedora | +| `xr` | `(xrh)` | Red Hat | `xc` | `(xcent)` | CentOS/Alma/Rocky | +| `xs` | `(xsuse)` | OpenSUSE | `xg` | `(xgen)` | Gentoo | +| `xj` | `(xmanj)` | Manjaro | `xsl` | `(xslk)` | Slackware | +| `xn` | `(xnix)` | NixOS | `xk` | `(xkali)` | Kali | +| `xv` | `(xvoid)` | Void | `xz` | `(xzor)` | Zorin | +| `xx` | `(xmx)` | MX Linux | `xt` | `(xtail)` | Tails | +| `xq` | `(xqube)` | Qubes | `xl` | `(xlfs)` | LFS | +| `xe` | `(xelem)` | Elementary | `xw` | `(xwsl)` | WSL | +| `xi` | `(ximmut)` | Immutable | `xb` | `(xbedrock)` | Bedrock | + +**BSD:** +`fb` FreeBSD · `ob` OpenBSD · `nb` NetBSD · `db` DragonflyBSD + +**Windows:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `wt` | `(wten)` | Windows 10 | `we` | `(welv)` | Windows 11 | +| `ws` | `(wsrv)` | Win Server | | | | + +**Mac:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `mc` | `(mac)` | macOS | | | | + +**Mobile:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `an` | `(android)` | Android | `io` | `(ios)` | iOS | +| `pm` | `(pmos)` | postmarketOS | `li` | `(lineage)` | LineageOS | + +**Other:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `sl` | `(sol)` | Solaris | `ax` | `(aix)` | AIX | +| `ch` | `(chrome)` | ChromeOS | `ha` | `(haiku)` | Haiku | +| `tm` | `(temple)` | TempleOS | `se` | `(serenity)` | SerenityOS | +| `rx` | `(redox)` | Redox | `pl` | `(plan9)` | Plan 9 | + +Scale: **proficiency** (see global scale table). + +> 0: Would mass-format every drive running this. +> 7: You ARE the sysadmin. First person plural. + +--- + +### p — Programming Languages + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `py` | `(python)` | Python | `rs` | `(rust)` | Rust | +| `c` | — | C | `cp` | `(cpp)` | C++ | +| `cs` | `(csharp)` | C# | `jv` | `(java)` | Java | +| `kt` | `(kotlin)` | Kotlin | `sw` | `(swift)` | Swift | +| `ph` | `(php)` | PHP | `pl` | `(perl)` | Perl | +| `lu` | `(lua)` | Lua | `hs` | `(haskell)` | Haskell | +| `cj` | `(clj)` | Clojure | `er` | `(erlang)` | Erlang | +| `ex` | `(elixir)` | Elixir | `sc` | `(scala)` | Scala | +| `jl` | `(julia)` | Julia | `r` | — | R | +| `m` | `(matlab)` | MATLAB | `zg` | `(zig)` | Zig | +| `ni` | `(nim)` | Nim | `as` | `(asm)` | Assembly | +| `sq` | `(sql)` | SQL | `ba` | `(bash)` | Bash | +| `ps` | `(psh)` | PowerShell | `vb` | — | Visual Basic | +| `da` | `(dart)` | Dart | `oc` | `(objc)` | Obj-C | +| `ls` | `(lisp)` | Lisp | `js` | — | JavaScript | +| `ts` | — | TypeScript | `go` | — | Go | +| `rb` | `(ruby)` | Ruby | `ml` | `(ocaml)` | OCaml | +| `fs` | `(fsharp)` | F# | `pr` | `(prolog)` | Prolog | +| `co` | `(cobol)` | COBOL | `fo` | `(fortran)` | Fortran | +| `gd` | `(gdscript)` | GDScript | `vl` | `(vhdl)` | VHDL | +| `sv` | `(sysv)` | SystemVerilog | `cr` | `(crystal)` | Crystal | +| `hy` | `(hylang)` | Hy | `rk` | `(racket)` | Racket | +| `tc` | `(tcl)` | Tcl | `aw` | `(awk)` | AWK | +| `sd` | `(sed)` | sed | `wm` | `(wasm)` | WASM | + +Scale: **proficiency** (see global scale table). + +> 0: Mass-produced negative opinions without writing a line. +> 7: You ARE the language. People cite your blog posts. + +--- + +### q — Security / Hacking + +0. **No security** — Password is 'password'. Click every link. +1. **Don't care** — Firewalls are a construction thing. +2. **Default passwords** — Pet name plus birth year. Reused everywhere. +3. **Basic awareness** — Know phishing is bad. Fallen for it once. +4. **Password manager** — Normal human security. +5. **2FA everywhere** — Judge friends' passwords. +6. **Encrypts everything** — Paranoid but justified. +7. **Tinfoil hat** — NSA has a file but can't read it either. + +--- + +### r — Religion + +**Single value only** — one tradition + devoutness rating. Parser rejects multiple sub-IDs. + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `ch` | `(chr)` | Christian | `is` | `(isl)` | Islam | +| `ju` | `(jud)` | Judaism | `hi` | `(hin)` | Hindu | +| `bu` | `(bud)` | Buddhist | `sk` | `(sik)` | Sikh | +| `pg` | `(pag)` | Pagan | `de` | `(dei)` | Deist | +| `ag` | `(agn)` | Agnostic | `at` | `(ath)` | Atheist | +| `pn` | `(pan)` | Pantheist | `sp` | `(spi)` | Spiritual | +| `zo` | `(zor)` | Zoroastrian | `ja` | `(jain)` | Jain | +| `ta` | `(tao)` | Taoist | `sh` | `(shin)` | Shinto | +| `ba` | `(bah)` | Bahá'í | `dr` | `(druz)` | Druze | + +Rating = devoutness: 0 = Hostile, 4 = Cultural, 7 = Clergy-level. + +> `ris6` — devout Muslim. `rath4` — culturally atheist. + +--- + +### s — Sex / Relationships + +0. **Celibate** — Made your peace. +1. **Not active (unwilling)** — Dating profile is unrewarded effort. +2. **Not active** — 'Focusing on yourself.' Friends skeptical. +3. **Complicated** — Status requires a flowchart. +4. **Private** — None of your business. +5. **Dating** — At least two dating apps. +6. **Partnered** — Found someone who tolerates your geekery. +7. **Married w/ kids** — Evidence is small, loud, doesn't sleep. + +--- + +### t — TV / Film + +**Sci-Fi:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `st` | `(trek)` | Star Trek | `sw` | `(wars)` | Star Wars | +| `sg` | `(gate)` | Stargate | `ex` | `(exp)` | Expanse | +| `bs` | `(bsg)` | Battlestar | `bb` | `(bab)` | Babylon 5 | +| `ff` | — | Firefly | `du` | `(dune)` | Dune | +| `fn` | `(fnd)` | Foundation | `dw` | `(drwho)` | Doctor Who | +| `tb` | `(tbp)` | 3 Body Problem | `si` | `(silo)` | Silo | +| `fa` | `(fall)` | Fallout | `al` | `(alien)` | Alien | +| `bd` | `(blade)` | Blade Runner | `or` | `(orville)` | Orville | + +**Fantasy:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `go` | `(got)` | Game of Thrones | `wo` | `(wot)` | Wheel of Time | +| `ro` | `(rop)` | Rings of Power | `wi` | `(wit)` | Witcher | +| `sn` | `(sand)` | Sandman | | | | + +**Other:** + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `mp` | `(monty)` | Monty Python | `bt` | `(bbt)` | Big Bang Theory | +| `rm` | — | Rick & Morty | `bl` | — | Black Mirror | +| `sv` | `(sev)` | Severance | `mr` | `(mrobot)` | Mr. Robot | +| `hm` | `(humans)` | Humans | `ww` | `(westworld)` | Westworld | +| `dp` | `(devs)` | Devs | | | | + +Scale: **enthusiasm** (see global scale table). + +> 0: Crime against storytelling. +> 7: Know the lore deeper than the writers. + +--- + +### v — Politics + +Format: `v<social>/<economic>`. No modifiers. + +**Social:** 0 = Far-right authoritarian · 4 = Centrist · 7 = Far-left libertarian +**Economic:** 0 = Unregulated capitalism · 4 = Mixed economy · 7 = Full socialism + +> `v5/3` — socially center-left, economically center-right. +> `v4/4` — centrist. You annoy both sides equally. + +--- + +### w — Hardware + +0. **Defeated by USB** — The cable won. +1. **Can plug in** — Big hole is for power. +2. **Replaced a part** — Three YouTube videos and mild weeping. +3. **Troubleshoots** — Can tell RAM from a graphics card. +4. **Built a PC** — Cable management isn't great but it posted. +5. **Comfortable** — Friends call when things break. +6. **Mods regularly** — Void warranties recreationally. +7. **Builds everything** — Manufactured your own PCBs. + +--- + +### x — Comics / Manga + +| Short | (Long) | Name | Short | (Long) | Name | +|---|---|---|---|---|---| +| `dc` | — | DC Comics | `mv` | `(marvel)` | Marvel | +| `mg` | `(manga)` | Manga | `dl` | `(dilbert)` | Dilbert | +| `xk` | `(xkcd)` | XKCD | `in` | `(indie)` | Indie | +| `wt` | `(webtoon)` | Webtoon | `gn` | `(graphicnovel)` | Graphic Novels | + +Scale: **enthusiasm** (see global scale table). + +> 0: Comics are for children. (You're wrong.) +> 7: Collection requires its own room. + +--- + +### y — Fitness + +0. **What is outside** — Cardiovascular exercise is walking to the fridge. +1. **Avoids activity** — Physical activity is a social obligation. +2. **Occasional walk** — Coffee is the point, not the walk. +3. **Light activity** — Own athletic wear. Never been to a gym. +4. **Some exercise** — Can climb stairs without existential crisis. +5. **Regular workouts** — Track your steps. +6. **Very active** — Resting heart rate impresses doctors. +7. **Daily athlete** — Body is a temple. Temple has a squat rack. + +--- + +### z — Maker / DIY + +0. **Defeated by IKEA** — Allen keys are your nemesis. +1. **IKEA once** — One screw left over. Probably fine. +2. **Basic repairs** — Holes approximately where they should be. +3. **Handy** — Fixes hold. Duct tape is structural. +4. **Simple projects** — A shelf here, a birdhouse there. +5. **Regular maker** — Projects folder never shrinks. +6. **Advanced** — Workshop has zones. +7. **Built my house** — Hardware store sends Christmas card. + +--- +## Extending UGI + +Any field with sub-IDs accepts custom entries via `~`: + +``` +gcs/~mycology geek of CS and mycology +m~synthwave6 synthwave enthusiast +t~severance7 severance obsessed +j~larp5 LARP, competent +o~beos4 BeOS, neutral nostalgia +``` + +Letters `n` and `u` are reserved for future spec versions. + +--- +## Examples + +### jdoe — Full-stack developer + +``` +------- BEGIN UGI BLOCK ------- +v:0 @jdoe Gcs$/dev +A4 E4 Len7 +Oxa6 Owe5 Ppy6$ Pjs6$ Pts5 Prs4+6 Dvs6 W5 Q4 +I5 V5/4 Rag4 +Tst5 Tex7 K5 Xxk6 Jst5 Jpc6 Mrk5 Min4 +B5/4 C3 Hh4 Hf5 Hm4 S6 Y4 F4 Z3 +-------- END UGI BLOCK -------- +``` + +``` +ugi:0@jdoe:gcs$/dev,a4,b5/4,c3,hh4f5m4,e4,len7,oxa6we5,ppy6$js6$ts5rs4+6,dvs6,w5,q4,i5,v5/4,rag4,tst5ex7,k5,xxk6,jst5pc6,mrk5in4,s6,y4,f4,z3 +``` + +### neo — Security researcher + +``` +------- BEGIN UGI BLOCK ------- +v:0 @neo Gcy$/sec/os +A3 E6 Len6 Lzh4 +Oxd7$ Ofb6 Pc6$ Pba7 Pas5 Prs5 Ded7 W6 Q7$ +I1 V6/5 Rat5 +Tff7 Tbs6 Tbl6 K7 Jro6 Jpc7 Mmt6 Mel5 +B5/3 C1 Hh2 S2 Y5 F3 Z6 +-------- END UGI BLOCK -------- +``` + +``` +ugi:0@neo:gcy$/sec/os,a3,b5/3,c1,hh2,e6,len6zh4,oxd7$fb6,pc6$ba7as5rs5,ded7,w6,q7$,i1,v6/5,rat5,tff7bs6bl6,k7,jro6pc7,mmt6el5,s2,y5,f3,z6 +``` + +### hax0r — Hardware hacker + +``` +------- BEGIN UGI BLOCK ------- +v:0/3 @hax0r Gee$/rb/dy~solder +A5 E4 Lja7 Len5 +Oxg7 Ofb5 Pc5 Pba6 Pas7$ Dem5 W7$ Q6 +I2 V3/3 Rbu5 +Tbb6 Tff7 K5 Xxk6 Xdl5 Jre7 Jpc6 Jbg5 Mmt7 Mpk6 +B6/5 C2 Hh3 Hf6+7 Hm5 S3 Y5 F4 Z7$ +-------- END UGI BLOCK -------- +``` + +``` +ugi:0/3@hax0r:gee$/rb/dy~solder,a5,b6/5,c2,hh3f6+7m5,e4,lja7en5,oxg7fb5,pc5ba6as7$,dem5,w7$,q6,i2,v3/3,rbu5,tbb6ff7,k5,xxk6dl5,jre7pc6bg5,mmt7pk6,s3,y5,f4,z7$ +``` + +--- +## Comparison with predecessors + +| | Geek Code 3.12 (1996) | Geek Code 2026 | Hacker Key v4 (2006) | UGI v0 | +|---|---|---|---|---| +| Rating | +/- stacking | +/- stacking | 0-9 | 0-7 octal | +| Length | 4-6 lines | 4-8 lines | 1 line | 1 or 4-8 | +| Fields | ~25 | ~35+ | ~16 | 24+2 | +| URI-safe | No | No | Mostly | Yes | +| Case | Sensitive | Sensitive | Sensitive | Insensitive | +| Extensible | No | No | Limited | ~ custom | +| Format | Block | Block | Inline | Block+URI | +| Active | 1996 | GitHub | 2006 | Draft | + +### Fields retained + +`a` age brackets, `b` dimensions, `c` dress/clothing, `e` education, `g` geek type, expanded, `h` hair/beard, `k` books, `o` os merged, `p` programming, `q` security/pgp, `s` sex, `t` tv merged, `v` politics, `w` hardware — all present in one or both predecessors. + +### Fields added + +`d` — Editor war must be documented. +`f` — Geeks cook now. +`i` — Defining divide of the 2020s. +`j` — Gaming beyond DOOM. +`l` — Geek culture is global. +`m` — Absent from predecessors. +`r` — From Hacker Key. +`x` — Beyond Dilbert. +`y` — Modern geek exercises. +`z` — Maker movement. + +### Fields removed + +Usenet — dead. Kibo — niche. DOOM — use j. OS/2, VMS — use o+~custom. MBTI — pop psychology. Residence — low value. GitHub handle — @handle. I/O — abstract. Cracking — use q. Lang hacking — in g. Math level — in e+g. Prog methodology — in p. + +--- +## Quick reference + +``` +FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields> +SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed +MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom + +a age b build c clothing d editor / ide e education +f food / cooking g geek specialization h hair i ai stance j gaming +k books l spoken languages m music [n reserved] o operating system +p programming languages q security / hacking r religion s sex / relationships t tv / film +[u reserved] v politics w hardware x comics / manga y fitness +z maker / diy + +$+ f j l o p q w y z $ only g + only b c d e h k s none a i m r t v x +``` + +--- +## References + +- [Geek Code (v0.1–latest)](https://sig.codes/geek/) — Robert A. Hayden, 1993-1996 +- [Geek Code 4.0+](https://sig.codes/geek-4.0/) — exarobibliologist, 2019-2026 +- [Hacker Key (v0.1–latest)](https://sig.codes/hacker-key/) — Chris Allegretta, 2003-2006 +- [sig.codes](https://sig.codes/) +- [Geek Code on Wikipedia](https://en.wikipedia.org/wiki/Geek_Code) +- [Geek Code v3.12 mirror](https://geekcode.xyz/) +- [Hacker Key v4 mirror](https://sig.codes/hacker-key/4/) + +--- + +## License + +MIT — see [LICENSE](./LICENSE). +## Check + +- **[ugi.gumx.cc](https://ugi.gumx.cc)** ([repo](https://git.sr.ht/~gumxcc/ugi.gumx.cc)) — web-based UGI encoder, decoder, and format converter +- **ugi-cli** ([repo](https://git.sr.ht/~gumxcc/ugi-cli)) — command-line tool for encoding, decoding, and converting UGI strings + +--- + +## Generator scripts + +The `scripts/` directory contains generators that produce all artifacts from `ugi_registry_v0.json`. +Each script takes the registry JSON path and output file path as arguments: + +```bash +python scripts/gen_spec.py ugi_registry_v0.json README.md +python scripts/gen_abnf.py ugi_registry_v0.json grammar/UGI_GRAMMAR_v0.abnf +python scripts/gen_ebnf.py ugi_registry_v0.json grammar/UGI_GRAMMAR_v0.ebnf +``` + +### Requirements + +- Python 3.8+ +- No external dependencies + +### Project structure + +``` +ugi-spec/ +├── ugi_registry_v0.json # source of truth +├── scripts/ +│ ├── gen_spec.py # spec markdown generator +│ ├── gen_abnf.py # ABNF grammar generator +│ └── gen_ebnf.py # EBNF grammar generator +├── grammar/ +│ ├── UGI_GRAMMAR_v0.abnf # generated ABNF +│ └── UGI_GRAMMAR_v0.ebnf # generated EBNF +├── tests/ +│ └── cases.json # test cases for all field types and formats +└── README.md # this file (generated spec + usage) +``` + +--- + +## License + +MIT — see [LICENSE](./LICENSE). + diff --git a/spec/grammar/UGI_GRAMMAR_v0.abnf b/spec/grammar/UGI_GRAMMAR_v0.abnf new file mode 100644 index 0000000..d0f7da1 --- /dev/null +++ b/spec/grammar/UGI_GRAMMAR_v0.abnf @@ -0,0 +1,148 @@ +; UGI Grammar v0 — RFC 5234 ABNF +; Generated from ugi_registry_v0.json +; +; Compatible with abnf.dev/abnf2svg + +; === Top-level === + +ugi-string = "ugi:" version "@" handle ":" field-list + +version = 1*DIGIT ["/" 1*DIGIT] + +; handle: alphanumeric, hyphens, underscores, dots +handle = 1*(ALPHA / DIGIT / %x2D / %x5F / %x2E) + +field-list = field *("," field) + +; === Field dispatch === + +field = field-a / field-b / field-c / field-d / field-e / field-f / field-g / field-h / field-i / field-j / field-k / field-l / field-m / field-o / field-p / field-q / field-r / field-s / field-t / field-v / field-w / field-x / field-y / field-z + +; === Common rules === + +ODIGIT = %x30-37 + +paid = "$" + +aspire = "+" ODIGIT + +paid-aspire = paid [aspire] + +modifier = paid-aspire / aspire + +alternative = "/" ODIGIT + +; custom sub-ID: ~ followed by 2+ alpha chars +custom-sub = "~" 2*ALPHA + +; sub-ID: 1-4 alpha chars +sub-id = 1*4ALPHA + +; === Per-field rules === + +; a — Age (direct) +field-a = %x61 ODIGIT + +; b — Build (special) +field-b = %x62 ODIGIT [aspire] "/" ODIGIT [aspire] + +; c — Clothing (direct) +field-c = %x63 ODIGIT [alternative / aspire] + +; d — Editor / IDE (multi) +field-d-entry = (sub-id / custom-sub) ODIGIT [aspire] +field-d = %x64 1*field-d-entry + +; e — Education (direct) +field-e = %x65 ODIGIT [aspire] + +; f — Food / Cooking (direct) +field-f = %x66 ODIGIT [modifier] + +; g — Geek Specialization (special) +g-domain = 2*4ALPHA ["$"] +g-custom = "~" 2*ALPHA +field-g = %x67 (g-domain / g-custom) *("/" (g-domain / g-custom)) + +; h — Hair (multi) +field-h-entry = (sub-id / custom-sub) ODIGIT [aspire] +field-h = %x68 1*field-h-entry + +; i — AI Stance (direct) +field-i = %x69 ODIGIT + +; j — Gaming (multi) +field-j-entry = (sub-id / custom-sub) ODIGIT [modifier] +field-j = %x6A 1*field-j-entry + +; k — Books (direct) +field-k = %x6B ODIGIT [aspire] + +; l — Spoken Languages (multi) +field-l-entry = 2ALPHA ODIGIT [modifier] +field-l = %x6C 1*field-l-entry + +; m — Music (multi) +field-m-entry = (sub-id / custom-sub) ODIGIT +field-m = %x6D 1*field-m-entry + +; o — Operating System (multi) +field-o-entry = (sub-id / custom-sub) ODIGIT [modifier] +field-o = %x6F 1*field-o-entry + +; p — Programming Languages (multi) +field-p-entry = (sub-id / custom-sub) ODIGIT [modifier] +field-p = %x70 1*field-p-entry + +; q — Security / Hacking (direct) +field-q = %x71 ODIGIT [alternative / modifier] + +; r — Religion (single) +field-r = %x72 (sub-id / custom-sub) ODIGIT + +; s — Sex / Relationships (direct) +field-s = %x73 ODIGIT [alternative / aspire] + +; t — TV / Film (multi) +field-t-entry = (sub-id / custom-sub) ODIGIT +field-t = %x74 1*field-t-entry + +; v — Politics (special) +field-v = %x76 ODIGIT "/" ODIGIT + +; w — Hardware (direct) +field-w = %x77 ODIGIT [modifier] + +; x — Comics / Manga (multi) +field-x-entry = (sub-id / custom-sub) ODIGIT +field-x = %x78 1*field-x-entry + +; y — Fitness (direct) +field-y = %x79 ODIGIT [modifier] + +; z — Maker / DIY (direct) +field-z = %x7A ODIGIT [modifier] + +; === Block format === + +ugi-block = block-header CRLF block-first-line CRLF 1*(block-field-line CRLF) block-footer + +block-header = "------- BEGIN UGI BLOCK -------" +block-footer = "-------- END UGI BLOCK --------" + +block-first-line = "v:" version SP "@" handle [SP block-geek-field] + +; In block format, G field uses uppercase and appears on first line +block-geek-field = "G" (g-domain / g-custom) *("/" (g-domain / g-custom)) + +block-field-line = block-field *(SP block-field) + +; Block fields use uppercase code prefix repeated per sub-ID +block-field = ALPHA 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E) + +; === Core rules (RFC 5234) === + +ALPHA = %x41-5A / %x61-7A +DIGIT = %x30-39 +SP = %x20 +CRLF = %x0D.0A / %x0A diff --git a/spec/grammar/UGI_GRAMMAR_v0.ebnf b/spec/grammar/UGI_GRAMMAR_v0.ebnf new file mode 100644 index 0000000..e37f87f --- /dev/null +++ b/spec/grammar/UGI_GRAMMAR_v0.ebnf @@ -0,0 +1,167 @@ +/* UGI Grammar v0 — W3C EBNF */ +/* Generated from ugi_registry_v0.json */ +/* Compatible with bottlecaps.de/rr/ui */ + +/* === Top-level === */ + +ugi_string ::= 'ugi:' version '@' handle ':' field_list + +version ::= digit+ ( '/' digit+ )? + +/* handle: alphanumeric, hyphens, underscores, dots */ +handle ::= ( alpha | digit | '-' | '_' | '.' )+ + +field_list ::= field ( ',' field )* + +/* === Field dispatch === */ + +field ::= field_a + | field_b + | field_c + | field_d + | field_e + | field_f + | field_g + | field_h + | field_i + | field_j + | field_k + | field_l + | field_m + | field_o + | field_p + | field_q + | field_r + | field_s + | field_t + | field_v + | field_w + | field_x + | field_y + | field_z + +/* === Common rules === */ + +odigit ::= [0-7] + +paid ::= '$' + +aspire ::= '+' odigit + +paid_aspire ::= paid aspire? + +modifier ::= paid_aspire | aspire + +alternative ::= '/' odigit + +/* custom sub-ID: ~ followed by 2+ alpha chars */ +custom_sub ::= '~' alpha alpha alpha* + +/* sub-ID: 1-4 alpha chars */ +sub_id ::= alpha alpha? alpha? alpha? + +/* === Per-field rules === */ + +/* a — Age (direct) */ +field_a ::= 'a' odigit + +/* b — Build (special) */ +field_b ::= 'b' odigit aspire? '/' odigit aspire? + +/* c — Clothing (direct) */ +field_c ::= 'c' odigit ( alternative | aspire )? + +/* d — Editor / IDE (multi) */ +field_d_entry ::= ( sub_id | custom_sub ) odigit ( aspire )? +field_d ::= 'd' field_d_entry+ + +/* e — Education (direct) */ +field_e ::= 'e' odigit ( aspire )? + +/* f — Food / Cooking (direct) */ +field_f ::= 'f' odigit ( modifier )? + +/* g — Geek Specialization (special) */ +g_domain ::= alpha alpha alpha? alpha? paid? +g_custom ::= '~' alpha alpha alpha* +field_g ::= 'g' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )* + +/* h — Hair (multi) */ +field_h_entry ::= ( sub_id | custom_sub ) odigit ( aspire )? +field_h ::= 'h' field_h_entry+ + +/* i — AI Stance (direct) */ +field_i ::= 'i' odigit + +/* j — Gaming (multi) */ +field_j_entry ::= ( sub_id | custom_sub ) odigit ( modifier )? +field_j ::= 'j' field_j_entry+ + +/* k — Books (direct) */ +field_k ::= 'k' odigit ( aspire )? + +/* l — Spoken Languages (multi) */ +field_l_entry ::= alpha alpha odigit ( modifier )? +field_l ::= 'l' field_l_entry+ + +/* m — Music (multi) */ +field_m_entry ::= ( sub_id | custom_sub ) odigit +field_m ::= 'm' field_m_entry+ + +/* o — Operating System (multi) */ +field_o_entry ::= ( sub_id | custom_sub ) odigit ( modifier )? +field_o ::= 'o' field_o_entry+ + +/* p — Programming Languages (multi) */ +field_p_entry ::= ( sub_id | custom_sub ) odigit ( modifier )? +field_p ::= 'p' field_p_entry+ + +/* q — Security / Hacking (direct) */ +field_q ::= 'q' odigit ( alternative | modifier )? + +/* r — Religion (single) */ +field_r ::= 'r' ( sub_id | custom_sub ) odigit + +/* s — Sex / Relationships (direct) */ +field_s ::= 's' odigit ( alternative | aspire )? + +/* t — TV / Film (multi) */ +field_t_entry ::= ( sub_id | custom_sub ) odigit +field_t ::= 't' field_t_entry+ + +/* v — Politics (special) */ +field_v ::= 'v' odigit '/' odigit + +/* w — Hardware (direct) */ +field_w ::= 'w' odigit ( modifier )? + +/* x — Comics / Manga (multi) */ +field_x_entry ::= ( sub_id | custom_sub ) odigit +field_x ::= 'x' field_x_entry+ + +/* y — Fitness (direct) */ +field_y ::= 'y' odigit ( modifier )? + +/* z — Maker / DIY (direct) */ +field_z ::= 'z' odigit ( modifier )? + +/* === Block format === */ + +ugi_block ::= block_header newline block_first_line newline block_field_line+ block_footer + +block_header ::= '------- BEGIN UGI BLOCK -------' +block_footer ::= '-------- END UGI BLOCK --------' + +block_first_line ::= 'v:' version ' @' handle ( ' ' block_geek_field )? + +block_geek_field ::= 'G' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )* + +block_field_line ::= block_field ( ' ' block_field )* newline + +block_field ::= alpha ( alpha | digit | '$' | '+' | '/' | '~' )+ + +/* === Terminals === */ + +alpha ::= [a-zA-Z] +digit ::= [0-9] +newline ::= #xD #xA | #xA diff --git a/spec/scripts/gen_abnf.py b/spec/scripts/gen_abnf.py new file mode 100644 index 0000000..9569195 --- /dev/null +++ b/spec/scripts/gen_abnf.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Generate UGI_GRAMMAR_v0.abnf from ugi_registry_v0.json.""" + +import json +import os +import sys + + +def load_registry(path): + with open(path, "r") as f: + return json.load(f) + + +def hex_char(c): + """Return ABNF hex notation for a character.""" + return f"%x{ord(c):02X}" + + +def hex_range(start, end): + """Return ABNF hex range.""" + return f"%x{ord(start):02X}-{ord(end):02X}" + + +def collect_sub_ids(f): + """Collect all sub-ID codes from a field.""" + ids = [] + if "sub_ids" in f: + ids.extend(f["sub_ids"].keys()) + if "sub_id_groups" in f: + for group in f["sub_id_groups"].values(): + if "sub_ids" in group: + ids.extend(group["sub_ids"].keys()) + return ids + + +def gen_sub_id_rule(name, sub_ids): + """Generate ABNF rule matching any of the given sub-ID strings.""" + # Quote each as case-insensitive string + parts = [] + for sid in sorted(set(sub_ids)): + # ABNF quoted strings are case-insensitive by default + parts.append(f'"{sid}"') + return f"{name} = " + " / ".join(parts) + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} <registry.json> <output.abnf>", file=sys.stderr) + sys.exit(1) + + registry_path, output_path = sys.argv[1], sys.argv[2] + reg = load_registry(registry_path) + fields = reg["fields"] + + lines = [] + lines.append("; UGI Grammar v0 — RFC 5234 ABNF") + lines.append("; Generated from ugi_registry_v0.json") + lines.append(";") + lines.append("; Compatible with abnf.dev/abnf2svg") + lines.append("") + + # ── Top-level ── + lines.append("; === Top-level ===") + lines.append("") + lines.append('ugi-string = "ugi:" version "@" handle ":" field-list') + lines.append("") + lines.append('version = 1*DIGIT ["/" 1*DIGIT]') + lines.append("") + lines.append("; handle: alphanumeric, hyphens, underscores, dots") + lines.append("handle = 1*(ALPHA / DIGIT / %x2D / %x5F / %x2E)") + lines.append("") + lines.append('field-list = field *("," field)') + lines.append("") + + # ── Field dispatch ── + lines.append("; === Field dispatch ===") + lines.append("") + + # Build field alternatives + field_rules = [] + for code in sorted(fields.keys()): + field_rules.append(f"field-{code}") + + lines.append("field = " + " / ".join(field_rules)) + lines.append("") + + # ── Common rules ── + lines.append("; === Common rules ===") + lines.append("") + lines.append("ODIGIT = %x30-37") + lines.append("") + lines.append('paid = "$"') + lines.append("") + lines.append('aspire = "+" ODIGIT') + lines.append("") + lines.append("paid-aspire = paid [aspire]") + lines.append("") + lines.append("modifier = paid-aspire / aspire") + lines.append("") + lines.append('alternative = "/" ODIGIT') + lines.append("") + lines.append('; custom sub-ID: ~ followed by 2+ alpha chars') + lines.append('custom-sub = "~" 2*ALPHA') + lines.append("") + lines.append("; sub-ID: 1-4 alpha chars") + lines.append("sub-id = 1*4ALPHA") + lines.append("") + + # ── Per-field rules ── + lines.append("; === Per-field rules ===") + lines.append("") + + for code in sorted(fields.keys()): + f = fields[code] + ftype = f["type"] + mods = f.get("modifiers", {}) + has_paid = mods.get("paid", False) + has_aspire = mods.get("aspire", False) + has_alt = mods.get("alternative", False) + + hex_code = hex_char(code) + lines.append(f"; {code} — {f['name']} ({ftype})") + + if ftype == "direct": + # Build modifier part + mod_parts = [] + if has_alt: + mod_parts.append("alternative") + if has_paid and has_aspire: + mod_parts.append("modifier") + elif has_paid: + mod_parts.append("paid") + elif has_aspire: + mod_parts.append("aspire") + + if mod_parts: + mod_str = " [" + " / ".join(mod_parts) + "]" + else: + mod_str = "" + + lines.append(f"field-{code} = {hex_code} ODIGIT{mod_str}") + + elif ftype == "multi": + # Entry: sub-id + digit + optional modifier + mod_parts = [] + if has_paid and has_aspire: + mod_parts.append("modifier") + elif has_paid: + mod_parts.append("paid") + elif has_aspire: + mod_parts.append("aspire") + + if mod_parts: + mod_str = " [" + " / ".join(mod_parts) + "]" + else: + mod_str = "" + + entry_rule = f"field-{code}-entry" + if code == "l": + # l uses 2-char ISO 639-1 codes instead of generic sub-ids + lines.append( + f"{entry_rule} = 2ALPHA ODIGIT{mod_str}" + ) + else: + lines.append( + f"{entry_rule} = (sub-id / custom-sub) ODIGIT{mod_str}" + ) + lines.append( + f"field-{code} = {hex_code} 1*{entry_rule}" + ) + + elif ftype == "single": + mod_parts = [] + if has_paid and has_aspire: + mod_parts.append("modifier") + elif has_paid: + mod_parts.append("paid") + elif has_aspire: + mod_parts.append("aspire") + + if mod_parts: + mod_str = " [" + " / ".join(mod_parts) + "]" + else: + mod_str = "" + + lines.append( + f"field-{code} = {hex_code} (sub-id / custom-sub) ODIGIT{mod_str}" + ) + + elif ftype == "special": + if code == "g": + # g: domains separated by /, $ after domain, ~ for custom, no digits + lines.append(f'g-domain = 2*4ALPHA ["$"]') + lines.append(f'g-custom = "~" 2*ALPHA') + lines.append( + f'field-g = {hex_code} (g-domain / g-custom) *("/" (g-domain / g-custom))' + ) + elif code == "b": + # b: digit/digit, optional aspire on each + aspire_str = " [aspire]" if has_aspire else "" + lines.append( + f'field-{code} = {hex_code} ODIGIT{aspire_str} "/" ODIGIT{aspire_str}' + ) + elif code == "v": + lines.append( + f'field-{code} = {hex_code} ODIGIT "/" ODIGIT' + ) + else: + # Fallback + lines.append(f"field-{code} = {hex_code} 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)") + + lines.append("") + + # ── Block format ── + lines.append("; === Block format ===") + lines.append("") + lines.append( + 'ugi-block = block-header CRLF block-first-line CRLF ' + '1*(block-field-line CRLF) block-footer' + ) + lines.append("") + lines.append('block-header = "------- BEGIN UGI BLOCK -------"') + lines.append('block-footer = "-------- END UGI BLOCK --------"') + lines.append("") + lines.append( + 'block-first-line = "v:" version SP "@" handle [SP block-geek-field]' + ) + lines.append("") + lines.append('; In block format, G field uses uppercase and appears on first line') + lines.append( + 'block-geek-field = "G" (g-domain / g-custom) *("/" (g-domain / g-custom))' + ) + lines.append("") + lines.append( + 'block-field-line = block-field *(SP block-field)' + ) + lines.append("") + lines.append( + "; Block fields use uppercase code prefix repeated per sub-ID" + ) + lines.append( + 'block-field = ALPHA 1*(ALPHA / DIGIT / %x24 / %x2B / %x2F / %x7E)' + ) + lines.append("") + + # ── Core rules ── + lines.append("; === Core rules (RFC 5234) ===") + lines.append("") + lines.append("ALPHA = %x41-5A / %x61-7A") + lines.append("DIGIT = %x30-39") + lines.append("SP = %x20") + lines.append("CRLF = %x0D.0A / %x0A") + lines.append("") + + output = "\n".join(lines) + + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + with open(output_path, "w") as f: + f.write(output) + + print(f"Generated {output_path}") + + +if __name__ == "__main__": + main() diff --git a/spec/scripts/gen_ebnf.py b/spec/scripts/gen_ebnf.py new file mode 100644 index 0000000..65824bd --- /dev/null +++ b/spec/scripts/gen_ebnf.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Generate UGI_GRAMMAR_v0.ebnf from ugi_registry_v0.json.""" + +import json +import os +import sys + + +def load_registry(path): + with open(path, "r") as f: + return json.load(f) + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} <registry.json> <output.ebnf>", file=sys.stderr) + sys.exit(1) + + registry_path, output_path = sys.argv[1], sys.argv[2] + reg = load_registry(registry_path) + fields = reg["fields"] + + lines = [] + lines.append("/* UGI Grammar v0 — W3C EBNF */") + lines.append("/* Generated from ugi_registry_v0.json */") + lines.append("/* Compatible with bottlecaps.de/rr/ui */") + lines.append("") + + # ── Top-level ── + lines.append("/* === Top-level === */") + lines.append("") + lines.append("ugi_string ::= 'ugi:' version '@' handle ':' field_list") + lines.append("") + lines.append("version ::= digit+ ( '/' digit+ )?") + lines.append("") + lines.append("/* handle: alphanumeric, hyphens, underscores, dots */") + lines.append("handle ::= ( alpha | digit | '-' | '_' | '.' )+") + lines.append("") + lines.append("field_list ::= field ( ',' field )*") + lines.append("") + + # ── Field dispatch ── + lines.append("/* === Field dispatch === */") + lines.append("") + + field_alts = [f"field_{code}" for code in sorted(fields.keys())] + # Break into multiple lines for readability + lines.append("field ::= " + "\n | ".join(field_alts)) + lines.append("") + + # ── Common rules ── + lines.append("/* === Common rules === */") + lines.append("") + lines.append("odigit ::= [0-7]") + lines.append("") + lines.append("paid ::= '$'") + lines.append("") + lines.append("aspire ::= '+' odigit") + lines.append("") + lines.append("paid_aspire ::= paid aspire?") + lines.append("") + lines.append("modifier ::= paid_aspire | aspire") + lines.append("") + lines.append("alternative ::= '/' odigit") + lines.append("") + lines.append("/* custom sub-ID: ~ followed by 2+ alpha chars */") + lines.append("custom_sub ::= '~' alpha alpha alpha*") + lines.append("") + lines.append("/* sub-ID: 1-4 alpha chars */") + lines.append("sub_id ::= alpha alpha? alpha? alpha?") + lines.append("") + + # ── Per-field rules ── + lines.append("/* === Per-field rules === */") + lines.append("") + + for code in sorted(fields.keys()): + f = fields[code] + ftype = f["type"] + mods = f.get("modifiers", {}) + has_paid = mods.get("paid", False) + has_aspire = mods.get("aspire", False) + has_alt = mods.get("alternative", False) + + lines.append(f"/* {code} — {f['name']} ({ftype}) */") + + if ftype == "direct": + mod_parts = [] + if has_alt: + mod_parts.append("alternative") + if has_paid and has_aspire: + mod_parts.append("modifier") + elif has_paid: + mod_parts.append("paid") + elif has_aspire: + mod_parts.append("aspire") + + if mod_parts: + mod_str = " ( " + " | ".join(mod_parts) + " )?" + else: + mod_str = "" + + lines.append(f"field_{code} ::= '{code}' odigit{mod_str}") + + elif ftype == "multi": + mod_parts = [] + if has_paid and has_aspire: + mod_parts.append("modifier") + elif has_paid: + mod_parts.append("paid") + elif has_aspire: + mod_parts.append("aspire") + + if mod_parts: + mod_str = " ( " + " | ".join(mod_parts) + " )?" + else: + mod_str = "" + + if code == "l": + # l uses 2-char ISO 639-1 codes instead of generic sub-ids + lines.append( + f"field_{code}_entry ::= alpha alpha odigit{mod_str}" + ) + else: + lines.append( + f"field_{code}_entry ::= ( sub_id | custom_sub ) odigit{mod_str}" + ) + lines.append( + f"field_{code} ::= '{code}' field_{code}_entry+" + ) + + elif ftype == "single": + mod_parts = [] + if has_paid and has_aspire: + mod_parts.append("modifier") + elif has_paid: + mod_parts.append("paid") + elif has_aspire: + mod_parts.append("aspire") + + if mod_parts: + mod_str = " ( " + " | ".join(mod_parts) + " )?" + else: + mod_str = "" + + lines.append( + f"field_{code} ::= '{code}' ( sub_id | custom_sub ) odigit{mod_str}" + ) + + elif ftype == "special": + if code == "g": + lines.append("g_domain ::= alpha alpha alpha? alpha? paid?") + lines.append("g_custom ::= '~' alpha alpha alpha*") + lines.append( + "field_g ::= 'g' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*" + ) + elif code == "b": + aspire_str = " aspire?" if has_aspire else "" + lines.append( + f"field_b ::= 'b' odigit{aspire_str} '/' odigit{aspire_str}" + ) + elif code == "v": + lines.append("field_v ::= 'v' odigit '/' odigit") + else: + lines.append( + f"field_{code} ::= '{code}' ( alpha | digit | '$' | '+' | '/' | '~' )+" + ) + + lines.append("") + + # ── Block format ── + lines.append("/* === Block format === */") + lines.append("") + lines.append( + "ugi_block ::= block_header newline block_first_line newline " + "block_field_line+ block_footer" + ) + lines.append("") + lines.append("block_header ::= '------- BEGIN UGI BLOCK -------'") + lines.append("block_footer ::= '-------- END UGI BLOCK --------'") + lines.append("") + lines.append( + "block_first_line ::= 'v:' version ' @' handle ( ' ' block_geek_field )?" + ) + lines.append("") + lines.append( + "block_geek_field ::= 'G' ( g_domain | g_custom ) ( '/' ( g_domain | g_custom ) )*" + ) + lines.append("") + lines.append("block_field_line ::= block_field ( ' ' block_field )* newline") + lines.append("") + lines.append( + "block_field ::= alpha ( alpha | digit | '$' | '+' | '/' | '~' )+" + ) + lines.append("") + + # ── Terminals ── + lines.append("/* === Terminals === */") + lines.append("") + lines.append("alpha ::= [a-zA-Z]") + lines.append("digit ::= [0-9]") + lines.append("newline ::= #xD #xA | #xA") + lines.append("") + + output = "\n".join(lines) + + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + with open(output_path, "w") as f: + f.write(output) + + print(f"Generated {output_path}") + + +if __name__ == "__main__": + main() diff --git a/spec/scripts/gen_spec.py b/spec/scripts/gen_spec.py new file mode 100644 index 0000000..4ab8a20 --- /dev/null +++ b/spec/scripts/gen_spec.py @@ -0,0 +1,1053 @@ +#!/usr/bin/env python3 +"""Generate UGI_SPEC_v0.md from ugi_registry_v0.json.""" + +import json +import os +import sys + + +def load_registry(path): + with open(path, "r") as f: + return json.load(f) + + +def gen_title(spec): + lines = [] + lines.append(f"# {spec['name']} — {spec['full_name']}") + lines.append("") + lines.append( + f"**Version: {spec['version']} ({spec['status'].title()}) · " + f"{spec['date']} · {spec['license']} License**" + ) + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_overview(spec): + lines = [] + lines.append("## Overview") + lines.append("") + lines.append(spec["description"]) + lines.append("") + lines.append("It descends from two predecessors:") + lines.append("") + for p in spec["predecessors"]: + line = f"- [{p['name']}]({p['url']}) by {p['author']} ({p['years']})" + if "continuation" in p: + c = p["continuation"] + line += f", with its [{c['name']}]({c['url']}) by {c['author']} ({c['years']})" + lines.append(line) + lines.append("") + lines.append( + "UGI takes the cultural breadth of the Geek Code, the numeric compactness " + "of the Hacker Key, and adds URI safety, modern fields, and extensibility." + ) + lines.append("") + lines.append("### Design goals") + lines.append("") + lines.append("1. Single-letter field codes — 24 active, 2 reserved") + lines.append("2. Octal (0–7) rating scale") + lines.append("3. URI-safe characters only — no percent-encoding needed") + lines.append("4. Dual format — URI for machines, block for humans") + lines.append("5. Case-insensitive") + lines.append("6. Extensible via `~` custom sub-IDs") + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_formats(fmt): + lines = [] + lines.append("## Formats") + lines.append("") + lines.append("### URI") + lines.append("") + lines.append("```") + lines.append(fmt["uri"]["template"]) + lines.append("```") + lines.append("") + lines.append("### Block") + lines.append("") + lines.append("```") + lines.append(fmt["block"]["header"]) + lines.append(fmt["block"]["first_line"]) + lines.append("<field> <field> ...") + lines.append(fmt["block"]["footer"]) + lines.append("```") + lines.append("") + lines.append( + "In block format, field codes are repeated per sub-ID for readability: " + "`Ppy6 Prs5` instead of `ppy6rs5`. Long-form sub-ID aliases (noted in " + "registries) are permitted in block format." + ) + lines.append("") + lines.append("### Conversion") + lines.append("") + lines.append( + "Block → URI: remove header/footer, merge repeated field codes, " + "replace spaces with commas, prepend `ugi:`." + ) + lines.append("") + lines.append( + "URI → Block: split on commas, expand merged sub-IDs by repeating " + "field codes, add header/footer." + ) + lines.append("") + lines.append( + "No modifier or character changes are needed — both formats use identical symbols." + ) + lines.append("") + lines.append("### Mandatory fields") + lines.append("") + mand = ", ".join(f"`{f}`" for f in fmt["mandatory_fields"]) + lines.append( + f"`@handle` and {mand} (geek type) are required. All others are optional." + ) + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_grammar(fields): + lines = [] + lines.append("## Grammar") + lines.append("") + lines.append("```") + lines.append("<field_code><sub_id><digit>[<modifier>]...") + lines.append("```") + lines.append("") + lines.append("- **Field codes** — single letter (a–z), case-insensitive") + lines.append("- **Sub-IDs** — 1–4 lowercase alpha chars") + lines.append("- **Ratings** — single octal digit (0–7), required on every sub-ID") + lines.append("- **Custom sub-IDs** — `~` prefix: `~fermenting5`") + lines.append("- **Modifiers** — after the digit:") + lines.append(" - `$` — paid") + lines.append(" - `+` followed by digit — aspiring toward that level") + lines.append(" - `$+` combined — paid and aspiring") + lines.append( + '- **Alternatives** — `/` between two digits on direct-value fields: `c3/6`' + ) + lines.append("") + lines.append("### Special formats") + lines.append("") + lines.append("| Field | Format | Example |") + lines.append("|---|---|---|") + lines.append( + "| `g` geek type | `/` separates domains, `~` for custom, `$` for paid, no ratings | `gcs$/ai/wr~fermenting` |" + ) + lines.append("| `b` build | `<height>/<width>` | `b6/3` |") + lines.append("| `l` languages | ISO 639-1 code + rating | `lar7en6` |") + lines.append("| `v` politics | `<social>/<economic>` | `v5/3` |") + lines.append("") + lines.append("### Parser algorithm") + lines.append("") + lines.append( + "After each comma or space, read a single letter (field code). Then:" + ) + lines.append("") + lines.append( + "- `g` → read alpha tokens separated by `/` and `~`-prefixed tokens until delimiter" + ) + lines.append("- `b` → digit, `/`, digit") + lines.append("- `v` → digit, `/`, digit") + lines.append("- `l` → repeat: 2 alpha + digit + optional modifier") + lines.append("- `r` → one sub-id + digit (single value only)") + lines.append("- direct fields → digit + optional modifier") + lines.append("- direct-alt fields → digit + optional `/` + digit + optional modifier") + lines.append("- multi fields → repeat: alpha (sub-id) + digit + optional modifier") + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_scale(scale): + lines = [] + lines.append("## Rating scale (0–7)") + lines.append("") + lines.append("| Value | General | Proficiency | Enthusiasm | Stance |") + lines.append("|---|---|---|---|---|") + for v in scale["values"]: + lines.append( + f"| {v['value']} | {v['general']} | {v['proficiency']} | " + f"{v['enthusiasm']} | {v['stance']} |" + ) + lines.append("") + return "\n".join(lines) + + +def gen_modifier_permissions(fields, modifiers): + lines = [] + lines.append("### Modifier permissions") + lines.append("") + lines.append("| Modifiers | Fields |") + lines.append("|---|---|") + + both = [] + paid_only = [] + aspire_only = [] + neither = [] + + for code in sorted(fields.keys()): + f = fields[code] + mods = f.get("modifiers", {}) + has_paid = mods.get("paid", False) + has_aspire = mods.get("aspire", False) + if has_paid and has_aspire: + both.append(code) + elif has_paid: + paid_only.append(code) + elif has_aspire: + aspire_only.append(code) + else: + neither.append(code) + + if both: + lines.append( + f"| `$` and `+` | {' '.join(f'`{c}`' for c in both)} |" + ) + if paid_only: + lines.append( + f"| `$` only | {' '.join(f'`{c}`' for c in paid_only)} |" + ) + if aspire_only: + lines.append( + f"| `+` only | {' '.join(f'`{c}`' for c in aspire_only)} |" + ) + if neither: + lines.append( + f"| Neither | {' '.join(f'`{c}`' for c in neither)} |" + ) + + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_field_list(fields, fmt): + lines = [] + lines.append("## Field list") + lines.append("") + lines.append("| Code | Field | Type | Category |") + lines.append("|---|---|---|---|") + + reserved = fmt.get("reserved_codes", []) + all_codes = sorted(set(list(fields.keys()) + reserved)) + + for code in all_codes: + if code in reserved: + lines.append(f"| `{code}` | *(reserved)* | — | — |") + continue + f = fields[code] + ftype = f["type"] + mods = f.get("modifiers", {}) + has_alt = mods.get("alternative", False) + + type_str = ftype.capitalize() + if ftype == "direct" and has_alt: + type_str = "Direct, `/` ok" + elif ftype == "special": + if code == "b": + type_str = "Special (h/w)" + elif code == "v": + type_str = "Special (s/e)" + elif code == "g": + type_str = "Special" + elif code == "l": + type_str = "Multi (ISO)" + elif ftype == "multi": + if code == "h": + type_str = "Multi (1-letter)" + elif code == "l": + type_str = "Multi (ISO)" + elif ftype == "single": + type_str = "Single" + + lines.append( + f"| `{code}` | {f['name']} | {type_str} | {f['category'].title()} |" + ) + + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_field_def_direct(code, f): + """Generate a direct-type field definition.""" + lines = [] + vals = f.get("values", {}) + if vals: + for i in range(8): + v = vals.get(str(i), {}) + label = v.get("label", "—") + humor = v.get("humor") + if humor: + lines.append(f"{i}. **{label}** — {humor}") + else: + lines.append(f"{i}. **{label}**") + lines.append("") + return "\n".join(lines) + + +def gen_field_def_special_b(f): + """Generate build field definition.""" + lines = [] + lines.append( + "Format: `b<height>/<width>`. Each 0–7. " + "0 = extremely small, 4 = average, 7 = extremely large. " + "`+` modifier permitted." + ) + lines.append("") + for dim_name, dim in f["dimensions"].items(): + lines.append(f"**{dim_name.title()}:** ", ) + vals = dim["values"] + parts = [f"{k} = {v}" for k, v in sorted(vals.items(), key=lambda x: int(x[0]))] + lines[-1] += " · ".join(parts) + lines.append("") + lines.append("> `b6/3` — tall and slim.") + lines.append("> `b4/4` — average. Chairs were designed for you.") + lines.append("") + return "\n".join(lines) + + +def gen_field_def_special_v(f): + """Generate politics field definition.""" + lines = [] + lines.append("Format: `v<social>/<economic>`. No modifiers.") + lines.append("") + for dim_name, dim in f["dimensions"].items(): + vals = dim["values"] + low = vals.get("0", "") + mid = vals.get("4", "") + high = vals.get("7", "") + lines.append(f"**{dim_name.title()}:** 0 = {low} · 4 = {mid} · 7 = {high}") + lines.append("") + lines.append("> `v5/3` — socially center-left, economically center-right.") + lines.append("> `v4/4` — centrist. You annoy both sides equally.") + lines.append("") + return "\n".join(lines) + + +def gen_field_def_special_g(f): + """Generate geek type field definition.""" + lines = [] + lines.append( + "Mandatory. `/` separates domains. `~` prefixes custom domains. " + "`$` after a domain = paid. No ratings." + ) + lines.append("") + + groups = f.get("sub_id_groups", {}) + for group_key, group in groups.items(): + label = group.get("label", group_key) + sub_ids = group.get("sub_ids", {}) + + # For larger groups, use a table + items = list(sub_ids.items()) + if len(items) > 7: + lines.append(f"#### {label}") + lines.append("") + lines.append("| Code | Domain | Code | Domain |") + lines.append("|---|---|---|---|") + # Pair them up + for i in range(0, len(items), 2): + c1, n1 = items[i] + if isinstance(n1, dict): + n1 = n1.get("name", n1.get("long", c1)) + row = f"| `{c1}` | {n1}" + if i + 1 < len(items): + c2, n2 = items[i + 1] + if isinstance(n2, dict): + n2 = n2.get("name", n2.get("long", c2)) + row += f" | `{c2}` | {n2} |" + else: + row += " | | |" + lines.append(row) + lines.append("") + else: + # Inline format for smaller groups + lines.append(f"#### {label}") + lines.append("") + parts = [] + for c, n in items: + if isinstance(n, dict): + n = n.get("name", n.get("long", c)) + parts.append(f"`{c}` {n}") + lines.append(" · ".join(parts)) + lines.append("") + + lines.append("#### Custom") + lines.append("") + lines.append( + "`~fermenting` `~beekeeping` `~lockpicking` `~origami` — anything goes." + ) + lines.append("") + lines.append("> `gcs$/ai/wr~fermenting` — paid CS geek, also into AI, writing, and fermenting.") + lines.append("") + return "\n".join(lines) + + +def gen_sub_id_table(sub_ids): + """Generate a sub-ID table from a flat sub_ids dict.""" + lines = [] + items = list(sub_ids.items()) + + # Check if it's a two-column table or three-column + has_long = any( + isinstance(v, dict) and "long" in v for v in sub_ids.values() + ) + + if has_long: + # Pair them for a wider table + lines.append("| Short | (Long) | Name | Short | (Long) | Name |") + lines.append("|---|---|---|---|---|---|") + for i in range(0, len(items), 2): + c1, v1 = items[i] + if isinstance(v1, dict): + long1 = f"`({v1['long']})`" if "long" in v1 else "—" + name1 = v1.get("name", c1) + else: + long1 = "—" + name1 = v1 + row = f"| `{c1}` | {long1} | {name1}" + + if i + 1 < len(items): + c2, v2 = items[i + 1] + if isinstance(v2, dict): + long2 = f"`({v2['long']})`" if "long" in v2 else "—" + name2 = v2.get("name", c2) + else: + long2 = "—" + name2 = v2 + row += f" | `{c2}` | {long2} | {name2} |" + else: + row += " | | | |" + lines.append(row) + else: + lines.append("| Short | Name |") + lines.append("|---|---|") + for c, v in items: + if isinstance(v, dict): + name = v.get("name", c) + else: + name = v + lines.append(f"| `{c}` | {name} |") + + lines.append("") + return "\n".join(lines) + + +def gen_grouped_sub_ids(groups, inline_threshold=7): + """Generate sub-ID tables from grouped sub_ids.""" + lines = [] + for group_key, group in groups.items(): + label = group.get("label", group_key) + sub_ids = group.get("sub_ids", {}) + items = list(sub_ids.items()) + + # Check for long forms + has_long = any( + isinstance(v, dict) and "long" in v for v in sub_ids.values() + ) + + if len(items) <= inline_threshold and not has_long: + # Inline format + parts = [] + for c, v in items: + if isinstance(v, dict): + long_str = f"({v['long']}) " if "long" in v else "" + name = v.get("name", c) + else: + long_str = "" + name = v + parts.append(f"`{c}` {long_str}{name}") + lines.append(f"**{label}:**") + lines.append(" · ".join(parts)) + lines.append("") + else: + # Table format with long forms + lines.append(f"**{label}:**") + lines.append("") + if has_long: + lines.append("| Short | (Long) | Name | Short | (Long) | Name |") + lines.append("|---|---|---|---|---|---|") + for i in range(0, len(items), 2): + c1, v1 = items[i] + if isinstance(v1, dict): + long1 = f"`({v1['long']})`" if "long" in v1 else "—" + name1 = v1.get("name", c1) + else: + long1 = "—" + name1 = v1 + row = f"| `{c1}` | {long1} | {name1}" + if i + 1 < len(items): + c2, v2 = items[i + 1] + if isinstance(v2, dict): + long2 = f"`({v2['long']})`" if "long" in v2 else "—" + name2 = v2.get("name", c2) + else: + long2 = "—" + name2 = v2 + row += f" | `{c2}` | {long2} | {name2} |" + else: + row += " | | | |" + lines.append(row) + else: + lines.append("| Short | Name |") + lines.append("|---|---|") + for c, v in items: + if isinstance(v, dict): + name = v.get("name", c) + else: + name = v + lines.append(f"| `{c}` | {name} |") + lines.append("") + + return "\n".join(lines) + + +def gen_field_def_multi(code, f, scale): + """Generate a multi-type field definition.""" + lines = [] + + # Sub-IDs + if "sub_id_groups" in f: + lines.append(gen_grouped_sub_ids(f["sub_id_groups"])) + elif "sub_ids" in f: + lines.append(gen_sub_id_table(f["sub_ids"])) + + # Scale info + scale_type = f.get("scale_type") + if scale_type and scale_type != "custom": + lines.append(f"Scale: **{scale_type}** (see global scale table).") + lines.append("") + + # Custom scale labels + if "scale_labels" in f: + humor = f.get("humor_scale", {}) or {} + for i in range(8): + label = f["scale_labels"].get(str(i), "—") + h = humor.get(str(i)) + if h: + lines.append(f"{i}. **{label}** — {h}") + else: + lines.append(f"{i}. **{label}**") + lines.append("") + elif f.get("humor_scale"): + humor = f["humor_scale"] + for val in ["0", "7"]: + if val in humor: + lines.append(f"> {val}: {humor[val]}") + lines.append("") + + return "\n".join(lines) + + +def gen_field_def_single(code, f): + """Generate a single-type field definition.""" + lines = [] + lines.append("**Single value only** — one tradition + devoutness rating. Parser rejects multiple sub-IDs.") + lines.append("") + + if "sub_ids" in f: + lines.append(gen_sub_id_table(f["sub_ids"])) + + if "scale_labels" in f: + sl = f["scale_labels"] + lines.append(f"Rating = devoutness: 0 = {sl.get('0', '')}, 4 = {sl.get('4', '')}, 7 = {sl.get('7', '')}.") + lines.append("") + + # Example + if code == "r": + lines.append("> `ris6` — devout Muslim. `rath4` — culturally atheist.") + lines.append("") + + return "\n".join(lines) + + +def gen_field_def_lang(f): + """Generate spoken languages field definition.""" + lines = [] + lines.append("ISO 639-1 codes + proficiency 0–7. `$` and `+` permitted.") + lines.append("") + + if "scale_labels" in f: + humor = f.get("humor_scale", {}) or {} + for i in range(8): + label = f["scale_labels"].get(str(i), "—") + h = humor.get(str(i)) + if h: + lines.append(f"{i}. **{label}** — {h}") + else: + lines.append(f"{i}. **{label}**") + lines.append("") + + # Common codes + codes = f.get("common_codes", {}) + if codes: + items = list(codes.items()) + # Show first ~25 inline + parts = [f"`{c}` {n}" for c, n in items[:26]] + lines.append("Common codes: " + " · ".join(parts)) + lines.append("") + + lines.append( + "> `lar7$en6de3+5` — native Arabic (paid translator), fluent English, " + "learning German (beginner, aspiring proficient)." + ) + lines.append("") + return "\n".join(lines) + + +def gen_field_definitions(fields, scale): + lines = [] + lines.append("## Field definitions") + lines.append("") + lines.append("---") + lines.append("") + + for code in sorted(fields.keys()): + f = fields[code] + lines.append(f"### {code} — {f['name']}") + lines.append("") + + ftype = f["type"] + + if ftype == "direct": + lines.append(gen_field_def_direct(code, f)) + elif ftype == "special": + if code == "g": + lines.append(gen_field_def_special_g(f)) + elif code == "b": + lines.append(gen_field_def_special_b(f)) + elif code == "v": + lines.append(gen_field_def_special_v(f)) + elif code == "l": + lines.append(gen_field_def_lang(f)) + elif ftype == "multi": + # Special intro for certain fields + if code == "d": + lines.append("The editor holy war, now quantified.") + lines.append("") + if code == "l": + # l is type "multi" in JSON but uses ISO 639-1 codes + lines.append(gen_field_def_lang(f)) + else: + lines.append(gen_field_def_multi(code, f, scale)) + elif ftype == "single": + lines.append(gen_field_def_single(code, f)) + + # Note modifiers + mods = f.get("modifiers", {}) + mod_parts = [] + if mods.get("paid"): + mod_parts.append("`$` paid") + if mods.get("aspire"): + mod_parts.append("`+` aspire") + if mods.get("alternative"): + mod_parts.append("`/` alternative") + # Only show if there are interesting modifiers to mention + # (skip if already covered in special format text) + if mod_parts and ftype not in ("special",): + pass # Modifiers are covered in the permissions table + + lines.append("---") + lines.append("") + + return "\n".join(lines) + + +def gen_examples(examples, fields): + lines = [] + lines.append("## Examples") + lines.append("") + + for ex in examples: + handle = ex["handle"] + desc = ex["description"] + uri = ex["uri"] + + lines.append(f"### {handle} — {desc}") + lines.append("") + + # Generate block format from URI + block = uri_to_block(uri, fields) + lines.append("```") + for bl in block: + lines.append(bl) + lines.append("```") + lines.append("") + lines.append("```") + lines.append(uri) + lines.append("```") + lines.append("") + + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def uri_to_block(uri, fields): + """Convert a URI string to block format lines.""" + # Parse: ugi:<version>@<handle>:<fields> + # Just use the existing block examples from the hand-written spec + # since exact conversion logic is complex. For now, output the URI. + # Actually, let's parse it properly. + + rest = uri + if rest.startswith("ugi:"): + rest = rest[4:] + + # Split version@handle:fields + at_idx = rest.index("@") + version = rest[:at_idx] + rest = rest[at_idx + 1:] + + colon_idx = rest.index(":") + handle = rest[:colon_idx] + field_str = rest[colon_idx + 1:] + + field_parts = field_str.split(",") + + block_lines = [] + block_lines.append("------- BEGIN UGI BLOCK -------") + + # First line: version, handle, and g field + first_line = f"v:{version} @{handle}" + remaining = [] + + for part in field_parts: + if not part: + continue + code = part[0].lower() + if code == "g": + # Geek type goes on first line + first_line += f" G{part[1:]}" + else: + remaining.append(part) + + block_lines.append(first_line) + + # Group remaining fields into lines by category + identity = [] + tech = [] + stance = [] + entertainment = [] + lifestyle = [] + + for part in remaining: + code = part[0].lower() + f = fields.get(code) + if not f: + tech.append(part) + continue + cat = f.get("category", "") + # Expand multi fields + expanded = expand_field_block(part, f) + if cat == "identity": + identity.extend(expanded) + elif cat in ("tech",): + tech.extend(expanded) + elif cat == "stance": + stance.extend(expanded) + elif cat == "entertainment": + entertainment.extend(expanded) + elif cat in ("lifestyle", "appearance"): + lifestyle.extend(expanded) + else: + tech.extend(expanded) + + # Output lines + if identity: + block_lines.append(" ".join(identity)) + if tech: + block_lines.append(" ".join(tech)) + if stance: + block_lines.append(" ".join(stance)) + if entertainment: + block_lines.append(" ".join(entertainment)) + if lifestyle: + block_lines.append(" ".join(lifestyle)) + + block_lines.append("-------- END UGI BLOCK --------") + return block_lines + + +def expand_field_block(part, f): + """Expand a URI field part into block format parts.""" + code = part[0] + rest = part[1:] + ftype = f["type"] + uc = code.upper() + + if ftype == "direct": + return [f"{uc}{rest}"] + elif ftype == "special": + if code == "b" or code == "v": + return [f"{uc}{rest}"] + elif code == "g": + return [f"G{rest}"] + elif code == "l": + # Parse: 2-char code + digit + mods, repeating + expanded = [] + i = 0 + while i < len(rest): + if rest[i] == "~": + # custom + j = i + 1 + while j < len(rest) and rest[j].isalpha(): + j += 1 + sub = rest[i:j] + digit = rest[j] if j < len(rest) and rest[j].isdigit() else "" + mod = "" + k = j + 1 + while k < len(rest) and rest[k] in "$+0123456789": + mod += rest[k] + k += 1 + expanded.append(f"L{sub}{digit}{mod}") + i = k + elif rest[i].isalpha(): + sub = rest[i:i + 2] + digit = rest[i + 2] if i + 2 < len(rest) else "" + mod = "" + k = i + 3 + while k < len(rest) and rest[k] in "$+0123456789": + mod += rest[k] + k += 1 + expanded.append(f"L{sub}{digit}{mod}") + i = k + else: + i += 1 + return expanded + return [f"{uc}{rest}"] + elif ftype in ("multi", "single"): + # Parse sub-id + digit + mods + expanded = [] + i = 0 + while i < len(rest): + if rest[i] == "~": + j = i + 1 + while j < len(rest) and rest[j].isalpha(): + j += 1 + sub = rest[i:j] + digit = rest[j] if j < len(rest) and rest[j].isdigit() else "" + mod = "" + k = j + 1 + while k < len(rest) and rest[k] in "$+0123456789": + mod += rest[k] + k += 1 + expanded.append(f"{uc}{sub}{digit}{mod}") + i = k + elif rest[i].isalpha(): + # Read sub-id (1-4 alpha chars) + j = i + while j < len(rest) and rest[j].isalpha(): + j += 1 + sub = rest[i:j] + digit = rest[j] if j < len(rest) and rest[j].isdigit() else "" + mod = "" + k = j + 1 + while k < len(rest) and rest[k] in "$+0123456789": + mod += rest[k] + k += 1 + expanded.append(f"{uc}{sub}{digit}{mod}") + i = k + else: + i += 1 + return expanded + return [f"{uc}{rest}"] + + +def gen_extending(): + lines = [] + lines.append("## Extending UGI") + lines.append("") + lines.append("Any field with sub-IDs accepts custom entries via `~`:") + lines.append("") + lines.append("```") + lines.append("gcs/~mycology geek of CS and mycology") + lines.append("m~synthwave6 synthwave enthusiast") + lines.append("t~severance7 severance obsessed") + lines.append("j~larp5 LARP, competent") + lines.append("o~beos4 BeOS, neutral nostalgia") + lines.append("```") + lines.append("") + lines.append("Letters `n` and `u` are reserved for future spec versions.") + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_comparison(comparison): + lines = [] + lines.append("## Comparison with predecessors") + lines.append("") + lines.append( + "| | Geek Code 3.12 (1996) | Geek Code 2026 | Hacker Key v4 (2006) | UGI v0 |" + ) + lines.append("|---|---|---|---|---|") + for row in comparison["features"]: + lines.append("| " + " | ".join(row) + " |") + lines.append("") + + # Retained + lines.append("### Fields retained") + lines.append("") + parts = [] + for r in comparison["retained"]: + parts.append(f"`{r['ugi']}` {r['note'].lower()}") + lines.append( + ", ".join(parts) + + " — all present in one or both predecessors." + ) + lines.append("") + + # Added + lines.append("### Fields added") + lines.append("") + for a in comparison["added"]: + lines.append(f"`{a['ugi']}` — {a['reason']}. ", ) + lines.append("") + + # Removed + lines.append("### Fields removed") + lines.append("") + parts = [] + for r in comparison["removed"]: + parts.append(f"{r['field']} — {r['reason'].lower()}") + lines.append(". ".join(parts) + ".") + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_quick_ref(fields): + lines = [] + lines.append("## Quick reference") + lines.append("") + lines.append("```") + lines.append("FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields>") + lines.append( + "SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed" + ) + lines.append( + "MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom" + ) + lines.append("") + + # Field codes + codes = sorted(fields.keys()) + all_codes = [] + for c in "abcdefghijklmnopqrstuvwxyz": + if c in fields: + f = fields[c] + name = f["name"].lower() + all_codes.append(f"{c} {name}") + elif c in ("n", "u"): + all_codes.append(f"[{c} reserved]") + + # Print in rows of 5 + for i in range(0, len(all_codes), 5): + row = all_codes[i : i + 5] + lines.append(" ".join(f"{x:<14}" for x in row).rstrip()) + lines.append("") + + # Modifier groups + both = [] + paid_only = [] + aspire_only = [] + neither = [] + for code in sorted(fields.keys()): + f = fields[code] + mods = f.get("modifiers", {}) + has_paid = mods.get("paid", False) + has_aspire = mods.get("aspire", False) + if has_paid and has_aspire: + both.append(code) + elif has_paid: + paid_only.append(code) + elif has_aspire: + aspire_only.append(code) + else: + neither.append(code) + + lines.append(f"$+ {' '.join(both)} $ only {' '.join(paid_only)} + only {' '.join(aspire_only)} none {' '.join(neither)}") + lines.append("```") + lines.append("") + lines.append("---") + lines.append("") + return "\n".join(lines) + + +def gen_references(spec): + lines = [] + lines.append("## References") + lines.append("") + for p in spec["predecessors"]: + lines.append(f"- [{p['name']} (v0.1–latest)]({p['url']}) — {p['author']}, {p['years']}") + if "continuation" in p: + c = p["continuation"] + lines.append(f"- [{c['name']}]({c['url']}) — {c['author']}, {c['years']}") + for r in spec.get("references", []): + lines.append(f"- [{r['title']}]({r['url']})") + lines.append("") + lines.append("---") + lines.append("") + lines.append("## License") + lines.append("") + lines.append(f"{spec['license']} — see [LICENSE](./LICENSE).") + lines.append("") + return "\n".join(lines) + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} <registry.json> <output.md>", file=sys.stderr) + sys.exit(1) + + registry_path, output_path = sys.argv[1], sys.argv[2] + reg = load_registry(registry_path) + spec = reg["spec"] + fmt = reg["format"] + scale = reg["scale"] + fields = reg["fields"] + modifiers = reg["modifiers"] + examples = reg["examples"] + comparison = reg["comparison"] + + parts = [ + gen_title(spec), + gen_overview(spec), + gen_formats(fmt), + gen_grammar(fields), + gen_scale(scale), + gen_modifier_permissions(fields, modifiers), + gen_field_list(fields, fmt), + gen_field_definitions(fields, scale), + gen_extending(), + gen_examples(examples, fields), + gen_comparison(comparison), + gen_quick_ref(fields), + gen_references(spec), + ] + + # Each part ends with "\n" from join; ensure blank line between sections + output = "\n".join(p.rstrip("\n") for p in parts) + "\n" + + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + with open(output_path, "w") as f: + f.write(output) + + print(f"Generated {output_path}") + + +if __name__ == "__main__": + main() diff --git a/spec/tests/cases.json b/spec/tests/cases.json new file mode 100644 index 0000000..d27976f --- /dev/null +++ b/spec/tests/cases.json @@ -0,0 +1,780 @@ +{ + "_doc": "UGI test cases. Each case has a URI, its equivalent block, and the expected decoded fields. All derived from ugi_registry_v0.json.", + + "cases": [ + + { + "id": "minimal", + "description": "Minimum valid UGI: handle + mandatory geek field, single domain", + "uri": "ugi:0@min:gcs", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @min Gcs\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "min", + "fields": { + "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": false}] } + } + } + }, + + { + "id": "minimal_paid_geek", + "description": "Single paid geek domain", + "uri": "ugi:0@dev:gcs$", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @dev Gcs$\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "dev", + "fields": { + "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": true}] } + } + } + }, + + { + "id": "geek_multi_domain", + "description": "Multiple geek domains, one paid", + "uri": "ugi:0@alice:gcs$/ai/wr", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @alice Gcs$/ai/wr\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "alice", + "fields": { + "g": { "type": "special", "domains": [ + {"id": "cs", "name": "Computer Science", "paid": true}, + {"id": "ai", "name": "Artificial Intelligence", "paid": false}, + {"id": "wr", "name": "Writing", "paid": false} + ]} + } + } + }, + + { + "id": "geek_custom_domain", + "description": "Geek field with custom ~domain", + "uri": "ugi:0@bob:gcs/~fermenting", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @bob Gcs/~fermenting\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "bob", + "fields": { + "g": { "type": "special", "domains": [ + {"id": "cs", "name": "Computer Science", "paid": false}, + {"id": "~fermenting", "name": "~fermenting", "paid": false} + ]} + } + } + }, + + { + "id": "geek_paid_and_custom", + "description": "Geek: paid domain + custom domain", + "uri": "ugi:0@chef:gfd$/~sourdough", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @chef Gfd$/~sourdough\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "chef", + "fields": { + "g": { "type": "special", "domains": [ + {"id": "fd", "name": "Food Science", "paid": true}, + {"id": "~sourdough", "name": "~sourdough", "paid": false} + ]} + } + } + }, + + { + "id": "version_with_revision", + "description": "Version with revision number", + "uri": "ugi:0/3@rev:gcs", + "block": "------- BEGIN UGI BLOCK -------\nv:0/3 @rev Gcs\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0/3", + "handle": "rev", + "fields": { + "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": false}] } + } + } + }, + + { + "id": "direct_no_modifiers", + "description": "Direct field with no modifiers (age: no $, no +, no /)", + "uri": "ugi:0@t1:gcs,a4", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t1 Gcs\nA4\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t1", + "fields": { + "g": { "type": "special", "domains": [{"id": "cs", "name": "Computer Science", "paid": false}] }, + "a": { "type": "direct", "value": 4, "label": "25-34" } + } + } + }, + + { + "id": "direct_all_values", + "description": "Direct field at each scale extreme: age 0 and age 7", + "uri_0": "ugi:0@kid:gcs,a0", + "uri_7": "ugi:0@elder:gcs,a7", + "decoded_0": { "a": { "value": 0, "label": "Under 10" } }, + "decoded_7": { "a": { "value": 7, "label": "65+" } } + }, + + { + "id": "direct_alternative", + "description": "Direct field with / alternative (clothing: c3/6)", + "uri": "ugi:0@t2:gcs,c3/6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t2 Gcs\nC3/6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t2", + "fields": { + "c": { "type": "direct", "value": 3, "label": "Jeans & tee", "alt_value": 6, "alt_label": "Formal" } + } + } + }, + + { + "id": "direct_paid", + "description": "Direct field with $ paid modifier (food: f5$)", + "uri": "ugi:0@t3:gcs,f5$", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t3 Gcs\nF5$\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t3", + "fields": { + "f": { "type": "direct", "value": 5, "label": "Experiments", "paid": true } + } + } + }, + + { + "id": "direct_aspire", + "description": "Direct field with +N aspire modifier (education: e4+6)", + "uri": "ugi:0@t4:gcs,e4+6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t4 Gcs\nE4+6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t4", + "fields": { + "e": { "type": "direct", "value": 4, "label": "Bachelors", "aspire": 6 } + } + } + }, + + { + "id": "direct_paid_aspire", + "description": "Direct field with both $ and +N (hardware: w5$+7)", + "uri": "ugi:0@t5:gcs,w5$+7", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t5 Gcs\nW5$+7\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t5", + "fields": { + "w": { "type": "direct", "value": 5, "label": "Comfortable", "paid": true, "aspire": 7 } + } + } + }, + + { + "id": "direct_alt_and_aspire", + "description": "Direct field with / alternative and +N aspire (security: q3/6+7)", + "uri": "ugi:0@t6:gcs,q3/6+7", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t6 Gcs\nQ3/6+7\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t6", + "fields": { + "q": { "type": "direct", "value": 3, "label": "Basic awareness", "alt_value": 6, "alt_label": "Encrypts everything", "aspire": 7 } + } + } + }, + + { + "id": "direct_all_three_modifiers", + "description": "Direct field with $, /, and + (security: q5$/6+7 — paid, fluctuates, aspires)", + "uri": "ugi:0@t6b:gcs,q5$/6+7", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t6b Gcs\nQ5$/6+7\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t6b", + "fields": { + "q": { "type": "direct", "value": 5, "label": "2FA everywhere", "paid": true, "alt_value": 6, "alt_label": "Encrypts everything", "aspire": 7 } + } + } + }, + + { + "id": "special_build", + "description": "Build field: height/width", + "uri": "ugi:0@t7:gcs,b6/3", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t7 Gcs\nB6/3\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t7", + "fields": { + "b": { "type": "special", "dimensions": {"height": {"value": 6, "label": "Tall"}, "width": {"value": 3, "label": "Below average"}} } + } + } + }, + + { + "id": "special_build_extremes", + "description": "Build field at extremes: b0/0 and b7/7", + "uri_min": "ugi:0@tiny:gcs,b0/0", + "uri_max": "ugi:0@huge:gcs,b7/7", + "decoded_min": { "b": { "dimensions": {"height": {"value": 0, "label": "Extremely short"}, "width": {"value": 0, "label": "Extremely thin"}} } }, + "decoded_max": { "b": { "dimensions": {"height": {"value": 7, "label": "Extremely tall"}, "width": {"value": 7, "label": "Extremely broad"}} } } + }, + + { + "id": "special_politics", + "description": "Politics field: social/economic", + "uri": "ugi:0@t8:gcs,v5/3", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t8 Gcs\nV5/3\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t8", + "fields": { + "v": { "type": "special", "dimensions": {"social": {"value": 5, "label": "Center-left"}, "economic": {"value": 3, "label": "Center-right"}} } + } + } + }, + + { + "id": "special_politics_extremes", + "description": "Politics at extremes: v0/0 and v7/7", + "uri_min": "ugi:0@auth:gcs,v0/0", + "uri_max": "ugi:0@lib:gcs,v7/7", + "decoded_min": { "v": { "dimensions": {"social": {"value": 0, "label": "Far-right authoritarian"}, "economic": {"value": 0, "label": "Unregulated capitalism"}} } }, + "decoded_max": { "v": { "dimensions": {"social": {"value": 7, "label": "Far-left libertarian"}, "economic": {"value": 7, "label": "Full socialism"}} } } + }, + + { + "id": "multi_single_subid", + "description": "Multi field with one sub-ID (programming: ppy6)", + "uri": "ugi:0@t9:gcs,ppy6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t9 Gcs\nPpy6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t9", + "fields": { + "p": { "type": "multi", "entries": [ + {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily"} + ]} + } + } + }, + + { + "id": "multi_merged_subids", + "description": "Multi field with multiple sub-IDs merged in URI, expanded in block", + "uri": "ugi:0@t10:gcs,ppy6rs5js4", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t10 Gcs\nPpy6 Prs5 Pjs4\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t10", + "fields": { + "p": { "type": "multi", "entries": [ + {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily"}, + {"sub_id": "rs", "name": "Rust", "value": 5, "label": "Competent"}, + {"sub_id": "js", "name": "JavaScript", "value": 4, "label": "Average"} + ]} + } + } + }, + + { + "id": "multi_paid_subid", + "description": "Multi field with $ on a sub-ID (programming: ppy6$)", + "uri": "ugi:0@t11:gcs,ppy6$", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t11 Gcs\nPpy6$\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t11", + "fields": { + "p": { "type": "multi", "entries": [ + {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily", "paid": true} + ]} + } + } + }, + + { + "id": "multi_aspire_subid", + "description": "Multi field with +N on a sub-ID (programming: prs4+6)", + "uri": "ugi:0@t12:gcs,prs4+6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t12 Gcs\nPrs4+6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t12", + "fields": { + "p": { "type": "multi", "entries": [ + {"sub_id": "rs", "name": "Rust", "value": 4, "label": "Average", "aspire": 6} + ]} + } + } + }, + + { + "id": "multi_paid_aspire_subid", + "description": "Multi field with $+N combined on a sub-ID (programming: pjs5$+7)", + "uri": "ugi:0@t13:gcs,pjs5$+7", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t13 Gcs\nPjs5$+7\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t13", + "fields": { + "p": { "type": "multi", "entries": [ + {"sub_id": "js", "name": "JavaScript", "value": 5, "label": "Competent", "paid": true, "aspire": 7} + ]} + } + } + }, + + { + "id": "multi_mixed_modifiers", + "description": "Multi field with different modifiers on different sub-IDs", + "uri": "ugi:0@t14:gcs,ppy7$rs5+7js6$+7go3", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t14 Gcs\nPpy7$ Prs5+7 Pjs6$+7 Pgo3\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t14", + "fields": { + "p": { "type": "multi", "entries": [ + {"sub_id": "py", "name": "Python", "value": 7, "label": "Master / creator", "paid": true}, + {"sub_id": "rs", "name": "Rust", "value": 5, "label": "Competent", "aspire": 7}, + {"sub_id": "js", "name": "JavaScript", "value": 6, "label": "Advanced / daily", "paid": true, "aspire": 7}, + {"sub_id": "go", "name": "Go", "value": 3, "label": "Beginner"} + ]} + } + } + }, + + { + "id": "multi_custom_subid", + "description": "Multi field with custom ~sub-ID (music: m~synthwave6)", + "uri": "ugi:0@t15:gcs,m~synthwave6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t15 Gcs\nM~synthwave6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t15", + "fields": { + "m": { "type": "multi", "entries": [ + {"sub_id": "~synthwave", "name": "~synthwave", "value": 6, "label": "Enthusiast"} + ]} + } + } + }, + + { + "id": "multi_mixed_and_custom", + "description": "Multi field with known + custom sub-IDs (music: mrk6~synthwave7el5)", + "uri": "ugi:0@t16:gcs,mrk6~synthwave7el5", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t16 Gcs\nMrk6 M~synthwave7 Mel5\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t16", + "fields": { + "m": { "type": "multi", "entries": [ + {"sub_id": "rk", "name": "Rock", "value": 6, "label": "Enthusiast"}, + {"sub_id": "~synthwave", "name": "~synthwave", "value": 7, "label": "Life-defining"}, + {"sub_id": "el", "name": "Electronic", "value": 5, "label": "Enjoy"} + ]} + } + } + }, + + { + "id": "multi_grouped_subids", + "description": "Multi field with sub-IDs from sub_id_groups (gaming: jrp7pc6dn5)", + "uri": "ugi:0@t17:gcs,jrp7pc6dn5", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t17 Gcs\nJrp7 Jpc6 Jdn5\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t17", + "fields": { + "j": { "type": "multi", "entries": [ + {"sub_id": "rp", "name": "RPG", "value": 7, "label": "Life-defining"}, + {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"}, + {"sub_id": "dn", "name": "D&D", "value": 5, "label": "Enjoy"} + ]} + } + } + }, + + { + "id": "multi_1char_subid", + "description": "Multi field with 1-char sub-IDs (hair: hh4f6) and single-char sub-IDs (programming: pc5 — 'c' is 1-char)", + "uri": "ugi:0@t18:gcs,hh4f6,pc5", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t18 Gcs\nHh4 Hf6\nPc5\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t18", + "fields": { + "h": { "type": "multi", "entries": [ + {"sub_id": "h", "name": "Head", "value": 4, "label": "Average"}, + {"sub_id": "f", "name": "Face / beard", "value": 6, "label": "Impressive"} + ]}, + "p": { "type": "multi", "entries": [ + {"sub_id": "c", "name": "C", "value": 5, "label": "Competent"} + ]} + } + } + }, + + { + "id": "multi_3char_subid", + "description": "Multi field with 3-char sub-ID from sub_id_groups (OS: oxsl5 — 'xsl' is Slackware)", + "uri": "ugi:0@t19:gcs,oxsl5", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t19 Gcs\nOxsl5\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t19", + "fields": { + "o": { "type": "multi", "entries": [ + {"sub_id": "xsl", "name": "Slackware", "value": 5, "label": "Competent"} + ]} + } + } + }, + + { + "id": "single_type", + "description": "Single-type field: exactly one sub-ID + digit (religion: ris6)", + "uri": "ugi:0@t20:gcs,ris6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t20 Gcs\nRis6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t20", + "fields": { + "r": { "type": "single", "entries": [ + {"sub_id": "is", "name": "Islam", "value": 6, "label": "Devout"} + ]} + } + } + }, + + { + "id": "single_type_atheist", + "description": "Single-type at extreme: rat0 (atheist, hostile to religion)", + "uri": "ugi:0@t21:gcs,rat0", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t21 Gcs\nRat0\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t21", + "fields": { + "r": { "type": "single", "entries": [ + {"sub_id": "at", "name": "Atheist", "value": 0, "label": "Hostile"} + ]} + } + } + }, + + { + "id": "languages_basic", + "description": "Languages field: ISO 639-1 codes + ratings (multi with 2-char fixed sub-IDs)", + "uri": "ugi:0@t22:gcs,lar7en6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t22 Gcs\nLar7 Len6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t22", + "fields": { + "l": { "type": "multi", "entries": [ + {"sub_id": "ar", "name": "Arabic", "value": 7, "label": "Native"}, + {"sub_id": "en", "name": "English", "value": 6, "label": "Fluent"} + ]} + } + } + }, + + { + "id": "languages_with_modifiers", + "description": "Languages with $ and + modifiers", + "uri": "ugi:0@t23:gcs,lar7$en6de3+5", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t23 Gcs\nLar7$ Len6 Lde3+5\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t23", + "fields": { + "l": { "type": "multi", "entries": [ + {"sub_id": "ar", "name": "Arabic", "value": 7, "label": "Native", "paid": true}, + {"sub_id": "en", "name": "English", "value": 6, "label": "Fluent"}, + {"sub_id": "de", "name": "German", "value": 3, "label": "Beginner", "aspire": 5} + ]} + } + } + }, + + { + "id": "multi_os_grouped", + "description": "OS field with sub-IDs from multiple groups (linux + bsd + windows)", + "uri": "ugi:0@t24:gcs,oxa7$fb5we3", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t24 Gcs\nOxa7$ Ofb5 Owe3\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t24", + "fields": { + "o": { "type": "multi", "entries": [ + {"sub_id": "xa", "name": "Arch", "value": 7, "label": "Master / creator", "paid": true}, + {"sub_id": "fb", "name": "FreeBSD", "value": 5, "label": "Competent"}, + {"sub_id": "we", "name": "Windows 11", "value": 3, "label": "Beginner"} + ]} + } + } + }, + + { + "id": "editor_with_aspire", + "description": "Editor field with aspire modifier (dvi7em3+6)", + "uri": "ugi:0@t25:gcs,dvi7em3+6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t25 Gcs\nDvi7 Dem3+6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t25", + "fields": { + "d": { "type": "multi", "entries": [ + {"sub_id": "vi", "name": "Vim / Neovim", "value": 7, "label": "Master / creator"}, + {"sub_id": "em", "name": "Emacs", "value": 3, "label": "Beginner", "aspire": 6} + ]} + } + } + }, + + { + "id": "hair_custom_scale", + "description": "Hair field uses custom scale_labels (hh5f7b3)", + "uri": "ugi:0@t26:gcs,hh5f7b3", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t26 Gcs\nHh5 Hf7 Hb3\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t26", + "fields": { + "h": { "type": "multi", "entries": [ + {"sub_id": "h", "name": "Head", "value": 5, "label": "Above average"}, + {"sub_id": "f", "name": "Face / beard", "value": 7, "label": "Sasquatch"}, + {"sub_id": "b", "name": "Brows", "value": 3, "label": "Below average"} + ]} + } + } + }, + + { + "id": "comics_enthusiasm_scale", + "description": "Comics field uses global enthusiasm scale (xxk7dc0)", + "uri": "ugi:0@t27:gcs,xxk7dc0", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t27 Gcs\nXxk7 Xdc0\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t27", + "fields": { + "x": { "type": "multi", "entries": [ + {"sub_id": "xk", "name": "XKCD", "value": 7, "label": "Life-defining"}, + {"sub_id": "dc", "name": "DC Comics", "value": 0, "label": "Despise"} + ]} + } + } + }, + + { + "id": "tv_grouped_subids", + "description": "TV field with sub-IDs from different groups (scifi + fantasy + other)", + "uri": "ugi:0@t28:gcs,tst7go5mr6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t28 Gcs\nTst7 Tgo5 Tmr6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t28", + "fields": { + "t": { "type": "multi", "entries": [ + {"sub_id": "st", "name": "Star Trek", "value": 7, "label": "Life-defining"}, + {"sub_id": "go", "name": "Game of Thrones", "value": 5, "label": "Enjoy"}, + {"sub_id": "mr", "name": "Mr. Robot", "value": 6, "label": "Enthusiast"} + ]} + } + } + }, + + { + "id": "gaming_paid_aspire", + "description": "Gaming with paid and aspire modifiers on different entries (jrp7$pc6st4+6)", + "uri": "ugi:0@t29:gcs,jrp7$pc6st4+6", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @t29 Gcs\nJrp7$ Jpc6 Jst4+6\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "t29", + "fields": { + "j": { "type": "multi", "entries": [ + {"sub_id": "rp", "name": "RPG", "value": 7, "label": "Life-defining", "paid": true}, + {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"}, + {"sub_id": "st", "name": "Strategy", "value": 4, "label": "Take or leave", "aspire": 6} + ]} + } + } + }, + + { + "id": "all_direct_fields", + "description": "Every direct-type field at once", + "uri": "ugi:0@full:gcs,a4,c3,e5,f4,i5,k6,q4,s6,w5,y4,z3", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @full Gcs\nA4 E5\nC3\nW5 Q4\nI5\nK6\nS6 Y4 F4 Z3\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "full", + "fields": { + "a": { "type": "direct", "value": 4, "label": "25-34" }, + "c": { "type": "direct", "value": 3, "label": "Jeans & tee" }, + "e": { "type": "direct", "value": 5, "label": "Masters" }, + "f": { "type": "direct", "value": 4, "label": "Home cook" }, + "i": { "type": "direct", "value": 5, "label": "Positive" }, + "k": { "type": "direct", "value": 6, "label": "Avid" }, + "q": { "type": "direct", "value": 4, "label": "Password manager" }, + "s": { "type": "direct", "value": 6, "label": "Partnered" }, + "w": { "type": "direct", "value": 5, "label": "Comfortable" }, + "y": { "type": "direct", "value": 4, "label": "Some exercise" }, + "z": { "type": "direct", "value": 3, "label": "Handy" } + } + } + }, + + { + "id": "spec_example_jdoe", + "description": "Full example from spec: jdoe full-stack developer", + "uri": "ugi:0@jdoe:gcs$/dev,a4,b5/4,c3,hh4f5m4,e4,len7,oxa6we5,ppy6$js6$ts5rs4+6,dvs6,w5,q4,i5,v5/4,rag4,tst5ex7,k5,xxk6,jst5pc6,mrk5in4,s6,y4,f4,z3", + "block": "------- BEGIN UGI BLOCK -------\nv:0 @jdoe Gcs$/dev\nA4 E4 Len7\nB5/4 C3 Hh4 Hf5 Hm4\nOxa6 Owe5 Ppy6$ Pjs6$ Pts5 Prs4+6 Dvs6 W5 Q4\nI5 V5/4 Rag4\nTst5 Tex7 K5 Xxk6 Jst5 Jpc6 Mrk5 Min4\nS6 Y4 F4 Z3\n-------- END UGI BLOCK --------", + "decoded": { + "version": "0", + "handle": "jdoe", + "fields": { + "g": { "type": "special", "domains": [ + {"id": "cs", "name": "Computer Science", "paid": true}, + {"id": "dev", "name": "dev", "paid": false} + ]}, + "a": { "type": "direct", "value": 4, "label": "25-34" }, + "b": { "type": "special", "dimensions": {"height": {"value": 5, "label": "Above average"}, "width": {"value": 4, "label": "Average"}} }, + "c": { "type": "direct", "value": 3, "label": "Jeans & tee" }, + "h": { "type": "multi", "entries": [ + {"sub_id": "h", "name": "Head", "value": 4, "label": "Average"}, + {"sub_id": "f", "name": "Face / beard", "value": 5, "label": "Above average"}, + {"sub_id": "m", "name": "Mustache", "value": 4, "label": "Average"} + ]}, + "e": { "type": "direct", "value": 4, "label": "Bachelors" }, + "l": { "type": "multi", "entries": [ + {"sub_id": "en", "name": "English", "value": 7, "label": "Native"} + ]}, + "o": { "type": "multi", "entries": [ + {"sub_id": "xa", "name": "Arch", "value": 6, "label": "Advanced / daily"}, + {"sub_id": "we", "name": "Windows 11", "value": 5, "label": "Competent"} + ]}, + "p": { "type": "multi", "entries": [ + {"sub_id": "py", "name": "Python", "value": 6, "label": "Advanced / daily", "paid": true}, + {"sub_id": "js", "name": "JavaScript", "value": 6, "label": "Advanced / daily", "paid": true}, + {"sub_id": "ts", "name": "TypeScript", "value": 5, "label": "Competent"}, + {"sub_id": "rs", "name": "Rust", "value": 4, "label": "Average", "aspire": 6} + ]}, + "d": { "type": "multi", "entries": [ + {"sub_id": "vs", "name": "VS Code", "value": 6, "label": "Advanced / daily"} + ]}, + "w": { "type": "direct", "value": 5, "label": "Comfortable" }, + "q": { "type": "direct", "value": 4, "label": "Password manager" }, + "i": { "type": "direct", "value": 5, "label": "Positive" }, + "v": { "type": "special", "dimensions": {"social": {"value": 5, "label": "Center-left"}, "economic": {"value": 4, "label": "Mixed economy"}} }, + "r": { "type": "single", "entries": [ + {"sub_id": "ag", "name": "Agnostic", "value": 4, "label": "Cultural"} + ]}, + "t": { "type": "multi", "entries": [ + {"sub_id": "st", "name": "Star Trek", "value": 5, "label": "Enjoy"}, + {"sub_id": "ex", "name": "Expanse", "value": 7, "label": "Life-defining"} + ]}, + "k": { "type": "direct", "value": 5, "label": "Regular" }, + "x": { "type": "multi", "entries": [ + {"sub_id": "xk", "name": "XKCD", "value": 6, "label": "Enthusiast"} + ]}, + "j": { "type": "multi", "entries": [ + {"sub_id": "st", "name": "Strategy", "value": 5, "label": "Enjoy"}, + {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"} + ]}, + "m": { "type": "multi", "entries": [ + {"sub_id": "rk", "name": "Rock", "value": 5, "label": "Enjoy"}, + {"sub_id": "in", "name": "Indie", "value": 4, "label": "Take or leave"} + ]}, + "s": { "type": "direct", "value": 6, "label": "Partnered" }, + "y": { "type": "direct", "value": 4, "label": "Some exercise" }, + "f": { "type": "direct", "value": 4, "label": "Home cook" }, + "z": { "type": "direct", "value": 3, "label": "Handy" } + } + } + }, + + { + "id": "spec_example_hax0r", + "description": "Full example from spec: hax0r hardware hacker with version/revision", + "uri": "ugi:0/3@hax0r:gee$/rb/dy~solder,a5,b6/5,c2,hh3f6+7m5,e4,lja7en5,oxg7fb5,pc5ba6as7$,dem5,w7$,q6,i2,v3/3,rbu5,tbb6ff7,k5,xxk6dl5,jre7pc6bg5,mmt7pk6,s3,y5,f4,z7$", + "decoded": { + "version": "0/3", + "handle": "hax0r", + "fields": { + "g": { "type": "special", "domains": [ + {"id": "ee", "name": "Electrical Eng.", "paid": true}, + {"id": "rb", "name": "Robotics", "paid": false}, + {"id": "dy~solder", "name": "dy~solder", "paid": false} + ]}, + "a": { "type": "direct", "value": 5, "label": "35-49" }, + "b": { "type": "special", "dimensions": {"height": {"value": 6, "label": "Tall"}, "width": {"value": 5, "label": "Above average"}} }, + "c": { "type": "direct", "value": 2, "label": "Political tees" }, + "h": { "type": "multi", "entries": [ + {"sub_id": "h", "name": "Head", "value": 3, "label": "Below average"}, + {"sub_id": "f", "name": "Face / beard", "value": 6, "label": "Impressive", "aspire": 7}, + {"sub_id": "m", "name": "Mustache", "value": 5, "label": "Above average"} + ]}, + "e": { "type": "direct", "value": 4, "label": "Bachelors" }, + "l": { "type": "multi", "entries": [ + {"sub_id": "ja", "name": "Japanese", "value": 7, "label": "Native"}, + {"sub_id": "en", "name": "English", "value": 5, "label": "Proficient"} + ]}, + "o": { "type": "multi", "entries": [ + {"sub_id": "xg", "name": "Gentoo", "value": 7, "label": "Master / creator"}, + {"sub_id": "fb", "name": "FreeBSD", "value": 5, "label": "Competent"} + ]}, + "p": { "type": "multi", "entries": [ + {"sub_id": "c", "name": "C", "value": 5, "label": "Competent"}, + {"sub_id": "ba", "name": "Bash", "value": 6, "label": "Advanced / daily"}, + {"sub_id": "as", "name": "Assembly", "value": 7, "label": "Master / creator", "paid": true} + ]}, + "d": { "type": "multi", "entries": [ + {"sub_id": "em", "name": "Emacs", "value": 5, "label": "Competent"} + ]}, + "w": { "type": "direct", "value": 7, "label": "Builds everything", "paid": true }, + "q": { "type": "direct", "value": 6, "label": "Encrypts everything" }, + "i": { "type": "direct", "value": 2, "label": "Skeptical" }, + "v": { "type": "special", "dimensions": {"social": {"value": 3, "label": "Center-right"}, "economic": {"value": 3, "label": "Center-right"}} }, + "r": { "type": "single", "entries": [ + {"sub_id": "bu", "name": "Buddhist", "value": 5, "label": "Moderate"} + ]}, + "t": { "type": "multi", "entries": [ + {"sub_id": "bb", "name": "Babylon 5", "value": 6, "label": "Enthusiast"}, + {"sub_id": "ff", "name": "Firefly", "value": 7, "label": "Life-defining"} + ]}, + "k": { "type": "direct", "value": 5, "label": "Regular" }, + "x": { "type": "multi", "entries": [ + {"sub_id": "xk", "name": "XKCD", "value": 6, "label": "Enthusiast"}, + {"sub_id": "dl", "name": "Dilbert", "value": 5, "label": "Enjoy"} + ]}, + "j": { "type": "multi", "entries": [ + {"sub_id": "re", "name": "Retro", "value": 7, "label": "Life-defining"}, + {"sub_id": "pc", "name": "PC", "value": 6, "label": "Enthusiast"}, + {"sub_id": "bg", "name": "Board games", "value": 5, "label": "Enjoy"} + ]}, + "m": { "type": "multi", "entries": [ + {"sub_id": "mt", "name": "Metal", "value": 7, "label": "Life-defining"}, + {"sub_id": "pk", "name": "Punk", "value": 6, "label": "Enthusiast"} + ]}, + "s": { "type": "direct", "value": 3, "label": "Complicated" }, + "y": { "type": "direct", "value": 5, "label": "Regular workouts" }, + "f": { "type": "direct", "value": 4, "label": "Home cook" }, + "z": { "type": "direct", "value": 7, "label": "Built my house", "paid": true } + } + } + } + ] +} diff --git a/spec/ugi_registry_v0.json b/spec/ugi_registry_v0.json new file mode 100644 index 0000000..6f28700 --- /dev/null +++ b/spec/ugi_registry_v0.json @@ -0,0 +1,134 @@ +{ + "spec": { + "name": "UGI", + "full_name": "Unified Geek Identifier", + "version": "0", + "status": "draft", + "date": "2026-03-28", + "license": "MIT", + "description": "A compact, URI-safe self-description code for geeks. Encodes identity, skills, interests, and opinions into a single string suitable for bios, email signatures, and URIs.", + "predecessors": [ + { "name": "Geek Code", "author": "Robert A. Hayden", "years": "1993-1996", "url": "https://sig.codes/geek/", "continuation": { "name": "Geek Code 4.0+", "author": "exarobibliologist", "years": "2019-2026", "url": "https://sig.codes/geek-4.0/" } }, + { "name": "Hacker Key", "author": "Chris Allegretta", "years": "2003-2006", "url": "https://sig.codes/hacker-key/" } + ], + "references": [ + { "title": "sig.codes", "url": "https://sig.codes/" }, + { "title": "Geek Code on Wikipedia", "url": "https://en.wikipedia.org/wiki/Geek_Code" }, + { "title": "Geek Code v3.12 mirror", "url": "https://geekcode.xyz/" }, + { "title": "Hacker Key v4 mirror", "url": "https://sig.codes/hacker-key/4/" } + ] + }, + "format": { + "uri": { "template": "ugi:<version>[/<revision>]@<handle>:<field>,<field>,...", "separator": ",", "prefix": "ugi:" }, + "block": { "header": "------- BEGIN UGI BLOCK -------", "footer": "-------- END UGI BLOCK --------", "first_line": "v:<version>[/<revision>] @<handle> [G<geek_types>]", "separator": " " }, + "case_sensitive": false, + "mandatory_fields": ["g"], + "mandatory_header": ["handle"], + "reserved_codes": ["n", "u"] + }, + "scale": { + "type": "octal", "range": [0, 7], + "values": [ + { "value": 0, "general": "Hostile / opposed", "proficiency": "Can't, won't", "enthusiasm": "Despise", "stance": "Strongly against" }, + { "value": 1, "general": "Strong dislike", "proficiency": "Aware it exists", "enthusiasm": "Avoid actively", "stance": "Against" }, + { "value": 2, "general": "Dislike / minimal", "proficiency": "Dabbled briefly", "enthusiasm": "Not interested", "stance": "Lean against" }, + { "value": 3, "general": "Slight negative", "proficiency": "Beginner", "enthusiasm": "Cold", "stance": "Slightly against" }, + { "value": 4, "general": "Baseline / neutral", "proficiency": "Average", "enthusiasm": "Take or leave", "stance": "No opinion" }, + { "value": 5, "general": "Moderate positive", "proficiency": "Competent", "enthusiasm": "Enjoy", "stance": "Lean toward" }, + { "value": 6, "general": "Strong positive", "proficiency": "Advanced / daily", "enthusiasm": "Enthusiast", "stance": "Support" }, + { "value": 7, "general": "Obsessed / defining", "proficiency": "Master / creator", "enthusiasm": "Life-defining", "stance": "Evangelist" } + ] + }, + "modifiers": { + "paid": { "symbol": "$", "description": "Earns money doing this", "example": "ppy6$" }, + "aspire": { "symbol": "+", "description": "Aspiring toward target level", "example": "prs5+7", "format": "+<digit>" }, + "paid_aspire": { "symbol": "$+", "description": "Paid and aspiring", "example": "pjs5$+7" }, + "alternative": { "symbol": "/", "description": "Fluctuates between values (direct fields) or separator (g, b, v)", "example": "c3/6" }, + "custom": { "symbol": "~", "description": "Custom sub-ID prefix", "example": "m~synthwave6" } + }, + "fields": { + "a": { "name": "Age", "category": "identity", "type": "direct", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Under 10", "humor": "Still loading. Baby Shark has more life experience." }, "1": { "label": "10-14", "humor": "Old enough for Linux opinions, too young to drive to the meetup." }, "2": { "label": "15-19", "humor": "Convinced you know everything. Might be right about the tech stuff." }, "3": { "label": "20-24", "humor": "Student loans arrived but so has 3am pizza freedom." }, "4": { "label": "25-34", "humor": "Peak earning-to-caring ratio. GitHub green, back complaining." }, "5": { "label": "35-49", "humor": "You remember dial-up noises. Kids ask what a floppy disk is." }, "6": { "label": "50-64", "humor": "You were there when it happened. All of it." }, "7": { "label": "65+", "humor": "Living legend. Your software runs on OSes born decades after you." } } }, + "b": { "name": "Build", "category": "appearance", "type": "special", "format": "<height>/<width>", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "dimensions": { "height": { "values": { "0": "Extremely short", "1": "Very short", "2": "Short", "3": "Below average", "4": "Average", "5": "Above average", "6": "Tall", "7": "Extremely tall" } }, "width": { "values": { "0": "Extremely thin", "1": "Very thin", "2": "Slim", "3": "Below average", "4": "Average", "5": "Above average", "6": "Broad", "7": "Extremely broad" } } } }, + "c": { "name": "Clothing", "category": "appearance", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": true }, "scale_type": "custom", "values": { "0": { "label": "Nudist", "humor": "Quite a fashion statement." }, "1": { "label": "Punk", "humor": "Safety pins are load-bearing." }, "2": { "label": "Political tees", "humor": "Your shirts have opinions so you don't have to." }, "3": { "label": "Jeans & tee", "humor": "Universal geek uniform. You own seven identical shirts." }, "4": { "label": "Average", "humor": "Inoffensive. Forgettable. Exactly as intended." }, "5": { "label": "Smart casual", "humor": "You've mastered the blazer-over-tee." }, "6": { "label": "Formal", "humor": "You iron things. On purpose." }, "7": { "label": "Suit daily", "humor": "CEO, lawyer, or cosplaying as one." } } }, + "d": { "name": "Editor / IDE", "category": "tech", "type": "multi", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "proficiency", "humor_scale": { "0": "Opened it by accident, couldn't close it.", "7": "The editor is an extension of my nervous system." }, "sub_ids": { "vi": { "long": "vim", "name": "Vim / Neovim" }, "em": { "long": "emacs", "name": "Emacs" }, "vs": { "long": "vscode", "name": "VS Code" }, "ij": { "long": "idea", "name": "IntelliJ / JetBrains" }, "xc": { "long": "xcode", "name": "Xcode" }, "vt": { "long": "vstudio", "name": "Visual Studio" }, "ed": { "name": "ed" }, "na": { "long": "nano", "name": "nano / pico" }, "su": { "long": "sublime", "name": "Sublime Text" }, "ec": { "long": "eclipse", "name": "Eclipse" }, "np": { "long": "npp", "name": "Notepad++" }, "hx": { "long": "helix", "name": "Helix" }, "ze": { "long": "zed", "name": "Zed" }, "ka": { "long": "kakoune", "name": "Kakoune" }, "lp": { "long": "lapce", "name": "Lapce" }, "at": { "long": "atom", "name": "Atom" }, "nv": { "long": "nvchad", "name": "NvChad / LazyVim" } } }, + "e": { "name": "Education", "category": "identity", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "None / primary", "humor": "School of life. And YouTube." }, "1": { "label": "Middle school", "humor": "Survived. Emotionally, jury's still out." }, "2": { "label": "High school", "humor": "They said best years. They lied." }, "3": { "label": "Trade / associates", "humor": "You can actually do things, unlike philosophy majors." }, "4": { "label": "Bachelors", "humor": "Four years and paper that cost more than your car." }, "5": { "label": "Masters", "humor": "First degree wasn't enough to convince anyone." }, "6": { "label": "PhD", "humor": "Call me Doctor. Especially at parties." }, "7": { "label": "Post-doc", "humor": "Forgotten what sunlight looks like." } } }, + "f": { "name": "Food / Cooking", "category": "lifestyle", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Microwave only", "humor": "Smoke detector is your kitchen timer." }, "1": { "label": "Boils water", "humor": "Sometimes remembers to turn off stove." }, "2": { "label": "Basic meals", "humor": "Eggs and pasta. The two food groups." }, "3": { "label": "Follows recipes", "humor": "To the letter. Substitution is heresy." }, "4": { "label": "Home cook", "humor": "Friends don't dread your dinner invitations." }, "5": { "label": "Experiments", "humor": "Sometimes works. You pretend you planned it." }, "6": { "label": "Hosts dinners", "humor": "Spice rack has subdivisions." }, "7": { "label": "Chef level", "humor": "Own a mandoline, all fingers intact." } } }, + "g": { "name": "Geek Specialization", "category": "identity", "type": "special", "format": "domain[/$]/domain/~custom", "separator": "/", "modifiers": { "paid": true, "aspire": false, "alternative": false }, "mandatory": true, "description": "/ separates domains, ~ prefixes custom, $ after domain = paid. No ratings.", "sub_id_groups": { "computing": { "label": "Computing & Tech", "sub_ids": { "cs": "Computer Science", "ai": "Artificial Intelligence", "ml": "Machine Learning", "cy": "Cybersecurity", "ds": "Data Science", "bc": "Blockchain", "io": "Internet of Things", "qc": "Quantum Computing", "vr": "Virtual Reality", "os": "Operating Systems", "dv": "DevOps", "ap": "API Development", "cl": "Cloud Infrastructure", "eg": "Edge Computing", "sc": "Ethical Hacking", "ux": "User Experience", "wp": "Web Performance", "db": "Databases", "em": "Embedded Systems", "fp": "Functional Programming", "nx": "Networking", "cv": "Computer Vision", "nl": "NLP / Comp. Linguistics", "cr": "Cryptography" } }, "science": { "label": "Science & Medicine", "sub_ids": { "bi": "Biology", "cm": "Chemistry", "ph": "Physics", "md": "Medicine", "nr": "Neuroscience", "ev": "Environmental", "fs": "Forensics", "fd": "Food Science", "as": "Astronomy", "gl": "Geology", "bt": "Biotech", "gn": "Genetics" } }, "engineering": { "label": "Engineering & Making", "sub_ids": { "ee": "Electrical Eng.", "me": "Mechanical Eng.", "rb": "Robotics", "dy": "DIY / Maker", "tp": "3D Printing", "ar": "Arduino", "ce": "Civil Eng.", "ae": "Aerospace Eng.", "am": "Additive Mfg.", "ct": "Control Systems" } }, "academic": { "label": "Academic & Humanities", "sub_ids": { "mt": "Mathematics", "pl": "Philosophy", "ed": "Education", "lb": "Library Science", "lw": "Law", "ss": "Social Science", "py": "Psychology", "cg": "Cognitive Science", "li": "Linguistics", "hs": "History", "an": "Anthropology", "ec": "Economics" } }, "art": { "label": "Art & Design", "sub_ids": { "gd": "Graphic Design", "ad": "Animation", "fa": "Fine Arts", "mu": "Music", "pa": "Performing Arts", "sr": "Screenwriting", "tw": "Tech Writing", "wr": "Writing", "vd": "Game Design", "pg": "Photography", "tx": "Textile Arts", "ty": "Typography" } }, "business": { "label": "Business & Comms", "sub_ids": { "bz": "Business", "mk": "Marketing", "gv": "Government", "lo": "Logistics", "qa": "QA", "pm": "Project Mgmt", "hr": "HR" } }, "culture": { "label": "Culture & Society", "sub_ids": { "mm": "Memes", "fn": "Fandom", "pp": "Pop Culture", "gn": "Gender Studies", "al": "Alt Lifestyles" } }, "games_media": { "label": "Games & Media", "sub_ids": { "rp": "RPG", "sm": "Streaming", "pd": "Podcasting", "cw": "Cosplay" } }, "futurism": { "label": "Futurism", "sub_ids": { "ax": "Aerospace", "ft": "Futurism", "ali": "Alien Theories", "tr": "Transhumanism" } } } }, + "h": { "name": "Hair", "category": "appearance", "type": "multi", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "custom", "scale_labels": { "0": "Alopecia", "1": "Shaved", "2": "Stubble", "3": "Below average", "4": "Average", "5": "Above average", "6": "Impressive", "7": "Sasquatch" }, "sub_ids": { "h": { "name": "Head" }, "f": { "name": "Face / beard" }, "b": { "name": "Brows" }, "m": { "name": "Mustache" }, "s": { "name": "Sideburns" } } }, + "i": { "name": "AI Stance", "category": "stance", "type": "direct", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Butlerian Jihadist", "humor": "Would unplug every GPU on earth." }, "1": { "label": "Strongly opposed", "humor": "'No AI' in bio, code, and dating profile." }, "2": { "label": "Skeptical", "humor": "Prefer human-authored everything." }, "3": { "label": "Cautious", "humor": "Ethics papers keep you up at night." }, "4": { "label": "Neutral tool", "humor": "Like a hammer. No opinions about hammers." }, "5": { "label": "Positive", "humor": "Copilot saved 20 minutes today." }, "6": { "label": "Enthusiast", "humor": "Cyborg partnership workflow." }, "7": { "label": "Singularity evangelist", "humor": "Grocery list is AI-generated." } } }, + "j": { "name": "Gaming", "category": "entertainment", "type": "multi", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "Video games are a waste of time.", "7": "People disconnect when they see your name." }, "sub_id_groups": { "genre": { "label": "Genre", "sub_ids": { "rp": { "long": "rpg", "name": "RPG" }, "fp": { "long": "fps", "name": "Shooter" }, "st": { "long": "str", "name": "Strategy" }, "pz": { "long": "pzl", "name": "Puzzle" }, "si": { "long": "sim", "name": "Simulation" }, "mm": { "long": "mmo", "name": "MMO" }, "ad": { "long": "adv", "name": "Adventure" }, "sp": { "long": "spo", "name": "Sports" }, "mb": { "long": "mba", "name": "Metroidvania" }, "ro": { "long": "rog", "name": "Roguelike" }, "su": { "long": "surv", "name": "Survival" }, "sa": { "long": "sand", "name": "Sandbox" }, "rr": { "long": "race", "name": "Racing" }, "fi": { "long": "fight", "name": "Fighting" }, "ho": { "long": "horror", "name": "Horror" } } }, "platform": { "label": "Platform", "sub_ids": { "pc": { "name": "PC" }, "ps": { "name": "PlayStation" }, "xb": { "long": "xbox", "name": "Xbox" }, "ns": { "long": "switch", "name": "Switch" }, "mo": { "long": "mobile", "name": "Mobile" }, "re": { "long": "retro", "name": "Retro" }, "vr": { "name": "VR" }, "sd": { "long": "steamdeck", "name": "Steam Deck" } } }, "tabletop": { "label": "Tabletop", "sub_ids": { "dn": { "long": "dnd", "name": "D&D" }, "pf": { "long": "path", "name": "Pathfinder" }, "cc": { "long": "coc", "name": "Call of Cthulhu" }, "sr": { "name": "Shadowrun" }, "wo": { "long": "wod", "name": "World of Darkness" }, "ft": { "long": "fate", "name": "Fate" }, "gu": { "long": "gurps", "name": "GURPS" }, "bg": { "name": "Board games" }, "mt": { "long": "mtg", "name": "Magic: TG" }, "wh": { "name": "Warhammer" } } } } }, + "k": { "name": "Books", "category": "entertainment", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Illiterate by choice", "humor": "Words are tiny drawings that haven't tried hard enough." }, "1": { "label": "Not since school", "humor": "Didn't finish that one either." }, "2": { "label": "Web only", "humor": "You call this 'reading.'" }, "3": { "label": "Occasional", "humor": "Mostly when trapped on a plane." }, "4": { "label": "Few/year", "humor": "Goodreads updated sporadically." }, "5": { "label": "Regular", "humor": "To-read pile doubles as furniture." }, "6": { "label": "Avid", "humor": "Library cards from multiple jurisdictions." }, "7": { "label": "Book/week", "humor": "Shelves have shelves. Read this spec for fun." } } }, + "l": { "name": "Spoken Languages", "category": "identity", "type": "multi", "format": "ISO 639-1 + rating", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "scale_labels": { "0": "None", "1": "Few words", "2": "Tourist", "3": "Beginner", "4": "Conversational", "5": "Proficient", "6": "Fluent", "7": "Native" }, "common_codes": { "en": "English", "es": "Spanish", "fr": "French", "de": "German", "zh": "Chinese", "ar": "Arabic", "ja": "Japanese", "ko": "Korean", "pt": "Portuguese", "ru": "Russian", "hi": "Hindi", "it": "Italian", "nl": "Dutch", "sv": "Swedish", "tr": "Turkish", "pl": "Polish", "uk": "Ukrainian", "fa": "Farsi", "he": "Hebrew", "th": "Thai", "vi": "Vietnamese", "id": "Indonesian", "ms": "Malay", "sw": "Swahili", "bn": "Bengali", "ta": "Tamil", "fi": "Finnish", "no": "Norwegian", "da": "Danish", "el": "Greek", "ro": "Romanian", "hu": "Hungarian", "cs": "Czech", "sk": "Slovak", "bg": "Bulgarian", "sr": "Serbian", "hr": "Croatian", "ka": "Georgian", "am": "Amharic", "ur": "Urdu" } }, + "m": { "name": "Music", "category": "entertainment", "type": "multi", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "This genre is an assault on sound.", "7": "This genre IS your personality." }, "sub_ids": { "rk": { "long": "rock", "name": "Rock" }, "mt": { "long": "metal", "name": "Metal" }, "pk": { "long": "punk", "name": "Punk" }, "jz": { "long": "jazz", "name": "Jazz" }, "bl": { "long": "blues", "name": "Blues" }, "fk": { "long": "folk", "name": "Folk" }, "cl": { "long": "clas", "name": "Classical" }, "el": { "long": "elec", "name": "Electronic" }, "hp": { "long": "hip", "name": "Hip-hop" }, "pp": { "long": "pop", "name": "Pop" }, "rb": { "long": "rnb", "name": "R&B" }, "cn": { "long": "coun", "name": "Country" }, "ab": { "long": "amb", "name": "Ambient" }, "pg": { "long": "prog", "name": "Progressive" }, "rg": { "long": "reg", "name": "Reggae" }, "in": { "long": "ind", "name": "Indie" }, "sl": { "long": "soul", "name": "Soul" }, "lt": { "long": "lat", "name": "Latin" }, "wr": { "long": "wrld", "name": "World" }, "dk": { "long": "dnb", "name": "Drum & Bass" }, "tr": { "long": "trap", "name": "Trap" }, "lo": { "long": "lofi", "name": "Lo-fi" }, "sy": { "long": "synth", "name": "Synthwave" }, "go": { "long": "gospel", "name": "Gospel" }, "op": { "long": "opera", "name": "Opera" }, "ga": { "long": "garage", "name": "Garage" } } }, + "o": { "name": "Operating System", "category": "tech", "type": "multi", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "proficiency", "humor_scale": { "0": "Would mass-format every drive running this.", "7": "You ARE the sysadmin. First person plural." }, "sub_id_groups": { "linux": { "label": "Linux (x prefix)", "sub_ids": { "xa": { "long": "xarch", "name": "Arch" }, "xd": { "long": "xdeb", "name": "Debian" }, "xu": { "long": "xubu", "name": "Ubuntu" }, "xm": { "long": "xmint", "name": "Mint" }, "xp": { "long": "xpop", "name": "Pop!_OS" }, "xf": { "long": "xfed", "name": "Fedora" }, "xr": { "long": "xrh", "name": "Red Hat" }, "xc": { "long": "xcent", "name": "CentOS/Alma/Rocky" }, "xs": { "long": "xsuse", "name": "OpenSUSE" }, "xg": { "long": "xgen", "name": "Gentoo" }, "xj": { "long": "xmanj", "name": "Manjaro" }, "xsl": { "long": "xslk", "name": "Slackware" }, "xn": { "long": "xnix", "name": "NixOS" }, "xk": { "long": "xkali", "name": "Kali" }, "xv": { "long": "xvoid", "name": "Void" }, "xz": { "long": "xzor", "name": "Zorin" }, "xx": { "long": "xmx", "name": "MX Linux" }, "xt": { "long": "xtail", "name": "Tails" }, "xq": { "long": "xqube", "name": "Qubes" }, "xl": { "long": "xlfs", "name": "LFS" }, "xe": { "long": "xelem", "name": "Elementary" }, "xw": { "long": "xwsl", "name": "WSL" }, "xi": { "long": "ximmut", "name": "Immutable" }, "xb": { "long": "xbedrock", "name": "Bedrock" } } }, "bsd": { "label": "BSD", "sub_ids": { "fb": { "name": "FreeBSD" }, "ob": { "name": "OpenBSD" }, "nb": { "name": "NetBSD" }, "db": { "name": "DragonflyBSD" } } }, "windows": { "label": "Windows", "sub_ids": { "wt": { "long": "wten", "name": "Windows 10" }, "we": { "long": "welv", "name": "Windows 11" }, "ws": { "long": "wsrv", "name": "Win Server" } } }, "mac": { "label": "Mac", "sub_ids": { "mc": { "long": "mac", "name": "macOS" } } }, "mobile": { "label": "Mobile", "sub_ids": { "an": { "long": "android", "name": "Android" }, "io": { "long": "ios", "name": "iOS" }, "pm": { "long": "pmos", "name": "postmarketOS" }, "li": { "long": "lineage", "name": "LineageOS" } } }, "other": { "label": "Other", "sub_ids": { "sl": { "long": "sol", "name": "Solaris" }, "ax": { "long": "aix", "name": "AIX" }, "ch": { "long": "chrome", "name": "ChromeOS" }, "ha": { "long": "haiku", "name": "Haiku" }, "tm": { "long": "temple", "name": "TempleOS" }, "se": { "long": "serenity", "name": "SerenityOS" }, "rx": { "long": "redox", "name": "Redox" }, "pl": { "long": "plan9", "name": "Plan 9" } } } } }, + "p": { "name": "Programming Languages", "category": "tech", "type": "multi", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "proficiency", "humor_scale": { "0": "Mass-produced negative opinions without writing a line.", "7": "You ARE the language. People cite your blog posts." }, "sub_ids": { "py": { "long": "python", "name": "Python" }, "rs": { "long": "rust", "name": "Rust" }, "c": { "name": "C" }, "cp": { "long": "cpp", "name": "C++" }, "cs": { "long": "csharp", "name": "C#" }, "jv": { "long": "java", "name": "Java" }, "kt": { "long": "kotlin", "name": "Kotlin" }, "sw": { "long": "swift", "name": "Swift" }, "ph": { "long": "php", "name": "PHP" }, "pl": { "long": "perl", "name": "Perl" }, "lu": { "long": "lua", "name": "Lua" }, "hs": { "long": "haskell", "name": "Haskell" }, "cj": { "long": "clj", "name": "Clojure" }, "er": { "long": "erlang", "name": "Erlang" }, "ex": { "long": "elixir", "name": "Elixir" }, "sc": { "long": "scala", "name": "Scala" }, "jl": { "long": "julia", "name": "Julia" }, "r": { "name": "R" }, "m": { "long": "matlab", "name": "MATLAB" }, "zg": { "long": "zig", "name": "Zig" }, "ni": { "long": "nim", "name": "Nim" }, "as": { "long": "asm", "name": "Assembly" }, "sq": { "long": "sql", "name": "SQL" }, "ba": { "long": "bash", "name": "Bash" }, "ps": { "long": "psh", "name": "PowerShell" }, "vb": { "name": "Visual Basic" }, "da": { "long": "dart", "name": "Dart" }, "oc": { "long": "objc", "name": "Obj-C" }, "ls": { "long": "lisp", "name": "Lisp" }, "js": { "name": "JavaScript" }, "ts": { "name": "TypeScript" }, "go": { "name": "Go" }, "rb": { "long": "ruby", "name": "Ruby" }, "ml": { "long": "ocaml", "name": "OCaml" }, "fs": { "long": "fsharp", "name": "F#" }, "pr": { "long": "prolog", "name": "Prolog" }, "co": { "long": "cobol", "name": "COBOL" }, "fo": { "long": "fortran", "name": "Fortran" }, "gd": { "long": "gdscript", "name": "GDScript" }, "vl": { "long": "vhdl", "name": "VHDL" }, "sv": { "long": "sysv", "name": "SystemVerilog" }, "cr": { "long": "crystal", "name": "Crystal" }, "hy": { "long": "hylang", "name": "Hy" }, "rk": { "long": "racket", "name": "Racket" }, "tc": { "long": "tcl", "name": "Tcl" }, "aw": { "long": "awk", "name": "AWK" }, "sd": { "long": "sed", "name": "sed" }, "wm": { "long": "wasm", "name": "WASM" } } }, + "q": { "name": "Security / Hacking", "category": "tech", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": true }, "scale_type": "custom", "values": { "0": { "label": "No security", "humor": "Password is 'password'. Click every link." }, "1": { "label": "Don't care", "humor": "Firewalls are a construction thing." }, "2": { "label": "Default passwords", "humor": "Pet name plus birth year. Reused everywhere." }, "3": { "label": "Basic awareness", "humor": "Know phishing is bad. Fallen for it once." }, "4": { "label": "Password manager", "humor": "Normal human security." }, "5": { "label": "2FA everywhere", "humor": "Judge friends' passwords." }, "6": { "label": "Encrypts everything", "humor": "Paranoid but justified." }, "7": { "label": "Tinfoil hat", "humor": "NSA has a file but can't read it either." } } }, + "r": { "name": "Religion", "category": "stance", "type": "single", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "custom", "scale_labels": { "0": "Hostile", "1": "Strongly negative", "2": "Lapsed", "3": "Indifferent", "4": "Cultural", "5": "Moderate", "6": "Devout", "7": "Clergy-level" }, "sub_ids": { "ch": { "long": "chr", "name": "Christian" }, "is": { "long": "isl", "name": "Islam" }, "ju": { "long": "jud", "name": "Judaism" }, "hi": { "long": "hin", "name": "Hindu" }, "bu": { "long": "bud", "name": "Buddhist" }, "sk": { "long": "sik", "name": "Sikh" }, "pg": { "long": "pag", "name": "Pagan" }, "de": { "long": "dei", "name": "Deist" }, "ag": { "long": "agn", "name": "Agnostic" }, "at": { "long": "ath", "name": "Atheist" }, "pn": { "long": "pan", "name": "Pantheist" }, "sp": { "long": "spi", "name": "Spiritual" }, "zo": { "long": "zor", "name": "Zoroastrian" }, "ja": { "long": "jain", "name": "Jain" }, "ta": { "long": "tao", "name": "Taoist" }, "sh": { "long": "shin", "name": "Shinto" }, "ba": { "long": "bah", "name": "Bahá'í" }, "dr": { "long": "druz", "name": "Druze" } } }, + "s": { "name": "Sex / Relationships", "category": "lifestyle", "type": "direct", "modifiers": { "paid": false, "aspire": true, "alternative": true }, "scale_type": "custom", "values": { "0": { "label": "Celibate", "humor": "Made your peace." }, "1": { "label": "Not active (unwilling)", "humor": "Dating profile is unrewarded effort." }, "2": { "label": "Not active", "humor": "'Focusing on yourself.' Friends skeptical." }, "3": { "label": "Complicated", "humor": "Status requires a flowchart." }, "4": { "label": "Private", "humor": "None of your business." }, "5": { "label": "Dating", "humor": "At least two dating apps." }, "6": { "label": "Partnered", "humor": "Found someone who tolerates your geekery." }, "7": { "label": "Married w/ kids", "humor": "Evidence is small, loud, doesn't sleep." } } }, + "t": { "name": "TV / Film", "category": "entertainment", "type": "multi", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "Crime against storytelling.", "7": "Know the lore deeper than the writers." }, "sub_id_groups": { "scifi": { "label": "Sci-Fi", "sub_ids": { "st": { "long": "trek", "name": "Star Trek" }, "sw": { "long": "wars", "name": "Star Wars" }, "sg": { "long": "gate", "name": "Stargate" }, "ex": { "long": "exp", "name": "Expanse" }, "bs": { "long": "bsg", "name": "Battlestar" }, "bb": { "long": "bab", "name": "Babylon 5" }, "ff": { "name": "Firefly" }, "du": { "long": "dune", "name": "Dune" }, "fn": { "long": "fnd", "name": "Foundation" }, "dw": { "long": "drwho", "name": "Doctor Who" }, "tb": { "long": "tbp", "name": "3 Body Problem" }, "si": { "long": "silo", "name": "Silo" }, "fa": { "long": "fall", "name": "Fallout" }, "al": { "long": "alien", "name": "Alien" }, "bd": { "long": "blade", "name": "Blade Runner" }, "or": { "long": "orville", "name": "Orville" } } }, "fantasy": { "label": "Fantasy", "sub_ids": { "go": { "long": "got", "name": "Game of Thrones" }, "wo": { "long": "wot", "name": "Wheel of Time" }, "ro": { "long": "rop", "name": "Rings of Power" }, "wi": { "long": "wit", "name": "Witcher" }, "sn": { "long": "sand", "name": "Sandman" } } }, "other": { "label": "Other", "sub_ids": { "mp": { "long": "monty", "name": "Monty Python" }, "bt": { "long": "bbt", "name": "Big Bang Theory" }, "rm": { "name": "Rick & Morty" }, "bl": { "name": "Black Mirror" }, "sv": { "long": "sev", "name": "Severance" }, "mr": { "long": "mrobot", "name": "Mr. Robot" }, "hm": { "long": "humans", "name": "Humans" }, "ww": { "long": "westworld", "name": "Westworld" }, "dp": { "long": "devs", "name": "Devs" } } } } }, + "v": { "name": "Politics", "category": "stance", "type": "special", "format": "<social>/<economic>", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "dimensions": { "social": { "values": { "0": "Far-right authoritarian", "1": "Strong conservative", "2": "Conservative", "3": "Center-right", "4": "Centrist", "5": "Center-left", "6": "Progressive", "7": "Far-left libertarian" } }, "economic": { "values": { "0": "Unregulated capitalism", "1": "Strong free market", "2": "Fiscal conservative", "3": "Center-right", "4": "Mixed economy", "5": "Social democrat", "6": "Democratic socialist", "7": "Full socialism" } } } }, + "w": { "name": "Hardware", "category": "tech", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Defeated by USB", "humor": "The cable won." }, "1": { "label": "Can plug in", "humor": "Big hole is for power." }, "2": { "label": "Replaced a part", "humor": "Three YouTube videos and mild weeping." }, "3": { "label": "Troubleshoots", "humor": "Can tell RAM from a graphics card." }, "4": { "label": "Built a PC", "humor": "Cable management isn't great but it posted." }, "5": { "label": "Comfortable", "humor": "Friends call when things break." }, "6": { "label": "Mods regularly", "humor": "Void warranties recreationally." }, "7": { "label": "Builds everything", "humor": "Manufactured your own PCBs." } } }, + "x": { "name": "Comics / Manga", "category": "entertainment", "type": "multi", "modifiers": { "paid": false, "aspire": false, "alternative": false }, "scale_type": "enthusiasm", "humor_scale": { "0": "Comics are for children. (You're wrong.)", "7": "Collection requires its own room." }, "sub_ids": { "dc": { "name": "DC Comics" }, "mv": { "long": "marvel", "name": "Marvel" }, "mg": { "long": "manga", "name": "Manga" }, "dl": { "long": "dilbert", "name": "Dilbert" }, "xk": { "long": "xkcd", "name": "XKCD" }, "in": { "long": "indie", "name": "Indie" }, "wt": { "long": "webtoon", "name": "Webtoon" }, "gn": { "long": "graphicnovel", "name": "Graphic Novels" } } }, + "y": { "name": "Fitness", "category": "lifestyle", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "What is outside", "humor": "Cardiovascular exercise is walking to the fridge." }, "1": { "label": "Avoids activity", "humor": "Physical activity is a social obligation." }, "2": { "label": "Occasional walk", "humor": "Coffee is the point, not the walk." }, "3": { "label": "Light activity", "humor": "Own athletic wear. Never been to a gym." }, "4": { "label": "Some exercise", "humor": "Can climb stairs without existential crisis." }, "5": { "label": "Regular workouts", "humor": "Track your steps." }, "6": { "label": "Very active", "humor": "Resting heart rate impresses doctors." }, "7": { "label": "Daily athlete", "humor": "Body is a temple. Temple has a squat rack." } } }, + "z": { "name": "Maker / DIY", "category": "lifestyle", "type": "direct", "modifiers": { "paid": true, "aspire": true, "alternative": false }, "scale_type": "custom", "values": { "0": { "label": "Defeated by IKEA", "humor": "Allen keys are your nemesis." }, "1": { "label": "IKEA once", "humor": "One screw left over. Probably fine." }, "2": { "label": "Basic repairs", "humor": "Holes approximately where they should be." }, "3": { "label": "Handy", "humor": "Fixes hold. Duct tape is structural." }, "4": { "label": "Simple projects", "humor": "A shelf here, a birdhouse there." }, "5": { "label": "Regular maker", "humor": "Projects folder never shrinks." }, "6": { "label": "Advanced", "humor": "Workshop has zones." }, "7": { "label": "Built my house", "humor": "Hardware store sends Christmas card." } } } + }, + "examples": [ + { "handle": "jdoe", "description": "Full-stack developer", "uri": "ugi:0@jdoe:gcs$/dev,a4,b5/4,c3,hh4f5m4,e4,len7,oxa6we5,ppy6$js6$ts5rs4+6,dvs6,w5,q4,i5,v5/4,rag4,tst5ex7,k5,xxk6,jst5pc6,mrk5in4,s6,y4,f4,z3" }, + { "handle": "neo", "description": "Security researcher", "uri": "ugi:0@neo:gcy$/sec/os,a3,b5/3,c1,hh2,e6,len6zh4,oxd7$fb6,pc6$ba7as5rs5,ded7,w6,q7$,i1,v6/5,rat5,tff7bs6bl6,k7,jro6pc7,mmt6el5,s2,y5,f3,z6" }, + { "handle": "hax0r", "description": "Hardware hacker", "uri": "ugi:0/3@hax0r:gee$/rb/dy~solder,a5,b6/5,c2,hh3f6+7m5,e4,lja7en5,oxg7fb5,pc5ba6as7$,dem5,w7$,q6,i2,v3/3,rbu5,tbb6ff7,k5,xxk6dl5,jre7pc6bg5,mmt7pk6,s3,y5,f4,z7$" } + ], + "comparison": { + "features": [ + ["Rating", "+/- stacking", "+/- stacking", "0-9", "0-7 octal"], + ["Length", "4-6 lines", "4-8 lines", "1 line", "1 or 4-8"], + ["Fields", "~25", "~35+", "~16", "24+2"], + ["URI-safe", "No", "No", "Mostly", "Yes"], + ["Case", "Sensitive", "Sensitive", "Sensitive", "Insensitive"], + ["Extensible", "No", "No", "Limited", "~ custom"], + ["Format", "Block", "Block", "Inline", "Block+URI"], + ["Active", "1996", "GitHub", "2006", "Draft"] + ], + "retained": [ + { "ugi": "a", "from": "both", "note": "Age brackets" }, + { "ugi": "b", "from": "geek_code", "note": "Dimensions" }, + { "ugi": "c", "from": "geek_code", "note": "Dress/clothing" }, + { "ugi": "e", "from": "both", "note": "Education" }, + { "ugi": "g", "from": "geek_code", "note": "Geek type, expanded" }, + { "ugi": "h", "from": "geek_code_2026", "note": "Hair/beard" }, + { "ugi": "k", "from": "both", "note": "Books" }, + { "ugi": "o", "from": "both", "note": "OS merged" }, + { "ugi": "p", "from": "both", "note": "Programming" }, + { "ugi": "q", "from": "both", "note": "Security/PGP" }, + { "ugi": "s", "from": "both", "note": "Sex" }, + { "ugi": "t", "from": "geek_code", "note": "TV merged" }, + { "ugi": "v", "from": "both", "note": "Politics" }, + { "ugi": "w", "from": "hacker_key", "note": "Hardware" } + ], + "added": [ + { "ugi": "d", "reason": "Editor war must be documented" }, + { "ugi": "f", "reason": "Geeks cook now" }, + { "ugi": "i", "reason": "Defining divide of the 2020s" }, + { "ugi": "j", "reason": "Gaming beyond DOOM" }, + { "ugi": "l", "reason": "Geek culture is global" }, + { "ugi": "m", "reason": "Absent from predecessors" }, + { "ugi": "r", "reason": "From Hacker Key" }, + { "ugi": "x", "reason": "Beyond Dilbert" }, + { "ugi": "y", "reason": "Modern geek exercises" }, + { "ugi": "z", "reason": "Maker movement" } + ], + "removed": [ + { "field": "Usenet", "reason": "Dead" }, + { "field": "Kibo", "reason": "Niche" }, + { "field": "DOOM", "reason": "Use j" }, + { "field": "OS/2, VMS", "reason": "Use o+~custom" }, + { "field": "MBTI", "reason": "Pop psychology" }, + { "field": "Residence", "reason": "Low value" }, + { "field": "GitHub handle", "reason": "@handle" }, + { "field": "I/O", "reason": "Abstract" }, + { "field": "Cracking", "reason": "Use q" }, + { "field": "Lang hacking", "reason": "In g" }, + { "field": "Math level", "reason": "In e+g" }, + { "field": "Prog methodology", "reason": "In p" } + ] + } +} diff --git a/web/LICENSE b/web/LICENSE new file mode 100644 index 0000000..dd35f66 --- /dev/null +++ b/web/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmed Mohamed Alaa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..50e8cef --- /dev/null +++ b/web/README.md @@ -0,0 +1,46 @@ +# ugi.gumx.cc + +Web-based UGI (Unified Geek Identifier) encoder, decoder, and format converter. + +## What is this? + +A self-contained HTML tool for working with UGI strings — the modernized Geek Code. It provides three functions: + +- **Encoder** — form-based interface to build a UGI string by selecting fields, sub-IDs, and ratings +- **Decoder** — paste a UGI string (URI or block format) and see a human-readable breakdown +- **Converter** — convert between URI format (`ugi:0@handle:...`) and block format + +The tool is a single HTML file with no external dependencies. The full UGI registry is embedded inline. + +## Usage + +Open `index.html` in any browser. No server required. + +Supports `prefers-color-scheme` for automatic dark mode. + +## Regenerating + +The generator script takes the registry JSON path and output file path as arguments: + +```bash +python scripts/gen_tool.py /path/to/ugi_registry_v0.json index.html +``` + +### Requirements + +- Python 3.8+ +- No external dependencies + +## Project structure + +``` +ugi.gumx.cc/ +├── index.html # generated tool (deploy this) +├── scripts/ +│ └── gen_tool.py # HTML tool generator +└── README.md +``` + +## License + +MIT diff --git a/web/scripts/gen_tool.py b/web/scripts/gen_tool.py new file mode 100644 index 0000000..232a7eb --- /dev/null +++ b/web/scripts/gen_tool.py @@ -0,0 +1,1388 @@ +#!/usr/bin/env python3 +"""Generate UGI_TOOL_v0.html from ugi_registry_v0.json.""" + +import json +import os +import sys +import html + + +def load_registry(path): + with open(path, "r") as f: + return json.load(f) + + +def generate_html(reg): + reg_json = json.dumps(reg, ensure_ascii=False) + + return f"""<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>UGI Tool v{reg['spec']['version']} — Encoder / Decoder / Converter</title> +<style> +@font-face {{ font-family: "Kawkab Mono"; src: url(fonts/KawkabMono-Regular.woff2); font-weight: normal; }} +@font-face {{ font-family: "Kawkab Mono"; src: url(fonts/KawkabMono-Bold.woff2); font-weight: bold; }} +* {{ box-sizing: border-box; margin: 0; padding: 0; unicode-bidi: plaintext; }} +html {{ color: black; background-color: white; }} +body {{ + font-family: "Kawkab Mono", monospace; + font-size: 14px; line-height: 1.5; + max-width: 900px; margin: 0 auto; padding: 1rem; +}} +h1 {{ font-size: 1.3rem; margin-bottom: 0.5rem; }} +h2 {{ font-size: 1.1rem; margin: 1rem 0 0.5rem; }} +h3 {{ font-size: 0.95rem; margin: 0.8rem 0 0.3rem; }} +hr {{ border: none; border-top: 1px solid; margin: 1rem 0; }} +a {{ color: inherit; }} +@media (prefers-color-scheme: dark) {{ html {{ filter: invert(1); }} img {{ filter: invert(1); }} }} +.tabs {{ + display: flex; gap: 0; border-bottom: 2px solid; margin-bottom: 1rem; +}} +.tab {{ + padding: 0.4rem 1rem; cursor: pointer; border: 1px solid transparent; + border-bottom: none; background: rgba(128,128,128,0.1); + font-family: inherit; font-size: inherit; +}} +.tab.active {{ background: white; border-color: currentColor; border-bottom: 1px solid white; margin-bottom: -2px; font-weight: bold; }} +.tab-content {{ display: none; }} +.tab-content.active {{ display: block; }} +label {{ display: block; margin: 0.3rem 0 0.1rem; font-weight: bold; }} +input, select, textarea {{ + font-family: inherit; font-size: inherit; + border: 1px solid; + padding: 0.3rem 0.5rem; border-radius: 3px; +}} +input[type="range"] {{ background: none; border: none; padding: 0; width: 200px; vertical-align: middle; }} +input[type="checkbox"] {{ width: auto; margin-right: 0.3rem; vertical-align: middle; }} +textarea {{ width: 100%; min-height: 100px; resize: vertical; }} +button {{ + font-family: inherit; font-size: inherit; padding: 0.3rem 0.8rem; + cursor: pointer; background: black; color: white; border: none; + border-radius: 3px; +}} +button:hover {{ opacity: 0.7; }} +.field-section {{ margin: 0.5rem 0; padding: 0.5rem; border: 1px solid; border-radius: 3px; }} +.field-section summary {{ cursor: pointer; font-weight: bold; }} +.chip {{ + display: inline-block; padding: 0.15rem 0.5rem; margin: 0.15rem; + background: rgba(128,128,128,0.15); border-radius: 3px; font-size: 0.85rem; +}} +.chip .remove {{ cursor: pointer; margin-left: 0.3rem; }} +.output-box {{ + background: rgba(128,128,128,0.08); padding: 0.8rem; border-radius: 3px; + word-break: break-all; margin: 0.5rem 0; min-height: 2rem; + border: 1px solid; +}} +.decode-table {{ width: 100%; border-collapse: collapse; margin: 0.5rem 0; }} +.decode-table th, .decode-table td {{ + text-align: left; padding: 0.3rem 0.5rem; border: 1px solid; +}} +.decode-table th {{ background: rgba(128,128,128,0.1); }} +.humor {{ font-style: italic; font-size: 0.85rem; }} +.slider-row {{ display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }} +.slider-val {{ min-width: 1.5rem; text-align: center; font-weight: bold; }} +.slider-label {{ font-size: 0.85rem; }} +.multi-add {{ display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin: 0.3rem 0; }} +details {{ margin: 0.5rem 0; }} +details summary {{ cursor: pointer; }} +.skip-check {{ margin-left: 0.5rem; font-weight: normal; font-size: 0.85rem; }} +footer {{ text-align: center; margin-top: 3rem; font-size: 0.85rem; }} +</style> +</head> +<body> + +<h1>UGI Tool v{reg['spec']['version']}</h1> +<p>{html.escape(reg['spec']['description'])}</p> +<hr> + +<div class="tabs"> + <button class="tab active" onclick="switchTab('encoder')">Encoder</button> + <button class="tab" onclick="switchTab('decoder')">Decoder</button> + <button class="tab" onclick="switchTab('converter')">Converter</button> + <button class="tab" onclick="switchTab('spec')">Spec</button> +</div> + +<div id="tab-encoder" class="tab-content active"> + <h2>Encode your UGI</h2> + + <label>Handle (required):</label> + <input type="text" id="enc-handle" placeholder="yourhandle" oninput="encodeUGI()"> + + <label>Version:</label> + <div class="slider-row"> + <input type="text" id="enc-version" value="0" size="5" oninput="encodeUGI()"> + </div> + + <div id="enc-fields"></div> + + <hr> + <h3>URI output:</h3> + <div class="output-box" id="enc-uri-output"></div> + <button onclick="copyOutput('enc-uri-output')">Copy URI</button> + + <h3>Block output:</h3> + <pre class="output-box" id="enc-block-output"></pre> + <button onclick="copyOutput('enc-block-output')">Copy Block</button> +</div> + +<div id="tab-decoder" class="tab-content"> + <h2>Decode a UGI string</h2> + <label>Paste UGI (URI or block):</label> + <textarea id="dec-input" placeholder="ugi:0@jdoe:gcs$/dev,a4,b5/4,..." oninput="decodeUGI()"></textarea> + <div id="dec-output"></div> +</div> + +<div id="tab-converter" class="tab-content"> + <h2>Convert between formats</h2> + <label>Paste either format:</label> + <textarea id="conv-input" placeholder="Paste URI or block format..." oninput="convertUGI()"></textarea> + <h3>Converted output:</h3> + <pre class="output-box" id="conv-output"></pre> + <button onclick="copyOutput('conv-output')">Copy</button> +</div> + +<div id="tab-spec" class="tab-content"> + <div id="spec-content"></div> +</div> + +<hr> +<details> + <summary><strong>Quick Reference</strong></summary> + <pre id="quick-ref"></pre> +</details> + +<script> +const REG = {reg_json}; + +/* === Tab switching === */ +function switchTab(name) {{ + document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); + document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')); + document.getElementById('tab-' + name).classList.add('active'); + document.querySelector('[onclick="switchTab(\\'' + name + '\\')"]').classList.add('active'); +}} + +/* === Utility === */ +function copyOutput(id) {{ + const text = document.getElementById(id).textContent; + navigator.clipboard.writeText(text); +}} + +function getScale(field) {{ + const f = REG.fields[field]; + if (!f) return {{}}; + if (f.values) return f.values; + if (f.scale_labels) {{ + const out = {{}}; + for (const [k,v] of Object.entries(f.scale_labels)) out[k] = {{label: v}}; + return out; + }} + const st = f.scale_type; + if (st && st !== 'custom') {{ + const out = {{}}; + for (const v of REG.scale.values) out[v.value] = {{label: v[st] || v.general}}; + return out; + }} + return {{}}; +}} + +function getSubIds(field) {{ + const f = REG.fields[field]; + if (!f) return []; + const out = []; + if (f.sub_ids) {{ + for (const [k,v] of Object.entries(f.sub_ids)) {{ + const name = typeof v === 'object' ? (v.name || k) : v; + out.push({{code: k, name}}); + }} + }} + if (f.sub_id_groups) {{ + for (const [gk, group] of Object.entries(f.sub_id_groups)) {{ + const label = group.label || gk; + for (const [k,v] of Object.entries(group.sub_ids)) {{ + const name = typeof v === 'object' ? (v.name || k) : v; + out.push({{code: k, name, group: label}}); + }} + }} + }} + if (field === 'l' && f.common_codes) {{ + for (const [k,v] of Object.entries(f.common_codes)) {{ + out.push({{code: k, name: v}}); + }} + }} + return out; +}} + +function getGeekDomains() {{ + const f = REG.fields.g; + const out = []; + if (f.sub_id_groups) {{ + for (const [gk, group] of Object.entries(f.sub_id_groups)) {{ + const label = group.label || gk; + for (const [k,v] of Object.entries(group.sub_ids)) {{ + const name = typeof v === 'string' ? v : (v.name || k); + out.push({{code: k, name, group: label}}); + }} + }} + }} + return out; +}} + +/* === Encoder state === */ +const encoderState = {{}}; + +function markIncluded(code) {{ + const el = document.getElementById('skip-' + code); + if (el && !el.disabled) el.checked = true; +}} + +function buildEncoder() {{ + const container = document.getElementById('enc-fields'); + const allCodes = Object.keys(REG.fields).sort(); + // Put g first (required field) + const codes = ['g', ...allCodes.filter(c => c !== 'g')]; + + for (const code of codes) {{ + const f = REG.fields[code]; + const isRequired = code === 'g'; + const section = document.createElement('details'); + section.className = 'field-section'; + if (isRequired) section.open = true; + + const summary = document.createElement('summary'); + const skipId = 'skip-' + code; + const reqLabel = isRequired ? ' (required)' : ''; + summary.innerHTML = code.toUpperCase() + ' — ' + f.name + reqLabel + + '<label class="skip-check"><input type="checkbox" id="' + skipId + '" checked onchange="encodeUGI()"' + + (isRequired ? ' disabled' : '') + '> include</label>'; + section.appendChild(summary); + + const inner = document.createElement('div'); + inner.id = 'enc-field-' + code; + + if (f.type === 'direct') {{ + buildDirectField(inner, code, f); + }} else if (f.type === 'multi') {{ + buildMultiField(inner, code, f); + }} else if (f.type === 'single') {{ + buildSingleField(inner, code, f); + }} else if (f.type === 'special') {{ + if (code === 'g') buildGeekField(inner, f); + else if (code === 'b') buildBuildField(inner, f); + else if (code === 'v') buildPoliticsField(inner, f); + else if (code === 'l') buildLangField(inner, f); + }} + + section.appendChild(inner); + container.appendChild(section); + + // Default: unchecked (not included) except g (always checked) + if (code !== 'g') {{ + document.getElementById(skipId).checked = false; + }} + }} +}} + +function buildDirectField(container, code, f) {{ + const scale = getScale(code); + const mods = f.modifiers || {{}}; + + const row = document.createElement('div'); + row.className = 'slider-row'; + + const slider = document.createElement('input'); + slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4; + slider.id = 'enc-' + code + '-val'; + slider.oninput = function() {{ + document.getElementById('enc-' + code + '-display').textContent = this.value; + const s = scale[this.value]; + document.getElementById('enc-' + code + '-label').textContent = s ? (s.label || '') : ''; + const h = s ? (s.humor || '') : ''; + document.getElementById('enc-' + code + '-humor').textContent = h; + markIncluded(code); + encodeUGI(); + }}; + row.appendChild(slider); + + const val = document.createElement('span'); + val.className = 'slider-val'; val.id = 'enc-' + code + '-display'; val.textContent = '4'; + row.appendChild(val); + + const lab = document.createElement('span'); + lab.className = 'slider-label'; lab.id = 'enc-' + code + '-label'; + lab.textContent = scale['4'] ? (scale['4'].label || '') : ''; + row.appendChild(lab); + + container.appendChild(row); + + const humor = document.createElement('div'); + humor.className = 'humor'; humor.id = 'enc-' + code + '-humor'; + humor.textContent = scale['4'] ? (scale['4'].humor || '') : ''; + container.appendChild(humor); + + if (mods.alternative) {{ + const altDiv = document.createElement('div'); + altDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-alt-on" onchange="encodeUGI()"> Alternative value</label>'; + const altSlider = document.createElement('input'); + altSlider.type = 'range'; altSlider.min = 0; altSlider.max = 7; altSlider.value = 4; + altSlider.id = 'enc-' + code + '-alt'; altSlider.oninput = function() {{ encodeUGI(); }}; + altDiv.appendChild(altSlider); + container.appendChild(altDiv); + }} + + if (mods.paid) {{ + const pDiv = document.createElement('div'); + pDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-paid" onchange="encodeUGI()"> Paid ($)</label>'; + container.appendChild(pDiv); + }} + + if (mods.aspire) {{ + const aDiv = document.createElement('div'); + aDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-aspire-on" onchange="encodeUGI()"> Aspiring to:</label>'; + const aSel = document.createElement('select'); + aSel.id = 'enc-' + code + '-aspire'; + for (let i = 5; i <= 7; i++) {{ + const opt = document.createElement('option'); + opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : ''); + aSel.appendChild(opt); + }} + aSel.onchange = function() {{ encodeUGI(); }}; + aDiv.appendChild(aSel); + container.appendChild(aDiv); + }} +}} + +function buildMultiField(container, code, f) {{ + const subIds = getSubIds(code); + const mods = f.modifiers || {{}}; + const scale = getScale(code); + + encoderState[code] = []; + + const addRow = document.createElement('div'); + addRow.className = 'multi-add'; + + const sel = document.createElement('select'); + sel.id = 'enc-' + code + '-sel'; + const defOpt = document.createElement('option'); + defOpt.value = ''; defOpt.textContent = '— select —'; + sel.appendChild(defOpt); + + let lastGroup = ''; + for (const s of subIds) {{ + if (s.group && s.group !== lastGroup) {{ + const optg = document.createElement('optgroup'); + optg.label = s.group; + sel.appendChild(optg); + lastGroup = s.group; + }} + const opt = document.createElement('option'); + opt.value = s.code; opt.textContent = s.code + ' — ' + s.name; + if (lastGroup) {{ + sel.lastElementChild.appendChild(opt); + }} else {{ + sel.appendChild(opt); + }} + }} + addRow.appendChild(sel); + + const rSlider = document.createElement('input'); + rSlider.type = 'range'; rSlider.min = 0; rSlider.max = 7; rSlider.value = 5; + rSlider.id = 'enc-' + code + '-rating'; + addRow.appendChild(rSlider); + + const rVal = document.createElement('span'); + rVal.className = 'slider-val'; + rVal.id = 'enc-' + code + '-rating-display'; + rVal.textContent = '5'; + addRow.appendChild(rVal); + const rLabel = document.createElement('span'); + rLabel.className = 'slider-label'; + rLabel.id = 'enc-' + code + '-rating-label'; + rLabel.textContent = scale['5'] ? (scale['5'].label || '') : ''; + rSlider.oninput = function() {{ + rVal.textContent = this.value; + const s = scale[this.value]; + rLabel.textContent = s ? (s.label || '') : ''; + }}; + addRow.appendChild(rLabel); + + if (mods.paid) {{ + const pCb = document.createElement('label'); + pCb.innerHTML = '<input type="checkbox" id="enc-' + code + '-add-paid"> $'; + addRow.appendChild(pCb); + }} + if (mods.aspire) {{ + const aCb = document.createElement('label'); + aCb.innerHTML = '<input type="checkbox" id="enc-' + code + '-add-aspire-on"> +'; + addRow.appendChild(aCb); + const aSel = document.createElement('select'); + aSel.id = 'enc-' + code + '-add-aspire'; + for (let i = 5; i <= 7; i++) {{ + const opt = document.createElement('option'); + opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : ''); + aSel.appendChild(opt); + }} + addRow.appendChild(aSel); + }} + + const addBtn = document.createElement('button'); + addBtn.textContent = 'Add'; + addBtn.onclick = function() {{ + const subId = sel.value; + if (!subId) return; + const rating = rSlider.value; + const entry = {{ sub: subId, rating: parseInt(rating) }}; + if (mods.paid) {{ + const pEl = document.getElementById('enc-' + code + '-add-paid'); + if (pEl && pEl.checked) entry.paid = true; + }} + if (mods.aspire) {{ + const aOn = document.getElementById('enc-' + code + '-add-aspire-on'); + const aVal = document.getElementById('enc-' + code + '-add-aspire'); + if (aOn && aOn.checked) entry.aspire = parseInt(aVal.value); + }} + encoderState[code].push(entry); + renderChips(code); + markIncluded(code); + encodeUGI(); + }}; + addRow.appendChild(addBtn); + container.appendChild(addRow); + + const chipBox = document.createElement('div'); + chipBox.id = 'enc-' + code + '-chips'; + container.appendChild(chipBox); +}} + +function renderChips(code) {{ + const box = document.getElementById('enc-' + code + '-chips'); + box.innerHTML = ''; + for (let i = 0; i < encoderState[code].length; i++) {{ + const e = encoderState[code][i]; + const chip = document.createElement('span'); + chip.className = 'chip'; + let txt = e.sub + e.rating; + if (e.paid) txt += '$'; + if (e.aspire) txt += '+' + e.aspire; + chip.innerHTML = txt + ' <span class="remove" onclick="removeChip(\\''+code+'\\','+i+')">×</span>'; + box.appendChild(chip); + }} +}} + +function removeChip(code, idx) {{ + encoderState[code].splice(idx, 1); + renderChips(code); + encodeUGI(); +}} + +function buildSingleField(container, code, f) {{ + const subIds = getSubIds(code); + const scale = getScale(code); + + const sel = document.createElement('select'); + sel.id = 'enc-' + code + '-sub'; + sel.onchange = function() {{ markIncluded(code); encodeUGI(); }}; + const defOpt = document.createElement('option'); + defOpt.value = ''; defOpt.textContent = '— select —'; + sel.appendChild(defOpt); + for (const s of subIds) {{ + const opt = document.createElement('option'); + opt.value = s.code; opt.textContent = s.code + ' — ' + s.name; + sel.appendChild(opt); + }} + container.appendChild(sel); + + const row = document.createElement('div'); + row.className = 'slider-row'; + const slider = document.createElement('input'); + slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4; + slider.id = 'enc-' + code + '-val'; + slider.oninput = function() {{ + document.getElementById('enc-' + code + '-display').textContent = this.value; + const s = scale[this.value]; + document.getElementById('enc-' + code + '-label').textContent = s ? (s.label || '') : ''; + markIncluded(code); + encodeUGI(); + }}; + row.appendChild(slider); + const val = document.createElement('span'); + val.className = 'slider-val'; val.id = 'enc-' + code + '-display'; val.textContent = '4'; + row.appendChild(val); + const lab = document.createElement('span'); + lab.className = 'slider-label'; lab.id = 'enc-' + code + '-label'; + lab.textContent = scale['4'] ? (scale['4'].label || '') : ''; + row.appendChild(lab); + container.appendChild(row); +}} + +function buildGeekField(container, f) {{ + const domains = getGeekDomains(); + encoderState.g = {{ domains: [], custom: '' }}; + + let lastGroup = ''; + let gridDiv = null; + for (const d of domains) {{ + if (d.group && d.group !== lastGroup) {{ + const h = document.createElement('h3'); + h.textContent = d.group; + container.appendChild(h); + lastGroup = d.group; + gridDiv = document.createElement('div'); + gridDiv.style.display = 'grid'; + gridDiv.style.gridTemplateColumns = 'repeat(auto-fill, minmax(220px, 1fr))'; + gridDiv.style.gap = '0.1rem 0'; + container.appendChild(gridDiv); + }} + const lbl = document.createElement('label'); + lbl.innerHTML = '<input type="checkbox" data-gcode="' + d.code + '" onchange="updateGeek()"> ' + + d.code + ' ' + d.name + + ' <input type="checkbox" data-gpaid="' + d.code + '" onchange="updateGeek()" title="paid ($)" style="margin-left:4px"> $'; + (gridDiv || container).appendChild(lbl); + }} + + const customDiv = document.createElement('div'); + customDiv.style.marginTop = '0.5rem'; + customDiv.innerHTML = '<label>Custom (~): <input type="text" id="enc-g-custom" placeholder="fermenting" oninput="updateGeek()"></label>'; + container.appendChild(customDiv); +}} + +function updateGeek() {{ + const domains = []; + document.querySelectorAll('[data-gcode]').forEach(cb => {{ + if (cb.checked) {{ + const code = cb.getAttribute('data-gcode'); + const paidCb = document.querySelector('[data-gpaid="' + code + '"]'); + domains.push({{ code, paid: paidCb && paidCb.checked }}); + }} + }}); + encoderState.g = {{ + domains, + custom: (document.getElementById('enc-g-custom').value || '').trim() + }}; + encodeUGI(); +}} + +function buildBuildField(container, f) {{ + for (const dim of ['height', 'width']) {{ + const vals = f.dimensions[dim].values; + const row = document.createElement('div'); + row.className = 'slider-row'; + row.innerHTML = '<strong>' + dim + ':</strong>'; + const slider = document.createElement('input'); + slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4; + slider.id = 'enc-b-' + dim; + const disp = document.createElement('span'); + disp.className = 'slider-val'; disp.id = 'enc-b-' + dim + '-display'; disp.textContent = '4'; + const lab = document.createElement('span'); + lab.className = 'slider-label'; lab.id = 'enc-b-' + dim + '-label'; + lab.textContent = vals['4'] || ''; + slider.oninput = function() {{ + disp.textContent = this.value; + lab.textContent = vals[this.value] || ''; + markIncluded('b'); + encodeUGI(); + }}; + row.appendChild(slider); row.appendChild(disp); row.appendChild(lab); + container.appendChild(row); + }} + + const aDiv = document.createElement('div'); + aDiv.innerHTML = '<label><input type="checkbox" id="enc-b-aspire-on" onchange="encodeUGI()"> Aspiring (+)</label>'; + container.appendChild(aDiv); +}} + +function buildPoliticsField(container, f) {{ + for (const dim of ['social', 'economic']) {{ + const vals = f.dimensions[dim].values; + const row = document.createElement('div'); + row.className = 'slider-row'; + row.innerHTML = '<strong>' + dim + ':</strong>'; + const slider = document.createElement('input'); + slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4; + slider.id = 'enc-v-' + dim; + const disp = document.createElement('span'); + disp.className = 'slider-val'; disp.id = 'enc-v-' + dim + '-display'; disp.textContent = '4'; + const lab = document.createElement('span'); + lab.className = 'slider-label'; lab.id = 'enc-v-' + dim + '-label'; + lab.textContent = vals['4'] || ''; + slider.oninput = function() {{ + disp.textContent = this.value; + lab.textContent = vals[this.value] || ''; + markIncluded('v'); + encodeUGI(); + }}; + row.appendChild(slider); row.appendChild(disp); row.appendChild(lab); + container.appendChild(row); + }} +}} + +function buildLangField(container, f) {{ + encoderState.l = []; + const mods = f.modifiers || {{}}; + const scale = getScale('l'); + + const addRow = document.createElement('div'); + addRow.className = 'multi-add'; + + const codes = f.common_codes || {{}}; + const sel = document.createElement('select'); + sel.id = 'enc-l-sel'; + const defOpt = document.createElement('option'); + defOpt.value = ''; defOpt.textContent = '— select or type —'; + sel.appendChild(defOpt); + for (const [k,v] of Object.entries(codes)) {{ + const opt = document.createElement('option'); + opt.value = k; opt.textContent = k + ' — ' + v; + sel.appendChild(opt); + }} + addRow.appendChild(sel); + + const customInput = document.createElement('input'); + customInput.type = 'text'; customInput.size = 4; customInput.maxLength = 2; + customInput.placeholder = 'or ISO'; customInput.id = 'enc-l-custom'; + addRow.appendChild(customInput); + + const rSlider = document.createElement('input'); + rSlider.type = 'range'; rSlider.min = 0; rSlider.max = 7; rSlider.value = 5; + rSlider.id = 'enc-l-rating'; + addRow.appendChild(rSlider); + const rVal = document.createElement('span'); + rVal.className = 'slider-val'; rVal.id = 'enc-l-rating-display'; rVal.textContent = '5'; + addRow.appendChild(rVal); + const rLabel = document.createElement('span'); + rLabel.className = 'slider-label'; rLabel.id = 'enc-l-rating-label'; + rLabel.textContent = scale['5'] ? (scale['5'].label || '') : ''; + rSlider.oninput = function() {{ + rVal.textContent = this.value; + const s = scale[this.value]; + rLabel.textContent = s ? (s.label || '') : ''; + }}; + addRow.appendChild(rLabel); + + if (mods.paid) {{ + const pCb = document.createElement('label'); + pCb.innerHTML = '<input type="checkbox" id="enc-l-add-paid"> $'; + addRow.appendChild(pCb); + }} + if (mods.aspire) {{ + const aCb = document.createElement('label'); + aCb.innerHTML = '<input type="checkbox" id="enc-l-add-aspire-on"> +'; + addRow.appendChild(aCb); + const aSel = document.createElement('select'); + aSel.id = 'enc-l-add-aspire'; + for (let i = 5; i <= 7; i++) {{ + const opt = document.createElement('option'); + opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : ''); + aSel.appendChild(opt); + }} + addRow.appendChild(aSel); + }} + + const addBtn = document.createElement('button'); + addBtn.textContent = 'Add'; + addBtn.onclick = function() {{ + let langCode = sel.value || customInput.value.toLowerCase().trim(); + if (!langCode || langCode.length !== 2) return; + const rating = document.getElementById('enc-l-rating').value; + const entry = {{ sub: langCode, rating: parseInt(rating) }}; + if (mods.paid) {{ + const pEl = document.getElementById('enc-l-add-paid'); + if (pEl && pEl.checked) entry.paid = true; + }} + if (mods.aspire) {{ + const aOn = document.getElementById('enc-l-add-aspire-on'); + const aVal = document.getElementById('enc-l-add-aspire'); + if (aOn && aOn.checked) entry.aspire = parseInt(aVal.value); + }} + encoderState.l.push(entry); + renderChips('l'); + markIncluded('l'); + encodeUGI(); + }}; + addRow.appendChild(addBtn); + container.appendChild(addRow); + + const chipBox = document.createElement('div'); + chipBox.id = 'enc-l-chips'; + container.appendChild(chipBox); +}} + +/* === Encode === */ +function encodeUGI() {{ + const handle = document.getElementById('enc-handle').value.trim(); + const version = document.getElementById('enc-version').value.trim() || '0'; + if (!handle) {{ + document.getElementById('enc-uri-output').textContent = '(enter a handle)'; + document.getElementById('enc-block-output').textContent = ''; + return; + }} + + const parts = []; + const blockParts = []; + + const codes = Object.keys(REG.fields).sort(); + for (const code of codes) {{ + const skipEl = document.getElementById('skip-' + code); + if (skipEl && !skipEl.checked) continue; + + const f = REG.fields[code]; + const ftype = f.type; + + if (ftype === 'direct') {{ + const valEl = document.getElementById('enc-' + code + '-val'); + if (!valEl) continue; + let s = code + valEl.value; + let bs = code.toUpperCase() + valEl.value; + const mods = f.modifiers || {{}}; + + if (mods.alternative) {{ + const altOn = document.getElementById('enc-' + code + '-alt-on'); + if (altOn && altOn.checked) {{ + const altVal = document.getElementById('enc-' + code + '-alt'); + s += '/' + altVal.value; + bs += '/' + altVal.value; + }} + }} + if (mods.paid) {{ + const pEl = document.getElementById('enc-' + code + '-paid'); + if (pEl && pEl.checked) {{ s += '$'; bs += '$'; }} + }} + if (mods.aspire) {{ + const aOn = document.getElementById('enc-' + code + '-aspire-on'); + if (aOn && aOn.checked) {{ + const aVal = document.getElementById('enc-' + code + '-aspire').value; + s += '+' + aVal; bs += '+' + aVal; + }} + }} + parts.push(s); + blockParts.push(bs); + + }} else if (ftype === 'multi') {{ + const entries = encoderState[code] || []; + if (entries.length === 0) continue; + let uri = code; + const bps = []; + for (const e of entries) {{ + let frag = e.sub + e.rating; + if (e.paid) frag += '$'; + if (e.aspire) frag += '+' + e.aspire; + uri += frag; + bps.push(code.toUpperCase() + frag); + }} + parts.push(uri); + blockParts.push(...bps); + + }} else if (ftype === 'single') {{ + const subEl = document.getElementById('enc-' + code + '-sub'); + const valEl = document.getElementById('enc-' + code + '-val'); + if (!subEl || !subEl.value) continue; + const s = code + subEl.value + valEl.value; + const bs = code.toUpperCase() + subEl.value + valEl.value; + parts.push(s); + blockParts.push(bs); + + }} else if (ftype === 'special') {{ + if (code === 'g') {{ + const gState = encoderState.g || {{ domains: [], custom: '' }}; + if (gState.domains.length === 0 && !gState.custom) continue; + const dParts = gState.domains.map(d => d.code + (d.paid ? '$' : '')); + if (gState.custom) dParts.push('~' + gState.custom); + const gStr = 'g' + dParts.join('/'); + parts.push(gStr); + blockParts.push('G' + dParts.join('/')); + + }} else if (code === 'b') {{ + const h = document.getElementById('enc-b-height'); + const w = document.getElementById('enc-b-width'); + if (!h || !w) continue; + parts.push('b' + h.value + '/' + w.value); + blockParts.push('B' + h.value + '/' + w.value); + + }} else if (code === 'v') {{ + const s = document.getElementById('enc-v-social'); + const e = document.getElementById('enc-v-economic'); + if (!s || !e) continue; + parts.push('v' + s.value + '/' + e.value); + blockParts.push('V' + s.value + '/' + e.value); + + }} else if (code === 'l') {{ + const entries = encoderState.l || []; + if (entries.length === 0) continue; + let uri = 'l'; + const bps = []; + for (const e of entries) {{ + let frag = e.sub + e.rating; + if (e.paid) frag += '$'; + if (e.aspire) frag += '+' + e.aspire; + uri += frag; + bps.push('L' + frag); + }} + parts.push(uri); + blockParts.push(...bps); + }} + }} + }} + + // Build URI + const uri = 'ugi:' + version + '@' + handle + ':' + parts.join(','); + document.getElementById('enc-uri-output').textContent = uri; + + // Build block + // G field goes on first line + const gPart = blockParts.find(p => p.startsWith('G')); + const otherParts = blockParts.filter(p => !p.startsWith('G')); + + let block = '------- BEGIN UGI BLOCK -------\\n'; + block += 'v:' + version + ' @' + handle; + if (gPart) block += ' ' + gPart; + block += '\\n'; + + // Group by category + const cats = {{}}; + for (const bp of otherParts) {{ + const c = bp[0].toLowerCase(); + const fld = REG.fields[c]; + const cat = fld ? fld.category : 'other'; + if (!cats[cat]) cats[cat] = []; + cats[cat].push(bp); + }} + + const catOrder = ['identity', 'appearance', 'tech', 'stance', 'entertainment', 'lifestyle']; + for (const cat of catOrder) {{ + if (cats[cat] && cats[cat].length > 0) {{ + block += cats[cat].join(' ') + '\\n'; + }} + }} + + block += '-------- END UGI BLOCK --------'; + document.getElementById('enc-block-output').textContent = block; +}} + +/* === Decoder === */ +function decodeUGI() {{ + const input = document.getElementById('dec-input').value.trim(); + const output = document.getElementById('dec-output'); + if (!input) {{ output.innerHTML = ''; return; }} + + try {{ + let uri = input; + // Detect block format + if (input.includes('BEGIN UGI BLOCK')) {{ + uri = blockToUri(input); + }} + const parsed = parseUri(uri); + renderDecoded(parsed, output); + }} catch(e) {{ + output.innerHTML = '<p style="color:red">Parse error: ' + e.message + '</p>'; + }} +}} + +function parseUri(uri) {{ + let rest = uri; + if (rest.toLowerCase().startsWith('ugi:')) rest = rest.substring(4); + + const atIdx = rest.indexOf('@'); + if (atIdx < 0) throw new Error('Missing @handle'); + const version = rest.substring(0, atIdx); + rest = rest.substring(atIdx + 1); + + const colonIdx = rest.indexOf(':'); + if (colonIdx < 0) throw new Error('Missing : after handle'); + const handle = rest.substring(0, colonIdx); + rest = rest.substring(colonIdx + 1); + + const fieldStrs = rest.split(','); + const result = {{ version, handle, fields: [] }}; + + for (const fs of fieldStrs) {{ + if (!fs) continue; + const code = fs[0].toLowerCase(); + const raw = fs.substring(1); + const f = REG.fields[code]; + if (!f) {{ + result.fields.push({{ code, raw, name: '(unknown)', decoded: raw }}); + continue; + }} + + result.fields.push({{ code, raw, name: f.name, decoded: decodeField(code, raw, f) }}); + }} + + return result; +}} + +function decodeField(code, raw, f) {{ + const ftype = f.type; + const scale = getScale(code); + + if (ftype === 'direct') {{ + if (!raw) return ''; + const digit = raw[0]; + const label = scale[digit] ? (scale[digit].label || digit) : digit; + let result = label; + let rest = raw.substring(1); + if (rest.includes('/')) {{ + const parts = rest.split('/'); + if (parts[1]) {{ + const altLabel = scale[parts[1][0]] ? (scale[parts[1][0]].label || parts[1][0]) : parts[1][0]; + result += ' / ' + altLabel; + rest = parts[1].substring(1); + }} + }} else {{ + // modifiers + rest = raw.substring(1); + }} + if (rest.includes('$')) result += ' [paid]'; + const aspMatch = rest.match(/\\+(\\d)/); + if (aspMatch) result += ' [aspiring to ' + aspMatch[1] + ']'; + return result; + + }} else if (ftype === 'special') {{ + if (code === 'g') {{ + return raw.split('/').map(d => {{ + if (d.startsWith('~')) return 'custom:' + d.substring(1); + const paid = d.endsWith('$'); + const dcode = paid ? d.slice(0,-1) : d; + const name = resolveGeekDomain(dcode); + return name + (paid ? ' [paid]' : ''); + }}).join(', '); + }} else if (code === 'b' || code === 'v') {{ + const parts = raw.split('/'); + const dims = Object.keys(f.dimensions); + return dims.map((d, i) => {{ + const v = parts[i] ? parts[i][0] : '?'; + const vals = f.dimensions[d].values; + return d + ': ' + (vals[v] || v); + }}).join(', '); + }} + return raw; + + }} else if (ftype === 'multi' || ftype === 'single') {{ + // l uses ISO 639-1 codes with common_codes lookup, not sub_ids + if (code === 'l') return decodeLangEntries(raw, f); + return decodeMultiEntries(code, raw, f, scale); + }} + return raw; +}} + +function decodeMultiEntries(code, raw, f, scale) {{ + const entries = []; + let i = 0; + while (i < raw.length) {{ + if (raw[i] === '~') {{ + let j = i + 1; + while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++; + const sub = raw.substring(i, j); + const digit = j < raw.length && /[0-7]/.test(raw[j]) ? raw[j] : '?'; + let mod = ''; + let k = j + 1; + while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }} + const label = scale[digit] ? (scale[digit].label || digit) : digit; + entries.push('custom:' + sub.substring(1) + '=' + label + modStr(mod)); + i = k; + }} else if (/[a-zA-Z]/.test(raw[i])) {{ + let j = i; + while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++; + const sub = raw.substring(i, j); + const digit = j < raw.length && /[0-7]/.test(raw[j]) ? raw[j] : '?'; + let mod = ''; + let k = j + 1; + while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }} + const name = resolveSubId(code, sub, f); + const label = scale[digit] ? (scale[digit].label || digit) : digit; + entries.push(name + '=' + label + modStr(mod)); + i = k; + }} else {{ + i++; + }} + }} + return entries.join(', '); +}} + +function decodeLangEntries(raw, f) {{ + const entries = []; + const scale = getScale('l'); + let i = 0; + while (i < raw.length) {{ + if (/[a-zA-Z]/.test(raw[i]) && i + 2 < raw.length) {{ + const lang = raw.substring(i, i+2); + const digit = raw[i+2]; + let mod = ''; + let k = i + 3; + while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }} + const langName = (REG.fields.l.common_codes || {{}})[lang] || lang; + const label = scale[digit] ? (scale[digit].label || digit) : digit; + entries.push(langName + '=' + label + modStr(mod)); + i = k; + }} else {{ + i++; + }} + }} + return entries.join(', '); +}} + +function modStr(mod) {{ + let s = ''; + if (mod.includes('$')) s += ' [paid]'; + const m = mod.match(/\\+(\\d)/); + if (m) s += ' [aspiring to ' + m[1] + ']'; + return s; +}} + +function resolveSubId(code, sub, f) {{ + sub = sub.toLowerCase(); + if (f.sub_ids && f.sub_ids[sub]) {{ + const v = f.sub_ids[sub]; + return typeof v === 'object' ? (v.name || sub) : v; + }} + if (f.sub_id_groups) {{ + for (const group of Object.values(f.sub_id_groups)) {{ + if (group.sub_ids && group.sub_ids[sub]) {{ + const v = group.sub_ids[sub]; + return typeof v === 'object' ? (v.name || sub) : v; + }} + }} + }} + return sub; +}} + +function resolveGeekDomain(code) {{ + code = code.toLowerCase(); + const f = REG.fields.g; + if (f.sub_id_groups) {{ + for (const group of Object.values(f.sub_id_groups)) {{ + if (group.sub_ids && group.sub_ids[code]) {{ + const v = group.sub_ids[code]; + return typeof v === 'string' ? v : (v.name || code); + }} + }} + }} + return code; +}} + +function renderDecoded(parsed, container) {{ + let html = '<h3>Decoded: @' + parsed.handle + ' (v' + parsed.version + ')</h3>'; + html += '<table class="decode-table"><tr><th>Code</th><th>Field</th><th>Raw</th><th>Decoded</th></tr>'; + for (const f of parsed.fields) {{ + html += '<tr><td>' + f.code + '</td><td>' + f.name + '</td><td><code>' + f.raw + '</code></td><td>' + f.decoded + '</td></tr>'; + }} + html += '</table>'; + container.innerHTML = html; +}} + +/* === Converter === */ +function convertUGI() {{ + const input = document.getElementById('conv-input').value.trim(); + const output = document.getElementById('conv-output'); + if (!input) {{ output.textContent = ''; return; }} + + try {{ + if (input.includes('BEGIN UGI BLOCK')) {{ + output.textContent = blockToUri(input); + }} else if (input.toLowerCase().startsWith('ugi:')) {{ + output.textContent = uriToBlock(input); + }} else {{ + output.textContent = '(could not detect format — paste a URI starting with "ugi:" or a block)'; + }} + }} catch(e) {{ + output.textContent = 'Error: ' + e.message; + }} +}} + +function blockToUri(block) {{ + const lines = block.split(/\\r?\\n/).map(l => l.trim()).filter(l => l && !l.startsWith('---')); + if (lines.length === 0) throw new Error('Empty block'); + + // First line: v:0 @handle G... + const firstLine = lines[0]; + const vMatch = firstLine.match(/^v:(\\S+)\\s+@(\\S+)(?:\\s+(.*))?$/i); + if (!vMatch) throw new Error('Invalid first line'); + const version = vMatch[1]; + const handle = vMatch[2]; + const gPart = vMatch[3] || ''; + + // Remaining lines: space-separated block fields + const blockFields = []; + if (gPart) blockFields.push(gPart); + for (let i = 1; i < lines.length; i++) {{ + blockFields.push(...lines[i].split(/\\s+/)); + }} + + // Group by field code (first letter), merge multi entries + const fieldMap = {{}}; + for (const bf of blockFields) {{ + if (!bf) continue; + const code = bf[0].toLowerCase(); + const rest = bf.substring(1); + if (!fieldMap[code]) fieldMap[code] = ''; + fieldMap[code] += rest; + }} + + const parts = []; + // Sort by field code + for (const code of Object.keys(fieldMap).sort()) {{ + parts.push(code + fieldMap[code].toLowerCase()); + }} + + return 'ugi:' + version + '@' + handle + ':' + parts.join(','); +}} + +function uriToBlock(uri) {{ + let rest = uri; + if (rest.toLowerCase().startsWith('ugi:')) rest = rest.substring(4); + const atIdx = rest.indexOf('@'); + const version = rest.substring(0, atIdx); + rest = rest.substring(atIdx + 1); + const colonIdx = rest.indexOf(':'); + const handle = rest.substring(0, colonIdx); + rest = rest.substring(colonIdx + 1); + + const fieldStrs = rest.split(','); + let block = '------- BEGIN UGI BLOCK -------\\n'; + let firstLine = 'v:' + version + ' @' + handle; + + const blockParts = []; + for (const fs of fieldStrs) {{ + if (!fs) continue; + const code = fs[0].toLowerCase(); + const raw = fs.substring(1); + const f = REG.fields[code]; + if (!f) {{ + blockParts.push({{ code, parts: [code.toUpperCase() + raw] }}); + continue; + }} + + if (code === 'g') {{ + firstLine += ' G' + raw; + continue; + }} + + const ftype = f.type; + if (ftype === 'multi' || (ftype === 'special' && code === 'l')) {{ + // Expand: split sub-id entries + const expanded = expandMultiToBlock(code, raw); + blockParts.push({{ code, parts: expanded, cat: f.category }}); + }} else {{ + blockParts.push({{ code, parts: [code.toUpperCase() + raw], cat: f.category }}); + }} + }} + + block += firstLine + '\\n'; + + // Group by category + const cats = {{}}; + for (const bp of blockParts) {{ + const cat = bp.cat || 'other'; + if (!cats[cat]) cats[cat] = []; + cats[cat].push(...bp.parts); + }} + + const catOrder = ['identity', 'appearance', 'tech', 'stance', 'entertainment', 'lifestyle']; + for (const cat of catOrder) {{ + if (cats[cat] && cats[cat].length > 0) {{ + block += cats[cat].join(' ') + '\\n'; + }} + }} + + block += '-------- END UGI BLOCK --------'; + return block; +}} + +function expandMultiToBlock(code, raw) {{ + const parts = []; + const uc = code.toUpperCase(); + let i = 0; + while (i < raw.length) {{ + if (raw[i] === '~') {{ + let j = i + 1; + while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++; + let sub = raw.substring(i, j); + let digit = ''; + if (j < raw.length && /[0-7]/.test(raw[j])) {{ digit = raw[j]; j++; }} + let mod = ''; + while (j < raw.length && /[\\$\\+0-9]/.test(raw[j])) {{ mod += raw[j]; j++; }} + parts.push(uc + sub + digit + mod); + i = j; + }} else if (/[a-zA-Z]/.test(raw[i])) {{ + let j = i; + while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++; + let sub = raw.substring(i, j); + let digit = ''; + if (j < raw.length && /[0-7]/.test(raw[j])) {{ digit = raw[j]; j++; }} + let mod = ''; + while (j < raw.length && /[\\$\\+0-9]/.test(raw[j])) {{ mod += raw[j]; j++; }} + parts.push(uc + sub + digit + mod); + i = j; + }} else {{ + i++; + }} + }} + return parts; +}} + +/* === Quick Reference === */ +function buildQuickRef() {{ + const fields = REG.fields; + const codes = Object.keys(fields).sort(); + let ref = 'FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields>\\n'; + ref += 'SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed\\n'; + ref += 'MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom\\n\\n'; + + const allCodes = []; + for (const c of 'abcdefghijklmnopqrstuvwxyz') {{ + if (fields[c]) {{ + allCodes.push(c + ' ' + fields[c].name.toLowerCase()); + }} else if (c === 'n' || c === 'u') {{ + allCodes.push('[' + c + ' reserved]'); + }} + }} + + for (let i = 0; i < allCodes.length; i += 5) {{ + ref += allCodes.slice(i, i+5).map(s => s.padEnd(16)).join('') + '\\n'; + }} + + document.getElementById('quick-ref').textContent = ref; +}} + +/* === Spec tab === */ +function buildSpec() {{ + const el = document.getElementById('spec-content'); + let out = ''; + + // Global scale table + out += '<h2>Global Scale (0–7)</h2>'; + out += '<table><thead><tr><th>#</th><th>General</th><th>Proficiency</th><th>Enthusiasm</th><th>Stance</th></tr></thead><tbody>'; + for (const sv of REG.scale.values) {{ + out += '<tr><td>' + sv.value + '</td><td>' + sv.general + '</td><td>' + sv.proficiency + '</td><td>' + sv.enthusiasm + '</td><td>' + sv.stance + '</td></tr>'; + }} + out += '</tbody></table>'; + + // Fields: g first, then alphabetical + const allCodes = Object.keys(REG.fields).sort(); + const codes = ['g', ...allCodes.filter(c => c !== 'g')]; + + for (const code of codes) {{ + const f = REG.fields[code]; + out += '<hr><h2>' + code.toUpperCase() + ' — ' + f.name; + if (code === 'g') out += ' <em>(required)</em>'; + out += '</h2>'; + if (f.description) out += '<p>' + f.description + '</p>'; + + let meta = '<strong>Type:</strong> ' + f.type; + if (f.category) meta += ' | <strong>Category:</strong> ' + f.category; + if (f.scale_type) meta += ' | <strong>Scale:</strong> ' + f.scale_type; + const mods = Object.keys(f.modifiers || {{}}); + if (mods.length) meta += ' | <strong>Modifiers:</strong> ' + mods.join(', '); + out += '<p>' + meta + '</p>'; + + // Custom values (direct fields) + if (f.values) {{ + out += '<ol start="0">'; + for (let i = 0; i <= 7; i++) {{ + const v = f.values[String(i)] || {{}}; + out += '<li><strong>' + (v.label || '—') + '</strong>'; + if (v.humor) out += ' — <em>' + v.humor + '</em>'; + out += '</li>'; + }} + out += '</ol>'; + }} + + // Custom scale labels (multi / lang / single fields) + if (f.scale_labels) {{ + out += '<ol start="0">'; + for (let i = 0; i <= 7; i++) {{ + const label = f.scale_labels[String(i)] || '—'; + const humor = f.humor_scale ? f.humor_scale[String(i)] : null; + out += '<li><strong>' + label + '</strong>'; + if (humor) out += ' — <em>' + humor + '</em>'; + out += '</li>'; + }} + out += '</ol>'; + }} + + // Humor scale without scale_labels (multi fields referencing global scale) + if (!f.scale_labels && f.humor_scale) {{ + out += '<p><em>Notable:</em></p><ul>'; + for (const [k, h] of Object.entries(f.humor_scale)) {{ + out += '<li>' + k + ': ' + h + '</li>'; + }} + out += '</ul>'; + }} + + // Sub-IDs (flat) + if (f.sub_ids) {{ + out += '<ul>'; + for (const [sid, sv] of Object.entries(f.sub_ids)) {{ + out += '<li><code>' + sid + '</code> — ' + sv.name; + if (sv.long) out += ' (block alias: <code>' + sv.long + '</code>)'; + out += '</li>'; + }} + out += '</ul>'; + }} + + // Sub-ID groups + if (f.sub_id_groups) {{ + for (const [gkey, group] of Object.entries(f.sub_id_groups)) {{ + out += '<h3>' + (group.label || gkey) + '</h3><ul>'; + for (const [sid, sv] of Object.entries(group.sub_ids || {{}})) {{ + out += '<li><code>' + sid + '</code> — ' + sv.name + '</li>'; + }} + out += '</ul>'; + }} + }} + + // Build / Politics dimensions + if (f.dimensions) {{ + for (const [dimName, dim] of Object.entries(f.dimensions)) {{ + out += '<h3>' + dimName.charAt(0).toUpperCase() + dimName.slice(1) + '</h3><ul>'; + for (const [k, v] of Object.entries(dim.values || {{}})) {{ + out += '<li>' + k + ': ' + v + '</li>'; + }} + out += '</ul>'; + }} + }} + }} + + el.innerHTML = out; +}} + +/* === Init === */ +buildEncoder(); +buildQuickRef(); +buildSpec(); +encodeUGI(); +</script> +<footer> +<hr> +<a href="https://gumx.cc">gumx.cc</a> / +<a href="https://git.gumx.cc">git</a> / +<a href="https://mail.gumx.cc">mail</a> / +<a href="https://irc.gumx.cc">irc</a> / +<a href="https://vpn.gumx.cc">vpn</a> / +<a href="https://pgp.gumx.cc">pgp</a> / +<a href="https://wk.fo">wk.fo</a> +</footer> +</body> +</html>""" + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} <registry.json> <output.html>", file=sys.stderr) + sys.exit(1) + + registry_path, output_path = sys.argv[1], sys.argv[2] + reg = load_registry(registry_path) + html_content = generate_html(reg) + + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + with open(output_path, "w") as f: + f.write(html_content) + + print(f"Generated {output_path}") + + +if __name__ == "__main__": + main() |
