open_blog 0.1.0
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 +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/app/assets/builds/open_blog/blog.css +2 -0
- data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
- data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
- data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
- data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
- data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
- data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
- data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
- data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
- data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
- data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
- data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
- data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
- data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
- data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
- data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
- data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
- data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
- data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
- data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
- data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
- data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
- data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
- data/app/controllers/open_blog/application_controller.rb +5 -0
- data/app/controllers/open_blog/authors_controller.rb +14 -0
- data/app/controllers/open_blog/categories_controller.rb +15 -0
- data/app/controllers/open_blog/errors_controller.rb +7 -0
- data/app/controllers/open_blog/feeds_controller.rb +22 -0
- data/app/controllers/open_blog/mcp_controller.rb +38 -0
- data/app/controllers/open_blog/media_controller.rb +20 -0
- data/app/controllers/open_blog/pages_controller.rb +12 -0
- data/app/controllers/open_blog/posts_controller.rb +57 -0
- data/app/controllers/open_blog/previews_controller.rb +35 -0
- data/app/controllers/open_blog/search_controller.rb +32 -0
- data/app/controllers/open_blog/series_controller.rb +15 -0
- data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
- data/app/controllers/open_blog/tags_controller.rb +15 -0
- data/app/helpers/open_blog/content_helper.rb +79 -0
- data/app/helpers/open_blog/dates_helper.rb +18 -0
- data/app/helpers/open_blog/faq_helper.rb +59 -0
- data/app/helpers/open_blog/head_helper.rb +75 -0
- data/app/helpers/open_blog/icons_helper.rb +10 -0
- data/app/helpers/open_blog/notices_helper.rb +47 -0
- data/app/helpers/open_blog/pagination_helper.rb +18 -0
- data/app/helpers/open_blog/structured_data_helper.rb +63 -0
- data/app/helpers/open_blog/theme_helper.rb +52 -0
- data/app/helpers/open_blog/url_helper.rb +58 -0
- data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
- data/app/models/concerns/open_blog/immutable.rb +13 -0
- data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
- data/app/models/concerns/open_blog/previewable.rb +38 -0
- data/app/models/open_blog/api_token.rb +34 -0
- data/app/models/open_blog/application_record.rb +5 -0
- data/app/models/open_blog/approval.rb +19 -0
- data/app/models/open_blog/author.rb +11 -0
- data/app/models/open_blog/baseline.rb +26 -0
- data/app/models/open_blog/category.rb +10 -0
- data/app/models/open_blog/connection_declaration.rb +16 -0
- data/app/models/open_blog/faq.rb +22 -0
- data/app/models/open_blog/image.rb +45 -0
- data/app/models/open_blog/page.rb +23 -0
- data/app/models/open_blog/page_view.rb +7 -0
- data/app/models/open_blog/post.rb +132 -0
- data/app/models/open_blog/publication.rb +17 -0
- data/app/models/open_blog/redirect.rb +16 -0
- data/app/models/open_blog/revision.rb +12 -0
- data/app/models/open_blog/series.rb +10 -0
- data/app/models/open_blog/tag.rb +11 -0
- data/app/models/open_blog/tagging.rb +7 -0
- data/app/queries/open_blog/api_post_query.rb +54 -0
- data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
- data/app/serializers/open_blog/author_serializer.rb +10 -0
- data/app/serializers/open_blog/category_serializer.rb +7 -0
- data/app/serializers/open_blog/doctor_serializer.rb +7 -0
- data/app/serializers/open_blog/error_serializer.rb +7 -0
- data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
- data/app/serializers/open_blog/findings_serializer.rb +13 -0
- data/app/serializers/open_blog/image_serializer.rb +9 -0
- data/app/serializers/open_blog/page_serializer.rb +9 -0
- data/app/serializers/open_blog/post_serializer.rb +59 -0
- data/app/serializers/open_blog/preview_serializer.rb +11 -0
- data/app/serializers/open_blog/records_serializer.rb +28 -0
- data/app/serializers/open_blog/redirect_serializer.rb +7 -0
- data/app/serializers/open_blog/series_serializer.rb +7 -0
- data/app/serializers/open_blog/tag_serializer.rb +7 -0
- data/app/serializers/open_blog/write_result_serializer.rb +9 -0
- data/app/views/open_blog/attachments/_blob.html.erb +1 -0
- data/app/views/open_blog/feeds/atom.xml.builder +27 -0
- data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
- data/config/locales/open_blog.en.yml +95 -0
- data/config/routes.rb +52 -0
- data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
- data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
- data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
- data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
- data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
- data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
- data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
- data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
- data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
- data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
- data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
- data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
- data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
- data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
- data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
- data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
- data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
- data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
- data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
- data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
- data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
- data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
- data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
- data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
- data/lib/generators/open_blog/generator_support.rb +64 -0
- data/lib/generators/open_blog/install/install_generator.rb +188 -0
- data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
- data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
- data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
- data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
- data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
- data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
- data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
- data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
- data/lib/generators/open_blog/views/views_generator.rb +21 -0
- data/lib/open_blog/actor.rb +7 -0
- data/lib/open_blog/admin_suite.rb +73 -0
- data/lib/open_blog/adopt/contract.rb +152 -0
- data/lib/open_blog/adopt/snapshot.rb +35 -0
- data/lib/open_blog/adopt.rb +159 -0
- data/lib/open_blog/api_fields.rb +40 -0
- data/lib/open_blog/approve.rb +18 -0
- data/lib/open_blog/authentication.rb +18 -0
- data/lib/open_blog/build_css.rb +53 -0
- data/lib/open_blog/configuration.rb +185 -0
- data/lib/open_blog/configuration_error.rb +4 -0
- data/lib/open_blog/content_guard.rb +77 -0
- data/lib/open_blog/doctor/files.rb +61 -0
- data/lib/open_blog/doctor/javascript.rb +57 -0
- data/lib/open_blog/doctor.rb +177 -0
- data/lib/open_blog/engine.rb +45 -0
- data/lib/open_blog/errors.rb +40 -0
- data/lib/open_blog/faq_extraction.rb +184 -0
- data/lib/open_blog/findings/body_checks.rb +58 -0
- data/lib/open_blog/findings/check.rb +19 -0
- data/lib/open_blog/findings/link_check.rb +78 -0
- data/lib/open_blog/findings/record_checks.rb +94 -0
- data/lib/open_blog/findings/registry.rb +18 -0
- data/lib/open_blog/findings.rb +27 -0
- data/lib/open_blog/image_fetch.rb +115 -0
- data/lib/open_blog/image_import.rb +164 -0
- data/lib/open_blog/image_resolution.rb +171 -0
- data/lib/open_blog/instructions.rb +7 -0
- data/lib/open_blog/json_feed.rb +36 -0
- data/lib/open_blog/label_policy.rb +41 -0
- data/lib/open_blog/markdown_view.rb +57 -0
- data/lib/open_blog/mcp/definition.rb +28 -0
- data/lib/open_blog/mcp/dispatcher.rb +33 -0
- data/lib/open_blog/mcp/image_input.rb +30 -0
- data/lib/open_blog/mcp/registry.rb +139 -0
- data/lib/open_blog/mcp/schemas.rb +72 -0
- data/lib/open_blog/mcp.rb +24 -0
- data/lib/open_blog/not_found.rb +4 -0
- data/lib/open_blog/operation.rb +36 -0
- data/lib/open_blog/page_views.rb +72 -0
- data/lib/open_blog/pagination.rb +18 -0
- data/lib/open_blog/plain_text.rb +36 -0
- data/lib/open_blog/policy_url.rb +9 -0
- data/lib/open_blog/post_attributes.rb +172 -0
- data/lib/open_blog/post_identity.rb +28 -0
- data/lib/open_blog/publish.rb +7 -0
- data/lib/open_blog/reader_dates.rb +21 -0
- data/lib/open_blog/reader_page.rb +21 -0
- data/lib/open_blog/reader_queries.rb +90 -0
- data/lib/open_blog/record_release.rb +66 -0
- data/lib/open_blog/redirect_target.rb +58 -0
- data/lib/open_blog/remove.rb +43 -0
- data/lib/open_blog/renderer/highlighter.rb +44 -0
- data/lib/open_blog/renderer/markdown.rb +14 -0
- data/lib/open_blog/renderer/rich_text.rb +19 -0
- data/lib/open_blog/renderer/sanitizer.rb +72 -0
- data/lib/open_blog/renderer/scrubber.rb +35 -0
- data/lib/open_blog/renderer/transform.rb +115 -0
- data/lib/open_blog/renderer.rb +51 -0
- data/lib/open_blog/result.rb +11 -0
- data/lib/open_blog/revision_payload.rb +67 -0
- data/lib/open_blog/rich_text_guard.rb +26 -0
- data/lib/open_blog/sample.rb +56 -0
- data/lib/open_blog/save_draft.rb +7 -0
- data/lib/open_blog/search.rb +21 -0
- data/lib/open_blog/sitemap_entries.rb +66 -0
- data/lib/open_blog/surface_report/expected_content.rb +27 -0
- data/lib/open_blog/surface_report/http.rb +92 -0
- data/lib/open_blog/surface_report/page_checks.rb +303 -0
- data/lib/open_blog/surface_report/reach_checks.rb +141 -0
- data/lib/open_blog/surface_report/record_checks.rb +248 -0
- data/lib/open_blog/surface_report/robots.rb +49 -0
- data/lib/open_blog/surface_report.rb +223 -0
- data/lib/open_blog/syntax_css.rb +55 -0
- data/lib/open_blog/unpublish.rb +13 -0
- data/lib/open_blog/version.rb +3 -0
- data/lib/open_blog/write_post.rb +205 -0
- data/lib/open_blog.rb +91 -0
- data/lib/tasks/open_blog.rake +71 -0
- data/skills/open-blog-adopt/SKILL.md +12 -0
- data/skills/open-blog-install/SKILL.md +10 -0
- data/skills/open-blog-policy-pages/SKILL.md +10 -0
- data/skills/open-blog-publish/SKILL.md +14 -0
- data/skills/open-blog-report/SKILL.md +10 -0
- data/skills/open-blog-update/SKILL.md +16 -0
- metadata +515 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class LabelPolicy
|
|
3
|
+
def self.for(post)
|
|
4
|
+
new(post).label
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def initialize(post)
|
|
8
|
+
@post = post
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def label
|
|
12
|
+
return :none if @post.provenance_human_written?
|
|
13
|
+
notice = @post.provenance_ai_assisted? ? :ai_assisted : :ai_unknown
|
|
14
|
+
return notice if @post.provenance_ai_assisted? && OpenBlog.config.ai_label == :always
|
|
15
|
+
return notice unless OpenBlog.policy_url(:responsible_party).present?
|
|
16
|
+
return notice unless revision && @post.current_revision_identifier == revision.identifier
|
|
17
|
+
return notice unless image_digests_present?
|
|
18
|
+
return notice unless @post.approvals.where(revision_id: revision.id, facts_checked: true).exists?
|
|
19
|
+
:none
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def revision
|
|
25
|
+
return @revision if defined?(@revision)
|
|
26
|
+
@revision = if @post.published?
|
|
27
|
+
@post.public_revision
|
|
28
|
+
else
|
|
29
|
+
@post.revisions.find_by(identifier: @post.current_revision_identifier)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def image_digests_present?
|
|
34
|
+
payload = JSON.parse(revision.payload)
|
|
35
|
+
images = payload["images"] if payload.is_a?(Hash)
|
|
36
|
+
images.is_a?(Array) && images.all? { |entry| entry.is_a?(Hash) && entry["sha256"].present? }
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class MarkdownView
|
|
3
|
+
def self.render(post, base_url: OpenBlog.config.public_base_url, now: Time.current)
|
|
4
|
+
new(post, base_url: base_url, now: now).render
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def initialize(post, base_url:, now:)
|
|
8
|
+
@post, @base_url, @now = post, base_url, now
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def render
|
|
12
|
+
parts = [ "# #{plain(@post.title)}", plain(@post.description).presence, byline, *notices, body ]
|
|
13
|
+
entries = @post.faq_list
|
|
14
|
+
if entries.any?
|
|
15
|
+
parts << "## #{plain(translate('faq.heading'))}"
|
|
16
|
+
entries.each { |entry| parts.concat([ "### #{plain(entry[:question])}", plain(entry[:answer]) ]) }
|
|
17
|
+
end
|
|
18
|
+
parts << (@post.canonical_url.presence || @post.url(base: @base_url) || @post.path)
|
|
19
|
+
"#{parts.compact.join("\n\n")}\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def body
|
|
25
|
+
@post.markdown? ? @post.body_markdown.to_s : plain(@post.rich_body.to_plain_text)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def byline
|
|
29
|
+
published = ReaderDates.published_at(@post, now: @now)
|
|
30
|
+
modified = ReaderDates.modified_at(@post, now: @now)
|
|
31
|
+
parts = [ @post.author_name ]
|
|
32
|
+
parts << date(published, :published) if published
|
|
33
|
+
parts << date(modified, :updated) if modified && (!published || modified > published)
|
|
34
|
+
plain(parts.join(" · "))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def date(time, label)
|
|
38
|
+
"#{translate("dates.#{label}")} #{I18n.l(time.to_date, format: :long, locale: OpenBlog.config.locale)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def notices
|
|
42
|
+
label = LabelPolicy.for(@post)
|
|
43
|
+
lines = label == :none ? [] : [ translate("notices.#{label}") ]
|
|
44
|
+
lines << translate("notices.paid") if @post.connection_declarations.order(id: :desc).first&.third_party_paid?
|
|
45
|
+
lines.map { |line| plain(line) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def translate(key)
|
|
49
|
+
I18n.t("open_blog.#{key}", locale: OpenBlog.config.locale)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def plain(text)
|
|
53
|
+
text.to_s.gsub(/[\\`*_{}\[\]<>&!#|~+\-]/) { |character| "\\#{character}" }
|
|
54
|
+
.gsub(/^(\s*\d+)([.)])(?=\s)/) { "#{Regexp.last_match(1)}\\#{Regexp.last_match(2)}" }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Mcp
|
|
3
|
+
class Definition
|
|
4
|
+
attr_reader :name, :title, :description, :input_schema, :annotations, :scope
|
|
5
|
+
|
|
6
|
+
def initialize(name:, title:, description:, input_schema:, annotations:, scope:, handler:)
|
|
7
|
+
@name, @title, @description = name, title, description
|
|
8
|
+
@input_schema, @annotations, @scope, @handler = input_schema, annotations, scope, handler
|
|
9
|
+
@validator = ::MCP::Tool::InputSchema.new(input_schema)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(arguments, actor:, base_url: nil)
|
|
13
|
+
raise Error::Unauthenticated unless actor
|
|
14
|
+
unless actor.respond_to?(:scopes) && actor.scopes.map(&:to_s).include?(scope.to_s)
|
|
15
|
+
raise Error::ScopeRequired.new(details: [ scope.to_s ])
|
|
16
|
+
end
|
|
17
|
+
raise Error::ValidationFailed.new(details: [ "arguments" ]) unless arguments.is_a?(Hash)
|
|
18
|
+
@validator.validate_arguments(arguments)
|
|
19
|
+
result = @handler.call(arguments.deep_symbolize_keys, actor: actor, base_url: base_url)
|
|
20
|
+
JSON.parse(JSON.generate(result))
|
|
21
|
+
rescue ::MCP::Tool::InputSchema::ValidationError
|
|
22
|
+
JSON.parse(JSON.generate(ErrorSerializer.call(Error::ValidationFailed.new(details: [ "arguments" ]))))
|
|
23
|
+
rescue Error => error
|
|
24
|
+
JSON.parse(JSON.generate(ErrorSerializer.call(error)))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "stringio"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
module Mcp
|
|
6
|
+
class Dispatcher
|
|
7
|
+
CONTROLLERS = %w[posts approvals connections records findings doctor images categories tags authors series redirects adoptions faq_extractions previews pages views report].freeze
|
|
8
|
+
METHODS = %w[GET POST PUT PATCH DELETE].freeze
|
|
9
|
+
|
|
10
|
+
def self.call(controller:, action:, method:, arguments:, actor:, route_params: {}, base_url: nil)
|
|
11
|
+
raise ArgumentError, "Unknown publishing controller" unless CONTROLLERS.include?(controller.to_s)
|
|
12
|
+
verb = method.to_s.upcase
|
|
13
|
+
raise ArgumentError, "Unsupported request method" unless METHODS.include?(verb)
|
|
14
|
+
klass = "OpenBlog::Api::V1::#{controller.to_s.camelize}Controller".constantize
|
|
15
|
+
raise ArgumentError, "Unknown publishing action" unless klass.action_methods.include?(action.to_s)
|
|
16
|
+
origin = URI.parse(OpenBlog.config.public_base_url || base_url || "http://localhost")
|
|
17
|
+
env = { "REQUEST_METHOD" => verb, "SCRIPT_NAME" => "", "PATH_INFO" => "#{OpenBlog.mount_path}/api/v1/#{controller}",
|
|
18
|
+
"QUERY_STRING" => "", "SERVER_NAME" => origin.host, "SERVER_PORT" => origin.port.to_s,
|
|
19
|
+
"rack.url_scheme" => origin.scheme, "rack.input" => StringIO.new(""), "CONTENT_TYPE" => "application/json",
|
|
20
|
+
"action_dispatch.request.request_parameters" => arguments.deep_stringify_keys,
|
|
21
|
+
"action_dispatch.request.path_parameters" => route_params.symbolize_keys.merge(controller: klass.controller_path, action: action.to_s) }
|
|
22
|
+
request = ActionDispatch::Request.new(env)
|
|
23
|
+
response = ActionDispatch::Response.new
|
|
24
|
+
_status, _headers, body = klass.new.send(:dispatch_internal, action.to_s, request, response, actor: actor)
|
|
25
|
+
text = +""
|
|
26
|
+
body.each { |chunk| text << chunk }
|
|
27
|
+
text.empty? ? {} : JSON.parse(text)
|
|
28
|
+
ensure
|
|
29
|
+
body&.close if body.respond_to?(:close)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "tempfile"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
module Mcp
|
|
6
|
+
module ImageInput
|
|
7
|
+
def self.with_upload(arguments)
|
|
8
|
+
if arguments.key?(:url)
|
|
9
|
+
raise Error::ImageNotPermitted unless arguments.keys == [ :url ]
|
|
10
|
+
return yield(arguments)
|
|
11
|
+
end
|
|
12
|
+
encoded = arguments[:base64]
|
|
13
|
+
maximum = OpenBlog.config.max_image_bytes
|
|
14
|
+
raise Error::ImageNotPermitted unless encoded.is_a?(String) && encoded.bytesize <= ((maximum + 2) / 3) * 4
|
|
15
|
+
bytes = Base64.strict_decode64(encoded)
|
|
16
|
+
raise Error::ImageNotPermitted if bytes.bytesize > maximum
|
|
17
|
+
filename, content_type = arguments.values_at(:filename, :content_type)
|
|
18
|
+
raise Error::ImageNotPermitted unless filename.is_a?(String) && filename.present? && content_type.is_a?(String) && content_type.present?
|
|
19
|
+
Tempfile.create([ "open-blog-upload", ".bin" ], binmode: true) do |file|
|
|
20
|
+
file.write(bytes)
|
|
21
|
+
file.rewind
|
|
22
|
+
upload = ActionDispatch::Http::UploadedFile.new(tempfile: file, filename: filename, type: content_type)
|
|
23
|
+
yield(file: upload)
|
|
24
|
+
end
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
raise Error::ImageNotPermitted
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Mcp
|
|
3
|
+
module Registry
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def definitions
|
|
7
|
+
[
|
|
8
|
+
endpoint("list_posts", :read, "posts", :index, fields: ApiFields::POST_LIST),
|
|
9
|
+
endpoint("search_posts", :read, "posts", :index, fields: ApiFields::POST_LIST, required: [ :q ]),
|
|
10
|
+
endpoint("get_post", :read, "posts", :show, id: :required),
|
|
11
|
+
endpoint("get_post_records", :read, "records", :show, id: :required),
|
|
12
|
+
endpoint("check_post", :read, "findings", :show, id: :required),
|
|
13
|
+
endpoint("save_draft", :write, "posts", :create, fields: ApiFields::POST_WRITE, force: { publish: false }),
|
|
14
|
+
endpoint("get_preview_link", :read, "previews", :show, id: :required),
|
|
15
|
+
endpoint("publish_post", :publish, "posts", :publish, fields: ApiFields::POST_WRITE, id: :optional, create_publish: true),
|
|
16
|
+
endpoint("update_post", :publish, "posts", :update, fields: ApiFields::POST_WRITE, id: :required),
|
|
17
|
+
endpoint("correct_post", :publish, "posts", :update, fields: ApiFields::POST_WRITE, id: :required, required: [ :note ], force: { change: "correction" }),
|
|
18
|
+
endpoint("approve_revision", :publish, "approvals", :create, fields: ApiFields::APPROVAL, id: :required, required: ApiFields::APPROVAL),
|
|
19
|
+
endpoint("declare_connections", :publish, "connections", :create, fields: ApiFields::CONNECTION, id: :required,
|
|
20
|
+
properties: Schemas.connection_fields, required: %i[connections third_party_paid declared_by]),
|
|
21
|
+
endpoint("unpublish_post", :publish, "posts", :unpublish, id: :required),
|
|
22
|
+
endpoint("remove_post", :publish, "posts", :destroy, fields: ApiFields::REMOVE, id: :required),
|
|
23
|
+
upload_definition,
|
|
24
|
+
endpoint("list_categories", :read, "categories", :index, fields: ApiFields::PAGE),
|
|
25
|
+
endpoint("save_category", :write, "categories", :create, fields: ApiFields::CATEGORY, id: :optional, upsert: true),
|
|
26
|
+
endpoint("list_tags", :read, "tags", :index, fields: ApiFields::PAGE),
|
|
27
|
+
endpoint("list_authors", :read, "authors", :index, fields: ApiFields::PAGE),
|
|
28
|
+
endpoint("save_author", :write, "authors", :create, fields: ApiFields::AUTHOR, id: :optional, upsert: true),
|
|
29
|
+
endpoint("list_series", :read, "series", :index, fields: ApiFields::PAGE),
|
|
30
|
+
endpoint("save_series", :write, "series", :create, fields: ApiFields::SERIES, id: :optional, upsert: true),
|
|
31
|
+
endpoint("list_redirects", :read, "redirects", :index, fields: ApiFields::PAGE),
|
|
32
|
+
endpoint("save_redirect", :publish, "redirects", :create, fields: ApiFields::REDIRECT, required: [ :old_path ]),
|
|
33
|
+
site_page_definition("get_site_page", :read),
|
|
34
|
+
site_page_definition("save_site_page", :write),
|
|
35
|
+
page_views_definition,
|
|
36
|
+
report_definition,
|
|
37
|
+
endpoint("doctor", :read, "doctor", :show),
|
|
38
|
+
endpoint("extract_faq", :read, "faq_extractions", :create, fields: ApiFields::EXTRACTION, required: [ :body ]),
|
|
39
|
+
endpoint("adopt_post", :publish, "adoptions", :create, fields: ApiFields::ADOPTION,
|
|
40
|
+
required: %i[source_system source_id slug title body_format body])
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def endpoint(name, scope, controller, action, fields: [], id: nil, required: [], force: {}, upsert: false, create_publish: false, properties: nil)
|
|
45
|
+
properties ||= Schemas.fields(fields)
|
|
46
|
+
properties = properties.merge("id" => upsert ? { type: "integer", minimum: 1 } : Schemas.identifier) if id
|
|
47
|
+
required = required + (id == :required ? [ :id ] : [])
|
|
48
|
+
force.each { |key, value| properties[key.to_s] = properties.fetch(key.to_s).merge(const: value) if properties.key?(key.to_s) }
|
|
49
|
+
definition(name, scope, Schemas.object(properties, required: required)) do |arguments, actor:, base_url:|
|
|
50
|
+
arguments = arguments.dup
|
|
51
|
+
identity = arguments.delete(:id) if id
|
|
52
|
+
selected = upsert && identity ? :update : action
|
|
53
|
+
if create_publish && !identity
|
|
54
|
+
selected = :create
|
|
55
|
+
arguments[:publish] = true
|
|
56
|
+
end
|
|
57
|
+
arguments.merge!(force)
|
|
58
|
+
if selected == :index
|
|
59
|
+
maximum = [ OpenBlog.config.mcp.max_page_size, 100 ].min
|
|
60
|
+
arguments[:per_page] = [ arguments.fetch(:per_page, 25).to_i, maximum ].min
|
|
61
|
+
end
|
|
62
|
+
method = { index: :get, show: :get, update: :patch, destroy: :delete }.fetch(selected, :post)
|
|
63
|
+
Dispatcher.call(controller: controller, action: selected, method: method, arguments: arguments,
|
|
64
|
+
actor: actor, route_params: identity ? { id: identity } : {}, base_url: base_url)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def site_page_definition(name, scope)
|
|
69
|
+
properties = name == "save_site_page" ? Schemas.fields(ApiFields::SITE_PAGE) : {}
|
|
70
|
+
properties["kind"] = { type: "string", enum: %w[responsible_party corrections editorial ai_use] }
|
|
71
|
+
if name == "save_site_page"
|
|
72
|
+
properties["status"] = { type: "string", enum: %w[draft published] }
|
|
73
|
+
%w[title body slug].each { |field| properties[field] = Schemas.text }
|
|
74
|
+
%w[approved_by approved_on].each { |field| properties[field] = Schemas.text(nullable: true) }
|
|
75
|
+
end
|
|
76
|
+
definition(name, scope, Schemas.object(properties, required: [ :kind ])) do |arguments, actor:, base_url:|
|
|
77
|
+
input = arguments.dup
|
|
78
|
+
kind = input.delete(:kind)
|
|
79
|
+
writing = name == "save_site_page"
|
|
80
|
+
Dispatcher.call(controller: "pages", action: writing ? :update : :show, method: writing ? :put : :get,
|
|
81
|
+
arguments: input, actor: actor, route_params: { kind: kind }, base_url: base_url)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def report_definition
|
|
86
|
+
properties = { "scope" => { type: "string", enum: %w[site post all] }, "post" => Schemas.identifier,
|
|
87
|
+
"page" => { anyOf: [ { type: "integer", minimum: 1, maximum: 1_000_000 }, { type: "string", pattern: "^[1-9][0-9]*$" } ] },
|
|
88
|
+
"reach" => { anyOf: [ { type: "boolean" }, { type: "string", enum: %w[true false] } ] } }
|
|
89
|
+
definition("get_surface_report", :read, Schemas.object(properties)) do |arguments, actor:, base_url:|
|
|
90
|
+
Dispatcher.call(controller: "report", action: :show, method: :get, arguments: arguments, actor: actor, base_url: base_url)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def page_views_definition
|
|
95
|
+
properties = { "id" => Schemas.identifier }
|
|
96
|
+
ApiFields::VIEWS.each { |field| properties[field.to_s] = Schemas.text(nullable: true) }
|
|
97
|
+
ApiFields::TOP_VIEWS.each do |field|
|
|
98
|
+
maximum = field == :days ? 36500 : 100
|
|
99
|
+
properties[field.to_s] = { anyOf: [ { type: "integer", minimum: 1, maximum: maximum }, { type: "string", pattern: "^[1-9][0-9]*$" } ] }
|
|
100
|
+
end
|
|
101
|
+
schema = Schemas.object(properties).merge(oneOf: [
|
|
102
|
+
{ required: [ "id" ], not: { anyOf: [ { required: [ "days" ] }, { required: [ "limit" ] } ] } },
|
|
103
|
+
{ not: { anyOf: [ { required: [ "id" ] }, { required: [ "from" ] }, { required: [ "to" ] } ] } }
|
|
104
|
+
])
|
|
105
|
+
definition("get_page_views", :read, schema) do |arguments, actor:, base_url:|
|
|
106
|
+
input = arguments.dup
|
|
107
|
+
id = input.delete(:id)
|
|
108
|
+
Dispatcher.call(controller: "views", action: id ? :show : :top, method: :get, arguments: input,
|
|
109
|
+
actor: actor, route_params: id ? { post_id: id } : {}, base_url: base_url)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def upload_definition
|
|
114
|
+
properties = Schemas.fields(%i[url filename content_type]).merge("base64" => Schemas.text)
|
|
115
|
+
schema = Schemas.object(properties).merge(oneOf: [ { required: [ "url" ], maxProperties: 1 }, { required: %w[base64 filename content_type], maxProperties: 3 } ])
|
|
116
|
+
definition("upload_image", :write, schema) do |arguments, actor:, base_url:|
|
|
117
|
+
ImageInput.with_upload(arguments) do |input|
|
|
118
|
+
Dispatcher.call(controller: "images", action: :create, method: :post, arguments: input, actor: actor, route_params: {}, base_url: base_url)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def definition(name, scope, schema, &handler)
|
|
124
|
+
instruction = if name == "adopt_post"
|
|
125
|
+
OpenBlog::ADOPTION_INSTRUCTION
|
|
126
|
+
elsif %w[publish_post update_post correct_post approve_revision].include?(name)
|
|
127
|
+
"#{OpenBlog::APPROVAL_INSTRUCTION} #{OpenBlog::PROVENANCE_INSTRUCTION}"
|
|
128
|
+
else
|
|
129
|
+
OpenBlog::APPROVAL_POINTER
|
|
130
|
+
end
|
|
131
|
+
instruction += " #{OpenBlog::DRAFT_INSTRUCTION}" if name == "save_draft"
|
|
132
|
+
description = "#{name.tr('_', ' ').capitalize}. #{instruction}"
|
|
133
|
+
Definition.new(name: "blog_#{name}", title: name.tr("_", " ").capitalize, description: description,
|
|
134
|
+
input_schema: schema, annotations: { read_only_hint: scope == :read, destructive_hint: name == "remove_post" },
|
|
135
|
+
scope: scope, handler: handler)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Mcp
|
|
3
|
+
module Schemas
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def object(properties, required: [])
|
|
7
|
+
{ type: "object", properties: properties, required: required.map(&:to_s), additionalProperties: false }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def text(nullable: false)
|
|
11
|
+
{ type: nullable ? [ "string", "null" ] : "string" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def identifier
|
|
15
|
+
{ anyOf: [ { type: "integer", minimum: 1 }, { type: "string", minLength: 1 } ] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fields(names)
|
|
19
|
+
names.to_h { |name| [ name.to_s, field(name) ] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def field(name)
|
|
23
|
+
case name.to_sym
|
|
24
|
+
when :id then identifier
|
|
25
|
+
when :featured, :facts_checked, :third_party_paid, :dry_run then { type: "boolean" }
|
|
26
|
+
when :made_by_ai then { type: [ "boolean", "null" ] }
|
|
27
|
+
when :position then { type: "integer", minimum: -2_147_483_648, maximum: 2_147_483_647 }
|
|
28
|
+
when :series_position, :post_id then { type: [ "integer", "null" ], minimum: 1 }
|
|
29
|
+
when :page, :per_page
|
|
30
|
+
{ anyOf: [ { type: "integer", minimum: 1, maximum: 999_999_999 }, { type: "string", pattern: "^[1-9][0-9]{0,8}$" } ] }
|
|
31
|
+
when :tags, :profile_urls, :standalone_questions then { type: "array", items: text }
|
|
32
|
+
when :faq then { type: "array", items: object(fields(ApiFields::POST_NESTED.fetch(:faq)), required: %w[question answer]) }
|
|
33
|
+
when :author
|
|
34
|
+
{ anyOf: [ text, object(fields(ApiFields::POST_NESTED.fetch(:author)), required: [ "name" ]) ] }
|
|
35
|
+
when :cover_image, :social_image, :avatar
|
|
36
|
+
{ anyOf: [ { type: "null" }, image ] }
|
|
37
|
+
when :approval
|
|
38
|
+
object(fields(ApiFields::POST_NESTED.fetch(:approval)), required: %w[name facts_checked])
|
|
39
|
+
when :connections
|
|
40
|
+
object(connection_fields, required: %w[connections third_party_paid declared_by])
|
|
41
|
+
when :declaration
|
|
42
|
+
object(fields(%i[reviewer_name approved_at facts_checked declared_on declared_by declared_first_published_at]), required: %w[reviewer_name approved_at facts_checked declared_on declared_by])
|
|
43
|
+
when :imported_approval
|
|
44
|
+
object(fields(%i[reviewer_name approved_at facts_checked evidence confirmed_by]), required: %w[reviewer_name approved_at facts_checked evidence confirmed_by])
|
|
45
|
+
when :old_slugs
|
|
46
|
+
{ type: "array", items: { anyOf: [ text, object(fields(%i[slug moved_on]), required: [ "slug" ]) ] } }
|
|
47
|
+
when :image_id then { type: "integer", minimum: 1 }
|
|
48
|
+
when :type then { type: "string", enum: %w[person organization] }
|
|
49
|
+
when :body_format then { type: "string", enum: %w[markdown rich_text] }
|
|
50
|
+
when :provenance then { type: "string", enum: %w[ai_assisted human_written unknown] }
|
|
51
|
+
when :change then { type: "string", enum: %w[substantive correction maintenance] }
|
|
52
|
+
when :status then { type: "string", enum: %w[draft scheduled published archived] }
|
|
53
|
+
when :body, :description, :search_title, :search_description, :canonical_url, :cover_alt, :external_id,
|
|
54
|
+
:category, :series, :provenance_evidence, :publish_at, :first_published_at, :last_modified_at,
|
|
55
|
+
:first_published_evidence, :last_modified_evidence, :declared_first_published_at, :source_body_sha256,
|
|
56
|
+
:bio, :url, :host_reference, :new_path, :redirect_to, :moved_on
|
|
57
|
+
text(nullable: true)
|
|
58
|
+
else text
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def connection_fields
|
|
63
|
+
fields(ApiFields::CONNECTION - [ :connections ]).merge("connections" => { type: "array",
|
|
64
|
+
items: object(fields(%i[party relation]), required: %w[party relation]) })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def image
|
|
68
|
+
object(fields(%i[image_id signed_id url])).merge(minProperties: 1, maxProperties: 1)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Mcp
|
|
3
|
+
autoload :Definition, "open_blog/mcp/definition"
|
|
4
|
+
autoload :Dispatcher, "open_blog/mcp/dispatcher"
|
|
5
|
+
autoload :Registry, "open_blog/mcp/registry"
|
|
6
|
+
autoload :Schemas, "open_blog/mcp/schemas"
|
|
7
|
+
autoload :ImageInput, "open_blog/mcp/image_input"
|
|
8
|
+
|
|
9
|
+
def self.definitions
|
|
10
|
+
Registry.definitions
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.tools
|
|
14
|
+
definitions.map do |definition|
|
|
15
|
+
::MCP::Tool.define(name: definition.name, title: definition.title, description: definition.description,
|
|
16
|
+
input_schema: definition.input_schema, annotations: definition.annotations) do |server_context: nil, **arguments|
|
|
17
|
+
result = definition.call(arguments, actor: server_context&.[](:actor), base_url: server_context&.[](:base_url))
|
|
18
|
+
::MCP::Tool::Response.new([ { type: "text", text: JSON.generate(result) } ],
|
|
19
|
+
structured_content: result, error: result.key?("error"))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Operation
|
|
3
|
+
def self.run(post: nil, attributes: {}, actor:, now: Time.current, context: {}, authorize: nil)
|
|
4
|
+
current = nil
|
|
5
|
+
result = nil
|
|
6
|
+
Post.transaction(requires_new: true) do
|
|
7
|
+
resolved = PostIdentity.resolve(attributes, post: post)
|
|
8
|
+
current = resolved.persisted? ? Post.lock.find(resolved.id) : resolved
|
|
9
|
+
authorize&.call(current)
|
|
10
|
+
created = current.new_record?
|
|
11
|
+
records = yield(current, created)
|
|
12
|
+
label = current.destroyed? ? :none : LabelPolicy.for(current)
|
|
13
|
+
findings = current.destroyed? ? [] : Findings.for(current, context: context)
|
|
14
|
+
result = Result.new(post: current, created: created, records: records, label: label, findings: findings)
|
|
15
|
+
end
|
|
16
|
+
result
|
|
17
|
+
rescue Error => error
|
|
18
|
+
failure(current, error)
|
|
19
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
20
|
+
collision = error.record.is_a?(Post) && %i[slug external_id].any? do |field|
|
|
21
|
+
error.record.errors.details[field].any? { |detail| detail[:error] == :taken }
|
|
22
|
+
end
|
|
23
|
+
refusal = collision ? Error::IdentityConflict.new : Error::ValidationFailed.new(details: error.record.errors.attribute_names.map(&:to_s))
|
|
24
|
+
failure(current, refusal)
|
|
25
|
+
rescue ActiveRecord::RecordNotFound
|
|
26
|
+
failure(current, Error::NotFound.new)
|
|
27
|
+
rescue ActiveRecord::RecordNotUnique
|
|
28
|
+
failure(current, Error::IdentityConflict.new)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.failure(post, error)
|
|
32
|
+
post = post.class.find_by(id: post.id) if post&.persisted?
|
|
33
|
+
Result.new(post: post&.persisted? ? post : nil, error: error)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module PageViews
|
|
3
|
+
BOT_PATTERN = /bot|crawler|spider|slurp|preview|monitor|curl|wget|headless|facebookexternalhit|WhatsApp|ChatGPT-User|Claude-User|Perplexity-User|anthropic-ai|cohere-ai|Google-InspectionTool/i
|
|
4
|
+
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def count!(post, request, now: Time.current)
|
|
8
|
+
return unless OpenBlog.config.page_views && post.published? && request.get? && request.format.html?
|
|
9
|
+
return if request.user_agent.blank? || OpenBlog.config.page_view_bot_pattern.match?(request.user_agent)
|
|
10
|
+
return if request.headers["Sec-Purpose"].to_s.match?(/prefetch/i)
|
|
11
|
+
|
|
12
|
+
connection = PageView.connection
|
|
13
|
+
table = connection.quote_table_name(PageView.table_name)
|
|
14
|
+
connection.execute("INSERT INTO #{table} (post_id, day, views) VALUES (#{connection.quote(post.id)}, #{connection.quote(now.utc.to_date)}, 1) ON CONFLICT (post_id, day) DO UPDATE SET views = #{table}.views + 1")
|
|
15
|
+
nil
|
|
16
|
+
rescue StandardError => error
|
|
17
|
+
Rails.logger.warn("OpenBlog page view counter failed: #{error.class.name}")
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def for(post, from: nil, to: nil, now: Time.current)
|
|
22
|
+
to = date(to, :to) || now.utc.to_date
|
|
23
|
+
from = date(from, :from) || to - 29
|
|
24
|
+
invalid!(:from) if from > to
|
|
25
|
+
days = PageView.where(post_id: post.id, day: from..to).order(:day).pluck(:day, :views).map { |day, views| { day: day, views: views } }
|
|
26
|
+
{ post_id: post.id, from: from, to: to, total: days.sum { |row| row[:views] }, days: days }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def top(days: 30, limit: 5, base_url: nil, scope: Post.all, now: Time.current)
|
|
30
|
+
days = positive(days, :days, 36500)
|
|
31
|
+
limit = positive(limit, :limit, 100)
|
|
32
|
+
today = now.utc.to_date
|
|
33
|
+
totals = PageView.where(day: (today - days + 1)..today, post_id: scope.select(:id))
|
|
34
|
+
.group(:post_id).order(Arel.sql("SUM(views) DESC"), :post_id).limit(limit).pluck(:post_id, Arel.sql("SUM(views)"))
|
|
35
|
+
posts = Post.where(id: totals.map(&:first)).index_by(&:id)
|
|
36
|
+
{ days: days, posts: totals.filter_map do |id, views|
|
|
37
|
+
post = posts[id]
|
|
38
|
+
next unless post
|
|
39
|
+
{ id: id, slug: post.slug, title: post.title, url: post.url(base: OpenBlog.config.public_base_url || base_url), views: views }
|
|
40
|
+
end }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prune!(now: Time.current)
|
|
44
|
+
retention = OpenBlog.config.page_view_retention_days
|
|
45
|
+
return 0 if retention.nil?
|
|
46
|
+
invalid!(:page_view_retention_days) unless retention.is_a?(Integer) && retention.positive?
|
|
47
|
+
PageView.where("day < ?", now.utc.to_date - retention).delete_all
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def date(value, field)
|
|
51
|
+
return value if value.is_a?(Date)
|
|
52
|
+
return nil if value.nil?
|
|
53
|
+
invalid!(field) unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
54
|
+
Date.iso8601(value)
|
|
55
|
+
rescue Date::Error
|
|
56
|
+
invalid!(field)
|
|
57
|
+
end
|
|
58
|
+
private_class_method :date
|
|
59
|
+
|
|
60
|
+
def positive(value, field, maximum)
|
|
61
|
+
value = value.to_i if value.is_a?(String) && value.match?(/\A[1-9]\d*\z/)
|
|
62
|
+
invalid!(field) unless value.is_a?(Integer) && value.between?(1, maximum)
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
private_class_method :positive
|
|
66
|
+
|
|
67
|
+
def invalid!(field)
|
|
68
|
+
raise Error::ValidationFailed.new(details: [ field.to_s ])
|
|
69
|
+
end
|
|
70
|
+
private_class_method :invalid!
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Pagination
|
|
3
|
+
Page = Data.define(:records, :page, :total_pages)
|
|
4
|
+
|
|
5
|
+
def self.page(scope, page:, per_page:)
|
|
6
|
+
value = page.nil? ? 1 : page
|
|
7
|
+
unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[1-9]\d*\z/))
|
|
8
|
+
raise NotFound
|
|
9
|
+
end
|
|
10
|
+
number = value.to_i
|
|
11
|
+
raise NotFound unless number.positive? && per_page.is_a?(Integer) && per_page.positive?
|
|
12
|
+
count = scope.reorder(nil).count
|
|
13
|
+
total = [ (count.to_f / per_page).ceil, 1 ].max
|
|
14
|
+
raise NotFound if number > total
|
|
15
|
+
Page.new(records: scope.offset((number - 1) * per_page).limit(per_page).to_a, page: number, total_pages: total)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "commonmarker"
|
|
2
|
+
require "nokogiri"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
module PlainText
|
|
6
|
+
class << self
|
|
7
|
+
def from_markdown(text)
|
|
8
|
+
text_from_node(Commonmarker.parse(text.to_s.encode(Encoding::UTF_8))).strip
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def from_html(html)
|
|
12
|
+
Nokogiri::HTML.fragment(html.to_s).text
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def text_from_node(node)
|
|
18
|
+
case node.type
|
|
19
|
+
when :text, :code
|
|
20
|
+
node.literal
|
|
21
|
+
when :code_block
|
|
22
|
+
"#{node.literal.sub(/\n+\z/, "")}\n"
|
|
23
|
+
when :html_inline
|
|
24
|
+
from_html(node.literal)
|
|
25
|
+
when :html_block
|
|
26
|
+
"#{from_html(node.literal)}\n"
|
|
27
|
+
when :softbreak, :linebreak, :thematic_break
|
|
28
|
+
"\n"
|
|
29
|
+
else
|
|
30
|
+
text = node.each.map { |child| text_from_node(child) }.join
|
|
31
|
+
%i[paragraph heading table_row].include?(node.type) ? "#{text}\n" : text
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|