Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

1751 lignes
57KB

  1. package handlers
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "net/url"
  8. "regexp"
  9. "sort"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "unicode"
  14. "github.com/go-chi/chi/v5"
  15. "qctextbuilder/internal/buildsvc"
  16. "qctextbuilder/internal/config"
  17. "qctextbuilder/internal/domain"
  18. "qctextbuilder/internal/draftsvc"
  19. "qctextbuilder/internal/mapping"
  20. "qctextbuilder/internal/onboarding"
  21. "qctextbuilder/internal/store"
  22. "qctextbuilder/internal/templatesvc"
  23. )
  24. type UI struct {
  25. templateSvc *templatesvc.Service
  26. onboardSvc *onboarding.Service
  27. draftSvc *draftsvc.Service
  28. buildSvc buildsvc.Service
  29. settings store.SettingsStore
  30. cfg config.Config
  31. render htmlRenderer
  32. }
  33. type htmlRenderer interface {
  34. Render(w http.ResponseWriter, name string, data any)
  35. }
  36. type pageData struct {
  37. Title string
  38. Msg string
  39. Err string
  40. Current string
  41. }
  42. type homePageData struct {
  43. pageData
  44. TemplateCount int
  45. }
  46. type settingsPageData struct {
  47. pageData
  48. QCBaseURL string
  49. PollIntervalSeconds int
  50. PollTimeoutSeconds int
  51. PollMaxConcurrent int
  52. TokenConfigured bool
  53. LanguageOutputMode string
  54. MasterPrompt string
  55. PromptBlocks []domain.PromptBlockConfig
  56. }
  57. type templatesPageData struct {
  58. pageData
  59. Templates []domain.Template
  60. }
  61. type templateFieldView struct {
  62. Path string
  63. FieldKind string
  64. IsEnabled bool
  65. IsRequiredByUs bool
  66. DisplayLabel string
  67. DisplayOrder int
  68. WebsiteSection string
  69. Notes string
  70. SampleValue string
  71. }
  72. type websiteSectionOptionView struct {
  73. Value string
  74. Label string
  75. }
  76. type templateDetailPageData struct {
  77. pageData
  78. Detail *templatesvc.TemplateDetail
  79. Fields []templateFieldView
  80. WebsiteSectionOptions []websiteSectionOptionView
  81. }
  82. type buildFieldView struct {
  83. Index int
  84. AnchorID string
  85. Path string
  86. DisplayLabel string
  87. SampleValue string
  88. Value string
  89. SuggestedValue string
  90. SuggestionReason string
  91. SuggestionStatus string
  92. SuggestionSource string
  93. }
  94. type buildFieldGroupView struct {
  95. Title string
  96. Fields []buildFieldView
  97. }
  98. type buildFieldSectionView struct {
  99. Key string
  100. Title string
  101. Description string
  102. EditableGroups []buildFieldGroupView
  103. EditableFields []buildFieldView
  104. DisabledFields []buildFieldView
  105. }
  106. type semanticSlotPreviewView struct {
  107. Slot string
  108. Count int
  109. Examples string
  110. }
  111. type pendingField struct {
  112. Field domain.TemplateField
  113. View buildFieldView
  114. }
  115. type fieldRole struct {
  116. Label string
  117. Order int
  118. }
  119. var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`)
  120. var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`)
  121. var knownBlockAreas = map[string]string{
  122. "m1710": "Hero / Haupttitel",
  123. "c7886": "Intro / Einleitung",
  124. "r4830": "Services",
  125. "m4178": "Gallery / Medien",
  126. "c2929": "Ueber uns / About",
  127. "r4748": "Team",
  128. "r1508": "Testimonials",
  129. "c1165": "CTA / Highlight / Banner",
  130. }
  131. type buildNewPageData struct {
  132. pageData
  133. Templates []domain.Template
  134. Drafts []domain.BuildDraft
  135. SelectedDraftID string
  136. SelectedTemplateID int64
  137. SelectedManifestID string
  138. FieldSections []buildFieldSectionView
  139. EditableFields []buildFieldView
  140. EnabledFields []buildFieldView
  141. SuggestionStateJSON string
  142. AutofillFocusID string
  143. Form buildFormInput
  144. SemanticSlots []semanticSlotPreviewView
  145. }
  146. type buildFormInput struct {
  147. DraftID string
  148. DraftSource string
  149. DraftStatus string
  150. DraftNotes string
  151. RequestName string
  152. CompanyName string
  153. BusinessType string
  154. Username string
  155. Email string
  156. Phone string
  157. OrgNumber string
  158. StartDate string
  159. Mission string
  160. DescriptionShort string
  161. DescriptionLong string
  162. SiteLanguage string
  163. AddressLine1 string
  164. AddressLine2 string
  165. AddressCity string
  166. AddressRegion string
  167. AddressZIP string
  168. AddressCountry string
  169. WebsiteURL string
  170. WebsiteSummary string
  171. LocaleStyle string
  172. MarketStyle string
  173. AddressMode string
  174. ContentTone string
  175. PromptInstructions string
  176. MasterPrompt string
  177. PromptBlocks []domain.PromptBlockConfig
  178. }
  179. type buildDetailPageData struct {
  180. pageData
  181. Build *domain.SiteBuild
  182. EffectiveGlobal []byte
  183. CanPoll bool
  184. CanFetchEditorURL bool
  185. AutoRefreshSeconds int
  186. }
  187. func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, settings store.SettingsStore, cfg config.Config, render htmlRenderer) *UI {
  188. return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, settings: settings, cfg: cfg, render: render}
  189. }
  190. func (u *UI) Home(w http.ResponseWriter, r *http.Request) {
  191. templates, err := u.templateSvc.ListTemplates(r.Context())
  192. if err != nil {
  193. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0})
  194. return
  195. }
  196. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)})
  197. }
  198. func (u *UI) Settings(w http.ResponseWriter, r *http.Request) {
  199. settings := u.loadPromptSettings(r.Context())
  200. u.render.Render(w, "settings", settingsPageData{
  201. pageData: basePageData(r, "Settings", "/settings"),
  202. QCBaseURL: u.cfg.QCBaseURL,
  203. PollIntervalSeconds: u.cfg.PollIntervalSeconds,
  204. PollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
  205. PollMaxConcurrent: u.cfg.PollMaxConcurrent,
  206. TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "",
  207. LanguageOutputMode: "EN",
  208. MasterPrompt: settings.MasterPrompt,
  209. PromptBlocks: settings.PromptBlocks,
  210. })
  211. }
  212. func (u *UI) SavePromptSettings(w http.ResponseWriter, r *http.Request) {
  213. if err := r.ParseForm(); err != nil {
  214. http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther)
  215. return
  216. }
  217. settings := u.loadPromptSettings(r.Context())
  218. settings.MasterPrompt = domain.NormalizeMasterPrompt(r.FormValue("master_prompt"))
  219. settings.PromptBlocks = parsePromptBlocksFromRequest(r)
  220. if err := u.settings.UpsertSettings(r.Context(), settings); err != nil {
  221. http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
  222. return
  223. }
  224. http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther)
  225. }
  226. func (u *UI) Templates(w http.ResponseWriter, r *http.Request) {
  227. templates, err := u.templateSvc.ListTemplates(r.Context())
  228. if err != nil {
  229. http.Error(w, err.Error(), http.StatusBadRequest)
  230. return
  231. }
  232. u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates})
  233. }
  234. func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) {
  235. if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil {
  236. http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther)
  237. return
  238. }
  239. http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther)
  240. }
  241. func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) {
  242. templateID, ok := parseTemplateID(w, r)
  243. if !ok {
  244. return
  245. }
  246. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
  247. if err != nil {
  248. http.Error(w, err.Error(), http.StatusNotFound)
  249. return
  250. }
  251. fields := make([]templateFieldView, 0, len(detail.Fields))
  252. for _, f := range detail.Fields {
  253. fields = append(fields, templateFieldView{
  254. Path: f.Path,
  255. FieldKind: f.FieldKind,
  256. IsEnabled: f.IsEnabled,
  257. IsRequiredByUs: f.IsRequiredByUs,
  258. DisplayLabel: f.DisplayLabel,
  259. DisplayOrder: f.DisplayOrder,
  260. WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection),
  261. Notes: f.Notes,
  262. SampleValue: f.SampleValue,
  263. })
  264. }
  265. u.render.Render(w, "template_detail", templateDetailPageData{
  266. pageData: basePageData(r, "Template Detail", "/templates"),
  267. Detail: detail,
  268. Fields: fields,
  269. WebsiteSectionOptions: websiteSectionOptions(),
  270. })
  271. }
  272. func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) {
  273. templateID, ok := parseTemplateID(w, r)
  274. if !ok {
  275. return
  276. }
  277. if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil {
  278. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  279. return
  280. }
  281. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther)
  282. }
  283. func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
  284. templateID, ok := parseTemplateID(w, r)
  285. if !ok {
  286. return
  287. }
  288. if err := r.ParseForm(); err != nil {
  289. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther)
  290. return
  291. }
  292. count, _ := strconv.Atoi(r.FormValue("field_count"))
  293. patches := make([]onboarding.FieldPatch, 0, count)
  294. for i := 0; i < count; i++ {
  295. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  296. if path == "" {
  297. continue
  298. }
  299. enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on"
  300. required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on"
  301. label := r.FormValue(fmt.Sprintf("field_label_%d", i))
  302. notes := r.FormValue(fmt.Sprintf("field_notes_%d", i))
  303. websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i))
  304. order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i))))
  305. if err != nil {
  306. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther)
  307. return
  308. }
  309. patches = append(patches, onboarding.FieldPatch{
  310. Path: path,
  311. IsEnabled: boolPtr(enabled),
  312. IsRequiredByUs: boolPtr(required),
  313. DisplayLabel: strPtr(label),
  314. DisplayOrder: intPtr(order),
  315. WebsiteSection: strPtr(websiteSection),
  316. Notes: strPtr(notes),
  317. })
  318. }
  319. manifestID := r.FormValue("manifest_id")
  320. if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil {
  321. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  322. return
  323. }
  324. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther)
  325. }
  326. func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) {
  327. settings := u.loadPromptSettings(r.Context())
  328. selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64)
  329. selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id"))
  330. form := buildFormInput{
  331. DraftID: selectedDraftID,
  332. DraftSource: "ui",
  333. DraftStatus: "draft",
  334. MasterPrompt: settings.MasterPrompt,
  335. PromptBlocks: clonePromptBlocks(settings.PromptBlocks),
  336. }
  337. fieldValues := map[string]string{}
  338. suggestionState := domain.DraftSuggestionState{}
  339. if selectedDraftID != "" {
  340. draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID)
  341. if err == nil {
  342. selectedTemplateID = draft.TemplateID
  343. form = buildFormInputFromDraft(draft)
  344. fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON)
  345. suggestionState = parseSuggestionStateJSON(draft.SuggestionStateJSON)
  346. form.MasterPrompt = settings.MasterPrompt
  347. form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
  348. }
  349. }
  350. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  351. data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues, suggestionState)
  352. if err != nil {
  353. http.Error(w, err.Error(), http.StatusBadRequest)
  354. return
  355. }
  356. u.render.Render(w, "build_new", data)
  357. }
  358. func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
  359. if err := r.ParseForm(); err != nil {
  360. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  361. return
  362. }
  363. form := buildFormInputFromRequest(r)
  364. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  365. fieldValues := parseBuildFieldValues(r)
  366. suggestionState := parseSuggestionStateFromRequest(r)
  367. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  368. CompanyName: form.CompanyName,
  369. BusinessType: form.BusinessType,
  370. Username: form.Username,
  371. Email: form.Email,
  372. Phone: form.Phone,
  373. OrgNumber: form.OrgNumber,
  374. StartDate: form.StartDate,
  375. Mission: form.Mission,
  376. DescriptionShort: form.DescriptionShort,
  377. DescriptionLong: form.DescriptionLong,
  378. SiteLanguage: form.SiteLanguage,
  379. AddressLine1: form.AddressLine1,
  380. AddressLine2: form.AddressLine2,
  381. AddressCity: form.AddressCity,
  382. AddressRegion: form.AddressRegion,
  383. AddressZIP: form.AddressZIP,
  384. AddressCountry: form.AddressCountry,
  385. })
  386. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  387. if err != nil || templateID <= 0 {
  388. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  389. return
  390. }
  391. result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{
  392. TemplateID: templateID,
  393. RequestName: form.RequestName,
  394. GlobalData: globalData,
  395. FieldValues: fieldValues,
  396. })
  397. if err != nil {
  398. data, loadErr := u.loadBuildNewPageData(r, pageData{
  399. Title: "New Build",
  400. Err: err.Error(),
  401. Current: "/builds/new",
  402. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  403. if loadErr != nil {
  404. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  405. return
  406. }
  407. u.render.Render(w, "build_new", data)
  408. return
  409. }
  410. if form.DraftID != "" {
  411. _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  412. DraftID: form.DraftID,
  413. TemplateID: int64Ptr(templateID),
  414. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  415. Source: form.DraftSource,
  416. RequestName: form.RequestName,
  417. GlobalData: globalData,
  418. FieldValues: fieldValues,
  419. DraftContext: buildDraftContextFromForm(form, globalData),
  420. SuggestionState: &suggestionState,
  421. Status: "submitted",
  422. Notes: form.DraftNotes,
  423. })
  424. }
  425. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther)
  426. }
  427. func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
  428. if err := r.ParseForm(); err != nil {
  429. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  430. return
  431. }
  432. form := buildFormInputFromRequest(r)
  433. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  434. fieldValues := parseBuildFieldValues(r)
  435. suggestionState := parseSuggestionStateFromRequest(r)
  436. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  437. if err != nil || templateID <= 0 {
  438. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  439. return
  440. }
  441. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  442. CompanyName: form.CompanyName,
  443. BusinessType: form.BusinessType,
  444. Username: form.Username,
  445. Email: form.Email,
  446. Phone: form.Phone,
  447. OrgNumber: form.OrgNumber,
  448. StartDate: form.StartDate,
  449. Mission: form.Mission,
  450. DescriptionShort: form.DescriptionShort,
  451. DescriptionLong: form.DescriptionLong,
  452. SiteLanguage: form.SiteLanguage,
  453. AddressLine1: form.AddressLine1,
  454. AddressLine2: form.AddressLine2,
  455. AddressCity: form.AddressCity,
  456. AddressRegion: form.AddressRegion,
  457. AddressZIP: form.AddressZIP,
  458. AddressCountry: form.AddressCountry,
  459. })
  460. draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  461. DraftID: form.DraftID,
  462. TemplateID: int64Ptr(templateID),
  463. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  464. Source: form.DraftSource,
  465. RequestName: form.RequestName,
  466. GlobalData: globalData,
  467. FieldValues: fieldValues,
  468. DraftContext: buildDraftContextFromForm(form, globalData),
  469. SuggestionState: &suggestionState,
  470. Status: defaultDraftStatus(form.DraftStatus),
  471. Notes: form.DraftNotes,
  472. })
  473. if err != nil {
  474. data, loadErr := u.loadBuildNewPageData(r, pageData{
  475. Title: "New Build",
  476. Err: err.Error(),
  477. Current: "/builds/new",
  478. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  479. if loadErr != nil {
  480. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  481. return
  482. }
  483. u.render.Render(w, "build_new", data)
  484. return
  485. }
  486. http.Redirect(w, r, fmt.Sprintf("/builds/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), http.StatusSeeOther)
  487. }
  488. func (u *UI) AutofillDraft(w http.ResponseWriter, r *http.Request) {
  489. if err := r.ParseForm(); err != nil {
  490. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  491. return
  492. }
  493. form := buildFormInputFromRequest(r)
  494. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  495. fieldValues := parseBuildFieldValues(r)
  496. suggestionState := parseSuggestionStateFromRequest(r)
  497. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  498. if err != nil || templateID <= 0 {
  499. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  500. return
  501. }
  502. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
  503. if err != nil || detail.Manifest == nil {
  504. http.Redirect(w, r, "/builds/new?err=template+detail+missing", http.StatusSeeOther)
  505. return
  506. }
  507. globalData := buildGlobalDataFromForm(form)
  508. draftContext := buildDraftContextFromForm(form, globalData)
  509. action, targetFieldPath := parseAutofillAction(strings.TrimSpace(r.FormValue("autofill_action")))
  510. focusFieldPath := targetFieldPath
  511. now := time.Now().UTC()
  512. req := mapping.SuggestionRequest{
  513. Fields: detail.Fields,
  514. GlobalData: globalData,
  515. DraftContext: draftContext,
  516. Existing: fieldValues,
  517. }
  518. msg := "autofill ready"
  519. switch action {
  520. case "generate_all":
  521. suggestionState = mapping.GenerateAllSuggestions(req, suggestionState, now)
  522. msg = "suggestions generated"
  523. case "regenerate_all":
  524. suggestionState = mapping.RegenerateAllSuggestions(req, suggestionState, now)
  525. msg = "suggestions regenerated"
  526. case "apply_all":
  527. fieldValues, suggestionState = mapping.ApplyAllSuggestions(fieldValues, suggestionState, now)
  528. msg = "all suggestions applied"
  529. case "apply_all_empty":
  530. fieldValues, suggestionState = mapping.ApplySuggestionsToEmptyFields(fieldValues, suggestionState, now)
  531. msg = "suggestions applied to empty fields"
  532. case "apply_field":
  533. fieldValues, suggestionState = mapping.ApplySuggestionToField(fieldValues, suggestionState, targetFieldPath, now)
  534. msg = "field suggestion applied"
  535. case "regenerate_field":
  536. suggestionState = mapping.RegenerateFieldSuggestion(req, suggestionState, targetFieldPath, now)
  537. msg = "field suggestion regenerated"
  538. default:
  539. msg = "unknown autofill action"
  540. }
  541. if strings.TrimSpace(form.DraftID) != "" {
  542. _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  543. DraftID: form.DraftID,
  544. TemplateID: int64Ptr(templateID),
  545. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  546. Source: form.DraftSource,
  547. RequestName: form.RequestName,
  548. GlobalData: globalData,
  549. FieldValues: fieldValues,
  550. DraftContext: draftContext,
  551. SuggestionState: &suggestionState,
  552. Status: defaultDraftStatus(form.DraftStatus),
  553. Notes: form.DraftNotes,
  554. })
  555. }
  556. data, loadErr := u.loadBuildNewPageData(r, pageData{
  557. Title: "New Build",
  558. Msg: msg,
  559. Current: "/builds/new",
  560. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  561. if loadErr != nil {
  562. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  563. return
  564. }
  565. data.AutofillFocusID = fieldAnchorID(focusFieldPath)
  566. u.render.Render(w, "build_new", data)
  567. }
  568. func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) {
  569. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  570. build, err := u.buildSvc.GetBuild(r.Context(), buildID)
  571. if err != nil {
  572. http.Error(w, err.Error(), http.StatusNotFound)
  573. return
  574. }
  575. status := strings.ToLower(strings.TrimSpace(build.QCStatus))
  576. canPoll := status == "queued" || status == "processing"
  577. canFetchEditor := (status == "done" || status == "failed" || status == "timeout") &&
  578. build.QCSiteID != nil &&
  579. strings.TrimSpace(build.QCEditorURL) == ""
  580. autoRefresh := 0
  581. if canPoll && u.cfg.PollIntervalSeconds > 0 {
  582. autoRefresh = u.cfg.PollIntervalSeconds
  583. }
  584. effectiveGlobal := build.GlobalDataJSON
  585. if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 {
  586. effectiveGlobal = payloadGlobal
  587. }
  588. u.render.Render(w, "build_detail", buildDetailPageData{
  589. pageData: basePageData(r, "Build Detail", "/builds"),
  590. Build: build,
  591. EffectiveGlobal: effectiveGlobal,
  592. CanPoll: canPoll,
  593. CanFetchEditorURL: canFetchEditor,
  594. AutoRefreshSeconds: autoRefresh,
  595. })
  596. }
  597. func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) {
  598. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  599. if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil {
  600. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  601. return
  602. }
  603. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther)
  604. }
  605. func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) {
  606. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  607. if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil {
  608. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  609. return
  610. }
  611. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther)
  612. }
  613. func basePageData(r *http.Request, title, current string) pageData {
  614. q := r.URL.Query()
  615. return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current}
  616. }
  617. func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) {
  618. rawID := chi.URLParam(r, "id")
  619. templateID, err := strconv.ParseInt(rawID, 10, 64)
  620. if err != nil {
  621. http.Error(w, "invalid template id", http.StatusBadRequest)
  622. return 0, false
  623. }
  624. return templateID, true
  625. }
  626. func urlQuery(s string) string {
  627. return url.QueryEscape(s)
  628. }
  629. func boolPtr(v bool) *bool { return &v }
  630. func intPtr(v int) *int { return &v }
  631. func int64Ptr(v int64) *int64 { return &v }
  632. func strPtr(v string) *string {
  633. return &v
  634. }
  635. func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string, suggestionState domain.DraftSuggestionState) (buildNewPageData, error) {
  636. if strings.TrimSpace(form.MasterPrompt) == "" {
  637. form.MasterPrompt = domain.SeedMasterPrompt
  638. }
  639. form.PromptBlocks = domain.NormalizePromptBlocks(form.PromptBlocks)
  640. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  641. templates, err := u.templateSvc.ListTemplates(r.Context())
  642. if err != nil {
  643. return buildNewPageData{}, err
  644. }
  645. drafts, err := u.draftSvc.ListDrafts(r.Context(), 50)
  646. if err != nil {
  647. return buildNewPageData{}, err
  648. }
  649. data := buildNewPageData{
  650. pageData: page,
  651. Templates: templates,
  652. Drafts: drafts,
  653. SelectedDraftID: selectedDraftID,
  654. SelectedTemplateID: selectedTemplateID,
  655. SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState),
  656. Form: form,
  657. }
  658. if selectedTemplateID <= 0 {
  659. return data, nil
  660. }
  661. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID)
  662. if err != nil || detail.Manifest == nil {
  663. return data, nil
  664. }
  665. data.SelectedManifestID = detail.Manifest.ID
  666. data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues, suggestionState.ByFieldPath)
  667. data.EnabledFields = data.EditableFields
  668. data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields))
  669. return data, nil
  670. }
  671. func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormInput) buildFormInput {
  672. settings := u.loadPromptSettings(ctx)
  673. form.MasterPrompt = settings.MasterPrompt
  674. form.PromptBlocks = clonePromptBlocks(settings.PromptBlocks)
  675. if strings.TrimSpace(form.DraftID) == "" {
  676. return form
  677. }
  678. draft, err := u.draftSvc.GetDraft(ctx, strings.TrimSpace(form.DraftID))
  679. if err != nil || draft == nil {
  680. return form
  681. }
  682. mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
  683. form.MasterPrompt = settings.MasterPrompt
  684. form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
  685. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  686. return form
  687. }
  688. func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string, suggestions map[string]domain.DraftSuggestion) ([]buildFieldView, []buildFieldSectionView) {
  689. sectionOrder := []string{
  690. domain.WebsiteSectionHero,
  691. domain.WebsiteSectionIntro,
  692. domain.WebsiteSectionServices,
  693. domain.WebsiteSectionAbout,
  694. domain.WebsiteSectionTeam,
  695. domain.WebsiteSectionTestimonials,
  696. domain.WebsiteSectionCTA,
  697. domain.WebsiteSectionContact,
  698. domain.WebsiteSectionGallery,
  699. domain.WebsiteSectionFooter,
  700. domain.WebsiteSectionOther,
  701. }
  702. sectionDescriptions := map[string]string{
  703. domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.",
  704. domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.",
  705. domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.",
  706. domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.",
  707. domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.",
  708. domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.",
  709. domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.",
  710. domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.",
  711. domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.",
  712. domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.",
  713. domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.",
  714. }
  715. sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder))
  716. pendingByKey := make(map[string][]pendingField, len(sectionOrder))
  717. for _, key := range sectionOrder {
  718. sectionsByKey[key] = buildFieldSectionView{
  719. Key: key,
  720. Title: domain.WebsiteSectionLabel(key),
  721. Description: sectionDescriptions[key],
  722. }
  723. }
  724. for _, f := range fields {
  725. targetSection := preferredBuildSection(f)
  726. if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery {
  727. labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName)
  728. if blockID := extractBlockID(f); blockID != "" {
  729. labelFallback = "Media - " + blockGroupTitle(blockID)
  730. }
  731. media := sectionsByKey[domain.WebsiteSectionGallery]
  732. media.DisabledFields = append(media.DisabledFields, buildFieldView{
  733. AnchorID: fieldAnchorID(f.Path),
  734. Path: f.Path,
  735. DisplayLabel: effectiveLabel(f, labelFallback),
  736. SampleValue: f.SampleValue,
  737. Value: "",
  738. })
  739. sectionsByKey[domain.WebsiteSectionGallery] = media
  740. continue
  741. }
  742. if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
  743. continue
  744. }
  745. suggestion := suggestions[f.Path]
  746. pf := pendingField{
  747. Field: f,
  748. View: buildFieldView{
  749. AnchorID: fieldAnchorID(f.Path),
  750. Path: f.Path,
  751. DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
  752. SampleValue: f.SampleValue,
  753. Value: strings.TrimSpace(fieldValues[f.Path]),
  754. SuggestedValue: strings.TrimSpace(suggestion.Value),
  755. SuggestionReason: strings.TrimSpace(suggestion.Reason),
  756. SuggestionStatus: strings.TrimSpace(suggestion.Status),
  757. SuggestionSource: strings.TrimSpace(suggestion.Source),
  758. },
  759. }
  760. pendingByKey[targetSection] = append(pendingByKey[targetSection], pf)
  761. }
  762. for _, key := range sectionOrder {
  763. section := sectionsByKey[key]
  764. items := pendingByKey[key]
  765. switch key {
  766. case domain.WebsiteSectionServices:
  767. section = applyServicesGrouping(section, items)
  768. case domain.WebsiteSectionTestimonials:
  769. section = applyTestimonialsGrouping(section, items)
  770. case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter:
  771. section = applyTextGrouping(section, items)
  772. case domain.WebsiteSectionOther:
  773. section = applyOtherGrouping(section, items)
  774. case domain.WebsiteSectionGallery:
  775. // Gallery fields are handled as disabled entries only in this MVP.
  776. default:
  777. section = applyOtherGrouping(section, items)
  778. }
  779. sectionsByKey[key] = section
  780. }
  781. sections := make([]buildFieldSectionView, 0, len(sectionOrder))
  782. for _, key := range sectionOrder {
  783. sections = append(sections, sectionsByKey[key])
  784. }
  785. return assignEditableIndexes(sections)
  786. }
  787. func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  788. return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback)
  789. }
  790. func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  791. titles := make([]pendingField, 0)
  792. descriptions := make([]pendingField, 0)
  793. other := make([]buildFieldView, 0)
  794. for _, pf := range fields {
  795. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  796. switch {
  797. case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"):
  798. titles = append(titles, pf)
  799. case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"):
  800. descriptions = append(descriptions, pf)
  801. default:
  802. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName))
  803. other = append(other, pf.View)
  804. }
  805. }
  806. maxCount := len(titles)
  807. if len(descriptions) > maxCount {
  808. maxCount = len(descriptions)
  809. }
  810. for i := 0; i < maxCount; i++ {
  811. block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)}
  812. if i < len(titles) {
  813. item := titles[i]
  814. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1))
  815. block.Fields = append(block.Fields, item.View)
  816. }
  817. if i < len(descriptions) {
  818. item := descriptions[i]
  819. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1))
  820. block.Fields = append(block.Fields, item.View)
  821. }
  822. if len(block.Fields) > 0 {
  823. section.EditableGroups = append(section.EditableGroups, block)
  824. }
  825. }
  826. section.EditableFields = append(section.EditableFields, other...)
  827. return section
  828. }
  829. func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  830. return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback)
  831. }
  832. func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  833. names := make([]pendingField, 0)
  834. titles := make([]pendingField, 0)
  835. descriptions := make([]pendingField, 0)
  836. other := make([]buildFieldView, 0)
  837. for _, pf := range fields {
  838. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  839. switch {
  840. case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"):
  841. names = append(names, pf)
  842. case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"):
  843. titles = append(titles, pf)
  844. case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"):
  845. descriptions = append(descriptions, pf)
  846. default:
  847. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName))
  848. other = append(other, pf.View)
  849. }
  850. }
  851. maxCount := len(names)
  852. if len(titles) > maxCount {
  853. maxCount = len(titles)
  854. }
  855. if len(descriptions) > maxCount {
  856. maxCount = len(descriptions)
  857. }
  858. for i := 0; i < maxCount; i++ {
  859. block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)}
  860. if i < len(names) {
  861. item := names[i]
  862. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1))
  863. block.Fields = append(block.Fields, item.View)
  864. }
  865. if i < len(titles) {
  866. item := titles[i]
  867. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1))
  868. block.Fields = append(block.Fields, item.View)
  869. }
  870. if i < len(descriptions) {
  871. item := descriptions[i]
  872. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1))
  873. block.Fields = append(block.Fields, item.View)
  874. }
  875. if len(block.Fields) > 0 {
  876. section.EditableGroups = append(section.EditableGroups, block)
  877. }
  878. }
  879. section.EditableFields = append(section.EditableFields, other...)
  880. return section
  881. }
  882. func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  883. return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback)
  884. }
  885. func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  886. titles := make([]pendingField, 0)
  887. descriptions := make([]pendingField, 0)
  888. names := make([]pendingField, 0)
  889. other := make([]buildFieldView, 0)
  890. for _, pf := range fields {
  891. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  892. switch {
  893. case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"):
  894. titles = append(titles, pf)
  895. case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"):
  896. descriptions = append(descriptions, pf)
  897. case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"):
  898. names = append(names, pf)
  899. default:
  900. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName))
  901. other = append(other, pf.View)
  902. }
  903. }
  904. maxCount := len(titles)
  905. if len(descriptions) > maxCount {
  906. maxCount = len(descriptions)
  907. }
  908. if len(names) > maxCount {
  909. maxCount = len(names)
  910. }
  911. for i := 0; i < maxCount; i++ {
  912. block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)}
  913. if i < len(titles) {
  914. item := titles[i]
  915. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1))
  916. block.Fields = append(block.Fields, item.View)
  917. }
  918. if i < len(descriptions) {
  919. item := descriptions[i]
  920. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1))
  921. block.Fields = append(block.Fields, item.View)
  922. }
  923. if i < len(names) {
  924. item := names[i]
  925. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1))
  926. block.Fields = append(block.Fields, item.View)
  927. }
  928. if len(block.Fields) > 0 {
  929. section.EditableGroups = append(section.EditableGroups, block)
  930. }
  931. }
  932. section.EditableFields = append(section.EditableFields, other...)
  933. return section
  934. }
  935. func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  936. return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback)
  937. }
  938. func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  939. for _, pf := range fields {
  940. pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName))
  941. section.EditableFields = append(section.EditableFields, pf.View)
  942. }
  943. sort.SliceStable(section.EditableFields, func(i, j int) bool {
  944. return section.EditableFields[i].Path < section.EditableFields[j].Path
  945. })
  946. return section
  947. }
  948. func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView {
  949. grouped := map[string][]pendingField{}
  950. withoutBlockID := make([]pendingField, 0)
  951. for _, pf := range fields {
  952. blockID := extractBlockID(pf.Field)
  953. if blockID == "" {
  954. withoutBlockID = append(withoutBlockID, pf)
  955. continue
  956. }
  957. grouped[blockID] = append(grouped[blockID], pf)
  958. }
  959. if len(grouped) > 0 {
  960. blockIDs := make([]string, 0, len(grouped))
  961. for blockID := range grouped {
  962. blockIDs = append(blockIDs, blockID)
  963. }
  964. sort.SliceStable(blockIDs, func(i, j int) bool {
  965. li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j])
  966. if li != lj {
  967. return li < lj
  968. }
  969. return blockIDs[i] < blockIDs[j]
  970. })
  971. for _, blockID := range blockIDs {
  972. items := grouped[blockID]
  973. sort.SliceStable(items, func(i, j int) bool {
  974. ri := deriveFieldRole(items[i].Field.KeyName)
  975. rj := deriveFieldRole(items[j].Field.KeyName)
  976. if ri.Order != rj.Order {
  977. return ri.Order < rj.Order
  978. }
  979. return items[i].Field.Path < items[j].Field.Path
  980. })
  981. group := buildFieldGroupView{Title: blockGroupTitle(blockID)}
  982. for _, item := range items {
  983. role := deriveFieldRole(item.Field.KeyName)
  984. fallbackLabel := role.Label
  985. if fallbackLabel == "" {
  986. fallbackLabel = humanizeKey(item.Field.KeyName)
  987. }
  988. item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel)
  989. group.Fields = append(group.Fields, item.View)
  990. }
  991. if len(group.Fields) > 0 {
  992. section.EditableGroups = append(section.EditableGroups, group)
  993. }
  994. }
  995. }
  996. if len(withoutBlockID) > 0 {
  997. if fallback != nil {
  998. return fallback(section, withoutBlockID)
  999. }
  1000. for _, pf := range withoutBlockID {
  1001. labelPrefix := fallbackPrefix
  1002. if labelPrefix == "" {
  1003. labelPrefix = normalizedSectionTitle(pf.Field.Section)
  1004. }
  1005. pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName))
  1006. section.EditableFields = append(section.EditableFields, pf.View)
  1007. }
  1008. }
  1009. return section
  1010. }
  1011. func extractBlockID(f domain.TemplateField) string {
  1012. candidates := []string{f.KeyName, f.Path}
  1013. for _, candidate := range candidates {
  1014. normalized := strings.ToLower(strings.TrimSpace(candidate))
  1015. if normalized == "" {
  1016. continue
  1017. }
  1018. if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  1019. return match[1]
  1020. }
  1021. if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  1022. return match[1]
  1023. }
  1024. }
  1025. return ""
  1026. }
  1027. func blockGroupTitle(blockID string) string {
  1028. blockID = strings.ToLower(strings.TrimSpace(blockID))
  1029. if blockID == "" {
  1030. return "Unbekannter Block"
  1031. }
  1032. if known, ok := knownBlockAreas[blockID]; ok {
  1033. return fmt.Sprintf("%s (%s)", known, blockID)
  1034. }
  1035. return "Block " + blockID
  1036. }
  1037. func blockSortRank(blockID string) int {
  1038. switch strings.ToLower(strings.TrimSpace(blockID)) {
  1039. case "m1710":
  1040. return 10
  1041. case "c7886":
  1042. return 20
  1043. case "r4830":
  1044. return 30
  1045. case "c2929":
  1046. return 40
  1047. case "r4748":
  1048. return 50
  1049. case "r1508":
  1050. return 60
  1051. case "c1165":
  1052. return 70
  1053. case "m4178":
  1054. return 80
  1055. default:
  1056. return 1000
  1057. }
  1058. }
  1059. func deriveFieldRole(key string) fieldRole {
  1060. normalized := strings.ToLower(strings.TrimSpace(key))
  1061. switch {
  1062. case strings.Contains(normalized, "subtitle"):
  1063. return fieldRole{Label: "Untertitel", Order: 15}
  1064. case strings.Contains(normalized, "title"):
  1065. return fieldRole{Label: "Titel", Order: 20}
  1066. case strings.Contains(normalized, "description"):
  1067. return fieldRole{Label: "Beschreibung", Order: 30}
  1068. case strings.Contains(normalized, "name"):
  1069. return fieldRole{Label: "Name", Order: 40}
  1070. case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"):
  1071. return fieldRole{Label: "CTA Text", Order: 50}
  1072. default:
  1073. return fieldRole{Label: humanizeKey(key), Order: 100}
  1074. }
  1075. }
  1076. func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) {
  1077. editable := make([]buildFieldView, 0)
  1078. nextIndex := 0
  1079. for si := range sections {
  1080. for gi := range sections[si].EditableGroups {
  1081. for fi := range sections[si].EditableGroups[gi].Fields {
  1082. sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex
  1083. editable = append(editable, sections[si].EditableGroups[gi].Fields[fi])
  1084. nextIndex++
  1085. }
  1086. }
  1087. for fi := range sections[si].EditableFields {
  1088. sections[si].EditableFields[fi].Index = nextIndex
  1089. editable = append(editable, sections[si].EditableFields[fi])
  1090. nextIndex++
  1091. }
  1092. }
  1093. return editable, sections
  1094. }
  1095. func preferredBuildSection(f domain.TemplateField) string {
  1096. websiteSection := strings.TrimSpace(f.WebsiteSection)
  1097. if websiteSection != "" {
  1098. normalized := domain.NormalizeWebsiteSection(websiteSection)
  1099. if normalized == domain.WebsiteSectionServiceItem {
  1100. return domain.WebsiteSectionServices
  1101. }
  1102. return normalized
  1103. }
  1104. return fallbackBuildSection(f)
  1105. }
  1106. func fallbackBuildSection(f domain.TemplateField) string {
  1107. switch normalizedSection(f.Section) {
  1108. case "services":
  1109. return domain.WebsiteSectionServices
  1110. case "testimonials":
  1111. return domain.WebsiteSectionTestimonials
  1112. case "text":
  1113. if isMediaOrGalleryField(f) {
  1114. return domain.WebsiteSectionGallery
  1115. }
  1116. return domain.WebsiteSectionOther
  1117. default:
  1118. if isMediaOrGalleryField(f) {
  1119. return domain.WebsiteSectionGallery
  1120. }
  1121. return domain.WebsiteSectionOther
  1122. }
  1123. }
  1124. func normalizedSection(raw string) string {
  1125. section := strings.ToLower(strings.TrimSpace(raw))
  1126. switch section {
  1127. case "ext":
  1128. return "text"
  1129. default:
  1130. return section
  1131. }
  1132. }
  1133. func normalizedSectionTitle(raw string) string {
  1134. switch normalizedSection(raw) {
  1135. case "text":
  1136. return "Text"
  1137. case "services":
  1138. return "Services"
  1139. case "testimonials":
  1140. return "Testimonials"
  1141. case "gallery", "media":
  1142. return "Media"
  1143. default:
  1144. return "Feld"
  1145. }
  1146. }
  1147. func isMediaOrGalleryField(f domain.TemplateField) bool {
  1148. if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") {
  1149. return true
  1150. }
  1151. section := strings.ToLower(strings.TrimSpace(f.Section))
  1152. key := strings.ToLower(strings.TrimSpace(f.KeyName))
  1153. path := strings.ToLower(strings.TrimSpace(f.Path))
  1154. if section == "gallery" || section == "media" {
  1155. return true
  1156. }
  1157. hints := []string{"gallery", "image", "img", "photo", "picture"}
  1158. for _, hint := range hints {
  1159. if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) {
  1160. return true
  1161. }
  1162. }
  1163. return false
  1164. }
  1165. func effectiveLabel(f domain.TemplateField, fallback string) string {
  1166. if !isRawPathLikeLabel(f.DisplayLabel, f.Path) {
  1167. return strings.TrimSpace(f.DisplayLabel)
  1168. }
  1169. return strings.TrimSpace(fallback)
  1170. }
  1171. func isRawPathLikeLabel(label string, path string) bool {
  1172. l := strings.TrimSpace(label)
  1173. if l == "" {
  1174. return true
  1175. }
  1176. if strings.EqualFold(l, strings.TrimSpace(path)) {
  1177. return true
  1178. }
  1179. if strings.Contains(l, ".") || strings.Contains(l, "_") {
  1180. return true
  1181. }
  1182. return false
  1183. }
  1184. func humanizeKey(key string) string {
  1185. raw := strings.TrimSpace(key)
  1186. if raw == "" {
  1187. return "Inhalt"
  1188. }
  1189. base := raw
  1190. if idx := strings.Index(base, "_"); idx > 0 {
  1191. base = base[:idx]
  1192. }
  1193. runes := make([]rune, 0, len(base)+4)
  1194. for i, r := range base {
  1195. if i > 0 && unicode.IsUpper(r) {
  1196. runes = append(runes, ' ')
  1197. }
  1198. runes = append(runes, r)
  1199. }
  1200. human := strings.TrimSpace(string(runes))
  1201. if human == "" {
  1202. return "Inhalt"
  1203. }
  1204. words := strings.Fields(strings.ToLower(human))
  1205. for i := range words {
  1206. if len(words[i]) > 0 {
  1207. words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
  1208. }
  1209. }
  1210. return strings.Join(words, " ")
  1211. }
  1212. func buildFormInputFromRequest(r *http.Request) buildFormInput {
  1213. form := buildFormInput{
  1214. DraftID: strings.TrimSpace(r.FormValue("draft_id")),
  1215. DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
  1216. DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
  1217. DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
  1218. RequestName: strings.TrimSpace(r.FormValue("request_name")),
  1219. CompanyName: strings.TrimSpace(r.FormValue("company_name")),
  1220. BusinessType: strings.TrimSpace(r.FormValue("business_type")),
  1221. Username: strings.TrimSpace(r.FormValue("username")),
  1222. Email: strings.TrimSpace(r.FormValue("email")),
  1223. Phone: strings.TrimSpace(r.FormValue("phone")),
  1224. OrgNumber: strings.TrimSpace(r.FormValue("org_number")),
  1225. StartDate: strings.TrimSpace(r.FormValue("start_date")),
  1226. Mission: strings.TrimSpace(r.FormValue("mission")),
  1227. DescriptionShort: strings.TrimSpace(r.FormValue("description_short")),
  1228. DescriptionLong: strings.TrimSpace(r.FormValue("description_long")),
  1229. SiteLanguage: strings.TrimSpace(r.FormValue("site_language")),
  1230. AddressLine1: strings.TrimSpace(r.FormValue("address_line1")),
  1231. AddressLine2: strings.TrimSpace(r.FormValue("address_line2")),
  1232. AddressCity: strings.TrimSpace(r.FormValue("address_city")),
  1233. AddressRegion: strings.TrimSpace(r.FormValue("address_region")),
  1234. AddressZIP: strings.TrimSpace(r.FormValue("address_zip")),
  1235. AddressCountry: strings.TrimSpace(r.FormValue("address_country")),
  1236. WebsiteURL: strings.TrimSpace(r.FormValue("website_url")),
  1237. WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")),
  1238. LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")),
  1239. MarketStyle: strings.TrimSpace(r.FormValue("market_style")),
  1240. AddressMode: strings.TrimSpace(r.FormValue("address_mode")),
  1241. ContentTone: strings.TrimSpace(r.FormValue("content_tone")),
  1242. }
  1243. return form
  1244. }
  1245. func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput {
  1246. form := buildFormInput{
  1247. DraftID: draft.ID,
  1248. DraftSource: draft.Source,
  1249. DraftStatus: draft.Status,
  1250. DraftNotes: draft.Notes,
  1251. RequestName: draft.RequestName,
  1252. }
  1253. mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON)
  1254. mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
  1255. return form
  1256. }
  1257. func parseBuildFieldValues(r *http.Request) map[string]string {
  1258. fieldValues := map[string]string{}
  1259. count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count")))
  1260. for i := 0; i < count; i++ {
  1261. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  1262. value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i)))
  1263. if path != "" {
  1264. fieldValues[path] = value
  1265. }
  1266. }
  1267. return fieldValues
  1268. }
  1269. func parseSuggestionStateFromRequest(r *http.Request) domain.DraftSuggestionState {
  1270. return parseSuggestionStateRaw(strings.TrimSpace(r.FormValue("suggestion_state_json")))
  1271. }
  1272. func parseSuggestionStateJSON(raw []byte) domain.DraftSuggestionState {
  1273. return parseSuggestionStateRaw(strings.TrimSpace(string(raw)))
  1274. }
  1275. func parseSuggestionStateRaw(raw string) domain.DraftSuggestionState {
  1276. if strings.TrimSpace(raw) == "" {
  1277. return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
  1278. }
  1279. var state domain.DraftSuggestionState
  1280. if err := json.Unmarshal([]byte(raw), &state); err != nil {
  1281. return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
  1282. }
  1283. if state.ByFieldPath == nil {
  1284. state.ByFieldPath = map[string]domain.DraftSuggestion{}
  1285. }
  1286. return state
  1287. }
  1288. func encodeSuggestionStateJSON(state domain.DraftSuggestionState) string {
  1289. normalized := state
  1290. if normalized.ByFieldPath == nil {
  1291. normalized.ByFieldPath = map[string]domain.DraftSuggestion{}
  1292. }
  1293. raw, err := json.Marshal(normalized)
  1294. if err != nil {
  1295. return `{"byFieldPath":{}}`
  1296. }
  1297. return string(raw)
  1298. }
  1299. func parseAutofillAction(raw string) (string, string) {
  1300. value := strings.TrimSpace(raw)
  1301. if value == "" {
  1302. return "", ""
  1303. }
  1304. parts := strings.SplitN(value, "::", 2)
  1305. action := strings.TrimSpace(parts[0])
  1306. if len(parts) == 1 {
  1307. return action, ""
  1308. }
  1309. return action, strings.TrimSpace(parts[1])
  1310. }
  1311. func fieldAnchorID(fieldPath string) string {
  1312. path := strings.TrimSpace(strings.ToLower(fieldPath))
  1313. if path == "" {
  1314. return ""
  1315. }
  1316. var b strings.Builder
  1317. b.Grow(len(path) + len("field-"))
  1318. b.WriteString("field-")
  1319. lastDash := false
  1320. for _, r := range path {
  1321. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
  1322. b.WriteRune(r)
  1323. lastDash = false
  1324. continue
  1325. }
  1326. if !lastDash {
  1327. b.WriteByte('-')
  1328. lastDash = true
  1329. }
  1330. }
  1331. out := strings.Trim(b.String(), "-")
  1332. if out == "" || out == "field" {
  1333. return "field-anchor"
  1334. }
  1335. return out
  1336. }
  1337. func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) {
  1338. if len(raw) == 0 {
  1339. return nil, nil
  1340. }
  1341. var payload struct {
  1342. GlobalData map[string]any `json:"globalData"`
  1343. }
  1344. if err := json.Unmarshal(raw, &payload); err != nil {
  1345. return nil, err
  1346. }
  1347. if len(payload.GlobalData) == 0 {
  1348. return nil, nil
  1349. }
  1350. data, err := json.Marshal(payload.GlobalData)
  1351. if err != nil {
  1352. return nil, err
  1353. }
  1354. return data, nil
  1355. }
  1356. func parseFieldValuesJSON(raw []byte) map[string]string {
  1357. out := map[string]string{}
  1358. if len(raw) == 0 {
  1359. return out
  1360. }
  1361. _ = json.Unmarshal(raw, &out)
  1362. return out
  1363. }
  1364. func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) {
  1365. if form == nil || len(raw) == 0 {
  1366. return
  1367. }
  1368. var global map[string]any
  1369. if err := json.Unmarshal(raw, &global); err != nil {
  1370. return
  1371. }
  1372. form.CompanyName = getString(global["companyName"])
  1373. form.BusinessType = getString(global["businessType"])
  1374. form.Username = getString(global["username"])
  1375. form.Email = getString(global["email"])
  1376. form.Phone = getString(global["phone"])
  1377. form.OrgNumber = getString(global["orgNumber"])
  1378. form.StartDate = getString(global["startDate"])
  1379. form.Mission = getString(global["mission"])
  1380. form.DescriptionShort = getString(global["descriptionShort"])
  1381. form.DescriptionLong = getString(global["descriptionLong"])
  1382. form.SiteLanguage = getString(global["siteLanguage"])
  1383. address, _ := global["address"].(map[string]any)
  1384. form.AddressLine1 = getString(address["line1"])
  1385. form.AddressLine2 = getString(address["line2"])
  1386. form.AddressCity = getString(address["city"])
  1387. form.AddressRegion = getString(address["region"])
  1388. form.AddressZIP = getString(address["zip"])
  1389. form.AddressCountry = getString(address["country"])
  1390. }
  1391. func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) {
  1392. if form == nil || len(raw) == 0 {
  1393. return
  1394. }
  1395. var ctx domain.DraftContext
  1396. if err := json.Unmarshal(raw, &ctx); err != nil {
  1397. return
  1398. }
  1399. if strings.TrimSpace(form.BusinessType) == "" {
  1400. form.BusinessType = strings.TrimSpace(ctx.LLM.BusinessType)
  1401. }
  1402. form.WebsiteURL = strings.TrimSpace(ctx.LLM.WebsiteURL)
  1403. form.WebsiteSummary = strings.TrimSpace(ctx.LLM.WebsiteSummary)
  1404. form.LocaleStyle = strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle)
  1405. form.MarketStyle = strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle)
  1406. form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode)
  1407. form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
  1408. form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions)
  1409. form.PromptBlocks = clonePromptBlocks(ctx.LLM.Prompt.Blocks)
  1410. }
  1411. func buildGlobalDataFromForm(form buildFormInput) map[string]any {
  1412. return buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  1413. CompanyName: form.CompanyName,
  1414. BusinessType: form.BusinessType,
  1415. Username: form.Username,
  1416. Email: form.Email,
  1417. Phone: form.Phone,
  1418. OrgNumber: form.OrgNumber,
  1419. StartDate: form.StartDate,
  1420. Mission: form.Mission,
  1421. DescriptionShort: form.DescriptionShort,
  1422. DescriptionLong: form.DescriptionLong,
  1423. SiteLanguage: form.SiteLanguage,
  1424. AddressLine1: form.AddressLine1,
  1425. AddressLine2: form.AddressLine2,
  1426. AddressCity: form.AddressCity,
  1427. AddressRegion: form.AddressRegion,
  1428. AddressZIP: form.AddressZIP,
  1429. AddressCountry: form.AddressCountry,
  1430. })
  1431. }
  1432. func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext {
  1433. businessType := strings.TrimSpace(form.BusinessType)
  1434. if businessType == "" {
  1435. businessType = strings.TrimSpace(getString(globalData["businessType"]))
  1436. }
  1437. return &domain.DraftContext{
  1438. IntakeSource: strings.TrimSpace(form.DraftSource),
  1439. LLM: domain.DraftLLMContext{
  1440. BusinessType: businessType,
  1441. WebsiteURL: strings.TrimSpace(form.WebsiteURL),
  1442. WebsiteSummary: strings.TrimSpace(form.WebsiteSummary),
  1443. StyleProfile: domain.DraftStyleProfile{
  1444. LocaleStyle: strings.TrimSpace(form.LocaleStyle),
  1445. MarketStyle: strings.TrimSpace(form.MarketStyle),
  1446. AddressMode: strings.TrimSpace(form.AddressMode),
  1447. ContentTone: strings.TrimSpace(form.ContentTone),
  1448. PromptInstructions: strings.TrimSpace(form.PromptInstructions),
  1449. },
  1450. Prompt: domain.DraftPromptConfig{
  1451. Blocks: clonePromptBlocks(form.PromptBlocks),
  1452. },
  1453. },
  1454. }
  1455. }
  1456. func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings {
  1457. settings := domain.AppSettings{
  1458. QCBaseURL: u.cfg.QCBaseURL,
  1459. QCBearerTokenEncrypted: u.cfg.QCToken,
  1460. LanguageOutputMode: "EN",
  1461. JobPollIntervalSeconds: u.cfg.PollIntervalSeconds,
  1462. JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
  1463. MasterPrompt: domain.SeedMasterPrompt,
  1464. PromptBlocks: domain.DefaultPromptBlocks(),
  1465. }
  1466. if u.settings == nil {
  1467. return settings
  1468. }
  1469. stored, err := u.settings.GetSettings(ctx)
  1470. if err != nil || stored == nil {
  1471. return settings
  1472. }
  1473. if strings.TrimSpace(stored.QCBaseURL) != "" {
  1474. settings.QCBaseURL = strings.TrimSpace(stored.QCBaseURL)
  1475. }
  1476. if strings.TrimSpace(stored.QCBearerTokenEncrypted) != "" {
  1477. settings.QCBearerTokenEncrypted = strings.TrimSpace(stored.QCBearerTokenEncrypted)
  1478. }
  1479. if strings.TrimSpace(stored.LanguageOutputMode) != "" {
  1480. settings.LanguageOutputMode = strings.TrimSpace(stored.LanguageOutputMode)
  1481. }
  1482. if stored.JobPollIntervalSeconds > 0 {
  1483. settings.JobPollIntervalSeconds = stored.JobPollIntervalSeconds
  1484. }
  1485. if stored.JobPollTimeoutSeconds > 0 {
  1486. settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds
  1487. }
  1488. settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt)
  1489. settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks)
  1490. return settings
  1491. }
  1492. func parsePromptBlocksFromRequest(r *http.Request) []domain.PromptBlockConfig {
  1493. count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("prompt_block_count")))
  1494. if count <= 0 {
  1495. return domain.DefaultPromptBlocks()
  1496. }
  1497. out := make([]domain.PromptBlockConfig, 0, count)
  1498. for i := 0; i < count; i++ {
  1499. id := strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_id_%d", i)))
  1500. if id == "" {
  1501. continue
  1502. }
  1503. out = append(out, domain.PromptBlockConfig{
  1504. ID: id,
  1505. Label: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_label_%d", i))),
  1506. Instruction: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_instruction_%d", i))),
  1507. Enabled: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_enabled_%d", i))) == "on",
  1508. })
  1509. }
  1510. return domain.NormalizePromptBlocks(out)
  1511. }
  1512. func clonePromptBlocks(blocks []domain.PromptBlockConfig) []domain.PromptBlockConfig {
  1513. if len(blocks) == 0 {
  1514. return nil
  1515. }
  1516. out := make([]domain.PromptBlockConfig, len(blocks))
  1517. copy(out, blocks)
  1518. return out
  1519. }
  1520. func mergePromptBlocks(current []domain.PromptBlockConfig, defaults []domain.PromptBlockConfig) []domain.PromptBlockConfig {
  1521. merged := make([]domain.PromptBlockConfig, 0, len(defaults))
  1522. merged = append(merged, clonePromptBlocks(defaults)...)
  1523. overrides := make(map[string]domain.PromptBlockConfig, len(current))
  1524. for _, block := range current {
  1525. id := strings.TrimSpace(block.ID)
  1526. if id == "" {
  1527. continue
  1528. }
  1529. overrides[id] = block
  1530. }
  1531. for i := range merged {
  1532. if override, ok := overrides[merged[i].ID]; ok {
  1533. if strings.TrimSpace(override.Label) != "" {
  1534. merged[i].Label = strings.TrimSpace(override.Label)
  1535. }
  1536. if strings.TrimSpace(override.Instruction) != "" {
  1537. merged[i].Instruction = strings.TrimSpace(override.Instruction)
  1538. }
  1539. merged[i].Enabled = override.Enabled
  1540. delete(overrides, merged[i].ID)
  1541. }
  1542. }
  1543. for _, override := range overrides {
  1544. merged = append(merged, override)
  1545. }
  1546. return domain.NormalizePromptBlocks(merged)
  1547. }
  1548. func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form buildFormInput) []domain.PromptBlockConfig {
  1549. out := clonePromptBlocks(blocks)
  1550. if len(out) == 0 {
  1551. return out
  1552. }
  1553. for i := range out {
  1554. switch out[i].ID {
  1555. case "business_type":
  1556. if strings.TrimSpace(form.BusinessType) != "" {
  1557. out[i].Enabled = true
  1558. }
  1559. case "website_summary":
  1560. if strings.TrimSpace(form.WebsiteSummary) != "" {
  1561. out[i].Enabled = true
  1562. }
  1563. case "address_mode":
  1564. if strings.TrimSpace(form.AddressMode) != "" {
  1565. out[i].Enabled = true
  1566. }
  1567. case "content_tone":
  1568. if strings.TrimSpace(form.ContentTone) != "" {
  1569. out[i].Enabled = true
  1570. }
  1571. case "free_instructions":
  1572. if strings.TrimSpace(form.PromptInstructions) != "" {
  1573. out[i].Enabled = true
  1574. }
  1575. }
  1576. }
  1577. return out
  1578. }
  1579. func semanticSlotPreview(mappingResult mapping.SemanticSlotMapping) []semanticSlotPreviewView {
  1580. if len(mappingResult.BySlot) == 0 {
  1581. return nil
  1582. }
  1583. slotKeys := make([]string, 0, len(mappingResult.BySlot))
  1584. for slot := range mappingResult.BySlot {
  1585. slotKeys = append(slotKeys, slot)
  1586. }
  1587. sort.Strings(slotKeys)
  1588. out := make([]semanticSlotPreviewView, 0, len(slotKeys))
  1589. for _, slot := range slotKeys {
  1590. targets := mappingResult.BySlot[slot]
  1591. examples := make([]string, 0, 2)
  1592. for i := 0; i < len(targets) && i < 2; i++ {
  1593. examples = append(examples, targets[i].FieldPath)
  1594. }
  1595. out = append(out, semanticSlotPreviewView{
  1596. Slot: slot,
  1597. Count: len(targets),
  1598. Examples: strings.Join(examples, ", "),
  1599. })
  1600. }
  1601. return out
  1602. }
  1603. func getString(v any) string {
  1604. s, _ := v.(string)
  1605. return strings.TrimSpace(s)
  1606. }
  1607. func defaultDraftStatus(status string) string {
  1608. switch strings.ToLower(strings.TrimSpace(status)) {
  1609. case "reviewed", "submitted":
  1610. return strings.ToLower(strings.TrimSpace(status))
  1611. default:
  1612. return "draft"
  1613. }
  1614. }
  1615. func websiteSectionOptions() []websiteSectionOptionView {
  1616. values := domain.WebsiteSectionOptions()
  1617. out := make([]websiteSectionOptionView, 0, len(values))
  1618. for _, value := range values {
  1619. out = append(out, websiteSectionOptionView{
  1620. Value: value,
  1621. Label: domain.WebsiteSectionLabel(value),
  1622. })
  1623. }
  1624. return out
  1625. }