initial commit
This commit is contained in:
361
README.md
Normal file
361
README.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# timefmt
|
||||
|
||||
[](https://pkg.go.dev/git.haelnorr.com/h/timefmt)
|
||||
[](https://goreportcard.com/report/git.haelnorr.com/h/timefmt)
|
||||
|
||||
A Go library for building and converting time format strings with human-readable descriptions. `timefmt` provides a fluent builder API for constructing time formats and can convert between Go's reference time format, LDML tokens, and plain English descriptions.
|
||||
|
||||
## Features
|
||||
|
||||
- 🏗️ **Builder Pattern**: Fluent, chainable API for constructing time formats
|
||||
- 🔄 **Multi-Format Output**: Convert to Go format, LDML tokens, or English descriptions
|
||||
- 📦 **Pre-built Formats**: Common formats like ISO8601, RFC3339, etc. ready to use
|
||||
- 🔍 **Format Parser**: Parse existing Go time format strings
|
||||
- 📝 **Type-Safe**: No string concatenation errors
|
||||
- ✅ **Well-Tested**: Comprehensive test coverage
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.haelnorr.com/h/timefmt
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"git.haelnorr.com/h/timefmt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Build a format using the fluent API
|
||||
format := timefmt.NewBuilder().
|
||||
Year4().Dash().
|
||||
MonthNumeric2().Dash().
|
||||
DayNumeric2().Space().
|
||||
Hour24().Colon().
|
||||
Minute().Colon().
|
||||
Second().
|
||||
Build()
|
||||
|
||||
// Get different representations
|
||||
fmt.Println(format.GoFormat()) // "2006-01-02 15:04:05"
|
||||
fmt.Println(format.LDML()) // "yyyy-MM-dd HH:mm:ss"
|
||||
fmt.Println(format.Description()) // "Year (4-digit), dash, Month (2-digit), ..."
|
||||
|
||||
// Use it to format times
|
||||
now := time.Now()
|
||||
fmt.Println(format.Format(now)) // "2026-02-08 15:04:05"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Building Formats
|
||||
|
||||
The builder provides methods for all time components:
|
||||
|
||||
```go
|
||||
format := timefmt.NewBuilder().
|
||||
WeekdayFull().Comma(). // "Monday, "
|
||||
MonthFull().Space(). // "January "
|
||||
DayNumeric().Comma(). // "8, "
|
||||
Year4().Space(). // "2006 "
|
||||
Literal("at").Space(). // "at "
|
||||
Hour12().Colon(). // "3:"
|
||||
Minute().Space(). // "04 "
|
||||
AMPM(). // "PM"
|
||||
Build()
|
||||
|
||||
// Outputs: "Monday, January 8, 2006 at 3:04 PM"
|
||||
```
|
||||
|
||||
### Pre-built Formats
|
||||
|
||||
Common formats are available as constants:
|
||||
|
||||
```go
|
||||
// ISO 8601 / RFC 3339
|
||||
fmt.Println(timefmt.ISO8601.GoFormat()) // "2006-01-02T15:04:05Z07:00"
|
||||
fmt.Println(timefmt.RFC3339Nano.GoFormat()) // "2006-01-02T15:04:05.999999999Z07:00"
|
||||
|
||||
// Date and time
|
||||
fmt.Println(timefmt.DateOnly.GoFormat()) // "2006-01-02"
|
||||
fmt.Println(timefmt.TimeOnly.GoFormat()) // "15:04:05"
|
||||
fmt.Println(timefmt.DateTime.GoFormat()) // "2006-01-02 15:04:05"
|
||||
|
||||
// Regional formats
|
||||
fmt.Println(timefmt.DateUS.GoFormat()) // "01/02/2006"
|
||||
fmt.Println(timefmt.DateEU.GoFormat()) // "02/01/2006"
|
||||
|
||||
// Kitchen and stamps
|
||||
fmt.Println(timefmt.Kitchen.GoFormat()) // "3:04 PM"
|
||||
fmt.Println(timefmt.StampMilli.GoFormat()) // "Jan _2 15:04:05.000"
|
||||
```
|
||||
|
||||
Available pre-built formats:
|
||||
- `ISO8601`, `RFC3339`, `RFC3339Nano`
|
||||
- `DateOnly`, `TimeOnly`, `DateTime`, `DateTimeWithMillis`
|
||||
- `DateUS`, `DateEU`, `DateTimeUS`, `DateTimeEU`
|
||||
- `Kitchen`, `Stamp`, `StampMilli`, `StampMicro`, `StampNano`
|
||||
|
||||
### Parsing Existing Go Formats
|
||||
|
||||
Convert Go time format strings to get LDML and descriptions:
|
||||
|
||||
```go
|
||||
format, err := timefmt.ParseGoFormat("02/01/2006")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(format.GoFormat()) // "02/01/2006"
|
||||
fmt.Println(format.LDML()) // "dd/MM/yyyy"
|
||||
fmt.Println(format.Description()) // "Day (2-digit), slash, Month (2-digit), slash, Year (4-digit)"
|
||||
```
|
||||
|
||||
### Multiple Output Formats
|
||||
|
||||
Every `Format` can output in three ways:
|
||||
|
||||
```go
|
||||
format := timefmt.NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
Build()
|
||||
|
||||
// Go reference time format (for time.Format/Parse)
|
||||
goFmt := format.GoFormat() // "2006-01-02"
|
||||
|
||||
// LDML tokens (Unicode standard, used by ICU, Java, Swift, etc.)
|
||||
ldml := format.LDML() // "yyyy-MM-dd"
|
||||
|
||||
// Human-readable English description
|
||||
desc := format.Description() // "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)"
|
||||
```
|
||||
|
||||
### Format and Parse Times
|
||||
|
||||
Use the Format to actually format and parse times:
|
||||
|
||||
```go
|
||||
format := timefmt.DateTime
|
||||
|
||||
// Format a time
|
||||
now := time.Now()
|
||||
formatted := format.Format(now) // "2026-02-08 15:04:05"
|
||||
|
||||
// Parse a time string
|
||||
parsed, err := format.Parse("2026-02-08 15:04:05")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Parse with location
|
||||
loc, _ := time.LoadLocation("America/New_York")
|
||||
parsed, err = format.ParseInLocation("2026-02-08 15:04:05", loc)
|
||||
```
|
||||
|
||||
### Get Example Output
|
||||
|
||||
See what a format looks like with example data:
|
||||
|
||||
```go
|
||||
format := timefmt.NewBuilder().
|
||||
MonthShort().Space().DayNumeric().Comma().Year4().
|
||||
Build()
|
||||
|
||||
example := format.Example() // "Feb 8, 2026" (using reference time)
|
||||
```
|
||||
|
||||
## Builder API Reference
|
||||
|
||||
### Year Methods
|
||||
- `Year4()` - 4-digit year (2006)
|
||||
- `Year2()` - 2-digit year (06)
|
||||
|
||||
### Month Methods
|
||||
- `MonthNumeric()` - Numeric month without leading zero (1-12)
|
||||
- `MonthNumeric2()` - 2-digit month with leading zero (01-12)
|
||||
- `MonthShort()` - Abbreviated month name (Jan, Feb, ...)
|
||||
- `MonthFull()` - Full month name (January, February, ...)
|
||||
|
||||
### Day Methods
|
||||
- `DayNumeric()` - Numeric day without leading zero (1-31)
|
||||
- `DayNumeric2()` - 2-digit day with leading zero (01-31)
|
||||
- `DaySpacePadded()` - Space-padded day ( 1-31)
|
||||
- `DayOfYear()` - 3-digit day of year (001-365)
|
||||
- `DayOfYearSpacePadded()` - Space-padded day of year ( 1-365)
|
||||
|
||||
### Weekday Methods
|
||||
- `WeekdayShort()` - Abbreviated weekday (Mon, Tue, ...)
|
||||
- `WeekdayFull()` - Full weekday (Monday, Tuesday, ...)
|
||||
|
||||
### Hour Methods
|
||||
- `Hour24()` - 24-hour format with leading zero (00-23)
|
||||
- `Hour12()` - 12-hour format without leading zero (1-12)
|
||||
- `Hour12Padded()` - 12-hour format with leading zero (01-12)
|
||||
|
||||
### Minute Methods
|
||||
- `Minute()` - 2-digit minute with leading zero (00-59)
|
||||
- `MinuteUnpadded()` - Minute without leading zero (0-59)
|
||||
|
||||
### Second Methods
|
||||
- `Second()` - 2-digit second with leading zero (00-59)
|
||||
- `SecondUnpadded()` - Second without leading zero (0-59)
|
||||
|
||||
### Subsecond Methods
|
||||
- `Millisecond()` - 3-digit milliseconds (.000)
|
||||
- `MillisecondTrim()` - Milliseconds with trailing zeros removed (.999)
|
||||
- `Microsecond()` - 6-digit microseconds (.000000)
|
||||
- `MicrosecondTrim()` - Microseconds with trailing zeros removed (.999999)
|
||||
- `Nanosecond()` - 9-digit nanoseconds (.000000000)
|
||||
- `NanosecondTrim()` - Nanoseconds with trailing zeros removed (.999999999)
|
||||
|
||||
### AM/PM Methods
|
||||
- `AMPM()` - AM/PM in uppercase
|
||||
- `AMPMLower()` - am/pm in lowercase
|
||||
|
||||
### Timezone Methods
|
||||
- `TimezoneOffset()` - Timezone offset as ±HHMM (e.g., -0700)
|
||||
- `TimezoneOffsetColon()` - Timezone offset as ±HH:MM (e.g., -07:00)
|
||||
- `TimezoneOffsetHourOnly()` - Timezone offset hours only ±HH (e.g., -07)
|
||||
- `TimezoneOffsetSeconds()` - Timezone offset with seconds ±HHMMSS (e.g., -070000)
|
||||
- `TimezoneOffsetColonSeconds()` - Timezone offset with seconds ±HH:MM:SS (e.g., -07:00:00)
|
||||
- `TimezoneISO8601()` - ISO 8601 timezone (Z or ±HHMM)
|
||||
- `TimezoneISO8601Colon()` - ISO 8601 timezone with colon (Z or ±HH:MM)
|
||||
- `TimezoneName()` - Timezone abbreviation (MST, PST, etc.)
|
||||
|
||||
### Literal Methods
|
||||
- `Literal(s string)` - Add custom literal text
|
||||
- `Dash()` - Add "-"
|
||||
- `Slash()` - Add "/"
|
||||
- `Colon()` - Add ":"
|
||||
- `Space()` - Add " "
|
||||
- `T()` - Add "T" (for ISO 8601)
|
||||
- `Comma()` - Add ", "
|
||||
- `Period()` - Add "."
|
||||
|
||||
### Building
|
||||
- `Build()` - Finalize and return the Format
|
||||
|
||||
## Fragment Constants
|
||||
|
||||
All format components are also available as `Fragment` constants for advanced use:
|
||||
|
||||
```go
|
||||
type Fragment struct {
|
||||
GoFormat string // Go's reference format
|
||||
LDML string // LDML token
|
||||
Description string // Human-readable description
|
||||
}
|
||||
|
||||
// Example fragments
|
||||
timefmt.Year4Digit // Fragment{GoFormat: "2006", LDML: "yyyy", Description: "Year (4-digit)"}
|
||||
timefmt.MonthNumeric2 // Fragment{GoFormat: "01", LDML: "MM", Description: "Month (2-digit)"}
|
||||
timefmt.Hour24 // Fragment{GoFormat: "15", LDML: "HH", Description: "Hour (24-hour, 2-digit)"}
|
||||
```
|
||||
|
||||
You can use fragments directly with `NewFormat()`:
|
||||
|
||||
```go
|
||||
format := timefmt.NewFormat(
|
||||
timefmt.Year4Digit,
|
||||
"-",
|
||||
timefmt.MonthNumeric2,
|
||||
"-",
|
||||
timefmt.DayNumeric2,
|
||||
)
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### User-Facing Format Display
|
||||
|
||||
Perfect for showing users what format is expected:
|
||||
|
||||
```go
|
||||
format := timefmt.ParseGoFormat(userConfigFormat)
|
||||
|
||||
// Show LDML tokens (familiar to developers)
|
||||
fmt.Printf("Format: %s\n", format.LDML())
|
||||
// Output: "Format: yyyy-MM-dd HH:mm:ss"
|
||||
|
||||
// Show description (clear for everyone)
|
||||
fmt.Printf("Expected format: %s\n", format.Description())
|
||||
// Output: "Expected format: Year (4-digit), dash, Month (2-digit), ..."
|
||||
|
||||
// Show example
|
||||
fmt.Printf("Example: %s\n", format.Example())
|
||||
// Output: "Example: 2026-02-08 15:04:05"
|
||||
```
|
||||
|
||||
### Configuration Documentation
|
||||
|
||||
Generate documentation for time format configuration:
|
||||
|
||||
```go
|
||||
formats := map[string]*timefmt.Format{
|
||||
"log_timestamp": timefmt.DateTime,
|
||||
"api_response": timefmt.RFC3339,
|
||||
"display_date": timefmt.DateUS,
|
||||
}
|
||||
|
||||
for name, format := range formats {
|
||||
fmt.Printf("%s:\n", name)
|
||||
fmt.Printf(" Go format: %s\n", format.GoFormat())
|
||||
fmt.Printf(" LDML: %s\n", format.LDML())
|
||||
fmt.Printf(" Description: %s\n", format.Description())
|
||||
fmt.Printf(" Example: %s\n\n", format.Example())
|
||||
}
|
||||
```
|
||||
|
||||
### Form Validation Messages
|
||||
|
||||
```go
|
||||
format := timefmt.DateOnly
|
||||
|
||||
err := fmt.Errorf(
|
||||
"Invalid date format. Expected format: %s (example: %s)",
|
||||
format.Description(),
|
||||
format.Example(),
|
||||
)
|
||||
// Error: Invalid date format. Expected format: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit) (example: 2026-02-08)
|
||||
```
|
||||
|
||||
## LDML Token Compatibility
|
||||
|
||||
The LDML output follows the [Unicode LDML standard](https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table), making it compatible with:
|
||||
|
||||
- **ICU** (International Components for Unicode)
|
||||
- **Java** (`SimpleDateFormat`, `DateTimeFormatter`)
|
||||
- **Swift** (`DateFormatter`)
|
||||
- **Moment.js** and **Day.js**
|
||||
- **date-fns**
|
||||
- Most modern date/time libraries
|
||||
|
||||
## Why timefmt?
|
||||
|
||||
Go's time formatting using reference times is unique and powerful, but:
|
||||
|
||||
1. **Not immediately intuitive** - New developers need to memorize the reference time
|
||||
2. **Hard to document** - "2006-01-02" doesn't clearly communicate "YYYY-MM-DD"
|
||||
3. **No validation helpers** - No easy way to show users what format is expected
|
||||
|
||||
`timefmt` solves these problems by providing:
|
||||
|
||||
- A type-safe builder that prevents format errors
|
||||
- Automatic conversion to widely-understood LDML tokens
|
||||
- Plain English descriptions for end users
|
||||
- Pre-built formats for common use cases
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
538
TIME_FORMAT_RESEARCH.md
Normal file
538
TIME_FORMAT_RESEARCH.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# Time Format Patterns - Cross-System Comparison
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive comparison of time format patterns across different programming languages and systems. This research will help inform the design of a widely-recognized, human-readable time format.
|
||||
|
||||
---
|
||||
|
||||
## 1. Unicode LDML (Locale Data Markup Language)
|
||||
|
||||
**Used by:** ICU, Java, Swift, JavaScript (Intl), and many internationalization libraries
|
||||
|
||||
### Key Characteristics
|
||||
- Single letters represent minimal/numeric forms
|
||||
- Repeated letters increase width or change format type
|
||||
- Case-sensitive (different meanings for upper/lower case)
|
||||
- Canonical order matters for skeletons
|
||||
|
||||
### Format Tokens
|
||||
|
||||
| Element | Token | Examples | Notes |
|
||||
|---------|-------|----------|-------|
|
||||
| **Year** |
|
||||
| 4-digit year | `yyyy` or `y` | 2024, 0070 | `y` adapts to context |
|
||||
| 2-digit year | `yy` | 24, 70 | 00-99 |
|
||||
| **Month** |
|
||||
| Numeric, no zero | `M` | 1, 12 | 1-12 |
|
||||
| Numeric, zero-padded | `MM` | 01, 12 | 01-12 |
|
||||
| Abbreviated name | `MMM` | Jan, Dec | Locale-specific |
|
||||
| Full name | `MMMM` | January, December | Locale-specific |
|
||||
| Stand-alone abbrev | `LLL` | Jan, Dec | Nominative form |
|
||||
| Stand-alone full | `LLLL` | January, December | Nominative form |
|
||||
| **Day of Month** |
|
||||
| Numeric, no zero | `d` | 1, 31 | 1-31 |
|
||||
| Numeric, zero-padded | `dd` | 01, 31 | 01-31 |
|
||||
| **Weekday** |
|
||||
| Abbreviated | `EEE` | Mon, Fri | Locale-specific |
|
||||
| Full name | `EEEE` | Monday, Friday | Locale-specific |
|
||||
| Narrow | `EEEEE` | M, F | Single character |
|
||||
| Short | `EEEEEE` | Mo, Fr | Between abbrev & narrow |
|
||||
| **Hour (12-hour)** |
|
||||
| No zero (1-12) | `h` | 1, 12 | Requires AM/PM |
|
||||
| Zero-padded (01-12) | `hh` | 01, 12 | Requires AM/PM |
|
||||
| **Hour (24-hour)** |
|
||||
| No zero (0-23) | `H` | 0, 23 | |
|
||||
| Zero-padded (00-23) | `HH` | 00, 23 | |
|
||||
| **Minute** |
|
||||
| No zero | `m` | 0, 59 | 0-59 |
|
||||
| Zero-padded | `mm` | 00, 59 | 00-59 |
|
||||
| **Second** |
|
||||
| No zero | `s` | 0, 59 | 0-59 |
|
||||
| Zero-padded | `ss` | 00, 59 | 00-59 |
|
||||
| **Fractional Seconds** |
|
||||
| Milliseconds | `SSS` | 000, 999 | Always 3 digits |
|
||||
| Variable precision | `S` to `SSSSSSSSS` | 0 to 9 digits | Fractional precision |
|
||||
| **AM/PM** |
|
||||
| Period | `a` | AM, PM | Locale-specific |
|
||||
| Period, narrow | `aaaaa` | a, p | Single character |
|
||||
| Day period (flexible) | `b` | at night, in the morning | Locale-specific |
|
||||
| Day period (specific) | `B` | noon, midnight | Locale-specific |
|
||||
| **Timezone** |
|
||||
| ISO offset | `Z` to `ZZZZZ` | -0800, -08:00, Z | Various formats |
|
||||
| Localized GMT | `O` to `OOOO` | GMT-8, GMT-08:00 | |
|
||||
| Generic non-location | `v` | PT | Short form |
|
||||
| Generic non-location | `vvvv` | Pacific Time | Long form |
|
||||
| Specific non-location | `z` | PST | Short form |
|
||||
| Specific non-location | `zzzz` | Pacific Standard Time | Long form |
|
||||
|
||||
### Common Pattern Examples
|
||||
- `yyyy-MM-dd` → 2024-02-08
|
||||
- `MMM d, yyyy` → Feb 8, 2024
|
||||
- `EEEE, MMMM d, yyyy` → Thursday, February 8, 2024
|
||||
- `h:mm a` → 3:45 PM
|
||||
- `HH:mm:ss` → 15:45:30
|
||||
- `yyyy-MM-dd'T'HH:mm:ss` → 2024-02-08T15:45:30
|
||||
|
||||
---
|
||||
|
||||
## 2. strftime (C, Python, Ruby, PHP, etc.)
|
||||
|
||||
**Used by:** C, C++, Python, Ruby, PHP, Perl, Unix/Linux systems
|
||||
|
||||
### Key Characteristics
|
||||
- All format codes start with `%`
|
||||
- Single character codes (case-sensitive)
|
||||
- Platform-dependent for some codes
|
||||
- Locale-aware variants with `E` and `O` modifiers
|
||||
|
||||
### Format Codes
|
||||
|
||||
| Element | Code | Examples | Notes |
|
||||
|---------|------|----------|-------|
|
||||
| **Year** |
|
||||
| 4-digit year | `%Y` | 2024, 0070 | |
|
||||
| 2-digit year | `%y` | 24, 70 | 00-99 |
|
||||
| Century | `%C` | 20 | First 2 digits |
|
||||
| **Month** |
|
||||
| Numeric, zero-padded | `%m` | 01, 12 | 01-12 |
|
||||
| Abbreviated name | `%b` or `%h` | Jan, Dec | Locale-specific |
|
||||
| Full name | `%B` | January, December | Locale-specific |
|
||||
| **Day of Month** |
|
||||
| Zero-padded | `%d` | 01, 31 | 01-31 |
|
||||
| Space-padded | `%e` | " 1", "31" | 1-31 with space |
|
||||
| **Weekday** |
|
||||
| Abbreviated | `%a` | Mon, Fri | Locale-specific |
|
||||
| Full name | `%A` | Monday, Friday | Locale-specific |
|
||||
| Numeric (0-6) | `%w` | 0, 6 | Sunday = 0 |
|
||||
| Numeric (1-7) | `%u` | 1, 7 | Monday = 1 |
|
||||
| **Hour (12-hour)** |
|
||||
| Zero-padded | `%I` | 01, 12 | 01-12, requires AM/PM |
|
||||
| **Hour (24-hour)** |
|
||||
| Zero-padded | `%H` | 00, 23 | 00-23 |
|
||||
| Space-padded | `%k` | " 0", "23" | 0-23 with space |
|
||||
| **Minute** |
|
||||
| Zero-padded | `%M` | 00, 59 | 00-59 |
|
||||
| **Second** |
|
||||
| Zero-padded | `%S` | 00, 59 | 00-59 |
|
||||
| **Fractional Seconds** |
|
||||
| Microseconds | `%f` | 000000, 999999 | Python-specific, 6 digits |
|
||||
| **AM/PM** |
|
||||
| Period | `%p` | AM, PM | Locale-specific |
|
||||
| Period lowercase | `%P` | am, pm | GNU extension |
|
||||
| **Timezone** |
|
||||
| Name or abbreviation | `%Z` | PST, EST | Platform-dependent |
|
||||
| Offset | `%z` | -0800, +0530 | ±HHMM format |
|
||||
| **Composite Formats** |
|
||||
| Date (MM/DD/YY) | `%D` | 02/08/24 | Equivalent to %m/%d/%y |
|
||||
| ISO date | `%F` | 2024-02-08 | Equivalent to %Y-%m-%d |
|
||||
| 12-hour time | `%r` | 03:45:30 PM | Locale-specific |
|
||||
| 24-hour time (HH:MM) | `%R` | 15:45 | Equivalent to %H:%M |
|
||||
| Time with seconds | `%T` | 15:45:30 | Equivalent to %H:%M:%S |
|
||||
| Date and time | `%c` | Thu Feb 8 15:45:30 2024 | Locale-specific |
|
||||
|
||||
### Common Pattern Examples
|
||||
- `%Y-%m-%d` → 2024-02-08
|
||||
- `%b %d, %Y` → Feb 08, 2024
|
||||
- `%A, %B %d, %Y` → Thursday, February 08, 2024
|
||||
- `%I:%M %p` → 03:45 PM
|
||||
- `%H:%M:%S` → 15:45:30
|
||||
- `%Y-%m-%dT%H:%M:%S` → 2024-02-08T15:45:30
|
||||
|
||||
---
|
||||
|
||||
## 3. Moment.js / Day.js (JavaScript)
|
||||
|
||||
**Used by:** Moment.js, Day.js (legacy JavaScript libraries)
|
||||
|
||||
### Key Characteristics
|
||||
- No special prefix character
|
||||
- Case-sensitive
|
||||
- Repetition changes format
|
||||
- Inspired by PHP date() function
|
||||
|
||||
### Format Tokens
|
||||
|
||||
| Element | Token | Examples | Notes |
|
||||
|---------|-------|----------|-------|
|
||||
| **Year** |
|
||||
| 4-digit year | `YYYY` | 2024, 0070 | |
|
||||
| 2-digit year | `YY` | 24, 70 | 00-99 |
|
||||
| **Month** |
|
||||
| Numeric, no zero | `M` | 1, 12 | 1-12 |
|
||||
| Numeric, zero-padded | `MM` | 01, 12 | 01-12 |
|
||||
| Abbreviated name | `MMM` | Jan, Dec | |
|
||||
| Full name | `MMMM` | January, December | |
|
||||
| **Day of Month** |
|
||||
| Numeric, no zero | `D` | 1, 31 | 1-31 |
|
||||
| Numeric, zero-padded | `DD` | 01, 31 | 01-31 |
|
||||
| Ordinal | `Do` | 1st, 31st | With suffix |
|
||||
| **Weekday** |
|
||||
| Abbreviated | `ddd` | Mon, Fri | |
|
||||
| Full name | `dddd` | Monday, Friday | |
|
||||
| Min (2 chars) | `dd` | Mo, Fr | |
|
||||
| Numeric (0-6) | `d` | 0, 6 | Sunday = 0 |
|
||||
| **Hour (12-hour)** |
|
||||
| No zero (1-12) | `h` | 1, 12 | Requires A or a |
|
||||
| Zero-padded (01-12) | `hh` | 01, 12 | Requires A or a |
|
||||
| **Hour (24-hour)** |
|
||||
| No zero (0-23) | `H` | 0, 23 | |
|
||||
| Zero-padded (00-23) | `HH` | 00, 23 | |
|
||||
| **Minute** |
|
||||
| No zero | `m` | 0, 59 | 0-59 |
|
||||
| Zero-padded | `mm` | 00, 59 | 00-59 |
|
||||
| **Second** |
|
||||
| No zero | `s` | 0, 59 | 0-59 |
|
||||
| Zero-padded | `ss` | 00, 59 | 00-59 |
|
||||
| **Fractional Seconds** |
|
||||
| 1-3 digits | `S`, `SS`, `SSS` | 0, 00, 000 | Tenths, hundredths, ms |
|
||||
| Up to 9 digits | `SSSS` to `SSSSSSSSS` | Variable | Extended precision |
|
||||
| **AM/PM** |
|
||||
| Lowercase | `a` | am, pm | |
|
||||
| Uppercase | `A` | AM, PM | |
|
||||
| **Timezone** |
|
||||
| Offset | `Z` | -08:00, +05:30 | With colon |
|
||||
| Compact offset | `ZZ` | -0800, +0530 | Without colon |
|
||||
|
||||
### Common Pattern Examples
|
||||
- `YYYY-MM-DD` → 2024-02-08
|
||||
- `MMM D, YYYY` → Feb 8, 2024
|
||||
- `dddd, MMMM D, YYYY` → Thursday, February 8, 2024
|
||||
- `h:mm A` → 3:45 PM
|
||||
- `HH:mm:ss` → 15:45:30
|
||||
- `YYYY-MM-DDTHH:mm:ss` → 2024-02-08T15:45:30
|
||||
|
||||
---
|
||||
|
||||
## 4. date-fns (Modern JavaScript)
|
||||
|
||||
**Used by:** date-fns library
|
||||
|
||||
### Key Characteristics
|
||||
- Based on Unicode LDML tokens
|
||||
- More standardized than Moment.js
|
||||
- Case-sensitive
|
||||
- Uses escape sequences for literals
|
||||
|
||||
### Format Tokens
|
||||
|
||||
*date-fns uses Unicode LDML tokens (see Section 1) with some differences:*
|
||||
|
||||
| Element | Token | Examples | Notes |
|
||||
|---------|-------|----------|-------|
|
||||
| **Year** |
|
||||
| Extended year | `uuuu` | 2024, -0001 | Recommended over yyyy |
|
||||
| Calendar year | `yyyy` | 2024 | Use uuuu instead |
|
||||
| 2-digit year | `yy` or `uu` | 24 | |
|
||||
| **Month** | | | Same as LDML |
|
||||
| **Day** |
|
||||
| Day of month | `d`, `do`, `dd` | 1, 1st, 01 | |
|
||||
| Day of year | `D`, `Do`, `DD`, `DDD` | 1, 1st, 01, 001 | |
|
||||
| **Week** |
|
||||
| Local week | `w`, `wo`, `ww` | 1, 1st, 01 | |
|
||||
| ISO week | `I`, `Io`, `II` | 1, 1st, 01 | |
|
||||
| **Weekday** |
|
||||
| Short | `eee`, `eeeee` | Mon, M | Locale day |
|
||||
| Long | `eeee`, `eeeeee` | Monday, Mo | Locale day |
|
||||
| ISO | `i`, `io`, `iii`, `iiii`, `iiiii`, `iiiiii` | 1, 1st, Mon, Monday, M, Mo | |
|
||||
| **Hour, Minute, Second** | | | Same as LDML |
|
||||
| **AM/PM** | | | Same as LDML |
|
||||
| **Timezone** | | | Extended LDML support |
|
||||
|
||||
### Common Pattern Examples
|
||||
- `yyyy-MM-dd` → 2024-02-08
|
||||
- `MMM d, yyyy` → Feb 8, 2024
|
||||
- `EEEE, MMMM d, yyyy` → Thursday, February 8, 2024
|
||||
- `h:mm a` → 3:45 PM
|
||||
- `HH:mm:ss` → 15:45:30
|
||||
|
||||
---
|
||||
|
||||
## 5. .NET DateTime Formats
|
||||
|
||||
**Used by:** C#, F#, VB.NET, .NET platform
|
||||
|
||||
### Key Characteristics
|
||||
- Single character codes (case-sensitive)
|
||||
- Can use multiple characters for wider format
|
||||
- Custom format strings combine individual specifiers
|
||||
- Standard format strings (single character) produce locale-specific output
|
||||
|
||||
### Format Specifiers
|
||||
|
||||
| Element | Specifier | Examples | Notes |
|
||||
|---------|-----------|----------|-------|
|
||||
| **Year** |
|
||||
| 1-2 digit year | `y` | 8, 24 | Minimum digits |
|
||||
| 2-digit year | `yy` | 08, 24 | 00-99 |
|
||||
| 3-digit year | `yyy` | 008, 024 | Minimum 3 digits |
|
||||
| 4-digit year | `yyyy` | 0008, 2024 | |
|
||||
| 5-digit year | `yyyyy` | 00008, 02024 | |
|
||||
| **Month** |
|
||||
| Numeric, no zero | `M` | 1, 12 | 1-12 |
|
||||
| Numeric, zero-padded | `MM` | 01, 12 | 01-12 |
|
||||
| Abbreviated name | `MMM` | Jan, Dec | Locale-specific |
|
||||
| Full name | `MMMM` | January, December | Locale-specific |
|
||||
| **Day of Month** |
|
||||
| Numeric, no zero | `d` | 1, 31 | 1-31 |
|
||||
| Numeric, zero-padded | `dd` | 01, 31 | 01-31 |
|
||||
| **Weekday** |
|
||||
| Abbreviated | `ddd` | Mon, Fri | Locale-specific |
|
||||
| Full name | `dddd` | Monday, Friday | Locale-specific |
|
||||
| **Hour (12-hour)** |
|
||||
| No zero (1-12) | `h` | 1, 12 | Requires tt or t |
|
||||
| Zero-padded (01-12) | `hh` | 01, 12 | Requires tt or t |
|
||||
| **Hour (24-hour)** |
|
||||
| No zero (0-23) | `H` | 0, 23 | |
|
||||
| Zero-padded (00-23) | `HH` | 00, 23 | |
|
||||
| **Minute** |
|
||||
| No zero | `m` | 0, 59 | 0-59 |
|
||||
| Zero-padded | `mm` | 00, 59 | 00-59 |
|
||||
| **Second** |
|
||||
| No zero | `s` | 0, 59 | 0-59 |
|
||||
| Zero-padded | `ss` | 00, 59 | 00-59 |
|
||||
| **Fractional Seconds** |
|
||||
| Tenths | `f` | 0-9 | Always shown |
|
||||
| Hundredths | `ff` | 00-99 | Always shown |
|
||||
| Milliseconds | `fff` | 000-999 | Always shown |
|
||||
| Optional tenths | `F` | 0-9 or nothing | Not shown if zero |
|
||||
| Optional hundredths | `FF` | 00-99 or nothing | Not shown if zero |
|
||||
| Optional milliseconds | `FFF` | 000-999 or nothing | Not shown if zero |
|
||||
| Up to 7 digits | `fffffff`, `FFFFFFF` | Nanosecond precision | |
|
||||
| **AM/PM** |
|
||||
| First char | `t` | A, P | Locale-specific |
|
||||
| Full designator | `tt` | AM, PM | Locale-specific |
|
||||
| **Timezone** |
|
||||
| Hours offset | `z` | -8, +5 | No leading zero |
|
||||
| Hours offset padded | `zz` | -08, +05 | With leading zero |
|
||||
| Full offset | `zzz` | -08:00, +05:30 | Hours and minutes |
|
||||
| **Other** |
|
||||
| Era | `g` or `gg` | A.D. | Period/era |
|
||||
| Time separator | `:` | : | Locale-specific |
|
||||
| Date separator | `/` | / | Locale-specific |
|
||||
|
||||
### Standard Format Strings
|
||||
- `d` - Short date pattern (MM/dd/yyyy)
|
||||
- `D` - Long date pattern (dddd, MMMM dd, yyyy)
|
||||
- `t` - Short time pattern (h:mm tt)
|
||||
- `T` - Long time pattern (h:mm:ss tt)
|
||||
- `f` - Full date/time short (dddd, MMMM dd, yyyy h:mm tt)
|
||||
- `F` - Full date/time long (dddd, MMMM dd, yyyy h:mm:ss tt)
|
||||
- `g` - General short (M/d/yyyy h:mm tt)
|
||||
- `G` - General long (M/d/yyyy h:mm:ss tt)
|
||||
- `o` - ISO 8601 (yyyy-MM-ddTHH:mm:ss.fffffffK)
|
||||
- `s` - Sortable (yyyy-MM-ddTHH:mm:ss)
|
||||
|
||||
### Common Pattern Examples
|
||||
- `yyyy-MM-dd` → 2024-02-08
|
||||
- `MMM d, yyyy` → Feb 8, 2024
|
||||
- `dddd, MMMM d, yyyy` → Thursday, February 8, 2024
|
||||
- `h:mm tt` → 3:45 PM
|
||||
- `HH:mm:ss` → 15:45:30
|
||||
- `yyyy-MM-ddTHH:mm:ss` → 2024-02-08T15:45:30
|
||||
|
||||
---
|
||||
|
||||
## 6. ISO 8601 Standard
|
||||
|
||||
**International Standard for date and time representation**
|
||||
|
||||
### Key Characteristics
|
||||
- Designed for unambiguous machine-readable formats
|
||||
- Always uses Gregorian calendar
|
||||
- Year-month-day order (largest to smallest units)
|
||||
- Uses T as date/time separator
|
||||
- Uses Z to denote UTC
|
||||
|
||||
### Standard Representations
|
||||
|
||||
| Format | Pattern | Example | Notes |
|
||||
|--------|---------|---------|-------|
|
||||
| **Date** |
|
||||
| Calendar date | `YYYY-MM-DD` | 2024-02-08 | Extended format |
|
||||
| Calendar date | `YYYYMMDD` | 20240208 | Basic format |
|
||||
| Week date | `YYYY-Www-D` | 2024-W06-4 | Week 6, day 4 |
|
||||
| Week date | `YYYYWwwD` | 2024W064 | Basic format |
|
||||
| Ordinal date | `YYYY-DDD` | 2024-039 | Day 39 of year |
|
||||
| Ordinal date | `YYYYDDD` | 2024039 | Basic format |
|
||||
| Year and month | `YYYY-MM` | 2024-02 | |
|
||||
| Year only | `YYYY` | 2024 | |
|
||||
| **Time** |
|
||||
| Hours and minutes | `hh:mm` | 15:45 | Extended format |
|
||||
| Hours and minutes | `hhmm` | 1545 | Basic format |
|
||||
| Hours, min, sec | `hh:mm:ss` | 15:45:30 | Extended format |
|
||||
| Hours, min, sec | `hhmmss` | 154530 | Basic format |
|
||||
| With fractional sec | `hh:mm:ss.sss` | 15:45:30.123 | Variable precision |
|
||||
| **DateTime** |
|
||||
| Combined | `YYYY-MM-DDThh:mm:ss` | 2024-02-08T15:45:30 | T separator |
|
||||
| Combined basic | `YYYYMMDDThhmmss` | 20240208T154530 | No separators |
|
||||
| **Timezone** |
|
||||
| UTC indicator | `Z` | 2024-02-08T15:45:30Z | Zulu time |
|
||||
| Offset | `±hh:mm` | 2024-02-08T15:45:30-08:00 | Extended format |
|
||||
| Offset | `±hhmm` | 2024-02-08T15:45:30-0800 | Basic format |
|
||||
| Offset | `±hh` | 2024-02-08T15:45:30-08 | Hours only |
|
||||
|
||||
### Common ISO 8601 Examples
|
||||
- `2024-02-08` (Date only)
|
||||
- `15:45:30` (Time only)
|
||||
- `2024-02-08T15:45:30` (Local datetime)
|
||||
- `2024-02-08T15:45:30Z` (UTC datetime)
|
||||
- `2024-02-08T15:45:30-08:00` (With timezone offset)
|
||||
- `2024-W06-4` (Week date: year 2024, week 6, Thursday)
|
||||
|
||||
---
|
||||
|
||||
## Cross-System Comparison Table
|
||||
|
||||
### Year Formats
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| 4-digit | `yyyy` | `%Y` | `YYYY` | `yyyy` | `yyyy` | `YYYY` |
|
||||
| 2-digit | `yy` | `%y` | `YY` | `yy` | `yy` | `YY` |
|
||||
|
||||
### Month Formats
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| Numeric no zero | `M` | - | `M` | `M` | `M` | - |
|
||||
| Numeric zero-pad | `MM` | `%m` | `MM` | `MM` | `MM` | `MM` |
|
||||
| Abbreviated name | `MMM` | `%b` | `MMM` | `MMM` | `MMM` | - |
|
||||
| Full name | `MMMM` | `%B` | `MMMM` | `MMMM` | `MMMM` | - |
|
||||
|
||||
### Day Formats
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| Numeric no zero | `d` | - | `D` | `d` | `d` | - |
|
||||
| Numeric zero-pad | `dd` | `%d` | `DD` | `dd` | `dd` | `DD` |
|
||||
| Abbreviated weekday | `EEE` | `%a` | `ddd` | `eee` | `ddd` | - |
|
||||
| Full weekday | `EEEE` | `%A` | `dddd` | `eeee` | `dddd` | - |
|
||||
|
||||
### Hour Formats (12-hour)
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| No zero (1-12) | `h` | - | `h` | `h` | `h` | - |
|
||||
| Zero-pad (01-12) | `hh` | `%I` | `hh` | `hh` | `hh` | - |
|
||||
|
||||
### Hour Formats (24-hour)
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| No zero (0-23) | `H` | - | `H` | `H` | `H` | - |
|
||||
| Zero-pad (00-23) | `HH` | `%H` | `HH` | `HH` | `HH` | `hh` |
|
||||
|
||||
### Minute/Second Formats
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| Minutes no zero | `m` | - | `m` | `m` | `m` | - |
|
||||
| Minutes zero-pad | `mm` | `%M` | `mm` | `mm` | `mm` | `mm` |
|
||||
| Seconds no zero | `s` | - | `s` | `s` | `s` | - |
|
||||
| Seconds zero-pad | `ss` | `%S` | `ss` | `ss` | `ss` | `ss` |
|
||||
|
||||
### AM/PM Indicators
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| Uppercase | `a` | `%p` | `A` | `a` | `tt` | N/A |
|
||||
| Lowercase | `aaaaa` | `%P`* | `a` | `aaa` | - | N/A |
|
||||
| First char only | - | - | - | - | `t` | N/A |
|
||||
|
||||
*GNU extension, not POSIX standard
|
||||
|
||||
### Timezone Formats
|
||||
|
||||
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|
||||
|--------|------|----------|--------|----------|------|----------|
|
||||
| Offset with colon | `ZZZZZ` or `XXXXX` | - | `Z` | `XXX` | `zzz` | `±hh:mm` |
|
||||
| Offset no colon | `ZZZ` or `XX` | `%z` | `ZZ` | `XX` | - | `±hhmm` |
|
||||
| UTC indicator | - | - | - | `X` | - | `Z` |
|
||||
| Named timezone | `z`, `zzzz` | `%Z` | - | - | - | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Recommendations for Human-Readable Format
|
||||
|
||||
Based on this research, here are recommendations for a widely-recognized human-readable format:
|
||||
|
||||
### Most Universal Patterns
|
||||
|
||||
1. **Date Only - ISO 8601 style (MOST UNIVERSAL)**
|
||||
- Pattern: `YYYY-MM-DD`
|
||||
- Example: `2024-02-08`
|
||||
- Recognized by: ALL systems
|
||||
- Pros: Unambiguous, sortable, internationally recognized
|
||||
- Cons: Less readable for some English speakers
|
||||
|
||||
2. **Date Only - Text month**
|
||||
- Pattern: `MMM DD, YYYY` or `DD MMM YYYY`
|
||||
- Example: `Feb 08, 2024` or `08 Feb 2024`
|
||||
- Recognized by: All major systems (with slight variations)
|
||||
- Pros: Very readable, avoids MM/DD vs DD/MM confusion
|
||||
- Cons: Not sortable, locale-dependent
|
||||
|
||||
3. **Time Only - 24-hour (MOST UNIVERSAL)**
|
||||
- Pattern: `HH:mm:ss`
|
||||
- Example: `15:45:30`
|
||||
- Recognized by: ALL systems
|
||||
- Pros: Unambiguous, no AM/PM needed
|
||||
- Cons: Some users prefer 12-hour format
|
||||
|
||||
4. **Time Only - 12-hour**
|
||||
- Pattern: `hh:mm:ss AM` or `h:mm:ss a`
|
||||
- Example: `03:45:30 PM`
|
||||
- Recognized by: All major systems (with variations)
|
||||
- Pros: Familiar to many users
|
||||
- Cons: Requires AM/PM designator
|
||||
|
||||
5. **Full DateTime - ISO 8601 (MOST UNIVERSAL)**
|
||||
- Pattern: `YYYY-MM-DDTHH:mm:ss`
|
||||
- Example: `2024-02-08T15:45:30`
|
||||
- Recognized by: ALL systems
|
||||
- Pros: Unambiguous, sortable, international standard
|
||||
- Cons: T separator less readable
|
||||
|
||||
6. **Full DateTime - Human-readable**
|
||||
- Pattern: `MMMM DD, YYYY HH:mm:ss` or `DD MMMM YYYY HH:mm:ss`
|
||||
- Example: `February 08, 2024 15:45:30`
|
||||
- Recognized by: All major systems (with slight variations)
|
||||
- Pros: Very readable
|
||||
- Cons: Verbose, locale-dependent month names
|
||||
|
||||
### Token Compatibility Summary
|
||||
|
||||
**Highest Compatibility (work in 5-6 systems):**
|
||||
- `yyyy`/`YYYY` - 4-digit year
|
||||
- `MM` - 2-digit month (zero-padded)
|
||||
- `dd`/`DD` - 2-digit day (zero-padded)
|
||||
- `HH` - 24-hour time (zero-padded)
|
||||
- `mm` - minutes (zero-padded)
|
||||
- `ss` - seconds (zero-padded)
|
||||
|
||||
**High Compatibility (work in 4-5 systems):**
|
||||
- `MMM` - Abbreviated month name
|
||||
- `MMMM` - Full month name
|
||||
- `hh` - 12-hour time (zero-padded)
|
||||
- `a`/`A`/`tt` - AM/PM (varies by system)
|
||||
|
||||
**Moderate Compatibility:**
|
||||
- `EEE`/`ddd` - Abbreviated weekday
|
||||
- `EEEE`/`dddd` - Full weekday
|
||||
- Single-letter tokens (no zero padding) - variable support
|
||||
|
||||
### Recommended Format for timefmt Project
|
||||
|
||||
For maximum compatibility and readability, consider:
|
||||
|
||||
1. **For machine-readable output**: `YYYY-MM-DD HH:mm:ss` (ISO 8601 without T)
|
||||
2. **For human-readable output**: `MMM DD, YYYY HH:mm:ss` (e.g., "Feb 08, 2024 15:45:30")
|
||||
3. **For compact output**: `YYYY-MM-DD HH:mm` (omit seconds if not needed)
|
||||
|
||||
These formats:
|
||||
- Avoid MM/DD vs DD/MM confusion
|
||||
- Don't require AM/PM logic
|
||||
- Use widely recognized token patterns
|
||||
- Are unambiguous across cultures
|
||||
- Balance readability with precision
|
||||
278
builder.go
Normal file
278
builder.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package timefmt
|
||||
|
||||
// Builder provides a fluent interface for constructing Format instances.
|
||||
// It uses a mutable pattern where each method modifies the builder and returns it for chaining.
|
||||
type Builder struct {
|
||||
format *Format
|
||||
}
|
||||
|
||||
// NewBuilder creates a new format builder with an empty format.
|
||||
func NewBuilder() *Builder {
|
||||
return &Builder{
|
||||
format: &Format{
|
||||
fragments: make([]interface{}, 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Year4 adds a 4-digit year (e.g., 2026) to the format.
|
||||
func (b *Builder) Year4() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Year4Digit)
|
||||
return b
|
||||
}
|
||||
|
||||
// Year2 adds a 2-digit year (e.g., 26) to the format.
|
||||
func (b *Builder) Year2() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Year2Digit)
|
||||
return b
|
||||
}
|
||||
|
||||
// MonthNumeric adds a numeric month without leading zero (1-12) to the format.
|
||||
func (b *Builder) MonthNumeric() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, MonthNumeric)
|
||||
return b
|
||||
}
|
||||
|
||||
// MonthNumeric2 adds a 2-digit month with leading zero (01-12) to the format.
|
||||
func (b *Builder) MonthNumeric2() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, MonthNumeric2)
|
||||
return b
|
||||
}
|
||||
|
||||
// MonthShort adds an abbreviated month name (Jan, Feb, Mar, etc.) to the format.
|
||||
func (b *Builder) MonthShort() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, MonthShort)
|
||||
return b
|
||||
}
|
||||
|
||||
// MonthFull adds a full month name (January, February, March, etc.) to the format.
|
||||
func (b *Builder) MonthFull() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, MonthFull)
|
||||
return b
|
||||
}
|
||||
|
||||
// DayNumeric adds a numeric day without leading zero (1-31) to the format.
|
||||
func (b *Builder) DayNumeric() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, DayNumeric)
|
||||
return b
|
||||
}
|
||||
|
||||
// DayNumeric2 adds a 2-digit day with leading zero (01-31) to the format.
|
||||
func (b *Builder) DayNumeric2() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, DayNumeric2)
|
||||
return b
|
||||
}
|
||||
|
||||
// DaySpacePadded adds a space-padded day ( 1-31) to the format.
|
||||
func (b *Builder) DaySpacePadded() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, DaySpacePadded)
|
||||
return b
|
||||
}
|
||||
|
||||
// DayOfYear adds a 3-digit day of year (001-365) to the format.
|
||||
func (b *Builder) DayOfYear() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, DayOfYearNumeric)
|
||||
return b
|
||||
}
|
||||
|
||||
// DayOfYearSpacePadded adds a space-padded day of year ( 1-365) to the format.
|
||||
func (b *Builder) DayOfYearSpacePadded() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, DayOfYearSpacePadded)
|
||||
return b
|
||||
}
|
||||
|
||||
// WeekdayShort adds an abbreviated weekday name (Mon, Tue, Wed, etc.) to the format.
|
||||
func (b *Builder) WeekdayShort() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, WeekdayShort)
|
||||
return b
|
||||
}
|
||||
|
||||
// WeekdayFull adds a full weekday name (Monday, Tuesday, Wednesday, etc.) to the format.
|
||||
func (b *Builder) WeekdayFull() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, WeekdayFull)
|
||||
return b
|
||||
}
|
||||
|
||||
// Hour24 adds a 24-hour format with leading zero (00-23) to the format.
|
||||
func (b *Builder) Hour24() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Hour24)
|
||||
return b
|
||||
}
|
||||
|
||||
// Hour12 adds a 12-hour format without leading zero (1-12) to the format.
|
||||
func (b *Builder) Hour12() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Hour12)
|
||||
return b
|
||||
}
|
||||
|
||||
// Hour12Padded adds a 12-hour format with leading zero (01-12) to the format.
|
||||
func (b *Builder) Hour12Padded() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Hour12Padded)
|
||||
return b
|
||||
}
|
||||
|
||||
// Minute adds a 2-digit minute with leading zero (00-59) to the format.
|
||||
func (b *Builder) Minute() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Minute)
|
||||
return b
|
||||
}
|
||||
|
||||
// MinuteUnpadded adds a minute without leading zero (0-59) to the format.
|
||||
func (b *Builder) MinuteUnpadded() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, MinuteUnpadded)
|
||||
return b
|
||||
}
|
||||
|
||||
// Second adds a 2-digit second with leading zero (00-59) to the format.
|
||||
func (b *Builder) Second() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Second)
|
||||
return b
|
||||
}
|
||||
|
||||
// SecondUnpadded adds a second without leading zero (0-59) to the format.
|
||||
func (b *Builder) SecondUnpadded() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, SecondUnpadded)
|
||||
return b
|
||||
}
|
||||
|
||||
// Millisecond adds milliseconds as 3 digits (.000) to the format.
|
||||
func (b *Builder) Millisecond() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Millisecond)
|
||||
return b
|
||||
}
|
||||
|
||||
// MillisecondTrim adds milliseconds with trailing zeros removed (.999) to the format.
|
||||
func (b *Builder) MillisecondTrim() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, MillisecondTrim)
|
||||
return b
|
||||
}
|
||||
|
||||
// Microsecond adds microseconds as 6 digits (.000000) to the format.
|
||||
func (b *Builder) Microsecond() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Microsecond)
|
||||
return b
|
||||
}
|
||||
|
||||
// MicrosecondTrim adds microseconds with trailing zeros removed (.999999) to the format.
|
||||
func (b *Builder) MicrosecondTrim() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, MicrosecondTrim)
|
||||
return b
|
||||
}
|
||||
|
||||
// Nanosecond adds nanoseconds as 9 digits (.000000000) to the format.
|
||||
func (b *Builder) Nanosecond() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, Nanosecond)
|
||||
return b
|
||||
}
|
||||
|
||||
// NanosecondTrim adds nanoseconds with trailing zeros removed (.999999999) to the format.
|
||||
func (b *Builder) NanosecondTrim() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, NanosecondTrim)
|
||||
return b
|
||||
}
|
||||
|
||||
// AMPM adds an AM/PM marker in uppercase to the format.
|
||||
func (b *Builder) AMPM() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, AMPM)
|
||||
return b
|
||||
}
|
||||
|
||||
// AMPMLower adds an am/pm marker in lowercase to the format.
|
||||
func (b *Builder) AMPMLower() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, AMPMLower)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneOffset adds a timezone offset as ±HHMM (e.g., -0700) to the format.
|
||||
func (b *Builder) TimezoneOffset() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneOffset)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneOffsetColon adds a timezone offset as ±HH:MM (e.g., -07:00) to the format.
|
||||
func (b *Builder) TimezoneOffsetColon() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneOffsetColon)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneOffsetHourOnly adds a timezone offset hours only as ±HH (e.g., -07) to the format.
|
||||
func (b *Builder) TimezoneOffsetHourOnly() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneOffsetHourOnly)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneOffsetSeconds adds a timezone offset with seconds as ±HHMMSS (e.g., -070000) to the format.
|
||||
func (b *Builder) TimezoneOffsetSeconds() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneOffsetSeconds)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneOffsetColonSeconds adds a timezone offset with seconds as ±HH:MM:SS (e.g., -07:00:00) to the format.
|
||||
func (b *Builder) TimezoneOffsetColonSeconds() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneOffsetColonSeconds)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneISO8601 adds an ISO 8601 timezone with Z for UTC (e.g., Z or -0700) to the format.
|
||||
func (b *Builder) TimezoneISO8601() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneISO8601)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneISO8601Colon adds an ISO 8601 timezone with colon (e.g., Z or -07:00) to the format.
|
||||
func (b *Builder) TimezoneISO8601Colon() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneISO8601Colon)
|
||||
return b
|
||||
}
|
||||
|
||||
// TimezoneName adds a timezone abbreviation (e.g., MST, PST) to the format.
|
||||
func (b *Builder) TimezoneName() *Builder {
|
||||
b.format.fragments = append(b.format.fragments, TimezoneName)
|
||||
return b
|
||||
}
|
||||
|
||||
// Literal adds arbitrary literal text to the format.
|
||||
func (b *Builder) Literal(s string) *Builder {
|
||||
b.format.fragments = append(b.format.fragments, s)
|
||||
return b
|
||||
}
|
||||
|
||||
// Dash adds a dash (-) to the format.
|
||||
func (b *Builder) Dash() *Builder {
|
||||
return b.Literal("-")
|
||||
}
|
||||
|
||||
// Slash adds a slash (/) to the format.
|
||||
func (b *Builder) Slash() *Builder {
|
||||
return b.Literal("/")
|
||||
}
|
||||
|
||||
// Colon adds a colon (:) to the format.
|
||||
func (b *Builder) Colon() *Builder {
|
||||
return b.Literal(":")
|
||||
}
|
||||
|
||||
// Space adds a space ( ) to the format.
|
||||
func (b *Builder) Space() *Builder {
|
||||
return b.Literal(" ")
|
||||
}
|
||||
|
||||
// T adds a literal 'T' to the format (commonly used in ISO 8601).
|
||||
func (b *Builder) T() *Builder {
|
||||
return b.Literal("T")
|
||||
}
|
||||
|
||||
// Comma adds a comma and space (, ) to the format.
|
||||
func (b *Builder) Comma() *Builder {
|
||||
return b.Literal(", ")
|
||||
}
|
||||
|
||||
// Period adds a period (.) to the format.
|
||||
func (b *Builder) Period() *Builder {
|
||||
return b.Literal(".")
|
||||
}
|
||||
|
||||
// Build finalizes the builder and returns the constructed Format.
|
||||
func (b *Builder) Build() *Format {
|
||||
return b.format
|
||||
}
|
||||
346
builder_test.go
Normal file
346
builder_test.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package timefmt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuilder_BasicFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
builder func() *Builder
|
||||
wantGo string
|
||||
wantLDML string
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
builder: func() *Builder {
|
||||
return NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2()
|
||||
},
|
||||
wantGo: "2006-01-02",
|
||||
wantLDML: "yyyy-MM-dd",
|
||||
},
|
||||
{
|
||||
name: "24-hour time",
|
||||
builder: func() *Builder {
|
||||
return NewBuilder().
|
||||
Hour24().Colon().Minute().Colon().Second()
|
||||
},
|
||||
wantGo: "15:04:05",
|
||||
wantLDML: "HH:mm:ss",
|
||||
},
|
||||
{
|
||||
name: "Full datetime",
|
||||
builder: func() *Builder {
|
||||
return NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
Space().
|
||||
Hour24().Colon().Minute().Colon().Second()
|
||||
},
|
||||
wantGo: "2006-01-02 15:04:05",
|
||||
wantLDML: "yyyy-MM-dd HH:mm:ss",
|
||||
},
|
||||
{
|
||||
name: "12-hour with AM/PM",
|
||||
builder: func() *Builder {
|
||||
return NewBuilder().
|
||||
Hour12().Colon().Minute().Space().AMPM()
|
||||
},
|
||||
wantGo: "3:04 PM",
|
||||
wantLDML: "h:mm a",
|
||||
},
|
||||
{
|
||||
name: "US date format",
|
||||
builder: func() *Builder {
|
||||
return NewBuilder().
|
||||
MonthNumeric2().Slash().DayNumeric2().Slash().Year4()
|
||||
},
|
||||
wantGo: "01/02/2006",
|
||||
wantLDML: "MM/dd/yyyy",
|
||||
},
|
||||
{
|
||||
name: "European date format",
|
||||
builder: func() *Builder {
|
||||
return NewBuilder().
|
||||
DayNumeric2().Slash().MonthNumeric2().Slash().Year4()
|
||||
},
|
||||
wantGo: "02/01/2006",
|
||||
wantLDML: "dd/MM/yyyy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
format := tt.builder().Build()
|
||||
|
||||
gotGo := format.GoFormat()
|
||||
if gotGo != tt.wantGo {
|
||||
t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo)
|
||||
}
|
||||
|
||||
gotLDML := format.LDML()
|
||||
if gotLDML != tt.wantLDML {
|
||||
t.Errorf("LDML() = %q, want %q", gotLDML, tt.wantLDML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_ChainableMethods(t *testing.T) {
|
||||
// Test that all methods return *Builder for chaining
|
||||
builder := NewBuilder()
|
||||
|
||||
result := builder.
|
||||
Year4().Year2().
|
||||
MonthNumeric().MonthNumeric2().MonthShort().MonthFull().
|
||||
DayNumeric().DayNumeric2().DaySpacePadded().DayOfYear().
|
||||
WeekdayShort().WeekdayFull().
|
||||
Hour24().Hour12().Hour12Padded().
|
||||
Minute().MinuteUnpadded().
|
||||
Second().SecondUnpadded().
|
||||
Millisecond().Microsecond().Nanosecond().
|
||||
AMPM().AMPMLower().
|
||||
TimezoneOffset().TimezoneOffsetColon().TimezoneName().
|
||||
Literal("test").Dash().Slash().Colon().Space().T().Comma().Period()
|
||||
|
||||
if result == nil {
|
||||
t.Error("Builder methods should return *Builder for chaining")
|
||||
}
|
||||
|
||||
// Verify it's the same builder instance (mutable pattern)
|
||||
if result != builder {
|
||||
t.Error("Builder methods should return the same builder instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_LiteralShortcuts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
builder func() *Builder
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Dash",
|
||||
builder: func() *Builder { return NewBuilder().Dash() },
|
||||
want: "-",
|
||||
},
|
||||
{
|
||||
name: "Slash",
|
||||
builder: func() *Builder { return NewBuilder().Slash() },
|
||||
want: "/",
|
||||
},
|
||||
{
|
||||
name: "Colon",
|
||||
builder: func() *Builder { return NewBuilder().Colon() },
|
||||
want: ":",
|
||||
},
|
||||
{
|
||||
name: "Space",
|
||||
builder: func() *Builder { return NewBuilder().Space() },
|
||||
want: " ",
|
||||
},
|
||||
{
|
||||
name: "T",
|
||||
builder: func() *Builder { return NewBuilder().T() },
|
||||
want: "T",
|
||||
},
|
||||
{
|
||||
name: "Comma",
|
||||
builder: func() *Builder { return NewBuilder().Comma() },
|
||||
want: ", ",
|
||||
},
|
||||
{
|
||||
name: "Period",
|
||||
builder: func() *Builder { return NewBuilder().Period() },
|
||||
want: ".",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
format := tt.builder().Build()
|
||||
got := format.GoFormat()
|
||||
if got != tt.want {
|
||||
t.Errorf("GoFormat() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_ComplexFormat(t *testing.T) {
|
||||
// Build a complex format with multiple components
|
||||
format := NewBuilder().
|
||||
WeekdayFull().Comma().
|
||||
MonthFull().Space().DayNumeric().Comma().Year4().
|
||||
Space().Literal("at").Space().
|
||||
Hour12().Colon().Minute().Space().AMPM().
|
||||
Space().Literal("(").TimezoneName().Literal(")").
|
||||
Build()
|
||||
|
||||
wantGo := "Monday, January 2, 2006 at 3:04 PM (MST)"
|
||||
gotGo := format.GoFormat()
|
||||
if gotGo != wantGo {
|
||||
t.Errorf("GoFormat() = %q, want %q", gotGo, wantGo)
|
||||
}
|
||||
|
||||
// Test that it actually formats correctly
|
||||
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.FixedZone("MST", -7*3600))
|
||||
formatted := format.Format(testTime)
|
||||
expected := "Sunday, February 8, 2026 at 3:04 PM (MST)"
|
||||
if formatted != expected {
|
||||
t.Errorf("Format() = %q, want %q", formatted, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrebuiltFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
wantGo string
|
||||
}{
|
||||
{
|
||||
name: "ISO8601",
|
||||
format: ISO8601,
|
||||
wantGo: "2006-01-02T15:04:05Z07:00",
|
||||
},
|
||||
{
|
||||
name: "RFC3339",
|
||||
format: RFC3339,
|
||||
wantGo: "2006-01-02T15:04:05Z07:00",
|
||||
},
|
||||
{
|
||||
name: "RFC3339Nano",
|
||||
format: RFC3339Nano,
|
||||
wantGo: "2006-01-02T15:04:05.999999999Z07:00",
|
||||
},
|
||||
{
|
||||
name: "DateOnly",
|
||||
format: DateOnly,
|
||||
wantGo: "2006-01-02",
|
||||
},
|
||||
{
|
||||
name: "TimeOnly",
|
||||
format: TimeOnly,
|
||||
wantGo: "15:04:05",
|
||||
},
|
||||
{
|
||||
name: "DateTime",
|
||||
format: DateTime,
|
||||
wantGo: "2006-01-02 15:04:05",
|
||||
},
|
||||
{
|
||||
name: "DateTimeWithMillis",
|
||||
format: DateTimeWithMillis,
|
||||
wantGo: "2006-01-02 15:04:05.000",
|
||||
},
|
||||
{
|
||||
name: "DateUS",
|
||||
format: DateUS,
|
||||
wantGo: "01/02/2006",
|
||||
},
|
||||
{
|
||||
name: "DateEU",
|
||||
format: DateEU,
|
||||
wantGo: "02/01/2006",
|
||||
},
|
||||
{
|
||||
name: "DateTimeUS",
|
||||
format: DateTimeUS,
|
||||
wantGo: "01/02/2006 3:04:05 PM",
|
||||
},
|
||||
{
|
||||
name: "DateTimeEU",
|
||||
format: DateTimeEU,
|
||||
wantGo: "02/01/2006 15:04:05",
|
||||
},
|
||||
{
|
||||
name: "Kitchen",
|
||||
format: Kitchen,
|
||||
wantGo: "3:04 PM",
|
||||
},
|
||||
{
|
||||
name: "Stamp",
|
||||
format: Stamp,
|
||||
wantGo: "Jan _2 15:04:05",
|
||||
},
|
||||
{
|
||||
name: "StampMilli",
|
||||
format: StampMilli,
|
||||
wantGo: "Jan _2 15:04:05.000",
|
||||
},
|
||||
{
|
||||
name: "StampMicro",
|
||||
format: StampMicro,
|
||||
wantGo: "Jan _2 15:04:05.000000",
|
||||
},
|
||||
{
|
||||
name: "StampNano",
|
||||
format: StampNano,
|
||||
wantGo: "Jan _2 15:04:05.000000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.format == nil {
|
||||
t.Fatal("Format is nil")
|
||||
}
|
||||
|
||||
gotGo := tt.format.GoFormat()
|
||||
if gotGo != tt.wantGo {
|
||||
t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo)
|
||||
}
|
||||
|
||||
// Verify LDML and Description don't panic
|
||||
_ = tt.format.LDML()
|
||||
_ = tt.format.Description()
|
||||
_ = tt.format.Example()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrebuiltFormats_Formatting(t *testing.T) {
|
||||
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "DateOnly",
|
||||
format: DateOnly,
|
||||
want: "2026-02-08",
|
||||
},
|
||||
{
|
||||
name: "TimeOnly",
|
||||
format: TimeOnly,
|
||||
want: "15:04:05",
|
||||
},
|
||||
{
|
||||
name: "DateTime",
|
||||
format: DateTime,
|
||||
want: "2026-02-08 15:04:05",
|
||||
},
|
||||
{
|
||||
name: "DateUS",
|
||||
format: DateUS,
|
||||
want: "02/08/2026",
|
||||
},
|
||||
{
|
||||
name: "DateEU",
|
||||
format: DateEU,
|
||||
want: "08/02/2026",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.format.Format(testTime)
|
||||
if got != tt.want {
|
||||
t.Errorf("Format() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
57
cmd/demo/demo.go
Normal file
57
cmd/demo/demo.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"git.haelnorr.com/h/timefmt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== timefmt Demo ===\n")
|
||||
|
||||
// 1. Building a custom format
|
||||
fmt.Println("1. Building a custom format:")
|
||||
format := timefmt.NewBuilder().
|
||||
Year4().Dash().
|
||||
MonthNumeric2().Dash().
|
||||
DayNumeric2().Space().
|
||||
Hour24().Colon().
|
||||
Minute().Colon().
|
||||
Second().
|
||||
Build()
|
||||
|
||||
fmt.Printf(" Go format: %s\n", format.GoFormat())
|
||||
fmt.Printf(" LDML: %s\n", format.LDML())
|
||||
fmt.Printf(" Description: %s\n\n", format.Description())
|
||||
|
||||
// 2. Using pre-built formats
|
||||
fmt.Println("2. Pre-built formats:")
|
||||
now := time.Now()
|
||||
fmt.Printf(" ISO8601: %s\n", timefmt.ISO8601.Format(now))
|
||||
fmt.Printf(" DateTime: %s\n", timefmt.DateTime.Format(now))
|
||||
fmt.Printf(" DateUS: %s\n", timefmt.DateUS.Format(now))
|
||||
fmt.Printf(" Kitchen: %s\n\n", timefmt.Kitchen.Format(now))
|
||||
|
||||
// 3. Parsing existing Go formats
|
||||
fmt.Println("3. Parsing an existing Go format:")
|
||||
parsed, _ := timefmt.ParseGoFormat("02/01/2006")
|
||||
fmt.Printf(" Input: 02/01/2006\n")
|
||||
fmt.Printf(" LDML: %s\n", parsed.LDML())
|
||||
fmt.Printf(" Description: %s\n\n", parsed.Description())
|
||||
|
||||
// 4. Complex format example
|
||||
fmt.Println("4. Complex format with custom text:")
|
||||
complex := timefmt.NewBuilder().
|
||||
WeekdayFull().Comma().
|
||||
MonthFull().Space().
|
||||
DayNumeric().Comma().
|
||||
Year4().Space().
|
||||
Literal("at").Space().
|
||||
Hour12().Colon().
|
||||
Minute().Space().
|
||||
AMPM().
|
||||
Build()
|
||||
|
||||
fmt.Printf(" Formatted: %s\n", complex.Format(now))
|
||||
fmt.Printf(" LDML: %s\n", complex.LDML())
|
||||
}
|
||||
264
constants.go
Normal file
264
constants.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package timefmt
|
||||
|
||||
// Fragment represents a single component of a time format.
|
||||
// It maps between Go's reference time format, LDML tokens, and human-readable descriptions.
|
||||
type Fragment struct {
|
||||
// GoFormat is Go's reference time format (e.g., "2006" for 4-digit year)
|
||||
GoFormat string
|
||||
// LDML is the Unicode LDML token (e.g., "yyyy" for 4-digit year)
|
||||
LDML string
|
||||
// Description is a human-readable English description (e.g., "Year (4-digit)")
|
||||
Description string
|
||||
}
|
||||
|
||||
// Year fragments represent different year formats
|
||||
var (
|
||||
// Year4Digit represents a 4-digit year (e.g., 2026)
|
||||
Year4Digit = Fragment{GoFormat: "2006", LDML: "yyyy", Description: "Year (4-digit)"}
|
||||
// Year2Digit represents a 2-digit year (e.g., 26)
|
||||
Year2Digit = Fragment{GoFormat: "06", LDML: "yy", Description: "Year (2-digit)"}
|
||||
)
|
||||
|
||||
// Month fragments represent different month formats
|
||||
var (
|
||||
// MonthNumeric represents a numeric month without leading zero (1-12)
|
||||
MonthNumeric = Fragment{GoFormat: "1", LDML: "M", Description: "Month (numeric)"}
|
||||
// MonthNumeric2 represents a 2-digit month with leading zero (01-12)
|
||||
MonthNumeric2 = Fragment{GoFormat: "01", LDML: "MM", Description: "Month (2-digit)"}
|
||||
// MonthShort represents an abbreviated month name (Jan, Feb, Mar, etc.)
|
||||
MonthShort = Fragment{GoFormat: "Jan", LDML: "MMM", Description: "Month (abbreviated)"}
|
||||
// MonthFull represents a full month name (January, February, March, etc.)
|
||||
MonthFull = Fragment{GoFormat: "January", LDML: "MMMM", Description: "Month (full name)"}
|
||||
)
|
||||
|
||||
// Day fragments represent different day-of-month formats
|
||||
var (
|
||||
// DayNumeric represents a numeric day without leading zero (1-31)
|
||||
DayNumeric = Fragment{GoFormat: "2", LDML: "d", Description: "Day (numeric)"}
|
||||
// DayNumeric2 represents a 2-digit day with leading zero (01-31)
|
||||
DayNumeric2 = Fragment{GoFormat: "02", LDML: "dd", Description: "Day (2-digit)"}
|
||||
// DaySpacePadded represents a space-padded day ( 1-31)
|
||||
DaySpacePadded = Fragment{GoFormat: "_2", LDML: "d", Description: "Day (space-padded)"}
|
||||
// DayOfYearNumeric represents the day of year as a 3-digit number (001-365)
|
||||
DayOfYearNumeric = Fragment{GoFormat: "002", LDML: "DDD", Description: "Day of year (3-digit)"}
|
||||
// DayOfYearSpacePadded represents the day of year as a space-padded number ( 1-365)
|
||||
DayOfYearSpacePadded = Fragment{GoFormat: "__2", LDML: "DDD", Description: "Day of year (space-padded)"}
|
||||
)
|
||||
|
||||
// Weekday fragments represent different weekday formats
|
||||
var (
|
||||
// WeekdayShort represents an abbreviated weekday name (Mon, Tue, Wed, etc.)
|
||||
WeekdayShort = Fragment{GoFormat: "Mon", LDML: "EEE", Description: "Weekday (abbreviated)"}
|
||||
// WeekdayFull represents a full weekday name (Monday, Tuesday, Wednesday, etc.)
|
||||
WeekdayFull = Fragment{GoFormat: "Monday", LDML: "EEEE", Description: "Weekday (full name)"}
|
||||
)
|
||||
|
||||
// Hour fragments represent different hour formats
|
||||
var (
|
||||
// Hour24 represents 24-hour format with leading zero (00-23)
|
||||
Hour24 = Fragment{GoFormat: "15", LDML: "HH", Description: "Hour (24-hour, 2-digit)"}
|
||||
// Hour12 represents 12-hour format without leading zero (1-12)
|
||||
Hour12 = Fragment{GoFormat: "3", LDML: "h", Description: "Hour (12-hour)"}
|
||||
// Hour12Padded represents 12-hour format with leading zero (01-12)
|
||||
Hour12Padded = Fragment{GoFormat: "03", LDML: "hh", Description: "Hour (12-hour, 2-digit)"}
|
||||
)
|
||||
|
||||
// Minute fragments represent different minute formats
|
||||
var (
|
||||
// Minute represents minutes with leading zero (00-59)
|
||||
Minute = Fragment{GoFormat: "04", LDML: "mm", Description: "Minute (2-digit)"}
|
||||
// MinuteUnpadded represents minutes without leading zero (0-59)
|
||||
MinuteUnpadded = Fragment{GoFormat: "4", LDML: "m", Description: "Minute"}
|
||||
)
|
||||
|
||||
// Second fragments represent different second formats
|
||||
var (
|
||||
// Second represents seconds with leading zero (00-59)
|
||||
Second = Fragment{GoFormat: "05", LDML: "ss", Description: "Second (2-digit)"}
|
||||
// SecondUnpadded represents seconds without leading zero (0-59)
|
||||
SecondUnpadded = Fragment{GoFormat: "5", LDML: "s", Description: "Second"}
|
||||
)
|
||||
|
||||
// Subsecond fragments represent fractional seconds
|
||||
var (
|
||||
// Millisecond represents milliseconds as 3 digits (.000)
|
||||
Millisecond = Fragment{GoFormat: ".000", LDML: ".SSS", Description: "Millisecond (3-digit)"}
|
||||
// MillisecondTrim represents milliseconds with trailing zeros removed (.999)
|
||||
MillisecondTrim = Fragment{GoFormat: ".999", LDML: ".SSS", Description: "Millisecond (trim zeros)"}
|
||||
// Microsecond represents microseconds as 6 digits (.000000)
|
||||
Microsecond = Fragment{GoFormat: ".000000", LDML: ".SSSSSS", Description: "Microsecond (6-digit)"}
|
||||
// MicrosecondTrim represents microseconds with trailing zeros removed (.999999)
|
||||
MicrosecondTrim = Fragment{GoFormat: ".999999", LDML: ".SSSSSS", Description: "Microsecond (trim zeros)"}
|
||||
// Nanosecond represents nanoseconds as 9 digits (.000000000)
|
||||
Nanosecond = Fragment{GoFormat: ".000000000", LDML: ".SSSSSSSSS", Description: "Nanosecond (9-digit)"}
|
||||
// NanosecondTrim represents nanoseconds with trailing zeros removed (.999999999)
|
||||
NanosecondTrim = Fragment{GoFormat: ".999999999", LDML: ".SSSSSSSSS", Description: "Nanosecond (trim zeros)"}
|
||||
)
|
||||
|
||||
// AMPM fragments represent AM/PM markers
|
||||
var (
|
||||
// AMPM represents AM/PM in uppercase
|
||||
AMPM = Fragment{GoFormat: "PM", LDML: "a", Description: "AM/PM (uppercase)"}
|
||||
// AMPMLower represents am/pm in lowercase
|
||||
AMPMLower = Fragment{GoFormat: "pm", LDML: "a", Description: "AM/PM (lowercase)"}
|
||||
)
|
||||
|
||||
// Timezone fragments represent different timezone formats
|
||||
var (
|
||||
// TimezoneOffset represents timezone offset as ±HHMM (e.g., -0700)
|
||||
TimezoneOffset = Fragment{GoFormat: "-0700", LDML: "ZZZ", Description: "Timezone offset (±HHMM)"}
|
||||
// TimezoneOffsetColon represents timezone offset as ±HH:MM (e.g., -07:00)
|
||||
TimezoneOffsetColon = Fragment{GoFormat: "-07:00", LDML: "ZZZZZ", Description: "Timezone offset (±HH:MM)"}
|
||||
// TimezoneOffsetHourOnly represents timezone offset hours only as ±HH (e.g., -07)
|
||||
TimezoneOffsetHourOnly = Fragment{GoFormat: "-07", LDML: "ZZ", Description: "Timezone offset (±HH)"}
|
||||
// TimezoneOffsetSeconds represents timezone offset with seconds as ±HHMMSS (e.g., -070000)
|
||||
TimezoneOffsetSeconds = Fragment{GoFormat: "-070000", LDML: "ZZZZ", Description: "Timezone offset (±HHMMSS)"}
|
||||
// TimezoneOffsetColonSeconds represents timezone offset with seconds as ±HH:MM:SS (e.g., -07:00:00)
|
||||
TimezoneOffsetColonSeconds = Fragment{GoFormat: "-07:00:00", LDML: "ZZZZZ", Description: "Timezone offset (±HH:MM:SS)"}
|
||||
// TimezoneISO8601 represents ISO 8601 timezone with Z for UTC (e.g., Z or -0700)
|
||||
TimezoneISO8601 = Fragment{GoFormat: "Z0700", LDML: "ZZZ", Description: "ISO 8601 timezone (Z or ±HHMM)"}
|
||||
// TimezoneISO8601Colon represents ISO 8601 timezone with colon (e.g., Z or -07:00)
|
||||
TimezoneISO8601Colon = Fragment{GoFormat: "Z07:00", LDML: "ZZZZZ", Description: "ISO 8601 timezone (Z or ±HH:MM)"}
|
||||
// TimezoneName represents timezone abbreviation (e.g., MST, PST)
|
||||
TimezoneName = Fragment{GoFormat: "MST", LDML: "zzz", Description: "Timezone abbreviation"}
|
||||
)
|
||||
|
||||
// Pre-built common formats for convenience.
|
||||
// These are initialized in init() to avoid initialization cycles.
|
||||
var (
|
||||
// ISO8601 represents the ISO 8601 datetime format: 2006-01-02T15:04:05Z07:00
|
||||
ISO8601 *Format
|
||||
// RFC3339 represents the RFC 3339 datetime format (same as ISO8601): 2006-01-02T15:04:05Z07:00
|
||||
RFC3339 *Format
|
||||
// RFC3339Nano represents the RFC 3339 datetime format with nanoseconds: 2006-01-02T15:04:05.999999999Z07:00
|
||||
RFC3339Nano *Format
|
||||
// DateOnly represents a date-only format: 2006-01-02
|
||||
DateOnly *Format
|
||||
// TimeOnly represents a time-only format: 15:04:05
|
||||
TimeOnly *Format
|
||||
// DateTime represents a simple datetime format: 2006-01-02 15:04:05
|
||||
DateTime *Format
|
||||
// DateTimeWithMillis represents datetime with milliseconds: 2006-01-02 15:04:05.000
|
||||
DateTimeWithMillis *Format
|
||||
// DateUS represents US date format: 01/02/2006
|
||||
DateUS *Format
|
||||
// DateEU represents European date format: 02/01/2006
|
||||
DateEU *Format
|
||||
// DateTimeUS represents US datetime format: 01/02/2006 3:04:05 PM
|
||||
DateTimeUS *Format
|
||||
// DateTimeEU represents European datetime format: 02/01/2006 15:04:05
|
||||
DateTimeEU *Format
|
||||
// Kitchen represents kitchen time format: 3:04 PM
|
||||
Kitchen *Format
|
||||
// Stamp represents a timestamp format: Jan _2 15:04:05
|
||||
Stamp *Format
|
||||
// StampMilli represents a timestamp with milliseconds: Jan _2 15:04:05.000
|
||||
StampMilli *Format
|
||||
// StampMicro represents a timestamp with microseconds: Jan _2 15:04:05.000000
|
||||
StampMicro *Format
|
||||
// StampNano represents a timestamp with nanoseconds: Jan _2 15:04:05.000000000
|
||||
StampNano *Format
|
||||
)
|
||||
|
||||
func init() {
|
||||
// ISO 8601 / RFC 3339: 2006-01-02T15:04:05Z07:00
|
||||
ISO8601 = NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
T().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
TimezoneISO8601Colon().
|
||||
Build()
|
||||
RFC3339 = ISO8601 // RFC 3339 is the same as ISO 8601
|
||||
|
||||
// RFC 3339 with nanoseconds: 2006-01-02T15:04:05.999999999Z07:00
|
||||
RFC3339Nano = NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
T().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
NanosecondTrim().
|
||||
TimezoneISO8601Colon().
|
||||
Build()
|
||||
|
||||
// Date only: 2006-01-02
|
||||
DateOnly = NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
Build()
|
||||
|
||||
// Time only: 15:04:05
|
||||
TimeOnly = NewBuilder().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Build()
|
||||
|
||||
// DateTime: 2006-01-02 15:04:05
|
||||
DateTime = NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Build()
|
||||
|
||||
// DateTime with milliseconds: 2006-01-02 15:04:05.000
|
||||
DateTimeWithMillis = NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Millisecond().
|
||||
Build()
|
||||
|
||||
// US date: 01/02/2006
|
||||
DateUS = NewBuilder().
|
||||
MonthNumeric2().Slash().DayNumeric2().Slash().Year4().
|
||||
Build()
|
||||
|
||||
// European date: 02/01/2006
|
||||
DateEU = NewBuilder().
|
||||
DayNumeric2().Slash().MonthNumeric2().Slash().Year4().
|
||||
Build()
|
||||
|
||||
// US datetime: 01/02/2006 3:04:05 PM
|
||||
DateTimeUS = NewBuilder().
|
||||
MonthNumeric2().Slash().DayNumeric2().Slash().Year4().
|
||||
Space().
|
||||
Hour12().Colon().Minute().Colon().Second().
|
||||
Space().AMPM().
|
||||
Build()
|
||||
|
||||
// European datetime: 02/01/2006 15:04:05
|
||||
DateTimeEU = NewBuilder().
|
||||
DayNumeric2().Slash().MonthNumeric2().Slash().Year4().
|
||||
Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Build()
|
||||
|
||||
// Kitchen: 3:04 PM
|
||||
Kitchen = NewBuilder().
|
||||
Hour12().Colon().Minute().
|
||||
Space().AMPM().
|
||||
Build()
|
||||
|
||||
// Stamp: Jan _2 15:04:05
|
||||
Stamp = NewBuilder().
|
||||
MonthShort().Space().DaySpacePadded().Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Build()
|
||||
|
||||
// Stamp with milliseconds: Jan _2 15:04:05.000
|
||||
StampMilli = NewBuilder().
|
||||
MonthShort().Space().DaySpacePadded().Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Millisecond().
|
||||
Build()
|
||||
|
||||
// Stamp with microseconds: Jan _2 15:04:05.000000
|
||||
StampMicro = NewBuilder().
|
||||
MonthShort().Space().DaySpacePadded().Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Microsecond().
|
||||
Build()
|
||||
|
||||
// Stamp with nanoseconds: Jan _2 15:04:05.000000000
|
||||
StampNano = NewBuilder().
|
||||
MonthShort().Space().DaySpacePadded().Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Nanosecond().
|
||||
Build()
|
||||
}
|
||||
143
converter.go
Normal file
143
converter.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package timefmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseGoFormat parses a Go time format string and returns a Format.
|
||||
// It analyzes the Go reference time format (e.g., "2006-01-02 15:04:05")
|
||||
// and converts it into a Format with the appropriate fragments.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// format, err := ParseGoFormat("2006-01-02 15:04:05")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Println(format.LDML()) // "yyyy-MM-dd HH:mm:ss"
|
||||
// fmt.Println(format.Description()) // Full English description
|
||||
func ParseGoFormat(goFormat string) (*Format, error) {
|
||||
if goFormat == "" {
|
||||
return nil, fmt.Errorf("empty format string")
|
||||
}
|
||||
|
||||
// Define token mappings in order of precedence to avoid partial matches
|
||||
// CRITICAL: Patterns that could conflict must be ordered carefully!
|
||||
// - Longer patterns before shorter
|
||||
// - More specific patterns before general
|
||||
// - Unique identifiers (like "15" for hour, "04" for minute) before ambiguous ones
|
||||
tokens := []struct {
|
||||
goToken string
|
||||
fragment Fragment
|
||||
}{
|
||||
// Subseconds (must come before other number patterns due to decimal point)
|
||||
{".000000000", Nanosecond},
|
||||
{".999999999", NanosecondTrim},
|
||||
{".000000", Microsecond},
|
||||
{".999999", MicrosecondTrim},
|
||||
{".000", Millisecond},
|
||||
{".999", MillisecondTrim},
|
||||
|
||||
// Timezone (longer patterns first)
|
||||
{"-07:00:00", TimezoneOffsetColonSeconds},
|
||||
{"-070000", TimezoneOffsetSeconds},
|
||||
{"Z07:00", TimezoneISO8601Colon},
|
||||
{"Z0700", TimezoneISO8601},
|
||||
{"-07:00", TimezoneOffsetColon},
|
||||
{"-0700", TimezoneOffset},
|
||||
{"-07", TimezoneOffsetHourOnly},
|
||||
{"MST", TimezoneName},
|
||||
|
||||
// Year (must come before month numbers)
|
||||
{"2006", Year4Digit},
|
||||
{"06", Year2Digit},
|
||||
|
||||
// Month names (before numeric months to avoid conflicts)
|
||||
{"January", MonthFull},
|
||||
{"Jan", MonthShort},
|
||||
|
||||
// Weekday names (before numeric days)
|
||||
{"Monday", WeekdayFull},
|
||||
{"Mon", WeekdayShort},
|
||||
|
||||
// Day of year (before regular days, longer patterns first)
|
||||
{"__2", DayOfYearSpacePadded},
|
||||
{"002", DayOfYearNumeric},
|
||||
|
||||
// Time components (MUST come before month/day numbers to avoid conflicts!)
|
||||
// "15" is unique to 24-hour format, "04" is unique to minutes, "05" to seconds
|
||||
{"15", Hour24}, // Must come before "1" (month) and "5" (second)
|
||||
{"04", Minute}, // Must come before "4" (minute unpadded)
|
||||
{"05", Second}, // Must come before "5" (second unpadded)
|
||||
{"03", Hour12Padded}, // Must come before "3" (hour)
|
||||
|
||||
// Month and day numbers (after time components!)
|
||||
{"01", MonthNumeric2}, // Padded month
|
||||
{"02", DayNumeric2}, // Padded day
|
||||
{"_2", DaySpacePadded}, // Space-padded day
|
||||
|
||||
// Single digit patterns (LAST to avoid premature matching!)
|
||||
{"1", MonthNumeric},
|
||||
{"2", DayNumeric},
|
||||
{"3", Hour12},
|
||||
{"4", MinuteUnpadded},
|
||||
{"5", SecondUnpadded},
|
||||
|
||||
// AM/PM
|
||||
{"PM", AMPM},
|
||||
{"pm", AMPMLower},
|
||||
}
|
||||
|
||||
var fragments []interface{}
|
||||
i := 0
|
||||
|
||||
for i < len(goFormat) {
|
||||
matched := false
|
||||
|
||||
// Try to match tokens (longest first)
|
||||
for _, token := range tokens {
|
||||
if strings.HasPrefix(goFormat[i:], token.goToken) {
|
||||
fragments = append(fragments, token.fragment)
|
||||
i += len(token.goToken)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
// This is a literal character
|
||||
// Collect consecutive literal characters
|
||||
literalStart := i
|
||||
i++
|
||||
// Continue collecting until we hit a token
|
||||
for i < len(goFormat) {
|
||||
foundToken := false
|
||||
for _, token := range tokens {
|
||||
if strings.HasPrefix(goFormat[i:], token.goToken) {
|
||||
foundToken = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundToken {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
literal := goFormat[literalStart:i]
|
||||
fragments = append(fragments, literal)
|
||||
}
|
||||
}
|
||||
|
||||
return &Format{fragments: fragments}, nil
|
||||
}
|
||||
|
||||
// MustParseGoFormat is like ParseGoFormat but panics on error.
|
||||
// It's useful for initialization of package-level variables.
|
||||
func MustParseGoFormat(goFormat string) *Format {
|
||||
format, err := ParseGoFormat(goFormat)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("MustParseGoFormat: %v", err))
|
||||
}
|
||||
return format
|
||||
}
|
||||
302
converter_test.go
Normal file
302
converter_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package timefmt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseGoFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goFormat string
|
||||
wantLDML string
|
||||
wantDesc string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
goFormat: "2006-01-02",
|
||||
wantLDML: "yyyy-MM-dd",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)",
|
||||
},
|
||||
{
|
||||
name: "24-hour time",
|
||||
goFormat: "15:04:05",
|
||||
wantLDML: "HH:mm:ss",
|
||||
wantDesc: "Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
|
||||
},
|
||||
{
|
||||
name: "Full datetime",
|
||||
goFormat: "2006-01-02 15:04:05",
|
||||
wantLDML: "yyyy-MM-dd HH:mm:ss",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
|
||||
},
|
||||
{
|
||||
name: "12-hour with AM/PM",
|
||||
goFormat: "3:04 PM",
|
||||
wantLDML: "h:mm a",
|
||||
wantDesc: "Hour (12-hour), colon, Minute (2-digit), space, AM/PM (uppercase)",
|
||||
},
|
||||
{
|
||||
name: "US date format",
|
||||
goFormat: "01/02/2006",
|
||||
wantLDML: "MM/dd/yyyy",
|
||||
wantDesc: "Month (2-digit), slash, Day (2-digit), slash, Year (4-digit)",
|
||||
},
|
||||
{
|
||||
name: "European date format",
|
||||
goFormat: "02/01/2006",
|
||||
wantLDML: "dd/MM/yyyy",
|
||||
wantDesc: "Day (2-digit), slash, Month (2-digit), slash, Year (4-digit)",
|
||||
},
|
||||
{
|
||||
name: "RFC3339",
|
||||
goFormat: "2006-01-02T15:04:05Z07:00",
|
||||
wantLDML: "yyyy-MM-dd'T'HH:mm:ssZZZZZ",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal 'T', Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), ISO 8601 timezone (Z or ±HH:MM)",
|
||||
},
|
||||
{
|
||||
name: "With milliseconds",
|
||||
goFormat: "2006-01-02 15:04:05.000",
|
||||
wantLDML: "yyyy-MM-dd HH:mm:ss.SSS",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), Millisecond (3-digit)",
|
||||
},
|
||||
{
|
||||
name: "With nanoseconds",
|
||||
goFormat: "2006-01-02 15:04:05.000000000",
|
||||
wantLDML: "yyyy-MM-dd HH:mm:ss.SSSSSSSSS",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), Nanosecond (9-digit)",
|
||||
},
|
||||
{
|
||||
name: "Month names",
|
||||
goFormat: "January 2, 2006",
|
||||
wantLDML: "MMMM d, yyyy",
|
||||
wantDesc: "Month (full name), space, Day (numeric), comma-space, Year (4-digit)",
|
||||
},
|
||||
{
|
||||
name: "Abbreviated month and weekday",
|
||||
goFormat: "Mon, Jan 2 2006",
|
||||
wantLDML: "EEE, MMM d yyyy",
|
||||
wantDesc: "Weekday (abbreviated), comma-space, Month (abbreviated), space, Day (numeric), space, Year (4-digit)",
|
||||
},
|
||||
{
|
||||
name: "Full weekday and month",
|
||||
goFormat: "Monday, January 2, 2006",
|
||||
wantLDML: "EEEE, MMMM d, yyyy",
|
||||
wantDesc: "Weekday (full name), comma-space, Month (full name), space, Day (numeric), comma-space, Year (4-digit)",
|
||||
},
|
||||
{
|
||||
name: "With timezone name",
|
||||
goFormat: "2006-01-02 15:04:05 MST",
|
||||
wantLDML: "yyyy-MM-dd HH:mm:ss zzz",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone abbreviation",
|
||||
},
|
||||
{
|
||||
name: "With timezone offset",
|
||||
goFormat: "2006-01-02 15:04:05 -0700",
|
||||
wantLDML: "yyyy-MM-dd HH:mm:ss ZZZ",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone offset (±HHMM)",
|
||||
},
|
||||
{
|
||||
name: "Kitchen time",
|
||||
goFormat: "3:04PM",
|
||||
wantLDML: "h:mma",
|
||||
wantDesc: "Hour (12-hour), colon, Minute (2-digit), AM/PM (uppercase)",
|
||||
},
|
||||
{
|
||||
name: "With literal text",
|
||||
goFormat: "2006-01-02 at 15:04",
|
||||
wantLDML: "yyyy-MM-dd' at 'HH:mm",
|
||||
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal ' at ', Hour (24-hour, 2-digit), colon, Minute (2-digit)",
|
||||
},
|
||||
{
|
||||
name: "Space-padded day",
|
||||
goFormat: "Jan _2 15:04:05",
|
||||
wantLDML: "MMM d HH:mm:ss",
|
||||
wantDesc: "Month (abbreviated), space, Day (space-padded), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
goFormat: "",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
format, err := ParseGoFormat(tt.goFormat)
|
||||
|
||||
if tt.shouldError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGoFormat() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify the format can reproduce the original Go format
|
||||
gotGoFormat := format.GoFormat()
|
||||
if gotGoFormat != tt.goFormat {
|
||||
t.Errorf("GoFormat() = %q, want %q", gotGoFormat, tt.goFormat)
|
||||
}
|
||||
|
||||
// Verify LDML conversion
|
||||
gotLDML := format.LDML()
|
||||
if gotLDML != tt.wantLDML {
|
||||
t.Errorf("LDML() = %q, want %q", gotLDML, tt.wantLDML)
|
||||
}
|
||||
|
||||
// Verify description
|
||||
gotDesc := format.Description()
|
||||
if gotDesc != tt.wantDesc {
|
||||
t.Errorf("Description() = %q, want %q", gotDesc, tt.wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGoFormat_StdlibFormats(t *testing.T) {
|
||||
// Test that we can parse all the standard library time format constants
|
||||
tests := []struct {
|
||||
name string
|
||||
goFormat string
|
||||
}{
|
||||
{"time.ANSIC", "Mon Jan _2 15:04:05 2006"},
|
||||
{"time.UnixDate", "Mon Jan _2 15:04:05 MST 2006"},
|
||||
{"time.RubyDate", "Mon Jan 02 15:04:05 -0700 2006"},
|
||||
{"time.RFC822", "02 Jan 06 15:04 MST"},
|
||||
{"time.RFC822Z", "02 Jan 06 15:04 -0700"},
|
||||
{"time.RFC850", "Monday, 02-Jan-06 15:04:05 MST"},
|
||||
{"time.RFC1123", "Mon, 02 Jan 2006 15:04:05 MST"},
|
||||
{"time.RFC1123Z", "Mon, 02 Jan 2006 15:04:05 -0700"},
|
||||
{"time.RFC3339", "2006-01-02T15:04:05Z07:00"},
|
||||
{"time.RFC3339Nano", "2006-01-02T15:04:05.999999999Z07:00"},
|
||||
{"time.Kitchen", "3:04PM"},
|
||||
{"time.Stamp", "Jan _2 15:04:05"},
|
||||
{"time.StampMilli", "Jan _2 15:04:05.000"},
|
||||
{"time.StampMicro", "Jan _2 15:04:05.000000"},
|
||||
{"time.StampNano", "Jan _2 15:04:05.000000000"},
|
||||
{"time.DateTime", "2006-01-02 15:04:05"},
|
||||
{"time.DateOnly", "2006-01-02"},
|
||||
{"time.TimeOnly", "15:04:05"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
format, err := ParseGoFormat(tt.goFormat)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGoFormat() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify roundtrip
|
||||
gotGoFormat := format.GoFormat()
|
||||
if gotGoFormat != tt.goFormat {
|
||||
t.Errorf("GoFormat() = %q, want %q", gotGoFormat, tt.goFormat)
|
||||
}
|
||||
|
||||
// Verify it can actually format a time
|
||||
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.FixedZone("MST", -7*3600))
|
||||
formatted := format.Format(testTime)
|
||||
if formatted == "" {
|
||||
t.Error("Format() returned empty string")
|
||||
}
|
||||
|
||||
// Verify LDML and Description don't panic
|
||||
_ = format.LDML()
|
||||
_ = format.Description()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGoFormat_RoundTrip(t *testing.T) {
|
||||
// Test that parsing a format and converting back to Go format is lossless
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"01/02/2006 3:04:05 PM",
|
||||
"Monday, January 2, 2006",
|
||||
"Jan _2 15:04:05.000",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"02 Jan 06 15:04 MST",
|
||||
}
|
||||
|
||||
for _, original := range formats {
|
||||
t.Run(original, func(t *testing.T) {
|
||||
format, err := ParseGoFormat(original)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGoFormat() error = %v", err)
|
||||
}
|
||||
|
||||
result := format.GoFormat()
|
||||
if result != original {
|
||||
t.Errorf("Round trip failed: got %q, want %q", result, original)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustParseGoFormat(t *testing.T) {
|
||||
t.Run("Valid format", func(t *testing.T) {
|
||||
// Should not panic
|
||||
format := MustParseGoFormat("2006-01-02")
|
||||
if format == nil {
|
||||
t.Error("MustParseGoFormat returned nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid format panics", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustParseGoFormat should panic on empty string")
|
||||
}
|
||||
}()
|
||||
MustParseGoFormat("")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseGoFormat_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goFormat string
|
||||
wantGo string
|
||||
}{
|
||||
{
|
||||
name: "Consecutive literals",
|
||||
goFormat: "2006-01-02T15:04:05",
|
||||
wantGo: "2006-01-02T15:04:05",
|
||||
},
|
||||
{
|
||||
name: "Just literals",
|
||||
goFormat: "Hello, World!",
|
||||
wantGo: "Hello, World!",
|
||||
},
|
||||
{
|
||||
name: "Mixed tokens and literals",
|
||||
goFormat: "Year: 2006, Month: 01",
|
||||
wantGo: "Year: 2006, Month: 01",
|
||||
},
|
||||
{
|
||||
name: "Special characters",
|
||||
goFormat: "2006/01/02 @ 15:04:05",
|
||||
wantGo: "2006/01/02 @ 15:04:05",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
format, err := ParseGoFormat(tt.goFormat)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGoFormat() error = %v", err)
|
||||
}
|
||||
|
||||
gotGo := format.GoFormat()
|
||||
if gotGo != tt.wantGo {
|
||||
t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
86
doc.go
Normal file
86
doc.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Package timefmt provides tools for building and converting time format strings
|
||||
// with human-readable descriptions.
|
||||
//
|
||||
// It offers a fluent builder API for constructing time formats and can convert
|
||||
// between Go's reference time format, LDML (Unicode Locale Data Markup Language)
|
||||
// tokens, and plain English descriptions.
|
||||
//
|
||||
// # Basic Usage
|
||||
//
|
||||
// Build a time format using the fluent builder:
|
||||
//
|
||||
// format := timefmt.NewBuilder().
|
||||
// Year4().Dash().
|
||||
// MonthNumeric2().Dash().
|
||||
// DayNumeric2().
|
||||
// Build()
|
||||
//
|
||||
// fmt.Println(format.GoFormat()) // "2006-01-02"
|
||||
// fmt.Println(format.LDML()) // "yyyy-MM-dd"
|
||||
// fmt.Println(format.Description()) // "Year (4-digit), dash, ..."
|
||||
//
|
||||
// # Pre-built Formats
|
||||
//
|
||||
// Common formats are available as package-level constants:
|
||||
//
|
||||
// now := time.Now()
|
||||
// fmt.Println(timefmt.ISO8601.Format(now))
|
||||
// fmt.Println(timefmt.DateTime.Format(now))
|
||||
// fmt.Println(timefmt.DateUS.Format(now))
|
||||
//
|
||||
// # Parsing Existing Formats
|
||||
//
|
||||
// Parse an existing Go time format string to get human-readable descriptions:
|
||||
//
|
||||
// format, err := timefmt.ParseGoFormat("02/01/2006 15:04:05")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// fmt.Println(format.LDML()) // "dd/MM/yyyy HH:mm:ss"
|
||||
// fmt.Println(format.Description()) // Full English description
|
||||
//
|
||||
// # Multiple Output Formats
|
||||
//
|
||||
// Every Format provides three representations:
|
||||
//
|
||||
// - GoFormat(): Go's reference time format (e.g., "2006-01-02")
|
||||
// - LDML(): Unicode LDML tokens (e.g., "yyyy-MM-dd")
|
||||
// - Description(): Plain English description
|
||||
//
|
||||
// This makes it easy to document time formats for both developers and end users.
|
||||
//
|
||||
// # Use Cases
|
||||
//
|
||||
// Display format information to users:
|
||||
//
|
||||
// format := timefmt.DateOnly
|
||||
// fmt.Printf("Expected format: %s\n", format.Description())
|
||||
// fmt.Printf("Example: %s\n", format.Example())
|
||||
//
|
||||
// Validate user input with helpful error messages:
|
||||
//
|
||||
// if _, err := format.Parse(userInput); err != nil {
|
||||
// return fmt.Errorf("invalid date. Expected: %s (example: %s)",
|
||||
// format.Description(), format.Example())
|
||||
// }
|
||||
//
|
||||
// Generate documentation for configuration:
|
||||
//
|
||||
// for name, fmt := range configFormats {
|
||||
// fmt.Printf("%s: %s (LDML: %s)\n",
|
||||
// name, fmt.Description(), fmt.LDML())
|
||||
// }
|
||||
//
|
||||
// # LDML Compatibility
|
||||
//
|
||||
// The LDML output follows the Unicode LDML standard, making it compatible with:
|
||||
//
|
||||
// - ICU (International Components for Unicode)
|
||||
// - Java (SimpleDateFormat, DateTimeFormatter)
|
||||
// - Swift (DateFormatter)
|
||||
// - JavaScript (Moment.js, Day.js, date-fns)
|
||||
// - Most modern date/time libraries
|
||||
//
|
||||
// This allows seamless integration with systems that use LDML for time formatting.
|
||||
package timefmt
|
||||
189
examples/example_test.go
Normal file
189
examples/example_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package examples
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.haelnorr.com/h/timefmt"
|
||||
)
|
||||
|
||||
// Example demonstrating basic usage of the builder
|
||||
func Example_basic() {
|
||||
format := timefmt.NewBuilder().
|
||||
Year4().Dash().
|
||||
MonthNumeric2().Dash().
|
||||
DayNumeric2().
|
||||
Build()
|
||||
|
||||
fmt.Println("Go format:", format.GoFormat())
|
||||
fmt.Println("LDML:", format.LDML())
|
||||
fmt.Println("Description:", format.Description())
|
||||
|
||||
// Output:
|
||||
// Go format: 2006-01-02
|
||||
// LDML: yyyy-MM-dd
|
||||
// Description: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)
|
||||
}
|
||||
|
||||
// Example showing how to use pre-built formats
|
||||
func Example_prebuilt() {
|
||||
now := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC)
|
||||
|
||||
fmt.Println("ISO8601:", timefmt.ISO8601.Format(now))
|
||||
fmt.Println("DateOnly:", timefmt.DateOnly.Format(now))
|
||||
fmt.Println("DateTime:", timefmt.DateTime.Format(now))
|
||||
fmt.Println("DateUS:", timefmt.DateUS.Format(now))
|
||||
fmt.Println("Kitchen:", timefmt.Kitchen.Format(now))
|
||||
|
||||
// Output:
|
||||
// ISO8601: 2026-02-08T15:04:05Z
|
||||
// DateOnly: 2026-02-08
|
||||
// DateTime: 2026-02-08 15:04:05
|
||||
// DateUS: 02/08/2026
|
||||
// Kitchen: 3:04 PM
|
||||
}
|
||||
|
||||
// Example parsing an existing Go format string
|
||||
func Example_parsing() {
|
||||
format, err := timefmt.ParseGoFormat("02/01/2006 15:04:05")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("LDML:", format.LDML())
|
||||
fmt.Println("Description:", format.Description())
|
||||
|
||||
// Output:
|
||||
// LDML: dd/MM/yyyy HH:mm:ss
|
||||
// Description: Day (2-digit), slash, Month (2-digit), slash, Year (4-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)
|
||||
}
|
||||
|
||||
// Example creating a complex custom format
|
||||
func Example_complex() {
|
||||
format := timefmt.NewBuilder().
|
||||
WeekdayFull().Comma().
|
||||
MonthFull().Space().
|
||||
DayNumeric().Comma().
|
||||
Year4().Space().
|
||||
Literal("at").Space().
|
||||
Hour12().Colon().
|
||||
Minute().Space().
|
||||
AMPM().
|
||||
Build()
|
||||
|
||||
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC)
|
||||
|
||||
fmt.Println("Formatted:", format.Format(testTime))
|
||||
fmt.Println("LDML:", format.LDML())
|
||||
|
||||
// Output:
|
||||
// Formatted: Sunday, February 8, 2026 at 3:04 PM
|
||||
// LDML: EEEE, MMMM d, yyyy 'at' h:mm a
|
||||
}
|
||||
|
||||
// Example showing format conversion for user documentation
|
||||
func Example_documentation() {
|
||||
// Imagine this comes from user configuration
|
||||
userFormat := "2006-01-02 15:04:05 MST"
|
||||
|
||||
format, err := timefmt.ParseGoFormat(userFormat)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Display to user
|
||||
fmt.Println("Your timestamp format:")
|
||||
fmt.Printf(" Format: %s\n", format.LDML())
|
||||
fmt.Printf(" Example: %s\n", format.Example())
|
||||
fmt.Println()
|
||||
fmt.Println("Detailed breakdown:")
|
||||
fmt.Printf(" %s\n", format.Description())
|
||||
|
||||
// Output:
|
||||
// Your timestamp format:
|
||||
// Format: yyyy-MM-dd HH:mm:ss zzz
|
||||
// Example: 2026-02-08 15:04:05 MST
|
||||
//
|
||||
// Detailed breakdown:
|
||||
// Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone abbreviation
|
||||
}
|
||||
|
||||
// Example using format fragments directly
|
||||
func Example_fragments() {
|
||||
// Create a format using fragments directly
|
||||
format := timefmt.NewFormat(
|
||||
timefmt.Year4Digit,
|
||||
"/",
|
||||
timefmt.MonthNumeric2,
|
||||
"/",
|
||||
timefmt.DayNumeric2,
|
||||
)
|
||||
|
||||
fmt.Println("Go format:", format.GoFormat())
|
||||
fmt.Println("LDML:", format.LDML())
|
||||
|
||||
// Output:
|
||||
// Go format: 2006/01/02
|
||||
// LDML: yyyy/MM/dd
|
||||
}
|
||||
|
||||
// Example for form validation error messages
|
||||
func Example_validation() {
|
||||
format := timefmt.DateOnly
|
||||
|
||||
// Simulated user input validation
|
||||
userInput := "02-08-2026" // Wrong format
|
||||
_, err := format.Parse(userInput)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error: Invalid date format\n")
|
||||
fmt.Printf("Expected: %s\n", format.Description())
|
||||
fmt.Printf("Example: %s\n", format.Example())
|
||||
}
|
||||
|
||||
// Output:
|
||||
// Error: Invalid date format
|
||||
// Expected: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)
|
||||
// Example: 2026-02-08
|
||||
}
|
||||
|
||||
// Example showing timezone handling
|
||||
func Example_timezone() {
|
||||
format := timefmt.NewBuilder().
|
||||
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
|
||||
Space().
|
||||
Hour24().Colon().Minute().Colon().Second().
|
||||
Space().
|
||||
TimezoneOffsetColon().
|
||||
Build()
|
||||
|
||||
loc := time.FixedZone("EST", -5*3600)
|
||||
t := time.Date(2026, time.February, 8, 15, 4, 5, 0, loc)
|
||||
|
||||
fmt.Println("Formatted:", format.Format(t))
|
||||
fmt.Println("LDML:", format.LDML())
|
||||
|
||||
// Output:
|
||||
// Formatted: 2026-02-08 15:04:05 -05:00
|
||||
// LDML: yyyy-MM-dd HH:mm:ss ZZZZZ
|
||||
}
|
||||
|
||||
// Example demonstrating millisecond precision
|
||||
func Example_subseconds() {
|
||||
format := timefmt.NewBuilder().
|
||||
Hour24().Colon().
|
||||
Minute().Colon().
|
||||
Second().
|
||||
Millisecond().
|
||||
Build()
|
||||
|
||||
t := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.UTC)
|
||||
|
||||
fmt.Println("Formatted:", format.Format(t))
|
||||
fmt.Println("Go format:", format.GoFormat())
|
||||
|
||||
// Output:
|
||||
// Formatted: 15:04:05.123
|
||||
// Go format: 15:04:05.000
|
||||
}
|
||||
143
format.go
Normal file
143
format.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package timefmt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Format represents a time format pattern composed of fragments and literals.
|
||||
// It can convert between Go's reference time format, LDML tokens, and human-readable descriptions.
|
||||
type Format struct {
|
||||
fragments []interface{} // Can be Fragment or string (for literals)
|
||||
}
|
||||
|
||||
// NewFormat creates a new Format from a slice of fragments and literals.
|
||||
// Each element can be either a Fragment or a string literal.
|
||||
func NewFormat(fragments ...interface{}) *Format {
|
||||
return &Format{fragments: fragments}
|
||||
}
|
||||
|
||||
// GoFormat returns the Go reference time format string.
|
||||
// Example: "2006-01-02 15:04:05"
|
||||
func (f *Format) GoFormat() string {
|
||||
var sb strings.Builder
|
||||
for _, frag := range f.fragments {
|
||||
switch v := frag.(type) {
|
||||
case Fragment:
|
||||
sb.WriteString(v.GoFormat)
|
||||
case string:
|
||||
sb.WriteString(v)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// LDML returns the LDML-style human-readable format string.
|
||||
// Example: "yyyy-MM-dd HH:mm:ss"
|
||||
func (f *Format) LDML() string {
|
||||
var sb strings.Builder
|
||||
for _, frag := range f.fragments {
|
||||
switch v := frag.(type) {
|
||||
case Fragment:
|
||||
sb.WriteString(v.LDML)
|
||||
case string:
|
||||
// Escape literals that might conflict with LDML tokens
|
||||
// by wrapping them in single quotes if they contain letters
|
||||
if needsEscaping(v) {
|
||||
sb.WriteRune('\'')
|
||||
sb.WriteString(v)
|
||||
sb.WriteRune('\'')
|
||||
} else {
|
||||
sb.WriteString(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Description returns a full English description of the format.
|
||||
// Example: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)"
|
||||
func (f *Format) Description() string {
|
||||
var parts []string
|
||||
for _, frag := range f.fragments {
|
||||
switch v := frag.(type) {
|
||||
case Fragment:
|
||||
parts = append(parts, v.Description)
|
||||
case string:
|
||||
parts = append(parts, describeLiteral(v))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// Format formats a time.Time using this format pattern.
|
||||
// It uses Go's time.Format internally.
|
||||
func (f *Format) Format(t time.Time) string {
|
||||
return t.Format(f.GoFormat())
|
||||
}
|
||||
|
||||
// Parse parses a time string using this format pattern.
|
||||
// It uses Go's time.Parse internally.
|
||||
func (f *Format) Parse(value string) (time.Time, error) {
|
||||
return time.Parse(f.GoFormat(), value)
|
||||
}
|
||||
|
||||
// ParseInLocation parses a time string using this format pattern in the specified location.
|
||||
// It uses Go's time.ParseInLocation internally.
|
||||
func (f *Format) ParseInLocation(value string, loc *time.Location) (time.Time, error) {
|
||||
return time.ParseInLocation(f.GoFormat(), value, loc)
|
||||
}
|
||||
|
||||
// Example returns an example of what this format looks like using a reference time.
|
||||
// The reference time used is: February 8, 2026 at 15:04:05.999999999 in UTC-7
|
||||
func (f *Format) Example() string {
|
||||
// Use a specific reference time that shows all components clearly
|
||||
referenceTime := time.Date(2026, time.February, 8, 15, 4, 5, 999999999, time.FixedZone("MST", -7*3600))
|
||||
return f.Format(referenceTime)
|
||||
}
|
||||
|
||||
// Fragments returns a copy of the format's fragments slice.
|
||||
// This is useful for inspecting the format's structure.
|
||||
func (f *Format) Fragments() []interface{} {
|
||||
result := make([]interface{}, len(f.fragments))
|
||||
copy(result, f.fragments)
|
||||
return result
|
||||
}
|
||||
|
||||
// needsEscaping returns true if a literal string contains letters that might
|
||||
// be confused with LDML format tokens and should be escaped with quotes.
|
||||
func needsEscaping(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// describeLiteral converts a literal string into a human-readable description.
|
||||
func describeLiteral(s string) string {
|
||||
switch s {
|
||||
case "-":
|
||||
return "dash"
|
||||
case "/":
|
||||
return "slash"
|
||||
case ":":
|
||||
return "colon"
|
||||
case " ":
|
||||
return "space"
|
||||
case ".":
|
||||
return "period"
|
||||
case ",":
|
||||
return "comma"
|
||||
case ", ":
|
||||
return "comma-space"
|
||||
case "T":
|
||||
return "literal 'T'"
|
||||
case "Z":
|
||||
return "literal 'Z'"
|
||||
default:
|
||||
// For other literals, describe as "literal 'X'"
|
||||
return "literal '" + s + "'"
|
||||
}
|
||||
}
|
||||
480
format_test.go
Normal file
480
format_test.go
Normal file
@@ -0,0 +1,480 @@
|
||||
package timefmt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormat_GoFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
),
|
||||
want: "2006-01-02",
|
||||
},
|
||||
{
|
||||
name: "24-hour time",
|
||||
format: NewFormat(
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "15:04:05",
|
||||
},
|
||||
{
|
||||
name: "Full datetime",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
" ",
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "2006-01-02 15:04:05",
|
||||
},
|
||||
{
|
||||
name: "12-hour with AM/PM",
|
||||
format: NewFormat(
|
||||
Hour12,
|
||||
":",
|
||||
Minute,
|
||||
" ",
|
||||
AMPM,
|
||||
),
|
||||
want: "3:04 PM",
|
||||
},
|
||||
{
|
||||
name: "Full date with weekday and month name",
|
||||
format: NewFormat(
|
||||
WeekdayFull,
|
||||
", ",
|
||||
MonthFull,
|
||||
" ",
|
||||
DayNumeric,
|
||||
", ",
|
||||
Year4Digit,
|
||||
),
|
||||
want: "Monday, January 2, 2006",
|
||||
},
|
||||
{
|
||||
name: "ISO 8601 with timezone",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
"T",
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
TimezoneISO8601Colon,
|
||||
),
|
||||
want: "2006-01-02T15:04:05Z07:00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.format.GoFormat()
|
||||
if got != tt.want {
|
||||
t.Errorf("GoFormat() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_LDML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
),
|
||||
want: "yyyy-MM-dd",
|
||||
},
|
||||
{
|
||||
name: "24-hour time",
|
||||
format: NewFormat(
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "HH:mm:ss",
|
||||
},
|
||||
{
|
||||
name: "Full datetime",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
" ",
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "yyyy-MM-dd HH:mm:ss",
|
||||
},
|
||||
{
|
||||
name: "12-hour with AM/PM",
|
||||
format: NewFormat(
|
||||
Hour12,
|
||||
":",
|
||||
Minute,
|
||||
" ",
|
||||
AMPM,
|
||||
),
|
||||
want: "h:mm a",
|
||||
},
|
||||
{
|
||||
name: "ISO 8601 with T literal",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
"T",
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "yyyy-MM-dd'T'HH:mm:ss",
|
||||
},
|
||||
{
|
||||
name: "Month name (abbreviated)",
|
||||
format: NewFormat(
|
||||
MonthShort,
|
||||
" ",
|
||||
DayNumeric2,
|
||||
", ",
|
||||
Year4Digit,
|
||||
),
|
||||
want: "MMM dd, yyyy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.format.LDML()
|
||||
if got != tt.want {
|
||||
t.Errorf("LDML() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_Description(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
),
|
||||
want: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)",
|
||||
},
|
||||
{
|
||||
name: "24-hour time",
|
||||
format: NewFormat(
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
|
||||
},
|
||||
{
|
||||
name: "12-hour with AM/PM",
|
||||
format: NewFormat(
|
||||
Hour12,
|
||||
":",
|
||||
Minute,
|
||||
" ",
|
||||
AMPM,
|
||||
),
|
||||
want: "Hour (12-hour), colon, Minute (2-digit), space, AM/PM (uppercase)",
|
||||
},
|
||||
{
|
||||
name: "Date with literal T",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
"T",
|
||||
Hour24,
|
||||
),
|
||||
want: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal 'T', Hour (24-hour, 2-digit)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.format.Description()
|
||||
if got != tt.want {
|
||||
t.Errorf("Description() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_Format(t *testing.T) {
|
||||
// Use a specific test time
|
||||
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
),
|
||||
want: "2026-02-08",
|
||||
},
|
||||
{
|
||||
name: "24-hour time",
|
||||
format: NewFormat(
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "15:04:05",
|
||||
},
|
||||
{
|
||||
name: "Full datetime",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
" ",
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
want: "2026-02-08 15:04:05",
|
||||
},
|
||||
{
|
||||
name: "Month name",
|
||||
format: NewFormat(
|
||||
MonthFull,
|
||||
" ",
|
||||
DayNumeric,
|
||||
", ",
|
||||
Year4Digit,
|
||||
),
|
||||
want: "February 8, 2026",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.format.Format(testTime)
|
||||
if got != tt.want {
|
||||
t.Errorf("Format() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_Parse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
input string
|
||||
wantYear int
|
||||
wantMonth time.Month
|
||||
wantDay int
|
||||
wantHour int
|
||||
wantMin int
|
||||
wantSec int
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
),
|
||||
input: "2026-02-08",
|
||||
wantYear: 2026,
|
||||
wantMonth: time.February,
|
||||
wantDay: 8,
|
||||
},
|
||||
{
|
||||
name: "Full datetime",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
" ",
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
input: "2026-02-08 15:04:05",
|
||||
wantYear: 2026,
|
||||
wantMonth: time.February,
|
||||
wantDay: 8,
|
||||
wantHour: 15,
|
||||
wantMin: 4,
|
||||
wantSec: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.format.Parse(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if got.Year() != tt.wantYear {
|
||||
t.Errorf("Parse() year = %v, want %v", got.Year(), tt.wantYear)
|
||||
}
|
||||
if got.Month() != tt.wantMonth {
|
||||
t.Errorf("Parse() month = %v, want %v", got.Month(), tt.wantMonth)
|
||||
}
|
||||
if got.Day() != tt.wantDay {
|
||||
t.Errorf("Parse() day = %v, want %v", got.Day(), tt.wantDay)
|
||||
}
|
||||
if got.Hour() != tt.wantHour {
|
||||
t.Errorf("Parse() hour = %v, want %v", got.Hour(), tt.wantHour)
|
||||
}
|
||||
if got.Minute() != tt.wantMin {
|
||||
t.Errorf("Parse() minute = %v, want %v", got.Minute(), tt.wantMin)
|
||||
}
|
||||
if got.Second() != tt.wantSec {
|
||||
t.Errorf("Parse() second = %v, want %v", got.Second(), tt.wantSec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_Example(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format *Format
|
||||
// We'll just check that Example() returns a non-empty string
|
||||
// The exact output depends on the reference time used
|
||||
}{
|
||||
{
|
||||
name: "ISO 8601 date",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Full datetime",
|
||||
format: NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
"-",
|
||||
DayNumeric2,
|
||||
" ",
|
||||
Hour24,
|
||||
":",
|
||||
Minute,
|
||||
":",
|
||||
Second,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.format.Example()
|
||||
if got == "" {
|
||||
t.Errorf("Example() returned empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_Fragments(t *testing.T) {
|
||||
format := NewFormat(
|
||||
Year4Digit,
|
||||
"-",
|
||||
MonthNumeric2,
|
||||
)
|
||||
|
||||
fragments := format.Fragments()
|
||||
if len(fragments) != 3 {
|
||||
t.Errorf("Fragments() returned %d fragments, want 3", len(fragments))
|
||||
}
|
||||
|
||||
// Verify it's a copy (modifying it doesn't affect the original)
|
||||
fragments[0] = "modified"
|
||||
originalFragments := format.Fragments()
|
||||
if len(originalFragments) != 3 {
|
||||
t.Errorf("Original format was modified")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user