Plugin Directory

Changeset 3799313


Ignore:
Timestamp:
11/12/2026 09:48:00 PM (less than one hour ago)
Author:
basicus
Message:

Prepare 1.5.21 for Norwegian translation while retaining stable 1.5.17

Location:
devenia-autoposter-for-linkedin/trunk
Files:
2 added
3 edited

Legend:

Unmodified
Added
Removed
  • devenia-autoposter-for-linkedin/trunk/devenia-autoposter-for-linkedin.php

    r3694248 r3799313  
    44 * Plugin URI: https://devenia.com/plugins/linkedin-autoposter/
    55 * Description: Automatically share posts to LinkedIn when published. Uses official LinkedIn API - no scraping, no bloat.
    6  * Version: 1.5.17
     6 * Version: 1.5.21
    77 * Requires at least: 5.4
    88 * Requires PHP: 7.4
    … …  
    1919}
    2020
    21 define('DLAP_VERSION', '1.5.17');
     21add_action( 'admin_init', static function () {
     22        require_once __DIR__ . '/includes/devenia-updater-notice.php';
     23        dlap_Updater_Notice::register( __FILE__ );
     24} );
     25
     26define('DLAP_VERSION', '1.5.21');
    2227define('DLAP_PLUGIN_DIR', plugin_dir_path(__FILE__));
    2328define('DLAP_PLUGIN_URL', plugin_dir_url(__FILE__));
    … …  
    600605                    <?php if (!empty($readiness['surface_probe']['summary']['warnings'])): ?>
    601606                        <div style="border-left: 4px solid #d63638; background: #fcf0f1; padding: 8px 12px; margin: 10px 0;">
    602                             <strong>LinkedIn API surface warnings from last reconnect</strong>
     607                            <strong>Recorded API check results from last reconnect</strong>
     608                            <p>These are recorded check results, not a test of publishing. Administrative API access is separate from permission to create company posts.</p>
    603609                            <ul style="margin: 6px 0 0 18px;">
    604610                                <?php foreach ($readiness['surface_probe']['summary']['warnings'] as $warning): ?>
    … …  
    868874     */
    869875    public function handle_oauth_callback() {
     876        if (!current_user_can('manage_options')) {
     877            return;
     878        }
    870879        // Handle disconnect
    871880        $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
    872881        if ( isset( $_GET['dlap_disconnect'] ) && wp_verify_nonce( $nonce, 'dlap_disconnect' ) ) {
    873882            delete_option('dlap_access_token');
     883            delete_option('dlap_token_scopes');
    874884            delete_option('dlap_token_expires');
    875885            delete_option('dlap_member_id');
    … …  
    925935            update_option('dlap_access_token', $body['access_token']);
    926936            update_option('dlap_token_expires', time() + $body['expires_in']);
     937            // Only the token response proves granted scopes; never infer them from the request.
     938            $scope_text = isset($body['scope']) && is_string($body['scope']) ? $body['scope'] : '';
     939            $token_scopes = preg_split('/[\s,]+/', trim($scope_text), -1, PREG_SPLIT_NO_EMPTY);
     940            update_option('dlap_token_scopes', $token_scopes ? array_values(array_unique($token_scopes)) : array(), false);
    927941
    928942            // Get member ID
    … …  
    10141028        }
    10151029
    1016         // Return success if at least one post succeeded
     1030        // Report complete success only when every selected destination succeeded.
    10171031        if (!empty($results)) {
    10181032            if (!empty($errors)) {
    10191033                // Partial success - some posts failed
    1020                 set_transient('dlap_test_error', 'Partial success. Errors: ' . implode('; ', $errors), 60);
     1034                set_transient('dlap_test_error', 'Partial success (' . implode(', ', array_keys($results)) . '). Errors: ' . implode('; ', $errors), 60);
     1035                return false;
    10211036            }
    10221037            return true;
    … …  
    10321047    private function get_admin_organizations() {
    10331048        $organizations = array();
     1049
     1050        if (!$this->has_token_scope(array('r_organization_admin', 'rw_organization_admin'))) {
     1051            return $organizations;
     1052        }
    10341053
    10351054        // Get organization access control (admin roles)
    … …  
    11301149        } elseif (in_array('permission_error', $organization_probe_classifications, true)) {
    11311150            $organization_permission_verification = 'unavailable';
    1132         } elseif (empty($organization_probe_classifications)) {
     1151        } elseif (empty($organization_probe_classifications) || in_array('not_checked_scope', $organization_probe_classifications, true)) {
    11331152            $organization_permission_verification = 'not_checked';
    11341153        } else {
    … …  
    11531172                $warnings[] = 'Company page target is enabled, but organization ID is missing.';
    11541173            } elseif ('unavailable' === $organization_permission_verification) {
    1155                 $warnings[] = 'LinkedIn company-page diagnostics are unavailable for this token. Publishing remains enabled because the Posts API is the authoritative permission check.';
     1174                $warnings[] = 'Optional company-page administrative checks were denied. This does not establish a posting failure. Company posting has not been verified by these checks; inspect the result of an authorized post.';
    11561175            } elseif (!$organization_permission_confirmed) {
    1157                 $warnings[] = 'Company page access was not confirmed by the optional diagnostics. Publishing remains enabled because the Posts API is the authoritative permission check.';
     1176                $warnings[] = 'Company-page administrative access has not been checked or confirmed. These optional checks do not verify posting; inspect the result of an authorized post.';
    11581177            }
    11591178        }
    … …  
    11891208
    11901209    /**
     1210     * Check scopes actually returned for this token, without requesting broader access.
     1211     * Older tokens with unknown scopes do not authorize optional administrative probes.
     1212     */
     1213    private function has_token_scope($required_scopes) {
     1214        $scopes = get_option('dlap_token_scopes', array());
     1215        return is_array($scopes) && (bool) array_intersect($required_scopes, $scopes);
     1216    }
     1217
     1218    /**
     1219     * Explain an optional probe that was not sent. This is not a successful check.
     1220     */
     1221    private function skipped_admin_probe() {
     1222        return array(
     1223            'success' => null,
     1224            'status_code' => 0,
     1225            'message' => 'Not checked: the token has no confirmed administrative scope. Company posting uses w_organization_social, not administrative access.',
     1226            'classification' => 'not_checked_scope',
     1227        );
     1228    }
     1229
     1230    /**
    11911231     * Return the last LinkedIn surface probe captured during reconnect.
    11921232     */
    … …  
    12301270        $probe['checks']['userinfo'] = $this->normalize_linkedin_response($userinfo_response, array(200), 'userinfo');
    12311271
    1232         $acl_response = $this->linkedin_rest_request(
    1233             'GET',
    1234             'https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR',
    1235             $access_token
    1236         );
    1237         $probe['checks']['organization_acls'] = $this->normalize_linkedin_response($acl_response, array(200), 'organization_acls');
    1238 
    1239         if ('' !== $member_id && '' !== $organization_id) {
     1272        if ($this->has_token_scope(array('r_organization_admin', 'rw_organization_admin'))) {
     1273            $acl_response = $this->linkedin_rest_request(
     1274                'GET',
     1275                'https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR',
     1276                $access_token
     1277            );
     1278            $probe['checks']['organization_acls'] = $this->normalize_linkedin_response($acl_response, array(200), 'organization_acls');
     1279
     1280        } else {
     1281            $probe['checks']['organization_acls'] = $this->skipped_admin_probe();
     1282        }
     1283
     1284        if (!$this->has_token_scope(array('rw_organization_admin'))) {
     1285            $probe['checks']['organization_authorization_organic_share_create'] = $this->skipped_admin_probe();
     1286        } elseif ('' !== $member_id && '' !== $organization_id) {
    12401287            $person_urn = rawurlencode('urn:li:person:' . $member_id);
    12411288            $organization_urn = rawurlencode('urn:li:organization:' . $organization_id);
    … …  
    13261373     * Create a share attempt record.
    13271374     */
    1328     private function start_share_attempt($post_id, $attempt, $readiness) {
     1375    private function start_share_attempt($post_id, $attempt, $readiness, $held_targets = array()) {
    13291376        $record = array(
    13301377            'id'             => wp_generate_uuid4(),
    … …  
    13371384            'finished_at_iso'=> '',
    13381385            'readiness'      => $readiness,
    1339             'targets'        => array(),
     1386            'targets'        => $held_targets,
    13401387            'message'        => '',
    13411388        );
    … …  
    14291476            'language_selection' => $this->get_post_language_rules($post, get_option('dlap_settings', array())),
    14301477            'queue'          => $this->get_queue_state($post_id),
     1478            'delivery'       => array(
     1479                'personal' => get_option('dlap_delivery_' . $post_id . '_personal', array()),
     1480                'organization' => get_option('dlap_delivery_' . $post_id . '_organization', array()),
     1481            ),
    14311482            'last_attempt'   => get_post_meta($post_id, DLAP_LAST_ATTEMPT_META_KEY, true),
    14321483            'attempts'       => $history,
    … …  
    15841635
    15851636        $last_response = false;
     1637        $safe_to_retry = in_array(strtoupper($method), array('GET', 'HEAD'), true);
    15861638
    15871639        foreach ($this->get_linkedin_api_versions() as $version) {
    … …  
    15931645
    15941646                if (is_wp_error($response)) {
    1595                     if ($attempt < DLAP_HTTP_MAX_ATTEMPTS) {
     1647                    if ($safe_to_retry && $attempt < DLAP_HTTP_MAX_ATTEMPTS) {
    15961648                        $delay = $this->get_http_retry_delay_seconds($attempt);
    15971649                        dlap_log('DLAP LinkedIn API - Request error, retrying in ' . $delay . 's: ' . $response->get_error_message());
    … …  
    16111663                }
    16121664
    1613                 if ($this->is_retryable_linkedin_response_code($response_code) && $attempt < DLAP_HTTP_MAX_ATTEMPTS) {
     1665                if ($safe_to_retry && $this->is_retryable_linkedin_response_code($response_code) && $attempt < DLAP_HTTP_MAX_ATTEMPTS) {
    16141666                    $delay = $this->get_http_retry_delay_seconds($attempt, $response);
    16151667                    dlap_log('DLAP LinkedIn API - Retryable response (' . $response_code . '), retrying in ' . $delay . 's.');
    … …  
    18591911
    18601912        $readiness = $this->get_connection_readiness();
    1861         $attempt_record = $this->start_share_attempt($post_id, $attempt, $readiness);
     1913        // Carry terminal outcomes only within this automatic retry chain.
     1914        // A new explicit attempt starts a fresh chain after configuration is corrected.
     1915        $previous = get_post_meta($post_id, DLAP_LAST_ATTEMPT_META_KEY, true);
     1916        $held_targets = array();
     1917        if (is_array($previous) && (
     1918            ('retry_scheduled' === ($previous['status'] ?? '') && $attempt === (int) $previous['attempt'] + 1) ||
     1919            ('running' === ($previous['status'] ?? '') && $attempt === (int) $previous['attempt'])
     1920        )) {
     1921            foreach (($previous['targets'] ?? array()) as $target => $result) {
     1922                if ('failed' === ($result['status'] ?? '') && empty($result['response']['retryable'])) {
     1923                    $held_targets[$target] = $result;
     1924                }
     1925            }
     1926        }
     1927        $attempt_record = $this->start_share_attempt($post_id, $attempt, $readiness, $held_targets);
    18621928
    18631929        try {
    … …  
    18751941                        update_post_meta($post_id, '_dlap_share_queued', time() + $delay);
    18761942                        update_post_meta($post_id, '_dlap_share_attempt', $attempt);
    1877                         $this->finish_share_attempt($post_id, $attempt_record, 'retry_scheduled', $message, array());
     1943                        $this->finish_share_attempt($post_id, $attempt_record, 'retry_scheduled', $message, $held_targets);
    18781944                    } else {
    1879                         $this->finish_share_attempt($post_id, $attempt_record, 'queue_failed', $message, array());
     1945                        $this->finish_share_attempt($post_id, $attempt_record, 'queue_failed', $message, $held_targets);
    18801946                    }
    18811947                } else {
    18821948                    $this->update_queue_state($post_id, 'exhausted', $attempt, 0, $message);
    1883                     $this->finish_share_attempt($post_id, $attempt_record, 'failed', $message, array());
     1949                    $this->finish_share_attempt($post_id, $attempt_record, 'failed', $message, $held_targets);
    18841950                }
    18851951                return;
    18861952            }
    18871953
    1888             $result = $this->share_post($post);
     1954            $completed_targets = get_post_meta($post_id, '_dlap_post_id', true);
     1955            $completed_targets = is_array($completed_targets) ? $completed_targets : array();
     1956            $result = $this->share_post($post, $attempt_record);
     1957            if (is_array($result) && !empty($result['linkedin_ids'])) {
     1958                update_post_meta($post_id, '_dlap_post_id', array_merge($completed_targets, $result['linkedin_ids']));
     1959            }
    18891960            if (is_array($result) && !empty($result['skipped'])) {
    18901961                delete_post_meta($post_id, '_dlap_error');
    … …  
    18951966                return;
    18961967            }
    1897             if (is_array($result) && !empty($result['success'])) {
     1968            if (is_array($result) && !empty($result['success']) && empty($result['incomplete'])) {
    18981969                update_post_meta($post_id, '_dlap_shared', time());
    1899                 update_post_meta($post_id, '_dlap_post_id', isset($result['linkedin_ids']) ? $result['linkedin_ids'] : array());
     1970                update_post_meta($post_id, '_dlap_post_id', array_merge($completed_targets, isset($result['linkedin_ids']) ? $result['linkedin_ids'] : array()));
    19001971                delete_post_meta($post_id, '_dlap_error');
    1901                 delete_post_meta($post_id, '_dlap_error_personal');
    1902                 delete_post_meta($post_id, '_dlap_error_organization');
     1972                // Successful targets clear their own errors; preserve other target history.
    19031973                delete_post_meta($post_id, '_dlap_share_attempt');
    19041974                delete_post_meta($post_id, '_dlap_share_queued');
    … …  
    19151985            update_post_meta($post_id, '_dlap_error', $last_error);
    19161986            update_post_meta($post_id, '_dlap_share_attempt', $attempt);
     1987
     1988            $retryable = false;
     1989            $has_uncertain_delivery = false;
     1990            foreach ($target_results as $target_result) {
     1991                if ('delivery_uncertain' === ($target_result['response']['classification'] ?? '')) {
     1992                    $has_uncertain_delivery = true;
     1993                }
     1994                if ('failed' === $target_result['status']) {
     1995                    if (!empty($target_result['response']['retryable'])) {
     1996                        $retryable = true;
     1997                    }
     1998                }
     1999            }
     2000            if (!$retryable) {
     2001                $state = $has_uncertain_delivery ? 'needs_reconciliation' : (!empty($result['success']) ? 'partial_failed' : 'failed');
     2002                delete_post_meta($post_id, '_dlap_share_queued');
     2003                $this->update_queue_state($post_id, $state, $attempt, 0, $last_error);
     2004                $this->finish_share_attempt($post_id, $attempt_record, $state, $last_error, $target_results);
     2005                return;
     2006            }
    19172007
    19182008            if ($attempt < DLAP_SHARE_MAX_ATTEMPTS) {
    … …  
    21122202     * Share post to LinkedIn
    21132203     */
    2114     public function share_post($post) {
     2204    public function share_post($post, $attempt_record = null) {
     2205        $held_targets = is_array($attempt_record) ? $attempt_record['targets'] : array();
     2206        $completed_targets = get_post_meta($post->ID, '_dlap_post_id', true);
     2207        $completed_targets = is_array($completed_targets) ? $completed_targets : array();
    21152208        $member_id = get_option('dlap_member_id', '');
    21162209        $access_token = get_option('dlap_access_token', '');
    … …  
    22112304        $enabled_ready_target = false;
    22122305
    2213         if (($post_target === 'personal' || $post_target === 'both') && $language_rules['allowed']['personal'] && $this->is_target_ready('personal', $readiness)) {
     2306        if (($post_target === 'personal' || $post_target === 'both') && $language_rules['allowed']['personal'] && empty($completed_targets['personal']) && empty($held_targets['personal']) && $this->is_target_ready('personal', $readiness)) {
    22142307            $enabled_ready_target = true;
    22152308        }
    2216         if (($post_target === 'organization' || $post_target === 'both') && $language_rules['allowed']['organization'] && $this->is_target_ready('organization', $readiness)) {
     2309        if (($post_target === 'organization' || $post_target === 'both') && $language_rules['allowed']['organization'] && empty($completed_targets['organization']) && empty($held_targets['organization']) && $this->is_target_ready('organization', $readiness)) {
    22172310            $enabled_ready_target = true;
    22182311        }
    … …  
    22242317            foreach (array('personal', 'organization') as $target) {
    22252318                if ('both' === $post_target || $target === $post_target) {
     2319                    if (!empty($completed_targets[$target]) && $language_rules['allowed'][$target]) {
     2320                        $has_success = true;
     2321                        $linkedin_ids[$target] = $completed_targets[$target];
     2322                        $targets[$target] = $this->build_target_result($target, 'success', 'Already shared; not sent again.', array('linkedin_id' => $completed_targets[$target]));
     2323                        continue;
     2324                    }
    22262325                    $targets[$target] = $language_rules['allowed'][$target]
    22272326                        ? $this->build_target_result($target, 'skipped', $message)
    … …  
    22292328                }
    22302329            }
    2231             return array('success' => false, 'message' => $message, 'targets' => $targets, 'linkedin_ids' => array());
     2330            return array('success' => $has_success, 'incomplete' => true, 'message' => $message, 'targets' => $targets, 'linkedin_ids' => $linkedin_ids);
    22322331        }
    22332332
    … …  
    22362335            if (!$language_rules['allowed']['personal']) {
    22372336                $targets['personal'] = $this->language_skip_result('personal', $language_rules);
     2337            } elseif (!empty($completed_targets['personal'])) {
     2338                $has_success = true;
     2339                $linkedin_ids['personal'] = $completed_targets['personal'];
     2340                $targets['personal'] = $this->build_target_result('personal', 'success', 'Already shared; not sent again.', array('linkedin_id' => $completed_targets['personal']));
     2341            } elseif (isset($held_targets['personal'])) {
     2342                $targets['personal'] = $held_targets['personal'];
    22382343            } elseif (!$this->is_target_ready('personal', $readiness)) {
    22392344                $message = 'Personal profile target is not ready.';
    … …  
    22562361                }
    22572362
    2258                 $response = $this->post_to_linkedin_result($access_token, $author_urn, $content, null, null, null, null, $image_urn);
     2363                $response = $this->deliver_post_target($post->ID, 'personal', $access_token, $author_urn, $content, $image_urn);
    22592364                if (!empty($response['success'])) {
    22602365                    $has_success = true;
    … …  
    22772382        }
    22782383
     2384        // Preserve the first destination's outcome before the next external request.
     2385        if (is_array($attempt_record)) {
     2386            $attempt_record['targets'] = array_merge($held_targets, $targets);
     2387            $this->save_share_attempt($post->ID, $attempt_record);
     2388        }
     2389
    22792390        // Post to company page if target is organization or both
    22802391        if ($post_target === 'organization' || $post_target === 'both') {
    22812392            if (!$language_rules['allowed']['organization']) {
    22822393                $targets['organization'] = $this->language_skip_result('organization', $language_rules);
     2394            } elseif (!empty($completed_targets['organization'])) {
     2395                $has_success = true;
     2396                $linkedin_ids['organization'] = $completed_targets['organization'];
     2397                $targets['organization'] = $this->build_target_result('organization', 'success', 'Already shared; not sent again.', array('linkedin_id' => $completed_targets['organization']));
     2398            } elseif (isset($held_targets['organization'])) {
     2399                $targets['organization'] = $held_targets['organization'];
    22832400            } elseif (!$this->is_target_ready('organization', $readiness)) {
    22842401                $message = 'Company page target is not ready or was not confirmed for this token.';
    … …  
    23012418                }
    23022419
    2303                 $response = $this->post_to_linkedin_result($access_token, $author_urn, $content, null, null, null, null, $image_urn);
     2420                $response = $this->deliver_post_target($post->ID, 'organization', $access_token, $author_urn, $content, $image_urn);
    23042421                if (!empty($response['success'])) {
    23052422                    $has_success = true;
    … …  
    23242441        $failed_messages = array();
    23252442        foreach ($targets as $target_result) {
    2326             if (empty($target_result['success']) && !empty($target_result['message'])) {
     2443            if (empty($target_result['success']) && !empty($language_rules['allowed'][$target_result['target']]) && !empty($target_result['message'])) {
    23272444                $failed_messages[] = $target_result['target'] . ': ' . $target_result['message'];
    23282445            }
    … …  
    23312448        return array(
    23322449            'success' => $has_success,
    2333             'message' => $has_success ? 'At least one LinkedIn target succeeded.' : implode('; ', $failed_messages),
     2450            'incomplete' => !empty($failed_messages),
     2451            'message' => !empty($failed_messages) ? implode('; ', $failed_messages) : 'All eligible LinkedIn targets succeeded.',
    23342452            'targets' => $targets,
    23352453            'linkedin_ids' => $linkedin_ids,
    23362454        );
     2455    }
     2456
     2457    /**
     2458     * Own durable delivery at the post/destination seam, before the external POST.
     2459     * A pending marker survives process failure and must not be sent again blindly.
     2460     */
     2461    private function deliver_post_target($post_id, $target, $access_token, $author_urn, $content, $image_urn) {
     2462        $marker = 'dlap_delivery_' . absint($post_id) . '_' . sanitize_key($target);
     2463        $pending = array('state' => 'pending', 'author' => $author_urn, 'started_at' => time());
     2464        if (!add_option($marker, $pending, '', false)) {
     2465            $existing = get_option($marker, array());
     2466            if (is_array($existing) && 'succeeded' === ($existing['state'] ?? '') && !empty($existing['response']['linkedin_id'])) {
     2467                return $existing['response'];
     2468            }
     2469            return array('success' => false, 'status_code' => 0, 'classification' => 'delivery_uncertain', 'retryable' => false,
     2470                'message' => 'An earlier delivery may have reached LinkedIn. Check its outcome before retrying this destination.', 'linkedin_id' => '');
     2471        }
     2472        $response = $this->post_to_linkedin_result($access_token, $author_urn, $content, null, null, null, null, $image_urn);
     2473        if (!empty($response['success'])) {
     2474            // Save the provider receipt before attempting the next destination.
     2475            $ids = get_post_meta($post_id, '_dlap_post_id', true);
     2476            $ids = is_array($ids) ? $ids : array();
     2477            $ids[$target] = $response['linkedin_id'];
     2478            update_post_meta($post_id, '_dlap_post_id', $ids);
     2479            update_option($marker, array('state' => 'succeeded', 'author' => $author_urn, 'response' => $response), false);
     2480        } elseif ('delivery_uncertain' === ($response['classification'] ?? '')) {
     2481            update_option($marker, array('state' => 'uncertain', 'author' => $author_urn, 'response' => $response), false);
     2482        } else {
     2483            // An explicit rejection did not create a post; a later authorized retry is safe.
     2484            delete_option($marker);
     2485        }
     2486        return $response;
    23372487    }
    23382488
    … …  
    25762726
    25772727        $result = $this->normalize_linkedin_response($response, array(201), 'posts_create');
     2728        // Transport failures, server errors, and missing creation receipts have uncertain effects.
     2729        if (0 === $result['status_code'] || $result['status_code'] >= 500 || (!empty($result['success']) && empty($result['linkedin_id']))) {
     2730            $result['success'] = false;
     2731            $result['classification'] = 'delivery_uncertain';
     2732            $result['retryable'] = false;
     2733            $result['message'] = 'LinkedIn delivery outcome is uncertain. Check for an existing post before retrying. ' . $result['message'];
     2734        }
     2735
    25782736        if (empty($result['success'])) {
    25792737            set_transient('dlap_last_error', isset($result['message']) ? $result['message'] : 'LinkedIn sharing failed.', 60);
    … …  
    26602818        <?php endif; ?>
    26612819
     2820        <?php if (current_user_can('manage_options')): ?>
     2821            <?php foreach (array('personal', 'organization') as $target):
     2822                $delivery = get_option('dlap_delivery_' . $post->ID . '_' . $target, array());
     2823                if (!is_array($delivery) || !in_array($delivery['state'] ?? '', array('pending', 'uncertain'), true)) {
     2824                    continue;
     2825                }
     2826            ?>
     2827                <p><strong><?php echo esc_html(ucfirst($target)); ?>: <?php esc_html_e('Delivery needs reconciliation', 'devenia-autoposter-for-linkedin'); ?></strong><br>
     2828                    <?php esc_html_e('Check LinkedIn before choosing an outcome. Saving a confirmed non-publication queues a new attempt; an incorrect choice can create a duplicate.', 'devenia-autoposter-for-linkedin'); ?></p>
     2829                <p><label><?php esc_html_e('Verified outcome', 'devenia-autoposter-for-linkedin'); ?>
     2830                    <select name="dlap_resolution[<?php echo esc_attr($target); ?>]">
     2831                        <option value=""><?php esc_html_e('Leave unresolved', 'devenia-autoposter-for-linkedin'); ?></option>
     2832                        <option value="published"><?php esc_html_e('I confirmed this post is published', 'devenia-autoposter-for-linkedin'); ?></option>
     2833                        <option value="not_published"><?php esc_html_e('I confirmed it was not published; retry', 'devenia-autoposter-for-linkedin'); ?></option>
     2834                    </select>
     2835                </label></p>
     2836                <p><label><?php esc_html_e('Published LinkedIn post URN (required for published outcome)', 'devenia-autoposter-for-linkedin'); ?>
     2837                    <input type="text" name="dlap_resolution_id[<?php echo esc_attr($target); ?>]" placeholder="urn:li:share:123456789" class="widefat">
     2838                </label></p>
     2839            <?php endforeach; ?>
     2840        <?php endif; ?>
     2841
    26622842        <?php if (!$this->is_connected()): ?>
    26632843            <p style="color: #d63638;">
    … …  
    26882868        $disabled = isset( $_POST['dlap_disable'] ) ? '1' : '';
    26892869        update_post_meta( $post_id, '_dlap_disable', $disabled );
     2870
     2871        // Reconciliation is an explicit administrator action through the native post form.
     2872        $post = get_post($post_id);
     2873        if (!current_user_can('manage_options') || $disabled || !$post instanceof WP_Post || 'publish' !== $post->post_status) {
     2874            return;
     2875        }
     2876        if ((int) get_option('dlap_lock_post_share_' . $post_id, 0) > time()) {
     2877            return;
     2878        }
     2879        $resolutions = isset($_POST['dlap_resolution']) && is_array($_POST['dlap_resolution']) ? array_map('sanitize_key', wp_unslash($_POST['dlap_resolution'])) : array();
     2880        $post_ids = isset($_POST['dlap_resolution_id']) && is_array($_POST['dlap_resolution_id']) ? array_map('sanitize_text_field', wp_unslash($_POST['dlap_resolution_id'])) : array();
     2881        $resolved = false;
     2882        foreach (array('personal', 'organization') as $target) {
     2883            $choice = isset($resolutions[$target]) && is_string($resolutions[$target]) ? $resolutions[$target] : '';
     2884            $marker = 'dlap_delivery_' . $post_id . '_' . $target;
     2885            $delivery = get_option($marker, array());
     2886            if (!is_array($delivery) || !in_array($delivery['state'] ?? '', array('pending', 'uncertain'), true)) {
     2887                continue;
     2888            }
     2889            $previous_delivery = $delivery;
     2890            $id = isset($post_ids[$target]) && is_string($post_ids[$target]) ? $post_ids[$target] : '';
     2891            if ('published' === $choice && preg_match('/^urn:li:(share|ugcPost):[0-9]+$/', $id)) {
     2892                $ids = get_post_meta($post_id, '_dlap_post_id', true);
     2893                $ids = is_array($ids) ? $ids : array();
     2894                $ids[$target] = $id;
     2895                update_post_meta($post_id, '_dlap_post_id', $ids);
     2896                $delivery['state'] = 'succeeded';
     2897                $delivery['response'] = array('success' => true, 'linkedin_id' => $id, 'message' => 'Publication confirmed by administrator.', 'retryable' => false);
     2898                update_option($marker, $delivery, false);
     2899            } elseif ('not_published' === $choice) {
     2900                delete_option($marker);
     2901            } else {
     2902                continue;
     2903            }
     2904            update_post_meta($post_id, '_dlap_resolution_' . $target, array('choice' => $choice, 'linkedin_id' => $id, 'resolved_at' => time(), 'previous_delivery' => $previous_delivery));
     2905            delete_post_meta($post_id, '_dlap_error_' . $target);
     2906            $resolved = true;
     2907        }
     2908        if ($resolved) {
     2909            $this->schedule_post_share($post_id, 1, 10);
     2910        }
    26902911    }
    26912912
  • devenia-autoposter-for-linkedin/trunk/readme.txt

    r3694264 r3799313  
    6060== Installation ==
    6161
     62For update notifications in WordPress, install [Devenia MCP Updater](https://downloads.devenia.com/devenia-mcp-updater.zip). The updater is optional. You choose which plugins update automatically through WordPress.
     63
    62641. Download the [plugin ZIP](https://downloads.devenia.com/devenia-autoposter-for-linkedin.zip).
    63652. In WordPress, open Plugins > Add New > Upload Plugin. Upload and activate it.
    … …  
    9698== Changelog ==
    9799
     100= 1.5.21 =
     101* Preserve partial queue failures and successful destination receipts. A later retry does not resend to a destination that already succeeded.
     102* Report partial test-post results instead of claiming both destinations succeeded.
     103* Save delivery state before each post and preserve each receipt immediately. Interrupted or uncertain deliveries stop for reconciliation instead of being sent again blindly.
     104* Let administrators record a verified delivery outcome in the post editor before retrying.
     105
     106= 1.5.20 =
     107* Run optional company administration checks only when the token confirms the required administrative scope. No broader OAuth permissions are requested.
     108* Distinguish unverified company posting from optional administrative check results, retaining recorded access denials.
     109* Keep personal and company publishing independent when either destination fails.
     110
     111= 1.5.19 =
     112* Add one dismissible Plugins-screen reminder when Devenia MCP Updater is missing or inactive, with persistent install or activate links. Automatic updates remain your choice in WordPress.
     113
    98114= 1.5.17 =
    99115* Added: Separate post-language selections for personal profiles and company pages.
  • devenia-autoposter-for-linkedin/trunk/uninstall.php

    r3561915 r3799313  
    1717delete_option( 'dlap_settings' );
    1818delete_option( 'dlap_access_token' );
     19delete_option( 'dlap_token_scopes' );
    1920delete_option( 'dlap_token_expires' );
    2021delete_option( 'dlap_member_id' );
Note: See TracChangeset for help on using the changeset viewer.