From 5e7ff8ca5adfffcac1ab74eb0e95444befe77f72 Mon Sep 17 00:00:00 2001 From: Alexander Buntakov Date: Fri, 10 Feb 2023 00:15:01 +0300 Subject: [PATCH] add google sheets --- build.gradle.kts | 3 + settings.gradle.kts | 2 + spreadsheets-google/build.gradle.kts | 20 ++ .../spreadsheets/EnableGoogleSpreadSheets.kt | 8 + .../GoogleSpreadSheetsConfiguration.kt | 52 ++++ .../spreadsheets/google/enums/Dimension.kt | 6 + .../google/enums/ValueInputOption.kt | 11 + .../google/enums/ValueRenderOption.kt | 12 + .../GoogleCredentialsMissingException.kt | 5 + .../exceptions/GoogleSheetMissingException.kt | 18 ++ ...nvalidGoogleSpreadsheetsSourceException.kt | 7 + .../GoogleSpreadsheetsProperties.kt | 13 + .../services/GoogleSheetsUrlServiceImpl.kt | 37 +++ .../GoogleSpreadsheetBuilderFactoryImpl.kt | 17 ++ .../services/GoogleSpreadsheetBuilderImpl.kt | 40 +++ .../services/GoogleSpreadsheetServiceImpl.kt | 244 ++++++++++++++++++ .../GoogleSpreadSheetsFormulaParserUtil.kt | 43 +++ .../google/GoogleSheetsTestApplication.kt | 12 + .../GoogleSheetsUrlServiceImplTest.kt | 96 +++++++ .../GoogleSheetsFormulaParserUtilTest.kt | 53 ++++ spreadsheets/build.gradle.kts | 8 + .../services/SpreadsheetBuilder.kt | 12 + .../services/SpreadsheetBuilderFactory.kt | 7 + .../services/SpreadsheetService.kt | 24 ++ .../services/SpreadsheetsUrlService.kt | 12 + .../spreadsheets/services/dto/CellType.kt | 10 + .../spreadsheets/services/dto/SheetCell.kt | 6 + .../spreadsheets/services/dto/SheetHeader.kt | 9 + .../spreadsheets/services/dto/SheetRange.kt | 8 + .../spreadsheets/services/dto/Spreadsheet.kt | 7 + .../services/dto/SpreadsheetSource.kt | 17 ++ .../services/dto/cells/SheetNullCell.kt | 30 +++ .../services/dto/cells/SheetNumberCell.kt | 10 + .../services/dto/cells/SheetStringCell.kt | 10 + 34 files changed, 869 insertions(+) create mode 100644 spreadsheets-google/build.gradle.kts create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/EnableGoogleSpreadSheets.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/configurations/GoogleSpreadSheetsConfiguration.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/Dimension.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueInputOption.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueRenderOption.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleCredentialsMissingException.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleSheetMissingException.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/InvalidGoogleSpreadsheetsSourceException.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/properties/GoogleSpreadsheetsProperties.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSheetsUrlServiceImpl.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderFactoryImpl.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderImpl.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetServiceImpl.kt create mode 100644 spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/utils/GoogleSpreadSheetsFormulaParserUtil.kt create mode 100644 spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/GoogleSheetsTestApplication.kt create mode 100644 spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/services/GoogleSheetsUrlServiceImplTest.kt create mode 100644 spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/utils/GoogleSheetsFormulaParserUtilTest.kt create mode 100644 spreadsheets/build.gradle.kts create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilder.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilderFactory.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetService.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetsUrlService.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/CellType.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetCell.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetHeader.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetRange.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/Spreadsheet.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SpreadsheetSource.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNullCell.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNumberCell.kt create mode 100644 spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetStringCell.kt diff --git a/build.gradle.kts b/build.gradle.kts index a5c0d14..7fd36d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -90,6 +90,9 @@ subprojects { dependency("software.amazon.awssdk:s3:2.10.11") dependency("com.google.firebase:firebase-admin:9.0.0") + dependency("com.google.api-client:google-api-client:1.33.0") + dependency("com.google.apis:google-api-services-sheets:v4-rev20210629-1.32.1") + dependency("com.google.auth:google-auth-library-oauth2-http:1.3.0") dependency("com.github.ua-parser:uap-java:1.5.3") } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5636de6..73680a2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -62,3 +62,5 @@ include("geoip-core") include("user-agent") include("smart-migration") include("telegram-bot-spring") +include("spreadsheets") +include("spreadsheets-google") diff --git a/spreadsheets-google/build.gradle.kts b/spreadsheets-google/build.gradle.kts new file mode 100644 index 0000000..43d6919 --- /dev/null +++ b/spreadsheets-google/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("kotlin") + id("kotlin-spring") +} + +dependencies { + api(project(":spreadsheets")) + + implementation(project(":common")) + + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + implementation("org.springframework.boot:spring-boot") + + implementation("com.google.api-client:google-api-client") + implementation("com.google.apis:google-api-services-sheets") + implementation("com.google.auth:google-auth-library-oauth2-http") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/EnableGoogleSpreadSheets.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/EnableGoogleSpreadSheets.kt new file mode 100644 index 0000000..1441195 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/EnableGoogleSpreadSheets.kt @@ -0,0 +1,8 @@ +package ru.touchin.spreadsheets + +import org.springframework.context.annotation.Import +import ru.touchin.spreadsheets.google.configurations.GoogleSpreadSheetsConfiguration + +@Suppress("unused") +@Import(value = [GoogleSpreadSheetsConfiguration::class]) +annotation class EnableGoogleSpreadSheets diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/configurations/GoogleSpreadSheetsConfiguration.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/configurations/GoogleSpreadSheetsConfiguration.kt new file mode 100644 index 0000000..8a607f1 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/configurations/GoogleSpreadSheetsConfiguration.kt @@ -0,0 +1,52 @@ +package ru.touchin.spreadsheets.google.configurations + +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.sheets.v4.Sheets +import com.google.api.services.sheets.v4.SheetsScopes +import com.google.auth.http.HttpCredentialsAdapter +import com.google.auth.oauth2.GoogleCredentials +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.Configuration +import ru.touchin.spreadsheets.google.exceptions.GoogleCredentialsMissingException +import ru.touchin.spreadsheets.google.properties.GoogleSpreadsheetsProperties +import java.io.File +import java.io.InputStream + +@Configuration +@ComponentScan("ru.touchin.spreadsheets.google") +@ConfigurationPropertiesScan("ru.touchin.spreadsheets.google") +class GoogleSpreadSheetsConfiguration { + + @Bean + fun sheets(googleSheetsProperties: GoogleSpreadsheetsProperties): Sheets { + val transport = NetHttpTransport.Builder().build() + val jsonFactory = GsonFactory.getDefaultInstance() + val initializer = HttpCredentialsAdapter( + GoogleCredentials.fromStream(getCredentialsStream(googleSheetsProperties)) + .createScoped(SheetsScopes.SPREADSHEETS) + .let { credentials -> + googleSheetsProperties.delegate + ?.let { user -> + credentials.createDelegated(user) + } + ?: credentials + } + ) + + return Sheets.Builder(transport, jsonFactory, initializer) + .setApplicationName(googleSheetsProperties.appName) + .build() + } + + private fun getCredentialsStream(googleSheetsProperties: GoogleSpreadsheetsProperties): InputStream { + return googleSheetsProperties.credentialsPath + ?.takeIf(String::isNotBlank) + ?.let { File(it).inputStream() } + ?: googleSheetsProperties.credentials?.byteInputStream() + ?: throw GoogleCredentialsMissingException() + } + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/Dimension.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/Dimension.kt new file mode 100644 index 0000000..2531734 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/Dimension.kt @@ -0,0 +1,6 @@ +package ru.touchin.spreadsheets.google.enums + +enum class Dimension(val value: String) { + Rows("ROWS"), + Columns("COLUMNS"); +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueInputOption.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueInputOption.kt new file mode 100644 index 0000000..245da25 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueInputOption.kt @@ -0,0 +1,11 @@ +package ru.touchin.spreadsheets.google.enums + +/** + * @see Documentation + */ +enum class ValueInputOption(val code: String) { + + RAW("RAW"), + USER_ENTERED("USER_ENTERED"), + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueRenderOption.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueRenderOption.kt new file mode 100644 index 0000000..523b0e1 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/enums/ValueRenderOption.kt @@ -0,0 +1,12 @@ +package ru.touchin.spreadsheets.google.enums + +/** + * @see Documentation + */ +enum class ValueRenderOption(val code: String) { + + FORMATTED_VALUE("FORMATTED_VALUE"), + UNFORMATTED_VALUE("UNFORMATTED_VALUE"), + FORMULA("FORMULA"), + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleCredentialsMissingException.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleCredentialsMissingException.kt new file mode 100644 index 0000000..ea9608e --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleCredentialsMissingException.kt @@ -0,0 +1,5 @@ +package ru.touchin.spreadsheets.google.exceptions + +import ru.touchin.common.exceptions.CommonException + +class GoogleCredentialsMissingException : CommonException("Google credentials are not set") diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleSheetMissingException.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleSheetMissingException.kt new file mode 100644 index 0000000..59f75a5 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/GoogleSheetMissingException.kt @@ -0,0 +1,18 @@ +package ru.touchin.spreadsheets.google.exceptions + +import ru.touchin.common.exceptions.CommonException +import ru.touchin.spreadsheets.services.dto.SpreadsheetSource + +class GoogleSheetMissingException private constructor( + val source: SpreadsheetSource, + message: String, +): CommonException(message) { + + companion object { + fun create(source: SpreadsheetSource) = GoogleSheetMissingException( + source = source, + message = "Missing sheet '${source.sheetId}' in spreadsheet '${source.spreadsheetId}", + ) + } + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/InvalidGoogleSpreadsheetsSourceException.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/InvalidGoogleSpreadsheetsSourceException.kt new file mode 100644 index 0000000..f54d677 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/exceptions/InvalidGoogleSpreadsheetsSourceException.kt @@ -0,0 +1,7 @@ +package ru.touchin.spreadsheets.google.exceptions + +import ru.touchin.common.exceptions.CommonException + +class InvalidGoogleSpreadsheetsSourceException( + url: String, +) : CommonException("Unable to parse source $url") diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/properties/GoogleSpreadsheetsProperties.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/properties/GoogleSpreadsheetsProperties.kt new file mode 100644 index 0000000..88da77d --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/properties/GoogleSpreadsheetsProperties.kt @@ -0,0 +1,13 @@ +package ru.touchin.spreadsheets.google.properties + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding + +@ConstructorBinding +@ConfigurationProperties(prefix = "spreadsheets.google") +data class GoogleSpreadsheetsProperties( + val appName: String, + val credentials: String? = null, + val credentialsPath: String? = null, + val delegate: String? = null, +) diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSheetsUrlServiceImpl.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSheetsUrlServiceImpl.kt new file mode 100644 index 0000000..43b91d0 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSheetsUrlServiceImpl.kt @@ -0,0 +1,37 @@ +package ru.touchin.spreadsheets.google.services + +import org.springframework.stereotype.Service +import ru.touchin.spreadsheets.google.exceptions.InvalidGoogleSpreadsheetsSourceException +import ru.touchin.spreadsheets.services.SpreadsheetsUrlService +import ru.touchin.spreadsheets.services.dto.SpreadsheetSource +import java.net.URL + +@Service +class GoogleSheetsUrlServiceImpl : SpreadsheetsUrlService { + + override fun parse(url: URL): SpreadsheetSource { + return parse(url.toString()) + } + + override fun parse(url: String): SpreadsheetSource { + val groups = spreadSheetUrlRegex.find(url)?.groups + ?: throw InvalidGoogleSpreadsheetsSourceException(url) + + val spreadSheetId = groups["spreadsheetId"]?.value + ?: throw InvalidGoogleSpreadsheetsSourceException(url) + + val sheetId = groups["sheetId"]?.value?.toInt() + ?: 0 + + return SpreadsheetSource(spreadsheetId = spreadSheetId, sheetId = sheetId) + } + + private companion object { + + val spreadSheetUrlRegex = Regex( + "https://docs\\.google\\.com/spreadsheets/d/(?[\\da-zA-Z-_]+)/edit(?:#gid=(?\\d+))?", + ) + + } + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderFactoryImpl.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderFactoryImpl.kt new file mode 100644 index 0000000..8737768 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderFactoryImpl.kt @@ -0,0 +1,17 @@ +package ru.touchin.spreadsheets.google.services + +import org.springframework.beans.factory.ObjectFactory +import org.springframework.stereotype.Service +import ru.touchin.spreadsheets.services.SpreadsheetBuilder +import ru.touchin.spreadsheets.services.SpreadsheetBuilderFactory + +@Service +class GoogleSpreadsheetBuilderFactoryImpl( + private val spreadsheetBuilderFactory: ObjectFactory, +) : SpreadsheetBuilderFactory { + + override fun create(): SpreadsheetBuilder { + return spreadsheetBuilderFactory.`object` + } + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderImpl.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderImpl.kt new file mode 100644 index 0000000..743dc6e --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetBuilderImpl.kt @@ -0,0 +1,40 @@ +package ru.touchin.spreadsheets.google.services + +import com.google.api.services.sheets.v4.Sheets +import org.springframework.stereotype.Component +import ru.touchin.spreadsheets.services.SpreadsheetBuilder +import ru.touchin.spreadsheets.services.SpreadsheetService +import ru.touchin.spreadsheets.services.SpreadsheetsUrlService +import java.net.URL + +@Component +class GoogleSpreadsheetBuilderImpl( + private val spreadsheetsUrlService: SpreadsheetsUrlService, + private val sheets: Sheets, +) : SpreadsheetBuilder { + + private var sheetId: Int? = null + + private var autoInit: Boolean = true + + override fun setSheetId(sheetId: Int): SpreadsheetBuilder = this.also { + it.sheetId = sheetId + } + + override fun setAutoInit(autoInit: Boolean): SpreadsheetBuilder = this.also { + it.autoInit = autoInit + } + + override fun build(url: URL): SpreadsheetService { + val source = spreadsheetsUrlService.parse(url) + val sheetId = this.sheetId ?: source.sheetId + + return GoogleSpreadsheetServiceImpl(source.copy(sheetId = sheetId), sheets) + .also { + if (autoInit) { + it.init() + } + } + } + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetServiceImpl.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetServiceImpl.kt new file mode 100644 index 0000000..ad88b40 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/services/GoogleSpreadsheetServiceImpl.kt @@ -0,0 +1,244 @@ +package ru.touchin.spreadsheets.google.services + +import com.google.api.services.sheets.v4.Sheets +import com.google.api.services.sheets.v4.model.BatchGetValuesByDataFilterRequest +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest +import com.google.api.services.sheets.v4.model.CellData +import com.google.api.services.sheets.v4.model.DataFilter +import com.google.api.services.sheets.v4.model.DimensionRange +import com.google.api.services.sheets.v4.model.ExtendedValue +import com.google.api.services.sheets.v4.model.GridCoordinate +import com.google.api.services.sheets.v4.model.GridRange +import com.google.api.services.sheets.v4.model.InsertDimensionRequest +import com.google.api.services.sheets.v4.model.Request +import com.google.api.services.sheets.v4.model.RowData +import com.google.api.services.sheets.v4.model.UpdateCellsRequest +import ru.touchin.spreadsheets.google.enums.Dimension +import ru.touchin.spreadsheets.google.enums.ValueRenderOption +import ru.touchin.spreadsheets.google.exceptions.GoogleSheetMissingException +import ru.touchin.spreadsheets.services.SpreadsheetService +import ru.touchin.spreadsheets.services.dto.CellType +import ru.touchin.spreadsheets.services.dto.SheetCell +import ru.touchin.spreadsheets.services.dto.SheetHeader +import ru.touchin.spreadsheets.services.dto.SheetRange +import ru.touchin.spreadsheets.services.dto.Spreadsheet +import ru.touchin.spreadsheets.services.dto.SpreadsheetSource +import ru.touchin.spreadsheets.services.dto.cells.SheetStringCell +import java.math.BigDecimal +import kotlin.math.max + +internal class GoogleSpreadsheetServiceImpl( + private val source: SpreadsheetSource, + private val sheets: Sheets, +) : SpreadsheetService { + + private lateinit var spreadsheet: Spreadsheet + + override fun init(): Spreadsheet { + val spreadsheet = sheets.spreadsheets() + .get(source.spreadsheetId) + .execute() + + val sheet = spreadsheet.sheets + .find { it.properties.sheetId == source.sheetId } + ?: throw GoogleSheetMissingException.create(source) + + val gridProperties = sheet.properties.gridProperties + + return Spreadsheet( + rowCount = gridProperties.rowCount, + columnCount = gridProperties.columnCount, + locale = spreadsheet.properties.locale, + ).also { + this.spreadsheet = it + } + } + + override fun getSheet(): Spreadsheet { + return spreadsheet.copy() + } + + override fun getHeader(): SheetHeader { + val values = getValues(source.headerRange) + .map { rows -> + rows.map { SheetStringCell(it.toString()) } + } + + return SheetHeader(values) + } + + override fun createHeader(header: SheetHeader) { + updateValues(source.headerRange, header.values) + } + + override fun getContentColumn(column: Int): List { + return getValues( + getContentRange().copy( + startColumnIndex = column, + endColumnIndex = column + 1, + ), + dimension = Dimension.Columns, + ) + .firstOrNull() + ?: emptyList() + } + + override fun updateRows(range: SheetRange, values: List>) { + return updateValues(range, values) + } + + override fun updateContentRows(startRowIndex: Int, values: List>) { + updateRows( + range = SheetRange( + startColumnIndex = 0, + startRowIndex = startRowIndex + (source.headerRange.endRowIndex ?: 0), + ), + values = values, + ) + } + + private fun updateValues(range: SheetRange, values: List>) { + val maxColumnIndex = getMaxIndex(values, Dimension.Columns) + range.startColumnIndex + val maxRowIndex = getMaxIndex(values, Dimension.Rows) + range.startRowIndex + + val requests = mutableListOf() + + if (maxColumnIndex > spreadsheet.columnCount) { + requests.add( + createInsertRequest( + insertCount = maxColumnIndex - spreadsheet.columnCount, + dimension = Dimension.Columns, + ) + ) + } + + if (maxRowIndex > spreadsheet.rowCount) { + requests.add( + createInsertRequest( + insertCount = maxRowIndex - spreadsheet.rowCount, + dimension = Dimension.Rows, + ) + ) + } + + requests.add( + createUpdateCellsRequest(range, values) + ) + + sheets.spreadsheets() + .batchUpdate( + source.spreadsheetId, + BatchUpdateSpreadsheetRequest().apply { + this.requests = requests + } + ) + .execute() + + spreadsheet = spreadsheet.copy( + columnCount = max(spreadsheet.columnCount, maxColumnIndex), + rowCount = max(spreadsheet.rowCount, maxRowIndex), + ) + } + + private fun createUpdateCellsRequest(range: SheetRange, values: List>): Request { + return Request().setUpdateCells( + UpdateCellsRequest().apply { + fields = "*" + start = GridCoordinate().apply { + sheetId = source.sheetId + columnIndex = range.startColumnIndex + rowIndex = range.startRowIndex + } + rows = values.map { rows -> + RowData().setValues( + rows.map { row -> + row.toCellData() + } + ) + } + } + ) + } + + private fun getContentRange(): SheetRange { + return SheetRange( + startColumnIndex = 0, + startRowIndex = source.headerRange.endRowIndex ?: 0, + ) + } + + private fun SheetCell.toCellData(): CellData { + val value = ExtendedValue() + + when(type) { + CellType.String -> value.stringValue = this.value.toString() + CellType.Null -> value.stringValue = null + CellType.Date -> value.stringValue = this.value.toString() + CellType.DateTime -> value.stringValue = this.value.toString() + CellType.Price -> value.numberValue = (this.value as BigDecimal).toDouble() + CellType.Number -> value.numberValue = (this.value as Number).toDouble() + } + + return CellData().also { it.userEnteredValue = value } + } + + private fun createInsertRequest(insertCount: Int, dimension: Dimension): Request { + val startIndex = when(dimension) { + Dimension.Rows -> spreadsheet.rowCount - 1 + Dimension.Columns -> spreadsheet.columnCount - 1 + } + + return Request().setInsertDimension( + InsertDimensionRequest() + .setRange( + DimensionRange() + .setSheetId(source.sheetId) + .setDimension(dimension.value) + .setStartIndex(startIndex) + .setEndIndex(startIndex + insertCount) + ) + ) + } + + private fun getMaxIndex(values: List>, dimension: Dimension): Int { + return when (dimension) { + Dimension.Rows -> values.size + Dimension.Columns -> values.maxOf { it.size } + } + } + + private fun getValues(range: SheetRange, dimension: Dimension = Dimension.Rows): List> { + val values = sheets.spreadsheets() + .values() + .batchGetByDataFilter( + source.spreadsheetId, + BatchGetValuesByDataFilterRequest().apply { + majorDimension = dimension.value + valueRenderOption = ValueRenderOption.FORMATTED_VALUE.code + dataFilters = listOf( + DataFilter().apply { + gridRange = range.toGridRange() + } + ) + } + ) + .execute() + .valueRanges + .first() + .valueRange + .getValues() + + return values?.toList() ?: emptyList() + } + + private fun SheetRange.toGridRange(): GridRange { + return GridRange().also { + it.sheetId = source.sheetId + it.startColumnIndex = startColumnIndex + it.startRowIndex = startRowIndex + it.endColumnIndex = endColumnIndex + it.endRowIndex = endRowIndex + } + } + +} diff --git a/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/utils/GoogleSpreadSheetsFormulaParserUtil.kt b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/utils/GoogleSpreadSheetsFormulaParserUtil.kt new file mode 100644 index 0000000..30bd2b2 --- /dev/null +++ b/spreadsheets-google/src/main/kotlin/ru/touchin/spreadsheets/google/utils/GoogleSpreadSheetsFormulaParserUtil.kt @@ -0,0 +1,43 @@ +package ru.touchin.spreadsheets.google.utils + +import java.net.URL + +object GoogleSpreadSheetsFormulaParserUtil { + + private const val HYPERLINK_REGEX_LINK_INDEX: Int = 1 + private const val RUS_LOCALE = "ru_RU" + private const val DEFAULT_DIVIDER = "," + private const val RUS_DIVIDER = ";" + + fun isFormulaHyperlink(jiraIssueLink: String, locale: String): Boolean { + return getHyperLinkRegexByLocale(locale).matches(jiraIssueLink) + } + + fun asFormulaHyperlink(url: URL, label: String, locale: String): String { + val divider = getFormulaeDividerByLocale(locale) + return "=HYPERLINK(\"$url\"$divider\"$label\")" + } + + fun fromFormulaHyperlink(hyperlink: String, locale: String): URL { + return getHyperLinkRegexByLocale(locale).matchEntire(hyperlink) + ?.groupValues + ?.get(HYPERLINK_REGEX_LINK_INDEX) + ?.trim { it == '"' } + ?.let { URL(it) } + ?: throw IllegalStateException("Could not parse hyperlink \"$hyperlink\"") + } + + private fun getHyperLinkRegexByLocale(locale: String): Regex { + val divider = getFormulaeDividerByLocale(locale) + return Regex("=HYPERLINK\\((.*)$divider(.*)\\)") + } + + private fun getFormulaeDividerByLocale(locale: String): String { + return if (locale == RUS_LOCALE) { + RUS_DIVIDER + } else { + DEFAULT_DIVIDER + } + } + +} diff --git a/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/GoogleSheetsTestApplication.kt b/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/GoogleSheetsTestApplication.kt new file mode 100644 index 0000000..728afee --- /dev/null +++ b/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/GoogleSheetsTestApplication.kt @@ -0,0 +1,12 @@ +package ru.touchin.sheets.google + +import org.springframework.boot.SpringBootConfiguration +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.ComponentScan + +@ComponentScan +@TestConfiguration +@SpringBootConfiguration +@EnableAutoConfiguration +class GoogleSheetsTestApplication diff --git a/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/services/GoogleSheetsUrlServiceImplTest.kt b/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/services/GoogleSheetsUrlServiceImplTest.kt new file mode 100644 index 0000000..8c3d782 --- /dev/null +++ b/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/services/GoogleSheetsUrlServiceImplTest.kt @@ -0,0 +1,96 @@ +package ru.touchin.sheets.google.services + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import ru.touchin.spreadsheets.google.exceptions.InvalidGoogleSpreadsheetsSourceException +import ru.touchin.spreadsheets.google.services.GoogleSheetsUrlServiceImpl +import ru.touchin.spreadsheets.services.SpreadsheetsUrlService +import ru.touchin.spreadsheets.services.dto.SpreadsheetSource + +@SpringBootTest( + classes = [ + GoogleSheetsUrlServiceImpl::class, + ] +) +internal class GoogleSheetsUrlServiceImplTest { + + @Autowired + lateinit var sheetsUrlService: SpreadsheetsUrlService + + @Test + fun shouldParseOk() { + val expectedSpreadsheetSource = SpreadsheetSource( + spreadsheetId = "1KgQTBmFq-yddGotA5PUd5SRkg-J3dtdULpRNz7uctAE", + sheetId = 1, + ) + + val url = "https://docs.google.com/spreadsheets/d/${expectedSpreadsheetSource.spreadsheetId}/edit#gid=${expectedSpreadsheetSource.sheetId}" + + val text1 = """ + Test + Test $url + ssss + """.trimIndent() + + val actualSpreadsheetSource1 = sheetsUrlService.parse(text1) + + assertEquals(expectedSpreadsheetSource, actualSpreadsheetSource1) + + val text2 = """ + Test ($url) + """.trimIndent() + + val actualSpreadsheetSource2 = sheetsUrlService.parse(text2) + + assertEquals(expectedSpreadsheetSource, actualSpreadsheetSource2) + } + + @Test + fun shouldParseOk2() { + val expectedSpreadsheetSource = SpreadsheetSource( + spreadsheetId = "1KgQTBmFq-yddGotA5PUd5SRkg-J3dtdULpRNz7uctAE", + sheetId = 0, + ) + + val text = """ + Test + Test https://docs.google.com/spreadsheets/d/${expectedSpreadsheetSource.spreadsheetId}/edit Test + Test + """.trimIndent() + + val actualSpreadsheetSource1 = sheetsUrlService.parse(text) + + assertEquals(expectedSpreadsheetSource, actualSpreadsheetSource1) + } + + @Test + fun shouldThrowException() { + val text1 = """ + Test + """.trimIndent() + + assertThrows(InvalidGoogleSpreadsheetsSourceException::class.java) { + sheetsUrlService.parse(text1) + } + + val text2 = """ + Test (https://dcs.google.com/spreadsheets/d/1KgQTBmFq-yddGotA5PUd5SRkg-J3dtdULpRNz7uctAE/edit#gid=0) + """.trimIndent() + + assertThrows(InvalidGoogleSpreadsheetsSourceException::class.java) { + sheetsUrlService.parse(text2) + } + + val text3 = """ + Test (https://dcs.google.com/spreadsheets/d/1KgQTBmFq-yddGotA5PUd5SRkg-J + 3dtdULpRNz7uctAE/edit#gid=0) + """.trimIndent() + + assertThrows(InvalidGoogleSpreadsheetsSourceException::class.java) { + sheetsUrlService.parse(text3) + } + } + +} diff --git a/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/utils/GoogleSheetsFormulaParserUtilTest.kt b/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/utils/GoogleSheetsFormulaParserUtilTest.kt new file mode 100644 index 0000000..efcbb41 --- /dev/null +++ b/spreadsheets-google/src/test/kotlin/ru/touchin/sheets/google/utils/GoogleSheetsFormulaParserUtilTest.kt @@ -0,0 +1,53 @@ +package ru.touchin.sheets.google.utils + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import ru.touchin.spreadsheets.google.utils.GoogleSpreadSheetsFormulaParserUtil +import java.net.URL + +class GoogleSheetsFormulaParserUtilTest { + + private val url = URL("https://example.com") + + @Test + fun isFormulaHyperlink_trueIfCorrectSyntax() { + val text = "=HYPERLINK(someurl;label)" + + val result = GoogleSpreadSheetsFormulaParserUtil.isFormulaHyperlink(text, "ru_RU") + + Assertions.assertTrue(result) + } + + @Test + fun isFormulaHyperlink_falseIfUrl() { + val result = GoogleSpreadSheetsFormulaParserUtil.isFormulaHyperlink(url.toString(), "ru_RU") + + Assertions.assertFalse(result) + } + + @Test + fun asFormulaHyperlink_correct() { + val label = "link" + + val expected = "=HYPERLINK(\"$url\";\"$label\")" + val actual = GoogleSpreadSheetsFormulaParserUtil.asFormulaHyperlink(url, label, "ru_RU") + + Assertions.assertEquals( + expected, + actual + ) + } + + @Test + fun fromFormulaHyperlink_correct() { + val expected = url.toString() + val actual = GoogleSpreadSheetsFormulaParserUtil.fromFormulaHyperlink("=HYPERLINK(\"$url\";\"label\")", "ru_RU").toString() + + + Assertions.assertEquals( + expected, + actual + ) + } + +} diff --git a/spreadsheets/build.gradle.kts b/spreadsheets/build.gradle.kts new file mode 100644 index 0000000..70c2616 --- /dev/null +++ b/spreadsheets/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("kotlin") + id("kotlin-spring") +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilder.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilder.kt new file mode 100644 index 0000000..6f9c5b6 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilder.kt @@ -0,0 +1,12 @@ +package ru.touchin.spreadsheets.services + +import java.net.URL + +interface SpreadsheetBuilder { + + fun setSheetId(sheetId: Int): SpreadsheetBuilder + fun setAutoInit(autoInit: Boolean): SpreadsheetBuilder + + fun build(url: URL): SpreadsheetService + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilderFactory.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilderFactory.kt new file mode 100644 index 0000000..b2ed42f --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetBuilderFactory.kt @@ -0,0 +1,7 @@ +package ru.touchin.spreadsheets.services + +interface SpreadsheetBuilderFactory { + + fun create(): SpreadsheetBuilder + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetService.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetService.kt new file mode 100644 index 0000000..17513dc --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetService.kt @@ -0,0 +1,24 @@ +package ru.touchin.spreadsheets.services + +import ru.touchin.spreadsheets.services.dto.SheetCell +import ru.touchin.spreadsheets.services.dto.SheetHeader +import ru.touchin.spreadsheets.services.dto.SheetRange +import ru.touchin.spreadsheets.services.dto.Spreadsheet + +interface SpreadsheetService { + + fun init(): Spreadsheet + + fun getSheet(): Spreadsheet + + fun getHeader(): SheetHeader + + fun createHeader(header: SheetHeader) + + fun getContentColumn(column: Int): List + + fun updateRows(range: SheetRange, values: List>) + + fun updateContentRows(startRowIndex: Int, values: List>) + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetsUrlService.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetsUrlService.kt new file mode 100644 index 0000000..7e12ba3 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/SpreadsheetsUrlService.kt @@ -0,0 +1,12 @@ +package ru.touchin.spreadsheets.services + +import ru.touchin.spreadsheets.services.dto.SpreadsheetSource +import java.net.URL + +interface SpreadsheetsUrlService { + + fun parse(url: URL): SpreadsheetSource + + fun parse(url: String): SpreadsheetSource + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/CellType.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/CellType.kt new file mode 100644 index 0000000..4bc7d78 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/CellType.kt @@ -0,0 +1,10 @@ +package ru.touchin.spreadsheets.services.dto + +enum class CellType { + Null, + String, + Date, + DateTime, + Price, + Number, +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetCell.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetCell.kt new file mode 100644 index 0000000..ad78256 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetCell.kt @@ -0,0 +1,6 @@ +package ru.touchin.spreadsheets.services.dto + +interface SheetCell { + val value: Any + val type: CellType +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetHeader.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetHeader.kt new file mode 100644 index 0000000..4917878 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetHeader.kt @@ -0,0 +1,9 @@ +package ru.touchin.spreadsheets.services.dto + +data class SheetHeader( + val values: List> +) { + + fun hasHeader() = values.isNotEmpty() + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetRange.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetRange.kt new file mode 100644 index 0000000..5daafba --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SheetRange.kt @@ -0,0 +1,8 @@ +package ru.touchin.spreadsheets.services.dto + +data class SheetRange( + val startColumnIndex: Int, + val startRowIndex: Int, + val endColumnIndex: Int? = null, + val endRowIndex: Int? = null, +) diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/Spreadsheet.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/Spreadsheet.kt new file mode 100644 index 0000000..2277290 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/Spreadsheet.kt @@ -0,0 +1,7 @@ +package ru.touchin.spreadsheets.services.dto + +data class Spreadsheet( + val rowCount: Int, + val columnCount: Int, + val locale: String, +) diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SpreadsheetSource.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SpreadsheetSource.kt new file mode 100644 index 0000000..382a964 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/SpreadsheetSource.kt @@ -0,0 +1,17 @@ +package ru.touchin.spreadsheets.services.dto + +data class SpreadsheetSource( + val spreadsheetId: String, + val sheetId: Int, + val headerRange: SheetRange = DEFAULT_HEADER_RANGE, +) { + + companion object { + val DEFAULT_HEADER_RANGE = SheetRange( + startColumnIndex = 0, + startRowIndex = 0, + endRowIndex = 1, + ) + } + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNullCell.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNullCell.kt new file mode 100644 index 0000000..7de504f --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNullCell.kt @@ -0,0 +1,30 @@ +package ru.touchin.spreadsheets.services.dto.cells + +import ru.touchin.spreadsheets.services.dto.CellType +import ru.touchin.spreadsheets.services.dto.SheetCell + +class SheetNullCell() : SheetCell { + + override val value = 0 + + override val type = CellType.Null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SheetNullCell + + if (value != other.value) return false + if (type != other.type) return false + + return true + } + + override fun hashCode(): Int { + var result = value + result = 31 * result + type.hashCode() + return result + } + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNumberCell.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNumberCell.kt new file mode 100644 index 0000000..074172b --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetNumberCell.kt @@ -0,0 +1,10 @@ +package ru.touchin.spreadsheets.services.dto.cells + +import ru.touchin.spreadsheets.services.dto.CellType +import ru.touchin.spreadsheets.services.dto.SheetCell + +data class SheetNumberCell(override val value: Number) : SheetCell { + + override val type = CellType.Number + +} diff --git a/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetStringCell.kt b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetStringCell.kt new file mode 100644 index 0000000..c486de5 --- /dev/null +++ b/spreadsheets/src/main/kotlin/ru/touchin/spreadsheets/services/dto/cells/SheetStringCell.kt @@ -0,0 +1,10 @@ +package ru.touchin.spreadsheets.services.dto.cells + +import ru.touchin.spreadsheets.services.dto.CellType +import ru.touchin.spreadsheets.services.dto.SheetCell + +data class SheetStringCell(override val value: String) : SheetCell { + + override val type = CellType.String + +}