plugin_name = $plugin_name;
$this->version = $version;
}
/**
* Check whether the current admin screen belongs to Linkmora.
*
* @since 1.0.0
*
* @param string $hook_suffix Optional admin page hook suffix.
* @return bool
*/
private function linkmora_is_admin_page( $hook_suffix = '' ) {
if ( isset( $_GET['page'] ) && is_scalar( $_GET['page'] ) ) {
$page = sanitize_key( wp_unslash( (string) $_GET['page'] ) );
return in_array( $page, array( 'linkmora-links', 'linkmora-add-link', 'linkmora-settings' ), true );
}
return strpos( (string) $hook_suffix, 'linkmora' ) !== false;
}
/**
* Enqueue admin styles for Linkmora screens.
*
* @since 1.0.0
*
* @param string $hook_suffix Optional admin page hook suffix.
*/
public function enqueue_styles( $hook_suffix = '' ) {
if ( ! $this->linkmora_is_admin_page( $hook_suffix ) ) {
return;
}
wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/linkmora-admin.css', array(), $this->version, 'all' );
wp_style_add_data( $this->plugin_name, 'rtl', true );
wp_enqueue_style( 'linkmora-modern', plugin_dir_url( __FILE__ ) . 'css/linkmora-modern.css', array( $this->plugin_name ), $this->version, 'all' );
if ( $this->use_jalali_admin_dates() ) {
wp_enqueue_style( 'persian-date-picker', plugin_dir_url( __FILE__ ) . 'css/persianDatepicker-default.css', array(), $this->version, 'all' );
}
}
/**
* Register the JavaScript for the admin area.
*
* @since 1.0.0
*
* @param string $hook_suffix Optional admin page hook suffix.
*/
public function enqueue_scripts( $hook_suffix = '' ) {
if ( ! $this->linkmora_is_admin_page( $hook_suffix ) ) {
return;
}
wp_enqueue_script( 'linkmora-admin', plugin_dir_url( __FILE__ ) . 'js/linkmora-admin.js', array( 'jquery', 'jquery-ui-sortable' ), $this->version, false );
if ( $this->use_jalali_admin_dates() ) {
wp_enqueue_script( 'persian-date-picker', plugin_dir_url( __FILE__ ) . 'js/persian-datepicker.min.js', array( 'jquery' ), $this->version, false );
}
$this_translate_array = array(
'deleteConfirmMsg' => __( 'Are you sure you want to delete this link?', 'linkmora' ),
'bulkDeleteConfirmMsg' => __( 'Are you sure you want to delete the selected links?', 'linkmora' ),
'copySuccess' => __( 'Shortcode copied.', 'linkmora' ),
'copyError' => __( 'Could not copy the shortcode.', 'linkmora' ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'tablePageUrl' => admin_url( 'admin.php' ),
'reorderNonce' => wp_create_nonce( 'linkmora_reorder' ),
'tableNonce' => wp_create_nonce( 'linkmora_table' ),
'tableError' => __( 'The links table could not be updated. Please try again.', 'linkmora' ),
'isLinksPage' => isset( $_GET['page'] ) && is_scalar( $_GET['page'] ) && sanitize_key( wp_unslash( (string) $_GET['page'] ) ) === 'linkmora-links',
'useJalaliDates' => $this->use_jalali_admin_dates(),
);
wp_localize_script( 'linkmora-admin', 'linkmoraJsTranslate', $this_translate_array );
/**
* Fires after Core admin assets are enqueued on a Linkmora screen.
*
* @since 2.5.0
*
* @param string $hook_suffix Current admin page hook suffix.
* @param string $version Linkmora Core version.
*/
do_action( 'linkmora_admin_assets', $hook_suffix, $this->version );
}
/**
* Register the Linkmora admin menu and submenu pages.
*
* @since 1.0.0
*/
public function add_admin_menu() {
add_menu_page(
__( 'Linkmora', 'linkmora' ),
__( 'Linkmora', 'linkmora' ),
'manage_options',
'linkmora-links',
array( $this, 'linkmora_table_callback' ),
'dashicons-admin-links'
);
$links_hook = add_submenu_page(
'linkmora-links',
__( 'Links', 'linkmora' ),
__( 'Links', 'linkmora' ),
'manage_options',
'linkmora-links',
array( $this, 'linkmora_table_callback' ),
);
add_action(
"load-$links_hook",
function () {
add_screen_option(
'per_page',
array(
'label' => __( 'Links per page', 'linkmora' ),
'default' => 25,
'option' => 'linkmora_per_page',
)
);
}
);
add_submenu_page(
'linkmora-links',
__( 'Add Link', 'linkmora' ),
__( 'Add Link', 'linkmora' ),
'manage_options',
'linkmora-add-link',
array( $this, 'linkmora_add_link_callback' ),
);
add_submenu_page(
'linkmora-links',
__( 'Linkmora Settings', 'linkmora' ),
__( 'Settings', 'linkmora' ),
'manage_options',
'linkmora-settings',
array( $this, 'linkmora_settings_callback' ),
);
}
/**
* Shared header (icon, title, tab navigation) rendered at the top of
* every Linkmora admin page, for a consistent look.
*
* @since 1.9.0
*
* @param string $active Active tab key.
*/
private function render_admin_page_header( $active ) {
$tabs = array(
'links' => array(
'label' => __( 'Links', 'linkmora' ),
'icon' => 'admin-links',
'url' => admin_url( 'admin.php?page=linkmora-links' ),
),
'add-link' => array(
'label' => __( 'Add Link', 'linkmora' ),
'icon' => 'plus-alt2',
'url' => admin_url( 'admin.php?page=linkmora-add-link' ),
),
'settings' => array(
'label' => __( 'Settings', 'linkmora' ),
'icon' => 'admin-generic',
'url' => admin_url( 'admin.php?page=linkmora-settings' ),
),
);
?>
' . $label . '';
}
return '' . $label . ' ';
}
/**
* Render a disabled setting row that advertises a Pro-only capability.
*
* @since 2.6.1
*
* @param string $label The setting label.
* @param string $description The setting description.
* @param string $capability Optional capability key.
*/
private function render_locked_setting_row( $label, $description, $capability = '' ) {
?>
use_jalali_admin_dates() ? linkmora_to_persian_digits( $formatted ) : $formatted;
}
/**
* Normalize and validate a submitted link URL.
*
* @since 2.6.1
*
* @param mixed $raw_url Raw URL value to normalize.
* @return string Normalized URL or empty string if invalid.
*/
private function normalize_link_url( $raw_url ) {
if ( ! is_string( $raw_url ) && ! is_numeric( $raw_url ) ) {
return '';
}
$url = trim( (string) $raw_url );
if ( '' === $url ) {
return '';
}
if ( strpos( $url, '//' ) === 0 ) {
$url = 'https:' . $url;
} elseif ( ! preg_match( '/^[a-z][a-z0-9+.-]*:\/\//i', $url ) ) {
$url = 'https://' . $url;
}
$scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
return '';
}
$url = esc_url_raw( $url, array( 'http', 'https' ) );
$host = wp_parse_url( $url, PHP_URL_HOST );
return empty( $host ) ? '' : $url;
}
/**
* Normalize a submitted link date into a usable ISO date string.
*
* @since 2.0.0
*
* @param mixed $raw_date Submitted date value to normalize.
* @return string Normalized date in Y-m-d format or empty string if invalid.
*/
private function normalize_submitted_link_date( $raw_date ) {
if ( ! is_string( $raw_date ) && ! is_numeric( $raw_date ) ) {
return '';
}
$raw_date = $this->normalize_date_digits( trim( sanitize_text_field( (string) $raw_date ) ) );
if ( '' === $raw_date ) {
return '';
}
if ( $this->use_jalali_admin_dates() ) {
$parts = preg_split( '/[\/\-]/', $raw_date );
if ( count( $parts ) === 3 && (int) $parts[0] < 1700 ) {
return $this->normalize_jalali_date( $parts );
}
$normalized_gregorian = $this->normalize_ymd_date( str_replace( '/', '-', $raw_date ) );
if ( '' !== $normalized_gregorian ) {
return $normalized_gregorian;
}
return '';
}
$normalized_gregorian = $this->normalize_ymd_date( str_replace( '/', '-', $raw_date ) );
return '' !== $normalized_gregorian ? $normalized_gregorian : '';
}
/**
* Normalize a Jalali date to ISO Y-m-d format.
*
* @since 2.0.0
*
* @param array $parts Jalali date parts as [year, month, day].
* @return string Normalized date in Y-m-d format or empty string if invalid.
*/
private function normalize_jalali_date( $parts ) {
if ( count( $parts ) !== 3 ) {
return '';
}
$jalali_year = (int) $parts[0];
$jalali_month = (int) $parts[1];
$jalali_day = (int) $parts[2];
if ( ! $this->is_valid_jalali_date( $jalali_year, $jalali_month, $jalali_day ) ) {
return '';
}
$parts = \Linkmora\Vendor\Jalaali\Jalaali::toGregorian( $jalali_year, $jalali_month, $jalali_day );
$gregorian = sprintf( '%04d-%02d-%02d', $parts['gy'], $parts['gm'], $parts['gd'] );
return $this->normalize_ymd_date( $gregorian );
}
/**
* Normalize Persian and Arabic digits to ASCII digits.
*
* @since 2.0.0
*
* @param string $date Date string containing localized digits.
* @return string Date string with Persian/Arabic digits converted to ASCII digits.
*/
private function normalize_date_digits( $date ) {
return strtr(
$date,
array(
'۰' => '0',
'۱' => '1',
'۲' => '2',
'۳' => '3',
'۴' => '4',
'۵' => '5',
'۶' => '6',
'۷' => '7',
'۸' => '8',
'۹' => '9',
'٠' => '0',
'١' => '1',
'٢' => '2',
'٣' => '3',
'٤' => '4',
'٥' => '5',
'٦' => '6',
'٧' => '7',
'٨' => '8',
'٩' => '9',
)
);
}
/**
* Normalize a date string to ISO Y-m-d format.
*
* @since 2.0.0
*
* @param string $date Date string to normalize.
* @return string Normalized date in Y-m-d format or empty string if invalid.
*/
private function normalize_ymd_date( $date ) {
$parts = preg_split( '/[\/\-]/', (string) $date );
if ( count( $parts ) !== 3 ) {
return '';
}
$year = (int) $parts[0];
$month = (int) $parts[1];
$day = (int) $parts[2];
if ( ! checkdate( $month, $day, $year ) ) {
return '';
}
return sprintf( '%04d-%02d-%02d', $year, $month, $day );
}
/**
* Validate whether a Jalali date is within the supported range.
*
* @since 2.0.0
*
* @param int $year The Jalali year.
* @param int $month The Jalali month.
* @param int $day The Jalali day.
* @return bool Whether the date is valid.
*/
private function is_valid_jalali_date( $year, $month, $day ) {
if ( $year < 1200 || $year > 1600 || $month < 1 || $month > 12 || $day < 1 ) {
return false;
}
return \Linkmora\Vendor\Jalaali\Jalaali::isValidJalaaliDate( $year, $month, $day );
}
/**
* Render the add/edit link admin form and populate data when editing an existing link.
*
* @since 2.0.0
*/
public function linkmora_add_link_callback() {
$form_action = 'insert';
if ( isset( $_GET['id'] ) && is_scalar( $_GET['id'] ) && ! empty( $_GET['id'] ) ) {
$link_id = absint( wp_unslash( $_GET['id'] ) );
global $wpdb;
$table_name = esc_sql( $wpdb->prefix . 'linkmora_links' );
// The table name uses the trusted WordPress prefix; the row ID is prepared.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current custom-table data with a trusted identifier and prepared value.
$link_data = $wpdb->get_row(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The trusted table identifier cannot be a prepared value; the row ID is prepared.
$wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $link_id )
);
$form_action = 'update';
}
$use_jalali_dates = $this->use_jalali_admin_dates();
$start_date_value = isset( $link_data ) ? $this->format_link_date_for_input( $link_data->start_date ) : '';
$end_date_value = isset( $link_data ) ? $this->format_link_date_for_input( $link_data->end_date ) : '';
$start_date_display_value = isset( $link_data ) ? $this->format_link_date_for_display( $link_data->start_date ) : '';
$end_date_display_value = isset( $link_data ) ? $this->format_link_date_for_display( $link_data->end_date ) : '';
$publish_date = isset( $link_data ) ? linkmora_format_admin_date( $link_data->start_date ) : __( 'Not published yet.', 'linkmora' );
$expiry_date = isset( $link_data ) ? linkmora_format_admin_date( $link_data->end_date ) : '-';
require 'partials/add-link-display.php';
}
/**
* Persist the "Links per page" Screen Option when saved.
*
* @since 2.1.0
*
* @param mixed $status The current status/value for the option.
* @param string $option The option name being updated.
* @param mixed $value The submitted value for the option.
* @return mixed
*/
public function save_screen_options( $status, $option, $value ) {
if ( 'linkmora_per_page' === $option ) {
return absint( $value );
}
return $status;
}
/**
* Render the settings page container.
*/
public function linkmora_settings_callback() {
require 'partials/settings-display.php';
}
/**
* Register the settings sections owned by Core.
*
* @since 2.5.0
*
* @param array> $sections Registered sections.
* @param string $tab_id Current tab ID.
* @return array>
*/
public function register_current_settings_sections( $sections, $tab_id ) {
if ( 'admin' === $tab_id ) {
$sections['general'] = array( 'label' => __( 'Admin Settings', 'linkmora' ) );
}
if ( 'appearance' === $tab_id && ! $this->is_pro_available( 'appearance' ) ) {
$sections['discover'] = array( 'label' => __( 'Appearance', 'linkmora' ) );
}
return $sections;
}
/**
* Render current settings fields through the public settings action.
*
* @since 2.5.0
*
* @param string $tab_id Current tab ID.
* @param string $section_id Current section ID.
* @param array $context Settings rendering context.
*/
public function render_current_settings_fields( $tab_id, $section_id, $context ) {
$request_key = $context['request_key'];
$options = $context['options'];
$linkmora_toggle_row = $context['linkmora_toggle_row'];
$linkmora_number_field = $context['linkmora_number_field'];
$linkmora_color_field = $context['linkmora_color_field'];
$linkmora_quad_field = $context['linkmora_quad_field'];
$trbl = $context['trbl'];
$trbl_labels = $context['trbl_labels'];
$corners = $context['corners'];
$corner_labels = $context['corner_labels'];
$strip_px = $context['strip_px'];
require 'partials/settings-fields.php';
}
/**
* AJAX handler: persist a drag-and-drop reorder of the links list.
*
* @since 2.1.0
*/
public function ajax_save_link_order() {
check_ajax_referer( 'linkmora_reorder', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error();
}
$posted = isset( $_POST ) && is_array( $_POST ) ? wp_unslash( $_POST ) : array();
if ( ! empty( $posted['s'] ) || ! empty( $posted['orderby'] ) || ( ! empty( $posted['status_filter'] ) && 'all' !== $posted['status_filter'] ) ) {
wp_send_json_error( array( 'message' => __( 'Clear filters before reordering backlinks.', 'linkmora' ) ), 400 );
}
$ordered_ids = array();
if ( isset( $posted['order'] ) && is_array( $posted['order'] ) ) {
foreach ( $posted['order'] as $raw_id ) {
if ( is_scalar( $raw_id ) && absint( $raw_id ) > 0 ) {
$ordered_ids[] = absint( $raw_id );
}
}
}
if ( empty( $ordered_ids ) ) {
wp_send_json_error();
}
$list_table = new Linkmora_Table();
$per_page = $list_table->get_current_per_page();
$paged = isset( $posted['paged'] ) && is_scalar( $posted['paged'] ) ? max( 1, absint( $posted['paged'] ) ) : 1;
$start_position = ( $paged - 1 ) * $per_page;
$changed = $list_table->save_dragged_order( $ordered_ids, $start_position );
if ( $changed && function_exists( 'linkmora_clear_rocket_cache' ) ) {
linkmora_clear_rocket_cache();
}
wp_send_json_success();
}
/**
* AJAX handler: render only the prepared Links table component.
*
* The normal page callback and this endpoint share the same renderer and
* Linkmora_Table query path so PHP remains the source of truth.
*
* @since 2.5.0
*/
public function ajax_render_links_table() {
check_ajax_referer( 'linkmora_table', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
array( 'message' => __( 'You are not allowed to manage backlinks.', 'linkmora' ) ),
403
);
}
$list_table = new Linkmora_Table();
$list_table->prepare_items();
ob_start();
$this->render_links_table_component( $list_table );
$html = ob_get_clean();
wp_send_json_success( array( 'html' => $html ) );
}
/**
* Save handler for the plugin's own Settings page.
*
* @since 1.9.0
*/
public function linkmora_save_settings() {
if ( ! isset( $_POST['linkmora_settings_submit'] ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
check_admin_referer( 'linkmora_settings_nonce', 'linkmora_settings_nonce' );
$request_key = 'linkmora_settings';
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The unslashed input is immediately passed through known or explicitly registered per-key sanitizers.
$submitted = isset( $_POST[ $request_key ] ) && is_array( $_POST[ $request_key ] ) ? wp_unslash( $_POST[ $request_key ] ) : array();
$posted = linkmora_sanitize_submitted_settings( $submitted );
if ( is_wp_error( $posted ) ) {
$this->redirect_after_invalid_settings();
}
$switches = array(
'linkmora_enable_bulk_delete',
'linkmora_enable_detailed_status',
'linkmora_enable_status_date',
'linkmora_enable_expiry_notice',
'linkmora_delete_data_on_uninstall',
'linkmora_open_links_new_tab',
);
$data = array();
foreach ( $switches as $key ) {
$data[ $key ] = ! empty( $posted[ $key ] );
}
/**
* Filter sanitized settings before they are routed to Core or Pro storage.
*
* @since 2.5.0
* @since 2.8.1 The second argument contains sanitized known settings and
* explicitly registered extension settings, not raw request data.
* Extensions that previously sanitized raw values from this
* argument should register those keys with
* linkmora_setting_sanitizers instead. Submitted or returned
* extension settings without a valid sanitizer reject the save.
*
* @param array $data Sanitized settings.
* @param array $posted Sanitized submitted settings.
* @param array $existing Existing effective settings.
*/
$data = apply_filters( 'linkmora_sanitize_settings', $data, $posted, linkmora_get_settings() );
$data = linkmora_validate_filtered_settings( $data );
if ( is_wp_error( $data ) ) {
$this->redirect_after_invalid_settings();
}
$core_changes = array();
$pro_changes = array();
foreach ( $data as $key => $value ) {
if ( linkmora_get_setting_scope( $key, $value ) === 'pro' ) {
$pro_changes[ $key ] = $value;
} else {
$core_changes[ $key ] = $value;
}
}
if ( $core_changes ) {
linkmora_update_core_options( $core_changes );
}
if ( $pro_changes ) {
linkmora_update_pro_options( $pro_changes );
}
if ( function_exists( 'linkmora_schedule_cron_jobs' ) ) {
linkmora_schedule_cron_jobs();
}
$active_tab = isset( $_POST['linkmora_settings_active_tab'] ) && is_scalar( $_POST['linkmora_settings_active_tab'] ) ? sanitize_key( wp_unslash( $_POST['linkmora_settings_active_tab'] ) ) : '';
$redirect = admin_url( 'admin.php?page=linkmora-settings¬ice=saved' );
if ( $active_tab ) {
$redirect .= '#' . rawurlencode( $active_tab );
}
wp_safe_redirect( $redirect );
exit();
}
/**
* Reject a settings save when an extension setting cannot be validated.
*
* @since 2.8.1
*/
private function redirect_after_invalid_settings() {
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-settings¬ice=invalid' ) );
exit();
}
/**
* Handle GET/POST row actions (edit redirect, delete, reorder) for the
* links list page.
*
* IMPORTANT: this must run on 'admin_init' (before WP prints any admin
* HTML), not from inside the page render callback. Running it from the
* render callback meant wp_redirect() (used for the Edit action) was
* called after admin-header.php had already sent output, so the
* redirect silently failed and the user was left on a blank page.
*
* @since 1.1.0
*/
public function handle_link_list_actions() {
$get = isset( $_GET ) && is_array( $_GET ) ? wp_unslash( $_GET ) : array();
$post = isset( $_POST ) && is_array( $_POST ) ? wp_unslash( $_POST ) : array();
if ( ! is_admin() || ! isset( $get['page'] ) || ! is_scalar( $get['page'] ) || sanitize_key( $get['page'] ) !== 'linkmora-links' ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
if ( isset( $get['linkmora_export_csv'] ) ) {
$this->export_links_csv();
}
if ( isset( $post['linkmora_import_submit'] ) ) {
$this->import_links_csv();
}
// Edit: just a redirect, not a state change, so no nonce is required.
if ( isset( $get['action'], $get['link_id'] ) && 'edit' === $get['action'] && is_scalar( $get['link_id'] ) ) {
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-add-link&id=' . absint( $get['link_id'] ) ) );
exit();
}
$list_table = new Linkmora_Table();
if ( isset( $get['action'], $get['link_id'] ) && 'delete' === $get['action'] && is_scalar( $get['link_id'] ) ) {
check_admin_referer( 'linkmora_link_action' );
$link_id = absint( $get['link_id'] );
$list_table->delete_link( $link_id );
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links¬ice=deleted' ) );
exit();
}
if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'post' === sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) && isset( $post['bulk-item-selection'] ) && is_array( $post['bulk-item-selection'] ) ) {
$bulk_action = $list_table->current_action();
if ( 'bulk-delete' === $bulk_action && linkmora_feature_enabled( 'linkmora_enable_bulk_delete' ) ) {
check_admin_referer( 'bulk-links' );
foreach ( $post['bulk-item-selection'] as $raw_id ) {
if ( is_scalar( $raw_id ) ) {
$list_table->delete_link( absint( $raw_id ) );
}
}
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links¬ice=deleted' ) );
exit();
}
}
}
/**
* Export all (non-history) links as a downloadable CSV file.
*
* @since 2.4.0
*/
public function export_links_csv() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(
esc_html__( 'You do not have permission to export backlinks.', 'linkmora' ),
'',
array( 'response' => 403 )
);
}
check_admin_referer( 'linkmora_export_csv' );
global $wpdb;
$table_name = esc_sql( $wpdb->prefix . 'linkmora_links' );
// CSV export needs current custom-table rows; the table name uses the trusted WordPress prefix.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- No request values are interpolated and no stale cache is appropriate for a download.
$links = $wpdb->get_results( "SELECT * FROM $table_name WHERE status <> 'history' ORDER BY link_order ASC", ARRAY_A );
$links = is_array( $links ) ? $links : array();
$columns = array(
'title' => 'title',
'link_url' => 'link_url',
'follow' => 'follow',
'display_option' => 'display_option',
'start_date' => 'start_date',
'end_date' => 'end_date',
'status' => 'status',
);
/**
* Filter columns included in a backlinks CSV export.
*
* @since 2.5.0
*
* @param array $columns Database keys mapped to CSV headers.
*/
$filtered_columns = apply_filters( 'linkmora_csv_export_columns', $columns );
if ( is_array( $filtered_columns ) ) {
$valid_columns = array();
foreach ( $filtered_columns as $key => $label ) {
if ( ( ! is_string( $key ) && ! is_int( $key ) ) || ! is_scalar( $label ) ) {
continue;
}
$key = sanitize_key( (string) $key );
$label = sanitize_text_field( (string) $label );
if ( '' !== $key && '' !== $label ) {
$valid_columns[ $key ] = $label;
}
}
if ( $valid_columns ) {
$columns = $valid_columns;
}
}
// This is an HTTP response stream, not a filesystem path.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Opening php://output is required to stream the CSV response.
$output = fopen( 'php://output', 'w' );
if ( false === $output ) {
wp_die( esc_html__( 'The CSV export could not be started.', 'linkmora' ) );
}
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=linkmora-' . gmdate( 'Y-m-d' ) . '.csv' );
$write_failed = fputcsv( $output, array_map( 'linkmora_csv_escape_cell', array_values( $columns ) ), ',', '"', '' ) === false;
foreach ( $links as $link ) {
if ( $write_failed ) {
break;
}
if ( ! is_array( $link ) ) {
continue;
}
$row = array();
foreach ( $columns as $key => $header ) {
$row[ $key ] = isset( $link[ $key ] ) && is_scalar( $link[ $key ] ) ? $link[ $key ] : '';
}
/**
* Filter one prepared CSV export row.
*
* @since 2.5.0
*
* @param array $row Ordered export values keyed by field.
* @param array $link Full backlink row.
* @param array $columns Export column definitions.
*/
$row = apply_filters( 'linkmora_csv_export_row', $row, $link, $columns );
$row = is_array( $row ) ? $row : array();
$ordered_row = array();
foreach ( array_keys( $columns ) as $key ) {
$value = isset( $row[ $key ] ) && is_scalar( $row[ $key ] ) ? $row[ $key ] : '';
$ordered_row[] = linkmora_csv_escape_cell( $value );
}
$write_failed = fputcsv( $output, $ordered_row, ',', '"', '' ) === false;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the php://output HTTP response stream, not a filesystem file.
fclose( $output );
if ( $write_failed ) {
exit();
}
exit();
}
/**
* Import links from an uploaded CSV file. Expects the same columns
* export_links_csv() produces (title, link_url are required; the
* rest are optional and get sensible defaults).
*
* @since 2.4.0
*/
public function import_links_csv() {
check_admin_referer( 'linkmora_import_csv', 'linkmora_import_csv_nonce' );
$upload = array();
if ( isset( $_FILES['linkmora_import_file'] ) && is_array( $_FILES['linkmora_import_file'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Preserve the upload temp path; error code and is_uploaded_file() are checked before it is read.
$upload = $_FILES['linkmora_import_file'];
}
$tmp_name = isset( $upload['tmp_name'] ) && is_string( $upload['tmp_name'] ) ? $upload['tmp_name'] : '';
if (
! isset( $upload['error'] ) || UPLOAD_ERR_OK !== $upload['error'] ||
'' === $tmp_name || ! is_uploaded_file( $tmp_name )
) {
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=error' ) );
exit();
}
$filtered_max_file_size = apply_filters( 'linkmora_csv_import_max_size', 1024 * 1024 );
$max_file_size = is_scalar( $filtered_max_file_size ) && is_numeric( $filtered_max_file_size )
? max( 1, (int) $filtered_max_file_size )
: 1024 * 1024;
// The path is a verified PHP upload temporary file.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_filesize -- Read the byte size of the validated upload before CSV parsing.
$file_size = filesize( $tmp_name );
if ( false === $file_size || $file_size > $max_file_size ) {
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=file_too_large' ) );
exit();
}
// The path is an uploaded temporary file validated with is_uploaded_file().
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Stream access is required to parse the HTTP-uploaded CSV.
$handle = fopen( $tmp_name, 'r' );
if ( ! $handle ) {
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=error' ) );
exit();
}
$header = fgetcsv( $handle, 0, ',', '"', '' );
if ( ! $header ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the validated upload stream.
fclose( $handle );
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=error' ) );
exit();
}
$header = array_map( 'sanitize_key', array_map( 'trim', $header ) );
// Accept click_count from Phase 1 and Pro exports, but never import it:
// Core owns and preserves the count already stored for each backlink.
$allowed_headers = array( 'title', 'link_url', 'follow', 'display_option', 'start_date', 'end_date', 'status', 'click_count' );
/**
* Filter CSV headers accepted by the importer.
*
* @since 2.5.0
*
* @param string[] $allowed_headers Accepted lowercase header names.
*/
$filtered_headers = apply_filters( 'linkmora_csv_import_columns', $allowed_headers );
$allowed_headers = array();
if ( is_array( $filtered_headers ) ) {
foreach ( $filtered_headers as $allowed_header ) {
if ( is_string( $allowed_header ) ) {
$allowed_header = sanitize_key( $allowed_header );
if ( '' !== $allowed_header ) {
$allowed_headers[] = $allowed_header;
}
}
}
}
$required_headers = array( 'title', 'link_url' );
if (
! $allowed_headers ||
count( $header ) !== count( array_unique( $header ) ) ||
array_diff( $required_headers, $header ) ||
array_diff( $header, $allowed_headers )
) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the validated upload stream.
fclose( $handle );
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=invalid_header' ) );
exit();
}
global $wpdb;
$table_name = esc_sql( $wpdb->prefix . 'linkmora_links' );
$current_date = linkmora_current_date();
// The table identifier is built only from WordPress's configured prefix; no request data enters this query.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Import ordering uses a fresh aggregate over the custom table and a trusted identifier.
$max_order = absint( $wpdb->get_var( "SELECT COALESCE(MAX(link_order), 0) FROM $table_name WHERE status = 'active'" ) );
$imported = 0;
$skipped = 0;
$invalid = 0;
while ( ( $row = fgetcsv( $handle, 0, ',', '"', '' ) ) !== false ) {
if ( count( $row ) < count( $header ) ) {
++$skipped;
continue;
}
$data = array_combine( $header, array_map( 'linkmora_csv_unescape_cell', array_slice( $row, 0, count( $header ) ) ) );
/**
* Filter a raw CSV row before Core sanitization and persistence.
*
* @since 2.5.0
*
* @param array $data CSV row keyed by header.
* @param array $header Validated CSV header.
*/
$data = apply_filters( 'linkmora_csv_import_row', $data, $header );
if ( ! is_array( $data ) ) {
++$skipped;
continue;
}
$title = isset( $data['title'] ) && is_scalar( $data['title'] ) ? sanitize_text_field( (string) $data['title'] ) : '';
$link_url_value = isset( $data['link_url'] ) && is_scalar( $data['link_url'] ) ? trim( (string) $data['link_url'] ) : '';
if ( '' === $title || '' === $link_url_value ) {
++$skipped;
continue;
}
$link_url = $this->normalize_link_url( $link_url_value );
if ( empty( $link_url ) ) {
++$invalid;
continue;
}
// Match the existing table column capacities before inserting, so
// permissive database modes cannot silently truncate imported values.
$title_length = mb_strlen( $title, 'UTF-8' );
$link_url_length = mb_strlen( $link_url, 'UTF-8' );
if ( $title_length > 255 || $link_url_length > 512 ) {
++$invalid;
continue;
}
$follow = isset( $data['follow'] ) && is_scalar( $data['follow'] ) ? sanitize_key( (string) $data['follow'] ) : 'follow';
if ( ! array_key_exists( $follow, linkmora_get_allowed_link_types() ) ) {
++$invalid;
continue;
}
$display_option = isset( $data['display_option'] ) && is_scalar( $data['display_option'] ) ? sanitize_text_field( (string) $data['display_option'] ) : 'all';
if ( ! in_array( $display_option, array( 'all', 'home', 'inner' ), true ) ) {
$display_option = 'all';
}
$start_date = ! empty( $data['start_date'] ) ? $this->normalize_submitted_link_date( $data['start_date'] ) : $current_date;
$end_date = ! empty( $data['end_date'] ) ? $this->normalize_submitted_link_date( $data['end_date'] ) : linkmora_relative_date( '+30 days' );
if ( '' === $start_date || '' === $end_date || strtotime( $end_date ) <= strtotime( $start_date ) ) {
++$invalid;
continue;
}
$status = ( strtotime( $current_date ) >= strtotime( $start_date ) ) && ( strtotime( $current_date ) < strtotime( $end_date ) ) ? 'active' : 'inactive';
$link_order = null;
if ( 'active' === $status ) {
++$max_order;
$link_order = $max_order;
}
$inserted = $wpdb->insert(
$table_name,
array(
'title' => $title,
'link_url' => $link_url,
'follow' => $follow,
'display_option' => $display_option,
'link_order' => $link_order,
'start_date' => $start_date,
'end_date' => $end_date,
'status' => $status,
'status_changed_at' => $current_date,
)
);
if ( false !== $inserted ) {
$insert_id = intval( $wpdb->insert_id );
++$imported;
$imported_data = array_merge(
$data,
compact( 'title', 'link_url', 'follow', 'display_option', 'start_date', 'end_date', 'status', 'link_order' )
);
/** This action is documented in add_backlinks(). */
do_action( 'linkmora_after_backlink_created', $insert_id, $imported_data, 'csv_import' );
/**
* Fires after Core imports and persists one CSV row.
*
* @since 2.5.0
*
* @param int $insert_id New backlink ID.
* @param array $imported_data Sanitized and raw imported values.
*/
do_action( 'linkmora_after_csv_import_row', $insert_id, $imported_data );
} else {
++$invalid;
}
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the validated upload stream.
fclose( $handle );
if ( $imported > 0 && function_exists( 'linkmora_clear_rocket_cache' ) ) {
linkmora_clear_rocket_cache();
}
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links&import_notice=' . ( $imported > 0 ? 'success' : 'error' ) . '&count=' . $imported . '&skipped=' . $skipped . '&invalid=' . $invalid ) );
exit();
}
/**
* Render dashboard panels backed by existing plugin functionality.
*
* @since 2.5.0
*/
private function render_dashboard_sidebar() {
$export_url = wp_nonce_url( admin_url( 'admin.php?page=linkmora-links&linkmora_export_csv=1' ), 'linkmora_export_csv' );
?>
';
echo '' . esc_html__( 'Updating links...', 'linkmora' ) . '
';
echo '' . esc_html__( 'Retry', 'linkmora' ) . '
';
// Keep one native form around the search, bulk actions, and pagination so
// every interaction continues to work when JavaScript is unavailable.
echo '';
echo '';
}
/**
* Render the admin links table page and success/error notices.
*/
public function linkmora_table_callback() {
$query = isset( $_GET ) && is_array( $_GET ) ? wp_unslash( $_GET ) : array();
$list_table = new Linkmora_Table();
echo '';
$this->render_admin_page_header( 'links' );
if ( isset( $query['notice'] ) && is_scalar( $query['notice'] ) ) {
$notice_messages = array(
'update' => __( 'Link updated successfully!', 'linkmora' ),
'insert' => __( 'Link inserted successfully!', 'linkmora' ),
'deleted' => __( 'Link deleted successfully!', 'linkmora' ),
'reordered' => __( 'Link order updated successfully!', 'linkmora' ),
);
$notice_key = sanitize_key( (string) $query['notice'] );
if ( isset( $notice_messages[ $notice_key ] ) ) {
echo '
' . esc_html( $notice_messages[ $notice_key ] ) . '
';
}
}
$list_table->prepare_items();
if ( isset( $query['import_notice'] ) && is_scalar( $query['import_notice'] ) ) {
$import_key = sanitize_key( (string) $query['import_notice'] );
if ( 'success' === $import_key ) {
$count = isset( $query['count'] ) && is_scalar( $query['count'] ) ? absint( $query['count'] ) : 0;
$skipped = isset( $query['skipped'] ) && is_scalar( $query['skipped'] ) ? absint( $query['skipped'] ) : 0;
$invalid = isset( $query['invalid'] ) && is_scalar( $query['invalid'] ) ? absint( $query['invalid'] ) : 0;
echo '
' . esc_html(
sprintf(
/* translators: 1: imported row count, 2: skipped row count, 3: invalid row count */
__( '%1$d imported / %2$d skipped / %3$d invalid.', 'linkmora' ),
$count,
$skipped,
$invalid
)
) . '
';
} elseif ( 'invalid_header' === $import_key ) {
echo '
' . esc_html__( 'The CSV header is invalid. Use the exported CSV format and include title and link_url columns.', 'linkmora' ) . '
';
} elseif ( 'file_too_large' === $import_key ) {
echo '
' . esc_html__( 'The CSV file is too large to import.', 'linkmora' ) . '
';
} elseif ( 'error' === $import_key ) {
$count = isset( $query['count'] ) && is_scalar( $query['count'] ) ? absint( $query['count'] ) : 0;
$skipped = isset( $query['skipped'] ) && is_scalar( $query['skipped'] ) ? absint( $query['skipped'] ) : 0;
$invalid = isset( $query['invalid'] ) && is_scalar( $query['invalid'] ) ? absint( $query['invalid'] ) : 0;
if ( $skipped || $invalid ) {
echo '
' . esc_html(
sprintf(
/* translators: 1: imported row count, 2: skipped row count, 3: invalid row count */
__( '%1$d imported / %2$d skipped / %3$d invalid.', 'linkmora' ),
$count,
$skipped,
$invalid
)
) . '
';
} else {
echo '
' . esc_html__( 'The CSV file could not be imported. Please check the file and try again.', 'linkmora' ) . '
';
}
}
}
/**
* Fires above the links table for registered dashboard/statistics cards.
*
* @since 2.5.0
*
* @param Linkmora_Table $list_table Prepared links list table.
*/
do_action( 'linkmora_admin_dashboard_widgets', $list_table );
echo '
';
echo '
';
echo '';
echo '';
$this->render_links_table_component( $list_table );
echo '
';
echo ' ';
$this->render_dashboard_sidebar();
echo '
';
echo '
';
}
/**
* WP-Cron only runs when something visits the site (and many hosts
* disable it entirely in favour of a real server cron hitting
* wp-cron.php on a schedule the site owner has to configure). If that
* never happens, the scheduled expiry/notification/deletion events can
* sit due for a long time without ever firing. As a safety net, run the
* same cleanup directly on any wp-admin page load, rate-limited to
* once an hour so it stays cheap.
*
* @since 1.3.0
*/
public function maybe_run_scheduled_cleanup() {
if ( get_transient( 'linkmora_last_cleanup_run' ) ) {
return;
}
set_transient( 'linkmora_last_cleanup_run', 1, HOUR_IN_SECONDS );
$linkmora_options = linkmora_get_settings();
if ( function_exists( 'linkmora_deactivate_expired_links' ) ) {
linkmora_deactivate_expired_links();
}
/**
* Fires during the hourly admin fallback after Core status sync runs.
*
* @since 2.5.0
*
* @param array $linkmora_options Effective settings.
*/
do_action( 'linkmora_run_automation_fallback', $linkmora_options );
}
/**
* Show a dashboard notice (on the plugin's own admin screens) listing
* backlinks that are about to expire, as a visible complement to the
* email notification -- some admins don't check the admin email inbox.
*
* @since 1.2.0
*/
public function show_expiry_admin_notice() {
if ( ! linkmora_feature_enabled( 'linkmora_enable_expiry_notice' ) ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || strpos( $screen->id, 'linkmora' ) === false ) {
return;
}
global $wpdb;
$table_name = esc_sql( $wpdb->prefix . 'linkmora_links' );
$soon = linkmora_relative_date( '+1 day' );
$effective_status = linkmora_get_link_status_sql();
// The custom table name and status expression come from trusted internal values; request dates remain prepared.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query current custom-table rows using a trusted status expression and prepared values.
$expiring = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name and status SQL expression are trusted internals; request values are prepared.
$wpdb->prepare( "SELECT id, title, end_date FROM $table_name WHERE ($effective_status) = %s AND end_date <= %s", 'active', $soon )
);
if ( empty( $expiring ) ) {
return;
}
// Identifies this specific notice (which links are expiring) so a
// dismissal keeps suppressing it as long as that same set of links
// is what's flagged -- it only reappears once the set actually
// changes (a link gets renewed/removed, or a new one starts
// expiring).
$expiring_ids = wp_list_pluck( $expiring, 'id' );
sort( $expiring_ids );
$notice_id = md5( implode( ',', $expiring_ids ) );
$dismissed = get_user_meta( get_current_user_id(), 'linkmora_dismissed_expiry_notice', true );
if ( $dismissed === $notice_id ) {
return;
}
$titles = wp_list_pluck( $expiring, 'title' );
/* translators: 1: number of expiring backlinks, 2: backlink titles. */
$notice_format = _n( '%1$d backlink is about to expire (within a day): %2$s', '%1$d backlinks are about to expire (within a day): %2$s', count( $expiring ), 'linkmora' );
printf(
'',
esc_attr( $notice_id ),
esc_attr( wp_create_nonce( 'linkmora_dismiss_expiry_notice' ) )
);
printf(
esc_html( $notice_format ),
count( $expiring ),
esc_html( implode( ', ', $titles ) )
);
echo '
';
}
/**
* AJAX handler: persist the dismissal of the expiry notice for the
* current admin user so it doesn't reappear on the next page load.
*
* @since 1.4.0
*/
public function ajax_dismiss_expiry_notice() {
check_ajax_referer( 'linkmora_dismiss_expiry_notice', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error();
}
$notice_id = isset( $_POST['notice_id'] ) && is_scalar( $_POST['notice_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['notice_id'] ) ) : '';
update_user_meta( get_current_user_id(), 'linkmora_dismissed_expiry_notice', $notice_id );
wp_send_json_success();
}
/**
* Process a submitted backlink form, either inserting or updating a row.
*
* Validates the nonce and user capability before persisting the normalized
* backlink data, including its URL, follow type, and active date range.
*
* @since 1.0.0
*/
public function add_backlinks() {
$submitted_nonce = isset( $_POST['linkmora_add_link_nonce'] ) && is_scalar( $_POST['linkmora_add_link_nonce'] )
? sanitize_text_field( wp_unslash( (string) $_POST['linkmora_add_link_nonce'] ) )
: '';
if ( isset( $_POST['submit_backlinks'] ) && wp_verify_nonce( $submitted_nonce, 'linkmora_add_link_nonce' ) ) {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$operation = isset( $_POST['action'] ) && is_scalar( $_POST['action'] ) ? sanitize_key( wp_unslash( (string) $_POST['action'] ) ) : '';
if ( ! in_array( $operation, array( 'insert', 'update' ), true ) ) {
return;
}
$link_id = isset( $_POST['link_id'] ) && is_scalar( $_POST['link_id'] ) ? absint( wp_unslash( $_POST['link_id'] ) ) : 0;
$current_link = null;
global $wpdb;
$table_name = esc_sql( $wpdb->prefix . 'linkmora_links' );
if ( 'update' === $operation ) {
// The table name uses the trusted WordPress prefix; the row ID is prepared.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current custom-table data with a trusted identifier and prepared value.
$current_link = $wpdb->get_row(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The trusted table identifier cannot be a prepared value; the row ID is prepared.
$wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $link_id )
);
if ( ! $current_link ) {
add_settings_error( 'my_messages', 'backlink_update_error', __( 'Error updating link: link not found.', 'linkmora' ), 'error' );
return;
}
}
$title = isset( $_POST['linkmora_link_title'] ) && is_scalar( $_POST['linkmora_link_title'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['linkmora_link_title'] ) ) : '';
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalize_link_url() restricts HTTP(S), sanitizes the URL, and requires a host.
$link_url = $this->normalize_link_url( isset( $_POST['linkmora_link_url'] ) && is_scalar( $_POST['linkmora_link_url'] ) ? wp_unslash( (string) $_POST['linkmora_link_url'] ) : '' );
$raw_follow = isset( $_POST['linkmora_link_follow'] ) && is_scalar( $_POST['linkmora_link_follow'] ) ? sanitize_key( wp_unslash( (string) $_POST['linkmora_link_follow'] ) ) : '';
$allowed_link_types = linkmora_get_allowed_link_types();
if ( '' === $raw_follow ) {
$raw_follow = 'update' === $operation && $current_link ? (string) $current_link->follow : 'follow';
}
$preserve_existing_type = 'update' === $operation
&& $current_link
&& ! array_key_exists( $current_link->follow, $allowed_link_types )
&& $raw_follow === $current_link->follow;
$follow = $preserve_existing_type ? $current_link->follow : $raw_follow;
if ( ! $preserve_existing_type && ! array_key_exists( $follow, $allowed_link_types ) ) {
add_settings_error( 'my_messages', 'backlink_link_type_error', __( 'The selected link type is not currently available.', 'linkmora' ), 'error' );
return;
}
if ( '' === $link_url ) {
add_settings_error( 'my_messages', 'backlink_url_error', __( 'Please enter a valid website URL.', 'linkmora' ), 'error' );
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The normalizer sanitizes input and accepts only valid Gregorian or Jalali dates.
$start_date = $this->normalize_submitted_link_date( isset( $_POST['linkmora_link_start_date'] ) && is_scalar( $_POST['linkmora_link_start_date'] ) ? wp_unslash( (string) $_POST['linkmora_link_start_date'] ) : '' );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The normalizer sanitizes input and accepts only valid Gregorian or Jalali dates.
$end_date = $this->normalize_submitted_link_date( isset( $_POST['linkmora_link_end_date'] ) && is_scalar( $_POST['linkmora_link_end_date'] ) ? wp_unslash( (string) $_POST['linkmora_link_end_date'] ) : '' );
// End date is exclusive: the link expires once current date reaches end_date.
if ( '' === $start_date || '' === $end_date || strtotime( $end_date ) <= strtotime( $start_date ) ) {
add_settings_error( 'my_messages', 'backlink_date_error', __( 'Please enter valid start and end dates. The end date must be after the start date.', 'linkmora' ), 'error' );
return;
}
$display_option = isset( $_POST['linkmora_link_display_option'] ) && is_scalar( $_POST['linkmora_link_display_option'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['linkmora_link_display_option'] ) ) : 'all';
if ( ! in_array( $display_option, array( 'all', 'home', 'inner' ), true ) ) {
$display_option = 'all';
}
$backlink_data = linkmora_sanitize_backlink_data(
array(
'title' => $title,
'link_url' => $link_url,
'follow' => $follow,
'display_option' => $display_option,
'start_date' => $start_date,
'end_date' => $end_date,
),
$operation,
array(
'linkmora_link_title' => $title,
'linkmora_link_url' => $link_url,
'linkmora_link_follow' => $follow,
'linkmora_link_display_option' => $display_option,
'linkmora_link_start_date' => $start_date,
'linkmora_link_end_date' => $end_date,
)
);
$title = sanitize_text_field( $backlink_data['title'] ?? '' );
$link_url = $this->normalize_link_url( $backlink_data['link_url'] ?? '' );
$follow = sanitize_key( $backlink_data['follow'] ?? 'follow' );
$display_option = sanitize_key( $backlink_data['display_option'] ?? 'all' );
$start_date = $this->normalize_submitted_link_date( $backlink_data['start_date'] ?? '' );
$end_date = $this->normalize_submitted_link_date( $backlink_data['end_date'] ?? '' );
$preserves_stored_type = 'update' === $operation
&& $current_link
&& ! array_key_exists( $current_link->follow, linkmora_get_allowed_link_types() )
&& $follow === $current_link->follow;
if ( '' === $link_url || ( ! array_key_exists( $follow, linkmora_get_allowed_link_types() ) && ! $preserves_stored_type ) || ! in_array( $display_option, array( 'all', 'home', 'inner' ), true ) || '' === $start_date || '' === $end_date || strtotime( $end_date ) <= strtotime( $start_date ) ) {
add_settings_error( 'my_messages', 'backlink_extension_validation_error', __( 'The backlink data is invalid.', 'linkmora' ), 'error' );
return;
}
$backlink_data = array_merge(
$backlink_data,
compact( 'title', 'link_url', 'follow', 'display_option', 'start_date', 'end_date' )
);
$validation_errors = linkmora_validate_backlink_data( new WP_Error(), $backlink_data, $operation );
if ( $validation_errors->has_errors() ) {
add_settings_error( 'my_messages', 'backlink_extension_validation_error', $validation_errors->get_error_message(), 'error' );
return;
}
$current_date = linkmora_current_date();
$saved_id = 0;
$previous_link = null;
if ( 'insert' === $operation ) {
$notice = 'insert';
// The table name is trusted and the aggregate query contains no request values.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Obtain fresh ordering from the custom table.
$link_order = $wpdb->get_var( "SELECT COALESCE(MAX(link_order), 0) FROM $table_name;" );
++$link_order;
$status = ( strtotime( $current_date ) >= strtotime( $start_date ) ) && ( strtotime( $current_date ) < strtotime( $end_date ) ) ? 'active' : 'inactive';
$link_order = 'active' === $status ? $link_order : null;
$result = $wpdb->insert(
$table_name,
array(
'title' => $title,
'link_url' => $link_url,
'follow' => $follow,
'display_option' => $display_option,
'link_order' => $link_order,
'start_date' => $start_date,
'end_date' => $end_date,
'status' => $status,
'status_changed_at' => $current_date,
)
);
$insert_id = $wpdb->insert_id;
$saved_id = intval( $insert_id );
// Keep a history row when a backlink is first created.
$wpdb->insert(
$table_name,
array(
'title' => $title,
'link_url' => $link_url,
'follow' => $follow,
'display_option' => $display_option,
'link_order' => null,
'start_date' => $start_date,
'end_date' => $end_date,
'status' => 'history',
'parent_id' => $insert_id,
)
);
}
if ( 'update' === $operation ) {
$notice = 'update';
$id = $link_id;
$previous_link = get_object_vars( $current_link );
$saved_id = $id;
$was_active = ( 'active' === $current_link->status );
// Use submitted dates so extending an expired link can reactivate it.
$status = ( strtotime( $current_date ) >= strtotime( $start_date ) ) && ( strtotime( $current_date ) < strtotime( $end_date ) ) ? 'active' : 'inactive';
$wpdb->insert(
$table_name,
array(
'title' => $title,
'link_url' => $link_url,
'follow' => $follow,
'display_option' => $display_option,
'link_order' => null,
'start_date' => $start_date,
'end_date' => $end_date,
'status' => 'history',
'parent_id' => $id,
)
);
$update_data = array(
'title' => $title,
'link_url' => $link_url,
'follow' => $follow,
'display_option' => $display_option,
'start_date' => $start_date,
'end_date' => $end_date,
'status' => $status,
);
if ( 'active' === $status ) {
if ( ! $was_active ) {
// Was inactive/expired and is now active again: put it
// at the end of the current order (it had no order
// while inactive).
// The table name is trusted and the aggregate query contains no request values.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Obtain fresh ordering from the custom table.
$next_order = absint( $wpdb->get_var( "SELECT COALESCE(MAX(link_order), 0) FROM $table_name WHERE status = 'active'" ) ) + 1;
$update_data['link_order'] = $next_order;
$update_data['status_changed_at'] = $current_date;
}
} else {
$update_data['link_order'] = null;
if ( $was_active ) {
$update_data['status_changed_at'] = $current_date;
}
}
$result = $wpdb->update(
$table_name,
$update_data,
array(
'id' => $id,
)
);
}
if ( false !== $result ) {
if ( 'insert' === $notice ) {
/**
* Fires after a backlink and its initial history row are created.
*
* @since 2.5.0
*
* @param int $saved_id New backlink ID.
* @param array $backlink_data Sanitized backlink data.
* @param string $context Creation context.
*/
do_action( 'linkmora_after_backlink_created', $saved_id, $backlink_data, 'admin' );
} else {
/**
* Fires after an existing backlink is updated.
*
* @since 2.5.0
*
* @param int $saved_id Updated backlink ID.
* @param array $backlink_data Sanitized backlink data.
* @param array $previous_link Backlink row before the update.
* @param string $context Update context.
*/
do_action( 'linkmora_after_backlink_updated', $saved_id, $backlink_data, $previous_link, 'admin' );
}
linkmora_clear_rocket_cache();
wp_safe_redirect( admin_url( 'admin.php?page=linkmora-links¬ice=' . rawurlencode( $notice ) ) );
exit();
} else {
add_settings_error( 'my_messages', 'backlink_insert_error', __( 'Error adding link.', 'linkmora' ), 'error' );
}
}
}
}