feat: implement article version content storage and reconstruction logic; enhance article repository with version management

This commit is contained in:
2026-04-07 22:21:16 +08:00
parent d0f0271346
commit 41f8e0621e
9 changed files with 324 additions and 48 deletions
+2 -1
View File
@@ -62,6 +62,7 @@ require (
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
@@ -84,6 +85,6 @@ require (
google.golang.org/grpc v1.66.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+6
View File
@@ -103,6 +103,7 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -155,6 +156,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@@ -169,6 +172,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -277,6 +281,8 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+40 -24
View File
@@ -21,6 +21,7 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type ArticleService struct {
@@ -161,7 +162,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
// List
listQuery := `SELECT a.id, a.source_type, a.template_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, av.markdown_content, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
LEFT JOIN article_templates t ON t.id = a.template_id
@@ -236,17 +237,15 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
for rows.Next() {
var item ArticleListItem
var dbSourceType string
var markdownContent *string
var wizardStateJSON []byte
var inputParamsJSON []byte
if err := rows.Scan(&item.ID, &dbSourceType, &item.TemplateID, &item.PromptRuleID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
&item.Title, &item.GenerationErrorMessage, &markdownContent, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil {
&item.Title, &item.GenerationErrorMessage, &item.WordCount, &item.SourceLabel, &item.TemplateName, &item.PromptRuleName, &wizardStateJSON, &inputParamsJSON); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
item.SourceType = dbSourceType
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
item.WordCount = resolveWordCount(markdownContent, item.WordCount)
items = append(items, item)
}
if items == nil {
@@ -281,12 +280,13 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
actor := auth.MustActor(ctx)
var d ArticleDetailResponse
var dbSourceType string
var currentVersionID *int64
var wizardStateJSON []byte
var inputParamsJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT a.id, a.source_type, a.template_id, a.generate_status, a.publish_status, a.created_at,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), av.html_content, av.markdown_content, COALESCE(av.word_count, 0), av.source_label, av.version_no,
SELECT a.id, a.source_type, a.template_id, a.current_version_id, a.generate_status, a.publish_status, a.created_at,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), COALESCE(av.word_count, 0), av.source_label, av.version_no,
t.template_name, gt.error_message, a.wizard_state_json, gt.input_params_json
FROM articles a
LEFT JOIN article_versions av ON av.id = a.current_version_id
@@ -299,12 +299,20 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
LIMIT 1
) gt ON true
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
`, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
&d.Title, &d.HTMLContent, &d.MarkdownContent, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
`, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, &currentVersionID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
&d.Title, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
if err != nil {
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
}
d.SourceType = dbSourceType
if currentVersionID != nil {
content, err := repository.LoadArticleVersionContent(ctx, s.pool, d.ID, *currentVersionID)
if err != nil {
return nil, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
}
d.HTMLContent = content.HTMLContent
d.MarkdownContent = content.MarkdownContent
}
if len(wizardStateJSON) > 0 {
d.WizardState = map[string]interface{}{}
_ = json.Unmarshal(wizardStateJSON, &d.WizardState)
@@ -341,12 +349,17 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
}
sourceLabel := "自由创作"
var versionID int64
if err := tx.QueryRow(ctx, `
INSERT INTO article_versions (article_id, version_no, title, html_content, markdown_content, word_count, source_label)
VALUES ($1, 1, $2, NULL, '', 0, $3)
RETURNING id
`, articleID, title, sourceLabel).Scan(&versionID); err != nil {
articleRepo := repository.NewArticleRepository(tx)
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
ArticleID: articleID,
VersionNo: 1,
Title: title,
HTMLContent: "",
MarkdownContent: "",
WordCount: 0,
SourceLabel: sourceLabel,
})
if err != nil {
return nil, response.ErrInternal(50014, "create_failed", "failed to create article version")
}
@@ -426,12 +439,17 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
}
var versionID int64
if err := tx.QueryRow(ctx, `
INSERT INTO article_versions (article_id, version_no, title, html_content, markdown_content, word_count, source_label)
VALUES ($1, $2, $3, NULL, $4, $5, $6)
RETURNING id
`, id, nextVersionNo, title, markdown, wordCount, sourceLabel).Scan(&versionID); err != nil {
articleRepo := repository.NewArticleRepository(tx)
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
ArticleID: id,
VersionNo: nextVersionNo,
Title: title,
HTMLContent: "",
MarkdownContent: markdown,
WordCount: wordCount,
SourceLabel: sourceLabel,
})
if err != nil {
return nil, response.ErrInternal(50012, "update_failed", "failed to create article version")
}
@@ -527,7 +545,7 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
}
rows, err := s.pool.Query(ctx, `
SELECT id, version_no, title, markdown_content, word_count, source_label, created_at
SELECT id, version_no, title, word_count, source_label, created_at
FROM article_versions WHERE article_id = $1 ORDER BY version_no DESC
`, articleID)
if err != nil {
@@ -538,11 +556,9 @@ func (s *ArticleService) Versions(ctx context.Context, articleID int64) ([]Versi
var versions []VersionItem
for rows.Next() {
var v VersionItem
var markdownContent *string
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &markdownContent, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil {
if err := rows.Scan(&v.ID, &v.VersionNo, &v.Title, &v.WordCount, &v.SourceLabel, &v.CreatedAt); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
v.WordCount = resolveWordCount(markdownContent, v.WordCount)
versions = append(versions, v)
}
if versions == nil {
@@ -30,11 +30,12 @@ type ArticleRepository interface {
}
type articleRepository struct {
q generated.Querier
db generated.DBTX
q generated.Querier
}
func NewArticleRepository(db generated.DBTX) ArticleRepository {
return &articleRepository{q: newQuerier(db)}
return &articleRepository{db: db, q: newQuerier(db)}
}
func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArticleInput) (int64, error) {
@@ -46,15 +47,42 @@ func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArtic
}
func (r *articleRepository) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) {
return r.q.CreateArticleVersion(ctx, generated.CreateArticleVersionParams{
ArticleID: input.ArticleID,
VersionNo: int32(input.VersionNo),
Title: pgText(&input.Title),
HtmlContent: pgText(&input.HTMLContent),
MarkdownContent: pgText(&input.MarkdownContent),
WordCount: int32(input.WordCount),
SourceLabel: pgText(&input.SourceLabel),
previous, err := loadLatestArticleVersionContent(ctx, r.db, input.ArticleID)
if err != nil {
return 0, err
}
storedHTML, storedMarkdown := buildArticleVersionDiffStorage(*previous, ArticleVersionContent{
HTMLContent: stringPtr(input.HTMLContent),
MarkdownContent: stringPtr(input.MarkdownContent),
})
var versionID int64
err = r.db.QueryRow(ctx, `
INSERT INTO article_versions (
article_id,
version_no,
title,
html_content,
markdown_content,
word_count,
source_label
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
input.ArticleID,
input.VersionNo,
pgText(&input.Title),
pgText(storedHTML),
pgText(storedMarkdown),
input.WordCount,
pgText(&input.SourceLabel),
).Scan(&versionID)
if err != nil {
return 0, err
}
return versionID, nil
}
func (r *articleRepository) UpdateArticleCurrentVersion(ctx context.Context, articleID, tenantID, versionID int64) error {
@@ -0,0 +1,163 @@
package repository
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type ArticleVersionContent struct {
HTMLContent *string
MarkdownContent *string
}
type storedArticleVersionContent struct {
VersionNo int
HTMLContent *string
MarkdownContent *string
}
func LoadArticleVersionContent(
ctx context.Context,
db generated.DBTX,
articleID int64,
versionID int64,
) (*ArticleVersionContent, error) {
rows, err := db.Query(ctx, `
SELECT version_no, html_content, markdown_content
FROM article_versions
WHERE article_id = $1
AND version_no <= (
SELECT version_no
FROM article_versions
WHERE id = $2 AND article_id = $1
)
ORDER BY version_no ASC
`, articleID, versionID)
if err != nil {
return nil, err
}
defer rows.Close()
versions, err := collectStoredArticleVersionContents(rows)
if err != nil {
return nil, err
}
if len(versions) == 0 {
return nil, pgx.ErrNoRows
}
return reconstructStoredArticleVersionContents(versions)
}
func loadLatestArticleVersionContent(
ctx context.Context,
db generated.DBTX,
articleID int64,
) (*ArticleVersionContent, error) {
rows, err := db.Query(ctx, `
SELECT version_no, html_content, markdown_content
FROM article_versions
WHERE article_id = $1
ORDER BY version_no ASC
`, articleID)
if err != nil {
return nil, err
}
defer rows.Close()
versions, err := collectStoredArticleVersionContents(rows)
if err != nil {
return nil, err
}
if len(versions) == 0 {
return &ArticleVersionContent{}, nil
}
return reconstructStoredArticleVersionContents(versions)
}
func collectStoredArticleVersionContents(rows pgx.Rows) ([]storedArticleVersionContent, error) {
versions := make([]storedArticleVersionContent, 0)
for rows.Next() {
var version storedArticleVersionContent
if err := rows.Scan(&version.VersionNo, &version.HTMLContent, &version.MarkdownContent); err != nil {
return nil, err
}
versions = append(versions, version)
}
if err := rows.Err(); err != nil {
return nil, err
}
return versions, nil
}
func buildArticleVersionDiffStorage(previous, current ArticleVersionContent) (*string, *string) {
return stringPtr(articleVersionPatchText(textValue(previous.HTMLContent), textValue(current.HTMLContent))),
stringPtr(articleVersionPatchText(textValue(previous.MarkdownContent), textValue(current.MarkdownContent)))
}
func reconstructStoredArticleVersionContents(versions []storedArticleVersionContent) (*ArticleVersionContent, error) {
var html string
var markdown string
for _, version := range versions {
nextHTML, err := applyArticleVersionPatch(html, version.HTMLContent)
if err != nil {
return nil, fmt.Errorf("apply html patch for version %d: %w", version.VersionNo, err)
}
nextMarkdown, err := applyArticleVersionPatch(markdown, version.MarkdownContent)
if err != nil {
return nil, fmt.Errorf("apply markdown patch for version %d: %w", version.VersionNo, err)
}
html = nextHTML
markdown = nextMarkdown
}
result := &ArticleVersionContent{}
result.HTMLContent = stringPtr(html)
result.MarkdownContent = stringPtr(markdown)
return result, nil
}
func articleVersionPatchText(previous, current string) string {
dmp := diffmatchpatch.New()
patches := dmp.PatchMake(previous, current)
return dmp.PatchToText(patches)
}
func applyArticleVersionPatch(base string, patchText *string) (string, error) {
if patchText == nil || *patchText == "" {
return base, nil
}
dmp := diffmatchpatch.New()
patches, err := dmp.PatchFromText(*patchText)
if err != nil {
return "", err
}
next, applied := dmp.PatchApply(patches, base)
for _, ok := range applied {
if !ok {
return "", fmt.Errorf("patch apply failed")
}
}
return next, nil
}
func textValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func stringPtr(value string) *string {
copied := value
return &copied
}
@@ -0,0 +1,67 @@
package repository
import "testing"
func TestBuildArticleVersionDiffStorage_ReconstructsFromEmptyBase(t *testing.T) {
current := ArticleVersionContent{
HTMLContent: stringPtr(""),
MarkdownContent: stringPtr("# First version\n\nHello world"),
}
storedHTML, storedMarkdown := buildArticleVersionDiffStorage(ArticleVersionContent{}, current)
reconstructed, err := reconstructStoredArticleVersionContents([]storedArticleVersionContent{
{
VersionNo: 1,
HTMLContent: storedHTML,
MarkdownContent: storedMarkdown,
},
})
if err != nil {
t.Fatalf("reconstruct stored content: %v", err)
}
if textValue(reconstructed.HTMLContent) != textValue(current.HTMLContent) {
t.Fatalf("unexpected html content: %q", textValue(reconstructed.HTMLContent))
}
if textValue(reconstructed.MarkdownContent) != textValue(current.MarkdownContent) {
t.Fatalf("unexpected markdown content: %q", textValue(reconstructed.MarkdownContent))
}
}
func TestReconstructStoredArticleVersionContents_DiffChain(t *testing.T) {
_, markdownV1 := buildArticleVersionDiffStorage(
ArticleVersionContent{},
ArticleVersionContent{MarkdownContent: stringPtr("alpha")},
)
_, markdownV2 := buildArticleVersionDiffStorage(
ArticleVersionContent{MarkdownContent: stringPtr("alpha")},
ArticleVersionContent{MarkdownContent: stringPtr("alpha beta")},
)
_, markdownV3 := buildArticleVersionDiffStorage(
ArticleVersionContent{MarkdownContent: stringPtr("alpha beta")},
ArticleVersionContent{MarkdownContent: stringPtr("alpha beta gamma")},
)
reconstructed, err := reconstructStoredArticleVersionContents([]storedArticleVersionContent{
{
VersionNo: 1,
MarkdownContent: markdownV1,
},
{
VersionNo: 2,
MarkdownContent: markdownV2,
},
{
VersionNo: 3,
MarkdownContent: markdownV3,
},
})
if err != nil {
t.Fatalf("reconstruct stored content: %v", err)
}
if got := textValue(reconstructed.MarkdownContent); got != "alpha beta gamma" {
t.Fatalf("unexpected markdown content: %q", got)
}
}
@@ -4,9 +4,6 @@ import (
"context"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
@@ -76,7 +73,7 @@ func (r *workspaceRepository) GetRecentArticles(ctx context.Context, tenantID in
GenerationMode: nullableText(row.GenerationMode),
CreatedAt: timeFromTimestamp(row.CreatedAt),
Title: nullableText(row.Title),
WordCount: resolveWorkspaceWordCount(row.MarkdownContent, intFromInt4(row.WordCount)),
WordCount: intFromInt4(row.WordCount),
SourceLabel: nullableText(row.SourceLabel),
TemplateName: nullableText(row.TemplateName),
})
@@ -107,12 +104,3 @@ func (r *workspaceRepository) GetActivePlanForTenant(ctx context.Context, tenant
EndAt: timeFromTimestamp(row.EndAt),
}, nil
}
func resolveWorkspaceWordCount(markdown pgtype.Text, stored int) int {
if markdown.Valid {
if counted := contentstats.CountWords(markdown.String); counted > 0 {
return counted
}
}
return stored
}
@@ -0,0 +1,6 @@
UPDATE articles
SET current_version_id = NULL,
updated_at = NOW()
WHERE current_version_id IS NOT NULL;
DELETE FROM article_versions;