Conversation
Release v0.1.54
- A6 verified: PhpPresentation parses CBF PPTX with sufficient fidelity - R1 closed: multi-column geometry detection fully functional - Key impl notes: shapes in pixels (div9525 for EMU), Drawing\Gd::getContents() for image bytes, ZipArchive for hidden-slide detection - P2.4/P2.7 checklist items updated with concrete implementation guidance - Plan bumped to v1.3.0
All Phase 0 gates cleared. Phase 1 (plugin scaffold and auth) unblocked. Plan bumped to v1.4.0.
Boilerplate matches cbf-multisite structure (Genyus/WordPress-Plugin-Boilerplate).
Modules created:
- Bootstrap: cbf-slides-importer.php, Main.php, Install.php, Utils.php
- Crypto.php: AES-256-GCM encrypt/decrypt keyed by CBF_SI_ENCRYPTION_KEY env var
- Assets.php, Admin/{Main,Assets,SettingsPage,ImporterPage}.php
- Api/{Router,AuthController,DriveController,ConfigController,JobController,PreviewController}.php
- Google/{OAuthClient,DriveClient}.php: OAuth2 + Drive file export
- Pptx/{Parser,GeometryDetector,SlideClassifier,BlockRenderer}.php
- Import/{JobRunner,LearnDashImporter}.php
DB tables (dbDelta): cbf_slide_import_configs, cbf_slide_import_jobs
Composer deps: google/apiclient ^2.15, phpoffice/phppresentation ^1.1
All PHP files parse clean (php -l).
.gitignore: add cbf-slides-importer plugin exception
Install.php: change slide_overrides, created_post_ids, result_summary
from LONGTEXT NOT NULL DEFAULT '{}' to LONGTEXT NULL DEFAULT NULL.
MariaDB strict mode forbids default values on TEXT/BLOB columns.
Main.php: add plugin.php include guard before is_plugin_active() so
the check works outside admin context (WP-Cron, REST, WP-CLI).
Also correct REQUIRED_PLUGIN entry-file to learndash-bulk-create.php
(actual file, not learndash-bulk-lessons-or-topics.php).
Verified: clean activation, both tables created, 13 REST routes at
cbf-si/v1 registered, /auth/status returns 401 unauthenticated.
Adds a dev-only 32-char encryption key so the Crypto class works in the local Lando environment. Must be replaced with a strong random value in any shared or staging environment.
…t secret sanitize_textarea_field() runs htmlspecialchars() which encodes double-quotes to ", making json_decode() fail on perfectly valid JSON. Switch to wp_unslash()+trim() which strips WP magic quotes without mangling JSON. Also add structural validation: require "web" or "installed" key with client_id and client_secret present, and re-encode via wp_json_encode() before encrypting to normalise whitespace.
Was calling create_auth_url() on a raw GoogleClient — that method does not exist on GoogleClient and, more critically, it bypassed the OAuthClient wrapper that stores the state nonce transient. The callback hash_equals() check would always fail without the transient in place.
The "Connect Google Drive" button was an <a href> pointing directly at the /auth/begin REST endpoint. A plain browser GET to a REST endpoint never carries X-WP-Nonce, so WP cookie-auth returns 401. Fix: - Add Admin\OAuthBridge: registers admin_post_cbf_si_auth_begin, verifies wp_nonce, calls OAuthClient::create_auth_url(), wp_redirect() to Google. - ImporterPage now renders OAuthBridge::begin_url() (nonce-protected admin-post URL) instead of the raw REST URL. - AuthController::callback() permission_callback changed to __return_true; auth checked manually inside (Google redirect carries cookie but never X-WP-Nonce — the OAuth state nonce is the CSRF protection). - Admin\Main::hooks() registers OAuthBridge. The REST /auth/begin endpoint is kept for the Phase-3 JS SPA which will send X-WP-Nonce in the request header.
is_user_logged_in() always returns false in the REST API callback because
WP REST cookie-auth requires X-WP-Nonce, which Google's browser redirect
never sends.
Fix: state parameter is now "{user_id}:{nonce}" (set in create_auth_url).
The callback parses user_id from state, loads the user with get_user_by(),
validates capability and nonce, then calls exchange_code() with that user_id.
No session cookie or nonce header needed — the state nonce is the CSRF token.
Admin/Assets.php: restrict enqueue to importer page, register gapi, fix ajax_url. JS: full Google Picker implementation, job list with status polling, fix localize global name, wire bindJobActions. .gitignore: layered negations to track source JS/CSS.
- js: guard DOMContentLoaded with readyState check — in the WP admin, 74+ synchronous scripts can cause the event to fire before our footer script executes; fall through to immediate bootstrap() when DOM is already interactive/complete - js: replace object spread with Object.assign() to satisfy the root ESLint parser; add plugin-level .eslintrc.js (ecmaVersion 2020) to support async/await and modern browser globals - php: bump VERSION to 1.0.1 to bust browser asset cache - db: add 'parsed' to status ENUM in Install.php — missing value caused MySQL to store '' for jobs that completed parsing, showing '-' in the UI - parser: replace Font::isUnderline() (non-existent) with getUnderline() !== Font::UNDERLINE_NONE (correct PhpPresentation API)
- Admin/Assets.php: add wp_rest_url to localized params for WP REST access
- Api/JobController.php: trigger_import accepts mode/course_id body params
and merges them into result_summary.config before scheduling import phase
- assets/js/admin/cbf-slides-importer.js v1.0.2:
- Add Api.getCourses() fetches sfwd-courses via WP REST with nonce
- Add Api.triggerImport(id, config) passes mode/course_id to backend
- Add _loadCourses() caches course list to avoid repeat fetches
- Replace direct import button with 'Configure and Import' which expands
an inline config panel below the job row
- Config panel: mode radio toggle plus course dropdown
- _doImport reads panel values and passes config to trigger_import
- Warn if lesson-with-topics selected without a course
- Panel toggles on/off; closes after import triggered
- cbf-slides-importer.php: bump version to 1.0.2
- plans/cbf-slides-importer-plugin.md: mark Phase 1 and Phase 2 complete,
P3.1-P3.2 complete; update plan version to 1.6.0
The learndash-bulk plugin exposes its instance as a global variable $extended_learndash_bulk_create, not via a named function or static method — fix get_bulk_plugin() to use the global. run_import_cli() takes a CSV file path; the actual programmatic API is run_import($content_type, $headers, $rows, $options) — fix run_import_row() to call run_import() directly: - Extract post_type as $content_type (not a column) - Build $headers and $rows from the associative row array - Pass $img_dir via options.media_dir so ELDBC_Media handles rewrite - Extract created/updated post IDs from the returned stats arrays Media rewrite is now handled inside run_import() via ELDBC_Media so the separate rewrite_post_images() call is no longer needed.
Drive API files.export has a ~10 MB cap; presentations with many slides or high-res images exceed it with HTTP 403 exportSizeLimitExceeded. Add try_direct_export() which streams the presentation via the standard Docs export URL (https://docs.google.com/presentation/d/{id}/export/pptx) using the user's Bearer token and wp_remote_get() stream mode, writing directly to disk without loading the full file into memory. - export_pptx() now tries the API first; on size-limit 403 it falls back - is_size_limit_error() detects 'exportSizeLimitExceeded' or 'too large' - should_retry() extracted to reduce try_api_export() cyclomatic complexity - Last-error default initialised inline to avoid null-coalescing branch
PHP shape objects cannot survive JSON serialisation. The import phase previously used classified data decoded from the DB, where all PhpPresentation Shape RichText instances had become empty arrays, causing every instanceof check to fail and all slide content to render as empty strings. Fix: import phase now re-parses the PPTX from the stored pptx_path and re-classifies using the config stored in result_summary. The classified key is no longer written to result_summary. Also adds a post_title field to the config panel (defaulting to the deck name) so the lesson title can be set before import, rather than being hardcoded as Imported Lesson.
Two post-import bug fixes: Image paths (media library not populated) - BlockRenderer::render_image_block() was emitting src="/filename.png" (absolute path, no prefix). ELDBC_Media::rewrite_paths() only matches the pattern media/filename so it never uploaded or rewrote these paths. - Changed to emit src="media/filename.png" when no media_base_url is supplied (the import path). ELDBC_Media's resolve_under_media_dir() candidate base/without_prefix then resolves to the actual file. - Switched from esc_url() to esc_attr() for the placeholder src value. Footer exclusion (copyright text / CBF icon on every slide) - GeometryDetector: added FOOTER_TOP_RATIO = 0.87 (calibrated against Session 07 deck: footer shapes at t>=475 on 540px slides, body at t=96-113). Added FOOTER_PLACEHOLDER_TYPES (sldNum, ftr, dt) for named footer shapes. - build_content_blocks() now accepts slide_height_px and passes it down. - collect_content_shapes() excludes sldNum/ftr/dt placeholders and shapes whose top-edge >= footer cutoff (87% of slide height). - Parser::extract_images() skips Drawing shapes in the footer zone. - Parser passes slide_height_px through parse() -> parse_slide() -> extract_images() and build_content_blocks(). Bumped plugin version to 1.0.4 to bust browser JS cache.
Google Slides progressive-reveal exports produce several consecutive PPTX slides with the same title (one per animation step). This caused the same H2 heading to repeat in the rendered output for every build-step slide. BlockRenderer now tracks the last emitted heading title across the slide loop. A slide whose title is non-empty and identical to the previous non-empty title has its heading suppressed — only the first slide in each run of identical titles emits an H2. This applies in both render modes: - lesson-only: prev_title tracked across all body slides. - lesson-with-topics: prev_title tracked across the whole pass; heading-type slides (which become LearnDash Topics) also update the tracker, so the first body slide in a topic does not repeat the topic title as a redundant H2.
wp:list-item blocks must not wrap their text in <p> — the extra paragraph tags add unwanted bottom margins and break the block parser's expectations. Also added the wp:list-item block comments around each <li>, which Gutenberg requires for proper round-trip block serialisation.
…dismiss Two picker UX bugs: Page jump on open - picker.setVisible() injects an iframe into the document body and in some browsers the viewport scrolls to it. Fixed by saving window.scrollX/Y before the picker renders and restoring it synchronously and via requestAnimationFrame() after setVisible() returns. Button stays disabled after dismissal - _openPicker() only re-enabled the button when a file was selected. Dismissing the picker without a selection left the button disabled and showing 'Loading picker…' for 30 s (the blunt fallback timeout). - Picker.open() now accepts an onDismissed callback alongside onSelected. _buildAndShow() calls onDismissed when data.action === CANCEL. The 30 s timeout fallback is removed — the CANCEL action fires reliably. - Error path in Picker.open() also calls onDismissed so the button recovers if the picker-config fetch fails.
… timing Button label - Added onReady callback to Picker.open() / _buildAndShow(). It fires immediately after picker.setVisible(true). _openPicker() uses it to reset the button text to the normal label while keeping the button disabled — so the 'Loading picker...' message disappears as soon as the modal is on screen rather than waiting for user interaction. Scroll position - Previous fix saved window.scrollX/Y inside _buildAndShow(), which runs after gapi.load() completes. gapi.load() injects a hidden iframe on first use which causes the viewport jump, so the saved position was already wrong by the time it was read. - scrollX/scrollY are now captured in _openPicker() (synchronously, before Api.pickerConfig() or gapi.load() run) and passed through Picker.open() → _buildAndShow() so the true pre-open position is always available for restoration.
Previous approach called window.scrollTo() synchronously and via
requestAnimationFrame() after picker.setVisible(). This was too early:
the browser's scroll-to-focus on the picker iframe fires asynchronously,
overriding both restores.
Instead, a scroll event listener is registered before setVisible() with
{ once: true } so it fires in direct response to the browser-initiated
scroll and immediately calls window.scrollTo() with the pre-open
coordinates. { once: true } auto-removes the listener on first fire,
ensuring it cannot interfere with any scroll the user makes while the
picker is open. A 1 s setTimeout removes the guard if no scroll event
fires (already at top of page, or subsequent opens where the iframe is
already in the DOM and no focus-scroll occurs).
…urge Implements the cbf_si_cleanup WP-Cron event (P2.11 / R4 / Failure Isolation). New Import/Janitor class - CLEANUP_HOOK = 'cbf_si_cleanup', registered as an hourly WP-Cron event. - reset_stale_jobs(): queries for jobs in 'downloading', 'parsing', or 'importing' status whose updated_at is older than 30 minutes. Resets each to 'pending' with a human-readable note in error_message so the next WP-Cron tick retries the job from scratch. 'parsed' is excluded — it is a stable waiting-for-user state, not in-flight processing. - purge_orphaned_tmp_dirs(): scans cbf-slides-tmp/ for job_N subdirs whose mtime is older than 2 hours and deletes them via Utils::rmdir_recursive(). Only touches job_* subdirs; the root dir and its index.php sentinel are left alone. Install - install(): schedules 'cbf_si_cleanup' as an hourly event on activation if not already registered (idempotent). - deactivate(): now also clears the cleanup hook alongside the job processor hook, so no orphaned cron events remain after deactivation. Main - Registers Janitor::hooks() alongside JobRunner::hooks() so the cleanup event handler is active whenever the plugin is loaded.
P2.11: mark complete in plan.
P3.3 — Slide map UI component:
- JobRunner now extracts serialisable slides_meta (index, slide_number,
title, layout_name, is_hidden, is_cover, slide_type) after classify()
and stores it in result_summary alongside pptx_path/img_dir.
- New GET /jobs/{id}/slides REST endpoint (JobController::slides()) reads
slides_meta and merges any stored overrides so the UI pre-populates
on re-open.
- Admin JS: _openConfigPanel() now fetches slides in parallel with
courses. A collapsible <details> slide map renders below the config
form with #, title, layout name, auto-detected type badge, and an
override <select> per slide (options: auto, Cover, Heading/Topic,
Content, Hidden).
- Api.getJobSlides(id) added; errors silently fall back to empty list.
P3.5 — Config validation + slide_overrides wiring:
- Title field is now required; _doImport() blocks with alert() and
focuses the field when empty.
- post_title is always included in the import payload (not optional).
- slide_overrides collected from all [data-slide-override] selects;
included in POST /jobs/{id}/import body when non-empty.
- New slide_overrides REST arg (type: object) on /jobs/{id}/import.
- save_import_overrides() sanitises each entry (absint key, sanitize_key
value, allow-list check) and stores as JSON in config.slide_overrides.
- classify_from_summary() already decodes slide_overrides, so overrides
take effect at import time with no further changes.
Root cause of blank action column (job 12):
- bulk plugin's find_post_for_row() matched existing lesson by title
('Introduction to Software Development', post 152) and returned
status:'skipped' with overwrite:false
- run_import_row() only collected created_entries + updated_entries,
silently discarding skipped_entries → created_post_ids:[] → blank UI
Fixes:
- run_import_row(): also iterates skipped_entries and appends their IDs
to the returned post ID list; logs a warning with the skip count
- run_import_row(): now accepts bool $overwrite and passes it to the
bulk plugin (previously hard-coded false)
- import_lesson_only() / import_lesson_with_topics(): thread $overwrite
through from import() which reads config['overwrite']
- save_import_overrides(): handle new 'overwrite' boolean REST param
- /jobs/{id}/import: register 'overwrite' as type:boolean REST arg
- Config panel: 'Overwrite existing content' checkbox with description
- _doImport(): reads checkbox, always sends overwrite in importConfig
Data fix: job 12 created_post_ids patched to [152] via direct SQL
so the action column immediately reflects the existing lesson.
The connection text and both buttons shared one flex row, so a narrow screen wrapped them wherever they happened to fit — sometimes splitting "Use a different account" from "Disconnect", which read as two unrelated controls. The text is now its own paragraph and the buttons sit together beneath it. They keep a flex row of their own so they stay adjacent and evenly spaced, and can still wrap against each other rather than overflowing on a very narrow viewport. The unconnected panel gets the same shape. It has only one button so it could not split, but leaving it inline would have made the two states sit differently on the same screen.
- Rename --skip-assets flag to --skip-media to follow WP conventions more closely
- Due to exec PHP option being disabled after 8.5 upgrade, db export command failed. - Script now calls latest vendor db-command instead of older version bundled with global WP-CLI
Genyus
force-pushed
the
main
branch
2 times, most recently
from
September 12, 2026 04:42
39f6b02 to
f2c2936
Compare
- Prevent admin emails being sent on every reset
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replace previous local import process with admin screen