Sheetshow (Sheetshow v0.1.0)

Copy Markdown View Source

Google Sheets from Elixir, as values.

A spreadsheet is a list of Sheetshow.Cell structs in no particular order. The functions here build such lists from rows, columns, tables and records and lay them out next to and below each other, and plan/2 turns the result into the ops a backend carries out.

iex> import Sheetshow
iex> costs = [%{item: "Rent", cost: 1000}, %{item: "Food", cost: 400}]
iex> cells =
...>   stack([
...>     row(["Item", "Cost"], style: %{bold: true}),
...>     records(costs, [:item, :cost])
...>   ])
...>   |> put_sheet("Costs")
iex> to_rows(cells)
[["Item", "Cost"], ["Rent", 1000], ["Food", 400]]
iex> Sheetshow.Range.bounding(cells) |> Sheetshow.Range.to_a1()
"Costs!A1:B3"

Layout works on row and column numbers only. A group keeps its own internal offsets when concatenated, so a group whose first cell sits at row 2 lands two rows below the group above it.

Sheetshow defines no GenServer, supervisor or application callback module, and no macros. Credentials, tokens and caches are values you pass in, and so are retries. A plan is one request however many ops it holds, which is what keeps you inside Google's quota; see What Sheetshow Can Promise for that, and for what a write can and cannot guarantee.

Summary

Functions

Trades the workbook's credentials for an access token. When and whether to do this again is yours to decide: Sheetshow.Client.ready?/2 answers it.

Places groups of cells side by side, each starting on the column after the previous group's last one. Empty groups take no room.

Same as beside([left, right]), for pipelines.

One column of cells from a list of values, top to bottom. Same options as row/2.

Makes a workbook ready to use and learns the sheets it has. For Google that is a token and one metadata request, and it authenticates only when there is no good token already, so calling it again is cheap in everything but the request. For a backend with nothing to reach it is free.

Asks the spreadsheet which sheets it has, and puts them on the workbook.

The highest column any cell sits on, or nil for no cells.

The highest row any cell sits on, or nil for no cells.

Adds n empty rows below the cells, so the next stack/1 leaves a gap. The room is held by one empty cell in the first column of the last padded row.

Adds n empty columns to the right of the cells; see pad_below/2.

Turns cells into a plan: the ops that write them, in the order they are to be carried out.

Same as plan/2, raising on failure.

Puts every cell on a sheet.

Merges a style into every cell's; see Sheetshow.Cell.put_style/2.

The cells in a range, in reading order, with empty cells left out.

Same as read_cells/2, raising on failure.

The values in a range, as rows: what the cells work out to, rather than what they hold.

Same as read_rows/2, raising on failure.

One row of cells per record (a map or struct), one column per key, in key order; a missing key is an empty cell.

One row of cells from a list of values, left to right.

Cells from rows of values. Rows may be ragged. Same options as row/2.

Carries out a plan and gives back the workbook. At Google it is one spreadsheets.batchUpdate, applied whole or not at all, so there is no half written spreadsheet to clean up; Sheetshow.Backend.supports?/2 says whether a given backend promises that much.

Moves every cell by rows down and cols right; see Sheetshow.Cell.shift/3.

Stacks groups of cells, each starting on the row after the previous group's last one. Empty groups take no room.

Same as stack([above, below]), for pipelines.

Values back from cells, as rows relative to their bounding range; gaps are nil. When two cells share a coordinate the later one wins. Cells must share a sheet. No cells is no rows, which is what a read of an empty range gives.

Types

cells()

@type cells() :: [Sheetshow.Cell.t()]

layout_opts()

@type layout_opts() :: [
  row: non_neg_integer(),
  col: non_neg_integer(),
  sheet: String.t(),
  style: Sheetshow.Style.t()
]

plan_opts()

@type plan_opts() :: [sheet: String.t(), existing_sheets: [String.t()]]

Functions

authenticate(workbook)

@spec authenticate(Sheetshow.Workbook.t()) ::
  {:ok, Sheetshow.Workbook.t()} | {:error, Sheetshow.Error.t()}

Trades the workbook's credentials for an access token. When and whether to do this again is yours to decide: Sheetshow.Client.ready?/2 answers it.

A backend with no credentials to trade, which means the in-memory one and every file backend, answers {:error, %Sheetshow.Error{reason: :unsupported}}.

beside(groups)

@spec beside([cells()]) :: cells()

Places groups of cells side by side, each starting on the column after the previous group's last one. Empty groups take no room.

iex> Sheetshow.beside([Sheetshow.col([1, 2]), Sheetshow.col([3, 4])])
...> |> Sheetshow.to_rows()
[[1, 3], [2, 4]]

beside(left, right)

@spec beside(cells(), cells()) :: cells()

Same as beside([left, right]), for pipelines.

col(values, opts \\ [])

@spec col([Sheetshow.Value.t()], layout_opts()) :: cells()

One column of cells from a list of values, top to bottom. Same options as row/2.

iex> Sheetshow.col([1, 2, 3]) |> Sheetshow.max_row()
2

connect(workbook)

@spec connect(Sheetshow.Workbook.t()) ::
  {:ok, Sheetshow.Workbook.t()} | {:error, Sheetshow.Error.t()}

Makes a workbook ready to use and learns the sheets it has. For Google that is a token and one metadata request, and it authenticates only when there is no good token already, so calling it again is cheap in everything but the request. For a backend with nothing to reach it is free.

workbook = Sheetshow.Workbook.google(id, credentials: Sheetshow.ServiceAccount.from_file!(path))
{:ok, workbook} = Sheetshow.connect(workbook)
workbook.sheets
%{"Sheet1" => 0}

iex> {:ok, workbook} = Sheetshow.Workbook.memory(["Costs"]) |> Sheetshow.connect()
iex> Sheetshow.Workbook.titles(workbook)
["Costs"]

fetch_sheets(workbook)

@spec fetch_sheets(Sheetshow.Workbook.t()) ::
  {:ok, Sheetshow.Workbook.t()} | {:error, Sheetshow.Error.t()}

Asks the spreadsheet which sheets it has, and puts them on the workbook.

max_col(cells)

@spec max_col(cells()) :: non_neg_integer() | nil

The highest column any cell sits on, or nil for no cells.

iex> Sheetshow.max_col(Sheetshow.row([1, 2, 3]))
2

max_row(cells)

@spec max_row(cells()) :: non_neg_integer() | nil

The highest row any cell sits on, or nil for no cells.

iex> Sheetshow.max_row([])
nil

pad_below(cells, n \\ 1)

@spec pad_below(cells(), pos_integer()) :: cells()

Adds n empty rows below the cells, so the next stack/1 leaves a gap. The room is held by one empty cell in the first column of the last padded row.

iex> Sheetshow.row([1]) |> Sheetshow.pad_below(2) |> Sheetshow.max_row()
2

pad_right(cells, n \\ 1)

@spec pad_right(cells(), pos_integer()) :: cells()

Adds n empty columns to the right of the cells; see pad_below/2.

iex> Sheetshow.col([1]) |> Sheetshow.pad_right() |> Sheetshow.max_col()
1

plan(cells, opts \\ [])

@spec plan(cells(), plan_opts()) ::
  {:ok, Sheetshow.Op.plan()} | {:error, Sheetshow.Error.t()}

Turns cells into a plan: the ops that write them, in the order they are to be carried out.

Options:

  • :sheet, the sheet for cells that name none. Without it, a cell that names no sheet is an error, because an op has to say where it writes.
  • :existing_sheets, the sheets the spreadsheet already has. Every other sheet the cells name gets a Sheetshow.Op.AddSheet at the front of the plan. Leave it out and the plan assumes the sheets are all there.

Cells are checked before anything else, so a plan is only ever made of cells that can be written. Scattered cells become one Sheetshow.Op.PutCells per run, so a write never clears a neighbour; a date, time or datetime with no :number_format of its own gets the format that makes it readable; and the :col_width and :row_height styles are lifted out into Sheetshow.Op.SetDimensions. Two cells at one coordinate are a late edit of the same cell: the last one wins.

iex> cells = [Sheetshow.Cell.new("A1", 1), Sheetshow.Cell.new("C1", 3)]
iex> {:ok, plan} = Sheetshow.plan(cells, sheet: "Costs")
iex> Enum.map(plan, &Sheetshow.Range.to_a1(Sheetshow.Op.PutCells.range(&1)))
["Costs!A1", "Costs!C1"]

iex> {:error, %Sheetshow.Error{reason: :missing_sheet}} =
...>   Sheetshow.plan(Sheetshow.row([1]))

plan!(cells, opts \\ [])

@spec plan!(cells(), plan_opts()) :: Sheetshow.Op.plan()

Same as plan/2, raising on failure.

iex> Sheetshow.row([1, 2], sheet: "Costs") |> Sheetshow.plan!() |> length()
1

put_sheet(cells, sheet)

@spec put_sheet(cells(), String.t() | nil) :: cells()

Puts every cell on a sheet.

iex> Sheetshow.row([1]) |> Sheetshow.put_sheet("Costs") |> hd() |> Map.fetch!(:coord)
%Sheetshow.Coord{row: 0, col: 0, sheet: "Costs"}

put_style(cells, style)

@spec put_style(cells(), Sheetshow.Style.t()) :: cells()

Merges a style into every cell's; see Sheetshow.Cell.put_style/2.

iex> Sheetshow.row([1], style: %{bold: true}) |> Sheetshow.put_style(%{italic: true}) |> hd() |> Map.fetch!(:style)
%{bold: true, italic: true}

read_cells(range, workbook)

@spec read_cells(Sheetshow.Range.t() | String.t(), Sheetshow.Workbook.t()) ::
  {:ok, cells()} | {:error, Sheetshow.Error.t()}

The cells in a range, in reading order, with empty cells left out.

A cell's value is what was entered, so a formula reads back as a formula and the cells you read are cells you can write again. What Sheets worked it out to is in meta, under :formatted and :effective. The range needs a sheet, because a spreadsheet has more than one.

{:ok, cells} = Sheetshow.read_cells("Costs!A1:C10", workbook)
Sheetshow.to_rows(cells)
[["Item", "Cost"], ["Rent", 1000]]

read_cells!(range, workbook)

@spec read_cells!(Sheetshow.Range.t() | String.t(), Sheetshow.Workbook.t()) :: cells()

Same as read_cells/2, raising on failure.

read_rows(ranges, workbook)

@spec read_rows(
  Sheetshow.Range.t() | String.t() | [Sheetshow.Range.t() | String.t()],
  Sheetshow.Workbook.t()
) :: {:ok, [[term()]] | [[[term()]]]} | {:error, Sheetshow.Error.t()}

The values in a range, as rows: what the cells work out to, rather than what they hold.

This is the other read, and the one a stored table wants. read_cells/2 asks for cells and gets back everything about them: what was entered, what Sheets made of it, and the format, which is the only thing that says a number is a date. read_rows/2 asks the values endpoint for the computed value alone, which is about a twelfth of the bytes, and leaves the question of what a number means to a Sheetshow.Schema that already knows. A formula arrives as its result here, and a formula's error as the text of it, so cells you read this way are not cells you can write back.

Rows are padded to the width of the widest and an empty cell is nil, as in to_rows/1. The range needs a sheet.

{:ok, rows} = Sheetshow.read_rows("Costs!A1:C10", workbook)
[["Item", "Cost"], ["Rent", 1000]]

Given a list of ranges it asks for all of them at once, and answers with one table per range, in the order you gave them. Two reads that would otherwise be two round trips, a header and the rows below a cursor, say, become one.

{:ok, [header, rows]} = Sheetshow.read_rows(["log!A1:C1", "log!A900:C"], workbook)

read_rows!(ranges, workbook)

@spec read_rows!(
  Sheetshow.Range.t() | String.t() | [Sheetshow.Range.t() | String.t()],
  Sheetshow.Workbook.t()
) :: [[term()]] | [[[term()]]]

Same as read_rows/2, raising on failure.

records(records, keys, opts \\ [])

@spec records([map()], [term()], keyword()) :: cells()

One row of cells per record (a map or struct), one column per key, in key order; a missing key is an empty cell.

Same options as row/2, plus :header: true puts the keys' names in a first row, a list of strings puts those labels there.

iex> [%{item: "Rent", cost: 1000}, %{item: "Food"}]
...> |> Sheetshow.records([:item, :cost], header: true)
...> |> Sheetshow.to_rows()
[["item", "cost"], ["Rent", 1000], ["Food", nil]]

row(values, opts \\ [])

@spec row([Sheetshow.Value.t()], layout_opts()) :: cells()

One row of cells from a list of values, left to right.

Options: :row and :col place the first cell (default 0, 0); :sheet and :style apply to every cell.

iex> Sheetshow.row(["a", "b"], row: 1, style: %{bold: true}) |> Sheetshow.to_rows()
[["a", "b"]]

rows(rows, opts \\ [])

@spec rows([[Sheetshow.Value.t()]], layout_opts()) :: cells()

Cells from rows of values. Rows may be ragged. Same options as row/2.

iex> Sheetshow.rows([[1, 2], [3]]) |> Enum.map(&Sheetshow.Coord.to_a1(&1.coord))
["A1", "B1", "A2"]

run(plan, workbook)

Carries out a plan and gives back the workbook. At Google it is one spreadsheets.batchUpdate, applied whole or not at all, so there is no half written spreadsheet to clean up; Sheetshow.Backend.supports?/2 says whether a given backend promises that much.

The workbook comes back knowing about any sheets the plan created, so a second plan against it needs no fresh metadata. An empty plan asks the backend nothing.

{:ok, workbook} = Sheetshow.connect(workbook)
cells = Sheetshow.row(["Rent", 1000], sheet: "Costs")
{:ok, workbook} =
  cells
  |> Sheetshow.plan!(existing_sheets: Map.keys(workbook.sheets))
  |> Sheetshow.run(workbook)

iex> workbook = Sheetshow.Workbook.memory()
iex> cells = Sheetshow.row(["Rent", 1000], sheet: "Costs")
iex> {:ok, workbook} = cells |> Sheetshow.plan!(existing_sheets: []) |> Sheetshow.run(workbook)
iex> Sheetshow.read_cells!("Costs!A1:B1", workbook) |> Sheetshow.to_rows()
[["Rent", 1000]]

shift(cells, rows, cols)

@spec shift(cells(), integer(), integer()) :: cells()

Moves every cell by rows down and cols right; see Sheetshow.Cell.shift/3.

iex> Sheetshow.row([1]) |> Sheetshow.shift(2, 3) |> hd() |> Map.fetch!(:coord) |> Sheetshow.Coord.to_a1()
"D3"

stack(groups)

@spec stack([cells()]) :: cells()

Stacks groups of cells, each starting on the row after the previous group's last one. Empty groups take no room.

iex> Sheetshow.stack([Sheetshow.row([1, 2]), Sheetshow.row([3, 4])])
...> |> Sheetshow.to_rows()
[[1, 2], [3, 4]]

stack(above, below)

@spec stack(cells(), cells()) :: cells()

Same as stack([above, below]), for pipelines.

to_rows(cells)

@spec to_rows(cells()) :: [[Sheetshow.Value.t()]]

Values back from cells, as rows relative to their bounding range; gaps are nil. When two cells share a coordinate the later one wins. Cells must share a sheet. No cells is no rows, which is what a read of an empty range gives.

iex> Sheetshow.to_rows([Sheetshow.Cell.new("B2", 1), Sheetshow.Cell.new("C3", 2)])
[[1, nil], [nil, 2]]
iex> Sheetshow.to_rows([])
[]