sutty-migration 0.3.5 → 0.4.0rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1fd9ce789787d3a77c484a988bc3a97d687d2db636ced588954578c2a069b60
4
- data.tar.gz: 848ae95f5a846a9ad8d7102a3bfbfc2c8813bf8f656e40bbae79a66eb41f482f
3
+ metadata.gz: 41536e494891ca8e5b5a6ea4f98dcf2cdd780bb8f1a16cdfc10bcee9f5195eb6
4
+ data.tar.gz: d73e41285e4bfd4080aad2beb3b9b3a29332ee0f31e64b4438fd0bcc7a479952
5
5
  SHA512:
6
- metadata.gz: 1b3908174927eb8034605f60c56e40c2455514f1e4a707d425c1eab65dc892f9223ffa2eec055d97f4810795f7cf4599daf0803ae823c62eafb7cbc793d4b858
7
- data.tar.gz: 767de3717e12d798fba13af63c9f889e26e155228784b07faadfdb8aeddc88a5930437760305a146422366239debe3dd3f13e8b6935a1951aff89ac7008cb0c1
6
+ metadata.gz: b7e64d51403a731be28d4eae8385747dee6abc99e3e5c7b8220240a673b1dee555584706ef0d10f203751eacb51b89022c02ec1d9019b3854a547650f9489e1d
7
+ data.tar.gz: f7ae65bde83caa3404d7de2b825fe751fdc0490fda3c399f4c98f2cf320e037323172b065ec82e9e7af4a5e09ecc5c27e095f3b955c19268e1fe0cc55522a557
data/README.md CHANGED
@@ -91,75 +91,45 @@ SQLite syntax, since `mysql2sqlite` doesn't support this yet.
91
91
  Wordpress websites can include lots of posts and metadata, depending on
92
92
  the amount of plugins installed. We don't have an official way of
93
93
  dumping everything into Jekyll, because you will probably want to move
94
- things around. You can write a plugin like this:
94
+ things around. You can write a plugin like this to inspect what's
95
+ happening on every stage:
95
96
 
96
97
  ```ruby
97
98
  # _plugins/wordpress.rb
98
99
  # frozen_string_literal: true
99
100
 
100
- require 'sutty_migration/wordpress'
101
- require 'sutty_migration/jekyll/document_creator'
102
- require 'jekyll-write-and-commit-changes'
101
+ require 'jekyll-wordpress-sql-migration'
102
+ require 'pry'
103
103
 
104
- Jekyll::Hooks.register :site, :post_read, priority: :low do |site|
105
- wp = SuttyMigration::Wordpress.new(site: site, database: 'wordpress.sqlite3', prefix: 'wp_', url: 'https://wordpre.ss')
106
-
107
- # Download all files
108
- wp.download_all
109
-
110
- wp.posts(layout: 'post').each do |post|
111
- doc = Jekyll::Document.create(site: site, title: post[:post_title], date: post[:post_date], collection: 'posts')
112
- doc.content = post[:content]
113
- doc.save
114
- end
104
+ Jekyll::Hooks.register :wordpress_post, :post_init do |item|
105
+ binding.pry
115
106
  end
116
107
  ```
117
108
 
109
+ Available hooks are `:wordpress_attachment`, `:wordpress_post` and
110
+ `:migration` for the `:post_init`, `:pre_render`, `:post_convert`,
111
+ `:post_render` and `:post_write` stages.
112
+
113
+ And then set `JEKYLL_ENV=migration` for the migration to run.
114
+
115
+ If the website is still available (although probably doesn't),
116
+ attachments will be downloaded to `wp-content/`. If you have them from
117
+ a backup, put them there so the plugin finds them.
118
+
118
119
  ### WordPress XML
119
120
 
120
121
  If you have the XML dump from a WordPress site, you can migrate content
121
- by writing a migration plugin.
122
+ by using the XML migration plugin and hooking to its steps.
122
123
 
123
124
  ```ruby
125
+ # _plugins/wordpress.rb
124
126
  # frozen_string_literal: true
125
127
 
126
- require 'sutty_migration/jekyll/document_creator'
127
- require 'sutty_migration/wordpress_xml'
128
- require 'jekyll-write-and-commit-changes'
129
- require 'securerandom'
130
-
131
- # Run after reading the site
132
- Jekyll::Hooks.register :site, :post_read do |site|
133
- # Put the XML dump at _files/wordpress.xml
134
- xml = SuttyMigration::WordpressXml.new site: site, file: '_files/wordpress.xml'
135
-
136
- # Download all files
137
- xml.attachments.values.map(&:download)
138
-
139
- # Migrate posts. You can move metadata around and recover
140
- # relationships or any info your theme requires.
141
- xml.posts.values.each do |post|
142
- # Update documents already migrated.
143
- doc = Jekyll::Document.find_or_create(site: site, collection: locale, title: post.title, slug: post.slug, date: post.date)
144
- # Don't change the UUIDv4
145
- d.data['uuid'] ||= SecureRandom.uuid
146
- d.data['draft'] = post.draft?
147
- d.data['layout'] = 'post'
148
- d.data['last_modified_at'] = post.last_modified_at
149
-
150
- d.data['categories'] = post.categories.map { |c| c[:title] }
151
- d.data['tags'] = post.tags.map { |t| t[:title] }
152
-
153
- d.data['author'] = post.author[:email]
154
- d.data['description'] = post.description
155
- d.content = post.content
156
-
157
- doc.save
158
- rescue => e
159
- Jekyll.logger.warn "Couldn't migrate #{post.title}"
160
- end
161
-
162
- exit # Stop here
128
+ require 'jekyll-wordpress-xml-migration'
129
+ require 'pry'
130
+
131
+ Jekyll::Hooks.register :wordpress_post, :post_init do |item|
132
+ binding.pry
163
133
  end
164
134
  ```
165
135
 
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enable this plugin if you have an SQL dump backup of your Wordpress
4
+ # site. To avoid having to run a MySQL/MariaDB database, we convert the
5
+ # dump into an SQLite database with our fork of
6
+ # [mysql2sqlite](https://git.sutty.abyaya.la/misc/mysql2sqlite)
7
+ #
8
+ # Put the SQLite file on _files/wordpress.sqlite3 and set the URL on the
9
+ # `url` field on `_config.yml`. If the new website doesn't match the
10
+ # URL, you can use `wordpress_url`.
11
+ #
12
+ # If the prefix for Wordpress tables is different than "wp_", set
13
+ # `wordpress_prefix` on `_config.yml`.
14
+ #
15
+ # ```sh
16
+ # JEKYLL_ENV=migration bundle exec jekyll build
17
+ # ```
18
+ #
19
+ # ## Hooks
20
+ #
21
+ # Hook to `:migration` if you need to access and modify data during the
22
+ # migration process:
23
+ #
24
+ # * `:post_init`: will receive the `Hash`
25
+ # * `:pre_render`: will receive the `SuttyMigration::WordpressXml::Post`
26
+ # * `:post_convert`: will receive the `Jekyll::Document` after setting front matter
27
+ # * `:post_render`: will receive the `Jekyll::Document` after sanitizing the content
28
+ # * `:post_write`: will receive the `Jekyll::Document` after writing to disk
29
+
30
+ Jekyll::Hooks.register :site, :post_read, priority: :high do |site|
31
+ next unless Jekyll.env == 'migration'
32
+
33
+ database = '_files/wordpress.sqlite3'
34
+
35
+ Jekyll.logger.abort_with 'Migration:', "#{database} doesn't exist!" unless File.exist? database
36
+
37
+ require 'sutty_migration/wordpress'
38
+ require 'sutty_migration/wordpress_xml/post'
39
+ require 'sutty_migration/wordpress_xml/attachment'
40
+ require 'sutty_migration/jekyll/document_creator'
41
+ require 'jekyll-write-and-commit-changes'
42
+
43
+ url = site.config['wordpress_url'] || site.config['url']
44
+ prefix = site.config['wordpress_prefix'] || 'wp_'
45
+
46
+ wordpress = SuttyMigration::Wordpress.new(site:, url:, database:, prefix:)
47
+ migrated_posts = []
48
+
49
+ Jekyll::Hooks.trigger :migration, :post_init, wordpress
50
+
51
+ wordpress.posts(layout: 'attachment').each do |item|
52
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_init, item
53
+
54
+ attachment = SuttyMigration::WordpressXml::Attachment.new(wordpress:, item:)
55
+
56
+ # Download and convert to Sutty's public/
57
+ Jekyll::Hooks.trigger :wordpress_attachment, :pre_render, attachment
58
+ # Index attachments by id, public path and original destination so
59
+ # we can find them later.
60
+ wordpress.attachments[attachment.attachment_url] =
61
+ wordpress.attachments[attachment.public_path] =
62
+ wordpress.attachments[item[:id]] =
63
+ attachment
64
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_convert, attachment
65
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_render, attachment
66
+
67
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_write, attachment
68
+ end
69
+
70
+ Jekyll::Hooks.trigger :migration, :pre_render, wordpress
71
+
72
+ # We have to instantiate Posts from a Hash instead of an XML object.
73
+ wordpress.posts(layout: 'post').each do |item|
74
+ Jekyll::Hooks.trigger :wordpress_post, :post_init, item
75
+
76
+ item[:title] = 'Draft' if item[:title].empty?
77
+ item[:slug] = Jekyll::Utils.slugify(item[:title], mode: 'latin') if item[:slug].empty?
78
+
79
+ post = SuttyMigration::WordpressXml::Post.new(wordpress:, item:).tap do |p|
80
+ p.categories = [item.dig(:terms, :category)].flatten.compact
81
+ p.tags = [item.dig(:terms, :post_tag)].flatten.compact
82
+ p.date = item[:date] || item[:last_modified_at]
83
+ p.last_modified_at = item[:last_modified_at]
84
+ end
85
+
86
+ Jekyll::Hooks.trigger :wordpress_post, :pre_render, post
87
+
88
+ Jekyll::Document.find_or_create(site:, slug: post.slug, title: post.title, date: post.date,
89
+ collection: 'posts').tap do |doc|
90
+ # If you want to customize the layout, change it with a :post_init
91
+ # hook.
92
+ doc.data['layout'] = item[:layout]
93
+ doc.data['created_at'] = post.date
94
+ doc.data['last_modified_at'] = post.last_modified_at&.localtime
95
+ doc.data['uuid'] ||= SecureRandom.uuid
96
+ doc.data['categories'] = post.categories
97
+ doc.data['tags'] = post.tags
98
+ doc.data['draft'] = post.draft?
99
+
100
+ unless (attachment_id = item.dig(:front_matter, :_thumbnail_id).to_i).zero?
101
+ attachment = wordpress.attachments[attachment_id]
102
+
103
+ if attachment
104
+ doc.data['image'] = {
105
+ 'path' => attachment.public_path,
106
+ 'description' => ''
107
+ }
108
+ end
109
+ end
110
+
111
+ Jekyll::Hooks.trigger :wordpress_post, :post_convert, doc
112
+
113
+ doc.content = post.beautiful_content
114
+
115
+ Jekyll::Hooks.trigger :wordpress_post, :post_render, doc
116
+
117
+ migrated_posts << doc
118
+ end
119
+ end
120
+
121
+ Jekyll::Hooks.trigger :migration, :post_convert, wordpress
122
+ Jekyll::Hooks.trigger :migration, :post_render, migrated_posts
123
+
124
+ # Save them all at the end
125
+ migrated_posts.each do |doc|
126
+ doc.save
127
+
128
+ Jekyll::Hooks.trigger :wordpress_post, :post_write, doc
129
+ end
130
+
131
+ Jekyll::Hooks.trigger :migration, :post_write, migrated_posts
132
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enable this plugin if you have an XML backup of your Wordpress site.
4
+ # Although this is the easiest path, it's not always possible to have
5
+ # one, either because the website is not working or producing the XML
6
+ # dump gets canceled by PHP resource usage limits.
7
+ #
8
+ # Put the XML file on _files/wordpress.xml and set the URL on the `url`
9
+ # field on _config.yml. If the new website doesn't match the URL, you
10
+ # can use `wordpress_url`.
11
+ #
12
+ # JEKYLL_ENV=migration bundle exec jekyll build
13
+ Jekyll::Hooks.register :site, :post_read, priority: :high do |site|
14
+ next unless Jekyll.env == 'migration'
15
+
16
+ file = '_files/wordpress.xml'
17
+
18
+ Jekyll.logger.abort_with 'Migration:', "#{file} is missing!" unless File.exist? file
19
+
20
+ require 'sutty_migration/wordpress_xml'
21
+ require 'sutty_migration/jekyll/document_creator'
22
+ require 'jekyll-write-and-commit-changes'
23
+
24
+ wordpress = SuttyMigration::WordpressXml.new(site:, file:)
25
+ migrated_posts = []
26
+
27
+ Jekyll::Hooks.trigger :migration, :post_init, wordpress
28
+
29
+ wordpress.attachments.values.uniq.each do |attachment|
30
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_init, attachment.item
31
+
32
+ Jekyll::Hooks.trigger :wordpress_attachment, :pre_render, attachment
33
+ # Download and convert to Sutty's public/
34
+ attachment.public_path
35
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_convert, attachment
36
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_render, attachment
37
+ Jekyll::Hooks.trigger :wordpress_attachment, :post_write, attachment
38
+ end
39
+
40
+ wordpress.posts.each_value do |post|
41
+ Jekyll::Hooks.trigger :wordpress_post, :post_init, post.item
42
+ Jekyll::Hooks.trigger :wordpress_post, :pre_render, post
43
+ Jekyll::Document.find_or_create(site:, slug: post.slug, title: post.title, date: post.date,
44
+ collection: 'posts').tap do |doc|
45
+ # If you want to customize the layout, change it with a :post_init
46
+ # hook.
47
+ doc.data['layout'] = 'post'
48
+ doc.data['permalink'] = post.permalink
49
+ doc.data['created_at'] = post.date
50
+ doc.data['last_modified_at'] = post.last_modified_at&.localtime
51
+ doc.data['uuid'] ||= SecureRandom.uuid
52
+ doc.data['categories'] = post.categories.map { _1[:title] }
53
+ doc.data['tags'] = post.tags.map { _1[:title] }
54
+ doc.data['draft'] = post.draft?
55
+
56
+ unless (attachment_id = post.meta['_thumbnail_id'].to_i).zero?
57
+ attachment = wordpress.attachments[attachment_id]
58
+
59
+ if attachment
60
+ doc.data['image'] = {
61
+ 'path' => attachment.public_path,
62
+ 'description' => ''
63
+ }
64
+ end
65
+ end
66
+
67
+ Jekyll::Hooks.trigger :wordpress_post, :post_convert, doc
68
+
69
+ doc.content = post.beautiful_content
70
+
71
+ Jekyll::Hooks.trigger :wordpress_post, :post_render, doc
72
+
73
+ migrated_posts << doc
74
+ end
75
+ end
76
+
77
+ Jekyll::Hooks.trigger :migration, :post_convert, wordpress
78
+ Jekyll::Hooks.trigger :migration, :post_render, migrated_posts
79
+
80
+ # Save them all at the end
81
+ migrated_posts.each do |doc|
82
+ doc.save
83
+
84
+ Jekyll::Hooks.trigger :wordpress_post, :post_write, doc
85
+ end
86
+
87
+ Jekyll::Hooks.trigger :migration, :post_write, migrated_posts
88
+ end
@@ -27,9 +27,9 @@ Jekyll::Hooks.register :site, :post_read, priority: :low do |site|
27
27
  end
28
28
 
29
29
  document ||= begin
30
- data = row.slice(*%w[date slug title]).transform_keys(&:to_sym)
31
- Jekyll::Document.find_or_create(site: site, collection: 'posts', **data)
32
- end
30
+ data = row.slice(*%w[date slug title]).transform_keys(&:to_sym)
31
+ Jekyll::Document.find_or_create(site: site, collection: 'posts', **data)
32
+ end
33
33
  next unless document
34
34
 
35
35
  row.each do |attribute, value|
@@ -17,7 +17,7 @@ module SuttyMigration
17
17
  # migration plugin where you can convert data by yourself. We may add
18
18
  # this feature in the future.
19
19
  class Wordpress
20
- attr_reader :site, :prefix, :limit, :url, :wp, :database, :multisite
20
+ attr_reader :site, :prefix, :limit, :url, :wp, :database, :multisite, :attachments
21
21
 
22
22
  # @param :site [Jekyll::Site] Jekyll site
23
23
  # @param :url [String] Wordpress site URL (must be up for downloads)
@@ -32,13 +32,14 @@ module SuttyMigration
32
32
  @url = url.freeze
33
33
  @database = database || File.join(site.source, '_data', 'wordpress.sqlite3')
34
34
  @multisite = multisite
35
+ @attachments = {}
35
36
  end
36
37
 
37
38
  # Generate database connections for a multisite WP
38
39
  #
39
40
  # @return [Hash] { "ID" => SuttyMigration::Wordpress }
40
41
  def blogs
41
- @blogs ||= wp["select * from #{prefix}blogs"].to_a.map do |blog|
42
+ @blogs ||= wp["select * from #{prefix}blogs"].to_a.to_h do |blog|
42
43
  url = "https://#{blog[:domain]}#{blog[:path]}"
43
44
  pfx = "#{prefix}#{blog[:blog_id]}_" if blog[:blog_id] > 1
44
45
  pfx ||= prefix
@@ -46,11 +47,11 @@ module SuttyMigration
46
47
  [blog[:blog_id],
47
48
  blog.merge(db: self.class.new(site: site, url: url, prefix: pfx, database: database, limit: limit,
48
49
  multisite: self))]
49
- end.to_h
50
+ end
50
51
  end
51
52
 
52
53
  def options
53
- @options ||= wp["select option_name, option_value from #{prefix}options"].to_a.map(&:values).to_h.transform_keys(&:to_sym)
54
+ @options ||= wp["select option_name, option_value from #{prefix}options"].to_a.to_h(&:values).transform_keys(&:to_sym)
54
55
  end
55
56
 
56
57
  # Open the database.
@@ -62,53 +63,6 @@ module SuttyMigration
62
63
  end
63
64
  end
64
65
 
65
- # Download all attachments. Adds the local path to them.
66
- #
67
- # @param :progress [Boolean] Toggle progress bar
68
- # @return [Nil]
69
- def download_all(progress: true)
70
- posts(layout: 'attachment').each do |attachment|
71
- attachment[:front_matter]['file_path'] = download(url: attachment[:guid], progress: progress)
72
- end
73
- end
74
-
75
- # Downloads a file if needed, optionally showing a progress bar.
76
- #
77
- # @param :url [String] File URL
78
- # @param :progress [Boolean] Toggle progress bar
79
- # @return [String] File local path
80
- def download(url:, progress: true)
81
- uri = URI(url)
82
- dest = uri.path.sub(%r{\A/}, '')
83
- full = File.join(site.source, dest)
84
-
85
- return dest if File.exist? full
86
-
87
- ::Jekyll.logger.info "Downloading #{dest}"
88
-
89
- FileUtils.mkdir_p File.dirname(full)
90
-
91
- File.open(full, 'w') do |f|
92
- if progress
93
- head = Faraday.head(url)
94
- content_length = head.headers['content-length'].to_i
95
- progress = ProgressBar.create(title: File.basename(dest), total: content_length, output: $stderr)
96
- end
97
-
98
- Faraday.get(url) do |req|
99
- req.options.on_data = proc do |chunk, downloaded_bytes|
100
- f.write chunk
101
-
102
- if progress
103
- progress.progress = downloaded_bytes > content_length ? content_length : downloaded_bytes
104
- end
105
- end
106
- end
107
- end
108
-
109
- dest
110
- end
111
-
112
66
  # List post types
113
67
  #
114
68
  # @return [Array]
@@ -129,7 +83,7 @@ module SuttyMigration
129
83
  end
130
84
 
131
85
  wp[post_query(**options)].each_page(limit).to_a.map(&:to_a).flatten.tap do |p|
132
- p.map do |post|
86
+ p.each do |post|
133
87
  # Sequel parses dates on localtime
134
88
  post[:date] = ::Jekyll::Utils.parse_date(post[:date]) unless post[:date].blank?
135
89
  unless post[:last_modified_at].blank?
@@ -4,6 +4,7 @@ require_relative 'post'
4
4
  require 'php-serialize'
5
5
  require 'faraday'
6
6
  require 'progressbar'
7
+ require 'addressable'
7
8
 
8
9
  module SuttyMigration
9
10
  class WordpressXml
@@ -13,14 +14,22 @@ module SuttyMigration
13
14
  #
14
15
  # @return [String]
15
16
  def attachment_url
16
- @attachment_url ||= attribute_value 'attachment_url'
17
+ @attachment_url ||= attribute_value('attachment_url') || attribute_value('guid')
18
+ end
19
+
20
+ def parsed_url
21
+ @parsed_url ||= Addressable::URI.parse(attachment_url)
22
+ end
23
+
24
+ def normalized_url
25
+ @normalized_url ||= parsed_url.normalize
17
26
  end
18
27
 
19
28
  # File destination
20
29
  #
21
30
  # @return [String]
22
31
  def dest
23
- @dest ||= CGI.unescape(URI(attachment_url).path.sub(%r{\A/}, ''))
32
+ @dest ||= CGI.unescape(parsed_url.path.sub(%r{\A/}, '').unicode_normalize)
24
33
  end
25
34
 
26
35
  # Metadata, with file information as a Hash
@@ -40,30 +49,89 @@ module SuttyMigration
40
49
  def download(progress: true)
41
50
  return true if File.exist? dest
42
51
 
43
- ::Jekyll.logger.info 'Downloading:', dest
44
-
45
52
  FileUtils.mkdir_p File.dirname(dest)
46
53
 
47
54
  File.open(dest, 'w') do |f|
48
- if progress
49
- head = Faraday.head(attachment_url)
50
- content_length = head.headers['content-length'].to_i
51
- progress = ProgressBar.create(title: File.basename(dest), total: content_length, output: $stderr)
55
+ head = Faraday.head(normalized_url)
56
+
57
+ if head.status > 399
58
+ ::Jekyll.logger.warn 'Error:', "#{head.status} #{normalized_url}"
59
+ return false
52
60
  end
53
61
 
54
- Faraday.get(attachment_url) do |req|
55
- req.options.on_data = proc do |chunk, downloaded_bytes|
56
- f.write chunk
62
+ content_length = head.headers['content-length'].to_i
63
+
64
+ if content_length.zero?
65
+ ::Jekyll.logger.warn 'Error:', "Content length is zero for #{normalized_url}"
66
+ return false
67
+ end
57
68
 
58
- if progress
59
- progress.progress = downloaded_bytes > content_length ? content_length : downloaded_bytes
69
+ ::Jekyll.logger.info 'Downloading:', dest
70
+
71
+ progress_bar =
72
+ (ProgressBar.create(title: File.basename(dest), total: content_length, output: $stderr) if progress)
73
+
74
+ begin
75
+ Faraday.get(normalized_url) do |req|
76
+ req.options.on_data = proc do |chunk, downloaded_bytes|
77
+ if downloaded_bytes > content_length
78
+ raise Faraday::Error, 'File is bigger than informed, aborting download'
79
+ end
80
+
81
+ f.write chunk
82
+
83
+ progress_bar&.progress = downloaded_bytes
60
84
  end
61
85
  end
86
+ rescue Faraday::Error => e
87
+ ::Jekyll.logger.warn 'Error:', e.message
62
88
  end
89
+
90
+ progress_bar&.stop
63
91
  end
64
92
 
65
93
  File.exist? dest
66
94
  end
95
+
96
+ # Local path following Sutty's conventions. Instead of producing
97
+ # a SecureRandom.base36 key, we use the first 28 characters of the
98
+ # digest to obtain constand names.
99
+ #
100
+ # It then hardlinks the original file.
101
+ #
102
+ # @return [String]
103
+ def public_path
104
+ return unless digest
105
+
106
+ @public_path ||= File.join('public', digest[..27], dest.split('/').last).tap do
107
+ next if File.exist? _1
108
+
109
+ # If the directory exists and it's not empty, but the file is
110
+ # not there, we either have the same file with different names
111
+ # or old thumbnails.
112
+ dir = File.dirname(_1)
113
+ if File.directory?(dir) && !Dir.empty?(dir)
114
+ ::Jekyll.logger.warn 'Migration:',
115
+ "#{dir} is not empty, you probably have the same file with different names"
116
+ end
117
+
118
+ FileUtils.mkdir_p(File.dirname(_1))
119
+ FileUtils.ln(dest, _1)
120
+ end
121
+ end
122
+
123
+ # Download and produce a digest. Produce an error if the file
124
+ # can't be downloaded.
125
+ #
126
+ # @return [String]
127
+ def digest
128
+ @digest ||=
129
+ begin
130
+ require 'digest'
131
+
132
+ Digest::SHA2.file(dest).hexdigest if download
133
+ end
134
+ end
67
135
  end
68
136
  end
69
137
  end
@@ -1,18 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rails-html-sanitizer'
3
4
  require 'wordpress_formatting/wpautop'
5
+ require 'htmlbeautifier'
4
6
  require 'jekyll/utils'
7
+ require 'nokogiri'
5
8
 
6
9
  module SuttyMigration
7
10
  class WordpressXml
8
11
  # Represents a WordPress post
9
12
  class Post
10
13
  attr_reader :wordpress, :item
14
+ attr_writer :tags, :categories, :author, :meta, :date, :last_modified_at
11
15
 
16
+ SANITIZER = Rails::HTML::Sanitizer.safe_list_sanitizer.new
17
+ # This follows https://www.npmjs.com/package/@suttyweb/editor and
18
+ # also allows some known WP tags, you can add your own during
19
+ # plugin initilization.
20
+ #
21
+ # rubocop:disable Style/MutableConstant
22
+ TAGS = %w[a aside audio blockquote br caption code del em figcaption figure h1 h2 h3 h4 h5 h6 hr iframe img li mark ol p
23
+ pre small strong sub sup table tbody td tfoot th thead tr u ul video]
24
+ ATTRIBUTES = %w[alt colspan controls data-align data-id data-lang data-multimedia data-multimedia-inner
25
+ data-turbo href id name referrerpolicy rel reversed rowspan src start target title type]
26
+ # rubocop:enable Style/MutableConstant
12
27
  SLASH = '/'
28
+ THUMBNAIL_PREFIX = /-\d+x\d+(\.....?)\z/
29
+ # TODO: Move to wordpress-formatting
30
+ WORDPRESS_ELEMENT_START = /\[(\w+ [^\]]+)\]/
31
+ WORDPRESS_ELEMENT_END = %r{\[(/\w+)\]}
13
32
 
14
33
  # @param :wordpress [SuttyMigration::WordpressXml]
15
- # @param :item [Nokogiri::XML::Element]
34
+ # @param :item [Nokogiri::XML::Element, Hash]
16
35
  def initialize(wordpress:, item:)
17
36
  @wordpress = wordpress
18
37
  @item = item
@@ -31,17 +50,17 @@ module SuttyMigration
31
50
 
32
51
  # Permalink. Absolute URL to the post.
33
52
  #
34
- # @return [String]
53
+ # @return [String, nil]
35
54
  def permalink
36
55
  @permalink ||=
37
56
  begin
38
- p = attribute_value('link').sub(wordpress.url, '')
57
+ p = attribute_value('link')&.sub(wordpress.url, '')&.split('?', 2)&.first
39
58
 
40
- if !p.end_with?(SLASH) && File.extname(p).empty?
41
- p << SLASH
42
- end
59
+ unless p.nil? || p.blank?
60
+ p << SLASH if !p.end_with?(SLASH) && File.extname(p).empty?
43
61
 
44
- p.squeeze(SLASH)
62
+ p.squeeze(SLASH)
63
+ end
45
64
  end
46
65
  end
47
66
 
@@ -63,7 +82,9 @@ module SuttyMigration
63
82
  #
64
83
  # @return [String]
65
84
  def slug
66
- @slug ||= attribute_value('post_name').tr(SLASH, '-').squeeze('-').sub(/\A-+/, '').sub(/-+\z/, '')
85
+ @slug ||= (attribute_value('post_name') || attribute_value('slug')).to_s.tr(SLASH, '-').squeeze('-').sub(/\A-+/, '').sub(
86
+ /-+\z/, ''
87
+ )
67
88
  end
68
89
 
69
90
  # Publication date.
@@ -83,19 +104,93 @@ module SuttyMigration
83
104
  #
84
105
  # @return [Time, nil]
85
106
  def last_modified_at
86
- @last_modified_at ||=
87
- if (date = attribute_value('post_modified_gmt'))
88
- ::Jekyll::Utils.parse_date(date)
89
- end
107
+ @last_modified_at ||= %w[post_modified_gmt post_modified].map do |date_attr|
108
+ ::Jekyll::Utils.parse_date attribute_value(date_attr)
109
+ rescue StandardError
110
+ end.compact.first
90
111
  end
91
112
 
92
- # Content as HTML, with site URL removed.
113
+ # Content as an HTML string, converting Wordpress [tags] to <tags>
93
114
  #
94
115
  # @return [String]
95
116
  def content
96
- @content ||= WordpressFormatting::Wpautop.wpautop(attribute_value('encoded')).gsub(
97
- / (href|src)="#{wordpress.url}/, ' \\1="'
98
- )
117
+ @content ||= WordpressFormatting::Wpautop.wpautop((attribute_value('encoded') || attribute_value('content')).gsub(WORDPRESS_ELEMENT_START, '<\\1>').gsub(
118
+ WORDPRESS_ELEMENT_END, '<\\1>'
119
+ ))
120
+ end
121
+
122
+ # Remove extraneous code
123
+ #
124
+ # @return [String]
125
+ def sanitized_content
126
+ @sanitized_content ||= SANITIZER.sanitize(content.to_s, tags: TAGS, attributes: ATTRIBUTES)
127
+ end
128
+
129
+ # Returns a fragment that can be queried using Nokogiri and
130
+ # transformed for final migration. Applies common transformations
131
+ # too.
132
+ #
133
+ # @return [Nokogiri::HTML]
134
+ def parsed_content
135
+ @parsed_content ||= Nokogiri::HTML.fragment(sanitized_content).tap do |html|
136
+ html.css('[src]').each do |element|
137
+ # Remove the thumbnail suffix, we only want the original files
138
+ element['src'] = element['src'].sub(THUMBNAIL_PREFIX, '\\1')
139
+
140
+ if element['src'].start_with? wordpress.url
141
+ attachment = wordpress.attachments[CGI.unescape element['src']]
142
+
143
+ unless attachment
144
+ ::Jekyll.logger.warn 'Error:', "Not found #{element['src']}"
145
+ next
146
+ end
147
+
148
+ element['src'] = attachment.public_path
149
+
150
+ # Move the element into a <figure> like our editor does
151
+ unless element.parent && element.parent.name == '#document-fragment'
152
+ parent = element.parent
153
+
154
+ parent = parent.parent until parent.parent.nil? || parent.parent.name == '#document-fragment'
155
+
156
+ figure = parent.add_previous_sibling('<figure></figure>').first
157
+ figure.add_child element
158
+ # This was the [caption] WP element
159
+ figcaption = html.css('caption').first
160
+
161
+ if figcaption
162
+ figcaption.name = 'figcaption'
163
+ figure.add_child figcaption
164
+ element['alt'] = figcaption.text.strip
165
+ end
166
+ end
167
+
168
+ # Iframes are converted to an autolinked paragraph so
169
+ # jekyll-embed-urls can pick them up during build
170
+ elsif element.name == 'iframe'
171
+ element.parent.name = 'p'
172
+ element.name = 'a'
173
+ element['href'] = element['src']
174
+ if element['href'].include? 'youtube.com/embed/'
175
+ element['href'] = Addressable::URI.parse(element['href']).tap do |uri|
176
+ uri.query = URI.encode_www_form(URI.decode_www_form(uri.query.to_s).tap do |query|
177
+ query << ['v', uri.path.split('/').last]
178
+ end)
179
+ uri.path = '/watch'
180
+ end.to_s
181
+ end
182
+ element.delete 'src'
183
+ element.inner_html = element['href']
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ # Beautifies the parsed content.
190
+ #
191
+ # @return [String]
192
+ def beautiful_content
193
+ HtmlBeautifier.beautify(parsed_content.to_s.squeeze(' ').gsub(/>\s+</, '><'))
99
194
  end
100
195
 
101
196
  # Author attributes.
@@ -176,9 +271,16 @@ module SuttyMigration
176
271
 
177
272
  # Get a value from the attribute
178
273
  #
274
+ # @param key [String, Symbol]
179
275
  # @return [String, nil]
180
276
  def attribute_value(key)
181
- item.at_css(key)&.text
277
+ (case item
278
+ when Hash
279
+ item[key.to_sym]
280
+ else
281
+ item.at_css(key.to_s)&.text
282
+ end
283
+ )&.unicode_normalize&.strip
182
284
  end
183
285
  end
184
286
  end
@@ -15,7 +15,13 @@ module SuttyMigration
15
15
  def initialize(site:, file:)
16
16
  @site = site
17
17
  @file = file
18
- @xml = Nokogiri::XML File.read(file)
18
+ @xml = Nokogiri::XML File.read(file).delete("\b").delete("\u0002")
19
+
20
+ if xml.errors.empty?
21
+ ::Jekyll.logger.info 'Migration:', "#{file} parsed correctly"
22
+ else
23
+ ::Jekyll.logger.warn 'Migration:', "#{xml.errors.count} parsing errors found on #{file}!"
24
+ end
19
25
 
20
26
  # Make things easier by removing namespaces.
21
27
  xml.remove_namespaces!
@@ -108,8 +114,8 @@ module SuttyMigration
108
114
  #
109
115
  # @return [Hash]
110
116
  def posts
111
- @posts ||= items_find_by('post_type', 'post').map do |post|
112
- { attribute_value(post, 'post_id').to_i => Post.new(wordpress: self, item: post) }
117
+ @posts ||= items_find_by('post_type', 'post').map do |item|
118
+ { attribute_value(item, 'post_id').to_i => Post.new(wordpress: self, item:) }
113
119
  end.reduce(&:merge)
114
120
  end
115
121
 
@@ -117,18 +123,23 @@ module SuttyMigration
117
123
  #
118
124
  # @return [Hash]
119
125
  def pages
120
- @pages ||= items_find_by('post_type', 'page').map do |page|
121
- { attribute_value(page, 'post_id').to_i => Post.new(wordpress: self, item: page) }
122
- end.reduce(&:merge)
126
+ @pages ||= items_find_by('post_type', 'page').to_h do |item|
127
+ [attribute_value(page, 'post_id').to_i, Post.new(wordpress: self, item:)]
128
+ end
123
129
  end
124
130
 
125
- # Attachments, indexed by ID
131
+ # Attachments, indexed by URL and ID
126
132
  #
127
133
  # @return [Hash]
128
134
  def attachments
129
- @attachments ||= items_find_by('post_type', 'attachment').map do |attachment|
130
- { attribute_value(attachment, 'post_id').to_i => Attachment.new(wordpress: self, item: attachment) }
131
- end.reduce(&:merge)
135
+ @attachments ||= items_find_by('post_type', 'attachment').map do |item|
136
+ attachment = Attachment.new(wordpress: self, item:)
137
+
138
+ [
139
+ [attribute_value(item, 'post_id').to_i, attachment],
140
+ [attribute_value(item, 'attachment_url'), attachment]
141
+ ]
142
+ end.flatten(1).to_h
132
143
  end
133
144
 
134
145
  # Find items by attribute and value
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sutty-migration
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.4.0rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - f
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: addressable
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: fast_blank
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -150,6 +164,34 @@ dependencies:
150
164
  - - "~>"
151
165
  - !ruby/object:Gem::Version
152
166
  version: 1.3.0
167
+ - !ruby/object:Gem::Dependency
168
+ name: htmlbeautifier
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :runtime
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: rails-html-sanitizer
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :runtime
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
153
195
  - !ruby/object:Gem::Dependency
154
196
  name: pry
155
197
  requirement: !ruby/object:Gem::Requirement
@@ -164,7 +206,21 @@ dependencies:
164
206
  - - ">="
165
207
  - !ruby/object:Gem::Version
166
208
  version: '0'
167
- description: Takes datafiles and converts them into posts
209
+ - !ruby/object:Gem::Dependency
210
+ name: rubocop
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
223
+ description: Takes datafiles, Wordpress backups and converts them into Jekyll posts
168
224
  email:
169
225
  - f@sutty.nl
170
226
  executables: []
@@ -175,6 +231,8 @@ extra_rdoc_files:
175
231
  files:
176
232
  - LICENSE.txt
177
233
  - README.md
234
+ - lib/jekyll-wordpress-sql-migration.rb
235
+ - lib/jekyll-wordpress-xml-migration.rb
178
236
  - lib/sutty-migration.rb
179
237
  - lib/sutty_migration/core_extensions.rb
180
238
  - lib/sutty_migration/data.rb
@@ -183,19 +241,18 @@ files:
183
241
  - lib/sutty_migration/wordpress_xml.rb
184
242
  - lib/sutty_migration/wordpress_xml/attachment.rb
185
243
  - lib/sutty_migration/wordpress_xml/post.rb
186
- homepage: https://0xacab.org/sutty/jekyll/sutty-migration
244
+ homepage: https://git.sutty.abyaya.la/jekyll/sutty-migration
187
245
  licenses:
188
246
  - GPL-3.0
189
247
  metadata:
190
- bug_tracker_uri: https://0xacab.org/sutty/jekyll/sutty-migration/issues
191
- homepage_uri: https://0xacab.org/sutty/jekyll/sutty-migration
192
- source_code_uri: https://0xacab.org/sutty/jekyll/sutty-migration
193
- changelog_uri: https://0xacab.org/sutty/jekyll/sutty-migration/-/blob/master/CHANGELOG.md
248
+ bug_tracker_uri: https://git.sutty.abyaya.la/jekyll/sutty-migration/issues
249
+ homepage_uri: https://git.sutty.abyaya.la/jekyll/sutty-migration
250
+ source_code_uri: https://git.sutty.abyaya.la/jekyll/sutty-migration
194
251
  documentation_uri: https://rubydoc.info/gems/sutty-migration
195
252
  post_install_message:
196
253
  rdoc_options:
197
254
  - "--title"
198
- - sutty-migration - Jekyll data migration for Sutty
255
+ - sutty-migration - CSV, Wordpress SQL and Wordpress XML migration
199
256
  - "--main"
200
257
  - README.md
201
258
  - "--line-numbers"
@@ -207,15 +264,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
207
264
  requirements:
208
265
  - - ">="
209
266
  - !ruby/object:Gem::Version
210
- version: 2.6.0
267
+ version: '3.1'
211
268
  required_rubygems_version: !ruby/object:Gem::Requirement
212
269
  requirements:
213
- - - ">="
270
+ - - ">"
214
271
  - !ruby/object:Gem::Version
215
- version: '0'
272
+ version: 1.3.1
216
273
  requirements: []
217
274
  rubygems_version: 3.3.27
218
275
  signing_key:
219
276
  specification_version: 4
220
- summary: Jekyll data migration for Sutty
277
+ summary: CSV, Wordpress SQL and Wordpress XML migration
221
278
  test_files: []