Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

1557 lines
51KB

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