2025-11-20 16:41:13 +00:00
|
|
|
package cv
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/mail"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ValidationError represents a validation error with context
|
|
|
|
|
type ValidationError struct {
|
|
|
|
|
Field string
|
|
|
|
|
Message string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e ValidationError) Error() string {
|
|
|
|
|
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ValidationErrors is a collection of validation errors
|
|
|
|
|
type ValidationErrors []ValidationError
|
|
|
|
|
|
|
|
|
|
func (e ValidationErrors) Error() string {
|
|
|
|
|
if len(e) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
sb.WriteString("validation failed:\n")
|
|
|
|
|
for _, err := range e {
|
|
|
|
|
sb.WriteString(" - ")
|
|
|
|
|
sb.WriteString(err.Error())
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
return sb.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate performs comprehensive validation on the CV
|
|
|
|
|
func (cv *CV) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
// Validate Personal section
|
|
|
|
|
if err := cv.Personal.Validate(); err != nil {
|
|
|
|
|
if verrs, ok := err.(ValidationErrors); ok {
|
|
|
|
|
errors = append(errors, verrs...)
|
|
|
|
|
} else {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "personal", Message: err.Error()})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate Experience entries
|
|
|
|
|
for i, exp := range cv.Experience {
|
|
|
|
|
if err := exp.Validate(); err != nil {
|
|
|
|
|
if verrs, ok := err.(ValidationErrors); ok {
|
|
|
|
|
for _, verr := range verrs {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("experience[%d].%s", i, verr.Field),
|
|
|
|
|
Message: verr.Message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("experience[%d]", i),
|
|
|
|
|
Message: err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate Education entries
|
|
|
|
|
for i, edu := range cv.Education {
|
|
|
|
|
if err := edu.Validate(); err != nil {
|
|
|
|
|
if verrs, ok := err.(ValidationErrors); ok {
|
|
|
|
|
for _, verr := range verrs {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("education[%d].%s", i, verr.Field),
|
|
|
|
|
Message: verr.Message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("education[%d]", i),
|
|
|
|
|
Message: err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate Skills
|
|
|
|
|
if err := cv.Skills.Validate(); err != nil {
|
|
|
|
|
if verrs, ok := err.(ValidationErrors); ok {
|
|
|
|
|
errors = append(errors, verrs...)
|
|
|
|
|
} else {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "skills", Message: err.Error()})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate Languages
|
|
|
|
|
for i, lang := range cv.Languages {
|
|
|
|
|
if err := lang.Validate(); err != nil {
|
|
|
|
|
if verrs, ok := err.(ValidationErrors); ok {
|
|
|
|
|
for _, verr := range verrs {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("languages[%d].%s", i, verr.Field),
|
|
|
|
|
Message: verr.Message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("languages[%d]", i),
|
|
|
|
|
Message: err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate Projects
|
|
|
|
|
for i, proj := range cv.Projects {
|
|
|
|
|
if err := proj.Validate(); err != nil {
|
|
|
|
|
if verrs, ok := err.(ValidationErrors); ok {
|
|
|
|
|
for _, verr := range verrs {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("projects[%d].%s", i, verr.Field),
|
|
|
|
|
Message: verr.Message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("projects[%d]", i),
|
|
|
|
|
Message: err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate Meta (essential CV metadata)
|
|
|
|
|
if err := cv.Meta.Validate(); err != nil {
|
|
|
|
|
if verrs, ok := err.(ValidationErrors); ok {
|
|
|
|
|
errors = append(errors, verrs...)
|
|
|
|
|
} else {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "meta", Message: err.Error()})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate checks Personal information
|
|
|
|
|
func (p *Personal) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(p.Name) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "name", Message: "name is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(p.Email) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "email", Message: "email is required"})
|
|
|
|
|
} else if !isValidEmail(p.Email) {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "email", Message: "invalid email format"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate URLs if present
|
|
|
|
|
if p.LinkedIn != "" && !isValidURL(p.LinkedIn) {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "linkedin", Message: "invalid URL format"})
|
|
|
|
|
}
|
|
|
|
|
if p.GitHub != "" && !isValidURL(p.GitHub) {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "github", Message: "invalid URL format"})
|
|
|
|
|
}
|
|
|
|
|
if p.Website != "" && !isValidURL(p.Website) {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "website", Message: "invalid URL format"})
|
|
|
|
|
}
|
|
|
|
|
if p.Domestika != "" && !isValidURL(p.Domestika) {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "domestika", Message: "invalid URL format"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate checks Experience entry
|
|
|
|
|
func (e *Experience) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(e.Position) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "position", Message: "position is required"})
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(e.Company) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "company", Message: "company is required"})
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(e.StartDate) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "startDate", Message: "start date is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EndDate is only required if not current
|
|
|
|
|
if !e.Current && strings.TrimSpace(e.EndDate) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "endDate", Message: "end date is required when current is false"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate company URL if present
|
|
|
|
|
if e.CompanyURL != "" && !isValidURL(e.CompanyURL) {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "companyURL", Message: "invalid URL format"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate checks Education entry
|
|
|
|
|
func (e *Education) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(e.Degree) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "degree", Message: "degree is required"})
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(e.Institution) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "institution", Message: "institution is required"})
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(e.StartDate) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "startDate", Message: "start date is required"})
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(e.EndDate) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "endDate", Message: "end date is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate checks Skills section
|
|
|
|
|
func (s *Skills) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
for i, cat := range s.Technical {
|
|
|
|
|
if strings.TrimSpace(cat.Category) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("technical[%d].category", i),
|
|
|
|
|
Message: "category name is required",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 00:07:51 +01:00
|
|
|
// Proficiency should be between 1-10 (half-star increments over 5 stars)
|
|
|
|
|
if cat.Proficiency < 1 || cat.Proficiency > 10 {
|
2025-11-20 16:41:13 +00:00
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("technical[%d].proficiency", i),
|
2026-04-13 00:07:51 +01:00
|
|
|
Message: "proficiency must be between 1 and 10",
|
2025-11-20 16:41:13 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(cat.Items) == 0 {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("technical[%d].items", i),
|
|
|
|
|
Message: "at least one skill item is required",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate sidebar value
|
|
|
|
|
if cat.Sidebar != "" && cat.Sidebar != "left" && cat.Sidebar != "right" {
|
|
|
|
|
errors = append(errors, ValidationError{
|
|
|
|
|
Field: fmt.Sprintf("technical[%d].sidebar", i),
|
|
|
|
|
Message: "sidebar must be 'left', 'right', or empty",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate checks Language entry
|
|
|
|
|
func (l *Language) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(l.Language) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "language", Message: "language name is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(l.Proficiency) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "proficiency", Message: "proficiency is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Level should be between 1-5 (typical language proficiency scale)
|
|
|
|
|
if l.Level < 1 || l.Level > 5 {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "level", Message: "level must be between 1 and 5"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate checks Project entry
|
|
|
|
|
func (p *Project) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(p.Title) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "title", Message: "title is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate URL if present
|
|
|
|
|
if p.URL != "" && !isValidURL(p.URL) {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "url", Message: "invalid URL format"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GitRepoUrl can be either a URL or a local filesystem path
|
|
|
|
|
// We don't validate it strictly since it supports both formats
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate checks Meta information
|
|
|
|
|
func (m *Meta) Validate() error {
|
|
|
|
|
var errors ValidationErrors
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(m.Version) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "version", Message: "version is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(m.Language) == "" {
|
|
|
|
|
errors = append(errors, ValidationError{Field: "language", Message: "language is required"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errors) > 0 {
|
|
|
|
|
return errors
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper functions
|
|
|
|
|
|
|
|
|
|
// isValidEmail checks if an email address is valid
|
|
|
|
|
func isValidEmail(email string) bool {
|
|
|
|
|
email = strings.TrimSpace(email)
|
|
|
|
|
if email == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
_, err := mail.ParseAddress(email)
|
|
|
|
|
return err == nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isValidURL checks if a URL is valid
|
|
|
|
|
func isValidURL(urlStr string) bool {
|
|
|
|
|
urlStr = strings.TrimSpace(urlStr)
|
|
|
|
|
if urlStr == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse the URL
|
|
|
|
|
u, err := url.Parse(urlStr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check that it has a scheme (http, https, etc.)
|
|
|
|
|
if u.Scheme == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check that it has a host
|
|
|
|
|
if u.Host == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|