diff --git a/app.xqm b/app.xqm deleted file mode 100644 index ab90141..0000000 --- a/app.xqm +++ /dev/null @@ -1,101 +0,0 @@ -xquery version "3.1"; - -module namespace app="http://joewiz.org/ns/xquery/airlock/app"; - -(: see browse.xq for where the color CSS classes are used :) -declare function app:wrap($content, $title) { - - - - - - - - - - - - { $title } - - -
- { $content } -
- - -}; diff --git a/browse.xq b/browse.xq deleted file mode 100644 index e62dc8a..0000000 --- a/browse.xq +++ /dev/null @@ -1,619 +0,0 @@ -xquery version "3.1"; - -import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; -import module namespace markdown="http://exist-db.org/xquery/markdown"; - -declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; - -declare option output:method "html"; -declare option output:media-type "text/html"; - -(: - : TODO - : - implement remaining data types in local:render-field - : - implement remaining field typeOptions - : - show field name instead of field ID when displaying formulaTextParsed -:) - - -(: I captured this list of class pairings from Airtable's multiselect color picker. I don't have a definitive list of color-to-css-class mappings, but any missing ones should be here: - - - -The values used in Indexing Sandbox are: - - blue, - blueDarker, - cyan, - gray, - green, - orange, - pink, - pinkMedium, - purple, - purpleDark, - purpleMedium, - red, - redDark, - redDarker, - teal, - tealDarker, - yellow - -:) -declare variable $local:color-to-css-class := - map { - "blue": "blueBright text-white", - "blueDarker": "blueDark1 text-blue-light2", - "cyan": "cyanBright text-white", - "gray": "grayBright text-white", - "grayDark": "grayDark1 text-gray-light2", - "green": "greenBright text-white", - "greenDark": "greenDark1 text-green-light2", - "orange": "orangeBright text-white", - "pink": "pinkBright text-white", - "pinkDark": "pinkDark1 text-pink-light2", - "pinkMedium": "pinkLight1 text-pink-dark1", - "purple": "purpleBright text-white", - "purpleDark": "purpleDark1 text-purple-light2", - "purpleMedium": "purpleLight1 text-purple-dark1", - "red": "redBright text-white", - "redDark": "redDark1 text-red-light2", - "redDarker": "redDark1 text-red-light2", - "teal": "tealBright text-white", - "tealDarker": "tealDark1 text-teal-light2", - "yellow": "yellowBright text-white", - "yellowDark": "yellowDark1 text-yellow-light2" - } -; - -declare function local:render-airtable-flavored-markdown($markdown) { - $markdown - => replace("\*\*", "") - => replace(" ", " ") - => markdown:parse() -}; - -declare function local:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) { - let $fields := $record?fields - let $field := $fields?($field-key) - let $columns := $table?columns - let $column := $columns?*[?name eq $field-key] - let $type := $column?type - let $type-options := $column?typeOptions - return - switch ($type) - (: TODO: add handling for these types: - - formula(text|date) - - lookup(error|text|multilineText|foreignKey) - - rollup - - select - - checkbox - - date - - number - - collaborator - - multipleAttachment - :) - case "text" return - if ($type-options?validatorName eq "url") then - element a { - attribute href { - $field - }, - $field - } - else - $field - case "multilineText" - case "richText" return - local:render-airtable-flavored-markdown($field) - case "foreignKey" return - let $foreign-table-id := $column?foreignTableId - let $foreign-table := $tables[?id eq $foreign-table-id] - let $primary-column-name := $foreign-table?primaryColumnName - return - if (array:size($field) gt 1) then - element ul { - for $foreign-record-id in $field?* - let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] - let $foreign-record-label := $foreign-record?fields?($primary-column-name) - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $foreign-table-id, - "record-id=" || $foreign-record-id - ), "&") - }, - $foreign-record-label - } - } - } - else - let $foreign-record-id := $field - let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] - let $foreign-record-label := $foreign-record?fields?($primary-column-name) - return - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $foreign-table-id, - "record-id=" || $foreign-record-id - ), "&") - }, - $foreign-record-label - } - case "multiSelect" return - let $values := $field - let $choices := $type-options?choices - return - if (count($values) gt 1) then - element ol { - let $ordered-values := sort($values, (), function($value) { index-of($type-options?choiceOrder?*, $value) }) - for $value in $ordered-values - let $color := $choices[?name eq $value?color] - return - element li { - element span { - attribute class { "badge badge-pill " || $local:color-to-css-class?($color) }, - $value - } - } - } - else - let $value := $values - let $color := $choices[?name eq $value]?color - return - element span { - attribute class { "badge badge-pill " || $local:color-to-css-class?($color) }, - $value - } - case "multipleAttachment" return - let $attachments := $field?* - for $attachment in $attachments - let $filename := $attachment?filename - let $type := $attachment?type - let $url := $attachment?url - let $size := $attachment?size - return - switch ($type) - case "image/jpeg" case "application/pdf" return - let $thumbnails := $attachment?thumbnails - let $large := $thumbnails?large - return - element p { - element a { - attribute href { $url }, - element img { - attribute src { - $large?url - } - } - } - } - default return - "unknown image type " || $type - default return - if ($field instance of array(*)) then - if (array:size($field) gt 1) then - element ul { - $field?* ! element li { . } - } - else - $field?* - (: "Position Length" : { "specialValue" : "NaN" } :) - else if ($field instance of map(*)) then - $field?* - else - $field -}; - -let $base-id := request:get-parameter("base-id", ()) -let $snapshot-id := request:get-parameter("snapshot-id", ()) -let $table-id := request:get-parameter("table-id", ()) -let $field-id := request:get-parameter("field-id", ()) -let $record-id := request:get-parameter("record-id", ()) -let $bases := doc("/db/apps/airlock-data/bases/bases.xml")//base -let $base := $bases[id eq $base-id] -let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot -let $snapshot := $snapshots[id eq $snapshot-id] -let $tables := if ($base-id and $snapshot-id) then xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables") ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables/" || .) else () -let $table := $tables[?id eq $table-id] -let $columns := $table?columns?* -let $records := $table?records?* -let $column := $columns[?id eq $field-id] -let $record := $records[?id eq $record-id] -let $fields := $record?fields -let $render-function := function($field-key) { local:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) } -let $base-name := $base/name/string() -let $api-key := $base/api-key/string() -let $snapshot-dateTime := $snapshot/created-dateTime => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]") -let $table-name := $table?name -let $column-name := $column?name -let $primary-column-name := $table?primaryColumnName -let $adjusted-primary-column-name := - if ($primary-column-name eq "id") then - "ID" - else - $primary-column-name -let $record-primary-field := $fields?($adjusted-primary-column-name) -let $item := - if (empty($base-id)) then - - - - - - - - - - - { - for $base in $bases - let $base-id := $base/id/string() - let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot - let $base-name := $base/name/string() - let $api-key := $base/api-key/string() - let $created-dateTime := $base/created-dateTime cast as xs:dateTime - let $last-snapshot := $snapshots[last()]/created-dateTime[. ne ""] ! (. cast as xs:dateTime) - order by $base-name - return - - - - - - - - } -
NameBase IDAPI KeyDate CreatedLast Snapshot
{$base-name}{$base-id}{$api-key}{format-dateTime($created-dateTime, "[MNn] [D], [Y] [h]:[m01] [PN]")}{if (exists($last-snapshot)) then format-dateTime($last-snapshot, "[MNn] [D], [Y] [h]:[m01] [PN]") else No snapshots}
- else if (exists($base-id) and empty($snapshot-id)) then - let $created-dateTime := $base/created-dateTime cast as xs:dateTime - let $last-snapshot := $snapshots[last()]/created-dateTime[. ne ""] ! (. cast as xs:dateTime) - return -
-
-
Base ID
-
{$base-id}
-
API Key
-
{$api-key}
-
- - - - - - - - - - - - - { - for $snapshot in $snapshots - let $snapshot-id := $snapshot/id/string() - let $created-dateTime := $snapshot/created-dateTime cast as xs:dateTime - order by $created-dateTime - return - - - - - - - - - } -
SnapshotDate CreatedTablesRecordsFieldsCells
{$snapshot-id}{format-dateTime($created-dateTime, "[MNn] [D], [Y] [h]:[m01] [PN]")}{$snapshot/tables-count/string()}{$snapshot/records-count/string()}{$snapshot/fields-count/string()}{$snapshot/cells-count/string()}
-
- else if (exists($base-id) and exists($snapshot-id) and empty($table-id)) then - element div { - element h2 { "Tables" }, - element ul { - for $table in $tables - let $table-id := $table?id - let $table-name := $table?name - order by $table-name - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $table-id - ), "&" - ) - }, - $table-name - } - } - } - } - else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and (empty($record-id) and empty($field-id))) then - element div { - element h2 { "Fields" }, - element ul { - for $column in $columns - let $column-id := $column?id - let $column-name := $column?name - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $table-id, - "field-id=" || $column-id - ), "&" - ) - }, - $column-name - } - } - }, - element h2 { "Records" }, - element ul { - let $primary-column-name := $table?primaryColumnName - let $adjusted-primary-column-name := - (: - if ($primary-column-name eq "id") then - "ID" - else - :) - $primary-column-name - (: return element pre { serialize($table, map{"indent": true()}) }:) - let $records := $table?records - for $record in $records?* - let $record-id := $record?id - let $fields := $record?fields - let $record-name := $fields?($adjusted-primary-column-name) - order by $record-name collation "http://www.w3.org/2013/collation/UCA?numeric=yes" - return - element li { - element a { - attribute href { - "?" - || string-join(( - "base-id=" || $base-id, - "snapshot-id=" || $snapshot-id, - "table-id=" || $table-id, - "record-id=" || $record-id - ), "&" - ) - }, - $record-name - } - } - } - } - else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and exists($field-id)) then - element dl { - element dt { "id" }, - element dd { $field-id }, - element dt { "name" }, - element dd { $column?name }, - element dt { "type" }, - element dd { $column?type }, - if (map:contains($column, "typeOptions")) then - ( - element dt { "typeOptions" }, - element dd { - let $entries := $column?typeOptions - let $choices := $entries?choices?* - let $choice-order := $entries?choiceOrder - let $foreign-table-id := $entries?foreignTableId - let $foreign-table := $tables[?id eq $foreign-table-id] - let $symmetric-column-id := $entries?symmetricColumnId - let $symmetric-column := $foreign-table?columns?*[?id eq $symmetric-column-id] - let $relation-column-id := $entries?relationColumnId - let $relation-column := $table?columns?*[?id eq $relation-column-id] -(: "foreignTableRollupColumnId":) - return - if (exists($choices) and exists($choice-order)) then - element dl { - element dt { "choices" }, - element dd { - let $ordered-choices := sort($choices, (), function($choice) { index-of($choice-order, $choice?id) }) - return - element ol { - for $choice in $ordered-choices - let $color := $choice?color - return - element li { - element span { - attribute class { "badge badge-pill " || $local:color-to-css-class?($color) }, - $choice?name - } - } - } - } - } - else - for $key in ($entries ! map:keys(.)[not(. = ("choices", "choice-order"))]) (: except ($choices, $choice-order) :) - let $entry := $entries($key) - return - element dl { - element dt { $key }, - element dd { - switch ($key) - case "foreignTableId" return - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $foreign-table-id - }, - $foreign-table?name - } - case "symmetricColumnId" return - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $foreign-table-id || "&field-id=" || $symmetric-column-id - }, - $symmetric-column?name - } - case "relationColumnId" return - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $table-id || "&field-id=" || $relation-column-id - }, - $relation-column?name - } - case "dependencies" return - element ol { - for $referenced-column-id in $entry/?referencedColumnIdsForValue?* - let $referenced-column := $table?columns?*[?id eq $referenced-column-id] - return - element li { - element a { - attribute href { - "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $table-id || "&field-id=" || $referenced-column-id - }, - $referenced-column?name - } - } - } - (: TODO parse field references :) - case "formulaTextParsed" return - element code { $entry } - default return - $entry - } - } - } - ) - else - () - } - else if (exists($base-id) and exists($snapshot-id) and exists($table-id) and exists($record-id)) then - element dl { - element dt { "id" }, - element dd { $record-id }, - for $field-key in ($adjusted-primary-column-name, map:keys($fields)[. ne $adjusted-primary-column-name]) - let $column-id := $columns[?name eq $field-key]?id - return - ( - element dt { - element a { - attribute href { "?base-id=" || $base-id || "&snapshot-id=" || $snapshot-id || "&table-id=" || $table-id || "&field-id=" || $column-id}, - $field-key - } - }, - element dd { $render-function($field-key) } - ) - } - else - () -let $breadcrumbs := - ( - Airlock, - text { " > " }, - Bases, - if ($base-id) then - ( - text { " > " }, - {$base-name} - ) - else - (), - if ($snapshot-id) then - ( - text { " > " }, - Snapshot {$snapshot-id} - ) - else - (), - if ($table-id) then - ( - text { " > " }, - “{$table-name}” Table - ) - else - (), - if ($field-id) then - ( - text { " > " }, - “{$column-name}” Field - ) - else - (), - if ($record-id) then - ( - text { " > " }, - “{$record-primary-field}” Record - ) - else - () - ) -let $title := $breadcrumbs/normalize-space() -let $content := - element div { - element h1 { - $breadcrumbs - }, - $item - } -return - app:wrap($content, $title) \ No newline at end of file diff --git a/controller.xql b/controller.xql index 5c3cd1e..d6db81c 100644 --- a/controller.xql +++ b/controller.xql @@ -6,20 +6,29 @@ declare variable $exist:controller external; declare variable $exist:prefix external; declare variable $exist:root external; +declare variable $base-url := request:get-context-path() || $exist:prefix || $exist:controller; + (: redirect requests for app root "" to "/" :) if ($exist:path eq "") then - -(: handle request for landing page "/" :) -else if ($exist:path eq "/") then - - - - -(: pass all other requests through :) + +(: all other requests are passed on the Open API router :) else - - + + + + + + + + + \ No newline at end of file diff --git a/expath-pkg.xml b/expath-pkg.xml index a1122d0..0ce1ce8 100644 --- a/expath-pkg.xml +++ b/expath-pkg.xml @@ -1,9 +1,9 @@ - - Airlock - - - + + Airlock + + + + diff --git a/modules/api.json b/modules/api.json new file mode 100644 index 0000000..44cd8d3 --- /dev/null +++ b/modules/api.json @@ -0,0 +1,1065 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Airlock API", + "description": "Airlock custom endpoints" + }, + "servers": [ + { + "description": "Endpoint for testing on localhost", + "url": "http://localhost:8080/exist/apps/airlock" + } + ], + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "cookieAuth": { + "type": "apiKey", + "name": "airlock.joewiz.org.login", + "in": "cookie" + } + } + }, + "paths": { + "/": { + "get": { + "summary": "Landing page", + "operationId": "bases:welcome", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the landing page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases": { + "get": { + "summary": "List bases", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the listing of bases!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Add a new base", + "description": "Add a new base", + "operationId": "bases:create-base", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "query", + "description": "Base ID", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "base-name", + "in": "query", + "description": "Base Name", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "permission-level", + "in": "query", + "description": "Permission level", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "Base notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + }, + "/bases/{base-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/edit": { + "get": { + "summary": "Edit a base", + "operationId": "bases:edit-base-form", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Edit a base", + "operationId": "bases:edit-base", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "description": "Base ID", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "new-base-id", + "in": "query", + "description": "Base Name", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "base-name", + "in": "query", + "description": "Base Name", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "permission-level", + "in": "query", + "description": "Permission level", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "Base notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/delete": { + "get": { + "summary": "Delete a base", + "operationId": "bases:delete-base-confirm", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Delete a base", + "operationId": "bases:delete-base", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/edit/custom-reports": { + "get": { + "summary": "Add new Custom Report form", + "description": "Add new Custom Report form", + "operationId": "bases:create-custom-report-form", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + }, + "post": { + "summary": "Add a new Custom Report", + "description": "Add a new Custom Report", + "operationId": "bases:create-custom-report", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "report-label", + "in": "query", + "description": "Airtable Username", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "report-description", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "report-location", + "in": "query", + "description": "Metadata API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/delete": { + "get": { + "summary": "Delete a snapshot", + "operationId": "bases:delete-snapshot", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/records/{record-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "record-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshots/{snapshot-id}/{table-id}/fields/{field-id}": { + "get": { + "summary": "View a base", + "operationId": "bases:view", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "snapshot-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "table-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "field-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the info about the base!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/snapshot": { + "get": { + "summary": "Take snapshot", + "operationId": "bases:create-snapshot", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/bases/{base-id}/base-metadata": { + "post": { + "summary": "Post body as application/octet-stream", + "operationId": "bases:update-base-metadata", + "x-constraints": { + "group": "airlock" + }, + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files[]": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "base-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Upload result", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Permission denied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "details": { + "type": "object", + "nullable": true + } + } + } + } + } + }, + "404": { + "description": "Upload collection not found", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "500": { + "description": "Upload collection not found", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/keys": { + "get": { + "summary": "Manage API keys", + "description": "Manage API Keys", + "operationId": "keys:welcome", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + }, + "post": { + "summary": "Add a new API key", + "description": "Login the given user", + "operationId": "keys:create-key", + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "username", + "in": "query", + "description": "Airtable Username", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "metadata-api-key", + "in": "query", + "description": "Metadata API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "API key notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + }, + "/keys/{key-id}/delete": { + "get": { + "summary": "Delete API key", + "description": "Delete API key", + "operationId": "keys:delete-key-confirm", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Delete API key", + "description": "Delete API key", + "operationId": "keys:delete-key", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/keys/{key-id}": { + "get": { + "summary": "Edit API key", + "description": "Edit API key", + "operationId": "keys:edit-form", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "... the page!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } + }, + "post": { + "summary": "Edit API key", + "description": "Edit API key", + "operationId": "keys:update-key", + "x-constraints": { + "group": "airlock" + }, + "parameters": [ + { + "name": "base-url", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "key-id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "username", + "in": "query", + "description": "Airtable Username", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "rest-api-key", + "in": "query", + "description": "REST API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "metadata-api-key", + "in": "query", + "description": "Metadata API key", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + }, + { + "name": "notes", + "in": "query", + "description": "API key notes", + "schema": { + "type": "string", + "example": "tei", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "... the result of the snapshot!", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "401": { + "description": "Wrong user or password" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "basicAuth": [] + } + ] +} \ No newline at end of file diff --git a/modules/api.xq b/modules/api.xq new file mode 100644 index 0000000..76c8588 --- /dev/null +++ b/modules/api.xq @@ -0,0 +1,39 @@ +xquery version "3.1"; + +declare namespace api="http://e-editiones.org/roasted/test-api"; +declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; + +import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; +import module namespace bases="http://joewiz.org/ns/xquery/airlock/bases" at "bases.xqm"; +import module namespace config="http://joewiz.org/ns/xquery/airlock/config" at "config.xqm"; +import module namespace keys="http://joewiz.org/ns/xquery/airlock/keys" at "keys.xqm"; + +import module namespace errors="http://e-editiones.org/roaster/errors"; +import module namespace roaster="http://e-editiones.org/roaster"; +import module namespace rutil="http://e-editiones.org/roaster/util"; + + +(:~ + : list of definition files to use + :) +declare variable $api:definitions := "modules/api.json"; + + +(:~ + : You can add application specific route handlers here. + : Having them in imported modules is preferred. + :) + + +(: end of route handlers :) + +(:~ + : This function "knows" all modules and their functions + : that are imported here + : You can leave it as it is, but it has to be here + :) +declare function api:lookup($name as xs:string) { + function-lookup(xs:QName($name), 1) +}; + +roaster:route($api:definitions, api:lookup#1) diff --git a/modules/app.xqm b/modules/app.xqm new file mode 100644 index 0000000..34ff5cf --- /dev/null +++ b/modules/app.xqm @@ -0,0 +1,210 @@ +xquery version "3.1"; + +module namespace app="http://joewiz.org/ns/xquery/airlock/app"; + +import module namespace config="http://joewiz.org/ns/xquery/airlock/config" at "config.xqm"; + +import module namespace login="http://exist-db.org/xquery/login" at "resource:org/exist/xquery/modules/persistentlogin/login.xql"; + +declare namespace sm="http://exist-db.org/xquery/securitymanager"; + +declare function app:login-form($request as map(*)) { + let $base-url := $request?parameters?base-url + let $title := "Log in" + let $content := +
+

Airlock

+ +

{$title}

+
+
+ + +
User must be a member of the airlock group.
+
+
+ + +
+ +
+ { + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then +
+ + +
+ else + () + } +
+ return + app:wrap($content, $title) +}; + + +(:~ + : Either login a user (if parameter `user` is specified) or check if the current user is logged in. + : Setting parameter `logout` to any value will log out the current user. + : + : Copied and adapted from tei-publisher, I think? + :) +declare function app:login($request as map(*)) { + let $base-url := $request?parameters?base-url + let $logout := $request?parameters?logout + let $user := $request?parameters?user + let $loginDomain := $config:login-domain + return + if ($logout) then + ( + login:set-user($loginDomain, (), false()), + let $title := "Success" + let $content := + + return + app:wrap($content, $title) + ) + else if (sm:get-user-groups($user) = config:repo-permissions()?group) then + ( + login:set-user($loginDomain, (), false()), + let $user := request:get-attribute($loginDomain || ".user") + return + if (exists($user)) then + let $title := "Success" + let $content := + + return + app:wrap($content, $title) + else + let $title := "Login failed" + let $content := + + return + app:wrap($content, $title) + ) + else + let $title := "Login failed" + let $content := + + return + app:wrap($content, $title) +}; + +(: see browse.xq for where the color CSS classes are used :) +declare function app:wrap($content, $title) { + + + + + + + + + + + + { $title } + + +
+ { $content } +
+ + +}; diff --git a/modules/bases.xqm b/modules/bases.xqm new file mode 100644 index 0000000..a70572f --- /dev/null +++ b/modules/bases.xqm @@ -0,0 +1,1285 @@ +xquery version "3.1"; + +module namespace bases="http://joewiz.org/ns/xquery/airlock/bases"; + +import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; +import module namespace config="http://joewiz.org/ns/xquery/airlock/config" at "config.xqm"; + +import module namespace airtable="http://joewiz.org/ns/xquery/airtable"; +import module namespace markdown="http://exist-db.org/xquery/markdown"; + +declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; + +declare option output:method "html"; +declare option output:media-type "text/html"; + +(: + : TODO + : - implement remaining data types in bases:render-field + : - implement remaining field typeOptions + : - show field name instead of field ID when displaying formulaTextParsed +:) + + +(: I captured this list of class pairings from Airtable's multiselect color picker. I don't have a definitive list of color-to-css-class mappings, but any missing ones should be here: + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +The values used in Indexing Sandbox are: + + blue, + blueDarker, + cyan, + gray, + green, + orange, + pink, + pinkMedium, + purple, + purpleDark, + purpleMedium, + red, + redDark, + redDarker, + teal, + tealDarker, + yellow + +:) +declare variable $bases:color-to-css-class := + map { + "blue": "blueBright text-white", + "blueDarker": "blueDark1 text-blue-light2", + "cyan": "cyanBright text-white", + "gray": "grayBright text-white", + "grayDark": "grayDark1 text-gray-light2", + "green": "greenBright text-white", + "greenDark": "greenDark1 text-green-light2", + "orange": "orangeBright text-white", + "pink": "pinkBright text-white", + "pinkDark": "pinkDark1 text-pink-light2", + "pinkMedium": "pinkLight1 text-pink-dark1", + "purple": "purpleBright text-white", + "purpleDark": "purpleDark1 text-purple-light2", + "purpleMedium": "purpleLight1 text-purple-dark1", + "red": "redBright text-white", + "redDark": "redDark1 text-red-light2", + "redDarker": "redDark1 text-red-light2", + "teal": "tealBright text-white", + "tealDarker": "tealDark1 text-teal-light2", + "yellow": "yellowBright text-white", + "yellowDark": "yellowDark1 text-yellow-light2" + } +; + +declare function bases:render-airtable-flavored-markdown($markdown) { + $markdown + => replace("\*\*", "") + => replace(" ", " ") + => markdown:parse() +}; + +declare function bases:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) { + let $fields := $record?fields + let $field := $fields?($field-key) + let $columns := $table?columns + let $column := $columns?*[?name eq $field-key] + let $type := $column?type + let $type-options := $column?typeOptions + return + switch ($type) + (: TODO: add handling for these types: + - formula(text|date) + - lookup(error|text|multilineText|foreignKey) + - rollup + - select + - checkbox + - date + - number + - collaborator + - multipleAttachment + :) + case "text" return + if ($type-options?validatorName eq "url") then + element a { + attribute href { + $field + }, + $field + } + else + $field + case "multilineText" + case "richText" return + bases:render-airtable-flavored-markdown($field) + case "foreignKey" return + let $foreign-table-id := $column?foreignTableId + let $foreign-table := $tables[?id eq $foreign-table-id] + let $primary-column-name := $foreign-table?primaryColumnName + return + if (array:size($field) gt 1) then + element ul { + for $foreign-record-id in $field?* + let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] + let $foreign-record-label := $foreign-record?fields?($primary-column-name) + return + element li { + element a { + attribute href { + "?" + || string-join(( + "base-id=" || $base-id, + "snapshot-id=" || $snapshot-id, + "table-id=" || $foreign-table-id, + "record-id=" || $foreign-record-id + ), "&") + }, + $foreign-record-label + } + } + } + else + let $foreign-record-id := $field + let $foreign-record := $foreign-table?records?*[?id eq $foreign-record-id] + let $foreign-record-label := $foreign-record?fields?($primary-column-name) + return + element a { + attribute href { + "?" + || string-join(( + "base-id=" || $base-id, + "snapshot-id=" || $snapshot-id, + "table-id=" || $foreign-table-id, + "record-id=" || $foreign-record-id + ), "&") + }, + $foreign-record-label + } + case "multiSelect" return + let $values := $field?* + let $choices := $type-options?choices + return + if (count($values) gt 1) then + element ol { + let $ordered-values := sort($values, (), function($value) { index-of($type-options?choiceOrder?*, $value) }) + for $value in $ordered-values + let $color := $choices?*[?name eq $value]?color + return + element li { + element span { + attribute class { "badge rounded-pill " || $bases:color-to-css-class?($color) }, + $value + } + } + } + else + let $value := $values + let $color := $choices?*[?name eq $value]?color + return + element span { + attribute class { "badge rounded-pill " || $bases:color-to-css-class?($color) }, + $value + } + case "multipleAttachment" return + let $attachments := $field?* + for $attachment in $attachments + let $filename := $attachment?filename + let $type := $attachment?type + let $url := $attachment?url + let $size := $attachment?size + return + switch ($type) + case "image/jpeg" case "application/pdf" return + let $thumbnails := $attachment?thumbnails + let $large := $thumbnails?large + return + element p { + element a { + attribute href { $url }, + element img { + attribute src { + $large?url + } + } + } + } + default return + "unknown image type " || $type + default return + if ($field instance of array(*)) then + if (array:size($field) gt 1) then + element ul { + $field?* ! element li { . } + } + else + $field?* + (: "Position Length" : { "specialValue" : "NaN" } :) + else if ($field instance of map(*)) then + $field?* + else + $field +}; + +declare function bases:view($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $snapshot-id := $request?parameters?snapshot-id + let $table-id := $request?parameters?table-id + let $field-id := $request?parameters?field-id + let $record-id := $request?parameters?record-id + let $bases := doc("/db/apps/airlock-data/bases/bases.xml")//base + let $base := $bases[id eq $base-id] + let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot + let $snapshot := $snapshots[id eq $snapshot-id] + let $tables := if ($base-id and $snapshot-id) then xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables") ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id || "/tables/" || .) else () + let $table := $tables[?id eq $table-id] + let $columns := $table?columns?* + let $records := $table?records?* + let $column := $columns[?id eq $field-id] + let $record := $records[?id eq $record-id] + let $fields := $record?fields + let $render-function := function($field-key) { bases:render-field($base-id, $snapshot-id, $tables, $table, $record, $field-key) } + let $base-name := $base/name/string() + let $api-key := $base/api-key/string() + let $custom-reports := $base//custom-report + let $snapshot-dateTime := $snapshot/created-dateTime => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]") + let $table-name := $table?name + let $column-name := $column?name + let $primary-column-name := $table?primaryColumnName + let $adjusted-primary-column-name := + if ($primary-column-name eq "id") then + "ID" + else + $primary-column-name + let $record-primary-field := $fields?($adjusted-primary-column-name) + let $item := + if (empty($base-id)) then +
+

Bases

+ { + if ($bases) then + + + + + + + + + { + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then + + else + () + } + + + { + for $base in $bases + let $base-id := $base/id/string() + let $snapshots := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")//snapshot + let $base-name := $base/name/string() + let $notes := $base/notes/string() + let $api-key := $base/api-key/string() + let $created-dateTime := $base/created-dateTime cast as xs:dateTime + let $last-snapshot := $snapshots[last()]/created-dateTime[. ne ""] ! (. cast as xs:dateTime) + order by $base-name + return + + + + + + + { + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then + + else + () + } + + } +
NameBase IDNotesDate CreatedLast SnapshotAction
{$base-name}{$base-id}{$notes}{format-dateTime($created-dateTime, "[MNn] [D], [Y] [h]:[m01] [PN]")}{ + if (exists($last-snapshot)) then + format-dateTime($last-snapshot, "[MNn] [D], [Y] [h]:[m01] [PN]") + else + No snapshots + }Edit
+ else +

No bases have been added.

+ , + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then + if (empty(doc("/db/apps/airlock-data/keys/keys.xml")//key-set)) then +
+

Add a base

+

To add a base, first add your API key.

+
+ else +
+

Add a base

+

Use the Notes field as you wish. + +

+
+
+ + +
To find your base's ID, go to https://airtable.com/api and select your base to view its API documentation. Copy the ID from the “Introduction.”
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ Delete + +

Custom Reports

+ { + if ($custom-reports) then + + + + + + + + + + + + { + for $report in $custom-reports + return + + + + + + + + } + +
IDLabelDescriptionLocationAction
{$report/id/string()}{$report/label/string()}{$report/description/normalize-space()}{$report/location/string()}Edit
+ else +

No custom reports. Add a custom report.

+ } +
+ return + app:wrap($content, $title) +}; + +declare function bases:custom-report-element($report-id as xs:string, $report-label as xs:string, $report-description as xs:string, $report-location as xs:string) as element(custom-report) { + element custom-report { + element id { $report-id }, + element label { $report-label }, + element description { $report-description }, + element location { $report-location } + } +}; + +declare function bases:create-custom-report-form($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] + let $custom-reports := $base/custom-reports + let $max-id := max(($custom-reports/custom-report/id ! (. cast as xs:integer), 0)) + let $next-id := $max-id + 1 + let $title := "Create custom report" + let $content := +
+

Airlock

+ +

{$title}

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+
+ return + app:wrap($content, $title) +}; + +declare function bases:create-custom-report($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] + let $custom-reports := $base/custom-reports + let $max-id := max(($custom-reports/custom-report/id ! (. cast as xs:integer), 0)) + let $next-id := $max-id + 1 + let $report-label := $request?parameters?report-label + let $report-description := $request?parameters?report-description + let $report-location := $request?parameters?report-location + let $new-custom-report := bases:custom-report-element($next-id, $report-label, $report-description, $report-location) + let $add := update insert $new-custom-report into $custom-reports + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +declare function bases:base-element($base-id as xs:string, $base-name as xs:string, $api-key as xs:string, $permission-level as xs:string, $notes as xs:string, $custom-reports as element(custom-report)*, $created-dateTime as xs:dateTime, $last-modified-dateTime as xs:dateTime?) { + element base { + element id { $base-id }, + element name { $base-name }, + element api-key { $api-key }, + element permission-level { $permission-level }, + element notes { $notes }, + element custom-reports { $custom-reports }, + element created-dateTime { $created-dateTime }, + element last-modified-dateTime { $last-modified-dateTime } + } +}; + +declare function bases:create-base($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base-name := $request?parameters?base-name + let $rest-api-key := $request?parameters?rest-api-key + let $permission-level := $request?parameters?permission-level + let $notes := $request?parameters?notes + let $new-base := bases:base-element($base-id, $base-name, $rest-api-key, $permission-level, $notes, (), current-dateTime(), ()) + let $add := + ( + update insert $new-base into doc("/db/apps/airlock-data/bases/bases.xml")/bases, + bases:mkcol("/db/apps/airlock-data/bases", $base-id || "/snapshots"), + bases:store("/db/apps/airlock-data/bases/" || $base-id, "snapshots.xml", ) + ) + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +declare function bases:update-base-metadata($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $file := request:get-uploaded-file-data("files[]") + let $base-col := + if (xmldb:collection-available("/db/apps/airlock-data/bases/" || $base-id)) then + "/db/apps/airlock-data/bases/" || $base-id + else + bases:mkcol("/db/apps/airlock-data/bases", $base-id) + let $store := bases:store("/db/apps/airlock-data/bases/" || $base-id, "base-metadata.json", $file) + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +(:~ + : Recursively create a collection hierarchy + :) +declare %private function bases:mkcol($collection as xs:string, $path as xs:string) { + bases:mkcol-recursive($collection, tokenize($path, "/")) +}; + +declare + %private +function bases:mkcol-recursive($collection as xs:string, $components as xs:string*) { + if (exists($components)) then + let $newColl := concat($collection, "/", $components[1]) + return ( + xmldb:create-collection($collection, $components[1]) ! + ( + sm:chgrp(xs:anyURI(.), config:repo-permissions()?group), + sm:chmod(xs:anyURI(.), config:repo-permissions()?mode), + . + ), + bases:mkcol-recursive($newColl, subsequence($components, 2)) + ) + else + () +}; + + +(:~ + : Helper function to store resources and set permissions for access by repo group + :) +declare function bases:store($collection-uri as xs:string, $resource-name as xs:string, $contents as item()?) as xs:string { + xmldb:store($collection-uri, $resource-name, $contents) ! + ( + sm:chgrp(., config:repo-permissions()?group), + sm:chmod(., config:repo-permissions()?mode), + . + ) +}; + +declare function bases:get-table-for-snapshot($api-key as xs:string, $base-id as xs:string, $base-metadata as map(*)) { + let $table := airtable:list-records($api-key, $base-id, $base-metadata?name, true(), (), (), (), (), (), (), ()) + return + (: pass error messages back through :) + if ($table instance of element()) then + $table + else + map { + "name": $base-metadata?name, + "id": $base-metadata?id, + "primaryColumnName": $base-metadata?primaryColumnName, + "columns": $base-metadata?columns?*, + "records": array:join($table?records) + } +}; + +(: ensure columns entry contains an array :) +declare function bases:fix-columns-entry($table as map(*)*) { + map:merge(( + for $key in map:keys($table) + return + if ($key eq "columns" and map:get($table, $key) instance of map(*)) then + map:entry("columns", array { $table?columns } ) + else + map:entry($key, $table($key)) + )) +}; + +declare function bases:create-snapshot($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $base := doc("/db/apps/airlock-data/bases/bases.xml")//base[id eq $base-id] + let $base-name := $base/name/string() + let $api-key := $base/api-key/string() + let $snapshots-doc := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")/snapshots + let $next-id := ($snapshots-doc/snapshot[last()]/id, "0")[1] cast as xs:integer + 1 + let $snapshot := element snapshot { element id { $next-id }, element created-dateTime { current-dateTime() } } + let $prepare := + ( + bases:mkcol("/db/apps/airlock-data/bases/", $base-id || "/snapshots/" || $next-id || "/tables"), + xmldb:copy-resource( + "/db/apps/airlock-data/bases/" || $base-id, + "base-metadata.json", + "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id, + "base-metadata.json", + true() + ), + if (exists($snapshots-doc)) then + update insert $snapshot into $snapshots-doc + else + bases:store("/db/apps/airlock-data/bases/" || $base-id, "snapshots.xml", element snapshots { $snapshot } ) + ) + + (: https://github.com/Airtable/airtable.js/issues/12#issuecomment-349987627 + : + : for exporting table metadata, start from https://airtable.com/appe0AfkruafOCgrw/api/docs :) + let $tables := json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/base-metadata.json")?* + let $store := + for $base-metadata in $tables + let $contents := bases:get-table-for-snapshot($api-key, $base-id, $base-metadata) + (: + if ($contents instance of element()) then + app:wrap( + element div { + element h1 { error }, + element pre { $contents => serialize() } + }, + "Error" + ) + else + :) + return + bases:store( + "/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables", + ($base-metadata?name || ".json") + (: space, nbsp, plus, colon, slash, bullet point :) + => replace("[  +:/•]", "-") + (: parentheses :) + => replace("[\(\)]", "") + => replace("-+", "-") + => replace("^-", "_") + => lower-case(), + $contents => serialize(map{ "method": "json", "indent": true() }) + ) + let $summarize := + let $resources := xmldb:get-child-resources("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables") + let $tables := $resources ! json-doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $next-id || "/tables/" || .) + let $records-count := + sum( + for $table in $tables + return + array:size($table?records) + ) + let $fields-count := + sum( + for $table in $tables + return + if ($table?columns instance of array(*)) then + array:size($table?columns) + else + 1 + ) + let $cells-count := + sum( + for $record in $tables?records?* + return + map:size($record?fields) + ) + return + update insert ( + element tables-count { count($tables) }, + element records-count { $records-count }, + element fields-count { $fields-count }, + element cells-count { $cells-count } + ) + into $snapshots-doc//snapshot[id eq $next-id cast as xs:string] + let $content := + element div { + attribute class { "alert alert-success" }, + attribute role { "alert" }, + element h4 { attribute class { "alert-heading" }, "Success" }, + element p { "Stored " || count($store) || " resources:" }, + element ul { $store ! element li { . } }, + element p { + element a { + attribute href { $base-url || "/bases/" || $base-id || "/snapshots/" || $next-id }, + "View snapshot " || $next-id + } + } + } + return + app:wrap($content, "Created snapshot " || $next-id) +}; + +declare function bases:delete-snapshot($request as map(*)) { + let $base-url := $request?parameters?base-url + let $base-id := $request?parameters?base-id + let $snapshot-id := $request?parameters?snapshot-id + let $snapshot := doc("/db/apps/airlock-data/bases/" || $base-id || "/snapshots.xml")/snapshots/snapshot[id eq $snapshot-id] + let $delete := + ( + update delete $snapshot, + xmldb:remove("/db/apps/airlock-data/bases/" || $base-id || "/snapshots/" || $snapshot-id) + ) + let $content := + element div { + attribute class { "alert alert-success" }, + attribute role { "alert" }, + element h4 { attribute class { "alert-heading" }, "Success" }, + element p { "Deleted snapshot " || $snapshot-id }, + element p { + element a { + attribute href { $base-url || "/bases/" || $base-id }, + "Return to base" + } + } + } + return + app:wrap($content, "Success") +}; + +declare function bases:welcome($request as map(*)) { + let $title := "Airlock" + let $base-url := $request?parameters?base-url + let $content := +
+

{$title}

+ +

Welcome to Airlock. Guest users can browse existing bases. To add an API key, add bases, and take snapshots, log in as a user who is a member of the {config:repo-permissions()?group} group.

+
    + { + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then +
  • API Keys
  • + else + () + } +
  • Bases
  • + { + if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then +
  • Log out. You are logged in as {sm:id()//sm:real/sm:username/string()}.
  • + else +
  • Log in
  • + } +
+
+ return + app:wrap($content, $title) +}; + diff --git a/modules/config.xqm b/modules/config.xqm new file mode 100644 index 0000000..34db962 --- /dev/null +++ b/modules/config.xqm @@ -0,0 +1,104 @@ +xquery version "3.1"; + +(:~ + : Configuration options for the application and a set of helper functions to access + : the application context. + :) + +module namespace config="http://joewiz.org/ns/xquery/airlock/config"; + +declare namespace system="http://exist-db.org/xquery/system"; + +declare namespace expath="http://expath.org/ns/pkg"; +declare namespace repo="http://exist-db.org/xquery/repo"; + +declare variable $config:login-domain := "org.joewiz.airlock.login"; + +(: Determine the application root collection from the current module load path :) +declare variable $config:app-root := + let $rawPath := system:get-module-load-path() + let $modulePath := + (: strip the xmldb: part :) + if (starts-with($rawPath, "xmldb:exist://")) then + if (starts-with($rawPath, "xmldb:exist://embedded-eXist-server")) then + substring($rawPath, 36) + else if (starts-with($rawPath, "xmldb:exist://null")) then + substring($rawPath, 19) + else + substring($rawPath, 15) + else + $rawPath + return + substring-before($modulePath, "/modules") +; + +(: Default collection and resource names for binary assets and extracted package metadata :) + +declare variable $config:app-data-parent-col := "/db/apps"; +declare variable $config:app-data-col-name := "airlock-data"; +declare variable $config:bases-col-name := "bases"; +declare variable $config:keys-col-name := "keys"; +declare variable $config:snapshots-col-name := "snapshots"; +declare variable $config:tables-json-col-name := "tables-json"; +declare variable $config:tables-xml-col-name := "tables-xml"; + +declare variable $config:app-data-col := $config:app-data-parent-col || "/" || $config:app-data-col-name; +declare variable $config:bases-col := $config:app-data-col || "/" || $config:bases-col-name; +declare variable $config:keys-col := $config:app-data-col || "/" || $config:keys-col-name; + +declare variable $config:bases-doc-name := "bases.xml"; +declare variable $config:keys-doc-name := "keys.xml"; +declare variable $config:base-metadata-doc-name := "base-metadata.json"; + +declare variable $config:bases-doc := $config:bases-col || "/" || $config:bases-doc-name; +declare variable $config:keys-doc := $config:keys-col || "/" || $config:keys-doc-name; + +(:~ + : Returns the repo.xml descriptor for the current application. + :) +declare function config:repo-descriptor() as element(repo:meta) { + doc(concat($config:app-root, "/repo.xml"))/repo:meta +}; + +(:~ + : Returns the permissions information from the repo.xml descriptor. + :) +declare function config:repo-permissions() as map(*) { + config:repo-descriptor()/repo:permissions ! + map { + "user": ./@user/string(), + "group": ./@group/string(), + "mode": ./@mode/string() + } +}; + +(:~ + : Returns the expath-pkg.xml descriptor for the current application. + :) +declare function config:expath-descriptor() as element(expath:package) { + doc(concat($config:app-root, "/expath-pkg.xml"))/expath:package +}; + +(:~ + : For debugging: generates a table showing all properties defined + : in the application descriptors. + :) +declare function config:app-info($node as node(), $params as element(parameters)?, $modes as item()*) { + let $expath := config:expath-descriptor() + let $repo := config:repo-descriptor() + return + + + + + + { + for $attr in ($expath/@*, $expath/*, $repo/*) + return + + + + + } +
app collection:{$config:app-root}
{node-name($attr)}:{$attr/string()}
+}; \ No newline at end of file diff --git a/modules/keys.xqm b/modules/keys.xqm new file mode 100644 index 0000000..1701ede --- /dev/null +++ b/modules/keys.xqm @@ -0,0 +1,227 @@ +xquery version "3.1"; + +module namespace keys="http://joewiz.org/ns/xquery/airlock/keys"; + +import module namespace app="http://joewiz.org/ns/xquery/airlock/app" at "app.xqm"; + +declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; + +declare option output:method "html"; +declare option output:media-type "text/html"; + +declare function keys:create-key-set($key-id as xs:string, $username as xs:string, $rest-api-key as xs:string, $metadata-api-key as xs:string, $notes as xs:string, $created-dateTime as xs:dateTime, $last-modified-dateTime as xs:dateTime?) { + element key-set { + element id { $key-id }, + element username { $username }, + element rest-api-key { $rest-api-key }, + element metadata-api-key { $metadata-api-key }, + element notes { $notes }, + element created-dateTime { $created-dateTime }, + element last-modified-dateTime { $last-modified-dateTime } + } +}; + +declare function keys:delete-key-confirm($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $title := "Are you sure?" + let $content := + + return + app:wrap($content, $title) +}; + +declare function keys:delete-key($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $key-set := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set[id eq $key-id] + let $action := update delete $key-set + let $title := "Success" + let $content := + + return + app:wrap($content, $title) +}; + +declare function keys:update-key($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $key-set := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set[id eq $key-id] + let $username := $request?parameters?username + let $rest-api-key := $request?parameters?rest-api-key + let $metadata-api-key := $request?parameters?metadata-api-key + let $notes := $request?parameters?notes + let $new-key-set := keys:create-key-set($key-id, $username, $rest-api-key, $metadata-api-key, $notes, $key-set/created-dateTime cast as xs:dateTime, current-dateTime()) + let $update := update replace $key-set with $new-key-set + let $title := "Success" + let $content := + + return + $content => app:wrap($title) +}; + +declare function keys:create-key($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-sets := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets + let $max-id := max(($key-sets/key-set/id ! (. cast as xs:integer), 0)) + let $new-id := $max-id + 1 + let $username := $request?parameters?username + let $rest-api-key := $request?parameters?rest-api-key + let $metadata-api-key := $request?parameters?metadata-api-key + let $notes := $request?parameters?notes + let $new-key-set := keys:create-key-set($new-id, $username, $rest-api-key, $metadata-api-key, $notes, current-dateTime(), ()) + let $update := update insert $new-key-set into $key-sets + let $title := "Success" + let $content := + + return + $content => app:wrap($title) +}; + +declare function keys:edit-form($request as map(*)) { + let $base-url := $request?parameters?base-url + let $key-id := $request?parameters?key-id + let $key-set := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set[id eq $key-id] + let $title := "Edit Key " || $key-id + let $content := +
+

Airlock

+ +

{$title}

+ +
+
+ + +
The Airtable username associated with your API key. To help you distinguish between multiple API keys.
+
+
+ + +
To find your API Key, go to https://airtable.com/account and look at the section called “API.”
+
+
+ + +
If you have applied for a Airtable Metadata API Access, Airtable support will email the Metadata API Key to you; otherwise, leave this blank.
+
+
+ + +
Any additional notes.
+
+ + +
+ Delete +
+
+ return + app:wrap($content, $title) +}; + +declare function keys:welcome($request as map(*)) { + let $title := "API Keys" + let $base-url := $request?parameters?base-url + let $key-sets := doc("/db/apps/airlock-data/keys/keys.xml")/key-sets/key-set + let $content := +
+

Airlock

+ +

{$title}

+ { + if (exists($key-sets)) then + + + + + + + + + + + + + + { + for $key-set in $key-sets + return + + + + + + + + + + + } +
IDAirtable UsernameREST API KeyMetadata API KeyNotesDate CreatedLast ModifiedActions
{$key-set/id/string()}{$key-set/username/string()}{$key-set/rest-api-key/string()}{$key-set/metadata-api-key/string()}{$key-set/notes/string()}{ + ($key-set/created-dateTime cast as xs:dateTime) + => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]")}{ + if ($key-set/last-modified-dateTime ne "") then + ($key-set/last-modified-dateTime cast as xs:dateTime) + => format-dateTime("[MNn] [D], [Y] at [h]:[m01] [PN]") + else + No modifications + }Edit
+ else +

No keys have been added.

+ } +

Create a new key

+
+
+ + +
The Airtable username associated with your API key. To help you distinguish between multiple API keys.
+
+
+ + +
To find your API Key, go to https://airtable.com/account and look at the section called “API.”
+
+
+ + +
If you have applied for a Airtable Metadata API Access, Airtable support will email the Metadata API Key to you; otherwise, leave this blank.
+
+
+ +