Files
dobotai-platform/internal/questiontable/row.go

164 lines
4.3 KiB
Go

package questiontable
import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// Field IDs for the DingTalk Notable v1 question table.
// Verified against the live Notable v1 base.
const (
FieldChineseQuestion = "ZaMZF6j" // text
FieldAskedTime = "SCug9My" // date (Unix milliseconds)
FieldAsker = "S2XA1uZ" // user (array of {name, unionId})
FieldOriginalQuestion = "bETcL3r" // text
FieldAnswer = "Y4cpmxU" // text
FieldTriggerPerson = "JgY2f1z" // text
FieldLanguage = "DNIV3mQ" // text
FieldDepartment = "eBc6a6b" // text
FieldRegion = "fPNLeVW" // text
)
// Row holds the data needed to produce a Notable v1 typed record.
type Row struct {
Question string
Answer string
AskedTime time.Time
AskerName string
AskerUnionID string
TriggerPerson string
Department string
Region string
QuestionImageURLs []string
}
// ToFields returns a typed field-ID → value map suitable for the Notable v1
// InsertRecord request body. The caller is responsible for the semantics of
// each field; this method only handles mapping, language detection, time
// formatting, and formula-injection sanitisation.
// The five v1 select fields (QMn9UoU, H3gtTuv, nFvv8OR, UMqUPAG, lnF7gF9)
// are omitted entirely.
func (r Row) ToFields() map[string]interface{} {
fields := make(map[string]interface{})
lang := detectLanguage(r.Question)
// ChineseQuestion: only when Han present.
if lang == "中文" {
fields[FieldChineseQuestion] = sanitise(r.Question)
}
// Answer.
fields[FieldAnswer] = sanitise(r.Answer)
// AskedTime: Unix milliseconds if non-zero; omit if zero.
if !r.AskedTime.IsZero() {
fields[FieldAskedTime] = r.AskedTime.UnixMilli()
}
// Asker: user object only when AskerUnionID is non-blank.
if strings.TrimSpace(r.AskerUnionID) != "" {
fields[FieldAsker] = []interface{}{
map[string]interface{}{
"name": strings.TrimSpace(r.AskerName),
"unionId": strings.TrimSpace(r.AskerUnionID),
},
}
}
// OriginalQuestion: include image marker and stable URLs for image turns.
fields[FieldOriginalQuestion] = sanitise(questionWithImageURLs(r.Question, r.QuestionImageURLs))
// Language.
fields[FieldLanguage] = sanitise(lang)
// TriggerPerson: fallback to AskerName.
triggerPerson := r.TriggerPerson
if triggerPerson == "" {
triggerPerson = r.AskerName
}
fields[FieldTriggerPerson] = sanitise(triggerPerson)
// Department.
fields[FieldDepartment] = sanitise(r.Department)
// Region.
fields[FieldRegion] = sanitise(r.Region)
return fields
}
// detectLanguage returns "中文" when s contains any Han character,
// "英文" when it contains Latin letters but no Han,
// and "其他" otherwise.
func detectLanguage(s string) string {
hasHan := false
hasLatin := false
for _, r := range s {
if isHan(r) {
hasHan = true
}
if isLatin(r) {
hasLatin = true
}
}
if hasHan {
return "中文"
}
if hasLatin {
return "英文"
}
return "其他"
}
func isHan(r rune) bool {
return unicode.Is(unicode.Han, r)
}
func isLatin(r rune) bool {
return unicode.Is(unicode.Latin, r)
}
// sanitise protects against spreadsheet formula injection. If the string
// begins with =, +, -, or @, a single apostrophe is prefixed. The string
// is otherwise returned unchanged.
func sanitise(s string) string {
if s == "" {
return s
}
r, _ := utf8.DecodeRuneInString(s)
switch r {
case '=', '+', '-', '@':
return "'" + s
}
return s
}
// questionWithImageURLs prefixes an image marker and renders the stable image
// URLs as markdown image syntax when the turn carried image input. Markdown
// image syntax lets the Notable cell preview the image (same as the answer
// column); a plain-text URL would render as an unpreviewable link.
func questionWithImageURLs(question string, imageURLs []string) string {
if len(imageURLs) == 0 {
return question
}
var builder strings.Builder
builder.WriteString("[图片] ")
builder.WriteString(strings.TrimSpace(question))
for index, imageURL := range imageURLs {
imageURL = strings.TrimSpace(imageURL)
if imageURL == "" {
continue
}
builder.WriteString("\n![图片")
builder.WriteString(strconv.Itoa(index + 1))
builder.WriteString("](")
builder.WriteString(imageURL)
builder.WriteString(")")
}
return builder.String()
}