Sheetshow.Google (Sheetshow v0.1.0)

Copy Markdown View Source

Ops as Google Sheets API requests: the body of one spreadsheets.batchUpdate.

Google addresses sheets by a number, not a title, so encoding needs the ids the spreadsheet already has: %{"Costs" => 0}, which a backend reads off the metadata before it plans. A Sheetshow.Op.AddSheet picks an id for the sheet it creates, so the ops after it in the same plan can write to a tab that does not exist yet.

iex> plan = Sheetshow.row(["Rent", 1000], sheet: "Costs") |> Sheetshow.plan!()
iex> {:ok, body} = Sheetshow.Google.encode(plan, %{"Costs" => 0})
iex> [%{updateCells: update}] = body.requests
iex> update.start
%{sheetId: 0, rowIndex: 0, columnIndex: 0}

A batch is all-or-nothing at Google, so an op naming a sheet that is not there is an error here rather than a half-written spreadsheet, which is the answer Sheetshow.Memory gives too.

Writing a cell replaces it: the field mask covers the value and the whole format, so a cell written without a style comes out with none, whatever was there before. That is what makes cells values rather than patches.

Summary

Functions

Cells from a spreadsheets.get answer.

Rows of values from a spreadsheets.values.get answer.

The scopes Sheetshow asks Google for when you name none, whichever kind of credential is asking.

A plan as a spreadsheets.batchUpdate body, given the sheet ids the spreadsheet already has.

Same as encode/2, raising on failure.

The id a new sheet will claim, given the ids already in use.

Types

sheet_ids()

@type sheet_ids() :: %{required(String.t()) => integer()}

Functions

decode(answer)

@spec decode(map()) :: [Sheetshow.Cell.t()]

Cells from a spreadsheets.get answer.

A cell's value is what was entered: a formula reads back as a formula, so cells you read are cells you can write again. What Sheets worked it out to goes in meta: :formatted is the text it displayed, :effective the value behind that text, which is where a Sheetshow.CellError turns up. Empty cells are not cells and are not returned.

iex> answer = %{"sheets" => [%{
...>   "properties" => %{"title" => "Costs"},
...>   "data" => [%{"rowData" => [%{"values" => [
...>     %{"userEnteredValue" => %{"stringValue" => "Rent"}, "formattedValue" => "Rent"}
...>   ]}]}]
...> }]}
iex> [cell] = Sheetshow.Google.decode(answer)
iex> {Sheetshow.Coord.to_a1(cell.coord), cell.value, cell.meta}
{"Costs!A1", "Rent", %{formatted: "Rent"}}

Sheets keeps a date as a number and only its number format says so, so that is what the decoding leans on: a serial number formatted as a date comes back a Date, and one formatted as anything else stays a number.

decode_values(answer)

@spec decode_values(map()) :: [[term()]]

Rows of values from a spreadsheets.values.get answer.

Google leaves trailing empty cells off the end of a row and trailing empty rows off the end of the answer, so the rows come back ragged; they are padded here to the width of the widest, and an empty cell is nil, which is the shape Sheetshow.to_rows/1 gives.

iex> Sheetshow.Google.decode_values(%{"values" => [["Rent", 1000], ["Food"]]})
[["Rent", 1000], ["Food", nil]]

What is lost against decode/1 is what a cell is: this endpoint renders a formula's result and a formula's error as plain values, so #DIV/0! arrives as the string "#DIV/0!" and is indistinguishable from someone typing it. What is gained is the computed value and about a twelfth of the bytes, which is the trade a database read wants and a cell read does not.

iex> Sheetshow.Google.decode_values(%{})
[]

default_scopes()

@spec default_scopes() :: [String.t()]

The scopes Sheetshow asks Google for when you name none, whichever kind of credential is asking.

iex> Sheetshow.Google.default_scopes()
["https://www.googleapis.com/auth/spreadsheets"]

drive.file is narrower but reaches only files the account itself created, so it cannot see a spreadsheet someone shared with the account.

encode(plan, sheet_ids)

@spec encode(Sheetshow.Op.plan(), sheet_ids()) ::
  {:ok, map()} | {:error, Sheetshow.Error.t()}

A plan as a spreadsheets.batchUpdate body, given the sheet ids the spreadsheet already has.

iex> {:error, %Sheetshow.Error{reason: :unknown_sheet}} =
...>   Sheetshow.Google.encode(Sheetshow.plan!([Sheetshow.Cell.new("Costs!A1", 1)]), %{})

encode!(plan, sheet_ids)

@spec encode!(Sheetshow.Op.plan(), sheet_ids()) :: map()

Same as encode/2, raising on failure.

sheet_id(title, taken \\ %{})

@spec sheet_id(String.t(), sheet_ids()) :: non_neg_integer()

The id a new sheet will claim, given the ids already in use.

It comes from the title, so it is the same every time, which keeps a plan readable in a test, and two writers adding differently named tabs at the same time ask for different ids. A title that is already taken is the one real conflict, and Google refuses that on its own; picking, say, the lowest free id instead would invent a second conflict between writers who were not in each other's way at all.

iex> id = Sheetshow.Google.sheet_id("log")
iex> Sheetshow.Google.sheet_id("log") == id
true
iex> Sheetshow.Google.sheet_id("log", %{"Costs" => id}) == id + 1
true