file = str_replace('\\', '/', __FILE__);
}
/**
* Helper method for the ConfigStore object to get the version number from the
* comment at the top of this file, as is the official location for WordPress
* plugins.
*
* @return string
* The version number of this plugin.
*/
public function getVersionNumber(): string
{
return get_plugins()['acumulus/acumulus.php']['Version'];
}
/**
* Set up the hooks for this plugin.
*/
public function registerHooks(): void
{
// activate/deactivate actions: may be undefined in test situations.
if (function_exists('register_activation_hook')) {
register_activation_hook($this->file, [$this, 'activate']);
register_deactivation_hook($this->file, [$this, 'deactivate']);
}
// Actions:
// - Add our forms to the admin menu.
add_action('admin_menu', [$this, 'addMenuLinks'], 900);
// - Admin notices, meta-boxes, and ajax requests from them.
add_action('admin_notices', [$this, 'showAdminNotices']);
// Hook "add_meta_boxes_woocommerce_page_wc-orders" is triggered on the HPOS screen.
add_action('add_meta_boxes_woocommerce_page_wc-orders', [$this, 'addShopOrderMetaBox'], 10, 1);
// Hook "add_meta_boxes_shop_order" is triggered on the legacy screen (/wp-admin/edit.php?post_type=shop_order).
add_action('add_meta_boxes_shop_order', [$this, 'addShopOrderMetaBox'], 10, 1);
add_action('wp_ajax_acumulus_ajax_action', [$this, 'handleAjaxRequest']);
add_filter('woocommerce_admin_order_actions', [$this, 'adminOrderActions'], 100, 2);
// - WooCommerce order/refund events.
add_action('woocommerce_new_order', [$this, 'woocommerceOrderChanged'], 10, 2);
add_action('woocommerce_order_status_changed', [$this, 'woocommerceOrderChanged'], 10, 4);
add_action('woocommerce_order_refunded', [$this, 'woocommerceOrderRefunded'], 10, 2);
// - WooCommerce stock events.
/* These are events based on the sales side. Events on the purchase side - either
* a manual or automated direct update of the stock - are not handled (for now).
* Those would be:
* - wc_update_product_stock() or
* WC_Product_Data_Store_Interface::handle_updated_props() (via public create()
* or update()):
* - woocommerce_variation_before_set_stock' or 'woocommerce_product_before_set_stock'
* -'woocommerce_variation_set_stock' or 'woocommerce_product_set_stock'
* - WC_Product_Data_Store_Interface::update_product_stock():
* - 'woocommerce_updated_product_stock', called between the actions above when
* called via wc_update_product_stock() but not when called via create() or
* update(), so is a lesser option.
*/
add_action('woocommerce_reduce_order_item_stock', [$this, 'woocommerceReduceStock'], 10, 3);
add_action('woocommerce_restore_order_item_stock', [$this, 'woocommerceIncreaseStock'], 10, 4);
// - Our own invoice-related events.
add_filter('acumulus_line_collect_before', [$this, 'acumulusLineCollectBefore'], 10, 2);
add_filter('acumulus_line_collect_after', [$this, 'acumulusLineCollectAfter'], 10, 2);
add_filter('acumulus_invoice_collect_After', [$this, 'acumulusInvoiceCollectAfter'], 10, 3);
// WooCommerce HPOS compatibility. Declare compatibility.
add_action('before_woocommerce_init', static function () {
if (class_exists(FeaturesUtil::class)) {
FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
}
});
}
/**
* Returns whether the current page being rendered is the dashboard.
*
* @return bool
*/
private function isDashboard(): bool
{
$screen = get_current_screen();
return $screen && $screen->id === 'dashboard';
}
/**
* Returns whether the current page being rendered is one of our own pages.
*
* @return bool
*/
private function isOwnPage(): bool
{
$screenIds = [
'settings_page_acumulus_settings',
'settings_page_acumulus_mappings',
'settings_page_acumulus_config',
'settings_page_acumulus_advanced',
'woocommerce_page_acumulus_batch',
];
$screen = get_current_screen();
return $screen && in_array($screen->id, $screenIds);
}
/**
* Returns whether HPOS is used or the legacy order as post-storage.
*/
public function useHpos(): bool
{
/** @noinspection PhpUnhandledExceptionInspection */
return class_exists(CustomOrdersTableController::class)
&& wc_get_container()->get(CustomOrdersTableController::class)->custom_orders_table_usage_is_enabled();
}
/**
* Returns the page that shows the list of orders.
*/
public function getOrderListPage(): string
{
return $this->useHpos() ? 'admin.php?page=wc-orders' : 'edit.php?post_type=shop_order';
}
/**
* Helper method to translate strings.
*
* @param string $key
* The key to get a translation for.
*
* @return string
* The translation for the given key or the key itself if no translation
* could be found.
*/
private function t(string $key): string
{
return $this->getAcumulusContainer()->getTranslator()->get($key);
}
/**
* Loads our library and creates the Container.
*
* This method gets called by {@see \Acumulus::getAcumulusContainer()} and most of the
* time that is the right moment. However, in some actions, class constants from our
* library are used before the container is retrieved, and in those cases, that action
* method should call init() itself.
*/
private function init(): void
{
if (!isset($this->acumulusContainer)) {
// Get access to our library.
require_once __DIR__ . '/vendor/autoload.php';
// Get language.
$languageCode = get_bloginfo('language');
if (empty($languageCode)) {
$languageCode = 'nl';
}
$languageCode = substr($languageCode, 0, 2);
$shopNamespace = 'WooCommerce';
$this->acumulusContainer = new Container($shopNamespace, $languageCode);
// Start with a high log level, will be corrected when the config is
// loaded.
$this->getAcumulusContainer()->getLog()->setLogLevel(Severity::Log);
// Check for any updates to perform.
$this->upgrade();
}
}
/**
* Returns an Acumulus container.
*
* Perhaps, more importantly - especially for 3rd parties that want to use
* features from libAcumulus - the 1st time it is called, it ensures that
* libAcumulus autoloading is defined and that a Container with the correct
* settings is created.
*
* So, for 3rd parties, this is the correct way to access the libAcumulus:
* Acumulus::create()->getAcumulusContainer()
*
* @return \Siel\Acumulus\Helpers\Container
*/
public function getAcumulusContainer(): Container
{
$this->init();
return $this->acumulusContainer;
}
/**
* Adds our pages to the admin menu.
*
* We load the translations for each form to be able to show the translated
* form titles and headers.
*/
public function addMenuLinks(): void
{
// Initialise translations.
$this->getAcumulusContainer()->getTranslator()->add(new ConfigFormTranslations());
add_submenu_page(
'options-general.php',
$this->t('settings_form_title'),
$this->t('settings_form_header'),
'manage_options',
'acumulus_settings',
[$this, 'processSettingsForm']
);
add_submenu_page(
'options-general.php',
$this->t('mappings_form_title'),
$this->t('mappings_form_header'),
'manage_options',
'acumulus_mappings',
[$this, 'processMappingsForm']
);
$this->getAcumulusContainer()->getTranslator()->add(new ActivateSupportFormTranslations());
add_submenu_page(
'options-general.php',
$this->t('activate_form_title'),
$this->t('activate_form_header'),
'manage_options',
'acumulus_activate',
[$this, 'processActivateForm']
);
// Do not show the registration form if we have valid account settings. In WP you
// do so by using another page as the parent.
$accountStatus = $this->getAcumulusContainer()->getCheckAccount()->getAccountStatus();
$parent = $accountStatus === true ? 'acumulus_activate' : 'options-general.php';
$this->getAcumulusContainer()->getTranslator()->add(new RegisterFormTranslations());
add_submenu_page(
$parent,
$this->t('register_form_title'),
$this->t('register_form_header'),
'manage_options',
'acumulus_register',
[$this, 'processRegisterForm']
);
$this->getAcumulusContainer()->getTranslator()->add(new BatchFormTranslations());
add_submenu_page(
'woocommerce',
$this->t('batch_form_title'),
$this->t('batch_form_header'),
'manage_woocommerce',
'acumulus_batch',
[$this, 'processBatchForm']
);
}
/**
* Shows admin notices.
*
* Due to the order of execution and the habit of redirecting at the end of
* an action, just adding a notice may not work. Therefore, we work with
* transients.
*
* @throws \Throwable
*/
public function showAdminNotices(): void
{
// These notices should only show on the main dashboard and our own screens.
if ($this->isDashboard() || $this->isOwnPage()) {
// Notice about the new version.
if (time() >= $this->getAcumulusContainer()->getConfig()->get('showPluginV84Message')) {
echo $this->processMessageForm();
}
// Notice about rating our plugin.
if (time() >= $this->getAcumulusContainer()->getConfig()->getShowRatePluginMessage()) {
echo $this->processRatePluginForm();
}
}
}
/**
* Handles ajax requests for this plugin.
*
* Uses of ajax:
* - Invoice status overview.
* - Rate plugin message.
* - plugin new version message.
* - Mail invoice/packing slip from the order list. This use is not a real ajax
* request but uses admin-ajax.php as the entry point like WooCommerce does:
* no need to define a proper admin page and finish with a redirect back to
* the order list.
*
* @throws \Throwable
*/
public function handleAjaxRequest(): void
{
check_ajax_referer('acumulus_ajax_action', 'acumulus_nonce');
// Check where the ajax call came from.
if (isset($_POST['area'])) {
$content = match ($_POST['area']) {
'acumulus-invoice' => $this->processInvoiceStatusForm(),
'acumulus-rate' => $this->processRatePluginForm(),
'acumulus-message' => $this->processMessageForm(),
default => $this->renderNotice('Area parameter of ajax request unknown to Acumulus.', 'error'),
};
} elseif (isset($_REQUEST['acumulus_action'])) {
// @nth: should we have some visual feedback here, in case of both
// error and success. Given the redirect at the end, this needs
// transient flags or messages. See e.g:
// - https://wordpress.stackexchange.com/questions/261167/getting-admin-notices-to-appear-after-page-refresh
// - https://github.com/wpscholar/wp-transient-admin-notices
switch ($_REQUEST['acumulus_action']) {
case 'acumulus-document-invoice-mail':
case 'acumulus-document-packing-slip-mail':
$result = $this->mailPdf($_REQUEST['acumulus_action'], $_REQUEST['order_id']);
/** @noinspection PhpUnusedLocalVariableInspection @todo: do something with this content. */
$content = !$result->hasError() ? '✓' : '❌';
break;
default:
/** @noinspection PhpUnusedLocalVariableInspection */
$content = $this->renderNotice('Acumulus_action parameter of ajax request unknown to Acumulus.', 'error');
}
wp_safe_redirect(wp_get_referer() ?: admin_url($this->getOrderListPage()));
exit;
} else {
$content = $this->renderNotice('No recognised Acumulus ajax request.', 'error');
}
wp_send_json(['content' => $content]);
}
/**
* Mails an Acumulus invoice or packing slip PDF.
*
* @param string $acumulusAction
* Either 'acumulus-invoice' or 'acumulus-packing-slip' (already checked
* for).
* @param int $order_id
* The order id. The parameter type conversion will throw an error if
* someone played with the order_id parameter.
*
* @return \Siel\Acumulus\ApiClient\AcumulusResult
*/
public function mailPdf(string $acumulusAction, int $order_id): AcumulusResult
{
$source = $this->getAcumulusContainer()->createSource(Source::Order, $order_id);
return $acumulusAction === 'acumulus-document-invoice-mail'
? $this->getAcumulusContainer()->getInvoiceManager()->emailInvoiceAsPdf($source)
: $this->getAcumulusContainer()->getInvoiceManager()->emailPackingSlipAsPdf($source);
}
/**
* Action handler for the add_meta_boxes_{order-type-or-screen-id} actions.
*
* @param \WP_Post|\WC_Order $shopOrderOrPost
* This will be a:
* - WP_POST: on the legacy screen.
* - WC_Order: on the HPOS screen.
*
* @throws \Throwable
*/
public function addShopOrderMetaBox(WP_Post|WC_Abstract_Order $shopOrderOrPost): void
{
$orderId = ($shopOrderOrPost instanceof WP_Post) ? $shopOrderOrPost->ID : $shopOrderOrPost->get_id();
$invoiceStatusSettings = $this->getAcumulusContainer()->getConfig()->getInvoiceStatusSettings();
if ($invoiceStatusSettings['showInvoiceStatus']) {
// Create the form to load the form translations and set its Source.
/** @var \Siel\Acumulus\Shop\InvoiceStatusForm $form */
try {
$form = $this->getForm('invoice');
$source = $this->getAcumulusContainer()->createSource(Source::Order, $orderId);
$form->setSource($source);
add_meta_box(
'acumulus-invoice-status-overview',
$this->t('invoice_form_title'),
[$this, 'outputInvoiceStatusInfoBox'],
get_current_screen()?->id,
'side'
);
} catch (Throwable $e) {
// We do not show the meta-box, not even a message (though we
// could add an action for admin_notices), the mail should
// suffice to inform the user.
try {
$crashReporter = $this->getAcumulusContainer()->getCrashReporter();
$crashReporter->logAndMail($e);
} catch (Throwable) {
// We don't know if we have informed the user per mail or
// screen, so assume we didn't, and rethrow the original exception.
throw $e;
}
}
}
}
/**
* Callback that renders the contents of the Acumulus invoice info box.
*
* @\param WP_Post $shopOrderPost
* The post for the current order.
*
* @throws \Throwable
*/
public function outputInvoiceStatusInfoBox(/*WP_Post $shopOrderPost*/): void
{
echo $this->processInvoiceStatusForm();
}
/**
* Callback that renders the Acumulus actions on the order list page.
*
* @param array $actions
* @param \WC_Order $order
*
* @return array
* The $actions array with the (enabled) Acumulus actions added to it.
*/
public function adminOrderActions(array $actions, WC_Order $order): array
{
$documentsSettings = $this->getAcumulusContainer()->getConfig()->getDocumentsSettings();
if ($documentsSettings['showInvoiceList']
|| $documentsSettings['mailInvoiceList']
|| $documentsSettings['showPackingSlipList']
|| $documentsSettings['mailPackingSlipList']
) {
$source = $this->getAcumulusContainer()->createSource(Source::Order, $order);
$acumulusEntry = $this->getAcumulusContainer()->getAcumulusEntryManager()->getByInvoiceSource($source);
if ($acumulusEntry !== null) {
$token = $acumulusEntry->getToken();
// If sent as concept, token will be null.
if ($token !== null) {
$subActions = [];
$ajaxAction = 'acumulus_ajax_action';
$document = ucfirst($this->t('document_invoice'));
if ($documentsSettings['showInvoiceList']) {
$action = 'acumulus-document-invoice-show';
$uri = $this->getAcumulusContainer()->getAcumulusApiClient()->getInvoicePdfUri($token);
$subActions[$action] = [
'url' => $uri,
'action' => $action,
'name' => sprintf($this->t('document_show'), $document),
'title' => sprintf($this->t('document_show_title'), $document),
];
}
if ($documentsSettings['mailInvoiceList']) {
$action = 'acumulus-document-invoice-mail';
$uri = sprintf('admin-ajax.php?action=%s&acumulus_action=%s&order_id=%d', $ajaxAction, $action, $order->get_id());
$subActions[$action] = [
'url' => wp_nonce_url(admin_url($uri), $ajaxAction, 'acumulus_nonce'),
'action' => $action,
'name' => sprintf($this->t('document_mail'), $document),
'title' => sprintf($this->t('document_mail_title'), $document),
];
}
$document = ucfirst($this->t('document_packing_slip'));
if ($documentsSettings['showPackingSlipList']) {
$action = 'acumulus-document-packing-slip-show';
$uri = $this->getAcumulusContainer()->getAcumulusApiClient()->getPackingSlipPdfUri($token);
$subActions[$action] = [
'url' => $uri,
'action' => $action,
'name' => sprintf($this->t('document_show'), $document),
'title' => sprintf($this->t('document_show_title'), $document),
];
}
if ($documentsSettings['mailPackingSlipList']) {
$action = 'acumulus-document-packing-slip-mail';
$uri = sprintf('admin-ajax.php?action=%s&acumulus_action=%s&order_id=%d', $ajaxAction, $action, $order->get_id());
$subActions[$action] = [
'url' => wp_nonce_url(admin_url($uri), $ajaxAction, 'acumulus_nonce'),
'action' => $action,
'name' => sprintf($this->t('document_mail'), $document),
'title' => sprintf($this->t('document_mail_title'), $document),
];
}
// Add the actions and our own CSS.
$actions += $subActions;
$pluginUrl = plugins_url('/acumulus');
wp_enqueue_style('acumulus_css_admin', $pluginUrl . '/acumulus.css');
}
}
}
return $actions;
}
/**
* Getter for the form object.
*
* @param string $type
*
* @return \Siel\Acumulus\Helpers\Form
*/
private function getForm(string $type): Form
{
return $this->getAcumulusContainer()->getForm($type);
}
/**
* Implements the admin_post_acumulus_register action.
*
* Processes and renders the batch form.
*
* @throws \Throwable
*/
public function processRegisterForm(): void
{
$this->checkCapability('manage_options');
$this->checkCapability('manage_woocommerce');
echo $this->processForm('register');
}
/**
* Implements the admin_post_acumulus_settings action.
*
* Processes and renders the settings form.
*
* @throws \Throwable
*/
public function processSettingsForm(): void
{
$this->checkCapability('manage_options');
$this->checkCapability('manage_woocommerce');
echo $this->processForm('settings');
}
/**
* Implements the admin_post_acumulus_mappings action.
*
* Processes and renders the 'mappings' form.
*
* @throws \Throwable
*/
public function processMappingsForm(): void
{
$this->checkCapability('manage_options');
$this->checkCapability('manage_woocommerce');
echo $this->processForm('mappings');
}
/**
* Implements the admin_post_acumulus_activate action.
*
* Processes and renders the "activate pro-support" form.
*
* @throws \Throwable
*/
public function processActivateForm(): void
{
$this->checkCapability('manage_options');
$this->checkCapability('manage_woocommerce');
echo $this->processForm('activate');
}
/**
* Implements the admin_post_acumulus_batch action.
*
* Processes and renders the batch form.
*
* @throws \Throwable
*/
public function processBatchForm(): void
{
$this->checkCapability('manage_woocommerce');
echo $this->processForm('batch');
}
/**
* Processes and renders the Acumulus invoice status overview form.
*
* Either called via:
* - Callback that renders the contents of the Acumulus invoice info box.
* - Ajax request handler.
*
* @return string
* The rendered form (embedded in any necessary HTML).
*
* @throws \Throwable
*/
public function processInvoiceStatusForm(): string
{
return $this->processForm('invoice');
}
/**
* Processes and renders the Rate Acumulus plugin form.
*
* Either called via:
* - Render admin notice.
* - Ajax request handler.
*
* @return string
* The rendered form (embedded in any necessary HTML).
*
* @throws \Throwable
*/
public function processRatePluginForm(): string
{
return $this->processForm('rate');
}
/**
* Processes and renders the Acumulus message form.
*
* Called via:
* - Render admin notice.
*
* @return string
* The rendered form (embedded in any necessary HTML).
*
* @throws \Throwable
*/
public function processMessageForm(): string
{
return $this->processForm('message');
}
/**
* Processes and renders the form of the given type.
*
* @param string $type
* The form type: 'settings', 'mappings', 'batch' or one of the other forms.
*
* @return string
* the form HTML to output.
*
* @throws \Throwable
*/
protected function processForm(string $type): string
{
$form = $this->getForm($type);
try {
$this->preProcessForm($form);
$form->process();
$this->preRenderForm($form);
// Render the form first before wrapping it in its final format so that any
// messages added during rendering can be shown on top.
$formOutput = $this->getAcumulusContainer()->getFormRenderer()->render($form);
} catch (Throwable $e) {
// We handle our "own" exceptions but only when we can process them
// as we want, i.e. show it as an error at the beginning of the
// form. That's why we start catching only after we have a form and
// stop catching just before postRenderForm().
try {
$crashReporter = $this->getAcumulusContainer()->getCrashReporter();
$message = $crashReporter->logAndMail($e);
$form->createAndAddMessage($message, Severity::Exception);
} catch (Throwable) {
// We don't know if we have informed the user per mail or
// screen, so assume we didn't and rethrow the original exception.
throw $e;
}
}
$output = $this->postRenderForm($form, $formOutput ?? 'ERROR');
$this->postProcessForm($form);
return $output;
}
/**
* Performs form type-specific actions before processing a form.
*
* @param \Siel\Acumulus\Helpers\Form $form
* The form that is going to be processed.
*/
private function preProcessForm(Form $form): void
{
$type = $form->getType();
// Check nonce.
if (!wp_doing_ajax() && $form->isSubmitted()) {
check_admin_referer("acumulus_{$type}_nonce");
}
// Form processing may depend on determining the payment status, but the
// default states as returned by wc_get_is_paid_statuses() are not how
// we would define "is paid".
if (in_array($type, ['batch', 'invoice'])) {
// We use WC_Order::is_paid()
add_action('woocommerce_order_is_paid_statuses', [$this, 'woocommerceOrderIsPaidStatuses'], 10, 2);
}
}
/**
* Performs form type-specific actions after a form has been processed.
*
* @param \Siel\Acumulus\Helpers\Form $form
* The form that has been processed.
*/
private function postProcessForm(Form $form): void
{
$type = $form->getType();
// Remove our actions that redefine "is paid".
if (in_array($type, ['batch', 'invoice'])) {
remove_action('woocommerce_order_is_paid_statuses', [$this, 'woocommerceOrderIsPaidStatuses']);
}
}
/**
* Performs form type-specific actions before rendering a form.
*
* @param \Siel\Acumulus\Helpers\Form $form
* The form that is going to be rendered.
*/
private function preRenderForm(Form $form): void
{
// Get a new FormRenderer as the rate plugin message may be shown inside our
// pages, and that one has different settings.
$formRenderer = $this->getAcumulusContainer()->getFormRenderer(true);
// Add our own js.
$type = $form->getType();
$pluginUrl = plugins_url('/acumulus');
switch ($type) {
case 'invoice':
// Add some js.
wp_enqueue_script('jquery-ui-datepicker');
wp_enqueue_script('acumulus-ajax.js', $pluginUrl . '/' . 'acumulus-ajax.js');
// The invoice status overview is not rendered as other forms.
// Therefore, we change some properties of the form renderer.
$formRenderer
->setProperty('usePopupDescription', true)
->setProperty('fieldsetContentWrapperClass', 'data')
->setProperty('detailsWrapperClass', '')
->setProperty('labelWrapperClass', 'label')
->setProperty('inputDescriptionWrapperClass', 'value')
->setProperty('markupWrapperTag', '');
break;
case 'rate':
case 'message':
// Add some js.
wp_enqueue_script('acumulus-ajax.js', $pluginUrl . '/' . 'acumulus-ajax.js');
// The rate plugin message is not rendered as other forms.
// Therefore, we change some properties of the form renderer.
$formRenderer
->setProperty('fieldsetContentWrapperTag', 'div')
->setProperty('fieldsetContentWrapperClass', '')
->setProperty('elementWrapperTag', '')
->setProperty('inputDescriptionWrapperTag', '')
->setProperty('renderEmptyLabel', false)
->setProperty('markupWrapperTag', '');
break;
}
// Add our own CSS.
wp_enqueue_style('acumulus_css_admin', $pluginUrl . '/acumulus.css');
}
/**
* Performs form type-specific actions after a form has been rendered.
*
* @param \Siel\Acumulus\Helpers\Form $form
* The form that has been rendered.
* @param string $formOutput
* The HTML of the rendered form.
*
* @return string
* The rendered form with any wrapping around it.
*/
private function postRenderForm(Form $form, string $formOutput): string
{
$output = '';
$type = $form->getType();
$id = "acumulus-$type";
$wait = $this->t('wait');
$nonce = wp_create_nonce('acumulus_ajax_action');
$url = admin_url("admin.php?page=acumulus_$type");
$output .= $this->showNotices($form);
switch ($type) {
case 'register':
case 'settings':
case 'mappings':
case 'activate':
case 'batch':
case 'invoice':
$wrap = $form->isFullPage();
if ($wrap) {
$output .= '
. * * @return string * The rendered notice. */ private function renderNotice(string $message, string $type, string $id = '', array $extraAttributes = [], bool $isHtml = false): string { $for = ''; if (!empty($id) && func_num_args() === 3) { // Form field message (because: 3 arguments (I know: this sucks)): // make it a clickable label. $for = $id; $id = ''; } if (!empty($id)) { $id = ' id="' . $id . '"'; } $class = ''; if (!empty($extraAttributes['class'])) { $class = ' ' . $extraAttributes['class']; unset($extraAttributes['class']); } $extraAttributesString = ''; foreach ($extraAttributes as $attribute => $value) { $extraAttributesString .= " $attribute='$value'"; } $result = "
'; } if ($for) { $result .= "'; } if (!$isHtml) { $result .= '
'; } $result .= '