diff --git a/README.md b/README.md index 270d961..5cc878a 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,32 @@ package main import ( "fmt" "github.com/mr-pmillz/eoldate" + "log" ) func main() { - client := eoldate.NewClient(eoldate.EOLBaseURL) - releaseVersions, err := client.GetProduct("php") + client := eoldate.NewClient() + products, err := client.GetProduct("php") if err != nil { - panic(err) + log.Fatalf("Error fetching product data: %v", err) } - fmt.Println(releaseVersions) + + versionToCheck := 7.4 + + for _, product := range products { + supported, err := product.IsVersionSupported(versionToCheck) + if err != nil { + continue + } + + if supported { + fmt.Printf("PHP %.1f is still supported\n", versionToCheck) + } else { + fmt.Printf("PHP %.1f is no longer supported\n", versionToCheck) + } + return // Exit after finding the matching cycle + } + + fmt.Printf("PHP %.1f was not found in any product cycle\n", versionToCheck) } ``` \ No newline at end of file diff --git a/cmd/eoldate/main.go b/cmd/eoldate/main.go index fdde38b..573f2f2 100644 --- a/cmd/eoldate/main.go +++ b/cmd/eoldate/main.go @@ -1,14 +1,17 @@ package main import ( + "encoding/json" "flag" "fmt" + "github.com/olekukonko/tablewriter" "os" + "reflect" + "sort" "strings" "time" "github.com/mr-pmillz/eoldate" - "github.com/olekukonko/tablewriter" "github.com/projectdiscovery/gologger" ) @@ -60,246 +63,239 @@ func main() { os.Exit(1) } - releaseVersions, err := client.GetProduct(eolOptions.Tech) + data, err := client.Get(fmt.Sprintf("%s.json", eolOptions.Tech)) if err != nil { gologger.Fatal().Msgf("Error fetching product data: %v", err) } - headers, includeFlags := determineHeaders(releaseVersions) - tableString := &strings.Builder{} - table := createTable(tableString, headers) - - currentDate := time.Now() - - for _, release := range releaseVersions { - row := buildRow(release, includeFlags) - colorRow(table, row, release.EOL, release.Support, currentDate) + var products []eoldate.Product + if err = json.Unmarshal(data, &products); err != nil { + gologger.Fatal().Msgf("Error parsing JSON: %v", err) } - setTableProperties(table, eolOptions.Tech, len(headers)) // Pass the number of columns - table.Render() - fmt.Println(tableString.String()) + tableBuilder := NewTableBuilder(products) + tableString := tableBuilder.Render() + fmt.Println(tableString) if eolOptions.Output != "" { - writeOutputFiles(eolOptions, tableString.String(), releaseVersions) + writeOutputFiles(eolOptions, tableString, products) } } -func determineHeaders(releases []eoldate.Product) ([]string, map[string]bool) { - headers := []string{"Cycle", "Release Date", "EOL Date", "Latest"} - includeFlags := map[string]bool{ - "Link": false, - "LatestReleaseDate": false, - "LTS": false, - "Support": false, - "ExtendedSupport": false, - "MinJavaVersion": false, - "SupportedPHPVersions": false, +func writeOutputFiles(options eoldate.Options, tableString string, products []eoldate.Product) { + files := map[string]func() error{ + fmt.Sprintf("%s/%s.txt", options.Output, options.Tech): func() error { + return eoldate.WriteStringToFile(fmt.Sprintf("%s/%s.txt", options.Output, options.Tech), tableString) + }, + fmt.Sprintf("%s/%s.json", options.Output, options.Tech): func() error { + return eoldate.WriteStructToJSONFile(products, fmt.Sprintf("%s/%s.json", options.Output, options.Tech)) + }, + fmt.Sprintf("%s/%s.csv", options.Output, options.Tech): func() error { + return eoldate.WriteStructToCSVFile(products, fmt.Sprintf("%s/%s.csv", options.Output, options.Tech)) + }, } - for _, release := range releases { - if release.Link != "" { - includeFlags["Link"] = true - } - if release.LatestReleaseDate != "" { - includeFlags["LatestReleaseDate"] = true - } - if release.LTS != nil { - includeFlags["LTS"] = true - } - if release.Support != nil { - includeFlags["Support"] = true - } - if release.ExtendedSupport != nil { - includeFlags["ExtendedSupport"] = true - } - if release.MinJavaVersion != nil { - includeFlags["MinJavaVersion"] = true - } - if release.SupportedPHPVersions != "" { - includeFlags["SupportedPHPVersions"] = true + for filename, writeFunc := range files { + if err := writeFunc(); err != nil { + gologger.Error().Msgf("Failed to write %s: %v", filename, err) } } - - // Add headers in the desired order - if includeFlags["Link"] { - headers = append(headers, "Link") - } - if includeFlags["LatestReleaseDate"] { - headers = append(headers, "Latest Release Date") - } - if includeFlags["LTS"] { - headers = append(headers, "LTS") - } - if includeFlags["Support"] { - headers = append(headers, "Support") - } - if includeFlags["ExtendedSupport"] { - headers = append(headers, "Extended Support") - } - if includeFlags["MinJavaVersion"] { - headers = append(headers, "Min Java Version") - } - if includeFlags["SupportedPHPVersions"] { - headers = append(headers, "Supported PHP Versions") - } - - return headers, includeFlags } -func createTable(writer *strings.Builder, headers []string) *tablewriter.Table { - table := tablewriter.NewWriter(writer) - table.SetHeader(headers) - table.SetAutoWrapText(true) - table.SetRowLine(true) - alignments := make([]int, len(headers)) - for i := range alignments { - alignments[i] = tablewriter.ALIGN_CENTER - } - table.SetColumnAlignment(alignments) - return table +// TableBuilder handles the creation and population of the table +type TableBuilder struct { + products []eoldate.Product + headers []string + rows [][]string } -func buildRow(release eoldate.Product, includeFlags map[string]bool) []string { - row := []string{release.Cycle, release.ReleaseDate, formatEOL(release.EOL), release.Latest} +// NewTableBuilder creates a new TableBuilder instance +func NewTableBuilder(products []eoldate.Product) *TableBuilder { + tb := &TableBuilder{products: products} + tb.determineHeaders() + tb.buildRows() + return tb +} - if includeFlags["Link"] { - row = append(row, release.Link) - } - if includeFlags["LatestReleaseDate"] { - row = append(row, release.LatestReleaseDate) - } - if includeFlags["LTS"] { - row = append(row, formatBool(release.LTS)) - } - if includeFlags["Support"] { - row = append(row, formatInterface(release.Support)) - } - if includeFlags["ExtendedSupport"] { - row = append(row, formatInterface(release.ExtendedSupport)) - } - if includeFlags["MinJavaVersion"] && release.MinJavaVersion != nil { - row = append(row, formatJavaVersion(*release.MinJavaVersion)) - } - if includeFlags["SupportedPHPVersions"] { - row = append(row, release.SupportedPHPVersions) +// determineHeaders identifies all unique keys across all products with non-empty values +func (tb *TableBuilder) determineHeaders() { + headerSet := make(map[string]bool) + for _, product := range tb.products { + v := reflect.ValueOf(product) + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + if field.Name != "AdditionalFields" { + tag := field.Tag.Get("json") + if tag != "" && tag != "-" { + tagName := strings.Split(tag, ",")[0] + fieldValue := v.Field(i).Interface() + if !isEmptyValue(fieldValue) { + headerSet[tagName] = true + } + } + } + } + for key, value := range product.AdditionalFields { + if !isEmptyValue(value) { + headerSet[key] = true + } + } } - return row + tb.headers = make([]string, 0, len(headerSet)) + for header := range headerSet { + if header != "" { + tb.headers = append(tb.headers, header) + } + } + sort.Strings(tb.headers) } -func formatEOL(eol interface{}) string { - switch v := eol.(type) { +// isEmptyValue checks if a value is considered empty +func isEmptyValue(v interface{}) bool { + if v == nil { + return true + } + switch value := v.(type) { case string: - return v + return value == "" case bool: - return fmt.Sprintf("%t", v) + return !value + case int, int8, int16, int32, int64: + return value == 0 + case float32, float64: + return value == 0 + case []interface{}: + return len(value) == 0 + case map[string]interface{}: + return len(value) == 0 + case *float64: + return value == nil default: - return "N/A" + return false } } -func formatBool(value interface{}) string { - if v, ok := value.(bool); ok { - return fmt.Sprintf("%t", v) +// buildRows constructs the rows for the table +func (tb *TableBuilder) buildRows() { + for _, product := range tb.products { + row := make([]string, len(tb.headers)) + v := reflect.ValueOf(product) + t := v.Type() + for i, header := range tb.headers { + value := "" + for j := 0; j < v.NumField(); j++ { + field := t.Field(j) + if field.Name == "AdditionalFields" { + continue + } + tag := field.Tag.Get("json") + if tag != "" && tag != "-" && strings.Split(tag, ",")[0] == header { + value = tb.formatValue(v.Field(j).Interface()) + break + } + } + if value == "" { + if val, ok := product.AdditionalFields[header]; ok { + value = tb.formatValue(val) + } + } + row[i] = value + } + tb.rows = append(tb.rows, row) } - return "N/A" } -func formatInterface(value interface{}) string { - if value == nil { +// formatValue converts an interface{} value to a string representation +func (tb *TableBuilder) formatValue(v interface{}) string { + if v == nil { return "N/A" } - return fmt.Sprintf("%v", value) -} - -func colorRow(table *tablewriter.Table, row []string, eol interface{}, support interface{}, currentDate time.Time) { - eolDate, eolErr := parseEOLDate(eol) - supportDate, supportErr := parseEOLDate(support) - - colors := make([]tablewriter.Colors, len(row)) - for i := range colors { - colors[i] = tablewriter.Colors{} - } - if eolErr == nil && !eolDate.IsZero() { - if eolDate.Before(currentDate) { - colors[2] = tablewriter.Colors{tablewriter.FgRedColor} - } else { - colors[2] = tablewriter.Colors{tablewriter.FgGreenColor} + switch value := v.(type) { + case string: + return value + case float64: + if value == float64(int64(value)) { + return fmt.Sprintf("%.0f", value) } + return fmt.Sprintf("%.2f", value) + case bool: + return fmt.Sprintf("%t", value) + case time.Time: + return value.Format("2006-01-02") + case interface{}: + return fmt.Sprintf("%v", value) + default: + return fmt.Sprintf("%v", value) } +} - supportIndex := -1 - for i, val := range row { - if val == formatInterface(support) { - supportIndex = i - break - } +// Render creates and renders the table +func (tb *TableBuilder) Render() string { + var buf strings.Builder + table := tablewriter.NewWriter(&buf) + table.SetHeader(tb.headers) + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_CENTER) + table.SetAlignment(tablewriter.ALIGN_CENTER) + + // Set header colors + headerColors := make([]tablewriter.Colors, len(tb.headers)) + for i := range headerColors { + headerColors[i] = tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiYellowColor, tablewriter.BgBlackColor} } + table.SetHeaderColor(headerColors...) - if supportIndex != -1 && supportErr == nil && !supportDate.IsZero() { - if supportDate.Before(currentDate) { - colors[supportIndex] = tablewriter.Colors{tablewriter.FgRedColor} - } else { - colors[supportIndex] = tablewriter.Colors{tablewriter.FgGreenColor} - } + for _, row := range tb.rows { + colors := tb.colorizeRow(row) + table.Rich(row, colors) } - table.Rich(row, colors) + table.Render() + return buf.String() } -func parseEOLDate(date interface{}) (time.Time, error) { - if dateStr, ok := date.(string); ok { - layouts := []string{ - "2006-01-02", - "2006-01", - "2006", - } - for _, layout := range layouts { - if t, err := time.Parse(layout, dateStr); err == nil { - return t, nil - } +// colorizeRow applies color to specific columns based on their values +func (tb *TableBuilder) colorizeRow(row []string) []tablewriter.Colors { + colors := make([]tablewriter.Colors, len(row)) + for i, header := range tb.headers { + switch strings.ToLower(header) { + case "eol", "support": + colors[i] = tb.getDateColor(row[i]) } } - return time.Time{}, fmt.Errorf("invalid date format") + return colors } -// setTableProperties ... -func setTableProperties(table *tablewriter.Table, tech string, columnCount int) { - footer := make([]string, columnCount) - footer[0] = strings.ToUpper(tech) - for i := 1; i < columnCount; i++ { - footer[i] = "" +// getDateColor returns the appropriate color based on the date value +func (tb *TableBuilder) getDateColor(dateStr string) tablewriter.Colors { + date, err := tb.parseDate(dateStr) + if err != nil { + return tablewriter.Colors{} } - table.SetFooter(footer) - table.SetFooterAlignment(tablewriter.ALIGN_LEFT) -} -// Add this new function to format the Java version -func formatJavaVersion(version float64) string { - if version == float64(int(version)) { - return fmt.Sprintf("%.0f", version) + if date.Before(time.Now()) { + return tablewriter.Colors{tablewriter.FgRedColor} } - return fmt.Sprintf("%.1f", version) + return tablewriter.Colors{tablewriter.FgGreenColor} } -func writeOutputFiles(options eoldate.Options, tableString string, releaseVersions []eoldate.Product) { - files := map[string]func() error{ - fmt.Sprintf("%s/%s.txt", options.Output, options.Tech): func() error { - return eoldate.WriteStringToFile(fmt.Sprintf("%s/%s.txt", options.Output, options.Tech), tableString) - }, - fmt.Sprintf("%s/%s.json", options.Output, options.Tech): func() error { - return eoldate.WriteStructToJSONFile(releaseVersions, fmt.Sprintf("%s/%s.json", options.Output, options.Tech)) - }, - fmt.Sprintf("%s/%s.csv", options.Output, options.Tech): func() error { - return eoldate.WriteStructToCSVFile(releaseVersions, fmt.Sprintf("%s/%s.csv", options.Output, options.Tech)) - }, +// parseDate attempts to parse a date string in various formats +func (tb *TableBuilder) parseDate(dateStr string) (time.Time, error) { + formats := []string{ + "2006-01-02", + "2006-01", + "2006", } - for filename, writeFunc := range files { - if err := writeFunc(); err != nil { - gologger.Error().Msgf("Failed to write %s: %v", filename, err) + for _, format := range formats { + if date, err := time.Parse(format, dateStr); err == nil { + return date, nil } } + + return time.Time{}, fmt.Errorf("unable to parse date: %s", dateStr) } diff --git a/eoldate.go b/eoldate.go index adc0d2a..7bfc50c 100644 --- a/eoldate.go +++ b/eoldate.go @@ -5,9 +5,11 @@ import ( "fmt" "io" "net/http" + "strconv" + "time" ) -const CurrentVersion = `v0.0.5` +const CurrentVersion = `v0.0.6` const EOLBaseURL = "https://endoflife.date/api" // Options ... @@ -20,17 +22,94 @@ type Options struct { // Product represents the structure of the JSON data type Product struct { - Cycle string `json:"cycle,omitempty"` - ReleaseDate string `json:"releaseDate,omitempty"` - EOL interface{} `json:"eol,omitempty"` - Latest string `json:"latest,omitempty"` - Link string `json:"link,omitempty"` - LatestReleaseDate string `json:"latestReleaseDate,omitempty"` - LTS interface{} `json:"lts,omitempty"` - Support interface{} `json:"support,omitempty"` - ExtendedSupport interface{} `json:"extendedSupport,omitempty"` - MinJavaVersion *float64 `json:"minJavaVersion,omitempty"` - SupportedPHPVersions string `json:"supportedPHPVersions,omitempty"` + Cycle string `json:"cycle,omitempty"` + ReleaseDate string `json:"releaseDate,omitempty"` + EOL interface{} `json:"eol,omitempty"` + Latest string `json:"latest,omitempty"` + Link string `json:"link,omitempty"` + LatestReleaseDate string `json:"latestReleaseDate,omitempty"` + LTS interface{} `json:"lts,omitempty"` + Support interface{} `json:"support,omitempty"` + ExtendedSupport interface{} `json:"extendedSupport,omitempty"` + MinJavaVersion *float64 `json:"minJavaVersion,omitempty"` + SupportedPHPVersions string `json:"supportedPHPVersions,omitempty"` + AdditionalFields map[string]interface{} `json:"-"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface +func (p *Product) UnmarshalJSON(data []byte) error { + type ProductAlias Product + alias := &struct { + *ProductAlias + AdditionalFields map[string]interface{} `json:"-"` + }{ + ProductAlias: (*ProductAlias)(p), + } + + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + p.AdditionalFields = make(map[string]interface{}) + for k, v := range raw { + switch k { + case "cycle", "releaseDate", "eol", "latest", "link", "latestReleaseDate", "lts", "support", "extendedSupport", "minJavaVersion", "supportedPHPVersions": + // These fields are already handled by the struct + default: + p.AdditionalFields[k] = v + } + } + + return nil +} + +// IsVersionSupported checks if the given version is supported in this product cycle +func (p *Product) IsVersionSupported(version float64) (bool, error) { + productCycle, err := strconv.ParseFloat(p.Cycle, 64) + if err != nil { + return false, fmt.Errorf("invalid cycle version: %s", p.Cycle) + } + + if productCycle == version { + eolDate, err := p.GetEOLDate() + if err != nil { + return false, err + } + + return time.Now().Before(eolDate), nil + } + + return false, fmt.Errorf("version %.1f does not match this product cycle %.1f", version, productCycle) +} + +// GetEOLDate returns the end-of-life date for the product +func (p *Product) GetEOLDate() (time.Time, error) { + switch eol := p.EOL.(type) { + case string: + formats := []string{ + "2006-01-02", + "2006-01", + "2006", + } + for _, format := range formats { + if t, err := time.Parse(format, eol); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unable to parse EOL date: %s", eol) + case bool: + if eol { + return time.Now().AddDate(-1, 0, 0), nil // Assume EOL was a year ago if true + } + return time.Now().AddDate(100, 0, 0), nil // Assume far in the future if false + default: + return time.Time{}, fmt.Errorf("unexpected EOL type: %T", p.EOL) + } } type AllProducts []string @@ -69,10 +148,10 @@ func (c *Client) Get(endpoint string) ([]byte, error) { func (c *Client) GetProduct(product string) ([]Product, error) { data, err := c.Get(fmt.Sprintf("%s.json", product)) if err != nil { - return nil, LogError(err) + return nil, err } - products := make([]Product, 0) + var products []Product err = json.Unmarshal(data, &products) return products, err } @@ -81,10 +160,10 @@ func (c *Client) GetProduct(product string) ([]Product, error) { func (c *Client) GetAllProducts() (AllProducts, error) { data, err := c.Get("all.json") if err != nil { - return nil, LogError(err) + return nil, err } - all := make(AllProducts, 0) + var all AllProducts err = json.Unmarshal(data, &all) return all, err } diff --git a/examples/is-supported/main.go b/examples/is-supported/main.go new file mode 100644 index 0000000..7d627f3 --- /dev/null +++ b/examples/is-supported/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "github.com/mr-pmillz/eoldate" + "log" +) + +func main() { + client := eoldate.NewClient() + products, err := client.GetProduct("php") + if err != nil { + log.Fatalf("Error fetching product data: %v", err) + } + + versionToCheck := 7.4 + + for _, product := range products { + supported, err := product.IsVersionSupported(versionToCheck) + if err != nil { + continue + } + + if supported { + fmt.Printf("PHP %.1f is still supported\n", versionToCheck) + } else { + fmt.Printf("PHP %.1f is no longer supported\n", versionToCheck) + } + return // Exit after finding the matching cycle + } + + fmt.Printf("PHP %.1f was not found in any product cycle\n", versionToCheck) +}