dolibarr 25.0.0-alpha
externalModules.class.php
1<?php
2/*
3 * Copyright (C) 2025 Mohamed DAOUD <mdaoud@dolicloud.com>
4 * Copyright (C) 2025-2026 MDW <mdeweerd@users.noreply.github.com>
5 * Copyright (C) 2025 Frédéric France <frederic.france@free.fr>
6 *
7 * This program is free software; you can redistribute it and/or modifyion 2.0 (the "License");
8 * it under the terms of the GNU General Public License as published bypliance with the License.
9 * the Free Software Foundation; either version 3 of the License, or
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 * or see https://www.gnu.org/
19 */
20
21include_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
22
27{
31 public $no_page;
32
36 public $per_page;
40 public $categorie;
41
45 public $search;
46
47 // setups
55 public $file_source_url;
56
60 public $cache_file;
61
66 public $url;
70 public $shop_url; // the url of the shop
74 public $lang; // the integer representing the lang in the store
78 public $debug_api; // useful if no dialog
82 public $dolistore_api_url;
86 public $dolistore_api_key;
87
91 public $dolistoreApiStatus;
92
96 public $dolistoreApiError;
97
101 public $githubFileStatus;
102
106 public $githubFileError;
107
111 public $error;
112
116 public $numberOfProviders;
117
121 public $products;
122
126 public $numberTotalOfProducts;
127
131 public $numberTotalOfPages;
132
136 public $numberOfProducts;
137
143 public function __construct($debug = false)
144 {
145 global $langs;
146
147 $this->debug_api = $debug;
148
149 $this->url = DOL_URL_ROOT.'/admin/modules.php?mode=marketplace';
150
151 // For dolistore modules
152 $this->dolistore_api_url = getDolGlobalString('MAIN_MODULE_DOLISTORE_API_SRV', 'https://www.dolistore.com/api/'); // 'https://www.dolistore.com/api/', 'https://admin2.dolibarr.org/api/index.php/marketplace/'
153 $this->dolistore_api_key = getDolGlobalString('MAIN_MODULE_DOLISTORE_API_KEY', 'dolistorepublicapi');
154 $this->shop_url = getDolGlobalString('MAIN_MODULE_DOLISTORE_SHOP_URL', 'https://www.dolistore.com');
155
156 // For community modules
157 $this->file_source_url = "https://raw.githubusercontent.com/Dolibarr/dolibarr-community-modules/refs/heads/main/index.yaml";
158 $this->cache_file = DOL_DATA_ROOT.'/admin/temp/remote_github_modules_file.yaml';
159
160 $lang = $langs->defaultlang;
161 $lang_array = array('en_US', 'fr_FR', 'es_ES', 'it_IT', 'de_DE');
162 if (!in_array($lang, $lang_array)) {
163 $lang = 'en_US';
164 }
165 $this->lang = $lang;
166 }
167
174 public function loadRemoteSources($debug = false)
175 {
176 // Check access to Community repo
177 if (getDolGlobalString('MAIN_ENABLE_EXTERNALMODULES_COMMUNITY')) {
178 $cachedelayforgithubrepo = getDolGlobalInt('MAIN_REMOTE_GITHUBREPO_CACHE_DELAY', 86400);
179
180 $this->getRemoteYamlFile($this->file_source_url, $cachedelayforgithubrepo);
181
182 $this->githubFileError = $this->error;
183 $this->githubFileStatus = dol_is_file($this->cache_file) ? 1 : 0;
184 }
185
186 // Check access to Dolistore API /api/categories -> /api/index.php/marketplace/categories
187 if (getDolGlobalString('MAIN_ENABLE_EXTERNALMODULES_DOLISTORE')) {
188 $this->dolistoreApiStatus = $this->checkApiStatus();
189 }
190
191 // Count the number of online providers
192 $this->numberOfProviders = $this->dolistoreApiStatus + $this->githubFileStatus;
193 }
194
202 public function callApi($resource, $options = false)
203 {
204 // If no dolistore_api_key is set, we can't access the API
205 if (empty($this->dolistore_api_key) || empty($this->dolistore_api_url)) {
206 return array('status_code' => 0, 'response' => null);
207 }
208
209 // Add basic auth if needed
210 $basicAuthLogin = getDolGlobalString('MAIN_MODULE_DOLISTORE_BASIC_LOGIN');
211 $basicAuthPassword = getDolGlobalString('MAIN_MODULE_DOLISTORE_BASIC_PASSWORD');
212
213 $httpheader = array('DOLAPIKEY: '.$this->dolistore_api_key);
214 if ($basicAuthLogin) {
215 $httpheader[] = 'Authorization: Basic '.base64_encode($basicAuthLogin.':'.$basicAuthPassword);
216 }
217
218 $url = $this->dolistore_api_url . (preg_match('/\/$/', $this->dolistore_api_url) ? '' : '/') . $resource;
219
220 $options['apikey'] = $this->dolistore_api_key;
221
222 if ($options) {
223 $url .= '?' . http_build_query($options);
224 }
225
226 $response = getURLContent($url, 'GET', '', 1, $httpheader, array('https'), 0, -1, 5, 5);
227
228 $status_code = $response['http_code'];
229 $body = 'Error';
230
231 if ($status_code == 200) {
232 $body = $response['content'];
233 $body = json_decode($body, true);
234 $returnarray = array(
235 'status_code' => $status_code,
236 'response' => $body
237 );
238 } else {
239 $returnarray = array(
240 'status_code' => $status_code,
241 'response' => $body
242 );
243 if (!empty($response['curl_error_no'])) {
244 $returnarray['curl_error_no'] = $response['curl_error_no'];
245 }
246 if (!empty($response['curl_error_msg'])) {
247 $returnarray['curl_error_msg'] = $response['curl_error_msg'];
248 }
249 }
250
251 return $returnarray;
252 }
253
260 public function fetchModulesFromFile($options = array())
261 {
262 $modules = array();
263
264 if (!empty($this->cache_file) && file_exists($this->cache_file)) {
265 dol_syslog(__METHOD__ . " - Loading cache file: " . $this->cache_file, LOG_DEBUG);
266
267 $content = file_get_contents($this->cache_file);
268 if ($content !== false) {
269 $modules = $this->readYaml($content);
270 } else {
271 dol_syslog(__METHOD__ . " - Error reading cache file", LOG_ERR);
272 }
273 }
274
275 return $modules;
276 }
277
284 public function getCategories($active = 0)
285 {
286 $organized_tree = array();
287 $html = '';
288
289 $data = [
290 'lang' => $this->lang
291 ];
292
293 $current = $active;
294
295 $resCategories = $this->callApi('categories', $data);
296 if (isset($resCategories['response']) && is_array($resCategories['response'])) {
297 $organized_tree = $resCategories['response'];
298 } else {
299 return $html ;
300 }
301
302 $html = '';
303 foreach ($organized_tree as $key => $value) {
304 if ($value['label'] != "Versions" && $value['label'] != "Specials") {
305 $html .= '<li' . ($current == $value['rowid'] ? ' class="active"' : '') . '>';
306 $html .= '<a href="?mode=marketplace&categorie=' . $value['rowid'] . '">' . $value['label'] . '</a>';
307 if (isset($value['children'])) {
308 $html .= '<ul>';
309 usort($value['children'], $this->buildSorter('position'));
310 foreach ($value['children'] as $key_children => $value_children) {
311 $html .= '<li' . ($current == $value_children['rowid'] ? ' class="active"' : '') . '>';
312 $html .= '<a href="?mode=marketplace&categorie=' . $value_children['rowid'] . '" title="' . dol_escape_htmltag(strip_tags($value_children['description'])) . '">' . $value_children['label'] . '</a>';
313 $html .= '</li>';
314 }
315 $html .= '</ul>';
316 }
317 $html .= '</li>';
318 }
319 }
320 return $html;
321 }
322
330 public function getProducts($options, $modules = array())
331 {
332 global $langs;
333
334 $langs->load("products");
335
336 $html = "";
337 $last_month = dol_now() - (30 * 24 * 60 * 60);
338 $dolibarrversiontouse = DOL_VERSION; // full string with version
339
340 $this->products = array();
341
342 // Build a map of installed external module names to their versions (lowercase name => version)
343 $installedModules = array();
344 if (is_array($modules)) {
345 foreach ($modules as $objMod) {
346 if (is_object($objMod) && $objMod->isCoreOrExternalModule() != 'core') {
347 $moduleName = strtolower($objMod->name);
348 $moduleVersion = $objMod->getVersion(0);
349 $installedModules[$moduleName] = $moduleVersion;
350 }
351 }
352 }
353
354 $this->categorie = $options['categorie'] ?? 0;
355 $this->per_page = $options['per_page'] ?? 11;
356 $this->no_page = $options['no_page'] ?? 1;
357 $this->search = $options['search'] ?? '';
358
359 $this->per_page = 11; // We fix number of products per page to 11
360
361 // Length of $search must be at least 2 characters
362 if (!empty($this->search) && strlen(str_replace(' ', '', (string) $this->search)) < 2) {
363 $html .= '<tr class=""><td colspan="3" class="center">';
364 $html .= '<br><br>';
365 $html .= $langs->trans("SearchStringMinLength").'...';
366 $html .= '<br><br>';
367 $html .= '</td></tr>';
368 return $html;
369 }
370
371 $data = [
372 'categorieid' => $this->categorie,
373 'limit' => $this->per_page,
374 'page' => $this->no_page,
375 'search' => $this->search,
376 'lang' => $this->lang
377 ];
378
379
380 $this->numberTotalOfProducts = 0;
381
382 // Special case of category goodies
383 if ($this->categorie == 87) {
384 $html = '<div class="shop-container">
385 <div class="shop-image">
386 <a href="https://merch.dolibarr.org/" target="_blank">
387 <img src="https://www.dolistore.com/medias/image/marketplace/img/goodies-shop.jpg" width="50%" alt="DoliStore Merch and Gifts" />
388 <div class="shop-overlay">
389 <button target="new" class="shop-button">'.$langs->trans("GoodiesButtonTitle").' <i class="icon-chevron-right"></i></button>
390 </div>
391 </a>
392 </div>
393 </div>';
394
395 return $html;
396 }
397
398 // Fetch the products from Dolistore source
399
400 $dolistoreProducts = array();
401 $dolistoreProductsTotal = 0;
402 if ($this->dolistoreApiStatus > 0 && getDolGlobalInt('MAIN_ENABLE_EXTERNALMODULES_DOLISTORE')) {
403 $getDolistoreProducts = $this->callApi('products', $data);
404
405 if (!isset($getDolistoreProducts['response']) || !is_array($getDolistoreProducts['response']) || ($getDolistoreProducts['status_code'] != 200 && $getDolistoreProducts['status_code'] != 201)) {
406 $dolistoreProducts = array();
407 $dolistoreProductsTotal = 0;
408 } else {
409 $dolistoreProducts = $this->adaptData($getDolistoreProducts['response']['products'], 'dolistore');
410 $dolistoreProductsTotal = (int) $getDolistoreProducts['response']['total'];
411 $this->numberTotalOfProducts += $dolistoreProductsTotal;
412 }
413 }
414
415 // Fetch the products from the github repo
416
417 $fileProducts = array();
418 $fileProductsTotal = 0;
419 if (!empty($this->githubFileStatus) && getDolGlobalInt('MAIN_ENABLE_EXTERNALMODULES_COMMUNITY')) {
420 $fileProducts = $this->fetchModulesFromFile($data); // Return an array with all modules from the cache filecontent in $data
421
422 $fileProducts = $this->adaptData($fileProducts, 'githubcommunity');
423
424 $fileProducts = $this->applyFilters($fileProducts, $data);
425
426 $fileProductsTotal = $fileProducts['total'];
427
428 $this->numberTotalOfProducts += $fileProductsTotal;
429
430 $fileProducts = $fileProducts['data'];
431 }
432
433 // Number of pages
434 $this->numberTotalOfPages = (int) ceil(max($fileProductsTotal / $this->per_page, $dolistoreProductsTotal / $this->per_page));
435
436 // Merge both sources (github community modules have priority on dolistore).
437 $this->products = $dolistoreProducts;
438 foreach ($fileProducts as $fileProduct) {
439 $id = $fileProduct['id'];
440 if ($id > 0) {
441 if (empty($this->products[$id])) { // Not already present in array
442 array_unshift($this->products, $fileProduct);
443 } else {
444 $this->products[$id] = $fileProduct;
445 $this->products[$id]['category'] = $fileProduct['category'];
446 }
447 } else {
448 array_unshift($this->products, $fileProduct);
449 }
450 }
451
452
453 $i = 0;
454 foreach ($this->products as $product) {
455 $i++;
456
457 // check new product ?
458 $newapp = '';
459 if ($last_month < strtotime($product['datec']) && $product["status"] != 'soon' && $product["status"] != 'development' && $product["status"] != 'experimental') {
460 $newapp .= '<span class="newApp" title="'.$product['tms'].'">'.$langs->trans('New').'</span> ';
461 }
462
463 // check updated ?
464 if ($newapp == '' && $last_month < strtotime($product['tms']) && $product["status"] != 'soon' && $product["status"] != 'development' && $product["status"] != 'experimental') {
465 $newapp .= '<span class="updatedApp" title="'.$product['tms'].'">'.$langs->trans('UpdatedRecently').'</span> ';
466 }
467
468 // add image or default ?
469 if ($product["cover_photo_url"] != '' && $product["cover_photo_url"] != '#') {
470 $images = '<a href="'.$product["cover_photo_url"].'" class="documentpreview" target="_blank" rel="noopener noreferrer" mime="image/png" title="'.dol_escape_htmltag($product["label"].', '.$langs->trans('Version').' '.$product["module_version"]).'">';
471 $images .= '<img class="imgstore" src="'.$product["cover_photo_url"].'" alt="" /></a>';
472 } else {
473 $images = '<img class="imgstore" src="'.DOL_URL_ROOT.'/public/theme/common/nophoto.png" />';
474 }
475
476 // Set and check version
477 $version = '';
478 $compatible = '';
479 if ($product["status"] == 'soon' || $product["status"] == 'development' || $product["status"] == 'experimental') {
480 $version = '<span class="warning">'.$langs->trans("NotYetAvailable").' - '.$langs->trans("StillInDevelopment").'</span>';
481 $compatible = 'NotCompatible';
482 } elseif ($this->versionCompare($product["dolibarr_min"], $dolibarrversiontouse) <= 0) {
483 if (!empty($product["dolibarr_max"]) && $product["dolibarr_max"] != 'auto' && $product["dolibarr_max"] != 'unknown' && $this->versionCompare($product["dolibarr_max"], $dolibarrversiontouse) >= 0) {
484 // Compatible
485 $version = '<span class="compatible hideonsmartphone">'.$langs->trans(
486 'CompatibleUpTo',
487 $dolibarrversiontouse,
488 $product["dolibarr_min"],
489 $product["dolibarr_max"]
490 ).'</span>';
491 $compatible = '';
492 } else {
493 // Never compatible, module expired
494 $version = '<span class="warning">'.$langs->trans(
495 'NotCompatible',
496 $dolibarrversiontouse,
497 $product["dolibarr_min"],
498 $product["dolibarr_max"]
499 ).'</span>';
500 $compatible = 'NotCompatible';
501 }
502 } else {
503 if ($product["dolibarr_min"] == 'auto' || $product["dolibarr_min"] != 'unknown') {
504 // Never compatible, module expired
505 $version = '<span class="warning">'.$langs->trans(
506 'NotCompatible',
507 $dolibarrversiontouse,
508 $product["dolibarr_min"],
509 $product["dolibarr_max"]
510 ).'</span>';
511 $compatible = 'NotCompatible';
512 } else {
513 // Need update
514 $version = '<span class="compatibleafterupdate">'.$langs->trans(
515 'CompatibleAfterUpdate',
516 $dolibarrversiontouse,
517 $product["dolibarr_min"],
518 $product["dolibarr_max"]
519 ).'</span>';
520 $compatible = 'NotCompatible';
521 }
522 }
523
524 // free or pay ?
525 $install_link = '';
526 if (array_key_exists('price_ht', $product) && price2num($product["price_ht"]) > 0) {
527 $price = '<h3>'.price(price2num($product["price_ht"], 'MT'), 0, $langs, 1, -1, -1, 'EUR').' '.$langs->trans("HT").'</h3>';
528
529 $download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$this->shop_url.'/product.php?id='.((int) $product['id']).'">';
530 $download_link .= img_picto('', 'url', 'class="size2x paddingright"');
531 $download_link .= '</a>';
532 } else {
533 $download_link = '#';
534 if ($product['source'] === 'dolistore') { // 0 on dolistore may mean 0 or a complementary fee to subscribe
535 $urlview = $this->shop_url.'/product.php?id='.((int) $product["id"]);
536 $price = '<h3><a href="'.$urlview.'" target="_blank">'.$langs->trans('SeeOnDoliStore').'</a></h3>';
537 } elseif ($product['source'] === 'githubcommunity') {
538 if (array_key_exists('price_ht', $product) && empty($product['price_ht'])) {
539 if ($product['status'] == 'soon') {
540 $price = '<h3>'.$langs->trans('StillInDevelopment').'</h3>';
541 } else {
542 $price = '<h3>'.$langs->trans('Free').'</h3>';
543 }
544 } else {
545 if ($product["dolistore-download"]) {
546 $price = '<h3><a href="'.$product["dolistore-download"].'" target="_blank">'.$langs->trans('SeeOnDoliStore').'</a></h3>';
547 } else {
548 $price = '<h3>'.$langs->trans('Unknown').'</h3>';
549 }
550 }
551 } else {
552 $price = '<h3>'.$langs->trans('Unknown').'</h3>';
553 }
554
555 if ($product['source'] === 'githubcommunity') {
556 $download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("Sources").'" href="'.$product["link"].'">';
557 $download_link .= img_picto('', 'file-code', 'class="size2x paddingright colorgrey"');
558 $download_link .= '</a>';
559
560 $urlview = $product["dolistore-download"]; // View on Dolistore
561 if ($urlview) {
562 $download_link .= '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$urlview.'" rel="noopener noreferrer">';
563 $download_link .= img_picto('', 'url', 'class="size2x"');
564 $download_link .= '</a>';
565 }
566
567 if (!empty($product['direct-download']) && $product['direct-download'] == 'yes') {
568 $reg = array();
569 if (preg_match('/https:.*\?id=(\d+)$/', $urlview, $reg)) {
570 $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p='.$reg[1];
571 $download_link .= '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("Download").'" href="'.$urldownload.'" rel="noopener noreferrer">';
572 $download_link .= img_picto('', 'download', 'class="size2x paddingright"');
573 $download_link .= '</a>';
574 }
575 }
576 } elseif ($product['source'] === 'dolistore') {
577 $urlview = $this->shop_url.'/product.php?id='.((int) $product["id"]);
578 $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $product['id'];
579 $download_link = '<a class="paddingleft paddingright valignmiddle" target="_blank" title="'.$langs->trans("View").'" href="'.$urlview.'">';
580 $download_link .= img_picto('', 'url', 'class="size2x"');
581 $download_link .= '</a>';
582 $download_link .= '<a class="paddingleft paddingright" target="_blank" title="'.$langs->trans("Download").'" href="'.$urldownload.'" rel="noopener noreferrer">';
583 $download_link .= img_picto('', 'download', 'class="size2x paddingright"');
584 $download_link .= '</a>';
585 }
586
587 // Direct install
588 if (($product['direct-download'] && in_array($product['direct-download'], array('yes', 'dolistore'))) || $product['source'] === 'dolistore') {
589 $urldownload = '';
590
591 if ($product['source'] === 'githubcommunity') {
592 $current_version = $product['module_version'] ?? '';
593 $module_name = strtolower(preg_replace('/@.*$/', '', $product['ref'] ?? ''));
594
595 // Remove "-" followed by current version at the end of the string if it exists
596 $module_name = preg_replace('/-' . preg_quote($current_version, '/') . '$/', '', $module_name);
597
598 $urldownload = 'https://github.com/Dolibarr/dolibarr-community-modules/raw/refs/heads/main/dev/build/bin/module_' . $module_name . '-' . $current_version . '.zip';
599
600 $reg = array();
601 $urlview = $product["dolistore-download"]; // View on Dolistore
602
603 // For community modules, we download from community repo.
604 // But we can force to download from dolistore if MAIN_DOWNLOAD_FROM_DOLISTORE_IN_PRIORITY is set (less reliable, less up to date)
605 if ($product["direct-download"] == 'dolistore' || getDolGlobalString("MAIN_DOWNLOAD_FROM_DOLISTORE_IN_PRIORITY")) {
606 if (preg_match('/https:.*\?id=(\d+)$/', $urlview, $reg)) {
607 $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p='.$reg[1];
608 }
609 }
610 }
611 if ($product['source'] === 'dolistore') {
612 $urldownload = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $product['id'];
613 }
614
615
616 $disableInstall = ($compatible === 'NotCompatible') && !getDolGlobalInt('MAIN_FEATURES_LEVEL');
617 // $disableInstall = false; // TODO: remove this.
618 $disableInfo = $disableInstall ? dol_string_nohtmltag($version) : '';
619 $fields = ['action' => 'install', 'token' => newToken()];
620 foreach ($product as $key => $value) {
621 $fields['producttoinstall['.$key.']'] = $value;
622 }
623
624 $installConfirmMessage = $langs->transnoentities(
625 "extModuleConfirmInstallText",
626 $product['label'] ?? '',
627 $product['module_version'] ?? '',
628 $product['ref'] ?? '',
629 !empty($product['tms']) ? dol_print_date($product['tms'], '%d/%m/%Y') : ''
630 );
631 $installConfirmMessage .= $langs->trans("Path").' : '.$urldownload;
632
633 // Check if module is already installed locally to show "Upgrade" or "Re-install" instead of "Install"
634 $buttonLabel = $langs->trans("Install");
635 $remoteVersion = $product['module_version'] ?? '';
636 $remoteModuleName = strtolower(preg_replace('/@.*$/', '', $product['ref'] ?? ''));
637 // Remove "-" followed by current version at the end of the string if it exists
638 $remoteModuleName = preg_replace('/-' . preg_quote($remoteVersion, '/') . '$/', '', $remoteModuleName);
639 if (!empty($installedModules[$remoteModuleName]) && $remoteVersion && $remoteVersion != 'unknown') {
640 $localVersion = $installedModules[$remoteModuleName];
641 // $localVersion is guaranteed non-empty here (see !empty() test above), so only the 'unknown' value must be excluded
642 if ($localVersion != 'unknown') {
643 $versionDiff = $this->versionCompare($localVersion, $remoteVersion);
644 if ($versionDiff < 0) {
645 $buttonLabel = $langs->trans("Upgrade");
646 } elseif ($versionDiff == 0) {
647 $buttonLabel = $langs->trans("ReInstall");
648 }
649 }
650 }
651
652 $install_link = '<button class="valignmiddle ' . ($disableInstall ? 'butActionRefused' : 'butAction') . ' paddingleft paddingright"'
653 . ($disableInfo ? ' title="' . dol_escape_htmltag($disableInfo) . '"' : '')
654 . (!$disableInstall ? ' data-confirm' : '')
655 . (!$disableInstall ? ' data-fields="' . dol_escape_htmltag(json_encode($fields)) . '"' : '')
656 . (!$disableInstall ? ' data-url="' . dol_escape_htmltag($this->url) . '"' : '')
657 . (!$disableInstall ? ' data-confirm-title="' . dol_escape_htmltag($langs->trans("extModuleConfirmInstallTitle")) . '"' : '')
658 . (!$disableInstall ? ' data-confirm-text="' . dol_escape_htmltag($installConfirmMessage) . '"' : '')
659 . (!$disableInstall ? ' data-confirm-label="' . dol_escape_htmltag($buttonLabel) . '"' : '')
660 . '>' . $buttonLabel . '</button>';
661 }
662 }
663
664 // Output the line
665 $html .= '<tr class="'.(getDolOptimizeSmallScreen() ? 'app' : 'app app2').' oddeven nohover '.dol_escape_htmltag($compatible).'">';
666
667 // Logo
668 $html .= '<td class="center width150"><div class="newAppParent">';
669 $html .= $newapp.$images; // No dol_escape_htmltag, it is already escape html
670 $html .= '</div></td>';
671
672 // Description
673 $html .= '<td class="margeCote minwidth400imp"><h2 class="appTitle">';
674 $html .= dolPrintHTML(dol_string_nohtmltag(ucfirst($product["label"])));
675 if (!empty($product['author']) && $product['author'] != 'unkownauthor') {
676 $html .= '<span class="small"> &nbsp; - &nbsp; '.img_picto('', 'company', 'class="pictofixedwidth"');
677 if (!empty($product['author_url'])) {
678 $html .= '<a href="'.$product['author_url'].'" target="_blank">'.$product['author'].'</a>';
679 } else {
680 $html .= $product['author'];
681 }
682 $html .= '</span>';
683 }
684 $html .= '<br><span class="small">';
685 $html .= $version; // Version Dolibarr. No dol_escape_htmltag, it is already escape html
686 $html .= '</span>';
687 $html .= '</h2>';
688
689 $html .= '<small class="appDateCreation appRef"> ';
690 if (empty($product['tms'])) {
691 $html .= img_picto($langs->trans('DateCreation'), 'calendar', 'class="pictofixedwidth"').'<span class="opacitymedium"><span class="hideonsmartphone">'.$langs->trans("DateCreation").': </span>';
692 $html .= (!empty($product['datec']) ? dol_print_date(dol_stringtotime($product['datec']), 'day') : $langs->trans("Unknown")).'</span>';
693 } else {
694 $html .= img_picto($langs->trans('DateModification'), 'calendar', 'class="pictofixedwidth"').'<span class="opacitymedium">'.dol_print_date(dol_stringtotime($product['tms']), 'day').'</span>';
695 }
696 $html .= ' &nbsp; &nbsp; ';
697
698 $html .= '<div class="appSource inline-block valigntop">';
699 if ($product["source"] == 'dolistore') {
700 $html .= '<img border="0" title="'.dolPrintHTML($langs->trans('Source').": DoliStore").'" class="imgautosize valignmiddle inline-block pictofixedwidth" style="height: 14px" src="'.DOL_URL_ROOT.'/theme/dolistore_squarred.svg">';
701 } elseif ($product["source"] == 'githubcommunity') {
702 $html .= img_picto($langs->trans('Source').': GitHub community repo', 'group', 'class="pictofixedwidth valignmiddle"');
703 } else {
704 $html .= img_picto($langs->trans('Source').': '.$langs->trans('Other'), 'generic', 'class="pictofixedwidth"');
705 }
706 $html .= '</div>';
707
708 $html .= $langs->trans('Ref').' '.dolPrintHTML(preg_replace('/@.*$/', '', $product["ref"]));
709 $html .= '</small><br>';
710
711
712 $html .= '&nbsp;';
713 if (!empty($product['phpmin']) && $product['phpmin'] != 'unknown') {
714 $html .= ' <span class="badge-secondary small" style="padding: 3px; border-radius: 5px">PHP min '.$product['phpmin'].'</span>';
715 }
716 if (!empty($product['phpmax']) && $product['phpmax'] != 'unknown') {
717 $html .= ' <span class="badge-secondary small" style="padding: 3px; border-radius: 5px">PHP max '.$product['phpmax'].'</span>';
718 }
719 $html .= '<br>';
720
721 $html .= '<br>';
722 $html .= '<div class="storedesc">'.dolPrintHTML(dol_string_nohtmltag($product["description"])).'</div>';
723 $html .= '</td>';
724
726 $html .= '</tr><tr class="app2 oddeven nohover borderbottom '.dol_escape_htmltag($compatible).'">';
727 }
728
729 // Price - do not load if display none
730 $html .= '<td class="margeCote center amount'.(getDolOptimizeSmallScreen() ? ' left" colspan="2"' : '"').'>';
731 $html .= $price;
732 if (($product['direct-download'] && in_array($product['direct-download'], array('yes', 'dolistore'))) || ($product['source'] === 'dolistore' && empty((float) $product['price_ht']))) {
733 if ($install_link) {
734 $html .= $install_link;
735 }
736 }
737
739 $html .= '</td>';
740 $html .= '<td class="margeCote nowraponall">';
741 }
742
743 // Links
744 $html .= $download_link;
745 $html .= '</td>';
746
747 $html .= '</tr>';
748 }
749
750 if (empty($this->products)) {
751 $colspan = (getDolOptimizeSmallScreen() ? 1 : 3);
752 $langs->load("website");
753
754 $html .= '<tr class=""><td colspan="'.$colspan.'" class="center">';
755 $html .= '<br><br>';
756 $html .= $langs->trans("noResultsWereFound").'...';
757 $html .= '<br><br>';
758 $html .= '</td></tr>';
759 }
760
761 // JS for confirm install
762 $confirmLabel = $langs->trans("Install");
763 $cancelLabel = $langs->trans("Cancel");
764 $html .= '<script>
765 $(document).on("click","[data-confirm]",function(){
766 var button = $(this);
767 var confirmTitle = button.data("confirm-title");
768 var confirmText = button.data("confirm-text");
769 var buttons = {};
770 buttons[button.data("confirm-label")||\'' . dol_escape_js($confirmLabel) . '\'] = function(){
771 var form = $("<form method=\'POST\' style=\'display:none\'>").attr("action", button.data("url"));
772 $.each(button.data("fields"), function(name, value){
773 form.append($("<input type=\'hidden\'>").attr("name", name).val(value));
774 });
775 $("body").append(form);
776 form.submit();
777 $(this).dialog("close");
778 };
779 buttons[\'' . dol_escape_js($cancelLabel) . '\'] = function(){$(this).dialog("close");};
780 $("<div>").html(confirmText).dialog({
781 title: confirmTitle,
782 minWidth: 580,
783 modal: true,
784 buttons: buttons
785 });
786 });
787 </script>';
788
789 $this->numberOfProducts = count($this->products);
790
791 return $html;
792 }
793
800 public function buildSorter(string $key): Closure
801 {
802 return
808 function (array $a, array $b) use ($key) {
809 $valA = isset($a[$key]) && is_scalar($a[$key]) ? (string) $a[$key] : '';
810 $valB = isset($b[$key]) && is_scalar($b[$key]) ? (string) $b[$key] : '';
811
812 return strnatcmp($valA, $valB);
813 };
814 }
815
823 public function versionCompare($v1, $v2)
824 {
825 // Clean v1 and v2
826 $v1 = str_replace(array('v', 'V'), '', $v1);
827 $v2 = str_replace(array('v', 'V'), '', $v2);
828
829 $v1 = explode('.', $v1);
830 $v2 = explode('.', $v2);
831 $ret = 0;
832 $level = 0;
833 $count1 = count($v1);
834 $count2 = count($v2);
835 $maxcount = max($count1, $count2);
836 while ($level < $maxcount) {
837 $operande1 = isset($v1[$level]) ? $v1[$level] : 'x';
838 $operande2 = isset($v2[$level]) ? $v2[$level] : 'x';
839 $level++;
840 if (strtoupper($operande1) == 'X' || strtoupper($operande2) == 'X' || $operande1 == '*' || $operande2 == '*') {
841 break;
842 }
843 if ($operande1 < $operande2) {
844 $ret = -$level;
845 break;
846 }
847 if ($operande1 > $operande2) {
848 $ret = $level;
849 break;
850 }
851 }
852 //print join('.',$versionarray1).'('.count($versionarray1).') / '.join('.',$versionarray2).'('.count($versionarray2).') => '.$ret.'<br>'."\n";
853 return $ret;
854 }
855
856 // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
863 public function get_previous_link($text = '<<')
864 {
865 // phpcs:enable
866 return '<a href="'.$this->get_previous_url().'" class="button">'.dol_escape_htmltag($text).'</a>';
867 }
868
869 // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
876 public function get_next_link($text = '>>')
877 {
878 // phpcs:enable
879 return '<a href="'.$this->get_next_url().'" class="button">'.dol_escape_htmltag($text).'</a>';
880 }
881
882 // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
888 public function get_previous_url()
889 {
890 // phpcs:enable
891 $param_array = array();
892 if ($this->no_page > 1) {
893 $sub = 1;
894 } else {
895 $sub = 0;
896 }
897 if (!empty($this->search)) {
898 $param_array['search_keyword'] = $this->search;
899 }
900 $param_array['no_page'] = $this->no_page - $sub;
901 if ($this->categorie != 0) {
902 $param_array['categorie'] = $this->categorie;
903 }
904 $param = http_build_query($param_array);
905 return $this->url."&".$param;
906 }
907
908 // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
914 public function get_next_url()
915 {
916 // phpcs:enable
917 $param_array = array();
918 if ($this->products !== null && count($this->products) < $this->per_page) {
919 $add = 0;
920 } else {
921 $add = 1;
922 }
923 if (!empty($this->search)) {
924 $param_array['search_keyword'] = $this->search;
925 }
926 $param_array['no_page'] = $this->no_page + $add;
927 if ($this->categorie != 0) {
928 $param_array['categorie'] = $this->categorie;
929 }
930 $param = http_build_query($param_array);
931 return $this->url."&".$param;
932 }
933
939 public function getPagination()
940 {
941
942 global $langs;
943
944 $page = $this->no_page;
945 $limit = $this->per_page;
946 $totalnboflines = $this->numberTotalOfProducts ?: 0;
947 $num = $this->numberOfProducts;
948
949 $html = "";
950
951 // Show navigation bar
952 $pagelist = '';
953 if ($page > 0 || $num > $limit) {
954 if ($totalnboflines) {
955 if ($limit > 0) {
956 $nbpages = $this->numberTotalOfPages;
957 } else {
958 $nbpages = 1;
959 }
960
961 // Show previous page
962 if ($page > 1) {
963 $pagelist .= '<li class="pagination paginationpage paginationpageleft"><a class="paginationprevious reposition" href="'.$this->get_previous_url().'"><i class="fa fa-chevron-left" title="'.dol_escape_htmltag($langs->trans("Previous")).'"></i></a></li>';
964 }
965
966 $pagelist .= '<li class="pagination">';
967 $pagelist .= '<label for="page_input">Page </label>';
968 if ($this->categorie != 0) {
969 $pagelist .= '<input type="hidden" name="categorie" value="' . $this->categorie . '">';
970 }
971 $pagelist .= '<input type="text" id="page_input" name="no_page" value="'.($page).'" min="1" max="'.$nbpages.'" class="width40 page_input right" oninput="if(this.value > '.$nbpages.') this.value='.$nbpages.'">';
972 $pagelist .= ' / '.$nbpages;
973 $pagelist .= '</li>';
974
975 // Show next page
976 if ($page < $nbpages) {
977 $pagelist .= '<li class="pagination paginationpage paginationpageright"><a class="paginationnext reposition" href="'.$this->get_next_url().'"><i class="fa fa-chevron-right" title="'.dol_escape_htmltag($langs->trans("Next")).'"></i></a></li>';
978 }
979 }
980 }
981
982 if ($limit || $pagelist) {
983 $html .= '<div class="pagination" style="padding: 7px;">';
984 $html .= '<ul>';
985 $html .= $pagelist;
986 $html .= '</ul>';
987 $html .= '</div>';
988 }
989
990 $html .= ajax_autoselect('.page_input');
991
992 return $html;
993 }
994
1001 protected function checkStatusCode($request)
1002 {
1003 // Define error messages
1004 $error_messages = [
1005 204 => 'No content',
1006 400 => 'Bad Request',
1007 401 => 'Unauthorized',
1008 404 => 'Not Found',
1009 405 => 'Method Not Allowed',
1010 500 => 'Internal Server Error',
1011 ];
1012
1013 // If status code is 200 or 201, return an empty string
1014 if ($request['status_code'] === 200 || $request['status_code'] === 201) {
1015 return '';
1016 }
1017
1018 // Get the predefined error message or use a default one
1019 $error_message = $error_messages[$request['status_code']] ?? 'Unexpected HTTP status: ' . $request['status_code'];
1020
1021 // Append error details if available
1022 if (!empty($request['response']) && isset($request['response']['errors']) && is_array($request['response']['errors'])) {
1023 foreach ($request['response']['errors'] as $error) {
1024 $error_message .= ' - (Code ' . $error['code'] . '): ' . $error['message'];
1025 }
1026 }
1027
1028 if (!empty($request['curl_error_msg'])) {
1029 $error_message .= ' - ' . $request['curl_error_msg'];
1030 }
1031
1032 // Return the formatted error message
1033 return sprintf('This call to the API failed and returned an HTTP status of %d. That means: %s.', $request['status_code'], $error_message);
1034 }
1035
1043 public function getRemoteYamlFile($file_source_url, $cache_time)
1044 {
1045 $yaml = '';
1046 $cache_file = $this->cache_file;
1047 $cache_folder = dirname($cache_file);
1048
1049 // Check if cache directory exists
1050 if (!dol_is_dir($cache_folder)) {
1051 dol_mkdir($cache_folder, DOL_DATA_ROOT);
1052 }
1053
1054 if (!file_exists($cache_file) || filemtime($cache_file) < (dol_now() - $cache_time)) {
1055 // We get remote url
1056 $addheaders = array();
1057 $result = getURLContent($file_source_url, 'GET', '', 1, $addheaders); // TODO Force timeout to 5 s on both connect and response.
1058 if (!empty($result) && $result['http_code'] == 200) {
1059 $yaml = $result['content'];
1060 $result = file_put_contents($cache_file, $yaml);
1061 if ($result === false) {
1062 $this->error = 'Failed to create cache file: ' . $cache_file;
1063 } else {
1064 dolChmod($cache_file);
1065 }
1066 }
1067 } else {
1068 $yaml = file_get_contents($cache_file);
1069 }
1070
1071 return $yaml;
1072 }
1073
1074
1081 public function readYaml($yaml)
1082 {
1083 $data = [];
1084 $currentPackage = null;
1085 $currentSection = null;
1086
1087 foreach (explode("\n", trim($yaml)) as $line) {
1088 $trimmedLine = trim($line);
1089
1090 // Ignore empty lines and comments
1091 if ($trimmedLine === '' || strpos($trimmedLine, '#') === 0) {
1092 continue;
1093 }
1094
1095 // Match a new package entry (e.g., "- modulename: 'helloasso'") - Found a break in file.
1096 $matches = array();
1097 if (preg_match('/^\s*-\s*modulename:\s*["\']?(.*?)["\']?$/', $trimmedLine, $matches)) {
1098 if ($currentPackage !== null) {
1099 // Add the package to $data
1100 if (!empty($currentPackage['status']) && in_array($currentPackage['status'], array('enabled', 'soon'))) {
1101 $data[] = $currentPackage;
1102 }
1103 }
1104 $currentPackage = ['modulename' => $matches[1]];
1105 $currentSection = null;
1106 continue;
1107 }
1108
1109 // If the key doesn't start with fr, en, es, it, de, treat it as a section
1110 if (!preg_match('/^\s*(fr|en|es|it|de):\s*["\']?(.*?)["\']?$/', $trimmedLine)) {
1111 $currentSection = null;
1112 }
1113
1114 // Match a top-level key-value pair (e.g., "author: 'Dolicloud'")
1115 if (preg_match('/^(\w[\w-]*):\s*["\']?(.*?)["\']?$/', $trimmedLine, $matches)) {
1116 if ($currentPackage !== null) {
1117 if ($currentSection) {
1118 // Store in the sub section (language into label or description for example)
1119 $currentPackage[$currentSection][$matches[1]] = $matches[2] === '' ? null : $matches[2];
1120 } else {
1121 // Store as a normal key-value pair
1122 $currentPackage[$matches[1]] = $matches[2] === '' ? null : $matches[2];
1123 }
1124 }
1125
1126 // Match a nested section (e.g., "label:")
1127 if (preg_match('/^\s*(label|description):\s*$/', $trimmedLine, $matches)) {
1128 $currentSection = $matches[1];
1129 $currentPackage[$currentSection] = []; // Initialize as an empty array for nested sections
1130 }
1131
1132 continue;
1133 }
1134 }
1135
1136 // Add the last package if available
1137 if ($currentPackage !== null) {
1138 if (!empty($currentPackage['status']) && in_array($currentPackage['status'], array('enabled', 'soon'))) {
1139 $data[] = $currentPackage;
1140 }
1141 }
1142
1143 return $data;
1144 }
1145
1153 public function adaptData($data, $source)
1154 {
1155 $adaptedData = [];
1156
1157 if (!is_array($data) || empty($data) || empty($source)) {
1158 return $adaptedData;
1159 }
1160
1161 if ($source === 'githubcommunity') {
1162 foreach ($data as $package) {
1163 if (empty($package['modulename'])) {
1164 continue;
1165 }
1166
1167 // Check if there is a known ID
1168 $reg = array();
1169 $id = 0;
1170 if (!empty($package['dolistore-download']) && preg_match('/www\.dolistore\.com\/product\.php\?id=(\d+)/', (string) $package['dolistore-download'], $reg)) {
1171 $id = $reg[1];
1172 }
1173
1174 $adaptedPackage = [
1175 'id' => $id,
1176 'ref' => str_replace(' ', '', $package['modulename'] . '-' . $package['current_version'] . '@' .
1177 (array_key_exists('author', $package) ? $package['author'] : 'unkownauthor')),
1178 'label' => !empty($package['label'][substr($this->lang, 0, 2)])
1179 ? $package['label'][substr($this->lang, 0, 2)]
1180 : (!empty($package['label']['en']) ? $package['label']['en'] : $package['modulename']),
1181 'description' => !empty($package['description'][substr($this->lang, 0, 2)])
1182 ? $package['description'][substr($this->lang, 0, 2)]
1183 : (!empty($package['description']['en']) ? $package['description']['en'] : ''),
1184 'datec' => (!empty($package['created_at']) && is_string($package['created_at']))
1185 ? date('Y-m-d H:i:s', strtotime($package['created_at']))
1186 : '',
1187 'tms' => (!empty($package['last_updated_at']) && is_string($package['last_updated_at']))
1188 ? date('Y-m-d H:i:s', strtotime($package['last_updated_at']))
1189 : '',
1190 'author' => array_key_exists('author', $package) ? $package['author'] : '',
1191 'author_url' => array_key_exists('author_url', $package) ? $package['author_url'] : '',
1192 'dolibarr_min' => !empty($package['dolibarrmin'])
1193 ? $package['dolibarrmin']
1194 : 'unknown',
1195 'dolibarr_max' => !empty($package['dolibarrmax'])
1196 ? $package['dolibarrmax']
1197 : 'unknown',
1198 'phpmin' => !empty($package['phpmin'])
1199 ? $package['phpmin']
1200 : 'unknown',
1201 'phpmax' => !empty($package['phpmax'])
1202 ? $package['phpmax']
1203 : 'unknown',
1204 'module_version' => !empty($package['current_version'])
1205 ? $package['current_version']
1206 : 'unknown',
1207 'cover_photo_url' => !empty($package['cover'])
1208 ? $package['cover']
1209 : '#',
1210 'category' => (!empty($package['category']) && is_string($package['category']))
1211 ? explode(',', str_replace(' ', '', (string) $package['category']))
1212 : array(),
1213 'link' => !empty($package['git'])
1214 ? $package['git']
1215 : '#',
1216 'source' => 'githubcommunity',
1217 'status' => !empty($package['status']) ? $package['status'] : '',
1218 'direct-download' => !empty($package['direct-download'])
1219 ? $package['direct-download']
1220 : '',
1221 'dolistore-download' => !empty($package['dolistore-download'])
1222 ? $package['dolistore-download']
1223 : '',
1224 ];
1225
1226 // If a price entry exists
1227 if (array_key_exists('price', $package) && $package['price'] != null) {
1228 $adaptedPackage['price_ht'] = $package['price'];
1229 }
1230
1231 $adaptedData[] = $adaptedPackage;
1232 }
1233 }
1234
1235 if ($source === 'dolistore') {
1236 foreach ($data as $package) {
1237 $urlphoto = $this->shop_url.$package['cover_photo_url'];
1238
1239 if (preg_match('/^\/?wrapper\.php\?hashp=/', $package['cover_photo_url']) && !preg_match('/attachment=/', $package['cover_photo_url'])) {
1240 $urlphoto .= '&attachment=0';
1241 }
1242
1243 $adaptedPackage = [
1244 'id' => $package['id'],
1245 'ref' => $package['ref'],
1246 'label' => $package['label'],
1247 'description' => $package['description'],
1248 'datec' => $package['datec'],
1249 'tms' => $package['tms'],
1250 'author' => array_key_exists('author', $package) ? $package['author'] : '',
1251 'author_url' => array_key_exists('author_url', $package) ? $package['author_url'] : '',
1252 'price_ttc' => $package['price_ttc'],
1253 'price_ht' => $package['price_ht'],
1254 'dolibarr_min' => $package['dolibarr_min'],
1255 'dolibarr_max' => $package['dolibarr_max'],
1256 'phpmin' => empty($package['phpmin']) ? '' : $package['phpmin'],
1257 'phpmax' => empty($package['phpmax']) ? '' : $package['phpmax'],
1258 'module_version' => $package['module_version'],
1259 'cover_photo_url' => $urlphoto,
1260 'source' => 'dolistore',
1261 'status' => empty($package['status']) ? '' : $package['status']
1262 ];
1263
1264 $adaptedData[$package['id']] = $adaptedPackage;
1265 }
1266 }
1267
1268 return $adaptedData;
1269 }
1270
1278 public function applyFilters($list, $options)
1279 {
1280 $filteredData = $list;
1281
1282 // Sort products list by datec
1283 usort(
1284 $filteredData,
1292 static function ($a, $b) {
1293 return strtotime($b['datec'] ?? '0') - strtotime($a['datec'] ?? '0');
1294 }
1295 );
1296
1297 if (!empty($options['search'])) {
1298 $filteredData = array_filter(
1299 $filteredData,
1307 static function ($package) use ($options) {
1308 return stripos($package['label'], $options['search']) !== false || stripos($package['description'], $options['search']) !== false;
1309 }
1310 );
1311 }
1312
1313 if (!empty($options['categorieid'])) {
1314 $filteredData = array_filter(
1315 $filteredData,
1323 static function ($package) use ($options) {
1324 return in_array($options['categorieid'], $package['category']);
1325 }
1326 );
1327 }
1328
1329 $total = count($filteredData);
1330
1331 // Pagination
1332 $filteredData = array_values($filteredData);
1333 $filteredData = array_slice($filteredData, ($options['page'] - 1) * $options['limit'], $options['limit']);
1334
1335 return ['total' => $total, 'data' => $filteredData];
1336 }
1337
1343 public function checkApiStatus()
1344 {
1345 // Call remote API
1346 $testRequest = $this->callApi('categories');
1347
1348 if (!isset($testRequest['response']) || !is_array($testRequest['response']) || ($testRequest['status_code'] != 200 && $testRequest['status_code'] != 201)) {
1349 $this->dolistoreApiError = $this->checkStatusCode($testRequest);
1350 return 0;
1351 } else {
1352 return 1;
1353 }
1354 }
1355
1364 public function libStatus($status, $mode = 3, $moretext = '')
1365 {
1366 global $langs;
1367
1368 $statusType = 'status4';
1369 if ($status == 0) {
1370 $statusType = 'status3';
1371 }
1372
1373 $labelStatus = [];
1374 $labelStatusShort = [];
1375
1376 $labelStatus[0] = $langs->transnoentitiesnoconv("NotConnected");
1377 $labelStatus[1] = $langs->transnoentitiesnoconv("online");
1378 $labelStatusShort[0] = $langs->transnoentitiesnoconv("NotConnected");
1379 $labelStatusShort[1] = $langs->transnoentitiesnoconv("online");
1380
1381 return dolGetStatus($labelStatus[$status], $labelStatusShort[$status], '', $statusType, $mode, '', array('badgeParams' => array('attr' => array('class' => 'classfortooltip', 'title' => $labelStatusShort[$status].$moretext))));
1382 }
1383
1384
1391 public function getModuleZIP($producttoinstall = array())
1392 {
1393 global $conf;
1394
1395 // Check if cURL is available
1396 if (!function_exists('curl_init')) {
1397 dol_syslog(__METHOD__ . ': cURL is not available', LOG_ERR);
1398 return false;
1399 }
1400
1401 // Check required fields
1402 if (empty($producttoinstall['ref'])) {
1403 dol_syslog(__METHOD__ . ': Missing producttoinstall', LOG_ERR);
1404 return false;
1405 }
1406
1407 $current_version = $producttoinstall['module_version'] ?? '';
1408 $module_name = strtolower(preg_replace('/@.*$/', '', $producttoinstall['ref'] ?? ''));
1409
1410 // Remove "-" followed by current version at the end of the string if it exists
1411 $module_name = preg_replace('/-' . preg_quote($current_version, '/') . '$/', '', $module_name);
1412
1413 if (empty($module_name) || empty($current_version) || $current_version == 'unknown') {
1414 dol_syslog(__METHOD__ . ': Missing or unknown module name/version for product', LOG_ERR);
1415 return false;
1416 }
1417
1418 // Create a temporary directory for the download
1419 $tmpdir = $conf->admin->dir_temp . '/remotestoredl';
1420 dol_mkdir($tmpdir);
1421
1422 $downloaded = false;
1423 switch ($producttoinstall['source']) {
1424 case 'dolistore':
1425 if ($producttoinstall['id'] > 0) {
1426 $source_url = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $producttoinstall['id'];
1427 $downloaded = $this->_downloadFile($source_url, $tmpdir);
1428 if (!$downloaded) {
1429 dol_syslog(__METHOD__ . ': Dolistore download failed: ' . $source_url, LOG_ERR);
1430 return false;
1431 }
1432 } else {
1433 dol_syslog(__METHOD__ . ': Invalid product ID for Dolistore download: ' . $producttoinstall['id'], LOG_ERR);
1434 return false;
1435 }
1436 break;
1437 case 'githubcommunity':
1438 if ($producttoinstall['direct-download'] && in_array($producttoinstall['direct-download'], array('yes', 'dolistore'))) {
1439 $source_url = 'https://github.com/Dolibarr/dolibarr-community-modules/raw/refs/heads/main/dev/build/bin/module_' . $module_name . '-' . $current_version . '.zip';
1440 $downloaded = $this->_downloadFile($source_url, $tmpdir);
1441 if (!$downloaded) {
1442 dol_syslog(__METHOD__ . ': GitHub community module download failed: ' . $source_url . ', Try to find a Dolistore link', LOG_WARNING);
1443 if ($producttoinstall['id'] > 0) {
1444 $source_url = 'https://www.dolistore.com/_service_download.php?t=free&p=' . $producttoinstall['id'];
1445 $downloaded = $this->_downloadFile($source_url, $tmpdir);
1446 if (!$downloaded) {
1447 dol_syslog(__METHOD__ . ': Dolistore download failed: ' . $source_url, LOG_ERR);
1448 return false;
1449 }
1450 } else {
1451 dol_syslog(__METHOD__ . ': No direct download available for this GitHub community module', LOG_ERR);
1452 return false;
1453 }
1454 }
1455 } else {
1456 dol_syslog(__METHOD__ . ': No direct download available for this GitHub community module', LOG_ERR);
1457 return false;
1458 }
1459 break;
1460 default:
1461 dol_syslog(__METHOD__ . ': Unsupported source type: ' . $producttoinstall['source'], LOG_ERR);
1462 }
1463
1464
1465 dol_syslog(__METHOD__ . ': Module downloaded successfully to: ' . $downloaded, LOG_DEBUG);
1466 return $downloaded;
1467 }
1468
1469
1477 private function _downloadFile(string $url, string $dest_path)
1478 {
1479 // HEAD request to get real filename from Content-Disposition
1480 $filename = '';
1481 $head = getURLContent($url, 'HEAD');
1482 // Try to extract filename from Content-Disposition header
1483 if (!empty($head['header'])) {
1484 if (preg_match_all('/Content-Disposition:.*filename=["\']?([^"\';\r\n]+)/i', $head['header'], $m)) {
1485 $filename = trim(end($m[1]), " \t\"'");
1486 }
1487 }
1488
1489 // If filename is not found in headers, try to extract it from URL
1490 if (empty($filename)) {
1491 $filename = basename(parse_url($url, PHP_URL_PATH));
1492 }
1493
1494 // If filename is still empty or file name ne match the expected pattern (module_modulename-version.zip), log error and return false
1495 if (empty($filename) || !preg_match('/^module_[a-z0-9_]+-[0-9]+\.[0-9]+\.[0-9]+\.zip$/i', $filename)) {
1496 dol_syslog(__METHOD__ . ': Cannot determine filename from URL: ' . $url, LOG_ERR);
1497 return false;
1498 }
1499
1500 // Download the file
1501 $response = getURLContent($url, 'GET');
1502 if (empty($response['content']) || (isset($response['http_code']) && $response['http_code'] !== 200)) {
1503 dol_syslog(
1504 __METHOD__ . ': Download failed — HTTP ' . ($response['http_code'] ?? 'unknown') . ' — ' . $url,
1505 LOG_WARNING
1506 );
1507 return false;
1508 }
1509
1510 // Write to destination
1511 $dest_file = $dest_path . '/' . $filename;
1512 if (file_exists(dol_osencode($dest_file))) { // If file already exists, try to delete it first
1513 chmod(dol_osencode($dest_file), 0755);
1514 @unlink(dol_osencode($dest_file));
1515 }
1516 $writtenfile = file_put_contents(dol_osencode($dest_file), $response['content']);
1517 if ($writtenfile === false || $writtenfile === 0) {
1518 dol_syslog(__METHOD__ . ': Cannot write file: ' . $dest_file, LOG_ERR);
1519 @unlink(dol_osencode($dest_file));
1520 return false;
1521 }
1522
1523 dol_syslog(__METHOD__ . ': Downloaded successfully to: ' . $dest_file, LOG_DEBUG);
1524 return $dest_file;
1525 }
1526}
$id
Support class for third parties, contacts, members, users or resources.
Definition account.php:47
$propal type
'integer', 'integer:ObjectClass:PathToClass[:AddCreateButtonOrNot[:Filter[:Sortfield]]]',...
Definition propal.php:280
Class ExternalModules.
__construct($debug=false)
Constructor.
libStatus($status, $mode=3, $moretext='')
Retrieve the status icon.
checkApiStatus()
Check if an Dolistore API is up.
getProducts($options, $modules=array())
Generate HTML for products.
versionCompare($v1, $v2)
version compare
getRemoteYamlFile($file_source_url, $cache_time)
Get YAML file from remote source and put it into the cache file.
buildSorter(string $key)
Sort an array by a key.
getModuleZIP($producttoinstall=array())
Download a Dolibarr module from a Git repository URL or Dolistore download URL.
getCategories($active=0)
Generate HTML for categories and their children.
applyFilters($list, $options)
Apply filters to the data.
fetchModulesFromFile($options=array())
Fetch modules from a cache YAML file.
_downloadFile(string $url, string $dest_path)
Download a remote URL to a local file using getURLContent (native Dolibarr).
readYaml($yaml)
Read a YAML string and convert it to an array.
callApi($resource, $options=false)
Test if we can access to remote Dolistore market place.
adaptData($data, $source)
Adapter data fetched from github remote source to the expected format.
loadRemoteSources($debug=false)
loadRemoteSources
print $langs trans("Ref").' m titre as m m statut as status
Or an array listing all the potential status of the object: array: int of the status => translated la...
Definition index.php:169
dol_stringtotime($string, $gm=1, $processnotimeasnoon=0)
Convert a string date into a GM Timestamps date Warning: YYYY-MM-DDTHH:MM:SS+02:00 (RFC3339) is not s...
Definition date.lib.php:442
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
dol_is_file($pathoffile)
Return if path is a file.
dol_now($mode='gmt')
Return date for now.
dol_osencode($str)
Return a string encoded into OS filesystem encoding.
dol_string_nohtmltag($stringtoclean, $removelinefeed=1, $pagecodeto='UTF-8', $strip_tags=0, $removedoublespaces=1)
Clean a string from all HTML tags and entities.
price2num($amount, $rounding='', $option=0)
Function that return a number with universal decimal format (decimal separator is '.
getDolOptimizeSmallScreen()
Return if render must be optimized for small screen.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
dol_escape_js($stringtoescape, $mode=0, $noescapebackslashn=0)
Returns text escaped for inclusion into JavaScript code.
newToken()
Return the value of token currently saved into session with name 'newtoken'.
dol_print_date($time, $format='', $tzoutput='auto', $outputlangs=null, $encodetooutput=false, $decorate=0)
Output date in a string format according to outputlangs (or langs if not defined).
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
dol_mkdir($dir, $dataroot='', $newmask='')
Creation of a directory (this can create recursive subdir)
getURLContent($url, $postorget='GET', $param='', $followlocation=1, $addheaders=array(), $allowedschemes=array('http', 'https'), $localurl=0, $ssl_verifypeer=-1, $timeoutconnect=0, $timeoutresponse=0, $otherCurlOptions=array(), $morelogsuffix='')
Function to get a content from an URL (use proxy if proxy defined).
multi select button
0 = Do not include form tag and submit button -1 = Do not include form tag but include submit button
dol_escape_htmltag($stringtoescape, $keepb=0, $keepn=0, $noescapetags='', $escapeonlyhtmltags=0, $cleanalsojavascript=0)
Definition html.lib.php:181
print $langs trans('Date')." left Ref Label right Qty right Price right TotalHT right TotalTTC right right right right right right right right right centpercent right TotalHT right n right VAT right n right TotalVAT right n No sujeto a RE IRPF right TotalLT1 right n right TotalLT2 right n right TotalTTC right n takeposcustomercurrency takeposcustomercurrency takeposcustomercurrency takeposcustomercurrency right TotalTTC takeposcustomercurrency right takeposcustomercurrency n right Paid right PaymentTypeShortLIQ right SELECT p pos_change as p datep as date
Definition receipt.php:492
$conf db name
Only used if Module[ID]Name translation string is not found.
Definition repair.php:140