post_status || ! in_array( $post->post_type, SiloTree_Scan::post_types(), true ) || ! current_user_can( 'edit_post', $post->ID ) ) { return $actions; } $actions['silotree_links'] = sprintf( '%s', esc_url( admin_url( 'admin.php?page=silotree-links&post=' . (int) $post->ID ) ), /* translators: %s: post title. */ esc_attr( sprintf( __( 'Links in and out of “%s”', 'silotree' ), get_the_title( $post ) ) ), esc_html__( 'Links', 'silotree' ) ); return $actions; } /** The report behind the "Links" row action. */ public static function links_page() { $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { wp_die( esc_html__( 'You cannot see that post.', 'silotree' ), 403 ); } echo '
'; printf( '

%s%s

', esc_url( admin_url( 'edit.php' . ( 'post' === get_post_type( $post_id ) ? '' : '?post_type=' . get_post_type( $post_id ) ) ) ), esc_html__( 'All posts', 'silotree' ), esc_html__( 'Links', 'silotree' ) ); SiloTree_Screen::page_head( get_the_title( $post_id ), __( 'Every link in this post, and every post that links to it, with the words each link sits on.', 'silotree' ), sprintf( '%s%s %s', esc_url( (string) get_edit_post_link( $post_id ) ), esc_html__( 'Edit post', 'silotree' ), esc_url( (string) get_permalink( $post_id ) ), esc_html__( 'View', 'silotree' ), esc_html__( '(opens in a new tab)', 'silotree' ) ) ); SiloTree_Screen::page_help( 'per-post-links-report', __( 'Reading the links report', 'silotree' ) ); echo '
'; do_action( 'silotree_screen_top', 'links', $post_id ); self::bulk_result_banner( 'silotree_links_done' ); SiloTree_Screen::links_report( SiloTree_Graph::links_report( $post_id ) ); echo '
'; } /** * The Classic Editor's Silotree box: the same suggestions as the block editor's sidebar, and * the post's links in and out. Only where the block editor is not. */ public static function classic_box( $post_type, $post ) { if ( ! $post || ! in_array( $post_type, SiloTree_Scan::post_types(), true ) ) { return; } if ( function_exists( 'use_block_editor_for_post' ) && use_block_editor_for_post( $post ) ) { return; } add_meta_box( 'silotree-classic', __( 'Silotree', 'silotree' ), array( __CLASS__, 'classic_box_body' ), $post_type, 'side', 'default' ); } public static function classic_box_body( $post ) { echo '
'; echo '

'; self::classic_keywords( $post ); // The shape of what is coming, so the box does not jump when the suggestions arrive. echo '
'; echo '' . esc_html__( 'Reading your draft…', 'silotree' ) . ''; for ( $i = 0; $i < 2; $i++ ) { echo ''; } echo '
'; if ( 'publish' === $post->post_status ) { echo '
' . esc_html__( 'Links in and out, as saved', 'silotree' ) . ''; SiloTree_Screen::links_report( SiloTree_Graph::links_report( $post->ID, 30 ), true ); echo '
'; } echo '
'; } /** * The Classic box's target keywords: drawn by classic.js, saved with the post through the hidden * field (SiloTree_Keywords::classic_save), which is sent only once the author changes the list. */ private static function classic_keywords( $post ) { $state = SiloTree_Keywords::editor_state( $post->ID ); echo '
'; echo '

' . esc_html__( 'Target keywords', 'silotree' ) . '

'; echo '

' . esc_html__( 'The words this post is about. Silotree suggests links to it on them.', 'silotree' ) . '

'; echo '
'; echo ''; echo ''; wp_nonce_field( SiloTree_Keywords::NONCE . '_' . (int) $post->ID, SiloTree_Keywords::NONCE, false ); echo '
'; } /** * Three numbers, one action; connected, the Silotree Score and "Fix N things" above them. Off * by default (silotree_show_dashboard_widget): the dashboard is not Silotree's to nag on. */ public static function widget() { if ( ! get_option( 'silotree_show_dashboard_widget' ) ) { return; } wp_add_dashboard_widget( 'silotree_overview', __( 'Silotree', 'silotree' ), array( __CLASS__, 'widget_body' ) ); } public static function widget_body() { /* * Connected, the score leads, from the cached answer only: the dashboard never waits on the * network (SiloTree_API::cached()). With no score to show, the local counts are the widget. */ $score = SiloTree_Auth::connected() ? SiloTree_Score::from_site( SiloTree_API::cached( '/api/plugin/site', array(), 300 ) ) : null; SiloTree_Score::widget_line( $score ); $summary = SiloTree_Scan::shown_summary()['summary']; echo '

' . esc_html( number_format_i18n( $summary['orphans'] ) ) . ' '; esc_html_e( 'unlinked posts', 'silotree' ); echo ' · ' . esc_html( number_format_i18n( $summary['broken'] ) ) . ' '; esc_html_e( 'broken', 'silotree' ); echo ' · ' . esc_html( number_format_i18n( $summary['dead_ends'] ) ) . ' '; esc_html_e( 'dead ends', 'silotree' ); echo '

'; if ( ! SiloTree_Score::widget_fix( $score ) ) { echo '

'; esc_html_e( 'Open Silotree', 'silotree' ); echo '

'; } } public static function broken() { if ( ! current_user_can( 'edit_posts' ) ) { return; } /** * The broken links this screen lists. An add-on may hold some back for a while (a link * somebody chose to look at later); the counts on Reports are not changed by it. * * @param object[] $rows from_post, url, anchor, post_title. */ $rows = array_values( (array) apply_filters( 'silotree_broken_rows', SiloTree_Graph::broken( 80 ) ) ); $linked = SiloTree_Auth::connected() && ! is_wp_error( SiloTree_API::site() ); echo '
'; SiloTree_Screen::page_head( __( 'Link fixes', 'silotree' ), __( 'Links in your posts that point at a page this site does not publish.', 'silotree' ) ); SiloTree_Screen::page_help( 'fix-broken-links', __( 'Fixing broken and redirected links', 'silotree' ) ); echo '
'; /* * Connected, Link fixes in the app is the place to work: its crawl also sees redirects and * chains, and fixes them in one batch. This screen says so and keeps its own count, labelled * as the quick check it is, with the list one click away rather than in the way. */ if ( $linked ) { SiloTree_Screen::fixes_in_app(); } /** * The top of one of Silotree's screens, under its heading: where an add-on says what its * last action on this screen did. * * @param string $screen 'reports', 'broken' or 'links'. * @param int $post_id On 'links', the post the screen is about; 0 elsewhere. */ do_action( 'silotree_screen_top', 'broken', 0 ); self::bulk_result_banner( 'silotree_broken_done' ); if ( $linked ) { echo '
' . esc_html( sprintf( /* translators: %s: broken links this plugin counted. */ __( 'This plugin’s quick check: %s', 'silotree' ), sprintf( /* translators: %s: a number. */ _n( '%s broken link', '%s broken links', count( $rows ), 'silotree' ), number_format_i18n( count( $rows ) ) ) ) ) . ''; } SiloTree_Screen::broken_table( $rows ); if ( $linked ) { echo '
'; } /** * The foot of one of Silotree's screens, under its list: where an add-on puts what is less * urgent than the list itself. * * @param string $screen 'broken'. */ do_action( 'silotree_screen_bottom', 'broken' ); // A batch fix is worth suggesting once there is more than one to fix. if ( count( $rows ) > 1 && current_user_can( 'manage_options' ) ) { SiloTree_Growth::pro_card( 'broken' ); } echo '
'; } public static function undo() { if ( ! current_user_can( 'edit_others_posts' ) ) { return; } echo '
'; SiloTree_Screen::page_head( __( 'Undo history', 'silotree' ), __( 'Each batch of links you applied, newest first. Undo takes out exactly the links a batch added and leaves everything written since.', 'silotree' ) ); SiloTree_Screen::page_help( 'write-and-undo-batches', __( 'How undo works', 'silotree' ) ); echo '
'; /* * Back from an Undo button somewhere else (a notice, another screen) that undid a batch * and sent the person here to see the result. */ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only; the undo itself was nonce-checked. $undone = isset( $_GET['silotree_undone'] ) ? sanitize_key( wp_unslash( $_GET['silotree_undone'] ) ) : ''; if ( 'undone' === $undone ) { SiloTree_Screen::banner( 'success', __( 'That batch was put back.', 'silotree' ), __( 'Its changes are out of those posts, and everything else in them is as it was.', 'silotree' ) ); } elseif ( 'partial' === $undone ) { SiloTree_Screen::banner( 'error', __( 'Not every link could be put back.', 'silotree' ), __( 'Some of those posts have been edited since. Any batch still listed below can be tried again.', 'silotree' ) ); } if ( isset( $_POST['silotree_undo'] ) && check_admin_referer( 'silotree_undo', '_silotree' ) ) { $result = SiloTree_Apply::revert( sanitize_text_field( wp_unslash( $_POST['silotree_undo'] ) ) ); if ( is_wp_error( $result ) ) { SiloTree_Screen::banner( 'error', __( 'Nothing was undone.', 'silotree' ), $result->get_error_message() ); } elseif ( ! empty( $result['failed'] ) ) { $list = ''; SiloTree_Screen::banner( 'error', sprintf( /* translators: 1: posts and descriptions put back, 2: those that could not be. */ __( '%1$d were put back and %2$d could not be:', 'silotree' ), count( $result['restored'] ) + count( $result['restored_terms'] ?? array() ), count( $result['failed'] ) ), '', $list ); } else { SiloTree_Screen::banner( 'success', __( 'That batch was put back.', 'silotree' ), __( 'Its links are out of those posts, and everything else in them is as it was.', 'silotree' ) ); } } SiloTree_Screen::undo_table( SiloTree_Graph::undo_batches() ); echo '
'; } /** * The Silotree screen. * * Local first, always. The plugin has to be worth installing before anybody has an account, so * what it worked out on this server leads, complete and uncapped, and the case for an account * sits beside it as an argument rather than a wall. */ public static function overview() { if ( ! current_user_can( 'edit_posts' ) ) { return; } echo '
'; self::handle_actions(); SiloTree_Graph::catch_up(); $payload = SiloTree_Growth::scan_payload(); $first = $payload['first']; // The last complete scan's figures while a rescan runs, never the half-built graph's. $summary = SiloTree_Scan::shown_summary()['summary']; $cloud = SiloTree_API::site(); $linked = ! is_wp_error( $cloud ); $app = $linked ? SiloTree_Growth::app_link( '/site/{site}', 'overview' ) : ''; $paid = $linked && SiloTree_Growth::paid(); SiloTree_Screen::header( $linked, $app ); SiloTree_Screen::intro( $linked ? __( 'Your internal links, worked out on your own server, with Silotree’s best suggestions beside them.', 'silotree' ) : __( 'Your internal links, worked out on your own server. No account, nothing sent anywhere, and no limits.', 'silotree' ) ); SiloTree_Screen::page_help( 'plugin-reports', __( 'Reading the Reports screen', 'silotree' ) ); echo '
'; SiloTree_Growth::review_card(); /** See SiloTree_Admin::broken(). */ do_action( 'silotree_screen_top', 'reports', 0 ); SiloTree_Screen::scan_banner( $payload ); /* * The Silotree Score, connected: the app's number for this site, never one worked out here * (SiloTree_Score). It leads because it is the one figure that sums the rest up. */ $score = $linked ? SiloTree_Score::from_site( $cloud ) : null; if ( $score ) { SiloTree_Score::card( $score, is_string( $cloud['last_crawled_at'] ?? null ) ? $cloud['last_crawled_at'] : '' ); } if ( $linked && ! get_option( 'silotree_last_map' ) && false === get_option( 'silotree_map_pending', false ) && ! wp_next_scheduled( 'silotree_map_posts' ) ) { wp_schedule_single_event( time() + 10, 'silotree_map_posts' ); } // A first scan has no figures yet: the banner's progress is the whole story until it ends. if ( ! $first ) { $cards = array( array( 'label' => __( 'Unlinked posts', 'silotree' ), 'value' => number_format_i18n( $summary['orphans'] ), 'note' => __( 'no other post links to them', 'silotree' ), 'tone' => $summary['orphans'] > 0 ? 'problem' : '', 'url' => '#st-orphans', ), array( 'label' => __( 'Dead ends', 'silotree' ), 'value' => number_format_i18n( $summary['dead_ends'] ), 'note' => __( 'posts that link nowhere on your site', 'silotree' ), 'tone' => $summary['dead_ends'] > 0 ? 'warn' : '', 'url' => '', ), array( 'label' => __( 'Broken links', 'silotree' ), 'value' => number_format_i18n( $summary['broken'] ), 'note' => __( 'pointing at a page you do not publish', 'silotree' ), 'tone' => $summary['broken'] > 0 ? 'warn' : '', 'url' => $linked ? SiloTree_Growth::app_link( '/site/{site}/link-health', 'overview' ) : admin_url( 'admin.php?page=silotree-broken' ), ), ); /* * Depth is shown only when it can be worked out: with no home page to count from, or * nothing reached past it, the card is left out rather than printing a dash or an * average of nothing (the same rule as the app's screens). */ if ( null !== $summary['avg_depth'] ) { $cards[] = array( 'label' => __( 'Avg click depth', 'silotree' ), 'value' => number_format_i18n( $summary['avg_depth'], 1 ), 'note' => __( 'clicks from your home page, menus included', 'silotree' ), 'tone' => '', 'url' => '', ); } SiloTree_Screen::stats( $cards, $payload['stale'], // Connected, the app's crawl is the one to work from; these are counted here, from the posts. $linked ? __( 'This plugin’s quick check, counted from your posts on this server. Silotree’s scan, in the app, is the one to work from.', 'silotree' ) : '' ); } // Never connected: the way to a score. Not when connected and the app is only out of reach. if ( ! $first && ! SiloTree_Auth::connected() ) { SiloTree_Score::prompt(); } echo '
'; echo '
'; if ( $linked ) { // Only the count: the links themselves are approved in the app (SiloTree_Screen::work_in_app). $best = SiloTree_API::get( '/api/plugin/opportunities', array( 'limit' => 1 ), 120 ); SiloTree_Screen::work_in_app( is_wp_error( $best ) ? -1 : (int) ( $best['waiting'] ?? 0 ) ); echo '
'; wp_nonce_field( 'silotree_map', '_silotree' ); printf( ' %s', esc_attr__( 'Matching…', 'silotree' ), esc_html__( 'Match posts to your scan', 'silotree' ), esc_html__( 'Matches your WordPress posts to the pages in Silotree’s last scan, so approved links can be written without scanning again.', 'silotree' ) ); echo '
'; /* * The local list stays on a connected site too: it is this plugin's own free report, and * the list an add-on's batch actions (Silotree Pro's "Add suggested links…") work on. */ if ( ! $first ) { self::bulk_result_banner( 'silotree_report_done' ); $orphans = SiloTree_Graph::orphans( 8 ); SiloTree_Screen::orphan_table( $orphans, $summary['orphans'], count( $orphans ) ); } } else { if ( $first ) { // Not "every post has a way in": nothing has been counted yet, so nothing is claimed. echo '

' . esc_html__( 'Unlinked posts', 'silotree' ) . '

'; SiloTree_Screen::empty_state( 'setup-scan.svg', __( 'Your first scan is still reading', 'silotree' ), __( 'Unlinked posts, the ones no other post links to, are listed here, longest first, as soon as every post has been read.', 'silotree' ) ); echo '
'; } else { self::bulk_result_banner( 'silotree_report_done' ); $orphans = SiloTree_Graph::orphans( 8 ); SiloTree_Screen::orphan_table( $orphans, $summary['orphans'], count( $orphans ) ); } if ( ! $first && $summary['orphans'] > 1 && current_user_can( 'manage_options' ) ) { SiloTree_Growth::card( 'orphans', array( 'title' => sprintf( /* translators: %s: number of posts nothing links to. */ __( 'Get a free link list for all %s', 'silotree' ), number_format_i18n( $summary['orphans'] ) ), 'text' => __( 'Silotree reads every post, finds the sentences that should link to each of these, matched by meaning, and lists them for you to approve. Your first 3 are written for you. Free account, no card, and connecting a new site starts 14 days of Solo, Silotree Pro included.', 'silotree' ), 'cta' => __( 'Get my free link list', 'silotree' ), 'url' => SiloTree_Growth::connect_url( 'orphans' ), 'note' => __( 'Or keep going one post at a time here. Both are free.', 'silotree' ), ) ); } } // Category and tag pages: which are link targets, and which posts already link to. if ( ! $first && class_exists( 'SiloTree_Terms' ) ) { SiloTree_Screen::term_table( SiloTree_Terms::report( 12 ) ); } // Only while click counting is on, or counts from before are kept (SiloTree_Clicks). SiloTree_Clicks::report_card(); echo '
'; echo '
'; if ( $linked ) { // A count from the service and a link to it; nothing here waits on it (SiloTree_Growth::meaning_card). SiloTree_Growth::meaning_card( $cloud ); } if ( ! $linked ) { // One loud button per screen: when the orphan list carries it, the panel's steps back. $orphan_prompt = $summary['orphans'] > 1 && current_user_can( 'manage_options' ) && SiloTree_Growth::visible( 'orphans' ); // One real link from the list, found on this server, so the offer shows rather than tells. $sample = array(); foreach ( SiloTree_Graph::orphans( 3 ) as $orphan ) { $found = SiloTree_Inbound::for_post( (int) $orphan->post_id, 1 ); if ( $found ) { $sample = $found[0] + array( 'target' => get_the_title( $orphan->post_id ) ); break; } } SiloTree_Screen::sidebar( SiloTree_Growth::connect_url( 'overview' ), $orphan_prompt, (int) $summary['orphans'], $sample ); } elseif ( ! $paid && current_user_can( 'manage_options' ) ) { SiloTree_Growth::card( 'overview', array( 'tone' => 'forest', 'eyebrow' => __( 'Solo, Studio and Agency', 'silotree' ), 'title' => __( 'Apply more suggestions from Silotree', 'silotree' ), 'text' => ( SiloTree_Growth::trial_ended() ? __( 'Your 14-day Solo trial has ended. Links written during it stay.', 'silotree' ) . ' ' : '' ) . __( 'Your free plan covers your first 3 suggestions. Apply them here in WordPress, or from the app if you turn on one-click apply with your own Application Password. A paid plan covers as many as you approve, from the app or the weekly email, and includes Silotree Pro.', 'silotree' ), 'points' => array( __( 'Apply as many suggestions as you approve, in batches you can undo', 'silotree' ), __( 'Posts grouped by topic, and competing posts found', 'silotree' ), __( 'Ranked by the clicks each link should earn', 'silotree' ), __( 'Silotree Pro for WordPress: keyword rules, bulk links, links that follow moved posts', 'silotree' ), ), 'cta' => __( 'See plans', 'silotree' ), 'url' => SiloTree_Growth::upgrade_url( 'overview' ), 'note' => __( '30-day money-back guarantee', 'silotree' ), ) ); } if ( ! $first ) { SiloTree_Growth::pro_card( 'overview', (int) $summary['orphans'] ); } echo '

' . esc_html__( 'Everything here can be undone', 'silotree' ) . '

' . esc_html__( 'Every link the plugin writes is one batch with a revision of each post. Undo works without an account, even if Silotree disappeared tomorrow.', 'silotree' ) . '

' . ( current_user_can( 'edit_others_posts' ) ? '

' . esc_html__( 'Undo history', 'silotree' ) . '

' : '' ) . '
'; echo '
'; echo '
'; echo '
'; } /** Re-scan and CSV, both nonce-checked because both are things a link should not be able to do. */ private static function handle_actions() { if ( isset( $_POST['silotree_rescan'] ) && check_admin_referer( 'silotree_rescan', '_silotree' ) ) { SiloTree_Scan::start(); SiloTree_Screen::banner( 'success', __( 'Scanning again.', 'silotree' ), __( 'It runs in the background; you can leave this page.', 'silotree' ) ); } if ( isset( $_POST['silotree_map'] ) && check_admin_referer( 'silotree_map', '_silotree' ) ) { $result = SiloTree_API::map_posts(); if ( is_wp_error( $result ) ) { SiloTree_Screen::banner( 'error', __( 'Posts were not mapped.', 'silotree' ), $result->get_error_message() ); } else { $mapped = isset( $result['mapped'] ) ? (int) $result['mapped'] : 0; SiloTree_API::forget(); SiloTree_Screen::banner( 'success', sprintf( /* translators: %d: how many posts were matched to crawled pages. */ _n( 'Mapped %d post to Silotree.', 'Mapped %d posts to Silotree.', $mapped, 'silotree' ), $mapped ), ! empty( $result['more'] ) ? __( 'The rest are being mapped in the background.', 'silotree' ) : '' ); } } } /** * A batch action from the posts-nothing-links-to report, handed to whoever offered it. * * The report only shows checkboxes when an add-on offers an action through * silotree_report_bulk_actions, so with nothing listening this is never reached. When it is: * the nonce and capability are checked here, the post ids cleaned, and anything not on offer * refused, before silotree_report_bulk runs. The add-on does the work and normally redirects; * if it does not, the person lands back on Reports. */ public static function report_bulk() { check_admin_referer( 'silotree_report_bulk', '_silotree' ); if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You cannot do that.', 'silotree' ), 403 ); } $action = isset( $_POST['silotree_bulk'] ) ? sanitize_key( wp_unslash( $_POST['silotree_bulk'] ) ) : ''; $offered = (array) apply_filters( 'silotree_report_bulk_actions', array() ); $ids = isset( $_POST['post_ids'] ) ? array_values( array_unique( array_filter( array_map( 'absint', (array) wp_unslash( $_POST['post_ids'] ) ) ) ) ) : array(); $back = admin_url( 'admin.php?page=silotree-overview#st-orphans' ); if ( '' === $action || ! isset( $offered[ $action ] ) ) { wp_safe_redirect( $back ); exit; } if ( ! $ids ) { wp_safe_redirect( add_query_arg( 'silotree_report_done', 'none', $back ) ); exit; } /** * Run a batch action from the report. Nonce and capability are already checked. * * @param string $action One of the keys from silotree_report_bulk_actions. * @param int[] $post_ids The posts ticked, nothing links to each. */ do_action( 'silotree_report_bulk', $action, $ids ); wp_safe_redirect( $back ); exit; } /** * A batch action from Broken links, the same way: only reached when an add-on offers one * through silotree_broken_bulk_actions. Each ticked row arrives as "post id|address". */ public static function broken_bulk() { check_admin_referer( 'silotree_broken_bulk', '_silotree' ); if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You cannot do that.', 'silotree' ), 403 ); } $action = isset( $_POST['silotree_bulk'] ) ? sanitize_key( wp_unslash( $_POST['silotree_bulk'] ) ) : ''; $offered = (array) apply_filters( 'silotree_broken_bulk_actions', array() ); $back = admin_url( 'admin.php?page=silotree-broken' ); $items = array(); foreach ( isset( $_POST['links'] ) ? (array) wp_unslash( $_POST['links'] ) : array() as $raw ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each part is sanitized below. $parts = explode( '|', (string) $raw, 2 ); $post = absint( $parts[0] ); $url = isset( $parts[1] ) ? esc_url_raw( trim( $parts[1] ) ) : ''; if ( $post && '' !== $url ) { $items[ $post . '|' . $url ] = array( 'from_post' => $post, 'url' => $url, ); } } if ( '' === $action || ! isset( $offered[ $action ] ) ) { wp_safe_redirect( $back ); exit; } if ( ! $items ) { wp_safe_redirect( add_query_arg( 'silotree_broken_done', 'none', $back ) ); exit; } /** * Run a batch action from Broken links. Nonce and capability are already checked. * * @param string $action One of the keys from silotree_broken_bulk_actions. * @param array $items Each: from_post (the post the link is in), url (as written). */ do_action( 'silotree_broken_bulk', $action, array_values( $items ) ); wp_safe_redirect( $back ); exit; } /** * A batch action from one post's Links screen, the same way: only reached when an add-on offers * one through silotree_links_bulk_actions. Each ticked row arrives as "post id|address", the * post being the one the link is written in. */ public static function links_bulk() { check_admin_referer( 'silotree_links_bulk', '_silotree' ); if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You cannot do that.', 'silotree' ), 403 ); } $post_id = isset( $_POST['post'] ) ? absint( $_POST['post'] ) : 0; $action = isset( $_POST['silotree_bulk'] ) ? sanitize_key( wp_unslash( $_POST['silotree_bulk'] ) ) : ''; $offered = (array) apply_filters( 'silotree_links_bulk_actions', array(), $post_id ); $back = admin_url( 'admin.php?page=silotree-links&post=' . $post_id ); $items = array(); foreach ( isset( $_POST['links'] ) ? (array) wp_unslash( $_POST['links'] ) : array() as $raw ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each part is sanitized below. $parts = explode( '|', (string) $raw, 2 ); $post = absint( $parts[0] ); $url = isset( $parts[1] ) ? esc_url_raw( trim( $parts[1] ) ) : ''; if ( $post && '' !== $url ) { $items[ $post . '|' . $url ] = array( 'from_post' => $post, 'url' => $url, ); } } if ( ! $post_id || '' === $action || ! isset( $offered[ $action ] ) ) { wp_safe_redirect( $post_id ? $back : admin_url( 'admin.php?page=silotree-overview' ) ); exit; } if ( ! $items ) { wp_safe_redirect( add_query_arg( 'silotree_links_done', 'none', $back ) ); exit; } /** * Run a batch action from one post's Links screen. Nonce and capability are already checked. * * @param string $action One of the keys from silotree_links_bulk_actions. * @param array $items Each: from_post (the post the link is in), url (as written). * @param int $post_id The post the screen is about. */ do_action( 'silotree_links_bulk', $action, array_values( $items ), $post_id ); wp_safe_redirect( $back ); exit; } /** "Nothing was ticked", after a batch action sent back with nothing to do. */ private static function bulk_result_banner( $arg ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. if ( isset( $_GET[ $arg ] ) && 'none' === sanitize_key( wp_unslash( $_GET[ $arg ] ) ) ) { SiloTree_Screen::banner( 'info', __( 'Nothing was selected.', 'silotree' ), __( 'Tick the rows to include, then choose the action again.', 'silotree' ) ); } } /** * Everything the scan found, as a file. * * Their data, so there is no limit on it and no account needed to take it away. Sent before any * of the page is rendered, which is why it is hooked early rather than called from the screen. */ public static function maybe_csv() { // The whole site's link data in one file: for people who can edit every post, like Undo. if ( ! isset( $_GET['silotree_csv'] ) || ! current_user_can( 'edit_others_posts' ) ) { return; } check_admin_referer( 'silotree_csv' ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=silotree-' . gmdate( 'Y-m-d' ) . '.csv' ); $out = fopen( 'php://output', 'w' ); fputcsv( $out, array( 'post_id', 'title', 'url', 'published', 'words', 'inbound_links', 'outbound_links', 'click_depth' ) ); foreach ( SiloTree_Graph::all_rows() as $row ) { fputcsv( $out, array( $row->post_id, get_the_title( $row->post_id ), get_permalink( $row->post_id ), mysql2date( 'Y-m-d', $row->post_date ), $row->words, $row->inbound, $row->outbound, null === $row->click_depth ? '' : $row->click_depth, ) ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- the php://output stream of a download, not a file. exit; } /** * Two columns, because sorting an archive by fewest inbound links is the thing people came for. * * The count is the plugin's own, from the local scan, so it is there before anybody has an * account. The suggestions column only appears when a site is connected. a column of dashes * advertising a feature is worse than no column. */ public static function column( $columns ) { $columns['silotree'] = __( 'Links in', 'silotree' ); // The cached answer only: a posts list never waits on the network to draw its header. if ( ! is_wp_error( SiloTree_API::cached( '/api/plugin/site', array(), 300 ) ) ) { $columns['silotree_sug'] = __( 'Suggestions', 'silotree' ); } return $columns; } /** * "Find links for selected". * * Reading, never writing. It answers how many links each of these posts could carry and where * they would go, and then stops. a bulk action that edited twenty articles from a dropdown is * the kind of thing this product exists not to be. */ public static function bulk_action( $actions ) { $actions['silotree_find'] = __( 'Find links for selected', 'silotree' ); return $actions; } public static function handle_bulk( $redirect, $action, $post_ids ) { if ( 'silotree_find' !== $action ) { return $redirect; } $found = 0; $posts = 0; foreach ( array_slice( (array) $post_ids, 0, 40 ) as $post_id ) { if ( ! current_user_can( 'edit_post', $post_id ) ) { continue; } $rows = SiloTree_Inbound::outbound_for( (int) $post_id, null, 4 ); if ( $rows ) { ++$posts; $found += count( $rows ); } } return add_query_arg( array( 'silotree_found' => $found, 'silotree_posts' => $posts, 'silotree_seen' => min( 40, count( (array) $post_ids ) ), ), $redirect ); } public static function bulk_notice() { if ( ! isset( $_GET['silotree_found'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || 'edit' !== $screen->base || ! current_user_can( 'edit_posts' ) ) { return; } $found = (int) $_GET['silotree_found']; // phpcs:ignore WordPress.Security.NonceVerification $posts = isset( $_GET['silotree_posts'] ) ? absint( $_GET['silotree_posts'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification $seen = isset( $_GET['silotree_seen'] ) ? absint( $_GET['silotree_seen'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification if ( 0 === $found ) { printf( '

%s

', esc_html( sprintf( /* translators: %s: how many posts were looked at. */ _n( 'Nothing to add in %s post. It already links where it should.', 'Nothing to add in those %s posts. They already link where they should.', $seen, 'silotree' ), number_format_i18n( $seen ) ) ) ); return; } printf( '

%s %s

', esc_html( sprintf( /* translators: 1: number of links, 2: number of posts. */ __( '%1$s worth adding, across %2$s.', 'silotree' ), /* translators: %s: a number of links. */ sprintf( _n( '%s link', '%s links', $found, 'silotree' ), number_format_i18n( $found ) ), /* translators: %s: a number of posts. */ sprintf( _n( '%s post', '%s posts', $posts, 'silotree' ), number_format_i18n( $posts ) ) ) ), esc_html__( 'Open a post to see them in the Silotree panel; nothing was changed.', 'silotree' ) ); } /** * The link column and the bulk action, on every screen for a post type the scan reads. * * These were registered for `post` alone, spelled into six filter names. The scan was widened * to every public post type. so a recipe site's recipes and a documentation site's pages were * being read, counted, and reported as orphans on the Silotree screen, while the list screen * where somebody would actually work through them showed no link column and offered no bulk * action. The finding was there and the way to act on it was missing. * * Registered on admin_init rather than at load, because a custom post type does not exist yet * when this file is included and the list would have been `post` and `page` again by accident. */ public static function list_screens() { foreach ( SiloTree_Scan::post_types() as $type ) { add_filter( "manage_{$type}_posts_columns", array( __CLASS__, 'column' ) ); add_action( "manage_{$type}_posts_custom_column", array( __CLASS__, 'cell' ), 10, 2 ); add_filter( "manage_edit-{$type}_sortable_columns", array( __CLASS__, 'sortable' ) ); add_filter( "bulk_actions-edit-{$type}", array( __CLASS__, 'bulk_action' ) ); add_filter( "handle_bulk_actions-edit-{$type}", array( __CLASS__, 'handle_bulk' ), 10, 3 ); } } public static function sortable( $columns ) { $columns['silotree'] = 'silotree_inbound'; return $columns; } /** * Sorting done in the query, not in the browser. * * A JavaScript sort of the current page orders twenty posts out of two thousand, which on the * one interaction this product is most used for would be an answer that looks right and is not. * The join is left, so posts the scan has not reached yet sort as zero rather than vanishing. */ public static function sort_clauses( $clauses, $query ) { global $wpdb; if ( ! is_admin() || ! $query->is_main_query() || 'silotree_inbound' !== $query->get( 'orderby' ) ) { return $clauses; } $table = $wpdb->prefix . 'silotree_pages'; $clauses['join'] .= " LEFT JOIN $table silotree_p ON silotree_p.post_id = {$wpdb->posts}.ID"; $direction = 'DESC' === strtoupper( (string) $query->get( 'order' ) ) ? 'DESC' : 'ASC'; $clauses['orderby'] = "COALESCE(silotree_p.inbound, 0) $direction, {$wpdb->posts}.post_date DESC"; return $clauses; } /** * One cell. * * Three states, the same three the app uses. A post on one link is not linked. it is one edit * away from being an orphan. and a column that only knew "zero or not" made the two screens * disagree about the same post. */ public static function cell( $column, $post_id ) { if ( 'silotree_sug' === $column ) { $summary = self::posts_summary(); $row = is_wp_error( $summary ) ? null : ( $summary[ $post_id ] ?? null ); $pending = $row ? (int) ( $row['pending'] ?? 0 ) : 0; // Links waiting in Links → To add that go in this post, the ones its editor panel offers. if ( $pending > 0 ) { printf( '%2$d', esc_attr( sprintf( /* translators: %d: number of links. */ _n( '%d link waiting to be added to this post', '%d links waiting to be added to this post', $pending, 'silotree' ), $pending ) ), (int) $pending ); } else { echo '-'; } return; } if ( 'silotree' !== $column ) { return; } // Every visible row's counts in one query, the first time a cell asks. static $primed = false; if ( ! $primed ) { $primed = true; global $wp_query; if ( isset( $wp_query->posts ) && is_array( $wp_query->posts ) ) { SiloTree_Graph::prime( wp_list_pluck( $wp_query->posts, 'ID' ) ); } } $local = SiloTree_Graph::for_post( $post_id ); if ( null === $local ) { // Not scanned yet rather than zero, and it says which. A zero here would be a claim. echo '-'; return; } $in = (int) $local['inbound']; /* * The front page is not an orphan and is not thin. Nothing contextual links to a home page, * because people reach it from the logo, and the logo is navigation. * * The same rule is in SiloTree_Graph::summary() and ::orphans(). This is the third place it * has to hold, because this cell asks the graph for one post's count rather than asking for * the orphan list. so it went on calling the site's front page an orphan, in red, on the * Pages screen, after the other two had been fixed. */ if ( 0 === $in && SiloTree_Graph::is_front_page( $post_id ) ) { printf( '%d', (int) $in ); return; } if ( 0 === $in ) { $tone = 'silotree-problem'; $note = __( 'unlinked', 'silotree' ); } elseif ( $in < 3 ) { $tone = 'silotree-warn'; $note = __( 'thin', 'silotree' ); } else { $tone = ''; $note = ''; } printf( '%d', esc_attr( $tone ), (int) $in ); if ( $note ) { echo ' ' . esc_html( $note ) . ''; } } /** * Inbound counts for every post on the current screen, in one request. * * Held for the life of the request, so twenty cells share one call. Cached briefly on top of * that, because somebody paging through their posts list does not need the number to be * second-accurate and does need the page to load. */ private static function posts_summary() { static $summary = null; if ( null !== $summary ) { return $summary; } $data = SiloTree_API::cached( '/api/plugin/posts', array(), 300 ); if ( is_wp_error( $data ) ) { $summary = $data; return $summary; } $summary = array(); foreach ( $data['posts'] ?? array() as $row ) { if ( isset( $row['cms_post_id'] ) ) { $summary[ (int) $row['cms_post_id'] ] = $row; } } return $summary; } /** * The editor panel's script, only where the block editor actually is. * * Not on the classic editor: there the scripts' dependencies (wp-edit-post and the rest of the * block editor) were loaded for nothing, on every post edit screen, and the panel had nowhere to * appear. */ public static function assets( $hook ) { if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) { return; } $post = get_post(); if ( ! $post || ! in_array( $post->post_type, SiloTree_Scan::post_types(), true ) ) { return; } $config = array( 'panelEndpoint' => rest_url( 'silotree/v1/panel' ), 'linksEndpoint' => rest_url( 'silotree/v1/links' ), 'inboundApply' => rest_url( 'silotree/v1/inbound/apply' ), 'undoEndpoint' => rest_url( 'silotree/v1/undo' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'neverLinkUrl' => current_user_can( SiloTree_Ignore_Screen::CAP ) ? SiloTree_Ignore_Screen::url() : '', 'dismissEndpoint' => rest_url( 'silotree/v1/prompt/dismiss' ), 'teaser' => self::editor_teaser(), // The block allowlist, from the linker itself, so the editor writes only where it would. 'proseBlocks' => array_values( array_filter( SiloTree_Linker::prose_blocks() ) ), 'containerBlocks' => array_values( SiloTree_Linker::container_blocks() ), 'allDone' => SiloTree_Screen::illustration( 'all-done.svg' ), // Known before the first suggestions arrive, so a builder page never shows the plain-post panel first. 'builder' => class_exists( 'SiloTree_Builders' ) ? SiloTree_Builders::editor_state( $post ) : null, // Target keywords: where Search Console suggestions come from, the cap, and the list // the editor starts from (with whether it is the SEO plugin's, imported). 'keywordsSuggest' => rest_url( 'silotree/v1/keywords/suggest' ), 'keywordsMax' => SiloTree_Keywords::MAX, 'keywords' => SiloTree_Keywords::editor_state( $post->ID ), ); // The anchor-finding rules both editors share, so the two cannot disagree about a phrase. wp_register_script( 'silotree-prose', plugins_url( 'assets/prose.js', SILOTREE_DIR . 'silotree.php' ), array(), SILOTREE_VERSION, true ); if ( ! function_exists( 'use_block_editor_for_post' ) || ! use_block_editor_for_post( $post ) ) { wp_enqueue_script( 'silotree-classic', plugins_url( 'assets/classic.js', SILOTREE_DIR . 'silotree.php' ), array( 'silotree-prose', 'wp-i18n' ), SILOTREE_VERSION, true ); wp_set_script_translations( 'silotree-classic', 'silotree', SILOTREE_DIR . 'languages' ); wp_localize_script( 'silotree-classic', 'SiloTreeEditor', $config ); /** The editor's scripts are queued (the Classic Editor's here), for an add-on to extend. */ do_action( 'silotree_editor_assets', $post, false ); return; } wp_enqueue_script( 'silotree-editor', plugins_url( 'assets/editor.js', SILOTREE_DIR . 'silotree.php' ), array( 'silotree-prose', 'wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-block-editor', 'wp-i18n' ), SILOTREE_VERSION, true ); wp_set_script_translations( 'silotree-editor', 'silotree', SILOTREE_DIR . 'languages' ); wp_localize_script( 'silotree-editor', 'SiloTreeEditor', $config ); /* * The publish prompt is a separate script from the panel. * * The panel needs the cloud; this works from the site's own posts alone (it uses the * service's matches only once the site is connected), and it is the one surface that has to * work for somebody who has never signed up to anything. */ wp_enqueue_script( 'silotree-publish-prompt', plugins_url( 'assets/publish-prompt.js', SILOTREE_DIR . 'silotree.php' ), array( 'wp-data', 'wp-element', 'wp-editor', 'wp-i18n', 'wp-hooks', 'wp-api-fetch', 'wp-blocks', 'wp-core-data' ), SILOTREE_VERSION, true ); wp_set_script_translations( 'silotree-publish-prompt', 'silotree', SILOTREE_DIR . 'languages' ); wp_localize_script( 'silotree-publish-prompt', 'SiloTreePublish', array( 'endpoint' => rest_url( 'silotree/v1/new-post' ), 'applyEndpoint' => rest_url( 'silotree/v1/new-post/apply' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'dismissEndpoint' => rest_url( 'silotree/v1/prompt/dismiss' ), // One line after the links are in, before anybody has an account; a link, nothing fetched. 'teaser' => ! SiloTree_Auth::connected() && current_user_can( 'manage_options' ) && SiloTree_Growth::visible( 'publish' ) ? array( 'url' => SiloTree_Growth::connect_url( 'publish' ) ) : null, ) ); /** * The block editor's scripts are queued: the sidebar (silotree-editor) and the publish * prompt (silotree-publish-prompt), for an add-on to extend. The prompt reads the wp.hooks * filters silotree.publishPrompt.rowControl and silotree.publishPrompt.footer when it opens. * * @param WP_Post $post The post being edited. * @param bool $block_editor True here; false for the Classic Editor. */ do_action( 'silotree_editor_assets', $post, true ); } /** * The editor panel's note about the service, or null for none: none on a paid plan, and none * once this person has put it away. 'explain' before an account exists; 'plans' on the free * plan, a single plain line that the free audit also matches by meaning, with a link to it in * the app, and nothing drawn as locked. * * @return array{mode:string,url:string}|null */ private static function editor_teaser() { if ( ! SiloTree_Growth::visible( 'editor' ) ) { return null; } if ( SiloTree_Auth::connected() ) { return SiloTree_Growth::paid() ? null : array( 'mode' => 'plans', 'url' => current_user_can( 'manage_options' ) ? SiloTree_Growth::app_link( '/site/{site}/links', 'editor' ) : '', ); } return array( 'mode' => 'explain', 'url' => current_user_can( 'manage_options' ) ? SiloTree_Growth::connect_url( 'editor' ) : '', ); } /** * Styles, added inline to an enqueued handle because the plugin has no build step and this is * about a kilobyte. * * Brand accents on native WordPress controls, never a restyle of WordPress: buttons keep their * admin sizing and shape and only take the action colour; notices, list tables and the menu are * untouched. The inline rules below use the admin system stack; the Silotree screens' own * stylesheet (assets/admin.css) loads its bundled fonts from this plugin's folder, never from a * third-party font service. Colours are * the tokens in apps/web/app/tokens.css. * * The Gutenberg parts (the editor panel and the publish prompt) are branded by setting the * editor's own theme-colour variables on their containers, so the native Button components * pick the colour up and still behave exactly like every other button in the editor. */ public static function styles( $hook = '' ) { /* * The brand stylesheet and its few lines of behaviour, only where Silotree draws a screen: * its own pages, the settings page, and the post editor (the sidebar and the Classic box). */ if ( false !== strpos( (string) $hook, 'silotree' ) || in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) { wp_enqueue_style( 'silotree-ui', plugins_url( 'assets/admin.css', SILOTREE_DIR . 'silotree.php' ), array(), SILOTREE_VERSION ); wp_enqueue_script( 'silotree-ui', plugins_url( 'assets/admin.js', SILOTREE_DIR . 'silotree.php' ), array(), SILOTREE_VERSION, true ); wp_localize_script( 'silotree-ui', 'SiloTreeUI', array( 'nonce' => wp_create_nonce( 'wp_rest' ), 'dismiss' => rest_url( 'silotree/v1/prompt/dismiss' ), 'scanStep' => rest_url( 'silotree/v1/scan/step' ), /* translators: 1: posts read so far, 2: posts in total. */ 'progress' => __( 'Reading your posts: %1$s of %2$s', 'silotree' ), /* translators: 1: posts read so far, 2: posts in total. */ 'rescan' => __( 'Rescanning… %1$s of %2$s posts', 'silotree' ), 'scanFailed' => __( 'The scan paused. It carries on in the background; reload this page in a minute.', 'silotree' ), /* translators: %s: how many rows are ticked. */ 'selected' => __( '%s selected', 'silotree' ), 'selectNone' => __( 'Tick the rows to include.', 'silotree' ), 'selectFirst' => __( 'Tick at least one row first.', 'silotree' ), ) ); } // A handle with no file, so the rules go out through WordPress's own style queue rather // than as a