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
-
-
-
-
Name
-
Base ID
-
API Key
-
Date Created
-
Last Snapshot
-
-
- {
- 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
-
{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}
-
-
-
-
Take new snapshot (Note: This may take ~1-2 minutes, depending on the size of the base; to avoid errors or omitted data, be sure that the base-metadata.json file is up-to-date, since snapshots require a complete list of every table's name; to obtain a current copy, go to this base’s API documentation and use the Airtable Schema Extractor Chrome extension to copy and paste the complete JSON file into a text editor; save the file as base-metadata.json and upload it to the /db/apps/airlock-data/bases/{$base-id} collection in eXist using eXide or a WebDAV client like oXygen or Transmit.)
-
-
-
-
-
-
Snapshot
-
Date Created
-
Tables
-
Records
-
Fields
-
Cells
-
-
- {
- 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
-
+
+ {
+ 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)
+};
+
+(: 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
+
+
+
+
Name
+
Base ID
+
Notes
+
Date Created
+
Last Snapshot
+ {
+ if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then
+
Action
+ 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
+ if (empty(doc("/db/apps/airlock-data/keys/keys.xml")//key-set)) then
+
+ 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
+
+ {
+ if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then
+ (
+ if (util:binary-doc-available("/db/apps/airlock-data/bases/" || $base-id || "/base-metadata.json")) then
+
Take a new snapshot (Note: This may take several minutes, depending on the size of the base.)
+ {
+ if (sm:id()//sm:real/sm:groups/sm:group = config:repo-permissions()?group) then
+
Action
+ else
+ ()
+ }
+
+
+ {
+ 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
+
+ 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)
+};
+
+(:~
+ : 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
+
+ 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
+
+
+
app collection:
+
{$config:app-root}
+
+ {
+ for $attr in ($expath/@*, $expath/*, $repo/*)
+ return
+
+
{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
+ $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}
+
+
+
+ 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
+
+
+
+
ID
+
Airtable Username
+
REST API Key
+
Metadata API Key
+
Notes
+
Date Created
+
Last Modified
+
Actions
+
+
+ {
+ for $key-set in $key-sets
+ return
+
+
{$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
+ }