Page Menu
Home
DevCentral
Search
Configure Global Search
Log In
Files
F12576061
D3835.id.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
21 KB
Referenced Files
None
Subscribers
None
D3835.id.diff
View Options
diff --git a/apps/orbeon-forms/composer.json b/apps/orbeon-forms/composer.json
new file mode 100644
--- /dev/null
+++ b/apps/orbeon-forms/composer.json
@@ -0,0 +1,32 @@
+{
+ "name": "waystone/orbeon-forms",
+ "description": "Read and allows to annotate forms filled with Orbeon",
+ "keywords": [
+ "orbeon",
+ "waystone",
+ "obsidian"
+ ],
+ "minimum-stability": "stable",
+ "license": "BSD-2-Clause",
+ "authors": [
+ {
+ "name": "Sébastien Santoro",
+ "email": "dereckson@espace-win.org"
+ }
+ ],
+ "require-dev": {
+ "nasqueron/codestyle": "^0.1.2",
+ "phpunit/phpunit": "^12.5",
+ "squizlabs/php_codesniffer": "^4.0"
+ },
+ "require": {
+ "keruald/database": "0.6.1",
+ "waystone/workspaces": "^1.0.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Waystone\\Apps\\OrbeonForms\\": "src/",
+ "Waystone\\Apps\\OrbeonForms\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/apps/orbeon-forms/src/Forms/Entry.php b/apps/orbeon-forms/src/Forms/Entry.php
new file mode 100644
--- /dev/null
+++ b/apps/orbeon-forms/src/Forms/Entry.php
@@ -0,0 +1,150 @@
+<?php
+
+namespace Waystone\Apps\OrbeonForms\Forms;
+
+use Waystone\Workspaces\Engines\Exceptions\DocumentNotFoundException;
+use Waystone\Workspaces\Engines\Exceptions\WorkspaceException;
+
+use Keruald\Database\Engines\PDOEngine;
+use Keruald\OmniTools\Collections\HashMap;
+use Keruald\OmniTools\Collections\Vector;
+
+use DateTime;
+
+class Entry {
+
+ ///
+ /// Members
+ ///
+
+ private Form $form;
+
+ private string $document_id;
+
+ private HashMap $content;
+
+ private DateTime $created;
+
+ private PDOEngine $db;
+
+ ///
+ /// Constructors
+ ///
+
+ public function __construct (PDOEngine $db, Form $form, string $document_id) {
+ $this->form = $form;
+ $this->document_id = $document_id;
+ $this->db = $db;
+
+ $this->loadFromDatabase();
+ }
+
+ ///
+ /// Getters
+ ///
+
+ public function getContent () : HashMap {
+ return $this->content;
+ }
+
+ public function getDate () : string {
+ return $this->created->format("Y-m-d");
+ }
+
+ ///
+ /// Helper methods
+ ///
+
+ /**
+ * Guess the title of the entry, based on the first field.
+ * If specific fields are declared for the index, the first of those is used.
+ *
+ * @return string
+ */
+ public function guessTitle () : string {
+ $field = $this->form
+ ->getIndexKeys()
+ ->firstOr($this->form->getRawFields()->first());
+
+ return $this->content[$field] ?? "(untitled entry)";
+ }
+
+ public function countAttachments () : int {
+ $this->validate();
+
+ $sql = "SELECT count(*)
+ FROM orbeon_form_data_attach
+ WHERE document_id = :document_id";
+
+ return $this->db
+ ->prepare($sql)
+ ->with("document_id", $this->document_id)
+ ->query()
+ ->fetchScalar();
+ }
+
+ public function hasAttachments () : bool {
+ return $this->countAttachments() > 0;
+ }
+
+ ///
+ /// Load from database
+ ///
+
+ private function loadFromDatabase () : void {
+ $sql = $this->buildSelectQuery();
+
+ $result = $this->db
+ ->prepare($sql)
+ ->with("document_id", $this->document_id)
+ ->query();
+
+ if (!$result || $result->numRows() === 0) {
+ throw new DocumentNotFoundException(
+ "Document ID not found: $this->document_id"
+ );
+ }
+
+ $row = $result->fetchRow();
+ $this->created = new DateTime($row["metadata_created"]);
+ $this->content = $this->form->getFields()
+ ->mapValuesAndKeys(function ($fieldColumn, $fieldName) use ($row) {
+ return [$fieldName, $row[$fieldColumn]];
+ });
+ }
+
+ ///
+ /// SQL helper methods
+ ///
+
+ private function buildSelectQuery () : string {
+ $sql = "SELECT ";
+ $sql .= $this->getQueryFields()->implode(", ");
+ $sql .= " FROM ";
+ $sql .= $this->form->getView();
+ $sql .= " WHERE metadata_document_id = :document_id";
+
+ return $sql;
+ }
+
+ private function getQueryFields () : Vector {
+ return $this->form->getRawFields()
+ ->push("metadata_document_id")
+ ->push("metadata_created");
+ }
+
+ ///
+ /// Validate configuration syntax
+ ///
+
+ public function validate () : void {
+ $this->form->validate();
+
+ if (!ctype_xdigit($this->document_id)) {
+ throw new WorkspaceException(
+ "Invalid document ID: $this->document_id"
+ );
+ }
+ }
+
+}
diff --git a/apps/orbeon-forms/src/Forms/Form.php b/apps/orbeon-forms/src/Forms/Form.php
new file mode 100644
--- /dev/null
+++ b/apps/orbeon-forms/src/Forms/Form.php
@@ -0,0 +1,195 @@
+<?php
+
+namespace Waystone\Apps\OrbeonForms\Forms;
+
+use Keruald\Database\Engines\PDOEngine;
+use Keruald\OmniTools\Collections\HashMap;
+use Keruald\OmniTools\Collections\Vector;
+use Waystone\Workspaces\Engines\Exceptions\WorkspaceException;
+
+class Form {
+
+ ///
+ /// Private members
+ ///
+
+ private string $name;
+
+ private string $slug;
+
+ private string $view;
+
+ private string $orbeon_base_url;
+
+ private array $fields;
+
+ /**
+ * @var string[]
+ */
+ private array $index;
+
+ private PDOEngine $db;
+
+ ///
+ /// Constructor
+ ///
+
+ public function __construct (PDOEngine $db, array $config) {
+ $this->db = $db;
+
+ $this->name = $config["name"];
+ $this->slug = $config["slug"];
+ $this->view = $config["view"];
+ $this->orbeon_base_url = $config["orbeon_base_url"];
+ $this->fields = $config["fields"];
+ $this->index = $config["index"] ?? [];
+
+ $this->validate();
+ }
+
+ ///
+ /// Getters
+ ///
+
+ public function getName () : string {
+ return $this->name;
+ }
+
+ public function getSlug () : string {
+ return $this->slug;
+ }
+
+ public function getView () : string {
+ return $this->view;
+ }
+
+ public function getOrbeonBaseUrl () : string {
+ return $this->orbeon_base_url;
+ }
+
+ public function getRawFields () : Vector {
+ return Vector::from(array_keys($this->fields));
+ }
+
+ public function getFields () : HashMap {
+ return HashMap::from($this->fields);
+ }
+
+ /**
+ * @return Vector<string>
+ */
+ public function getIndexKeys () : Vector {
+ return Vector::from($this->index)
+ ->map(fn ($key) => $this->fields[$key]);
+ }
+
+ ///
+ /// Interact with the form entries
+ ///
+
+ /**
+ * @return iterable<array<string, array<string, string>>>
+ */
+ public function getAllEntries () : iterable {
+ $this->validate();
+
+ $sql = $this->buildSelectQuery();
+
+ $result = $this->db->query($sql);
+ foreach ($result as $row) {
+ yield $this->entryFromRow($row);
+ }
+
+ return [];
+ }
+
+ public function getEntry (string $documentId) : Entry {
+ return new Entry($this->db, $this, $documentId);
+ }
+
+ ///
+ /// Enrich SQL content
+ ///
+
+ private function entryFromRow (array $row) : array {
+ $entry = [
+ "data" => [],
+ "metadata" => [],
+ ];
+
+ foreach ($row as $key => $value) {
+ if (str_starts_with($key, "metadata_")) {
+ $key = substr($key, 9);
+ $entry["metadata"][$key] = $value;
+ } else {
+ $key = $this->fields[$key];
+ $entry["data"][$key] = $value;
+ }
+ }
+
+ return $entry;
+ }
+
+ ///
+ /// SQL helper methods
+ ///
+
+ private function buildSelectQuery () : string {
+ $sql = "SELECT ";
+ $sql .= $this->getQueryFields()->implode(", ");
+ $sql .= " FROM ";
+ $sql .= $this->view;
+ $sql .= " ORDER BY metadata_created DESC";
+
+ return $sql;
+ }
+
+ private function getIndexFields() : array {
+ if ($this->index) {
+ return $this->index;
+ }
+
+ // If omitted, use all fields
+ return array_keys($this->fields);
+ }
+
+ private function getQueryFields () : Vector {
+ $fields = $this->getIndexFields();
+ $fields[] = "metadata_document_id";
+ $fields[] = "metadata_created";
+
+ return Vector::from($fields);
+ }
+
+ ///
+ /// Validate configuration syntax
+ ///
+
+ const string RE_DB_ITEM_EXPRESSION = "/^[a-z0-9_]+$/";
+
+ public function validate () : void {
+ $fields = array_keys($this->fields);
+
+ foreach ($fields as $field) {
+ if (!$this->validateDatabaseItem($field)) {
+ throw new WorkspaceException("Invalid database item expression: $field");
+ }
+ }
+
+ foreach ($this->index as $field) {
+ if (!in_array($field, $fields)) {
+ throw new WorkspaceException("Invalid index field: $field (must be declared into fields too)");
+ }
+ }
+
+ if (!$this->validateDatabaseItem($this->view)) {
+ throw new WorkspaceException("Invalid view name: $this->view");
+ }
+ }
+
+ private function validateDatabaseItem ($expression) : bool {
+ // Expression can only contain [a-z0-9_]
+ return preg_match(self::RE_DB_ITEM_EXPRESSION, $expression);
+ }
+
+}
diff --git a/apps/orbeon-forms/src/Reader/Application.php b/apps/orbeon-forms/src/Reader/Application.php
new file mode 100644
--- /dev/null
+++ b/apps/orbeon-forms/src/Reader/Application.php
@@ -0,0 +1,231 @@
+<?php
+
+namespace Waystone\Apps\OrbeonForms\Reader;
+
+use Waystone\Apps\OrbeonForms\Forms\Form;
+use Waystone\Workspaces\Engines\Apps\Application as BaseApplication;
+use Waystone\Workspaces\Engines\Errors\ErrorHandling;
+
+use Keruald\Database\Engines\PDOEngine;
+use Keruald\OmniTools\Collections\Vector;
+use Keruald\OmniTools\DataTypes\Option\None;
+use Keruald\OmniTools\DataTypes\Option\Option;
+use Keruald\OmniTools\DataTypes\Option\Some;
+
+use ErrorPageController;
+use FooterController;
+use HeaderController;
+
+class Application extends BaseApplication {
+
+ private PDOEngine $db;
+
+ protected function onAfterInitialize () : void {
+ /** @var ApplicationConfiguration $config */
+ $config = $this->context->configuration;
+
+ $this->db = PDOEngine::initialize($config->orbeonDatabase);
+ }
+
+ ///
+ /// Controller methods
+ ///
+
+ /**
+ * Serves the application index page
+ * @param Vector<Form> $forms
+ */
+ private function index (Vector $forms) : void {
+ $count = $forms->count();
+
+ if ($count == 0) {
+ ErrorHandling::messageAndDie(GENERAL_ERROR, "No form set in workspace configuration");
+ }
+
+ if ($count == 1) {
+ $slug = $forms[0]["slug"];
+ $this->redirectTo($slug);
+ }
+
+ ///
+ /// View: List of forms
+ ///
+
+ $smarty = $this->context->templateEngine;
+
+ // List of forms, with their URL
+ $forms = $forms->map(function ($form) {
+ return [
+ "name" => $form["name"],
+ "url" => $this->buildUrl($form["slug"]),
+ ];
+ });
+
+ // Header
+ $smarty->assign("PAGE_TITLE", "Forms");
+ HeaderController::run($this->context);
+
+ // Body
+ $smarty->display("apps/_blocks/page_header.tpl");
+
+ $smarty->assign("items", $forms);
+ $smarty->display("apps/_blocks/menu_items.tpl");
+
+ // Footer
+ FooterController::run($this->context);
+ }
+
+ private function formIndex (array $formConfig) : void {
+ $form = new Form($this->db, $formConfig);
+ $entries = $form->getAllEntries();
+
+ ///
+ /// View: List of entries
+ ///
+
+ $smarty = $this->context->templateEngine;
+
+ $keys = $form->getIndexKeys();
+
+ $url_base = $form->getSlug();
+ $items = Vector::from($entries)
+ ->map(function ($entry) use ($url_base) {
+ return $entry["data"] + [
+ "url" => $this->buildUrl(
+ $url_base . "/" . $entry["metadata"]["document_id"]
+ ),
+ ];
+ });
+
+ // Header
+ $smarty->assign("PAGE_TITLE", $form->getName());
+ HeaderController::run($this->context);
+
+ // Body
+ $smarty->display("apps/_blocks/page_header.tpl");
+
+ $smarty->assign("keys", $keys);
+ $smarty->assign("items", $items);
+ $smarty->display("apps/_crud/list.tpl");
+
+ // Footer
+ FooterController::run($this->context);
+ }
+
+ private function formEntry (array $formConfig, string $document_id) {
+ $form = new Form($this->db, $formConfig);
+ $entry = $form->getEntry($document_id);
+ $title = $entry->guessTitle();
+
+ $content = $entry->getContent()
+ ->set("🗓️ Filing date", $entry->getDate());
+
+ ///
+ /// View: Form entry
+ ///
+
+ $smarty = $this->context->templateEngine;
+
+ // Header
+ $smarty->assign('PAGE_TITLE', $form->getName());
+ $smarty->assign('custom_css', "dd {white-space: pre-line;}");
+ HeaderController::run($this->context);
+
+ // Body
+ $smarty->display("apps/_blocks/page_header.tpl");
+
+ echo '<div class="row"><h2>', $title, '</h2></div>';
+ $smarty->assign('items', $content);
+ $smarty->display("apps/_blocks/dl.tpl");
+
+ if ($entry->hasAttachments()) {
+ $smarty->assign("alert_level", "info");
+ $smarty->assign("alert_note", "📎 This form has documents attached.");
+ $smarty->display("apps/_blocks/alert.tpl");
+ }
+
+ $view_url = $form->getOrbeonBaseUrl() . "/view/" . $document_id;
+ echo '<div class="row">';
+ echo '<p>➕ <a href="' . $view_url . '">View full form on Orbeon</a></p>';
+ echo '<p>↩ <a href="' . $this->buildUrl($form->getSlug()) . '">Back to list</a></p>';
+ echo '</div>';
+
+ // Footer
+ FooterController::run($this->context);
+ }
+
+ ///
+ /// Controller handler
+ ///
+
+ public function handleRequest () {
+ /** @var ApplicationConfiguration $config */
+ $config = $this->context->configuration;
+ $forms = Vector::from($config->forms);
+
+ $argc = count($this->context->url);
+
+ if ($argc == 1) {
+ $this->index($forms);
+ return;
+ }
+
+ $form = $this->get_form_config($this->context->url[1]);
+ if ($form->isNone()) {
+ // URL points to a non-existing form
+ ErrorPageController::show($this->context, 404);
+ exit;
+ }
+
+ if ($argc == 2) {
+ $this->formIndex($form->getValue());
+ exit;
+ }
+
+ if ($argc == 3) {
+ $document_id = $this->context->url[2];
+
+ if (ctype_xdigit($document_id)) {
+ // URL points to a form entry
+ $this->formEntry($form->getValue(), $this->context->url[2]);
+ exit;
+ }
+ }
+
+ // Unknown URL
+ ErrorPageController::show($this->context, 404);
+ exit;
+ }
+
+ private function buildUrl (string $slug) : string {
+ return "/" . Vector::from([
+ $this->context->workspace->code,
+ $this->context->configuration->bind,
+ $slug,
+ ])->implode("/");
+ }
+
+ private function redirectTo (mixed $slug) : never {
+ $url = $this->buildUrl($slug);
+
+ header("Location: $url");
+ exit;
+ }
+
+ /**
+ * @return Option<array<string, mixed>>
+ */
+ private function get_form_config (string $slug) : Option {
+ /** @var ApplicationConfiguration $config */
+ $config = $this->context->configuration;
+
+ foreach ($config->forms as $form) {
+ if ($form["slug"] == $slug) {
+ return new Some($form);
+ }
+ }
+
+ return new None;
+ }
+
+}
diff --git a/apps/orbeon-forms/src/Reader/ApplicationConfiguration.php b/apps/orbeon-forms/src/Reader/ApplicationConfiguration.php
new file mode 100644
--- /dev/null
+++ b/apps/orbeon-forms/src/Reader/ApplicationConfiguration.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Waystone\Apps\OrbeonForms\Reader;
+
+use Waystone\Workspaces\Engines\Apps\ApplicationConfiguration as BaseApplicationConfiguration;
+
+class ApplicationConfiguration extends BaseApplicationConfiguration {
+
+ /**
+ * @var array Configuration for Orbeon Forms
+ */
+ public array $orbeonDatabase = [];
+
+ /**
+ * @var array Configuration for Orbeon forms
+ */
+ public array $forms = [];
+
+}
diff --git a/composer.json b/composer.json
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,7 @@
}
],
"require": {
- "keruald/database": "0.5.2",
+ "keruald/database": "0.6.1",
"keruald/omnitools": "0.16.0",
"keruald/yaml": "0.1.1",
"netresearch/jsonmapper": "^v5.0.0",
@@ -27,14 +27,17 @@
},
"require-dev": {
"nasqueron/codestyle": "^0.1.2",
- "phpunit/phpunit": "^12.4",
+ "phpunit/phpunit": "^12.5",
"squizlabs/php_codesniffer": "^4.0"
},
"replace": {
+ "waystone/orbeon-forms": "0.1.0",
"waystone/workspaces": "1.0.0"
},
"autoload": {
"psr-4": {
+ "Waystone\\Apps\\OrbeonForms\\": "apps/orbeon-forms/src/",
+ "Waystone\\Apps\\OrbeonForms\\Tests\\": "apps/orbeon-forms/tests/",
"Waystone\\Workspaces\\": "workspaces/src/",
"Waystone\\Workspaces\\Tests\\": "workspaces/tests/"
}
diff --git a/workspaces/src/Engines/Exceptions/DocumentNotFoundException.php b/workspaces/src/Engines/Exceptions/DocumentNotFoundException.php
new file mode 100644
--- /dev/null
+++ b/workspaces/src/Engines/Exceptions/DocumentNotFoundException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Waystone\Workspaces\Engines\Exceptions;
+
+use RuntimeException;
+
+class DocumentNotFoundException extends RuntimeException {
+
+}
diff --git a/workspaces/src/skins/bluegray/apps/_blocks/dl.tpl b/workspaces/src/skins/bluegray/apps/_blocks/dl.tpl
new file mode 100644
--- /dev/null
+++ b/workspaces/src/skins/bluegray/apps/_blocks/dl.tpl
@@ -0,0 +1,8 @@
+<div class="row">
+ <dl>
+ {foreach from=$items key=key item=item}
+ <dt>{$key}</dt>
+ <dd>{$item}</dd>
+ {/foreach}
+ </dl>
+</div>
diff --git a/workspaces/src/skins/bluegray/apps/_blocks/menu_items.tpl b/workspaces/src/skins/bluegray/apps/_blocks/menu_items.tpl
new file mode 100644
--- /dev/null
+++ b/workspaces/src/skins/bluegray/apps/_blocks/menu_items.tpl
@@ -0,0 +1,11 @@
+<div class="row">
+ {if $items}
+ <ul id="documents">
+ {foreach from=$items item=item}
+ <li class="document"><a href="{$item.url}">{$item.name}</a></li>
+ {/foreach}
+ </ul>
+ {else}
+ <p>No element currently available.</p>
+ {/if}
+</div>
diff --git a/workspaces/src/skins/bluegray/apps/_blocks/page_header.tpl b/workspaces/src/skins/bluegray/apps/_blocks/page_header.tpl
new file mode 100644
--- /dev/null
+++ b/workspaces/src/skins/bluegray/apps/_blocks/page_header.tpl
@@ -0,0 +1,3 @@
+<header class="row">
+ <h1 class="page-header">{$PAGE_TITLE}</h1>
+</header>
diff --git a/workspaces/src/skins/bluegray/apps/_crud/list.tpl b/workspaces/src/skins/bluegray/apps/_crud/list.tpl
new file mode 100644
--- /dev/null
+++ b/workspaces/src/skins/bluegray/apps/_crud/list.tpl
@@ -0,0 +1,30 @@
+<div class="row">
+ {if $items}
+ <table class="table">
+ <thead>
+ <tr>
+ {foreach from=$keys item=key}
+ <th scope="col">{$key}</th>
+ {/foreach}
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {foreach from=$items item=item}
+ <tr>
+ {foreach from=$keys item=key name=loop}
+ {if $smarty.foreach.loop.index == 0}
+ <td><a href="{$item.url}">{$item[$key]}</a></td>
+ {else}
+ <td>{$item[$key]}</td>
+ {/if}
+ {/foreach}
+ <td><a href="{$item.url}">🔎</a></td>
+ </tr>
+ {/foreach}
+ </tbody>
+ </table>
+ {else}
+ <p>No item.</p>
+ {/if}
+</div>
diff --git a/workspaces/src/skins/bluegray/header.tpl b/workspaces/src/skins/bluegray/header.tpl
--- a/workspaces/src/skins/bluegray/header.tpl
+++ b/workspaces/src/skins/bluegray/header.tpl
@@ -9,6 +9,12 @@
<link href="{#StaticContentURL#}/css/bluegray.css" rel="stylesheet">
<link href="{#StaticContentURL#}/favicon.ico" rel="shorcut icon" type="image/x-icon">
<link href="{#StaticContentURL#}/favicon.png" rel="icon" type="image/png" />
+{if isset($custom_css)}
+ <style>
+ /* Custom CSS specific for this page */
+ {$custom_css}
+ </style>
+{/if}
</head>
<body>
<a href="#content" class="sr-only">{#SkipNavigation#}</a>
@@ -84,4 +90,4 @@
{include file='nav_main.tpl'}
{/if}
- <div id="content">
\ No newline at end of file
+ <div id="content">
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Wed, Nov 12, 07:19 (19 h, 26 s)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3158253
Default Alt Text
D3835.id.diff (21 KB)
Attached To
Mode
D3835: Read from Orbeon Forms
Attached
Detach File
Event Timeline
Log In to Comment