Stable release of v4.0.8

Restore router functions.
This commit is contained in:
Robot 2024-03-07 18:29:45 +02:00
parent dfaab46ef2
commit 239d3a0040
Signed by: Robot
GPG Key ID: 14DECD44E7E1BB95
8 changed files with 790 additions and 34 deletions

View File

@ -1,3 +1,7 @@
# v4.0.8
- Restore router functions.
# v4.0.7
- Fix missing token variable in ajax call

View File

@ -755,7 +755,7 @@ class Com_GetbibleInstallerScript implements InstallerScriptInterface
echo '<div style="background-color: #fff;" class="alert alert-info"><a target="_blank" href="https://getbible.net" title="Get Bible">
<img src="components/com_getbible/assets/images/vdm-component.jpg"/>
</a>
<h3>Upgrade to Version 4.0.7 Was Successful! Let us know if anything is not working as expected.</h3></div>';
<h3>Upgrade to Version 4.0.8 Was Successful! Let us know if anything is not working as expected.</h3></div>';
// Add/Update component in the action logs extensions table.
$this->setActionLogsExtensions();

View File

@ -1,4 +1,4 @@
# Get Bible (4.0.7)
# Get Bible (4.0.8)
![Get Bible image](https://git.vdm.dev/getBible/joomla-component/raw/branch/5.0/admin/assets/images/vdm-component.jpg "GetBible")
@ -19,23 +19,23 @@ In essence, The Bible for Joomla is designed to transform how the Word of God is
+ *Name*: [Get Bible](https://getbible.net)
+ *First Build*: 3rd December, 2015
+ *Last Build*: 7th March, 2024
+ *Version*: 4.0.7
+ *Version*: 4.0.8
+ *Copyright*: Copyright (C) 2015. All Rights Reserved
+ *License*: GNU/GPL Version 2 or later - http://www.gnu.org/licenses/gpl-2.0.html
## Build Time
**571 Hours** or **71 Eight Hour Days** (actual time the author saved -
**573 Hours** or **72 Eight Hour Days** (actual time the author saved -
due to [Automated Component Builder](https://www.joomlacomponentbuilder.com))
> (if creating a folder and file took **5 seconds** and writing one line of code took **10 seconds**,
> never making one mistake or taking any coffee break.)
+ *Line count*: **204547**
+ *File count*: **1694**
+ *Line count*: **205303**
+ *File count*: **1695**
+ *Folder count*: **174**
**376 Hours** or **47 Eight Hour Days** (the actual time the author spent)
**378 Hours** or **47 Eight Hour Days** (the actual time the author spent)
> (with the following break down:
> **debugging @143hours** = codingtime / 4;
@ -43,13 +43,13 @@ due to [Automated Component Builder](https://www.joomlacomponentbuilder.com))
> **mapping @57hours** = codingtime / 10;
> **office @95hours** = codingtime / 6;)
**947 Hours** or **118 Eight Hour Days**
**951 Hours** or **119 Eight Hour Days**
(a total of the realistic time frame for this project)
> (if creating a folder and file took **5 seconds** and writing one line of code took **10 seconds**,
> with the normal everyday realities at the office, that includes the component planning, mapping & debugging.)
Project duration: **23.6 weeks** or **4.9 months**
Project duration: **23.8 weeks** or **5 months**
> This **component** was build with a Joomla [Automated Component Builder](https://www.joomlacomponentbuilder.com).
> Developed by [Llewellyn van der Merwe](mailto:joomla@vdm.io)

View File

@ -1,4 +1,4 @@
# Get Bible (4.0.7)
# Get Bible (4.0.8)
![Get Bible image](https://git.vdm.dev/getBible/joomla-component/raw/branch/5.0/admin/assets/images/vdm-component.jpg "GetBible")
@ -19,23 +19,23 @@ In essence, The Bible for Joomla is designed to transform how the Word of God is
+ *Name*: [Get Bible](https://getbible.net)
+ *First Build*: 3rd December, 2015
+ *Last Build*: 7th March, 2024
+ *Version*: 4.0.7
+ *Version*: 4.0.8
+ *Copyright*: Copyright (C) 2015. All Rights Reserved
+ *License*: GNU/GPL Version 2 or later - http://www.gnu.org/licenses/gpl-2.0.html
## Build Time
**571 Hours** or **71 Eight Hour Days** (actual time the author saved -
**573 Hours** or **72 Eight Hour Days** (actual time the author saved -
due to [Automated Component Builder](https://www.joomlacomponentbuilder.com))
> (if creating a folder and file took **5 seconds** and writing one line of code took **10 seconds**,
> never making one mistake or taking any coffee break.)
+ *Line count*: **204547**
+ *File count*: **1694**
+ *Line count*: **205303**
+ *File count*: **1695**
+ *Folder count*: **174**
**376 Hours** or **47 Eight Hour Days** (the actual time the author spent)
**378 Hours** or **47 Eight Hour Days** (the actual time the author spent)
> (with the following break down:
> **debugging @143hours** = codingtime / 4;
@ -43,13 +43,13 @@ due to [Automated Component Builder](https://www.joomlacomponentbuilder.com))
> **mapping @57hours** = codingtime / 10;
> **office @95hours** = codingtime / 6;)
**947 Hours** or **118 Eight Hour Days**
**951 Hours** or **119 Eight Hour Days**
(a total of the realistic time frame for this project)
> (if creating a folder and file took **5 seconds** and writing one line of code took **10 seconds**,
> with the normal everyday realities at the office, that includes the component planning, mapping & debugging.)
Project duration: **23.6 weeks** or **4.9 months**
Project duration: **23.8 weeks** or **5 months**
> This **component** was build with a Joomla [Automated Component Builder](https://www.joomlacomponentbuilder.com).
> Developed by [Llewellyn van der Merwe](mailto:joomla@vdm.io)

View File

@ -0,0 +1 @@

View File

@ -7,9 +7,9 @@
<authorUrl>https://getbible.net</authorUrl>
<copyright>Copyright (C) 2015. All Rights Reserved</copyright>
<license>GNU/GPL Version 2 or later - http://www.gnu.org/licenses/gpl-2.0.html</license>
<version>4.0.7</version>
<version>4.0.8</version>
<description><![CDATA[
<h1>Get Bible (v.4.0.7)</h1>
<h1>Get Bible (v.4.0.8)</h1>
<div style="clear: both;"></div>
<p>Welcome to the next level of scripture engagement - The Bible for Joomla! Our purpose is to bring the Word of God to every person, in their native language, entirely free. This isn't just a typical extension; it's a groundbreaking tool developed to span language divides and deliver a rich, customizable Bible study experience to users worldwide.

View File

@ -31,6 +31,8 @@ use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;
use TrueChristianChurch\Component\Getbible\Administrator\Helper\GetbibleHelper;
use VDM\Joomla\Utilities\Component\Helper;
use VDM\Joomla\Utilities\GetHelper;
// No direct access to this file
\defined('_JEXEC') or die;
@ -103,21 +105,21 @@ class Router extends RouterView
$this->params = ComponentHelper::getParams('com_getbible');
$this->noIDs = (bool) $this->params->get('sef_ids', false);
// Add the (app:view) router configuration
$app = new RouterViewConfiguration('app');
$this->registerView($app);
// Add the (tag:view) router configuration
$tag = new RouterViewConfiguration('tag');
$this->registerView($tag);
// Add the (search:view) router configuration
$search = new RouterViewConfiguration('search');
$this->registerView($search);
// Add the (openai:view) router configuration
$openai = new RouterViewConfiguration('openai');
$this->registerView($openai);
Helper::setOption('com_getbible');
$this->defaultTranslation = $this->params->get('default_translation', 'kjv');
// Define views and register them
$appView = new RouterViewConfiguration('app');
$this->registerView($appView);
$searchView = new RouterViewConfiguration('search');
$this->registerView($searchView);
$tagView = new RouterViewConfiguration('tag');
$this->registerView($tagView);
$openaiView = new RouterViewConfiguration('openai');
$this->registerView($openaiView);
parent::__construct($app, $menu);
@ -125,4 +127,735 @@ class Router extends RouterView
$this->attachRule(new StandardRules($this));
$this->attachRule(new NomenuRules($this));
}
/**
* The default translation
*
* @var string|null
* @since 3.3
*/
private ?string $defaultTranslation = null;
/**
* Build the route for the com_getbible component
*
* @param array &$query An array of URL arguments
*
* @return array The URL arguments to use to assemble the subsequent URL.
* @since 3.3
*/
public function build(&$query)
{
$segments = [];
$view = $query['view'] ?? 'app';
if ($view === 'search')
{
$segments[0] = 'search';
$segments[1] = $query['t'] ?? $query['version'] ?? $query['translation'] ?? $this->defaultTranslation;
$criteria = $query['criteria'] ?? null;
if ($criteria === null)
{
$word = $query['words'] ?? 1;
$match = $query['match'] ?? 2;
$case = $query['case'] ?? 1;
$target = $query['target'] ?? 1000;
$criteria = "{$word}-{$match}-{$case}-{$target}";
}
if (strpos($criteria, '-') !== false)
{
$word = $this->criteriaString(
$criteria, 0,
[
'allwords' => 'allwords',
'anywords' => 'anywords',
'exactwords' => 'exactwords',
1 => 'allwords',
2 => 'anywords',
3 => 'exactwords'
], 'allwords');
$match = $this->criteriaString(
$criteria, 1,
[
'exactmatch' => 'exactmatch',
'partialmatch' => 'partialmatch',
1 => 'exactmatch',
2 => 'partialmatch'
], 'partialmatch');
$case = $this->criteriaString(
$criteria, 2,
[
'caseinsensitive' => 'caseinsensitive',
'casesensitive' => 'casesensitive',
1 => 'caseinsensitive',
2 => 'casesensitive'
], 'caseinsensitive');
$target = $this->criteriaString(
$criteria, 3,
[
'allbooks' => 'allbooks',
'oldtestament' => 'oldtestament',
'newtestament' => 'newtestament',
1000 => 'allbooks',
2000 => 'oldtestament',
3000 => 'newtestament',
4000 => 'booknames'
], 'allbooks');
if ($target === 'booknames')
{
$target = $this->criteriaBook($criteria, 3);
}
$segments[2] = "{$word}-{$match}-{$case}-{$target}";
}
else
{
$segments[2] = 'allwords-partialmatch-caseinsensitive-allbooks';
}
$segments[3] = $query['search'] ?? $query['s'] ?? '';
}
elseif ($view === 'openai')
{
$segments[0] = 'openai';
$segments[1] = $query['guid'] ?? '';
$segments[2] = $query['t'] ?? $query['version'] ?? $query['translation'] ?? $this->defaultTranslation;
$segments[3] = $query['book'] ?? '';
$segments[4] = $query['chapter'] ?? '';
$segments[5] = $query['verse'] ?? '';
$segments[6] = $query['words'] ?? '';
// remove what is not there
if (empty($segments[5]))
{
unset($segments[5]);
}
if (empty($segments[6]))
{
unset($segments[6]);
}
}
elseif ($view === 'api')
{
$segments[0] = 'api';
$segments[1] = $query['t'] ?? $query['version'] ?? $query['translation'] ?? $this->defaultTranslation;
$segments[2] = $query['get'] ?? '';
}
elseif ($view === 'tag')
{
$segments[0] = 'tag';
$segments[1] = $query['t'] ?? $query['version'] ?? $query['translation'] ?? $this->defaultTranslation;
$segments[2] = $query['guid'] ?? '';
if (!empty($query['guid']) && ($tag_name = GetHelper::var('tag', $query['guid'], 'guid', 'name')) !== null)
{
$tag_name = preg_replace('/[^\p{L}\p{N}\s]/u', '', $tag_name);
$segments[3] = urlencode($tag_name);
}
}
else
{
$segments[0] = $query['t'] ?? $query['translation'] ?? $this->defaultTranslation;
$book = $query['ref'] ?? $query['b'] ?? $query['book'] ?? '';
if (is_numeric($book) && $book > 0)
{
$book = $this->getBookName((int) $book, $segments[0]);
}
$segments[1] = $book;
$chapter = $query['chapter'] ?? $query['c'] ?? '';
if (strlen($chapter) && is_numeric($chapter))
{
$segments[2] = $chapter;
}
$verse = $query['verse'] ?? $query['v'] ?? '';
if (strlen($verse))
{
$segments[3] = $verse;
}
}
// remove all values used
unset($query['view']);
unset($query['ref']);
unset($query['t']);
unset($query['version']);
unset($query['translation']);
unset($query['b']);
unset($query['book']);
unset($query['target_book']);
unset($query['c']);
unset($query['chapter']);
unset($query['verse']);
unset($query['v']);
unset($query['criteria']);
unset($query['words']);
unset($query['match']);
unset($query['case']);
unset($query['target']);
unset($query['search']);
unset($query['guid']);
return $segments;
}
/**
* Parse the segments of a URL.
*
* @param array &$segments The segments of the URL to parse.
*
* @return array The URL attributes to be used by the application.
* @since 3.3
*/
public function parse(&$segments)
{
$vars = [];
$vars['view'] = 'app';
$key = 0;
$vars['t'] = $segments[$key] ?? '';
// if first value is a valid translation, we are on the app page
if ($this->validTranslation($vars['t']))
{
$key++;
$this->setAppVars($vars, $key, $segments);
}
// if the first value is search we are on the search page
elseif ($vars['t'] === 'search')
{
$key++;
$this->setSearchVars($vars, $key, $segments);
}
// if the first api is search we are on the api page
elseif ($vars['t'] === 'api')
{
$vars['view'] = 'api';
// set the translation
$key++;
$this->setTranslation($vars, $key, $segments);
$vars['ref'] = $segments[$key] ?? '';
}
// if the first api is search we are on the api page
elseif ($vars['t'] === 'api')
{
$vars['view'] = 'api';
// set the translation
$key++;
$this->setTranslation($vars, $key, $segments);
$vars['ref'] = $segments[$key] ?? '';
}
// if the first openai is search we are on the openai page
elseif ($vars['t'] === 'openai')
{
$vars['view'] = 'openai';
// set the AI guid
$key++;
$vars['guid'] = $segments[$key] ?? '';
// set the translation
$key++;
$this->setTranslation($vars, $key, $segments);
// set the targets
$vars['book'] = $segments[$key] ?? '';
$key++;
$vars['chapter'] = $segments[$key] ?? '';
$key++;
$vars['verse'] = $segments[$key] ?? '';
$key++;
$vars['words'] = $segments[$key] ?? '';
}
// if the first tag is search we are on the tag page
elseif ($vars['t'] === 'tag')
{
$vars['view'] = 'tag';
// set the translation
$key++;
$this->setTranslation($vars, $key, $segments);
// set the Tag guid
$vars['guid'] = $segments[$key] ?? '';
}
// if the first tag is none of the above, we are probably on the app page
else
{
$vars['t'] = $this->defaultTranslation;
$this->setAppVars($vars, $key, $segments);
}
// always clear the segment (crazy right)
$segments = [];
return $vars;
}
/**
* Set the app variables
*
* @param array $vars The active variables found
* @param int $key The active key state of the segment array pointer
* @param array $segments The URL segments
*
* @return void
* @since 3.3
*/
protected function setAppVars(array &$vars, int &$key, array $segments): void
{
$value = $segments[$key] ?? null;
$book_number = 0;
$book_name = $this->getBook($value, $book_number, $vars['t']);
if ($book_name !== null && $book_number > 0)
{
$vars['ref'] = $book_name;
$vars['book'] = $book_number;
$key++;
$chapter_number = $this->getChapter($vars, $key, $segments);
$has_verses = $this->getVerses($vars, $key, $segments);
}
$this->checkAndRedirectApiCall(
$vars,
$key,
$segments,
$book_number ?? null,
$chapter_number ?? null,
$has_verses ?? false
);
}
/**
* Set the Search Variables
*
* @param array $vars The active variables found
* @param int $key The active key state of the segment array pointer
* @param array $segments The URL segments
*
* @return void
* @since 3.3
*/
protected function setSearchVars(array &$vars, int &$key, array $segments): void
{
$vars['view'] = 'search';
// set the translation
$this->setTranslation($vars, $key, $segments);
// set the criteria for the search
$this->setSearchCriteria($vars, $key, $segments);
// get the search value
$vars['search'] = $segments[$key] ?? '';
// check if this is an API call
if (!empty($vars['search']))
{
$key++;
$api_call = $segments[$key] ?? 'not_api_call';
if ($api_call === 'get_bible.json')
{
echo '<pre>';
var_dump('We have an API call!');
var_dump($vars);
exit;
}
}
}
/**
* Retrieve book based on the value provided
*
* @param mixed $value
* @param int &$bookNumber
* @param string|null $translation The book translation.
*
* @return string|null
* @since 3.3
*/
private function getBook($value, int &$bookNumber, ?string $translation = null): ?string
{
if (is_numeric($value))
{
$bookNumber = $value;
return $this->getBookName((int) $value, $translation);
}
elseif (!empty($value) && ($bookNumber = $this->getBookNumber($value)) !== null)
{
return $value;
}
return null;
}
/**
* Retrieve chapter from the segments
*
* @param array &$vars
* @param int &$key
* @param array $segments
*
* @return int|null
* @since 3.3
*/
private function getChapter(array &$vars, int &$key, array $segments): ?int
{
$value = $segments[$key] ?? null;
if (!empty($value) && is_numeric($value) && $value > 0)
{
$vars['ref'] .= ' ' . $value;
$vars['chapter'] = $value;
$key++;
return (int) $value;
}
return null;
}
/**
* Retrieve verses from the segments
*
* @param array &$vars
* @param int &$key
* @param array $segments
*
* @return bool
* @since 3.3
*/
private function getVerses(array &$vars, int &$key, array $segments): bool
{
$value = $segments[$key] ?? null;
if (!empty($value) && (is_numeric($value) || strpos($value, '-') !== false))
{
$vars['ref'] .= ':' . $value;
$vars['verse'] = $value;
$key++;
return true;
}
return false;
}
/**
* Check if the request is an API call and redirect if necessary
*
* @param array $vars
* @param int &$key
* @param array $segments
* @param int|null $bookNumber
* @param int|null $chapterNumber
* @param bool $hasVerses
*
* @return void
* @since 3.3
*/
private function checkAndRedirectApiCall(
array $vars, int $key, array $segments,
?int $bookNumber, ?int $chapterNumber, bool $hasVerses): void
{
$apiCall = $segments[$key] ?? 'not_api_call';
if ($apiCall === 'get_bible.json')
{
if ($hasVerses)
{
header("Location: https://query.getbible.net/v2/{$vars['t']}/{$vars['ref']}");
exit;
}
elseif (!empty($bookNumber) && !empty($chapterNumber))
{
header("Location: https://api.getbible.net/v2/{$vars['t']}/$bookNumber/$chapterNumber.json");
exit;
}
elseif (!empty($bookNumber))
{
header("Location: https://api.getbible.net/v2/{$vars['t']}/$bookNumber.json");
exit;
}
else
{
header("Location: https://api.getbible.net/v2/{$vars['t']}.json");
exit;
}
}
}
/**
* Set The Translation
*
* @param array $vars The active variables found
* @param int $key The active key state of the segment array pointer
* @param array $segments The URL segments
*
* @return void
* @since 3.3
*/
private function setTranslation(array &$vars, int &$key, array $segments): void
{
$vars['t'] = $segments[$key] ?? $this->defaultTranslation;
if ($this->validTranslation($vars['t']))
{
$key++;
}
else
{
$vars['t'] = $this->defaultTranslation;
}
}
/**
* Set The Search Criteria
*
* @param array $vars The active variables found
* @param int $key The active key state of the segment array pointer
* @param array $segments The URL segments
*
* @return void
* @since 3.3
*/
private function setSearchCriteria(array &$vars, int &$key, array $segments): void
{
// set the criteria values
$criteria = $segments[$key] ?? null;
if ($criteria === null || strpos($criteria , '-') === false)
{
// the default is greedy
$criteria = 'allwords-partialmatch-caseinsensitive-allbooks';
}
else
{
$key++;
}
/**
* > words (0)
* 1 = allwords, 2 = anywords, 3 = exactwords
*/
$vars['words'] = $this->criteria(
$criteria, 0,
[
'allwords' => 1,
'anywords' => 2,
'exactwords' => 3,
1 => 1, 2 => 2, 3 => 3
], 1);
/**
* > match (1)
* 1 = exactmatch, 2 = partialmatch
*/
$vars['match'] = $this->criteria(
$criteria, 1,
[
'exactmatch' => 1,
'partialmatch' => 2,
1 => 1, 2 => 2
], 2);
/**
* > case (2)
* 1 = caseinsensitive, 2 = casesensitive
*/
$vars['case'] = $this->criteria(
$criteria, 2,
[
'caseinsensitive' => 1,
'casesensitive' => 2,
1 => 1, 2 => 2
], 1);
/**
* > target (3)
* 1000 = all, 2000 = old, 3000 = new, 4000 = book_name
*/
$vars['target'] = $this->criteria(
$criteria, 3,
[
'allbooks' => 1000,
'oldtestament' => 2000,
'newtestament' => 3000,
1000 => 1000, 2000 => 2000, 3000 => 3000
], 4000);
/**
* When we have 4000 we need to get the book name
*/
if ($vars['target'] == 4000)
{
$vars['target_book'] = $this->criteriaBook($criteria, 3);
if ($vars['target_book'] === null)
{
$vars['target'] = 1000;
}
}
}
/**
* Get the int value of the criteria string
*
* @param string $value The criteria string.
* @param int $position The criteria position.
* @param array $criteria The criteria target.
* @param int $default The criteria default.
*
* @return int The int value of the targeted criteria.
* @since 3.3
*/
private function criteria(string $value, int $position, array $criteria, int $default): int
{
if (strpos($value, '-') !== false)
{
$array = explode('-', $value);
if (isset($array[$position]) && isset($criteria[$array[$position]]))
{
return $criteria[$array[$position]];
}
}
return $default;
}
/**
* Get the book value from the criteria string
*
* @param string $value The criteria string.
* @param int $position The criteria position.
*
* @return mixed the book value
* @since 3.3
*/
private function criteriaBook(string $value, int $position)
{
if (strpos($value, '-') !== false)
{
$array = explode('-', $value);
if (isset($array[$position]))
{
return $array[$position];
}
}
return null;
}
/**
* Get the string value of the criteria int
*
* @param string $value The criteria string.
* @param int $position The criteria position.
* @param array $criteria The criteria target.
* @param int $default The criteria default.
*
* @return string The string value of the targeted criteria.
* @since 3.3
*/
private function criteriaString(string $value, int $position, array $criteria, string $default): string
{
if (strpos($value, '-') !== false)
{
$array = explode('-', $value);
if (isset($array[$position]) && isset($criteria[$array[$position]]))
{
return $criteria[$array[$position]];
}
}
return $default;
}
/**
* Get a Book number
*
* @param string $value The book name.
*
* @return int|null The book number
* @since 3.3
*/
private function getBookNumber(string $name): ?int
{
if (($number = GetHelper::var('book', $name, 'name', 'nr')) !== null
&& $number > 0)
{
return $number;
}
return null;
}
/**
* Get a Book name
*
* @param int $value The book number.
* @param string|null $translation The book translation.
*
* @return string|null The book name
* @since 3.3
*/
private function getBookName(int $value, ?string $translation = null): ?string
{
if (!empty($translation) && is_numeric($value) && $value > 0)
{
// Create a new query object.
$query = $this->db->getQuery(true);
$query->select($this->db->quoteName('name'));
$query->from($this->db->quoteName('#__getbible_book'));
$query->where($this->db->quoteName('nr') . ' = '. (int) $value);
$query->where($this->db->quoteName('abbreviation') . ' = ' . $this->db->quote((string) $translation));
$this->db->setQuery($query);
$this->db->execute();
if ($this->db->getNumRows())
{
return $this->db->loadResult();
}
}
if (($name = GetHelper::var('book', $value, 'nr', 'name')) !== null)
{
return $name;
}
return null;
}
/**
* Validate if this is a active translation
*
* @param string $value The criteria string.
*
* @return bool True if its a valid translation
* @since 3.3
*/
private function validTranslation(string $value): bool
{
if (strlen($value) > 0)
{
if (($published = GetHelper::var('translation', $value, 'abbreviation', 'published')) !== null
&& $published == 1)
{
return true;
}
}
return false;
}
}

View File

@ -161,4 +161,22 @@
<maintainerurl>https://getbible.net</maintainerurl>
<targetplatform name="joomla" version="4\.[01234]"/>
</update>
<update>
<name>Get Bible</name>
<description>The Bible for Joomla</description>
<element>pkg_getbible</element>
<type>package</type>
<client>site</client>
<version>4.0.8</version>
<infourl title="Get Bible!">https://getbible.net</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.vdm.dev/api/v1/repos/getBible/joomla-pkg/archive/v4.0.8.zip</downloadurl>
</downloads>
<tags>
<tag>stable</tag>
</tags>
<maintainer>Llewellyn van der Merwe</maintainer>
<maintainerurl>https://getbible.net</maintainerurl>
<targetplatform name="joomla" version="4\.[01234]"/>
</update>
</updates>