From b24a8cc377aa5ec2f1a2765330854201ec98d150 Mon Sep 17 00:00:00 2001 From: Petr Date: Thu, 25 Jun 2026 22:04:44 +0200 Subject: [PATCH] feat: add fulltext search with ?q= parameter Filter posts by case-insensitive match in title and body. Extracted filter_by_lang and search_posts helpers to keep complexity in check. --- CHANGELOG.md | 10 ++++++++++ lib/volumen/server.rb | 23 +++++++++++++++++++---- test/server_test.rb | 25 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afa0f8..50a8c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CSRF protection on `POST /admin/preview`. - Symlink-safe containment in `Store#media_file` via `File.realpath`. +### Added + +- **Scheduled publishing:** a `publish_at` frontmatter field hides posts from the + public API and feeds until their publication date arrives. The admin form + includes a Publish at date picker. +- **JSON Feed:** `GET /api/volumen/feed.json` serves a jsonfeed.org v1.1 feed + with `content_html` and `date_published` per item. +- **Fulltext search:** `GET /api/volumen/posts?q=…` filters posts by + case-insensitive match in title and body. + ### Changed - Split `lib/volumen/server.rb` into `api_routes.rb` and `admin_routes.rb` modules, diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb index d2667c8..15d2b3d 100644 --- a/lib/volumen/server.rb +++ b/lib/volumen/server.rb @@ -102,14 +102,29 @@ module Volumen def filtered_posts posts = published_posts - if params["lang"] - lang = params["lang"] - posts = posts.select { |post| post.lang == lang || post.all_langs? } - end + posts = filter_by_lang(posts) posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"] + posts = search_posts(posts) if params["q"] posts end + def filter_by_lang(posts) + return posts unless params["lang"] + + lang = params["lang"] + posts.select { |post| post.lang == lang || post.all_langs? } + end + + def search_posts(posts) + query = params["q"].to_s.strip.downcase + return posts if query.empty? + + posts.select do |post| + post.title.to_s.downcase.include?(query) || + post.body.downcase.include?(query) + end + end + def tag_counts counts = Hash.new(0) published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } } diff --git a/test/server_test.rb b/test/server_test.rb index af3d59d..224d975 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -153,4 +153,29 @@ class ServerTest < Minitest::Test assert_includes data["items"].first["content_html"], "world" refute_includes data["items"].map { |i| i["title"] }, "Draft" end + + def test_search_by_title + get "/api/volumen/posts?q=Hello" + data = JSON.parse(last_response.body) + assert_equal 1, data["total"] + assert_equal "hello", data["posts"].first["slug"] + end + + def test_search_by_body + get "/api/volumen/posts?q=world" + data = JSON.parse(last_response.body) + assert_equal 1, data["total"] + end + + def test_search_no_match + get "/api/volumen/posts?q=nonexistent" + data = JSON.parse(last_response.body) + assert_equal 0, data["total"] + end + + def test_search_is_case_insensitive + get "/api/volumen/posts?q=HELLO" + data = JSON.parse(last_response.body) + assert_equal 1, data["total"] + end end