harbouR maps SeaTable’s column types onto predictable R types, so the tibble you get back is analysis-ready, not raw JSON. This vignette is the reference.
The table below is derived from hb_column_types() so it
cannot drift out of sync with the code:
| seatable | r | is_list | read_only | notes |
|---|---|---|---|---|
| text | character | FALSE | FALSE | free text |
| long-text | character | FALSE | FALSE | markdown blob |
| character | FALSE | FALSE | validated as email server-side | |
| url | character | FALSE | FALSE | validated as URL server-side |
| number | double | FALSE | FALSE | 64-bit precision caveat applies |
| percent | double | FALSE | FALSE | stored as a fraction, displayed as a percentage |
| dollar | double | FALSE | FALSE | number with a currency format |
| euro | double | FALSE | FALSE | number with a currency format |
| duration | double | FALSE | FALSE | seconds |
| rate | integer | FALSE | FALSE | 0..N stars |
| checkbox | logical | FALSE | FALSE | TRUE/FALSE; an unset box reads as FALSE |
| date | POSIXct | FALSE | FALSE | UTC; date-only columns have a zero time component |
| single-select | character | FALSE | FALSE | not validated on write: SeaTable adds an unknown name as a new option |
| multiple-select | list |
TRUE | FALSE | always a list-column |
| collaborator | list |
TRUE | FALSE | list-column of email addresses |
| image | list |
TRUE | FALSE | list-column of URLs |
| file | list |
TRUE | FALSE | list-column of {name,size,type,url} lists |
| geolocation | list | TRUE | FALSE | list-column with lat/lng/address |
| link | list | TRUE | TRUE | managed via the link endpoints, not by writing the cell |
| link-formula | list | TRUE | TRUE | mirrors a column in a linked table |
| formula | character | FALSE | TRUE | computed server-side |
| auto-number | character | FALSE | TRUE | server-generated identifier |
| button | list | TRUE | TRUE | carries no data |
| digital-sign | list | TRUE | TRUE | signature metadata; not written, since it should come from the signer |
| creator | character | FALSE | TRUE | user email |
| last-modifier | character | FALSE | TRUE | user email |
| ctime | POSIXct | FALSE | TRUE | row creation time |
| mtime | POSIXct | FALSE | TRUE | row modification time |
Three of its rows are not column types of their own.
percent, dollar and euro are
formats of a number column: SeaTable reports such a column as type
number, with the format in its data. They are
listed so that a table carrying them as a type still reads as
numbers.
hb_example_rows() is a small hand-written frame covering
the common types. The bundled .dtable fixture is the
exhaustive one - it carries a column of nearly every type - and the last
section reads it.
samples <- hb_example_rows("Samples")
samples
#> # A tibble: 3 × 8
#> Name Concentration Status Tags Collected Collaborators Reports
#> <chr> <dbl> <chr> <list> <dttm> <list> <list>
#> 1 S-001 12.4 draft <chr> 2026-04-01 09:00:00 <chr [1]> <list>
#> 2 S-002 8.1 ready <chr> 2026-04-03 12:30:00 <chr [2]> <list>
#> 3 S-003 21 shipped <chr> 2026-04-05 16:15:00 <chr [0]> <list>
#> # ℹ 1 more variable: `_id` <chr>str(samples[, c("Name", "Concentration", "Status", "Collected")])
#> tibble [3 × 4] (S3: tbl_df/tbl/data.frame)
#> $ Name : chr [1:3] "S-001" "S-002" "S-003"
#> $ Concentration: num [1:3] 12.4 8.1 21
#> $ Status : chr [1:3] "draft" "ready" "shipped"
#> $ Collected : POSIXct[1:3], format: "2026-04-01 09:00:00" "2026-04-03 12:30:00" ...text / single-select come back as
character. A single-select value is not checked against the
column’s options when you write it: SeaTable adds an unknown name as a
new option, so a typo becomes one.number becomes a double (caveat:
SeaTable’s numeric column is 64-bit; large integers may round - if
precision matters store them as text).checkbox becomes logical. A box that was
never ticked reads as FALSE, as SeaTable shows it. When you
write, harbouR sends the JSON true or false
SeaTable needs: a number is ticked unless it is 0, and a string is read
as as.logical() reads it, so "TRUE",
"true" and "T" tick the box and
"FALSE", "false" and "F" clear
it. Any other non-empty string, NA and NaN are
sent as empty, the string with a warning.date always becomes POSIXct, in UTC. In a
date-only column - one whose format has no time - the value is midnight
UTC on the date SeaTable shows, whatever the server’s time zone, so
as.Date() gives that calendar date. A date-time column
holds an instant: a server sends it with its UTC offset and harbouR
converts it to UTC, while a .dtable stores it without one
and harbouR reads it as UTC. A column that reports no format is read as
a date-time. Written to a date-only column, a Date is sent
as itself and a date-time as the date it shows in its own time zone,
except one at exactly midnight UTC, which keeps its UTC date, so a cell
read and written back keeps its day.These types always come back as list-columns, even when every row has one value or none. That is the type-stability rule: the same column has the same R type for every row.
subset(hb_column_types(), is_list)$seatable
#> [1] "multiple-select" "collaborator" "image" "file"
#> [5] "geolocation" "link" "link-formula" "button"
#> [9] "digital-sign"samples$Tags
#> [[1]]
#> [1] "urgent" "blood"
#>
#> [[2]]
#> character(0)
#>
#> [[3]]
#> [1] "plasma"Flatten with tidyr::unnest when you need a long
form:
samples$Reports[[1]]
#> [[1]]
#> [[1]]$name
#> [1] "S-001.pdf"
#>
#> [[1]]$size
#> [1] 1234
#>
#> [[1]]$type
#> [1] "application/pdf"
#>
#> [[1]]$url
#> [1] "https://example.org/files/S-001.pdf"Each entry is a list of file objects with name,
size, type and url - ready to be
downloaded via hb_download_file().
harbouR leaves columns of these types out of every write, so you can round-trip a tibble through harbouR without writing back computed values by accident.
subset(hb_column_types(), read_only)$seatable
#> [1] "link" "link-formula" "formula" "auto-number"
#> [5] "button" "digital-sign" "creator" "last-modifier"
#> [9] "ctime" "mtime"Most of them are computed or maintained by SeaTable. Links are kept
through SeaTable’s link endpoints rather than by writing the cell, and a
button carries no data. A digital-sign cell is left out for
another reason: a signature should come from the signer, not from a
script.
A table with no rows still comes back as a tibble with every column in its correct type:
empty <- hb_example_rows("Samples")[0, ]
empty
#> # A tibble: 0 × 8
#> # ℹ 8 variables: Name <chr>, Concentration <dbl>, Status <chr>, Tags <list>,
#> # Collected <dttm>, Collaborators <list>, Reports <list>, _id <chr>
str(empty, max.level = 1)
#> tibble [0 × 8] (S3: tbl_df/tbl/data.frame)Every column keeps the type it would have had with rows present -
Concentration is still a double, Collected
still a POSIXct, and Tags still a list-column
- so downstream code that expects a schema does not have to special-case
the empty result.
hb_example_rows() is deliberately small. The bundled
.dtable fixture carries a column of nearly every type
SeaTable defines, so it is the better place to see the whole mapping at
work:
base <- hb_read_dtable(
system.file("extdata", "example.dtable", package = "harbouR")
)
schema <- hb_list_columns(base, "Samples")
knitr::kable(schema[, c("name", "type", "editable")])| name | type | editable |
|---|---|---|
| Name | text | TRUE |
| Notes | long-text | TRUE |
| Concentration | number | TRUE |
| Share | percent | TRUE |
| Cost | dollar | TRUE |
| Preis | euro | TRUE |
| Runtime | duration | TRUE |
| Rating | rate | TRUE |
| Consented | checkbox | TRUE |
| Collected | date | TRUE |
| Status | single-select | TRUE |
| Tags | multiple-select | TRUE |
| Collaborators | collaborator | TRUE |
| Photos | image | TRUE |
| Reports | file | TRUE |
| Where | geolocation | TRUE |
| Homepage | url | TRUE |
| Contact | TRUE | |
| Temperatur (°C) | number | TRUE |
| Doubled | formula | FALSE |
| Ref | auto-number | FALSE |
| Created by | creator | FALSE |
| Changed by | last-modifier | FALSE |
| Created | ctime | FALSE |
| Changed | mtime | FALSE |
| Action | button | FALSE |
| Signature | digital-sign | FALSE |
data <- hb_read_table(base, "Samples")
vapply(data, function(col) class(col)[[1L]], character(1))
#> Name Notes Concentration Share Cost
#> "character" "character" "numeric" "numeric" "numeric"
#> Preis Runtime Rating Consented Collected
#> "numeric" "numeric" "integer" "logical" "POSIXct"
#> Status Tags Collaborators Photos Reports
#> "character" "list" "list" "list" "list"
#> Where Homepage Contact Temperatur (°C) Doubled
#> "list" "character" "character" "numeric" "character"
#> Ref Created by Changed by Created Changed
#> "character" "character" "character" "POSIXct" "POSIXct"
#> Action Signature _id
#> "list" "list" "character"vignette("harbouR") - the five-minute end-to-end
story.vignette("explorer-app") - exploring a base
interactively.