322 lines
7.5 KiB
Go
322 lines
7.5 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/csv"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||
|
|
)
|
||
|
|
|
||
|
|
// CSVData represents the CSV data passed between Go and the frontend.
|
||
|
|
type CSVData struct {
|
||
|
|
Headers []string `json:"Headers"`
|
||
|
|
Rows [][]string `json:"Rows"`
|
||
|
|
FilePath string `json:"FilePath"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// App struct holds the application state.
|
||
|
|
type App struct {
|
||
|
|
ctx context.Context
|
||
|
|
headers []string
|
||
|
|
rows [][]string
|
||
|
|
currentFile string
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewApp creates a new App application struct.
|
||
|
|
func NewApp() *App {
|
||
|
|
return &App{}
|
||
|
|
}
|
||
|
|
|
||
|
|
// startup is called when the app starts. The context is saved
|
||
|
|
// so we can call the runtime methods.
|
||
|
|
func (a *App) startup(ctx context.Context) {
|
||
|
|
a.ctx = ctx
|
||
|
|
|
||
|
|
// Set up file drop handler
|
||
|
|
runtime.OnFileDrop(ctx, func(x, y int, paths []string) {
|
||
|
|
if len(paths) > 0 {
|
||
|
|
runtime.EventsEmit(ctx, "file-dropped", paths[0])
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// domReady is called after the front-end DOM is ready.
|
||
|
|
func (a *App) domReady(ctx context.Context) {
|
||
|
|
// placeholder for any post-DOM-ready initialisation
|
||
|
|
}
|
||
|
|
|
||
|
|
// LoadCSV reads a CSV file and returns its data. The first row is treated as headers.
|
||
|
|
func (a *App) LoadCSV(filePath string) (*CSVData, error) {
|
||
|
|
data, err := os.ReadFile(filePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
reader := csv.NewReader(bytes.NewReader(data))
|
||
|
|
reader.FieldsPerRecord = -1 // allow variable field counts
|
||
|
|
|
||
|
|
records, err := reader.ReadAll()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to parse CSV: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(records) == 0 {
|
||
|
|
a.headers = []string{}
|
||
|
|
a.rows = [][]string{}
|
||
|
|
a.currentFile = filePath
|
||
|
|
a.updateWindowTitle()
|
||
|
|
return &CSVData{Headers: a.headers, Rows: a.rows, FilePath: filePath}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
a.headers = records[0]
|
||
|
|
if len(records) > 1 {
|
||
|
|
a.rows = records[1:]
|
||
|
|
} else {
|
||
|
|
a.rows = [][]string{}
|
||
|
|
}
|
||
|
|
a.currentFile = filePath
|
||
|
|
a.updateWindowTitle()
|
||
|
|
|
||
|
|
return &CSVData{Headers: a.headers, Rows: a.rows, FilePath: filePath}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SaveCSV writes headers and rows to the given file path as CSV.
|
||
|
|
func (a *App) SaveCSV(filePath string, headers []string, rows [][]string) error {
|
||
|
|
f, err := os.Create(filePath)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to create file: %w", err)
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
writer := csv.NewWriter(f)
|
||
|
|
defer writer.Flush()
|
||
|
|
|
||
|
|
if err := writer.Write(headers); err != nil {
|
||
|
|
return fmt.Errorf("failed to write headers: %w", err)
|
||
|
|
}
|
||
|
|
for _, row := range rows {
|
||
|
|
if err := writer.Write(row); err != nil {
|
||
|
|
return fmt.Errorf("failed to write row: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
a.headers = headers
|
||
|
|
a.rows = rows
|
||
|
|
a.currentFile = filePath
|
||
|
|
a.updateWindowTitle()
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SaveCurrentFile saves to the currently open file.
|
||
|
|
func (a *App) SaveCurrentFile(headers []string, rows [][]string) error {
|
||
|
|
if a.currentFile == "" {
|
||
|
|
return fmt.Errorf("no file is currently open")
|
||
|
|
}
|
||
|
|
return a.SaveCSV(a.currentFile, headers, rows)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetTableData returns the current CSV state.
|
||
|
|
func (a *App) GetTableData() *CSVData {
|
||
|
|
return &CSVData{
|
||
|
|
Headers: a.headers,
|
||
|
|
Rows: a.rows,
|
||
|
|
FilePath: a.currentFile,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetTableData updates the in-memory state from the frontend.
|
||
|
|
func (a *App) SetTableData(headers []string, rows [][]string) {
|
||
|
|
a.headers = headers
|
||
|
|
a.rows = rows
|
||
|
|
}
|
||
|
|
|
||
|
|
// OpenFileDialog shows a native open dialog filtered for CSV files.
|
||
|
|
func (a *App) OpenFileDialog() (string, error) {
|
||
|
|
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||
|
|
Title: "Open CSV File",
|
||
|
|
Filters: []runtime.FileFilter{
|
||
|
|
{
|
||
|
|
DisplayName: "CSV Files (*.csv)",
|
||
|
|
Pattern: "*.csv",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
DisplayName: "All Files (*.*)",
|
||
|
|
Pattern: "*.*",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// SaveFileDialog shows a native save dialog.
|
||
|
|
func (a *App) SaveFileDialog() (string, error) {
|
||
|
|
return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||
|
|
Title: "Save CSV File",
|
||
|
|
DefaultFilename: "untitled.csv",
|
||
|
|
Filters: []runtime.FileFilter{
|
||
|
|
{
|
||
|
|
DisplayName: "CSV Files (*.csv)",
|
||
|
|
Pattern: "*.csv",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
DisplayName: "All Files (*.*)",
|
||
|
|
Pattern: "*.*",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ParseCSVString parses CSV text content and returns structured data.
|
||
|
|
func (a *App) ParseCSVString(content string) (*CSVData, error) {
|
||
|
|
reader := csv.NewReader(strings.NewReader(content))
|
||
|
|
reader.FieldsPerRecord = -1
|
||
|
|
|
||
|
|
records, err := reader.ReadAll()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to parse CSV string: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(records) == 0 {
|
||
|
|
return &CSVData{Headers: []string{}, Rows: [][]string{}}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
headers := records[0]
|
||
|
|
var rows [][]string
|
||
|
|
if len(records) > 1 {
|
||
|
|
rows = records[1:]
|
||
|
|
} else {
|
||
|
|
rows = [][]string{}
|
||
|
|
}
|
||
|
|
|
||
|
|
return &CSVData{Headers: headers, Rows: rows}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// FormatAsCSV formats headers and rows as a CSV string.
|
||
|
|
func (a *App) FormatAsCSV(headers []string, rows [][]string) string {
|
||
|
|
var buf bytes.Buffer
|
||
|
|
writer := csv.NewWriter(&buf)
|
||
|
|
|
||
|
|
_ = writer.Write(headers)
|
||
|
|
for _, row := range rows {
|
||
|
|
_ = writer.Write(row)
|
||
|
|
}
|
||
|
|
writer.Flush()
|
||
|
|
|
||
|
|
return buf.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// FormatRowsAsCSV formats just the rows (no headers) as CSV text.
|
||
|
|
func (a *App) FormatRowsAsCSV(rows [][]string) string {
|
||
|
|
var buf bytes.Buffer
|
||
|
|
writer := csv.NewWriter(&buf)
|
||
|
|
for _, row := range rows {
|
||
|
|
_ = writer.Write(row)
|
||
|
|
}
|
||
|
|
writer.Flush()
|
||
|
|
return buf.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// FormatAsMarkdown formats headers and rows as a Markdown table.
|
||
|
|
func (a *App) FormatAsMarkdown(headers []string, rows [][]string) string {
|
||
|
|
if len(headers) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
var sb strings.Builder
|
||
|
|
|
||
|
|
// Header row
|
||
|
|
sb.WriteString("| ")
|
||
|
|
sb.WriteString(strings.Join(headers, " | "))
|
||
|
|
sb.WriteString(" |\n")
|
||
|
|
|
||
|
|
// Separator row
|
||
|
|
separators := make([]string, len(headers))
|
||
|
|
for i := range separators {
|
||
|
|
separators[i] = "---"
|
||
|
|
}
|
||
|
|
sb.WriteString("| ")
|
||
|
|
sb.WriteString(strings.Join(separators, " | "))
|
||
|
|
sb.WriteString(" |\n")
|
||
|
|
|
||
|
|
// Data rows
|
||
|
|
for _, row := range rows {
|
||
|
|
padded := padRow(row, len(headers))
|
||
|
|
sb.WriteString("| ")
|
||
|
|
sb.WriteString(strings.Join(padded, " | "))
|
||
|
|
sb.WriteString(" |\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
return sb.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// FormatAsJira formats headers and rows as Jira table markup.
|
||
|
|
func (a *App) FormatAsJira(headers []string, rows [][]string) string {
|
||
|
|
if len(headers) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
var sb strings.Builder
|
||
|
|
|
||
|
|
// Header row: || Header1 || Header2 ||
|
||
|
|
sb.WriteString("|| ")
|
||
|
|
sb.WriteString(strings.Join(headers, " || "))
|
||
|
|
sb.WriteString(" ||\n")
|
||
|
|
|
||
|
|
// Data rows: | val1 | val2 |
|
||
|
|
for _, row := range rows {
|
||
|
|
padded := padRow(row, len(headers))
|
||
|
|
sb.WriteString("| ")
|
||
|
|
sb.WriteString(strings.Join(padded, " | "))
|
||
|
|
sb.WriteString(" |\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
return sb.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// FormatAsSingleColumn formats a single column of data as newline-separated lines.
|
||
|
|
func (a *App) FormatAsSingleColumn(rows [][]string) string {
|
||
|
|
var lines []string
|
||
|
|
for _, row := range rows {
|
||
|
|
if len(row) > 0 {
|
||
|
|
lines = append(lines, row[0])
|
||
|
|
} else {
|
||
|
|
lines = append(lines, "")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return strings.Join(lines, "\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetWindowTitle sets the window title. If title is empty, resets to default.
|
||
|
|
func (a *App) SetWindowTitle(title string) {
|
||
|
|
if title == "" {
|
||
|
|
runtime.WindowSetTitle(a.ctx, "CSV Tool")
|
||
|
|
} else {
|
||
|
|
runtime.WindowSetTitle(a.ctx, title+" - CSV Tool")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// updateWindowTitle sets the window title based on the current file.
|
||
|
|
func (a *App) updateWindowTitle() {
|
||
|
|
if a.currentFile == "" {
|
||
|
|
runtime.WindowSetTitle(a.ctx, "CSV Tool")
|
||
|
|
} else {
|
||
|
|
name := filepath.Base(a.currentFile)
|
||
|
|
runtime.WindowSetTitle(a.ctx, name+" - CSV Tool")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// padRow ensures a row has exactly n columns, padding with empty strings if needed.
|
||
|
|
func padRow(row []string, n int) []string {
|
||
|
|
if len(row) >= n {
|
||
|
|
return row[:n]
|
||
|
|
}
|
||
|
|
padded := make([]string, n)
|
||
|
|
copy(padded, row)
|
||
|
|
return padded
|
||
|
|
}
|