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
+
+}