trailingslashit( $up['basedir'] ) . UPLOAD_DIR, 'url' => trailingslashit( $up['baseurl'] ) . UPLOAD_DIR, ); } /** * Move caption files saved by Pro 1.0.0–1.0.1 (uploads/captionwright-pro/) into * the shared folder. A newer file already in the shared folder wins. The old * folder is removed only when no caption file is left in it. */ function move_legacy_files() { if ( get_option( 'captionwright_folder_checked' ) ) { return; } $fs = fs(); if ( ! $fs ) { return; } $up = wp_upload_dir( null, false ); $legacy = trailingslashit( $up['basedir'] ) . LEGACY_DIR; if ( $fs->is_dir( $legacy ) ) { $st = storage(); if ( ! $fs->is_dir( $st['dir'] ) && ! wp_mkdir_p( $st['dir'] ) ) { return; } foreach ( array_keys( (array) $fs->dirlist( $legacy ) ) as $name ) { if ( ! preg_match( '/^\d+-[a-z0-9-]+\.vtt$/', $name ) ) { continue; } $old = $legacy . '/' . $name; $new = $st['dir'] . '/' . $name; if ( $fs->exists( $new ) && $fs->mtime( $new ) >= $fs->mtime( $old ) ) { $fs->delete( $old ); } else { $fs->move( $old, $new, true ); } } if ( ! $fs->exists( $st['dir'] . '/.htaccess' ) ) { $fs->put_contents( $st['dir'] . '/.htaccess', "AddType text/vtt .vtt\nAddCharset utf-8 .vtt\n", FS_CHMOD_FILE ); } $left = array_filter( array_keys( (array) $fs->dirlist( $legacy ) ), fn( $n ) => '.htaccess' !== $n ); if ( $left ) { return; // Something could not be moved: keep the folder and try again next time. } $fs->rmdir( $legacy, true ); } update_option( 'captionwright_folder_checked', 1, true ); } add_action( 'init', __NAMESPACE__ . '\move_legacy_files' ); /** * Whether a language code looks valid (en, sv, pt-br …). * * @param string $lang Code. * @return bool */ function valid_lang( $lang ) { return (bool) preg_match( '/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/', (string) $lang ); } /** * Tracks saved for an attachment, keyed by language. * * @param int $id Attachment ID. * @return array */ function get_tracks( $id ) { $meta = get_post_meta( (int) $id, META, true ); if ( ! is_array( $meta ) ) { return array(); } $base = storage(); $out = array(); foreach ( $meta as $lang => $t ) { if ( ! valid_lang( $lang ) || ! is_array( $t ) ) { continue; } $updated = isset( $t['updated'] ) ? (int) $t['updated'] : 0; $out[ $lang ] = array( 'label' => isset( $t['label'] ) ? (string) $t['label'] : $lang, 'updated' => $updated, 'cues' => isset( $t['cues'] ) ? (int) $t['cues'] : 0, 'url' => add_query_arg( 'ver', $updated, $base['url'] . '/' . (int) $id . '-' . $lang . '.vtt' ), ); } return $out; } /** * Normalise and check WebVTT text from the browser. * * @param string $vtt Raw text. * @return string|\WP_Error */ function clean_vtt( $vtt ) { $vtt = str_replace( array( "\r\n", "\r" ), "\n", (string) $vtt ); $vtt = preg_replace( '/^\xEF\xBB\xBF/', '', $vtt ); if ( strlen( $vtt ) > 2 * MB_IN_BYTES ) { return new \WP_Error( 'captionwright_too_big', __( 'The caption file is larger than 2 MB.', 'captionwright' ), array( 'status' => 400 ) ); } if ( 0 !== strpos( ltrim( $vtt ), 'WEBVTT' ) ) { return new \WP_Error( 'captionwright_not_vtt', __( 'Captions must be in WebVTT format and start with "WEBVTT".', 'captionwright' ), array( 'status' => 400 ) ); } if ( ! parse_vtt( $vtt ) ) { return new \WP_Error( 'captionwright_empty', __( 'The caption file has no cues.', 'captionwright' ), array( 'status' => 400 ) ); } return trim( $vtt ) . "\n"; } /** * Save a track for an attachment. * * @param int $id Attachment ID. * @param string $lang Language code. * @param string $vtt WebVTT text (already cleaned). * @param string $label Human label shown in the player. * @return true|\WP_Error */ function save_track( $id, $lang, $vtt, $label ) { $fs = fs(); $st = storage(); if ( ! $fs ) { return new \WP_Error( 'captionwright_fs', __( 'Could not access the uploads folder.', 'captionwright' ), array( 'status' => 500 ) ); } if ( ! $fs->is_dir( $st['dir'] ) && ! wp_mkdir_p( $st['dir'] ) ) { return new \WP_Error( 'captionwright_mkdir', __( 'Could not create the captions folder in uploads.', 'captionwright' ), array( 'status' => 500 ) ); } // Some browsers want text/vtt; many hosts serve unknown types as octet-stream. if ( ! $fs->exists( $st['dir'] . '/.htaccess' ) ) { $fs->put_contents( $st['dir'] . '/.htaccess', "AddType text/vtt .vtt\nAddCharset utf-8 .vtt\n", FS_CHMOD_FILE ); } $file = $st['dir'] . '/' . (int) $id . '-' . $lang . '.vtt'; if ( ! $fs->put_contents( $file, $vtt, FS_CHMOD_FILE ) ) { return new \WP_Error( 'captionwright_write', __( 'Could not write the caption file.', 'captionwright' ), array( 'status' => 500 ) ); } $meta = get_post_meta( (int) $id, META, true ); $meta = is_array( $meta ) ? $meta : array(); $meta[ $lang ] = array( 'label' => $label, 'updated' => time(), 'cues' => count( parse_vtt( $vtt ) ), ); update_post_meta( (int) $id, META, $meta ); return true; } /** * Delete one track (or all tracks when $lang is null). * * @param int $id Attachment ID. * @param string|null $lang Language code. */ function delete_track( $id, $lang = null ) { $fs = fs(); $st = storage(); $meta = get_post_meta( (int) $id, META, true ); $meta = is_array( $meta ) ? $meta : array(); $gone = null === $lang ? array_keys( $meta ) : array( $lang ); foreach ( $gone as $l ) { if ( $fs && valid_lang( $l ) ) { $fs->delete( $st['dir'] . '/' . (int) $id . '-' . $l . '.vtt' ); } unset( $meta[ $l ] ); } if ( $meta ) { update_post_meta( (int) $id, META, $meta ); } else { delete_post_meta( (int) $id, META ); } } add_action( 'delete_attachment', function ( $id ) { delete_track( $id ); } ); /** * Read a saved track's WebVTT text. * * @param int $id Attachment ID. * @param string $lang Language code. * @return string */ function read_track( $id, $lang ) { $fs = fs(); if ( ! $fs || ! valid_lang( $lang ) ) { return ''; } $file = storage()['dir'] . '/' . (int) $id . '-' . $lang . '.vtt'; return $fs->exists( $file ) ? (string) $fs->get_contents( $file ) : ''; } /** * Turn "01:02:03.456" or "02:03.456" into seconds. * * @param string $t Timestamp. * @return float */ function vtt_seconds( $t ) { $parts = array_reverse( explode( ':', trim( str_replace( ',', '.', $t ) ) ) ); $secs = 0.0; foreach ( $parts as $i => $p ) { $secs += (float) $p * pow( 60, $i ); } return $secs; } /** * Parse WebVTT into cues. * * @param string $vtt WebVTT text. * @return array */ function parse_vtt( $vtt ) { $cues = array(); $blocks = preg_split( "/\n\s*\n/", str_replace( array( "\r\n", "\r" ), "\n", (string) $vtt ) ); foreach ( $blocks as $block ) { $lines = explode( "\n", trim( $block ) ); foreach ( $lines as $i => $line ) { if ( ! preg_match( '/^\s*([\d:.,]+)\s+-->\s+([\d:.,]+)/', $line, $m ) ) { continue; } $text = trim( wp_strip_all_tags( implode( ' ', array_slice( $lines, $i + 1 ) ) ) ); if ( '' !== $text ) { $cues[] = array( 'start' => vtt_seconds( $m[1] ), 'end' => vtt_seconds( $m[2] ), 'text' => $text, ); } break; } } return $cues; }