. A verbatim copy is included with this plugin in the file LICENSE. */ // No direct access. defined( 'ABSPATH' ) || exit; define( 'LUMEN_PAGES_VERSION', '1.0.3' ); define( 'LUMEN_PAGES_FILE', __FILE__ ); define( 'LUMEN_PAGES_MENU_SLUG', 'lumen-pages' ); /* * SINGLE SOURCE OF TRUTH for what {Term} does. * * 1.0.2 stated this rule in three separate places — this file's admin screen, readme.txt, and the * product page on lumenandink.com — as three independently hand-written sentences. They drifted, and * the one in the box was the one that was wrong. The rule now exists exactly once, here. The admin * screen echoes this constant, readme.txt quotes it verbatim, and the website quotes it verbatim. * If the behaviour changes, change it here and propagate; do not re-word it in place. */ define( 'LUMEN_PAGES_TERM_RULE', '{Term} adds capitals to a word that has none and leaves a word you capitalised yourself alone: miami becomes Miami, while McAllen, DeLand, O\'Fallon and HVAC come out exactly as you typed them.' ); /* * 1.0.1 — the per-run cap and the paid-upgrade path are GONE, not disabled. * * 1.0.0 shipped a hardcoded 10-pages-per-run limit that only a paid licence removed, plus an * in-admin upgrade link. Locking a feature behind payment is what WordPress.org plugin guideline 5 * forbids outright, and the link pointed at a page that says there is no checkout. There is now no * cap, no licence code, no upgrade prompt and no price anywhere in this plugin. It does the whole * job, for free, offline. Nothing here will ever ask you for money. * * 1.0.2 — {Term} no longer destroys capitals the user already typed, and a re-run is safe. * * The one transformation this plugin performs was wrong. {Term} ran the term through * mb_convert_case( ..., MB_CASE_TITLE ), which is an UNCONDITIONAL re-case: it uppercases the first * letter of each word and lowercases everything after it. That is right for "miami" and destructive * for every place name carrying internal capitals — McAllen became "Mcallen", DeLand became * "Deland", McKinney became "Mckinney", LaGrange became "Lagrange", O'Fallon became "O'fallon". * The shipped default title template is "Water damage restoration in {Term}", so a first run on any * such list produced visibly wrong titles. (1.0.2's comment here also said DeLand, DeSoto and * LaBelle "sit in this plugin's own Florida example corpus". They did not: 1.0.2 shipped three files * and no data at all. That sentence is corrected in 1.0.3, which does ship an example list — see the * 1.0.3 note below for what is actually in it.) The person who found it put it exactly right: the honest version of this * plugin still generated dishonest-looking pages. * * lumen_pages_title_case() in 1.0.2 re-cased a token when that token was entirely lower-case OR * entirely upper-case. That fixed the mixed-case names and left a second bug in place; see 1.0.3. * * Also in 1.0.2, the consequences of removing the 10-page cap in 1.0.1 — the cap had been holding * the blast radius of a re-run down to ten pages without anyone intending it to: * - post/redirect/get, so a browser refresh cannot silently re-run an unbounded batch; * - a post_exists() skip, so re-running resumes instead of duplicating — which is exactly what * both the readme and the admin help text tell people to do after a timeout; * - a progress marker, so a run cut short by a PHP time limit leaves evidence of how far it got; * - deduplication moved onto the GENERATED TITLE, because "Miami" and "miami" are two distinct * terms that collapse to one identical title, and the title is the thing that must be unique; * - a line containing exactly "0" is no longer silently dropped by array_filter's default test; * - the result notice accounts for every line pasted, instead of counting only what survived * filtering — previously a 500-line paste could honestly report "Created 486. Skipped 0." with * 14 lines unexplained. * * 1.0.3 — the half of the {Term} bug that 1.0.2 did not fix, and the false sentence about it. * * 1.0.2 treated an ALL-CAPS token as carrying no case information and re-cased it, so HVAC repair * became "Hvac Repair", AC repair "Ac Repair", BBQ catering "Bbq Catering", SEO services "Seo * Services" and US 1 corridor "Us 1 Corridor". Meanwhile this file's own admin screen told the * reader "{Term} only adds capitals, it never removes them" — and readme.txt said it twice more. * The person who found it had already checked the SHA-256 and diffed the published source against * the zip; the first thing he ran the function on was his own client list, and the box contradicted * itself on the first term. For a plugin whose only claimed advantage over more-featured free * alternatives is that you can read it end to end and believe what it says, a false sentence in the * file is worse than a missing feature. * * 1.0.3 changes: * - lumen_pages_title_case() leaves an all-upper-case token of more than one character exactly as * typed. HVAC stays HVAC. The consequence, stated plainly because it is a real trade-off: a term * pasted as MIAMI now stays MIAMI instead of becoming Miami. Nothing this function does can * remove a capital any more, which is what the screen has been claiming all along. * - The rule is defined once, in LUMEN_PAGES_TERM_RULE above, and echoed rather than re-typed. * - examples/us-metros.txt ships with the plugin: the 91 metro areas restorationanswers.com * publishes water-damage cost pages for, derived from that site's own public sitemap. It is an * example input, not a data product, and it is not a complete list of anything. */ /** * Load translations. */ function lumen_pages_load_textdomain() { load_plugin_textdomain( 'lumen-pages', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); } add_action( 'init', 'lumen_pages_load_textdomain' ); /** * Register the top-level admin menu, and hook form processing to that screen's `load-` action so it * runs BEFORE any output. That is what makes post/redirect/get possible: handling the POST from * inside the render callback (as 1.0.0 and 1.0.1 did) means output has already begun and there is * nowhere left to redirect from. */ function lumen_pages_admin_menu() { $hook = add_menu_page( __( 'Lumen Pages', 'lumen-pages' ), __( 'Lumen Pages', 'lumen-pages' ), 'manage_options', LUMEN_PAGES_MENU_SLUG, 'lumen_pages_render_admin', 'dashicons-networking', 58 ); if ( $hook ) { add_action( 'load-' . $hook, 'lumen_pages_maybe_handle_submit' ); } } add_action( 'admin_menu', 'lumen_pages_admin_menu' ); /** * Title Case a term WITHOUT destroying capitals the user already typed. * * A token is re-cased only when it carries no case information of its own — it is entirely * lower-case ("miami") or entirely upper-case ("MIAMI"). Anything already mixed case ("McAllen", * "DeLand", "O'Fallon") is a deliberate spelling and is returned unchanged. * * @param string $term A single term. * @return string */ function lumen_pages_title_case( $term ) { // mbstring is normal but not guaranteed. Both paths run the SAME token-by-token decision, so a // host without mbstring gets identical results for ASCII terms rather than a second, worse // behaviour nobody tested. (1.0.1's fallback was a bare ucwords() over the whole string, which // left "MIAMI" as "MIAMI" and turned "miami-dade" into "Miami-dade".) $mb = function_exists( 'mb_strtolower' ) && function_exists( 'mb_strtoupper' ) && function_exists( 'mb_convert_case' ); $parts = preg_split( '/([\s\-])/u', $term, -1, PREG_SPLIT_DELIM_CAPTURE ); if ( ! is_array( $parts ) ) { // The /u modifier makes preg_split fail on invalid UTF-8; retry without it before giving up. $parts = preg_split( '/([\s\-])/', $term, -1, PREG_SPLIT_DELIM_CAPTURE ); } if ( ! is_array( $parts ) ) { return $term; } $out = ''; foreach ( $parts as $tok ) { if ( '' === $tok ) { continue; } $lower = $mb ? mb_strtolower( $tok, 'UTF-8' ) : strtolower( $tok ); $upper = $mb ? mb_strtoupper( $tok, 'UTF-8' ) : strtoupper( $tok ); $len = $mb && function_exists( 'mb_strlen' ) ? mb_strlen( $tok, 'UTF-8' ) : strlen( $tok ); // 1.0.3: an all-upper-case token longer than one character is an acronym or an initialism the // user typed on purpose — HVAC, AC, BBQ, SEO, US, LLC. 1.0.2 re-cased it, producing "Hvac". // Leaving it alone is the only behaviour consistent with LUMEN_PAGES_TERM_RULE. The cost is // that MIAMI stays MIAMI; that is the correct trade, because MIAMI is also what was typed. // (A one-character token is excluded: "A" is equally its own upper and lower case, and // re-casing it is a no-op, so the guard would only add a branch nobody can observe.) if ( $tok === $upper && $tok !== $lower && $len > 1 ) { $out .= $tok; continue; } // A token equal to its own lower-case form carries no case information — re-casing it is the // best available guess. Anything else was cased on purpose. if ( $tok === $lower || $tok === $upper ) { $out .= $mb ? mb_convert_case( $tok, MB_CASE_TITLE, 'UTF-8' ) : ucwords( $lower ); } else { $out .= $tok; } } return $out; } /** * Fill a template's {term} / {Term} / {TERM} placeholders for one term. * * @param string $template Raw template string. * @param string $term A single term (already trimmed). * @return string */ function lumen_pages_fill( $template, $term ) { $replacements = array( '{term}' => $term, '{Term}' => lumen_pages_title_case( $term ), '{TERM}' => function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $term, 'UTF-8' ) : strtoupper( $term ), ); return strtr( $template, $replacements ); } /** * Split the pasted textarea into non-empty trimmed lines. * * array_filter() with no callback drops every falsy value, and the string "0" is falsy — so a line * containing exactly "0" used to vanish with nothing reporting it. The callback here removes empty * strings and nothing else. * * @param string $raw Raw textarea contents. * @return string[] */ function lumen_pages_split_terms( $raw ) { $lines = preg_split( '/\r\n|\r|\n/', (string) $raw ); if ( ! is_array( $lines ) ) { return array(); } $lines = array_map( 'trim', $lines ); $lines = array_filter( $lines, static function ( $t ) { return '' !== $t; } ); return array_values( $lines ); } /** * Transient key for this user's stashed run result / progress marker. * * @param string $what 'result' or 'progress'. * @return string */ function lumen_pages_transient_key( $what ) { return 'lumen_pages_' . $what . '_' . get_current_user_id(); } /** * Handle the generate form submission. Verifies nonce + capability, then creates one draft post per * supplied term — every term, with no cap — skipping any term whose generated title already exists. * * @return array{created:int, skipped:int, duplicate:int, existing:int, lines:int, ids:int[]}|null */ function lumen_pages_handle_submit() { if ( ! isset( $_POST['lumen_pages_nonce'] ) ) { return null; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to generate pages.', 'lumen-pages' ) ); } if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['lumen_pages_nonce'] ) ), 'lumen_pages_generate' ) ) { wp_die( esc_html__( 'Security check failed. Please reload and try again.', 'lumen-pages' ) ); } $post_type = isset( $_POST['lumen_post_type'] ) ? sanitize_key( wp_unslash( $_POST['lumen_post_type'] ) ) : 'page'; $post_type = in_array( $post_type, array( 'page', 'post' ), true ) ? $post_type : 'page'; $title_template = isset( $_POST['lumen_title'] ) ? sanitize_text_field( wp_unslash( $_POST['lumen_title'] ) ) : ''; $body_template = isset( $_POST['lumen_body'] ) ? wp_kses_post( wp_unslash( $_POST['lumen_body'] ) ) : ''; $terms_raw = isset( $_POST['lumen_terms'] ) ? sanitize_textarea_field( wp_unslash( $_POST['lumen_terms'] ) ) : ''; // Persist templates so the form remembers them (sanitized above). update_option( 'lumen_pages_last', array( 'post_type' => $post_type, 'title' => $title_template, 'body' => $body_template, ) ); $lines = lumen_pages_split_terms( $terms_raw ); $input_lines = count( $lines ); $terms = array_values( array_unique( $lines ) ); // post_exists() lives in an admin include that is not guaranteed to be loaded this early. if ( ! function_exists( 'post_exists' ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/includes/post.php' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } // A long list is now the normal case rather than an impossible one, so ask for the time. Hosts // that forbid this simply ignore it; the post_exists() skip below is what makes the documented // "split the list and run it again" recovery non-destructive when a run is cut short anyway. if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } // Every supplied term is used. There is no per-run limit and no licence check. $created = 0; $skipped = 0; $duplicate = 0; $existing = 0; $ids = array(); $seen = array(); $total = count( $terms ); $progress_key = lumen_pages_transient_key( 'progress' ); set_transient( $progress_key, array( 'created' => 0, 'total' => $total, 'done' => false, 'at' => time(), ), HOUR_IN_SECONDS ); foreach ( $terms as $term ) { $title = trim( lumen_pages_fill( $title_template, $term ) ); if ( '' === $title ) { $skipped++; continue; } // Deduplicate on the GENERATED TITLE, not the raw term: "Miami" and "miami" are two terms // that collapse to one title, and two drafts with an identical title is the thing the user // actually does not want. $title_key = function_exists( 'mb_strtolower' ) ? mb_strtolower( $title, 'UTF-8' ) : strtolower( $title ); if ( isset( $seen[ $title_key ] ) ) { $duplicate++; continue; } $seen[ $title_key ] = true; // Idempotence. This is what makes a browser refresh, a double-click and the documented // "split the list and run it again" recovery all safe: a post that already carries this // title is left alone rather than duplicated. if ( function_exists( 'post_exists' ) && post_exists( $title, '', '', $post_type ) ) { $existing++; continue; } $body = lumen_pages_fill( $body_template, $term ); $new_id = wp_insert_post( array( 'post_title' => $title, 'post_content' => $body, 'post_status' => 'draft', 'post_type' => $post_type, 'meta_input' => array( '_lumen_pages_generated' => LUMEN_PAGES_VERSION ), ), true ); if ( is_wp_error( $new_id ) || ! $new_id ) { $skipped++; continue; } $created++; $ids[] = (int) $new_id; // Leave evidence every 10 inserts, so a run killed by a PHP time limit is still countable. if ( 0 === $created % 10 ) { set_transient( $progress_key, array( 'created' => $created, 'total' => $total, 'done' => false, 'at' => time(), ), HOUR_IN_SECONDS ); } } // The run reached its own end, so there is nothing to warn about on the next screen. delete_transient( $progress_key ); return array( 'created' => $created, 'skipped' => $skipped, 'duplicate' => $duplicate, 'existing' => $existing, 'lines' => $input_lines, 'ids' => $ids, ); } /** * Runs on `load-toplevel_page_lumen-pages`, before any output: process the POST, stash the result in * a user-scoped transient, then redirect to a clean GET. Refreshing that GET re-renders a notice and * creates nothing. */ function lumen_pages_maybe_handle_submit() { if ( ! isset( $_POST['lumen_pages_nonce'] ) ) { return; } $result = lumen_pages_handle_submit(); if ( is_array( $result ) ) { set_transient( lumen_pages_transient_key( 'result' ), $result, 5 * MINUTE_IN_SECONDS ); } wp_safe_redirect( add_query_arg( 'lumen_done', 1, admin_url( 'admin.php?page=' . LUMEN_PAGES_MENU_SLUG ) ) ); exit; } /** * Read and clear this user's stashed run result. * * @return array|null */ function lumen_pages_take_result() { $key = lumen_pages_transient_key( 'result' ); $result = get_transient( $key ); if ( ! is_array( $result ) ) { return null; } delete_transient( $key ); return $result; } /** * Render the admin screen. */ function lumen_pages_render_admin() { if ( ! current_user_can( 'manage_options' ) ) { return; } // Normally null: lumen_pages_maybe_handle_submit() already processed the POST on `load-` and // redirected here. Kept as a fallback so the screen still works if that hook never registered. $result = lumen_pages_handle_submit(); if ( ! is_array( $result ) ) { $result = lumen_pages_take_result(); } // A progress marker still present means a previous run never reached its own cleanup — almost // always a PHP execution timeout on a long list. $stalled = get_transient( lumen_pages_transient_key( 'progress' ) ); if ( is_array( $stalled ) ) { delete_transient( lumen_pages_transient_key( 'progress' ) ); } $last = wp_parse_args( get_option( 'lumen_pages_last', array() ), array( 'post_type' => 'page', 'title' => 'Water damage restoration in {Term}', 'body' => "
Looking for water damage restoration in {Term}? Here's what local homeowners need to know about costs, insurance, and finding a trusted pro.
", ) ); ?> ' . esc_html__( 'Generate', 'lumen-pages' ) . ''; $support = '' . esc_html__( 'Support', 'lumen-pages' ) . ''; array_unshift( $links, $link, $support ); return $links; } add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'lumen_pages_action_links' );