///|
/// Response helpers, ported from `src/helpers.ts`.
///
/// The TypeScript SDK pages with async iterators. MoonBit has no equivalent
/// `for await`, so the `iterate*` helpers here collect a page set into an array;
/// `collect*` is kept as the primary name and the iterate names are aliases.
///|
/// A page of results from any paginated Notion API.
pub(all) struct PaginatedList {
results : Array[Json]
next_cursor : String?
has_more : Bool
}
///|
/// Reads the pagination fields out of a list response.
pub fn parse_paginated_list(response : Json) -> PaginatedList {
let fields = object_fields(response)
{
results: match fields.get("results") {
Some(Json::Array(items)) => items
_ => []
},
next_cursor: match fields.get("next_cursor") {
Some(Json::String(cursor)) => Some(cursor)
_ => None
},
has_more: match fields.get("has_more") {
Some(Json::True) => true
_ => false
},
}
}
///|
/// Collects all of the results of paginating an API into an in-memory array.
pub fn collect_paginated_api(
list_fn : (Json) -> Json raise,
first_page_args : Json,
) -> Array[Json] raise {
let results : Array[Json] = []
let mut cursor = start_cursor_of(first_page_args)
while true {
let response = list_fn(set_start_cursor(first_page_args, cursor))
let page = parse_paginated_list(response)
for item in page.results {
results.push(item)
}
cursor = page.next_cursor
if cursor is None {
break
}
}
results
}
///|
/// Alias for `collect_paginated_api`. Present so callers porting TypeScript
/// `iteratePaginatedAPI` calls have a matching name.
pub fn iterate_paginated_api(
list_fn : (Json) -> Json raise,
first_page_args : Json,
) -> Array[Json] raise {
collect_paginated_api(list_fn, first_page_args)
}
///|
/// Collects every data source template into an in-memory array.
pub fn collect_data_source_templates(
client : Client,
args : Json,
) -> Array[Json] raise {
let results : Array[Json] = []
let mut cursor = start_cursor_of(args)
while true {
let response = (client.data_sources().list_templates)(
set_start_cursor(args, cursor),
)
let fields = object_fields(response)
match fields.get("templates") {
Some(Json::Array(items)) =>
for item in items {
results.push(item)
}
_ => ()
}
cursor = match fields.get("next_cursor") {
Some(Json::String(next)) => Some(next)
_ => None
}
if cursor is None {
break
}
}
results
}
///|
/// Alias for `collect_data_source_templates`.
pub fn iterate_data_source_templates(
client : Client,
args : Json,
) -> Array[Json] raise {
collect_data_source_templates(client, args)
}
///|
/// Collects every row of a data source, including rows past the per-query
/// result limit.
///
/// A single query (one filter and sort) returns at most a fixed number of rows.
/// Once that limit is reached, `has_more` becomes `false` and the response
/// carries `request_status.type === "incomplete"`; plain pagination stops there
/// and silently misses the rest.
///
/// This helper partitions the data source into created_time windows. It sorts
/// by created_time ascending; whenever a window reaches the limit, it starts a
/// fresh query from the last row's created_time. Rows that share a boundary
/// timestamp are de-duplicated by id, so every row is yielded exactly once.
pub fn collect_all_data_source_rows(
client : Client,
args : Json,
) -> Array[Json] raise {
let seen : Map[String, Bool] = Map([])
let rows : Array[Json] = []
let mut window_start : String? = None
while true {
let mut limit_reached = false
let mut last_created_time : String? = None
let mut cursor : String? = None
let mut more = true
while more {
let query_args = build_full_data_source_query(args, window_start, cursor)
let response = (client.data_sources().query)(query_args)
let fields = object_fields(response)
match fields.get("results") {
Some(Json::Array(items)) =>
for row in items {
if is_full_page_or_data_source(row) {
last_created_time = match object_fields(row).get("created_time") {
Some(Json::String(time)) => Some(time)
_ => last_created_time
}
}
match object_fields(row).get("id") {
Some(Json::String(id)) =>
if !seen.contains(id) {
seen.set(id, true)
rows.push(row)
}
_ => ()
}
}
_ => ()
}
match fields.get("request_status") {
Some(Json::Object(status)) =>
match status.get("type") {
Some(Json::String("incomplete")) => limit_reached = true
_ => ()
}
_ => ()
}
cursor = match fields.get("next_cursor") {
Some(Json::String(next)) => Some(next)
_ => None
}
more = cursor is Some(_)
}
if !limit_reached {
return rows
}
match last_created_time {
None =>
raise notion_progress_error(
"iterateAllDataSourceRows cannot make progress: the per-query result " +
"limit was reached but the created_time window could not advance.",
)
Some(time) =>
if window_start is Some(start) && start == time {
raise notion_progress_error(
"iterateAllDataSourceRows cannot make progress: the per-query result " +
"limit was reached but the created_time window could not advance " +
"past " +
time +
". More rows share this timestamp than the limit allows. Add a filter " +
"to narrow the query.",
)
} else {
window_start = Some(time)
}
}
} nobreak {
rows
}
}
///|
/// Alias for `collect_all_data_source_rows`.
pub fn iterate_all_data_source_rows(
client : Client,
args : Json,
) -> Array[Json] raise {
collect_all_data_source_rows(client, args)
}
///|
/// Builds the query arguments for one window of
/// `collect_all_data_source_rows`.
fn build_full_data_source_query(
args : Json,
window_start : String?,
cursor : String?,
) -> Json {
let fields = object_fields(args)
let out : Map[String, Json] = Map([])
fields.each((key, value) => out.set(key, value))
out.set(
"sorts",
Json::array([
Json::object(
Map([
("timestamp", Json::string("created_time")),
("direction", Json::string("ascending")),
]),
),
]),
)
match created_time_lower_bound(fields.get("filter"), window_start) {
Some(filter) => out.set("filter", filter)
None => out.remove("filter")
}
match cursor {
Some(c) => out.set("start_cursor", Json::string(c))
None => out.remove("start_cursor")
}
Json::object(out)
}
///|
/// Combines a caller-provided filter with the created_time lower bound used to
/// advance the partition window.
fn created_time_lower_bound(filter : Json?, window_start : String?) -> Json? {
match window_start {
None => filter
Some(start) => {
let bound = Json::object(
Map([
("timestamp", Json::string("created_time")),
(
"created_time",
Json::object(Map([("on_or_after", Json::string(start))])),
),
]),
)
match filter {
None => Some(bound)
Some(f) =>
match object_fields(f).get("and") {
Some(Json::Array(existing)) => {
let combined : Array[Json] = []
for item in existing {
combined.push(item)
}
combined.push(bound)
Some(Json::object(Map([("and", Json::array(combined))])))
}
_ => Some(Json::array([f, bound]))
}
}
}
}
}
///|
/// Builds the error raised when the created_time window cannot advance.
fn notion_progress_error(message : String) -> NotionClientError {
NotionClientError(
NotionError::new(
kind=UnknownHTTPResponseError,
code=ClientErrorCode::ResponseError.to_string(),
message~,
),
)
}
///|
/// Returns the fields of a JSON object, or an empty map for other values.
fn object_fields(value : Json) -> Map[String, Json] {
match value {
Json::Object(fields) => fields
_ => Map([])
}
}
///|
/// Reads `start_cursor` out of a query arguments object.
fn start_cursor_of(args : Json) -> String? {
match object_fields(args).get("start_cursor") {
Some(Json::String(cursor)) => Some(cursor)
_ => None
}
}
///|
/// Returns a copy of `args` with `start_cursor` set (or removed when absent),
/// matching the client's treatment of a null cursor.
pub fn set_start_cursor(args : Json, cursor : String?) -> Json {
let out : Map[String, Json] = Map([])
object_fields(args).each((key, value) => {
if key != "start_cursor" {
out.set(key, value)
}
})
match cursor {
Some(c) => out.set("start_cursor", Json::string(c))
None => ()
}
Json::object(out)
}
// ---------------------------------------------------------------------------
// Response type guards
// ---------------------------------------------------------------------------
///|
/// `true` if `response` is a full `BlockObjectResponse`.
pub fn is_full_block(response : Json) -> Bool {
has_string(response, "object", "block") && has_key(response, "type")
}
///|
/// `true` if `response` is a full `PageObjectResponse`.
pub fn is_full_page(response : Json) -> Bool {
has_string(response, "object", "page") && has_key(response, "url")
}
///|
/// `true` if `response` is a full `DataSourceObjectResponse`.
pub fn is_full_data_source(response : Json) -> Bool {
has_string(response, "object", "data_source") && has_key(response, "title")
}
///|
/// `true` if `response` is a full `DatabaseObjectResponse`.
pub fn is_full_database(response : Json) -> Bool {
has_string(response, "object", "database") && has_key(response, "title")
}
///|
/// `true` if `response` is a full page or a full data source.
pub fn is_full_page_or_data_source(response : Json) -> Bool {
if has_string(response, "object", "data_source") {
is_full_data_source(response)
} else {
is_full_page(response)
}
}
///|
/// `true` if `response` is a full `UserObjectResponse`.
pub fn is_full_user(response : Json) -> Bool {
has_key(response, "type")
}
///|
/// `true` if `response` is a full `CommentObjectResponse`.
pub fn is_full_comment(response : Json) -> Bool {
has_key(response, "created_by")
}
///|
/// `true` if `response` is a full `DataSourceViewObjectResponse`.
pub fn is_full_view(response : Json) -> Bool {
has_key(response, "type")
}
///|
/// `true` if `rich_text` is a text rich text item.
pub fn is_text_rich_text_item_response(rich_text : Json) -> Bool {
has_string(rich_text, "type", "text")
}
///|
/// `true` if `rich_text` is an equation rich text item.
pub fn is_equation_rich_text_item_response(rich_text : Json) -> Bool {
has_string(rich_text, "type", "equation")
}
///|
/// `true` if `rich_text` is a mention rich text item.
pub fn is_mention_rich_text_item_response(rich_text : Json) -> Bool {
has_string(rich_text, "type", "mention")
}
///|
/// Whether an object has a key with a given string value.
fn has_string(response : Json, key : String, expected : String) -> Bool {
match object_fields(response).get(key) {
Some(Json::String(value)) => value == expected
_ => false
}
}
///|
/// Whether an object has a key at all.
fn has_key(response : Json, key : String) -> Bool {
object_fields(response).get(key) is Some(_)
}
// ---------------------------------------------------------------------------
// Notion ID extraction
// ---------------------------------------------------------------------------
///|
/// Extracts a Notion ID from a Notion URL, or returns the input if it is
/// already a valid ID. Path IDs are prioritized over query parameters to avoid
/// extracting view IDs instead of database IDs.
pub fn extract_notion_id(url_or_id : String) -> String? {
let trimmed = url_or_id.trim().to_owned()
if trimmed.is_empty() {
return None
}
let lower = trimmed.to_lower()
if regex_matches(
"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", lower,
) {
return Some(lower)
}
if regex_matches("^[0-9a-f]{32}$", lower) {
return Some(format_uuid(lower))
}
match regex_group("/[^/?#]*-([0-9a-f]{32})([/?#]|$)", lower, 1) {
Some(id) => return Some(format_uuid(id))
None => ()
}
match regex_group("[?&](p|page_id|database_id)=([0-9a-f]{32})", lower, 2) {
Some(id) => return Some(format_uuid(id))
None => ()
}
match regex_group("([0-9a-f]{32})", lower, 1) {
Some(id) => Some(format_uuid(id))
None => None
}
}
///|
/// Extracts a database ID from a Notion database URL.
pub fn extract_database_id(database_url : String) -> String? {
extract_notion_id(database_url)
}
///|
/// Extracts a page ID from a Notion page URL.
pub fn extract_page_id(page_url : String) -> String? {
extract_notion_id(page_url)
}
///|
/// Extracts a block ID from a Notion URL with a block fragment. Looks for
/// `#block-` or `#` patterns.
pub fn extract_block_id(url_with_block : String) -> String? {
if url_with_block.is_empty() {
return None
}
match regex_group("#(block-)?([0-9a-f]{32})", url_with_block.to_lower(), 2) {
Some(id) => Some(format_uuid(id))
None => None
}
}
///|
/// Formats a 32-character hex string into a standard UUID.
fn format_uuid(compact_id : String) -> String {
let clean = compact_id.to_lower()
clean[:8].to_owned() +
"-" +
clean[8:12].to_owned() +
"-" +
clean[12:16].to_owned() +
"-" +
clean[16:20].to_owned() +
"-" +
clean[20:32].to_owned()
}
///|
/// Whether a regular expression matches anywhere in the text.
fn regex_matches(pattern : String, text : String) -> Bool {
let re = @string.Regex::unsafe_from_string(pattern)
re.execute(text) is Some(_)
}
///|
/// Returns a capture group from the first match of a regular expression.
fn regex_group(pattern : String, text : String, index : Int) -> String? {
let re = @string.Regex::unsafe_from_string(pattern)
match re.execute(text) {
Some(result) =>
match result.group(index) {
Some(value) => Some(value.to_owned())
None => None
}
None => None
}
}