blob: 8396c839bfe555dbc4f7a487d5bdd45d00929b45 [file]
/*
* 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"
)
// postTable is a table with original_text/parsed_text; objectType 0 means its
// rows have no revisions.
type postTable struct {
name string
objectType int
}
// postTables lists the tables that may link uploads. Comments have no upload
// button but keep pasted image URLs.
func postTables() []postTable {
return []postTable{
{"question", constant.ObjectTypeStrMapping[constant.QuestionObjectType]},
{"answer", constant.ObjectTypeStrMapping[constant.AnswerObjectType]},
{"tag", constant.ObjectTypeStrMapping[constant.TagObjectType]},
{"comment", 0},
}
}
// fixPost fixes each post with its revisions as one unit, so --limit never
// leaves old revisions that approving a pending edit would write back. It
// returns the units, counted by --limit, and the post file records, not counted.
func fixPost(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState) (units, files fixResult, err error) {
for _, t := range postTables() {
r, err := fixPostTable(x, opts, state, t)
if err != nil {
return fixResult{}, fixResult{}, err
}
units.add(r)
if r, err = fixOrphanRevisions(x, opts, state, t); err != nil {
return fixResult{}, fixResult{}, err
}
units.add(r)
}
// Must run after revisions, which it checks.
files, err = fixFileRecordBySources(x, opts,
[]string{string(plugin.UserPost), string(plugin.UserPostAttachment)}, fixTypePost)
if err != nil {
return fixResult{}, fixResult{}, err
}
return units, files, nil
}
type postRow struct {
ID string `xorm:"id"`
OriginalText string `xorm:"original_text"`
ParsedText string `xorm:"parsed_text"`
}
type postUnit struct {
row *postRow
revisions []*entity.Revision
}
func fixPostTable(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState, t postTable) (fixResult, error) {
result := fixResult{}
err := runBatchedFix(state,
func(afterID int64) ([]*postUnit, int64, error) { return queryPostUnits(x, t, afterID) },
func(u *postUnit) error {
items := []textFix{{
target: fmt.Sprintf("%s id=%s", t.name, u.row.ID),
texts: []string{u.row.OriginalText, u.row.ParsedText},
markers: textURLMarkers,
update: func(db xorm.Interface) error {
_, err := db.Table(t.name).Where("id = ?", u.row.ID).Cols("original_text", "parsed_text").
Update(&postRow{
OriginalText: fixTextField(u.row.OriginalText, opts, textURLMarkers),
ParsedText: fixTextField(u.row.ParsedText, opts, textURLMarkers),
})
if err != nil {
return fmt.Errorf("update %s (id=%s) failed: %w", t.name, u.row.ID, err)
}
return nil
},
}}
items = append(items, revisionFixes(opts, u.revisions)...)
return applyUnitFix(x, &result, opts, state, items)
})
if err != nil {
return fixResult{}, err
}
return result, nil
}
// queryPostUnits loads a page of posts and, in one more query, their revisions.
func queryPostUnits(x *xorm.Engine, t postTable, afterID int64) ([]*postUnit, int64, error) {
rows := make([]*postRow, 0)
err := x.Table(t.name).Select("id, original_text, parsed_text").
Where("id > ?", afterID).
OrderBy("id").
Limit(fixBatchSize).
Find(&rows)
if err != nil {
return nil, 0, fmt.Errorf("query %s failed: %w", t.name, err)
}
lastID, err := lastStringID(rows, func(r *postRow) string { return r.ID })
if err != nil || len(rows) == 0 {
return nil, lastID, err
}
units := make([]*postUnit, len(rows))
byID := make(map[string]*postUnit, len(rows))
ids := make([]string, len(rows))
for i, row := range rows {
units[i] = &postUnit{row: row}
byID[row.ID] = units[i]
ids[i] = row.ID
}
if t.objectType == 0 {
return units, lastID, nil
}
revisions := make([]*entity.Revision, 0)
err = x.Select("id, object_id, content").
Where("object_type = ?", t.objectType).
In("object_id", ids).
OrderBy("id").
Find(&revisions)
if err != nil {
return nil, 0, fmt.Errorf("query %s revision failed: %w", t.name, err)
}
for _, rev := range revisions {
if u := byID[rev.ObjectID]; u != nil {
u.revisions = append(u.revisions, rev)
}
}
return units, lastID, nil
}
// fixOrphanRevisions fixes revisions whose post row is gone, one unit each.
func fixOrphanRevisions(x *xorm.Engine, opts *FixURLPrefixOptions, state *fixRunState, t postTable) (fixResult, error) {
result := fixResult{}
if t.objectType == 0 {
return result, nil
}
err := runBatchedFix(state,
func(afterID int64) ([]*entity.Revision, int64, error) {
revisions := make([]*entity.Revision, 0)
err := x.Select("id, content").
Where("id > ?", afterID).
And("object_type = ?", t.objectType).
And(fmt.Sprintf("object_id NOT IN (SELECT id FROM %s)", t.name)).
OrderBy("id").
Limit(fixBatchSize).
Find(&revisions)
if err != nil {
return nil, 0, fmt.Errorf("query orphan %s revision failed: %w", t.name, err)
}
lastID, err := lastStringID(revisions, func(r *entity.Revision) string { return r.ID })
return revisions, lastID, err
},
func(rev *entity.Revision) error {
return applyUnitFix(x, &result, opts, state, revisionFixes(opts, []*entity.Revision{rev}))
})
if err != nil {
return fixResult{}, err
}
return result, nil
}
// fixTextField leaves fields not classified as fixable alone, so a field on a
// DST_PREFIX that starts with SRC_PREFIX is not nested (e.g. /uploads/cdn/cdn/).
func fixTextField(text string, opts *FixURLPrefixOptions, markers []string) string {
if classifyText(text, opts.SrcPrefix, opts.DstPrefix, markers) != matchFix {
return text
}
return replaceURLPrefix(text, opts.SrcPrefix, opts.DstPrefix, markers)
}
func revisionFixes(opts *FixURLPrefixOptions, revisions []*entity.Revision) []textFix {
items := make([]textFix, 0, len(revisions))
for _, rev := range revisions {
items = append(items, textFix{
target: fmt.Sprintf("revision id=%s", rev.ID),
texts: []string{rev.Content},
markers: jsonURLMarkers,
update: func(db xorm.Interface) error {
content := replaceURLPrefix(rev.Content, opts.SrcPrefix, opts.DstPrefix, jsonURLMarkers)
if _, err := db.ID(rev.ID).Cols("content").NoAutoTime().Update(&entity.Revision{Content: content}); err != nil {
return fmt.Errorf("update revision (id=%s) failed: %w", rev.ID, err)
}
return nil
},
})
}
return items
}
// textFix is one record of a unit: its text fields and how to write them back.
type textFix struct {
target string
texts []string
markers []string
update func(db xorm.Interface) error
}
// applyUnitFix writes a unit's fixable records in one transaction and counts
// the unit once: affected if any record is fixable, skipped if a record is
// ambiguous (left for manual fix; the rest is still fixed) or none is fixable
// but some are already on DST_PREFIX.
func applyUnitFix(x *xorm.Engine, result *fixResult, opts *FixURLPrefixOptions, state *fixRunState, items []textFix) error {
var fixable []textFix
ambiguous, onDst := false, false
for _, item := range items {
switch classifyFields(item.texts, opts.SrcPrefix, opts.DstPrefix, item.markers) {
case matchBoth:
fmt.Printf("[%s] WARN %s: a field has BOTH prefixes and DST_PREFIX starts with SRC_PREFIX, skipping (manual fix needed)\n", fixTypePost, item.target)
ambiguous = true
case matchSkip:
fmt.Printf("[%s] skip %s: already has DST_PREFIX\n", fixTypePost, item.target)
onDst = true
case matchFix:
fixable = append(fixable, item)
}
}
if ambiguous || (len(fixable) == 0 && onDst) {
result.Skipped++
}
if len(fixable) == 0 {
return nil
}
state.markAffected(result)
for _, item := range fixable {
fmt.Printf("[%s] %s: replacing %q with %q\n", fixTypePost, item.target, opts.SrcPrefix, opts.DstPrefix)
}
if opts.DryRun {
return nil
}
_, err := x.Transaction(func(session *xorm.Session) (any, error) {
for _, item := range fixable {
if err := item.update(session); err != nil {
return nil, err
}
}
return nil, nil
})
if err != nil {
return err
}
result.Fixed++
return nil
}