denotesrv/pkg/metadata/metadata.go

170 lines
4.6 KiB
Go

package metadata
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
)
// Metadata is the metadata encoded into Denote-style
// file names.
type Metadata struct {
Path string
Identifier string
Signature string
Title string
Tags []string
}
type Results []*Metadata
type SortBy string
const (
SortById SortBy = "id"
SortByDate SortBy = "date"
SortByTitle SortBy = "title"
)
type SortOrder int
const (
SortOrderAsc SortOrder = iota
SortOrderDesc
)
// Sort organizes a list of notes by sortType and order using metadata.
func Sort(md Results, sortType SortBy, order SortOrder) {
switch sortType {
case SortById, SortByDate:
sort.Slice(md, func(i, j int) bool {
if order == SortOrderAsc {
return md[i].Identifier < md[j].Identifier // Reverse chronological by default
} else {
return md[i].Identifier > md[j].Identifier // Reverse chronological by default
}
})
case SortByTitle:
sort.Slice(md, func(i, j int) bool {
if order == SortOrderAsc {
return strings.ToLower(md[i].Title) < strings.ToLower(md[j].Title)
} else {
return strings.ToLower(md[i].Title) > strings.ToLower(md[j].Title)
}
})
default:
sort.Slice(md, func(i, j int) bool {
return md[i].Identifier > md[j].Identifier // Reverse chronological by default
})
}
}
// ParseFilename extracts Denote metadata from a filename only (no file I/O).
// Returns metadata with Path, Identifier, Signature, Title (from filename), and Tags.
func ParseFilename(path string) *Metadata {
fname := filepath.Base(path)
note := &Metadata{Path: path}
if m := regexp.MustCompile(`^(\d{8}T\d{6})`).FindStringSubmatch(fname); m != nil {
note.Identifier = m[1]
}
// Extract signature (optional component between identifier and title)
if m := regexp.MustCompile(`==([^-]+?)--`).FindStringSubmatch(fname); m != nil {
note.Signature = m[1]
}
// Extract title from filename
if m := regexp.MustCompile(`--([^_\.]+)`).FindStringSubmatch(fname); m != nil {
note.Title = strings.ReplaceAll(m[1], "-", " ")
}
if m := regexp.MustCompile(`__(.+?)(?:\.|$)`).FindStringSubmatch(fname); m != nil {
note.Tags = strings.Split(m[1], "_")
}
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")
}
// slugifyTitle converts a title to a filesystem-safe slug.
func slugifyTitle(title string) string {
slug := strings.ToLower(title)
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
return regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(slug, "")
}
// slugifySignature converts a signature to Denote-compliant format.
// Following Denote rules: lowercase, spaces/underscores -> double equals,
// remove special chars, normalize consecutive equals to double equals.
func slugifySignature(sig string) string {
slug := strings.ToLower(sig)
slug = strings.ReplaceAll(slug, " ", "==")
slug = strings.ReplaceAll(slug, "_", "==")
// Remove special characters per Denote spec
slug = regexp.MustCompile(`[{}!@#$%^&*()+'"?,.\\|;:~\x60''""/-]`).ReplaceAllString(slug, "")
// Normalize consecutive equals signs (3 or more) to double equals
slug = regexp.MustCompile(`={3,}`).ReplaceAllString(slug, "==")
// Trim trailing equals
slug = strings.Trim(slug, "=")
return slug
}
// formatKeywords formats keywords for a denote filename.
func formatKeywords(keywords []string) string {
if len(keywords) == 0 {
return ""
}
return "__" + strings.Join(keywords, "_")
}
// formatSignature formats a signature for a denote filename.
// Returns empty string if signature is empty, otherwise returns ==signature.
func formatSignature(sig string) string {
if sig == "" {
return ""
}
return "==" + slugifySignature(sig)
}
// BuildFilename constructs a denote filename from metadata components.
func BuildFilename(fm *FrontMatter, ext string) string {
titleSlug := slugifyTitle(fm.Title)
signaturePart := formatSignature(fm.Signature)
keywordsPart := formatKeywords(fm.Tags)
return fmt.Sprintf("%s%s--%s%s%s", fm.Identifier, signaturePart, titleSlug, keywordsPart, ext)
}