// Copyright 2026 MarketAlly. All rights reserved. // SPDX-License-Identifier: MIT package setting import ( "crypto/sha256" "fmt" "io" "net/http" "slices" "strings" "sync" pages_model "code.gitcaddy.com/server/v3/models/pages" repo_model "code.gitcaddy.com/server/v3/models/repo" "code.gitcaddy.com/server/v3/modules/ai" "code.gitcaddy.com/server/v3/modules/git" "code.gitcaddy.com/server/v3/modules/json" "code.gitcaddy.com/server/v3/modules/log" pages_module "code.gitcaddy.com/server/v3/modules/pages" "code.gitcaddy.com/server/v3/modules/storage" "code.gitcaddy.com/server/v3/modules/templates" "code.gitcaddy.com/server/v3/modules/typesniffer" "code.gitcaddy.com/server/v3/services/context" pages_service "code.gitcaddy.com/server/v3/services/pages" ) const ( tplRepoSettingsPages templates.TplName = "repo/settings/pages" tplRepoSettingsPagesBrand templates.TplName = "repo/settings/pages_brand" tplRepoSettingsPagesHero templates.TplName = "repo/settings/pages_hero" tplRepoSettingsPagesContent templates.TplName = "repo/settings/pages_content" tplRepoSettingsPagesComparison templates.TplName = "repo/settings/pages_comparison" tplRepoSettingsPagesSocial templates.TplName = "repo/settings/pages_social" tplRepoSettingsPagesPricing templates.TplName = "repo/settings/pages_pricing" tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer" tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme" tplRepoSettingsPagesLanguages templates.TplName = "repo/settings/pages_languages" ) // getPagesLandingConfig loads the landing page configuration func getPagesLandingConfig(ctx *context.Context) *pages_module.LandingConfig { config, err := pages_service.GetPagesConfig(ctx, ctx.Repo.Repository) if err != nil { // Return default config return &pages_module.LandingConfig{ Enabled: false, Template: "open-source-hero", Brand: pages_module.BrandConfig{Name: ctx.Repo.Repository.Name}, Hero: pages_module.HeroConfig{ Headline: ctx.Repo.Repository.Name, Subheadline: ctx.Repo.Repository.Description, }, Theme: pages_module.ThemeConfig{Mode: "auto"}, } } return config } // savePagesLandingConfig saves the landing page configuration func savePagesLandingConfig(ctx *context.Context, config *pages_module.LandingConfig) error { configJSON, err := json.Marshal(config) if err != nil { return err } dbConfig, err := repo_model.GetPagesConfigByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { if repo_model.IsErrPagesConfigNotExist(err) { // Create new config dbConfig = &repo_model.PagesConfig{ RepoID: ctx.Repo.Repository.ID, Enabled: config.Enabled, Template: repo_model.PagesTemplate(config.Template), ConfigJSON: string(configJSON), } return repo_model.CreatePagesConfig(ctx, dbConfig) } return err } // Update existing config dbConfig.Enabled = config.Enabled dbConfig.Template = repo_model.PagesTemplate(config.Template) dbConfig.ConfigJSON = string(configJSON) return repo_model.UpdatePagesConfig(ctx, dbConfig) } // applyTemplateDefaultLabels populates Navigation label fields with // template-specific defaults. Existing non-empty labels are preserved. func applyTemplateDefaultLabels(config *pages_module.LandingConfig) { defaults := pages_module.TemplateDefaultLabels(config.Template) nav := &config.Navigation if nav.LabelValueProps == "" { nav.LabelValueProps = defaults.LabelValueProps } if nav.LabelFeatures == "" { nav.LabelFeatures = defaults.LabelFeatures } if nav.LabelPricing == "" { nav.LabelPricing = defaults.LabelPricing } if nav.LabelBlog == "" { nav.LabelBlog = defaults.LabelBlog } if nav.LabelGallery == "" { nav.LabelGallery = defaults.LabelGallery } if nav.LabelCompare == "" { nav.LabelCompare = defaults.LabelCompare } } // setCommonPagesData sets common data for all pages settings pages func setCommonPagesData(ctx *context.Context) { config := getPagesLandingConfig(ctx) ctx.Data["Config"] = config ctx.Data["PagesEnabled"] = config.Enabled ctx.Data["PagesSubdomain"] = pages_service.GetPagesSubdomain(ctx.Repo.Repository) ctx.Data["PagesURL"] = pages_service.GetPagesURL(ctx.Repo.Repository) ctx.Data["PagesTemplates"] = pages_module.ValidTemplates() ctx.Data["PagesTemplateNames"] = pages_module.TemplateDisplayNames() ctx.Data["AIEnabled"] = ai.IsEnabled() } // Pages shows the repository pages settings (General page) func Pages(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesGeneral"] = true setCommonPagesData(ctx) // Get pages config config, err := repo_model.GetPagesConfigByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil && !repo_model.IsErrPagesConfigNotExist(err) { ctx.ServerError("GetPagesConfig", err) return } if config != nil { ctx.Data["PagesEnabled"] = config.Enabled ctx.Data["PagesTemplate"] = config.Template } // Get pages domains domains, err := repo_model.GetPagesDomains(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetPagesDomains", err) return } ctx.Data["PagesDomains"] = domains ctx.HTML(http.StatusOK, tplRepoSettingsPages) } func PagesPost(ctx *context.Context) { action := ctx.FormString("action") switch action { case "enable": template := ctx.FormString("template") if template == "" || !pages_module.IsValidTemplate(template) { template = "open-source-hero" } config := getPagesLandingConfig(ctx) config.Enabled = true config.Template = template applyTemplateDefaultLabels(config) if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("EnablePages", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.enabled_success")) case "disable": config := getPagesLandingConfig(ctx) config.Enabled = false if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("DisablePages", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.disabled_success")) case "update_template": template := ctx.FormString("template") if template == "" || !pages_module.IsValidTemplate(template) { template = "open-source-hero" } config := getPagesLandingConfig(ctx) config.Template = template applyTemplateDefaultLabels(config) if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("UpdateTemplate", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) case "add_domain": domain := ctx.FormString("domain") if domain == "" { ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_required")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages") return } sslExternal := ctx.FormBool("ssl_external") _, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, domain, sslExternal) if err != nil { if repo_model.IsErrPagesDomainAlreadyExist(err) { ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_exists")) } else { ctx.ServerError("AddPagesDomain", err) return } } else { ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_added")) } case "delete_domain": domainID := ctx.FormInt64("domain_id") if err := repo_model.DeletePagesDomain(ctx, domainID); err != nil { ctx.ServerError("DeletePagesDomain", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_deleted")) case "activate_ssl": domainID := ctx.FormInt64("domain_id") if err := repo_model.ActivatePagesDomainSSL(ctx, domainID); err != nil { ctx.ServerError("ActivatePagesDomainSSL", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.ssl_activated")) case "verify_domain": domainID := ctx.FormInt64("domain_id") if err := pages_service.VerifyDomain(ctx, domainID); err != nil { ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_verification_failed")) } else { ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_verified")) } case "ai_generate": readme := loadRawReadme(ctx, ctx.Repo.Repository) generated, err := pages_service.GenerateLandingPageContent(ctx, ctx.Repo.Repository, readme) if err != nil { log.Error("AI landing page generation failed: %v", err) ctx.Flash.Error(ctx.Tr("repo.settings.pages.ai_generate_failed")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages") return } // Merge AI-generated content into existing config, preserving settings config := getPagesLandingConfig(ctx) config.Brand.Name = generated.Brand.Name config.Brand.Tagline = generated.Brand.Tagline config.Hero = generated.Hero config.Stats = generated.Stats config.ValueProps = generated.ValueProps config.Features = generated.Features config.CTASection = generated.CTASection config.SEO = generated.SEO if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_generate_success")) default: ctx.NotFound(nil) return } ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages") } func PagesBrand(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.brand") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesBrand"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesBrand) } func PagesBrandPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Brand.Name = ctx.FormString("brand_name") config.Brand.Tagline = ctx.FormString("brand_tagline") if config.Brand.UploadedLogo == "" { config.Brand.LogoURL = ctx.FormString("brand_logo_url") } if config.Brand.UploadedFavicon == "" { config.Brand.FaviconURL = ctx.FormString("brand_favicon_url") } if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/brand") } const maxPagesImageSize = 5 * 1024 * 1024 // 5 MB // pagesUploadImage is a shared helper for uploading images to pages config fields. // formField is the multipart form field name, filePrefix is used in the stored filename, // redirectPath is the settings page to redirect to, getOld/setNew read/write the config field, // and successKey is the locale key for the success flash message. func pagesUploadImage( ctx *context.Context, formField, filePrefix, redirectPath string, getOld func(*pages_module.LandingConfig) string, setNew func(*pages_module.LandingConfig, string), errorKey, tooLargeKey, notImageKey, successKey string, ) { repo := ctx.Repo.Repository redirect := repo.Link() + redirectPath file, header, err := ctx.Req.FormFile(formField) if err != nil { ctx.Flash.Error(ctx.Tr(errorKey)) ctx.Redirect(redirect) return } defer file.Close() if header.Size > maxPagesImageSize { ctx.Flash.Error(ctx.Tr(tooLargeKey)) ctx.Redirect(redirect) return } data, err := io.ReadAll(file) if err != nil { ctx.ServerError("ReadAll", err) return } st := typesniffer.DetectContentType(data) if !(st.IsImage() && !st.IsSvgImage()) { ctx.Flash.Error(ctx.Tr(notImageKey)) ctx.Redirect(redirect) return } hash := sha256.Sum256(data) filename := fmt.Sprintf("%s-%d-%x", filePrefix, repo.ID, hash[:8]) config := getPagesLandingConfig(ctx) if old := getOld(config); old != "" { _ = storage.RepoAvatars.Delete(old) } if err := storage.SaveFrom(storage.RepoAvatars, filename, func(w io.Writer) error { _, err := w.Write(data) return err }); err != nil { ctx.ServerError("SaveFrom", err) return } setNew(config, filename) if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr(successKey)) ctx.Redirect(redirect) } // pagesDeleteImage is a shared helper for deleting uploaded images from pages config fields. func pagesDeleteImage( ctx *context.Context, redirectPath string, getOld func(*pages_module.LandingConfig) string, clearField func(*pages_module.LandingConfig), successKey string, ) { repo := ctx.Repo.Repository config := getPagesLandingConfig(ctx) if old := getOld(config); old != "" { _ = storage.RepoAvatars.Delete(old) clearField(config) if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } } ctx.Flash.Success(ctx.Tr(successKey)) ctx.Redirect(repo.Link() + redirectPath) } // PagesBrandUploadLogo handles logo image file upload. func PagesBrandUploadLogo(ctx *context.Context) { pagesUploadImage(ctx, "brand_logo", "pages-logo", "/settings/pages/brand", func(c *pages_module.LandingConfig) string { return c.Brand.UploadedLogo }, func(c *pages_module.LandingConfig, f string) { c.Brand.UploadedLogo = f }, "repo.settings.pages.brand_upload_error", "repo.settings.pages.brand_image_too_large", "repo.settings.pages.brand_not_an_image", "repo.settings.pages.brand_logo_uploaded") } // PagesBrandDeleteLogo removes the uploaded logo image. func PagesBrandDeleteLogo(ctx *context.Context) { pagesDeleteImage(ctx, "/settings/pages/brand", func(c *pages_module.LandingConfig) string { return c.Brand.UploadedLogo }, func(c *pages_module.LandingConfig) { c.Brand.UploadedLogo = "" }, "repo.settings.pages.brand_logo_deleted") } // PagesBrandUploadFavicon handles favicon file upload. func PagesBrandUploadFavicon(ctx *context.Context) { pagesUploadImage(ctx, "brand_favicon", "pages-favicon", "/settings/pages/brand", func(c *pages_module.LandingConfig) string { return c.Brand.UploadedFavicon }, func(c *pages_module.LandingConfig, f string) { c.Brand.UploadedFavicon = f }, "repo.settings.pages.brand_upload_error", "repo.settings.pages.brand_image_too_large", "repo.settings.pages.brand_not_an_image", "repo.settings.pages.brand_favicon_uploaded") } // PagesBrandDeleteFavicon removes the uploaded favicon. func PagesBrandDeleteFavicon(ctx *context.Context) { pagesDeleteImage(ctx, "/settings/pages/brand", func(c *pages_module.LandingConfig) string { return c.Brand.UploadedFavicon }, func(c *pages_module.LandingConfig) { c.Brand.UploadedFavicon = "" }, "repo.settings.pages.brand_favicon_deleted") } func PagesHero(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.hero") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesHero"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesHero) } func PagesHeroPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Hero.Headline = ctx.FormString("headline") config.Hero.Subheadline = ctx.FormString("subheadline") config.Hero.ImageURL = ctx.FormString("image_url") config.Hero.VideoURL = ctx.FormString("video_url") config.Hero.CodeExample = ctx.FormString("code_example") config.Hero.PrimaryCTA.Label = ctx.FormString("primary_cta_label") config.Hero.PrimaryCTA.URL = ctx.FormString("primary_cta_url") config.Hero.PrimaryCTA.Variant = ctx.FormString("primary_cta_variant") config.Hero.SecondaryCTA.Label = ctx.FormString("secondary_cta_label") config.Hero.SecondaryCTA.URL = ctx.FormString("secondary_cta_url") config.Hero.SecondaryCTA.Variant = ctx.FormString("secondary_cta_variant") if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/hero") } // PagesHeroUploadImage handles hero image file upload. func PagesHeroUploadImage(ctx *context.Context) { pagesUploadImage(ctx, "hero_image", "pages-hero", "/settings/pages/hero", func(c *pages_module.LandingConfig) string { return c.Hero.UploadedImage }, func(c *pages_module.LandingConfig, f string) { c.Hero.UploadedImage = f }, "repo.settings.pages.hero_upload_error", "repo.settings.pages.hero_image_too_large", "repo.settings.pages.hero_not_an_image", "repo.settings.pages.hero_image_uploaded") } // PagesHeroDeleteImage removes the uploaded hero image. func PagesHeroDeleteImage(ctx *context.Context) { pagesDeleteImage(ctx, "/settings/pages/hero", func(c *pages_module.LandingConfig) string { return c.Hero.UploadedImage }, func(c *pages_module.LandingConfig) { c.Hero.UploadedImage = "" }, "repo.settings.pages.hero_image_deleted") } func PagesContent(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.content") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesContent"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesContent) } func PagesContentPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Advanced.PublicReleases = ctx.FormBool("public_releases") config.Advanced.HideMobileReleases = ctx.FormBool("hide_mobile_releases") config.Advanced.GooglePlayID = strings.TrimSpace(ctx.FormString("google_play_id")) config.Advanced.AppStoreID = strings.TrimSpace(ctx.FormString("app_store_id")) config.Navigation.ShowDocs = ctx.FormBool("nav_show_docs") config.Navigation.ShowAPI = ctx.FormBool("nav_show_api") config.Navigation.ShowRepository = ctx.FormBool("nav_show_repository") config.Navigation.ShowReleases = ctx.FormBool("nav_show_releases") config.Navigation.ShowIssues = ctx.FormBool("nav_show_issues") config.Blog.Enabled = ctx.FormBool("blog_enabled") config.Blog.Headline = ctx.FormString("blog_headline") config.Blog.Subheadline = ctx.FormString("blog_subheadline") if maxPosts := ctx.FormInt("blog_max_posts"); maxPosts > 0 { config.Blog.MaxPosts = maxPosts } config.Gallery.Enabled = ctx.FormBool("gallery_enabled") config.Gallery.Headline = ctx.FormString("gallery_headline") config.Gallery.Subheadline = ctx.FormString("gallery_subheadline") if maxImages := ctx.FormInt("gallery_max_images"); maxImages > 0 { config.Gallery.MaxImages = maxImages } if cols := ctx.FormInt("gallery_columns"); cols >= 2 && cols <= 4 { config.Gallery.Columns = cols } config.Stats = nil for i := range 10 { value := ctx.FormString(fmt.Sprintf("stat_value_%d", i)) label := ctx.FormString(fmt.Sprintf("stat_label_%d", i)) if value == "" && label == "" { continue } config.Stats = append(config.Stats, pages_module.StatConfig{Value: value, Label: label}) } config.ValueProps = nil for i := range 10 { title := ctx.FormString(fmt.Sprintf("valueprop_title_%d", i)) desc := ctx.FormString(fmt.Sprintf("valueprop_desc_%d", i)) icon := ctx.FormString(fmt.Sprintf("valueprop_icon_%d", i)) if title == "" && desc == "" { continue } config.ValueProps = append(config.ValueProps, pages_module.ValuePropConfig{Title: title, Description: desc, Icon: icon}) } config.Features = nil for i := range 20 { title := ctx.FormString(fmt.Sprintf("feature_title_%d", i)) desc := ctx.FormString(fmt.Sprintf("feature_desc_%d", i)) icon := ctx.FormString(fmt.Sprintf("feature_icon_%d", i)) imageURL := ctx.FormString(fmt.Sprintf("feature_image_%d", i)) if title == "" && desc == "" { continue } config.Features = append(config.Features, pages_module.FeatureConfig{Title: title, Description: desc, Icon: icon, ImageURL: imageURL}) } // Comparison enabled toggle (full config is on /settings/pages/comparison) config.Comparison.Enabled = ctx.FormBool("comparison_enabled") if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/content") } func PagesComparison(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.comparison") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesComparison"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesComparison) } func PagesComparisonPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Comparison.Headline = ctx.FormString("comparison_headline") config.Comparison.Subheadline = ctx.FormString("comparison_subheadline") config.Comparison.Columns = make([]pages_module.ComparisonColumnConfig, 3) for c := range 3 { config.Comparison.Columns[c] = pages_module.ComparisonColumnConfig{ Name: ctx.FormString(fmt.Sprintf("comparison_col_name_%d", c)), Highlight: ctx.FormBool(fmt.Sprintf("comparison_col_highlight_%d", c)), } } config.Comparison.Groups = nil for g := range 10 { groupName := ctx.FormString(fmt.Sprintf("comparison_group_name_%d", g)) var features []pages_module.ComparisonFeatureConfig for f := range 20 { featName := ctx.FormString(fmt.Sprintf("comparison_feat_name_%d_%d", g, f)) if featName == "" { continue } var values []string for c := range 3 { values = append(values, ctx.FormString(fmt.Sprintf("comparison_feat_val_%d_%d_%d", g, f, c))) } features = append(features, pages_module.ComparisonFeatureConfig{Name: featName, Values: values}) } if groupName == "" && len(features) == 0 { continue } config.Comparison.Groups = append(config.Comparison.Groups, pages_module.ComparisonGroupConfig{Name: groupName, Features: features}) } if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/comparison") } func PagesSocial(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.social") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesSocial"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesSocial) } func PagesSocialPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.SocialProof.Logos = nil for i := range 20 { logo := ctx.FormString(fmt.Sprintf("logo_%d", i)) if logo == "" { continue } config.SocialProof.Logos = append(config.SocialProof.Logos, logo) } config.SocialProof.Testimonials = nil for i := range 10 { quote := ctx.FormString(fmt.Sprintf("testimonial_quote_%d", i)) author := ctx.FormString(fmt.Sprintf("testimonial_author_%d", i)) role := ctx.FormString(fmt.Sprintf("testimonial_role_%d", i)) avatar := ctx.FormString(fmt.Sprintf("testimonial_avatar_%d", i)) if quote == "" && author == "" { continue } config.SocialProof.Testimonials = append(config.SocialProof.Testimonials, pages_module.TestimonialConfig{Quote: quote, Author: author, Role: role, Avatar: avatar}) } if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/social") } func PagesPricing(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.pricing") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesPricing"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesPricing) } func PagesPricingPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Pricing.Headline = ctx.FormString("pricing_headline") config.Pricing.Subheadline = ctx.FormString("pricing_subheadline") config.Pricing.Plans = nil for i := range 5 { name := ctx.FormString(fmt.Sprintf("plan_name_%d", i)) price := ctx.FormString(fmt.Sprintf("plan_price_%d", i)) if name == "" && price == "" { continue } featuresText := ctx.FormString(fmt.Sprintf("plan_%d_features", i)) var features []string for f := range strings.SplitSeq(featuresText, "\n") { f = strings.TrimSpace(f) if f != "" { features = append(features, f) } } config.Pricing.Plans = append(config.Pricing.Plans, pages_module.PricingPlanConfig{ Name: name, Price: price, Period: ctx.FormString(fmt.Sprintf("plan_period_%d", i)), Features: features, CTA: ctx.FormString(fmt.Sprintf("plan_cta_%d", i)), Featured: ctx.FormBool(fmt.Sprintf("plan_featured_%d", i)), }) } if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/pricing") } func PagesFooter(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.footer") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesFooter"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesFooter) } func PagesFooterPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.CTASection.Headline = ctx.FormString("cta_headline") config.CTASection.Subheadline = ctx.FormString("cta_subheadline") config.CTASection.Button.Label = ctx.FormString("cta_button_label") config.CTASection.Button.URL = ctx.FormString("cta_button_url") config.CTASection.Button.Variant = ctx.FormString("cta_button_variant") config.Footer.Copyright = ctx.FormString("footer_copyright") config.Footer.Links = nil for i := range 10 { label := ctx.FormString(fmt.Sprintf("footer_link_label_%d", i)) url := ctx.FormString(fmt.Sprintf("footer_link_url_%d", i)) if label == "" && url == "" { continue } config.Footer.Links = append(config.Footer.Links, pages_module.FooterLink{Label: label, URL: url}) } config.Footer.Social = nil for i := range 10 { platform := ctx.FormString(fmt.Sprintf("social_platform_%d", i)) url := ctx.FormString(fmt.Sprintf("social_url_%d", i)) if platform == "" && url == "" { continue } config.Footer.Social = append(config.Footer.Social, pages_module.SocialLink{Platform: platform, URL: url}) } if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/footer") } func PagesTheme(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.theme") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesTheme"] = true setCommonPagesData(ctx) ctx.HTML(http.StatusOK, tplRepoSettingsPagesTheme) } func PagesThemePost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Theme.PrimaryColor = ctx.FormString("primary_color") config.Theme.AccentColor = ctx.FormString("accent_color") config.Theme.Mode = ctx.FormString("theme_mode") config.SEO.Title = ctx.FormString("seo_title") config.SEO.Description = ctx.FormString("seo_description") keywords := ctx.FormString("seo_keywords") if keywords != "" { parts := strings.Split(keywords, ",") config.SEO.Keywords = make([]string, 0, len(parts)) for _, kw := range parts { if trimmed := strings.TrimSpace(kw); trimmed != "" { config.SEO.Keywords = append(config.SEO.Keywords, trimmed) } } } else { config.SEO.Keywords = nil } config.SEO.UseMediaKitOG = ctx.FormBool("use_media_kit_og") if !config.SEO.UseMediaKitOG { config.SEO.OGImage = ctx.FormString("og_image") } if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/theme") } // TranslationView is a flattened view of a translation for the settings UI. // Slice fields are padded to match the base config array lengths. type TranslationView struct { // Brand BrandName string BrandTagline string // Hero Headline string Subheadline string PrimaryCTA string SecondaryCTA string // Stats (parallel to Config.Stats) StatsValues []string StatsLabels []string // Value Props (parallel to Config.ValueProps) ValuePropTitles []string ValuePropDescs []string // Features (parallel to Config.Features) FeatureTitles []string FeatureDescs []string // Testimonials (parallel to Config.SocialProof.Testimonials) TestimonialQuotes []string TestimonialRoles []string // Pricing PricingHeadline string PricingSubheadline string PlanNames []string PlanPeriods []string PlanCTAs []string // CTA Section CTAHeadline string CTASubheadline string CTAButton string // Blog BlogHeadline string BlogSubheadline string BlogCTAButton string // Gallery GalleryHeadline string GallerySubheadline string // Comparison ComparisonHeadline string ComparisonSubheadline string // Footer FooterCopyright string FooterLinkLabels []string // SEO SEOTitle string SEODescription string // Navigation labels NavLabelValueProps string NavLabelFeatures string NavLabelPricing string NavLabelBlog string NavLabelGallery string NavLabelCompare string NavLabelDocs string NavLabelReleases string NavLabelAPI string NavLabelIssues string } // overlayString extracts a string from a map func overlayString(m map[string]any, key string) string { if v, ok := m[key].(string); ok { return v } return "" } // overlayStringSlice extracts a []string-field slice from an overlay array of objects. // Each array element is a map; fieldKey is the key to extract from each map. func overlayStringSlice(overlay map[string]any, arrayKey, fieldKey string, padLen int) []string { result := make([]string, padLen) arr, ok := overlay[arrayKey].([]any) if !ok { return result } for i, item := range arr { if i >= padLen { break } if m, ok := item.(map[string]any); ok { if v, ok := m[fieldKey].(string); ok { result[i] = v } } } return result } // parseTranslationView parses a PagesTranslation into a flat view for the template. // config is used to determine array lengths for padding slices. func parseTranslationView(t *pages_model.Translation, config *pages_module.LandingConfig) *TranslationView { if t == nil || t.ConfigJSON == "" { return nil } var overlay map[string]any if err := json.Unmarshal([]byte(t.ConfigJSON), &overlay); err != nil { return nil } view := &TranslationView{} // Brand if brand, ok := overlay["brand"].(map[string]any); ok { view.BrandName = overlayString(brand, "name") view.BrandTagline = overlayString(brand, "tagline") } // Hero if hero, ok := overlay["hero"].(map[string]any); ok { view.Headline = overlayString(hero, "headline") view.Subheadline = overlayString(hero, "subheadline") if cta, ok := hero["primary_cta"].(map[string]any); ok { view.PrimaryCTA = overlayString(cta, "label") } if cta, ok := hero["secondary_cta"].(map[string]any); ok { view.SecondaryCTA = overlayString(cta, "label") } } // Stats view.StatsValues = overlayStringSlice(overlay, "stats", "value", len(config.Stats)) view.StatsLabels = overlayStringSlice(overlay, "stats", "label", len(config.Stats)) // Value Props view.ValuePropTitles = overlayStringSlice(overlay, "value_props", "title", len(config.ValueProps)) view.ValuePropDescs = overlayStringSlice(overlay, "value_props", "description", len(config.ValueProps)) // Features view.FeatureTitles = overlayStringSlice(overlay, "features", "title", len(config.Features)) view.FeatureDescs = overlayStringSlice(overlay, "features", "description", len(config.Features)) // Testimonials (stored under social_proof.testimonials) view.TestimonialQuotes = make([]string, len(config.SocialProof.Testimonials)) view.TestimonialRoles = make([]string, len(config.SocialProof.Testimonials)) if sp, ok := overlay["social_proof"].(map[string]any); ok { if arr, ok := sp["testimonials"].([]any); ok { for i, item := range arr { if i >= len(config.SocialProof.Testimonials) { break } if m, ok := item.(map[string]any); ok { view.TestimonialQuotes[i] = overlayString(m, "quote") view.TestimonialRoles[i] = overlayString(m, "role") } } } } // Pricing if pricing, ok := overlay["pricing"].(map[string]any); ok { view.PricingHeadline = overlayString(pricing, "headline") view.PricingSubheadline = overlayString(pricing, "subheadline") if plans, ok := pricing["plans"].([]any); ok { view.PlanNames = make([]string, len(config.Pricing.Plans)) view.PlanPeriods = make([]string, len(config.Pricing.Plans)) view.PlanCTAs = make([]string, len(config.Pricing.Plans)) for i, item := range plans { if i >= len(config.Pricing.Plans) { break } if m, ok := item.(map[string]any); ok { view.PlanNames[i] = overlayString(m, "name") view.PlanPeriods[i] = overlayString(m, "period") view.PlanCTAs[i] = overlayString(m, "cta") } } } else { view.PlanNames = make([]string, len(config.Pricing.Plans)) view.PlanPeriods = make([]string, len(config.Pricing.Plans)) view.PlanCTAs = make([]string, len(config.Pricing.Plans)) } } else { view.PlanNames = make([]string, len(config.Pricing.Plans)) view.PlanPeriods = make([]string, len(config.Pricing.Plans)) view.PlanCTAs = make([]string, len(config.Pricing.Plans)) } // CTA Section if ctaSec, ok := overlay["cta_section"].(map[string]any); ok { view.CTAHeadline = overlayString(ctaSec, "headline") view.CTASubheadline = overlayString(ctaSec, "subheadline") if btn, ok := ctaSec["button"].(map[string]any); ok { view.CTAButton = overlayString(btn, "label") } } // Blog if blog, ok := overlay["blog"].(map[string]any); ok { view.BlogHeadline = overlayString(blog, "headline") view.BlogSubheadline = overlayString(blog, "subheadline") if btn, ok := blog["cta_button"].(map[string]any); ok { view.BlogCTAButton = overlayString(btn, "label") } } // Gallery if gallery, ok := overlay["gallery"].(map[string]any); ok { view.GalleryHeadline = overlayString(gallery, "headline") view.GallerySubheadline = overlayString(gallery, "subheadline") } // Comparison if comp, ok := overlay["comparison"].(map[string]any); ok { view.ComparisonHeadline = overlayString(comp, "headline") view.ComparisonSubheadline = overlayString(comp, "subheadline") } // Footer view.FooterLinkLabels = make([]string, len(config.Footer.Links)) if footer, ok := overlay["footer"].(map[string]any); ok { view.FooterCopyright = overlayString(footer, "copyright") if links, ok := footer["links"].([]any); ok { for i, item := range links { if i >= len(config.Footer.Links) { break } if m, ok := item.(map[string]any); ok { view.FooterLinkLabels[i] = overlayString(m, "label") } } } } // SEO if seo, ok := overlay["seo"].(map[string]any); ok { view.SEOTitle = overlayString(seo, "title") view.SEODescription = overlayString(seo, "description") } // Navigation labels if nav, ok := overlay["navigation"].(map[string]any); ok { view.NavLabelValueProps = overlayString(nav, "label_value_props") view.NavLabelFeatures = overlayString(nav, "label_features") view.NavLabelPricing = overlayString(nav, "label_pricing") view.NavLabelBlog = overlayString(nav, "label_blog") view.NavLabelGallery = overlayString(nav, "label_gallery") view.NavLabelCompare = overlayString(nav, "label_compare") view.NavLabelDocs = overlayString(nav, "label_docs") view.NavLabelReleases = overlayString(nav, "label_releases") view.NavLabelAPI = overlayString(nav, "label_api") view.NavLabelIssues = overlayString(nav, "label_issues") } return view } // buildTranslationJSON builds a JSON overlay string from form values func buildTranslationJSON(ctx *context.Context) string { overlay := map[string]any{} // Brand brand := map[string]any{} if v := ctx.FormString("trans_brand_name"); v != "" { brand["name"] = v } if v := ctx.FormString("trans_brand_tagline"); v != "" { brand["tagline"] = v } if len(brand) > 0 { overlay["brand"] = brand } // Hero hero := map[string]any{} if v := ctx.FormString("trans_headline"); v != "" { hero["headline"] = v } if v := ctx.FormString("trans_subheadline"); v != "" { hero["subheadline"] = v } if v := ctx.FormString("trans_primary_cta"); v != "" { hero["primary_cta"] = map[string]any{"label": v} } if v := ctx.FormString("trans_secondary_cta"); v != "" { hero["secondary_cta"] = map[string]any{"label": v} } if len(hero) > 0 { overlay["hero"] = hero } // Stats (indexed) var stats []map[string]any for i := range 20 { v := ctx.FormString(fmt.Sprintf("trans_stat_%d_value", i)) l := ctx.FormString(fmt.Sprintf("trans_stat_%d_label", i)) if v == "" && l == "" { // Check if there are more by looking ahead if ctx.FormString(fmt.Sprintf("trans_stat_%d_value", i+1)) == "" && ctx.FormString(fmt.Sprintf("trans_stat_%d_label", i+1)) == "" { break } } stats = append(stats, map[string]any{"value": v, "label": l}) } if len(stats) > 0 { overlay["stats"] = stats } // Value Props (indexed) var valueProps []map[string]any for i := range 20 { t := ctx.FormString(fmt.Sprintf("trans_vp_%d_title", i)) d := ctx.FormString(fmt.Sprintf("trans_vp_%d_desc", i)) if t == "" && d == "" { if ctx.FormString(fmt.Sprintf("trans_vp_%d_title", i+1)) == "" && ctx.FormString(fmt.Sprintf("trans_vp_%d_desc", i+1)) == "" { break } } valueProps = append(valueProps, map[string]any{"title": t, "description": d}) } if len(valueProps) > 0 { overlay["value_props"] = valueProps } // Features (indexed) var features []map[string]any for i := range 20 { t := ctx.FormString(fmt.Sprintf("trans_feat_%d_title", i)) d := ctx.FormString(fmt.Sprintf("trans_feat_%d_desc", i)) if t == "" && d == "" { if ctx.FormString(fmt.Sprintf("trans_feat_%d_title", i+1)) == "" && ctx.FormString(fmt.Sprintf("trans_feat_%d_desc", i+1)) == "" { break } } features = append(features, map[string]any{"title": t, "description": d}) } if len(features) > 0 { overlay["features"] = features } // Testimonials (indexed) var testimonials []map[string]any for i := range 20 { q := ctx.FormString(fmt.Sprintf("trans_test_%d_quote", i)) r := ctx.FormString(fmt.Sprintf("trans_test_%d_role", i)) if q == "" && r == "" { if ctx.FormString(fmt.Sprintf("trans_test_%d_quote", i+1)) == "" && ctx.FormString(fmt.Sprintf("trans_test_%d_role", i+1)) == "" { break } } testimonials = append(testimonials, map[string]any{"quote": q, "role": r}) } if len(testimonials) > 0 { overlay["social_proof"] = map[string]any{"testimonials": testimonials} } // Pricing pricing := map[string]any{} if v := ctx.FormString("trans_pricing_headline"); v != "" { pricing["headline"] = v } if v := ctx.FormString("trans_pricing_subheadline"); v != "" { pricing["subheadline"] = v } var plans []map[string]any for i := range 10 { n := ctx.FormString(fmt.Sprintf("trans_plan_%d_name", i)) p := ctx.FormString(fmt.Sprintf("trans_plan_%d_period", i)) c := ctx.FormString(fmt.Sprintf("trans_plan_%d_cta", i)) if n == "" && p == "" && c == "" { if ctx.FormString(fmt.Sprintf("trans_plan_%d_name", i+1)) == "" { break } } plans = append(plans, map[string]any{"name": n, "period": p, "cta": c}) } if len(plans) > 0 { pricing["plans"] = plans } if len(pricing) > 0 { overlay["pricing"] = pricing } // CTA Section ctaSec := map[string]any{} if v := ctx.FormString("trans_cta_headline"); v != "" { ctaSec["headline"] = v } if v := ctx.FormString("trans_cta_subheadline"); v != "" { ctaSec["subheadline"] = v } if v := ctx.FormString("trans_cta_button"); v != "" { ctaSec["button"] = map[string]any{"label": v} } if len(ctaSec) > 0 { overlay["cta_section"] = ctaSec } // Blog blog := map[string]any{} if v := ctx.FormString("trans_blog_headline"); v != "" { blog["headline"] = v } if v := ctx.FormString("trans_blog_subheadline"); v != "" { blog["subheadline"] = v } if v := ctx.FormString("trans_blog_cta"); v != "" { blog["cta_button"] = map[string]any{"label": v} } if len(blog) > 0 { overlay["blog"] = blog } // Gallery gallery := map[string]any{} if v := ctx.FormString("trans_gallery_headline"); v != "" { gallery["headline"] = v } if v := ctx.FormString("trans_gallery_subheadline"); v != "" { gallery["subheadline"] = v } if len(gallery) > 0 { overlay["gallery"] = gallery } // Comparison comp := map[string]any{} if v := ctx.FormString("trans_comparison_headline"); v != "" { comp["headline"] = v } if v := ctx.FormString("trans_comparison_subheadline"); v != "" { comp["subheadline"] = v } if len(comp) > 0 { overlay["comparison"] = comp } // Footer footer := map[string]any{} if v := ctx.FormString("trans_footer_copyright"); v != "" { footer["copyright"] = v } var footerLinks []map[string]any for i := range 20 { l := ctx.FormString(fmt.Sprintf("trans_footer_link_%d", i)) if l == "" { if ctx.FormString(fmt.Sprintf("trans_footer_link_%d", i+1)) == "" { break } } footerLinks = append(footerLinks, map[string]any{"label": l}) } if len(footerLinks) > 0 { footer["links"] = footerLinks } if len(footer) > 0 { overlay["footer"] = footer } // SEO seo := map[string]any{} if v := ctx.FormString("trans_seo_title"); v != "" { seo["title"] = v } if v := ctx.FormString("trans_seo_description"); v != "" { seo["description"] = v } if len(seo) > 0 { overlay["seo"] = seo } // Navigation labels nav := map[string]any{} for _, key := range []string{ "label_value_props", "label_features", "label_pricing", "label_blog", "label_gallery", "label_compare", "label_docs", "label_releases", "label_api", "label_issues", } { if v := ctx.FormString("trans_nav_" + key); v != "" { nav[key] = v } } if len(nav) > 0 { overlay["navigation"] = nav } if len(overlay) == 0 { return "" } data, _ := json.Marshal(overlay) return string(data) } func PagesLanguages(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.languages") ctx.Data["PageIsSettingsPages"] = true ctx.Data["PageIsSettingsPagesLanguages"] = true setCommonPagesData(ctx) config := getPagesLandingConfig(ctx) ctx.Data["LanguageNames"] = pages_module.LanguageDisplayNames() // Build a map to check if a language is enabled enabledLangsMap := make(map[string]bool) for _, code := range config.I18n.Languages { enabledLangsMap[code] = true } ctx.Data["EnabledLangs"] = enabledLangsMap // Load translations into a map[lang]*TranslationView translationMap := make(map[string]*TranslationView) translations, err := pages_model.GetTranslationsByRepoID(ctx, ctx.Repo.Repository.ID) if err == nil { for _, t := range translations { translationMap[t.Lang] = parseTranslationView(t, config) } } ctx.Data["TranslationMap"] = translationMap ctx.HTML(http.StatusOK, tplRepoSettingsPagesLanguages) } func PagesLanguagesPost(ctx *context.Context) { action := ctx.FormString("action") config := getPagesLandingConfig(ctx) switch action { case "update_i18n": config.I18n.DefaultLang = ctx.FormString("default_lang") if config.I18n.DefaultLang == "" { config.I18n.DefaultLang = "en" } selectedLangs := ctx.Req.Form["languages"] // Ensure default language is always in the list if !slices.Contains(selectedLangs, config.I18n.DefaultLang) { selectedLangs = append([]string{config.I18n.DefaultLang}, selectedLangs...) } config.I18n.Languages = selectedLangs if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.languages_saved")) case "save_translation": targetLang := ctx.FormString("target_lang") if targetLang == "" { ctx.Flash.Error("Language is required") break } configJSON := buildTranslationJSON(ctx) if configJSON == "" { ctx.Flash.Warning(ctx.Tr("repo.settings.pages.translation_empty")) break } existing, err := pages_model.GetTranslation(ctx, ctx.Repo.Repository.ID, targetLang) if err != nil { ctx.ServerError("GetTranslation", err) return } if existing != nil { existing.ConfigJSON = configJSON existing.AutoGenerated = false if err := pages_model.UpdateTranslation(ctx, existing); err != nil { ctx.ServerError("UpdateTranslation", err) return } } else { t := &pages_model.Translation{ RepoID: ctx.Repo.Repository.ID, Lang: targetLang, ConfigJSON: configJSON, } if err := pages_model.CreateTranslation(ctx, t); err != nil { ctx.ServerError("CreateTranslation", err) return } } ctx.Flash.Success(ctx.Tr("repo.settings.pages.translation_saved")) case "delete_translation": targetLang := ctx.FormString("target_lang") if err := pages_model.DeleteTranslation(ctx, ctx.Repo.Repository.ID, targetLang); err != nil { ctx.ServerError("DeleteTranslation", err) return } ctx.Flash.Success(ctx.Tr("repo.settings.pages.translation_deleted")) case "ai_translate": targetLang := ctx.FormString("target_lang") if targetLang == "" { ctx.Flash.Error("Language is required") break } translated, err := pages_service.TranslateLandingPageContent(ctx, ctx.Repo.Repository, config, targetLang) if err != nil { log.Error("AI translation failed: %v", err) ctx.Flash.Error(fmt.Sprintf("AI translation failed: %v", err)) break } // Save or update the translation existing, err := pages_model.GetTranslation(ctx, ctx.Repo.Repository.ID, targetLang) if err != nil { ctx.ServerError("GetTranslation", err) return } if existing != nil { existing.ConfigJSON = translated existing.AutoGenerated = true if err := pages_model.UpdateTranslation(ctx, existing); err != nil { ctx.ServerError("UpdateTranslation", err) return } } else { t := &pages_model.Translation{ RepoID: ctx.Repo.Repository.ID, Lang: targetLang, ConfigJSON: translated, AutoGenerated: true, } if err := pages_model.CreateTranslation(ctx, t); err != nil { ctx.ServerError("CreateTranslation", err) return } } ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_translate_success")) case "ai_translate_all": defaultLang := config.I18n.DefaultLang if defaultLang == "" { defaultLang = "en" } var ( mu sync.Mutex successCount int failCount int wg sync.WaitGroup ) repoID := ctx.Repo.Repository.ID repo := ctx.Repo.Repository for _, lang := range config.I18n.Languages { if lang == defaultLang { continue } wg.Add(1) go func(lang string) { defer wg.Done() translated, err := pages_service.TranslateLandingPageContent(ctx, repo, config, lang) if err != nil { log.Error("AI translation failed for %s: %v", lang, err) mu.Lock() failCount++ mu.Unlock() return } mu.Lock() defer mu.Unlock() existing, err := pages_model.GetTranslation(ctx, repoID, lang) if err != nil { log.Error("GetTranslation failed for %s: %v", lang, err) failCount++ return } if existing != nil { existing.ConfigJSON = translated existing.AutoGenerated = true if err := pages_model.UpdateTranslation(ctx, existing); err != nil { log.Error("UpdateTranslation failed for %s: %v", lang, err) failCount++ return } } else { t := &pages_model.Translation{ RepoID: repoID, Lang: lang, ConfigJSON: translated, AutoGenerated: true, } if err := pages_model.CreateTranslation(ctx, t); err != nil { log.Error("CreateTranslation failed for %s: %v", lang, err) failCount++ return } } successCount++ }(lang) } wg.Wait() if failCount == 0 { ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_translate_all_success", successCount)) } else { ctx.Flash.Warning(ctx.Tr("repo.settings.pages.ai_translate_all_partial", successCount, successCount+failCount, failCount)) } } ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/languages") } // loadRawReadme loads the raw README content from the repository for AI consumption func loadRawReadme(ctx *context.Context, repo *repo_model.Repository) string { gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) if err != nil { return "" } defer gitRepo.Close() branch := repo.DefaultBranch if branch == "" { branch = "main" } commit, err := gitRepo.GetBranchCommit(branch) if err != nil { return "" } for _, name := range []string{"README.md", "readme.md", "README", "README.txt"} { entry, err := commit.GetTreeEntryByPath(name) if err != nil { continue } reader, err := entry.Blob().DataAsync() if err != nil { continue } content := make([]byte, entry.Blob().Size()) _, _ = reader.Read(content) reader.Close() return string(content) } return "" }