diff --git a/appview/config/config.go b/appview/config/config.go index b5e861cc..6766be3f 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -135,6 +135,10 @@ type BlueskyConfig struct { UpdateInterval time.Duration `env:"UPDATE_INTERVAL, default=1h"` } +type OgcardConfig struct { + Host string `env:"HOST, default=https://og.tangled.org"` +} + func (cfg RedisConfig) ToURL() string { u := &url.URL{ Scheme: "redis", @@ -166,6 +170,7 @@ type Config struct { Label LabelConfig `env:",prefix=TANGLED_LABEL_"` Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` Sites SitesConfig `env:",prefix=TANGLED_SITES_"` + Ogcard OgcardConfig `env:",prefix=TANGLED_OGCARD_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/issues/issues.go b/appview/issues/issues.go index f0dbeaf1..54c31d97 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -23,6 +23,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" @@ -48,6 +49,7 @@ type Issues struct { logger *slog.Logger validator *validator.Validator indexer *issues_indexer.Indexer + ogcardClient *ogcard.Client } func New( @@ -77,6 +79,7 @@ func New( logger: logger, validator: validator, indexer: indexer, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } diff --git a/appview/issues/opengraph.go b/appview/issues/opengraph.go index 6f6448d3..5fae97e6 100644 --- a/appview/issues/opengraph.go +++ b/appview/issues/opengraph.go @@ -1,226 +1,15 @@ package issues import ( - "bytes" "context" - "fmt" - "image" - "image/color" - "image/png" "log" "net/http" + "time" "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" ) -func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { - width, height := ogcard.DefaultSize() - mainCard, err := ogcard.NewCard(width, height) - if err != nil { - return nil, err - } - - // Split: content area (75%) and status/stats area (25%) - contentCard, statsArea := mainCard.Split(false, 75) - - // Add padding to content - contentCard.SetMargin(50) - - // Split content horizontally: main content (80%) and avatar area (20%) - mainContent, avatarArea := contentCard.Split(true, 80) - - // Add margin to main content like repo card - mainContent.SetMargin(10) - - // Use full main content area for repo name and title - bounds := mainContent.Img.Bounds() - startX := bounds.Min.X + mainContent.Margin - startY := bounds.Min.Y + mainContent.Margin - - // Draw full repository name at top (owner/repo format) - var repoOwner string - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) - if err != nil { - repoOwner = repo.Did - } else { - repoOwner = "@" + owner.Handle.String() - } - - fullRepoName := repoOwner + " / " + repo.Name - if len(fullRepoName) > 60 { - fullRepoName = fullRepoName[:60] + "…" - } - - grayColor := color.RGBA{88, 96, 105, 255} - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Draw issue title below repo name with wrapping - titleY := startY + 60 - titleX := startX - - // Truncate title if too long - issueTitle := issue.Title - maxTitleLength := 80 - if len(issueTitle) > maxTitleLength { - issueTitle = issueTitle[:maxTitleLength] + "…" - } - - // Create a temporary card for the title area to enable wrapping - titleBounds := mainContent.Img.Bounds() - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID - - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) - titleCard := &ogcard.Card{ - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), - Font: mainContent.Font, - Margin: 0, - } - - // Draw wrapped title - lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Calculate where title ends (number of lines * line height) - lineHeight := 60 // Approximate line height for 54pt font - titleEndY := titleY + (len(lines) * lineHeight) + 10 - - // Draw issue ID in gray below the title - issueIdText := fmt.Sprintf("#%d", issue.IssueId) - err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - - // Get issue author handle (needed for avatar and metadata) - var authorHandle string - author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) - if err != nil { - authorHandle = issue.Did - } else { - authorHandle = "@" + author.Handle.String() - } - - // Draw avatar circle on the right side - avatarBounds := avatarArea.Img.Bounds() - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin - if avatarSize > 220 { - avatarSize = 220 - } - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) - avatarY := avatarBounds.Min.Y + 20 - - // Get avatar URL for issue author - avatarURL := rp.pages.AvatarUrl(authorHandle, "256") - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) - if err != nil { - log.Printf("failed to draw avatar (non-fatal): %v", err) - } - - // Split stats area: left side for status/comments (80%), right side for dolly (20%) - statusArea, dollyArea := statsArea.Split(true, 80) - - // Draw status and comment count in status/comments area - statsBounds := statusArea.Img.Bounds() - statsX := statsBounds.Min.X + 60 // left padding - statsY := statsBounds.Min.Y - - iconColor := color.RGBA{88, 96, 105, 255} - iconSize := 36 - textSize := 36.0 - labelSize := 28.0 - iconBaselineOffset := int(textSize) / 2 - - // Draw status (open/closed) with colored icon and text - var statusIcon string - var statusText string - var statusColor color.RGBA - - if issue.Open { - statusIcon = "circle-dot" - statusText = "open" - statusColor = color.RGBA{34, 139, 34, 255} // green - } else { - statusIcon = "ban" - statusText = "closed" - statusColor = color.RGBA{52, 58, 64, 255} // dark gray - } - - statusTextWidth := statusArea.TextWidth(statusText, textSize) - badgePadding := 12 - badgeHeight := int(textSize) + (badgePadding * 2) - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) - cornerRadius := 8 - badgeX := 60 - badgeY := 0 - - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) - - whiteColor := color.RGBA{255, 255, 255, 255} - iconX := statsX + badgePadding - iconY := statsY + (badgeHeight-iconSize)/2 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) - if err != nil { - log.Printf("failed to draw status icon: %v", err) - } - - textX := statsX + badgePadding + iconSize + badgePadding - textY := statsY + (badgeHeight-int(textSize))/2 - 5 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw status text: %v", err) - } - - currentX := statsX + badgeWidth + 50 - - // Draw comment count - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw comment icon: %v", err) - } - - currentX += iconSize + 15 - commentText := fmt.Sprintf("%d comments", commentCount) - if commentCount == 1 { - commentText = "1 comment" - } - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw comment text: %v", err) - } - - // Draw dolly logo on the right side - dollyBounds := dollyArea.Img.Bounds() - dollySize := 90 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly not available (this is ok): %v", err) - } - - // Draw "opened by @author" and date at the bottom with more spacing - labelY := statsY + iconSize + 30 - - // Format the opened date - openedDate := issue.Created.Format("Jan 2, 2006") - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) - - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw metadata: %v", err) - } - - return mainCard, nil -} - func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { f, err := rp.repoResolver.Resolve(r) if err != nil { @@ -235,41 +24,58 @@ func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) return } - // Get comment count - commentCount := len(issue.Comments) - - // Get owner handle for avatar var ownerHandle string - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) if err != nil { ownerHandle = f.Did } else { ownerHandle = "@" + owner.Handle.String() } - card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) + var authorHandle string + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) if err != nil { - log.Println("failed to draw issue summary card", err) - http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError) - return + authorHandle = issue.Did + } else { + authorHandle = "@" + author.Handle.String() + } + + avatarUrl := rp.pages.AvatarUrl(authorHandle, "256") + + status := "closed" + if issue.Open { + status = "open" } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) + commentCount := len(issue.Comments) + + payload := ogcard.IssueCardPayload{ + Type: "issue", + RepoName: f.Name, + OwnerHandle: ownerHandle, + AvatarUrl: avatarUrl, + Title: issue.Title, + IssueNumber: issue.IssueId, + Status: status, + Labels: []ogcard.LabelData{}, + CommentCount: commentCount, + ReactionCount: 0, + CreatedAt: issue.Created.Format(time.RFC3339), + } + + imageBytes, err := rp.ogcardClient.RenderIssueCard(r.Context(), payload) if err != nil { - log.Println("failed to encode issue summary card", err) - http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) + log.Println("failed to render issue card", err) + http.Error(w, "failed to render issue card", http.StatusInternalServerError) return } - imageBytes := imageBuffer.Bytes() - w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour + w.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write issue summary card", err) + log.Println("failed to write issue card", err) return } } diff --git a/appview/ogcard/client.go b/appview/ogcard/client.go index 5d0add68..44f019cf 100644 --- a/appview/ogcard/client.go +++ b/appview/ogcard/client.go @@ -29,6 +29,23 @@ type LabelData struct { Color string `json:"color"` } +type LanguageData struct { + Color string `json:"color"` + Percentage float32 `json:"percentage"` +} + +type RepositoryCardPayload struct { + Type string `json:"type"` + RepoName string `json:"repoName"` + OwnerHandle string `json:"ownerHandle"` + Stars int `json:"stars"` + Pulls int `json:"pulls"` + Issues int `json:"issues"` + UpdatedAt string `json:"updatedAt"` + AvatarUrl string `json:"avatarUrl"` + Languages []LanguageData `json:"languages"` +} + type IssueCardPayload struct { Type string `json:"type"` RepoName string `json:"repoName"` @@ -43,13 +60,30 @@ type IssueCardPayload struct { CreatedAt string `json:"createdAt"` } -func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) ([]byte, error) { +type PullRequestCardPayload struct { + Type string `json:"type"` + RepoName string `json:"repoName"` + OwnerHandle string `json:"ownerHandle"` + AvatarUrl string `json:"avatarUrl"` + Title string `json:"title"` + PullRequestNumber int `json:"pullRequestNumber"` + Status string `json:"status"` + FilesChanged int `json:"filesChanged"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Rounds int `json:"rounds"` + CommentCount int `json:"commentCount"` + ReactionCount int `json:"reactionCount"` + CreatedAt string `json:"createdAt"` +} + +func (c *Client) doRequest(ctx context.Context, path string, payload any) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshal payload: %w", err) } - url := fmt.Sprintf("%s/issue", c.host) + url := fmt.Sprintf("%s/%s", c.host, path) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) @@ -69,3 +103,15 @@ func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) return io.ReadAll(resp.Body) } + +func (c *Client) RenderRepositoryCard(ctx context.Context, payload RepositoryCardPayload) ([]byte, error) { + return c.doRequest(ctx, "repository", payload) +} + +func (c *Client) RenderIssueCard(ctx context.Context, payload IssueCardPayload) ([]byte, error) { + return c.doRequest(ctx, "issue", payload) +} + +func (c *Client) RenderPullRequestCard(ctx context.Context, payload PullRequestCardPayload) ([]byte, error) { + return c.doRequest(ctx, "pullRequest", payload) +} diff --git a/appview/pulls/opengraph.go b/appview/pulls/opengraph.go index 3e237180..96a5635d 100644 --- a/appview/pulls/opengraph.go +++ b/appview/pulls/opengraph.go @@ -1,106 +1,38 @@ package pulls import ( - "bytes" "context" - "fmt" - "image" - "image/color" - "image/png" "log" "net/http" + "time" "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" "tangled.org/core/patchutil" - "tangled.org/core/types" ) -func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, diffStats types.DiffFileStat, filesChanged int) (*ogcard.Card, error) { - width, height := ogcard.DefaultSize() - mainCard, err := ogcard.NewCard(width, height) - if err != nil { - return nil, err - } - - // Split: content area (75%) and status/stats area (25%) - contentCard, statsArea := mainCard.Split(false, 75) - - // Add padding to content - contentCard.SetMargin(50) - - // Split content horizontally: main content (80%) and avatar area (20%) - mainContent, avatarArea := contentCard.Split(true, 80) - - // Add margin to main content - mainContent.SetMargin(10) - - // Use full main content area for repo name and title - bounds := mainContent.Img.Bounds() - startX := bounds.Min.X + mainContent.Margin - startY := bounds.Min.Y + mainContent.Margin - - // Draw full repository name at top (owner/repo format) - var repoOwner string - owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did) - if err != nil { - repoOwner = repo.Did - } else { - repoOwner = "@" + owner.Handle.String() - } - - fullRepoName := repoOwner + " / " + repo.Name - if len(fullRepoName) > 60 { - fullRepoName = fullRepoName[:60] + "…" - } - - grayColor := color.RGBA{88, 96, 105, 255} - err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) +func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { + f, err := s.repoResolver.Resolve(r) if err != nil { - return nil, err - } - - // Draw pull request title below repo name with wrapping - titleY := startY + 60 - titleX := startX - - // Truncate title if too long - pullTitle := pull.Title - maxTitleLength := 80 - if len(pullTitle) > maxTitleLength { - pullTitle = pullTitle[:maxTitleLength] + "…" - } - - // Create a temporary card for the title area to enable wrapping - titleBounds := mainContent.Img.Bounds() - titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin - titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID - - titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) - titleCard := &ogcard.Card{ - Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), - Font: mainContent.Font, - Margin: 0, + log.Println("failed to get repo and knot", err) + return } - // Draw wrapped title - lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err + pull, ok := r.Context().Value("pull").(*models.Pull) + if !ok { + log.Println("pull not found in context") + http.Error(w, "pull not found", http.StatusNotFound) + return } - // Calculate where title ends (number of lines * line height) - lineHeight := 60 // Approximate line height for 54pt font - titleEndY := titleY + (len(lines) * lineHeight) + 10 - - // Draw pull ID in gray below the title - pullIdText := fmt.Sprintf("#%d", pull.PullId) - err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) + var ownerHandle string + owner, err := s.idResolver.ResolveIdent(context.Background(), f.Did) if err != nil { - return nil, err + ownerHandle = f.Did + } else { + ownerHandle = "@" + owner.Handle.String() } - // Get pull author handle (needed for avatar and metadata) var authorHandle string author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) if err != nil { @@ -109,214 +41,66 @@ func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, diffSt authorHandle = "@" + author.Handle.String() } - // Draw avatar circle on the right side - avatarBounds := avatarArea.Img.Bounds() - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin - if avatarSize > 220 { - avatarSize = 220 - } - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) - avatarY := avatarBounds.Min.Y + 20 - - // Get avatar URL for pull author - avatarURL := s.pages.AvatarUrl(authorHandle, "256") - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) - if err != nil { - log.Printf("failed to draw avatar (non-fatal): %v", err) - } - - // Split stats area: left side for status/stats (80%), right side for dolly (20%) - statusArea, dollyArea := statsArea.Split(true, 80) - - // Draw status and stats - statsBounds := statusArea.Img.Bounds() - statsX := statsBounds.Min.X + 60 // left padding - statsY := statsBounds.Min.Y - - iconColor := color.RGBA{88, 96, 105, 255} - iconSize := 36 - textSize := 36.0 - labelSize := 28.0 - iconBaselineOffset := int(textSize) / 2 - - // Draw status (open/merged/closed) with colored icon and text - var statusIcon string - var statusText string - var statusColor color.RGBA + avatarUrl := s.pages.AvatarUrl(authorHandle, "256") + var status string if pull.State.IsOpen() { - statusIcon = "git-pull-request" - statusText = "open" - statusColor = color.RGBA{34, 139, 34, 255} // green + status = "open" } else if pull.State.IsMerged() { - statusIcon = "git-merge" - statusText = "merged" - statusColor = color.RGBA{138, 43, 226, 255} // purple + status = "merged" } else { - statusIcon = "git-pull-request-closed" - statusText = "closed" - statusColor = color.RGBA{52, 58, 64, 255} // dark gray + status = "closed" } - statusTextWidth := statusArea.TextWidth(statusText, textSize) - badgePadding := 12 - badgeHeight := int(textSize) + (badgePadding * 2) - badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2) - cornerRadius := 8 - badgeX := 60 - badgeY := 0 - - statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor) + var filesChanged int + var additions int64 + var deletions int64 - whiteColor := color.RGBA{255, 255, 255, 255} - iconX := statsX + badgePadding - iconY := statsY + (badgeHeight-iconSize)/2 - err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor) - if err != nil { - log.Printf("failed to draw status icon: %v", err) - } - - textX := statsX + badgePadding + iconSize + badgePadding - textY := statsY + (badgeHeight-int(textSize))/2 - 5 - err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw status text: %v", err) - } - - currentX := statsX + badgeWidth + 50 - - // Draw comment count - err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw comment icon: %v", err) - } - - currentX += iconSize + 15 - commentCount := pull.TotalComments() - commentText := fmt.Sprintf("%d comments", commentCount) - if commentCount == 1 { - commentText = "1 comment" - } - err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw comment text: %v", err) - } - - commentTextWidth := len(commentText) * 20 - currentX += commentTextWidth + 40 - - // Draw files changed - err = statusArea.DrawLucideIcon("file-diff", currentX, iconY, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw file diff icon: %v", err) - } - - currentX += iconSize + 15 - filesText := fmt.Sprintf("%d files", filesChanged) - if filesChanged == 1 { - filesText = "1 file" - } - err = statusArea.DrawTextAt(filesText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw files text: %v", err) - } - - filesTextWidth := len(filesText) * 20 - currentX += filesTextWidth - - // Draw additions (green +) - greenColor := color.RGBA{34, 139, 34, 255} - additionsText := fmt.Sprintf("+%d", diffStats.Insertions) - err = statusArea.DrawTextAt(additionsText, currentX, textY, greenColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw additions text: %v", err) - } - - additionsTextWidth := len(additionsText) * 20 - currentX += additionsTextWidth + 30 - - // Draw deletions (red -) right next to additions - redColor := color.RGBA{220, 20, 60, 255} - deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) - err = statusArea.DrawTextAt(deletionsText, currentX, textY, redColor, textSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw deletions text: %v", err) - } - - // Draw dolly logo on the right side - dollyBounds := dollyArea.Img.Bounds() - dollySize := 90 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly silhouette not available (this is ok): %v", err) - } - - // Draw "opened by @author" and date at the bottom with more spacing - labelY := statsY + iconSize + 30 - - // Format the opened date - openedDate := pull.Created.Format("Jan 2, 2006") - metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate) - - err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw metadata: %v", err) - } - - return mainCard, nil -} - -func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { - f, err := s.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - pull, ok := r.Context().Value("pull").(*models.Pull) - if !ok { - log.Println("pull not found in context") - http.Error(w, "pull not found", http.StatusNotFound) - return - } - - // Calculate diff stats from latest submission using patchutil - var diffStats types.DiffFileStat - filesChanged := 0 if len(pull.Submissions) > 0 { latestSubmission := pull.LatestSubmission() niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) - diffStats.Insertions = int64(niceDiff.Stat.Insertions) - diffStats.Deletions = int64(niceDiff.Stat.Deletions) filesChanged = niceDiff.Stat.FilesChanged + additions = int64(niceDiff.Stat.Insertions) + deletions = int64(niceDiff.Stat.Deletions) } - card, err := s.drawPullSummaryCard(pull, f, diffStats, filesChanged) - if err != nil { - log.Println("failed to draw pull summary card", err) - http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError) - return + commentCount := pull.TotalComments() + + rounds := len(pull.Submissions) + if rounds == 0 { + rounds = 1 } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) + payload := ogcard.PullRequestCardPayload{ + Type: "pullRequest", + RepoName: f.Name, + OwnerHandle: ownerHandle, + AvatarUrl: avatarUrl, + Title: pull.Title, + PullRequestNumber: pull.PullId, + Status: status, + FilesChanged: filesChanged, + Additions: int(additions), + Deletions: int(deletions), + Rounds: rounds, + CommentCount: commentCount, + ReactionCount: 0, + CreatedAt: pull.Created.Format(time.RFC3339), + } + + imageBytes, err := s.ogcardClient.RenderPullRequestCard(r.Context(), payload) if err != nil { - log.Println("failed to encode pull summary card", err) - http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) + log.Println("failed to render pull request card", err) + http.Error(w, "failed to render pull request card", http.StatusInternalServerError) return } - imageBytes := imageBuffer.Bytes() - w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour + w.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write pull summary card", err) + log.Println("failed to write pull request card", err) return } } diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 50fb8a3f..9aecb4c8 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -26,6 +26,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/pages/repoinfo" @@ -65,6 +66,7 @@ type Pulls struct { logger *slog.Logger validator *validator.Validator indexer *pulls_indexer.Indexer + ogcardClient *ogcard.Client } func New( @@ -94,6 +96,7 @@ func New( logger: logger, validator: validator, indexer: indexer, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } } diff --git a/appview/repo/opengraph.go b/appview/repo/opengraph.go index 1cb2a2ee..b5e2bf52 100644 --- a/appview/repo/opengraph.go +++ b/appview/repo/opengraph.go @@ -1,341 +1,36 @@ package repo import ( - "bytes" "context" - "encoding/hex" - "fmt" - "image/color" - "image/png" "log" "net/http" "sort" - "strings" + "time" "github.com/go-enry/go-enry/v2" "tangled.org/core/appview/db" - "tangled.org/core/appview/models" "tangled.org/core/appview/ogcard" "tangled.org/core/orm" "tangled.org/core/types" ) -func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { - width, height := ogcard.DefaultSize() - mainCard, err := ogcard.NewCard(width, height) +func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { + f, err := rp.repoResolver.Resolve(r) if err != nil { - return nil, err + log.Println("failed to get repo and knot", err) + return } - // Split: content area (75%) and language bar + icons (25%) - contentCard, bottomArea := mainCard.Split(false, 75) - - // Add padding to content - contentCard.SetMargin(50) - - // Split content horizontally: main content (80%) and avatar area (20%) - mainContent, avatarArea := contentCard.Split(true, 80) - - // Use main content area for both repo name and description to allow dynamic wrapping. - mainContent.SetMargin(10) - var ownerHandle string - owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) + owner, err := rp.idResolver.ResolveIdent(context.Background(), f.Did) if err != nil { - ownerHandle = repo.Did + ownerHandle = f.Did } else { ownerHandle = "@" + owner.Handle.String() } - bounds := mainContent.Img.Bounds() - startX := bounds.Min.X + mainContent.Margin - startY := bounds.Min.Y + mainContent.Margin - currentX := startX - currentY := startY - lineHeight := 64 // Font size 54 + padding - textColor := color.RGBA{88, 96, 105, 255} - - // Draw owner handle - ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += ownerWidth - - // Draw separator - sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += sepWidth - - words := strings.Fields(repo.Name) - spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) - if spaceWidth == 0 { - spaceWidth = 15 - } - - for _, word := range words { - // estimate bold width by measuring regular width and adding a multiplier - regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) - estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text - - if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { - currentX = startX - currentY += lineHeight - } - - _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) - if err != nil { - return nil, err - } - currentX += estimatedBoldWidth + spaceWidth - } - - // update Y position for the description - currentY += lineHeight - - // draw description - if currentY < bounds.Max.Y-mainContent.Margin { - totalHeight := float64(bounds.Dy()) - repoNameHeight := float64(currentY - bounds.Min.Y) - - if totalHeight > 0 && repoNameHeight < totalHeight { - repoNamePercent := (repoNameHeight / totalHeight) * 100 - if repoNamePercent < 95 { // Ensure there's space left for description - _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) - descriptionCard.SetMargin(8) - - description := repo.Description - if len(description) > 70 { - description = description[:70] + "…" - } - - _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) - if err != nil { - log.Printf("failed to draw description: %v", err) - } - } - } - } - - // Draw avatar circle on the right side - avatarBounds := avatarArea.Img.Bounds() - avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin - if avatarSize > 220 { - avatarSize = 220 - } - avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) - avatarY := avatarBounds.Min.Y + 20 - - // Get avatar URL and draw it - avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") - err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) - if err != nil { - log.Printf("failed to draw avatar (non-fatal): %v", err) - } - - // Split bottom area: icons area (65%) and language bar (35%) - iconsArea, languageBarCard := bottomArea.Split(false, 75) - - // Split icons area: left side for stats (80%), right side for dolly (20%) - statsArea, dollyArea := iconsArea.Split(true, 80) - - // Draw stats with icons in the stats area - starsText := repo.RepoStats.StarCount - issuesText := repo.RepoStats.IssueCount.Open - pullRequestsText := repo.RepoStats.PullCount.Open - - iconColor := color.RGBA{88, 96, 105, 255} - iconSize := 36 - textSize := 36.0 - - // Position stats in the middle of the stats area - statsBounds := statsArea.Img.Bounds() - statsX := statsBounds.Min.X + 60 // left padding - statsY := statsBounds.Min.Y - currentX = statsX - labelSize := 22.0 - // Draw star icon, count, and label - // Align icon baseline with text baseline - iconBaselineOffset := int(textSize) / 2 - err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw star icon: %v", err) - } - starIconX := currentX - currentX += iconSize + 15 - - starText := fmt.Sprintf("%d", starsText) - err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw star text: %v", err) - } - starTextWidth := len(starText) * 20 - starGroupWidth := iconSize + 15 + starTextWidth - - // Draw "stars" label below and centered under the icon+text group - labelY := statsY + iconSize + 15 - labelX := starIconX + starGroupWidth/2 - err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw stars label: %v", err) - } - - currentX += starTextWidth + 50 + avatarUrl := rp.pages.AvatarUrl(ownerHandle, "256") - // Draw issues icon, count, and label - issueStartX := currentX - err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw circle-dot icon: %v", err) - } - currentX += iconSize + 15 - - issueText := fmt.Sprintf("%d", issuesText) - err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw issue text: %v", err) - } - issueTextWidth := len(issueText) * 20 - issueGroupWidth := iconSize + 15 + issueTextWidth - - // Draw "issues" label below and centered under the icon+text group - labelX = issueStartX + issueGroupWidth/2 - err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw issues label: %v", err) - } - - currentX += issueTextWidth + 50 - - // Draw pull request icon, count, and label - prStartX := currentX - err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) - if err != nil { - log.Printf("failed to draw git-pull-request icon: %v", err) - } - currentX += iconSize + 15 - - prText := fmt.Sprintf("%d", pullRequestsText) - err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) - if err != nil { - log.Printf("failed to draw PR text: %v", err) - } - prTextWidth := len(prText) * 20 - prGroupWidth := iconSize + 15 + prTextWidth - - // Draw "pulls" label below and centered under the icon+text group - labelX = prStartX + prGroupWidth/2 - err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) - if err != nil { - log.Printf("failed to draw pulls label: %v", err) - } - - dollyBounds := dollyArea.Img.Bounds() - dollySize := 90 - dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) - dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 - dollyColor := color.RGBA{180, 180, 180, 255} // light gray - err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) - if err != nil { - log.Printf("dolly silhouette not available (this is ok): %v", err) - } - - // Draw language bar at bottom - err = drawLanguagesCard(languageBarCard, languageStats) - if err != nil { - log.Printf("failed to draw language bar: %v", err) - return nil, err - } - - return mainCard, nil -} - -// hexToColor converts a hex color to a go color -func hexToColor(colorStr string) (*color.RGBA, error) { - colorStr = strings.TrimLeft(colorStr, "#") - - b, err := hex.DecodeString(colorStr) - if err != nil { - return nil, err - } - - if len(b) < 3 { - return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) - } - - clr := color.RGBA{b[0], b[1], b[2], 255} - - return &clr, nil -} - -func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { - bounds := card.Img.Bounds() - cardWidth := bounds.Dx() - - if len(languageStats) == 0 { - // Draw a light gray bar if no languages detected - card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) - return nil - } - - // Limit to top 5 languages for the visual bar - displayLanguages := languageStats - if len(displayLanguages) > 5 { - displayLanguages = displayLanguages[:5] - } - - currentX := bounds.Min.X - - for _, lang := range displayLanguages { - var langColor *color.RGBA - var err error - - if lang.Color != "" { - langColor, err = hexToColor(lang.Color) - if err != nil { - // Fallback to a default color - langColor = &color.RGBA{149, 157, 165, 255} - } - } else { - // Default color if no color specified - langColor = &color.RGBA{149, 157, 165, 255} - } - - langWidth := float32(cardWidth) * (lang.Percentage / 100) - card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) - currentX += int(langWidth) - } - - // Fill remaining space with the last color (if any gap due to rounding) - if currentX < bounds.Max.X && len(displayLanguages) > 0 { - lastLang := displayLanguages[len(displayLanguages)-1] - var lastColor *color.RGBA - var err error - - if lastLang.Color != "" { - lastColor, err = hexToColor(lastLang.Color) - if err != nil { - lastColor = &color.RGBA{149, 157, 165, 255} - } - } else { - lastColor = &color.RGBA{149, 157, 165, 255} - } - card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) - } - - return nil -} - -func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - // Get language stats directly from database var languageStats []types.RepoLanguageDetails langs, err := db.GetRepoLanguages( rp.db, @@ -344,7 +39,6 @@ func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { ) if err != nil { log.Printf("failed to get language stats from db: %v", err) - // non-fatal, continue without language stats } else if len(langs) > 0 { var total int64 for _, l := range langs { @@ -375,29 +69,44 @@ func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { }) } - card, err := rp.drawRepoSummaryCard(f, languageStats) - if err != nil { - log.Println("failed to draw repo summary card", err) - http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) - return + var ogLanguages []ogcard.LanguageData + for _, lang := range languageStats { + if len(ogLanguages) >= 5 { + break + } + ogLanguages = append(ogLanguages, ogcard.LanguageData{ + Color: lang.Color, + Percentage: lang.Percentage, + }) } - var imageBuffer bytes.Buffer - err = png.Encode(&imageBuffer, card.Img) + updatedAt := f.Created + + payload := ogcard.RepositoryCardPayload{ + Type: "repository", + RepoName: f.Name, + OwnerHandle: ownerHandle, + Stars: f.RepoStats.StarCount, + Pulls: f.RepoStats.PullCount.Open, + Issues: f.RepoStats.IssueCount.Open, + UpdatedAt: updatedAt.Format(time.RFC3339), + AvatarUrl: avatarUrl, + Languages: ogLanguages, + } + + imageBytes, err := rp.ogcardClient.RenderRepositoryCard(r.Context(), payload) if err != nil { - log.Println("failed to encode repo summary card", err) - http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) + log.Println("failed to render repository card", err) + http.Error(w, "failed to render repository card", http.StatusInternalServerError) return } - imageBytes := imageBuffer.Bytes() - w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour + w.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) _, err = w.Write(imageBytes) if err != nil { - log.Println("failed to write repo summary card", err) + log.Println("failed to write repository card", err) return } } diff --git a/appview/repo/repo.go b/appview/repo/repo.go index 81f46173..daa79310 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -20,6 +20,7 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/notify" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/validator" @@ -53,6 +54,7 @@ type Repo struct { serviceAuth *serviceauth.ServiceAuth validator *validator.Validator cfClient *cloudflare.Client + ogcardClient *ogcard.Client } func New( @@ -82,6 +84,7 @@ func New( logger: logger, validator: validator, cfClient: cfClient, + ogcardClient: ogcard.NewClient(config.Ogcard.Host), } }