__( 'Title', 'steadypost' ), 'content' => __( 'Content (text, Markdown or HTML)', 'steadypost' ), 'doc_url' => __( 'Google Doc (optional — replaces Content)', 'steadypost' ), 'slug' => __( 'URL slug', 'steadypost' ), 'status' => __( 'Post status', 'steadypost' ), 'seo_title' => __( 'SEO meta title', 'steadypost' ), 'seo_description' => __( 'SEO meta description', 'steadypost' ), 'seo_keywords' => __( 'SEO keywords', 'steadypost' ), 'ref' => __( 'Unique row ID', 'steadypost' ), 'post_id' => __( 'WordPress post ID (update that post)', 'steadypost' ), 'excerpt' => __( 'Excerpt', 'steadypost' ), 'featured_image' => __( 'Featured image URL', 'steadypost' ), 'categories' => __( 'Categories', 'steadypost' ), 'tags' => __( 'Tags', 'steadypost' ), 'post_type' => __( 'Post type', 'steadypost' ), 'date' => __( 'Publish date', 'steadypost' ), 'author' => __( 'Author', 'steadypost' ), ); } /** * Header names auto-detected for each field, best guess first. * Deliberately conservative: a column called "type" or "keywords" is far more * often an editorial workflow column than a WordPress post type or tag list. */ public static function auto_map_candidates() { return array( 'title' => array( 'title', 'post_title', 'headline' ), 'doc_url' => array( 'doc_url', 'content_doc', 'google_doc', 'google_doc_url', 'doc_link', 'article', 'article_doc', 'article_url', 'doc', 'document', 'content_url' ), 'slug' => array( 'url_slug', 'slug', 'post_name', 'permalink' ), 'status' => array( 'post_status', 'wp_status', 'publish_status', 'status' ), 'seo_title' => array( 'meta_title', 'seo_title' ), 'seo_description' => array( 'meta_description', 'seo_description', 'meta_desc' ), 'seo_keywords' => array( 'keywords', 'seo_keywords', 'focus_keyword', 'focus_keyphrase' ), 'ref' => array( 'ref', 'row_id', 'unique_id', 'reference', 'id' ), 'post_id' => array( 'post_id', 'wp_post_id', 'wordpress_id' ), 'excerpt' => array( 'excerpt', 'post_excerpt', 'summary' ), 'content' => array( 'post_content', 'body', 'content' ), 'featured_image' => array( 'featured_image', 'feature_image', 'cover_image', 'thumbnail', 'image' ), 'categories' => array( 'categories', 'category' ), 'tags' => array( 'tags', 'tag' ), 'post_type' => array( 'post_type', 'wp_post_type' ), 'date' => array( 'post_date', 'publish_date', 'published_date', 'date_published' ), 'author' => array( 'post_author', 'author' ), ); } /** Guess a field => header map from the sheet's headers. */ public static function auto_map( $headers ) { $map = array(); foreach ( self::auto_map_candidates() as $field => $candidates ) { foreach ( $candidates as $candidate ) { if ( in_array( $candidate, $headers, true ) ) { $map[ $field ] = $candidate; break; } } } return $map; } /** "Featured Image " => "featured_image"; "meta: SEO Title" => "meta:seo_title". */ public static function normalise_header( $header ) { $header = strtolower( trim( (string) $header ) ); $header = preg_replace( '/^\x{FEFF}/u', '', $header ); if ( preg_match( '/^(meta|tax)\s*:\s*(.+)$/', $header, $m ) ) { return $m[1] . ':' . trim( preg_replace( '/[^a-z0-9_\-]+/', '_', trim( $m[2] ) ), '_' ); } return trim( preg_replace( '/[^a-z0-9]+/', '_', $header ), '_' ); } /** Pull the spreadsheet id + tab gid out of any Google Sheets URL. */ public static function parse_url( $url ) { $url = trim( (string) $url ); if ( '' === $url ) { return new WP_Error( 'steadypost_no_url', __( 'No Google Sheet URL has been set.', 'steadypost' ) ); } if ( ! preg_match( '#/spreadsheets/d/([a-zA-Z0-9\-_]+)#', $url, $m ) ) { return new WP_Error( 'steadypost_bad_url', __( 'That does not look like a Google Sheets URL. Expected something like https://docs.google.com/spreadsheets/d/XXXX/edit', 'steadypost' ) ); } $gid = '0'; if ( preg_match( '/[#&?]gid=([0-9]+)/', $url, $g ) ) { $gid = $g[1]; } return array( 'id' => $m[1], 'gid' => $gid, ); } /** * Read the configured source, with the page templates filled in per row. * * @return array|WP_Error 'headers', 'labels', 'rows', 'notes' */ public static function fetch( $settings = null ) { $settings = $settings ? $settings : SteadyPost_Settings::all(); $sheet = self::read( $settings ); return is_wp_error( $sheet ) ? $sheet : SteadyPost_Template::apply( $sheet, $settings ); } /** * A spreadsheet file at a given path, read the way an upload is. For WP-CLI, * which imports a file without making it the site's stored upload. * * @return array|WP_Error Same as fetch(). */ public static function fetch_file( $path, $settings ) { $file = SteadyPost_File::read_path( $path ); if ( is_wp_error( $file ) ) { return $file; } $sheet = self::to_rows( $file['table'], $file['links'], array() ); return is_wp_error( $sheet ) ? $sheet : SteadyPost_Template::apply( $sheet, $settings ); } protected static function read( $settings ) { $notes = array(); // The standalone source: an uploaded CSV / XLSX, no Google involved. if ( 'file' === $settings['source_mode'] ) { $file = SteadyPost_File::fetch(); if ( is_wp_error( $file ) ) { return $file; } return self::to_rows( $file['table'], $file['links'], $notes ); } $parsed = self::parse_url( $settings['sheet_url'] ); if ( is_wp_error( $parsed ) ) { return $parsed; } $links = array(); // A service account authenticates the API on its own, so API mode no // longer depends on an API key being present. $can_use_api = ! empty( $settings['api_key'] ) || SteadyPost_Google_Auth::configured( $settings ); if ( 'api' === $settings['source_mode'] && $can_use_api ) { $table = self::fetch_via_api( $parsed, $settings['api_key'] ); if ( is_wp_error( $table ) ) { return $table; } if ( ! empty( $settings['recover_links'] ) ) { $links = self::links_via_api( $parsed, $settings['api_key'] ); } } else { $table = self::fetch_via_csv( $parsed ); if ( is_wp_error( $table ) ) { return $table; } if ( ! empty( $settings['recover_links'] ) ) { $links = self::links_via_xlsx( $parsed, $table, $notes ); } } return self::to_rows( $table, $links, $notes ); } /** Turn a raw 2D table into headered associative rows. */ protected static function to_rows( $table, $links, $notes ) { // Locate the header row: the first row that isn't blank. $header_index = null; foreach ( $table as $index => $row ) { if ( '' !== trim( implode( '', array_map( 'strval', (array) $row ) ) ) ) { $header_index = $index; break; } } if ( null === $header_index ) { return new WP_Error( 'steadypost_empty', __( 'The sheet appears to be empty.', 'steadypost' ) ); } $headers = array(); $labels = array(); foreach ( $table[ $header_index ] as $i => $cell ) { $key = self::normalise_header( $cell ); if ( '' === $key ) { $key = 'column_' . ( $i + 1 ); } while ( in_array( $key, $headers, true ) ) { $key .= '_' . ( $i + 1 ); } $headers[] = $key; $labels[] = '' !== trim( (string) $cell ) ? trim( (string) $cell ) : $key; } $rows = array(); foreach ( $table as $index => $line ) { if ( $index <= $header_index ) { continue; } $line = (array) $line; if ( '' === trim( implode( '', array_map( 'strval', $line ) ) ) ) { continue; } $sheet_row = $index + 1; // 1-based, matching what Google shows in the gutter. $row = array( self::ROW_KEY => $sheet_row, self::LINKS_KEY => array(), ); foreach ( $headers as $i => $key ) { $row[ $key ] = isset( $line[ $i ] ) ? trim( (string) $line[ $i ] ) : ''; if ( isset( $links[ $sheet_row ][ $i + 1 ]['url'] ) ) { $row[ self::LINKS_KEY ][ $key ] = $links[ $sheet_row ][ $i + 1 ]['url']; } } $rows[] = $row; } return array( 'headers' => $headers, 'labels' => $labels, 'rows' => $rows, 'notes' => $notes, ); } /* ------------------------------------------------------------- fetching */ protected static function fetch_via_csv( $parsed ) { $url = sprintf( 'https://docs.google.com/spreadsheets/d/%s/export?format=csv&gid=%s', rawurlencode( $parsed['id'] ), rawurlencode( $parsed['gid'] ) ); $body = self::http_get( $url ); if ( is_wp_error( $body ) ) { return $body; } if ( self::looks_like_login_page( $body ) ) { return new WP_Error( 'steadypost_not_shared', sprintf( /* translators: %s: what to check about sharing */ __( 'Google returned a sign-in page instead of the sheet. %s', 'steadypost' ), self::sharing_hint() ) ); } return self::parse_csv( $body ); } protected static function fetch_via_api( $parsed, $api_key, $render = 'FORMATTED_VALUE' ) { $tab = self::api_tab_title( $parsed, $api_key ); if ( is_wp_error( $tab ) ) { return $tab; } $url = add_query_arg( self::api_args( $api_key, array( 'majorDimension' => 'ROWS', 'valueRenderOption' => $render, ) ), 'https://sheets.googleapis.com/v4/spreadsheets/' . rawurlencode( $parsed['id'] ) . '/values/' . rawurlencode( $tab ) ); $body = self::http_get( $url ); if ( is_wp_error( $body ) ) { return $body; } $json = json_decode( $body, true ); if ( ! is_array( $json ) || ! isset( $json['values'] ) ) { return new WP_Error( 'steadypost_api_values', __( 'The Sheets API returned no cell values for that tab.', 'steadypost' ) ); } return array_map( function ( $row ) { return array_map( 'strval', (array) $row ); }, $json['values'] ); } /** * Query args for a Sheets API call. A service account authenticates with a * Bearer header instead, and Google rejects a request that carries both an * API key and an OAuth token — so the key is only sent when there is no * service account to sign with. */ protected static function api_args( $api_key, $args = array() ) { if ( ! SteadyPost_Google_Auth::configured() && '' !== (string) $api_key ) { $args['key'] = rawurlencode( $api_key ); } return $args; } /** Resolve the tab title for the URL's gid. */ protected static function api_tab_title( $parsed, $api_key ) { $url = add_query_arg( self::api_args( $api_key, array( 'fields' => 'sheets.properties' ) ), 'https://sheets.googleapis.com/v4/spreadsheets/' . rawurlencode( $parsed['id'] ) ); $body = self::http_get( $url ); if ( is_wp_error( $body ) ) { return $body; } $json = json_decode( $body, true ); if ( ! is_array( $json ) || empty( $json['sheets'] ) ) { return new WP_Error( 'steadypost_api_meta', sprintf( /* translators: %s: what to check about sharing */ __( 'The Sheets API did not return any tabs. Check that the Google Sheets API is enabled for the project. %s', 'steadypost' ), self::sharing_hint() ) ); } foreach ( $json['sheets'] as $sheet ) { $props = isset( $sheet['properties'] ) ? $sheet['properties'] : array(); if ( isset( $props['sheetId'] ) && (string) $props['sheetId'] === (string) $parsed['gid'] ) { return $props['title']; } } return $json['sheets'][0]['properties']['title']; } /* ------------------------------------------------------ link recovery */ /** Sheets API: a second FORMULA-rendered pass exposes =HYPERLINK() targets. */ protected static function links_via_api( $parsed, $api_key ) { $table = self::fetch_via_api( $parsed, $api_key, 'FORMULA' ); if ( is_wp_error( $table ) ) { return array(); } $links = array(); foreach ( $table as $r => $row ) { foreach ( (array) $row as $c => $value ) { $link = self::parse_hyperlink_formula( $value ); if ( $link ) { $links[ $r + 1 ][ $c + 1 ] = $link; } } } return $links; } /** * CSV mode: the XLSX export keeps both =HYPERLINK() formulas and cells linked * with Insert > Link. The workbook has no gid, so each worksheet is scored * against the CSV we already have — the sheet whose link labels line up with * the CSV text at the same coordinates is the tab we are importing. */ protected static function links_via_xlsx( $parsed, $csv_table, &$notes ) { if ( ! class_exists( 'ZipArchive' ) ) { $notes[] = __( 'PHP is missing the Zip extension, so linked cells such as "Open Article" cannot be resolved to their Google Doc URLs. Add a plain-URL column, or switch to Sheets API mode.', 'steadypost' ); return array(); } require_once ABSPATH . 'wp-admin/includes/file.php'; $tmp = wp_tempnam( 'steadypost-workbook.xlsx' ); if ( ! $tmp ) { $notes[] = __( 'Could not create a temporary file for the workbook, so linked cells were skipped.', 'steadypost' ); return array(); } // Streamed rather than downloaded with download_url() because the request // has to carry the service account's Bearer header for a private sheet. $fetched = self::http_get( 'https://docs.google.com/spreadsheets/d/' . rawurlencode( $parsed['id'] ) . '/export?format=xlsx', array( 'stream' => $tmp ) ); if ( is_wp_error( $fetched ) ) { wp_delete_file( $tmp ); $notes[] = sprintf( /* translators: %s: error message */ __( 'Could not download the workbook to resolve linked cells: %s', 'steadypost' ), $fetched->get_error_message() ); return array(); } $zip = new ZipArchive(); if ( true !== $zip->open( $tmp ) ) { wp_delete_file( $tmp ); $notes[] = __( 'The workbook download was not a readable XLSX file, so linked cells were skipped.', 'steadypost' ); return array(); } $shared = self::shared_strings( $zip ); $best = array(); $best_score = 0; for ( $i = 0; $i < $zip->numFiles; $i++ ) { $name = $zip->getNameIndex( $i ); if ( ! preg_match( '#^xl/worksheets/sheet[0-9]+\.xml$#', (string) $name ) ) { continue; } $links = self::extract_worksheet_links( $zip, $name ); if ( ! $links ) { continue; } $score = self::score_worksheet( $zip, $name, $shared, $links, $csv_table ); if ( $score > $best_score ) { $best_score = $score; $best = $links; } } $zip->close(); wp_delete_file( $tmp ); if ( 0 === $best_score ) { $notes[] = __( 'No linked cells could be matched to this tab. If your doc column shows a clickable label rather than a visible URL, check that the Google Sheet URL points at the right tab (the #gid= part).', 'steadypost' ); } return $best; } /** @return array sheetRow => colIndex => array( 'url', 'label' ) */ public static function extract_worksheet_links( $zip, $sheet_name ) { $xml = $zip->getFromName( $sheet_name ); if ( ! $xml ) { return array(); } $links = array(); // =HYPERLINK("url", "label") if ( preg_match_all( '#]*>\s*([^<]*)#', $xml, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $link = self::parse_hyperlink_formula( html_entity_decode( $match[3], ENT_QUOTES, 'UTF-8' ) ); if ( $link ) { $links[ (int) $match[2] ][ self::column_index( $match[1] ) ] = $link; } } } // Cells linked with Insert > Link, which live in the worksheet rels. if ( preg_match_all( '#]*ref="([A-Z]+)([0-9]+)"[^>]*r:id="([^"]+)"#', $xml, $matches, PREG_SET_ORDER ) ) { $rels = self::worksheet_rels( $zip, $sheet_name ); foreach ( $matches as $match ) { if ( empty( $rels[ $match[3] ] ) ) { continue; } $row = (int) $match[2]; $col = self::column_index( $match[1] ); if ( ! isset( $links[ $row ][ $col ] ) ) { $links[ $row ][ $col ] = array( 'url' => $rels[ $match[3] ], 'label' => '', ); } } } return $links; } protected static function worksheet_rels( $zip, $sheet_name ) { $rels_name = dirname( $sheet_name ) . '/_rels/' . basename( $sheet_name ) . '.rels'; $xml = $zip->getFromName( $rels_name ); if ( ! $xml ) { return array(); } $rels = array(); if ( preg_match_all( '#]*Id="([^"]+)"[^>]*Target="([^"]+)"#', $xml, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $rels[ $match[1] ] = html_entity_decode( $match[2], ENT_QUOTES, 'UTF-8' ); } } return $rels; } /** The workbook's shared string table: index => text. */ public static function shared_strings( $zip ) { $xml = $zip->getFromName( 'xl/sharedStrings.xml' ); if ( ! $xml ) { return array(); } $strings = array(); if ( preg_match_all( '#(.*?)#s', $xml, $items ) ) { foreach ( $items[1] as $item ) { $text = ''; if ( preg_match_all( '#]*>(.*?)#s', $item, $parts ) ) { $text = implode( '', $parts[1] ); } $strings[] = html_entity_decode( $text, ENT_QUOTES | ENT_XML1, 'UTF-8' ); } } return $strings; } /** * How well a worksheet matches the CSV we already fetched. * * Link labels alone are not enough to identify the tab: a duplicated or * archived tab can carry the same "Open Article" labels in the same columns * and tie. So the worksheet's own text cells are compared against the CSV, * which separates them decisively. */ protected static function score_worksheet( $zip, $sheet_name, $shared, $links, $csv_table ) { $xml = $zip->getFromName( $sheet_name ); if ( ! $xml ) { return 0; } $score = 0; if ( preg_match_all( '#]*)>(.*?)#s', $xml, $cells, PREG_SET_ORDER ) ) { foreach ( $cells as $cell ) { $row = (int) $cell[2]; if ( $row > 200 ) { continue; // Comparing the top of the sheet is enough to identify it. } $text = self::cell_text( $cell[3], $cell[4], $shared ); if ( '' === $text ) { continue; } $col = self::column_index( $cell[1] ); $csv_cell = isset( $csv_table[ $row - 1 ][ $col - 1 ] ) ? trim( (string) $csv_table[ $row - 1 ][ $col - 1 ] ) : null; if ( null !== $csv_cell && $csv_cell === trim( $text ) ) { // The header row is the strongest signal that this is the tab. $score += ( 1 === $row ) ? 5 : 1; } } } return $score; } /** Text of a single worksheet cell; numbers and dates are ignored. */ protected static function cell_text( $attributes, $inner, $shared ) { $type = ''; if ( preg_match( '/\bt="([a-z]+)"/', $attributes, $m ) ) { $type = $m[1]; } if ( 'inlineStr' === $type ) { if ( preg_match_all( '#]*>(.*?)#s', $inner, $parts ) ) { return html_entity_decode( implode( '', $parts[1] ), ENT_QUOTES | ENT_XML1, 'UTF-8' ); } return ''; } if ( ! preg_match( '#(.*?)#s', $inner, $m ) ) { return ''; } $value = html_entity_decode( $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8' ); if ( 's' === $type ) { $index = (int) $value; return isset( $shared[ $index ] ) ? $shared[ $index ] : ''; } // "str" is a formula result; anything else is a number or date serial, // which will not match the CSV's formatted text. return ( 'str' === $type ) ? $value : ''; } /** * The Sheets API renders formulas with a leading "=", XLSX stores them * without one, so the sign is optional here. */ public static function parse_hyperlink_formula( $value ) { $value = trim( (string) $value ); if ( '' === $value || false === stripos( $value, 'HYPERLINK' ) ) { return null; } if ( ! preg_match( '/^=?\s*HYPERLINK\s*\(\s*"((?:[^"]|"")*)"\s*(?:[,;]\s*"((?:[^"]|"")*)"\s*)?\)/i', $value, $m ) ) { return null; } return array( 'url' => str_replace( '""', '"', $m[1] ), 'label' => isset( $m[2] ) ? str_replace( '""', '"', $m[2] ) : '', ); } /** "AB" => 28 */ public static function column_index( $letters ) { $index = 0; $len = strlen( $letters ); for ( $i = 0; $i < $len; $i++ ) { $index = ( $index * 26 ) + ( ord( $letters[ $i ] ) - 64 ); } return $index; } /* --------------------------------------------------------------- utils */ /** CSV -> 2D array, via a stream so quoted newlines survive. */ public static function parse_csv( $csv, $delimiter = ',' ) { $csv = preg_replace( '/^\x{FEFF}/u', '', $csv ); // An in-memory stream, not a file: fgetcsv() is the only parser in PHP // that handles quoted newlines, and it reads from a handle. $handle = fopen( 'php://temp', 'r+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( ! $handle ) { return new WP_Error( 'steadypost_csv', __( 'Could not open a temporary stream to parse the CSV.', 'steadypost' ) ); } fwrite( $handle, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite rewind( $handle ); $table = array(); while ( false !== ( $row = fgetcsv( $handle, 0, $delimiter, '"', '' ) ) ) { if ( array( null ) === $row ) { $table[] = array(); continue; } $table[] = $row; } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $table; } /** * GET a Google URL, signed as the service account when one is configured. * * @param string $url * @param array $args 'stream' => path to write the body to instead of * returning it (the XLSX workbook is too big to hold in * memory twice), 'anonymous' => true to skip auth. * @return string|true|WP_Error Body, or true when streamed to a file. */ public static function http_get( $url, $args = array() ) { $request = array( 'timeout' => 60, 'redirection' => 5, 'user-agent' => 'SteadyPost/' . STEADYPOST_VERSION . '; ' . home_url( '/' ), ); if ( empty( $args['anonymous'] ) ) { $headers = self::auth_headers(); if ( is_wp_error( $headers ) ) { return $headers; } if ( $headers ) { $request['headers'] = $headers; } } if ( ! empty( $args['stream'] ) ) { $request['stream'] = true; $request['filename'] = $args['stream']; $request['timeout'] = 120; } $response = wp_remote_get( $url, $request ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); // A streamed response wrote its body to disk, so read the failure back // off the file — otherwise every error would be a bare status code. if ( 200 !== $code && ! empty( $args['stream'] ) && file_exists( $args['stream'] ) ) { $body = (string) file_get_contents( $args['stream'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local temp file we just wrote, not a URL. } if ( 200 !== $code ) { $detail = ''; $json = json_decode( $body, true ); if ( isset( $json['error']['message'] ) ) { $detail = ' ' . $json['error']['message']; } elseif ( isset( $json['error_description'] ) ) { $detail = ' ' . $json['error_description']; } if ( in_array( $code, array( 401, 403, 404 ), true ) ) { $detail .= ' ' . self::sharing_hint(); } return new WP_Error( 'steadypost_http', sprintf( /* translators: 1: HTTP status code, 2: error detail from Google */ __( 'Google returned HTTP %1$d.%2$s', 'steadypost' ), $code, $detail ) ); } return ! empty( $args['stream'] ) ? true : $body; } /** Bearer header for the service account, or an empty array when none is set. */ protected static function auth_headers() { if ( ! SteadyPost_Google_Auth::configured() ) { return array(); } $token = SteadyPost_Google_Auth::token(); if ( is_wp_error( $token ) ) { return $token; } return array( 'Authorization' => 'Bearer ' . $token ); } /** * What the admin should check when Google refuses a file. The answer differs * entirely depending on whether the site is authenticating or not. */ public static function sharing_hint() { $email = SteadyPost_Google_Auth::account_email(); if ( '' !== $email ) { return sprintf( /* translators: %s: the service account's email address */ __( 'The service account can see the credential but not this file — open it in Google, click Share, and add %s as a Viewer.', 'steadypost' ), $email ); } return __( 'Open the file in Google, click Share, and set General access to "Anyone with the link" (Viewer) — or connect a Google service account in SteadyPost Settings to read privately shared files.', 'steadypost' ); } public static function looks_like_login_page( $body ) { if ( false !== strpos( $body, 'accounts.google.com/ServiceLogin' ) ) { return true; } return ( false !== stripos( $body, 'Sign in' ) ) && ( false === strpos( $body, 'doc-content' ) ); } }