服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
530 lines
19 KiB
Go
530 lines
19 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf16"
|
|
|
|
annotationrules "pvfine/internal/annotations"
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
// EquipmentPreviewAttribute is one visible line in an equipment tooltip.
|
|
type EquipmentPreviewAttribute struct {
|
|
Label string `json:"label"`
|
|
Value string `json:"value"`
|
|
Negative bool `json:"negative"`
|
|
}
|
|
|
|
// EquipmentSkillLevelup is one profession-aware skill level bonus.
|
|
type EquipmentSkillLevelup struct {
|
|
Job string `json:"job"`
|
|
Skill string `json:"skill"`
|
|
Level int32 `json:"level"`
|
|
}
|
|
|
|
// EquipmentSetAbility is one bonus unlocked by wearing a number of set pieces.
|
|
type EquipmentSetAbility struct {
|
|
Pieces int32 `json:"pieces"`
|
|
BaseExplain string `json:"baseExplain"`
|
|
DetailExplain string `json:"detailExplain"`
|
|
}
|
|
|
|
// EquipmentSetPreviewDocument is the set tooltip associated with an equipment.
|
|
type EquipmentSetPreviewDocument struct {
|
|
Name string `json:"name"`
|
|
Parts []string `json:"parts"`
|
|
Abilities []EquipmentSetAbility `json:"abilities"`
|
|
}
|
|
|
|
// EquipmentPreviewDocument is the game-style data model rendered by the
|
|
// frontend. Optional sections are represented by empty strings/slices.
|
|
type EquipmentPreviewDocument struct {
|
|
Icon *ImageReference `json:"icon"`
|
|
Name string `json:"name"`
|
|
Name2 string `json:"name2"`
|
|
Rarity int32 `json:"rarity"`
|
|
RarityLabel string `json:"rarityLabel"`
|
|
QualityText string `json:"qualityText"`
|
|
EquipmentType string `json:"equipmentType"`
|
|
ItemGroupName string `json:"itemGroupName"`
|
|
AttachType string `json:"attachType"`
|
|
MinimumLevelText string `json:"minimumLevelText"`
|
|
UsableJobs []string `json:"usableJobs"`
|
|
FameText string `json:"fameText"`
|
|
BaseAttributes []EquipmentPreviewAttribute `json:"baseAttributes"`
|
|
FourDimensions []EquipmentPreviewAttribute `json:"fourDimensions"`
|
|
OtherAttributes []EquipmentPreviewAttribute `json:"otherAttributes"`
|
|
SkillLevelups []EquipmentSkillLevelup `json:"skillLevelups"`
|
|
BaseExplain string `json:"baseExplain"`
|
|
DetailExplain string `json:"detailExplain"`
|
|
BuffBasicExplain string `json:"buffBasicExplain"`
|
|
FlavorText string `json:"flavorText"`
|
|
DurabilityText string `json:"durabilityText"`
|
|
WeightText string `json:"weightText"`
|
|
PriceText string `json:"priceText"`
|
|
PartSet *EquipmentSetPreviewDocument `json:"partSet,omitempty"`
|
|
Issues []PreviewIssue `json:"issues"`
|
|
}
|
|
|
|
// ParseEQU parses the current editor text. Archive state is only used for the
|
|
// file path and relation lookups; text itself always comes from the caller so
|
|
// unsaved changes are reflected immediately.
|
|
func (s *PreviewService) ParseEQU(fileIndex int32, text string) (*EquipmentPreviewDocument, error) {
|
|
defer debugPhase("ParseEQU", fmt.Sprintf("file=%d", fileIndex))()
|
|
if s == nil || s.c == nil {
|
|
engine, err := annotationrules.LoadDefault()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildEquipmentPreview("preview.equ", text, engine.ForVersion(""), nil), nil
|
|
}
|
|
|
|
wait := time.Now()
|
|
diagnoseCoreLock("ParseEQU", fmt.Sprintf("file=%d", fileIndex), s.c.mu.Lock)
|
|
debugLockAcquired("ParseEQU", wait)
|
|
defer s.c.mu.Unlock()
|
|
if s.c.archive == nil {
|
|
engine, err := annotationrules.LoadDefault()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildEquipmentPreview("preview.equ", text, engine.ForVersion(""), nil), nil
|
|
}
|
|
if err := validateAnnotationIndex(s.c.archive, fileIndex); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.c.annotationEngine == nil {
|
|
if s.c.annotationErr != nil {
|
|
return nil, s.c.annotationErr
|
|
}
|
|
return nil, fmt.Errorf("标注引擎未初始化")
|
|
}
|
|
finishParse := debugPhase("ParseEQU.fields-and-relations", fmt.Sprintf("file=%d", fileIndex))
|
|
doc := buildEquipmentPreview(
|
|
s.c.archive.Path(fileIndex), text, s.c.annotationEngine,
|
|
s.c.resolveAnnotationReferenceContextLocked,
|
|
)
|
|
finishParse()
|
|
// Newer clients store `<table::key>` placeholders instead of display text.
|
|
finishNames := debugPhase("ParseEQU.localized-names", fmt.Sprintf("file=%d", fileIndex))
|
|
doc.Name = resolvePreviewText(s.c.archive, doc.Name)
|
|
doc.Name2 = resolvePreviewText(s.c.archive, doc.Name2)
|
|
finishNames()
|
|
doc.BaseExplain = normalizeExplain(resolvePreviewDescription(s.c.archive, doc.BaseExplain))
|
|
doc.DetailExplain = normalizeExplain(resolvePreviewDescription(s.c.archive, doc.DetailExplain))
|
|
doc.BuffBasicExplain = normalizeExplain(resolvePreviewDescription(s.c.archive, doc.BuffBasicExplain))
|
|
doc.FlavorText = normalizeDisplayText(resolvePreviewDescription(s.c.archive, doc.FlavorText))
|
|
doc.PartSet = s.readEquipmentSetPreviewLocked(text, &doc.Issues)
|
|
return doc, nil
|
|
}
|
|
|
|
// untranslatedMark is appended to a name that only a language overlay could
|
|
// answer: this client's own localization has no text for that key (its entry is
|
|
// an empty `key=`), so the fallback text is the Korean/translated original
|
|
// rather than what the game displays.
|
|
const untranslatedMark = "(未翻译)"
|
|
|
|
// resolvePreviewText turns a raw name value into display text: an optional
|
|
// `{N=`...`}` block marker is unwrapped and `<table::key>` placeholders are
|
|
// resolved through the archive's string tables.
|
|
func resolvePreviewText(a *pvf.Archive, text string) string {
|
|
s := strings.TrimSpace(text)
|
|
if s == "" {
|
|
return text
|
|
}
|
|
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
|
|
if eq := strings.IndexByte(s, '='); eq > 0 {
|
|
s = strings.Trim(strings.TrimSpace(s[eq+1:len(s)-1]), "`")
|
|
}
|
|
}
|
|
if a == nil {
|
|
return s
|
|
}
|
|
return a.ResolvePlaceholdersMarked(s, untranslatedMark)
|
|
}
|
|
|
|
// resolvePreviewDescription preserves literal whitespace while unwrapping
|
|
// exported string tokens and resolving their string-table references.
|
|
func resolvePreviewDescription(a *pvf.Archive, text string) string {
|
|
s := strings.TrimSpace(text)
|
|
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
|
|
return resolvePreviewText(a, text)
|
|
}
|
|
return a.ResolvePlaceholdersMarked(text, untranslatedMark)
|
|
}
|
|
|
|
func buildEquipmentPreview(filePath, text string, engine *annotationrules.Engine, resolver annotationrules.ContextResolver) *EquipmentPreviewDocument {
|
|
document := &EquipmentPreviewDocument{
|
|
QualityText: "最上级(100%)",
|
|
UsableJobs: make([]string, 0),
|
|
BaseAttributes: make([]EquipmentPreviewAttribute, 0),
|
|
FourDimensions: make([]EquipmentPreviewAttribute, 0),
|
|
OtherAttributes: make([]EquipmentPreviewAttribute, 0),
|
|
SkillLevelups: make([]EquipmentSkillLevelup, 0),
|
|
Issues: make([]PreviewIssue, 0),
|
|
}
|
|
view := pvf.ParseScriptView(text)
|
|
occurrences := engine.ExtractPreviewFields(filePath, view, "equ")
|
|
|
|
roleValues := make(map[string][]annotationrules.PreviewFieldValue)
|
|
for _, occurrence := range occurrences {
|
|
role := ""
|
|
if occurrence.Field.Preview != nil {
|
|
role = occurrence.Field.Preview.Role
|
|
}
|
|
if role != "" {
|
|
roleValues[role] = append(roleValues[role], occurrence)
|
|
}
|
|
}
|
|
first := func(role string) (annotationrules.PreviewFieldValue, bool) {
|
|
items := roleValues[role]
|
|
if len(items) == 0 {
|
|
return annotationrules.PreviewFieldValue{}, false
|
|
}
|
|
return items[0], true
|
|
}
|
|
|
|
if value, ok := first("name"); ok {
|
|
document.Name = joinFieldValues(value.Values)
|
|
}
|
|
if value, ok := first("name2"); ok {
|
|
document.Name2 = joinFieldValues(value.Values)
|
|
}
|
|
if value, ok := first("icon"); ok {
|
|
readEquipmentIcon(document, value, text, &document.Issues)
|
|
}
|
|
if value, ok := first("rarity"); ok {
|
|
raw := firstValue(value.Values)
|
|
if parsed, ok := strconv.ParseInt(strings.TrimSpace(raw), 10, 32); ok == nil {
|
|
document.Rarity = int32(parsed)
|
|
} else {
|
|
addPreviewIssue(&document.Issues, text, value.Start, "warning", "rarity", "稀有度不是有效整数: "+raw)
|
|
}
|
|
document.RarityLabel = enumValue(value.Field, raw, document, text, value.Start)
|
|
}
|
|
if value, ok := first("equipment-type"); ok {
|
|
document.EquipmentType = enumValue(value.Field, firstValue(value.Values), document, text, value.Start)
|
|
}
|
|
if value, ok := first("item-group-name"); ok {
|
|
document.ItemGroupName = enumValue(value.Field, firstValue(value.Values), document, text, value.Start)
|
|
}
|
|
if value, ok := first("attach-type"); ok {
|
|
document.AttachType = enumValue(value.Field, firstValue(value.Values), document, text, value.Start)
|
|
}
|
|
if value, ok := first("minimum-level"); ok {
|
|
document.MinimumLevelText = formatMinimumLevel(value, text, &document.Issues)
|
|
}
|
|
if values := roleValues["usable-jobs"]; len(values) > 0 {
|
|
for _, occurrence := range values {
|
|
for _, raw := range occurrence.Values {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.EqualFold(raw, "[all]") {
|
|
continue
|
|
}
|
|
document.UsableJobs = append(document.UsableJobs, enumValue(occurrence.Field, raw, document, text, occurrence.Start))
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, role := range []string{"unique-option-fame", "fame"} {
|
|
if value, ok := first(role); ok {
|
|
fame, err := strconv.ParseInt(strings.TrimSpace(firstValue(value.Values)), 10, 32)
|
|
if err != nil || fame < 0 {
|
|
addPreviewIssue(&document.Issues, text, value.Start, "warning", value.Field.Target.Section, "冒险家名望不是有效的非负整数")
|
|
continue
|
|
}
|
|
document.FameText = "冒险家名望 " + strconv.FormatInt(fame, 10)
|
|
break
|
|
}
|
|
}
|
|
|
|
for _, occurrence := range roleValues["base-attribute"] {
|
|
if attribute, ok := parseEquipmentAttribute(occurrence, text, &document.Issues); ok {
|
|
document.BaseAttributes = append(document.BaseAttributes, attribute)
|
|
}
|
|
}
|
|
for _, occurrence := range roleValues["four-dimension"] {
|
|
if attribute, ok := parseEquipmentAttribute(occurrence, text, &document.Issues); ok {
|
|
document.FourDimensions = append(document.FourDimensions, attribute)
|
|
}
|
|
}
|
|
for _, occurrence := range roleValues["other-attribute"] {
|
|
if attribute, ok := parseEquipmentAttribute(occurrence, text, &document.Issues); ok {
|
|
document.OtherAttributes = append(document.OtherAttributes, attribute)
|
|
}
|
|
}
|
|
|
|
for _, occurrence := range roleValues["skill-levelup"] {
|
|
if len(occurrence.Values) < 3 {
|
|
addPreviewIssue(&document.Issues, text, occurrence.Start, "error", "skill levelup", "技能等级加成记录需要职业、技能 ID 和等级")
|
|
continue
|
|
}
|
|
job := enumValue(occurrence.Field, occurrence.Values[0], document, text, occurrence.Start)
|
|
skillID := strings.TrimSpace(occurrence.Values[1])
|
|
skill := skillID
|
|
if resolver != nil {
|
|
if reference, ok := resolver("技能", skillID, occurrence.Context); ok {
|
|
if strings.TrimSpace(reference.Name) != "" {
|
|
skill = reference.Name
|
|
}
|
|
} else {
|
|
addPreviewIssue(&document.Issues, text, occurrence.Start, "warning", "skill levelup", "未找到技能 ID: "+skillID)
|
|
}
|
|
} else if skillID != "" {
|
|
addPreviewIssue(&document.Issues, text, occurrence.Start, "warning", "skill levelup", "未配置技能关联,显示原始 ID: "+skillID)
|
|
}
|
|
level, err := strconv.ParseInt(strings.TrimSpace(occurrence.Values[2]), 10, 32)
|
|
if err != nil {
|
|
addPreviewIssue(&document.Issues, text, occurrence.Start, "warning", "skill levelup", "技能等级不是有效整数: "+occurrence.Values[2])
|
|
continue
|
|
}
|
|
document.SkillLevelups = append(document.SkillLevelups, EquipmentSkillLevelup{Job: job, Skill: skill, Level: int32(level)})
|
|
}
|
|
|
|
for _, occurrence := range roleValues["base-explain"] {
|
|
if document.BaseExplain == "" {
|
|
document.BaseExplain = normalizeExplain(joinFieldValues(occurrence.Values))
|
|
}
|
|
}
|
|
if value, ok := first("detail-explain"); ok {
|
|
document.DetailExplain = normalizeExplain(joinFieldValues(value.Values))
|
|
}
|
|
if value, ok := first("buff-basic-explain"); ok {
|
|
document.BuffBasicExplain = normalizeExplain(joinFieldValues(value.Values))
|
|
}
|
|
if value, ok := first("flavor-text"); ok {
|
|
document.FlavorText = normalizeDisplayText(joinFieldValuesPreserve(value.Values))
|
|
}
|
|
if value, ok := first("durability"); ok {
|
|
document.DurabilityText = formatDurability(value, text, &document.Issues)
|
|
}
|
|
if value, ok := first("weight"); ok {
|
|
document.WeightText = formatWeight(value, text, &document.Issues)
|
|
}
|
|
if value, ok := first("price"); ok {
|
|
document.PriceText = formatPrice(value, text, &document.Issues)
|
|
}
|
|
|
|
return document
|
|
}
|
|
|
|
func readEquipmentIcon(document *EquipmentPreviewDocument, occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) {
|
|
pathIndex := occurrence.Field.Target.ImagePathToken
|
|
imageIndex := occurrence.Field.Target.Index
|
|
if pathIndex == nil || imageIndex == nil || *pathIndex < 0 || *imageIndex < 0 ||
|
|
*pathIndex >= len(occurrence.Values) || *imageIndex >= len(occurrence.Values) {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", "icon", "图标记录缺少图片路径或索引")
|
|
return
|
|
}
|
|
imagePath := strings.TrimSpace(occurrence.Values[*pathIndex])
|
|
parsedIndex, err := strconv.ParseInt(strings.TrimSpace(occurrence.Values[*imageIndex]), 10, 32)
|
|
if imagePath == "" || err != nil || parsedIndex < 0 {
|
|
// An empty icon is a valid, non-blocking state. Do not manufacture a
|
|
// broken image reference for the frontend.
|
|
return
|
|
}
|
|
document.Icon = &ImageReference{Path: imagePath, Index: int32(parsedIndex)}
|
|
}
|
|
|
|
func enumValue(field annotationrules.FieldDefinition, raw string, document *EquipmentPreviewDocument, text string, start int) string {
|
|
value := strings.TrimSpace(raw)
|
|
if label, ok := field.Annotation.Values[value]; ok {
|
|
return label
|
|
}
|
|
if len(field.Annotation.Values) > 0 && value != "" {
|
|
addPreviewIssue(&document.Issues, text, start, "warning", field.Annotation.Title, "未知枚举值: "+value)
|
|
}
|
|
return value
|
|
}
|
|
|
|
func parseEquipmentAttribute(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) (EquipmentPreviewAttribute, bool) {
|
|
label := occurrence.Field.Annotation.Title
|
|
if occurrence.Field.Preview != nil && strings.TrimSpace(occurrence.Field.Preview.Label) != "" {
|
|
label = occurrence.Field.Preview.Label
|
|
}
|
|
if strings.TrimSpace(label) == "" {
|
|
return EquipmentPreviewAttribute{}, false
|
|
}
|
|
values := occurrence.Values
|
|
if len(values) == 0 {
|
|
return EquipmentPreviewAttribute{}, false
|
|
}
|
|
format := "signed-number"
|
|
if occurrence.Field.Preview != nil {
|
|
format = occurrence.Field.Preview.Format
|
|
}
|
|
if format == "range" {
|
|
if len(values) < 2 {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", label, "范围属性缺少最大值或最小值")
|
|
return EquipmentPreviewAttribute{}, false
|
|
}
|
|
left, leftOK := parsePreviewNumber(values[0])
|
|
right, rightOK := parsePreviewNumber(values[1])
|
|
if !leftOK || !rightOK {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", label, "范围属性包含非法数值")
|
|
return EquipmentPreviewAttribute{}, false
|
|
}
|
|
if left > right {
|
|
left, right = right, left
|
|
}
|
|
negative := left < 0 || right < 0
|
|
return EquipmentPreviewAttribute{Label: label, Value: signedNumber(left) + "-" + trimLeadingPlus(formatNumber(right)), Negative: negative}, true
|
|
}
|
|
|
|
number, ok := parsePreviewNumber(firstValue(values))
|
|
if !ok {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", label, "属性值不是有效数值: "+firstValue(values))
|
|
return EquipmentPreviewAttribute{}, false
|
|
}
|
|
value := signedNumber(number)
|
|
if format == "percent" {
|
|
value += "%"
|
|
}
|
|
return EquipmentPreviewAttribute{Label: label, Value: value, Negative: number < 0}, true
|
|
}
|
|
|
|
func formatMinimumLevel(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
|
|
raw := firstValue(occurrence.Values)
|
|
level := strings.Fields(strings.TrimSpace(raw))
|
|
if len(level) == 0 {
|
|
return ""
|
|
}
|
|
if _, err := strconv.Atoi(strings.Trim(level[0], "()")); err != nil {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", "minimum level", "使用等级不是有效整数: "+raw)
|
|
return ""
|
|
}
|
|
return "Lv" + strings.Trim(level[0], "()") + "以上可以使用"
|
|
}
|
|
|
|
func formatDurability(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
|
|
raw := firstValue(occurrence.Values)
|
|
number, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32)
|
|
if err != nil || number < 0 {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", "durability", "耐久度不是有效整数: "+raw)
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d/%d", number, number)
|
|
}
|
|
|
|
func formatWeight(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
|
|
number, ok := parsePreviewNumber(firstValue(occurrence.Values))
|
|
if !ok || number < 0 {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", "weight", "重量不是有效数值: "+firstValue(occurrence.Values))
|
|
return ""
|
|
}
|
|
if number < 1000 {
|
|
return formatNumber(number) + "g"
|
|
}
|
|
return formatNumber(number/1000) + "kg"
|
|
}
|
|
|
|
func formatPrice(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
|
|
number, ok := parsePreviewNumber(firstValue(occurrence.Values))
|
|
if !ok {
|
|
addPreviewIssue(issues, text, occurrence.Start, "warning", "value", "售价不是有效数值: "+firstValue(occurrence.Values))
|
|
return ""
|
|
}
|
|
return formatNumber(math.Trunc(number / 5))
|
|
}
|
|
|
|
func parsePreviewNumber(value string) (float64, bool) {
|
|
value = strings.TrimSpace(strings.TrimSuffix(value, "%"))
|
|
if value == "" {
|
|
return 0, false
|
|
}
|
|
parsed, err := strconv.ParseFloat(value, 64)
|
|
return parsed, err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0)
|
|
}
|
|
|
|
func signedNumber(value float64) string {
|
|
formatted := formatNumber(value)
|
|
if value >= 0 {
|
|
return "+" + formatted
|
|
}
|
|
return formatted
|
|
}
|
|
|
|
func trimLeadingPlus(value string) string {
|
|
return strings.TrimPrefix(value, "+")
|
|
}
|
|
|
|
func formatNumber(value float64) string {
|
|
if math.Trunc(value) == value {
|
|
return strconv.FormatInt(int64(value), 10)
|
|
}
|
|
return strconv.FormatFloat(value, 'f', -1, 64)
|
|
}
|
|
|
|
func joinFieldValues(values []string) string {
|
|
parts := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
parts = append(parts, strings.TrimSpace(value))
|
|
}
|
|
}
|
|
return strings.TrimSpace(strings.Join(parts, " "))
|
|
}
|
|
|
|
func joinFieldValuesPreserve(values []string) string {
|
|
return strings.Join(values, " ")
|
|
}
|
|
|
|
func firstValue(values []string) string {
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
return values[0]
|
|
}
|
|
|
|
func normalizeExplain(value string) string {
|
|
return strings.TrimSpace(normalizeDisplayText(strings.ReplaceAll(value, "%%", "%")))
|
|
}
|
|
|
|
func normalizeDisplayText(value string) string {
|
|
value = strings.ReplaceAll(value, "\r\n", "\n")
|
|
value = strings.ReplaceAll(value, "\r", "\n")
|
|
// Some exported text stores line breaks as the two-character escape `\\n`.
|
|
value = strings.ReplaceAll(value, `\r\n`, "\n")
|
|
value = strings.ReplaceAll(value, `\r`, "\n")
|
|
value = strings.ReplaceAll(value, `\n`, "\n")
|
|
return value
|
|
}
|
|
|
|
func addPreviewIssue(issues *[]PreviewIssue, text string, offset int, severity, section, message string) {
|
|
if issues == nil {
|
|
return
|
|
}
|
|
*issues = append(*issues, PreviewIssue{
|
|
Severity: severity,
|
|
Line: int32(lineAtUTF16Offset(text, offset)),
|
|
Section: section,
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
func lineAtUTF16Offset(text string, target int) int {
|
|
if target < 0 {
|
|
return 1
|
|
}
|
|
line := 1
|
|
offset := 0
|
|
runes := []rune(text)
|
|
for i, value := range runes {
|
|
if offset >= target {
|
|
return line
|
|
}
|
|
if value == '\n' {
|
|
line++
|
|
}
|
|
if value == '\r' && i+1 < len(runes) && runes[i+1] == '\n' {
|
|
continue
|
|
}
|
|
offset += len(utf16.Encode([]rune{value}))
|
|
}
|
|
return line
|
|
}
|