remove project options & add embed data
This commit is contained in:
parent
52a3a1e978
commit
6e07d97dba
5 changed files with 78 additions and 1430 deletions
|
@ -42,8 +42,6 @@ class TwingleProject {
|
|||
|
||||
private $values;
|
||||
|
||||
private $options;
|
||||
|
||||
|
||||
/**
|
||||
* TwingleProject constructor.
|
||||
|
@ -63,12 +61,6 @@ class TwingleProject {
|
|||
// Fetch custom field mapping once
|
||||
self::init();
|
||||
|
||||
// Create TwingleProjectOptions object
|
||||
$this->options = new TwingleProjectOptions($project['options'], $origin);
|
||||
|
||||
// Unset project options in $project array
|
||||
unset($project['options']);
|
||||
|
||||
// If values come from CiviCRM Campaign API
|
||||
if ($origin == self::CIVICRM) {
|
||||
|
||||
|
@ -172,28 +164,11 @@ class TwingleProject {
|
|||
self::IN
|
||||
);
|
||||
|
||||
// Prepare project option values for import into database
|
||||
$options_prepared_for_import = $this->options->getValues();
|
||||
TwingleProjectOptions::formatValues(
|
||||
$options_prepared_for_import,
|
||||
self::IN
|
||||
);
|
||||
self::translateCustomFields(
|
||||
$options_prepared_for_import,
|
||||
self::IN
|
||||
);
|
||||
|
||||
// Merge project values and project options values
|
||||
$merged = array_merge(
|
||||
$values_prepared_for_import,
|
||||
$options_prepared_for_import
|
||||
);
|
||||
|
||||
// Set id
|
||||
$merged['id'] = $this->id;
|
||||
$values_prepared_for_import['id'] = $this->id;
|
||||
|
||||
// Create campaign
|
||||
$result = civicrm_api3('Campaign', 'create', $merged);
|
||||
$result = civicrm_api3('Campaign', 'create', $values_prepared_for_import);
|
||||
|
||||
// Update id
|
||||
$this->id = $result['id'];
|
||||
|
@ -226,12 +201,6 @@ class TwingleProject {
|
|||
*/
|
||||
public function update(array $values) {
|
||||
|
||||
// Update project options
|
||||
$this->options->update($values['options']);
|
||||
|
||||
// Unset options array in project values
|
||||
unset($values['options']);
|
||||
|
||||
// Update project values
|
||||
$this->values = array_merge($this->values, $values);
|
||||
}
|
||||
|
@ -250,7 +219,6 @@ class TwingleProject {
|
|||
|
||||
$values = $this->values;
|
||||
self::formatValues($values, self::OUT);
|
||||
self::translateKeys($values, self::OUT);
|
||||
|
||||
// Get template for project
|
||||
$project = self::$templates['project'];
|
||||
|
@ -265,37 +233,6 @@ class TwingleProject {
|
|||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export options. Ensures that only those values will be exported which the
|
||||
* Twingle API expects. Missing values will get complemented with default
|
||||
* values.
|
||||
*
|
||||
* @return array
|
||||
* Array with all options to send to the Twingle API
|
||||
*
|
||||
* @throws Exception
|
||||
*
|
||||
*/
|
||||
public function exportOptions() {
|
||||
|
||||
$options = $this->values['options'];
|
||||
self::formatValues($options, self::OUT);
|
||||
self::translateKeys($options, self::OUT);
|
||||
|
||||
// Get Template for project options
|
||||
$project_options_template = self::$templates['project_options'];
|
||||
|
||||
// Replace array items which the Twingle API does not expect
|
||||
foreach ($options as $key => $value) {
|
||||
if (!key_exists($key, $project_options_template)) {
|
||||
unset($options[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Complement missing options with default values
|
||||
return array_merge($project_options_template, $options);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a TwingleProject campaign already exists and if so set attributes
|
||||
|
@ -349,9 +286,6 @@ class TwingleProject {
|
|||
// Translate keys from CiviCRM format to Twingle format
|
||||
self::translateKeys($values, self::OUT);
|
||||
|
||||
// Separate options from project values
|
||||
self::separateOptions($values);
|
||||
|
||||
// Set attributes to the values of the existing TwingleProject campaign
|
||||
// to reflect the state of the actual campaign in the database
|
||||
$this->update($values);
|
||||
|
@ -379,9 +313,6 @@ class TwingleProject {
|
|||
'id' => $id,
|
||||
]);
|
||||
|
||||
// Separate options from project values
|
||||
self::separateOptions($result['values']);
|
||||
|
||||
return new TwingleProject(
|
||||
$result['values'],
|
||||
self::CIVICRM
|
||||
|
@ -484,14 +415,17 @@ class TwingleProject {
|
|||
elseif ($direction == self::OUT) {
|
||||
|
||||
// Change DateTime string into timestamp
|
||||
$values['last_modified_date'] =
|
||||
self::getTimestamp($values['last_modified_date']);
|
||||
$values['last_update'] =
|
||||
self::getTimestamp($values['last_update']);
|
||||
|
||||
// default project_type to ''
|
||||
// Default project_type to ''
|
||||
$values['type'] = $values['type'] == 'default'
|
||||
? ''
|
||||
: $values['type'];
|
||||
|
||||
// Cast project target to integer
|
||||
$values['project_target'] = (int) $values['project_target'];
|
||||
|
||||
}
|
||||
else {
|
||||
|
||||
|
@ -568,31 +502,32 @@ class TwingleProject {
|
|||
}
|
||||
|
||||
/**
|
||||
* A function that picks all option values from the values array and puts them
|
||||
* into an own array.
|
||||
* Set embed data fields
|
||||
*
|
||||
* @param array $values
|
||||
* @param array $embedData
|
||||
* Array with embed data from Twingle API
|
||||
*/
|
||||
private static function separateOptions(array &$values) {
|
||||
public function setEmbedData(array $embedData) {
|
||||
|
||||
$options = [];
|
||||
// Get all embed_data keys from template
|
||||
$embed_data_keys = self::$templates['project_embed_data'];
|
||||
|
||||
// Get array with template for project values and options
|
||||
$options_template = self::$templates['project_options'];
|
||||
|
||||
// Map array items into $values and $options array
|
||||
foreach ($values as $key => $value) {
|
||||
if (key_exists($key, $options_template)) {
|
||||
$options[$key] = $value;
|
||||
unset($values[$key]);
|
||||
// Transfer all embed_data values
|
||||
foreach ($embed_data_keys as $key) {
|
||||
$this->values[$key] = htmlspecialchars($embedData[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert options array into values array
|
||||
$values['options'] = $options;
|
||||
/**
|
||||
* Set counter url
|
||||
*
|
||||
* @param String $counterUrl
|
||||
* URL of the counter
|
||||
*/
|
||||
public function setCounterUrl(string $counterUrl) {
|
||||
$this->values['counter'] = $counterUrl;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deactivate this TwingleProject campaign
|
||||
*
|
||||
|
@ -657,6 +592,7 @@ class TwingleProject {
|
|||
'title' => $this->values['name'],
|
||||
'id' => (int) $this->id,
|
||||
'project_id' => (int) $this->values['id'],
|
||||
'project_type' => $this->values['type'],
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
@ -730,12 +666,8 @@ class TwingleProject {
|
|||
* @return int|null
|
||||
*/
|
||||
public function lastUpdate() {
|
||||
$lastProjectUpdate = self::getTimestamp($this->values['last_update']);
|
||||
$lastOptionsUpdate = self::getTimestamp($this->options->lastUpdate());
|
||||
$lastUpdate = $lastProjectUpdate > $lastOptionsUpdate
|
||||
? $lastProjectUpdate
|
||||
: $lastOptionsUpdate;
|
||||
return self::getTimestamp($lastUpdate);
|
||||
|
||||
return self::getTimestamp($this->values['last_update']);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,243 +0,0 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace CRM\TwingleCampaign\BAO;
|
||||
|
||||
use CRM_TwingleCampaign_ExtensionUtil as E;
|
||||
use CRM_Utils_Array;
|
||||
use Exception;
|
||||
|
||||
include_once E::path() . '/CRM/TwingleCampaign/BAO/CustomField.php';
|
||||
|
||||
|
||||
class TwingleProjectOptions {
|
||||
|
||||
private $values;
|
||||
|
||||
|
||||
/**
|
||||
* TwingleProjectOptions constructor.
|
||||
*
|
||||
* @param array $options
|
||||
* Result array of Twingle API call to
|
||||
* https://project.twingle.de/api/by-organisation/$project_id/options
|
||||
*
|
||||
* @param string $origin
|
||||
* Origin of the arrays. It can be one of two constants:
|
||||
* TwingleProject::TWINGLE|CIVICRM
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct(array $options, string $origin) {
|
||||
|
||||
// If values come from CiviCRM Campaign API
|
||||
if ($origin == TwingleProject::CIVICRM) {
|
||||
|
||||
// Translate custom field names into Twingle field names
|
||||
TwingleProject::translateCustomFields($options, TwingleProject::OUT);
|
||||
|
||||
// Format values
|
||||
self::formatValues($options, TwingleProject::OUT);
|
||||
|
||||
}
|
||||
|
||||
// Unset project options id
|
||||
unset($options['id']);
|
||||
|
||||
// Set project values attribute
|
||||
$this->values = $options;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update an this object
|
||||
*
|
||||
* @param array $options
|
||||
* Array with values to update
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(array $options) {
|
||||
|
||||
// Update values
|
||||
$this->values = array_merge($this->values, $options);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Export values. Ensures that only those values will be exported which the
|
||||
* Twingle API expects.
|
||||
*
|
||||
* @return array
|
||||
* Array with all values to send to the Twingle API
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function export() {
|
||||
|
||||
$values = $this->values;
|
||||
self::formatValues($values, TwingleProject::OUT);
|
||||
TwingleProject::translateKeys($values, TwingleProject::OUT);
|
||||
|
||||
// Get Template for project options
|
||||
$project_options_template = TwingleProject::$templates['project_options'];
|
||||
|
||||
// Replace array items which the Twingle API does not expect
|
||||
foreach ($values as $key => $value) {
|
||||
if (!key_exists($key, $project_options_template)) {
|
||||
unset($values[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Format project target format
|
||||
if (key_exists('has_projecttarget_as_money', $values)) {
|
||||
$values['has_projecttarget_as_money'] =
|
||||
$values['has_projecttarget_as_money'] ? 'in Euro' : 'percentage';
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate values between CiviCRM Campaigns and Twingle
|
||||
*
|
||||
* @param array $values
|
||||
* array of which values shall be translated
|
||||
*
|
||||
* @param string $direction
|
||||
* TwingleProject::IN -> translate array values from Twingle to CiviCRM <br>
|
||||
* TwingleProject::OUT -> translate array values from CiviCRM to Twingle
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function formatValues(array &$values, string $direction) {
|
||||
|
||||
if ($direction == TwingleProject::IN) {
|
||||
|
||||
// Change timestamp into DateTime string
|
||||
if ($values['last_update']) {
|
||||
$values['last_update'] =
|
||||
TwingleProject::getDateTime($values['last_update']);
|
||||
}
|
||||
|
||||
// format donation rhythm
|
||||
if (is_array($values['donation_rhythm'])) {
|
||||
$tmp = [];
|
||||
foreach ($values['donation_rhythm'] as $key => $value) {
|
||||
if ($value) {
|
||||
$tmp[$key] = $key;
|
||||
}
|
||||
}
|
||||
$values['donation_rhythm'] = CRM_Utils_Array::implodePadded($tmp);
|
||||
}
|
||||
|
||||
// Format contact fields
|
||||
if ($values['exclude_contact_fields']) {
|
||||
$possible_contact_fields =
|
||||
TwingleProject::$campaigns['custom_fields']
|
||||
['twingle_project_exclude_contact_fields']['option_values'];
|
||||
|
||||
$exclude_contact_fields = explode(
|
||||
',',
|
||||
$values['exclude_contact_fields']
|
||||
);
|
||||
|
||||
foreach ($exclude_contact_fields as $exclude_contact_field) {
|
||||
unset($possible_contact_fields[$exclude_contact_field]);
|
||||
}
|
||||
|
||||
$values['exclude_contact_fields'] =
|
||||
CRM_Utils_Array::implodePadded($possible_contact_fields);
|
||||
}
|
||||
|
||||
// Format languages
|
||||
if ($values['languages']) {
|
||||
$values['languages'] =
|
||||
CRM_Utils_Array::implodePadded(
|
||||
explode(
|
||||
',',
|
||||
$values['languages']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Format project target format
|
||||
if (key_exists('has_projecttarget_as_money', $values)) {
|
||||
$values['has_projecttarget_as_money'] =
|
||||
$values['has_projecttarget_as_money'] ? 'in Euro' : 'percentage';
|
||||
}
|
||||
}
|
||||
|
||||
elseif ($direction == TwingleProject::OUT) {
|
||||
|
||||
// Change DateTime string into timestamp
|
||||
$values['last_update'] =
|
||||
TwingleProject::getTimestamp($values['last_update']);
|
||||
|
||||
// format donation rhythm
|
||||
if (is_array($values['donation_rhythm'])) {
|
||||
$tmp = [];
|
||||
foreach ($values['donation_rhythm'] as $key => $value) {
|
||||
if ($value) {
|
||||
$tmp[$key] = $key;
|
||||
}
|
||||
}
|
||||
$values['donation_rhythm'] = CRM_Utils_Array::implodePadded($tmp);
|
||||
}
|
||||
|
||||
// Format contact fields
|
||||
if ($values['exclude_contact_fields']) {
|
||||
$possible_contact_fields =
|
||||
TwingleProject::$campaigns['custom_fields']
|
||||
['twingle_project_exclude_contact_fields']['option_values'];
|
||||
|
||||
$exclude_contact_fields = explode(
|
||||
',',
|
||||
$values['exclude_contact_fields']
|
||||
);
|
||||
|
||||
foreach ($exclude_contact_fields as $exclude_contact_field) {
|
||||
unset($possible_contact_fields[$exclude_contact_field]);
|
||||
}
|
||||
|
||||
$values['exclude_contact_fields'] =
|
||||
CRM_Utils_Array::implodePadded($possible_contact_fields);
|
||||
}
|
||||
|
||||
// Format languages
|
||||
if ($values['languages']) {
|
||||
$values['languages'] =
|
||||
CRM_Utils_Array::implodePadded(
|
||||
explode(
|
||||
',',
|
||||
$values['languages']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Cast project_target to integer
|
||||
$values['project_target'] = (int) $values['project_target'];
|
||||
|
||||
}
|
||||
else {
|
||||
|
||||
throw new Exception(
|
||||
"Invalid Parameter $direction for formatValues()"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getValues(): array {
|
||||
return $this->values;
|
||||
}
|
||||
|
||||
public function lastUpdate() {
|
||||
return $this->values['last_update'];
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -10,77 +10,14 @@
|
|||
"type",
|
||||
"last_update"
|
||||
],
|
||||
"event": [
|
||||
"project_embed_data": [
|
||||
"widget",
|
||||
"form",
|
||||
"form-single",
|
||||
"widget-single",
|
||||
"eventall",
|
||||
"eventlist"
|
||||
],
|
||||
"project_options": {
|
||||
"has_confirmation_mail": false,
|
||||
"has_confirmation_mail_api": false,
|
||||
"has_donation_receipt": true,
|
||||
"has_contact_data": false,
|
||||
"donation_rhythm": {
|
||||
"yearly": true,
|
||||
"halfyearly": false,
|
||||
"quarterly": false,
|
||||
"monthly": true,
|
||||
"one_time": true
|
||||
},
|
||||
"has_newsletter_registration": false,
|
||||
"has_postinfo_registration": false,
|
||||
"design_background_color": "",
|
||||
"design_primary_color": "",
|
||||
"design_font": "",
|
||||
"design_font_color": "",
|
||||
"design_button_font_color": "",
|
||||
"design_button_font_color_light": "",
|
||||
"image": "",
|
||||
"bcc_email_address": "",
|
||||
"donation_value_min": 5,
|
||||
"donation_value_max": 500,
|
||||
"donation_value_default": 50,
|
||||
"exclude_contact_fields": "birthday",
|
||||
"custom_js": null,
|
||||
"custom_css": null,
|
||||
"share_url": null,
|
||||
"has_contact_mandatory": false,
|
||||
"has_doi": true,
|
||||
"doi_redirect": null,
|
||||
"has_force_donation_target_buttons": false,
|
||||
"has_show_single_target": false,
|
||||
"betterpayment_credit_card_theme": null,
|
||||
"app_js": null,
|
||||
"slidericon": "heart",
|
||||
"extra_field": [],
|
||||
"has_hidden_logo": false,
|
||||
"has_projecttarget_as_money": false,
|
||||
"rapidmail_recipient_list": null,
|
||||
"mailchimp_recipient_list": null,
|
||||
"has_mailchimp_add_all_user": null,
|
||||
"has_mail_hide_paymentblock": false,
|
||||
"has_mail_hide_paymentblock_api": false,
|
||||
"has_donationtarget_textfield": false,
|
||||
"has_civi_crm_activated": false,
|
||||
"has_step_index": false,
|
||||
"gift_donation": "",
|
||||
"gift_donation_icon": null,
|
||||
"gift_donation_image": null,
|
||||
"languages": "de",
|
||||
"has_buttons": false,
|
||||
"has_no_slider": false,
|
||||
"buttons": {
|
||||
"button1": {
|
||||
"amount": ""
|
||||
},
|
||||
"button2": {
|
||||
"amount": ""
|
||||
},
|
||||
"button3": {
|
||||
"amount": ""
|
||||
}
|
||||
},
|
||||
"has_newsletter_namerequest": false,
|
||||
"has_show_donator_data": false,
|
||||
"has_donation_letter": false,
|
||||
"has_donation_letter_api": false,
|
||||
"amount_images": []
|
||||
}
|
||||
"event": [
|
||||
]
|
||||
}
|
|
@ -74,18 +74,6 @@ class TwingleApiCall {
|
|||
return $response;
|
||||
}
|
||||
|
||||
|
||||
public function getProjectOptions(int $projectId) {
|
||||
$response = [];
|
||||
foreach ($this->organisationId as $organisationId) {
|
||||
$url = $this->protocol . 'project' . $this->baseUrl . $projectId .
|
||||
'/options';
|
||||
|
||||
$response = array_merge($this->curlGet($url));
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Returns all Events for the given $projectId
|
||||
|
@ -138,25 +126,6 @@ class TwingleApiCall {
|
|||
// If $values is an array
|
||||
if (is_array($values)) {
|
||||
|
||||
// Get project options
|
||||
try {
|
||||
$values['options'] = $this->getProjectOptions($values['id']);
|
||||
} catch (Exception $e) {
|
||||
|
||||
// Log Exception
|
||||
Civi::log()->error(
|
||||
"Failed to instantiate TwingleProject: $e->getMessage()"
|
||||
);
|
||||
|
||||
// Return result array with error description
|
||||
return [
|
||||
"title" => $values['name'],
|
||||
"project_id" => (int) $values['id'],
|
||||
"status" =>
|
||||
"Failed to get project options from Twingle: $e->getMessage()",
|
||||
];
|
||||
}
|
||||
|
||||
// Instantiate TwingleProject
|
||||
try {
|
||||
$project = new TwingleProject(
|
||||
|
@ -182,8 +151,9 @@ class TwingleApiCall {
|
|||
// Check if the TwingleProject campaign already exists
|
||||
if (!$project->exists()) {
|
||||
|
||||
// ... if not, create it
|
||||
// ... if not, get embed data and create project
|
||||
try {
|
||||
$this->getEmbedData($project);
|
||||
$result = $project->create($is_test);
|
||||
} catch (Exception $e) {
|
||||
|
||||
|
@ -206,12 +176,10 @@ class TwingleApiCall {
|
|||
|
||||
// If Twingle's version of the project is newer than the CiviCRM
|
||||
// TwingleProject campaign update the campaign
|
||||
$lastUpdate = $values['last_update'] > $values['options']['last_update']
|
||||
? $values['last_update']
|
||||
: $values['options']['last_update'];
|
||||
if ($lastUpdate > $project->lastUpdate()) {
|
||||
if ($values['last_update'] > $project->lastUpdate()) {
|
||||
try {
|
||||
$project->update($values);
|
||||
$this->getEmbedData($project);
|
||||
$result = $project->create();
|
||||
$result['status'] = $result['status'] == 'TwingleProject created'
|
||||
? 'TwingleProject updated'
|
||||
|
@ -229,7 +197,7 @@ class TwingleApiCall {
|
|||
}
|
||||
// If the CiviCRM TwingleProject campaign was changed, update the project
|
||||
// on Twingle's side
|
||||
elseif ($lastUpdate < $project->lastUpdate()) {
|
||||
elseif ($values['last_update'] < $project->lastUpdate()) {
|
||||
// If this is a test do not make database changes
|
||||
if ($is_test) {
|
||||
$result = $project->getResponse(
|
||||
|
@ -243,10 +211,8 @@ class TwingleApiCall {
|
|||
elseif ($result['status'] == 'TwingleProject exists') {
|
||||
$result = $project->getResponse('TwingleProject up to date');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Return a response of the synchronization
|
||||
return $result;
|
||||
}
|
||||
|
@ -256,6 +222,25 @@ class TwingleApiCall {
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \CRM\TwingleCampaign\BAO\TwingleProject $project
|
||||
*/
|
||||
private function getEmbedData(TwingleProject &$project) {
|
||||
|
||||
// Prepare url for curl
|
||||
$url = $this->protocol . 'project' . $this->baseUrl . $project->getProjectId();
|
||||
|
||||
// Send curl
|
||||
$result = $this->curlGet($url);
|
||||
|
||||
// Set embed data
|
||||
$project->setEmbedData($result['embed']);
|
||||
|
||||
// Set counter-url
|
||||
$project->setCounterUrl($result['counter-url']['url']);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an curl post call to Twingle to update an existing project and then
|
||||
* updates the TwingleProject campaign.
|
||||
|
@ -267,7 +252,7 @@ class TwingleApiCall {
|
|||
* Returns a response array that contains title, id, project_id and status
|
||||
*
|
||||
*/
|
||||
public function updateProject(TwingleProject &$project) {
|
||||
private function updateProject(TwingleProject &$project) {
|
||||
|
||||
try {
|
||||
$values = $project->export();
|
||||
|
@ -292,7 +277,8 @@ class TwingleApiCall {
|
|||
if (is_array($result) && !array_key_exists('message', $result)) {
|
||||
// Try to update the local TwingleProject campaign
|
||||
try {
|
||||
$project->update($result, TwingleProject::TWINGLE);
|
||||
$project->update($result);
|
||||
$this->getEmbedData($project);
|
||||
$project->create();
|
||||
return $project->getResponse('TwingleProject pushed to Twingle');
|
||||
} catch (Exception $e) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue