From 802a6f069fdc9d2399620a85aa03904fc6329e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 2 Jan 2025 02:49:58 +0100 Subject: [PATCH] Preserve images as blocks --- .../src/WP_Blocks_To_Markdown.php | 9 +- .../src/WP_Markdown_To_Blocks.php | 23 ++- .../plugin.php | 131 +++++++++++------- .../src/components/FilePickerTree/index.tsx | 43 +++--- .../ui/src/index.tsx | 41 +++++- .../WP_Filesystem_Entity_Reader.php | 8 ++ .../src/import/WP_Entity_Importer.php | 11 +- 7 files changed, 177 insertions(+), 89 deletions(-) diff --git a/packages/playground/data-liberation-markdown/src/WP_Blocks_To_Markdown.php b/packages/playground/data-liberation-markdown/src/WP_Blocks_To_Markdown.php index 276ba1f754..634a9498d4 100644 --- a/packages/playground/data-liberation-markdown/src/WP_Blocks_To_Markdown.php +++ b/packages/playground/data-liberation-markdown/src/WP_Blocks_To_Markdown.php @@ -82,7 +82,14 @@ private function block_to_markdown($block) { $attributes['alt'] = $processor->get_attribute('alt'); } } - return "![" . ($attributes['alt'] ?? '') . "](" . ($attributes['url'] ?? '') . ")\n\n"; + $escaped_url = $attributes['url'] ?? ''; + // @TODO: Figure out the correct markdown escaping for these things + $escaped_url = str_replace(' ', '%20', $escaped_url); + $escaped_url = str_replace(')', '%29', $escaped_url); + + $escaped_alt = $attributes['alt'] ?? ''; + $escaped_alt = str_replace(['[', ']'], '', $escaped_alt); + return "![" . $escaped_alt . "](" . $escaped_url . ")\n\n"; case 'core/heading': $level = $attributes['level'] ?? null; diff --git a/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php b/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php index 29efe4cca4..4aae88545d 100644 --- a/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php +++ b/packages/playground/data-liberation-markdown/src/WP_Markdown_To_Blocks.php @@ -221,7 +221,7 @@ private function convert_markdown_to_blocks() { $html = new WP_HTML_Tag_Processor( '' ); $html->next_tag(); if ( $node->getUrl() ) { - $html->set_attribute( 'src', $node->getUrl() ); + $html->set_attribute( 'src', urldecode($node->getUrl()) ); } if ( $node->getTitle() ) { $html->set_attribute( 'title', $node->getTitle() ); @@ -235,7 +235,26 @@ private function convert_markdown_to_blocks() { $children[0]->setLiteral( '' ); } - $this->append_content( $html->get_updated_html() ); + $image_tag = $html->get_updated_html(); + // @TODO: Decide between inline image and the image block + $in_paragraph = $this->current_block()->block_name === 'paragraph'; + if ( $in_paragraph ) { + $this->append_content( '

' ); + $this->pop_block(); + } + // @TODO: Find a way to plug in the attachment ID here + $image_block = << +
+ $image_tag +
+ +BLOCK; + $this->append_content( $image_block ); + if ( $in_paragraph ) { + $this->push_block('paragraph'); + $this->append_content( '

' ); + } break; case ExtensionInline\Link::class: diff --git a/packages/playground/data-liberation-static-files-editor/plugin.php b/packages/playground/data-liberation-static-files-editor/plugin.php index d6f318c82a..e7d4bbfbad 100644 --- a/packages/playground/data-liberation-static-files-editor/plugin.php +++ b/packages/playground/data-liberation-static-files-editor/plugin.php @@ -3,8 +3,6 @@ * Plugin Name: Data Liberation – WordPress Static files editor * * @TODO: Page metadata editor in Gutenberg - * @TODO: A special "filename" field in wp-admin and in Gutenberg. Either source from the page title or - * pin it to a specific, user-defined value. * @TODO: Choose the local file storage format (MD, HTML, etc.) in Gutenberg page options. * @TODO: HTML, XHTML, and Blocks renderers * @TODO: Integrity check – is the database still in sync with the files? @@ -12,11 +10,6 @@ * * Overwrite the database with the local files? This is a local files editor after all. * * Display a warning in wp-admin and let the user decide what to do? * @TODO: Consider tricky scenarios – moving a parent to trash and then restoring it. - * @TODO: Consider using hierarchical taxonomy to model the directory/file structure – instead of - * using the post_parent field. Could be more flexible (no need for index.md files) and require - * less complex operations in the code (no need to update a subtree of posts when moving a post, - * no need to periodically "flatten" the parent directory). - * @TODO: Maybe use Playground's FilePickerTree React component? Or re-implement it with interactivity API? */ use WordPress\Filesystem\WP_Local_Filesystem; @@ -143,6 +136,7 @@ static public function initialize() { // Register hooks register_activation_hook( __FILE__, array(self::class, 'import_static_pages') ); + add_action('init', function() { self::get_fs(); self::register_post_type(); @@ -254,7 +248,11 @@ function() { 'methods' => 'GET', 'callback' => array(self::class, 'download_file_endpoint'), 'permission_callback' => function() { - return current_user_can('edit_posts'); + // @TODO: Restrict access to this endpoint to editors, but + // don't require a nonce. Nonces are troublesome for + // static assets that don't have a dynamic URL. + // return current_user_can('edit_posts'); + return true; }, 'args' => array( 'path' => array( @@ -478,9 +476,10 @@ static private function refresh_post_from_local_file($post) { $converter = new WP_HTML_To_Blocks( WP_HTML_Processor::create_fragment( $content ) ); break; case 'md': - default: $converter = new WP_Markdown_To_Blocks( $content ); break; + default: + return false; } $converter->convert(); @@ -489,6 +488,7 @@ static private function refresh_post_from_local_file($post) { $metadata[$key] = $value[0]; } $new_content = $converter->get_block_markup(); + $new_content = self::wordpressify_static_assets_urls($new_content); $updated = wp_update_post(array( 'ID' => $post_id, @@ -527,9 +527,10 @@ static private function convert_post_to_string($path, $post) { case 'html': case 'xhtml': case 'md': - default: $converter = new WP_Blocks_To_Markdown( $content, $metadata ); break; + default: + return ''; } $converter->convert(); return $converter->get_result(); @@ -577,6 +578,35 @@ static private function unwordpressify_static_assets_urls($content) { return $p->get_updated_html(); } + /** + * Convert references to files served via path to the + * corresponding download_file_endpoint references. + */ + static private function wordpressify_static_assets_urls($content) { + $site_url = WP_URL::parse(get_site_url()); + $expected_endpoint_path = '/wp-json/static-files-editor/v1/download-file'; + $p = WP_Block_Markup_Url_Processor::create_from_html($content, $site_url); + while($p->next_url()) { + $url = $p->get_parsed_url(); + if(!is_child_url_of($url, get_site_url())) { + continue; + } + + // @TODO: Also work with tags, account + // for .md and directory links etc. + if($p->get_tag() !== 'IMG') { + continue; + } + + $new_url = WP_URL::parse($url->pathname, $site_url); + $new_url->pathname = $expected_endpoint_path; + $new_url->searchParams->set('path', $p->get_raw_url()); + $p->set_raw_url($new_url->__toString()); + } + + return $p->get_updated_html(); + } + static public function get_local_files_tree($subdirectory = '') { $tree = []; $fs = self::get_fs(); @@ -589,21 +619,33 @@ static public function get_local_files_tree($subdirectory = '') { 'fields' => 'id=>meta' )); - $path_to_post_id = array(); + $path_to_post = array(); foreach($file_posts as $post) { $file_path = get_post_meta($post->ID, 'local_file_path', true); if ($file_path) { - $path_to_post_id[$file_path] = $post->ID; + $path_to_post[$file_path] = $post; + } + } + + $attachments = get_posts(array( + 'post_type' => 'attachment', + 'posts_per_page' => -1, + 'meta_key' => 'local_file_path', + )); + foreach($attachments as $attachment) { + $attachment_path = get_post_meta($attachment->ID, 'local_file_path', true); + if ($attachment_path) { + $path_to_post[$attachment_path] = $attachment; } } $base_dir = $subdirectory ? $subdirectory : '/'; - self::build_local_file_tree_recursive($fs, $base_dir, $tree, $path_to_post_id); + self::build_local_file_tree_recursive($fs, $base_dir, $tree, $path_to_post); return $tree; } - static private function build_local_file_tree_recursive($fs, $dir, &$tree, $path_to_post_id) { + static private function build_local_file_tree_recursive($fs, $dir, &$tree, $path_to_post) { $items = $fs->ls($dir); if ($items === false) { return; @@ -633,15 +675,16 @@ static private function build_local_file_tree_recursive($fs, $dir, &$tree, $path // Recursively build children $last_index = count($tree) - 1; - self::build_local_file_tree_recursive($fs, $path, $tree[$last_index]['children'], $path_to_post_id); + self::build_local_file_tree_recursive($fs, $path, $tree[$last_index]['children'], $path_to_post); } else { $node = array( 'type' => 'file', 'name' => $item, ); - if (isset($path_to_post_id[$path])) { - $node['post_id'] = $path_to_post_id[$path]; + if (isset($path_to_post[$path])) { + $node['post_id'] = $path_to_post[$path]->ID; + $node['post_type'] = $path_to_post[$path]->post_type; } $tree[] = $node; @@ -655,10 +698,6 @@ static private function build_local_file_tree_recursive($fs, $dir, &$tree, $path * @TODO: Error handling */ static public function import_static_pages() { - if ( ! is_dir( WP_STATIC_PAGES_DIR ) ) { - return; - } - if ( defined('WP_IMPORTING') && WP_IMPORTING ) { return; } @@ -671,20 +710,26 @@ static public function import_static_pages() { // Prevent ID conflicts self::reset_db_data(); - self::do_import_static_pages(); + return self::do_import_static_pages(); } static private function do_import_static_pages($options = array()) { + $fs = $options['filesystem'] ?? self::get_fs(); $importer = WP_Stream_Importer::create( - function () use ($options) { + function () use ($fs, $options) { return new WP_Filesystem_Entity_Reader( - self::get_fs(), + $fs, array( 'post_type' => WP_LOCAL_FILE_POST_TYPE, 'post_tree_options' => $options['post_tree_options'] ?? array(), ) ); - } + }, + array( + 'attachment_downloader_options' => array( + 'source_from_filesystem' => $fs, + ), + ) ); $import_session = WP_Import_Session::create( @@ -693,7 +738,7 @@ function () use ($options) { ) ); - data_liberation_import_step( $import_session, $importer ); + return data_liberation_import_step( $import_session, $importer ); } static private function register_post_type() { @@ -910,33 +955,13 @@ static public function create_files_endpoint($request) { } } - $importer = WP_Stream_Importer::create( - function () use ($parent_id, $uploaded_fs) { - return new WP_Filesystem_Entity_Reader( - $uploaded_fs, - array( - 'post_type' => WP_LOCAL_FILE_POST_TYPE, - 'post_tree_options' => array( - 'root_parent_id' => $parent_id, - 'create_index_pages' => false, - ), - ) - ); - }, - array( - 'attachment_downloader_options' => array( - 'source_from_filesystem' => $uploaded_fs, - ), - ) - ); - - $import_session = WP_Import_Session::create( - array ( - 'data_source' => 'static_pages' - ) - ); - - $result = data_liberation_import_step( $import_session, $importer ); + $result = self::do_import_static_pages(array( + 'filesystem' => $uploaded_fs, + 'post_tree_options' => array( + 'root_parent_id' => $parent_id, + 'create_index_pages' => false, + ), + )); if(is_wp_error($result)) { return $result; } diff --git a/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx b/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx index dbde7e7f65..35a3f3ff3a 100644 --- a/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx +++ b/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx @@ -97,17 +97,9 @@ type FilePickerContextType = { onEditedNodeCancel: () => void; onNodeDeleted: (path: string) => void; startRenaming: (path: string) => void; - onDragStart: ( - e: React.DragEvent, - path: string, - type: 'file' | 'folder' - ) => void; - onDragOver: ( - e: React.DragEvent, - path: string, - type: 'file' | 'folder' - ) => void; - onDrop: (e: React.DragEvent, path: string, type: 'file' | 'folder') => void; + onDragStart: (e: React.DragEvent, path: string, node: FileNode) => void; + onDragOver: (e: React.DragEvent, path: string, node: FileNode) => void; + onDrop: (e: React.DragEvent, path: string, node: FileNode) => void; onDragEnd: () => void; dragState: DragState | null; }; @@ -301,6 +293,7 @@ export const FilePickerTree: React.FC = ({ path, hoverPath: null, hoverType: null, + isExternal: false, }); onDragStart?.(e, path, type); @@ -313,7 +306,7 @@ export const FilePickerTree: React.FC = ({ const handleDragOver = ( e: React.DragEvent, path: string, - type: 'file' | 'folder' + node: FileNode ) => { e.preventDefault(); @@ -324,7 +317,7 @@ export const FilePickerTree: React.FC = ({ path: '', isExternal: true, hoverPath: path, - hoverType: type, + hoverType: node.type, }); return; } @@ -340,7 +333,7 @@ export const FilePickerTree: React.FC = ({ setDragState({ ...dragState, hoverPath: path, - hoverType: type, + hoverType: node.type, }); } }; @@ -348,7 +341,7 @@ export const FilePickerTree: React.FC = ({ const handleDrop = async ( e: React.DragEvent, targetPath: string, - targetType: 'file' | 'folder' + targetNode: FileNode ) => { e.preventDefault(); // Prevent a parent element event handler from handling the drop @@ -363,7 +356,7 @@ export const FilePickerTree: React.FC = ({ // Prevent dropping a folder into its own descendant if ( dragState.path && - targetType === 'folder' && + targetNode.type === 'folder' && isDescendantPath(dragState.path, targetPath) ) { return; @@ -372,7 +365,7 @@ export const FilePickerTree: React.FC = ({ const fromPath = dragState.path.replace(/^\/+/, ''); const targetParentPath = - targetType === 'file' + targetNode.type === 'file' ? targetPath.split('/').slice(0, -1).join('/') : targetPath; @@ -396,7 +389,7 @@ export const FilePickerTree: React.FC = ({ // Drag&Drop from desktop into the FilePickerTree if (e.dataTransfer.items.length > 0) { const targetFolder = - targetType === 'folder' + targetNode.type === 'folder' ? targetPath : targetPath.split('/').slice(0, -1).join('/'); const items = Array.from(e.dataTransfer.items); @@ -571,7 +564,11 @@ export const FilePickerTree: React.FC = ({ ref={thisContainerRef} className={className} onDrop={(e) => { - handleDrop?.(e, '/', 'folder'); + handleDrop?.(e, '/', { + name: '', + type: 'folder', + children: [], + }); }} >

- onDragStart?.(e, path, node.type) + onDragStart?.(e, path, node) } onDragOver={(e) => - onDragOver?.(e, path, node.type) - } - onDrop={(e) => - onDrop?.(e, path, node.type) + onDragOver?.(e, path, node) } + onDrop={(e) => onDrop?.(e, path, node)} onDragEnd={onDragEnd} style={{ opacity: isBeingDragged ? 0.5 : 1, diff --git a/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx b/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx index f15503daaa..6ebb292a45 100644 --- a/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx +++ b/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx @@ -269,10 +269,10 @@ function ConnectedFilePickerTree() { const handleDragStart = ( e: React.DragEvent, path: string, - type: 'file' | 'folder' + node: FileNode ) => { // Directory downloads are not supported yet. - if (type === 'file') { + if (node.type === 'file') { const url = `${window.wpApiSettings.root}static-files-editor/v1/download-file?path=${path}&_wpnonce=${window.wpApiSettings.nonce}`; const filename = path.split('/').pop(); // For dragging & dropping to desktop @@ -280,11 +280,38 @@ function ConnectedFilePickerTree() { 'DownloadURL', `text/plain:${filename}:${url}` ); - // For dragging & dropping into the editor canvas - e.dataTransfer.setData( - 'text/html', - `${filename}` - ); + if ('post_type' in node && node.post_type === 'attachment') { + // Create DOM elements to safely construct HTML + + const figure = document.createElement('figure'); + figure.className = 'wp-block-image size-full'; + + const img = document.createElement('img'); + img.src = url; + img.alt = ''; + img.className = `wp-image-${node.post_id}`; + + figure.appendChild(img); + + // Wrap in WordPress block comments + // For dragging & dropping into the editor canvas + e.dataTransfer.setData( + 'text/html', + `', + '' + )},"sizeSlug":"full","linkDestination":"none"} --> +${figure.outerHTML} +` + ); + } else if (isStaticAssetPath(path)) { + const img = document.createElement('img'); + img.src = url; + img.alt = filename; + e.dataTransfer.setData('text/html', img.outerHTML); + } } }; diff --git a/packages/playground/data-liberation/src/entity-readers/WP_Filesystem_Entity_Reader.php b/packages/playground/data-liberation/src/entity-readers/WP_Filesystem_Entity_Reader.php index 4fe8860e16..50c10799c0 100644 --- a/packages/playground/data-liberation/src/entity-readers/WP_Filesystem_Entity_Reader.php +++ b/packages/playground/data-liberation/src/entity-readers/WP_Filesystem_Entity_Reader.php @@ -97,6 +97,14 @@ public function next_entity(): bool { 'attachment_url' => 'file://' . $post_tree_node['local_file_path'], ) ); + $this->entities[] = new WP_Imported_Entity( + 'post_meta', + array( + 'post_id' => $post_tree_node['post_id'], + 'key' => 'local_file_path', + 'value' => $post_tree_node['local_file_path'], + ) + ); // We're done emiting the entity. // wp_generate_attachment_metadata() et al. will be called by the // importer at the database insertion step. diff --git a/packages/playground/data-liberation/src/import/WP_Entity_Importer.php b/packages/playground/data-liberation/src/import/WP_Entity_Importer.php index 23fd1f1756..649b777323 100644 --- a/packages/playground/data-liberation/src/import/WP_Entity_Importer.php +++ b/packages/playground/data-liberation/src/import/WP_Entity_Importer.php @@ -451,6 +451,8 @@ public function import_post( $data ) { return false; } + $meta = array(); + $original_id = isset( $data['post_id'] ) ? (int) $data['post_id'] : 0; $parent_id = isset( $data['post_parent'] ) ? (int) $data['post_parent'] : 0; @@ -548,6 +550,12 @@ public function import_post( $data ) { $postdata[ $key ] = $data[ $key ]; } + if(!isset($postdata['post_date'])) { + $postdata['post_date'] = date('Y-m-d H:i:s'); + } + if(!isset($postdata['post_date_gmt'])) { + $postdata['post_date_gmt'] = date('Y-m-d H:i:s'); + } $postdata = apply_filters( 'wp_import_post_data_processed', $postdata, $data ); @@ -782,7 +790,6 @@ protected function process_attachment( $post, $meta ) { * @TODO: Explore other interfaces for attachment import. */ public function import_attachment( $filepath, $post_id ) { - $filename = basename( $filepath ); // Check if attachment with this guid already exists $existing_attachment = get_posts( @@ -863,7 +870,7 @@ public function import_post_meta( $meta_item, $post_id ) { $value = maybe_unserialize( $meta_item['value'] ); } - add_post_meta( $post_id, $key, $value ); + update_post_meta( $post_id, $key, $value ); do_action( 'import_post_meta', $post_id, $key, $value ); // if the post has a featured image, take note of this in case of remap