Skip to content

Add new Google Slides importer plugin - #982

Merged
Genyus merged 77 commits into
mainfrom
develop
Sep 12, 2026
Merged

Genyus merged 77 commits into
mainfrom
develop

Conversation

@Genyus

@Genyus Genyus commented Sep 10, 2026

Copy link
Copy Markdown
Member

Replace previous local import process with admin screen

Genyus added 30 commits August 21, 2026 03:37
- 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
Genyus merged commit 43b302c into main Sep 12, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant