From f11640f927cb8898945315492d4df58770e06ee7 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 2 Jun 2026 14:17:31 +0530
Subject: [PATCH 1/7] feat: add help center write methods to API client
Add CreatePortal, UpdatePortal, ListCategories, CreateCategory,
CreateArticle, UpdateArticle, and UploadImageExternalURL to the help
center service. Response wrappers match the Chatwoot controllers: portal
create/update return a bare object, while category and article writes wrap
the record under a payload key. Image upload posts a multipart external_url
form so the server re-hosts remote images via SafeFetch.
---
internal/sdk/help_center.go | 241 +++++++++++++++++++++++
internal/sdk/help_center_write_test.go | 253 +++++++++++++++++++++++++
2 files changed, 494 insertions(+)
create mode 100644 internal/sdk/help_center_write_test.go
diff --git a/internal/sdk/help_center.go b/internal/sdk/help_center.go
index 694c8cd..6149920 100644
--- a/internal/sdk/help_center.go
+++ b/internal/sdk/help_center.go
@@ -1,7 +1,11 @@
package sdk
import (
+ "bytes"
+ "encoding/json"
"fmt"
+ "mime/multipart"
+ "net/http"
"net/url"
"strconv"
"strings"
@@ -165,3 +169,240 @@ func helpCenterArticlesPath(portalSlug, locale, categorySlug string) (string, er
url.PathEscape(categorySlug),
), nil
}
+
+// ---------------------------------------------------------------------------
+// Write API (authoring) — account-scoped, admin token required.
+//
+// Response wrappers differ per endpoint (verified against the Chatwoot
+// controllers/jbuilder views): portal create/update return a BARE portal
+// object, while category and article create/update wrap the record under a
+// "payload" key.
+// ---------------------------------------------------------------------------
+
+// PortalConfigInput is the WRITE shape of a portal's config. Note that
+// allowed_locales is a flat []string on write, even though the read API
+// renders it as an array of objects.
+type PortalConfigInput struct {
+ AllowedLocales []string `json:"allowed_locales,omitempty"`
+ DefaultLocale string `json:"default_locale,omitempty"`
+ Layout string `json:"layout,omitempty"`
+}
+
+// PortalInput is the request body for creating/updating a portal.
+type PortalInput struct {
+ Name string `json:"name,omitempty"`
+ Slug string `json:"slug,omitempty"`
+ Config *PortalConfigInput `json:"config,omitempty"`
+}
+
+// CreatePortal creates a help center portal. The response is a bare portal
+// object (no payload wrapper).
+func (s *HelpCenterService) CreatePortal(req PortalInput) (*HelpCenterPortal, error) {
+ body, err := json.Marshal(map[string]PortalInput{"portal": req})
+ if err != nil {
+ return nil, err
+ }
+
+ var portal HelpCenterPortal
+ if err := s.client.Post("/portals", bytes.NewReader(body), &portal); err != nil {
+ return nil, err
+ }
+ return &portal, nil
+}
+
+// UpdatePortal updates a portal by slug (used to reconcile allowed_locales).
+// The response is a bare portal object.
+func (s *HelpCenterService) UpdatePortal(slug string, req PortalInput) (*HelpCenterPortal, error) {
+ if strings.TrimSpace(slug) == "" {
+ return nil, fmt.Errorf("portal slug is required")
+ }
+
+ body, err := json.Marshal(map[string]PortalInput{"portal": req})
+ if err != nil {
+ return nil, err
+ }
+
+ var portal HelpCenterPortal
+ path := fmt.Sprintf("/portals/%s", url.PathEscape(slug))
+ if err := s.client.Patch(path, bytes.NewReader(body), &portal); err != nil {
+ return nil, err
+ }
+ return &portal, nil
+}
+
+// HelpCenterCategory is a category record as returned by the write API.
+type HelpCenterCategory struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Locale string `json:"locale"`
+ Description string `json:"description"`
+ Position int `json:"position"`
+ AccountID int `json:"account_id"`
+}
+
+type HelpCenterCategoriesResponse struct {
+ Payload []HelpCenterCategory `json:"payload"`
+ Meta map[string]any `json:"meta"`
+}
+
+type categoryEnvelope struct {
+ Payload HelpCenterCategory `json:"payload"`
+}
+
+// CreateCategoryRequest is the request body for creating a category. slug is
+// REQUIRED — unlike articles, the server does not auto-generate it.
+type CreateCategoryRequest struct {
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Locale string `json:"locale"`
+ Description string `json:"description,omitempty"`
+ ParentCategoryID int `json:"parent_category_id,omitempty"`
+ AssociatedCategoryID int `json:"associated_category_id,omitempty"`
+ Position int `json:"position,omitempty"`
+}
+
+// ListCategories lists a portal's categories, optionally filtered by locale.
+func (s *HelpCenterService) ListCategories(portalSlug, locale string) (*HelpCenterCategoriesResponse, error) {
+ if strings.TrimSpace(portalSlug) == "" {
+ return nil, fmt.Errorf("portal slug is required")
+ }
+
+ params := url.Values{}
+ if strings.TrimSpace(locale) != "" {
+ params.Set("locale", locale)
+ }
+
+ var resp HelpCenterCategoriesResponse
+ path := fmt.Sprintf("/portals/%s/categories", url.PathEscape(portalSlug))
+ if err := s.client.Get(path, params, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+// CreateCategory creates a category under a portal. Returns the created
+// category (unwrapped from the payload envelope).
+func (s *HelpCenterService) CreateCategory(portalSlug string, req CreateCategoryRequest) (*HelpCenterCategory, error) {
+ if strings.TrimSpace(portalSlug) == "" {
+ return nil, fmt.Errorf("portal slug is required")
+ }
+
+ body, err := json.Marshal(map[string]CreateCategoryRequest{"category": req})
+ if err != nil {
+ return nil, err
+ }
+
+ var env categoryEnvelope
+ path := fmt.Sprintf("/portals/%s/categories", url.PathEscape(portalSlug))
+ if err := s.client.Post(path, bytes.NewReader(body), &env); err != nil {
+ return nil, err
+ }
+ return &env.Payload, nil
+}
+
+type articleEnvelope struct {
+ Payload HelpCenterArticle `json:"payload"`
+}
+
+// ArticleMetaInput is the optional SEO/meta object on an article.
+type ArticleMetaInput struct {
+ Title string `json:"title,omitempty"`
+ Description string `json:"description,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+}
+
+// CreateArticleRequest is the request body for creating/updating an article.
+// title and author_id are required; content is required only when published
+// (imports use draft). slug may be omitted — the server auto-generates it.
+// associated_article_id is honored at create time and flattened to the root.
+type CreateArticleRequest struct {
+ Title string `json:"title,omitempty"`
+ Content string `json:"content,omitempty"`
+ Description string `json:"description,omitempty"`
+ Slug string `json:"slug,omitempty"`
+ Status string `json:"status,omitempty"`
+ Locale string `json:"locale,omitempty"`
+ AuthorID int `json:"author_id,omitempty"`
+ Position int `json:"position,omitempty"`
+ CategoryID int `json:"category_id,omitempty"`
+ AssociatedArticleID int `json:"associated_article_id,omitempty"`
+ Meta *ArticleMetaInput `json:"meta,omitempty"`
+}
+
+// CreateArticle creates an article under a portal. Returns the created article
+// (unwrapped from the payload envelope).
+func (s *HelpCenterService) CreateArticle(portalSlug string, req CreateArticleRequest) (*HelpCenterArticle, error) {
+ if strings.TrimSpace(portalSlug) == "" {
+ return nil, fmt.Errorf("portal slug is required")
+ }
+
+ body, err := json.Marshal(map[string]CreateArticleRequest{"article": req})
+ if err != nil {
+ return nil, err
+ }
+
+ var env articleEnvelope
+ path := fmt.Sprintf("/portals/%s/articles", url.PathEscape(portalSlug))
+ if err := s.client.Post(path, bytes.NewReader(body), &env); err != nil {
+ return nil, err
+ }
+ return &env.Payload, nil
+}
+
+// UpdateArticle updates an article by id (used for re-link/repair). Returns the
+// updated article.
+func (s *HelpCenterService) UpdateArticle(portalSlug string, id int, req CreateArticleRequest) (*HelpCenterArticle, error) {
+ if strings.TrimSpace(portalSlug) == "" {
+ return nil, fmt.Errorf("portal slug is required")
+ }
+
+ body, err := json.Marshal(map[string]CreateArticleRequest{"article": req})
+ if err != nil {
+ return nil, err
+ }
+
+ var env articleEnvelope
+ path := fmt.Sprintf("/portals/%s/articles/%d", url.PathEscape(portalSlug), id)
+ if err := s.client.Patch(path, bytes.NewReader(body), &env); err != nil {
+ return nil, err
+ }
+ return &env.Payload, nil
+}
+
+// UploadResult is the response from the /upload endpoint. blob_id is an
+// ActiveStorage signed_id (a string).
+type UploadResult struct {
+ FileURL string `json:"file_url"`
+ BlobID string `json:"blob_id"`
+}
+
+// UploadImageExternalURL uploads an image by asking the server to fetch a
+// remote URL (via SafeFetch). Returns the hosted file_url to embed in article
+// content. Uses a multipart form with a single external_url field.
+func (s *HelpCenterService) UploadImageExternalURL(externalURL string) (*UploadResult, error) {
+ if strings.TrimSpace(externalURL) == "" {
+ return nil, fmt.Errorf("external_url is required")
+ }
+
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ if err := w.WriteField("external_url", externalURL); err != nil {
+ return nil, err
+ }
+ if err := w.Close(); err != nil {
+ return nil, err
+ }
+
+ headers := http.Header{"Content-Type": {w.FormDataContentType()}}
+ resp, err := s.client.RequestRaw(http.MethodPost, "/upload", &buf, true, headers)
+ if err != nil {
+ return nil, err
+ }
+
+ var result UploadResult
+ if err := json.Unmarshal(resp.Body, &result); err != nil {
+ return nil, fmt.Errorf("failed to decode upload response: %w", err)
+ }
+ return &result, nil
+}
diff --git a/internal/sdk/help_center_write_test.go b/internal/sdk/help_center_write_test.go
new file mode 100644
index 0000000..658eee3
--- /dev/null
+++ b/internal/sdk/help_center_write_test.go
@@ -0,0 +1,253 @@
+package sdk
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// decodeBody decodes a request's JSON body into a generic map for assertions.
+func decodeBody(t *testing.T, r *http.Request) map[string]any {
+ t.Helper()
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("decode body %q: %v", string(raw), err)
+ }
+ return m
+}
+
+func TestCreatePortalSendsConfigAndDecodesBareResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/portals" {
+ http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ body := decodeBody(t, r)
+ portal, ok := body["portal"].(map[string]any)
+ if !ok {
+ t.Fatalf("body not wrapped under portal: %#v", body)
+ }
+ if portal["slug"] != "acme-support" {
+ t.Errorf("slug = %v, want acme-support", portal["slug"])
+ }
+ cfg, ok := portal["config"].(map[string]any)
+ if !ok {
+ t.Fatalf("config missing: %#v", portal)
+ }
+ locales, ok := cfg["allowed_locales"].([]any)
+ if !ok || len(locales) != 2 || locales[0] != "en" || locales[1] != "fr" {
+ t.Errorf("allowed_locales = %#v, want [en fr] as strings", cfg["allowed_locales"])
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ // Bare portal object — NO payload wrapper.
+ _, _ = w.Write([]byte(`{"id": 7, "name": "Acme Support", "slug": "acme-support"}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client()))
+ portal, err := client.HelpCenter().CreatePortal(PortalInput{
+ Name: "Acme Support",
+ Slug: "acme-support",
+ Config: &PortalConfigInput{
+ AllowedLocales: []string{"en", "fr"},
+ DefaultLocale: "en",
+ },
+ })
+ if err != nil {
+ t.Fatalf("CreatePortal: %v", err)
+ }
+ if portal.ID != 7 || portal.Slug != "acme-support" {
+ t.Fatalf("unexpected portal: %#v", portal)
+ }
+}
+
+func TestUpdatePortalPatchesAllowedLocales(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || r.URL.Path != "/api/v1/accounts/9/portals/acme-support" {
+ http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ body := decodeBody(t, r)
+ cfg := body["portal"].(map[string]any)["config"].(map[string]any)
+ locales := cfg["allowed_locales"].([]any)
+ if len(locales) != 3 {
+ t.Errorf("allowed_locales len = %d, want 3", len(locales))
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id": 7, "slug": "acme-support"}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client()))
+ if _, err := client.HelpCenter().UpdatePortal("acme-support", PortalInput{
+ Config: &PortalConfigInput{AllowedLocales: []string{"en", "fr", "de"}},
+ }); err != nil {
+ t.Fatalf("UpdatePortal: %v", err)
+ }
+}
+
+func TestCreateCategorySendsSlugAndLinksAndUnwrapsPayload(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/portals/acme-support/categories" {
+ http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ cat := decodeBody(t, r)["category"].(map[string]any)
+ if cat["slug"] == nil || cat["slug"] == "" {
+ t.Errorf("slug must be present, got %#v", cat["slug"])
+ }
+ if cat["locale"] != "fr" {
+ t.Errorf("locale = %v, want fr", cat["locale"])
+ }
+ if cat["associated_category_id"] != float64(11) {
+ t.Errorf("associated_category_id = %v, want 11", cat["associated_category_id"])
+ }
+ if cat["parent_category_id"] != float64(5) {
+ t.Errorf("parent_category_id = %v, want 5", cat["parent_category_id"])
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"payload": {"id": 21, "slug": "getting-started", "locale": "fr"}}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client()))
+ cat, err := client.HelpCenter().CreateCategory("acme-support", CreateCategoryRequest{
+ Name: "Commencer",
+ Slug: "getting-started",
+ Locale: "fr",
+ ParentCategoryID: 5,
+ AssociatedCategoryID: 11,
+ })
+ if err != nil {
+ t.Fatalf("CreateCategory: %v", err)
+ }
+ if cat.ID != 21 || cat.Locale != "fr" {
+ t.Fatalf("unexpected category: %#v", cat)
+ }
+}
+
+func TestListCategoriesPassesLocaleQuery(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/accounts/9/portals/acme-support/categories" {
+ http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ if got := r.URL.Query().Get("locale"); got != "en" {
+ t.Errorf("locale = %q, want en", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"payload": [{"id": 3, "slug": "faq", "locale": "en"}], "meta": {"categories_count": 1}}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client()))
+ resp, err := client.HelpCenter().ListCategories("acme-support", "en")
+ if err != nil {
+ t.Fatalf("ListCategories: %v", err)
+ }
+ if len(resp.Payload) != 1 || resp.Payload[0].Slug != "faq" {
+ t.Fatalf("unexpected categories: %#v", resp)
+ }
+}
+
+func TestCreateArticleSendsLinkLocaleStatusAndUnwrapsPayload(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/portals/acme-support/articles" {
+ http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ art := decodeBody(t, r)["article"].(map[string]any)
+ if art["status"] != "draft" {
+ t.Errorf("status = %v, want draft", art["status"])
+ }
+ if art["locale"] != "fr" {
+ t.Errorf("locale = %v, want fr", art["locale"])
+ }
+ if art["associated_article_id"] != float64(100) {
+ t.Errorf("associated_article_id = %v, want 100", art["associated_article_id"])
+ }
+ if art["category_id"] != float64(21) {
+ t.Errorf("category_id = %v, want 21", art["category_id"])
+ }
+ if art["author_id"] != float64(2) {
+ t.Errorf("author_id = %v, want 2", art["author_id"])
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"payload": {"id": 101, "title": "Configurer SSO", "status": "draft"}}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client()))
+ art, err := client.HelpCenter().CreateArticle("acme-support", CreateArticleRequest{
+ Title: "Configurer SSO",
+ Content: "...
",
+ Status: "draft",
+ Locale: "fr",
+ AuthorID: 2,
+ CategoryID: 21,
+ AssociatedArticleID: 100,
+ })
+ if err != nil {
+ t.Fatalf("CreateArticle: %v", err)
+ }
+ if art.ID != 101 || art.Status != "draft" {
+ t.Fatalf("unexpected article: %#v", art)
+ }
+}
+
+func TestUpdateArticlePatchesByID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPatch || r.URL.Path != "/api/v1/accounts/9/portals/acme-support/articles/101" {
+ http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"payload": {"id": 101, "title": "Configurer SSO"}}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client()))
+ if _, err := client.HelpCenter().UpdateArticle("acme-support", 101, CreateArticleRequest{
+ AssociatedArticleID: 100,
+ }); err != nil {
+ t.Fatalf("UpdateArticle: %v", err)
+ }
+}
+
+func TestUploadImageExternalURLSendsMultipartField(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/api/v1/accounts/9/upload" {
+ http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Fatalf("ParseMultipartForm: %v", err)
+ }
+ if got := r.FormValue("external_url"); got != "https://cdn.intercom.io/img.png" {
+ t.Errorf("external_url = %q", got)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"file_url": "https://app/rails/active_storage/blobs/x/img.png", "blob_id": "signed-abc"}`))
+ }))
+ t.Cleanup(server.Close)
+
+ client := NewClient(server.URL, "test-token", 9, WithHTTPClient(server.Client()))
+ res, err := client.HelpCenter().UploadImageExternalURL("https://cdn.intercom.io/img.png")
+ if err != nil {
+ t.Fatalf("UploadImageExternalURL: %v", err)
+ }
+ if res.BlobID != "signed-abc" || res.FileURL == "" {
+ t.Fatalf("unexpected upload result: %#v", res)
+ }
+}
From 8ea10848d09f9e9cc6dbba061a9361bdf4546f58 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 2 Jun 2026 14:17:38 +0530
Subject: [PATCH 2/7] feat: add interactive terminal prompt package
Add a small Prompter interface (single-select, multi-select, text input,
yes/no confirm) with a TermPrompter built on the standard library and
golang.org/x/term. Kept dependency-light and behind an interface so command
flows can be driven by a scripted reader in tests.
---
internal/prompt/prompt.go | 174 +++++++++++++++++++++++++++++++++
internal/prompt/prompt_test.go | 120 +++++++++++++++++++++++
2 files changed, 294 insertions(+)
create mode 100644 internal/prompt/prompt.go
create mode 100644 internal/prompt/prompt_test.go
diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go
new file mode 100644
index 0000000..840af5f
--- /dev/null
+++ b/internal/prompt/prompt.go
@@ -0,0 +1,174 @@
+// Package prompt provides small, dependency-light interactive terminal
+// prompts (single-select, multi-select, free text, yes/no) built on the
+// standard library plus golang.org/x/term. It is intentionally CLI-only so
+// the import engine can stay non-interactive and provider-independent.
+package prompt
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+
+ "golang.org/x/term"
+)
+
+// Prompter is the interactive interface the CLI uses to gather choices. It is
+// an interface so commands can be tested with a scripted implementation.
+type Prompter interface {
+ // SelectOne shows a numbered list and returns the chosen 0-based index.
+ SelectOne(label string, options []string) (int, error)
+ // SelectMany shows a numbered list and returns the chosen 0-based indices.
+ // When allowAll is true the user may type "all" to select everything.
+ SelectMany(label string, options []string, allowAll bool) ([]int, error)
+ // Input reads a line of text, returning def when the input is empty.
+ Input(label, def string) (string, error)
+ // Confirm asks a yes/no question, defaulting to no on empty input.
+ Confirm(label string) (bool, error)
+}
+
+// IsInteractive reports whether stdin is a terminal. Commands use this to
+// require a TTY before prompting.
+func IsInteractive() bool {
+ return term.IsTerminal(int(os.Stdin.Fd()))
+}
+
+// TermPrompter is a Prompter backed by an io.Reader (input) and io.Writer
+// (prompts/echo). Use NewTermPrompter(os.Stdin, os.Stdout) in production and
+// inject strings.NewReader/bytes.Buffer in tests.
+type TermPrompter struct {
+ r *bufio.Reader
+ w io.Writer
+}
+
+// NewTermPrompter builds a TermPrompter over the given reader and writer.
+func NewTermPrompter(r io.Reader, w io.Writer) *TermPrompter {
+ return &TermPrompter{r: bufio.NewReader(r), w: w}
+}
+
+func (p *TermPrompter) readLine() (string, error) {
+ line, err := p.r.ReadString('\n')
+ if err != nil && line == "" {
+ return "", err
+ }
+ return strings.TrimSpace(line), nil
+}
+
+func (p *TermPrompter) printOptions(label string, options []string) {
+ fmt.Fprintln(p.w, label)
+ for i, opt := range options {
+ fmt.Fprintf(p.w, " %d) %s\n", i+1, opt)
+ }
+}
+
+// SelectOne shows a numbered list and reads a single choice, re-prompting on
+// invalid input until a valid number is entered or the input ends (EOF).
+func (p *TermPrompter) SelectOne(label string, options []string) (int, error) {
+ if len(options) == 0 {
+ return 0, fmt.Errorf("no options to choose from")
+ }
+ p.printOptions(label, options)
+ for {
+ fmt.Fprintf(p.w, "Enter number [1-%d]: ", len(options))
+ line, err := p.readLine()
+ if err != nil {
+ return 0, err
+ }
+ n, err := strconv.Atoi(line)
+ if err == nil && n >= 1 && n <= len(options) {
+ return n - 1, nil
+ }
+ fmt.Fprintf(p.w, "Please enter a number between 1 and %d.\n", len(options))
+ }
+}
+
+// SelectMany shows a numbered list and reads a comma-separated set of choices.
+// "all" (when allowAll) selects everything. Re-prompts on invalid input.
+func (p *TermPrompter) SelectMany(label string, options []string, allowAll bool) ([]int, error) {
+ if len(options) == 0 {
+ return nil, fmt.Errorf("no options to choose from")
+ }
+ p.printOptions(label, options)
+ hint := "Enter numbers (comma-separated)"
+ if allowAll {
+ hint += " or 'all'"
+ }
+ for {
+ fmt.Fprintf(p.w, "%s: ", hint)
+ line, err := p.readLine()
+ if err != nil {
+ return nil, err
+ }
+ if allowAll && strings.EqualFold(line, "all") {
+ idxs := make([]int, len(options))
+ for i := range options {
+ idxs[i] = i
+ }
+ return idxs, nil
+ }
+ idxs, ok := parseIndexList(line, len(options))
+ if ok && len(idxs) > 0 {
+ return idxs, nil
+ }
+ fmt.Fprintf(p.w, "Please enter one or more numbers between 1 and %d, separated by commas.\n", len(options))
+ }
+}
+
+// parseIndexList parses "1, 3,2" into a deduplicated, ordered []int of 0-based
+// indices. Returns ok=false if any token is not a valid in-range number.
+func parseIndexList(line string, n int) ([]int, bool) {
+ if strings.TrimSpace(line) == "" {
+ return nil, false
+ }
+ seen := make(map[int]bool)
+ var out []int
+ for tok := range strings.SplitSeq(line, ",") {
+ tok = strings.TrimSpace(tok)
+ if tok == "" {
+ continue
+ }
+ v, err := strconv.Atoi(tok)
+ if err != nil || v < 1 || v > n {
+ return nil, false
+ }
+ if !seen[v-1] {
+ seen[v-1] = true
+ out = append(out, v-1)
+ }
+ }
+ return out, true
+}
+
+// Input reads a line, returning def on empty input.
+func (p *TermPrompter) Input(label, def string) (string, error) {
+ if def != "" {
+ fmt.Fprintf(p.w, "%s [%s]: ", label, def)
+ } else {
+ fmt.Fprintf(p.w, "%s: ", label)
+ }
+ line, err := p.readLine()
+ if err != nil {
+ return "", err
+ }
+ if line == "" {
+ return def, nil
+ }
+ return line, nil
+}
+
+// Confirm asks a yes/no question, defaulting to no on empty input.
+func (p *TermPrompter) Confirm(label string) (bool, error) {
+ fmt.Fprintf(p.w, "%s [y/N]: ", label)
+ line, err := p.readLine()
+ if err != nil {
+ return false, err
+ }
+ switch strings.ToLower(line) {
+ case "y", "yes":
+ return true, nil
+ default:
+ return false, nil
+ }
+}
diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go
new file mode 100644
index 0000000..8a71884
--- /dev/null
+++ b/internal/prompt/prompt_test.go
@@ -0,0 +1,120 @@
+package prompt
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func newTest(input string) (*TermPrompter, *bytes.Buffer) {
+ var out bytes.Buffer
+ return NewTermPrompter(strings.NewReader(input), &out), &out
+}
+
+func TestSelectOne(t *testing.T) {
+ p, _ := newTest("2\n")
+ idx, err := p.SelectOne("Pick one", []string{"a", "b", "c"})
+ if err != nil {
+ t.Fatalf("SelectOne: %v", err)
+ }
+ if idx != 1 {
+ t.Fatalf("idx = %d, want 1", idx)
+ }
+}
+
+func TestSelectOneRepromptsThenSucceeds(t *testing.T) {
+ p, out := newTest("9\nx\n1\n")
+ idx, err := p.SelectOne("Pick one", []string{"a", "b"})
+ if err != nil {
+ t.Fatalf("SelectOne: %v", err)
+ }
+ if idx != 0 {
+ t.Fatalf("idx = %d, want 0", idx)
+ }
+ if !strings.Contains(out.String(), "between 1 and 2") {
+ t.Errorf("expected reprompt message, got: %q", out.String())
+ }
+}
+
+func TestSelectOneEOFReturnsError(t *testing.T) {
+ p, _ := newTest("")
+ if _, err := p.SelectOne("Pick", []string{"a"}); err == nil {
+ t.Fatal("expected error on EOF")
+ }
+}
+
+func TestSelectManyCommaList(t *testing.T) {
+ p, _ := newTest("1,3\n")
+ idxs, err := p.SelectMany("Pick many", []string{"a", "b", "c"}, true)
+ if err != nil {
+ t.Fatalf("SelectMany: %v", err)
+ }
+ if len(idxs) != 2 || idxs[0] != 0 || idxs[1] != 2 {
+ t.Fatalf("idxs = %v, want [0 2]", idxs)
+ }
+}
+
+func TestSelectManyAll(t *testing.T) {
+ p, _ := newTest("all\n")
+ idxs, err := p.SelectMany("Pick many", []string{"a", "b", "c"}, true)
+ if err != nil {
+ t.Fatalf("SelectMany: %v", err)
+ }
+ if len(idxs) != 3 {
+ t.Fatalf("idxs = %v, want all three", idxs)
+ }
+}
+
+func TestSelectManyDedupsAndReprompts(t *testing.T) {
+ p, _ := newTest("5\n2,2,1\n")
+ idxs, err := p.SelectMany("Pick", []string{"a", "b", "c"}, false)
+ if err != nil {
+ t.Fatalf("SelectMany: %v", err)
+ }
+ if len(idxs) != 2 || idxs[0] != 1 || idxs[1] != 0 {
+ t.Fatalf("idxs = %v, want [1 0] (deduped, ordered)", idxs)
+ }
+}
+
+func TestInputDefaultOnEmpty(t *testing.T) {
+ p, _ := newTest("\n")
+ got, err := p.Input("Slug", "acme-support")
+ if err != nil {
+ t.Fatalf("Input: %v", err)
+ }
+ if got != "acme-support" {
+ t.Fatalf("got %q, want default", got)
+ }
+}
+
+func TestInputOverride(t *testing.T) {
+ p, _ := newTest("custom-slug\n")
+ got, err := p.Input("Slug", "acme-support")
+ if err != nil {
+ t.Fatalf("Input: %v", err)
+ }
+ if got != "custom-slug" {
+ t.Fatalf("got %q, want custom-slug", got)
+ }
+}
+
+func TestConfirm(t *testing.T) {
+ cases := map[string]bool{
+ "y\n": true,
+ "yes\n": true,
+ "Y\n": true,
+ "\n": false,
+ "n\n": false,
+ "nope\n": false,
+ }
+ for in, want := range cases {
+ p, _ := newTest(in)
+ got, err := p.Confirm("Proceed?")
+ if err != nil {
+ t.Fatalf("Confirm(%q): %v", in, err)
+ }
+ if got != want {
+ t.Errorf("Confirm(%q) = %v, want %v", in, got, want)
+ }
+ }
+}
From af5233ed7dd7ef09a7ebc5b1d58c9521ade525d0 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 2 Jun 2026 14:17:50 +0530
Subject: [PATCH 3/7] feat: add help center import engine
Add a source-agnostic engine for importing help center content into
Chatwoot: a provider-neutral IR and Source interface, a pure Plan step and a
write-performing Execute step over a Sink, email-based author matching with a
token-owner fallback, and a resumable state file under ~/.chatwoot/imports.
The transform layer re-hosts images via the upload endpoint and rewrites
recognized provider iframes into the bare URLs Chatwoot expands into embeds.
Translations are linked as Chatwoot expects: per-locale categories and
articles, with variants placed in the matching-locale category and linked to
the default-locale root via associated_category_id/associated_article_id.
Adds golang.org/x/net for HTML parsing in the transform layer.
---
go.mod | 5 +-
go.sum | 6 +
internal/importer/author.go | 42 +++
internal/importer/author_test.go | 40 +++
internal/importer/chatwoot.go | 120 ++++++++
internal/importer/embeds.go | 96 ++++++
internal/importer/embeds_test.go | 42 +++
internal/importer/engine.go | 439 ++++++++++++++++++++++++++++
internal/importer/engine_test.go | 237 +++++++++++++++
internal/importer/ir.go | 170 +++++++++++
internal/importer/source.go | 24 ++
internal/importer/state.go | 173 +++++++++++
internal/importer/state_test.go | 97 ++++++
internal/importer/transform.go | 187 ++++++++++++
internal/importer/transform_test.go | 92 ++++++
internal/importer/util.go | 38 +++
16 files changed, 1806 insertions(+), 2 deletions(-)
create mode 100644 internal/importer/author.go
create mode 100644 internal/importer/author_test.go
create mode 100644 internal/importer/chatwoot.go
create mode 100644 internal/importer/embeds.go
create mode 100644 internal/importer/embeds_test.go
create mode 100644 internal/importer/engine.go
create mode 100644 internal/importer/engine_test.go
create mode 100644 internal/importer/ir.go
create mode 100644 internal/importer/source.go
create mode 100644 internal/importer/state.go
create mode 100644 internal/importer/state_test.go
create mode 100644 internal/importer/transform.go
create mode 100644 internal/importer/transform_test.go
create mode 100644 internal/importer/util.go
diff --git a/go.mod b/go.mod
index 52b59de..d44eee0 100644
--- a/go.mod
+++ b/go.mod
@@ -31,6 +31,7 @@ require (
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
- golang.org/x/sys v0.44.0 // indirect
- golang.org/x/text v0.14.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
)
diff --git a/go.sum b/go.sum
index 09d5200..c6e3237 100644
--- a/go.sum
+++ b/go.sum
@@ -71,12 +71,18 @@ github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIj
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/importer/author.go b/internal/importer/author.go
new file mode 100644
index 0000000..909103d
--- /dev/null
+++ b/internal/importer/author.go
@@ -0,0 +1,42 @@
+package importer
+
+import "strings"
+
+// AuthorResolver maps a source author id to a Chatwoot user id by matching
+// emails, falling back to the token owner when no match exists. It tracks how
+// many authors were matched vs fell back, for the import summary.
+type AuthorResolver struct {
+ sourceByID map[string]Author
+ cwByEmail map[string]int
+ fallbackID int
+
+ Matched int
+ Fallback int
+}
+
+// NewAuthorResolver builds a resolver. cwByEmail keys are lowercased
+// defensively so matching is case-insensitive.
+func NewAuthorResolver(sourceByID map[string]Author, cwByEmail map[string]int, fallbackID int) *AuthorResolver {
+ normalized := make(map[string]int, len(cwByEmail))
+ for email, id := range cwByEmail {
+ normalized[strings.ToLower(strings.TrimSpace(email))] = id
+ }
+ return &AuthorResolver{
+ sourceByID: sourceByID,
+ cwByEmail: normalized,
+ fallbackID: fallbackID,
+ }
+}
+
+// Resolve returns the Chatwoot user id for a source author, falling back to the
+// token owner on any miss (unknown author, no email, or email not an agent).
+func (r *AuthorResolver) Resolve(sourceAuthorID string) int {
+ if a, ok := r.sourceByID[sourceAuthorID]; ok && a.Email != "" {
+ if id, ok := r.cwByEmail[strings.ToLower(strings.TrimSpace(a.Email))]; ok && id != 0 {
+ r.Matched++
+ return id
+ }
+ }
+ r.Fallback++
+ return r.fallbackID
+}
diff --git a/internal/importer/author_test.go b/internal/importer/author_test.go
new file mode 100644
index 0000000..7cdff68
--- /dev/null
+++ b/internal/importer/author_test.go
@@ -0,0 +1,40 @@
+package importer
+
+import "testing"
+
+func TestAuthorResolverMatchesByEmailCaseInsensitive(t *testing.T) {
+ source := map[string]Author{
+ "i1": {ID: "i1", Email: "Ada@Acme.com", Name: "Ada"},
+ }
+ cw := map[string]int{"ada@acme.com": 42}
+ r := NewAuthorResolver(source, cw, 1)
+
+ if got := r.Resolve("i1"); got != 42 {
+ t.Errorf("Resolve = %d, want 42", got)
+ }
+ if r.Matched != 1 || r.Fallback != 0 {
+ t.Errorf("matched=%d fallback=%d, want 1/0", r.Matched, r.Fallback)
+ }
+}
+
+func TestAuthorResolverFallsBack(t *testing.T) {
+ source := map[string]Author{
+ "i1": {ID: "i1", Email: "nobody@elsewhere.com"},
+ "i2": {ID: "i2", Email: ""}, // no email
+ }
+ cw := map[string]int{"ada@acme.com": 42}
+ r := NewAuthorResolver(source, cw, 7)
+
+ if got := r.Resolve("i1"); got != 7 { // email not an agent
+ t.Errorf("Resolve(i1) = %d, want fallback 7", got)
+ }
+ if got := r.Resolve("i2"); got != 7 { // no email
+ t.Errorf("Resolve(i2) = %d, want fallback 7", got)
+ }
+ if got := r.Resolve("unknown"); got != 7 { // unknown author id
+ t.Errorf("Resolve(unknown) = %d, want fallback 7", got)
+ }
+ if r.Fallback != 3 || r.Matched != 0 {
+ t.Errorf("matched=%d fallback=%d, want 0/3", r.Matched, r.Fallback)
+ }
+}
diff --git a/internal/importer/chatwoot.go b/internal/importer/chatwoot.go
new file mode 100644
index 0000000..8ab38f7
--- /dev/null
+++ b/internal/importer/chatwoot.go
@@ -0,0 +1,120 @@
+package importer
+
+import (
+ "fmt"
+
+ "github.com/chatwoot/cli/internal/sdk"
+)
+
+// chatwootSink implements Sink over the Chatwoot SDK client.
+type chatwootSink struct {
+ client *sdk.Client
+}
+
+// NewChatwootSink returns a Sink backed by the given Chatwoot client.
+func NewChatwootSink(client *sdk.Client) Sink {
+ return &chatwootSink{client: client}
+}
+
+// EnsurePortal reuses an existing portal by slug (adding any missing locales)
+// or creates a new one. The created bool is true only when a portal is created.
+func (s *chatwootSink) EnsurePortal(target PortalTarget, locales []string) (PortalRef, bool, error) {
+ hc := s.client.HelpCenter()
+ slug := target.Slug()
+
+ portals, err := hc.ListPortals()
+ if err != nil {
+ return PortalRef{}, false, err
+ }
+
+ var existing *sdk.HelpCenterPortal
+ for i := range portals.Payload {
+ if portals.Payload[i].Slug == slug {
+ existing = &portals.Payload[i]
+ break
+ }
+ }
+
+ defaultLocale := ""
+ if len(locales) > 0 {
+ defaultLocale = locales[0]
+ }
+
+ if existing != nil {
+ have := map[string]bool{}
+ for _, l := range existing.Config.AllowedLocales {
+ have[l.Code] = true
+ }
+ missing := false
+ for _, l := range locales {
+ if !have[l] {
+ missing = true
+ break
+ }
+ }
+ if missing {
+ union := unionLocales(allowedCodes(existing.Config.AllowedLocales), locales)
+ dflt := existing.Config.DefaultLocale
+ if dflt == "" {
+ dflt = defaultLocale
+ }
+ updated, err := hc.UpdatePortal(slug, sdk.PortalInput{
+ Config: &sdk.PortalConfigInput{AllowedLocales: union, DefaultLocale: dflt},
+ })
+ if err != nil {
+ return PortalRef{}, false, fmt.Errorf("add locales to portal %q: %w", slug, err)
+ }
+ return PortalRef{Slug: updated.Slug, ID: updated.ID}, false, nil
+ }
+ return PortalRef{Slug: existing.Slug, ID: existing.ID}, false, nil
+ }
+
+ if !target.IsCreate() {
+ return PortalRef{}, false, fmt.Errorf("portal %q not found", slug)
+ }
+
+ created, err := hc.CreatePortal(sdk.PortalInput{
+ Name: target.CreateName,
+ Slug: slug,
+ Config: &sdk.PortalConfigInput{
+ AllowedLocales: locales,
+ DefaultLocale: defaultLocale,
+ },
+ })
+ if err != nil {
+ return PortalRef{}, false, err
+ }
+ return PortalRef{Slug: created.Slug, ID: created.ID}, true, nil
+}
+
+func (s *chatwootSink) CreateCategory(portalSlug string, req sdk.CreateCategoryRequest) (sdk.HelpCenterCategory, error) {
+ cat, err := s.client.HelpCenter().CreateCategory(portalSlug, req)
+ if err != nil {
+ return sdk.HelpCenterCategory{}, err
+ }
+ return *cat, nil
+}
+
+func (s *chatwootSink) CreateArticle(portalSlug string, req sdk.CreateArticleRequest) (sdk.HelpCenterArticle, error) {
+ art, err := s.client.HelpCenter().CreateArticle(portalSlug, req)
+ if err != nil {
+ return sdk.HelpCenterArticle{}, err
+ }
+ return *art, nil
+}
+
+func (s *chatwootSink) UploadImage(externalURL string) (string, error) {
+ res, err := s.client.HelpCenter().UploadImageExternalURL(externalURL)
+ if err != nil {
+ return "", err
+ }
+ return res.FileURL, nil
+}
+
+func allowedCodes(locales []sdk.HelpCenterPortalLocale) []string {
+ out := make([]string, 0, len(locales))
+ for _, l := range locales {
+ out = append(out, l.Code)
+ }
+ return out
+}
diff --git a/internal/importer/embeds.go b/internal/importer/embeds.go
new file mode 100644
index 0000000..509ca6d
--- /dev/null
+++ b/internal/importer/embeds.go
@@ -0,0 +1,96 @@
+package importer
+
+import "regexp"
+
+// EmbedRegistry maps a provider embed/iframe URL back to the canonical "bare"
+// URL form that Chatwoot's markdown embed registry (config/markdown_embeds.yml)
+// recognizes and expands into an iframe at render time. Intercom stores rich
+// media as
"}}},
+ },
+ Authors: map[string]Author{},
+ Locales: []string{"en"},
+ }
+ sel := Selections{SourceHCID: "hc1", Target: PortalTarget{CreateName: "P", CreateSlug: "p"}, Locales: []string{"en"}}
+ sink, res, _ := runImport(t, corpus, sel, nil)
+
+ if len(sink.cats) != 0 {
+ t.Errorf("no categories expected, got %d", len(sink.cats))
+ }
+ if len(sink.arts) != 1 {
+ t.Fatalf("articles = %d, want 1", len(sink.arts))
+ }
+ if sink.arts[0].CategoryID != 0 {
+ t.Errorf("uncategorized article should have no category, got %d", sink.arts[0].CategoryID)
+ }
+ if sink.arts[0].Locale != "en" {
+ t.Errorf("uncategorized article must still send explicit locale, got %q", sink.arts[0].Locale)
+ }
+ if res.UncategorizedCount != 0 {
+ // CollectionID was empty (genuinely uncategorized at source), not a failed
+ // category, so this is not counted as a fallback.
+ t.Errorf("UncategorizedCount = %d, want 0 for source-uncategorized", res.UncategorizedCount)
+ }
+}
+
+func TestExecuteResumeSkipsCreatedItems(t *testing.T) {
+ corpus := sampleCorpus()
+ sel := Selections{
+ SourceHCID: "hc1",
+ Target: PortalTarget{CreateName: "Acme", CreateSlug: "acme-support"},
+ Locales: []string{"en", "fr"},
+ }
+ // Pre-seed state as if a prior run created the en category and en root article.
+ st := NewState("intercom", "ws", "hc1")
+ st.SetCategory("c1", "en", ItemRef{ID: 1, Slug: "getting-started"})
+ st.SetArticle("a1", "en", ItemRef{ID: 100})
+
+ sink, res, _ := runImport(t, corpus, sel, st)
+
+ // en category + en article already present -> only fr category + fr article created.
+ if len(sink.cats) != 1 || sink.cats[0].Locale != "fr" {
+ t.Errorf("expected only fr category created, got %#v", sink.cats)
+ }
+ if len(sink.arts) != 1 || sink.arts[0].Locale != "fr" {
+ t.Errorf("expected only fr article created, got %#v", sink.arts)
+ }
+ if res.CategoriesSkipped != 1 || res.ArticlesSkipped != 1 {
+ t.Errorf("skipped cats=%d arts=%d, want 1/1", res.CategoriesSkipped, res.ArticlesSkipped)
+ }
+ // fr variant still links to the pre-existing en root id from state.
+ if sink.arts[0].AssociatedArticleID != 100 {
+ t.Errorf("fr article associated id = %d, want 100 (resumed root)", sink.arts[0].AssociatedArticleID)
+ }
+}
diff --git a/internal/importer/ir.go b/internal/importer/ir.go
new file mode 100644
index 0000000..12583c8
--- /dev/null
+++ b/internal/importer/ir.go
@@ -0,0 +1,170 @@
+// Package importer is a source-agnostic engine for importing Help Center
+// content into Chatwoot. Providers (Intercom today; Crisp/Zendesk later)
+// implement the Source interface and map their data into the provider-neutral
+// intermediate representation (IR) defined here. The engine then plans and
+// executes the writes against Chatwoot via a Sink.
+//
+// The engine is intentionally non-interactive: all prompting lives in the CLI
+// layer, which passes the user's choices in as a Selections value.
+package importer
+
+// HelpCenter is a selectable source help center.
+type HelpCenter struct {
+ ID string
+ Name string
+ DefaultLocale string
+}
+
+// Collection is a provider-neutral category/section. Nesting is expressed via
+// ParentID. Names/Descriptions are keyed by locale and must include the
+// collection's default-locale name.
+type Collection struct {
+ ID string
+ ParentID string
+ Names map[string]string
+ Descriptions map[string]string
+ Order int
+}
+
+// Name returns the best name for a locale, falling back to the default locale
+// then any available name.
+func (c Collection) Name(locale, defaultLocale string) string {
+ if n, ok := c.Names[locale]; ok && n != "" {
+ return n
+ }
+ if n, ok := c.Names[defaultLocale]; ok && n != "" {
+ return n
+ }
+ for _, n := range c.Names {
+ if n != "" {
+ return n
+ }
+ }
+ return c.ID
+}
+
+// Article carries the default-locale body as the root plus per-locale variants.
+type Article struct {
+ ID string
+ CollectionID string
+ DefaultLocale string
+ AuthorID string
+ Variants map[string]ArticleVariant
+ SourceURL string
+}
+
+// ArticleVariant is one localized rendition of an article.
+type ArticleVariant struct {
+ Locale string
+ Title string
+ Description string
+ BodyHTML string
+ AuthorID string // per-locale author override; falls back to Article.AuthorID
+ State string // provider state, kept for reporting; imports always write draft
+}
+
+// Author is a provider teammate/admin used for author matching.
+type Author struct {
+ ID string
+ Email string
+ Name string
+}
+
+// Corpus is the fully-scanned source graph for one help center.
+type Corpus struct {
+ HelpCenter HelpCenter
+ Collections []Collection
+ Articles []Article
+ Authors map[string]Author
+ Locales []string // derived during scan; default locale first
+}
+
+// PortalTarget describes the chosen Chatwoot portal: either an existing slug
+// or a new portal to create.
+type PortalTarget struct {
+ ExistingSlug string
+ CreateName string
+ CreateSlug string
+}
+
+// IsCreate reports whether a new portal should be created.
+func (t PortalTarget) IsCreate() bool { return t.ExistingSlug == "" }
+
+// Slug returns the portal slug (existing or to-be-created).
+func (t PortalTarget) Slug() string {
+ if t.ExistingSlug != "" {
+ return t.ExistingSlug
+ }
+ return t.CreateSlug
+}
+
+// Selections is the user's interactive choices, built by the CLI layer and
+// passed to Plan.
+type Selections struct {
+ SourceHCID string
+ Target PortalTarget
+ Locales []string // locales chosen for import; gates non-root variants
+}
+
+// ---------------------------------------------------------------------------
+// Plan — the resolved, ordered set of writes Execute will perform. Built by
+// Plan() with no network calls (author resolution uses pre-fetched maps).
+// ---------------------------------------------------------------------------
+
+// PlannedCategory is one category create (a collection in a specific locale).
+type PlannedCategory struct {
+ CollectionID string
+ ParentCollectionID string
+ Locale string
+ IsRoot bool // root = the collection's default-locale category
+ Name string
+ Description string
+ Slug string
+ Skip bool // already recorded in state
+}
+
+// PlannedArticle is one article create (an article variant in a locale).
+type PlannedArticle struct {
+ ArticleID string
+ CollectionID string
+ Locale string
+ RootLocale string // the article's default locale (the root variant's locale)
+ IsRoot bool // root = the article's default-locale variant
+ Title string
+ Description string
+ BodyHTML string // raw; Execute transforms images/embeds before writing
+ AuthorID int // resolved Chatwoot user id
+ Skip bool
+}
+
+// ImportPlan is the full ordered plan plus summary metadata.
+type ImportPlan struct {
+ Portal PortalTarget
+ DefaultLocale string // corpus default locale (the per-collection root locale)
+ PortalLocales []string // effective allowed locales to ensure on the portal
+ Categories []PlannedCategory
+ Articles []PlannedArticle
+ AuthorStats AuthorStats
+}
+
+// AuthorStats summarizes author matching for the plan/summary.
+type AuthorStats struct {
+ Matched int
+ Fallback int
+}
+
+// Result is the outcome of Execute, for the final summary.
+type Result struct {
+ PortalSlug string
+ PortalCreated bool
+ CategoriesCreated int
+ CategoriesSkipped int
+ ArticlesCreated int
+ ArticlesSkipped int
+ ImagesSwapped int
+ ImagesFailed int
+ EmbedsRewritten int
+ UncategorizedCount int
+ Failures []FailureRec
+ AuthorStats AuthorStats
+}
diff --git a/internal/importer/source.go b/internal/importer/source.go
new file mode 100644
index 0000000..d319a84
--- /dev/null
+++ b/internal/importer/source.go
@@ -0,0 +1,24 @@
+package importer
+
+import "context"
+
+// Source is the provider abstraction. Intercom is the first implementation;
+// Crisp/Zendesk can be added later as new packages without touching the engine.
+type Source interface {
+ // Name is a stable provider key used in the state-file id (e.g. "intercom").
+ Name() string
+
+ // Validate confirms credentials work and returns a stable workspace
+ // identifier used for state keying.
+ Validate(ctx context.Context) (workspaceID string, err error)
+
+ // ListHelpCenters returns the selectable source help centers. Providers
+ // without a multi-help-center concept return a single synthetic entry.
+ ListHelpCenters(ctx context.Context) ([]HelpCenter, error)
+
+ // Scan pulls the full IR graph for one help center: collections (with
+ // parents), articles (with per-locale variants + author ids), authors, and
+ // the derived locale set. Implementations handle pagination and rate
+ // limiting internally.
+ Scan(ctx context.Context, helpCenterID string) (*Corpus, error)
+}
diff --git a/internal/importer/state.go b/internal/importer/state.go
new file mode 100644
index 0000000..4297561
--- /dev/null
+++ b/internal/importer/state.go
@@ -0,0 +1,173 @@
+package importer
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// stateVersion is the on-disk schema version.
+const stateVersion = 1
+
+// ItemRef is a created Chatwoot record (category or article).
+type ItemRef struct {
+ ID int `json:"id"`
+ Slug string `json:"slug,omitempty"`
+}
+
+// FailureRec records a per-item failure for reporting and retry.
+type FailureRec struct {
+ Kind string `json:"kind"`
+ Error string `json:"error"`
+ At string `json:"at"`
+}
+
+// PortalRef is the resolved target portal.
+type PortalRef struct {
+ Slug string `json:"slug"`
+ ID int `json:"id"`
+}
+
+// State is the resumable import state persisted under ~/.chatwoot/imports/.
+// Categories and Articles map a source id -> locale -> created Chatwoot record.
+type State struct {
+ Version int `json:"version"`
+ Provider string `json:"provider"`
+ WorkspaceID string `json:"workspace_id"`
+ SourceHCID string `json:"source_help_center_id"`
+ TargetPortal PortalRef `json:"target_portal"`
+ Locales []string `json:"locales"`
+ Categories map[string]map[string]ItemRef `json:"categories"`
+ Articles map[string]map[string]ItemRef `json:"articles"`
+ Failures map[string]FailureRec `json:"failures"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// NewState returns an initialized, empty state.
+func NewState(provider, workspaceID, sourceHCID string) *State {
+ return &State{
+ Version: stateVersion,
+ Provider: provider,
+ WorkspaceID: workspaceID,
+ SourceHCID: sourceHCID,
+ Categories: map[string]map[string]ItemRef{},
+ Articles: map[string]map[string]ItemRef{},
+ Failures: map[string]FailureRec{},
+ }
+}
+
+// StateKey derives a stable file key from the import coordinates. Target slug
+// is known before any write (portal slugs are used verbatim by Chatwoot).
+func StateKey(provider, workspaceID, sourceHCID, targetSlug string) string {
+ sum := sha256.Sum256([]byte(provider + ":" + workspaceID + ":" + sourceHCID + ":" + targetSlug))
+ return hex.EncodeToString(sum[:])[:16]
+}
+
+// StatePath returns the on-disk path for a state key within dir.
+func StatePath(dir, key string) string {
+ return filepath.Join(dir, key+".json")
+}
+
+// LoadState reads state from path, returning nil (not an error) when the file
+// does not exist.
+func LoadState(path string) (*State, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to read import state: %w", err)
+ }
+ var s State
+ if err := json.Unmarshal(data, &s); err != nil {
+ return nil, fmt.Errorf("failed to parse import state: %w", err)
+ }
+ s.ensureMaps()
+ return &s, nil
+}
+
+func (s *State) ensureMaps() {
+ if s.Categories == nil {
+ s.Categories = map[string]map[string]ItemRef{}
+ }
+ if s.Articles == nil {
+ s.Articles = map[string]map[string]ItemRef{}
+ }
+ if s.Failures == nil {
+ s.Failures = map[string]FailureRec{}
+ }
+}
+
+// Save writes state atomically (tmp + rename) with 0600 perms, creating the
+// parent directory (0700) if needed.
+func (s *State) Save(path string) error {
+ s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("failed to create import state dir: %w", err)
+ }
+ data, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ return fmt.Errorf("failed to write import state: %w", err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return fmt.Errorf("failed to commit import state: %w", err)
+ }
+ return nil
+}
+
+// CategoryRef returns the recorded category for a collection+locale, if any.
+func (s *State) CategoryRef(collectionID, locale string) (ItemRef, bool) {
+ byLocale, ok := s.Categories[collectionID]
+ if !ok {
+ return ItemRef{}, false
+ }
+ ref, ok := byLocale[locale]
+ return ref, ok
+}
+
+// SetCategory records a created category and clears any prior failure for it.
+func (s *State) SetCategory(collectionID, locale string, ref ItemRef) {
+ if s.Categories[collectionID] == nil {
+ s.Categories[collectionID] = map[string]ItemRef{}
+ }
+ s.Categories[collectionID][locale] = ref
+ delete(s.Failures, categoryKey(collectionID, locale))
+}
+
+// ArticleRef returns the recorded article for an article+locale, if any.
+func (s *State) ArticleRef(articleID, locale string) (ItemRef, bool) {
+ byLocale, ok := s.Articles[articleID]
+ if !ok {
+ return ItemRef{}, false
+ }
+ ref, ok := byLocale[locale]
+ return ref, ok
+}
+
+// SetArticle records a created article and clears any prior failure for it.
+func (s *State) SetArticle(articleID, locale string, ref ItemRef) {
+ if s.Articles[articleID] == nil {
+ s.Articles[articleID] = map[string]ItemRef{}
+ }
+ s.Articles[articleID][locale] = ref
+ delete(s.Failures, articleKey(articleID, locale))
+}
+
+// RecordFailure stores a per-item failure keyed by its composite id.
+func (s *State) RecordFailure(key, kind, errMsg string) {
+ s.Failures[key] = FailureRec{Kind: kind, Error: errMsg, At: time.Now().UTC().Format(time.RFC3339)}
+}
+
+func categoryKey(collectionID, locale string) string {
+ return "category:" + collectionID + ":" + locale
+}
+func articleKey(articleID, locale string) string { return "article:" + articleID + ":" + locale }
diff --git a/internal/importer/state_test.go b/internal/importer/state_test.go
new file mode 100644
index 0000000..5e0721d
--- /dev/null
+++ b/internal/importer/state_test.go
@@ -0,0 +1,97 @@
+package importer
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestStateKeyIsStableAndCoordinateSensitive(t *testing.T) {
+ a := StateKey("intercom", "ws1", "hc1", "acme-support")
+ b := StateKey("intercom", "ws1", "hc1", "acme-support")
+ if a != b {
+ t.Fatalf("key not stable: %q != %q", a, b)
+ }
+ if a == StateKey("intercom", "ws1", "hc1", "other-portal") {
+ t.Fatal("different target slug should produce a different key")
+ }
+ if len(a) != 16 {
+ t.Fatalf("key length = %d, want 16", len(a))
+ }
+}
+
+func TestLoadStateMissingReturnsNil(t *testing.T) {
+ s, err := LoadState(filepath.Join(t.TempDir(), "nope.json"))
+ if err != nil {
+ t.Fatalf("LoadState: %v", err)
+ }
+ if s != nil {
+ t.Fatalf("expected nil state for missing file, got %#v", s)
+ }
+}
+
+func TestStateSaveLoadRoundTripAndPerms(t *testing.T) {
+ dir := t.TempDir()
+ path := StatePath(dir, "key123")
+
+ s := NewState("intercom", "ws1", "hc1")
+ s.TargetPortal = PortalRef{Slug: "acme-support", ID: 7}
+ s.Locales = []string{"en", "fr"}
+ s.SetCategory("coll1", "en", ItemRef{ID: 10, Slug: "faq"})
+ s.SetArticle("art1", "en", ItemRef{ID: 100, Slug: "hello"})
+
+ if err := s.Save(path); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ if runtime.GOOS != "windows" {
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ if perm := info.Mode().Perm(); perm != 0o600 {
+ t.Errorf("perm = %o, want 600", perm)
+ }
+ }
+
+ loaded, err := LoadState(path)
+ if err != nil {
+ t.Fatalf("LoadState: %v", err)
+ }
+ if loaded == nil {
+ t.Fatal("expected loaded state")
+ }
+ if ref, ok := loaded.CategoryRef("coll1", "en"); !ok || ref.ID != 10 {
+ t.Errorf("category ref = %#v ok=%v, want id 10", ref, ok)
+ }
+ if ref, ok := loaded.ArticleRef("art1", "en"); !ok || ref.ID != 100 {
+ t.Errorf("article ref = %#v ok=%v, want id 100", ref, ok)
+ }
+ if loaded.TargetPortal.ID != 7 {
+ t.Errorf("portal id = %d, want 7", loaded.TargetPortal.ID)
+ }
+}
+
+func TestSetCategoryClearsFailure(t *testing.T) {
+ s := NewState("intercom", "ws1", "hc1")
+ key := categoryKey("coll1", "fr")
+ s.RecordFailure(key, "category", "boom")
+ if _, ok := s.Failures[key]; !ok {
+ t.Fatal("failure not recorded")
+ }
+ s.SetCategory("coll1", "fr", ItemRef{ID: 5})
+ if _, ok := s.Failures[key]; ok {
+ t.Fatal("failure should be cleared after success")
+ }
+}
+
+func TestMissingRefsReturnFalse(t *testing.T) {
+ s := NewState("intercom", "ws1", "hc1")
+ if _, ok := s.CategoryRef("nope", "en"); ok {
+ t.Error("expected no category ref")
+ }
+ if _, ok := s.ArticleRef("nope", "en"); ok {
+ t.Error("expected no article ref")
+ }
+}
diff --git a/internal/importer/transform.go b/internal/importer/transform.go
new file mode 100644
index 0000000..09a84a7
--- /dev/null
+++ b/internal/importer/transform.go
@@ -0,0 +1,187 @@
+package importer
+
+import (
+ "bytes"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+// ImageUploader re-hosts a remote image URL and returns the new hosted URL.
+// It is injected so the transform stays pure/testable; in production it wraps
+// the Chatwoot upload endpoint.
+type ImageUploader func(srcURL string) (newURL string, err error)
+
+// TransformResult is the rewritten body plus per-article counters.
+type TransformResult struct {
+ HTML string
+ ImagesSwapped int
+ ImagesFailed int
+ EmbedsRewritten int
+}
+
+// TransformBody rewrites an HTML article body:
sources are re-hosted via
+// upload (best-effort — failures keep the original src), and recognized
+// provider