Plugin Directory

Changeset 3666532


Ignore:
Timestamp:
08/26/2026 08:32:47 AM (2 months ago)
Author:
neutropeinc
Message:

Release 0.3.0: add generic addon extension foundation

  • Add generic entry-detail extension points.
  • Add generic list columns, cells, and filter controls.
  • Add safe custom list-state carry support.
  • Add high-level query-argument and structured query-clause extension APIs.
  • Keep row/count pagination constraints consistent.
  • Support optional distinct queries.
  • Isolate list query extensions from CSV Export unless explicitly enabled.
  • Add a current-page preload hook for efficient addon columns without N+1 queries.
  • Clarify Activity-history backfill progress wording.
  • Hide migration-history guidance on genuine fresh installs.
  • Keep DB version 1.1.0 with no schema migration.
  • Tested up to WordPress 7.1.
Location:
neutrope-lead-desk-for-contact-form-7
Files:
22 edited
1 copied

Legend:

Unmodified
Added
Removed
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/admin/class-admin-menu.php

    r3666139 r3666532  
    185185                        $message .= ' ' . esc_html(
    186186                                sprintf(
    187                                         /* translators: 1: number of records processed, 2: total records, 3: percentage complete */
    188                                         __( '%1$s of %2$s (%3$s%%)', 'neutrope-lead-desk-for-contact-form-7' ),
     187                                        /* translators: 1: number of records processed, 2: total records to process, 3: percentage complete. "Records" counts processing units (entries plus notes), NOT the number of inquiries. */
     188                                        __( 'Processed %1$s of %2$s records (%3$s%%).', 'neutrope-lead-desk-for-contact-form-7' ),
    189189                                        number_format_i18n( (int) $progress['processed'] ),
    190190                                        number_format_i18n( (int) $progress['total'] ),
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/admin/class-entry-detail-page.php

    r3666139 r3666532  
    127127                $backfill_pending = class_exists( 'NLDCF7_Event_Backfill' ) && NLDCF7_Event_Backfill::is_pending();
    128128
     129                // The "history from before this plugin was updated" note only makes
     130                // sense where there IS such history. The migration snapshot records how
     131                // much there was to reconstruct at upgrade time: both totals are zero on
     132                // a site that installed this version fresh, and the note is then simply
     133                // untrue. The snapshot is used rather than today's entry count because
     134                // entries received since the upgrade say nothing about what came before.
     135                //
     136                // If the snapshot is missing or unreadable the note is shown: an
     137                // unnecessary explanation is a smaller harm than silently omitting the
     138                // one that tells an admin why their older entries look sparse.
     139                $show_history_note = true;
     140                if ( class_exists( 'NLDCF7_Event_Backfill' ) && NLDCF7_Event_Backfill::is_initialised() ) {
     141                        $backfill_state = NLDCF7_Event_Backfill::get_state();
     142                        if ( isset( $backfill_state['entry_total'], $backfill_state['note_total'] ) ) {
     143                                $show_history_note = ( (int) $backfill_state['entry_total'] > 0 )
     144                                        || ( (int) $backfill_state['note_total'] > 0 );
     145                        }
     146                }
     147
    129148                if ( $this->event_repo ) {
    130149                        $events_total       = $this->event_repo->count_for_entry( (int) $entry['id'] );
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/admin/class-entry-list-table.php

    r3666139 r3666532  
    129129                }
    130130
    131                 return $args;
     131                return self::apply_carried_args_filter( $args );
     132        }
     133
     134        /**
     135         * Maximum number of add-on contributed args carried in a list URL.
     136         */
     137        const CARRIED_MAX_ARGS = 20;
     138
     139        /**
     140         * Maximum length of a single carried value, in characters.
     141         */
     142        const CARRIED_MAX_VALUE = 200;
     143
     144        /**
     145         * Query keys an add-on may never contribute to a carried list URL.
     146         *
     147         * `page` is here because Free sets it itself; `paged` because v0.2.0
     148         * deliberately returns to the first page after an action; the rest are
     149         * mutation, authentication or routing state that must never be replayed
     150         * from a link the admin can bookmark or share.
     151         *
     152         * @var string[]
     153         */
     154        private static $carried_denylist = array(
     155                'page',
     156                'paged',
     157                'action',
     158                'action2',
     159                'entry_id',
     160                '_wpnonce',
     161                '_wp_http_referer',
     162                'view',
     163                'event_page',
     164                self::VIEW_QUERY_VAR,
     165        );
     166
     167        /**
     168         * Let add-ons contribute their own filter state, then re-impose Free's rules.
     169         *
     170         * Everything an add-on adds is treated as untrusted: the deny-list, key
     171         * normalisation, scalar-only rule and size caps are applied AFTER the
     172         * filter, so a careless listener cannot put `_wpnonce` or an array back
     173         * into a URL. Core args are re-applied last and always win.
     174         *
     175         * This is the one place custom filter state enters a list URL. Every path
     176         * that needs it -- the All/Trash views, the row actions, and the redirect
     177         * after a row or bulk action -- reaches it through current_filter_args(),
     178         * so there is a single implementation rather than one per call site.
     179         *
     180         * @param array<string,string|int> $core Sanitized core filter args.
     181         * @return array<string,string|int>
     182         */
     183        private static function apply_carried_args_filter( array $core ) {
     184                /**
     185                 * Filter the list state carried across view switches and row/bulk actions.
     186                 *
     187                 * Add-ons append their own already-sanitized scalar args. Free re-checks
     188                 * the result regardless; see the class constants for the size caps and
     189                 * `$carried_denylist` for the keys that are always stripped.
     190                 *
     191                 * Free never forwards the raw query string, so an add-on filter that
     192                 * adds nothing leaves the URL byte-identical to v0.2.0.
     193                 *
     194                 * @since 0.3.0
     195                 *
     196                 * @param array<string,string|int> $args Args to carry. Core keys included.
     197                 * @param array<string,string|int> $core The core args, for reference.
     198                 */
     199                $filtered = apply_filters( 'nldcf7_entry_list_carried_args', $core, $core );
     200
     201                if ( ! is_array( $filtered ) ) {
     202                        return $core;
     203                }
     204
     205                $extra = array();
     206                foreach ( $filtered as $key => $value ) {
     207                        if ( count( $extra ) >= self::CARRIED_MAX_ARGS ) {
     208                                self::debug_carried( 'too many carried args; extra dropped' );
     209                                break;
     210                        }
     211
     212                        $key = sanitize_key( (string) $key );
     213                        if ( '' === $key || array_key_exists( $key, $core ) ) {
     214                                // Empty/unusable key, or a core key -- core is authoritative and
     215                                // is re-applied below either way.
     216                                continue;
     217                        }
     218                        if ( in_array( $key, self::$carried_denylist, true ) ) {
     219                                self::debug_carried( 'reserved key refused: ' . $key );
     220                                continue;
     221                        }
     222                        if ( ! is_scalar( $value ) ) {
     223                                // Arrays and objects are dropped outright. Casting one would
     224                                // print the literal string "Array" into a URL.
     225                                self::debug_carried( 'non-scalar value dropped for key: ' . $key );
     226                                continue;
     227                        }
     228
     229                        $value = (string) $value;
     230                        if ( '' === $value ) {
     231                                continue;
     232                        }
     233                        if ( function_exists( 'mb_substr' ) ) {
     234                                $value = mb_substr( $value, 0, self::CARRIED_MAX_VALUE );
     235                        } else {
     236                                $value = substr( $value, 0, self::CARRIED_MAX_VALUE );
     237                        }
     238
     239                        $extra[ $key ] = sanitize_text_field( $value );
     240                }
     241
     242                // Core last: an add-on cannot override the screen's own filter state.
     243                return array_merge( $extra, $core );
     244        }
     245
     246        /**
     247         * Log a rejected carried-arg contribution while debugging.
     248         *
     249         * @param string $message
     250         * @return void
     251         */
     252        private static function debug_carried( $message ) {
     253                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
     254                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated by WP_DEBUG; an add-on silently losing filter state is otherwise very hard to diagnose.
     255                        error_log( '[neutrope-lead-desk-for-contact-form-7] carried args: ' . $message );
     256                }
    132257        }
    133258
    … …  
    234359
    235360        public function get_columns() {
    236                 return array(
     361                $core = array(
    237362                        'cb'                => '<input type="checkbox" />',
    238363                        'submitted_at'      => __( 'Received', 'neutrope-lead-desk-for-contact-form-7' ),
    … …  
    246371                        'note_count'        => __( 'Notes', 'neutrope-lead-desk-for-contact-form-7' ),
    247372                );
     373
     374                /**
     375                 * Filter the entries list columns.
     376                 *
     377                 * Add-ons may append their own columns. They may NOT remove, rename or
     378                 * repurpose a core column: every core key is restored to its own label
     379                 * after this filter runs, so reusing one silently changes nothing. That
     380                 * is enforced in code rather than left to convention, because a column
     381                 * whose header says one thing and whose cells say another is a data
     382                 * integrity problem, not a styling preference.
     383                 *
     384                 * Cells for added columns are rendered through
     385                 * `nldcf7_entry_list_column_content`.
     386                 *
     387                 * @since 0.3.0
     388                 *
     389                 * @param array<string,string> $columns column key => header label.
     390                 */
     391                $filtered = apply_filters( 'nldcf7_entry_list_columns', $core );
     392
     393                if ( ! is_array( $filtered ) ) {
     394                        return $core;
     395                }
     396
     397                // Keep only usable entries: a non-empty sanitize_key()-able key mapped
     398                // to a scalar label. Anything else is a malformed contribution and is
     399                // dropped rather than being allowed to break the header row.
     400                $clean = array();
     401                foreach ( $filtered as $key => $label ) {
     402                        $key = sanitize_key( (string) $key );
     403                        if ( '' === $key || ! is_scalar( $label ) ) {
     404                                continue;
     405                        }
     406                        $clean[ $key ] = (string) $label;
     407                }
     408
     409                // Core wins: restore every core column's own label, and re-add any the
     410                // filter dropped. `cb` goes back to the front so the bulk-select box
     411                // never ends up mid-row.
     412                foreach ( $core as $key => $label ) {
     413                        $clean[ $key ] = $label;
     414                }
     415                if ( isset( $clean['cb'] ) ) {
     416                        $cb = $clean['cb'];
     417                        unset( $clean['cb'] );
     418                        $clean = array_merge( array( 'cb' => $cb ), $clean );
     419                }
     420
     421                return $clean;
     422        }
     423
     424        /**
     425         * Render a cell for a column this class has no `column_{key}()` method for.
     426         *
     427         * Only add-on columns reach here: every core column has its own method.
     428         * WordPress core's own `column_default()` is an empty no-op, so without
     429         * this an added column would render as a blank cell.
     430         *
     431         * The listener returns HTML and is responsible for escaping it; Free then
     432         * runs the result through `wp_kses_post()` as a second layer, which is the
     433         * reason this is a filter with a return value rather than an action that
     434         * echoes -- output that Free never sees is output Free cannot make safe.
     435         *
     436         * @param array  $item        Entry row.
     437         * @param string $column_name Column key.
     438         * @return string Escaped HTML, or '' when nothing is contributed.
     439         */
     440        public function column_default( $item, $column_name ) {
     441                /**
     442                 * Filter the cell content for an add-on column on the entries list.
     443                 *
     444                 * @since 0.3.0
     445                 *
     446                 * @param string $html        Cell HTML. Default ''.
     447                 * @param string $column_name Column key being rendered.
     448                 * @param array  $item        Entry row, as stored.
     449                 */
     450                $html = apply_filters( 'nldcf7_entry_list_column_content', '', (string) $column_name, $item );
     451
     452                if ( ! is_string( $html ) || '' === $html ) {
     453                        return '';
     454                }
     455
     456                return wp_kses_post( $html );
    248457        }
    249458
    … …  
    495704                        </label>
    496705
    497                         <?php submit_button( __( 'Filter', 'neutrope-lead-desk-for-contact-form-7' ), '', 'nldcf7-filter-submit', false ); ?>
     706                        <?php
     707                        /**
     708                         * Fires inside the entries list filter bar, before the Filter button.
     709                         *
     710                         * Listeners print their own form controls here. The position is
     711                         * guaranteed to be inside the existing GET form, so a plain
     712                         * `<select>` or `<input>` submits with the core filters and needs
     713                         * nothing else wired up.
     714                         *
     715                         * No nonce is involved: this bar only reads, and Free's own filters
     716                         * carry none either. The listener owns its field names, its values
     717                         * and its escaping; Free owns only the placement.
     718                         *
     719                         * @since 0.3.0
     720                         *
     721                         * @param array<string,string|int> $current_args Sanitized core filter
     722                         *                                               state for this request.
     723                         */
     724                        do_action( 'nldcf7_entry_list_filters', self::current_filter_args() );
     725
     726                        submit_button( __( 'Filter', 'neutrope-lead-desk-for-contact-form-7' ), '', 'nldcf7-filter-submit', false );
     727                        ?>
    498728                </div>
    499729                <?php
    … …  
    529759                        // behaving as a soft delete rather than turning into a filter.
    530760                        'trashed_only' => ( self::VIEW_TRASH === self::current_view() ),
     761                        // Identifies this caller to the query extension hooks so an add-on
     762                        // can constrain the list without also constraining CSV export.
     763                        'context'      => NLDCF7_Entry_Repository::CONTEXT_LIST,
    531764                );
    532765
    … …  
    551784                        )
    552785                );
     786
     787                /**
     788                 * Fires once the rows for the current list page are known.
     789                 *
     790                 * Add-ons that render their own columns use this to load whatever they
     791                 * need for these rows in one query, before any cell is rendered.
     792                 * Without it they are pushed into N+1: the cell filter is handed a
     793                 * single row and has no way to learn what else is on the page, so the
     794                 * only alternative is a lookup per cell -- twenty rows with two custom
     795                 * columns being up to forty extra queries.
     796                 *
     797                 * Free's own note-count cache above is the same pattern; this is that
     798                 * opportunity handed to extensions.
     799                 *
     800                 * Contract:
     801                 *   - $items is THIS PAGE's rows, never the whole result set.
     802                 *   - Read-only. Listeners must not mutate the list's state, and no
     803                 *     return value is used.
     804                 *   - Fires exactly once per prepare cycle, including with an empty
     805                 *     array when the page has no rows, so "once per cycle" holds
     806                 *     without a special case for empty results.
     807                 *   - The entries screen only. CSV export does not build a list table
     808                 *     and so never reaches here.
     809                 *   - Running the secondary query safely -- preparing it, checking
     810                 *     capabilities -- belongs to the listener.
     811                 *
     812                 * Fired after this class has finished its own preparation so that a
     813                 * listener cannot interleave with it. The repository, the controller
     814                 * and this table instance are deliberately not passed.
     815                 *
     816                 * @since 0.3.0
     817                 *
     818                 * @param array<int,array<string,mixed>> $items Rows on the current page.
     819                 */
     820                do_action( 'nldcf7_entry_list_items_prepared', $this->items );
    553821        }
    554822
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/admin/views/entry-detail.php

    r3666139 r3666532  
    1212 *   $notice   array|null  Flash notice ['message'=>string,'type'=>string]
    1313 *                         pulled from per-user transient (consume_notice()).
     14 *   $show_history_note bool Whether the pre-upgrade history caveat applies.
    1415 *
    1516 * phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Locals in an included view, not actual globals.
    … …  
    140141                        <div class="nldcf7-card nldcf7-timeline-card">
    141142                                <h2><?php esc_html_e( 'Activity', 'neutrope-lead-desk-for-contact-form-7' ); ?></h2>
    142                                 <p class="description">
    143                                         <?php esc_html_e( 'History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved.', 'neutrope-lead-desk-for-contact-form-7' ); ?>
    144                                 </p>
     143                                <?php if ( $show_history_note ) : ?>
     144                                        <p class="description">
     145                                                <?php esc_html_e( 'History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved.', 'neutrope-lead-desk-for-contact-form-7' ); ?>
     146                                        </p>
     147                                <?php endif; ?>
    145148
    146149                                <?php if ( empty( $events ) ) : ?>
    … …  
    222225                        </div>
    223226
     227                        <?php
     228                        /**
     229                         * Fires after the main column's own cards on the entry detail screen.
     230                         *
     231                         * Add-ons print their own markup here -- including the surrounding
     232                         * `<div class="nldcf7-card">` -- and own escaping it. Nothing is
     233                         * buffered or filtered on the way out, so a listener that prints
     234                         * nothing costs nothing and leaves the page byte-identical.
     235                         *
     236                         * The position is deliberately "after every core card" rather than
     237                         * between two of them: card order is an internal detail that may
     238                         * change, and a between-cards contract could not be kept stable.
     239                         *
     240                         * @since 0.3.0
     241                         *
     242                         * @param array $entry Entry row, as stored. Passed by value.
     243                         */
     244                        do_action( 'nldcf7_entry_detail_main', $entry );
     245                        ?>
     246
    224247                </div>
    225248
    … …  
    273296                                </table>
    274297                        </div>
     298
     299                <?php
     300                /**
     301                 * Fires after the sidebar's own cards on the entry detail screen.
     302                 *
     303                 * Same ownership rules as `nldcf7_entry_detail_main`: the listener
     304                 * prints and escapes its own markup. Two hooks exist rather than one
     305                 * because an add-on may legitimately need to place content in both
     306                 * columns of the same screen.
     307                 *
     308                 * @since 0.3.0
     309                 *
     310                 * @param array $entry Entry row, as stored. Passed by value.
     311                 */
     312                do_action( 'nldcf7_entry_detail_sidebar', $entry );
     313                ?>
    275314                </aside>
    276315        </div>
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/includes/class-entry-repository.php

    r3666139 r3666532  
    1414
    1515class NLDCF7_Entry_Repository {
     16
     17        /**
     18         * Query contexts passed to the query extension hooks.
     19         *
     20         * The entries screen and the CSV exporter share this repository, so an
     21         * add-on constraint that did not say which one it meant would change both.
     22         * Listeners check $args['context'] to scope themselves.
     23         *
     24         * @since 0.3.0
     25         */
     26        const CONTEXT_LIST    = 'list';
     27        const CONTEXT_EXPORT  = 'export';
     28        const CONTEXT_DEFAULT = 'default';
    1629
    1730        /**
    … …  
    133146        public function count( array $args = array() ) {
    134147                global $wpdb;
    135                 $where   = $this->build_where( $args );
    136                 $table   = NLDCF7_DB::entries_table();
    137                 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from internal NLDCF7_DB method.
    138                 $sql      = "SELECT COUNT(*) FROM {$table} {$where['clause']}";
    139                 $prepared = $where['prepare_args']
    140                         ? $wpdb->prepare( $sql, $where['prepare_args'] ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     148
     149                // Same builder as query(). Sharing it is what makes it impossible for a
     150                // filtered list to report an unfiltered total: there is one query plan,
     151                // not two implementations that have to be kept in step.
     152                $parts    = $this->build_query_parts( $args );
     153                $table    = NLDCF7_DB::entries_table();
     154                $distinct = $parts['distinct'] ? 'DISTINCT ' : '';
     155
     156                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from internal NLDCF7_DB method; join/clause carry no request values (those are in prepare_args).
     157                $sql      = "SELECT COUNT({$distinct}{$table}.id) FROM {$table} {$parts['join']} {$parts['clause']}";
     158                $prepared = $parts['prepare_args']
     159                        ? $wpdb->prepare( $sql, $parts['prepare_args'] ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    141160                        : $sql;
    142                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal plugin table, built via build_where() with prepare_args.
     161                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal plugin table, built via build_query_parts() with prepare_args.
    143162                return (int) $wpdb->get_var( $prepared );
    144163        }
    … …  
    176195                $args = array_merge( $defaults, $args );
    177196
    178                 $where = $this->build_where( $args );
     197                $parts = $this->build_query_parts( $args );
    179198
    180199                $orderby = $this->sanitize_orderby( $args['orderby'] );
    … …  
    185204                $offset   = ( $page - 1 ) * $per_page;
    186205
    187                 $table = NLDCF7_DB::entries_table();
    188                 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table / orderby / order are internal allow-listed values; user input is carried in $prepare_args.
    189                 $sql = "SELECT * FROM {$table} {$where['clause']} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
    190 
    191                 $prepare_args   = $where['prepare_args'];
     206                $table    = NLDCF7_DB::entries_table();
     207                $distinct = $parts['distinct'] ? 'DISTINCT ' : '';
     208
     209                // `{$table}.*` rather than `*`: once an add-on may contribute a JOIN, a
     210                // bare `*` would also pull the joined table's columns into the row and
     211                // any shared column name (id, created_at) would overwrite ours. With no
     212                // join present the two forms select exactly the same thing, so this is
     213                // behaviour-preserving when nothing is listening.
     214                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table / orderby / order are internal allow-listed values; join/clause carry no request values (those are in prepare_args).
     215                $sql = "SELECT {$distinct}{$table}.* FROM {$table} {$parts['join']} {$parts['clause']} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
     216
     217                $prepare_args   = $parts['prepare_args'];
    192218                $prepare_args[] = $per_page;
    193219                $prepare_args[] = $offset;
    … …  
    463489        }
    464490
     491        /**
     492         * Build the complete constraint plan for one query.
     493         *
     494         * This is the single source of JOIN / WHERE / prepared values / DISTINCT for
     495         * BOTH query() and count(). Extending it in one place is what guarantees a
     496         * filtered list and its total can never disagree.
     497         *
     498         * Two extension points run here, in this order:
     499         *
     500         *   1. `nldcf7_entry_query_args`    -- high level. Adjust the documented
     501         *      args (including the long-standing `ids`) without writing SQL.
     502         *   2. `nldcf7_entry_query_clauses` -- low level. Contribute a JOIN and
     503         *      extra conditions with their values kept separate, for constraints
     504         *      that live in another table and should be resolved by the database
     505         *      rather than by loading every matching id into PHP.
     506         *
     507         * @param array $args Query args. `context` identifies the caller.
     508         * @return array{join:string,clause:string,prepare_args:array,distinct:bool}
     509         */
     510        private function build_query_parts( array $args ) {
     511                if ( ! isset( $args['context'] ) || ! is_string( $args['context'] ) || '' === $args['context'] ) {
     512                        $args['context'] = self::CONTEXT_DEFAULT;
     513                }
     514
     515                /**
     516                 * Filter the entry query args before the SQL is built.
     517                 *
     518                 * Use this when the constraint can be said with the args the repository
     519                 * already understands -- most usefully `ids`, which restricts the query
     520                 * to a set of entry IDs. Anything needing a JOIN belongs in
     521                 * `nldcf7_entry_query_clauses` instead.
     522                 *
     523                 * `$args['context']` says who is asking: 'list' for the entries screen,
     524                 * 'export' for CSV, 'default' otherwise. Listeners should check it --
     525                 * both callers share this repository, so an unconditional constraint
     526                 * meant for the list screen would silently change what CSV exports.
     527                 *
     528                 * @since 0.3.0
     529                 *
     530                 * @param array $args Query args, including 'context'.
     531                 */
     532                $filtered = apply_filters( 'nldcf7_entry_query_args', $args );
     533                if ( is_array( $filtered ) ) {
     534                        $args = $filtered;
     535                }
     536
     537                $where = $this->build_where( $args );
     538
     539                $parts = array(
     540                        'join'         => '',
     541                        'where'        => array(),
     542                        'prepare_args' => array(),
     543                        'distinct'     => false,
     544                );
     545
     546                /**
     547                 * Filter the SQL parts for an entry query.
     548                 *
     549                 * Contribute a JOIN and conditions whose values stay separate from the
     550                 * SQL: put `%s` / `%d` placeholders in each `where` entry and the values
     551                 * in `prepare_args`, in the same order. Free passes them to
     552                 * $wpdb->prepare(). A single pre-built SQL string is deliberately not
     553                 * accepted, so request values are never concatenated into a statement.
     554                 *
     555                 * Set `distinct` when the JOIN can match a row more than once; it is
     556                 * applied to the row query and the count together.
     557                 *
     558                 * Table and column identifiers inside `join` are the listener's own
     559                 * responsibility -- Free cannot validate another plugin's schema.
     560                 *
     561                 * @since 0.3.0
     562                 *
     563                 * @param array{join:string,where:array,prepare_args:array,distinct:bool} $parts
     564                 * @param array $args Query args, including 'context'.
     565                 */
     566                $contributed = apply_filters( 'nldcf7_entry_query_clauses', $parts, $args );
     567
     568                $join         = '';
     569                $conditions   = array();
     570                $prepare_args = $where['prepare_args'];
     571                $distinct     = false;
     572
     573                if ( is_array( $contributed ) ) {
     574                        $join     = isset( $contributed['join'] ) && is_string( $contributed['join'] ) ? trim( $contributed['join'] ) : '';
     575                        $distinct = ! empty( $contributed['distinct'] );
     576
     577                        $raw_where = isset( $contributed['where'] ) && is_array( $contributed['where'] ) ? $contributed['where'] : array();
     578                        $raw_prep  = isset( $contributed['prepare_args'] ) && is_array( $contributed['prepare_args'] ) ? array_values( $contributed['prepare_args'] ) : array();
     579
     580                        foreach ( $raw_where as $condition ) {
     581                                if ( is_string( $condition ) && '' !== trim( $condition ) ) {
     582                                        $conditions[] = '( ' . trim( $condition ) . ' )';
     583                                }
     584                        }
     585
     586                        // The placeholder count has to match the values supplied, or
     587                        // $wpdb->prepare() would mis-bind every value after the mismatch and
     588                        // return a broken statement. Rather than run that, drop the whole
     589                        // contribution and say so while debugging.
     590                        $expected = 0;
     591                        foreach ( $conditions as $condition ) {
     592                                $expected += preg_match_all( '/%[dfs]/', $condition );
     593                        }
     594                        if ( $expected !== count( $raw_prep ) ) {
     595                                $this->debug_clauses(
     596                                        sprintf( 'placeholder/value mismatch (%d placeholders, %d values); clause contribution dropped', $expected, count( $raw_prep ) )
     597                                );
     598                                $join       = '';
     599                                $conditions = array();
     600                                $raw_prep   = array();
     601                                $distinct   = false;
     602                        }
     603
     604                        foreach ( $raw_prep as $value ) {
     605                                $prepare_args[] = $value;
     606                        }
     607                }
     608
     609                $clause = $where['clause'];
     610                if ( $conditions ) {
     611                        $extra  = implode( ' AND ', $conditions );
     612                        $clause = '' === $clause ? ( 'WHERE ' . $extra ) : ( $clause . ' AND ' . $extra );
     613                }
     614
     615                return array(
     616                        'join'         => $join,
     617                        'clause'       => $clause,
     618                        'prepare_args' => $prepare_args,
     619                        'distinct'     => (bool) $distinct,
     620                );
     621        }
     622
     623        /**
     624         * Log a rejected clause contribution while debugging.
     625         *
     626         * @param string $message
     627         * @return void
     628         */
     629        private function debug_clauses( $message ) {
     630                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
     631                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated by WP_DEBUG; a silently dropped clause would otherwise look like a filter that simply did not fire.
     632                        error_log( '[neutrope-lead-desk-for-contact-form-7] entry query clauses: ' . $message );
     633                }
     634        }
     635
    465636        private function sanitize_orderby( $field ) {
    466637                $allowed = array( 'id', 'submitted_at', 'status', 'form_post_id' );
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/includes/class-export-service.php

    r3553617 r3666532  
    132132                                        'orderby'  => 'submitted_at',
    133133                                        'order'    => 'DESC',
     134                                        // Tells the query extension hooks this is CSV export, not the
     135                                        // entries screen, so a list-only constraint cannot reach it.
     136                                        'context'  => NLDCF7_Entry_Repository::CONTEXT_EXPORT,
    134137                                )
    135138                        );
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/languages/neutrope-lead-desk-for-contact-form-7-ja.po

    r3666139 r3666532  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.2.0\n"
     5"Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.3.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/neutrope-lead-desk-for-contact-form-7\n"
    77"Last-Translator: \n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-08-25T14:05:36+00:00\n"
    13 "PO-Revision-Date: 2026-08-25 00:00+0000\n"
     12"POT-Creation-Date: 2026-08-26T04:22:33+00:00\n"
     13"PO-Revision-Date: 2026-08-26 00:00+0000\n"
    1414"Language: ja\n"
    1515"Plural-Forms: nplurals=1; plural=0;\n"
    … …  
    154154msgstr "指定された問い合わせは見つかりませんでした。"
    155155
    156 #: admin/class-entry-list-table.php:211
     156#: admin/class-entry-list-table.php:336
    157157#: admin/views/export.php:43
    158158msgid "All"
    159159msgstr "すべて"
    160160
    161 #: admin/class-entry-list-table.php:227
    162 #: admin/class-entry-list-table.php:361
     161#: admin/class-entry-list-table.php:352
     162#: admin/class-entry-list-table.php:570
    163163#: admin/views/export.php:72
    164164msgid "Trash"
    … …  
    166166
    167167#: admin/class-entry-event-presenter.php:53
    168 #: admin/class-entry-list-table.php:238
    169 #: admin/views/entry-detail.php:253
     168#: admin/class-entry-list-table.php:363
     169#: admin/views/entry-detail.php:276
    170170#: includes/class-export-service.php:47
    171171msgid "Received"
    172172msgstr "受信日時"
    173173
    174 #: admin/class-entry-list-table.php:239
    175 #: admin/class-entry-list-table.php:471
    176 #: admin/views/entry-detail.php:228
     174#: admin/class-entry-list-table.php:364
     175#: admin/class-entry-list-table.php:680
     176#: admin/views/entry-detail.php:251
    177177#: admin/views/export.php:40
    178178#: includes/class-export-service.php:48
    … …  
    180180msgstr "ステータス"
    181181
    182 #: admin/class-entry-list-table.php:240
    183 #: admin/class-entry-list-table.php:479
    184 #: admin/views/entry-detail.php:254
     182#: admin/class-entry-list-table.php:365
     183#: admin/class-entry-list-table.php:688
     184#: admin/views/entry-detail.php:277
    185185#: admin/views/export.php:51
    186186#: includes/class-export-service.php:49
    … …  
    188188msgstr "フォーム"
    189189
    190 #: admin/class-entry-list-table.php:241
     190#: admin/class-entry-list-table.php:366
    191191#: admin/views/settings.php:73
    192192#: includes/class-export-service.php:51
    … …  
    194194msgstr "名前"
    195195
    196 #: admin/class-entry-list-table.php:242
     196#: admin/class-entry-list-table.php:367
    197197#: admin/views/settings.php:77
    198198#: includes/class-export-service.php:52
    … …  
    200200msgstr "メール"
    201201
    202 #: admin/class-entry-list-table.php:243
     202#: admin/class-entry-list-table.php:368
    203203#: admin/views/settings.php:81
    204204#: includes/class-export-service.php:53
    … …  
    206206msgstr "電話"
    207207
    208 #: admin/class-entry-list-table.php:244
     208#: admin/class-entry-list-table.php:369
    209209msgid "Source"
    210210msgstr "参照元"
    211211
    212 #: admin/class-entry-list-table.php:245
     212#: admin/class-entry-list-table.php:370
    213213msgid "UTM source"
    214214msgstr "UTM source"
    215215
    216 #: admin/class-entry-list-table.php:246
    217 #: admin/views/entry-detail.php:96
     216#: admin/class-entry-list-table.php:371
     217#: admin/views/entry-detail.php:97
    218218msgid "Notes"
    219219msgstr "メモ"
    220220
    221 #: admin/class-entry-list-table.php:264
    222 #: admin/class-entry-list-table.php:342
     221#: admin/class-entry-list-table.php:473
     222#: admin/class-entry-list-table.php:551
    223223msgid "Restore"
    224224msgstr "復元"
    225225
    226 #: admin/class-entry-list-table.php:269
     226#: admin/class-entry-list-table.php:478
    227227msgid "Set status: New"
    228228msgstr "ステータス: 未対応"
    229229
    230 #: admin/class-entry-list-table.php:270
     230#: admin/class-entry-list-table.php:479
    231231msgid "Set status: In progress"
    232232msgstr "ステータス: 対応中"
    233233
    234 #: admin/class-entry-list-table.php:271
     234#: admin/class-entry-list-table.php:480
    235235msgid "Set status: On hold"
    236236msgstr "ステータス: 保留"
    237237
    238 #: admin/class-entry-list-table.php:272
     238#: admin/class-entry-list-table.php:481
    239239msgid "Set status: Done"
    240240msgstr "ステータス: 完了"
    241241
    242 #: admin/class-entry-list-table.php:273
     242#: admin/class-entry-list-table.php:482
    243243msgid "Move to trash"
    244244msgstr "ゴミ箱へ移動"
    245245
    246 #: admin/class-entry-list-table.php:274
     246#: admin/class-entry-list-table.php:483
    247247msgid "Mark as spam"
    248248msgstr "スパムにする"
    249249
    250 #: admin/class-entry-list-table.php:299
     250#: admin/class-entry-list-table.php:508
    251251msgid "View"
    252252msgstr "詳細"
    253253
    254 #: admin/class-entry-list-table.php:430
     254#: admin/class-entry-list-table.php:639
    255255msgid "None"
    256256msgstr "なし"
    257257
    258 #: admin/class-entry-list-table.php:437
     258#: admin/class-entry-list-table.php:646
    259259msgid "No entries are in the trash."
    260260msgstr "ゴミ箱に問い合わせはありません。"
    261261
    262 #: admin/class-entry-list-table.php:440
     262#: admin/class-entry-list-table.php:649
    263263msgid "No entries match the current filters."
    264264msgstr "該当する問い合わせはありません。"
    265265
    266 #: admin/class-entry-list-table.php:468
    267 #: admin/class-entry-list-table.php:469
     266#: admin/class-entry-list-table.php:677
     267#: admin/class-entry-list-table.php:678
    268268msgid "Search keywords"
    269269msgstr "キーワード検索"
    270270
    271 #: admin/class-entry-list-table.php:473
     271#: admin/class-entry-list-table.php:682
    272272msgid "All statuses"
    273273msgstr "すべてのステータス"
    274274
    275 #: admin/class-entry-list-table.php:481
     275#: admin/class-entry-list-table.php:690
    276276#: admin/views/export.php:54
    277277msgid "All forms"
    278278msgstr "すべてのフォーム"
    279279
    280 #: admin/class-entry-list-table.php:487
     280#: admin/class-entry-list-table.php:696
    281281#: admin/views/export.php:32
    282282msgid "From date"
    283283msgstr "開始日"
    284284
    285 #: admin/class-entry-list-table.php:489
     285#: admin/class-entry-list-table.php:698
    286286#: admin/views/export.php:36
    287287msgid "To date"
    288288msgstr "終了日"
    289289
    290 #: admin/class-entry-list-table.php:494
     290#: admin/class-entry-list-table.php:703
    291291msgid "Show spam"
    292292msgstr "スパムも表示"
    293293
    294 #: admin/class-entry-list-table.php:497
     294#: admin/class-entry-list-table.php:726
    295295msgid "Filter"
    296296msgstr "絞り込み"
    297297
    298298#. translators: %d: entry ID
    299 #: admin/views/entry-detail.php:31
     299#: admin/views/entry-detail.php:32
    300300#, php-format
    301301msgid "Entry #%d"
    302302msgstr "問い合わせ #%d"
    303303
    304 #: admin/views/entry-detail.php:34
     304#: admin/views/entry-detail.php:35
    305305msgid "Back to list"
    306306msgstr "一覧へ戻る"
    307307
    308 #: admin/views/entry-detail.php:47
     308#: admin/views/entry-detail.php:48
    309309msgid "Submitted fields"
    310310msgstr "送信内容"
    311311
    312 #: admin/views/entry-detail.php:51
     312#: admin/views/entry-detail.php:52
    313313msgid "No submitted fields stored."
    314314msgstr "送信フィールドが保存されていません。"
    315315
    316 #: admin/views/entry-detail.php:72
     316#: admin/views/entry-detail.php:73
    317317msgid "Attachments (metadata only)"
    318318msgstr "添付ファイル(メタ情報のみ)"
    319319
    320 #: admin/views/entry-detail.php:76
     320#: admin/views/entry-detail.php:77
    321321msgid "File name"
    322322msgstr "ファイル名"
    323323
    324 #: admin/views/entry-detail.php:77
     324#: admin/views/entry-detail.php:78
    325325msgid "MIME"
    326326msgstr "MIME"
    327327
    328 #: admin/views/entry-detail.php:78
     328#: admin/views/entry-detail.php:79
    329329msgid "Size (bytes)"
    330330msgstr "サイズ (bytes)"
    331331
    332 #: admin/views/entry-detail.php:91
     332#: admin/views/entry-detail.php:92
    333333msgid "Only metadata is captured. The uploaded files themselves are not stored by this plugin."
    334334msgstr "添付ファイルの実体は保存せず、メタ情報(ファイル名・MIME・サイズ)のみを記録しています。"
    335335
    336 #: admin/views/entry-detail.php:102
     336#: admin/views/entry-detail.php:103
    337337msgid "Note body"
    338338msgstr "メモ本文"
    339339
    340 #: admin/views/entry-detail.php:105
     340#: admin/views/entry-detail.php:106
    341341msgid "Add note"
    342342msgstr "メモを追加"
    343343
    344 #: admin/views/entry-detail.php:109
     344#: admin/views/entry-detail.php:110
    345345msgid "No notes yet."
    346346msgstr "まだメモはありません。"
    347347
    348348#: admin/class-entry-event-presenter.php:119
    349 #: admin/views/entry-detail.php:115
     349#: admin/views/entry-detail.php:116
    350350msgid "Unknown user"
    351351msgstr "不明なユーザー"
    352352
    353 #: admin/views/entry-detail.php:238
     353#: admin/views/entry-detail.php:261
    354354msgid "Change status"
    355355msgstr "ステータスを変更"
    356356
    357 #: admin/views/entry-detail.php:246
     357#: admin/views/entry-detail.php:269
    358358msgid "Update"
    359359msgstr "更新"
    360360
    361 #: admin/views/entry-detail.php:251
     361#: admin/views/entry-detail.php:274
    362362msgid "Submission info"
    363363msgstr "送信情報"
    364364
    365 #: admin/views/entry-detail.php:255
     365#: admin/views/entry-detail.php:278
    366366#: includes/class-export-service.php:50
    367367msgid "Form ID"
    368368msgstr "フォーム ID"
    369369
    370 #: admin/views/entry-detail.php:256
     370#: admin/views/entry-detail.php:279
    371371#: includes/class-export-service.php:55
    372372msgid "Submission page URL"
    373373msgstr "送信ページ URL"
    374374
    375 #: admin/views/entry-detail.php:260
     375#: admin/views/entry-detail.php:283
    376376#: includes/class-export-service.php:56
    377377msgid "Referrer"
    378378msgstr "参照元"
    379379
    380 #: admin/views/entry-detail.php:264
     380#: admin/views/entry-detail.php:287
    381381#: includes/class-export-service.php:57
    382382msgid "Landing URL"
    383383msgstr "ランディング URL"
    384384
    385 #: admin/views/entry-detail.php:270
     385#: admin/views/entry-detail.php:293
    386386msgid "User Agent"
    387387msgstr "User Agent"
    388388
    389 #: admin/views/entry-detail.php:271
     389#: admin/views/entry-detail.php:294
    390390#: includes/class-export-service.php:64
    391391msgid "IP address"
    392392msgstr "IP アドレス"
    393393
    394 #: admin/views/entry-detail.php:272
     394#: admin/views/entry-detail.php:295
    395395#: includes/class-export-service.php:65
    396396msgid "Spam flag"
    397397msgstr "スパム判定"
    398398
    399 #: admin/views/entry-detail.php:272
     399#: admin/views/entry-detail.php:295
    400400#: admin/views/export.php:62
    401401msgid "Spam"
    402402msgstr "スパム"
    403403
    404 #: admin/views/entry-detail.php:272
     404#: admin/views/entry-detail.php:295
    405405msgid "Normal"
    406406msgstr "通常"
    … …  
    665665msgstr "設定を保存"
    666666
    667 #: includes/class-entry-repository.php:25
     667#: includes/class-entry-repository.php:38
    668668msgid "New"
    669669msgstr "未対応"
    670670
    671 #: includes/class-entry-repository.php:26
     671#: includes/class-entry-repository.php:39
    672672msgid "In progress"
    673673msgstr "対応中"
    674674
    675 #: includes/class-entry-repository.php:27
     675#: includes/class-entry-repository.php:40
    676676msgid "On hold"
    677677msgstr "保留"
    678678
    679 #: includes/class-entry-repository.php:28
     679#: includes/class-entry-repository.php:41
    680680msgid "Done"
    681681msgstr "完了"
    … …  
    693693msgstr "過去の問い合わせから対応履歴を準備しています。通常どおりご利用いただけます。"
    694694
    695 #. translators: 1: number of records processed, 2: total records, 3: percentage complete
    696 #: admin/class-admin-menu.php:188
    697 #, php-format
    698 msgid "%1$s of %2$s (%3$s%%)"
    699 msgstr "%2$s 件中 %1$s 件(%3$s%%)"
    700 
    701695#: admin/class-entry-event-presenter.php:54
    702696msgid "Status changed"
    … …  
    720714
    721715#: admin/class-entry-event-presenter.php:89
    722 #: admin/views/entry-detail.php:141
     716#: admin/views/entry-detail.php:142
    723717msgid "Activity"
    724718msgstr "対応履歴"
    … …  
    762756msgstr "メモを見る"
    763757
    764 #: admin/views/entry-detail.php:143
     758#: admin/views/entry-detail.php:145
    765759msgid "History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved."
    766760msgstr "アップデート前の履歴は、受信日時と既存メモから確認できる範囲のみ表示されます。"
    767761
    768 #: admin/views/entry-detail.php:155
     762#: admin/views/entry-detail.php:158
    769763msgid "No activity on this page."
    770764msgstr "このページに表示する対応履歴はありません。"
    771765
    772 #: admin/views/entry-detail.php:157
     766#: admin/views/entry-detail.php:160
    773767msgid "Back to the latest activity"
    774768msgstr "最新の対応履歴へ戻る"
    775769
    776 #: admin/views/entry-detail.php:162
     770#: admin/views/entry-detail.php:165
    777771msgid "Preparing activity history…"
    778772msgstr "対応履歴を準備しています…"
    779773
    780 #: admin/views/entry-detail.php:164
     774#: admin/views/entry-detail.php:167
    781775msgid "No activity recorded yet."
    782776msgstr "対応履歴はまだありません。"
    783777
    784 #: admin/views/entry-detail.php:198
     778#: admin/views/entry-detail.php:201
    785779msgid "Activity pagination"
    786780msgstr "対応履歴のページ送り"
    787781
    788782#. translators: 1: current page number, 2: total number of pages
    789 #: admin/views/entry-detail.php:203
     783#: admin/views/entry-detail.php:206
    790784#, php-format
    791785msgid "Page %1$s of %2$s"
    792786msgstr "%2$s ページ中 %1$s ページ"
    793787
    794 #: admin/views/entry-detail.php:211
     788#: admin/views/entry-detail.php:214
    795789msgid "Newer activity"
    796790msgstr "新しい対応履歴"
    797791
    798 #: admin/views/entry-detail.php:216
     792#: admin/views/entry-detail.php:219
    799793msgid "Older activity"
    800794msgstr "古い対応履歴"
    … …  
    815809msgid "For entries received before this feature was added, activity is limited to the received timestamp and any internal notes already saved. Earlier status, trash, and spam changes were never recorded, so they are not shown."
    816810msgstr "この機能が追加される前に受信した問い合わせでは、対応履歴は受信日時と保存済みのメモのみとなります。それ以前のステータス・ゴミ箱・スパムの変更は記録されていないため、表示されません。"
     811
     812#. translators: 1: number of records processed, 2: total records to process, 3: percentage complete. "Records" counts processing units (entries plus notes), NOT the number of inquiries.
     813#: admin/class-admin-menu.php:188
     814#, php-format
     815msgid "Processed %1$s of %2$s records (%3$s%%)."
     816msgstr "%2$s 件の履歴データのうち %1$s 件を処理しました(%3$s%%)。"
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/languages/neutrope-lead-desk-for-contact-form-7.pot

    r3666139 r3666532  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.2.0\n"
     5"Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.3.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/neutrope-lead-desk-for-contact-form-7\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-08-25T14:05:36+00:00\n"
     12"POT-Creation-Date: 2026-08-26T04:22:33+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    7070msgstr ""
    7171
    72 #. translators: 1: number of records processed, 2: total records, 3: percentage complete
     72#. translators: 1: number of records processed, 2: total records to process, 3: percentage complete. "Records" counts processing units (entries plus notes), NOT the number of inquiries.
    7373#: admin/class-admin-menu.php:188
    7474#, php-format
    75 msgid "%1$s of %2$s (%3$s%%)"
     75msgid "Processed %1$s of %2$s records (%3$s%%)."
    7676msgstr ""
    7777
    … …  
    172172
    173173#: admin/class-entry-event-presenter.php:53
    174 #: admin/class-entry-list-table.php:238
    175 #: admin/views/entry-detail.php:253
     174#: admin/class-entry-list-table.php:363
     175#: admin/views/entry-detail.php:276
    176176#: includes/class-export-service.php:47
    177177msgid "Received"
    … …  
    199199
    200200#: admin/class-entry-event-presenter.php:89
    201 #: admin/views/entry-detail.php:141
     201#: admin/views/entry-detail.php:142
    202202msgid "Activity"
    203203msgstr ""
    … …  
    208208
    209209#: admin/class-entry-event-presenter.php:119
    210 #: admin/views/entry-detail.php:115
     210#: admin/views/entry-detail.php:116
    211211msgid "Unknown user"
    212212msgstr ""
    … …  
    246246msgstr ""
    247247
    248 #: admin/class-entry-list-table.php:211
     248#: admin/class-entry-list-table.php:336
    249249#: admin/views/export.php:43
    250250msgid "All"
    251251msgstr ""
    252252
    253 #: admin/class-entry-list-table.php:227
    254 #: admin/class-entry-list-table.php:361
     253#: admin/class-entry-list-table.php:352
     254#: admin/class-entry-list-table.php:570
    255255#: admin/views/export.php:72
    256256msgid "Trash"
    257257msgstr ""
    258258
    259 #: admin/class-entry-list-table.php:239
    260 #: admin/class-entry-list-table.php:471
    261 #: admin/views/entry-detail.php:228
     259#: admin/class-entry-list-table.php:364
     260#: admin/class-entry-list-table.php:680
     261#: admin/views/entry-detail.php:251
    262262#: admin/views/export.php:40
    263263#: includes/class-export-service.php:48
    … …  
    265265msgstr ""
    266266
    267 #: admin/class-entry-list-table.php:240
    268 #: admin/class-entry-list-table.php:479
    269 #: admin/views/entry-detail.php:254
     267#: admin/class-entry-list-table.php:365
     268#: admin/class-entry-list-table.php:688
     269#: admin/views/entry-detail.php:277
    270270#: admin/views/export.php:51
    271271#: includes/class-export-service.php:49
    … …  
    273273msgstr ""
    274274
    275 #: admin/class-entry-list-table.php:241
     275#: admin/class-entry-list-table.php:366
    276276#: admin/views/settings.php:73
    277277#: includes/class-export-service.php:51
    … …  
    279279msgstr ""
    280280
    281 #: admin/class-entry-list-table.php:242
     281#: admin/class-entry-list-table.php:367
    282282#: admin/views/settings.php:77
    283283#: includes/class-export-service.php:52
    … …  
    285285msgstr ""
    286286
    287 #: admin/class-entry-list-table.php:243
     287#: admin/class-entry-list-table.php:368
    288288#: admin/views/settings.php:81
    289289#: includes/class-export-service.php:53
    … …  
    291291msgstr ""
    292292
    293 #: admin/class-entry-list-table.php:244
     293#: admin/class-entry-list-table.php:369
    294294msgid "Source"
    295295msgstr ""
    296296
    297 #: admin/class-entry-list-table.php:245
     297#: admin/class-entry-list-table.php:370
    298298msgid "UTM source"
    299299msgstr ""
    300300
    301 #: admin/class-entry-list-table.php:246
    302 #: admin/views/entry-detail.php:96
     301#: admin/class-entry-list-table.php:371
     302#: admin/views/entry-detail.php:97
    303303msgid "Notes"
    304304msgstr ""
    305305
    306 #: admin/class-entry-list-table.php:264
    307 #: admin/class-entry-list-table.php:342
     306#: admin/class-entry-list-table.php:473
     307#: admin/class-entry-list-table.php:551
    308308msgid "Restore"
    309309msgstr ""
    310310
    311 #: admin/class-entry-list-table.php:269
     311#: admin/class-entry-list-table.php:478
    312312msgid "Set status: New"
    313313msgstr ""
    314314
    315 #: admin/class-entry-list-table.php:270
     315#: admin/class-entry-list-table.php:479
    316316msgid "Set status: In progress"
    317317msgstr ""
    318318
    319 #: admin/class-entry-list-table.php:271
     319#: admin/class-entry-list-table.php:480
    320320msgid "Set status: On hold"
    321321msgstr ""
    322322
    323 #: admin/class-entry-list-table.php:272
     323#: admin/class-entry-list-table.php:481
    324324msgid "Set status: Done"
    325325msgstr ""
    326326
    327 #: admin/class-entry-list-table.php:273
     327#: admin/class-entry-list-table.php:482
    328328msgid "Move to trash"
    329329msgstr ""
    330330
    331 #: admin/class-entry-list-table.php:274
     331#: admin/class-entry-list-table.php:483
    332332msgid "Mark as spam"
    333333msgstr ""
    334334
    335 #: admin/class-entry-list-table.php:299
     335#: admin/class-entry-list-table.php:508
    336336msgid "View"
    337337msgstr ""
    338338
    339 #: admin/class-entry-list-table.php:430
     339#: admin/class-entry-list-table.php:639
    340340msgid "None"
    341341msgstr ""
    342342
    343 #: admin/class-entry-list-table.php:437
     343#: admin/class-entry-list-table.php:646
    344344msgid "No entries are in the trash."
    345345msgstr ""
    346346
    347 #: admin/class-entry-list-table.php:440
     347#: admin/class-entry-list-table.php:649
    348348msgid "No entries match the current filters."
    349349msgstr ""
    350350
    351 #: admin/class-entry-list-table.php:468
    352 #: admin/class-entry-list-table.php:469
     351#: admin/class-entry-list-table.php:677
     352#: admin/class-entry-list-table.php:678
    353353msgid "Search keywords"
    354354msgstr ""
    355355
    356 #: admin/class-entry-list-table.php:473
     356#: admin/class-entry-list-table.php:682
    357357msgid "All statuses"
    358358msgstr ""
    359359
    360 #: admin/class-entry-list-table.php:481
     360#: admin/class-entry-list-table.php:690
    361361#: admin/views/export.php:54
    362362msgid "All forms"
    363363msgstr ""
    364364
    365 #: admin/class-entry-list-table.php:487
     365#: admin/class-entry-list-table.php:696
    366366#: admin/views/export.php:32
    367367msgid "From date"
    368368msgstr ""
    369369
    370 #: admin/class-entry-list-table.php:489
     370#: admin/class-entry-list-table.php:698
    371371#: admin/views/export.php:36
    372372msgid "To date"
    373373msgstr ""
    374374
    375 #: admin/class-entry-list-table.php:494
     375#: admin/class-entry-list-table.php:703
    376376msgid "Show spam"
    377377msgstr ""
    378378
    379 #: admin/class-entry-list-table.php:497
     379#: admin/class-entry-list-table.php:726
    380380msgid "Filter"
    381381msgstr ""
    382382
    383383#. translators: %d: entry ID
    384 #: admin/views/entry-detail.php:31
     384#: admin/views/entry-detail.php:32
    385385#, php-format
    386386msgid "Entry #%d"
    387387msgstr ""
    388388
    389 #: admin/views/entry-detail.php:34
     389#: admin/views/entry-detail.php:35
    390390msgid "Back to list"
    391391msgstr ""
    392392
    393 #: admin/views/entry-detail.php:47
     393#: admin/views/entry-detail.php:48
    394394msgid "Submitted fields"
    395395msgstr ""
    396396
    397 #: admin/views/entry-detail.php:51
     397#: admin/views/entry-detail.php:52
    398398msgid "No submitted fields stored."
    399399msgstr ""
    400400
    401 #: admin/views/entry-detail.php:72
     401#: admin/views/entry-detail.php:73
    402402msgid "Attachments (metadata only)"
    403403msgstr ""
    404404
    405 #: admin/views/entry-detail.php:76
     405#: admin/views/entry-detail.php:77
    406406msgid "File name"
    407407msgstr ""
    408408
    409 #: admin/views/entry-detail.php:77
     409#: admin/views/entry-detail.php:78
    410410msgid "MIME"
    411411msgstr ""
    412412
    413 #: admin/views/entry-detail.php:78
     413#: admin/views/entry-detail.php:79
    414414msgid "Size (bytes)"
    415415msgstr ""
    416416
    417 #: admin/views/entry-detail.php:91
     417#: admin/views/entry-detail.php:92
    418418msgid "Only metadata is captured. The uploaded files themselves are not stored by this plugin."
    419419msgstr ""
    420420
    421 #: admin/views/entry-detail.php:102
     421#: admin/views/entry-detail.php:103
    422422msgid "Note body"
    423423msgstr ""
    424424
    425 #: admin/views/entry-detail.php:105
     425#: admin/views/entry-detail.php:106
    426426msgid "Add note"
    427427msgstr ""
    428428
    429 #: admin/views/entry-detail.php:109
     429#: admin/views/entry-detail.php:110
    430430msgid "No notes yet."
    431431msgstr ""
    432432
    433 #: admin/views/entry-detail.php:143
     433#: admin/views/entry-detail.php:145
    434434msgid "History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved."
    435435msgstr ""
    436436
    437 #: admin/views/entry-detail.php:155
     437#: admin/views/entry-detail.php:158
    438438msgid "No activity on this page."
    439439msgstr ""
    440440
    441 #: admin/views/entry-detail.php:157
     441#: admin/views/entry-detail.php:160
    442442msgid "Back to the latest activity"
    443443msgstr ""
    444444
    445 #: admin/views/entry-detail.php:162
     445#: admin/views/entry-detail.php:165
    446446msgid "Preparing activity history…"
    447447msgstr ""
    448448
    449 #: admin/views/entry-detail.php:164
     449#: admin/views/entry-detail.php:167
    450450msgid "No activity recorded yet."
    451451msgstr ""
    452452
    453 #: admin/views/entry-detail.php:198
     453#: admin/views/entry-detail.php:201
    454454msgid "Activity pagination"
    455455msgstr ""
    456456
    457457#. translators: 1: current page number, 2: total number of pages
    458 #: admin/views/entry-detail.php:203
     458#: admin/views/entry-detail.php:206
    459459#, php-format
    460460msgid "Page %1$s of %2$s"
    461461msgstr ""
    462462
    463 #: admin/views/entry-detail.php:211
     463#: admin/views/entry-detail.php:214
    464464msgid "Newer activity"
    465465msgstr ""
    466466
    467 #: admin/views/entry-detail.php:216
     467#: admin/views/entry-detail.php:219
    468468msgid "Older activity"
    469469msgstr ""
    470470
    471 #: admin/views/entry-detail.php:238
     471#: admin/views/entry-detail.php:261
    472472msgid "Change status"
    473473msgstr ""
    474474
    475 #: admin/views/entry-detail.php:246
     475#: admin/views/entry-detail.php:269
    476476msgid "Update"
    477477msgstr ""
    478478
    479 #: admin/views/entry-detail.php:251
     479#: admin/views/entry-detail.php:274
    480480msgid "Submission info"
    481481msgstr ""
    482482
    483 #: admin/views/entry-detail.php:255
     483#: admin/views/entry-detail.php:278
    484484#: includes/class-export-service.php:50
    485485msgid "Form ID"
    486486msgstr ""
    487487
    488 #: admin/views/entry-detail.php:256
     488#: admin/views/entry-detail.php:279
    489489#: includes/class-export-service.php:55
    490490msgid "Submission page URL"
    491491msgstr ""
    492492
    493 #: admin/views/entry-detail.php:260
     493#: admin/views/entry-detail.php:283
    494494#: includes/class-export-service.php:56
    495495msgid "Referrer"
    496496msgstr ""
    497497
    498 #: admin/views/entry-detail.php:264
     498#: admin/views/entry-detail.php:287
    499499#: includes/class-export-service.php:57
    500500msgid "Landing URL"
    501501msgstr ""
    502502
    503 #: admin/views/entry-detail.php:270
     503#: admin/views/entry-detail.php:293
    504504msgid "User Agent"
    505505msgstr ""
    506506
    507 #: admin/views/entry-detail.php:271
     507#: admin/views/entry-detail.php:294
    508508#: includes/class-export-service.php:64
    509509msgid "IP address"
    510510msgstr ""
    511511
    512 #: admin/views/entry-detail.php:272
     512#: admin/views/entry-detail.php:295
    513513#: includes/class-export-service.php:65
    514514msgid "Spam flag"
    515515msgstr ""
    516516
    517 #: admin/views/entry-detail.php:272
     517#: admin/views/entry-detail.php:295
    518518#: admin/views/export.php:62
    519519msgid "Spam"
    520520msgstr ""
    521521
    522 #: admin/views/entry-detail.php:272
     522#: admin/views/entry-detail.php:295
    523523msgid "Normal"
    524524msgstr ""
    … …  
    799799msgstr ""
    800800
    801 #: includes/class-entry-repository.php:25
     801#: includes/class-entry-repository.php:38
    802802msgid "New"
    803803msgstr ""
    804804
    805 #: includes/class-entry-repository.php:26
     805#: includes/class-entry-repository.php:39
    806806msgid "In progress"
    807807msgstr ""
    808808
    809 #: includes/class-entry-repository.php:27
     809#: includes/class-entry-repository.php:40
    810810msgid "On hold"
    811811msgstr ""
    812812
    813 #: includes/class-entry-repository.php:28
     813#: includes/class-entry-repository.php:41
    814814msgid "Done"
    815815msgstr ""
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/neutrope-lead-desk-for-contact-form-7.php

    r3666139 r3666532  
    44 * Plugin URI:        https://neutrope.biz/neutrope-lead-desk-for-contact-form-7/
    55 * Description:       Save Contact Form 7 submissions to the database and manage them as leads in the WordPress admin (list, detail, status, notes, CSV export).
    6  * Version:           0.2.0
     6 * Version:           0.3.0
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    2222}
    2323
    24 define( 'NLDCF7_VERSION', '0.2.0' );
     24define( 'NLDCF7_VERSION', '0.3.0' );
    2525define( 'NLDCF7_DB_VERSION', '1.1.0' );
    2626define( 'NLDCF7_PLUGIN_FILE', __FILE__ );
  • neutrope-lead-desk-for-contact-form-7/tags/0.3.0/readme.txt

    r3666139 r3666532  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.2.0
     7Stable tag: 0.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1616
    1717The free version is intentionally focused: it saves submissions reliably, gives you an admin list/detail UI, lets you change status, add internal notes, and export to CSV. It does this without pulling in external services, trackers, or upsell banners on unrelated screens.
     18
     19Version 0.3.0 is mainly a foundation release for add-ons: it adds documented, generic extension points so other plugins can add columns, filters and detail-screen panels without forking this one. Nothing in the plugin's own behaviour changes because of them, and no database change is involved. See "Extending the plugin" below.
    1820
    1921Since 0.2.0 each entry also keeps an **Activity Timeline** (対応履歴) so you can see what has already been done about an inquiry -- when it arrived, who changed its status and to what, when notes were added, and when it was moved to trash, restored, or marked as spam -- instead of inferring the handling history from the current status alone.
    … …  
    3436* Privacy-friendly defaults: IP capture is OFF by default
    3537* Opt-in data deletion on uninstall (off by default so accidental deletes don't nuke your leads)
    36 * Extensibility via action/filter hooks: `nldcf7_after_entry_saved`, `nldcf7_after_note_added`, `nldcf7_after_status_changed`, `nldcf7_after_entry_trashed`, `nldcf7_after_entry_restored`, `nldcf7_after_spam_changed`, `nldcf7_entry_data_before_insert`, `nldcf7_export_columns`, `nldcf7_export_row`, `nldcf7_statuses`, `nldcf7_event_render`, `nldcf7_required_capability`
     38* Extensibility via action/filter hooks: `nldcf7_after_entry_saved`, `nldcf7_after_note_added`, `nldcf7_after_status_changed`, `nldcf7_after_entry_trashed`, `nldcf7_after_entry_restored`, `nldcf7_after_spam_changed`, `nldcf7_entry_data_before_insert`, `nldcf7_export_columns`, `nldcf7_export_row`, `nldcf7_statuses`, `nldcf7_event_labels`, `nldcf7_event_render`, `nldcf7_required_capability`
     39* Since 0.3.0, nine further extension points let an add-on place panels on the entry detail screen, add list columns and filter controls, keep its own filter state across list actions, and constrain the entries query -- all documented under "Extending the plugin"
    3740
    3841**Requirements**
    … …  
    106109Please open an issue on the plugin's support forum or the project's issue tracker.
    107110
     111== Extending the plugin ==
     112
     113Version 0.3.0 adds nine extension points for add-on developers. They are
     114generic: this plugin knows nothing about what an add-on stores. All of them are
     115no-ops when nothing is listening, so a site with no add-on behaves exactly as
     116it did in 0.2.0.
     117
     118= nldcf7_entry_detail_main =
     119
     120Action. Introduced in 0.3.0. Fires on the entry detail screen after this
     121plugin's own cards in the main column, before the column closes.
     122
     123`do_action( 'nldcf7_entry_detail_main', array $entry )`
     124
     125`$entry` is the entry row as stored, passed by value. The listener prints its
     126own markup, including its own card wrapper, and is responsible for escaping it.
     127Output is not buffered or altered.
     128
     129= nldcf7_entry_detail_sidebar =
     130
     131Action. Introduced in 0.3.0. The same contract, for the sidebar column. Two
     132hooks exist so an add-on can place content in both columns of one screen.
     133
     134`do_action( 'nldcf7_entry_detail_sidebar', array $entry )`
     135
     136= nldcf7_entry_list_columns =
     137
     138Filter. Introduced in 0.3.0. Adds columns to the entries list.
     139
     140`apply_filters( 'nldcf7_entry_list_columns', array $columns )`
     141
     142Return the column map with your own keys appended; prefix them to avoid
     143collisions. Core columns cannot be removed, renamed or repurposed -- every core
     144key is restored to its own label after the filter runs, and the checkbox column
     145is forced back to the front. Labels are plain text and are escaped on output.
     146
     147= nldcf7_entry_list_column_content =
     148
     149Filter. Introduced in 0.3.0. Renders a cell for a column you added.
     150
     151`apply_filters( 'nldcf7_entry_list_column_content', string $html, string $column_name, array $item )`
     152
     153Return HTML for your own column, or the value you were given for anything else.
     154Escape your own output; the plugin then passes the result through
     155`wp_kses_post()` as a second layer, so ordinary post markup survives while
     156scripts and event handlers do not. A non-string return renders an empty cell.
     157
     158= nldcf7_entry_list_filters =
     159
     160Action. Introduced in 0.3.0. Fires inside the list's existing GET filter form,
     161before the Filter button, so a plain select or input submits alongside the
     162built-in filters.
     163
     164`do_action( 'nldcf7_entry_list_filters', array $current_args )`
     165
     166`$current_args` is this plugin's own sanitized filter state, for reference. You
     167own your field names, your values and your escaping. No nonce is involved: the
     168filter bar only reads.
     169
     170= nldcf7_entry_list_carried_args =
     171
     172Filter. Introduced in 0.3.0. Keeps your own filter state across the All and
     173Trash views, the row actions, and the redirect after a row or bulk action.
     174
     175`apply_filters( 'nldcf7_entry_list_carried_args', array $args, array $core )`
     176
     177Add your own already-sanitized scalar values. The plugin re-checks the result
     178afterwards: reserved keys (`page`, `paged`, `action`, `action2`, `entry_id`,
     179`_wpnonce`, `_wp_http_referer`, `view`, `event_page`, `status_view`) are
     180stripped, keys are normalised, non-scalars are dropped, and both value length
     181and argument count are capped. Core values always win. Do not attempt to
     182forward the whole query string.
     183
     184= nldcf7_entry_query_args =
     185
     186Filter. Introduced in 0.3.0. Adjusts the entries query using arguments the
     187repository already understands, without writing SQL.
     188
     189`apply_filters( 'nldcf7_entry_query_args', array $args )`
     190
     191The most useful is `ids`, an array of entry IDs to restrict the query to.
     192`$args['context']` says who is asking: `list` for the entries screen, `export`
     193for CSV, `default` otherwise. Check it -- both callers share the same
     194repository, so an unconditional constraint would also change what CSV exports.
     195
     196= nldcf7_entry_query_clauses =
     197
     198Filter. Introduced in 0.3.0. Constrains the entries query using a table of your
     199own, resolved by the database rather than by loading matching IDs into PHP.
     200
     201`apply_filters( 'nldcf7_entry_query_clauses', array $parts, array $args )`
     202
     203`$parts` has four keys:
     204
     205* `join` -- a JOIN clause string.
     206* `where` -- an array of condition strings, each using `%s` or `%d` placeholders.
     207* `prepare_args` -- the values for those placeholders, in order.
     208* `distinct` -- boolean, default false.
     209
     210Values must go in `prepare_args`, never inside the condition strings; the plugin
     211passes them to `$wpdb->prepare()`. A single pre-built SQL string is not
     212accepted. If the placeholder count and the value count disagree, the whole
     213contribution is discarded. Table and column identifiers inside `join` are your
     214responsibility. Set `distinct` when your JOIN can match a row more than once: it
     215is applied to the row query and the count together, so the list total and
     216pagination stay correct. `$args['context']` applies here too.
     217
     218= nldcf7_entry_list_items_prepared =
     219
     220Action. Introduced in 0.3.0. Fires once per list page after the rows are known
     221and before any cell is rendered.
     222
     223`do_action( 'nldcf7_entry_list_items_prepared', array $items )`
     224
     225`$items` is the current page's rows only -- never the whole result set. Use it
     226to load everything your columns need in one query and keep it in a
     227request-local cache, instead of querying once per cell. It is read-only, has no
     228return value, and fires with an empty array when the page has no rows. It
     229belongs to the entries screen only and never fires during CSV export.
     230
    108231== Screenshots ==
    109232
    … …  
    115238
    116239== Changelog ==
     240
     241= 0.3.0 =
     242* Add: generic extension points for the entry detail screen -- `nldcf7_entry_detail_main` and `nldcf7_entry_detail_sidebar` -- so an add-on can place panels in either column without modifying this plugin.
     243* Add: generic list extension points -- `nldcf7_entry_list_columns` for columns and `nldcf7_entry_list_column_content` for their cells. Core columns are protected: an add-on cannot remove, rename or repurpose one, and returned cell markup passes through `wp_kses_post()`.
     244* Add: `nldcf7_entry_list_filters`, which places an add-on's own controls inside the existing filter bar.
     245* Add: `nldcf7_entry_list_carried_args`, so an add-on's own filter stays applied across the All and Trash views and after row and bulk actions. Reserved keys such as nonces, action names and entry IDs are refused, non-scalar values are dropped, and size and count are capped, so a careless listener cannot put unsafe state into a list URL.
     246* Add: `nldcf7_entry_query_args` for high-level query constraints, including the existing `ids` argument, and `nldcf7_entry_query_clauses` for constraints that live in an add-on's own table. Clauses are structured -- a JOIN, conditions with placeholders, and their values kept separate -- rather than a raw SQL string, and values always go through `$wpdb->prepare()`.
     247* Add: optional `distinct` handling for add-on joins that can match a row more than once. It is applied to the row query and the count together, so the list and its total can never disagree.
     248* Add: query context. The entries screen and the CSV exporter share one repository, so each query now says which it is. An add-on constraint meant for the list no longer reaches CSV export unless the add-on opts in.
     249* Add: `nldcf7_entry_list_items_prepared`, which hands an add-on the current page's rows once, before any cell is rendered, so custom columns can load their data in a single query instead of one per cell.
     250* Fix: the Activity preparation notice counted processing records (entries plus notes) but read as a number of inquiries. It now names the unit; the arithmetic is unchanged.
     251* Fix: the "history from before this plugin was updated" note no longer appears on sites that installed this version fresh and therefore have no earlier history to explain.
     252* No database change. The schema and database version (1.1.0) are identical to 0.2.0, no migration runs, and existing entries, notes, attachments, activity, settings, capabilities and URLs are untouched. With no add-on installed the plugin behaves exactly as 0.2.0 did.
    117253
    118254= 0.2.0 =
    … …  
    165301== Upgrade Notice ==
    166302
     303= 0.3.0 =
     304Adds a documented extension foundation for add-ons, plus two small Activity-history wording fixes. Your leads, notes, activity and database schema are unchanged, and no migration runs. With no add-on installed the plugin behaves exactly as before.
     305
    167306= 0.2.0 =
    168307Adds a per-entry activity history. Existing entries have their received time and existing notes reconstructed in the background; status, trash, and spam changes made before this update were never recorded and are not invented. Existing lead data is preserved.
  • neutrope-lead-desk-for-contact-form-7/trunk/admin/class-admin-menu.php

    r3666139 r3666532  
    185185                        $message .= ' ' . esc_html(
    186186                                sprintf(
    187                                         /* translators: 1: number of records processed, 2: total records, 3: percentage complete */
    188                                         __( '%1$s of %2$s (%3$s%%)', 'neutrope-lead-desk-for-contact-form-7' ),
     187                                        /* translators: 1: number of records processed, 2: total records to process, 3: percentage complete. "Records" counts processing units (entries plus notes), NOT the number of inquiries. */
     188                                        __( 'Processed %1$s of %2$s records (%3$s%%).', 'neutrope-lead-desk-for-contact-form-7' ),
    189189                                        number_format_i18n( (int) $progress['processed'] ),
    190190                                        number_format_i18n( (int) $progress['total'] ),
  • neutrope-lead-desk-for-contact-form-7/trunk/admin/class-entry-detail-page.php

    r3666139 r3666532  
    127127                $backfill_pending = class_exists( 'NLDCF7_Event_Backfill' ) && NLDCF7_Event_Backfill::is_pending();
    128128
     129                // The "history from before this plugin was updated" note only makes
     130                // sense where there IS such history. The migration snapshot records how
     131                // much there was to reconstruct at upgrade time: both totals are zero on
     132                // a site that installed this version fresh, and the note is then simply
     133                // untrue. The snapshot is used rather than today's entry count because
     134                // entries received since the upgrade say nothing about what came before.
     135                //
     136                // If the snapshot is missing or unreadable the note is shown: an
     137                // unnecessary explanation is a smaller harm than silently omitting the
     138                // one that tells an admin why their older entries look sparse.
     139                $show_history_note = true;
     140                if ( class_exists( 'NLDCF7_Event_Backfill' ) && NLDCF7_Event_Backfill::is_initialised() ) {
     141                        $backfill_state = NLDCF7_Event_Backfill::get_state();
     142                        if ( isset( $backfill_state['entry_total'], $backfill_state['note_total'] ) ) {
     143                                $show_history_note = ( (int) $backfill_state['entry_total'] > 0 )
     144                                        || ( (int) $backfill_state['note_total'] > 0 );
     145                        }
     146                }
     147
    129148                if ( $this->event_repo ) {
    130149                        $events_total       = $this->event_repo->count_for_entry( (int) $entry['id'] );
  • neutrope-lead-desk-for-contact-form-7/trunk/admin/class-entry-list-table.php

    r3666139 r3666532  
    129129                }
    130130
    131                 return $args;
     131                return self::apply_carried_args_filter( $args );
     132        }
     133
     134        /**
     135         * Maximum number of add-on contributed args carried in a list URL.
     136         */
     137        const CARRIED_MAX_ARGS = 20;
     138
     139        /**
     140         * Maximum length of a single carried value, in characters.
     141         */
     142        const CARRIED_MAX_VALUE = 200;
     143
     144        /**
     145         * Query keys an add-on may never contribute to a carried list URL.
     146         *
     147         * `page` is here because Free sets it itself; `paged` because v0.2.0
     148         * deliberately returns to the first page after an action; the rest are
     149         * mutation, authentication or routing state that must never be replayed
     150         * from a link the admin can bookmark or share.
     151         *
     152         * @var string[]
     153         */
     154        private static $carried_denylist = array(
     155                'page',
     156                'paged',
     157                'action',
     158                'action2',
     159                'entry_id',
     160                '_wpnonce',
     161                '_wp_http_referer',
     162                'view',
     163                'event_page',
     164                self::VIEW_QUERY_VAR,
     165        );
     166
     167        /**
     168         * Let add-ons contribute their own filter state, then re-impose Free's rules.
     169         *
     170         * Everything an add-on adds is treated as untrusted: the deny-list, key
     171         * normalisation, scalar-only rule and size caps are applied AFTER the
     172         * filter, so a careless listener cannot put `_wpnonce` or an array back
     173         * into a URL. Core args are re-applied last and always win.
     174         *
     175         * This is the one place custom filter state enters a list URL. Every path
     176         * that needs it -- the All/Trash views, the row actions, and the redirect
     177         * after a row or bulk action -- reaches it through current_filter_args(),
     178         * so there is a single implementation rather than one per call site.
     179         *
     180         * @param array<string,string|int> $core Sanitized core filter args.
     181         * @return array<string,string|int>
     182         */
     183        private static function apply_carried_args_filter( array $core ) {
     184                /**
     185                 * Filter the list state carried across view switches and row/bulk actions.
     186                 *
     187                 * Add-ons append their own already-sanitized scalar args. Free re-checks
     188                 * the result regardless; see the class constants for the size caps and
     189                 * `$carried_denylist` for the keys that are always stripped.
     190                 *
     191                 * Free never forwards the raw query string, so an add-on filter that
     192                 * adds nothing leaves the URL byte-identical to v0.2.0.
     193                 *
     194                 * @since 0.3.0
     195                 *
     196                 * @param array<string,string|int> $args Args to carry. Core keys included.
     197                 * @param array<string,string|int> $core The core args, for reference.
     198                 */
     199                $filtered = apply_filters( 'nldcf7_entry_list_carried_args', $core, $core );
     200
     201                if ( ! is_array( $filtered ) ) {
     202                        return $core;
     203                }
     204
     205                $extra = array();
     206                foreach ( $filtered as $key => $value ) {
     207                        if ( count( $extra ) >= self::CARRIED_MAX_ARGS ) {
     208                                self::debug_carried( 'too many carried args; extra dropped' );
     209                                break;
     210                        }
     211
     212                        $key = sanitize_key( (string) $key );
     213                        if ( '' === $key || array_key_exists( $key, $core ) ) {
     214                                // Empty/unusable key, or a core key -- core is authoritative and
     215                                // is re-applied below either way.
     216                                continue;
     217                        }
     218                        if ( in_array( $key, self::$carried_denylist, true ) ) {
     219                                self::debug_carried( 'reserved key refused: ' . $key );
     220                                continue;
     221                        }
     222                        if ( ! is_scalar( $value ) ) {
     223                                // Arrays and objects are dropped outright. Casting one would
     224                                // print the literal string "Array" into a URL.
     225                                self::debug_carried( 'non-scalar value dropped for key: ' . $key );
     226                                continue;
     227                        }
     228
     229                        $value = (string) $value;
     230                        if ( '' === $value ) {
     231                                continue;
     232                        }
     233                        if ( function_exists( 'mb_substr' ) ) {
     234                                $value = mb_substr( $value, 0, self::CARRIED_MAX_VALUE );
     235                        } else {
     236                                $value = substr( $value, 0, self::CARRIED_MAX_VALUE );
     237                        }
     238
     239                        $extra[ $key ] = sanitize_text_field( $value );
     240                }
     241
     242                // Core last: an add-on cannot override the screen's own filter state.
     243                return array_merge( $extra, $core );
     244        }
     245
     246        /**
     247         * Log a rejected carried-arg contribution while debugging.
     248         *
     249         * @param string $message
     250         * @return void
     251         */
     252        private static function debug_carried( $message ) {
     253                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
     254                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated by WP_DEBUG; an add-on silently losing filter state is otherwise very hard to diagnose.
     255                        error_log( '[neutrope-lead-desk-for-contact-form-7] carried args: ' . $message );
     256                }
    132257        }
    133258
    … …  
    234359
    235360        public function get_columns() {
    236                 return array(
     361                $core = array(
    237362                        'cb'                => '<input type="checkbox" />',
    238363                        'submitted_at'      => __( 'Received', 'neutrope-lead-desk-for-contact-form-7' ),
    … …  
    246371                        'note_count'        => __( 'Notes', 'neutrope-lead-desk-for-contact-form-7' ),
    247372                );
     373
     374                /**
     375                 * Filter the entries list columns.
     376                 *
     377                 * Add-ons may append their own columns. They may NOT remove, rename or
     378                 * repurpose a core column: every core key is restored to its own label
     379                 * after this filter runs, so reusing one silently changes nothing. That
     380                 * is enforced in code rather than left to convention, because a column
     381                 * whose header says one thing and whose cells say another is a data
     382                 * integrity problem, not a styling preference.
     383                 *
     384                 * Cells for added columns are rendered through
     385                 * `nldcf7_entry_list_column_content`.
     386                 *
     387                 * @since 0.3.0
     388                 *
     389                 * @param array<string,string> $columns column key => header label.
     390                 */
     391                $filtered = apply_filters( 'nldcf7_entry_list_columns', $core );
     392
     393                if ( ! is_array( $filtered ) ) {
     394                        return $core;
     395                }
     396
     397                // Keep only usable entries: a non-empty sanitize_key()-able key mapped
     398                // to a scalar label. Anything else is a malformed contribution and is
     399                // dropped rather than being allowed to break the header row.
     400                $clean = array();
     401                foreach ( $filtered as $key => $label ) {
     402                        $key = sanitize_key( (string) $key );
     403                        if ( '' === $key || ! is_scalar( $label ) ) {
     404                                continue;
     405                        }
     406                        $clean[ $key ] = (string) $label;
     407                }
     408
     409                // Core wins: restore every core column's own label, and re-add any the
     410                // filter dropped. `cb` goes back to the front so the bulk-select box
     411                // never ends up mid-row.
     412                foreach ( $core as $key => $label ) {
     413                        $clean[ $key ] = $label;
     414                }
     415                if ( isset( $clean['cb'] ) ) {
     416                        $cb = $clean['cb'];
     417                        unset( $clean['cb'] );
     418                        $clean = array_merge( array( 'cb' => $cb ), $clean );
     419                }
     420
     421                return $clean;
     422        }
     423
     424        /**
     425         * Render a cell for a column this class has no `column_{key}()` method for.
     426         *
     427         * Only add-on columns reach here: every core column has its own method.
     428         * WordPress core's own `column_default()` is an empty no-op, so without
     429         * this an added column would render as a blank cell.
     430         *
     431         * The listener returns HTML and is responsible for escaping it; Free then
     432         * runs the result through `wp_kses_post()` as a second layer, which is the
     433         * reason this is a filter with a return value rather than an action that
     434         * echoes -- output that Free never sees is output Free cannot make safe.
     435         *
     436         * @param array  $item        Entry row.
     437         * @param string $column_name Column key.
     438         * @return string Escaped HTML, or '' when nothing is contributed.
     439         */
     440        public function column_default( $item, $column_name ) {
     441                /**
     442                 * Filter the cell content for an add-on column on the entries list.
     443                 *
     444                 * @since 0.3.0
     445                 *
     446                 * @param string $html        Cell HTML. Default ''.
     447                 * @param string $column_name Column key being rendered.
     448                 * @param array  $item        Entry row, as stored.
     449                 */
     450                $html = apply_filters( 'nldcf7_entry_list_column_content', '', (string) $column_name, $item );
     451
     452                if ( ! is_string( $html ) || '' === $html ) {
     453                        return '';
     454                }
     455
     456                return wp_kses_post( $html );
    248457        }
    249458
    … …  
    495704                        </label>
    496705
    497                         <?php submit_button( __( 'Filter', 'neutrope-lead-desk-for-contact-form-7' ), '', 'nldcf7-filter-submit', false ); ?>
     706                        <?php
     707                        /**
     708                         * Fires inside the entries list filter bar, before the Filter button.
     709                         *
     710                         * Listeners print their own form controls here. The position is
     711                         * guaranteed to be inside the existing GET form, so a plain
     712                         * `<select>` or `<input>` submits with the core filters and needs
     713                         * nothing else wired up.
     714                         *
     715                         * No nonce is involved: this bar only reads, and Free's own filters
     716                         * carry none either. The listener owns its field names, its values
     717                         * and its escaping; Free owns only the placement.
     718                         *
     719                         * @since 0.3.0
     720                         *
     721                         * @param array<string,string|int> $current_args Sanitized core filter
     722                         *                                               state for this request.
     723                         */
     724                        do_action( 'nldcf7_entry_list_filters', self::current_filter_args() );
     725
     726                        submit_button( __( 'Filter', 'neutrope-lead-desk-for-contact-form-7' ), '', 'nldcf7-filter-submit', false );
     727                        ?>
    498728                </div>
    499729                <?php
    … …  
    529759                        // behaving as a soft delete rather than turning into a filter.
    530760                        'trashed_only' => ( self::VIEW_TRASH === self::current_view() ),
     761                        // Identifies this caller to the query extension hooks so an add-on
     762                        // can constrain the list without also constraining CSV export.
     763                        'context'      => NLDCF7_Entry_Repository::CONTEXT_LIST,
    531764                );
    532765
    … …  
    551784                        )
    552785                );
     786
     787                /**
     788                 * Fires once the rows for the current list page are known.
     789                 *
     790                 * Add-ons that render their own columns use this to load whatever they
     791                 * need for these rows in one query, before any cell is rendered.
     792                 * Without it they are pushed into N+1: the cell filter is handed a
     793                 * single row and has no way to learn what else is on the page, so the
     794                 * only alternative is a lookup per cell -- twenty rows with two custom
     795                 * columns being up to forty extra queries.
     796                 *
     797                 * Free's own note-count cache above is the same pattern; this is that
     798                 * opportunity handed to extensions.
     799                 *
     800                 * Contract:
     801                 *   - $items is THIS PAGE's rows, never the whole result set.
     802                 *   - Read-only. Listeners must not mutate the list's state, and no
     803                 *     return value is used.
     804                 *   - Fires exactly once per prepare cycle, including with an empty
     805                 *     array when the page has no rows, so "once per cycle" holds
     806                 *     without a special case for empty results.
     807                 *   - The entries screen only. CSV export does not build a list table
     808                 *     and so never reaches here.
     809                 *   - Running the secondary query safely -- preparing it, checking
     810                 *     capabilities -- belongs to the listener.
     811                 *
     812                 * Fired after this class has finished its own preparation so that a
     813                 * listener cannot interleave with it. The repository, the controller
     814                 * and this table instance are deliberately not passed.
     815                 *
     816                 * @since 0.3.0
     817                 *
     818                 * @param array<int,array<string,mixed>> $items Rows on the current page.
     819                 */
     820                do_action( 'nldcf7_entry_list_items_prepared', $this->items );
    553821        }
    554822
  • neutrope-lead-desk-for-contact-form-7/trunk/admin/views/entry-detail.php

    r3666139 r3666532  
    1212 *   $notice   array|null  Flash notice ['message'=>string,'type'=>string]
    1313 *                         pulled from per-user transient (consume_notice()).
     14 *   $show_history_note bool Whether the pre-upgrade history caveat applies.
    1415 *
    1516 * phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Locals in an included view, not actual globals.
    … …  
    140141                        <div class="nldcf7-card nldcf7-timeline-card">
    141142                                <h2><?php esc_html_e( 'Activity', 'neutrope-lead-desk-for-contact-form-7' ); ?></h2>
    142                                 <p class="description">
    143                                         <?php esc_html_e( 'History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved.', 'neutrope-lead-desk-for-contact-form-7' ); ?>
    144                                 </p>
     143                                <?php if ( $show_history_note ) : ?>
     144                                        <p class="description">
     145                                                <?php esc_html_e( 'History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved.', 'neutrope-lead-desk-for-contact-form-7' ); ?>
     146                                        </p>
     147                                <?php endif; ?>
    145148
    146149                                <?php if ( empty( $events ) ) : ?>
    … …  
    222225                        </div>
    223226
     227                        <?php
     228                        /**
     229                         * Fires after the main column's own cards on the entry detail screen.
     230                         *
     231                         * Add-ons print their own markup here -- including the surrounding
     232                         * `<div class="nldcf7-card">` -- and own escaping it. Nothing is
     233                         * buffered or filtered on the way out, so a listener that prints
     234                         * nothing costs nothing and leaves the page byte-identical.
     235                         *
     236                         * The position is deliberately "after every core card" rather than
     237                         * between two of them: card order is an internal detail that may
     238                         * change, and a between-cards contract could not be kept stable.
     239                         *
     240                         * @since 0.3.0
     241                         *
     242                         * @param array $entry Entry row, as stored. Passed by value.
     243                         */
     244                        do_action( 'nldcf7_entry_detail_main', $entry );
     245                        ?>
     246
    224247                </div>
    225248
    … …  
    273296                                </table>
    274297                        </div>
     298
     299                <?php
     300                /**
     301                 * Fires after the sidebar's own cards on the entry detail screen.
     302                 *
     303                 * Same ownership rules as `nldcf7_entry_detail_main`: the listener
     304                 * prints and escapes its own markup. Two hooks exist rather than one
     305                 * because an add-on may legitimately need to place content in both
     306                 * columns of the same screen.
     307                 *
     308                 * @since 0.3.0
     309                 *
     310                 * @param array $entry Entry row, as stored. Passed by value.
     311                 */
     312                do_action( 'nldcf7_entry_detail_sidebar', $entry );
     313                ?>
    275314                </aside>
    276315        </div>
  • neutrope-lead-desk-for-contact-form-7/trunk/includes/class-entry-repository.php

    r3666139 r3666532  
    1414
    1515class NLDCF7_Entry_Repository {
     16
     17        /**
     18         * Query contexts passed to the query extension hooks.
     19         *
     20         * The entries screen and the CSV exporter share this repository, so an
     21         * add-on constraint that did not say which one it meant would change both.
     22         * Listeners check $args['context'] to scope themselves.
     23         *
     24         * @since 0.3.0
     25         */
     26        const CONTEXT_LIST    = 'list';
     27        const CONTEXT_EXPORT  = 'export';
     28        const CONTEXT_DEFAULT = 'default';
    1629
    1730        /**
    … …  
    133146        public function count( array $args = array() ) {
    134147                global $wpdb;
    135                 $where   = $this->build_where( $args );
    136                 $table   = NLDCF7_DB::entries_table();
    137                 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from internal NLDCF7_DB method.
    138                 $sql      = "SELECT COUNT(*) FROM {$table} {$where['clause']}";
    139                 $prepared = $where['prepare_args']
    140                         ? $wpdb->prepare( $sql, $where['prepare_args'] ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
     148
     149                // Same builder as query(). Sharing it is what makes it impossible for a
     150                // filtered list to report an unfiltered total: there is one query plan,
     151                // not two implementations that have to be kept in step.
     152                $parts    = $this->build_query_parts( $args );
     153                $table    = NLDCF7_DB::entries_table();
     154                $distinct = $parts['distinct'] ? 'DISTINCT ' : '';
     155
     156                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from internal NLDCF7_DB method; join/clause carry no request values (those are in prepare_args).
     157                $sql      = "SELECT COUNT({$distinct}{$table}.id) FROM {$table} {$parts['join']} {$parts['clause']}";
     158                $prepared = $parts['prepare_args']
     159                        ? $wpdb->prepare( $sql, $parts['prepare_args'] ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    141160                        : $sql;
    142                 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal plugin table, built via build_where() with prepare_args.
     161                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Internal plugin table, built via build_query_parts() with prepare_args.
    143162                return (int) $wpdb->get_var( $prepared );
    144163        }
    … …  
    176195                $args = array_merge( $defaults, $args );
    177196
    178                 $where = $this->build_where( $args );
     197                $parts = $this->build_query_parts( $args );
    179198
    180199                $orderby = $this->sanitize_orderby( $args['orderby'] );
    … …  
    185204                $offset   = ( $page - 1 ) * $per_page;
    186205
    187                 $table = NLDCF7_DB::entries_table();
    188                 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table / orderby / order are internal allow-listed values; user input is carried in $prepare_args.
    189                 $sql = "SELECT * FROM {$table} {$where['clause']} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
    190 
    191                 $prepare_args   = $where['prepare_args'];
     206                $table    = NLDCF7_DB::entries_table();
     207                $distinct = $parts['distinct'] ? 'DISTINCT ' : '';
     208
     209                // `{$table}.*` rather than `*`: once an add-on may contribute a JOIN, a
     210                // bare `*` would also pull the joined table's columns into the row and
     211                // any shared column name (id, created_at) would overwrite ours. With no
     212                // join present the two forms select exactly the same thing, so this is
     213                // behaviour-preserving when nothing is listening.
     214                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table / orderby / order are internal allow-listed values; join/clause carry no request values (those are in prepare_args).
     215                $sql = "SELECT {$distinct}{$table}.* FROM {$table} {$parts['join']} {$parts['clause']} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
     216
     217                $prepare_args   = $parts['prepare_args'];
    192218                $prepare_args[] = $per_page;
    193219                $prepare_args[] = $offset;
    … …  
    463489        }
    464490
     491        /**
     492         * Build the complete constraint plan for one query.
     493         *
     494         * This is the single source of JOIN / WHERE / prepared values / DISTINCT for
     495         * BOTH query() and count(). Extending it in one place is what guarantees a
     496         * filtered list and its total can never disagree.
     497         *
     498         * Two extension points run here, in this order:
     499         *
     500         *   1. `nldcf7_entry_query_args`    -- high level. Adjust the documented
     501         *      args (including the long-standing `ids`) without writing SQL.
     502         *   2. `nldcf7_entry_query_clauses` -- low level. Contribute a JOIN and
     503         *      extra conditions with their values kept separate, for constraints
     504         *      that live in another table and should be resolved by the database
     505         *      rather than by loading every matching id into PHP.
     506         *
     507         * @param array $args Query args. `context` identifies the caller.
     508         * @return array{join:string,clause:string,prepare_args:array,distinct:bool}
     509         */
     510        private function build_query_parts( array $args ) {
     511                if ( ! isset( $args['context'] ) || ! is_string( $args['context'] ) || '' === $args['context'] ) {
     512                        $args['context'] = self::CONTEXT_DEFAULT;
     513                }
     514
     515                /**
     516                 * Filter the entry query args before the SQL is built.
     517                 *
     518                 * Use this when the constraint can be said with the args the repository
     519                 * already understands -- most usefully `ids`, which restricts the query
     520                 * to a set of entry IDs. Anything needing a JOIN belongs in
     521                 * `nldcf7_entry_query_clauses` instead.
     522                 *
     523                 * `$args['context']` says who is asking: 'list' for the entries screen,
     524                 * 'export' for CSV, 'default' otherwise. Listeners should check it --
     525                 * both callers share this repository, so an unconditional constraint
     526                 * meant for the list screen would silently change what CSV exports.
     527                 *
     528                 * @since 0.3.0
     529                 *
     530                 * @param array $args Query args, including 'context'.
     531                 */
     532                $filtered = apply_filters( 'nldcf7_entry_query_args', $args );
     533                if ( is_array( $filtered ) ) {
     534                        $args = $filtered;
     535                }
     536
     537                $where = $this->build_where( $args );
     538
     539                $parts = array(
     540                        'join'         => '',
     541                        'where'        => array(),
     542                        'prepare_args' => array(),
     543                        'distinct'     => false,
     544                );
     545
     546                /**
     547                 * Filter the SQL parts for an entry query.
     548                 *
     549                 * Contribute a JOIN and conditions whose values stay separate from the
     550                 * SQL: put `%s` / `%d` placeholders in each `where` entry and the values
     551                 * in `prepare_args`, in the same order. Free passes them to
     552                 * $wpdb->prepare(). A single pre-built SQL string is deliberately not
     553                 * accepted, so request values are never concatenated into a statement.
     554                 *
     555                 * Set `distinct` when the JOIN can match a row more than once; it is
     556                 * applied to the row query and the count together.
     557                 *
     558                 * Table and column identifiers inside `join` are the listener's own
     559                 * responsibility -- Free cannot validate another plugin's schema.
     560                 *
     561                 * @since 0.3.0
     562                 *
     563                 * @param array{join:string,where:array,prepare_args:array,distinct:bool} $parts
     564                 * @param array $args Query args, including 'context'.
     565                 */
     566                $contributed = apply_filters( 'nldcf7_entry_query_clauses', $parts, $args );
     567
     568                $join         = '';
     569                $conditions   = array();
     570                $prepare_args = $where['prepare_args'];
     571                $distinct     = false;
     572
     573                if ( is_array( $contributed ) ) {
     574                        $join     = isset( $contributed['join'] ) && is_string( $contributed['join'] ) ? trim( $contributed['join'] ) : '';
     575                        $distinct = ! empty( $contributed['distinct'] );
     576
     577                        $raw_where = isset( $contributed['where'] ) && is_array( $contributed['where'] ) ? $contributed['where'] : array();
     578                        $raw_prep  = isset( $contributed['prepare_args'] ) && is_array( $contributed['prepare_args'] ) ? array_values( $contributed['prepare_args'] ) : array();
     579
     580                        foreach ( $raw_where as $condition ) {
     581                                if ( is_string( $condition ) && '' !== trim( $condition ) ) {
     582                                        $conditions[] = '( ' . trim( $condition ) . ' )';
     583                                }
     584                        }
     585
     586                        // The placeholder count has to match the values supplied, or
     587                        // $wpdb->prepare() would mis-bind every value after the mismatch and
     588                        // return a broken statement. Rather than run that, drop the whole
     589                        // contribution and say so while debugging.
     590                        $expected = 0;
     591                        foreach ( $conditions as $condition ) {
     592                                $expected += preg_match_all( '/%[dfs]/', $condition );
     593                        }
     594                        if ( $expected !== count( $raw_prep ) ) {
     595                                $this->debug_clauses(
     596                                        sprintf( 'placeholder/value mismatch (%d placeholders, %d values); clause contribution dropped', $expected, count( $raw_prep ) )
     597                                );
     598                                $join       = '';
     599                                $conditions = array();
     600                                $raw_prep   = array();
     601                                $distinct   = false;
     602                        }
     603
     604                        foreach ( $raw_prep as $value ) {
     605                                $prepare_args[] = $value;
     606                        }
     607                }
     608
     609                $clause = $where['clause'];
     610                if ( $conditions ) {
     611                        $extra  = implode( ' AND ', $conditions );
     612                        $clause = '' === $clause ? ( 'WHERE ' . $extra ) : ( $clause . ' AND ' . $extra );
     613                }
     614
     615                return array(
     616                        'join'         => $join,
     617                        'clause'       => $clause,
     618                        'prepare_args' => $prepare_args,
     619                        'distinct'     => (bool) $distinct,
     620                );
     621        }
     622
     623        /**
     624         * Log a rejected clause contribution while debugging.
     625         *
     626         * @param string $message
     627         * @return void
     628         */
     629        private function debug_clauses( $message ) {
     630                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
     631                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated by WP_DEBUG; a silently dropped clause would otherwise look like a filter that simply did not fire.
     632                        error_log( '[neutrope-lead-desk-for-contact-form-7] entry query clauses: ' . $message );
     633                }
     634        }
     635
    465636        private function sanitize_orderby( $field ) {
    466637                $allowed = array( 'id', 'submitted_at', 'status', 'form_post_id' );
  • neutrope-lead-desk-for-contact-form-7/trunk/includes/class-export-service.php

    r3553617 r3666532  
    132132                                        'orderby'  => 'submitted_at',
    133133                                        'order'    => 'DESC',
     134                                        // Tells the query extension hooks this is CSV export, not the
     135                                        // entries screen, so a list-only constraint cannot reach it.
     136                                        'context'  => NLDCF7_Entry_Repository::CONTEXT_EXPORT,
    134137                                )
    135138                        );
  • neutrope-lead-desk-for-contact-form-7/trunk/languages/neutrope-lead-desk-for-contact-form-7-ja.po

    r3666139 r3666532  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.2.0\n"
     5"Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.3.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/neutrope-lead-desk-for-contact-form-7\n"
    77"Last-Translator: \n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-08-25T14:05:36+00:00\n"
    13 "PO-Revision-Date: 2026-08-25 00:00+0000\n"
     12"POT-Creation-Date: 2026-08-26T04:22:33+00:00\n"
     13"PO-Revision-Date: 2026-08-26 00:00+0000\n"
    1414"Language: ja\n"
    1515"Plural-Forms: nplurals=1; plural=0;\n"
    … …  
    154154msgstr "指定された問い合わせは見つかりませんでした。"
    155155
    156 #: admin/class-entry-list-table.php:211
     156#: admin/class-entry-list-table.php:336
    157157#: admin/views/export.php:43
    158158msgid "All"
    159159msgstr "すべて"
    160160
    161 #: admin/class-entry-list-table.php:227
    162 #: admin/class-entry-list-table.php:361
     161#: admin/class-entry-list-table.php:352
     162#: admin/class-entry-list-table.php:570
    163163#: admin/views/export.php:72
    164164msgid "Trash"
    … …  
    166166
    167167#: admin/class-entry-event-presenter.php:53
    168 #: admin/class-entry-list-table.php:238
    169 #: admin/views/entry-detail.php:253
     168#: admin/class-entry-list-table.php:363
     169#: admin/views/entry-detail.php:276
    170170#: includes/class-export-service.php:47
    171171msgid "Received"
    172172msgstr "受信日時"
    173173
    174 #: admin/class-entry-list-table.php:239
    175 #: admin/class-entry-list-table.php:471
    176 #: admin/views/entry-detail.php:228
     174#: admin/class-entry-list-table.php:364
     175#: admin/class-entry-list-table.php:680
     176#: admin/views/entry-detail.php:251
    177177#: admin/views/export.php:40
    178178#: includes/class-export-service.php:48
    … …  
    180180msgstr "ステータス"
    181181
    182 #: admin/class-entry-list-table.php:240
    183 #: admin/class-entry-list-table.php:479
    184 #: admin/views/entry-detail.php:254
     182#: admin/class-entry-list-table.php:365
     183#: admin/class-entry-list-table.php:688
     184#: admin/views/entry-detail.php:277
    185185#: admin/views/export.php:51
    186186#: includes/class-export-service.php:49
    … …  
    188188msgstr "フォーム"
    189189
    190 #: admin/class-entry-list-table.php:241
     190#: admin/class-entry-list-table.php:366
    191191#: admin/views/settings.php:73
    192192#: includes/class-export-service.php:51
    … …  
    194194msgstr "名前"
    195195
    196 #: admin/class-entry-list-table.php:242
     196#: admin/class-entry-list-table.php:367
    197197#: admin/views/settings.php:77
    198198#: includes/class-export-service.php:52
    … …  
    200200msgstr "メール"
    201201
    202 #: admin/class-entry-list-table.php:243
     202#: admin/class-entry-list-table.php:368
    203203#: admin/views/settings.php:81
    204204#: includes/class-export-service.php:53
    … …  
    206206msgstr "電話"
    207207
    208 #: admin/class-entry-list-table.php:244
     208#: admin/class-entry-list-table.php:369
    209209msgid "Source"
    210210msgstr "参照元"
    211211
    212 #: admin/class-entry-list-table.php:245
     212#: admin/class-entry-list-table.php:370
    213213msgid "UTM source"
    214214msgstr "UTM source"
    215215
    216 #: admin/class-entry-list-table.php:246
    217 #: admin/views/entry-detail.php:96
     216#: admin/class-entry-list-table.php:371
     217#: admin/views/entry-detail.php:97
    218218msgid "Notes"
    219219msgstr "メモ"
    220220
    221 #: admin/class-entry-list-table.php:264
    222 #: admin/class-entry-list-table.php:342
     221#: admin/class-entry-list-table.php:473
     222#: admin/class-entry-list-table.php:551
    223223msgid "Restore"
    224224msgstr "復元"
    225225
    226 #: admin/class-entry-list-table.php:269
     226#: admin/class-entry-list-table.php:478
    227227msgid "Set status: New"
    228228msgstr "ステータス: 未対応"
    229229
    230 #: admin/class-entry-list-table.php:270
     230#: admin/class-entry-list-table.php:479
    231231msgid "Set status: In progress"
    232232msgstr "ステータス: 対応中"
    233233
    234 #: admin/class-entry-list-table.php:271
     234#: admin/class-entry-list-table.php:480
    235235msgid "Set status: On hold"
    236236msgstr "ステータス: 保留"
    237237
    238 #: admin/class-entry-list-table.php:272
     238#: admin/class-entry-list-table.php:481
    239239msgid "Set status: Done"
    240240msgstr "ステータス: 完了"
    241241
    242 #: admin/class-entry-list-table.php:273
     242#: admin/class-entry-list-table.php:482
    243243msgid "Move to trash"
    244244msgstr "ゴミ箱へ移動"
    245245
    246 #: admin/class-entry-list-table.php:274
     246#: admin/class-entry-list-table.php:483
    247247msgid "Mark as spam"
    248248msgstr "スパムにする"
    249249
    250 #: admin/class-entry-list-table.php:299
     250#: admin/class-entry-list-table.php:508
    251251msgid "View"
    252252msgstr "詳細"
    253253
    254 #: admin/class-entry-list-table.php:430
     254#: admin/class-entry-list-table.php:639
    255255msgid "None"
    256256msgstr "なし"
    257257
    258 #: admin/class-entry-list-table.php:437
     258#: admin/class-entry-list-table.php:646
    259259msgid "No entries are in the trash."
    260260msgstr "ゴミ箱に問い合わせはありません。"
    261261
    262 #: admin/class-entry-list-table.php:440
     262#: admin/class-entry-list-table.php:649
    263263msgid "No entries match the current filters."
    264264msgstr "該当する問い合わせはありません。"
    265265
    266 #: admin/class-entry-list-table.php:468
    267 #: admin/class-entry-list-table.php:469
     266#: admin/class-entry-list-table.php:677
     267#: admin/class-entry-list-table.php:678
    268268msgid "Search keywords"
    269269msgstr "キーワード検索"
    270270
    271 #: admin/class-entry-list-table.php:473
     271#: admin/class-entry-list-table.php:682
    272272msgid "All statuses"
    273273msgstr "すべてのステータス"
    274274
    275 #: admin/class-entry-list-table.php:481
     275#: admin/class-entry-list-table.php:690
    276276#: admin/views/export.php:54
    277277msgid "All forms"
    278278msgstr "すべてのフォーム"
    279279
    280 #: admin/class-entry-list-table.php:487
     280#: admin/class-entry-list-table.php:696
    281281#: admin/views/export.php:32
    282282msgid "From date"
    283283msgstr "開始日"
    284284
    285 #: admin/class-entry-list-table.php:489
     285#: admin/class-entry-list-table.php:698
    286286#: admin/views/export.php:36
    287287msgid "To date"
    288288msgstr "終了日"
    289289
    290 #: admin/class-entry-list-table.php:494
     290#: admin/class-entry-list-table.php:703
    291291msgid "Show spam"
    292292msgstr "スパムも表示"
    293293
    294 #: admin/class-entry-list-table.php:497
     294#: admin/class-entry-list-table.php:726
    295295msgid "Filter"
    296296msgstr "絞り込み"
    297297
    298298#. translators: %d: entry ID
    299 #: admin/views/entry-detail.php:31
     299#: admin/views/entry-detail.php:32
    300300#, php-format
    301301msgid "Entry #%d"
    302302msgstr "問い合わせ #%d"
    303303
    304 #: admin/views/entry-detail.php:34
     304#: admin/views/entry-detail.php:35
    305305msgid "Back to list"
    306306msgstr "一覧へ戻る"
    307307
    308 #: admin/views/entry-detail.php:47
     308#: admin/views/entry-detail.php:48
    309309msgid "Submitted fields"
    310310msgstr "送信内容"
    311311
    312 #: admin/views/entry-detail.php:51
     312#: admin/views/entry-detail.php:52
    313313msgid "No submitted fields stored."
    314314msgstr "送信フィールドが保存されていません。"
    315315
    316 #: admin/views/entry-detail.php:72
     316#: admin/views/entry-detail.php:73
    317317msgid "Attachments (metadata only)"
    318318msgstr "添付ファイル(メタ情報のみ)"
    319319
    320 #: admin/views/entry-detail.php:76
     320#: admin/views/entry-detail.php:77
    321321msgid "File name"
    322322msgstr "ファイル名"
    323323
    324 #: admin/views/entry-detail.php:77
     324#: admin/views/entry-detail.php:78
    325325msgid "MIME"
    326326msgstr "MIME"
    327327
    328 #: admin/views/entry-detail.php:78
     328#: admin/views/entry-detail.php:79
    329329msgid "Size (bytes)"
    330330msgstr "サイズ (bytes)"
    331331
    332 #: admin/views/entry-detail.php:91
     332#: admin/views/entry-detail.php:92
    333333msgid "Only metadata is captured. The uploaded files themselves are not stored by this plugin."
    334334msgstr "添付ファイルの実体は保存せず、メタ情報(ファイル名・MIME・サイズ)のみを記録しています。"
    335335
    336 #: admin/views/entry-detail.php:102
     336#: admin/views/entry-detail.php:103
    337337msgid "Note body"
    338338msgstr "メモ本文"
    339339
    340 #: admin/views/entry-detail.php:105
     340#: admin/views/entry-detail.php:106
    341341msgid "Add note"
    342342msgstr "メモを追加"
    343343
    344 #: admin/views/entry-detail.php:109
     344#: admin/views/entry-detail.php:110
    345345msgid "No notes yet."
    346346msgstr "まだメモはありません。"
    347347
    348348#: admin/class-entry-event-presenter.php:119
    349 #: admin/views/entry-detail.php:115
     349#: admin/views/entry-detail.php:116
    350350msgid "Unknown user"
    351351msgstr "不明なユーザー"
    352352
    353 #: admin/views/entry-detail.php:238
     353#: admin/views/entry-detail.php:261
    354354msgid "Change status"
    355355msgstr "ステータスを変更"
    356356
    357 #: admin/views/entry-detail.php:246
     357#: admin/views/entry-detail.php:269
    358358msgid "Update"
    359359msgstr "更新"
    360360
    361 #: admin/views/entry-detail.php:251
     361#: admin/views/entry-detail.php:274
    362362msgid "Submission info"
    363363msgstr "送信情報"
    364364
    365 #: admin/views/entry-detail.php:255
     365#: admin/views/entry-detail.php:278
    366366#: includes/class-export-service.php:50
    367367msgid "Form ID"
    368368msgstr "フォーム ID"
    369369
    370 #: admin/views/entry-detail.php:256
     370#: admin/views/entry-detail.php:279
    371371#: includes/class-export-service.php:55
    372372msgid "Submission page URL"
    373373msgstr "送信ページ URL"
    374374
    375 #: admin/views/entry-detail.php:260
     375#: admin/views/entry-detail.php:283
    376376#: includes/class-export-service.php:56
    377377msgid "Referrer"
    378378msgstr "参照元"
    379379
    380 #: admin/views/entry-detail.php:264
     380#: admin/views/entry-detail.php:287
    381381#: includes/class-export-service.php:57
    382382msgid "Landing URL"
    383383msgstr "ランディング URL"
    384384
    385 #: admin/views/entry-detail.php:270
     385#: admin/views/entry-detail.php:293
    386386msgid "User Agent"
    387387msgstr "User Agent"
    388388
    389 #: admin/views/entry-detail.php:271
     389#: admin/views/entry-detail.php:294
    390390#: includes/class-export-service.php:64
    391391msgid "IP address"
    392392msgstr "IP アドレス"
    393393
    394 #: admin/views/entry-detail.php:272
     394#: admin/views/entry-detail.php:295
    395395#: includes/class-export-service.php:65
    396396msgid "Spam flag"
    397397msgstr "スパム判定"
    398398
    399 #: admin/views/entry-detail.php:272
     399#: admin/views/entry-detail.php:295
    400400#: admin/views/export.php:62
    401401msgid "Spam"
    402402msgstr "スパム"
    403403
    404 #: admin/views/entry-detail.php:272
     404#: admin/views/entry-detail.php:295
    405405msgid "Normal"
    406406msgstr "通常"
    … …  
    665665msgstr "設定を保存"
    666666
    667 #: includes/class-entry-repository.php:25
     667#: includes/class-entry-repository.php:38
    668668msgid "New"
    669669msgstr "未対応"
    670670
    671 #: includes/class-entry-repository.php:26
     671#: includes/class-entry-repository.php:39
    672672msgid "In progress"
    673673msgstr "対応中"
    674674
    675 #: includes/class-entry-repository.php:27
     675#: includes/class-entry-repository.php:40
    676676msgid "On hold"
    677677msgstr "保留"
    678678
    679 #: includes/class-entry-repository.php:28
     679#: includes/class-entry-repository.php:41
    680680msgid "Done"
    681681msgstr "完了"
    … …  
    693693msgstr "過去の問い合わせから対応履歴を準備しています。通常どおりご利用いただけます。"
    694694
    695 #. translators: 1: number of records processed, 2: total records, 3: percentage complete
    696 #: admin/class-admin-menu.php:188
    697 #, php-format
    698 msgid "%1$s of %2$s (%3$s%%)"
    699 msgstr "%2$s 件中 %1$s 件(%3$s%%)"
    700 
    701695#: admin/class-entry-event-presenter.php:54
    702696msgid "Status changed"
    … …  
    720714
    721715#: admin/class-entry-event-presenter.php:89
    722 #: admin/views/entry-detail.php:141
     716#: admin/views/entry-detail.php:142
    723717msgid "Activity"
    724718msgstr "対応履歴"
    … …  
    762756msgstr "メモを見る"
    763757
    764 #: admin/views/entry-detail.php:143
     758#: admin/views/entry-detail.php:145
    765759msgid "History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved."
    766760msgstr "アップデート前の履歴は、受信日時と既存メモから確認できる範囲のみ表示されます。"
    767761
    768 #: admin/views/entry-detail.php:155
     762#: admin/views/entry-detail.php:158
    769763msgid "No activity on this page."
    770764msgstr "このページに表示する対応履歴はありません。"
    771765
    772 #: admin/views/entry-detail.php:157
     766#: admin/views/entry-detail.php:160
    773767msgid "Back to the latest activity"
    774768msgstr "最新の対応履歴へ戻る"
    775769
    776 #: admin/views/entry-detail.php:162
     770#: admin/views/entry-detail.php:165
    777771msgid "Preparing activity history…"
    778772msgstr "対応履歴を準備しています…"
    779773
    780 #: admin/views/entry-detail.php:164
     774#: admin/views/entry-detail.php:167
    781775msgid "No activity recorded yet."
    782776msgstr "対応履歴はまだありません。"
    783777
    784 #: admin/views/entry-detail.php:198
     778#: admin/views/entry-detail.php:201
    785779msgid "Activity pagination"
    786780msgstr "対応履歴のページ送り"
    787781
    788782#. translators: 1: current page number, 2: total number of pages
    789 #: admin/views/entry-detail.php:203
     783#: admin/views/entry-detail.php:206
    790784#, php-format
    791785msgid "Page %1$s of %2$s"
    792786msgstr "%2$s ページ中 %1$s ページ"
    793787
    794 #: admin/views/entry-detail.php:211
     788#: admin/views/entry-detail.php:214
    795789msgid "Newer activity"
    796790msgstr "新しい対応履歴"
    797791
    798 #: admin/views/entry-detail.php:216
     792#: admin/views/entry-detail.php:219
    799793msgid "Older activity"
    800794msgstr "古い対応履歴"
    … …  
    815809msgid "For entries received before this feature was added, activity is limited to the received timestamp and any internal notes already saved. Earlier status, trash, and spam changes were never recorded, so they are not shown."
    816810msgstr "この機能が追加される前に受信した問い合わせでは、対応履歴は受信日時と保存済みのメモのみとなります。それ以前のステータス・ゴミ箱・スパムの変更は記録されていないため、表示されません。"
     811
     812#. translators: 1: number of records processed, 2: total records to process, 3: percentage complete. "Records" counts processing units (entries plus notes), NOT the number of inquiries.
     813#: admin/class-admin-menu.php:188
     814#, php-format
     815msgid "Processed %1$s of %2$s records (%3$s%%)."
     816msgstr "%2$s 件の履歴データのうち %1$s 件を処理しました(%3$s%%)。"
  • neutrope-lead-desk-for-contact-form-7/trunk/languages/neutrope-lead-desk-for-contact-form-7.pot

    r3666139 r3666532  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.2.0\n"
     5"Project-Id-Version: Neutrope Lead Desk for Contact Form 7 0.3.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/neutrope-lead-desk-for-contact-form-7\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-08-25T14:05:36+00:00\n"
     12"POT-Creation-Date: 2026-08-26T04:22:33+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    7070msgstr ""
    7171
    72 #. translators: 1: number of records processed, 2: total records, 3: percentage complete
     72#. translators: 1: number of records processed, 2: total records to process, 3: percentage complete. "Records" counts processing units (entries plus notes), NOT the number of inquiries.
    7373#: admin/class-admin-menu.php:188
    7474#, php-format
    75 msgid "%1$s of %2$s (%3$s%%)"
     75msgid "Processed %1$s of %2$s records (%3$s%%)."
    7676msgstr ""
    7777
    … …  
    172172
    173173#: admin/class-entry-event-presenter.php:53
    174 #: admin/class-entry-list-table.php:238
    175 #: admin/views/entry-detail.php:253
     174#: admin/class-entry-list-table.php:363
     175#: admin/views/entry-detail.php:276
    176176#: includes/class-export-service.php:47
    177177msgid "Received"
    … …  
    199199
    200200#: admin/class-entry-event-presenter.php:89
    201 #: admin/views/entry-detail.php:141
     201#: admin/views/entry-detail.php:142
    202202msgid "Activity"
    203203msgstr ""
    … …  
    208208
    209209#: admin/class-entry-event-presenter.php:119
    210 #: admin/views/entry-detail.php:115
     210#: admin/views/entry-detail.php:116
    211211msgid "Unknown user"
    212212msgstr ""
    … …  
    246246msgstr ""
    247247
    248 #: admin/class-entry-list-table.php:211
     248#: admin/class-entry-list-table.php:336
    249249#: admin/views/export.php:43
    250250msgid "All"
    251251msgstr ""
    252252
    253 #: admin/class-entry-list-table.php:227
    254 #: admin/class-entry-list-table.php:361
     253#: admin/class-entry-list-table.php:352
     254#: admin/class-entry-list-table.php:570
    255255#: admin/views/export.php:72
    256256msgid "Trash"
    257257msgstr ""
    258258
    259 #: admin/class-entry-list-table.php:239
    260 #: admin/class-entry-list-table.php:471
    261 #: admin/views/entry-detail.php:228
     259#: admin/class-entry-list-table.php:364
     260#: admin/class-entry-list-table.php:680
     261#: admin/views/entry-detail.php:251
    262262#: admin/views/export.php:40
    263263#: includes/class-export-service.php:48
    … …  
    265265msgstr ""
    266266
    267 #: admin/class-entry-list-table.php:240
    268 #: admin/class-entry-list-table.php:479
    269 #: admin/views/entry-detail.php:254
     267#: admin/class-entry-list-table.php:365
     268#: admin/class-entry-list-table.php:688
     269#: admin/views/entry-detail.php:277
    270270#: admin/views/export.php:51
    271271#: includes/class-export-service.php:49
    … …  
    273273msgstr ""
    274274
    275 #: admin/class-entry-list-table.php:241
     275#: admin/class-entry-list-table.php:366
    276276#: admin/views/settings.php:73
    277277#: includes/class-export-service.php:51
    … …  
    279279msgstr ""
    280280
    281 #: admin/class-entry-list-table.php:242
     281#: admin/class-entry-list-table.php:367
    282282#: admin/views/settings.php:77
    283283#: includes/class-export-service.php:52
    … …  
    285285msgstr ""
    286286
    287 #: admin/class-entry-list-table.php:243
     287#: admin/class-entry-list-table.php:368
    288288#: admin/views/settings.php:81
    289289#: includes/class-export-service.php:53
    … …  
    291291msgstr ""
    292292
    293 #: admin/class-entry-list-table.php:244
     293#: admin/class-entry-list-table.php:369
    294294msgid "Source"
    295295msgstr ""
    296296
    297 #: admin/class-entry-list-table.php:245
     297#: admin/class-entry-list-table.php:370
    298298msgid "UTM source"
    299299msgstr ""
    300300
    301 #: admin/class-entry-list-table.php:246
    302 #: admin/views/entry-detail.php:96
     301#: admin/class-entry-list-table.php:371
     302#: admin/views/entry-detail.php:97
    303303msgid "Notes"
    304304msgstr ""
    305305
    306 #: admin/class-entry-list-table.php:264
    307 #: admin/class-entry-list-table.php:342
     306#: admin/class-entry-list-table.php:473
     307#: admin/class-entry-list-table.php:551
    308308msgid "Restore"
    309309msgstr ""
    310310
    311 #: admin/class-entry-list-table.php:269
     311#: admin/class-entry-list-table.php:478
    312312msgid "Set status: New"
    313313msgstr ""
    314314
    315 #: admin/class-entry-list-table.php:270
     315#: admin/class-entry-list-table.php:479
    316316msgid "Set status: In progress"
    317317msgstr ""
    318318
    319 #: admin/class-entry-list-table.php:271
     319#: admin/class-entry-list-table.php:480
    320320msgid "Set status: On hold"
    321321msgstr ""
    322322
    323 #: admin/class-entry-list-table.php:272
     323#: admin/class-entry-list-table.php:481
    324324msgid "Set status: Done"
    325325msgstr ""
    326326
    327 #: admin/class-entry-list-table.php:273
     327#: admin/class-entry-list-table.php:482
    328328msgid "Move to trash"
    329329msgstr ""
    330330
    331 #: admin/class-entry-list-table.php:274
     331#: admin/class-entry-list-table.php:483
    332332msgid "Mark as spam"
    333333msgstr ""
    334334
    335 #: admin/class-entry-list-table.php:299
     335#: admin/class-entry-list-table.php:508
    336336msgid "View"
    337337msgstr ""
    338338
    339 #: admin/class-entry-list-table.php:430
     339#: admin/class-entry-list-table.php:639
    340340msgid "None"
    341341msgstr ""
    342342
    343 #: admin/class-entry-list-table.php:437
     343#: admin/class-entry-list-table.php:646
    344344msgid "No entries are in the trash."
    345345msgstr ""
    346346
    347 #: admin/class-entry-list-table.php:440
     347#: admin/class-entry-list-table.php:649
    348348msgid "No entries match the current filters."
    349349msgstr ""
    350350
    351 #: admin/class-entry-list-table.php:468
    352 #: admin/class-entry-list-table.php:469
     351#: admin/class-entry-list-table.php:677
     352#: admin/class-entry-list-table.php:678
    353353msgid "Search keywords"
    354354msgstr ""
    355355
    356 #: admin/class-entry-list-table.php:473
     356#: admin/class-entry-list-table.php:682
    357357msgid "All statuses"
    358358msgstr ""
    359359
    360 #: admin/class-entry-list-table.php:481
     360#: admin/class-entry-list-table.php:690
    361361#: admin/views/export.php:54
    362362msgid "All forms"
    363363msgstr ""
    364364
    365 #: admin/class-entry-list-table.php:487
     365#: admin/class-entry-list-table.php:696
    366366#: admin/views/export.php:32
    367367msgid "From date"
    368368msgstr ""
    369369
    370 #: admin/class-entry-list-table.php:489
     370#: admin/class-entry-list-table.php:698
    371371#: admin/views/export.php:36
    372372msgid "To date"
    373373msgstr ""
    374374
    375 #: admin/class-entry-list-table.php:494
     375#: admin/class-entry-list-table.php:703
    376376msgid "Show spam"
    377377msgstr ""
    378378
    379 #: admin/class-entry-list-table.php:497
     379#: admin/class-entry-list-table.php:726
    380380msgid "Filter"
    381381msgstr ""
    382382
    383383#. translators: %d: entry ID
    384 #: admin/views/entry-detail.php:31
     384#: admin/views/entry-detail.php:32
    385385#, php-format
    386386msgid "Entry #%d"
    387387msgstr ""
    388388
    389 #: admin/views/entry-detail.php:34
     389#: admin/views/entry-detail.php:35
    390390msgid "Back to list"
    391391msgstr ""
    392392
    393 #: admin/views/entry-detail.php:47
     393#: admin/views/entry-detail.php:48
    394394msgid "Submitted fields"
    395395msgstr ""
    396396
    397 #: admin/views/entry-detail.php:51
     397#: admin/views/entry-detail.php:52
    398398msgid "No submitted fields stored."
    399399msgstr ""
    400400
    401 #: admin/views/entry-detail.php:72
     401#: admin/views/entry-detail.php:73
    402402msgid "Attachments (metadata only)"
    403403msgstr ""
    404404
    405 #: admin/views/entry-detail.php:76
     405#: admin/views/entry-detail.php:77
    406406msgid "File name"
    407407msgstr ""
    408408
    409 #: admin/views/entry-detail.php:77
     409#: admin/views/entry-detail.php:78
    410410msgid "MIME"
    411411msgstr ""
    412412
    413 #: admin/views/entry-detail.php:78
     413#: admin/views/entry-detail.php:79
    414414msgid "Size (bytes)"
    415415msgstr ""
    416416
    417 #: admin/views/entry-detail.php:91
     417#: admin/views/entry-detail.php:92
    418418msgid "Only metadata is captured. The uploaded files themselves are not stored by this plugin."
    419419msgstr ""
    420420
    421 #: admin/views/entry-detail.php:102
     421#: admin/views/entry-detail.php:103
    422422msgid "Note body"
    423423msgstr ""
    424424
    425 #: admin/views/entry-detail.php:105
     425#: admin/views/entry-detail.php:106
    426426msgid "Add note"
    427427msgstr ""
    428428
    429 #: admin/views/entry-detail.php:109
     429#: admin/views/entry-detail.php:110
    430430msgid "No notes yet."
    431431msgstr ""
    432432
    433 #: admin/views/entry-detail.php:143
     433#: admin/views/entry-detail.php:145
    434434msgid "History from before this plugin was updated is limited to what could be reconstructed: the time each entry arrived and any notes already saved."
    435435msgstr ""
    436436
    437 #: admin/views/entry-detail.php:155
     437#: admin/views/entry-detail.php:158
    438438msgid "No activity on this page."
    439439msgstr ""
    440440
    441 #: admin/views/entry-detail.php:157
     441#: admin/views/entry-detail.php:160
    442442msgid "Back to the latest activity"
    443443msgstr ""
    444444
    445 #: admin/views/entry-detail.php:162
     445#: admin/views/entry-detail.php:165
    446446msgid "Preparing activity history…"
    447447msgstr ""
    448448
    449 #: admin/views/entry-detail.php:164
     449#: admin/views/entry-detail.php:167
    450450msgid "No activity recorded yet."
    451451msgstr ""
    452452
    453 #: admin/views/entry-detail.php:198
     453#: admin/views/entry-detail.php:201
    454454msgid "Activity pagination"
    455455msgstr ""
    456456
    457457#. translators: 1: current page number, 2: total number of pages
    458 #: admin/views/entry-detail.php:203
     458#: admin/views/entry-detail.php:206
    459459#, php-format
    460460msgid "Page %1$s of %2$s"
    461461msgstr ""
    462462
    463 #: admin/views/entry-detail.php:211
     463#: admin/views/entry-detail.php:214
    464464msgid "Newer activity"
    465465msgstr ""
    466466
    467 #: admin/views/entry-detail.php:216
     467#: admin/views/entry-detail.php:219
    468468msgid "Older activity"
    469469msgstr ""
    470470
    471 #: admin/views/entry-detail.php:238
     471#: admin/views/entry-detail.php:261
    472472msgid "Change status"
    473473msgstr ""
    474474
    475 #: admin/views/entry-detail.php:246
     475#: admin/views/entry-detail.php:269
    476476msgid "Update"
    477477msgstr ""
    478478
    479 #: admin/views/entry-detail.php:251
     479#: admin/views/entry-detail.php:274
    480480msgid "Submission info"
    481481msgstr ""
    482482
    483 #: admin/views/entry-detail.php:255
     483#: admin/views/entry-detail.php:278
    484484#: includes/class-export-service.php:50
    485485msgid "Form ID"
    486486msgstr ""
    487487
    488 #: admin/views/entry-detail.php:256
     488#: admin/views/entry-detail.php:279
    489489#: includes/class-export-service.php:55
    490490msgid "Submission page URL"
    491491msgstr ""
    492492
    493 #: admin/views/entry-detail.php:260
     493#: admin/views/entry-detail.php:283
    494494#: includes/class-export-service.php:56
    495495msgid "Referrer"
    496496msgstr ""
    497497
    498 #: admin/views/entry-detail.php:264
     498#: admin/views/entry-detail.php:287
    499499#: includes/class-export-service.php:57
    500500msgid "Landing URL"
    501501msgstr ""
    502502
    503 #: admin/views/entry-detail.php:270
     503#: admin/views/entry-detail.php:293
    504504msgid "User Agent"
    505505msgstr ""
    506506
    507 #: admin/views/entry-detail.php:271
     507#: admin/views/entry-detail.php:294
    508508#: includes/class-export-service.php:64
    509509msgid "IP address"
    510510msgstr ""
    511511
    512 #: admin/views/entry-detail.php:272
     512#: admin/views/entry-detail.php:295
    513513#: includes/class-export-service.php:65
    514514msgid "Spam flag"
    515515msgstr ""
    516516
    517 #: admin/views/entry-detail.php:272
     517#: admin/views/entry-detail.php:295
    518518#: admin/views/export.php:62
    519519msgid "Spam"
    520520msgstr ""
    521521
    522 #: admin/views/entry-detail.php:272
     522#: admin/views/entry-detail.php:295
    523523msgid "Normal"
    524524msgstr ""
    … …  
    799799msgstr ""
    800800
    801 #: includes/class-entry-repository.php:25
     801#: includes/class-entry-repository.php:38
    802802msgid "New"
    803803msgstr ""
    804804
    805 #: includes/class-entry-repository.php:26
     805#: includes/class-entry-repository.php:39
    806806msgid "In progress"
    807807msgstr ""
    808808
    809 #: includes/class-entry-repository.php:27
     809#: includes/class-entry-repository.php:40
    810810msgid "On hold"
    811811msgstr ""
    812812
    813 #: includes/class-entry-repository.php:28
     813#: includes/class-entry-repository.php:41
    814814msgid "Done"
    815815msgstr ""
  • neutrope-lead-desk-for-contact-form-7/trunk/neutrope-lead-desk-for-contact-form-7.php

    r3666139 r3666532  
    44 * Plugin URI:        https://neutrope.biz/neutrope-lead-desk-for-contact-form-7/
    55 * Description:       Save Contact Form 7 submissions to the database and manage them as leads in the WordPress admin (list, detail, status, notes, CSV export).
    6  * Version:           0.2.0
     6 * Version:           0.3.0
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    2222}
    2323
    24 define( 'NLDCF7_VERSION', '0.2.0' );
     24define( 'NLDCF7_VERSION', '0.3.0' );
    2525define( 'NLDCF7_DB_VERSION', '1.1.0' );
    2626define( 'NLDCF7_PLUGIN_FILE', __FILE__ );
  • neutrope-lead-desk-for-contact-form-7/trunk/readme.txt

    r3666139 r3666532  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.2.0
     7Stable tag: 0.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1616
    1717The free version is intentionally focused: it saves submissions reliably, gives you an admin list/detail UI, lets you change status, add internal notes, and export to CSV. It does this without pulling in external services, trackers, or upsell banners on unrelated screens.
     18
     19Version 0.3.0 is mainly a foundation release for add-ons: it adds documented, generic extension points so other plugins can add columns, filters and detail-screen panels without forking this one. Nothing in the plugin's own behaviour changes because of them, and no database change is involved. See "Extending the plugin" below.
    1820
    1921Since 0.2.0 each entry also keeps an **Activity Timeline** (対応履歴) so you can see what has already been done about an inquiry -- when it arrived, who changed its status and to what, when notes were added, and when it was moved to trash, restored, or marked as spam -- instead of inferring the handling history from the current status alone.
    … …  
    3436* Privacy-friendly defaults: IP capture is OFF by default
    3537* Opt-in data deletion on uninstall (off by default so accidental deletes don't nuke your leads)
    36 * Extensibility via action/filter hooks: `nldcf7_after_entry_saved`, `nldcf7_after_note_added`, `nldcf7_after_status_changed`, `nldcf7_after_entry_trashed`, `nldcf7_after_entry_restored`, `nldcf7_after_spam_changed`, `nldcf7_entry_data_before_insert`, `nldcf7_export_columns`, `nldcf7_export_row`, `nldcf7_statuses`, `nldcf7_event_render`, `nldcf7_required_capability`
     38* Extensibility via action/filter hooks: `nldcf7_after_entry_saved`, `nldcf7_after_note_added`, `nldcf7_after_status_changed`, `nldcf7_after_entry_trashed`, `nldcf7_after_entry_restored`, `nldcf7_after_spam_changed`, `nldcf7_entry_data_before_insert`, `nldcf7_export_columns`, `nldcf7_export_row`, `nldcf7_statuses`, `nldcf7_event_labels`, `nldcf7_event_render`, `nldcf7_required_capability`
     39* Since 0.3.0, nine further extension points let an add-on place panels on the entry detail screen, add list columns and filter controls, keep its own filter state across list actions, and constrain the entries query -- all documented under "Extending the plugin"
    3740
    3841**Requirements**
    … …  
    106109Please open an issue on the plugin's support forum or the project's issue tracker.
    107110
     111== Extending the plugin ==
     112
     113Version 0.3.0 adds nine extension points for add-on developers. They are
     114generic: this plugin knows nothing about what an add-on stores. All of them are
     115no-ops when nothing is listening, so a site with no add-on behaves exactly as
     116it did in 0.2.0.
     117
     118= nldcf7_entry_detail_main =
     119
     120Action. Introduced in 0.3.0. Fires on the entry detail screen after this
     121plugin's own cards in the main column, before the column closes.
     122
     123`do_action( 'nldcf7_entry_detail_main', array $entry )`
     124
     125`$entry` is the entry row as stored, passed by value. The listener prints its
     126own markup, including its own card wrapper, and is responsible for escaping it.
     127Output is not buffered or altered.
     128
     129= nldcf7_entry_detail_sidebar =
     130
     131Action. Introduced in 0.3.0. The same contract, for the sidebar column. Two
     132hooks exist so an add-on can place content in both columns of one screen.
     133
     134`do_action( 'nldcf7_entry_detail_sidebar', array $entry )`
     135
     136= nldcf7_entry_list_columns =
     137
     138Filter. Introduced in 0.3.0. Adds columns to the entries list.
     139
     140`apply_filters( 'nldcf7_entry_list_columns', array $columns )`
     141
     142Return the column map with your own keys appended; prefix them to avoid
     143collisions. Core columns cannot be removed, renamed or repurposed -- every core
     144key is restored to its own label after the filter runs, and the checkbox column
     145is forced back to the front. Labels are plain text and are escaped on output.
     146
     147= nldcf7_entry_list_column_content =
     148
     149Filter. Introduced in 0.3.0. Renders a cell for a column you added.
     150
     151`apply_filters( 'nldcf7_entry_list_column_content', string $html, string $column_name, array $item )`
     152
     153Return HTML for your own column, or the value you were given for anything else.
     154Escape your own output; the plugin then passes the result through
     155`wp_kses_post()` as a second layer, so ordinary post markup survives while
     156scripts and event handlers do not. A non-string return renders an empty cell.
     157
     158= nldcf7_entry_list_filters =
     159
     160Action. Introduced in 0.3.0. Fires inside the list's existing GET filter form,
     161before the Filter button, so a plain select or input submits alongside the
     162built-in filters.
     163
     164`do_action( 'nldcf7_entry_list_filters', array $current_args )`
     165
     166`$current_args` is this plugin's own sanitized filter state, for reference. You
     167own your field names, your values and your escaping. No nonce is involved: the
     168filter bar only reads.
     169
     170= nldcf7_entry_list_carried_args =
     171
     172Filter. Introduced in 0.3.0. Keeps your own filter state across the All and
     173Trash views, the row actions, and the redirect after a row or bulk action.
     174
     175`apply_filters( 'nldcf7_entry_list_carried_args', array $args, array $core )`
     176
     177Add your own already-sanitized scalar values. The plugin re-checks the result
     178afterwards: reserved keys (`page`, `paged`, `action`, `action2`, `entry_id`,
     179`_wpnonce`, `_wp_http_referer`, `view`, `event_page`, `status_view`) are
     180stripped, keys are normalised, non-scalars are dropped, and both value length
     181and argument count are capped. Core values always win. Do not attempt to
     182forward the whole query string.
     183
     184= nldcf7_entry_query_args =
     185
     186Filter. Introduced in 0.3.0. Adjusts the entries query using arguments the
     187repository already understands, without writing SQL.
     188
     189`apply_filters( 'nldcf7_entry_query_args', array $args )`
     190
     191The most useful is `ids`, an array of entry IDs to restrict the query to.
     192`$args['context']` says who is asking: `list` for the entries screen, `export`
     193for CSV, `default` otherwise. Check it -- both callers share the same
     194repository, so an unconditional constraint would also change what CSV exports.
     195
     196= nldcf7_entry_query_clauses =
     197
     198Filter. Introduced in 0.3.0. Constrains the entries query using a table of your
     199own, resolved by the database rather than by loading matching IDs into PHP.
     200
     201`apply_filters( 'nldcf7_entry_query_clauses', array $parts, array $args )`
     202
     203`$parts` has four keys:
     204
     205* `join` -- a JOIN clause string.
     206* `where` -- an array of condition strings, each using `%s` or `%d` placeholders.
     207* `prepare_args` -- the values for those placeholders, in order.
     208* `distinct` -- boolean, default false.
     209
     210Values must go in `prepare_args`, never inside the condition strings; the plugin
     211passes them to `$wpdb->prepare()`. A single pre-built SQL string is not
     212accepted. If the placeholder count and the value count disagree, the whole
     213contribution is discarded. Table and column identifiers inside `join` are your
     214responsibility. Set `distinct` when your JOIN can match a row more than once: it
     215is applied to the row query and the count together, so the list total and
     216pagination stay correct. `$args['context']` applies here too.
     217
     218= nldcf7_entry_list_items_prepared =
     219
     220Action. Introduced in 0.3.0. Fires once per list page after the rows are known
     221and before any cell is rendered.
     222
     223`do_action( 'nldcf7_entry_list_items_prepared', array $items )`
     224
     225`$items` is the current page's rows only -- never the whole result set. Use it
     226to load everything your columns need in one query and keep it in a
     227request-local cache, instead of querying once per cell. It is read-only, has no
     228return value, and fires with an empty array when the page has no rows. It
     229belongs to the entries screen only and never fires during CSV export.
     230
    108231== Screenshots ==
    109232
    … …  
    115238
    116239== Changelog ==
     240
     241= 0.3.0 =
     242* Add: generic extension points for the entry detail screen -- `nldcf7_entry_detail_main` and `nldcf7_entry_detail_sidebar` -- so an add-on can place panels in either column without modifying this plugin.
     243* Add: generic list extension points -- `nldcf7_entry_list_columns` for columns and `nldcf7_entry_list_column_content` for their cells. Core columns are protected: an add-on cannot remove, rename or repurpose one, and returned cell markup passes through `wp_kses_post()`.
     244* Add: `nldcf7_entry_list_filters`, which places an add-on's own controls inside the existing filter bar.
     245* Add: `nldcf7_entry_list_carried_args`, so an add-on's own filter stays applied across the All and Trash views and after row and bulk actions. Reserved keys such as nonces, action names and entry IDs are refused, non-scalar values are dropped, and size and count are capped, so a careless listener cannot put unsafe state into a list URL.
     246* Add: `nldcf7_entry_query_args` for high-level query constraints, including the existing `ids` argument, and `nldcf7_entry_query_clauses` for constraints that live in an add-on's own table. Clauses are structured -- a JOIN, conditions with placeholders, and their values kept separate -- rather than a raw SQL string, and values always go through `$wpdb->prepare()`.
     247* Add: optional `distinct` handling for add-on joins that can match a row more than once. It is applied to the row query and the count together, so the list and its total can never disagree.
     248* Add: query context. The entries screen and the CSV exporter share one repository, so each query now says which it is. An add-on constraint meant for the list no longer reaches CSV export unless the add-on opts in.
     249* Add: `nldcf7_entry_list_items_prepared`, which hands an add-on the current page's rows once, before any cell is rendered, so custom columns can load their data in a single query instead of one per cell.
     250* Fix: the Activity preparation notice counted processing records (entries plus notes) but read as a number of inquiries. It now names the unit; the arithmetic is unchanged.
     251* Fix: the "history from before this plugin was updated" note no longer appears on sites that installed this version fresh and therefore have no earlier history to explain.
     252* No database change. The schema and database version (1.1.0) are identical to 0.2.0, no migration runs, and existing entries, notes, attachments, activity, settings, capabilities and URLs are untouched. With no add-on installed the plugin behaves exactly as 0.2.0 did.
    117253
    118254= 0.2.0 =
    … …  
    165301== Upgrade Notice ==
    166302
     303= 0.3.0 =
     304Adds a documented extension foundation for add-ons, plus two small Activity-history wording fixes. Your leads, notes, activity and database schema are unchanged, and no migration runs. With no add-on installed the plugin behaves exactly as before.
     305
    167306= 0.2.0 =
    168307Adds a per-entry activity history. Existing entries have their received time and existing notes reconstructed in the background; status, trash, and spam changes made before this update were never recorded and are not invented. Existing lead data is preserved.
Note: See TracChangeset for help on using the changeset viewer.