├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jens Rantil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Unfy 2 | ==== 3 | `unfy` is a command line utility that automagically identifies and translated 4 | UNIX timestamps (since epoch) to human readable timestamps. 5 | 6 | Example 7 | ------- 8 | Basic example: 9 | ```bash 10 | $ echo "Timestamp: 1613336683" | unfy 11 | Timestamp: 2021-02-14T22:04:43+01:00 12 | ``` 13 | Parsing UNIX timestamps in millisecond resolution: 14 | ```bash 15 | $ echo "Timestamp: 1613336683123" | unfy --milliseconds 16 | Timestamp: 2021-02-14T22:04:43+01:00 17 | $ echo "Timestamp: 1613336683123" | unfy --milliseconds --predefined-format RFC3339Nano 18 | Timestamp: 2021-02-14T22:04:43.123+01:00 19 | ``` 20 | Outputting with relative time: 21 | ```bash 22 | $ echo "Timestamp: 1613336683" | unfy --output-mode relative 23 | Timestamp: 2 days ago 24 | $ echo "Timestamp: 1613336683" | go run main.go --output-mode absolute+relative 25 | Timestamp: 2021-02-14T22:04:43+01:00 (2 days ago) 26 | ``` 27 | 28 | The name? 29 | --------- 30 | `unfy` is short for un-UNIX-fy. 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/JensRantil/unfy 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.2.15 // indirect 7 | github.com/nleeper/goment v1.4.0 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kong v0.2.15 h1:HP3K1XuFn0wGSWFGVW67V+65tXw/Ht8FDYiLNAuX2Ug= 2 | github.com/alecthomas/kong v0.2.15/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/nleeper/goment v1.4.0 h1:k+PnV26S9wnxI+ryxKEkpG7JhVtDJpM6+CzeRxh5S54= 6 | github.com/nleeper/goment v1.4.0/go.mod h1:zDl5bAyDhqxwQKAvkSXMRLOdCowrdZz53ofRJc4VhTo= 7 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 8 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 12 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 13 | github.com/tkuchiki/go-timezone v0.2.0 h1:yyZVHtQRVZ+wvlte5HXvSpBkR0dPYnPEIgq9qqAqltk= 14 | github.com/tkuchiki/go-timezone v0.2.0/go.mod h1:b1Ean9v2UXtxSq4TZF0i/TU9NuoWa9hOzOKoGCV2zqY= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "math" 9 | "os" 10 | "regexp" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/alecthomas/kong" 15 | "github.com/nleeper/goment" 16 | ) 17 | 18 | // TODO: Not use CLI as a singleton. Instead of instantiate it in the main 19 | // method to make all the functions testable. 20 | var CLI struct { 21 | Milliseconds bool `help:"Search for UNIX timestamps in millisecond resolution. Without this, second resolution is expected. Currently, decimal points for UNIX timestamps isn't supported."` 22 | 23 | From time.Time `help:"The earliest UNIX timestamp we match. RFC3339." group:"Exact time span for UNIX timestamp matching. Defaults to --relative-interval if not defined. Flags:"` 24 | To time.Time `help:"The latest UNIX timestamp we match. Uses RFC3339." group:"Exact time span for UNIX timestamp matching. Defaults to --relative-interval if not defined. Flags:"` 25 | 26 | RelTimeInterval time.Duration `name:"relative-interval" help:"The time interval +/- from current time for which UNIX timestamps are matched. Defaults to 10 years." default:"87600h"` 27 | 28 | Verbose bool `help:"Verbose logging to stderr. Useful for debugging." short:"v"` 29 | 30 | OutputMode string `name:"output-mode" help:"Whether the time should be absolute, relative, or both." enum:"absolute,relative,absolute+relative" default:"absolute"` 31 | 32 | PredefAbsoluteFormat string `name:"predefined-format" short:"p" help:"Predefined time format to replace UNIX timestamps with." enum:"RFC3339,RFC3339Nano,custom" default:"RFC3339"` 33 | Format string `help:"Time format to replace UNIX timestamps with. Uses the same format as https://golang.org/pkg/time/#Parse with the exception that 'REL' gets replaced with a relative time." default:"2006-01-02T15:04:05Z07:00"` 34 | 35 | Unbuffered bool `help:"Don't buffer output. This will slow down the application."` 36 | 37 | Paths []*os.File `arg name:"path" help:"Paths to list." default:"-"` 38 | } 39 | 40 | var number *regexp.Regexp 41 | 42 | func init() { 43 | number = regexp.MustCompile(fmt.Sprintf(`0*(?P[1-9][0-9]{0,%d})`, len(strconv.FormatInt(math.MaxInt64, 10)))) 44 | number.Longest() 45 | } 46 | 47 | func main() { 48 | kong.Parse(&CLI, kong.Description("A command line utility that will replace UNIX timestamps with human interpretable timestamps.")) 49 | 50 | unixRange := newUnixRange(newTimeRange()) 51 | timeConverter := newTimeConverter() 52 | formatter := newTimeFormatter() 53 | 54 | scanner := newScanner() 55 | output := newBufferedWriter(os.Stdout) 56 | defer output.Flush() 57 | 58 | splitter := &numberSplitter{} 59 | scanner.Split(splitter.Split) 60 | 61 | matcher := newMatcher(unixRange) 62 | for scanner.Scan() { 63 | data := scanner.Bytes() 64 | 65 | numberLoc := splitter.NumberLoc 66 | if numberLoc == nil { 67 | if _, err := output.Write(data); err != nil { 68 | fatalLn("Unable to write output:", err) 69 | } 70 | continue 71 | } 72 | unix, match := matcher.Match(data[numberLoc[2]:numberLoc[3]]) 73 | if !match { 74 | if _, err := output.Write(data); err != nil { 75 | fatalLn("Unable to write output:", err) 76 | } 77 | continue 78 | } 79 | tstamp := timeConverter(unix) 80 | toPrint := formatter.Format(tstamp) 81 | if _, err := output.Write([]byte(toPrint)); err != nil { 82 | fatalLn("Unable to write output:", err) 83 | } 84 | } 85 | if err := scanner.Err(); err != nil { 86 | fatalLn("Invalid input:", err) 87 | } 88 | } 89 | 90 | func newBufferedWriter(out io.Writer) *bufio.Writer { 91 | if CLI.Unbuffered { 92 | return bufio.NewWriterSize(out, 0) 93 | } 94 | return bufio.NewWriter(out) 95 | } 96 | 97 | func newScanner() *bufio.Scanner { 98 | readers := make([]io.Reader, len(CLI.Paths)) 99 | for i, p := range CLI.Paths { 100 | readers[i] = p 101 | } 102 | return bufio.NewScanner(bufio.NewReader(io.MultiReader(readers...))) 103 | } 104 | 105 | type timeRange struct { 106 | Lower time.Time 107 | Upper time.Time 108 | } 109 | 110 | func newTimeRange() timeRange { 111 | useAbsolute := !CLI.From.IsZero() || !CLI.To.IsZero() 112 | if useAbsolute { 113 | return timeRange{ 114 | Lower: CLI.From, 115 | Upper: CLI.To, 116 | } 117 | } 118 | 119 | now := time.Now() 120 | return timeRange{ 121 | Lower: now.Add(-CLI.RelTimeInterval), 122 | Upper: now.Add(CLI.RelTimeInterval), 123 | } 124 | } 125 | 126 | func newUnixRange(r timeRange) unixRange { 127 | if CLI.Milliseconds { 128 | return unixRange{ 129 | Lower: r.Lower.UnixNano() / nanosPerMs, 130 | Upper: r.Upper.UnixNano() / nanosPerMs, 131 | } 132 | } 133 | return unixRange{ 134 | Lower: r.Lower.Unix(), 135 | Upper: r.Upper.Unix(), 136 | } 137 | } 138 | 139 | func newTimeConverter() func(unix int64) time.Time { 140 | if CLI.Milliseconds { 141 | return millisecondConverter 142 | } 143 | return secondConverter 144 | } 145 | 146 | const nanosPerMs = int64(time.Millisecond / time.Nanosecond) 147 | 148 | func millisecondConverter(unix int64) time.Time { 149 | seconds, nanos := unix/1000, nanosPerMs*(unix%1000) 150 | return time.Unix(seconds, nanos) 151 | } 152 | 153 | func secondConverter(unix int64) time.Time { 154 | return time.Unix(unix, 0) 155 | } 156 | 157 | func newTimeFormatter() timeFormatter { 158 | switch CLI.OutputMode { 159 | case "absolute": 160 | return newAbsoluteFormatter() 161 | case "relative": 162 | return relativeFormatter{} 163 | case "absolute+relative": 164 | return combinedFormatter{ 165 | Base: newAbsoluteFormatter(), 166 | Parenthesis: relativeFormatter{}, 167 | } 168 | default: 169 | panic(fmt.Sprintf("unexpected mode: %s", CLI.OutputMode)) 170 | } 171 | 172 | } 173 | 174 | type timeFormatter interface { 175 | Format(time.Time) string 176 | } 177 | 178 | func newAbsoluteFormatter() absoluteFormatter { 179 | return absoluteFormatter{timeFormat()} 180 | } 181 | 182 | func timeFormat() string { 183 | switch CLI.PredefAbsoluteFormat { 184 | case "RFC3339": 185 | return time.RFC3339 186 | case "RFC3339Nano": 187 | return time.RFC3339Nano 188 | case "custom": 189 | return CLI.Format 190 | default: 191 | panic(fmt.Sprintf("unexpected predefined format: %s", CLI.OutputMode)) 192 | } 193 | } 194 | 195 | type absoluteFormatter struct { 196 | Layout string 197 | } 198 | 199 | func (g absoluteFormatter) Format(t time.Time) string { 200 | return t.Format(g.Layout) 201 | } 202 | 203 | type relativeFormatter struct { 204 | } 205 | 206 | func (g relativeFormatter) Format(t time.Time) string { 207 | // i'm not too fond of the api of goment.new(interface{}). would avoiding 208 | // using reflection here and instead be type-safe and not have to ignore 209 | // error. perhaps we should look for an alternative library. 210 | m, _ := goment.New(t) 211 | return m.FromNow() 212 | } 213 | 214 | type combinedFormatter struct { 215 | Base timeFormatter 216 | Parenthesis timeFormatter 217 | } 218 | 219 | func (g combinedFormatter) Format(t time.Time) string { 220 | return fmt.Sprintf("%s (%s)", g.Base.Format(t), g.Parenthesis.Format(t)) 221 | } 222 | 223 | func fatalLn(a ...interface{}) { 224 | fmt.Println(a...) 225 | os.Exit(1) 226 | } 227 | 228 | type numberSplitter struct { 229 | // NumberLoc contains the location the number in the returned data. 230 | NumberLoc []int 231 | nextLoc []int 232 | nextNeedMore bool 233 | } 234 | 235 | // Split implements bufio.SplitFunc. It splits into byte slices that either 236 | // match the number regexp, or not. If it matches a number, n.NumberLoc will 237 | // contain the exact location of the number in the previously returned token. 238 | func (n *numberSplitter) Split(data []byte, atEOF bool) (advance int, token []byte, err error) { 239 | if n.nextNeedMore { 240 | // Last time we saw we didn't have enough data for a full UNIX 241 | // timestamp. Avoiding a regular expression scan here and 242 | // simply ask for more data immediatelt. 243 | n.nextNeedMore = false 244 | return 0, nil, nil 245 | } 246 | if n.nextLoc != nil { 247 | // We have data from previous regexp scan. Avoid an unnecessary scan. 248 | loc := n.nextLoc 249 | n.NumberLoc = loc 250 | n.nextLoc = nil 251 | return loc[1] - loc[0], data[loc[0]:loc[1]], nil 252 | } 253 | 254 | loc := number.FindSubmatchIndex(data) 255 | if loc == nil { 256 | if atEOF { 257 | return len(data), data, bufio.ErrFinalToken 258 | } 259 | return len(data), data, nil 260 | } 261 | if loc[0] > 0 { 262 | // We found integers, but we first need to return some non-integer data. 263 | if loc[1] == len(data) { 264 | // We need to read more data. 265 | if atEOF { 266 | // ...but we can't. Return all data, but don't set n.NumberLoc since we didn't find any numbers. 267 | return len(data), data, bufio.ErrFinalToken 268 | } 269 | n.nextNeedMore = true 270 | return loc[0], data[0:loc[0]], nil 271 | } 272 | 273 | // We have enough data. 274 | toReturn := data[0:loc[0]] 275 | 276 | toSubtract := loc[0] 277 | loc[0] = 0 278 | loc[1] -= toSubtract 279 | loc[2] -= toSubtract 280 | loc[3] -= toSubtract 281 | n.nextLoc = loc 282 | 283 | return len(toReturn), toReturn, nil 284 | } 285 | 286 | n.NumberLoc = loc 287 | return loc[1] - loc[0], data[loc[0]:loc[1]], nil 288 | } 289 | 290 | type unixRange struct { 291 | Lower int64 // inclusive 292 | Upper int64 // inclusive 293 | } 294 | 295 | func (u unixRange) LowerString() string { return strconv.FormatInt(u.Lower, 10) } 296 | func (u unixRange) UpperString() string { return strconv.FormatInt(u.Upper, 10) } 297 | func (u unixRange) Contains(i int64) bool { 298 | return i >= u.Lower && i <= u.Upper 299 | } 300 | 301 | type matcher struct { 302 | uRange unixRange 303 | maxLen int 304 | minLen int 305 | prefix []byte 306 | } 307 | 308 | func newMatcher(r unixRange) matcher { 309 | lowerString, upperString := r.LowerString(), r.UpperString() 310 | return matcher{ 311 | uRange: r, 312 | maxLen: max(len(lowerString), len(upperString)), 313 | minLen: min(len(lowerString), len(upperString)), 314 | prefix: buildPrefix([]byte(lowerString), []byte(upperString)), 315 | } 316 | } 317 | 318 | func min(a, b int) int { 319 | if a < b { 320 | return a 321 | } 322 | return b 323 | } 324 | 325 | func max(a, b int) int { 326 | if a > b { 327 | return a 328 | } 329 | return b 330 | } 331 | 332 | // Match converts a series of bytes to UNIX timestamp. It quickly disregards 333 | // byte slices that it's sure can't be a UNIX timestamp. 334 | func (u matcher) Match(b []byte) (conversion int64, match bool) { 335 | if length := len(b); length < u.minLen || length > u.maxLen { 336 | return 0, false 337 | } 338 | if !bytes.HasPrefix(b, u.prefix) { 339 | return 0, false 340 | } 341 | conversion, err := strconv.ParseInt(string(b), 10, 64) 342 | if err != nil { 343 | return 0, false 344 | } 345 | return conversion, u.uRange.Contains(conversion) 346 | } 347 | 348 | func buildPrefix(a, b []byte) []byte { 349 | if len(a) < len(b) { 350 | return buildPrefixOrdered(a, b) 351 | } 352 | return buildPrefixOrdered(b, a) 353 | } 354 | 355 | func buildPrefixOrdered(shorter, longer []byte) []byte { 356 | res := make([]byte, 0, len(shorter)) 357 | for i, b := range shorter { 358 | if longer[i] != b { 359 | return res 360 | } 361 | res = append(res, b) 362 | } 363 | return res 364 | } 365 | --------------------------------------------------------------------------------