Existing tags.json files are unchanged; only new installs and "Restore default tags" get the shorter list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
232 lines
5.5 KiB
Go
232 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Tag highlights a keyword in displayed messages.
|
|
type Tag struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"` // two digits, shown on matching log lines
|
|
Pattern string `json:"pattern"`
|
|
Label string `json:"label,omitempty"` // shown instead of the pattern (presets)
|
|
Color string `json:"color"`
|
|
WholeWord bool `json:"wholeWord"`
|
|
CaseSensitive bool `json:"caseSensitive"`
|
|
Regex bool `json:"regex"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
func defaultTags() []Tag {
|
|
return []Tag{
|
|
{ID: "warning", Code: "01", Pattern: "warning", Color: "#fdba74", WholeWord: true, Enabled: true},
|
|
{ID: "error", Code: "02", Pattern: "error", Color: "#fca5a5", WholeWord: true, Enabled: true},
|
|
}
|
|
}
|
|
|
|
var codeRe = regexp.MustCompile(`^[0-9]{2}$`)
|
|
|
|
// freeCode returns the lowest code from 01 to 99 not used by tags, or "" when
|
|
// all are taken. A code stays with its tag until the tag is deleted.
|
|
func freeCode(tags []Tag) string {
|
|
used := map[string]bool{}
|
|
for _, t := range tags {
|
|
used[t.Code] = true
|
|
}
|
|
for n := 1; n <= 99; n++ {
|
|
if c := fmt.Sprintf("%02d", n); !used[c] {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// assignCodes gives a code to the tags that have none (files written before
|
|
// codes existed) or share one with an earlier tag.
|
|
func assignCodes(tags []Tag) bool {
|
|
changed := false
|
|
seen := map[string]bool{}
|
|
for i := range tags {
|
|
if codeRe.MatchString(tags[i].Code) && !seen[tags[i].Code] {
|
|
seen[tags[i].Code] = true
|
|
continue
|
|
}
|
|
tags[i].Code = ""
|
|
tags[i].Code = freeCode(tags)
|
|
seen[tags[i].Code] = true
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// Colors of the default tags in earlier versions: still unchanged, they are
|
|
// switched to the new pastel defaults when the file is loaded.
|
|
var oldDefaultColors = map[string]string{"warning": "#f59e0b", "error": "#ef4444", "ok": "#22c55e"}
|
|
|
|
func migrateDefaultColors(tags []Tag) bool {
|
|
newColors := map[string]string{}
|
|
for _, t := range defaultTags() {
|
|
newColors[t.ID] = t.Color
|
|
}
|
|
changed := false
|
|
for i := range tags {
|
|
if old, ok := oldDefaultColors[tags[i].ID]; ok && strings.EqualFold(tags[i].Color, old) {
|
|
tags[i].Color = newColors[tags[i].ID]
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// codedError carries a stable code the UI can translate.
|
|
type codedError struct {
|
|
code string
|
|
msg string
|
|
detail string
|
|
}
|
|
|
|
func (e *codedError) Error() string {
|
|
if e.detail != "" {
|
|
return e.msg + ": " + e.detail
|
|
}
|
|
return e.msg
|
|
}
|
|
|
|
var (
|
|
errTagNotFound = &codedError{code: "tag_not_found", msg: "tag not found"}
|
|
errTooManyTags = &codedError{code: "too_many_tags", msg: "too many tags (99 at most)"}
|
|
colorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
)
|
|
|
|
func (t *Tag) validate() error {
|
|
t.Pattern = strings.TrimSpace(t.Pattern)
|
|
t.Label = strings.TrimSpace(t.Label)
|
|
if t.Pattern == "" {
|
|
return &codedError{code: "pattern_required", msg: "the keyword is required"}
|
|
}
|
|
if !colorRe.MatchString(t.Color) {
|
|
return &codedError{code: "invalid_color", msg: "invalid color (expected #rrggbb)"}
|
|
}
|
|
if t.Regex {
|
|
if _, err := regexp.Compile(t.Pattern); err != nil {
|
|
return &codedError{code: "invalid_regex", msg: "invalid regular expression", detail: err.Error()}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TagStore keeps the tags in a JSON file (in the /data volume).
|
|
type TagStore struct {
|
|
mu sync.Mutex
|
|
path string
|
|
tags []Tag
|
|
}
|
|
|
|
func LoadTagStore(path string) (*TagStore, error) {
|
|
s := &TagStore{path: path}
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
s.tags = defaultTags()
|
|
return s, s.save()
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(b, &s.tags); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
if c1, c2 := migrateDefaultColors(s.tags), assignCodes(s.tags); c1 || c2 {
|
|
if err := s.save(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// save writes the file atomically; the caller holds the lock.
|
|
func (s *TagStore) save() error {
|
|
b, err := json.MarshalIndent(s.tags, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func (s *TagStore) List() []Tag {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]Tag(nil), s.tags...)
|
|
}
|
|
|
|
func (s *TagStore) Create(t Tag) (Tag, error) {
|
|
if err := t.validate(); err != nil {
|
|
return t, err
|
|
}
|
|
t.ID = newID()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if t.Code = freeCode(s.tags); t.Code == "" {
|
|
return t, errTooManyTags
|
|
}
|
|
s.tags = append(s.tags, t)
|
|
return t, s.save()
|
|
}
|
|
|
|
func (s *TagStore) Update(id string, t Tag) (Tag, error) {
|
|
if err := t.validate(); err != nil {
|
|
return t, err
|
|
}
|
|
t.ID = id
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.tags {
|
|
if s.tags[i].ID == id {
|
|
t.Code = s.tags[i].Code // assigned by the server, never changed
|
|
s.tags[i] = t
|
|
return t, s.save()
|
|
}
|
|
}
|
|
return t, errTagNotFound
|
|
}
|
|
|
|
func (s *TagStore) Delete(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.tags {
|
|
if s.tags[i].ID == id {
|
|
s.tags = append(s.tags[:i], s.tags[i+1:]...)
|
|
return s.save()
|
|
}
|
|
}
|
|
return errTagNotFound
|
|
}
|
|
|
|
func (s *TagStore) Reset() ([]Tag, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.tags = defaultTags()
|
|
return append([]Tag(nil), s.tags...), s.save()
|
|
}
|
|
|
|
func newID() string {
|
|
b := make([]byte, 6)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|