1package pdfexport23import (4 "bufio"5 "bytes"6 "encoding/json"7 "fmt"8 "os"9 "path/filepath"10 "slices"11 "strings"1213 "github.com/FelineStateMachine/puzzletea/difficulty"14)1516const (17 ExportSchemaV1 = "puzzletea.export.v1"18 ExportSchemaV2 = "puzzletea.export.v2"19)2021type JSONLRecord struct {22 Schema string `json:"schema"`23 Pack JSONLPackMeta `json:"pack"`24 Puzzle JSONLPuzzle `json:"puzzle"`25}2627type JSONLPackMeta struct {28 Generated string `json:"generated"`29 Version string `json:"version"`30 Category string `json:"category"`31 ModeSelection string `json:"mode_selection"`32 Count int `json:"count"`33 Seed string `json:"seed"`34}3536type JSONLPuzzle struct {37 Index int `json:"index"`38 Name string `json:"name"`39 Game string `json:"game"`40 Mode string `json:"mode"`41 TargetDifficultyElo *int `json:"target_difficulty_elo,omitempty"`42 ActualDifficultyElo *int `json:"actual_difficulty_elo,omitempty"`43 DifficultyConfidence string `json:"difficulty_confidence,omitempty"`44 Save json.RawMessage `json:"save"`45}4647func ParseJSONLFiles(paths []string) ([]PackDocument, error) {48 docs := make([]PackDocument, 0, len(paths))49 for _, path := range paths {50 doc, err := ParseJSONLFile(path)51 if err != nil {52 return nil, err53 }54 docs = append(docs, doc)55 }56 return docs, nil57}5859func ParseJSONLFile(path string) (PackDocument, error) {60 if !strings.EqualFold(filepath.Ext(path), ".jsonl") {61 return PackDocument{}, fmt.Errorf("%s: expected .jsonl input", path)62 }6364 f, err := os.Open(path)65 if err != nil {66 return PackDocument{}, fmt.Errorf("open input jsonl: %w", err)67 }68 defer f.Close()6970 scanner := bufio.NewScanner(f)71 scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)72 return parseJSONLScanner(path, scanner)73}7475func ParseJSONLData(sourceName string, data []byte) (PackDocument, error) {76 scanner := bufio.NewScanner(bytes.NewReader(data))77 scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)78 return parseJSONLScanner(sourceName, scanner)79}8081func ParseJSONLRecords(sourceName string, records []JSONLRecord) (PackDocument, error) {82 var b strings.Builder83 for _, record := range records {84 line, err := json.Marshal(record)85 if err != nil {86 return PackDocument{}, fmt.Errorf("encode jsonl record: %w", err)87 }88 b.Write(line)89 b.WriteByte('\n')90 }91 return ParseJSONLData(sourceName, []byte(b.String()))92}9394func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error) {95 doc := PackDocument{SourcePath: path}96 doc.Metadata.SourceFileName = filepath.Base(path)97 puzzles := []Puzzle{}98 lineNo := 099 seenAny := false100 unsupportedCategories := []string{}101102 for scanner.Scan() {103 lineNo++104 line := strings.TrimSpace(scanner.Text())105 if line == "" {106 continue107 }108 seenAny = true109110 var record JSONLRecord111 if err := json.Unmarshal([]byte(line), &record); err != nil {112 return PackDocument{}, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)113 }114 if !IsSupportedExportSchema(record.Schema) {115 return PackDocument{}, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)116 }117118 category := strings.TrimSpace(record.Puzzle.Game)119 if category == "" {120 category = strings.TrimSpace(record.Pack.Category)121 }122 mode := strings.TrimSpace(record.Puzzle.Mode)123 if mode == "" {124 mode = strings.TrimSpace(record.Pack.ModeSelection)125 }126127 p := Puzzle{128 SourcePath: path,129 SourceFileName: filepath.Base(path),130 Category: category,131 ModeSelection: mode,132 Name: record.Puzzle.Name,133 Index: record.Puzzle.Index,134 SaveData: append([]byte(nil), record.Puzzle.Save...),135 TargetDifficultyElo: cloneInt(record.Puzzle.TargetDifficultyElo),136 ActualDifficultyElo: cloneInt(record.Puzzle.ActualDifficultyElo),137 }138 annotateJSONLDifficulty(&p, record.Puzzle)139140 adapter, ok := LookupPrintAdapter(category)141 if !ok {142 if category != "" && !slices.Contains(unsupportedCategories, category) {143 unsupportedCategories = append(unsupportedCategories, category)144 }145 continue146 }147 payload, err := adapter.BuildPDFPayload(p.SaveData)148 if err != nil {149 return PackDocument{}, fmt.Errorf("%s:%d: build print payload: %w", path, lineNo, err)150 }151 if IsNilPrintPayload(payload) {152 continue153 }154 p.PrintPayload = payload155156 if doc.Metadata.GeneratedRaw == "" {157 doc.Metadata.GeneratedRaw = record.Pack.Generated158 doc.Metadata.Version = record.Pack.Version159 doc.Metadata.Count = record.Pack.Count160 doc.Metadata.Seed = record.Pack.Seed161 doc.Metadata.Category = strings.TrimSpace(p.Category)162 if doc.Metadata.Category == "" {163 doc.Metadata.Category = record.Pack.Category164 }165 doc.Metadata.ModeSelection = strings.TrimSpace(p.ModeSelection)166 if doc.Metadata.ModeSelection == "" {167 doc.Metadata.ModeSelection = record.Pack.ModeSelection168 }169 }170171 puzzles = append(puzzles, p)172 }173174 if err := scanner.Err(); err != nil {175 return PackDocument{}, fmt.Errorf("read input jsonl: %w", err)176 }177 if !seenAny {178 return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path)179 }180 if len(puzzles) == 0 {181 if len(unsupportedCategories) > 0 {182 return PackDocument{}, fmt.Errorf("%s: no printable puzzles found; unsupported categories: %s", path, strings.Join(unsupportedCategories, ", "))183 }184 return PackDocument{}, fmt.Errorf("%s: no printable puzzles found", path)185 }186 if doc.Metadata.Count == 0 {187 doc.Metadata.Count = len(puzzles)188 }189 doc.Puzzles = puzzles190 return doc, nil191}192193func IsSupportedExportSchema(schema string) bool {194 switch schema {195 case ExportSchemaV1, ExportSchemaV2:196 return true197 default:198 return false199 }200}201202func annotateJSONLDifficulty(p *Puzzle, puzzle JSONLPuzzle) {203 if puzzle.ActualDifficultyElo != nil {204 elo := difficulty.Elo(*puzzle.ActualDifficultyElo)205 p.DifficultyScore = difficulty.Score01(elo)206 p.DifficultyConfidence = parseDifficultyConfidence(puzzle.DifficultyConfidence)207 p.DifficultySource = "actual-elo"208 return209 }210 if puzzle.TargetDifficultyElo != nil {211 elo := difficulty.Elo(*puzzle.TargetDifficultyElo)212 p.DifficultyScore = difficulty.Score01(elo)213 p.DifficultyConfidence = parseDifficultyConfidence(puzzle.DifficultyConfidence)214 p.DifficultySource = "target-elo"215 }216}217218func parseDifficultyConfidence(raw string) DifficultyConfidence {219 switch difficulty.Confidence(strings.ToLower(strings.TrimSpace(raw))) {220 case difficulty.ConfidenceLow:221 return DifficultyConfidenceLow222 case difficulty.ConfidenceMedium:223 return DifficultyConfidenceMedium224 case difficulty.ConfidenceHigh:225 return DifficultyConfidenceHigh226 default:227 return DifficultyConfidenceMedium228 }229}230231func cloneInt(value *int) *int {232 if value == nil {233 return nil234 }235 v := *value236 return &v237}