summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKen D'Ambrosio <ken@jots.org>2026-06-08 19:11:51 +0000
committerKen D'Ambrosio <ken@jots.org>2026-06-08 19:11:51 +0000
commit00f63c03b7c5de68aea6a2305886bc1953a722b6 (patch)
tree054ff2f9029ea57c50da6e823982648f8766ec98
parent01f52565f460a0107679999588b73b770f01a98c (diff)
Add photo search with Boolean operators
- Server-side search index built from all album.json files + people.json, cached in memory for 5 minutes. Each photo document includes filename, album path words, title, caption, camera, date parts (year/month-name/ full date), and person names. - Recursive-descent Boolean parser: AND (explicit or implicit between consecutive terms), OR, NOT, with standard precedence. - GET /search?q=... returns a photo grid (max 300 results) linking each photo back to its album lightbox. - Search box added to the site header; hidden on mobile. - Results show filename, date, person names, and album path per photo. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--app.rb157
-rw-r--r--public/css/style.css27
-rw-r--r--views/layout.erb5
-rw-r--r--views/search.erb53
4 files changed, 242 insertions, 0 deletions
diff --git a/app.rb b/app.rb
index 5172eeb..3978c54 100644
--- a/app.rb
+++ b/app.rb
@@ -18,6 +18,11 @@ THUMB_SIZE = 300
UPDATE_JOBS = {}
UPDATE_JOBS_MUTEX = Mutex.new
+SEARCH_TTL = 300 # seconds before rebuilding search index
+MONTH_NAMES = %w[january february march april may june july
+ august september october november december].freeze
+$search_cache = { index: nil, built_at: nil, mutex: Mutex.new }
+
IMAGE_EXTS = %w[jpg jpeg png gif webp heic heif tiff bmp].freeze
VIDEO_EXTS = %w[mp4 mov avi mkv webm m4v ogv].freeze
AUDIO_EXTS = %w[mp3 flac ogg wav m4a aac].freeze
@@ -270,6 +275,142 @@ helpers do
end
end
+# ── Search index ──────────────────────────────────────────────────────────────
+
+def search_index
+ $search_cache[:mutex].synchronize do
+ if $search_cache[:index].nil? ||
+ (Time.now - $search_cache[:built_at]) > SEARCH_TTL
+ $search_cache[:index] = build_search_index
+ $search_cache[:built_at] = Time.now
+ end
+ $search_cache[:index]
+ end
+end
+
+def build_search_index
+ # people lookup: rel → [person name, ...]
+ people_by_rel = {}
+ (load_people_data['people'] || {}).each do |_, p|
+ next unless p['name']
+ (p['members'] || []).each do |m|
+ (people_by_rel[m['rel']] ||= []) << p['name']
+ end
+ end
+
+ docs = []
+ dirs = [MEDIA_ROOT] + Dir.glob("#{MEDIA_ROOT}/**/*/").sort
+ dirs.each do |dir|
+ dir_rel = dir.delete_prefix(MEDIA_ROOT).delete_prefix('/')
+ data = load_album(dir)
+ dir_words = dir_rel.split('/').flat_map { |s| s.downcase.split(/[-_]/) }
+ album_title = (data['title'] || (dir_rel.empty? ? '' : File.basename(dir))).downcase
+ album_vis = data['visible'] != false
+
+ (data['files'] || {}).each do |filename, meta|
+ meta ||= {}
+ rel = dir_rel.empty? ? filename : "#{dir_rel}/#{filename}"
+
+ parts = (dir_words + [album_title,
+ filename.downcase,
+ File.basename(filename, '.*').downcase,
+ meta['title'].to_s.downcase,
+ meta['caption'].to_s.downcase,
+ meta['camera'].to_s.downcase]).reject(&:empty?)
+
+ if (ts = meta['taken_at'])
+ parts << ts[0..3]
+ if ts.length >= 7
+ m_idx = ts[5..6].to_i - 1
+ parts << ts[5..6]
+ parts << MONTH_NAMES[m_idx] if m_idx.between?(0, 11)
+ end
+ parts << ts[0..9] if ts.length >= 10
+ end
+
+ names = people_by_rel[rel] || []
+ parts.concat(names.map(&:downcase))
+
+ docs << {
+ rel: rel,
+ filename: filename,
+ dir_rel: dir_rel,
+ text: parts.join(' '),
+ taken_at: meta['taken_at'],
+ people: names,
+ visible: album_vis && meta.fetch('visible', true) != false,
+ }
+ end
+ end
+ docs
+end
+
+# ── Boolean query parser ───────────────────────────────────────────────────────
+# Grammar: expr = or_expr
+# or_expr = and_expr ('OR' and_expr)*
+# and_expr = not_expr ('AND'? not_expr)* ← AND implicit between terms
+# not_expr = 'NOT' not_expr | atom
+# atom = TERM
+
+def search_tokenize(query)
+ query.downcase.scan(/\S+/).map do |t|
+ case t.upcase
+ when 'AND' then :AND
+ when 'OR' then :OR
+ when 'NOT' then :NOT
+ else t
+ end
+ end
+end
+
+def search_parse(query)
+ toks = search_tokenize(query)
+ pos = [0]
+ search_or(toks, pos)
+end
+
+def search_or(toks, pos)
+ left = search_and(toks, pos)
+ while toks[pos[0]] == :OR
+ pos[0] += 1
+ left = [:or, left, search_and(toks, pos)]
+ end
+ left
+end
+
+def search_and(toks, pos)
+ left = search_not(toks, pos)
+ loop do
+ t = toks[pos[0]]
+ break if t.nil? || t == :OR
+ pos[0] += 1 if t == :AND # consume explicit AND; implicit falls through
+ left = [:and, left, search_not(toks, pos)]
+ end
+ left
+end
+
+def search_not(toks, pos)
+ if toks[pos[0]] == :NOT
+ pos[0] += 1
+ [:not, search_not(toks, pos)]
+ else
+ t = toks[pos[0]]
+ return [:lit, true] if t.nil? || %i[AND OR NOT].include?(t)
+ pos[0] += 1
+ [:term, t]
+ end
+end
+
+def eval_search(node, text)
+ case node[0]
+ when :or then eval_search(node[1], text) || eval_search(node[2], text)
+ when :and then eval_search(node[1], text) && eval_search(node[2], text)
+ when :not then !eval_search(node[1], text)
+ when :term then text.include?(node[1])
+ when :lit then node[1]
+ end
+end
+
# ── Public routes ──────────────────────────────────────────────────────────────
get '/' do
@@ -402,6 +543,22 @@ def slideshow_view(rel)
erb :slideshow, layout: false
end
+get '/search' do
+ q = params[:q].to_s.strip
+ @title = q.empty? ? 'Search' : "Search: #{q}"
+ @search_query = q
+
+ unless q.empty?
+ ast = search_parse(q)
+ index = search_index
+ all = index.select { |d| (admin? || d[:visible]) && eval_search(ast, d[:text]) }
+ @total = all.length
+ @results = all.first(300)
+ end
+
+ erb :search
+end
+
# ── Admin routes ───────────────────────────────────────────────────────────────
get '/admin' do
diff --git a/public/css/style.css b/public/css/style.css
index ba9b822..47147f4 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -366,6 +366,32 @@ legend { padding: 0 8px; color: var(--text-dim); font-size: .85rem; }
.sub-album-list { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.sub-album-list a { padding: 4px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); font-size: .9rem; }
+/* ── Header search ─────────────────────────────────────────────────────── */
+.header-search { flex: 1; display: flex; justify-content: center; padding: 0 20px; }
+.header-search-input {
+ width: 200px;
+ background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius);
+ color: var(--text); padding: 5px 12px; font-size: .85rem;
+ transition: width .2s, border-color .15s;
+}
+.header-search-input:focus { width: 300px; border-color: var(--accent); outline: none; }
+
+/* ── Search results page ────────────────────────────────────────────────── */
+.search-form { display: flex; gap: 8px; margin: 14px 0 8px; }
+.search-input {
+ flex: 1; max-width: 540px;
+ background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius);
+ color: var(--text); padding: 8px 12px; font-size: 1rem;
+}
+.search-input:focus { border-color: var(--accent); outline: none; }
+.search-hint { font-size: .8rem; color: var(--text-dim); margin: 0 0 18px; }
+.search-hint code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-size: .85em; }
+.search-card .card-meta { padding: 6px 8px 8px; }
+.search-album-path { font-size: .72rem; color: var(--text-dim); margin-top: 3px;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.search-album-path a { color: var(--text-dim); }
+.search-album-path a:hover { color: var(--accent); }
+
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
:root { --thumb: 140px; --gap: 8px; }
@@ -373,6 +399,7 @@ legend { padding: 0 8px; color: var(--text-dim); font-size: .85rem; }
.lb-prev { left: 4px; }
.lb-next { right: 4px; }
.files-table { font-size: .78rem; }
+ .header-search { display: none; }
}
/* ── Unplayable originals (admin-only) ─────────────────────────────────── */
diff --git a/views/layout.erb b/views/layout.erb
index e0c5399..7069e76 100644
--- a/views/layout.erb
+++ b/views/layout.erb
@@ -16,6 +16,11 @@
<body>
<header class="site-header">
<a href="/browse/" class="site-logo">Albumen</a>
+ <form action="/search" method="get" class="header-search">
+ <input type="search" name="q" placeholder="Search…" autocomplete="off"
+ value="<%= defined?(@search_query) ? ERB::Util.html_escape(@search_query.to_s) : '' %>"
+ class="header-search-input">
+ </form>
<nav class="site-nav">
<% if FACES_ENABLED %><a href="/people">People</a><% end %>
<% if admin? %>
diff --git a/views/search.erb b/views/search.erb
new file mode 100644
index 0000000..1010284
--- /dev/null
+++ b/views/search.erb
@@ -0,0 +1,53 @@
+<div class="album-header">
+ <h1>Search</h1>
+ <form action="/search" method="get" class="search-form">
+ <input type="search" name="q" value="<%= ERB::Util.html_escape(@search_query.to_s) %>"
+ placeholder="e.g. ken 2004 OR carol AND NOT vacation"
+ class="search-input" autofocus autocomplete="off">
+ <button type="submit" class="btn">Search</button>
+ </form>
+ <p class="search-hint">
+ Searches filename, album, caption, date, and people names.
+ Consecutive terms are AND'd. Operators: <code>AND</code> <code>OR</code> <code>NOT</code>.
+ Example: <code>ken OR carol AND NOT vacation</code>
+ </p>
+</div>
+
+<% if @search_query && !@search_query.empty? %>
+ <% if @results %>
+ <p class="update-hint">
+ <%= @total %> result<%= @total == 1 ? '' : 's' %>
+ <% if @total > @results.length %>(showing first <%= @results.length %>)<% end %>
+ </p>
+ <% if @results.empty? %>
+ <p class="empty-album">No photos matched "<%= ERB::Util.html_escape(@search_query) %>".</p>
+ <% else %>
+ <div class="grid">
+ <% @results.each do |r| %>
+ <% album_url = r[:dir_rel].empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(r[:dir_rel])}" %>
+ <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(r[:filename]) %>"
+ class="card search-card" style="text-decoration:none">
+ <div class="thumb-wrap">
+ <img src="/thumb/<%= ERB::Util.html_escape(r[:rel]) %>" loading="lazy"
+ alt="<%= ERB::Util.html_escape(r[:filename]) %>">
+ </div>
+ <div class="card-meta">
+ <div class="filename"><%= ERB::Util.html_escape(r[:filename]) %></div>
+ <% if r[:taken_at] %>
+ <div class="card-caption"><%= ERB::Util.html_escape(r[:taken_at][0..9]) %></div>
+ <% end %>
+ <% unless r[:people].empty? %>
+ <div class="card-caption"><%= ERB::Util.html_escape(r[:people].join(', ')) %></div>
+ <% end %>
+ <div class="search-album-path">
+ <a href="<%= album_url %>" onclick="event.stopPropagation()">
+ <%= ERB::Util.html_escape(r[:dir_rel].empty? ? '(root)' : r[:dir_rel]) %>
+ </a>
+ </div>
+ </div>
+ </a>
+ <% end %>
+ </div>
+ <% end %>
+ <% end %>
+<% end %>