Compare commits

...

10 Commits

8 changed files with 420 additions and 73 deletions

14
README.md Normal file
View File

@ -0,0 +1,14 @@
# Hope Family Fellowship Notion API Client
## Building
### Software Packages
`hff_notion_api_client` depends on a number of packages which are not yet
available in he official Nim repository. The easiest way to get access to these
packages is to add a new `PackageList` to your [nimble configuration] for the
[JDB Software Nim packages repository]. The url is
`https://git.jdb-software.com/jdb/nim-packages/raw/main/packages.json`
[nimble configuration]: https://github.com/nim-lang/nimble#configuration
[JDB Software Nim packages]: https://git.jdb-software.com/jdb/nim-packages

View File

@ -0,0 +1,14 @@
# Package
version = "0.5.0"
author = "Jonathan Bernard"
description = "Utilities and bindings for HFF's Notion API."
license = "GPL-3.0-or-later"
srcDir = "src"
# Dependencies
requires "nim >= 1.4.8"
# packages from git.jdb-software.com/jdb/nim-packages
requires @["buffoonery", "timeutils >= 0.5.0", "namespaced_logging >= 0.3.1"]

View File

@ -1,13 +0,0 @@
# Package
version = "0.2.0"
author = "Jonathan Bernard"
description = "Utilities and bindings for the Notion API."
license = "GPL-3.0-or-later"
srcDir = "src"
# Dependencies
requires "nim >= 1.4.8"
requires "https://git.jdb-software.com/jdb/nim-time-utils.git >= 0.5.0"

View File

@ -0,0 +1,181 @@
import std/[httpclient, json, logging, options, sequtils, tables, strutils]
import buffoonery/jsonutils
import hff_notion_api_client/[config, models, sequtils_ext, utils]
type
NotionClientConfig* = object
apiBaseUrl*: string
apiVersion*: string
configDbId*: string
integrationToken*: string
NotionClient* = ref object
http: HttpClient
apiBaseUrl: string
config*: HffNotionConfig
proc loadNotionConfig(http: HttpClient, cfg: NotionClientConfig): HffNotionConfig =
let url = cfg.apiBaseUrl & "/databases/" & cfg.configDbId & "/query"
let body = $(%*{ "page_size": NOTION_MAX_PAGE_SIZE })
debug "loadNotionConfig\n\tPOST " & url & "\n\t" & $body
let resp = http.postContent(url, body)
let resultsJson = parseJson(resp)["results"]
result = parseHffNotionConfig(resultsJson)
proc initNotionClient*(cfg: NotionClientConfig): NotionClient =
result = NotionClient(
apiBaseUrl: cfg.apiBaseUrl,
http: newHttpClient(headers = newHttpHeaders([
("Content-Type", "application/json"),
("Authorization", "Bearer " & cfg.integrationToken),
("Notion-Version", cfg.apiVersion)
], true)))
result.config = result.http.loadNotionConfig(cfg)
proc `%`*(c: NotionClientConfig): JsonNode =
%*{
"apiBaseUrl": c.apiBaseUrl,
"apiVersion": c.apiVersion,
"configDbId": c.configDbId,
"integrationToken": c.integrationToken
}
proc parseNotionClientConfig*(n: JsonNode): NotionClientConfig =
NotionClientConfig(
apiBaseUrl: n.getOrFail("apiBaseUrl").getStr,
apiVersion: n.getOrFail("apiVersion").getStr,
configDbId: n.getOrFail("configDbId").getStr,
integrationToken: n.getOrFail("integrationToken").getStr)
proc fetchAllPages*(
http: HttpClient,
url: string,
bodyTemplate = %*{ "page_size": NOTION_MAX_PAGE_SIZE}): seq[JsonNode] =
result = @[]
var nextCursor: Option[string] = none[string]()
while true:
let body = parseJson($bodyTemplate)
if nextCursor.isSome: body["start_cursor"] = %nextCursor.get
debug "Fetching pages from database:\n\tPOST " & url & "\n\t" & $body
let jsonResp = parseJson(http.postContent(url, $body))
result = result & jsonResp["results"].getElems
if jsonResp.hasKey("next_cursor") and jsonResp["next_cursor"].kind != JNull:
nextCursor = some(jsonResp["next_cursor"].getStr)
else: break
template fetchDatabaseObject*(notion: NotionClient, dbId: string): untyped =
let resp = notion.http.get(notion.apiBaseUrl & "/databases/" & dbId)
if not resp.status.startsWith("2"):
debug resp.body
raise newException(HttpRequestError, "API Request failed: " & resp.body)
parseJson(resp.body)
template fetchPage*(notion: NotionClient, pageId: string): untyped =
let resp = notion.http.get(notion.apiBaseUrl & "/pages/" & pageId)
if not resp.status.startsWith("2"):
debug resp.body
raise newException(HttpRequestError, "API Request failed: " & resp.body)
parseJson(resp.body)
template createDbPage*(notion: NotionClient, parentDbId: string, r: typed): untyped =
let page = r.toPage
page["parent"] = %*{ "database_id": parentDbId }
let resp = notion.http.post(notion.apiBaseUrl & "/pages", $page)
if not resp.status.startsWith("2"):
debug resp.body
raise newException(HttpRequestError, "API Request failed: " & resp.body)
parseJson(resp.body)
template updatePage*(notion: NotionClient, r: typed): JsonNode =
let page = r.toPage
let resp = notion.http.patch(notion.apiBaseUrl & "/pages/" & r.id, $page)
if not resp.status.startsWith("2"): debug resp.body
parseJson(resp.body)
template delete*(notion: NotionClient, r: typed): string =
let resp = notion.http.delete(notion.apiBaseUrl & "/blocks/" & r.id)
if not resp.status.startsWith("2"): debug resp.body
parseJson(resp.body)["id"].getStr
proc fetchSyncRecords*(notion: NotionClient): seq[SyncRecord] =
let respRecords = notion.http.fetchAllPages(
notion.apiBaseUrl & "/databases/" & notion.config.syncRecordsDbId & "/query")
return respRecords.mapIt(syncRecordFromPage(it))
proc create*(notion: NotionClient, r: SyncRecord): SyncRecord =
return syncRecordFromPage(createDbPage(notion, notion.config.syncRecordsDbId, r))
proc update*(notion: NotionClient, r: SyncRecord): SyncRecord =
return syncRecordFromPage(updatePage(notion, r))
proc fetchAddress*(notion: NotionClient, addressId: string): Address =
return addressFromPage(notion.fetchPage(addressId))
proc fetchAddresses*(notion: NotionClient): seq[Address] =
let respRecords = notion.http.fetchAllPages(
notion.apiBaseUrl & "/databases/" & notion.config.addressesDbId & "/query")
return resprecords.mapIt(addressFromPage(it))
proc create*(notion: NotionClient, a: Address): Address =
return addressFromPage(notion.createDbPage(notion.config.addressesDbId , a))
proc update*(notion: NotionClient, r: Address): Address =
return addressFromPage(updatePage(notion, r))
proc fetchFamily*(notion: NotionClient, familyId: string): Family =
return familyFromPage(notion.fetchPage(familyId))
proc fetchFamilies*(notion: NotionClient): seq[Family] =
let respRecords = notion.http.fetchAllPages(
notion.apiBaseUrl & "/databases/" & notion.config.familiesDbId & "/query")
return respRecords.mapIt(familyFromPage(it))
proc create*(notion: NotionClient, f: Family): Family =
return familyFromPage(notion.createDbPage(notion.config.familiesDbId , f))
proc update*(notion: NotionClient, r: Family): Family =
return familyFromPage(updatePage(notion, r))
proc fetchPerson*(notion: NotionClient, personId: string): Person =
return personFromPage(notion.fetchPage(personId))
proc fetchPeople*(notion: NotionClient): seq[Person] =
let respRecords = notion.http.fetchAllPages(
notion.apiBaseUrl & "/databases/" & notion.config.peopleDbId & "/query")
return respRecords.mapIt(personFromPage(it))
proc create*(notion: NotionClient, p: Person): Person =
return personFromPage(notion.createDbPage(notion.config.peopleDbId, p))
proc update*(notion: NotionClient, r: Person): Person =
return personFromPage(updatePage(notion, r))
proc persist*[T](notion: NotionClient, r: T): T =
if r.id.isEmptyOrWhitespace: notion.create(r)
else: notion.update(r)
proc fetchMembershipDataSet*(notion: NotionClient): MembershipDataSet =
result = MembershipDataSet(
addresses: mapById(notion.fetchAddresses()),
families: mapById(notion.fetchFamilies()),
people: mapById(notion.fetchPeople()))

View File

@ -0,0 +1,47 @@
import std/json, std/sequtils, std/times
import timeutils
type
HffNotionConfigProperty = tuple[key: string, textNode: JsonNode, dateNode: JsonNode]
HffNotionConfig* = object
addressesDbId*: string
familiesDbId*: string
peopleDbId*: string
syncRecordsDbId*: string
lastSyncedAt*: DateTime
proc getCfgProp(cfgPages: JsonNode, key: string): HffNotionConfigProperty =
let propNodes = cfgPages.filterIt(
it{"properties", "Key", "title"}[0]["plain_text"].getStr == key)
if propNodes.len != 1:
raise newException(Exception,
"there is no configuration value for key '" & key &
"' in the Notion Configuration table.")
return (
propNodes[0]{"properties", "Key", "title"}[0]["plain_text"].getStr,
propNodes[0]{"properties", "TextValue", "rich_text"},
propNodes[0]{"properties", "DateTimeValue", "date"})
proc dateValue(prop: HffNotionConfigProperty): DateTime =
if isNil(prop.dateNode) or prop.dateNode.kind == JNull:
raise newException(Exception, prop.key & " does not have a date value")
return parseIso8601(prop.dateNode["start"].getStr)
proc textValue(prop: HffNotionConfigProperty): string =
if prop.textNode.len == 0:
raise newException(Exception, prop.key & " does not have a text value")
return prop.textNode[0]["plain_text"].getStr
proc parseHffNotionConfig*(n: JsonNode): HffNotionConfig =
result = HffNotionConfig(
addressesDbId: n.getCfgProp("addressesDbId").textValue,
familiesDbId: n.getCfgProp("familiesDbId").textValue,
peopleDbId: n.getCfgProp("peopleDbId").textValue,
syncRecordsDbId: n.getCfgProp("syncRecordsDbId").textValue,
lastSyncedAt: n.getCfgProp("lastSyncedAt").dateValue)

View File

@ -1,30 +1,35 @@
import std/json, std/options, std/tables, std/times import std/json, std/options, std/sequtils, std/sets, std/strutils, std/tables,
std/times
import timeutils import timeutils
import ../notion_utils import ./utils
type type
NAddressObj* = object AddressObj* = object
id*: string id*: string
city*: string city*: string
state*: string state*: string
street*: string street*: string
suiteOrBuilding*: string
zipCode*: string zipCode*: string
createdAt*: Option[DateTime] createdAt*: Option[DateTime]
lastUpdatedAt*: Option[DateTime] lastUpdatedAt*: Option[DateTime]
NFamilyObj* = object FamilyObj* = object
id*: string id*: string
name*: string name*: string
headsOfHousehold*: seq[string]
headOfHouseholdIds*: seq[string] headOfHouseholdIds*: seq[string]
primaryAddressId*: seq[string] primaryAddress*: string
primaryAddressId*: string
members*: seq[string]
memberIds*: seq[string] memberIds*: seq[string]
createdAt*: Option[DateTime] createdAt*: Option[DateTime]
lastUpdatedAt*: Option[DateTime] lastUpdatedAt*: Option[DateTime]
NPersonObj* = object PersonObj* = object
id*: string id*: string
preferredName*: string preferredName*: string
firstName*: string firstName*: string
@ -34,18 +39,39 @@ type
gender*: string gender*: string
primaryPhoneNumber*: string primaryPhoneNumber*: string
primaryEmailAddress*: string primaryEmailAddress*: string
addresses*: seq[string]
addressIds*: seq[string] addressIds*: seq[string]
marriedToId*: seq[string] marriedTo*: string
marriedToId*: string
anniversary*: Option[DateTime] anniversary*: Option[DateTime]
parents*: seq[string]
parentIds*: seq[string] parentIds*: seq[string]
children*: seq[string]
childIds*: seq[string] childIds*: seq[string]
relationshipToHff*: seq[string] relationshipToHff*: seq[string]
createdAt*: Option[DateTime] createdAt*: Option[DateTime]
lastUpdatedAt*: Option[DateTime] lastUpdatedAt*: Option[DateTime]
apiPermissions*: seq[string]
NAddress* = ref NAddressObj RecordType* = enum rtPerson, rtFamily, rtAddress
NFamily* = ref NFamilyObj
NPerson* = ref NPersonObj SyncRecordObj* = object
id*: string
knownIds*: HashSet[string]
recType*: RecordType
notionId*: string
pcoId*: string
Address* = ref AddressObj
Family* = ref FamilyObj
Person* = ref PersonObj
SyncRecord* = ref SyncRecordObj
MembershipDataSet* = ref object
addresses*: TableRef[string, Address]
families*: TableRef[string, Family]
people*: TableRef[string, Person]
func sameContents[T](a: seq[T], b: seq[T]): bool = func sameContents[T](a: seq[T], b: seq[T]): bool =
let aCount = toCountTable(a) let aCount = toCountTable(a)
@ -57,13 +83,13 @@ func sameContents[T](a: seq[T], b: seq[T]): bool =
return aCount.len == bCount.len return aCount.len == bCount.len
func `==`*(a, b: NFamily): bool = func `==`*(a, b: Family): bool =
return a.name == b.name and return a.name == b.name and
sameContents(a.headOfHouseholdIds, b.headOfHouseholdIds) and sameContents(a.headOfHouseholdIds, b.headOfHouseholdIds) and
sameContents(a.primaryAddressId, b.primaryAddressId) and sameContents(a.memberIds, b.memberIds) and
sameContents(a.memberIds, b.memberIds) a.primaryAddressId == b.primaryAddressId
func `==`*(a, b: NPerson): bool = func `==`*(a, b: Person): bool =
return a.preferredName == b.preferredName and return a.preferredName == b.preferredName and
a.firstName == b.firstName and a.firstName == b.firstName and
a.middleNames == b.middleNames and a.middleNames == b.middleNames and
@ -73,33 +99,51 @@ func `==`*(a, b: NPerson): bool =
a.primaryPhoneNumber == b.primaryPhoneNumber and a.primaryPhoneNumber == b.primaryPhoneNumber and
a.primaryEmailAddress == b.primaryEmailAddress and a.primaryEmailAddress == b.primaryEmailAddress and
a.anniversary == b.anniversary and a.anniversary == b.anniversary and
a.marriedToId == b.marriedToId and
sameContents(a.addressIds, b.addressIds) and sameContents(a.addressIds, b.addressIds) and
sameContents(a.marriedToId, b.marriedToId) and
sameContents(a.parentIds, b.parentIds) and sameContents(a.parentIds, b.parentIds) and
sameContents(a.childIds, b.childIds) and sameContents(a.childIds, b.childIds) and
sameContents(a.relationshipToHff, b.relationshipToHff) sameContents(a.relationshipToHff, b.relationshipToHff)
func toPage*(a: NAddress): JsonNode = func `$`*(rt: RecordType): string =
case rt:
of rtPerson: "Person"
of rtFamily: "Family"
of rtAddress: "Address"
func initSyncRecord*(recType: RecordType, notionId, pcoId: string, knownIds = initHashSet[string]()): SyncRecord =
result = SyncRecord(
recType: recType,
notionId: notionId,
pcoId: pcoId,
knownIds: knownIds)
result.knownIds.incl(notionId)
result.knownIds.incl(pcoId)
func toPage*(a: Address): JsonNode =
%*{ %*{
"properties": { "properties": {
"City": makeTextProp("rich_text", a.city), "City": makeTextProp("rich_text", a.city),
"State": makeSelectProp(a.state), "State": makeSelectProp(a.state),
"Street Address": makeTextProp("title", a.street), "Street Address": makeTextProp("title", a.street),
"Suite or Building": makeTextProp("rich_text", a.suiteOrBuilding),
"Zip Code": makeTextProp("rich_text", a.zipCode) "Zip Code": makeTextProp("rich_text", a.zipCode)
} }
} }
func toPage*(f: NFamily): JsonNode = func toPage*(f: Family): JsonNode =
%*{ %*{
"properties": { "properties": {
"Name": makeTextProp("title", f.name), "Name": makeTextProp("title", f.name),
"Head(s) of Household": makeRelationProp(f.headOfHouseholdIds), "Head(s) of Household": makeRelationProp(f.headOfHouseholdIds),
"Primary Address": makeRelationProp(f.primaryAddressId), "Primary Address": makeRelationProp(@[f.primaryAddressId]),
"Members": makeRelationProp(f.memberIds) "Members": makeRelationProp(f.memberIds)
} }
} }
func toPage*(p: NPerson): JsonNode = func toPage*(p: Person): JsonNode =
%*{ %*{
"properties": { "properties": {
"Preferred Name": makeTextProp("title", p.preferredName), "Preferred Name": makeTextProp("title", p.preferredName),
@ -112,35 +156,59 @@ func toPage*(p: NPerson): JsonNode =
"Email Address": { "email": p.primaryEmailAddress }, "Email Address": { "email": p.primaryEmailAddress },
"Relationship to HFF": makeMultiSelectProp(p.relationshipToHff), "Relationship to HFF": makeMultiSelectProp(p.relationshipToHff),
"Address": makeRelationProp(p.addressIds), "Address": makeRelationProp(p.addressIds),
"Married To": makeRelationProp(p.marriedToId), "Married To": makeRelationProp(@[p.marriedToId]),
"Anniversary": makeDateProp(p.anniversary), "Anniversary": makeDateProp(p.anniversary),
"Parents": makeRelationProp(p.parentIds), "Parents": makeRelationProp(p.parentIds),
"Children": makeRelationProp(p.childIds), "Children": makeRelationProp(p.childIds),
"System: API Permissions": makeMultiSelectProp(p.apiPermissions),
} }
} }
proc parseNAddress*(page: JsonNode): NAddress = func toPage*(sr: SyncRecord): JsonNode =
result = NAddress( result = %*{
"properties": {
"Notion Rec Id": makeTextProp("title", sr.notionId),
"PCO Rec Id": makeTextProp("rich_text", sr.pcoId),
"Alternate IDs": makeTextProp("rich_text", toSeq(sr.knownIds).join(";"))
}
}
case sr.recType
of rtAddress: result{"properties", "Rec Type"}= %"Address"
of rtFamily: result{"properties", "Rec Type"}= %"Family"
of rtPerson: result{"properties", "Rec Type"}= %"Person"
proc addressFromPage*(page: JsonNode): Address =
result = Address(
id: page["id"].getStr, id: page["id"].getStr,
city: page.getText("City"), city: page.getText("City"),
state: page.getSelect("State"), state: page.getSelect("State"),
street: page.getTitle("Street Address"), street: page.getTitle("Street Address"),
suiteOrBuilding: page.getText("Suite or Building"),
zipCode: page.getText("Zip Code"), zipCode: page.getText("Zip Code"),
createdAt: some(parseIso8601(page["created_time"].getStr)), createdAt: some(parseIso8601(page["created_time"].getStr)),
lastUpdatedAt: some(parseIso8601(page["last_edited_time"].getStr))) lastUpdatedAt: some(parseIso8601(page["last_edited_time"].getStr)))
proc parseNFamily*(page: JsonNode): NFamily = proc familyFromPage*(page: JsonNode): Family =
result = NFamily( let primaryAddressIds = page.getRelationIds("Primary Address")
result = Family(
id: page["id"].getStr, id: page["id"].getStr,
name: page.getTitle("Name"), name: page.getTitle("Name"),
headsOfHousehold: page.getRolledupRecordTitles("Head(s) of Household (display)"),
headOfHouseholdIds: page.getRelationIds("Head(s) of Household"), headOfHouseholdIds: page.getRelationIds("Head(s) of Household"),
primaryAddressId: page.getRelationIds("Primary Address"), primaryAddress: if primaryAddressIds.len == 0: ""
else: page.getRolledupRecordTitles("Primary Address (display)")[0],
primaryAddressId: if primaryAddressIds.len == 0: ""
else: primaryAddressIds[0],
members: page.getRolledupRecordTitles("Members (display)"),
memberIds: page.getRelationIds("Members"), memberIds: page.getRelationIds("Members"),
createdAt: some(parseIso8601(page["created_time"].getStr)), createdAt: some(parseIso8601(page["created_time"].getStr)),
lastUpdatedAt: some(parseIso8601(page["last_edited_time"].getStr))) lastUpdatedAt: some(parseIso8601(page["last_edited_time"].getStr)))
proc parseNPerson*(page: JsonNode): NPerson = proc personFromPage*(page: JsonNode): Person =
result = NPerson( let marriedToIds = page.getRelationIds("Married To")
result = Person(
id: page["id"].getStr, id: page["id"].getStr,
preferredName: page.getTitle("Preferred Name"), preferredName: page.getTitle("Preferred Name"),
firstName: page.getText("First Name"), firstName: page.getText("First Name"),
@ -151,10 +219,29 @@ proc parseNPerson*(page: JsonNode): NPerson =
primaryPhoneNumber: page.getPhone("Primary Phone Number"), primaryPhoneNumber: page.getPhone("Primary Phone Number"),
primaryEmailAddress: page.getEmail("Email Address"), primaryEmailAddress: page.getEmail("Email Address"),
relationshipToHff: page.getMultiSelect("Relationship to HFF"), relationshipToHff: page.getMultiSelect("Relationship to HFF"),
addresses: page.getRollupArrayValues("Full Address").mapIt(it{"formula", "string"}.getStr),
addressIds: page.getRelationIds("Address"), addressIds: page.getRelationIds("Address"),
marriedToId: page.getRelationIds("Married To"), marriedTo: if marriedToIds.len == 0: ""
else: page.getRolledupRecordTitles("Married To (display)")[0],
marriedToId: if marriedToIds.len == 0: ""
else: marriedToIds[0],
anniversary: page.getDateTime("Anniversary"), anniversary: page.getDateTime("Anniversary"),
parents: page.getRolledupRecordTitles("Parents (display)"),
parentIds: page.getRelationIds("Parents"), parentIds: page.getRelationIds("Parents"),
children: page.getRolledupRecordTitles("Children (display)"),
childIds: page.getRelationIds("Children"), childIds: page.getRelationIds("Children"),
createdAt: some(parseIso8601(page["created_time"].getStr)), createdAt: some(parseIso8601(page["created_time"].getStr)),
lastUpdatedAt: some(parseIso8601(page["last_edited_time"].getStr))) lastUpdatedAt: some(parseIso8601(page["last_edited_time"].getStr)),
apiPermissions: page.getMultiSelect("System: API Permissions"))
func syncRecordFromPage*(page: JsonNode): SyncRecord =
result = SyncRecord(
id: page["id"].getStr,
notionId: page.getTitle("Notion Rec Id"),
pcoId: page.getText("PCO Rec Id"),
knownIds: toHashSet(page.getText("Alternate IDs").split(";")))
case page.getSelect("Record Type")
of "Address": result.recType = rtAddress
of "Family": result.recType = rtFamily
of "Person": result.recType = rtPerson

View File

@ -0,0 +1,20 @@
import std/options, std/sugar, std/tables
func sameContents*[T](a: seq[T], b: seq[T]): bool =
let aCount = toCountTable(a)
let bCount = toCountTable(b)
for k in aCount.keys:
if not bCount.hasKey(k) or aCount[k] != bCount[k]: return false
return aCount.len == bCount.len
func findBy*[T](s: seq[T], operation: (T) -> bool): Option[T] =
for i in s:
if operation(i):
return some(i)
return none[T]()
proc mapById*[T](records: seq[T]): TableRef[string, T] =
result = newTable[string, T]()
for r in records: result[r.id] = r

View File

@ -1,10 +1,16 @@
import std/httpclient, std/json, std/logging, std/options, std/sequtils, import std/json, std/logging, std/options, std/sequtils, std/times
std/strutils, std/times import namespaced_logging
import timeutils import timeutils
const NOTION_MAX_PAGE_SIZE* = 100 const NOTION_MAX_PAGE_SIZE* = 100
const NOTION_DATE_FORMAT = "YYYY-MM-dd" const NOTION_DATE_FORMAT = "YYYY-MM-dd"
var logNs {.threadvar.}: LoggingNamespace
template log(): untyped =
if logNs.isNil: logNs = getLoggerForNamespace("hff_notion_api_client/utils", lvlInfo)
logNs
proc parseDate(str: string): DateTime = proc parseDate(str: string): DateTime =
try: result = parseIso8601(str) try: result = parseIso8601(str)
except: result = times.parse(str, NOTION_DATE_FORMAT) except: result = times.parse(str, NOTION_DATE_FORMAT)
@ -51,7 +57,7 @@ proc makeTextProp*(propType: string, value: string): JsonNode =
## Utility functions for reading Page property values ## Utility functions for reading Page property values
## -------------------------------------------------- ## --------------------------------------------------
proc getPropNode(page: JsonNode, propType, propName: string): JsonNode = proc getPropNode*(page: JsonNode, propType, propName: string): JsonNode =
result = page{"properties", propName, propType} result = page{"properties", propName, propType}
if isNil(result): if isNil(result):
@ -75,6 +81,25 @@ proc getRelationIds*(page: JsonNode, propName: string): seq[string] =
let propNode = page.getPropNode("relation", propName) let propNode = page.getPropNode("relation", propName)
return propNode.getElems.mapIt(it["id"].getStr) return propNode.getElems.mapIt(it["id"].getStr)
proc getRollupArrayValues*(page: JsonNode, propName: string): seq[JsonNode] =
return page.getPropNode("rollup", propName){"array"}.getElems(@[])
proc getRolledupRecordTitles*(page: JsonNode, propName: string): seq[string] =
let rollups = page.getPropNode("rollup", propName)["array"]
if rollups.getElems.len == 0: return @[]
result = @[]
for rollupItem in rollups.getElems:
if not rollupItem.hasKey("title") or rollupItem["title"].getElems.len != 1:
log().debug "Expected exactly one item of type 'title'. Received: \n" &
rollupItem.pretty
raise newException(
ValueError,
"unexpected format of rollup value for '" & propName & "' property")
result.add(rollupItem["title"].getElems[0]["plain_text"].getStr)
proc getText*(page: JsonNode, propName: string): string = proc getText*(page: JsonNode, propName: string): string =
let propNode = page.getPropNode("rich_text", propName) let propNode = page.getPropNode("rich_text", propName)
if propNode.len == 0: return "" if propNode.len == 0: return ""
@ -94,31 +119,3 @@ proc getDateTime*(page: JsonNode, propName: string): Option[DateTime] =
let propNode = page.getPropNode("date", propName) let propNode = page.getPropNode("date", propName)
if propNode.kind == JNull: result = none[DateTime]() if propNode.kind == JNull: result = none[DateTime]()
else: result = some(parseDate(propNode["start"].getStr)) else: result = some(parseDate(propNode["start"].getStr))
proc newNotionClient*(apiVersion, integrationToken: string): HttpClient =
return newHttpClient(headers = newHttpHeaders([
("Content-Type", "application/json"),
("Authorization", "Bearer " & integrationToken),
("Notion-Version", apiVersion)
], true))
proc fetchAllPages*(
http: HttpClient,
url: string,
bodyTemplate = %*{ "page_size": NOTION_MAX_PAGE_SIZE}): seq[JsonNode] =
result = @[]
var nextCursor: string = ""
while true:
let body = parseJson($bodyTemplate)
if not nextCursor.isEmptyOrWhitespace: body["start_cursor"] = %nextCursor
debug "Fetching pages from database:\n\tPOST " & url & "\n\t" & $body
let jsonResp = parseJson(http.postContent(url, $body))
result = result & jsonResp["results"].getElems
if jsonResp.hasKey("next_cursor") and jsonResp["next_cursor"].kind != JNull:
nextCursor = jsonResp["next_cursor"].getStr
if nextCursor.isEmptyOrWhitespace: break