Skip to content

Commit 7b426bb

Browse files
committed
feat: implement batch import functionality for log files with support for CSV and JSON formats
1 parent 0c8560e commit 7b426bb

6 files changed

Lines changed: 508 additions & 4 deletions

File tree

app.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ import (
44
"context"
55
"crypto/tls"
66
"crypto/x509"
7+
"encoding/csv"
8+
"encoding/json"
79
"fmt"
810
"io"
911
"log"
1012
"net"
1113
"os"
14+
"path/filepath"
15+
"strings"
1216
"sync"
1317
"time"
1418

@@ -1032,3 +1036,222 @@ func (a *App) ExportConfig() (string, error) {
10321036
func (a *App) ImportConfig(jsonData string, merge bool) error {
10331037
return importConfigToStorage(jsonData, merge)
10341038
}
1039+
1040+
// ================================================================================
1041+
// BATCH IMPORT API (Enterprise Features)
1042+
// ================================================================================
1043+
1044+
// BatchImportResult represents the result of a batch import operation
1045+
type BatchImportResult struct {
1046+
Messages []string `json:"messages"` // Successfully parsed messages
1047+
TotalLines int `json:"totalLines"` // Total lines processed
1048+
Errors []string `json:"errors"` // Any parsing errors
1049+
}
1050+
1051+
// SelectLogFile opens a file dialog to select a log file for batch import
1052+
// Supports CSV, JSON, and plain text files
1053+
func (a *App) SelectLogFile() (string, error) {
1054+
filePath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
1055+
Title: "Select Log File for Import",
1056+
Filters: []runtime.FileFilter{
1057+
{
1058+
DisplayName: "Log Files (*.csv, *.json, *.txt, *.log)",
1059+
Pattern: "*.csv;*.json;*.txt;*.log",
1060+
},
1061+
{
1062+
DisplayName: "CSV Files (*.csv)",
1063+
Pattern: "*.csv",
1064+
},
1065+
{
1066+
DisplayName: "JSON Files (*.json)",
1067+
Pattern: "*.json",
1068+
},
1069+
{
1070+
DisplayName: "Text Files (*.txt, *.log)",
1071+
Pattern: "*.txt;*.log",
1072+
},
1073+
{
1074+
DisplayName: "All Files (*.*)",
1075+
Pattern: "*.*",
1076+
},
1077+
},
1078+
})
1079+
if err != nil {
1080+
return "", fmt.Errorf("failed to open file dialog: %w", err)
1081+
}
1082+
return filePath, nil
1083+
}
1084+
1085+
// ImportLogFile reads and parses a log file, returning the messages
1086+
// Automatically detects format based on file extension and content
1087+
func (a *App) ImportLogFile(filePath string) (BatchImportResult, error) {
1088+
result := BatchImportResult{
1089+
Messages: []string{},
1090+
Errors: []string{},
1091+
}
1092+
1093+
// Read file content
1094+
content, err := os.ReadFile(filePath)
1095+
if err != nil {
1096+
return result, fmt.Errorf("failed to read file: %w", err)
1097+
}
1098+
1099+
// Detect format based on extension
1100+
ext := strings.ToLower(filepath.Ext(filePath))
1101+
1102+
switch ext {
1103+
case ".json":
1104+
return a.parseJSONLogs(content)
1105+
case ".csv":
1106+
return a.parseCSVLogs(content)
1107+
default:
1108+
// Try JSON first, then fallback to plain text
1109+
if len(content) > 0 && (content[0] == '[' || content[0] == '{') {
1110+
jsonResult, err := a.parseJSONLogs(content)
1111+
if err == nil && len(jsonResult.Messages) > 0 {
1112+
return jsonResult, nil
1113+
}
1114+
}
1115+
return a.parseTextLogs(content)
1116+
}
1117+
}
1118+
1119+
// parseJSONLogs parses JSON format logs
1120+
// Supports: array of strings, array of objects with "message" field, or newline-delimited JSON
1121+
func (a *App) parseJSONLogs(content []byte) (BatchImportResult, error) {
1122+
result := BatchImportResult{
1123+
Messages: []string{},
1124+
Errors: []string{},
1125+
}
1126+
1127+
// Try parsing as array of strings
1128+
var stringArray []string
1129+
if err := json.Unmarshal(content, &stringArray); err == nil {
1130+
for _, msg := range stringArray {
1131+
if trimmed := strings.TrimSpace(msg); trimmed != "" {
1132+
result.Messages = append(result.Messages, trimmed)
1133+
}
1134+
}
1135+
result.TotalLines = len(stringArray)
1136+
return result, nil
1137+
}
1138+
1139+
// Try parsing as array of objects with "message" field
1140+
var objArray []map[string]interface{}
1141+
if err := json.Unmarshal(content, &objArray); err == nil {
1142+
result.TotalLines = len(objArray)
1143+
for i, obj := range objArray {
1144+
if msg, ok := obj["message"].(string); ok && strings.TrimSpace(msg) != "" {
1145+
result.Messages = append(result.Messages, strings.TrimSpace(msg))
1146+
} else if msg, ok := obj["msg"].(string); ok && strings.TrimSpace(msg) != "" {
1147+
result.Messages = append(result.Messages, strings.TrimSpace(msg))
1148+
} else if msg, ok := obj["log"].(string); ok && strings.TrimSpace(msg) != "" {
1149+
result.Messages = append(result.Messages, strings.TrimSpace(msg))
1150+
} else {
1151+
result.Errors = append(result.Errors, fmt.Sprintf("Line %d: no 'message', 'msg', or 'log' field found", i+1))
1152+
}
1153+
}
1154+
return result, nil
1155+
}
1156+
1157+
// Try parsing as newline-delimited JSON (NDJSON)
1158+
lines := strings.Split(string(content), "\n")
1159+
result.TotalLines = len(lines)
1160+
for i, line := range lines {
1161+
line = strings.TrimSpace(line)
1162+
if line == "" {
1163+
continue
1164+
}
1165+
var obj map[string]interface{}
1166+
if err := json.Unmarshal([]byte(line), &obj); err == nil {
1167+
if msg, ok := obj["message"].(string); ok && strings.TrimSpace(msg) != "" {
1168+
result.Messages = append(result.Messages, strings.TrimSpace(msg))
1169+
} else if msg, ok := obj["msg"].(string); ok && strings.TrimSpace(msg) != "" {
1170+
result.Messages = append(result.Messages, strings.TrimSpace(msg))
1171+
} else {
1172+
result.Errors = append(result.Errors, fmt.Sprintf("Line %d: no message field found", i+1))
1173+
}
1174+
} else {
1175+
result.Errors = append(result.Errors, fmt.Sprintf("Line %d: invalid JSON", i+1))
1176+
}
1177+
}
1178+
1179+
return result, nil
1180+
}
1181+
1182+
// parseCSVLogs parses CSV format logs
1183+
// First column is treated as the message, or looks for "message" header
1184+
func (a *App) parseCSVLogs(content []byte) (BatchImportResult, error) {
1185+
result := BatchImportResult{
1186+
Messages: []string{},
1187+
Errors: []string{},
1188+
}
1189+
1190+
reader := csv.NewReader(strings.NewReader(string(content)))
1191+
reader.TrimLeadingSpace = true
1192+
reader.LazyQuotes = true
1193+
1194+
records, err := reader.ReadAll()
1195+
if err != nil {
1196+
return result, fmt.Errorf("failed to parse CSV: %w", err)
1197+
}
1198+
1199+
if len(records) == 0 {
1200+
return result, nil
1201+
}
1202+
1203+
result.TotalLines = len(records)
1204+
1205+
// Check for header row
1206+
messageColIdx := 0
1207+
hasHeader := false
1208+
header := records[0]
1209+
1210+
for i, col := range header {
1211+
colLower := strings.ToLower(strings.TrimSpace(col))
1212+
if colLower == "message" || colLower == "msg" || colLower == "log" {
1213+
messageColIdx = i
1214+
hasHeader = true
1215+
break
1216+
}
1217+
}
1218+
1219+
startRow := 0
1220+
if hasHeader {
1221+
startRow = 1
1222+
}
1223+
1224+
for i := startRow; i < len(records); i++ {
1225+
record := records[i]
1226+
if len(record) > messageColIdx {
1227+
msg := strings.TrimSpace(record[messageColIdx])
1228+
if msg != "" {
1229+
result.Messages = append(result.Messages, msg)
1230+
}
1231+
} else {
1232+
result.Errors = append(result.Errors, fmt.Sprintf("Line %d: insufficient columns", i+1))
1233+
}
1234+
}
1235+
1236+
return result, nil
1237+
}
1238+
1239+
// parseTextLogs parses plain text logs (one message per line)
1240+
func (a *App) parseTextLogs(content []byte) (BatchImportResult, error) {
1241+
result := BatchImportResult{
1242+
Messages: []string{},
1243+
Errors: []string{},
1244+
}
1245+
1246+
lines := strings.Split(string(content), "\n")
1247+
result.TotalLines = len(lines)
1248+
1249+
for _, line := range lines {
1250+
trimmed := strings.TrimSpace(line)
1251+
if trimmed != "" {
1252+
result.Messages = append(result.Messages, trimmed)
1253+
}
1254+
}
1255+
1256+
return result, nil
1257+
}

0 commit comments

Comments
 (0)