Page MenuHomeDevCentral

D3835.id.diff
No OneTemporary

D3835.id.diff

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

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)

Event Timeline