diff --git a/boost/spark.go b/boost/spark.go index 52fd5bb6..4f87dce7 100644 --- a/boost/spark.go +++ b/boost/spark.go @@ -7,7 +7,7 @@ import ( ) func Spark(envPath string) (*env.Environment, *pkg.Validator) { - validate := GetDefaultValidate() + validate := pkg.GetDefaultValidator() envMap, err := godotenv.Read(envPath) diff --git a/boost/validate.go b/boost/validate.go deleted file mode 100644 index ab166e2f..00000000 --- a/boost/validate.go +++ /dev/null @@ -1,12 +0,0 @@ -package boost - -import ( - baseValidator "github.com/go-playground/validator/v10" - "github.com/oullin/pkg" -) - -func GetDefaultValidate() *pkg.Validator { - return pkg.MakeValidatorFrom(baseValidator.New( - baseValidator.WithRequiredStructEnabled(), - )) -} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 00000000..3b7f6cd3 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "github.com/oullin/boost" + "github.com/oullin/cli/panel" + "github.com/oullin/cli/posts" + "github.com/oullin/env" + "github.com/oullin/pkg" + "github.com/oullin/pkg/cli" + "time" +) + +var environment *env.Environment + +func init() { + secrets, _ := boost.Spark("./../.env") + + environment = secrets +} + +func main() { + menu := panel.MakeMenu() + + for { + err := menu.CaptureInput() + + if err != nil { + cli.Errorln(err.Error()) + continue + } + + switch menu.GetChoice() { + case 1: + input, err := menu.CapturePostURL() + + if err != nil { + cli.Errorln(err.Error()) + continue + } + + httpClient := pkg.MakeDefaultClient(nil) + handler := posts.MakeHandler(input, httpClient, environment) + + if _, err := handler.NotParsed(); err != nil { + cli.Errorln(err.Error()) + continue + } + + return + case 2: + showTime() + case 3: + timeParse() + case 0: + cli.Successln("Goodbye!") + return + default: + cli.Errorln("Unknown option. Try again.") + } + + cli.Blueln("Press Enter to continue...") + + menu.PrintLine() + } +} + +func showTime() { + now := time.Now().Format("2006-01-02 15:04:05") + + cli.Cyanln("\nThe current time is: " + now) +} + +func timeParse() { + s := pkg.MakeStringable("2025-04-12") + + if seed, err := s.ToDatetime(); err != nil { + panic(err) + } else { + cli.Magentaln(seed.Format(time.DateTime)) + } +} diff --git a/cli/panel/menu.go b/cli/panel/menu.go new file mode 100644 index 00000000..2b1c03a9 --- /dev/null +++ b/cli/panel/menu.go @@ -0,0 +1,162 @@ +package panel + +import ( + "bufio" + "fmt" + "github.com/oullin/cli/posts" + "github.com/oullin/pkg" + "github.com/oullin/pkg/cli" + "golang.org/x/term" + "net/url" + "os" + "strconv" + "strings" +) + +type Menu struct { + Choice *int + Reader *bufio.Reader + Validator *pkg.Validator +} + +func MakeMenu() Menu { + menu := Menu{ + Reader: bufio.NewReader(os.Stdin), + Validator: pkg.GetDefaultValidator(), + } + + menu.Print() + + return menu +} + +func (p *Menu) PrintLine() { + _, _ = p.Reader.ReadString('\n') +} + +func (p *Menu) GetChoice() int { + if p.Choice == nil { + return 0 + } + + return *p.Choice +} + +func (p *Menu) CaptureInput() error { + fmt.Print(cli.YellowColour + "Select an option: " + cli.Reset) + input, err := p.Reader.ReadString('\n') + + if err != nil { + return fmt.Errorf("%s error reading input: %v %s", cli.RedColour, err, cli.Reset) + } + + input = strings.TrimSpace(input) + choice, err := strconv.Atoi(input) + + if err != nil { + return fmt.Errorf("%s Please enter a valid number. %s", cli.RedColour, cli.Reset) + } + + p.Choice = &choice + + return nil +} + +func (p *Menu) Print() { + // Try to get the terminal width; default to 80 if it fails + width, _, err := term.GetSize(int(os.Stdout.Fd())) + + if err != nil || width < 20 { + width = 80 + } + + inner := width - 2 // space between the two border chars + + // Build box pieces + border := "╔" + strings.Repeat("═", inner) + "╗" + title := "║" + p.CenterText(" Main Menu ", inner) + "║" + divider := "╠" + strings.Repeat("═", inner) + "╣" + footer := "╚" + strings.Repeat("═", inner) + "╝" + + // Print in color + fmt.Println() + fmt.Println(cli.CyanColour + border) + fmt.Println(title) + fmt.Println(divider) + + p.PrintOption("1) Parse Posts", inner) + p.PrintOption("2) Show Time", inner) + p.PrintOption("3) Show Date", inner) + p.PrintOption("0) Exit", inner) + + fmt.Println(footer + cli.Reset) +} + +// PrintOption left-pads a space, writes the text, then fills to the full inner width. +func (p *Menu) PrintOption(text string, inner int) { + content := " " + text + + if len(content) > inner { + content = content[:inner] + } + + padding := inner - len(content) + fmt.Printf("║%s%s║\n", content, strings.Repeat(" ", padding)) +} + +// CenterText centers s within width, padding with spaces. +func (p *Menu) CenterText(s string, width int) string { + if len(s) >= width { + return s[:width] + } + + pad := width - len(s) + left := pad / 2 + right := pad - left + + return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) +} + +func (p *Menu) CapturePostURL() (*posts.Input, error) { + fmt.Print("Enter the post markdown file URL: ") + + uri, err := p.Reader.ReadString('\n') + + if err != nil { + return nil, fmt.Errorf("%sError reading the given post URL: %v %s", cli.RedColour, err, cli.Reset) + } + + uri = strings.TrimSpace(uri) + if uri == "" { + return nil, fmt.Errorf("%sError: no URL provided: %s", cli.RedColour, cli.Reset) + } + + parsedURL, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("%sError: invalid URL: %v %s", cli.RedColour, err, cli.Reset) + } + + if parsedURL.Scheme != "https" || parsedURL.Host != "raw.githubusercontent.com" { + return nil, fmt.Errorf("%sError: URL must begin with https://raw.githubusercontent.com %s", cli.RedColour, cli.Reset) + } + + input := posts.Input{ + Url: parsedURL.String(), + } + + validate := p.Validator + + if _, err := validate.Rejects(input); err != nil { + return nil, fmt.Errorf( + "%sError validating the given post URL: %v %s \n%sViolations:%s %s", + cli.RedColour, + err, + cli.Reset, + cli.BlueColour, + cli.Reset, + validate.GetErrorsAsJason(), + ) + } + + return &input, nil +} diff --git a/cli/posts/factory.go b/cli/posts/factory.go new file mode 100644 index 00000000..822c4dbe --- /dev/null +++ b/cli/posts/factory.go @@ -0,0 +1,89 @@ +package posts + +import ( + "context" + "fmt" + "github.com/oullin/boost" + "github.com/oullin/database/repository" + "github.com/oullin/env" + "github.com/oullin/pkg" + "github.com/oullin/pkg/markdown" + "net/http" + "time" +) + +type Handler struct { + Input *Input + Client *pkg.Client + Posts *repository.Posts + Users *repository.Users + IsDebugging bool +} + +func MakeHandler(input *Input, client *pkg.Client, env *env.Environment) Handler { + db := boost.MakeDbConnection(env) + + return Handler{ + Input: input, + Client: client, + IsDebugging: false, + Posts: &repository.Posts{ + DB: db, + Categories: &repository.Categories{ + DB: db, + }, + }, + Users: &repository.Users{ + DB: db, + }, + } +} + +func (h Handler) NotParsed() (bool, error) { + var err error + var content string + uri := h.Input.Url + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + h.Client.OnHeaders = func(req *http.Request) { + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + } + + content, err = h.Client.Get(ctx, uri) + + if err != nil { + return false, fmt.Errorf("error fetching url [%s]: %w", uri, err) + } + + var article *markdown.Post + if article, err = markdown.Parse(content); err != nil || article == nil { + return false, fmt.Errorf("error parsing url [%s]: %w", uri, err) + } + + if h.IsDebugging { + h.RenderArticle(article) + } + + if err = h.HandlePost(article); err != nil { + return true, err + } + + return true, nil +} + +func (h Handler) RenderArticle(post *markdown.Post) { + fmt.Printf("Title: %s\n", post.Title) + fmt.Printf("Excerpt: %s\n", post.Excerpt) + fmt.Printf("Slug: %s\n", post.Slug) + fmt.Printf("Author: %s\n", post.Author) + fmt.Printf("Image URL: %s\n", post.ImageURL) + fmt.Printf("Image Alt: %s\n", post.ImageAlt) + fmt.Printf("Category: %s\n", post.Category) + fmt.Printf("Category Slug: %s\n", post.CategorySlug) + fmt.Printf("Tags Alt: %s\n", post.Tags) + fmt.Println("\n--- Content ---") + fmt.Println(post.Content) +} diff --git a/cli/posts/handler.go b/cli/posts/handler.go new file mode 100644 index 00000000..f3767fee --- /dev/null +++ b/cli/posts/handler.go @@ -0,0 +1,71 @@ +package posts + +import ( + "fmt" + "github.com/oullin/database" + "github.com/oullin/pkg/cli" + "github.com/oullin/pkg/markdown" + "time" +) + +func (h Handler) HandlePost(payload *markdown.Post) error { + var err error + var publishedAt *time.Time + + author := h.Users.FindBy( + payload.Author, + ) + + if author == nil { + return fmt.Errorf("handler: the given author [%s] does not exist", payload.Author) + } + + if publishedAt, err = payload.GetPublishedAt(); err != nil { + return fmt.Errorf("handler: the given published_at [%s] date is invalid", payload.PublishedAt) + } + + attrs := database.PostsAttrs{ + AuthorID: author.ID, + PublishedAt: publishedAt, + Slug: payload.Slug, + Title: payload.Title, + Excerpt: payload.Excerpt, + Content: payload.Content, + ImageURL: payload.ImageURL, + Categories: h.ParseCategories(payload), + Tags: h.ParseTags(payload), + } + + if _, err = h.Posts.Create(attrs); err != nil { + return fmt.Errorf("handler: error persiting the post [%s]: %s", attrs.Title, err.Error()) + } + + cli.Successln("\n" + fmt.Sprintf("Post [%s] created successfully.", attrs.Title)) + + return nil +} + +func (h Handler) ParseCategories(payload *markdown.Post) []database.CategoriesAttrs { + var categories []database.CategoriesAttrs + + slice := append(categories, database.CategoriesAttrs{ + Slug: payload.CategorySlug, + Name: payload.Category, + Description: "", + }) + + return slice +} + +func (h Handler) ParseTags(payload *markdown.Post) []database.TagAttrs { + var slice []database.TagAttrs + + for _, tag := range payload.Tags { + slice = append(slice, database.TagAttrs{ + Slug: tag, + Name: tag, + }) + } + + return slice +} diff --git a/cli/posts/input.go b/cli/posts/input.go new file mode 100644 index 00000000..300fe913 --- /dev/null +++ b/cli/posts/input.go @@ -0,0 +1,5 @@ +package posts + +type Input struct { + Url string `validate:"required,min=10"` +} diff --git a/database/attrs.go b/database/attrs.go new file mode 100644 index 00000000..8dc884fa --- /dev/null +++ b/database/attrs.go @@ -0,0 +1,64 @@ +package database + +import ( + "time" +) + +type UsersAttrs struct { + Username string + Name string + IsAdmin bool +} + +type CategoriesAttrs struct { + Slug string + Name string + Description string +} + +type TagAttrs struct { + Slug string + Name string +} + +type CommentsAttrs struct { + UUID string + PostID uint64 + AuthorID uint64 + ParentID *uint64 + Content string + ApprovedAt *time.Time +} + +type LikesAttrs struct { + UUID string `gorm:"type:uuid;unique;not null"` + PostID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"` + UserID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"` +} + +type NewsletterAttrs struct { + FirstName string + LastName string + Email string + SubscribedAt *time.Time + UnsubscribedAt *time.Time +} + +type PostViewsAttr struct { + Post Post + User User + IPAddress string + UserAgent string +} + +type PostsAttrs struct { + AuthorID uint64 + Slug string + Title string + Excerpt string + Content string + ImageURL string + PublishedAt *time.Time + Categories []CategoriesAttrs + Tags []TagAttrs +} diff --git a/database/connection.go b/database/connection.go index 10635b7c..5eeac376 100644 --- a/database/connection.go +++ b/database/connection.go @@ -73,3 +73,7 @@ func (c *Connection) Ping() { func (c *Connection) Sql() *gorm.DB { return c.driver } + +func (c *Connection) Transaction(callback func(db *gorm.DB) error) error { + return c.driver.Transaction(callback) +} diff --git a/database/schema.go b/database/model.go similarity index 100% rename from database/schema.go rename to database/model.go diff --git a/database/repository/categories.go b/database/repository/categories.go new file mode 100644 index 00000000..75490b52 --- /dev/null +++ b/database/repository/categories.go @@ -0,0 +1,95 @@ +package repository + +import ( + "fmt" + "github.com/google/uuid" + "github.com/oullin/database" + "github.com/oullin/env" + "github.com/oullin/pkg/gorm" + "strings" +) + +type Categories struct { + DB *database.Connection + Env *env.Environment +} + +func (c Categories) FindBy(slug string) *database.Category { + category := database.Category{} + + result := c.DB.Sql(). + Where("LOWER(slug) = ?", strings.ToLower(slug)). + First(&category) + + if gorm.HasDbIssues(result.Error) { + return nil + } + + if strings.Trim(category.UUID, " ") != "" { + return &category + } + + return nil +} + +func (c Categories) CreateOrUpdate(post database.Post, attrs database.PostsAttrs) (*[]database.Category, error) { + var output []database.Category + + for _, seed := range attrs.Categories { + exists, err := c.ExistOrUpdate(seed) + + if exists { + continue + } + + if err != nil { + return nil, fmt.Errorf("error creating/updating category [%s]: %s", seed.Name, err) + } + + category := database.Category{ + UUID: uuid.NewString(), + Name: seed.Name, + Slug: seed.Slug, + Description: seed.Description, + } + + if result := c.DB.Sql().Create(&category); gorm.HasDbIssues(result.Error) { + return nil, fmt.Errorf("error creating category [%s]: %s", seed.Name, result.Error) + } + + trace := database.PostCategory{ + CategoryID: category.ID, + PostID: post.ID, + } + + if result := c.DB.Sql().Create(&trace); gorm.HasDbIssues(result.Error) { + return nil, fmt.Errorf("error creating category trace [%s:%s]: %s", category.Name, post.Title, result.Error) + } + + output = append(output, category) + } + + return &output, nil +} + +func (c Categories) ExistOrUpdate(seed database.CategoriesAttrs) (bool, error) { + var category *database.Category + + if category = c.FindBy(seed.Slug); category == nil { + return false, nil + } + + if strings.Trim(seed.Name, " ") != "" { + category.Name = seed.Name + } + + if strings.Trim(seed.Description, " ") != "" { + category.Description = seed.Description + } + + if result := c.DB.Sql().Save(&category); gorm.HasDbIssues(result.Error) { + return false, fmt.Errorf("error on exist or update category [%s]: %s", category.Name, result.Error) + } + + return true, nil +} diff --git a/database/repository/posts.go b/database/repository/posts.go new file mode 100644 index 00000000..d05ea95c --- /dev/null +++ b/database/repository/posts.go @@ -0,0 +1,50 @@ +package repository + +import ( + "fmt" + "github.com/google/uuid" + "github.com/oullin/database" + "github.com/oullin/env" + "github.com/oullin/pkg/gorm" + baseGorm "gorm.io/gorm" +) + +type Posts struct { + DB *database.Connection + Env *env.Environment + Categories *Categories +} + +func (p Posts) Create(attrs database.PostsAttrs) (*database.Post, error) { + post := database.Post{ + UUID: uuid.NewString(), + AuthorID: attrs.AuthorID, + Slug: attrs.Slug, + Title: attrs.Title, + Excerpt: attrs.Excerpt, + Content: attrs.Content, + CoverImageURL: attrs.ImageURL, + PublishedAt: attrs.PublishedAt, + } + + err := p.DB.Transaction(func(db *baseGorm.DB) error { + // --- Post. + if result := db.Create(&post); gorm.HasDbIssues(result.Error) { + return fmt.Errorf("issue creating posts: %s", result.Error) + } + + // --- Categories. + if _, err := p.Categories.CreateOrUpdate(post, attrs); err != nil { + return fmt.Errorf("issue creating the given post [%s] category: %s", attrs.Slug, err.Error()) + } + + // --- Returning [nil] commits the whole transaction. + return nil + }) + + if err != nil { + return nil, fmt.Errorf("error creating posts [%s]: %s", attrs.Title, err.Error()) + } + + return &post, nil +} diff --git a/database/repository/users.go b/database/repository/users.go new file mode 100644 index 00000000..f4143c40 --- /dev/null +++ b/database/repository/users.go @@ -0,0 +1,31 @@ +package repository + +import ( + "github.com/oullin/database" + "github.com/oullin/env" + "github.com/oullin/pkg/gorm" + "strings" +) + +type Users struct { + DB *database.Connection + Env *env.Environment +} + +func (u Users) FindBy(username string) *database.User { + user := database.User{} + + result := u.DB.Sql(). + Where("LOWER(username) = ?", strings.ToLower(username)). + First(&user) + + if gorm.HasDbIssues(result.Error) { + return nil + } + + if strings.Trim(user.UUID, " ") != "" { + return &user + } + + return nil +} diff --git a/database/seeder/main.go b/database/seeder/main.go index fd3ba9da..7413ae37 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -36,7 +36,7 @@ func main() { if err := seeder.TruncateDB(); err != nil { panic(err) } else { - cli.MakeTextColour("DB Truncated successfully ...", cli.Green).Print() + cli.Successln("DB Truncated successfully ...") time.Sleep(2 * time.Second) } @@ -50,14 +50,14 @@ func main() { go func() { defer close(categoriesChan) - cli.MakeTextColour("Seeding categories ...", cli.Yellow).Print() + cli.Warningln("Seeding categories ...") categoriesChan <- seeder.SeedCategories() }() go func() { defer close(tagsChan) - cli.MakeTextColour("Seeding tags ...", cli.Magenta).Print() + cli.Magentaln("Seeding tags ...") tagsChan <- seeder.SeedTags() }() @@ -72,50 +72,51 @@ func main() { go func() { defer wg.Done() - cli.MakeTextColour("Seeding comments ...", cli.Blue).Print() + cli.Blueln("Seeding comments ...") seeder.SeedComments(posts...) }() go func() { defer wg.Done() - cli.MakeTextColour("Seeding likes ...", cli.Cyan).Print() + cli.Cyanln("Seeding likes ...") seeder.SeedLikes(posts...) }() go func() { defer wg.Done() - cli.MakeTextColour("Seeding posts-categories ...", cli.Gray).Print() + cli.Grayln("Seeding posts-categories ...") seeder.SeedPostsCategories(categories, posts) }() go func() { defer wg.Done() - cli.MakeTextColour("Seeding posts-tags ...", cli.Magenta).Print() + cli.Grayln("Seeding posts-tags ...") seeder.SeedPostTags(tags, posts) }() go func() { defer wg.Done() - cli.MakeTextColour("Seeding views ...", cli.Yellow).Print() + cli.Warningln("Seeding views ...") seeder.SeedPostViews(posts, UserA, UserB) }() go func() { defer wg.Done() - cli.MakeTextColour("Seeding Newsletters ...", cli.Green).Print() if err := seeder.SeedNewsLetters(); err != nil { - cli.MakeTextColour(err.Error(), cli.Red).Print() + cli.Error(err.Error()) + } else { + cli.Successln("Seeding Newsletters ...") } }() wg.Wait() - cli.MakeTextColour("DB seeded as expected.", cli.Green).Print() + cli.Magentaln("DB seeded as expected ....") } func clearScreen() { @@ -126,6 +127,6 @@ func clearScreen() { if err := cmd.Run(); err != nil { message := fmt.Sprintf("Could not clear screen. Error: %s", err.Error()) - cli.MakeTextColour(message, cli.Red).Print() + cli.Errorln(message) } } diff --git a/database/seeder/seeds/categories.go b/database/seeder/seeds/categories.go index dfe4a026..d56d265d 100644 --- a/database/seeder/seeds/categories.go +++ b/database/seeder/seeds/categories.go @@ -5,24 +5,20 @@ import ( "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/pkg/gorm" + "strings" ) type CategoriesSeed struct { db *database.Connection } -type CategoriesAttrs struct { - Slug string - Description string -} - func MakeCategoriesSeed(db *database.Connection) *CategoriesSeed { return &CategoriesSeed{ db: db, } } -func (s CategoriesSeed) Create(attrs CategoriesAttrs) ([]database.Category, error) { +func (s CategoriesSeed) Create(attrs database.CategoriesAttrs) ([]database.Category, error) { var categories []database.Category seeds := []string{ @@ -30,11 +26,11 @@ func (s CategoriesSeed) Create(attrs CategoriesAttrs) ([]database.Category, erro "Cloud", "Data", "DevOps", "ML", "Startups", "Engineering", } - for index, seed := range seeds { + for _, seed := range seeds { categories = append(categories, database.Category{ UUID: uuid.NewString(), Name: seed, - Slug: fmt.Sprintf("[%d]: slug-%s", index+1, attrs.Slug), + Slug: strings.ToLower(seed), Description: attrs.Description, }) } diff --git a/database/seeder/seeds/comments.go b/database/seeder/seeds/comments.go index aeef2c34..24bd03af 100644 --- a/database/seeder/seeds/comments.go +++ b/database/seeder/seeds/comments.go @@ -4,29 +4,19 @@ import ( "fmt" "github.com/google/uuid" "github.com/oullin/database" - "time" ) type CommentsSeed struct { db *database.Connection } -type CommentsAttrs struct { - UUID string - PostID uint64 - AuthorID uint64 - ParentID *uint64 - Content string - ApprovedAt *time.Time -} - func MakeCommentsSeed(db *database.Connection) *CommentsSeed { return &CommentsSeed{ db: db, } } -func (s CommentsSeed) Create(attrs ...CommentsAttrs) ([]database.Comment, error) { +func (s CommentsSeed) Create(attrs ...database.CommentsAttrs) ([]database.Comment, error) { var comments []database.Comment for _, attr := range attrs { diff --git a/database/seeder/seeds/seeder.go b/database/seeder/seeds/factory.go similarity index 87% rename from database/seeder/seeds/seeder.go rename to database/seeder/seeds/factory.go index e90fbb45..c1ebcee4 100644 --- a/database/seeder/seeds/seeder.go +++ b/database/seeder/seeds/factory.go @@ -38,7 +38,7 @@ func (s *Seeder) TruncateDB() error { func (s *Seeder) SeedUsers() (database.User, database.User) { users := MakeUsersSeed(s.dbConn) - UserA, err := users.Create(UsersAttrs{ + UserA, err := users.Create(database.UsersAttrs{ Username: "gocanto", Name: "Gus", IsAdmin: true, @@ -48,7 +48,7 @@ func (s *Seeder) SeedUsers() (database.User, database.User) { panic(err) } - UserB, err := users.Create(UsersAttrs{ + UserB, err := users.Create(database.UsersAttrs{ Username: "li", Name: "liane", IsAdmin: false, @@ -65,28 +65,26 @@ func (s *Seeder) SeedPosts(UserA, UserB database.User) []database.Post { posts := MakePostsSeed(s.dbConn) timex := time.Now() - PostsA, err := posts.CreatePosts(PostsAttrs{ + PostsA, err := posts.CreatePosts(database.PostsAttrs{ AuthorID: UserA.ID, Slug: fmt.Sprintf("post-slug-%s", uuid.NewString()), Title: fmt.Sprintf("Post %s title", uuid.NewString()), Excerpt: fmt.Sprintf("[%s] Sed at risus vel nulla consequat fermentum. Donec et orci mauris", uuid.NewString()), Content: fmt.Sprintf("[%s] Sed at risus vel nulla consequat fermentum. Donec et orci mauris. Nullam tempor velit id mi luctus, a scelerisque libero accumsan. In hac habitasse platea dictumst. Cras ac nunc nec massa tristique fringilla.", uuid.NewString()), PublishedAt: &timex, - Author: UserA, }, 1) if err != nil { panic(err) } - PostsB, err := posts.CreatePosts(PostsAttrs{ + PostsB, err := posts.CreatePosts(database.PostsAttrs{ AuthorID: UserB.ID, Slug: fmt.Sprintf("post-slug-%s", uuid.NewString()), Title: fmt.Sprintf("Post %s title", uuid.NewString()), Excerpt: fmt.Sprintf("[%s] Sed at risus vel nulla consequat fermentum. Donec et orci mauris", uuid.NewString()), Content: fmt.Sprintf("[%s] Sed at risus vel nulla consequat fermentum. Donec et orci mauris. Nullam tempor velit id mi luctus, a scelerisque libero accumsan. In hac habitasse platea dictumst. Cras ac nunc nec massa tristique fringilla.", uuid.NewString()), PublishedAt: &timex, - Author: UserB, }, 1) if err != nil { @@ -99,8 +97,9 @@ func (s *Seeder) SeedPosts(UserA, UserB database.User) []database.Post { func (s *Seeder) SeedCategories() []database.Category { categories := MakeCategoriesSeed(s.dbConn) - result, err := categories.Create(CategoriesAttrs{ + result, err := categories.Create(database.CategoriesAttrs{ Slug: fmt.Sprintf("category-slug-%s", uuid.NewString()), + Name: fmt.Sprintf("category-slug-%s", uuid.NewString()), Description: fmt.Sprintf("[%s] Sed at risus vel nulla consequat fermentum. Donec et orci mauris", uuid.NewString()), }) @@ -127,10 +126,10 @@ func (s *Seeder) SeedComments(posts ...database.Post) { seed := MakeCommentsSeed(s.dbConn) timex := time.Now() - var attrs []CommentsAttrs + var values []database.CommentsAttrs for index, post := range posts { - attrs = append(attrs, CommentsAttrs{ + values = append(values, database.CommentsAttrs{ PostID: post.ID, AuthorID: post.AuthorID, ParentID: nil, @@ -139,23 +138,23 @@ func (s *Seeder) SeedComments(posts ...database.Post) { }) } - if _, err := seed.Create(attrs...); err != nil { + if _, err := seed.Create(values...); err != nil { panic(err) } } func (s *Seeder) SeedLikes(posts ...database.Post) { seed := MakeLikesSeed(s.dbConn) - var attrs []LikesAttrs + var values []database.LikesAttrs for _, post := range posts { - attrs = append(attrs, LikesAttrs{ + values = append(values, database.LikesAttrs{ PostID: post.ID, UserID: post.AuthorID, }) } - _, err := seed.Create(attrs...) + _, err := seed.Create(values...) if err != nil { panic(err) @@ -221,11 +220,11 @@ func (s *Seeder) SeedPostViews(posts []database.Post, users ...database.User) { seed := MakePostViewsSeed(s.dbConn) - var attrs []PostViewsAttr + var values []database.PostViewsAttr for pIndex, post := range posts { for uIndex, user := range users { - attrs = append(attrs, PostViewsAttr{ + values = append(values, database.PostViewsAttr{ Post: post, User: user, IPAddress: fmt.Sprintf("192.168.0.%d", pIndex+1), @@ -234,7 +233,7 @@ func (s *Seeder) SeedPostViews(posts []database.Post, users ...database.User) { } } - err := seed.Create(attrs) + err := seed.Create(values) if err != nil { panic(err) @@ -242,9 +241,9 @@ func (s *Seeder) SeedPostViews(posts []database.Post, users ...database.User) { } func (s *Seeder) SeedNewsLetters() error { - var newsletters []NewsletterAttrs + var newsletters []database.NewsletterAttrs - a := NewsletterAttrs{ + a := database.NewsletterAttrs{ FirstName: "John", LastName: "Smith", Email: "john.smith@gmail.com", @@ -254,7 +253,7 @@ func (s *Seeder) SeedNewsLetters() error { currentTime := time.Now() last3Month := currentTime.AddDate(0, -3, 0) - b := NewsletterAttrs{ + b := database.NewsletterAttrs{ FirstName: "Don", LastName: "Smith", Email: "Don.smith@gmail.com", diff --git a/database/seeder/seeds/likes.go b/database/seeder/seeds/likes.go index a9b34862..0f50c731 100644 --- a/database/seeder/seeds/likes.go +++ b/database/seeder/seeds/likes.go @@ -11,19 +11,13 @@ type LikesSeed struct { db *database.Connection } -type LikesAttrs struct { - UUID string `gorm:"type:uuid;unique;not null"` - PostID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"` - UserID uint64 `gorm:"not null;index;uniqueIndex:idx_likes_post_user"` -} - func MakeLikesSeed(db *database.Connection) *LikesSeed { return &LikesSeed{ db: db, } } -func (s LikesSeed) Create(attrs ...LikesAttrs) ([]database.Like, error) { +func (s LikesSeed) Create(attrs ...database.LikesAttrs) ([]database.Like, error) { var likes []database.Like for _, attr := range attrs { diff --git a/database/seeder/seeds/newsletters.go b/database/seeder/seeds/newsletters.go index 5f99bb3b..a5934f59 100644 --- a/database/seeder/seeds/newsletters.go +++ b/database/seeder/seeds/newsletters.go @@ -4,28 +4,19 @@ import ( "fmt" "github.com/oullin/database" "github.com/oullin/pkg/gorm" - "time" ) type NewslettersSeed struct { db *database.Connection } -type NewsletterAttrs struct { - FirstName string - LastName string - Email string - SubscribedAt *time.Time - UnsubscribedAt *time.Time -} - func MakeNewslettersSeed(db *database.Connection) *NewslettersSeed { return &NewslettersSeed{ db: db, } } -func (s NewslettersSeed) Create(attrs []NewsletterAttrs) error { +func (s NewslettersSeed) Create(attrs []database.NewsletterAttrs) error { var newsletters []database.Newsletter for _, attr := range attrs { diff --git a/database/seeder/seeds/post_views.go b/database/seeder/seeds/post_views.go index 6a0b78ec..4562e3ce 100644 --- a/database/seeder/seeds/post_views.go +++ b/database/seeder/seeds/post_views.go @@ -10,20 +10,13 @@ type PostViewsSeed struct { db *database.Connection } -type PostViewsAttr struct { - Post database.Post - User database.User - IPAddress string - UserAgent string -} - func MakePostViewsSeed(db *database.Connection) *PostViewsSeed { return &PostViewsSeed{ db: db, } } -func (s PostViewsSeed) Create(attrs []PostViewsAttr) error { +func (s PostViewsSeed) Create(attrs []database.PostViewsAttr) error { for _, attr := range attrs { result := s.db.Sql().Create(&database.PostView{ PostID: attr.Post.ID, diff --git a/database/seeder/seeds/posts.go b/database/seeder/seeds/posts.go index e5a9a2b2..5dc897d9 100644 --- a/database/seeder/seeds/posts.go +++ b/database/seeder/seeds/posts.go @@ -5,43 +5,27 @@ import ( "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/pkg/gorm" - "time" ) type PostsSeed struct { db *database.Connection } -type PostsAttrs struct { - AuthorID uint64 - Slug string - Title string - Excerpt string - Content string - PublishedAt *time.Time - Author database.User - Categories []database.Category - Tags []database.Tag - PostViews []database.PostView - Comments []database.Comment - Likes []database.Like -} - func MakePostsSeed(db *database.Connection) *PostsSeed { return &PostsSeed{ db: db, } } -func (s PostsSeed) CreatePosts(attrs PostsAttrs, number int) ([]database.Post, error) { +func (s PostsSeed) CreatePosts(attrs database.PostsAttrs, number int) ([]database.Post, error) { var posts []database.Post for i := 1; i <= number; i++ { post := database.Post{ UUID: uuid.NewString(), AuthorID: attrs.AuthorID, - Slug: fmt.Sprintf("%s-post-%s-%d", attrs.Author.Username, attrs.Slug, i), - Title: fmt.Sprintf("Post: [%d] by %s", i, attrs.Author.Username), + Slug: attrs.Slug, + Title: attrs.Title, Excerpt: "This is an excerpt.", Content: "This is the full content of the post.", CoverImageURL: "", diff --git a/database/seeder/seeds/tags.go b/database/seeder/seeds/tags.go index 119ed883..8acae213 100644 --- a/database/seeder/seeds/tags.go +++ b/database/seeder/seeds/tags.go @@ -5,6 +5,7 @@ import ( "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/pkg/gorm" + "strings" ) type TagsSeed struct { @@ -24,11 +25,11 @@ func (s TagsSeed) Create() ([]database.Tag, error) { "Automation", "Teamwork", "Agile", "OpenAI", "Scaling", "Future", } - for index, name := range allowed { + for _, name := range allowed { tag := database.Tag{ UUID: uuid.NewString(), Name: name, - Slug: fmt.Sprintf("tag[%d]-slug-%s", index, name), + Slug: strings.ToLower(name), } tags = append(tags, tag) diff --git a/database/seeder/seeds/users.go b/database/seeder/seeds/users.go index d1f01dc8..6e1b6e7b 100644 --- a/database/seeder/seeds/users.go +++ b/database/seeder/seeds/users.go @@ -14,19 +14,13 @@ type UsersSeed struct { db *database.Connection } -type UsersAttrs struct { - Username string - Name string - IsAdmin bool -} - func MakeUsersSeed(db *database.Connection) *UsersSeed { return &UsersSeed{ db: db, } } -func (s UsersSeed) Create(attrs UsersAttrs) (database.User, error) { +func (s UsersSeed) Create(attrs database.UsersAttrs) (database.User, error) { pass, _ := pkg.MakePassword("password") fake := database.User{ diff --git a/go.mod b/go.mod index edd8e591..e91df159 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 golang.org/x/crypto v0.39.0 + golang.org/x/term v0.32.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.0 ) @@ -17,13 +19,13 @@ require ( github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/gocanto/blog v0.0.0-20250606062855-154f82d4ca21 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.15.0 // indirect diff --git a/go.sum b/go.sum index 1a2e584b..76880e72 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,9 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY= -github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg= github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -19,8 +16,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/gocanto/blog v0.0.0-20250606062855-154f82d4ca21 h1:geDmTIixTWigb/xl7UgJ/6Fvn3YSSUnhgfUkK4yTjbE= -github.com/gocanto/blog v0.0.0-20250606062855-154f82d4ca21/go.mod h1:1/ZDzK7KbNhxsw79APdtsdoSQOfHcnCnkWzZ3PYbLG8= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -29,8 +24,6 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= -github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= @@ -43,6 +36,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -55,7 +50,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -63,43 +57,25 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= -gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= -gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/pkg/Client.go b/pkg/Client.go new file mode 100644 index 00000000..4c0bd722 --- /dev/null +++ b/pkg/Client.go @@ -0,0 +1,82 @@ +package pkg + +import ( + "context" + "fmt" + "io" + "net/http" + "time" +) + +type Client struct { + UserAgent string + client *http.Client + transport *http.Transport + OnHeaders func(req *http.Request) + AbortOnNone2xx bool +} + +func GetDefaultTransport() *http.Transport { + return &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + } +} + +func MakeDefaultClient(transport *http.Transport) *Client { + if transport == nil { + transport = GetDefaultTransport() + } + + client := &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + } + + return &Client{ + client: client, + transport: transport, + UserAgent: "gocanto.dev", + OnHeaders: nil, + AbortOnNone2xx: false, + } +} + +func (f *Client) Get(ctx context.Context, url string) (string, error) { + if f == nil || f.client == nil { + return "", fmt.Errorf("client is nil") + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + if f.OnHeaders != nil { + f.OnHeaders(req) + } + + req.Header.Set("User-Agent", f.UserAgent) + + resp, err := f.client.Do(req) + if err != nil { + return "", fmt.Errorf("http request failed: %w", err) + } + + defer resp.Body.Close() + + if f.AbortOnNone2xx && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + return "", fmt.Errorf("received non-2xx status code: %d", resp.StatusCode) + } + + // To avoid allocating a massive buffer for a potentially huge response, we could use io.Copy with a limited reader + // if we need to process the body. However, if we must return a string, reading all is necessary. + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + return string(body), nil +} diff --git a/pkg/cli/colour.go b/pkg/cli/colour.go index d554793d..4642a26a 100644 --- a/pkg/cli/colour.go +++ b/pkg/cli/colour.go @@ -1,57 +1,11 @@ package cli -import ( - "fmt" - "os" - "slices" -) - var Reset = "\033[0m" -var Red = "\033[31m" -var Green = "\033[32m" -var Yellow = "\033[33m" -var Blue = "\033[34m" -var Magenta = "\033[35m" -var Cyan = "\033[36m" -var Gray = "\033[37m" -var White = "\033[97m" - -var colours = []string{Reset, Red, Green, Yellow, Blue, Magenta, Cyan, Gray, White} - -type TextColour struct { - text string - colour string - padding bool -} - -func MakeTextColour(text string, colour string) TextColour { - if !slices.Contains(colours, colour) { - colour = White - } - - return TextColour{ - text: text, - colour: colour, - padding: true, - } -} - -func (t TextColour) Print() { - _, err := fmt.Print(t.String()) - - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) - } -} - -func (t TextColour) Println() { - _, err := fmt.Println(t.String()) - - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) - } -} - -func (t TextColour) String() string { - return fmt.Sprintf("%s > %s %s\n", t.colour, t.text, Reset) -} +var RedColour = "\033[31m" +var GreenColour = "\033[32m" +var YellowColour = "\033[33m" +var BlueColour = "\033[34m" +var MagentaColour = "\033[35m" +var CyanColour = "\033[36m" +var GrayColour = "\033[37m" +var WhiteColour = "\033[97m" diff --git a/pkg/cli/message.go b/pkg/cli/message.go new file mode 100644 index 00000000..2b8c6887 --- /dev/null +++ b/pkg/cli/message.go @@ -0,0 +1,59 @@ +package cli + +import "fmt" + +func Error(message string) { + fmt.Print(RedColour + message + Reset) +} + +func Errorln(message string) { + fmt.Println(RedColour + message + Reset) +} + +func Success(message string) { + fmt.Print(GreenColour + message + Reset) +} + +func Successln(message string) { + fmt.Println(GreenColour + message + Reset) +} + +func Warning(message string) { + fmt.Print(YellowColour + message + Reset) +} + +func Warningln(message string) { + fmt.Println(YellowColour + message + Reset) +} + +func Magenta(message string) { + fmt.Print(MagentaColour + message + Reset) +} + +func Magentaln(message string) { + fmt.Println(MagentaColour + message + Reset) +} + +func Blue(message string) { + fmt.Print(BlueColour + message + Reset) +} + +func Blueln(message string) { + fmt.Println(BlueColour + message + Reset) +} + +func Cyan(message string) { + fmt.Print(CyanColour + message + Reset) +} + +func Cyanln(message string) { + fmt.Println(CyanColour + message + Reset) +} + +func Gray(message string) { + fmt.Print(GrayColour + message + Reset) +} + +func Grayln(message string) { + fmt.Println(GrayColour + message + Reset) +} diff --git a/pkg/markdown/handler.go b/pkg/markdown/handler.go new file mode 100644 index 00000000..29ef074c --- /dev/null +++ b/pkg/markdown/handler.go @@ -0,0 +1,109 @@ +package markdown + +import ( + "fmt" + "gopkg.in/yaml.v3" + "io" + "net/http" + "regexp" + "strings" +) + +func (p Parser) Fetch() (string, error) { + req, err := http.NewRequest("GET", p.Url, nil) + + if err != nil { + return "", err + } + + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Pragma", "no-cache") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch markdown: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + + if err != nil { + return "", err + } + + return string(body), nil +} + +// Parse splits the document into front-matter and content, then parses YAML. +// It also extracts a leading Parser image (header image) if present. +func Parse(data string) (*Post, error) { + var post Post + + // Expecting format: ---\n---\n + sections := strings.SplitN(data, "---", 3) + if len(sections) < 3 { + return nil, fmt.Errorf("invalid front-matter format") + } + + fm := strings.TrimSpace(sections[1]) + body := strings.TrimSpace(sections[2]) + + // Unmarshal YAML into FrontMatter + err := yaml.Unmarshal([]byte(fm), &post.FrontMatter) + if err != nil { + return nil, err + } + + // Look for a header image at the top of the content + // Parser image syntax: ![alt](url) + re := regexp.MustCompile(`^!\[(.*?)\]\((.*?)\)`) + + // Split first line from rest of content + parts := strings.SplitN(body, "\n", 2) + first := strings.TrimSpace(parts[0]) + + if m := re.FindStringSubmatch(first); len(m) == 3 { + post.ImageAlt = m[1] + post.ImageURL = m[2] + + // Remaining content excludes the header image line + if len(parts) > 1 { + post.Content = strings.TrimSpace(parts[1]) + } else { + post.Content = "" + } + } else { + // No header image found; the entire body is content + post.ImageAlt = "" + post.ImageURL = "" + post.Content = body + } + + parseCategory(&post) + + return &post, nil +} + +func parseCategory(post *Post) { + category := post.FrontMatter.Category + parts := strings.Split(category, ":") + + post.Category = parts[1] + post.CategorySlug = parts[0] + + if len(parts) >= 2 { + post.Category = parts[1] + post.CategorySlug = parts[0] + + return + } + + post.Category = category + post.Slug = strings.ToLower(category) +} diff --git a/pkg/markdown/schema.go b/pkg/markdown/schema.go new file mode 100644 index 00000000..784b56c2 --- /dev/null +++ b/pkg/markdown/schema.go @@ -0,0 +1,40 @@ +package markdown + +import ( + "fmt" + "github.com/oullin/pkg" + "time" +) + +type FrontMatter struct { + Title string `yaml:"title"` + Excerpt string `yaml:"excerpt"` + Slug string `yaml:"slug"` + Author string `yaml:"author"` + Category string `yaml:"category"` + PublishedAt string `yaml:"published_at"` + Tags []string `yaml:"tags"` +} + +type Post struct { + FrontMatter + ImageURL string + ImageAlt string + Content string + CategorySlug string +} + +type Parser struct { + Url string +} + +func (f FrontMatter) GetPublishedAt() (*time.Time, error) { + stringable := pkg.MakeStringable(f.PublishedAt) + publishedAt, err := stringable.ToDatetime() + + if err != nil { + return nil, fmt.Errorf("error parsing published_at: %v", err) + } + + return publishedAt, nil +} diff --git a/pkg/stringable.go b/pkg/stringable.go index 4f81086d..6ee000ac 100644 --- a/pkg/stringable.go +++ b/pkg/stringable.go @@ -3,6 +3,7 @@ package pkg import ( "fmt" "strings" + "time" "unicode" ) @@ -12,7 +13,7 @@ type Stringable struct { func MakeStringable(value string) *Stringable { return &Stringable{ - value: value, + value: strings.TrimSpace(value), } } @@ -36,3 +37,26 @@ func (s Stringable) ToSnakeCase() string { func (s Stringable) Dd(abstract any) { fmt.Println(fmt.Sprintf("dd: %+v", abstract)) } + +func (s Stringable) ToDatetime() (*time.Time, error) { + parsed, err := time.Parse(time.DateOnly, s.value) + + if err != nil { + return nil, fmt.Errorf("error parsing date string: %v", err) + } + + now := time.Now() + + produce := time.Date( + parsed.Year(), + parsed.Month(), + parsed.Day(), + now.Hour(), + now.Minute(), + now.Second(), + now.Nanosecond(), + now.Location(), + ) + + return &produce, nil +} diff --git a/pkg/validator.go b/pkg/validator.go index ba4656c5..e5ba8a6f 100644 --- a/pkg/validator.go +++ b/pkg/validator.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/go-playground/validator/v10" "strings" + "sync" ) type Validator struct { @@ -13,6 +14,21 @@ type Validator struct { Errors map[string]interface{} } +func GetDefaultValidator() *Validator { + var once sync.Once + var defaultValidator *Validator + + once.Do(func() { + defaultValidator = MakeValidatorFrom( + validator.New( + validator.WithRequiredStructEnabled(), + ), + ) + }) + + return defaultValidator +} + func MakeValidatorFrom(abstract *validator.Validate) *Validator { return &Validator{ Errors: make(map[string]interface{}),