Skip to content

Commit

Permalink
OPML Import: Support OPML without nesting (#403)
Browse files Browse the repository at this point in the history
  • Loading branch information
akirk authored Nov 29, 2024
1 parent 975a86c commit 5cd4454
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 43 deletions.
118 changes: 78 additions & 40 deletions includes/class-import.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,57 +18,95 @@
* @author Alex Kirk
*/
class Import {
public static function opml( $opml ) {
$opml = simplexml_load_string( $opml );
if ( ! $opml ) {
return new \WP_Error( 'friends_import_opml_error', __( 'Failed to parse OPML.', 'friends' ) );

private static function get_feed_from_opml_node( $friend ) {
$xml_url = (string) $friend['xmlUrl'];
if ( ! $xml_url ) {
return null;
}

$feeds = array();
foreach ( $opml->body->outline as $outline ) {
$role = (string) $outline['text'];
foreach ( $outline->outline as $friend ) {
$user_login = str_replace( ' ', '-', strtolower( sanitize_user( (string) $friend['text'] ) ) );
$feed = User_Feed::get_by_url( (string) $friend['xmlUrl'] );
if ( $feed instanceof User_Feed ) {
$friend_user = $feed->get_friend_user();
if ( ! isset( $feeds[ $friend_user->user_login ] ) ) {
$feeds[ $friend_user->user_login ] = array();
}
$feeds[ $friend_user->user_login ][] = $feed;
continue;
}
$username = (string) $friend['text'];
if ( ! $username ) {
$username = (string) $friend['title'];
}
if ( ! $username ) {
$username = (string) $friend['htmlUrl'];
$username = preg_replace( '/^https?:\/\//', '', $username );
}
if ( ! $username ) {
$username = (string) $xml_url;
$username = preg_replace( '/^https?:\/\//', '', $username );
}

$friend_user = User::get_by_username( $user_login );
if ( ! $friend_user || is_wp_error( $friend_user ) ) {
$friend_user = Subscription::create(
$user_login,
'subscription',
(string) $friend['htmlUrl'],
(string) $friend['text']
);
}
if ( ! $friend_user instanceof User ) {
continue;
}
$feed = $friend_user->save_feed(
(string) $friend['xmlUrl'],
array(
'active' => true,
'mime-type' => 'atom' === (string) $friend['type'] ? 'application/atom+xml' : 'application/rss+xml',
)
);
if ( ! $feed instanceof User_Feed ) {
continue;
}
$user_login = User::sanitize_username( $username );
$feed = User_Feed::get_by_url( $xml_url );
if ( $feed instanceof User_Feed ) {
return $feed;
}

$friend_user = User::get_by_username( $user_login );
if ( ! $friend_user || is_wp_error( $friend_user ) ) {
$friend_user = Subscription::create(
$user_login,
'subscription',
(string) $friend['htmlUrl'],
(string) $friend['text']
);
}

if ( ! $friend_user instanceof User ) {
return null;
}

$feed = $friend_user->save_feed(
$xml_url,
array(
'active' => true,
'mime-type' => 'atom' === (string) $friend['type'] ? 'application/atom+xml' : 'application/rss+xml',
)
);

if ( ! $feed instanceof User_Feed ) {
if ( is_wp_error( $feed ) && apply_filters( 'friends_debug', false ) ) {
wp_trigger_error( __FUNCTION__, $feed->get_error_message() );

}
return null;
}

return $feed;
}

private static function recurse_into_opml( $feeds, $friend ) {
$xml_url = (string) $friend['xmlUrl'];
if ( $xml_url ) {
$feed = self::get_feed_from_opml_node( $friend );
if ( $feed instanceof User_Feed ) {
$friend_user = $feed->get_friend_user();

if ( ! isset( $feeds[ $friend_user->user_login ] ) ) {
$feeds[ $friend_user->user_login ] = array();
}
$feeds[ $friend_user->user_login ][] = $feed;
}
return $feeds;
}

foreach ( $friend->outline as $child ) {
$feeds = self::recurse_into_opml( $feeds, $child );
}

return $feeds;
}

public static function opml( $opml ) {
$opml = simplexml_load_string( $opml );
if ( ! $opml ) {
return new \WP_Error( 'friends_import_opml_error', __( 'Failed to parse OPML.', 'friends' ) );
}

$feeds = self::recurse_into_opml( array(), $opml->body );

return $feeds;
}
}
8 changes: 5 additions & 3 deletions includes/class-user.php
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ public function save_feeds( $feeds = array() ) {
$errors = new \WP_Error();
foreach ( $feeds as $feed_url => $options ) {
if ( ! is_string( $feed_url ) || ! Friends::check_url( $feed_url ) ) {
$errors->add( 'invalid-url', 'An invalid URL was provided' );
$errors->add( 'invalid-url', 'An invalid URL was provided', $feed_url );
unset( $feeds[ $feed_url ] );
continue;
}
Expand All @@ -406,7 +406,8 @@ public function save_feeds( $feeds = array() ) {

$all_urls = array();
foreach ( wp_get_object_terms( $this->get_object_id(), User_Feed::TAXONOMY ) as $term ) {
$all_urls[ $term->name ] = $term->term_id;
$url = str_replace( '&', '&', $term->name );
$all_urls[ $url ] = $term->term_id;
}

$user_feeds = wp_set_object_terms( $this->get_object_id(), array_keys( array_merge( $all_urls, $feeds ) ), User_Feed::TAXONOMY );
Expand All @@ -415,7 +416,8 @@ public function save_feeds( $feeds = array() ) {
}

foreach ( wp_get_object_terms( $this->get_object_id(), User_Feed::TAXONOMY ) as $term ) {
$all_urls[ $term->name ] = $term->term_id;
$url = str_replace( '&', '&', $term->name );
$all_urls[ $url ] = $term->term_id;
}

foreach ( $feeds as $url => $feed_options ) {
Expand Down
44 changes: 44 additions & 0 deletions tests/data/feedland.opml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>bph/wordpress</title>
<description>bph's subscription list, wordpress category. List created by feedlandDatabase v0.7.55.</description>
<dateModified>Thu, 28 Nov 2024 16:14:43 GMT</dateModified>
</head>
<body>
<outline type="rss" text="WP Mayor" xmlUrl="https://wpmayor.com/feed/" htmlUrl="https://wpmayor.com/" category="wordpress"/>
<outline type="rss" text="Block themes — WordPress.org" xmlUrl="https://wordpress.org/themes/tags/full-site-editing/feed/" htmlUrl="https://wordpress.org/themes" category="wordpress"/>
<outline type="rss" text="All themes — WordPress.org" xmlUrl="https://wordpress.org/themes/feed/" htmlUrl="https://wordpress.org/themes" category="wordpress"/>
<outline type="rss" text="WP BizDev" xmlUrl="https://wpbiz.dev/feed/" htmlUrl="https://wpbiz.dev/" category="wordpress"/>
<outline type="rss" text="WordPress Guides, Tutorials &amp; Tips - Kinsta® Blog" xmlUrl="https://kinsta.com/blog/feed/" htmlUrl="https://kinsta.com/blog/" category="wordpress"/>
<outline type="rss" text="WebTNG" xmlUrl="https://www.webtng.com/feed/" htmlUrl="https://www.webtng.com/" category="wordpress"/>
<outline type="rss" text="Automattic Design" xmlUrl="https://automattic.design/feed/" htmlUrl="https://automattic.design/" category="wordpress"/>
<outline type="rss" text="WP-CONTENT.CO" xmlUrl="https://wp-content.co/feed" htmlUrl="https://wp-content.co/" category="wordpress"/>
<outline type="rss" text="Brian Coords" xmlUrl="https://www.briancoords.com/feed" htmlUrl="https://www.briancoords.com/" category="wordpress"/>
<outline type="rss" text="WP Plugins RSS" xmlUrl="https://wp-plugins-rss.edavis.workers.dev/feed/" htmlUrl="https://wordpress.org/plugins/browse/new/" category="wordpress"/>
<outline type="rss" text="editortips" xmlUrl="https://editortips.com/feed/" htmlUrl="https://editortips.com/" category="wordpress"/>
<outline type="rss" text="The WooCommerce Developer Blog" xmlUrl="https://developer.woo.com/feed/" htmlUrl="https://developer.woocommerce.com/" category="wordpress"/>
<outline type="rss" text="DevotePress" xmlUrl="https://devotepress.com/feed/" htmlUrl="https://devotepress.com/" category="wordpress"/>
<outline type="rss" text="Rich Tabor" xmlUrl="https://richtabor.com/feed/" htmlUrl="https://rich.blog/" category="wordpress"/>
<outline type="rss" text="Post Status" xmlUrl="https://poststatus.com/feed/" htmlUrl="https://poststatus.com/" category="wordpress"/>
<outline type="rss" text="WPShout" xmlUrl="https://www.codeinwp.com/feed/" htmlUrl="https://wpshout.com/" category="wordpress"/>
<outline type="rss" text="Matt Mullenweg" xmlUrl="https://ma.tt/feed/" htmlUrl="https://ma.tt/" category="wordpress"/>
<outline type="rss" text="WordPress News" xmlUrl="https://wordpress.org/news/feed/" htmlUrl="https://wordpress.org/news" category="wordpress"/>
<outline type="rss" text="Human Made Blog" xmlUrl="https://humanmade.com/blog/feed/" htmlUrl="https://humanmade.com/blog/" category="wordpress"/>
<outline type="rss" text="Builders" xmlUrl="https://wpengine.com/builders/feed/" htmlUrl="https://wpengine.com/builders/" category="wordpress"/>
<outline type="rss" text="Tom McFarlin" xmlUrl="https://tommcfarlin.com/feed/" htmlUrl="https://tommcfarlin.com/" category="wordpress"/>
<outline type="rss" text="Tutorials | Learn WordPress" xmlUrl="https://learn.wordpress.org/tutorials/feed/" htmlUrl="https://learn.wordpress.org/" category="wordpress"/>
<outline type="rss" text="Full Site Editing" xmlUrl="https://fullsiteediting.com/feed/" htmlUrl="https://fullsiteediting.com/" category="wordpress"/>
<outline type="rss" text="Blog – WP Fieldwork" xmlUrl="https://wpfieldwork.com/blog/feed/" htmlUrl="https://wpfieldwork.com/" category="wordpress"/>
<outline type="rss" text="Gutenberg Hub" xmlUrl="https://gutenberghub.com/feed" htmlUrl="https://gutenberghub.com/" category="wordpress"/>
<outline type="rss" text="The WP Minute" xmlUrl="https://thewpminute.com/feed/" htmlUrl="https://thewpminute.com/" category="wordpress"/>
<outline type="rss" text="WordPress Developer Blog" xmlUrl="https://developer.wordpress.org/news/feed/" htmlUrl="https://developer.wordpress.org/news" category="wordpress"/>
<outline type="rss" text="Ollie WordPress Block Theme" xmlUrl="https://olliewp.com/feed/" htmlUrl="https://olliewp.com/" category="wordpress"/>
<outline type="rss" text="WordPress.tv" xmlUrl="https://wordpress.tv/feed/" htmlUrl="https://wordpress.tv/" category="wordpress"/>
<outline type="rss" text="Torque" xmlUrl="https://torquemag.io/feed/" htmlUrl="https://torquemag.io/" category="wordpress"/>
<outline type="rss" text="WordPress – CSS-Tricks" xmlUrl="https://css-tricks.com/tag/wordpress/feed/" htmlUrl="https://css-tricks.com/" category="wordpress"/>
<outline type="rss" text="Blog - Bill Erickson" xmlUrl="https://www.billerickson.net/blog/feed/" htmlUrl="https://www.billerickson.net/blog/" category="wordpress"/>
<outline type="rss" text="WP Builds" xmlUrl="https://wpbuilds.com/feed/podcast/" htmlUrl="https://wpbuilds.com/series/wp-builds/" category="wordpress"/>
<outline type="rss" text="WP Tavern" xmlUrl="https://wptavern.com/feed" htmlUrl="https://wptavern.com/" category="wordpress"/>
</body>
</opml>
37 changes: 37 additions & 0 deletions tests/data/friends.opml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?><opml version="2.0">
<head>
<title>My Friends</title>
<dateCreated>Fri, 29 Nov 2024 06:47:29 +0000</dateCreated>
<ownerName>adm</ownerName>
</head>
<body>
<outline text="Friends">
<outline text="alex" htmlUrl="https://akirk.blog/friends/alex.mu.blog/" title="alex" type="rss" xmlUrl="https://alex.mu.blog/feed/?me=akirk.blog&#038;until=1764398848&#038;auth=$2y$12$H6eft9cdHzvnNopyVzC4C.TS.yuNGL1Qox3nu/qREMI1HqamMTQ7u"/>
<outline text="alex" htmlUrl="https://akirk.blog/friends/alex.mu.blog/" title="alex" type="rss" xmlUrl="https://mastodon.local/@alex"/>
<outline text="Matt on Not-WordPress" htmlUrl="https://akirk.blog/friends/matt.blog/" title="Matt on Not-WordPress" type="rss" xmlUrl="https://matt.blog/feed/?me=akirk.blog&#038;until=1764398848&#038;auth=$2y$12$hwATj8IIKltWpk3GJVPCnOEsgK8vbmk7mgReOzCaO8yh1kPWryaxa"/>
</outline>
<outline text="Subscriptions">
<outline text="Aaron Jorbin" htmlUrl="https://akirk.blog/friends/aaron-jorbin/" title="Aaron Jorbin" type="rss" xmlUrl="https://aaron.jorb.in/feed/"/>
<outline text="Adam Zielinski" htmlUrl="https://akirk.blog/friends/adam-zielinski/" title="Adam Zielinski" type="rss" xmlUrl="https://adamadam.blog/feed/"/>
<outline text="alex" htmlUrl="https://akirk.blog/friends/mastodon.local-users-alex/" title="alex" type="rss" xmlUrl="https://wpfriends.at/feed/"/>
<outline text="Alex Kirk" htmlUrl="https://akirk.blog/friends/alex-kirk/" title="Alex Kirk" type="rss" xmlUrl="https://alex.kirk.at/feed/"/>
<outline text="Andreas Klinger" htmlUrl="https://akirk.blog/friends/andreas-klinger/" title="Andreas Klinger" type="rss" xmlUrl="https://klinger.io/rss.xml"/>
<outline text="Barnaby Walters" htmlUrl="https://akirk.blog/friends/barnaby-walters/" title="Barnaby Walters" type="rss" xmlUrl="https://akirk.blog/friends/barnaby-walters/feed/"/>
<outline text="bengreeley.com" htmlUrl="https://akirk.blog/friends/bengreeley.com/" title="bengreeley.com" type="rss" xmlUrl="https://www.bengreeley.com/feed/"/>
<outline text="Chris Coyier" htmlUrl="https://akirk.blog/friends/chriscoyier.net/" title="Chris Coyier" type="rss" xmlUrl="https://chriscoyier.net/feed/"/>
<outline text="David Bisset" htmlUrl="https://akirk.blog/friends/davidbisset-phpc.social/" title="David Bisset" type="rss" xmlUrl="https://phpc.social/@davidbisset"/>
<outline text="Ingrid Brodnig" htmlUrl="https://akirk.blog/friends/mastodon.social-users-brodnig/" title="Ingrid Brodnig" type="rss" xmlUrl="https://mastodon.social/users/brodnig.rss"/>
<outline text="Jim Nielsen" htmlUrl="https://akirk.blog/friends/jim-nielsen.com/" title="Jim Nielsen" type="rss" xmlUrl="https://blog.jim-nielsen.com/feed.xml"/>
<outline text="Matt Mullenweg" htmlUrl="https://akirk.blog/friends/ma.tt/" title="Matt Mullenweg" type="rss" xmlUrl="https://ma.tt/feed/"/>
<outline text="mkln.org" htmlUrl="https://akirk.blog/friends/mkln.org/" title="mkln.org" type="rss" xmlUrl="https://mkln.org/feed"/>
<outline text="notiz.blog" htmlUrl="https://akirk.blog/friends/notiz.blog/" title="notiz.blog" type="rss" xmlUrl="https://notiz.blog/feed/"/>
<outline text="paolo.blog" htmlUrl="https://akirk.blog/friends/paolo.blog/" title="paolo.blog" type="rss" xmlUrl="https://paolo.blog/feed/"/>
<outline text="pfefferle" htmlUrl="https://akirk.blog/friends/pfefferle-mastodon-social/" title="pfefferle" type="rss" xmlUrl="https://mastodon-social/@pfefferle"/>
<outline text="restofworld.org" htmlUrl="https://akirk.blog/friends/restofworld.org/" title="restofworld.org" type="rss" xmlUrl="https://restofworld.org/feed/latest"/>
<outline text="simonwillison.net" htmlUrl="https://akirk.blog/friends/simonwillison.net/" title="simonwillison.net" type="atom" xmlUrl="https://simonwillison.net/atom/everything/"/>
<outline text="stratechery.com" htmlUrl="https://akirk.blog/friends/stratechery.com/" title="stratechery.com" type="rss" xmlUrl="https://stratechery.com/feed/"/>
<outline text="wptavern.com" htmlUrl="https://akirk.blog/friends/wptavern.com/" title="wptavern.com" type="rss" xmlUrl="https://wptavern.com/feed/"/>
<outline text="xkcd" htmlUrl="https://akirk.blog/friends/xkcd/" title="xkcd" type="rss" xmlUrl="https://xkcd.com/rss.xml"/>
</outline>
</body>
</opml>
28 changes: 28 additions & 0 deletions tests/test-feed.php
Original file line number Diff line number Diff line change
Expand Up @@ -678,4 +678,32 @@ public function test_podcast() {
$this->assertEquals( 'https://podcast.local/2022/10/episode-1/', $post->guid );
$this->assertStringContainsString( 'first-episode.mp3', $post->post_content );
}

public function test_import_feedland_opml() {
add_filter( 'friends_pre_check_url', '__return_true' );

$opml = file_get_contents( __DIR__ . '/data/feedland.opml' );
$feeds = Import::opml( $opml );
$users_created = count( $feeds );
$feeds_imported = 0;
foreach ( $feeds as $user => $user_feeds ) {
$feeds_imported += count( $user_feeds );
}
$this->assertEquals( 34, $users_created );
$this->assertEquals( 34, $feeds_imported );
}

public function test_import_friends_opml() {
add_filter( 'friends_pre_check_url', '__return_true' );

$opml = file_get_contents( __DIR__ . '/data/friends.opml' );
$feeds = Import::opml( $opml );
$users_created = count( $feeds );
$feeds_imported = 0;
foreach ( $feeds as $user => $user_feeds ) {
$feeds_imported += count( $user_feeds );
}
$this->assertEquals( 22, $users_created );
$this->assertEquals( 24, $feeds_imported );
}
}

0 comments on commit 5cd4454

Please sign in to comment.