From 72e9b4e1cdef3f5ad120d23b79041356efd09d33 Mon Sep 17 00:00:00 2001 From: Shaobiao Lin Date: Sun, 17 May 2026 23:09:23 +0800 Subject: [PATCH] feat(cli): add URL prefix fix command closes #861 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/command.go | 58 +++++- internal/cli/fix.go | 327 +++++++++++++++++++++++++++++++++ internal/cli/fix_avatar.go | 92 ++++++++++ internal/cli/fix_branding.go | 105 +++++++++++ internal/cli/fix_cache.go | 63 +++++++ internal/cli/fix_filerecord.go | 57 ++++++ internal/cli/fix_post.go | 188 +++++++++++++++++++ 7 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 internal/cli/fix.go create mode 100644 internal/cli/fix_avatar.go create mode 100644 internal/cli/fix_branding.go create mode 100644 internal/cli/fix_cache.go create mode 100644 internal/cli/fix_filerecord.go create mode 100644 internal/cli/fix_post.go diff --git a/cmd/command.go b/cmd/command.go index ca01aa80b..00470efea 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -59,6 +59,10 @@ var ( resetPasswordEmail string // resetPasswordPassword new password for password reset resetPasswordPassword string + // fixDryRun decides whether to run fix command in dry-run mode, it prints the affected rows but does not update the database + fixDryRun bool + // fixLimit limits the total number of rows fixed across all selected types. 0 means unlimited. + fixLimit int64 ) func init() { @@ -85,7 +89,10 @@ func init() { resetPasswordCmd.Flags().StringVarP(&resetPasswordEmail, "email", "e", "", "user email address") resetPasswordCmd.Flags().StringVarP(&resetPasswordPassword, "password", "p", "", "new password (not recommended, will be recorded in shell history)") - for _, cmd := range []*cobra.Command{initCmd, checkCmd, runCmd, dumpCmd, upgradeCmd, buildCmd, pluginCmd, configCmd, i18nCmd, resetPasswordCmd} { + fixCmd.Flags().BoolVar(&fixDryRun, "dry-run", false, "show what would be changed without modifying the database") + fixCmd.Flags().Int64Var(&fixLimit, "limit", 0, "stop after fixing NUM rows total across all selected fix types (0 = unlimited)") + + for _, cmd := range []*cobra.Command{initCmd, checkCmd, runCmd, dumpCmd, upgradeCmd, buildCmd, pluginCmd, configCmd, i18nCmd, resetPasswordCmd, fixCmd} { rootCmd.AddCommand(cmd) } } @@ -332,6 +339,55 @@ To run answer, use: } }, } + + fixCmd = &cobra.Command{ + Use: "fix [all|branding|avatar|post] SRC_PREFIX DST_PREFIX", + Short: "Fix stored URL prefixes in the database (stop Answer and back up data first)", + Long: `Fix stored URL prefixes for branding, avatars, and post content. + +WARNING: Stop Answer and back up your database before running this command. + +Rows already using DST_PREFIX are skipped so the command can be rerun safely. +If a text field contains both prefixes, it will be skipped and reported. + +After fixing branding data, the related site info cache will be invalidated.`, + Example: ` # Preview what would change + answer fix all /uploads/ /uploads/new/ --dry-run + + # Fix up to 50 rows + answer fix all /uploads/ /cdn/ --limit 50 + + # Fix only avatars + answer fix avatar /uploads/ /cdn/`, + Args: cobra.ExactArgs(3), + Run: func(_ *cobra.Command, args []string) { + path.FormatAllPath(dataDirPath) + c, err := conf.ReadConfig(path.GetConfigFilePath()) + if err != nil { + fmt.Fprintf(os.Stderr, "read config failed: %v\n", err) + os.Exit(1) + } + var cacheFilePath string + if c.Data.Cache != nil { + cacheFilePath = c.Data.Cache.FilePath + } + opts := &cli.FixURLPrefixOptions{ + FixType: args[0], + SrcPrefix: args[1], + DstPrefix: args[2], + DryRun: fixDryRun, + Limit: fixLimit, + CacheFilePath: cacheFilePath, + } + if fixDryRun { + fmt.Println("[dry-run] no changes will be written to the database") + } + if err := cli.FixURLPrefix(c.Data.Database, opts); err != nil { + fmt.Fprintf(os.Stderr, "fix failed: %v\n", err) + os.Exit(1) + } + }, + } ) // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/internal/cli/fix.go b/internal/cli/fix.go new file mode 100644 index 000000000..bc2a5e0c9 --- /dev/null +++ b/internal/cli/fix.go @@ -0,0 +1,327 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package cli + +import ( + "fmt" + "strings" + + "github.com/apache/answer/internal/base/data" +) + +const fixBatchSize = 100 + +const ( + fixTypeAll = "all" + fixTypeBranding = "branding" + fixTypeAvatar = "avatar" + fixTypePost = "post" +) + +// FixURLPrefixOptions holds options for the fix command. +type FixURLPrefixOptions struct { + // FixType is one of: all, branding, avatar, post + FixType string + SrcPrefix string + DstPrefix string + DryRun bool + // Limit is the max total number of rows fixed across all selected types. + // 0 means unlimited. + Limit int64 + // CacheFilePath is the path to the file-backed cache. + // If non-empty, affected cache entries are cleared after DB writes. + CacheFilePath string +} + +type fixResult struct { + Affected int64 + Fixed int64 + Skipped int64 +} + +func (r *fixResult) add(other fixResult) { + r.Affected += other.Affected + r.Fixed += other.Fixed + r.Skipped += other.Skipped +} + +func FixURLPrefix(dbConf *data.Database, opts *FixURLPrefixOptions) error { + if opts == nil { + return fmt.Errorf("fix options is nil") + } + if len(opts.SrcPrefix) == 0 { + return fmt.Errorf("SRC_PREFIX must not be empty") + } + if len(opts.DstPrefix) == 0 { + return fmt.Errorf("DST_PREFIX must not be empty") + } + if opts.SrcPrefix == opts.DstPrefix { + return fmt.Errorf("SRC_PREFIX and DST_PREFIX must be different") + } + + db, err := data.NewDB(false, dbConf) + if err != nil { + return fmt.Errorf("connect database failed: %w", err) + } + defer func() { + _ = db.Close() + }() + + fixTypes := resolveFixTypes(opts.FixType) + if len(fixTypes) == 0 { + return fmt.Errorf("unknown fix type %q, must be one of: %s, %s, %s, %s", opts.FixType, fixTypeAll, fixTypeBranding, fixTypeAvatar, fixTypePost) + } + + results := make(map[string]*fixResult, len(fixTypes)) + state := newFixRunState(opts.Limit) + + for _, fixType := range fixTypes { + if state.exhausted() { + break + } + + var result fixResult + switch fixType { + case fixTypeBranding: + result, err = fixBranding(db, opts, state) + case fixTypeAvatar: + result, err = fixAvatar(db, opts, state) + case fixTypePost: + result, err = fixPost(db, opts, state) + } + if err != nil { + return fmt.Errorf("fix %s failed: %w", fixType, err) + } + + results[fixType] = &result + } + + printFixSummary(fixTypes, results) + + if !opts.DryRun { + invalidateSiteInfoCache(opts.CacheFilePath, fixTypes) + } + return nil +} + +func resolveFixTypes(fixType string) []string { + switch fixType { + case fixTypeAll: + return []string{fixTypeBranding, fixTypeAvatar, fixTypePost} + case fixTypeBranding, fixTypeAvatar, fixTypePost: + return []string{fixType} + default: + return nil + } +} + +func printFixSummary(fixTypes []string, results map[string]*fixResult) { + fmt.Println() + fmt.Printf("%-12s %10s %10s %10s\n", "TYPE", "AFFECTED", "FIXED", "SKIPPED") + fmt.Println(strings.Repeat("-", 46)) + + var total fixResult + for _, fixType := range fixTypes { + result := results[fixType] + if result == nil { + continue + } + fmt.Printf("%-12s %10d %10d %10d\n", fixType, result.Affected, result.Fixed, result.Skipped) + total.add(*result) + } + + fmt.Println(strings.Repeat("-", 46)) + fmt.Printf("%-12s %10d %10d %10d\n", "TOTAL", total.Affected, total.Fixed, total.Skipped) +} + +// URL context markers used to anchor prefix matching/replacement to actual URL +// positions in text, preventing accidental rewrites of plain text content. +var ( + // textURLMarkers precede URLs in raw markdown [text](URL) and HTML attr="URL" + textURLMarkers = []string{"](", `="`} + // jsonURLMarkers precede URLs in JSON-encoded text (revision content stores + // the post entity as JSON, so HTML quotes are escaped as \") + jsonURLMarkers = []string{"](", `=\"`} +) + +// containsURLPrefix reports whether text contains marker+prefix for any marker. +func containsURLPrefix(text, prefix string, markers []string) bool { + for _, m := range markers { + if strings.Contains(text, m+prefix) { + return true + } + } + return false +} + +// prefixMatch classifies how stored data relates to the src/dst prefixes. +type prefixMatch int + +const ( + matchNone prefixMatch = iota // neither prefix present → nothing to do + matchFix // src prefix present → rewrite to dst + matchSkip // already on dst prefix → skip + matchBoth // both prefixes present → ambiguous, manual fix +) + +// replaceURLPrefix replaces marker+src with marker+dst for each marker. +func replaceURLPrefix(text, src, dst string, markers []string) string { + for _, m := range markers { + text = strings.ReplaceAll(text, m+src, m+dst) + } + return text +} + +// containsPrefixNotShadowed reports whether `prefix` occurs as a URL prefix +// that is not merely the head of the longer `shadow` prefix. Example: with +// prefix="/uploads/" and shadow="/uploads/new/", a URL "/uploads/new/x.png" +// does NOT count, but a standalone "/uploads/x.png" does. +func containsPrefixNotShadowed(text, prefix, shadow string, markers []string) bool { + if !containsURLPrefix(text, prefix, markers) { + return false + } + if len(shadow) > len(prefix) && strings.HasPrefix(shadow, prefix) { + scrubbed := text + for _, m := range markers { + scrubbed = strings.ReplaceAll(scrubbed, m+shadow, "") + } + return containsURLPrefix(scrubbed, prefix, markers) + } + return true +} + +func anyContainsPrefixNotShadowed(texts []string, prefix, shadow string, markers []string) bool { + for _, t := range texts { + if containsPrefixNotShadowed(t, prefix, shadow, markers) { + return true + } + } + return false +} + +// classifyValue classifies a stored value whose entire content is the URL, so +// prefixes are matched at the start. It never returns matchBoth. +func classifyValue(val, src, dst string) prefixMatch { + matchesSrc := strings.HasPrefix(val, src) + matchesDst := strings.HasPrefix(val, dst) + // When both match, prefer the longer prefix so a value already on dst is + // skipped even if dst and src share a common head. + if matchesDst && (!matchesSrc || len(dst) > len(src)) { + return matchSkip + } + if matchesSrc { + return matchFix + } + return matchNone +} + +// classifyText classifies free text that may embed URLs at marker positions. +func classifyText(texts []string, src, dst string, markers []string) prefixMatch { + hasSrc := anyContainsPrefixNotShadowed(texts, src, dst, markers) + hasDst := anyContainsPrefixNotShadowed(texts, dst, src, markers) + switch { + case hasSrc && hasDst: + return matchBoth + case hasDst: + return matchSkip + case hasSrc: + return matchFix + default: + return matchNone + } +} + +// fixRunState centralizes limit handling for all fix sub-routines. +type fixRunState struct { + limit int64 + remaining int64 +} + +func newFixRunState(limit int64) *fixRunState { + return &fixRunState{limit: limit, remaining: limit} +} + +func (s *fixRunState) exhausted() bool { + return s.limit > 0 && s.remaining <= 0 +} + +func (s *fixRunState) markAffected(result *fixResult) { + result.Affected++ + if s.limit > 0 { + s.remaining-- + } +} + +func applyValueFix(result *fixResult, opts *FixURLPrefixOptions, state *fixRunState, + label, target, current string, update func(newValue string) error, +) error { + switch classifyValue(current, opts.SrcPrefix, opts.DstPrefix) { + case matchSkip: + fmt.Printf("[%s] skip %s: already has DST_PREFIX: %s\n", label, target, current) + result.Skipped++ + return nil + case matchNone: + return nil + } + + newValue := opts.DstPrefix + strings.TrimPrefix(current, opts.SrcPrefix) + state.markAffected(result) + fmt.Printf("[%s] %s: %s -> %s\n", label, target, current, newValue) + + if opts.DryRun { + return nil + } + if err := update(newValue); err != nil { + return err + } + result.Fixed++ + return nil +} + +func runBatchedFix[T any](state *fixRunState, fetch func(offset int) ([]T, error), process func(item T) error) error { + offset := 0 + for { + if state.exhausted() { + return nil + } + + items, err := fetch(offset) + if err != nil { + return err + } + if len(items) == 0 { + return nil + } + + for _, item := range items { + if state.exhausted() { + return nil + } + if err := process(item); err != nil { + return err + } + } + + if len(items) < fixBatchSize { + return nil + } + offset += fixBatchSize + } +} diff --git a/internal/cli/fix_avatar.go b/internal/cli/fix_avatar.go new file mode 100644 index 000000000..74de49dc2 --- /dev/null +++ b/internal/cli/fix_avatar.go @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/plugin" + "xorm.io/xorm" +) + +// avatarData is the JSON structure stored in the user.avatar column. +type avatarData struct { + Type string `json:"type"` + Gravatar string `json:"gravatar,omitempty"` + Custom string `json:"custom,omitempty"` +} + +func fixAvatar(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState) (fixResult, error) { + result := fixResult{} + + err := runBatchedFix(state, + func(offset int) ([]*entity.User, error) { return queryCustomAvatarUsers(x, offset) }, + func(user *entity.User) error { return processAvatarUser(x, &result, opts, state, user) }) + if err != nil { + return fixResult{}, err + } + + frResult, err := fixFileRecordBySources(x, opts, state, + []string{string(plugin.UserAvatar)}, fixTypeAvatar) + if err != nil { + return fixResult{}, err + } + result.add(frResult) + + return result, nil +} + +func queryCustomAvatarUsers(x *xorm.Engine, offset int) ([]*entity.User, error) { + users := make([]*entity.User, 0) + err := x.Select("id, avatar"). + Where("avatar LIKE ?", `%"custom":"%`). + OrderBy("id"). + Limit(fixBatchSize, offset). + Find(&users) + if err != nil { + return nil, fmt.Errorf("query user failed: %w", err) + } + return users, nil +} + +func processAvatarUser(x *xorm.Engine, result *fixResult, opts *FixURLPrefixOptions, state *fixRunState, user *entity.User) error { + avatar := &avatarData{} + if err := json.Unmarshal([]byte(user.Avatar), avatar); err != nil { + return nil + } + if avatar.Type != constant.AvatarTypeCustom || len(avatar.Custom) == 0 { + return nil + } + return applyValueFix(result, opts, state, fixTypeAvatar, fmt.Sprintf("user %s", user.ID), avatar.Custom, + func(newCustom string) error { + avatar.Custom = newCustom + newJSON, err := json.Marshal(avatar) + if err != nil { + return fmt.Errorf("marshal avatar JSON (user=%s) failed: %w", user.ID, err) + } + if _, err := x.ID(user.ID).Cols("avatar").NoAutoTime().Update(&entity.User{Avatar: string(newJSON)}); err != nil { + return fmt.Errorf("update user avatar (id=%s) failed: %w", user.ID, err) + } + return nil + }) +} diff --git a/internal/cli/fix_branding.go b/internal/cli/fix_branding.go new file mode 100644 index 000000000..937fdc836 --- /dev/null +++ b/internal/cli/fix_branding.go @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/plugin" + "xorm.io/xorm" +) + +func fixBranding(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState) (fixResult, error) { + siteInfo := &entity.SiteInfo{Type: constant.SiteTypeBranding} + exist, err := x.Get(siteInfo) + if err != nil { + return fixResult{}, fmt.Errorf("get site info failed: %w", err) + } + if !exist { + fmt.Printf("[%s] no branding row found, skipping\n", fixTypeBranding) + return fixResult{}, nil + } + + type brandingContent struct { + Logo string `json:"logo"` + MobileLogo string `json:"mobile_logo"` + SquareIcon string `json:"square_icon"` + Favicon string `json:"favicon"` + } + + content := &brandingContent{} + if err = json.Unmarshal([]byte(siteInfo.Content), content); err != nil { + return fixResult{}, fmt.Errorf("parse branding content failed: %w", err) + } + + type brandingField struct { + name string + value *string + } + + fields := []brandingField{ + {name: "logo", value: &content.Logo}, + {name: "mobile_logo", value: &content.MobileLogo}, + {name: "square_icon", value: &content.SquareIcon}, + {name: "favicon", value: &content.Favicon}, + } + + result := fixResult{} + changed := false + + for _, field := range fields { + if state.exhausted() { + break + } + if len(*field.value) == 0 { + continue + } + if err := applyValueFix(&result, opts, state, fixTypeBranding, field.name, *field.value, func(newValue string) error { + *field.value = newValue + changed = true + return nil + }); err != nil { + return fixResult{}, err + } + } + + if changed { + newContent, err := json.Marshal(content) + if err != nil { + return fixResult{}, fmt.Errorf("marshal branding content failed: %w", err) + } + _, err = x.ID(siteInfo.ID).Cols("content").NoAutoTime().Update(&entity.SiteInfo{Content: string(newContent)}) + if err != nil { + return fixResult{}, fmt.Errorf("update site info failed: %w", err) + } + } + + frResult, err := fixFileRecordBySources(x, opts, state, + []string{string(plugin.AdminBranding)}, fixTypeBranding) + if err != nil { + return fixResult{}, err + } + result.add(frResult) + + return result, nil +} diff --git a/internal/cli/fix_cache.go b/internal/cli/fix_cache.go new file mode 100644 index 000000000..d0a646ca7 --- /dev/null +++ b/internal/cli/fix_cache.go @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package cli + +import ( + "context" + "fmt" + "slices" + + "github.com/apache/answer/internal/base/constant" + "github.com/segmentfault/pacman/contrib/cache/memory" +) + +func invalidateSiteInfoCache(cacheFilePath string, fixTypes []string) { + if !slices.Contains(fixTypes, fixTypeBranding) { + return + } + + // The branding site-info cache only lives in the file-backed memory cache. + // When a cache plugin (e.g. Redis) is used, no file path is configured, so + // warn the admin to flush it manually instead of silently doing nothing. + if len(cacheFilePath) == 0 { + fmt.Println("[cache] no file cache configured; flush your cache backend or restart the application to refresh branding") + return + } + + memCache := memory.NewCache() + if err := memory.Load(memCache, cacheFilePath); err != nil { + fmt.Printf("[cache] cannot load cache file %s: %v (skipping)\n", cacheFilePath, err) + return + } + + key := constant.SiteInfoCacheKey + constant.SiteTypeBranding + if err := memCache.Del(context.Background(), key); err != nil { + fmt.Printf("[cache] failed to delete key %s: %v\n", key, err) + } else { + fmt.Printf("[cache] invalidated %s\n", key) + } + + if err := memory.Save(memCache, cacheFilePath); err != nil { + fmt.Printf("[cache] failed to save cache file: %v\n", err) + return + } + + fmt.Println("[cache] cache file updated, restart the application to take full effect") +} diff --git a/internal/cli/fix_filerecord.go b/internal/cli/fix_filerecord.go new file mode 100644 index 000000000..439f81fe4 --- /dev/null +++ b/internal/cli/fix_filerecord.go @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package cli + +import ( + "fmt" + + "github.com/apache/answer/internal/entity" + "xorm.io/xorm" +) + +func fixFileRecordBySources(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState, sources []string, label string) (fixResult, error) { + result := fixResult{} + + err := runBatchedFix(state, func(offset int) ([]*entity.FileRecord, error) { + records := make([]*entity.FileRecord, 0) + err := x.Select("id, file_url"). + In("source", sources). + OrderBy("id"). + Limit(fixBatchSize, offset). + Find(&records) + if err != nil { + return nil, fmt.Errorf("query %s file_record failed: %w", label, err) + } + return records, nil + }, func(record *entity.FileRecord) error { + return applyValueFix(&result, opts, state, label, fmt.Sprintf("file_record id=%d", record.ID), record.FileURL, func(newURL string) error { + _, err := x.ID(record.ID).Cols("file_url").NoAutoTime().Update(&entity.FileRecord{FileURL: newURL}) + if err != nil { + return fmt.Errorf("update %s file_record (id=%d) failed: %w", label, record.ID, err) + } + return nil + }) + }) + if err != nil { + return fixResult{}, err + } + + return result, nil +} diff --git a/internal/cli/fix_post.go b/internal/cli/fix_post.go new file mode 100644 index 000000000..c743be462 --- /dev/null +++ b/internal/cli/fix_post.go @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package cli + +import ( + "fmt" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/plugin" + "xorm.io/xorm" +) + +func fixPost(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState) (fixResult, error) { + result := fixResult{} + + // Fix question and answer body text. + for _, tableName := range []string{"question", "answer"} { + r, err := fixPostTable(x, opts, state, tableName) + if err != nil { + return fixResult{}, err + } + result.add(r) + } + + // Fix revisions for questions and answers. + for _, objectType := range []int{ + constant.ObjectTypeStrMapping[constant.QuestionObjectType], + constant.ObjectTypeStrMapping[constant.AnswerObjectType], + } { + r, err := fixRevisionByObjectType(x, opts, state, objectType, fixTypePost) + if err != nil { + return fixResult{}, err + } + result.add(r) + } + + // Fix file records. + fileResult, err := fixFileRecordBySources(x, opts, state, + []string{string(plugin.UserPost), string(plugin.UserPostAttachment)}, fixTypePost) + if err != nil { + return fixResult{}, err + } + result.add(fileResult) + + return result, nil +} + +// postRow represents a question or answer row with its text fields. +type postRow struct { + ID string `xorm:"id"` + OriginalText string `xorm:"original_text"` + ParsedText string `xorm:"parsed_text"` +} + +func fixPostTable(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState, tableName string) (fixResult, error) { + result := fixResult{} + err := runBatchedFix(state, + func(offset int) ([]*postRow, error) { return queryPostRows(x, tableName, offset) }, + func(row *postRow) error { + return applyTextFix(&result, opts, state, fixTypePost, fmt.Sprintf("%s id=%s", tableName, row.ID), + []string{row.OriginalText, row.ParsedText}, textURLMarkers, + func() error { return updatePostText(x, tableName, row, opts) }) + }) + if err != nil { + return fixResult{}, err + } + return result, nil +} + +func queryPostRows(x *xorm.Engine, tableName string, offset int) ([]*postRow, error) { + rows := make([]*postRow, 0) + err := x.Table(tableName).Select("id, original_text, parsed_text"). + OrderBy("id"). + Limit(fixBatchSize, offset). + Find(&rows) + if err != nil { + return nil, fmt.Errorf("query %s failed: %w", tableName, err) + } + return rows, nil +} + +func updatePostText(x *xorm.Engine, tableName string, row *postRow, opts *FixURLPrefixOptions) error { + origText := replaceURLPrefix(row.OriginalText, opts.SrcPrefix, opts.DstPrefix, textURLMarkers) + parsedText := replaceURLPrefix(row.ParsedText, opts.SrcPrefix, opts.DstPrefix, textURLMarkers) + + var bean interface{} + switch tableName { + case "question": + bean = &entity.Question{OriginalText: origText, ParsedText: parsedText} + case "answer": + bean = &entity.Answer{OriginalText: origText, ParsedText: parsedText} + default: + return fmt.Errorf("unknown post table %q", tableName) + } + + if _, err := x.ID(row.ID).Cols("original_text", "parsed_text").NoAutoTime().Update(bean); err != nil { + return fmt.Errorf("update %s (id=%s) failed: %w", tableName, row.ID, err) + } + return nil +} + +func fixRevisionByObjectType(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState, objectType int, label string) (fixResult, error) { + result := fixResult{} + err := runBatchedFix(state, + func(offset int) ([]*entity.Revision, error) { + return queryRevisionsByObjectType(x, objectType, label, offset) + }, + func(revision *entity.Revision) error { + return applyTextFix(&result, opts, state, label, fmt.Sprintf("revision id=%s", revision.ID), + []string{revision.Content}, jsonURLMarkers, + func() error { return updateRevisionContent(x, revision, opts, label) }) + }) + if err != nil { + return fixResult{}, err + } + return result, nil +} + +func queryRevisionsByObjectType(x *xorm.Engine, objectType int, label string, offset int) ([]*entity.Revision, error) { + revisions := make([]*entity.Revision, 0) + err := x.Select("id, content"). + Where("object_type = ?", objectType). + OrderBy("id"). + Limit(fixBatchSize, offset). + Find(&revisions) + if err != nil { + return nil, fmt.Errorf("query %s revision failed: %w", label, err) + } + return revisions, nil +} + +func updateRevisionContent(x *xorm.Engine, revision *entity.Revision, opts *FixURLPrefixOptions, label string) error { + updated := &entity.Revision{ + Content: replaceURLPrefix(revision.Content, opts.SrcPrefix, opts.DstPrefix, jsonURLMarkers), + } + _, err := x.ID(revision.ID).Cols("content").NoAutoTime().Update(updated) + if err != nil { + return fmt.Errorf("update %s revision (id=%s) failed: %w", label, revision.ID, err) + } + return nil +} + +func applyTextFix(result *fixResult, opts *FixURLPrefixOptions, state *fixRunState, + label, target string, texts []string, markers []string, update func() error, +) error { + switch classifyText(texts, opts.SrcPrefix, opts.DstPrefix, markers) { + case matchBoth: + fmt.Printf("[%s] WARN %s: has BOTH prefixes, skipping (manual fix needed)\n", label, target) + result.Skipped++ + return nil + case matchSkip: + fmt.Printf("[%s] skip %s: already has DST_PREFIX\n", label, target) + result.Skipped++ + return nil + case matchNone: + return nil + } + + state.markAffected(result) + fmt.Printf("[%s] %s: replacing %q with %q\n", label, target, opts.SrcPrefix, opts.DstPrefix) + + if opts.DryRun { + return nil + } + if err := update(); err != nil { + return err + } + result.Fixed++ + return nil +}