diff --git a/backend/internal/api/knowledgeHub.go b/backend/internal/api/knowledgeHub.go index 8cb21f4..3a7057a 100644 --- a/backend/internal/api/knowledgeHub.go +++ b/backend/internal/api/knowledgeHub.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "time" "github.com/danielgtaylor/huma/v2" @@ -65,6 +66,13 @@ func (a *api) registerKnowledgeHubRoutes(_ chi.Router, api huma.API) { Path: prefix + "/{uuid}/related", Tags: tags, }, a.createRelatedArticleHandler) + + huma.Register(api, huma.Operation{ + OperationID: "generateTableOfContents", + Method: http.MethodPost, + Path: prefix + "/{uuid}/generate-toc", + Tags: tags, + }, a.generateTOCHandler) } type GetKnowledgeArticlesOutput struct { @@ -118,6 +126,16 @@ type CreateRelatedArticleInput struct { } `json:"body"` } +type GenerateTOCInput struct { + UUID string `path:"uuid"` +} + +type GenerateTOCOutput struct { + Body struct { + TableOfContents []domain.TableOfContent `json:"table_of_contents"` + } `json:"body"` +} + func (a *api) getAllKnowledgeArticlesHandler(ctx context.Context, input *struct{}) (*GetKnowledgeArticlesOutput, error) { resp := &GetKnowledgeArticlesOutput{} @@ -285,3 +303,70 @@ func (a *api) createRelatedArticleHandler( return nil, nil } + +func generateTOCFromContent(content string) []domain.TableOfContent { + var toc []domain.TableOfContent + lines := strings.Split(content, "\n") + order := 0 + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# ") { + order++ + toc = append(toc, domain.TableOfContent{ + Title: strings.TrimPrefix(line, "# "), + Level: 1, + Order: order, + }) + } else if strings.HasPrefix(line, "## ") { + order++ + toc = append(toc, domain.TableOfContent{ + Title: strings.TrimPrefix(line, "## "), + Level: 2, + Order: order, + }) + } else if strings.HasPrefix(line, "### ") { + order++ + toc = append(toc, domain.TableOfContent{ + Title: strings.TrimPrefix(line, "### "), + Level: 3, + Order: order, + }) + } + // Add more levels if needed + } + + return toc +} + +func (a *api) generateTOCHandler( + ctx context.Context, + input *GenerateTOCInput, +) (*GenerateTOCOutput, error) { + resp := &GenerateTOCOutput{} + + // Validate UUID format + if _, err := uuid.FromString(input.UUID); err != nil { + return nil, huma.Error400BadRequest("invalid UUID format") + } + + // Get the article + article, err := a.knowledgeHubRepo.GetArticleByID(ctx, input.UUID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, huma.Error404NotFound("article not found") + } + return nil, err + } + + // Generate TOC from content + tocItems := generateTOCFromContent(article.Content) + + // Save to database + if err := a.knowledgeHubRepo.CreateTableOfContents(ctx, input.UUID, tocItems); err != nil { + return nil, huma.Error500InternalServerError("failed to save table of contents") + } + + resp.Body.TableOfContents = tocItems + return resp, nil +} diff --git a/backend/internal/domain/knowledgeHub.go b/backend/internal/domain/knowledgeHub.go index 04ad316..9cdf08b 100644 --- a/backend/internal/domain/knowledgeHub.go +++ b/backend/internal/domain/knowledgeHub.go @@ -42,12 +42,13 @@ func (k *KnowledgeArticle) Validate() error { } type TableOfContent struct { - UUID string - ArticleID string - Title string - Order int - CreatedAt time.Time - UpdatedAt time.Time + UUID string `json:"uuid"` + ArticleID string `json:"article_id"` + Title string `json:"title"` + Level int `json:"level"` + Order int `json:"order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type RelatedArticle struct { @@ -60,13 +61,18 @@ type RelatedArticle struct { } type KnowledgeHubRepository interface { + // Article methods GetArticleByID(context.Context, string) (KnowledgeArticle, error) GetArticlesByCategory(ctx context.Context, category string) ([]KnowledgeArticle, error) GetAllArticles(ctx context.Context) ([]KnowledgeArticle, error) CreateOrUpdateArticle(context.Context, *KnowledgeArticle) error DeleteArticle(context.Context, string) error + // Table of Contents methods GetTableOfContents(ctx context.Context, articleID string) ([]TableOfContent, error) + CreateTableOfContents(ctx context.Context, articleID string, items []TableOfContent) error + + // Related Articles methods GetRelatedArticles(ctx context.Context, articleID string) ([]RelatedArticle, error) CreateRelatedArticle(ctx context.Context, articleID string, related *RelatedArticle) error } diff --git a/backend/internal/repository/connection.go b/backend/internal/repository/connection.go index 63d6305..0d0c99e 100644 --- a/backend/internal/repository/connection.go +++ b/backend/internal/repository/connection.go @@ -11,4 +11,5 @@ type Connection interface { Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) Query(context.Context, string, ...interface{}) (pgx.Rows, error) QueryRow(context.Context, string, ...interface{}) pgx.Row + BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) } diff --git a/backend/internal/repository/postgres_knowledgeHub.go b/backend/internal/repository/postgres_knowledgeHub.go index 75925a1..53b983d 100644 --- a/backend/internal/repository/postgres_knowledgeHub.go +++ b/backend/internal/repository/postgres_knowledgeHub.go @@ -2,6 +2,8 @@ package repository import ( "context" + "fmt" + "github.com/jackc/pgx/v5" "strings" "github.com/forfarm/backend/internal/domain" @@ -213,3 +215,51 @@ func (p *postgresKnowledgeHubRepository) CreateRelatedArticle( ) return err } + +func (p *postgresKnowledgeHubRepository) CreateTableOfContents( + ctx context.Context, + articleID string, + items []domain.TableOfContent, +) error { + // Begin transaction + tx, err := p.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + tx.Rollback(ctx) + } + }() + + // First delete existing TOC items for this article + _, err = tx.Exec(ctx, `DELETE FROM table_of_contents WHERE article_id = $1`, articleID) + if err != nil { + return fmt.Errorf("failed to delete existing TOC items: %w", err) + } + + // Insert new TOC items + for _, item := range items { + item.UUID = uuid.New().String() + _, err = tx.Exec(ctx, ` + INSERT INTO table_of_contents + (uuid, article_id, title, level, "order", created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, + item.UUID, + articleID, + item.Title, + item.Level, + item.Order, + ) + if err != nil { + return fmt.Errorf("failed to insert TOC item: %w", err) + } + } + + // Commit transaction + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} diff --git a/backend/internal/service/toc_generator.go b/backend/internal/service/toc_generator.go new file mode 100644 index 0000000..7f85f6a --- /dev/null +++ b/backend/internal/service/toc_generator.go @@ -0,0 +1,37 @@ +package service + +import ( + "github.com/forfarm/backend/internal/domain" + "regexp" + "strings" +) + +type TOCGenerator struct{} + +func NewTOCGenerator() *TOCGenerator { + return &TOCGenerator{} +} + +func (g *TOCGenerator) GenerateFromContent(content string) []domain.TableOfContent { + var toc []domain.TableOfContent + lines := strings.Split(content, "\n") + order := 0 + + headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.*)$`) + + for _, line := range lines { + if matches := headerRegex.FindStringSubmatch(line); matches != nil { + order++ + level := len(matches[1]) // Number of # indicates level + title := matches[2] + + toc = append(toc, domain.TableOfContent{ + Title: title, + Level: level, + Order: order, + }) + } + } + + return toc +} diff --git a/backend/migrations/00004_create_knowledge_hub_tables.sql b/backend/migrations/00004_create_knowledge_hub_tables.sql index 4d8b722..01598de 100644 --- a/backend/migrations/00004_create_knowledge_hub_tables.sql +++ b/backend/migrations/00004_create_knowledge_hub_tables.sql @@ -13,13 +13,13 @@ CREATE TABLE knowledge_articles ( ); CREATE TABLE table_of_contents ( - uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), - article_id UUID NOT NULL, - title TEXT NOT NULL, - "order" INT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT fk_toc_article FOREIGN KEY (article_id) REFERENCES knowledge_articles(uuid) ON DELETE CASCADE + uuid UUID PRIMARY KEY, + article_id UUID NOT NULL REFERENCES knowledge_articles(uuid), + title TEXT NOT NULL, + level INT NOT NULL, + "order" INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE related_articles (