170 lines
4.6 KiB
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)
|
|
}
|