register();
}
}
/**
* The plugin instance.
*
* @return self
*/
public static function instance() {
self::boot();
return self::$instance;
}
/**
* Wires services together.
*/
private function __construct() {
$this->cache = new Cache();
$this->connections = new ConnectionRepository( new Crypto() );
$this->sites = new ProfileRepository();
$this->access = new AccessPolicy( $this->sites );
$this->credentials = new CredentialsFactory( $this->connections );
$this->google = new ServicesFactory( $this->connections, $this->credentials, $this->cache );
$this->settings = new Settings();
$this->health = new HealthMonitor( $this->connections, $this->sites, $this->google, $this->settings );
$this->email_log = new EmailLog();
$this->runs = new RunRepository();
$this->deliveries = new DeliveryRepository();
$this->slack = new Attention\Slack( new Crypto() );
}
/**
* Hooks into WordPress.
*
* @return void
*/
private function register() {
( new Capabilities( $this->access ) )->register();
( new ViewerRole() )->register();
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( Jobs::HOOK, array( $this, 'run_jobs' ) );
add_action( Dispatcher::SITE_HOOK, array( $this, 'send_site_report' ) );
add_action( 'wp_initialize_site', array( Installer::class, 'initialize_site' ), 200 );
( new ShareLinks( $this->sites, $this->runs, array( $this, 'builder' ) ) )->register();
if ( is_admin() ) {
add_action( 'admin_init', array( Installer::class, 'maybe_install' ) );
add_action( 'admin_init', array( $this, 'add_privacy_policy_content' ) );
add_filter( 'plugin_action_links_' . plugin_basename( TURTLEREPORT_FILE ), array( $this, 'action_links' ) );
add_action( 'admin_notices', array( new Admin\ConnectionNotice( $this->health, $this->sites ), 'render' ) );
$lists = new SourceLists( $this->google );
$pages = array(
new ReportsPage( $this->access, $this->connections, $this->settings ),
new OverviewPage( $this->sites, $this->connections, $this->health, $this->runs, $this->email_log, array( $this, 'monitor' ), $this->slack ),
new SitesPage( $this->sites, $this->connections, $this->access, $this->email_log, $lists, $this->settings ),
new ConnectionsPage( $this->connections, $this->sites, $this->health ),
new DeliveriesPage( $this->sites, $this->runs, $this->deliveries ),
new SettingsPage( $this->settings, $this->sites, $this->email_log, $this->access, $this->slack, array( $this, 'advisor' ), array( $this, 'brief' ) ),
new GuidePage(),
);
( new Menu( ...$pages ) )->register();
( new Shell( $this->settings, $this->access, $this->health, ...$pages ) )->register();
( new ConnectionActions( $this->connections, $this->sites, $this->credentials, $this->cache, $this->health ) )->register();
( new SiteActions( $this->sites, $this->connections, $this->access, $this->cache, array( $this, 'dispatcher' ), $this->email_log, $lists ) )->register();
( new SettingsActions( $this->settings, array( $this, 'dispatcher' ), $this->sites, $this->email_log, $this->slack, array( $this, 'advisor' ), array( $this, 'brief' ) ) )->register();
( new Admin\AdvisorAssets( array( $this, 'advisor' ), $this->access ) )->register();
( new DeliveryActions( $this->sites, $this->runs, $this->deliveries, array( $this, 'dispatcher' ) ) )->register();
}
}
/**
* Registers REST routes.
*
* @return void
*/
public function register_rest_routes() {
( new ReportController( $this->reports(), $this->sites, $this->cache, $this->health ) )->register_routes();
( new SetupController( $this->connections, $this->google ) )->register_routes();
( new PreferencesController() )->register_routes();
( new MonitorController( $this->monitor(), $this->sites, $this->slack ) )->register_routes();
( new Rest\AdvisorController( $this->advisor() ) )->register_routes();
}
/**
* Reports exposed through the REST API.
*
* @return Reports\Report[]
*/
public function reports() {
if ( null === $this->reports ) {
$reports = array(
new Reports\SearchConsoleReport( $this->google ),
new Reports\ConversionReport( $this->google ),
new Reports\TrafficReport( $this->google ),
new Reports\AiTrafficReport( $this->google ),
new Reports\BusinessProfileReport( $this->google ),
new Reports\SegmentsReport( $this->google ),
);
/**
* Filters the reports exposed at /turtlereport/v1/sites/{site}/reports/{id}.
*
* Add your own by extending TurtleReport\Reports\Report. Access
* checks are applied automatically.
*
* @param Reports\Report[] $reports Reports.
* @param ServicesFactory $google Google services factory.
*/
$reports = apply_filters( 'turtlereport_reports', $reports, $this->google );
$this->reports = array_values(
array_filter(
(array) $reports,
array( __CLASS__, 'valid_report' )
)
);
}
return $this->reports;
}
/**
* Client Monitor (built on first use; needs the reports).
*
* @return Attention\Monitor
*/
public function monitor() {
if ( null === $this->monitor ) {
$this->monitor = new Attention\Monitor( $this->sites, new Attention\AlertStore(), new Attention\ReportData( $this->reports(), $this->google ), $this->settings );
}
return $this->monitor;
}
/**
* AI Advisor (built on first use). Its data is only loaded when someone asks.
*
* @return Advisor\Advisor
*/
public function advisor() {
if ( null === $this->advisor ) {
$this->advisor = new Advisor\Advisor(
$this->settings,
new Advisor\KeyStore( new Crypto() ),
new Advisor\Usage(),
$this->access,
function () {
return new Advisor\Context( new Summary( $this->reports() ), $this->reports(), $this->monitor()->enabled() ? $this->monitor() : null );
}
);
}
return $this->advisor;
}
/**
* Validates a filter-provided report before calling its methods.
*
* @param mixed $report Extension-provided value.
* @return bool
*/
private static function valid_report( $report ) {
return $report instanceof Reports\Report && 1 === preg_match( '/^[a-z0-9-]+$/', $report->id() );
}
/**
* Email dispatcher (built on first use; needs the reports).
*
* @return Dispatcher
*/
public function dispatcher() {
if ( null === $this->dispatcher ) {
$builder = $this->builder();
$mailer = new ReportMailer( new Summary( $this->reports() ), $this->settings, $builder );
$this->dispatcher = new Dispatcher( $this->sites, $mailer, $this->email_log, new Recipients( $this->access ), $this->runs, $this->deliveries, new Notifier( $this->settings ) );
}
return $this->dispatcher;
}
/**
* Founder Weekly Brief (built on first use).
*
* @return Attention\Brief
*/
public function brief() {
if ( null === $this->brief ) {
$this->brief = new Attention\Brief( $this->sites, $this->monitor(), $this->settings, $this->dispatcher()->mailer() );
}
return $this->brief;
}
/**
* Report builder: numbers, checks, insights and PDF (built on first use).
*
* @return ReportBuilder
*/
public function builder() {
if ( null === $this->builder ) {
$this->builder = new ReportBuilder( new Summary( $this->reports() ), $this->settings );
}
return $this->builder;
}
/**
* Hourly cron handler: due emails, then daily health checks and cache warming.
*
* @return void
*/
public function run_jobs() {
Installer::maybe_install();
( new Jobs( $this->dispatcher(), $this->health, new CacheWarmer( $this->sites, $this->reports() ), $this->settings, $this->monitor(), $this->brief() ) )->run();
}
/**
* Single cron event for one site's scheduled email.
*
* @param string $site_id Site ID.
* @return void
*/
public function send_site_report( $site_id ) {
if ( function_exists( 'set_time_limit' ) ) {
set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Background job talking to remote APIs and building a PDF.
}
Installer::ensure_schema();
Jobs::beat();
try {
$this->dispatcher()->send_site( (string) $site_id );
} catch ( \Throwable $e ) {
// Never let one report break WP-Cron for everything else; the hourly sweep tries again.
$this->email_log->update( (string) $site_id, array( 'last_error' => ErrorText::from( $e ) ) );
}
}
/**
* Install-wide settings, for extensions.
*
* @return Settings
*/
public function settings() {
return $this->settings;
}
/**
* Access rules, for extensions.
*
* @return AccessPolicy
*/
public function access() {
return $this->access;
}
/**
* Report runs, for extensions.
*
* @return RunRepository
*/
public function runs() {
return $this->runs;
}
/**
* Site storage, for extensions.
*
* @return ProfileRepository
*/
public function sites() {
return $this->sites;
}
/**
* Adds Connections and Reports links on the Plugins screen.
*
* @param string[] $links Existing links.
* @return string[]
*/
public function action_links( $links ) {
if ( current_user_can( Capabilities::MANAGE ) ) {
array_unshift(
$links,
sprintf( '%s', esc_url( ConnectionsPage::url() ), esc_html__( 'Connections', 'turtlereport' ) ),
sprintf( '%s', esc_url( ReportsPage::url() ), esc_html__( 'Reports', 'turtlereport' ) )
);
}
return $links;
}
/**
* Suggests privacy policy text.
*
* @return void
*/
public function add_privacy_policy_content() {
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
return;
}
wp_add_privacy_policy_content(
'TurtleReport',
wp_kses_post(
wpautop(
__( 'TurtleReport does not add tracking to your public website or collect data from your visitors. Logged-in administrators connect it to Google Search Console, Google Analytics and Google Business Profile, and it shows aggregated reports from those services inside the WordPress admin to the users they allow. Google credentials are stored encrypted in this site\'s database. Report data is fetched from Google\'s APIs and cached temporarily. When scheduled email reports are used, a delivery history (recipient email addresses, send times and results) and the report figures are kept for up to 400 days so failed emails can be resent; secure PDF links, if switched on, let anyone with the link download that one report until it expires. If a Slack webhook is saved for the Client Monitor, an administrator can send one issue at a time to that Slack channel: the client\'s name, the issue\'s figures, the pages or keywords involved, an optional note and the sender\'s name. If the optional AI Advisor is switched on with the site owner\'s own API key for OpenAI, Anthropic (Claude), Google (Gemini) or xAI (Grok), a question asked by an administrator is sent to that provider with the report figures it needs (client name and website, Search Console, Google Analytics and Business Profile figures, and open issues); WordPress account names, report recipient email addresses and Google credentials are not added to that context. Questions and report fields such as search queries or page URLs may themselves contain personal information. Setup tests and model discovery also contact the selected provider when requested by an administrator, even while the AI Advisor is switched off.', 'turtlereport' )
)
)
);
}
}