Warn on invalid tags instead of exiting

- Add IsValidTag/ValidateTags to pkg/metadata/metadata.go
- LoadAll warns on invalid tags with acme-style path (path:1)
- Split results.Unmarshal: lenient (warns) vs UnmarshalStrict (errors)
- Put command uses strict validation, index reading uses lenient
- Program continues running with invalid tags instead of panicking
This commit is contained in:
Levi Neely 2026-03-02 09:13:42 +01:00
parent 464ce00cde
commit 3657805994
5 changed files with 55 additions and 10 deletions

View File

@ -5,6 +5,7 @@ import (
"denote/pkg/metadata"
"denote/pkg/util"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
@ -169,6 +170,10 @@ func LoadAll(dir string) (metadata.Results, error) {
// Only include files with valid identifiers
if note.Identifier != "" {
// Warn on invalid tags but continue loading
if invalid := metadata.ValidateTags(note.Tags); len(invalid) > 0 {
log.Printf("warning: %s:1 invalid tags %v (must be lowercase alphanumeric)", path, invalid)
}
notes = append(notes, note)
}

View File

@ -376,8 +376,8 @@ func main() {
break
}
// Parse the window content
updated, err := results.Unmarshal(body)
// Parse the window content with strict validation
updated, err := results.UnmarshalStrict(body)
if err != nil {
log.Printf("failed to parse window: %v", err)
break

View File

@ -3,6 +3,7 @@ package results
import (
"bytes"
"fmt"
"log"
"regexp"
"strings"
@ -27,7 +28,18 @@ func Marshal(rs metadata.Results) []byte {
// Unmarshal parses pipe-delimited byte data into Results.
// Format: identifier | title | tags (comma-separated)
// Invalid tags produce warnings but parsing continues.
func Unmarshal(data []byte) (metadata.Results, error) {
return unmarshal(data, false)
}
// UnmarshalStrict parses pipe-delimited byte data into Results with strict tag validation.
// Returns an error if any tags are invalid.
func UnmarshalStrict(data []byte) (metadata.Results, error) {
return unmarshal(data, true)
}
func unmarshal(data []byte, strict bool) (metadata.Results, error) {
var results metadata.Results
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
// Allow lowercase Latin letters, other letters (CJK, etc.), and digits, no spaces
@ -55,7 +67,10 @@ func Unmarshal(data []byte) (metadata.Results, error) {
var tags []string
if tagsStr != "" {
if !tagPattern.MatchString(tagsStr) {
return nil, fmt.Errorf("line %d: tags must be comma-delimited lowercase unicode words (no spaces): got '%s'", lineNum+1, tagsStr)
if strict {
return nil, fmt.Errorf("line %d: tags must be comma-delimited lowercase unicode words (no spaces): got '%s'", lineNum+1, tagsStr)
}
log.Printf("warning: line %d: invalid tags '%s' (must be lowercase alphanumeric)", lineNum+1, tagsStr)
}
tags = strings.Split(tagsStr, ",")
} else {

View File

@ -7,6 +7,7 @@ import (
"sort"
"strings"
"time"
"unicode"
)
// Metadata is the metadata encoded into Denote-style
@ -89,6 +90,30 @@ func ParseFilename(path string) *Metadata {
return note
}
// IsValidTag returns true if the tag contains only lowercase letters, other unicode letters, or digits.
func IsValidTag(tag string) bool {
for _, r := range tag {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return false
}
if unicode.IsLetter(r) && unicode.IsUpper(r) {
return false
}
}
return len(tag) > 0
}
// ValidateTags checks all tags and returns invalid ones.
func ValidateTags(tags []string) []string {
var invalid []string
for _, tag := range tags {
if !IsValidTag(tag) {
invalid = append(invalid, tag)
}
}
return invalid
}
// GenerateIdentifier creates a new identifier timestamp.
func GenerateIdentifier() string {
return time.Now().Format("20060102T150405")

View File

@ -40,7 +40,7 @@ if(! ~ $#* 0) {
}
# Calculate reference timestamp based on interval
epoch=`{date -n}
epoch=`{9 date -n}
# Adjust epoch to interval start if no offset provided
if(~ $#* 0) {
@ -48,7 +48,7 @@ if(~ $#* 0) {
case weekly
# Get start of week (Monday)
# date outputs: Sun Nov 24 07:30:00 EST 2025
dow=`{date $epoch | awk '{print $1}'}
dow=`{9 date $epoch | awk '{print $1}'}
# Calculate days since Monday (Mon=0, Tue=1, ..., Sun=6)
daysback=0
switch($dow) {
@ -64,20 +64,20 @@ if(~ $#* 0) {
case monthly
# Get start of month (day 1)
# Extract year, month from current date
yearmonth=`{date $epoch | awk '{print $6 " " $2}'}
yearmonth=`{9 date $epoch | awk '{print $6 " " $2}'}
# Use date with format: Nov 1 2025
epoch=`{date -n `{echo $yearmonth | awk '{print $2 " 1 " $1}'}}
epoch=`{9 date -n `{echo $yearmonth | awk '{print $2 " 1 " $1}'}}
case yearly
# Get start of year (Jan 1)
year=`{date $epoch | awk '{print $6}'}
epoch=`{date -n 'Jan 1 '^$year}
year=`{9 date $epoch | awk '{print $6}'}
epoch=`{9 date -n 'Jan 1 '^$year}
}
}
targetepoch=`{hoc -e 'int('$epoch' + '$offset')'}
# Get formatted date (e.g., "Wednesday 12 November 2025 22:11")
datefull=`{date $targetepoch | awk '
datefull=`{9 date $targetepoch | awk '
BEGIN {
m["Jan"]="January"; m["Feb"]="February"; m["Mar"]="March"
m["Apr"]="April"; m["May"]="May"; m["Jun"]="June"