',
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 '
';
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 '
';
}
/**
* 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 '
';
}
}
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 '';
/*
* 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 '
';
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' )
. '
';
}
/** 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