motd-assist/internal/motd/sanitize.go
Tronax d56659e21a
feat: add motd-assist TUI for managing dynamic MOTDs
Initial Go project built with Bubble Tea and Lip Gloss:
- Live preview and management of /etc/update-motd.d scripts
- Embedded presets (ubuntu-default, minimal, server-dashboard)
- Bundled POSIX sh style scripts (brand header, sysinfo panel, footer)
- Snapshot/restore safety around applying changes
- CLI subcommands preview, apply, restore, list-presets
- Unit tests for detect, sanitize, and apply logic
2026-08-24 14:19:28 +02:00

120 lines
2.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package motd
import (
"strings"
"github.com/mattn/go-runewidth"
)
type escapeKind int
const (
escNone escapeKind = iota
escSGR
)
// parseEscape klassifiziert eine Escape-Sequenz ab runes[0] == ESC und
// liefert die konsumierte Rune-Anzahl. Nur SGR (Farben) ist relevant.
func parseEscape(r []rune) (string, int, escapeKind) {
if len(r) < 2 {
return "", len(r), escNone
}
switch r[1] {
case '[': // CSI: Parameter (0x300x3F), Zwischenzeichen (0x200x2F), Final
j := 2
for j < len(r) && r[j] >= 0x30 && r[j] <= 0x3f {
j++
}
for j < len(r) && r[j] >= 0x20 && r[j] <= 0x2f {
j++
}
if j >= len(r) {
return "", len(r), escNone // unvollständig
}
if r[j] == 'm' {
return string(r[:j+1]), j + 1, escSGR
}
return "", j + 1, escNone
case ']', 'P', 'X', '^', '_': // OSC/DCS/SOS/PM/APC: bis BEL oder ST
for j := 2; j < len(r); j++ {
if r[j] == 0x07 {
return "", j + 1, escNone
}
if r[j] == '\x1b' && j+1 < len(r) && r[j+1] == '\\' {
return "", j + 2, escNone
}
}
return "", len(r), escNone
case '(', ')', '*', '+', '#', '%': // Charset-/Zeichensatz-Wechsel
if len(r) >= 3 {
return "", 3, escNone
}
return "", len(r), escNone
default:
return "", 2, escNone
}
}
// Sanitize bereinigt Script-Output für die Anzeige in der TUI: behalten
// werden SGR-Farbsequenzen und Zeilenumbrüche; Cursor-Steuerung, OSC,
// Charset-Switches und andere Steuerzeichen werden entfernt. Jede Zeile
// wird auf width Spellen begrenzt und bei Farbnutzung mit Reset
// abgeschlossen, damit die Pane-Umrandung nicht verfärbt wird.
func Sanitize(s string, width int) string {
if width <= 0 {
width = 80
}
var b strings.Builder
b.Grow(len(s))
lineW, hasSGR := 0, false
runes := []rune(s)
for i := 0; i < len(runes); i++ {
r := runes[i]
switch {
case r == '\x1b':
seq, n, kind := parseEscape(runes[i:])
i += n - 1
if kind == escSGR {
b.WriteString(seq)
hasSGR = true
}
case r == '\n':
if hasSGR && !endsWithReset(&b) {
b.WriteString("\x1b[0m")
}
b.WriteByte('\n')
lineW, hasSGR = 0, false
case r == '\r', r == '\a', r == '\b', r == '\v', r == '\f':
// ignorieren
case r == '\t':
for k := 0; k < 4 && lineW < width; k++ {
b.WriteByte(' ')
lineW++
}
case r < 0x20 || r == 0x7f:
// übrige Steuerzeichen entfernen
default:
if lineW >= width {
continue // Zeile abgeschnitten
}
w := runewidth.RuneWidth(r)
if lineW+w > width {
lineW = width // breites Zeichen passt nicht mehr → Schnittstelle
continue
}
b.WriteRune(r)
lineW += w
}
}
out := b.String()
if hasSGR && !strings.HasSuffix(out, "\x1b[0m") {
out += "\x1b[0m" // letzte Zeile ohne Newline: offene Farbe schließen
}
return strings.TrimRight(out, "\n")
}
// endsWithReset prüft, ob der Builder bereits auf Farbe zurückgesetzt ist.
func endsWithReset(b *strings.Builder) bool {
return strings.HasSuffix(b.String(), "\x1b[0m")
}