From 0776850f02f5b3e06e0ae9b5a33f213554e0c462 Mon Sep 17 00:00:00 2001 From: Baraffe Robin Date: Wed, 23 Dec 2020 14:26:54 +0100 Subject: [PATCH] feat: allow user to choose stylesheet and starting coordinates (#44) --- docs/docs.go | 34 ++++++++++++++--- docs/swagger.json | 34 ++++++++++++++--- docs/swagger.yaml | 26 ++++++++++--- go.sum | 1 + handlers/reader.go | 30 +++++++-------- models/reader.go | 12 ++++++ services/reader.go | 60 ++++++++++++++++++++++------- services/reader_test.go | 82 ++++++++++++++++++++++++++++++++++++---- test/input.xlsx | Bin 9285 -> 9289 bytes test/~$input.xlsx | Bin 0 -> 165 bytes 10 files changed, 223 insertions(+), 56 deletions(-) create mode 100644 models/reader.go create mode 100644 test/~$input.xlsx diff --git a/docs/docs.go b/docs/docs.go index 10c7082..8ba4cf4 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -52,11 +52,11 @@ var doc = `{ } }, { - "description": "Sheets to extract", - "name": "sheets", + "description": "Reader optional options", + "name": "options", "in": "body", "schema": { - "type": "string" + "$ref": "#/definitions/models.ReaderOption" } } ], @@ -100,11 +100,11 @@ var doc = `{ } }, { - "description": "Sheets to extract", - "name": "sheets", + "description": "Reader optional options", + "name": "options", "in": "body", "schema": { - "type": "string" + "$ref": "#/definitions/models.ReaderOption" } } ], @@ -383,6 +383,17 @@ var doc = `{ } } }, + "models.Option": { + "type": "object", + "properties": { + "coordinates": { + "type": "string" + }, + "sheetname": { + "type": "string" + } + } + }, "models.PlotArea": { "type": "object", "properties": { @@ -417,6 +428,17 @@ var doc = `{ } } }, + "models.ReaderOption": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Option" + } + } + } + }, "models.Series": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 1555398..7dd257b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -35,11 +35,11 @@ } }, { - "description": "Sheets to extract", - "name": "sheets", + "description": "Reader optional options", + "name": "options", "in": "body", "schema": { - "type": "string" + "$ref": "#/definitions/models.ReaderOption" } } ], @@ -83,11 +83,11 @@ } }, { - "description": "Sheets to extract", - "name": "sheets", + "description": "Reader optional options", + "name": "options", "in": "body", "schema": { - "type": "string" + "$ref": "#/definitions/models.ReaderOption" } } ], @@ -366,6 +366,17 @@ } } }, + "models.Option": { + "type": "object", + "properties": { + "coordinates": { + "type": "string" + }, + "sheetname": { + "type": "string" + } + } + }, "models.PlotArea": { "type": "object", "properties": { @@ -400,6 +411,17 @@ } } }, + "models.ReaderOption": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Option" + } + } + } + }, "models.Series": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 56d5573..f8c0544 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -143,6 +143,13 @@ definitions: show_legend_key: type: boolean type: object + models.Option: + properties: + coordinates: + type: string + sheetname: + type: string + type: object models.PlotArea: properties: show_bubble_size: @@ -165,6 +172,13 @@ definitions: locked: type: boolean type: object + models.ReaderOption: + properties: + options: + items: + $ref: '#/definitions/models.Option' + type: array + type: object models.Series: properties: categories: @@ -235,11 +249,11 @@ paths: required: true schema: type: string - - description: Sheets to extract + - description: Reader optional options in: body - name: sheets + name: options schema: - type: string + $ref: '#/definitions/models.ReaderOption' produces: - application/json responses: @@ -266,11 +280,11 @@ paths: required: true schema: type: string - - description: Sheets to extract + - description: Reader optional options in: body - name: sheets + name: options schema: - type: string + $ref: '#/definitions/models.ReaderOption' produces: - application/json responses: diff --git a/go.sum b/go.sum index 5ff93e2..5c9feca 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,7 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/handlers/reader.go b/handlers/reader.go index acf38d9..d4dc8de 100644 --- a/handlers/reader.go +++ b/handlers/reader.go @@ -3,8 +3,8 @@ package handlers import ( "encoding/json" "net/http" - "strings" + "github.com/Los-Crackitos/Excelante/models" "github.com/Los-Crackitos/Excelante/services" ) @@ -15,21 +15,20 @@ import ( // @Accept mpfd // @Produce json // @Param file body string true "The Excel file to convert" -// @Param sheets body string false "Sheets to extract" +// @Param options body models.ReaderOption false "Reader optional options" // @Success 200 {object} services.Output // @Failure 400 {string} string // @Router /read/lines [post] func ReadExcelFileByLine(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") file, _, _ := r.FormFile("file") r.ParseForm() - sheets := r.Form.Get("sheets") + options := r.Form.Get("options") - var sheetsToExtract []string - if sheets != "" { - sheetsToExtract = strings.Split(sheets, ",") - } + readerOptions := models.ReaderOption{} + json.Unmarshal([]byte(options), &readerOptions) - output, err := services.ReadLines(file, sheetsToExtract) + output, err := services.ReadLines(file, readerOptions) if err != nil { http.Error(w, "An error occurred during file reading", http.StatusBadRequest) @@ -37,7 +36,6 @@ func ReadExcelFileByLine(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(output) } @@ -49,21 +47,20 @@ func ReadExcelFileByLine(w http.ResponseWriter, r *http.Request) { // @Accept mpfd // @Produce json // @Param file body string true "The Excel file to convert" -// @Param sheets body string false "Sheets to extract" +// @Param options body models.ReaderOption false "Reader optional options" // @Success 200 {object} services.Output // @Failure 400 {string} string // @Router /read/columns [post] func ReadExcelFileByColumn(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") file, _, _ := r.FormFile("file") r.ParseForm() - sheets := r.Form.Get("sheets") + options := r.Form.Get("options") - var sheetsToExtract []string - if sheets != "" { - sheetsToExtract = strings.Split(sheets, ",") - } + readerOptions := models.ReaderOption{} + json.Unmarshal([]byte(options), &readerOptions) - output, err := services.ReadColumns(file, sheetsToExtract) + output, err := services.ReadColumns(file, readerOptions) if err != nil { http.Error(w, "An error occurred during file reading", http.StatusBadRequest) @@ -71,7 +68,6 @@ func ReadExcelFileByColumn(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(output) } diff --git a/models/reader.go b/models/reader.go new file mode 100644 index 0000000..c2073ee --- /dev/null +++ b/models/reader.go @@ -0,0 +1,12 @@ +package models + +// ReaderOption contains reader optional options +type ReaderOption struct { + Options []Option `json:"options"` +} + +// Option represent a reader option object +type Option struct { + SheetName string `json:"sheet_name"` + StartingCoordinates string `json:"starting_coordinates,omitempty"` +} diff --git a/services/reader.go b/services/reader.go index 388acae..e7626b3 100644 --- a/services/reader.go +++ b/services/reader.go @@ -4,6 +4,8 @@ import ( "mime/multipart" "strings" + "github.com/Los-Crackitos/Excelante/models" + "github.com/360EntSecGroup-Skylar/excelize/v2" ) @@ -12,7 +14,7 @@ type Output map[string]map[int]interface{} // ReadLines read all lines of a given Excel file // Returns all values of the file using the Output type or an error -func ReadLines(file multipart.File, sheetsToExtract []string) (Output, error) { +func ReadLines(file multipart.File, readerOptions models.ReaderOption) (Output, error) { output := make(Output) f, err := excelize.OpenReader(file) @@ -20,9 +22,16 @@ func ReadLines(file multipart.File, sheetsToExtract []string) (Output, error) { return nil, err } + initialColIndex := 1 + initialRowIndex := 1 + sheetFound := false + for _, sheetName := range f.GetSheetMap() { - if len(sheetsToExtract) > 0 && !sheetFinder(sheetName, sheetsToExtract) { - continue + if len(readerOptions.Options) > 0 { + sheetFound, initialColIndex, initialRowIndex = sheetFinder(sheetName, readerOptions) + if !sheetFound { + continue + } } output[sheetName] = make(map[int]interface{}) @@ -40,9 +49,16 @@ func ReadLines(file multipart.File, sheetsToExtract []string) (Output, error) { return nil, err } + if currentRowIndex < initialRowIndex { + currentRowIndex++ + continue + } + rowValue := make([]interface{}, 0) - for _, cellValue := range row { + for i := (initialColIndex - 1); i < len(row); i++ { + cellValue := row[i] + if cellValue == "" { cellValue = "N/A" } @@ -60,7 +76,7 @@ func ReadLines(file multipart.File, sheetsToExtract []string) (Output, error) { // ReadColumns read all columns of a given Excel file // Returns all values of the file using the Output type or an error -func ReadColumns(file multipart.File, sheetsToExtract []string) (Output, error) { +func ReadColumns(file multipart.File, readerOptions models.ReaderOption) (Output, error) { output := make(Output) f, err := excelize.OpenReader(file) @@ -68,9 +84,16 @@ func ReadColumns(file multipart.File, sheetsToExtract []string) (Output, error) return nil, err } + initialColIndex := 1 + initialRowIndex := 1 + sheetFound := false + for _, sheetName := range f.GetSheetMap() { - if len(sheetsToExtract) > 0 && !sheetFinder(sheetName, sheetsToExtract) { - continue + if len(readerOptions.Options) > 0 { + sheetFound, initialColIndex, initialRowIndex = sheetFinder(sheetName, readerOptions) + if !sheetFound { + continue + } } output[sheetName] = make(map[int]interface{}) @@ -88,9 +111,16 @@ func ReadColumns(file multipart.File, sheetsToExtract []string) (Output, error) return nil, err } + if currentColIndex < initialColIndex { + currentColIndex++ + continue + } + rowValue := make([]interface{}, 0) - for _, cellValue := range col { + for i := (initialRowIndex - 1); i < len(col); i++ { + cellValue := col[i] + if cellValue == "" { cellValue = "N/A" } @@ -106,11 +136,15 @@ func ReadColumns(file multipart.File, sheetsToExtract []string) (Output, error) return output, nil } -func sheetFinder(sheetName string, sheetsToExtract []string) bool { - for _, v := range sheetsToExtract { - if v == sheetName { - return true +func sheetFinder(sheetName string, readerOptions models.ReaderOption) (bool, int, int) { + for _, option := range readerOptions.Options { + if option.SheetName == sheetName { + if option.StartingCoordinates != "" { + initialColIndex, initialRowIndex, _ := excelize.CellNameToCoordinates(option.StartingCoordinates) + return true, initialColIndex, initialRowIndex + } + return true, 1, 1 } } - return false + return false, 1, 1 } diff --git a/services/reader_test.go b/services/reader_test.go index bb72608..8aa187c 100644 --- a/services/reader_test.go +++ b/services/reader_test.go @@ -4,18 +4,20 @@ import ( "os" "testing" + "github.com/Los-Crackitos/Excelante/models" + "github.com/stretchr/testify/assert" ) func TestReadLines(t *testing.T) { protectedFile, _ := os.Open("../test/protected_file.xlsx") - protectedFileOutput, protectedFileErr := ReadLines(protectedFile, nil) + protectedFileOutput, protectedFileErr := ReadLines(protectedFile, models.ReaderOption{}) assert.Nil(t, protectedFileOutput) assert.Error(t, protectedFileErr) normalFile, _ := os.Open("../test/input.xlsx") - normalFileOutput, normalFileErr := ReadLines(normalFile, nil) + normalFileOutput, normalFileErr := ReadLines(normalFile, models.ReaderOption{}) assert.NoError(t, normalFileErr) @@ -29,7 +31,39 @@ func TestReadLines(t *testing.T) { assert.Contains(t, normalFileOutput["Feuil1"][4], "N/A", "Fourth Row should contains \"N/A\"") normalFile, _ = os.Open("../test/input.xlsx") - normalFileOutput, normalFileErr = ReadLines(normalFile, []string{"Feuil2"}) + readerOption := models.ReaderOption{ + Options: []models.Option{ + models.Option{ + SheetName: "Feuil2", + StartingCoordinates: "A2", + }, + }, + } + normalFileOutput, normalFileErr = ReadLines(normalFile, readerOption) + + assert.NoError(t, normalFileErr) + + firstRow = normalFileOutput["Feuil2"][1] + assert.Nil(t, firstRow) + + firstRow = normalFileOutput["Feuil2"][2] + + assert.Contains(t, firstRow, "feuil2 A2", "First Row should contains \"feuil2 A2\"") + assert.Contains(t, firstRow, "feuil2 B2", "First Row should contains \"feuil2 B2\"") + assert.NotContains(t, firstRow, "Cell A2", "First Row should not contains \"Cell A2\"") + assert.NotContains(t, firstRow, "Cell B3", "First Row should not contains \"Cell B3\"") + + assert.Contains(t, normalFileOutput["Feuil2"][3], "N/A", "Third Row should contains \"N/A\"") + + normalFile, _ = os.Open("../test/input.xlsx") + readerOption = models.ReaderOption{ + Options: []models.Option{ + models.Option{ + SheetName: "Feuil2", + }, + }, + } + normalFileOutput, normalFileErr = ReadLines(normalFile, readerOption) assert.NoError(t, normalFileErr) @@ -41,18 +75,17 @@ func TestReadLines(t *testing.T) { assert.NotContains(t, firstRow, "Cell B3", "First Row should not contains \"Cell B3\"") assert.Contains(t, normalFileOutput["Feuil2"][3], "N/A", "Third Row should contains \"N/A\"") - } func TestReadColumns(t *testing.T) { protectedFile, _ := os.Open("../test/protected_file.xlsx") - protectedFileOutput, protectedFileErr := ReadColumns(protectedFile, nil) + protectedFileOutput, protectedFileErr := ReadColumns(protectedFile, models.ReaderOption{}) assert.Nil(t, protectedFileOutput) assert.Error(t, protectedFileErr) normalFile, _ := os.Open("../test/input.xlsx") - normalFileOutput, normalFileErr := ReadColumns(normalFile, nil) + normalFileOutput, normalFileErr := ReadColumns(normalFile, models.ReaderOption{}) assert.NoError(t, normalFileErr) @@ -66,7 +99,41 @@ func TestReadColumns(t *testing.T) { assert.Contains(t, firstCol, "N/A", "Fourth Col should contains \"N/A\"") normalFile, _ = os.Open("../test/input.xlsx") - normalFileOutput, normalFileErr = ReadColumns(normalFile, []string{"Feuil2"}) + readerOption := models.ReaderOption{ + Options: []models.Option{ + models.Option{ + SheetName: "Feuil2", + StartingCoordinates: "B2", + }, + }, + } + + normalFileOutput, normalFileErr = ReadColumns(normalFile, readerOption) + + assert.NoError(t, normalFileErr) + + firstCol = normalFileOutput["Feuil2"][1] + assert.Nil(t, firstCol) + + firstCol = normalFileOutput["Feuil2"][2] + + assert.Contains(t, firstCol, "feuil2 B2", "First Col should contains \"feuil2 B2\"") + assert.Contains(t, firstCol, "feuil2 B3", "First Col should contains \"feuil2 B3\"") + assert.NotContains(t, firstCol, "Cell B1", "First Col should not contains \"Cell A2\"") + assert.NotContains(t, firstCol, "Cell B2", "First Col should not contains \"Cell B3\"") + + assert.Contains(t, firstCol, "N/A", "Third Col should contains \"N/A\"") + + normalFile, _ = os.Open("../test/input.xlsx") + readerOption = models.ReaderOption{ + Options: []models.Option{ + models.Option{ + SheetName: "Feuil2", + }, + }, + } + + normalFileOutput, normalFileErr = ReadColumns(normalFile, readerOption) assert.NoError(t, normalFileErr) @@ -78,5 +145,4 @@ func TestReadColumns(t *testing.T) { assert.NotContains(t, firstCol, "Cell B2", "First Col should not contains \"Cell B3\"") assert.Contains(t, firstCol, "N/A", "Third Col should contains \"N/A\"") - } diff --git a/test/input.xlsx b/test/input.xlsx index 12c62bae8a791873db8582a1a1db67383f0a8118..a0ea85f42408ca4928a4e0e09c719d74a7927831 100644 GIT binary patch delta 2144 zcmY*ac{tRG8y_=^Fc_jGw=v`xgdCA|#10~wMxwCe7!0|ub&Q)S8GI>sk@y*r`^uS} z+$@7(oYRy@g`A~W+w<)Ho_+uMJkR@np7)RU_!zP@gNSa`EsFR=S@}#x2H+{KBPPO90ZAE@!RxN=w=S>lyI`FpD{UKw zJ(SJQ6mt_Q$EQ{gn}-_HpQ}qbB0=ZYPa(6(Mtv1jgrY~3tb%QvyRxgHp!eIC6^a8M zwkpDClTR%|>{f>1#ES{mMR8Iqfkkz-sQkEWG9*K(oO6|C;W%~4KBEYH!}<&TdWgbZ zPDyvYcU~?A8JJQ=kPW99PA6f3f5{8+4SaAK8c&#u08eF9#+6^2#8!`j=xZnm%GDa< zenU<=Q?wxIjZS7&OEn`c8`{~gF!LL0glgQe$bAERt|Nz3xYOnB(D_>$Z3Wu}mx8PC zkA>BR)#ayG+)$2-k!s)gUH~&8eKIv2#CNm) zFsS#3@BtWeTS{qB3aNGqZ79+>pY)^{{$>pvX}d7dM1zTH>djHZdguD4x66xZ9qWEO zJyEZ{Z*M?o4wX?WZnD#|C6--#;1pjU0m7IbdgxSZbWCX7qg0g#-46~r*^TlPIm`%yj6Ifq6<>^x;4t$hL2Jcs_r>B4 znXb!^fUVs>t_I!Sof2v~>wiQ^>+&0(OC_7WoZ>3<*eezXs)FPSLgRc&c&gJjX)`w3*Z_yFlRXjukbQ|9VCDJI!r}9at6=s6XV2fO6P|34w_20RAE_g$t;`UfvLo)?~=C$vjmO7iP<&&k3}w0+X+dsKvg4Rst>9u<5TSu7)r z=(23mSeMEzSNMqmy;Xb3ZphJIz9jRt#X;9Wr)hgNY#;MFZ#c?w<*2r-C;6=Z&6{G* z=bPMa6ymHyat+rGA=M|K6AvA%#ckr|oeWBOpXmcVJc69X?HI7%$%nJE0gr_x3A1T* zRe_`p#a%I_CF3(-s7>a0&E>*-<`^2DSpdWH>o#&j$zMNR;xW!iHqgmq7s& zgLKV3yM=MTVFk@m$S+KHFQMr#?|&D4l`1Mol~#1o4mG?;F6}~ zN4}C`_cPmC*&ZXr=}o*JH740+~RjL}k@ton%>Q+~XbTTLOwBQnPGpmI) zX1swPC$?+_ zcjAR)5vk%KqL2{HIg^jWV`smLOm-t5n;H=7-`Q8N{~SglMatwUck>B1W7)a>x1Ke6 zUT6CSwHv`1*70(C*9=M6fPOatE~wX7@2c3$a3Z#PN(4%uin8ok$!e? z)DUgHGmbri(QF$>Z=ffg6!DlqIwp(({z{R+I$1pZ;K__!bYjf@0>lanvibxwu=@4d zqOvT6A+KIln8|+PQT+JgG<+wvM$E&)mRsQc#6)4bEU^N?WmW}rJ@=NYhQuo4K27E7 z#(#e6s5QW=?bKXmpaDO}0(ZdV`@}AiF~5`eB@%2(Jh=r{LE^}v^~ziJ$`0%*)s4$W z-1LHe{1gG!f}++K-%uCGZ%G34x(%^Y`u<8X-fM-_dctXwpW;mow^YYA-1e4^dD-&vG;o94EFP2t-{ zf2)m-7PGa)Q&`j+b~>x; z?=jYs#WNrr|C%*$S=x$wmFtIH2L^?Ey9ENv(gMIV59}oOPxJz#(grLHfETVP@~{4# z2!X&qgdgYvVBm71{}%}mNa#QHL}3617X+%{f+9aw$pQk|oHXJ44@nVNfa|k721I4x KY(7$dm;M4<>hRP6 delta 2187 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 d|54E7auVZzkpwVb5vnBF7#@BE1(NqS^cPww