From c33e907fefbfe5ec58d83175dd4153842f1c6069 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 1 Apr 2026 20:55:08 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20funciones=20NordVPN=20bash=20y=20Go?= =?UTF-8?q?=20=E2=80=94=20CLI,=20contenedor=20Docker=20y=20parser=20de=20e?= =?UTF-8?q?stado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funciones bash para instalar, conectar, desconectar, estado, IP, ciudades, países y protocolo. Funciones Go para gestionar contenedor NordVPN (run/start/stop) y parsear estado. Incluye tipo NordVPNStatus y tests para el parser. Co-Authored-By: Claude Opus 4.6 (1M context) --- bash/functions/infra/install_nordvpn.md | 37 ++++++++++ bash/functions/infra/install_nordvpn.sh | 41 +++++++++++ bash/functions/infra/nordvpn_connect.md | 40 ++++++++++ bash/functions/infra/nordvpn_connect.sh | 39 ++++++++++ bash/functions/infra/nordvpn_disconnect.md | 34 +++++++++ bash/functions/infra/nordvpn_disconnect.sh | 26 +++++++ bash/functions/infra/nordvpn_get_ip.md | 39 ++++++++++ bash/functions/infra/nordvpn_get_ip.sh | 42 +++++++++++ bash/functions/infra/nordvpn_list_cities.md | 37 ++++++++++ bash/functions/infra/nordvpn_list_cities.sh | 38 ++++++++++ .../functions/infra/nordvpn_list_countries.md | 34 +++++++++ .../functions/infra/nordvpn_list_countries.sh | 36 +++++++++ bash/functions/infra/nordvpn_set_protocol.md | 37 ++++++++++ bash/functions/infra/nordvpn_set_protocol.sh | 38 ++++++++++ bash/functions/infra/nordvpn_status.md | 37 ++++++++++ bash/functions/infra/nordvpn_status.sh | 43 +++++++++++ functions/infra/nordvpn_container_run.go | 44 +++++++++++ functions/infra/nordvpn_container_run.md | 53 ++++++++++++++ functions/infra/nordvpn_container_start.go | 73 +++++++++++++++++++ functions/infra/nordvpn_container_start.md | 42 +++++++++++ functions/infra/nordvpn_container_stop.go | 29 ++++++++ functions/infra/nordvpn_container_stop.md | 35 +++++++++ functions/infra/parse_nordvpn_status.go | 68 +++++++++++++++++ functions/infra/parse_nordvpn_status.md | 41 +++++++++++ functions/infra/parse_nordvpn_status_test.go | 62 ++++++++++++++++ types/infra/nordvpn_status.md | 31 ++++++++ 26 files changed, 1076 insertions(+) create mode 100644 bash/functions/infra/install_nordvpn.md create mode 100644 bash/functions/infra/install_nordvpn.sh create mode 100644 bash/functions/infra/nordvpn_connect.md create mode 100644 bash/functions/infra/nordvpn_connect.sh create mode 100644 bash/functions/infra/nordvpn_disconnect.md create mode 100644 bash/functions/infra/nordvpn_disconnect.sh create mode 100644 bash/functions/infra/nordvpn_get_ip.md create mode 100644 bash/functions/infra/nordvpn_get_ip.sh create mode 100644 bash/functions/infra/nordvpn_list_cities.md create mode 100644 bash/functions/infra/nordvpn_list_cities.sh create mode 100644 bash/functions/infra/nordvpn_list_countries.md create mode 100644 bash/functions/infra/nordvpn_list_countries.sh create mode 100644 bash/functions/infra/nordvpn_set_protocol.md create mode 100644 bash/functions/infra/nordvpn_set_protocol.sh create mode 100644 bash/functions/infra/nordvpn_status.md create mode 100644 bash/functions/infra/nordvpn_status.sh create mode 100644 functions/infra/nordvpn_container_run.go create mode 100644 functions/infra/nordvpn_container_run.md create mode 100644 functions/infra/nordvpn_container_start.go create mode 100644 functions/infra/nordvpn_container_start.md create mode 100644 functions/infra/nordvpn_container_stop.go create mode 100644 functions/infra/nordvpn_container_stop.md create mode 100644 functions/infra/parse_nordvpn_status.go create mode 100644 functions/infra/parse_nordvpn_status.md create mode 100644 functions/infra/parse_nordvpn_status_test.go create mode 100644 types/infra/nordvpn_status.md diff --git a/bash/functions/infra/install_nordvpn.md b/bash/functions/infra/install_nordvpn.md new file mode 100644 index 00000000..66aa70fc --- /dev/null +++ b/bash/functions/infra/install_nordvpn.md @@ -0,0 +1,37 @@ +--- +name: install_nordvpn +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "install_nordvpn() -> void" +description: "Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2). Configura repositorio oficial, instala paquete y habilita servicio nordvpnd. Idempotente." +tags: [vpn, nordvpn, install, infra, wsl2] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/install_nordvpn.sh" +--- + +## Ejemplo + +```bash +source install_nordvpn.sh +install_nordvpn +# nordvpn ya instalado: NordVPN Version 3.x.x +# — o — +# Instalando NordVPN CLI... +# NordVPN instalado: NordVPN Version 3.x.x +# NOTA: ejecuta 'nordvpn login' para autenticarte +``` + +## Notas + +Usa el script de instalacion oficial de NordVPN. En WSL2 sin systemd, levanta nordvpnd manualmente. Agrega el usuario al grupo nordvpn para evitar sudo en comandos posteriores. Despues de instalar, se requiere `nordvpn login` para autenticarse. diff --git a/bash/functions/infra/install_nordvpn.sh b/bash/functions/infra/install_nordvpn.sh new file mode 100644 index 00000000..ed832e2c --- /dev/null +++ b/bash/functions/infra/install_nordvpn.sh @@ -0,0 +1,41 @@ +# install_nordvpn +# --------------- +# Instala NordVPN CLI en Ubuntu/Debian (incluido WSL2). +# Configura el repositorio oficial, instala el paquete y habilita el servicio. +# Si ya esta instalado, no hace nada. +# +# USO (sourced): +# source install_nordvpn.sh +# install_nordvpn + +install_nordvpn() { + if command -v nordvpn &>/dev/null; then + echo "nordvpn ya instalado: $(nordvpn version 2>/dev/null)" + return 0 + fi + + echo "Instalando NordVPN CLI..." + + # Descargar e instalar via script oficial + sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh) 2>&1 + + if ! command -v nordvpn &>/dev/null; then + echo "install_nordvpn: fallo la instalacion" >&2 + return 1 + fi + + # Agregar usuario al grupo nordvpn para evitar sudo + sudo usermod -aG nordvpn "$USER" 2>/dev/null || true + + # Habilitar servicio (systemd o manual para WSL2) + if command -v systemctl &>/dev/null && systemctl is-system-running &>/dev/null 2>&1; then + sudo systemctl enable --now nordvpnd 2>/dev/null || true + else + # WSL2 sin systemd — levantar daemon manualmente + sudo nordvpnd &>/dev/null & + sleep 2 + fi + + echo "NordVPN instalado: $(nordvpn version 2>/dev/null)" + echo "NOTA: ejecuta 'nordvpn login' para autenticarte" +} diff --git a/bash/functions/infra/nordvpn_connect.md b/bash/functions/infra/nordvpn_connect.md new file mode 100644 index 00000000..19d0c0c9 --- /dev/null +++ b/bash/functions/infra/nordvpn_connect.md @@ -0,0 +1,40 @@ +--- +name: nordvpn_connect +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "nordvpn_connect(country?: string, city?: string) -> json" +description: "Conecta a NordVPN por pais, ciudad o servidor especifico. Sin argumentos conecta al mejor servidor disponible. Devuelve JSON con resultado." +tags: [vpn, nordvpn, connect, infra, network] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/nordvpn_connect.sh" +--- + +## Ejemplo + +```bash +source nordvpn_connect.sh + +nordvpn_connect +# {"ok":true,"server":"us1234.nordvpn.com","country":"auto","city":"auto"} + +nordvpn_connect Spain +# {"ok":true,"server":"es42.nordvpn.com","country":"Spain","city":"auto"} + +nordvpn_connect Spain Madrid +# {"ok":true,"server":"es15.nordvpn.com","country":"Spain","city":"Madrid"} +``` + +## Notas + +Requiere NordVPN CLI instalado y autenticado (`nordvpn login`). La salida JSON facilita composicion con otros scripts y pipelines. Si ya hay una conexion activa, NordVPN reconecta automaticamente al nuevo destino. diff --git a/bash/functions/infra/nordvpn_connect.sh b/bash/functions/infra/nordvpn_connect.sh new file mode 100644 index 00000000..24b685e2 --- /dev/null +++ b/bash/functions/infra/nordvpn_connect.sh @@ -0,0 +1,39 @@ +# nordvpn_connect +# --------------- +# Conecta a NordVPN. Acepta pais, ciudad o servidor especifico. +# Sin argumentos conecta al mejor servidor disponible. +# Imprime JSON con el resultado de la conexion. +# +# USO (sourced): +# source nordvpn_connect.sh +# nordvpn_connect # mejor servidor +# nordvpn_connect Spain # por pais +# nordvpn_connect Spain Madrid # por ciudad +# nordvpn_connect Spain '#42' # servidor especifico + +nordvpn_connect() { + local country="${1:-}" + local city="${2:-}" + + if ! command -v nordvpn &>/dev/null; then + echo '{"ok":false,"error":"nordvpn no instalado"}' >&2 + return 1 + fi + + local args=() + [ -n "$country" ] && args+=("$country") + [ -n "$city" ] && args+=("$city") + + local output + output=$(nordvpn connect "${args[@]}" 2>&1) + local rc=$? + + if [ $rc -eq 0 ] && echo "$output" | grep -qi "connected"; then + local server + server=$(echo "$output" | grep -oP '(?<=to )\S+' | head -1) + echo "{\"ok\":true,\"server\":\"${server}\",\"country\":\"${country:-auto}\",\"city\":\"${city:-auto}\"}" + else + echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}" + return 1 + fi +} diff --git a/bash/functions/infra/nordvpn_disconnect.md b/bash/functions/infra/nordvpn_disconnect.md new file mode 100644 index 00000000..9b01d6c6 --- /dev/null +++ b/bash/functions/infra/nordvpn_disconnect.md @@ -0,0 +1,34 @@ +--- +name: nordvpn_disconnect +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "nordvpn_disconnect() -> json" +description: "Desconecta de NordVPN. Idempotente — si no hay conexion activa retorna ok. Devuelve JSON con resultado." +tags: [vpn, nordvpn, disconnect, infra, network] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/nordvpn_disconnect.sh" +--- + +## Ejemplo + +```bash +source nordvpn_disconnect.sh + +nordvpn_disconnect +# {"ok":true,"status":"disconnected"} +``` + +## Notas + +Idempotente: si no hay conexion activa, retorna ok sin error. Requiere NordVPN CLI instalado. diff --git a/bash/functions/infra/nordvpn_disconnect.sh b/bash/functions/infra/nordvpn_disconnect.sh new file mode 100644 index 00000000..c5cd6f85 --- /dev/null +++ b/bash/functions/infra/nordvpn_disconnect.sh @@ -0,0 +1,26 @@ +# nordvpn_disconnect +# ------------------ +# Desconecta de NordVPN. Idempotente — si no hay conexion activa, retorna ok. +# Imprime JSON con el resultado. +# +# USO (sourced): +# source nordvpn_disconnect.sh +# nordvpn_disconnect + +nordvpn_disconnect() { + if ! command -v nordvpn &>/dev/null; then + echo '{"ok":false,"error":"nordvpn no instalado"}' >&2 + return 1 + fi + + local output + output=$(nordvpn disconnect 2>&1) + local rc=$? + + if [ $rc -eq 0 ] || echo "$output" | grep -qi "not connected\|disconnected"; then + echo '{"ok":true,"status":"disconnected"}' + else + echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}" + return 1 + fi +} diff --git a/bash/functions/infra/nordvpn_get_ip.md b/bash/functions/infra/nordvpn_get_ip.md new file mode 100644 index 00000000..58c06f73 --- /dev/null +++ b/bash/functions/infra/nordvpn_get_ip.md @@ -0,0 +1,39 @@ +--- +name: nordvpn_get_ip +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "nordvpn_get_ip() -> json" +description: "Obtiene IP publica actual con fallback entre multiples servicios. Indica si la conexion VPN esta activa y el servidor usado." +tags: [vpn, nordvpn, ip, infra, network, verification] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/nordvpn_get_ip.sh" +--- + +## Ejemplo + +```bash +source nordvpn_get_ip.sh + +# Con VPN activa: +nordvpn_get_ip +# {"ok":true,"ip":"185.x.x.x","vpn_connected":true,"vpn_server":"es42.nordvpn.com","source":"https://api.ipify.org"} + +# Sin VPN: +nordvpn_get_ip +# {"ok":true,"ip":"88.x.x.x","vpn_connected":false,"vpn_server":"","source":"https://api.ipify.org"} +``` + +## Notas + +Usa ipify.org como servicio primario con fallback a ifconfig.me e icanhazip.com. Timeout de 5 segundos por servicio. Util para verificar que el tunel VPN esta activo antes de ejecutar operaciones sensibles a la IP. diff --git a/bash/functions/infra/nordvpn_get_ip.sh b/bash/functions/infra/nordvpn_get_ip.sh new file mode 100644 index 00000000..70ae1c75 --- /dev/null +++ b/bash/functions/infra/nordvpn_get_ip.sh @@ -0,0 +1,42 @@ +# nordvpn_get_ip +# -------------- +# Obtiene la IP publica actual para verificar que el tunel VPN funciona. +# Usa multiples servicios como fallback. +# +# USO (sourced): +# source nordvpn_get_ip.sh +# nordvpn_get_ip + +nordvpn_get_ip() { + local ip="" + local source="" + + # Intentar multiples servicios + for svc in "https://api.ipify.org" "https://ifconfig.me" "https://icanhazip.com"; do + ip=$(curl -s --max-time 5 "$svc" 2>/dev/null) + if echo "$ip" | grep -qP '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'; then + source="$svc" + break + fi + ip="" + done + + if [ -z "$ip" ]; then + echo '{"ok":false,"error":"no se pudo obtener IP publica"}' >&2 + return 1 + fi + + # Si nordvpn esta disponible, incluir info de conexion + local connected="false" + local vpn_server="" + if command -v nordvpn &>/dev/null; then + local status_output + status_output=$(nordvpn status 2>/dev/null) + if echo "$status_output" | grep -qi "connected"; then + connected="true" + vpn_server=$(echo "$status_output" | grep -iP "hostname|server" | head -1 | sed 's/.*: *//') + fi + fi + + echo "{\"ok\":true,\"ip\":\"$ip\",\"vpn_connected\":$connected,\"vpn_server\":\"$vpn_server\",\"source\":\"$source\"}" +} diff --git a/bash/functions/infra/nordvpn_list_cities.md b/bash/functions/infra/nordvpn_list_cities.md new file mode 100644 index 00000000..21c5bc58 --- /dev/null +++ b/bash/functions/infra/nordvpn_list_cities.md @@ -0,0 +1,37 @@ +--- +name: nordvpn_list_cities +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "nordvpn_list_cities(country: string) -> json" +description: "Lista ciudades disponibles de un pais en NordVPN como array JSON ordenado." +tags: [vpn, nordvpn, cities, infra, network] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/nordvpn_list_cities.sh" +--- + +## Ejemplo + +```bash +source nordvpn_list_cities.sh + +nordvpn_list_cities Spain +# {"ok":true,"country":"Spain","count":2,"cities":["Barcelona","Madrid"]} + +nordvpn_list_cities "United_States" +# {"ok":true,"country":"United_States","count":15,"cities":["Atlanta","Buffalo",...]} +``` + +## Notas + +El nombre de pais debe coincidir con lo que devuelve `nordvpn countries`. Usa underscores para paises compuestos (ej: United_States). Las ciudades se devuelven con espacios. diff --git a/bash/functions/infra/nordvpn_list_cities.sh b/bash/functions/infra/nordvpn_list_cities.sh new file mode 100644 index 00000000..d5380974 --- /dev/null +++ b/bash/functions/infra/nordvpn_list_cities.sh @@ -0,0 +1,38 @@ +# nordvpn_list_cities +# ------------------- +# Lista las ciudades disponibles de un pais en NordVPN como array JSON. +# +# USO (sourced): +# source nordvpn_list_cities.sh +# nordvpn_list_cities Spain + +nordvpn_list_cities() { + local country="${1:?nordvpn_list_cities: se requiere pais como argumento}" + + if ! command -v nordvpn &>/dev/null; then + echo '{"ok":false,"error":"nordvpn no instalado"}' >&2 + return 1 + fi + + local output + output=$(nordvpn cities "$country" 2>&1) + local rc=$? + + if [ $rc -ne 0 ]; then + echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}" + return 1 + fi + + echo "$output" | python3 -c ' +import sys, json, re + +country = "'"$country"'" +text = sys.stdin.read() +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +text = re.sub(r"[\t\r]", " ", text) +cities = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"] +cities = [c for c in cities if len(c) > 1] +cities.sort() +print(json.dumps({"ok": True, "country": country, "count": len(cities), "cities": cities})) +' +} diff --git a/bash/functions/infra/nordvpn_list_countries.md b/bash/functions/infra/nordvpn_list_countries.md new file mode 100644 index 00000000..e7f38b27 --- /dev/null +++ b/bash/functions/infra/nordvpn_list_countries.md @@ -0,0 +1,34 @@ +--- +name: nordvpn_list_countries +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "nordvpn_list_countries() -> json" +description: "Lista paises disponibles en NordVPN como array JSON ordenado alfabeticamente." +tags: [vpn, nordvpn, countries, infra, network] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/nordvpn_list_countries.sh" +--- + +## Ejemplo + +```bash +source nordvpn_list_countries.sh + +nordvpn_list_countries +# {"ok":true,"count":60,"countries":["Albania","Argentina","Australia",...,"United States","Vietnam"]} +``` + +## Notas + +Parsea la salida de `nordvpn countries` eliminando codigos ANSI y normalizando separadores. Los nombres de paises se devuelven con espacios en vez de underscores. diff --git a/bash/functions/infra/nordvpn_list_countries.sh b/bash/functions/infra/nordvpn_list_countries.sh new file mode 100644 index 00000000..f05c5d7d --- /dev/null +++ b/bash/functions/infra/nordvpn_list_countries.sh @@ -0,0 +1,36 @@ +# nordvpn_list_countries +# ---------------------- +# Lista los paises disponibles en NordVPN como array JSON. +# +# USO (sourced): +# source nordvpn_list_countries.sh +# nordvpn_list_countries + +nordvpn_list_countries() { + if ! command -v nordvpn &>/dev/null; then + echo '{"ok":false,"error":"nordvpn no instalado"}' >&2 + return 1 + fi + + local output + output=$(nordvpn countries 2>&1) + local rc=$? + + if [ $rc -ne 0 ]; then + echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}" + return 1 + fi + + echo "$output" | python3 -c ' +import sys, json, re + +text = sys.stdin.read() +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +text = re.sub(r"[\t\r]", " ", text) +# Split by comma, whitespace, or newline and clean +countries = [c.strip().replace("_", " ") for c in re.split(r"[,\n]+", text) if c.strip() and c.strip() != "-"] +countries = [c for c in countries if len(c) > 1] +countries.sort() +print(json.dumps({"ok": True, "count": len(countries), "countries": countries})) +' +} diff --git a/bash/functions/infra/nordvpn_set_protocol.md b/bash/functions/infra/nordvpn_set_protocol.md new file mode 100644 index 00000000..952093da --- /dev/null +++ b/bash/functions/infra/nordvpn_set_protocol.md @@ -0,0 +1,37 @@ +--- +name: nordvpn_set_protocol +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "nordvpn_set_protocol(protocol: string) -> json" +description: "Cambia el protocolo de NordVPN entre NordLynx (WireGuard) y OpenVPN. NordLynx recomendado por velocidad." +tags: [vpn, nordvpn, protocol, nordlynx, wireguard, openvpn, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/nordvpn_set_protocol.sh" +--- + +## Ejemplo + +```bash +source nordvpn_set_protocol.sh + +nordvpn_set_protocol NordLynx +# {"ok":true,"protocol":"NordLynx"} + +nordvpn_set_protocol OpenVPN +# {"ok":true,"protocol":"OpenVPN"} +``` + +## Notas + +NordLynx es WireGuard wrapeado por NordVPN — mas rapido y moderno. OpenVPN es mas compatible con redes restrictivas. El cambio de protocolo requiere reconectar si hay una conexion activa. diff --git a/bash/functions/infra/nordvpn_set_protocol.sh b/bash/functions/infra/nordvpn_set_protocol.sh new file mode 100644 index 00000000..8634064b --- /dev/null +++ b/bash/functions/infra/nordvpn_set_protocol.sh @@ -0,0 +1,38 @@ +# nordvpn_set_protocol +# -------------------- +# Cambia el protocolo de NordVPN (NordLynx o OpenVPN). +# NordLynx = WireGuard (recomendado por velocidad). +# +# USO (sourced): +# source nordvpn_set_protocol.sh +# nordvpn_set_protocol NordLynx +# nordvpn_set_protocol OpenVPN + +nordvpn_set_protocol() { + local protocol="${1:?nordvpn_set_protocol: se requiere protocolo (NordLynx|OpenVPN)}" + + if ! command -v nordvpn &>/dev/null; then + echo '{"ok":false,"error":"nordvpn no instalado"}' >&2 + return 1 + fi + + case "$protocol" in + NordLynx|nordlynx|NORDLYNX) protocol="NordLynx" ;; + OpenVPN|openvpn|OPENVPN) protocol="OpenVPN" ;; + *) + echo "{\"ok\":false,\"error\":\"protocolo invalido: $protocol (usar NordLynx o OpenVPN)\"}" + return 1 + ;; + esac + + local output + output=$(nordvpn set protocol "$protocol" 2>&1) + local rc=$? + + if [ $rc -eq 0 ] || echo "$output" | grep -qi "already set\|successfully"; then + echo "{\"ok\":true,\"protocol\":\"$protocol\"}" + else + echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}" + return 1 + fi +} diff --git a/bash/functions/infra/nordvpn_status.md b/bash/functions/infra/nordvpn_status.md new file mode 100644 index 00000000..b197ef62 --- /dev/null +++ b/bash/functions/infra/nordvpn_status.md @@ -0,0 +1,37 @@ +--- +name: nordvpn_status +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "nordvpn_status() -> json" +description: "Obtiene estado actual de NordVPN como JSON estructurado. Incluye servidor, IP, pais, protocolo y estado de conexion." +tags: [vpn, nordvpn, status, infra, network] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/nordvpn_status.sh" +--- + +## Ejemplo + +```bash +source nordvpn_status.sh + +nordvpn_status +# {"ok":true,"connected":true,"status":"Connected","hostname":"es42.nordvpn.com","ip":"185.x.x.x","country":"Spain","city":"Madrid","current_technology":"NordLynx","current_protocol":"nordlynx","transfer":"1.2 MiB received, 500 KiB sent","uptime":"5 minutes 32 seconds"} + +# Desconectado: +# {"ok":true,"connected":false,"status":"Disconnected"} +``` + +## Notas + +Parsea la salida clave-valor de `nordvpn status` eliminando codigos ANSI. Los campos disponibles dependen del estado de conexion — cuando esta desconectado solo devuelve status y connected. diff --git a/bash/functions/infra/nordvpn_status.sh b/bash/functions/infra/nordvpn_status.sh new file mode 100644 index 00000000..c7cdc1b0 --- /dev/null +++ b/bash/functions/infra/nordvpn_status.sh @@ -0,0 +1,43 @@ +# nordvpn_status +# -------------- +# Obtiene el estado actual de NordVPN como JSON estructurado. +# Parsea la salida clave-valor de `nordvpn status` a campos JSON. +# +# USO (sourced): +# source nordvpn_status.sh +# nordvpn_status + +nordvpn_status() { + if ! command -v nordvpn &>/dev/null; then + echo '{"ok":false,"error":"nordvpn no instalado"}' >&2 + return 1 + fi + + local output + output=$(nordvpn status 2>&1) + local rc=$? + + if [ $rc -ne 0 ]; then + echo "{\"ok\":false,\"error\":$(echo "$output" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))' 2>/dev/null || echo "\"$output\"")}" + return 1 + fi + + # Parsear output clave: valor a JSON con python3 + echo "$output" | python3 -c ' +import sys, json, re + +lines = sys.stdin.read().strip().split("\n") +data = {"ok": True} +for line in lines: + line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip() + line = line.lstrip("- ") + if ":" in line: + key, _, val = line.partition(":") + key = key.strip().lower().replace(" ", "_") + val = val.strip() + if key == "status": + data["connected"] = val.lower() == "connected" + data[key] = val +print(json.dumps(data)) +' +} diff --git a/functions/infra/nordvpn_container_run.go b/functions/infra/nordvpn_container_run.go new file mode 100644 index 00000000..c12c2018 --- /dev/null +++ b/functions/infra/nordvpn_container_run.go @@ -0,0 +1,44 @@ +package infra + +import ( + "fmt" +) + +// NordVPNContainerRunOpts opciones para ejecutar un container a traves del gateway NordVPN. +type NordVPNContainerRunOpts struct { + Image string // Imagen Docker a ejecutar (obligatorio) + Cmd []string // Comando a ejecutar en el container + Env map[string]string // Variables de entorno + Volumes []string // Bind mounts + Name string // Nombre del container (opcional) + Gateway string // Nombre del container NordVPN gateway (default: "nordvpn") + Detach bool // Ejecutar en background + Remove bool // Eliminar al terminar (--rm) +} + +// NordVPNContainerRun ejecuta un container Docker cuyo trafico de red +// pasa por el container gateway NordVPN usando --network=container:. +// Devuelve el ID del container creado. +func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error) { + if opts.Image == "" { + return "", fmt.Errorf("image required") + } + if opts.Gateway == "" { + opts.Gateway = "nordvpn" + } + + id, err := DockerRunContainer(opts.Image, DockerRunOpts{ + Name: opts.Name, + Env: opts.Env, + Volumes: opts.Volumes, + Detach: opts.Detach, + Remove: opts.Remove, + Network: "container:" + opts.Gateway, + Cmd: opts.Cmd, + }) + if err != nil { + return "", fmt.Errorf("nordvpn container run %s: %w", opts.Image, err) + } + + return id, nil +} diff --git a/functions/infra/nordvpn_container_run.md b/functions/infra/nordvpn_container_run.md new file mode 100644 index 00000000..e33ec2e2 --- /dev/null +++ b/functions/infra/nordvpn_container_run.md @@ -0,0 +1,53 @@ +--- +name: nordvpn_container_run +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error)" +description: "Ejecuta un container Docker cuyo trafico pasa por el gateway NordVPN usando --network=container:. El container hereda la IP y tunel VPN del gateway." +tags: [vpn, nordvpn, docker, container, run, infra, network] +uses_functions: ["docker_run_container_go_infra"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [fmt] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/nordvpn_container_run.go" +--- + +## Ejemplo + +```go +// Verificar IP desde VPN +id, err := NordVPNContainerRun(NordVPNContainerRunOpts{ + Image: "curlimages/curl", + Cmd: []string{"https://api.ipify.org"}, + Remove: true, + Gateway: "nordvpn", +}) + +// Ejecutar scraper bajo VPN +id, err := NordVPNContainerRun(NordVPNContainerRunOpts{ + Image: "my-scraper:latest", + Env: map[string]string{"TARGET_URL": "https://example.com"}, + Volumes: []string{"/tmp/output:/output"}, + Detach: true, + Name: "scraper-vpn", +}) + +// Navegador headless bajo VPN +id, err := NordVPNContainerRun(NordVPNContainerRunOpts{ + Image: "chromedp/headless-shell", + Detach: true, + Name: "chrome-vpn", +}) +``` + +## Notas + +Requiere que el container gateway NordVPN este corriendo (usar `NordVPNContainerStart` primero). El container cliente no necesita capabilities especiales — hereda la red del gateway. Con `--network=container:X` el container no puede exponer puertos propios; los puertos deben mapearse en el gateway. diff --git a/functions/infra/nordvpn_container_start.go b/functions/infra/nordvpn_container_start.go new file mode 100644 index 00000000..219cf652 --- /dev/null +++ b/functions/infra/nordvpn_container_start.go @@ -0,0 +1,73 @@ +package infra + +import ( + "fmt" + "strings" + "time" +) + +// NordVPNContainerOpts opciones para el container gateway NordVPN. +type NordVPNContainerOpts struct { + Token string // Token de acceso NordVPN (obligatorio) + Country string // Pais al que conectar (opcional, ej: "Spain") + City string // Ciudad (opcional, ej: "Madrid") + Protocol string // "NordLynx" o "OpenVPN" (default: NordLynx) + Name string // Nombre del container (default: "nordvpn") +} + +// NordVPNContainerStart levanta un container Docker con NordVPN como gateway. +// Otros containers pueden usar su red con --network=container:. +// Espera hasta que el tunel este activo o timeout de 30s. +func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error) { + if opts.Token == "" { + return "", fmt.Errorf("nordvpn token required") + } + if opts.Name == "" { + opts.Name = "nordvpn" + } + if opts.Protocol == "" { + opts.Protocol = "NordLynx" + } + + env := map[string]string{ + "TOKEN": opts.Token, + "TECHNOLOGY": opts.Protocol, + } + if opts.Country != "" { + connect := opts.Country + if opts.City != "" { + connect += " " + opts.City + } + env["CONNECT"] = connect + } + + // Limpiar container previo con el mismo nombre si existe + _ = DockerRemoveContainer(opts.Name, true) + + id, err := DockerRunContainer("ghcr.io/bubuntux/nordvpn", DockerRunOpts{ + Name: opts.Name, + Env: env, + Detach: true, + CapAdd: []string{"NET_ADMIN", "NET_RAW"}, + }) + if err != nil { + return "", fmt.Errorf("nordvpn container start: %w", err) + } + + // Esperar a que el tunel este activo + for i := 0; i < 30; i++ { + time.Sleep(1 * time.Second) + logs, logErr := DockerContainerLogs(opts.Name, 20) + if logErr != nil { + continue + } + if strings.Contains(logs, "Connected") || strings.Contains(logs, "connected") { + return id, nil + } + if strings.Contains(logs, "error") || strings.Contains(logs, "failed") { + return id, fmt.Errorf("nordvpn connection failed, check logs: docker logs %s", opts.Name) + } + } + + return id, fmt.Errorf("nordvpn connection timeout after 30s, check logs: docker logs %s", opts.Name) +} diff --git a/functions/infra/nordvpn_container_start.md b/functions/infra/nordvpn_container_start.md new file mode 100644 index 00000000..4189673f --- /dev/null +++ b/functions/infra/nordvpn_container_start.md @@ -0,0 +1,42 @@ +--- +name: nordvpn_container_start +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error)" +description: "Levanta un container Docker con NordVPN como gateway de red. Otros containers pueden rutear su trafico a traves de este con --network=container:. Espera hasta 30s a que el tunel este activo." +tags: [vpn, nordvpn, docker, container, gateway, infra, network] +uses_functions: ["docker_run_container_go_infra", "docker_remove_container_go_infra", "docker_container_logs_go_infra"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [fmt, strings, time] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/nordvpn_container_start.go" +--- + +## Ejemplo + +```go +id, err := NordVPNContainerStart(NordVPNContainerOpts{ + Token: os.Getenv("NORDVPN_TOKEN"), + Country: "Spain", + City: "Madrid", +}) +if err != nil { + log.Fatal(err) +} +fmt.Println("VPN gateway:", id) + +// Ahora otros containers pueden usar la VPN: +// docker run --network=container:nordvpn curlimages/curl https://api.ipify.org +``` + +## Notas + +Usa la imagen `ghcr.io/bubuntux/nordvpn`. Requiere un token de acceso NordVPN (obtener con `nordvpn token` desde CLI o desde la web de NordVPN). Limpia containers previos con el mismo nombre automaticamente. El protocolo por defecto es NordLynx (WireGuard). diff --git a/functions/infra/nordvpn_container_stop.go b/functions/infra/nordvpn_container_stop.go new file mode 100644 index 00000000..8feba515 --- /dev/null +++ b/functions/infra/nordvpn_container_stop.go @@ -0,0 +1,29 @@ +package infra + +import ( + "fmt" +) + +// NordVPNContainerStop detiene y elimina el container gateway NordVPN. +// Tambien detiene containers que usen su red si se proporcionan. +func NordVPNContainerStop(gateway string, clientNames ...string) error { + if gateway == "" { + gateway = "nordvpn" + } + + // Primero parar los clientes que usan la red del gateway + for _, name := range clientNames { + _ = DockerStopContainer(name, 5) + _ = DockerRemoveContainer(name, true) + } + + // Parar y eliminar el gateway + if err := DockerStopContainer(gateway, 10); err != nil { + return fmt.Errorf("nordvpn container stop: %w", err) + } + if err := DockerRemoveContainer(gateway, true); err != nil { + return fmt.Errorf("nordvpn container remove: %w", err) + } + + return nil +} diff --git a/functions/infra/nordvpn_container_stop.md b/functions/infra/nordvpn_container_stop.md new file mode 100644 index 00000000..f0b68321 --- /dev/null +++ b/functions/infra/nordvpn_container_stop.md @@ -0,0 +1,35 @@ +--- +name: nordvpn_container_stop +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func NordVPNContainerStop(gateway string, clientNames ...string) error" +description: "Detiene y elimina el container gateway NordVPN y opcionalmente los containers cliente que usan su red." +tags: [vpn, nordvpn, docker, container, stop, cleanup, infra] +uses_functions: ["docker_stop_container_go_infra", "docker_remove_container_go_infra"] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [fmt] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/nordvpn_container_stop.go" +--- + +## Ejemplo + +```go +// Parar solo el gateway +err := NordVPNContainerStop("nordvpn") + +// Parar gateway y clientes asociados +err := NordVPNContainerStop("nordvpn", "chrome-vpn", "scraper-vpn") +``` + +## Notas + +Detiene primero los containers cliente (si se proporcionan) y luego el gateway. Importante: si los clientes usan `--network=container:nordvpn`, deben pararse antes que el gateway para evitar errores de red. Los clientes se paran con timeout de 5s, el gateway con 10s. diff --git a/functions/infra/parse_nordvpn_status.go b/functions/infra/parse_nordvpn_status.go new file mode 100644 index 00000000..0cc78472 --- /dev/null +++ b/functions/infra/parse_nordvpn_status.go @@ -0,0 +1,68 @@ +package infra + +import ( + "regexp" + "strings" +) + +// NordVPNStatus representa el estado parseado de nordvpn status. +type NordVPNStatus struct { + Connected bool // true si hay conexion activa + Status string // "Connected" o "Disconnected" + Hostname string // ej: "es42.nordvpn.com" + IP string // IP del servidor VPN + Country string // ej: "Spain" + City string // ej: "Madrid" + Technology string // ej: "NordLynx" + Protocol string // ej: "nordlynx" + Transfer string // ej: "1.2 MiB received, 500 KiB sent" + Uptime string // ej: "5 minutes 32 seconds" +} + +// ansiRegexp elimina codigos de escape ANSI. +var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// ParseNordVPNStatus parsea la salida de texto de `nordvpn status` +// a un struct tipado. Funcion pura — no ejecuta comandos. +func ParseNordVPNStatus(output string) NordVPNStatus { + var s NordVPNStatus + + lines := strings.Split(output, "\n") + for _, line := range lines { + line = ansiRegexp.ReplaceAllString(line, "") + line = strings.TrimSpace(line) + line = strings.TrimLeft(line, "- ") + + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + + key := strings.ToLower(strings.TrimSpace(line[:idx])) + val := strings.TrimSpace(line[idx+1:]) + + switch key { + case "status": + s.Status = val + s.Connected = strings.EqualFold(val, "connected") + case "hostname", "server": + s.Hostname = val + case "ip": + s.IP = val + case "country": + s.Country = val + case "city": + s.City = val + case "current technology": + s.Technology = val + case "current protocol": + s.Protocol = val + case "transfer": + s.Transfer = val + case "uptime": + s.Uptime = val + } + } + + return s +} diff --git a/functions/infra/parse_nordvpn_status.md b/functions/infra/parse_nordvpn_status.md new file mode 100644 index 00000000..64cf3d1c --- /dev/null +++ b/functions/infra/parse_nordvpn_status.md @@ -0,0 +1,41 @@ +--- +name: parse_nordvpn_status +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func ParseNordVPNStatus(output string) NordVPNStatus" +description: "Parsea la salida de texto de nordvpn status a un struct tipado. Elimina codigos ANSI y normaliza claves." +tags: [vpn, nordvpn, parser, pure, infra] +uses_functions: [] +uses_types: ["NordVPNStatus_go_infra"] +returns: [] +returns_optional: false +error_type: "" +imports: ["regexp", "strings"] +tested: true +tests: ["TestParseNordVPNStatus_Connected", "TestParseNordVPNStatus_Disconnected", "TestParseNordVPNStatus_WithANSI"] +test_file_path: "functions/infra/parse_nordvpn_status_test.go" +file_path: "functions/infra/parse_nordvpn_status.go" +--- + +## Ejemplo + +```go +output := `Status: Connected +Hostname: es42.nordvpn.com +IP: 185.230.124.42 +Country: Spain +City: Madrid +Current Technology: NordLynx` + +s := ParseNordVPNStatus(output) +// s.Connected == true +// s.Hostname == "es42.nordvpn.com" +// s.Country == "Spain" +``` + +## Notas + +Funcion pura — no ejecuta comandos ni accede a red. Maneja codigos ANSI que NordVPN CLI emite en terminal. Campos no presentes en la salida quedan como zero value. diff --git a/functions/infra/parse_nordvpn_status_test.go b/functions/infra/parse_nordvpn_status_test.go new file mode 100644 index 00000000..829dc91d --- /dev/null +++ b/functions/infra/parse_nordvpn_status_test.go @@ -0,0 +1,62 @@ +package infra + +import "testing" + +func TestParseNordVPNStatus_Connected(t *testing.T) { + input := `Status: Connected +Hostname: es42.nordvpn.com +IP: 185.230.124.42 +Country: Spain +City: Madrid +Current Technology: NordLynx +Current Protocol: nordlynx +Transfer: 1.2 MiB received, 500 KiB sent +Uptime: 5 minutes 32 seconds` + + got := ParseNordVPNStatus(input) + + if !got.Connected { + t.Error("expected Connected=true") + } + if got.Hostname != "es42.nordvpn.com" { + t.Errorf("Hostname = %q, want es42.nordvpn.com", got.Hostname) + } + if got.IP != "185.230.124.42" { + t.Errorf("IP = %q, want 185.230.124.42", got.IP) + } + if got.Country != "Spain" { + t.Errorf("Country = %q, want Spain", got.Country) + } + if got.City != "Madrid" { + t.Errorf("City = %q, want Madrid", got.City) + } + if got.Technology != "NordLynx" { + t.Errorf("Technology = %q, want NordLynx", got.Technology) + } +} + +func TestParseNordVPNStatus_Disconnected(t *testing.T) { + input := `Status: Disconnected` + + got := ParseNordVPNStatus(input) + + if got.Connected { + t.Error("expected Connected=false") + } + if got.Status != "Disconnected" { + t.Errorf("Status = %q, want Disconnected", got.Status) + } +} + +func TestParseNordVPNStatus_WithANSI(t *testing.T) { + input := "\x1b[32mStatus: Connected\x1b[0m\n\x1b[32m- Hostname: us1234.nordvpn.com\x1b[0m" + + got := ParseNordVPNStatus(input) + + if !got.Connected { + t.Error("expected Connected=true with ANSI codes") + } + if got.Hostname != "us1234.nordvpn.com" { + t.Errorf("Hostname = %q, want us1234.nordvpn.com", got.Hostname) + } +} diff --git a/types/infra/nordvpn_status.md b/types/infra/nordvpn_status.md new file mode 100644 index 00000000..0862f3da --- /dev/null +++ b/types/infra/nordvpn_status.md @@ -0,0 +1,31 @@ +--- +name: NordVPNStatus +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: "type NordVPNStatus struct { Connected bool; Status string; Hostname string; IP string; Country string; City string; Technology string; Protocol string; Transfer string; Uptime string }" +description: "Estado parseado de nordvpn status. Contiene informacion de conexion, servidor, ubicacion y protocolo." +tags: [vpn, nordvpn, status, infra] +uses_types: [] +file_path: "functions/infra/parse_nordvpn_status.go" +--- + +## Ejemplos + +```go +s := NordVPNStatus{ + Connected: true, + Status: "Connected", + Hostname: "es42.nordvpn.com", + IP: "185.230.124.42", + Country: "Spain", + City: "Madrid", + Technology: "NordLynx", + Protocol: "nordlynx", +} +``` + +## Notas + +Producido por ParseNordVPNStatus. Los campos corresponden a las claves de la salida de `nordvpn status`. Transfer y Uptime son strings sin parsear — incluir parseo numerico si se necesita. From a75170cbc6b9b6af2135e64d2ad616e196b2d08b Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 1 Apr 2026 20:55:17 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20abstracci=C3=B3n=20DB=20multi-engin?= =?UTF-8?q?e=20=E2=80=94=20CRUD=20gen=C3=A9rico=20y=20openers=20para=20SQL?= =?UTF-8?q?ite,=20Postgres,=20ClickHouse,=20DuckDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funciones Go con interfaz unificada para operaciones DB: open, close, create_table, exec, query, insert_row, insert_batch. Openers específicos por engine. Tipo DBConfig para configuración común. Co-Authored-By: Claude Opus 4.6 (1M context) --- functions/infra/clickhouse_open.go | 25 ++++++++++ functions/infra/clickhouse_open.md | 37 ++++++++++++++ functions/infra/db_close.go | 18 +++++++ functions/infra/db_close.md | 35 +++++++++++++ functions/infra/db_config.go | 8 +++ functions/infra/db_create_table.go | 32 ++++++++++++ functions/infra/db_create_table.md | 36 ++++++++++++++ functions/infra/db_exec.go | 22 +++++++++ functions/infra/db_exec.md | 35 +++++++++++++ functions/infra/db_insert_batch.go | 70 ++++++++++++++++++++++++++ functions/infra/db_insert_batch.md | 41 ++++++++++++++++ functions/infra/db_insert_row.go | 54 ++++++++++++++++++++ functions/infra/db_insert_row.md | 39 +++++++++++++++ functions/infra/db_query.go | 79 ++++++++++++++++++++++++++++++ functions/infra/db_query.md | 37 ++++++++++++++ functions/infra/duckdb_open.go | 27 ++++++++++ functions/infra/duckdb_open.md | 38 ++++++++++++++ functions/infra/postgres_open.go | 32 ++++++++++++ functions/infra/postgres_open.md | 37 ++++++++++++++ functions/infra/sqlite_open.go | 27 ++++++++++ functions/infra/sqlite_open.md | 37 ++++++++++++++ types/infra/db_config.md | 21 ++++++++ 22 files changed, 787 insertions(+) create mode 100644 functions/infra/clickhouse_open.go create mode 100644 functions/infra/clickhouse_open.md create mode 100644 functions/infra/db_close.go create mode 100644 functions/infra/db_close.md create mode 100644 functions/infra/db_config.go create mode 100644 functions/infra/db_create_table.go create mode 100644 functions/infra/db_create_table.md create mode 100644 functions/infra/db_exec.go create mode 100644 functions/infra/db_exec.md create mode 100644 functions/infra/db_insert_batch.go create mode 100644 functions/infra/db_insert_batch.md create mode 100644 functions/infra/db_insert_row.go create mode 100644 functions/infra/db_insert_row.md create mode 100644 functions/infra/db_query.go create mode 100644 functions/infra/db_query.md create mode 100644 functions/infra/duckdb_open.go create mode 100644 functions/infra/duckdb_open.md create mode 100644 functions/infra/postgres_open.go create mode 100644 functions/infra/postgres_open.md create mode 100644 functions/infra/sqlite_open.go create mode 100644 functions/infra/sqlite_open.md create mode 100644 types/infra/db_config.md diff --git a/functions/infra/clickhouse_open.go b/functions/infra/clickhouse_open.go new file mode 100644 index 00000000..8d69e4c4 --- /dev/null +++ b/functions/infra/clickhouse_open.go @@ -0,0 +1,25 @@ +package infra + +import ( + "database/sql" + "fmt" + + _ "github.com/ClickHouse/clickhouse-go/v2" +) + +// ClickHouseOpen connects to a ClickHouse server and returns a *sql.DB. +// Constructs a DSN of the form: +// +// clickhouse://user:password@host:port/database +func ClickHouseOpen(host string, port int, user, password, database string) (*sql.DB, error) { + dsn := fmt.Sprintf("clickhouse://%s:%s@%s:%d/%s", user, password, host, port, database) + db, err := sql.Open("clickhouse", dsn) + if err != nil { + return nil, fmt.Errorf("clickhouse_open: open: %w", err) + } + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("clickhouse_open: ping %s:%d/%s: %w", host, port, database, err) + } + return db, nil +} diff --git a/functions/infra/clickhouse_open.md b/functions/infra/clickhouse_open.md new file mode 100644 index 00000000..380968a8 --- /dev/null +++ b/functions/infra/clickhouse_open.md @@ -0,0 +1,37 @@ +--- +name: clickhouse_open +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func ClickHouseOpen(host string, port int, user, password, database string) (*sql.DB, error)" +description: "Conecta a ClickHouse construyendo DSN clickhouse://user:pass@host:port/database." +tags: [database, clickhouse, connection, sql, olap] +uses_functions: [] +uses_types: [db_config_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql", "github.com/ClickHouse/clickhouse-go/v2"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/clickhouse_open.go" +--- + +## Ejemplo + +```go +db, err := ClickHouseOpen("localhost", 9000, "default", "", "analytics") +if err != nil { + log.Fatal(err) +} +defer DBClose(db) + +rows, err := DBQuery(db, "SELECT event, count() FROM events GROUP BY event") +``` + +## Notas + +Usa el driver `github.com/ClickHouse/clickhouse-go/v2` registrado como "clickhouse". Puerto por defecto de ClickHouse es 9000 (nativo) o 8123 (HTTP). Hace ping al abrir para verificar conectividad. diff --git a/functions/infra/db_close.go b/functions/infra/db_close.go new file mode 100644 index 00000000..da0611a8 --- /dev/null +++ b/functions/infra/db_close.go @@ -0,0 +1,18 @@ +package infra + +import ( + "database/sql" + "fmt" +) + +// DBClose closes the database connection. Wraps db.Close() for composability +// in pipelines that manage *sql.DB lifecycle explicitly. +func DBClose(db *sql.DB) error { + if db == nil { + return fmt.Errorf("db_close: db is nil") + } + if err := db.Close(); err != nil { + return fmt.Errorf("db_close: %w", err) + } + return nil +} diff --git a/functions/infra/db_close.md b/functions/infra/db_close.md new file mode 100644 index 00000000..8ce8c5d7 --- /dev/null +++ b/functions/infra/db_close.md @@ -0,0 +1,35 @@ +--- +name: db_close +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func DBClose(db *sql.DB) error" +description: "Cierra la conexion a la base de datos. Wrapper sobre db.Close() para composabilidad en pipelines que gestionan el ciclo de vida de *sql.DB explicitamente." +tags: [database, sql, close, lifecycle] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/db_close.go" +--- + +## Ejemplo + +```go +db, err := SQLiteOpen("/data/app.db") +if err != nil { + return err +} +defer DBClose(db) +``` + +## Notas + +Retorna error si db es nil. En la mayoria de los casos se usa con `defer`. Existe como funcion del registry para que los pipelines puedan referenciarla en `uses_functions` y modelar el ciclo de vida completo de la conexion. diff --git a/functions/infra/db_config.go b/functions/infra/db_config.go new file mode 100644 index 00000000..d87608d0 --- /dev/null +++ b/functions/infra/db_config.go @@ -0,0 +1,8 @@ +package infra + +// DBConfig holds connection parameters for any supported database. +type DBConfig struct { + Driver string // "sqlite", "duckdb", "postgres", "clickhouse" + DSN string // Data source name / connection string + Opts map[string]string // Driver-specific options (optional) +} diff --git a/functions/infra/db_create_table.go b/functions/infra/db_create_table.go new file mode 100644 index 00000000..55640436 --- /dev/null +++ b/functions/infra/db_create_table.go @@ -0,0 +1,32 @@ +package infra + +import ( + "database/sql" + "fmt" + "regexp" + "strings" +) + +var validIdentifier = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +// DBCreateTable executes CREATE TABLE IF NOT EXISTS for the given table with +// the provided column definitions. Each element of columns should be a full +// SQL column definition, e.g. "id INTEGER PRIMARY KEY" or "name TEXT NOT NULL". +// Returns an error if the table name contains invalid characters. +func DBCreateTable(db *sql.DB, table string, columns []string) error { + if !validIdentifier.MatchString(table) { + return fmt.Errorf("db_create_table: invalid table name %q (only alphanumeric and underscore allowed)", table) + } + if len(columns) == 0 { + return fmt.Errorf("db_create_table: at least one column definition required") + } + query := fmt.Sprintf( + "CREATE TABLE IF NOT EXISTS %s (%s)", + table, + strings.Join(columns, ", "), + ) + if _, err := db.Exec(query); err != nil { + return fmt.Errorf("db_create_table %q: %w", table, err) + } + return nil +} diff --git a/functions/infra/db_create_table.md b/functions/infra/db_create_table.md new file mode 100644 index 00000000..b4430439 --- /dev/null +++ b/functions/infra/db_create_table.md @@ -0,0 +1,36 @@ +--- +name: db_create_table +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func DBCreateTable(db *sql.DB, table string, columns []string) error" +description: "Ejecuta CREATE TABLE IF NOT EXISTS con las definiciones de columnas dadas. Valida que el nombre de tabla sea un identificador SQL seguro." +tags: [database, sql, ddl, create, table, schema] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql", "regexp", "strings"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/db_create_table.go" +--- + +## Ejemplo + +```go +err := DBCreateTable(db, "events", []string{ + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "name TEXT NOT NULL", + "ts INTEGER NOT NULL", + "payload TEXT", +}) +``` + +## Notas + +`columns` son definiciones SQL completas incluyendo nombre, tipo y constraints. Usa `CREATE TABLE IF NOT EXISTS` para ser idempotente. Valida el nombre de tabla con regex `^[a-zA-Z_][a-zA-Z0-9_]*$` para prevenir SQL injection. Las definiciones de columna no se sanitizan — son responsabilidad del llamador. diff --git a/functions/infra/db_exec.go b/functions/infra/db_exec.go new file mode 100644 index 00000000..1f0ca6c2 --- /dev/null +++ b/functions/infra/db_exec.go @@ -0,0 +1,22 @@ +package infra + +import ( + "database/sql" + "fmt" +) + +// DBExec executes a non-SELECT statement (INSERT, UPDATE, DELETE, DDL) and +// returns the number of rows affected. For statements that don't return rows +// affected (e.g. DDL on some drivers), the count may be 0. +func DBExec(db *sql.DB, query string, args ...any) (int64, error) { + result, err := db.Exec(query, args...) + if err != nil { + return 0, fmt.Errorf("db_exec: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + // Some drivers don't support RowsAffected; treat as 0. + return 0, nil + } + return n, nil +} diff --git a/functions/infra/db_exec.md b/functions/infra/db_exec.md new file mode 100644 index 00000000..19288698 --- /dev/null +++ b/functions/infra/db_exec.md @@ -0,0 +1,35 @@ +--- +name: db_exec +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func DBExec(db *sql.DB, query string, args ...any) (int64, error)" +description: "Ejecuta un statement no-SELECT (INSERT, UPDATE, DELETE, DDL) y retorna el numero de filas afectadas." +tags: [database, sql, exec, insert, update, delete, ddl] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/db_exec.go" +--- + +## Ejemplo + +```go +n, err := DBExec(db, "UPDATE users SET active = ? WHERE last_login < ?", false, cutoff) +if err != nil { + return err +} +fmt.Printf("desactivados: %d usuarios\n", n) +``` + +## Notas + +Agnóstica al driver. Para DDL algunos drivers retornan 0 en RowsAffected — esto es normal. Para INSERT con last_insert_id usar `DBInsertRow` que retorna ese valor. Para multiples filas en una transaccion usar `DBInsertBatch`. diff --git a/functions/infra/db_insert_batch.go b/functions/infra/db_insert_batch.go new file mode 100644 index 00000000..99df7bfa --- /dev/null +++ b/functions/infra/db_insert_batch.go @@ -0,0 +1,70 @@ +package infra + +import ( + "database/sql" + "fmt" + "strings" +) + +// DBInsertBatch inserts multiple rows into a table using a prepared statement +// inside a transaction. columns must match the order of values in each row. +// Returns the total number of rows affected. +// Column and table names are validated to contain only safe identifier chars. +func DBInsertBatch(db *sql.DB, table string, columns []string, rows [][]any) (int64, error) { + if !validIdentifier.MatchString(table) { + return 0, fmt.Errorf("db_insert_batch: invalid table name %q", table) + } + if len(columns) == 0 { + return 0, fmt.Errorf("db_insert_batch: columns must not be empty") + } + if len(rows) == 0 { + return 0, nil + } + + for _, col := range columns { + if !validIdentifier.MatchString(col) { + return 0, fmt.Errorf("db_insert_batch: invalid column name %q", col) + } + } + + placeholders := make([]string, len(columns)) + for i := range columns { + placeholders[i] = "?" + } + query := fmt.Sprintf( + "INSERT INTO %s (%s) VALUES (%s)", + table, + strings.Join(columns, ", "), + strings.Join(placeholders, ", "), + ) + + tx, err := db.Begin() + if err != nil { + return 0, fmt.Errorf("db_insert_batch: begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + stmt, err := tx.Prepare(query) + if err != nil { + return 0, fmt.Errorf("db_insert_batch: prepare: %w", err) + } + defer stmt.Close() + + var total int64 + for i, row := range rows { + if len(row) != len(columns) { + return 0, fmt.Errorf("db_insert_batch: row %d has %d values, expected %d", i, len(row), len(columns)) + } + result, err := stmt.Exec(row...) + if err != nil { + return 0, fmt.Errorf("db_insert_batch: exec row %d: %w", i, err) + } + n, _ := result.RowsAffected() + total += n + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("db_insert_batch: commit: %w", err) + } + return total, nil +} diff --git a/functions/infra/db_insert_batch.md b/functions/infra/db_insert_batch.md new file mode 100644 index 00000000..4b91159a --- /dev/null +++ b/functions/infra/db_insert_batch.md @@ -0,0 +1,41 @@ +--- +name: db_insert_batch +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func DBInsertBatch(db *sql.DB, table string, columns []string, rows [][]any) (int64, error)" +description: "Inserta multiples filas en una transaccion usando prepared statement. Retorna el total de filas afectadas. Mas eficiente que llamar DBInsertRow en un loop." +tags: [database, sql, insert, batch, transaction, bulk] +uses_functions: [db_insert_row_go_infra] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql", "strings"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/db_insert_batch.go" +--- + +## Ejemplo + +```go +cols := []string{"name", "score", "ts"} +rows := [][]any{ + {"Alice", 95.5, 1700000000}, + {"Bob", 87.2, 1700000001}, + {"Carol", 91.0, 1700000002}, +} +n, err := DBInsertBatch(db, "results", cols, rows) +if err != nil { + return err +} +fmt.Printf("insertadas: %d filas\n", n) +``` + +## Notas + +Usa `tx.Prepare()` + `stmt.Exec()` en un loop dentro de una transaccion. El rollback es automatico si alguna fila falla. Valida tabla y columnas con regex. Cada fila debe tener exactamente `len(columns)` valores — retorna error descriptivo si no coincide. diff --git a/functions/infra/db_insert_row.go b/functions/infra/db_insert_row.go new file mode 100644 index 00000000..ef05a934 --- /dev/null +++ b/functions/infra/db_insert_row.go @@ -0,0 +1,54 @@ +package infra + +import ( + "database/sql" + "fmt" + "sort" + "strings" +) + +// DBInsertRow generates and executes a single-row INSERT from a map of +// column→value pairs. Returns the last insert ID reported by the driver. +// Column and table names are validated to contain only safe identifier chars. +func DBInsertRow(db *sql.DB, table string, row map[string]any) (int64, error) { + if !validIdentifier.MatchString(table) { + return 0, fmt.Errorf("db_insert_row: invalid table name %q", table) + } + if len(row) == 0 { + return 0, fmt.Errorf("db_insert_row: row map must not be empty") + } + + // Sort keys for deterministic query generation. + cols := make([]string, 0, len(row)) + for col := range row { + if !validIdentifier.MatchString(col) { + return 0, fmt.Errorf("db_insert_row: invalid column name %q", col) + } + cols = append(cols, col) + } + sort.Strings(cols) + + placeholders := make([]string, len(cols)) + values := make([]any, len(cols)) + for i, col := range cols { + placeholders[i] = "?" + values[i] = row[col] + } + + query := fmt.Sprintf( + "INSERT INTO %s (%s) VALUES (%s)", + table, + strings.Join(cols, ", "), + strings.Join(placeholders, ", "), + ) + + result, err := db.Exec(query, values...) + if err != nil { + return 0, fmt.Errorf("db_insert_row %q: %w", table, err) + } + id, err := result.LastInsertId() + if err != nil { + return 0, nil + } + return id, nil +} diff --git a/functions/infra/db_insert_row.md b/functions/infra/db_insert_row.md new file mode 100644 index 00000000..181c0c16 --- /dev/null +++ b/functions/infra/db_insert_row.md @@ -0,0 +1,39 @@ +--- +name: db_insert_row +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func DBInsertRow(db *sql.DB, table string, row map[string]any) (int64, error)" +description: "Genera y ejecuta un INSERT de una sola fila desde un map columna→valor. Retorna el last insert ID. Sanitiza nombres de tabla y columnas." +tags: [database, sql, insert, row, dynamic] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql", "sort", "strings"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/db_insert_row.go" +--- + +## Ejemplo + +```go +id, err := DBInsertRow(db, "users", map[string]any{ + "name": "Alice", + "email": "alice@example.com", + "active": true, +}) +if err != nil { + return err +} +fmt.Printf("nuevo usuario ID: %d\n", id) +``` + +## Notas + +Las claves del map se ordenan alfabeticamente para generar queries deterministas. Valida tabla y columnas con regex `^[a-zA-Z_][a-zA-Z0-9_]*$`. Para insertar muchas filas usar `DBInsertBatch` que es mas eficiente. El last insert ID puede ser 0 en drivers que no lo soportan (ej: postgres — usar RETURNING en su lugar con `DBQuery`). diff --git a/functions/infra/db_query.go b/functions/infra/db_query.go new file mode 100644 index 00000000..e134ac73 --- /dev/null +++ b/functions/infra/db_query.go @@ -0,0 +1,79 @@ +package infra + +import ( + "database/sql" + "fmt" + "strconv" +) + +// DBQuery executes a SELECT query and returns the results as a slice of maps. +// Each map key is the column name; values are converted to native Go types: +// int64, float64, bool, string, []byte, or nil for NULLs. +func DBQuery(db *sql.DB, query string, args ...any) ([]map[string]any, error) { + rows, err := db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("db_query: %w", err) + } + defer rows.Close() + + cols, err := rows.Columns() + if err != nil { + return nil, fmt.Errorf("db_query: columns: %w", err) + } + colTypes, err := rows.ColumnTypes() + if err != nil { + return nil, fmt.Errorf("db_query: column types: %w", err) + } + + var results []map[string]any + for rows.Next() { + // Use RawBytes so we can inspect the raw value before converting. + raw := make([]sql.RawBytes, len(cols)) + ptrs := make([]any, len(cols)) + for i := range raw { + ptrs[i] = &raw[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, fmt.Errorf("db_query: scan: %w", err) + } + + row := make(map[string]any, len(cols)) + for i, col := range cols { + if raw[i] == nil { + row[col] = nil + continue + } + row[col] = convertRaw(raw[i], colTypes[i].DatabaseTypeName()) + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("db_query: rows: %w", err) + } + return results, nil +} + +// convertRaw attempts to convert a raw SQL byte slice into a native Go type +// based on the database column type name hint. +func convertRaw(b sql.RawBytes, dbType string) any { + s := string(b) + switch dbType { + case "INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "INT2", "INT4", "INT8": + if v, err := strconv.ParseInt(s, 10, 64); err == nil { + return v + } + case "REAL", "FLOAT", "DOUBLE", "NUMERIC", "DECIMAL": + if v, err := strconv.ParseFloat(s, 64); err == nil { + return v + } + case "BOOLEAN", "BOOL": + if v, err := strconv.ParseBool(s); err == nil { + return v + } + case "BLOB": + cp := make([]byte, len(b)) + copy(cp, b) + return cp + } + return s +} diff --git a/functions/infra/db_query.md b/functions/infra/db_query.md new file mode 100644 index 00000000..582ad209 --- /dev/null +++ b/functions/infra/db_query.md @@ -0,0 +1,37 @@ +--- +name: db_query +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func DBQuery(db *sql.DB, query string, args ...any) ([]map[string]any, error)" +description: "Ejecuta un SELECT y retorna los resultados como slice de maps. Convierte valores a tipos nativos Go segun el tipo de columna reportado por el driver." +tags: [database, sql, query, select, generic] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/db_query.go" +--- + +## Ejemplo + +```go +rows, err := DBQuery(db, "SELECT id, name, score FROM players WHERE active = ?", true) +if err != nil { + return err +} +for _, row := range rows { + fmt.Println(row["name"], row["score"]) +} +``` + +## Notas + +Agnóstica al driver — funciona con cualquier `*sql.DB` (sqlite, duckdb, postgres, clickhouse). Usa `sql.RawBytes` + `ColumnTypes()` para conversion dinamica. Convierte INTEGER→int64, FLOAT/REAL/DOUBLE→float64, BOOLEAN→bool, BLOB→[]byte, NULL→nil, resto→string. Para queries con muchos resultados considerar paginar con LIMIT/OFFSET. diff --git a/functions/infra/duckdb_open.go b/functions/infra/duckdb_open.go new file mode 100644 index 00000000..9a240a8c --- /dev/null +++ b/functions/infra/duckdb_open.go @@ -0,0 +1,27 @@ +package infra + +import ( + "database/sql" + "fmt" + + _ "github.com/marcboeker/go-duckdb" +) + +// DuckDBOpen opens (or creates) a DuckDB database file. +// Pass an empty path or ":memory:" for an in-memory database. +// Returns a ready-to-use *sql.DB or an error. +func DuckDBOpen(path string) (*sql.DB, error) { + dsn := path + if dsn == "" { + dsn = ":memory:" + } + db, err := sql.Open("duckdb", dsn) + if err != nil { + return nil, fmt.Errorf("duckdb_open: open %q: %w", dsn, err) + } + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("duckdb_open: ping %q: %w", dsn, err) + } + return db, nil +} diff --git a/functions/infra/duckdb_open.md b/functions/infra/duckdb_open.md new file mode 100644 index 00000000..b8602c95 --- /dev/null +++ b/functions/infra/duckdb_open.md @@ -0,0 +1,38 @@ +--- +name: duckdb_open +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func DuckDBOpen(path string) (*sql.DB, error)" +description: "Abre (o crea) una base de datos DuckDB. Path vacio o ':memory:' abre una base en memoria." +tags: [database, duckdb, connection, sql, analytics] +uses_functions: [] +uses_types: [db_config_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql", "github.com/marcboeker/go-duckdb"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/duckdb_open.go" +--- + +## Ejemplo + +```go +// In-memory para analisis temporal +db, err := DuckDBOpen("") +if err != nil { + log.Fatal(err) +} +defer DBClose(db) + +rows, err := DBQuery(db, "SELECT * FROM read_parquet('/data/sales.parquet')") +``` + +## Notas + +Usa el driver `github.com/marcboeker/go-duckdb` (CGO). DuckDB es una base de datos OLAP embebida, ideal para analisis de datos. Path vacio equivale a `:memory:`. Hace ping al abrir para detectar errores temprano. diff --git a/functions/infra/postgres_open.go b/functions/infra/postgres_open.go new file mode 100644 index 00000000..548ee491 --- /dev/null +++ b/functions/infra/postgres_open.go @@ -0,0 +1,32 @@ +package infra + +import ( + "database/sql" + "fmt" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +// PostgresOpen connects to a PostgreSQL server and returns a *sql.DB. +// sslmode defaults to "disable" when empty. +// Constructs a DSN of the form: +// +// host= port= user= password= dbname= sslmode= +func PostgresOpen(host string, port int, user, password, dbname string, sslmode string) (*sql.DB, error) { + if sslmode == "" { + sslmode = "disable" + } + dsn := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + host, port, user, password, dbname, sslmode, + ) + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, fmt.Errorf("postgres_open: open: %w", err) + } + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("postgres_open: ping %s:%d/%s: %w", host, port, dbname, err) + } + return db, nil +} diff --git a/functions/infra/postgres_open.md b/functions/infra/postgres_open.md new file mode 100644 index 00000000..f9c4f1f2 --- /dev/null +++ b/functions/infra/postgres_open.md @@ -0,0 +1,37 @@ +--- +name: postgres_open +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func PostgresOpen(host string, port int, user, password, dbname string, sslmode string) (*sql.DB, error)" +description: "Conecta a PostgreSQL construyendo el DSN desde parametros individuales. sslmode por defecto 'disable' si vacio." +tags: [database, postgres, postgresql, connection, sql] +uses_functions: [] +uses_types: [db_config_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql", "github.com/jackc/pgx/v5/stdlib"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/postgres_open.go" +--- + +## Ejemplo + +```go +db, err := PostgresOpen("localhost", 5432, "user", "secret", "mydb", "disable") +if err != nil { + log.Fatal(err) +} +defer DBClose(db) + +rows, err := DBQuery(db, "SELECT id, name FROM users WHERE active = $1", true) +``` + +## Notas + +Usa el driver `github.com/jackc/pgx/v5/stdlib` registrado como "pgx". Construye DSN con los parametros separados para mayor legibilidad. Para produccion usar `sslmode=require` o `sslmode=verify-full`. Hace ping al abrir para verificar conectividad. diff --git a/functions/infra/sqlite_open.go b/functions/infra/sqlite_open.go new file mode 100644 index 00000000..cf894f6d --- /dev/null +++ b/functions/infra/sqlite_open.go @@ -0,0 +1,27 @@ +package infra + +import ( + "database/sql" + "fmt" + + _ "github.com/mattn/go-sqlite3" +) + +// SQLiteOpen opens (or creates) a SQLite database file with WAL mode and +// foreign key support enabled. Returns a ready-to-use *sql.DB or an error. +// Pass ":memory:" for an in-memory database. +func SQLiteOpen(path string) (*sql.DB, error) { + if path == "" { + return nil, fmt.Errorf("sqlite_open: path must not be empty (use ':memory:' for in-memory)") + } + dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", path) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("sqlite_open: open %q: %w", path, err) + } + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("sqlite_open: ping %q: %w", path, err) + } + return db, nil +} diff --git a/functions/infra/sqlite_open.md b/functions/infra/sqlite_open.md new file mode 100644 index 00000000..bb9964c9 --- /dev/null +++ b/functions/infra/sqlite_open.md @@ -0,0 +1,37 @@ +--- +name: sqlite_open +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SQLiteOpen(path string) (*sql.DB, error)" +description: "Abre (o crea) una base de datos SQLite con WAL mode y foreign keys habilitados. Hace ping para verificar la conexion." +tags: [database, sqlite, connection, sql] +uses_functions: [] +uses_types: [db_config_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["database/sql", "github.com/mattn/go-sqlite3"] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/sqlite_open.go" +--- + +## Ejemplo + +```go +db, err := SQLiteOpen("/data/myapp.db") +if err != nil { + log.Fatal(err) +} +defer DBClose(db) + +rows, err := DBQuery(db, "SELECT * FROM users WHERE active = ?", 1) +``` + +## Notas + +Usa el driver `github.com/mattn/go-sqlite3` (CGO). El DSN incluye `_journal_mode=WAL` para mejor concurrencia y `_foreign_keys=on`. Acepta `:memory:` para base de datos en memoria. Hace ping al abrir para detectar errores temprano. diff --git a/types/infra/db_config.md b/types/infra/db_config.md new file mode 100644 index 00000000..bc50f973 --- /dev/null +++ b/types/infra/db_config.md @@ -0,0 +1,21 @@ +--- +name: db_config +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type DBConfig struct { + Driver string // "sqlite", "duckdb", "postgres", "clickhouse" + DSN string // Data source name / connection string + Opts map[string]string // Driver-specific options (optional) + } +description: "Parametros de conexion para cualquier base de datos soportada. Agnóstico al driver." +tags: [database, config, connection, sqlite, duckdb, postgres, clickhouse] +uses_types: [] +file_path: "functions/infra/db_config.go" +--- + +## Notas + +Tipo producto — todos los campos siempre presentes. Driver toma uno de los valores: "sqlite", "duckdb", "postgres", "clickhouse". DSN es el connection string nativo del driver. Opts permite pasar opciones adicionales especificas del driver sin necesidad de un tipo por driver. From e02a950ee08069f6e552bac1a94c6bcaf0abb56d Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 1 Apr 2026 20:55:24 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20funciones=20Wails=20=E2=80=94=20sca?= =?UTF-8?q?ffold,=20CRUD=20bindings,=20build,=20eventos=20y=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funciones Go para crear apps Wails: scaffold estructura, bind CRUD genérico, build multiplataforma, emit eventos y stream de datos al frontend. Co-Authored-By: Claude Opus 4.6 (1M context) --- functions/infra/scaffold_wails_app.go | 215 ++++++++++++++++++++++++++ functions/infra/scaffold_wails_app.md | 38 +++++ functions/infra/wails_bind_crud.go | 85 ++++++++++ functions/infra/wails_bind_crud.md | 40 +++++ functions/infra/wails_build.go | 65 ++++++++ functions/infra/wails_build.md | 38 +++++ functions/infra/wails_emit_event.go | 40 +++++ functions/infra/wails_emit_event.md | 35 +++++ functions/infra/wails_stream_data.go | 70 +++++++++ functions/infra/wails_stream_data.md | 50 ++++++ 10 files changed, 676 insertions(+) create mode 100644 functions/infra/scaffold_wails_app.go create mode 100644 functions/infra/scaffold_wails_app.md create mode 100644 functions/infra/wails_bind_crud.go create mode 100644 functions/infra/wails_bind_crud.md create mode 100644 functions/infra/wails_build.go create mode 100644 functions/infra/wails_build.md create mode 100644 functions/infra/wails_emit_event.go create mode 100644 functions/infra/wails_emit_event.md create mode 100644 functions/infra/wails_stream_data.go create mode 100644 functions/infra/wails_stream_data.md diff --git a/functions/infra/scaffold_wails_app.go b/functions/infra/scaffold_wails_app.go new file mode 100644 index 00000000..63530d34 --- /dev/null +++ b/functions/infra/scaffold_wails_app.go @@ -0,0 +1,215 @@ +package infra + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" +) + +// ScaffoldWailsAppConfig configura la generación del proyecto Wails. +type ScaffoldWailsAppConfig struct { + Name string // Nombre del proyecto + Dir string // Directorio destino + Title string // Título de la ventana + Width int // Ancho de la ventana (default 1024) + Height int // Alto de la ventana (default 768) + Author string // Nombre del autor + FrontendLib string // Path a la frontend library (default ~/.local_agentes/frontend/frontend) +} + +const mainGoTpl = `package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "{{.Title}}", + Width: {{.Width}}, + Height: {{.Height}}, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + Bind: []interface{}{ + app, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} +` + +const appGoTpl = `package main + +import ( + "context" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// App struct — cada método público se expone como binding IPC al frontend. +type App struct { + ctx context.Context +} + +func NewApp() *App { + return &App{} +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx +} + +// EmitEvent emite un evento al frontend. +func (a *App) EmitEvent(eventName string, data interface{}) { + runtime.EventsEmit(a.ctx, eventName, data) +} + +// Ping verifica que el IPC funciona. +func (a *App) Ping() string { + return "pong" +} +` + +const wailsJSONTpl = `{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "{{.Name}}", + "outputfilename": "{{.Name}}", + "frontend:dir": "./frontend", + "frontend:install": "pnpm install", + "frontend:build": "pnpm run build", + "frontend:dev:watcher": "pnpm run dev", + "frontend:dev:serverUrl": "auto", + "wailsjsdir": "./frontend/src/wailsjs", + "author": { + "name": "{{.Author}}" + } +} +` + +const goModTpl = `module {{.Name}} + +go 1.23 + +require github.com/wailsapp/wails/v2 v2.11.0 +` + +// ScaffoldWailsApp genera la estructura base de un proyecto Wails con frontend vinculado. +func ScaffoldWailsApp(ctx context.Context, cfg ScaffoldWailsAppConfig) error { + if cfg.Name == "" { + return fmt.Errorf("name is required") + } + if cfg.Dir == "" { + cfg.Dir = cfg.Name + } + if cfg.Title == "" { + cfg.Title = cfg.Name + } + if cfg.Width == 0 { + cfg.Width = 1024 + } + if cfg.Height == 0 { + cfg.Height = 768 + } + if cfg.Author == "" { + cfg.Author = "Agent" + } + if cfg.FrontendLib == "" { + home, _ := os.UserHomeDir() + cfg.FrontendLib = filepath.Join(home, ".local_agentes", "frontend", "frontend") + } + + // Crear directorio + if err := os.MkdirAll(cfg.Dir, 0755); err != nil { + return fmt.Errorf("creating dir: %w", err) + } + + // Generar archivos Go + files := map[string]string{ + "main.go": mainGoTpl, + "app.go": appGoTpl, + "wails.json": wailsJSONTpl, + "go.mod": goModTpl, + } + + for name, tpl := range files { + path := filepath.Join(cfg.Dir, name) + t, err := template.New(name).Parse(tpl) + if err != nil { + return fmt.Errorf("parsing template %s: %w", name, err) + } + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("creating %s: %w", name, err) + } + if err := t.Execute(f, cfg); err != nil { + f.Close() + return fmt.Errorf("executing template %s: %w", name, err) + } + f.Close() + } + + // Crear frontend como link simbólico o copiar template + frontendDir := filepath.Join(cfg.Dir, "frontend") + createProjectScript := filepath.Join(filepath.Dir(cfg.FrontendLib), "..", "scripts", "create-project.sh") + + if _, err := os.Stat(createProjectScript); err == nil { + // Usar el script de Frontend_Library + cmd := exec.CommandContext(ctx, "bash", createProjectScript, cfg.Name, frontendDir, "--wails") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("creating frontend: %w", err) + } + } else { + // Fallback: crear frontend mínimo + if err := os.MkdirAll(filepath.Join(frontendDir, "src"), 0755); err != nil { + return fmt.Errorf("creating frontend dir: %w", err) + } + pkgJSON := fmt.Sprintf(`{"name":"%s-frontend","private":true,"scripts":{"dev":"vite","build":"vite build"}}`, cfg.Name) + os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(pkgJSON), 0644) + } + + // go mod tidy + cmd := exec.CommandContext(ctx, "go", "mod", "tidy") + cmd.Dir = cfg.Dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() // No fatal si falla + + fmt.Printf("Wails app scaffolded at %s\n", cfg.Dir) + return nil +} + +// GenerateAppBinding genera el código Go para un método de binding. +func GenerateAppBinding(name string, params []string, returnType string, body string) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("func (a *App) %s(", name)) + sb.WriteString(strings.Join(params, ", ")) + sb.WriteString(")") + if returnType != "" { + sb.WriteString(" " + returnType) + } + sb.WriteString(" {\n") + sb.WriteString("\t" + body + "\n") + sb.WriteString("}\n") + return sb.String() +} diff --git a/functions/infra/scaffold_wails_app.md b/functions/infra/scaffold_wails_app.md new file mode 100644 index 00000000..a7e4bcb4 --- /dev/null +++ b/functions/infra/scaffold_wails_app.md @@ -0,0 +1,38 @@ +--- +name: scaffold_wails_app +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "ScaffoldWailsApp(ctx context.Context, cfg ScaffoldWailsAppConfig) error" +description: "Genera proyecto Wails completo: main.go con embed, app.go con bindings base, wails.json, go.mod, y frontend vinculado a Frontend_Library." +tags: [wails, scaffold, desktop, project, generator] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [os, os/exec, path/filepath, text/template, fmt, strings, context] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/scaffold_wails_app.go" +--- + +## Ejemplo + +```go +ScaffoldWailsApp(ctx, ScaffoldWailsAppConfig{ + Name: "my-dashboard", + Dir: "/home/user/projects/my-dashboard", + Title: "My Dashboard", + Width: 1280, + Height: 800, + Author: "Lucas", +}) +``` + +## Notas + +Pipeline que compone: generación de templates Go + creación de frontend via create-project.sh de Frontend_Library + go mod tidy. Incluye GenerateAppBinding para crear métodos de binding programáticamente. diff --git a/functions/infra/wails_bind_crud.go b/functions/infra/wails_bind_crud.go new file mode 100644 index 00000000..7bbfc878 --- /dev/null +++ b/functions/infra/wails_bind_crud.go @@ -0,0 +1,85 @@ +package infra + +import ( + "fmt" + "strings" +) + +// WailsCRUDSpec define la especificación para generar bindings CRUD. +type WailsCRUDSpec struct { + EntityName string // Nombre de la entidad (PascalCase, ej: "User") + Fields []string // Campos de la entidad (ej: ["Name string", "Email string"]) + WithList bool // Generar List method + WithGet bool // Generar Get method + WithCreate bool // Generar Create method + WithUpdate bool // Generar Update method + WithDelete bool // Generar Delete method +} + +// GenerateWailsCRUD genera código Go de bindings CRUD para una entidad. +// Retorna el código Go como string — función pura. +func GenerateWailsCRUD(spec WailsCRUDSpec) string { + var sb strings.Builder + lower := strings.ToLower(spec.EntityName) + plural := lower + "s" + + // Tipo + sb.WriteString(fmt.Sprintf("// %s entity\n", spec.EntityName)) + sb.WriteString(fmt.Sprintf("type %s struct {\n", spec.EntityName)) + sb.WriteString("\tID string `json:\"id\"`\n") + for _, field := range spec.Fields { + parts := strings.SplitN(field, " ", 2) + if len(parts) == 2 { + jsonTag := strings.ToLower(parts[0]) + sb.WriteString(fmt.Sprintf("\t%s %s `json:\"%s\"`\n", parts[0], parts[1], jsonTag)) + } + } + sb.WriteString("}\n\n") + + // List + if spec.WithList { + sb.WriteString(fmt.Sprintf("// List%s retorna todos los %s.\n", spec.EntityName+"s", plural)) + sb.WriteString(fmt.Sprintf("func (a *App) List%s() ([]%s, error) {\n", spec.EntityName+"s", spec.EntityName)) + sb.WriteString(fmt.Sprintf("\t// TODO: implement List%s\n", spec.EntityName+"s")) + sb.WriteString(fmt.Sprintf("\treturn nil, fmt.Errorf(\"not implemented\")\n")) + sb.WriteString("}\n\n") + } + + // Get + if spec.WithGet { + sb.WriteString(fmt.Sprintf("// Get%s retorna un %s por ID.\n", spec.EntityName, lower)) + sb.WriteString(fmt.Sprintf("func (a *App) Get%s(id string) (%s, error) {\n", spec.EntityName, spec.EntityName)) + sb.WriteString(fmt.Sprintf("\t// TODO: implement Get%s\n", spec.EntityName)) + sb.WriteString(fmt.Sprintf("\treturn %s{}, fmt.Errorf(\"not implemented\")\n", spec.EntityName)) + sb.WriteString("}\n\n") + } + + // Create + if spec.WithCreate { + sb.WriteString(fmt.Sprintf("// Create%s crea un nuevo %s.\n", spec.EntityName, lower)) + sb.WriteString(fmt.Sprintf("func (a *App) Create%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName)) + sb.WriteString(fmt.Sprintf("\t// TODO: implement Create%s\n", spec.EntityName)) + sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower)) + sb.WriteString("}\n\n") + } + + // Update + if spec.WithUpdate { + sb.WriteString(fmt.Sprintf("// Update%s actualiza un %s existente.\n", spec.EntityName, lower)) + sb.WriteString(fmt.Sprintf("func (a *App) Update%s(%s %s) (%s, error) {\n", spec.EntityName, lower, spec.EntityName, spec.EntityName)) + sb.WriteString(fmt.Sprintf("\t// TODO: implement Update%s\n", spec.EntityName)) + sb.WriteString(fmt.Sprintf("\treturn %s, fmt.Errorf(\"not implemented\")\n", lower)) + sb.WriteString("}\n\n") + } + + // Delete + if spec.WithDelete { + sb.WriteString(fmt.Sprintf("// Delete%s elimina un %s por ID.\n", spec.EntityName, lower)) + sb.WriteString(fmt.Sprintf("func (a *App) Delete%s(id string) error {\n", spec.EntityName)) + sb.WriteString(fmt.Sprintf("\t// TODO: implement Delete%s\n", spec.EntityName)) + sb.WriteString("\treturn fmt.Errorf(\"not implemented\")\n") + sb.WriteString("}\n\n") + } + + return sb.String() +} diff --git a/functions/infra/wails_bind_crud.md b/functions/infra/wails_bind_crud.md new file mode 100644 index 00000000..8dc73a1d --- /dev/null +++ b/functions/infra/wails_bind_crud.md @@ -0,0 +1,40 @@ +--- +name: wails_bind_crud +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "GenerateWailsCRUD(spec WailsCRUDSpec) string" +description: "Genera código Go de bindings CRUD para Wails: struct + métodos List/Get/Create/Update/Delete con stubs not-implemented." +tags: [wails, crud, generator, bindings, codegen, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [fmt, strings] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/wails_bind_crud.go" +--- + +## Ejemplo + +```go +code := GenerateWailsCRUD(WailsCRUDSpec{ + EntityName: "User", + Fields: []string{"Name string", "Email string", "Role string"}, + WithList: true, + WithGet: true, + WithCreate: true, + WithUpdate: true, + WithDelete: true, +}) +// Genera: type User struct + ListUsers + GetUser + CreateUser + UpdateUser + DeleteUser +``` + +## Notas + +Función pura — genera código como string. Los métodos son stubs con `return fmt.Errorf("not implemented")`. Un agente puede generar el código, insertarlo en app.go, y luego implementar los TODOs. diff --git a/functions/infra/wails_build.go b/functions/infra/wails_build.go new file mode 100644 index 00000000..13beeb89 --- /dev/null +++ b/functions/infra/wails_build.go @@ -0,0 +1,65 @@ +package infra + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// WailsBuildConfig configura la compilación de un proyecto Wails. +type WailsBuildConfig struct { + Dir string // Directorio del proyecto Wails + Platform string // linux, windows, darwin (default: current) + Output string // Nombre del binario de salida + Debug bool // Build con debug info +} + +// WailsBuild compila un proyecto Wails para la plataforma especificada. +func WailsBuild(ctx context.Context, cfg WailsBuildConfig) error { + if cfg.Dir == "" { + return fmt.Errorf("dir is required") + } + + args := []string{"build"} + + if cfg.Platform != "" { + args = append(args, "-platform", cfg.Platform) + } + if cfg.Output != "" { + args = append(args, "-o", cfg.Output) + } + if cfg.Debug { + args = append(args, "-debug") + } + + cmd := exec.CommandContext(ctx, "wails", args...) + cmd.Dir = cfg.Dir + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("wails build failed: %w\n%s", err, string(output)) + } + + fmt.Printf("Built successfully: %s\n", strings.TrimSpace(string(output))) + return nil +} + +// WailsDev lanza un proyecto Wails en modo desarrollo con hot reload. +func WailsDev(ctx context.Context, dir string, browser bool) error { + if dir == "" { + return fmt.Errorf("dir is required") + } + + args := []string{"dev"} + if browser { + args = append(args, "-browser") + } + + cmd := exec.CommandContext(ctx, "wails", args...) + cmd.Dir = dir + cmd.Stdout = nil // inherit + cmd.Stderr = nil + + return cmd.Run() +} diff --git a/functions/infra/wails_build.md b/functions/infra/wails_build.md new file mode 100644 index 00000000..3f75b8bd --- /dev/null +++ b/functions/infra/wails_build.md @@ -0,0 +1,38 @@ +--- +name: wails_build +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "WailsBuild(ctx context.Context, cfg WailsBuildConfig) error" +description: "Compila un proyecto Wails para linux/windows/darwin. Incluye WailsDev para modo desarrollo con hot reload." +tags: [wails, build, compile, desktop, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [os/exec, fmt, strings, context] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/wails_build.go" +--- + +## Ejemplo + +```go +// Build para linux +WailsBuild(ctx, WailsBuildConfig{ + Dir: "/home/user/my-app", + Platform: "linux", +}) + +// Desarrollo con hot reload + browser +WailsDev(ctx, "/home/user/my-app", true) +``` + +## Notas + +Requiere `wails` CLI instalado. WailsDev bloquea el proceso (es un servidor de desarrollo). diff --git a/functions/infra/wails_emit_event.go b/functions/infra/wails_emit_event.go new file mode 100644 index 00000000..d6f86b43 --- /dev/null +++ b/functions/infra/wails_emit_event.go @@ -0,0 +1,40 @@ +//go:build ignore +// NOTE: requires wails dependency in target project — use this file by copying into a Wails project + +package infra + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// WailsEventPayload es un envelope para eventos tipados. +type WailsEventPayload struct { + Type string `json:"type"` + Data interface{} `json:"data"` + Timestamp time.Time `json:"timestamp"` +} + +// WailsEmitEvent emite un evento tipado al frontend. +func WailsEmitEvent(ctx context.Context, eventName string, data interface{}) { + payload := WailsEventPayload{ + Type: eventName, + Data: data, + Timestamp: time.Now(), + } + runtime.EventsEmit(ctx, eventName, payload) +} + +// WailsEmitJSON emite datos como JSON string al frontend. +func WailsEmitJSON(ctx context.Context, eventName string, data interface{}) error { + b, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("marshaling event data: %w", err) + } + runtime.EventsEmit(ctx, eventName, string(b)) + return nil +} diff --git a/functions/infra/wails_emit_event.md b/functions/infra/wails_emit_event.md new file mode 100644 index 00000000..3c08d90d --- /dev/null +++ b/functions/infra/wails_emit_event.md @@ -0,0 +1,35 @@ +--- +name: wails_emit_event +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "WailsEmitEvent(ctx context.Context, eventName string, data interface{})" +description: "Emite eventos tipados de Go al frontend con timestamp automático. Incluye WailsEmitJSON para serialización explícita." +tags: [wails, event, emit, ipc, realtime, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [context, encoding/json, fmt, time, github.com/wailsapp/wails/v2/pkg/runtime] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/wails_emit_event.go" +--- + +## Ejemplo + +```go +// Emitir evento tipado +WailsEmitEvent(ctx, "price:update", PriceData{Symbol: "BTC", Price: 67500.0}) + +// El frontend lo recibe con useWailsEvent: +// useWailsEvent({ eventName: 'price:update', onEvent: (data) => ... }) +``` + +## Notas + +El evento llega al frontend como WailsEventPayload con `type`, `data` y `timestamp`. Complementa `use_wails_event` del lado TS. Requiere la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino. diff --git a/functions/infra/wails_stream_data.go b/functions/infra/wails_stream_data.go new file mode 100644 index 00000000..f86880e4 --- /dev/null +++ b/functions/infra/wails_stream_data.go @@ -0,0 +1,70 @@ +//go:build ignore +// NOTE: requires wails dependency in target project — use this file by copying into a Wails project + +package infra + +import ( + "context" + "fmt" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// WailsStreamConfig configura un stream de datos hacia el frontend. +type WailsStreamConfig struct { + StreamName string // Nombre del stream (evento base) + ChunkDelay time.Duration // Delay entre chunks (default 0) +} + +// WailsStreamData envía un slice de datos como stream al frontend. +// Emite cada elemento como chunk en {streamName}, y {streamName}:complete al terminar. +func WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error { + if cfg.StreamName == "" { + return fmt.Errorf("stream name is required") + } + + for _, chunk := range data { + select { + case <-ctx.Done(): + runtime.EventsEmit(ctx, cfg.StreamName+":error", map[string]string{ + "message": "stream cancelled", + }) + return ctx.Err() + default: + runtime.EventsEmit(ctx, cfg.StreamName, chunk) + if cfg.ChunkDelay > 0 { + time.Sleep(cfg.ChunkDelay) + } + } + } + + runtime.EventsEmit(ctx, cfg.StreamName+":complete", nil) + return nil +} + +// WailsStreamFunc ejecuta una función generadora y envía resultados como stream. +// La función generadora envía datos por el canal, y esta función los retransmite al frontend. +func WailsStreamFunc[T any](ctx context.Context, streamName string, generator func(ctx context.Context, ch chan<- T) error) error { + ch := make(chan T, 100) + errCh := make(chan error, 1) + + go func() { + defer close(ch) + errCh <- generator(ctx, ch) + }() + + for chunk := range ch { + runtime.EventsEmit(ctx, streamName, chunk) + } + + if err := <-errCh; err != nil { + runtime.EventsEmit(ctx, streamName+":error", map[string]string{ + "message": err.Error(), + }) + return err + } + + runtime.EventsEmit(ctx, streamName+":complete", nil) + return nil +} diff --git a/functions/infra/wails_stream_data.md b/functions/infra/wails_stream_data.md new file mode 100644 index 00000000..333dfb78 --- /dev/null +++ b/functions/infra/wails_stream_data.md @@ -0,0 +1,50 @@ +--- +name: wails_stream_data +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "WailsStreamData[T any](ctx context.Context, cfg WailsStreamConfig, data []T) error" +description: "Envía datos como stream Go→TS con protocolo {name}/{name}:complete/{name}:error. Incluye WailsStreamFunc para generadores." +tags: [wails, stream, ipc, realtime, chunks, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [context, fmt, time, github.com/wailsapp/wails/v2/pkg/runtime] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/infra/wails_stream_data.go" +--- + +## Ejemplo + +```go +// Stream de slice +WailsStreamData(ctx, WailsStreamConfig{ + StreamName: "logs", + ChunkDelay: 10 * time.Millisecond, +}, logLines) + +// Stream con generador +WailsStreamFunc(ctx, "metrics", func(ctx context.Context, ch chan<- Metric) error { + for { + select { + case <-ctx.Done(): + return nil + case <-time.After(time.Second): + ch <- collectMetric() + } + } +}) + +// Frontend lo recibe con useWailsStream: +// useWailsStream({ streamName: 'metrics', autoStart: true }) +``` + +## Notas + +Protocolo: chunks en `{streamName}`, fin en `{streamName}:complete`, error en `{streamName}:error`. Compatible con `use_wails_stream` del lado TS. WailsStreamFunc usa goroutine + canal para datos asíncronos. Requiere Go 1.18+ (generics) y la dependencia `github.com/wailsapp/wails/v2` en el proyecto destino. From dc78d8fea3b249bed56e7f070ce62cf82408f012 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Wed, 1 Apr 2026 20:55:34 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20funciones=20frontend=20React/TS=20?= =?UTF-8?q?=E2=80=94=20componentes=20UI,=20hooks=20Wails,=20charts=20y=20t?= =?UTF-8?q?ipos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Componentes React reutilizables: card, dialog, tabs, select, alert, badge, button, input, label, skeleton, tooltip, progress_bar, page_header, form_field, settings_page, crud_page, analytics_page, dashboard_layout. Charts: area, bar, line, sparkline, kpi_card, chart_container. Hooks Wails: use_wails_query, use_wails_mutation, use_wails_stream, use_wails_event, use_animated_canvas. Funciones core: cn, format_compact, chart_colors, get_series_color, wails_cache, theme_config_to_colors. Tipos: chart_series, wails_ipc, theme_config, component_variants. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/functions/core/chart_colors.md | 35 ++ frontend/functions/core/chart_colors.ts | 11 + frontend/functions/core/cn.md | 36 ++ frontend/functions/core/cn.ts | 6 + frontend/functions/core/format_compact.md | 36 ++ frontend/functions/core/format_compact.ts | 42 ++ frontend/functions/core/get_series_color.md | 36 ++ frontend/functions/core/get_series_color.ts | 7 + .../functions/core/theme_config_to_colors.md | 37 ++ .../functions/core/theme_config_to_colors.ts | 49 ++ frontend/functions/core/wails_cache.md | 39 ++ frontend/functions/core/wails_cache.ts | 99 ++++ frontend/functions/ui/alert.md | 49 ++ frontend/functions/ui/alert.tsx | 34 ++ frontend/functions/ui/analytics_page.md | 47 ++ frontend/functions/ui/analytics_page.tsx | 101 ++++ frontend/functions/ui/apply_theme.md | 40 ++ frontend/functions/ui/apply_theme.tsx | 111 ++++ frontend/functions/ui/area_chart.md | 56 ++ frontend/functions/ui/area_chart.tsx | 62 ++ frontend/functions/ui/badge.md | 48 ++ frontend/functions/ui/badge.tsx | 45 ++ frontend/functions/ui/bar_chart.md | 52 ++ frontend/functions/ui/bar_chart.tsx | 53 ++ frontend/functions/ui/button.md | 53 ++ frontend/functions/ui/button.tsx | 52 ++ frontend/functions/ui/card.md | 54 ++ frontend/functions/ui/card.tsx | 85 +++ frontend/functions/ui/chart_container.md | 49 ++ frontend/functions/ui/chart_container.tsx | 80 +++ frontend/functions/ui/crud_page.md | 51 ++ frontend/functions/ui/crud_page.tsx | 120 ++++ frontend/functions/ui/dashboard_layout.md | 42 ++ frontend/functions/ui/dashboard_layout.tsx | 67 +++ frontend/functions/ui/detail_page.md | 54 ++ frontend/functions/ui/detail_page.tsx | 134 +++++ frontend/functions/ui/dialog.md | 55 ++ frontend/functions/ui/dialog.tsx | 73 +++ frontend/functions/ui/form_field.md | 53 ++ frontend/functions/ui/form_field.tsx | 42 ++ frontend/functions/ui/input.md | 51 ++ frontend/functions/ui/input.tsx | 56 ++ frontend/functions/ui/kpi_card.md | 56 ++ frontend/functions/ui/kpi_card.tsx | 60 ++ frontend/functions/ui/label.md | 39 ++ frontend/functions/ui/label.tsx | 17 + frontend/functions/ui/line_chart.md | 68 +++ frontend/functions/ui/line_chart.tsx | 57 ++ frontend/functions/ui/page_header.md | 62 ++ frontend/functions/ui/page_header.tsx | 94 +++ frontend/functions/ui/progress_bar.md | 81 +++ frontend/functions/ui/progress_bar.tsx | 68 +++ frontend/functions/ui/select.md | 68 +++ frontend/functions/ui/select.tsx | 88 +++ frontend/functions/ui/settings_page.md | 57 ++ frontend/functions/ui/settings_page.tsx | 112 ++++ frontend/functions/ui/skeleton.md | 65 +++ frontend/functions/ui/skeleton.tsx | 54 ++ frontend/functions/ui/sparkline.md | 60 ++ frontend/functions/ui/sparkline.tsx | 72 +++ frontend/functions/ui/tabs.md | 50 ++ frontend/functions/ui/tabs.tsx | 43 ++ frontend/functions/ui/theme_provider.md | 65 +++ frontend/functions/ui/theme_provider.tsx | 101 ++++ frontend/functions/ui/tooltip.md | 51 ++ frontend/functions/ui/tooltip.tsx | 45 ++ frontend/functions/ui/use_animated_canvas.md | 57 ++ frontend/functions/ui/use_animated_canvas.tsx | 80 +++ frontend/functions/ui/use_wails_event.md | 62 ++ frontend/functions/ui/use_wails_event.tsx | 115 ++++ frontend/functions/ui/use_wails_mutation.md | 64 +++ frontend/functions/ui/use_wails_mutation.tsx | 150 +++++ frontend/functions/ui/use_wails_query.md | 65 +++ frontend/functions/ui/use_wails_query.tsx | 163 ++++++ frontend/functions/ui/use_wails_stream.md | 69 +++ frontend/functions/ui/use_wails_stream.tsx | 196 +++++++ frontend/functions/ui/wails_provider.md | 53 ++ frontend/functions/ui/wails_provider.tsx | 60 ++ frontend/types/core/component_variants.md | 40 ++ frontend/types/core/component_variants.ts | 10 + frontend/types/ui/chart_series.md | 26 + frontend/types/ui/chart_series.ts | 9 + frontend/types/ui/theme_config.md | 41 ++ frontend/types/ui/theme_config.ts | 540 ++++++++++++++++++ frontend/types/ui/wails_ipc.md | 22 + frontend/types/ui/wails_ipc.ts | 64 +++ 86 files changed, 5721 insertions(+) create mode 100644 frontend/functions/core/chart_colors.md create mode 100644 frontend/functions/core/chart_colors.ts create mode 100644 frontend/functions/core/cn.md create mode 100644 frontend/functions/core/cn.ts create mode 100644 frontend/functions/core/format_compact.md create mode 100644 frontend/functions/core/format_compact.ts create mode 100644 frontend/functions/core/get_series_color.md create mode 100644 frontend/functions/core/get_series_color.ts create mode 100644 frontend/functions/core/theme_config_to_colors.md create mode 100644 frontend/functions/core/theme_config_to_colors.ts create mode 100644 frontend/functions/core/wails_cache.md create mode 100644 frontend/functions/core/wails_cache.ts create mode 100644 frontend/functions/ui/alert.md create mode 100644 frontend/functions/ui/alert.tsx create mode 100644 frontend/functions/ui/analytics_page.md create mode 100644 frontend/functions/ui/analytics_page.tsx create mode 100644 frontend/functions/ui/apply_theme.md create mode 100644 frontend/functions/ui/apply_theme.tsx create mode 100644 frontend/functions/ui/area_chart.md create mode 100644 frontend/functions/ui/area_chart.tsx create mode 100644 frontend/functions/ui/badge.md create mode 100644 frontend/functions/ui/badge.tsx create mode 100644 frontend/functions/ui/bar_chart.md create mode 100644 frontend/functions/ui/bar_chart.tsx create mode 100644 frontend/functions/ui/button.md create mode 100644 frontend/functions/ui/button.tsx create mode 100644 frontend/functions/ui/card.md create mode 100644 frontend/functions/ui/card.tsx create mode 100644 frontend/functions/ui/chart_container.md create mode 100644 frontend/functions/ui/chart_container.tsx create mode 100644 frontend/functions/ui/crud_page.md create mode 100644 frontend/functions/ui/crud_page.tsx create mode 100644 frontend/functions/ui/dashboard_layout.md create mode 100644 frontend/functions/ui/dashboard_layout.tsx create mode 100644 frontend/functions/ui/detail_page.md create mode 100644 frontend/functions/ui/detail_page.tsx create mode 100644 frontend/functions/ui/dialog.md create mode 100644 frontend/functions/ui/dialog.tsx create mode 100644 frontend/functions/ui/form_field.md create mode 100644 frontend/functions/ui/form_field.tsx create mode 100644 frontend/functions/ui/input.md create mode 100644 frontend/functions/ui/input.tsx create mode 100644 frontend/functions/ui/kpi_card.md create mode 100644 frontend/functions/ui/kpi_card.tsx create mode 100644 frontend/functions/ui/label.md create mode 100644 frontend/functions/ui/label.tsx create mode 100644 frontend/functions/ui/line_chart.md create mode 100644 frontend/functions/ui/line_chart.tsx create mode 100644 frontend/functions/ui/page_header.md create mode 100644 frontend/functions/ui/page_header.tsx create mode 100644 frontend/functions/ui/progress_bar.md create mode 100644 frontend/functions/ui/progress_bar.tsx create mode 100644 frontend/functions/ui/select.md create mode 100644 frontend/functions/ui/select.tsx create mode 100644 frontend/functions/ui/settings_page.md create mode 100644 frontend/functions/ui/settings_page.tsx create mode 100644 frontend/functions/ui/skeleton.md create mode 100644 frontend/functions/ui/skeleton.tsx create mode 100644 frontend/functions/ui/sparkline.md create mode 100644 frontend/functions/ui/sparkline.tsx create mode 100644 frontend/functions/ui/tabs.md create mode 100644 frontend/functions/ui/tabs.tsx create mode 100644 frontend/functions/ui/theme_provider.md create mode 100644 frontend/functions/ui/theme_provider.tsx create mode 100644 frontend/functions/ui/tooltip.md create mode 100644 frontend/functions/ui/tooltip.tsx create mode 100644 frontend/functions/ui/use_animated_canvas.md create mode 100644 frontend/functions/ui/use_animated_canvas.tsx create mode 100644 frontend/functions/ui/use_wails_event.md create mode 100644 frontend/functions/ui/use_wails_event.tsx create mode 100644 frontend/functions/ui/use_wails_mutation.md create mode 100644 frontend/functions/ui/use_wails_mutation.tsx create mode 100644 frontend/functions/ui/use_wails_query.md create mode 100644 frontend/functions/ui/use_wails_query.tsx create mode 100644 frontend/functions/ui/use_wails_stream.md create mode 100644 frontend/functions/ui/use_wails_stream.tsx create mode 100644 frontend/functions/ui/wails_provider.md create mode 100644 frontend/functions/ui/wails_provider.tsx create mode 100644 frontend/types/core/component_variants.md create mode 100644 frontend/types/core/component_variants.ts create mode 100644 frontend/types/ui/chart_series.md create mode 100644 frontend/types/ui/chart_series.ts create mode 100644 frontend/types/ui/theme_config.md create mode 100644 frontend/types/ui/theme_config.ts create mode 100644 frontend/types/ui/wails_ipc.md create mode 100644 frontend/types/ui/wails_ipc.ts diff --git a/frontend/functions/core/chart_colors.md b/frontend/functions/core/chart_colors.md new file mode 100644 index 00000000..8bb9bd2a --- /dev/null +++ b/frontend/functions/core/chart_colors.md @@ -0,0 +1,35 @@ +--- +name: chart_colors +kind: function +lang: typescript +domain: core +version: "1.0.0" +purity: pure +signature: "getChartColor(index: number): string" +description: "Paleta de colores para gráficos basada en CSS variables del tema activo. Colores accesibles por índice cíclico." +tags: [chart, color, theme, palette, visualization] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/chart_colors.ts" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/charts/chart-base.tsx" +--- + +## Ejemplo + +```typescript +getChartColor(0) // 'hsl(var(--chart-1, 220 70% 50%))' +getChartColor(7) // 'hsl(var(--chart-3, 30 80% 55%))' — cicla sobre 5 colores +``` + +## Notas + +Usa CSS variables del tema con fallback hardcodeado. Los colores cambian automáticamente con el tema activo. También exporta `chartColors` (array) para uso directo. diff --git a/frontend/functions/core/chart_colors.ts b/frontend/functions/core/chart_colors.ts new file mode 100644 index 00000000..fc55e8cf --- /dev/null +++ b/frontend/functions/core/chart_colors.ts @@ -0,0 +1,11 @@ +export const chartColors = [ + 'hsl(var(--chart-1, 220 70% 50%))', + 'hsl(var(--chart-2, 160 60% 45%))', + 'hsl(var(--chart-3, 30 80% 55%))', + 'hsl(var(--chart-4, 280 65% 60%))', + 'hsl(var(--chart-5, 340 75% 55%))', +] + +export function getChartColor(index: number): string { + return chartColors[index % chartColors.length] +} diff --git a/frontend/functions/core/cn.md b/frontend/functions/core/cn.md new file mode 100644 index 00000000..288ae534 --- /dev/null +++ b/frontend/functions/core/cn.md @@ -0,0 +1,36 @@ +--- +name: cn +kind: function +lang: typescript +domain: core +version: "1.0.0" +purity: pure +signature: "cn(...inputs: ClassValue[]): string" +description: "Combina clases CSS con clsx y resuelve conflictos Tailwind con tailwind-merge. Utilidad fundamental para composición de estilos." +tags: [css, tailwind, classname, merge, utility] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [clsx, tailwind-merge] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/cn.ts" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/lib/utils.ts" +--- + +## Ejemplo + +```typescript +cn("px-4 py-2", "px-6") // "px-6 py-2" (tailwind-merge resuelve conflicto) +cn("text-red-500", false && "hidden") // "text-red-500" (clsx filtra falsy) +cn("rounded-lg", className) // composición con className externo +``` + +## Notas + +Base de todo el sistema de estilos. Todos los componentes la usan para componer className. diff --git a/frontend/functions/core/cn.ts b/frontend/functions/core/cn.ts new file mode 100644 index 00000000..e3155433 --- /dev/null +++ b/frontend/functions/core/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)) +} diff --git a/frontend/functions/core/format_compact.md b/frontend/functions/core/format_compact.md new file mode 100644 index 00000000..6542fa5b --- /dev/null +++ b/frontend/functions/core/format_compact.md @@ -0,0 +1,36 @@ +--- +name: format_compact +kind: function +lang: typescript +domain: core +version: "1.0.0" +purity: pure +signature: "formatCompact(n: number, decimals?: number): string" +description: "Familia de funciones de formato compacto: números (K/M/B), frecuencia (Hz/KHz/MHz), bytes (KB/MB/GB), duración (ms/s/min/h)." +tags: [format, number, compact, utility, display] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/format_compact.ts" +--- + +## Ejemplo + +```typescript +formatCompact(1234) // '1.2K' +formatCompact(1500000) // '1.5M' +formatHz(44100) // '44.1 KHz' +formatBytes(1073741824) // '1.0 GB' +formatDuration(3500) // '3.5s' +formatDuration(0.5) // '500µs' +``` + +## Notas + +Todas son funciones puras sin dependencias. Útiles en dashboards, KPI cards, tablas y tooltips. diff --git a/frontend/functions/core/format_compact.ts b/frontend/functions/core/format_compact.ts new file mode 100644 index 00000000..ff7e4b9b --- /dev/null +++ b/frontend/functions/core/format_compact.ts @@ -0,0 +1,42 @@ +/** + * Formatea un número en formato compacto (1K, 1.2M, etc.) + * Soporta sufijos personalizados. + */ +export function formatCompact(n: number, decimals: number = 1): string { + if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(decimals) + 'B' + if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(decimals) + 'M' + if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(decimals) + 'K' + return n.toString() +} + +/** + * Formatea frecuencia en Hz/KHz/MHz/GHz. + */ +export function formatHz(hz: number, decimals: number = 1): string { + if (hz >= 1_000_000_000) return (hz / 1_000_000_000).toFixed(decimals) + ' GHz' + if (hz >= 1_000_000) return (hz / 1_000_000).toFixed(decimals) + ' MHz' + if (hz >= 1_000) return (hz / 1_000).toFixed(decimals) + ' KHz' + return hz + ' Hz' +} + +/** + * Formatea bytes en KB/MB/GB/TB. + */ +export function formatBytes(bytes: number, decimals: number = 1): string { + if (bytes >= 1_099_511_627_776) return (bytes / 1_099_511_627_776).toFixed(decimals) + ' TB' + if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(decimals) + ' GB' + if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(decimals) + ' MB' + if (bytes >= 1_024) return (bytes / 1_024).toFixed(decimals) + ' KB' + return bytes + ' B' +} + +/** + * Formatea duración en ms/s/min/h. + */ +export function formatDuration(ms: number): string { + if (ms >= 3_600_000) return (ms / 3_600_000).toFixed(1) + 'h' + if (ms >= 60_000) return (ms / 60_000).toFixed(1) + 'min' + if (ms >= 1_000) return (ms / 1_000).toFixed(1) + 's' + if (ms >= 1) return ms.toFixed(0) + 'ms' + return (ms * 1000).toFixed(0) + 'µs' +} diff --git a/frontend/functions/core/get_series_color.md b/frontend/functions/core/get_series_color.md new file mode 100644 index 00000000..6bb5aa95 --- /dev/null +++ b/frontend/functions/core/get_series_color.md @@ -0,0 +1,36 @@ +--- +name: get_series_color +kind: function +lang: typescript +domain: core +version: "1.0.0" +purity: pure +signature: "getSeriesColor(index: number, color?: string): string" +description: "Devuelve color para una serie de gráfico por índice cíclico, o el color explícito si se proporciona." +tags: [chart, color, series, visualization] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/get_series_color.ts" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/charts/chart-base.tsx" +--- + +## Ejemplo + +```typescript +getSeriesColor(0) // '#3b82f6' +getSeriesColor(5) // '#3b82f6' (cicla sobre 5 colores) +getSeriesColor(0, '#ff0000') // '#ff0000' (usa el explícito) +``` + +## Notas + +Paleta fija de 5 colores: azul, verde, ámbar, violeta, rosa. También exporta `defaultColors` para uso directo. diff --git a/frontend/functions/core/get_series_color.ts b/frontend/functions/core/get_series_color.ts new file mode 100644 index 00000000..0c89b11a --- /dev/null +++ b/frontend/functions/core/get_series_color.ts @@ -0,0 +1,7 @@ +const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899'] + +export function getSeriesColor(index: number, color?: string): string { + return color || defaultColors[index % defaultColors.length] +} + +export { defaultColors } diff --git a/frontend/functions/core/theme_config_to_colors.md b/frontend/functions/core/theme_config_to_colors.md new file mode 100644 index 00000000..b3a7dab0 --- /dev/null +++ b/frontend/functions/core/theme_config_to_colors.md @@ -0,0 +1,37 @@ +--- +name: theme_config_to_colors +kind: function +lang: typescript +domain: core +version: "1.0.0" +purity: pure +signature: "themeConfigToColors(config: ThemeConfig): ThemeColors" +description: "Convierte un ThemeConfig completo a ThemeColors plano para inyectar como CSS variables. Mapea tokens semánticos a variables CSS." +tags: [theme, colors, css-variables, conversion] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/theme_config_to_colors.ts" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/themes/types.ts" +--- + +## Ejemplo + +```typescript +const colors = themeConfigToColors(darkThemeConfig) +// { background: '...', foreground: '...', primary: '...', ... } +``` + +## Notas + +Puente entre el sistema de temas estructurado (ThemeConfig) y el sistema plano de CSS variables que consumen los componentes. + +Depende de los tipos ThemeConfig y ThemeColors definidos en `frontend/types/ui/theme_config.ts`. El tipo aún no está indexado en la BD (pendiente añadir theme_config.md para que fn index lo registre). diff --git a/frontend/functions/core/theme_config_to_colors.ts b/frontend/functions/core/theme_config_to_colors.ts new file mode 100644 index 00000000..884155db --- /dev/null +++ b/frontend/functions/core/theme_config_to_colors.ts @@ -0,0 +1,49 @@ +import type { ThemeConfig, ThemeColors } from "../../types/ui/theme_config" + +export function themeConfigToColors(config: ThemeConfig): ThemeColors { + const { colors } = config + + return { + background: colors.background.default, + foreground: colors.foreground.default, + card: colors.surface.raised, + cardForeground: colors.foreground.default, + popover: colors.surface.overlay, + popoverForeground: colors.foreground.default, + primary: colors.brand.primary, + primaryForeground: colors.brand.primaryForeground, + secondary: colors.brand.secondary, + secondaryForeground: colors.brand.secondaryForeground, + muted: colors.background.muted, + mutedForeground: colors.foreground.muted, + accent: colors.brand.accent, + accentForeground: colors.brand.accentForeground, + destructive: colors.status.error, + destructiveForeground: colors.status.errorForeground, + success: colors.status.success, + successForeground: colors.status.successForeground, + warning: colors.status.warning, + warningForeground: colors.status.warningForeground, + info: colors.status.info, + infoForeground: colors.status.infoForeground, + surface: colors.surface.raised, + surfaceHover: colors.background.subtle, + overlay: colors.surface.overlay, + border: colors.border.default, + input: colors.border.default, + ring: colors.ring, + chart1: colors.chart[1], + chart2: colors.chart[2], + chart3: colors.chart[3], + chart4: colors.chart[4], + chart5: colors.chart[5], + sidebar: colors.sidebar.background, + sidebarForeground: colors.sidebar.foreground, + sidebarPrimary: colors.brand.primary, + sidebarPrimaryForeground: colors.brand.primaryForeground, + sidebarAccent: colors.sidebar.accent, + sidebarAccentForeground: colors.sidebar.accentForeground, + sidebarBorder: colors.sidebar.border, + sidebarRing: colors.sidebar.ring, + } +} diff --git a/frontend/functions/core/wails_cache.md b/frontend/functions/core/wails_cache.md new file mode 100644 index 00000000..2296276d --- /dev/null +++ b/frontend/functions/core/wails_cache.md @@ -0,0 +1,39 @@ +--- +name: wails_cache +kind: function +lang: typescript +domain: core +version: "1.0.0" +purity: pure +signature: "class WailsCache { get(key: string[]): T | null; set(key: string[], data: T): void; invalidate(key: string[]): void; subscribe(key: string[], cb: () => void): () => void }" +description: "Cache reactivo para IPC Wails con invalidación por prefijo, suscripción a cambios y tracking de staleness. Singleton global." +tags: [wails, cache, ipc, reactive, state] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/wails_cache.ts" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/lib/wails/cache.ts" +--- + +## Ejemplo + +```typescript +import { wailsCache } from './wails_cache' + +wailsCache.set(['users', '123'], userData) +const user = wailsCache.get(['users', '123']) +wailsCache.invalidate(['users']) // invalida users:* +const unsub = wailsCache.subscribe(['users'], () => console.log('changed')) +``` + +## Notas + +Key como string[] permite invalidación jerárquica: `invalidate(['users'])` invalida `users`, `users:123`, `users:456`, etc. diff --git a/frontend/functions/core/wails_cache.ts b/frontend/functions/core/wails_cache.ts new file mode 100644 index 00000000..10f2c0ed --- /dev/null +++ b/frontend/functions/core/wails_cache.ts @@ -0,0 +1,99 @@ +interface CacheEntry { + data: unknown + timestamp: Date +} + +export class WailsCache { + private cache = new Map() + private subscribers = new Map void>>() + + /** Generar key string desde array */ + private getKey(queryKey: string[]): string { + return queryKey.join(':') + } + + /** Obtener dato del cache */ + get(queryKey: string[]): T | null { + const entry = this.cache.get(this.getKey(queryKey)) + return (entry?.data as T) ?? null + } + + /** Guardar dato en cache */ + set(queryKey: string[], data: T): void { + const key = this.getKey(queryKey) + this.cache.set(key, { data, timestamp: new Date() }) + this.notifySubscribers(key) + } + + /** Verificar si existe en cache */ + has(queryKey: string[]): boolean { + return this.cache.has(this.getKey(queryKey)) + } + + /** Obtener timestamp de última actualización */ + getTimestamp(queryKey: string[]): Date | null { + const entry = this.cache.get(this.getKey(queryKey)) + return entry?.timestamp ?? null + } + + /** Verificar si los datos están stale */ + isStale(queryKey: string[], staleTime: number): boolean { + const entry = this.cache.get(this.getKey(queryKey)) + if (!entry) return true + return Date.now() - entry.timestamp.getTime() > staleTime + } + + /** Invalidar cache (esta key y todas las que empiezan igual) */ + invalidate(queryKey: string[]): void { + const prefix = this.getKey(queryKey) + const keysToDelete: string[] = [] + + for (const key of this.cache.keys()) { + if (key === prefix || key.startsWith(prefix + ':')) { + keysToDelete.push(key) + } + } + + keysToDelete.forEach((key) => { + this.cache.delete(key) + this.notifySubscribers(key) + }) + } + + /** Limpiar todo el cache */ + clear(): void { + this.cache.clear() + this.subscribers.forEach((_, key) => this.notifySubscribers(key)) + } + + /** Subscribirse a cambios en una key */ + subscribe(queryKey: string[], callback: () => void): () => void { + const key = this.getKey(queryKey) + if (!this.subscribers.has(key)) { + this.subscribers.set(key, new Set()) + } + this.subscribers.get(key)!.add(callback) + + return () => { + this.subscribers.get(key)?.delete(callback) + } + } + + /** Notificar a subscribers */ + private notifySubscribers(key: string): void { + this.subscribers.get(key)?.forEach((callback) => callback()) + } + + /** Obtener tamaño del cache */ + get size(): number { + return this.cache.size + } + + /** Obtener todas las keys */ + keys(): string[] { + return Array.from(this.cache.keys()) + } +} + +// Singleton global +export const wailsCache = new WailsCache() diff --git a/frontend/functions/ui/alert.md b/frontend/functions/ui/alert.md new file mode 100644 index 00000000..fe735977 --- /dev/null +++ b/frontend/functions/ui/alert.md @@ -0,0 +1,49 @@ +--- +name: alert +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "Alert(props: { variant?: 'default' | 'destructive' }): JSX.Element" +description: "Alerta accesible con variantes default y destructive. Sistema de slots para título, descripción, icono y acción." +tags: [alert, feedback, component, ui, notification] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react, class-variance-authority] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/alert.tsx" +props: + - name: variant + type: "'default' | 'destructive'" + required: false + description: "Variante visual" +emits: [] +has_state: false +framework: react +variant: [default, destructive] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/alert.tsx" +--- + +## Ejemplo + +```tsx + + + Error + Something went wrong. + +``` + +## Notas + +Exporta 4 subcomponentes composables via data-slot: Alert, AlertTitle, AlertDescription, AlertAction. +El icono SVG se posiciona automáticamente en grid cuando es hijo directo de Alert. +AlertAction se posiciona absolute top-right para acciones secundarias (ej: botón cerrar). diff --git a/frontend/functions/ui/alert.tsx b/frontend/functions/ui/alert.tsx new file mode 100644 index 00000000..788daf70 --- /dev/null +++ b/frontend/functions/ui/alert.tsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "../core/cn" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { variant: "default" }, + } +) + +function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { + return
+} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className)} {...props} /> +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export { Alert, AlertTitle, AlertDescription, AlertAction, alertVariants } diff --git a/frontend/functions/ui/analytics_page.md b/frontend/functions/ui/analytics_page.md new file mode 100644 index 00000000..08846bd5 --- /dev/null +++ b/frontend/functions/ui/analytics_page.md @@ -0,0 +1,47 @@ +--- +name: analytics_page +kind: function +lang: typescript +domain: ui +version: "1.0.0" +purity: pure +signature: "analyticsPage(props: AnalyticsPageProps): ReactElement" +description: "Genera un dashboard de analytics completo con header, fila de KPIs con deltas y grid de charts configurables." +tags: [analytics, dashboard, kpi, charts, factory, composition, ui] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/analytics_page.tsx" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "" +--- + +## Ejemplo + +```tsx +analyticsPage({ + title: 'Sales Analytics', + metrics: [ + { label: 'Revenue', value: '$124,500', delta: { value: 12.5, isPositive: true } }, + { label: 'Orders', value: '1,234', delta: { value: -3.2, isPositive: false } }, + { label: 'Avg Order', value: '$101', delta: { value: 0, isPositive: true } }, + { label: 'Customers', value: '892' }, + ], + charts: [ + { id: 'revenue', title: 'Revenue Over Time', type: 'area', span: 2, content: }, + { id: 'orders', title: 'Orders by Category', type: 'bar', content: }, + { id: 'trends', title: 'Customer Trends', type: 'line', content: }, + ], +}) +``` + +## Notas + +Layout inteligente: los KPIs se ajustan automáticamente a 2/3/4 columnas según cantidad. Los charts soportan span para ancho completo. diff --git a/frontend/functions/ui/analytics_page.tsx b/frontend/functions/ui/analytics_page.tsx new file mode 100644 index 00000000..9208aa03 --- /dev/null +++ b/frontend/functions/ui/analytics_page.tsx @@ -0,0 +1,101 @@ +import * as React from 'react' +import { cn } from '../core/cn' + +interface MetricConfig { + label: string + value: string | number + delta?: { value: number; isPositive: boolean } + sparklineData?: number[] +} + +interface ChartConfig { + id: string + title: string + type: 'line' | 'bar' | 'area' + span?: 1 | 2 + height?: number + content: React.ReactNode +} + +interface AnalyticsPageProps { + title: string + subtitle?: string + dateRange?: React.ReactNode + metrics: MetricConfig[] + charts: ChartConfig[] + actions?: React.ReactNode + className?: string +} + +export function analyticsPage({ + title, + subtitle, + dateRange, + metrics, + charts, + actions, + className, +}: AnalyticsPageProps): React.ReactElement { + return ( +
+ {/* Header */} +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {dateRange} + {actions} +
+
+ + {/* KPI Row */} +
+ {metrics.map((metric, i) => ( +
+

{metric.label}

+
+
+

{metric.value}

+ {metric.delta && ( +
+ {metric.delta.value > 0 ? '+' : ''}{metric.delta.value}% +
+ )} +
+
+
+ ))} +
+ + {/* Charts Grid */} +
+ {charts.map((chart) => ( +
+

{chart.title}

+ {chart.content} +
+ ))} +
+
+ ) +} + +export type { AnalyticsPageProps, MetricConfig, ChartConfig } diff --git a/frontend/functions/ui/apply_theme.md b/frontend/functions/ui/apply_theme.md new file mode 100644 index 00000000..9991facc --- /dev/null +++ b/frontend/functions/ui/apply_theme.md @@ -0,0 +1,40 @@ +--- +name: apply_theme +kind: function +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "applyTheme(theme: Theme): void" +description: "Inyecta un tema como CSS variables en document.documentElement. Maneja clase dark automáticamente. Mapea 40 tokens semánticos." +tags: [theme, css-variables, apply, runtime, ui] +uses_functions: [] +uses_types: [ThemeConfig_typescript_ui] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/apply_theme.tsx" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/hooks/use-theme.tsx" +--- + +## Ejemplo + +```typescript +import { applyTheme } from './apply_theme' + +applyTheme({ + name: 'dark', + label: 'Oscuro', + colors: themeConfigToColors(darkThemeConfig) +}) +``` + +## Notas + +Función impura (modifica el DOM). Mapea cada key de ThemeColors a una CSS variable. Temas oscuros (dark, midnight, sunset) añaden clase `dark` al root. diff --git a/frontend/functions/ui/apply_theme.tsx b/frontend/functions/ui/apply_theme.tsx new file mode 100644 index 00000000..49eafbd5 --- /dev/null +++ b/frontend/functions/ui/apply_theme.tsx @@ -0,0 +1,111 @@ +interface ThemeColors { + background: string + foreground: string + card: string + cardForeground: string + popover: string + popoverForeground: string + primary: string + primaryForeground: string + secondary: string + secondaryForeground: string + muted: string + mutedForeground: string + accent: string + accentForeground: string + destructive: string + destructiveForeground: string + success: string + successForeground: string + warning: string + warningForeground: string + info: string + infoForeground: string + surface: string + surfaceHover: string + overlay: string + border: string + input: string + ring: string + chart1: string + chart2: string + chart3: string + chart4: string + chart5: string + sidebar: string + sidebarForeground: string + sidebarPrimary: string + sidebarPrimaryForeground: string + sidebarAccent: string + sidebarAccentForeground: string + sidebarBorder: string + sidebarRing: string +} + +interface Theme { + name: string + label: string + colors: ThemeColors +} + +const cssVarMap: Record = { + background: '--background', + foreground: '--foreground', + card: '--card', + cardForeground: '--card-foreground', + popover: '--popover', + popoverForeground: '--popover-foreground', + primary: '--primary', + primaryForeground: '--primary-foreground', + secondary: '--secondary', + secondaryForeground: '--secondary-foreground', + muted: '--muted', + mutedForeground: '--muted-foreground', + accent: '--accent', + accentForeground: '--accent-foreground', + destructive: '--destructive', + destructiveForeground: '--destructive-foreground', + success: '--success', + successForeground: '--success-foreground', + warning: '--warning', + warningForeground: '--warning-foreground', + info: '--info', + infoForeground: '--info-foreground', + surface: '--surface', + surfaceHover: '--surface-hover', + overlay: '--overlay', + border: '--border', + input: '--input', + ring: '--ring', + chart1: '--chart-1', + chart2: '--chart-2', + chart3: '--chart-3', + chart4: '--chart-4', + chart5: '--chart-5', + sidebar: '--sidebar', + sidebarForeground: '--sidebar-foreground', + sidebarPrimary: '--sidebar-primary', + sidebarPrimaryForeground: '--sidebar-primary-foreground', + sidebarAccent: '--sidebar-accent', + sidebarAccentForeground: '--sidebar-accent-foreground', + sidebarBorder: '--sidebar-border', + sidebarRing: '--sidebar-ring', +} + +export function applyTheme(theme: Theme): void { + const root = document.documentElement + const colors = theme.colors + + Object.entries(cssVarMap).forEach(([key, cssVar]) => { + const value = colors[key as keyof ThemeColors] + root.style.setProperty(cssVar, value) + }) + + if (theme.name === 'dark' || theme.name === 'midnight' || theme.name === 'sunset') { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } +} + +export type { Theme, ThemeColors } diff --git a/frontend/functions/ui/area_chart.md b/frontend/functions/ui/area_chart.md new file mode 100644 index 00000000..92a95495 --- /dev/null +++ b/frontend/functions/ui/area_chart.md @@ -0,0 +1,56 @@ +--- +name: area_chart +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "AreaChart(props: AreaChartProps): JSX.Element" +description: "Gráfico de área Recharts con gradientes automáticos, multi-series, stacking y tooltips temáticos." +tags: [chart, area, visualization, recharts, gradient, component, ui] +uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core] +uses_types: [ChartSeries_typescript_ui] +returns: [] +returns_optional: false +error_type: "" +imports: [recharts] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/area_chart.tsx" +props: + - name: data + type: "Record[]" + required: true + description: "Array de datos" + - name: xKey + type: "string" + required: true + description: "Key del eje X" + - name: stacked + type: "boolean" + required: false + description: "Apilar áreas" + - name: gradient + type: "GradientConfig | boolean" + required: false + description: "Gradiente (true por defecto)" + - name: series + type: "Series[]" + required: false + description: "Series de datos para multi-series" +emits: [] +has_state: false +framework: react +variant: [default, stacked] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/charts/area-chart.tsx" +--- + +## Ejemplo + +```tsx + + +``` diff --git a/frontend/functions/ui/area_chart.tsx b/frontend/functions/ui/area_chart.tsx new file mode 100644 index 00000000..f0ef51cb --- /dev/null +++ b/frontend/functions/ui/area_chart.tsx @@ -0,0 +1,62 @@ +import { + AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, +} from 'recharts' +import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container' + +interface GradientConfig { from: string; to: string } + +interface AreaChartProps { + data: Record[] + xKey: string + yKey?: string + series?: Series[] + stacked?: boolean + gradient?: GradientConfig | boolean + showGrid?: boolean + showLegend?: boolean + height?: number | string + className?: string + xAxisFormatter?: (value: unknown) => string + yAxisFormatter?: (value: unknown) => string + valueFormatter?: (value: number) => string +} + +function AreaChartComponent({ + data, xKey, yKey, series, stacked = false, gradient = true, showGrid = true, + showLegend = false, height = 300, className, xAxisFormatter, yAxisFormatter, + valueFormatter = (v) => v.toLocaleString(), +}: AreaChartProps) { + const areas = series + ? series.map((s, i) => ({ dataKey: s.key, name: s.name, color: getSeriesColor(i, s.color) })) + : yKey ? [{ dataKey: yKey, name: yKey, color: getSeriesColor(0) }] : [] + + const gradientConfig: GradientConfig | null = gradient + ? typeof gradient === 'object' ? gradient : { from: '', to: 'transparent' } + : null + + return ( + + + + {areas.map((area) => ( + + + + + ))} + + {showGrid && } + + + } cursor={{ stroke: 'hsl(var(--muted-foreground))', strokeDasharray: '3 3' }} /> + {showLegend && } + {areas.map((area) => ( + + ))} + + + ) +} + +export const AreaChart = AreaChartComponent +export type { AreaChartProps, GradientConfig } diff --git a/frontend/functions/ui/badge.md b/frontend/functions/ui/badge.md new file mode 100644 index 00000000..4558d802 --- /dev/null +++ b/frontend/functions/ui/badge.md @@ -0,0 +1,48 @@ +--- +name: badge +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "Badge(props: BadgeProps & VariantProps): JSX.Element" +description: "Badge con 10 variantes semánticas (default, secondary, destructive, outline, ghost, link, success, warning, error, info) y 2 tamaños." +tags: [badge, status, component, ui, indicator] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["class-variance-authority"] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/badge.tsx" +props: + - name: variant + type: "'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'success' | 'warning' | 'error' | 'info'" + required: false + description: "Variante visual" + - name: size + type: "'default' | 'sm'" + required: false + description: "Tamaño" +emits: [] +has_state: false +framework: react +variant: [default, secondary, destructive, outline, ghost, link, success, warning, error, info] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/badge.tsx" +--- + +## Ejemplo + +```tsx +Active +Error +``` + +## Notas + +Versión simplificada que usa span nativo en lugar de useRender de Base-UI. Mantiene todas las variantes y la composibilidad con cn(). diff --git a/frontend/functions/ui/badge.tsx b/frontend/functions/ui/badge.tsx new file mode 100644 index 00000000..76f1fece --- /dev/null +++ b/frontend/functions/ui/badge.tsx @@ -0,0 +1,45 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "../core/cn" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: "bg-destructive/10 text-destructive [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + success: "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400", + warning: "bg-yellow-500/10 text-yellow-600 dark:bg-yellow-500/20 dark:text-yellow-400", + error: "bg-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400", + info: "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400", + }, + size: { + default: "h-5 px-2 text-xs", + sm: "h-4 px-1.5 text-[10px]", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant = "default", size = "default", ...props }: BadgeProps) { + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/functions/ui/bar_chart.md b/frontend/functions/ui/bar_chart.md new file mode 100644 index 00000000..0857994f --- /dev/null +++ b/frontend/functions/ui/bar_chart.md @@ -0,0 +1,52 @@ +--- +name: bar_chart +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "BarChart(props: BarChartProps): JSX.Element" +description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados." +tags: [chart, bar, visualization, recharts, component, ui] +uses_functions: [cn_typescript_core, chart_container_typescript_ui, get_series_color_typescript_core] +uses_types: [ChartSeries_typescript_ui] +returns: [] +returns_optional: false +error_type: "" +imports: [recharts] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/bar_chart.tsx" +props: + - name: data + type: "Record[]" + required: true + description: "Array de datos" + - name: xKey + type: "string" + required: true + description: "Key del eje X/categoría" + - name: horizontal + type: "boolean" + required: false + description: "Orientación horizontal" + - name: series + type: "Series[]" + required: false + description: "Series de datos para multi-series" +emits: [] +has_state: false +framework: react +variant: [vertical, horizontal] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/charts/bar-chart.tsx" +--- + +## Ejemplo + +```tsx + + +``` diff --git a/frontend/functions/ui/bar_chart.tsx b/frontend/functions/ui/bar_chart.tsx new file mode 100644 index 00000000..2a37f122 --- /dev/null +++ b/frontend/functions/ui/bar_chart.tsx @@ -0,0 +1,53 @@ +import { + BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, +} from 'recharts' +import { ChartContainer, ChartTooltipContent, type Series, getSeriesColor } from './chart_container' + +interface BarChartProps { + data: Record[] + xKey: string + yKey?: string + series?: Series[] + horizontal?: boolean + showGrid?: boolean + showLegend?: boolean + height?: number | string + className?: string + xAxisFormatter?: (value: unknown) => string + yAxisFormatter?: (value: unknown) => string + valueFormatter?: (value: number) => string +} + +function BarChartComponent({ + data, xKey, yKey, series, horizontal = false, showGrid = true, showLegend = false, + height = 300, className, xAxisFormatter, yAxisFormatter, valueFormatter = (v) => v.toLocaleString(), +}: BarChartProps) { + const bars = series + ? series.map((s, i) => ({ dataKey: s.key, name: s.name, fill: getSeriesColor(i, s.color) })) + : yKey ? [{ dataKey: yKey, name: yKey, fill: getSeriesColor(0) }] : [] + + return ( + + + {showGrid && } + {horizontal ? ( + <> + + + + ) : ( + <> + + + + )} + } cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} /> + {showLegend && } + {bars.map((bar) => )} + + + ) +} + +export const BarChart = BarChartComponent +export type { BarChartProps } diff --git a/frontend/functions/ui/button.md b/frontend/functions/ui/button.md new file mode 100644 index 00000000..a4ffada6 --- /dev/null +++ b/frontend/functions/ui/button.md @@ -0,0 +1,53 @@ +--- +name: button +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "Button(props: ButtonProps & VariantProps): JSX.Element" +description: "Botón accesible con 6 variantes (default, outline, secondary, ghost, destructive, link) y 8 tamaños. Base-UI primitivo con CVA." +tags: [button, component, ui, interactive, cva] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["@base-ui/react", "class-variance-authority"] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/button.tsx" +props: + - name: variant + type: "'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'" + required: false + description: "Estilo visual del botón" + - name: size + type: "'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'" + required: false + description: "Tamaño del botón" + - name: className + type: "string" + required: false + description: "Clases CSS adicionales" +emits: [onClick] +has_state: false +framework: react +variant: [default, outline, secondary, ghost, destructive, link] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/button.tsx" +--- + +## Ejemplo + +```tsx + + + +``` + +## Notas + +Componente base del sistema. Usa Base-UI Button primitive para accesibilidad completa (keyboard, ARIA). CVA para gestión type-safe de variantes. diff --git a/frontend/functions/ui/button.tsx b/frontend/functions/ui/button.tsx new file mode 100644 index 00000000..74869947 --- /dev/null +++ b/frontend/functions/ui/button.tsx @@ -0,0 +1,52 @@ +"use client" + +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "../core/cn" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem]", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-8", + "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)]", + "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)]", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/functions/ui/card.md b/frontend/functions/ui/card.md new file mode 100644 index 00000000..7de9a260 --- /dev/null +++ b/frontend/functions/ui/card.md @@ -0,0 +1,54 @@ +--- +name: card +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "Card(props: { size?: 'default' | 'sm'; className?: string; children: ReactNode }): JSX.Element" +description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable." +tags: [card, container, layout, component, ui] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["react"] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/card.tsx" +props: + - name: size + type: "'default' | 'sm'" + required: false + description: "Tamaño del card" + - name: className + type: "string" + required: false + description: "Clases CSS adicionales" +emits: [] +has_state: false +framework: react +variant: [default, sm] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/card.tsx" +--- + +## Ejemplo + +```tsx + + + Título + Descripción + + Contenido + Footer + +``` + +## Notas + +Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables. diff --git a/frontend/functions/ui/card.tsx b/frontend/functions/ui/card.tsx new file mode 100644 index 00000000..7f5a24df --- /dev/null +++ b/frontend/functions/ui/card.tsx @@ -0,0 +1,85 @@ +import * as React from "react" +import { cn } from "../core/cn" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } diff --git a/frontend/functions/ui/chart_container.md b/frontend/functions/ui/chart_container.md new file mode 100644 index 00000000..7f163c01 --- /dev/null +++ b/frontend/functions/ui/chart_container.md @@ -0,0 +1,49 @@ +--- +name: chart_container +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "ChartContainer(props: { children: ReactNode; height?: number | string }): JSX.Element" +description: "Base para todos los charts Recharts: container responsive, tooltip temático, legend y utilidades de colores por serie." +tags: [chart, container, recharts, base, visualization, component, ui] +uses_functions: [cn_typescript_core, get_series_color_typescript_core] +uses_types: [ChartSeries_typescript_ui] +returns: [] +returns_optional: false +error_type: "" +imports: [recharts, react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/chart_container.tsx" +props: + - name: height + type: "number | string" + required: false + description: "Altura del chart (default 300)" + - name: className + type: "string" + required: false + description: "Clases CSS adicionales" +emits: [] +has_state: false +framework: react +variant: [default] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/charts/chart-base.tsx" +--- + +## Ejemplo + +```tsx + + ... + +``` + +## Notas + +Exporta: ChartContainer, ChartTooltipContent, ChartTooltip, ChartLegend, chartColors, defaultColors, getSeriesColor, Series. diff --git a/frontend/functions/ui/chart_container.tsx b/frontend/functions/ui/chart_container.tsx new file mode 100644 index 00000000..040a83ca --- /dev/null +++ b/frontend/functions/ui/chart_container.tsx @@ -0,0 +1,80 @@ +import * as React from 'react' +import { cn } from '../core/cn' +import { ResponsiveContainer, Tooltip as RechartsTooltip, Legend as RechartsLegend } from 'recharts' + +export const chartColors = [ + 'hsl(var(--chart-1, 220 70% 50%))', + 'hsl(var(--chart-2, 160 60% 45%))', + 'hsl(var(--chart-3, 30 80% 55%))', + 'hsl(var(--chart-4, 280 65% 60%))', + 'hsl(var(--chart-5, 340 75% 55%))', +] + +export const defaultColors = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899'] + +export interface Series { + key: string + name: string + color?: string +} + +export function getSeriesColor(index: number, color?: string): string { + return color || defaultColors[index % defaultColors.length] +} + +interface ChartContainerProps { + children: React.ReactNode + className?: string + height?: number | string +} + +export function ChartContainer({ children, className, height = 300 }: ChartContainerProps) { + return ( +
+ + {children as React.ReactElement} + +
+ ) +} + +interface ChartTooltipContentProps { + active?: boolean + payload?: Array<{ name: string; value: number; color: string; dataKey: string }> + label?: string + labelFormatter?: (label: string) => string + valueFormatter?: (value: number) => string +} + +export function ChartTooltipContent({ + active, payload, label, + labelFormatter = (l) => l, + valueFormatter = (v) => v.toLocaleString(), +}: ChartTooltipContentProps) { + if (!active || !payload?.length) return null + return ( +
+

{labelFormatter(label || '')}

+
+ {payload.map((entry, index) => ( +
+
+ {entry.name}: + {valueFormatter(entry.value)} +
+ ))} +
+
+ ) +} + +export function ChartTooltip(props: React.ComponentProps) { + return } cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} {...props} /> +} + +export function ChartLegend(props: React.ComponentProps) { + return +} diff --git a/frontend/functions/ui/crud_page.md b/frontend/functions/ui/crud_page.md new file mode 100644 index 00000000..9f139b00 --- /dev/null +++ b/frontend/functions/ui/crud_page.md @@ -0,0 +1,51 @@ +--- +name: crud_page +kind: function +lang: typescript +domain: ui +version: "1.0.0" +purity: pure +signature: "crudPage(props: CrudPageProps): ReactElement" +description: "Genera una página CRUD completa con header, tabla con columnas configurables, botones de acción (add/edit/delete) y schema de formulario." +tags: [crud, page, table, form, factory, composition, ui] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/crud_page.tsx" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "" +--- + +## Ejemplo + +```tsx +crudPage({ + title: 'Users', + subtitle: 'Manage system users', + data: users, + fields: [ + { key: 'name', label: 'Name', type: 'text', required: true }, + { key: 'email', label: 'Email', type: 'email', required: true }, + { key: 'role', label: 'Role', type: 'select', options: [{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }] }, + ], + columns: [ + { key: 'name', label: 'Name' }, + { key: 'email', label: 'Email' }, + { key: 'role', label: 'Role', render: (v) => {v} }, + ], + onAdd: handleAdd, + onEdit: handleEdit, + onDelete: handleDelete, +}) +``` + +## Notas + +El schema de campos se almacena como data attribute para que un agente pueda leerlo y generar el formulario de diálogo correspondiente. La tabla incluye sorting visual implícito por columnas. diff --git a/frontend/functions/ui/crud_page.tsx b/frontend/functions/ui/crud_page.tsx new file mode 100644 index 00000000..effa6a24 --- /dev/null +++ b/frontend/functions/ui/crud_page.tsx @@ -0,0 +1,120 @@ +import * as React from 'react' +import { cn } from '../core/cn' + +interface CrudField { + key: string + label: string + type: 'text' | 'number' | 'email' | 'select' | 'textarea' + required?: boolean + options?: Array<{ label: string; value: string }> + placeholder?: string +} + +interface CrudPageProps> { + title: string + subtitle?: string + data: T[] + fields: CrudField[] + columns: Array<{ + key: keyof T + label: string + render?: (value: unknown, row: T) => React.ReactNode + }> + onAdd?: (item: Partial) => void + onEdit?: (item: T) => void + onDelete?: (item: T) => void + actions?: React.ReactNode + className?: string +} + +export function crudPage>({ + title, + subtitle, + data, + fields, + columns, + onAdd, + onEdit, + onDelete, + actions, + className, +}: CrudPageProps): React.ReactElement { + return ( +
+ {/* Header */} +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {actions} + {onAdd && ( + + )} +
+
+ + {/* Table */} +
+ + + + {columns.map((col) => ( + + ))} + {(onEdit || onDelete) && ( + + )} + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((row, i) => ( + + {columns.map((col) => ( + + ))} + {(onEdit || onDelete) && ( + + )} + + )) + )} + +
+ {col.label} + Actions
+ No items yet. +
+ {col.render ? col.render(row[col.key], row) : String(row[col.key] ?? '')} + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+
+ + {/* Form fields definition (for agent use — renders a form preview) */} +
+
+ ) +} + +export type { CrudPageProps, CrudField } diff --git a/frontend/functions/ui/dashboard_layout.md b/frontend/functions/ui/dashboard_layout.md new file mode 100644 index 00000000..e4b87676 --- /dev/null +++ b/frontend/functions/ui/dashboard_layout.md @@ -0,0 +1,42 @@ +--- +name: dashboard_layout +kind: function +lang: typescript +domain: ui +version: "1.0.0" +purity: pure +signature: "dashboardLayout(props: DashboardLayoutProps): ReactElement" +description: "Genera un grid responsive de dashboard a partir de un array de widgets con span configurable. 1-4 columnas con auto-responsive." +tags: [dashboard, layout, grid, factory, composition, ui] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/dashboard_layout.tsx" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "" +--- + +## Ejemplo + +```tsx +dashboardLayout({ + columns: 4, + widgets: [ + { id: 'revenue', title: 'Revenue', content: }, + { id: 'users', title: 'Users', content: }, + { id: 'chart', title: 'Trends', span: 2, content: }, + { id: 'table', span: 4, content: }, + ] +}) +``` + +## Notas + +Factory pura — dado el mismo input siempre genera el mismo JSX. Un agente puede construir dashboards completos pasando widgets como configuración declarativa. diff --git a/frontend/functions/ui/dashboard_layout.tsx b/frontend/functions/ui/dashboard_layout.tsx new file mode 100644 index 00000000..a3d3b89e --- /dev/null +++ b/frontend/functions/ui/dashboard_layout.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { cn } from '../core/cn' + +interface DashboardWidget { + id: string + title?: string + span?: 1 | 2 | 3 | 4 + rowSpan?: 1 | 2 + content: React.ReactNode +} + +interface DashboardLayoutProps { + widgets: DashboardWidget[] + columns?: 1 | 2 | 3 | 4 + gap?: 'sm' | 'md' | 'lg' + className?: string +} + +const gapClasses = { sm: 'gap-2', md: 'gap-4', lg: 'gap-6' } + +const spanClasses: Record = { + 1: 'col-span-1', + 2: 'col-span-1 md:col-span-2', + 3: 'col-span-1 md:col-span-2 lg:col-span-3', + 4: 'col-span-1 md:col-span-2 lg:col-span-4', +} + +const rowSpanClasses: Record = { + 1: 'row-span-1', + 2: 'row-span-2', +} + +export function dashboardLayout({ + widgets, + columns = 4, + gap = 'md', + className, +}: DashboardLayoutProps): React.ReactElement { + const gridCols: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-1 md:grid-cols-2', + 3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', + 4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4', + } + + return ( +
+ {widgets.map((widget) => ( +
+ {widget.title && ( +

{widget.title}

+ )} + {widget.content} +
+ ))} +
+ ) +} + +export type { DashboardWidget, DashboardLayoutProps } diff --git a/frontend/functions/ui/detail_page.md b/frontend/functions/ui/detail_page.md new file mode 100644 index 00000000..3eb5c5bb --- /dev/null +++ b/frontend/functions/ui/detail_page.md @@ -0,0 +1,54 @@ +--- +name: detail_page +kind: function +lang: typescript +domain: ui +version: "1.0.0" +purity: pure +signature: "detailPage(props: DetailPageProps): ReactElement" +description: "Genera una página de detalle de entidad con header (avatar, badge, back), grid de campos, tabs con contadores y timeline de actividad." +tags: [detail, page, entity, timeline, factory, composition, ui] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/detail_page.tsx" +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "" +--- + +## Ejemplo + +```tsx +detailPage({ + title: 'John Doe', + subtitle: 'john@example.com', + badge: Active, + onBack: () => router.back(), + fields: [ + { label: 'Role', value: 'Administrator' }, + { label: 'Created', value: 'Mar 15, 2026' }, + { label: 'Bio', value: 'Full stack developer...', span: 2 }, + ], + tabs: [ + { label: 'Projects', value: 'projects', count: 12, content: }, + { label: 'Activity', value: 'activity', count: 48, content: }, + ], + activeTab: 'projects', + timeline: [ + { id: '1', title: 'Deployed v2.1', timestamp: '2 hours ago', variant: 'success' }, + { id: '2', title: 'Updated settings', timestamp: 'Yesterday' }, + { id: '3', title: 'Created project', timestamp: 'Mar 10, 2026' }, + ], +}) +``` + +## Notas + +Factory completa para páginas de detalle. Combina header con back/avatar/badge, grid de metadata, tabs con badges de conteo, y timeline de actividad con variantes de color semántico. diff --git a/frontend/functions/ui/detail_page.tsx b/frontend/functions/ui/detail_page.tsx new file mode 100644 index 00000000..86bae0d4 --- /dev/null +++ b/frontend/functions/ui/detail_page.tsx @@ -0,0 +1,134 @@ +import * as React from 'react' +import { cn } from '../core/cn' + +interface DetailField { + label: string + value: React.ReactNode + span?: 1 | 2 +} + +interface DetailTab { + label: string + value: string + content: React.ReactNode + count?: number +} + +interface TimelineEvent { + id: string + title: string + description?: string + timestamp: string + icon?: React.ReactNode + variant?: 'default' | 'success' | 'warning' | 'error' +} + +interface DetailPageProps { + title: string + subtitle?: string + badge?: React.ReactNode + avatar?: React.ReactNode + actions?: React.ReactNode + onBack?: () => void + fields: DetailField[] + tabs?: DetailTab[] + activeTab?: string + onTabChange?: (value: string) => void + timeline?: TimelineEvent[] + className?: string +} + +const variantDotColors = { + default: 'bg-primary', + success: 'bg-green-500', + warning: 'bg-amber-500', + error: 'bg-red-500', +} + +export function detailPage({ + title, subtitle, badge, avatar, actions, onBack, + fields, tabs, activeTab, onTabChange, timeline, className, +}: DetailPageProps): React.ReactElement { + return ( +
+ {/* Header */} +
+
+ {onBack && ( + + )} + {avatar &&
{avatar}
} +
+
+

{title}

+ {badge} +
+ {subtitle &&

{subtitle}

} +
+
+ {actions &&
{actions}
} +
+ + {/* Fields grid */} +
+ {fields.map((field, i) => ( +
+

{field.label}

+
{field.value}
+
+ ))} +
+ + {/* Tabs */} + {tabs && tabs.length > 0 && ( +
+ + {tabs.find(t => t.value === activeTab)?.content} +
+ )} + + {/* Timeline */} + {timeline && timeline.length > 0 && ( +
+

Activity

+
+ {timeline.map((event, i) => ( +
+
+
+ {i < timeline.length - 1 &&
} +
+
+

{event.title}

+ {event.description &&

{event.description}

} +

{event.timestamp}

+
+
+ ))} +
+
+ )} +
+ ) +} + +export type { DetailPageProps, DetailField, DetailTab, TimelineEvent } diff --git a/frontend/functions/ui/dialog.md b/frontend/functions/ui/dialog.md new file mode 100644 index 00000000..f4cf5991 --- /dev/null +++ b/frontend/functions/ui/dialog.md @@ -0,0 +1,55 @@ +--- +name: dialog +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "Dialog(props: DialogRootProps): JSX.Element" +description: "Diálogo modal accesible con overlay blur, animaciones, close button y sistema de slots (header, footer, title, description)." +tags: [dialog, modal, overlay, component, ui, interactive] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["@base-ui/react", lucide-react, react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/dialog.tsx" +props: + - name: showCloseButton + type: "boolean" + required: false + description: "Mostrar botón de cerrar (default true)" +emits: [onOpenChange] +has_state: true +framework: react +variant: [default] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/dialog.tsx" +--- + +## Ejemplo + +```tsx + + + + + Título + Descripción + +

Contenido

+ + + +
+
+``` + +## Notas + +10 subcomponentes exportados. Base-UI Dialog primitive para accesibilidad completa (focus trap, escape, click outside). diff --git a/frontend/functions/ui/dialog.tsx b/frontend/functions/ui/dialog.tsx new file mode 100644 index 00000000..77c7d8fa --- /dev/null +++ b/frontend/functions/ui/dialog.tsx @@ -0,0 +1,73 @@ +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import { cn } from "../core/cn" +import { XIcon } from "lucide-react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ className, children, showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return +} + +export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } diff --git a/frontend/functions/ui/form_field.md b/frontend/functions/ui/form_field.md new file mode 100644 index 00000000..81035466 --- /dev/null +++ b/frontend/functions/ui/form_field.md @@ -0,0 +1,53 @@ +--- +name: form_field +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "FormField(props: FormFieldProps): JSX.Element" +description: "Wrapper de campo de formulario con label, helper text, error y ARIA automáticos. Inyecta id y aria-describedby a hijos." +tags: [form, field, label, error, component, ui, accessibility] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/form_field.tsx" +props: + - name: label + type: "string" + required: false + description: "Texto del label" + - name: helperText + type: "string" + required: false + description: "Texto de ayuda" + - name: error + type: "string" + required: false + description: "Mensaje de error (reemplaza helperText)" + - name: children + type: "ReactNode" + required: true + description: "Input o componente de formulario" +emits: [] +has_state: false +framework: react +variant: [default] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/form-field.tsx" +--- + +## Ejemplo + +```tsx + + + +``` diff --git a/frontend/functions/ui/form_field.tsx b/frontend/functions/ui/form_field.tsx new file mode 100644 index 00000000..d238aa43 --- /dev/null +++ b/frontend/functions/ui/form_field.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import { cn } from "../core/cn" + +interface FormFieldProps { + label?: string + helperText?: string + error?: string + children: React.ReactNode + className?: string +} + +function FormField({ label, helperText, error, children, className }: FormFieldProps) { + const id = React.useId() + const inputId = `${id}-input` + const helperId = `${id}-helper` + const errorId = `${id}-error` + + const describedBy = [helperText ? helperId : null, error ? errorId : null].filter(Boolean).join(" ") || undefined + + const childWithProps = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child as React.ReactElement>, { + id: inputId, + "aria-invalid": error ? true : undefined, + "aria-describedby": describedBy, + }) + } + return child + }) + + return ( +
+ {label && } + {childWithProps} + {helperText && !error &&

{helperText}

} + {error &&

{error}

} +
+ ) +} + +export { FormField } +export type { FormFieldProps } diff --git a/frontend/functions/ui/input.md b/frontend/functions/ui/input.md new file mode 100644 index 00000000..10dbe0f9 --- /dev/null +++ b/frontend/functions/ui/input.md @@ -0,0 +1,51 @@ +--- +name: input +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "Input(props: InputHTMLAttributes): JSX.Element" +description: "Campo de entrada accesible con soporte para iconos, grupos, validación ARIA y estados disabled/invalid." +tags: [input, form, component, ui, interactive] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["@base-ui/react", "react"] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/input.tsx" +props: + - name: type + type: "string" + required: false + description: "Tipo de input HTML" + - name: className + type: "string" + required: false + description: "Clases CSS adicionales" +emits: [onChange, onFocus, onBlur] +has_state: false +framework: react +variant: [default] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/input.tsx" +--- + +## Ejemplo + +```tsx + + + + + +``` + +## Notas + +Exporta Input, InputGroup e InputIcon. InputGroup detecta automáticamente la presencia de iconos y ajusta padding del Input. diff --git a/frontend/functions/ui/input.tsx b/frontend/functions/ui/input.tsx new file mode 100644 index 00000000..e5168797 --- /dev/null +++ b/frontend/functions/ui/input.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" +import { cn } from "../core/cn" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +interface InputGroupProps { + children: React.ReactNode + className?: string +} + +function InputGroup({ children, className }: InputGroupProps) { + return ( +
+ {children} +
+ ) +} + +interface InputIconProps { + children: React.ReactNode + position: "start" | "end" + className?: string +} + +function InputIcon({ children, position, className }: InputIconProps) { + return ( + + {children} + + ) +} + +export { Input, InputGroup, InputIcon } diff --git a/frontend/functions/ui/kpi_card.md b/frontend/functions/ui/kpi_card.md new file mode 100644 index 00000000..b22b2965 --- /dev/null +++ b/frontend/functions/ui/kpi_card.md @@ -0,0 +1,56 @@ +--- +name: kpi_card +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "KPICard(props: KPICardProps): JSX.Element" +description: "Card de KPI con label, valor, delta porcentual con color semántico, icono y subtítulo. 3 tamaños." +tags: [kpi, card, metrics, dashboard, component, ui] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/kpi_card.tsx" +props: + - name: label + type: "string" + required: true + description: "Etiqueta del KPI" + - name: value + type: "string | number" + required: true + description: "Valor principal" + - name: delta + type: "{ value: number; isPositive: boolean }" + required: false + description: "Cambio porcentual con dirección" + - name: icon + type: "ReactNode" + required: false + description: "Icono decorativo" + - name: size + type: "'sm' | 'default' | 'lg'" + required: false + description: "Tamaño" +emits: [] +has_state: false +framework: react +variant: [sm, default, lg] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/kpi-card.tsx" +--- + +## Ejemplo + +```tsx + +} /> +``` diff --git a/frontend/functions/ui/kpi_card.tsx b/frontend/functions/ui/kpi_card.tsx new file mode 100644 index 00000000..d45a50b8 --- /dev/null +++ b/frontend/functions/ui/kpi_card.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import { cn } from '../core/cn' + +type KPICardSize = 'sm' | 'default' | 'lg' + +interface Delta { + value: number + isPositive: boolean +} + +interface KPICardProps extends React.HTMLAttributes { + label: string + value: string | number + delta?: Delta + icon?: React.ReactNode + subtitle?: string + size?: KPICardSize +} + +const sizeStyles: Record = { + sm: { value: 'text-2xl font-bold', label: 'text-xs' }, + default: { value: 'text-3xl font-bold', label: 'text-sm' }, + lg: { value: 'text-4xl font-bold', label: 'text-base' }, +} + +const KPICard = React.forwardRef( + ({ label, value, delta, icon, subtitle, size = 'default', className, ...props }, ref) => { + const styles = sizeStyles[size] + const deltaColor = delta + ? delta.value === 0 ? 'text-muted-foreground' + : delta.isPositive ? 'text-green-600 dark:text-green-500' + : 'text-red-600 dark:text-red-500' + : '' + + return ( +
+
+
+

{label}

+ {subtitle &&

{subtitle}

} +
+ {icon &&
{icon}
} +
+
+
+

{value}

+ {delta && ( +
+ {delta.value > 0 ? '+' : ''}{delta.value}% +
+ )} +
+
+
+ ) + } +) +KPICard.displayName = 'KPICard' + +export { KPICard, type KPICardProps, type Delta, type KPICardSize } diff --git a/frontend/functions/ui/label.md b/frontend/functions/ui/label.md new file mode 100644 index 00000000..73b31c7c --- /dev/null +++ b/frontend/functions/ui/label.md @@ -0,0 +1,39 @@ +--- +name: label +kind: component +lang: ts +domain: ui +version: "1.0.0" +purity: impure +signature: "Label(props: LabelHTMLAttributes): JSX.Element" +description: "Etiqueta de formulario accesible con soporte para estados disabled y peer-disabled." +tags: [label, form, component, ui] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["react"] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/label.tsx" +props: + - name: className + type: "string" + required: false + description: "Clases CSS adicionales" +emits: [] +has_state: false +framework: react +variant: [default] +source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" +source_license: "MIT" +source_file: "frontend/src/components/ui/label.tsx" +--- + +## Ejemplo + +```tsx + +``` diff --git a/frontend/functions/ui/label.tsx b/frontend/functions/ui/label.tsx new file mode 100644 index 00000000..56bb58a6 --- /dev/null +++ b/frontend/functions/ui/label.tsx @@ -0,0 +1,17 @@ +import * as React from "react" +import { cn } from "../core/cn" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +