diff options
-rw-r--r-- | data/assets/js/admin/tabs/import.js | 2 | ||||
-rw-r--r-- | src/guff/disposition.cr | 12 | ||||
-rw-r--r-- | src/guff/handlers.cr | 55 | ||||
-rw-r--r-- | src/guff/views/post-exporter.cr | 151 | ||||
-rw-r--r-- | src/views/panes/settings/import.ecr | 67 |
5 files changed, 254 insertions, 33 deletions
diff --git a/data/assets/js/admin/tabs/import.js b/data/assets/js/admin/tabs/import.js index 2d5bd7f..8027dcd 100644 --- a/data/assets/js/admin/tabs/import.js +++ b/data/assets/js/admin/tabs/import.js @@ -9,7 +9,7 @@ jQuery(function($) { }); $('#export-posts').click(function() { - alert('TODO: export posts'); + $('#export-posts-form').submit(); // stop event return false; diff --git a/src/guff/disposition.cr b/src/guff/disposition.cr new file mode 100644 index 0000000..96354b0 --- /dev/null +++ b/src/guff/disposition.cr @@ -0,0 +1,12 @@ +module Guff::Disposition + HEADER = "content-disposition" + FORMAT = "attachment; filename=\"%s\"; filename*=UTF-8''%s" + + def self.header(name : String) : String + FORMAT % [name.gsub(/[%";]+/, "_"), URI.escape(name)] + end + + def self.set(headers : HTTP::Headers, name : String) + headers[HEADER] = header(name) + end +end diff --git a/src/guff/handlers.cr b/src/guff/handlers.cr index d6c526f..20c44ea 100644 --- a/src/guff/handlers.cr +++ b/src/guff/handlers.cr @@ -396,11 +396,12 @@ module Guff::Handlers path = context.request.path.not_nil! if /\/$/.match(path) - # TODO: render page + # send headers context.response.headers["x-frame-options"] = "SAMEORIGIN" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 + # render page Views::Posts::Project.new(@context, r).to_s(context.response) else # redirect to project @@ -501,11 +502,12 @@ module Guff::Handlers def call(context : HTTP::Server::Context) site_id, r = find(context.request) if site_id && r - # TODO: render page + # send headers context.response.headers["x-frame-options"] = "SAMEORIGIN" context.response.content_type = "text/html; charset=utf-8" context.response.status_code = 200 + # render page Views::Blog::List.new(@context, site_id, r).to_s(context.response) else # unknown page @@ -742,6 +744,48 @@ module Guff::Handlers end end + class ExportPostsAPIHandler < AuthenticatedHandler + def initialize(context : Context) + super(context, %w{admin editor}) + end + + def authenticated_call(context : HTTP::Server::Context) + req_path = context.request.path.not_nil! + + if matching_request?(context.request.method, req_path) && + valid_origin_headers?(context.request.headers) + + # parse request parameters and get site_id + params = HTTP::Params.parse(context.request.body.not_nil!) + site_id = params["site_id"].not_nil!.to_i64 + + # create exporter + exporter = Views::PostExporter.new( + context: @context, + site_id: site_id, + ) + + # build response + context.response.headers["x-frame-options"] = "SAMEORIGIN" + context.response.status_code = 200 + context.response.content_type = exporter.content_type + Disposition.set(context.response.headers, exporter.file_name) + + # send response + exporter.to_s(context.response) + else + # not a matching request + call_next(context) + end + end + + PATH_RE = %r{^/guff/api/export/posts} + + private def matching_request?(method, path) + (method == "POST") && PATH_RE.match(path) + end + end + HANDLERS = [{ :dev => true, :id => :stub, @@ -787,6 +831,11 @@ module Guff::Handlers :dev => false, :id => :file_api, }, { + # NOTE: special handler for api/export/posts, should load + # before api handler + :dev => false, + :id => :export_posts_api, + }, { :dev => false, :id => :api, }, { @@ -822,6 +871,8 @@ module Guff::Handlers SessionHandler.new(context) when :file_api FileAPIHandler.new(context) + when :export_posts_api + ExportPostsAPIHandler.new(context) when :api APIHandler.new(context) when :assets diff --git a/src/guff/views/post-exporter.cr b/src/guff/views/post-exporter.cr new file mode 100644 index 0000000..0558c39 --- /dev/null +++ b/src/guff/views/post-exporter.cr @@ -0,0 +1,151 @@ +# +# WXR Blog Post Exporter +# +# Based on the information at the following URLs: +# https://devtidbits.com/2011/03/16/the-wordpress-extended-rss-wxr-exportimport-xml-document-format-decoded-and-explained/ +# http://wpcandy.com/made/the-sample-post-collection/ +# +class Guff::Views::PostExporter + TEMPLATES = Template::Cache.new({ + "head" => %{ + <?xml version="1.0" encoding="UTF-8"?> + <!-- Generated by Guff %{version | h} on %{date_iso8601 | h}. --> + + <rss version="2.0" + xmlns:content="http://purl.org/rss/1.0/modules/content/" + xmlns:wfw="http://wellformedweb.org/CommentAPI/" + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:wp="http://wordpress.org/export/1.0/" + > + <channel> + <title>%{name | h}</title> + <link>%{link | h}</link> + <description>%{text | h}</description> + <pubDate>%{date_rfc2822 | h}</pubDate> + <generator>https://guff.pablotron.org/?v=%{version | h}</generator> + <language>%{lang | h}</language> + <wp:wxr_version>1.0</wp:wxr_version> + <wp:base_site_url>%{link | h}</wp:base_site_url> + <wp:base_blog_url>%{link | h}</wp:base_blog_url> + }.strip, + + "head_tag" => %{ + <wp:tag> + <wp:tag_slug>%{tag | h}</wp:tag_slug> + <wp:tag_name>%{tag | h}</wp:tag_name> + </wp:tag> + }.strip, + + "item_tag" => %{ + <category domain="tag">%{tag | h}</category> + }.strip, + + "item" => %{ + <item> + %{tags} + <title>%{name | h}</title> + <link>%{link | h}</link> + <guid isPermaLink="false">%{link | h}</guid> + <description></description> + <content:encoded>%{body | h}</content:encoded> + <excerpt:encoded></excerpt:encoded> + <wp:post_id>%{post_id | h}</wp:post_id> + <wp:post_date>%{posted_at_text | h}</wp:post_date> + <wp:post_date_gmt>%{posted_at | h}</wp:post_date_gmt> + <wp:comment_status>closed</wp:comment_status> + <wp:ping_status>closed</wp:ping_status> + <wp:post_name>%{slug | h}</wp:post_name> + <wp:status>publish</wp:status> + <wp:post_parent>0</wp:post_parent> + <wp:menu_order>0</wp:menu_order> + <wp:post_type>post</wp:post_type> + <wp:post_password></wp:post_password> + </item> + }, + + "foot" => %{ + </channel> + </rss> + }.strip, + }) + + # TODO: add additional export options + def initialize( + @context : Context, + @site_id : Int64, + ) + end + + # FIXME: language should be specified on a per-site basis + LANG = "en-US" + + # FIXME: check this + RFC2822 = Time::Format.new("%c %z") + + EMPTY_HASH = {} of String => String + + def to_s(io) + # cache current timestamp + now = Time.now + + # write header + io << TEMPLATES["head"].run({ + "version" => Guff::VERSION, + "lang" => LANG, + + # timestamps + "date_rfc2822" => RFC2822.format(now), + "date_iso8601" => ISO8601.format(now), + + # TODO: site name, link, and description + "name" => "Site Name", + "link" => "https://example.com/", + "text" => "Some description of this blog." + }) + + # TODO: write tags used by blog posts + + # write posts + done, page = false, 0_i32 + until done + page += 1 + r = @context.models.blog.find( + site_id: @site_id, + page: page + ) + + if r.rows.size > 0 + r.rows.each do |row| + io << TEMPLATES["item"].run(row.merge({ + "tags" => "", + "link" => "/todo/" + row["slug"], + })) + end + else + done = true + end + end + + # write footer + io << TEMPLATES["foot"].run(EMPTY_HASH) + end + + CONTENT_TYPE = "text/xml; charset=utf-8" + + def content_type + CONTENT_TYPE + end + + # NOTE: wordpress uses name.wordpress.YYYY-MM-DD.xml + FILE_NAME_FORMAT = "%s-posts-%s.xml" + + FILE_NAME_TIME_FORMAT = Time::Format.new("%Y%m%d-%H%M%S") + + def file_name + site = @context.models.site.get(site_id: @site_id) + FILE_NAME_FORMAT % [ + site["name"].as(String), + FILE_NAME_TIME_FORMAT.format(Time.now), + ] + end +end diff --git a/src/views/panes/settings/import.ecr b/src/views/panes/settings/import.ecr index 5201e77..2cfa7ad 100644 --- a/src/views/panes/settings/import.ecr +++ b/src/views/panes/settings/import.ecr @@ -103,36 +103,43 @@ >WXR</a> format. </p> - <div class='form-group'> - <label for='export-site'> - Source Site - </label> - - <select - id='export-site' - class='form-control' - title='Choose source site.' - aria-describedby='export-site-help' - ><%= - site_options - %></select> - - <p id='export-site-help' class='help-block'> - Source site of blog posts to export. - </p> - </div><!-- form-group --> - - <div class='form-group'> - <a - href='#' - id='export-posts' - class='btn btn-primary' - title='Export blog posts to file in WXR format.' - > - <i class='fa fa-download'></i> - Export Blog Posts - </a> - </div><!-- form-group --> + <form + id='export-posts-form' + method='post' + action='../guff/api/export/posts' + > + <div class='form-group'> + <label for='export-site'> + Source Site + </label> + + <select + id='export-site' + name='site_id' + class='form-control' + title='Choose source site.' + aria-describedby='export-site-help' + ><%= + site_options + %></select> + + <p id='export-site-help' class='help-block'> + Source site of blog posts to export. + </p> + </div><!-- form-group --> + + <div class='form-group'> + <a + href='#' + id='export-posts' + class='btn btn-primary' + title='Export blog posts to file in WXR format.' + > + <i class='fa fa-download'></i> + Export Blog Posts + </a> + </div><!-- form-group --> + </form> </div><!-- panel-body --> </div><!-- panel --> </div><!-- tab-pane --> |