diff options
Diffstat (limited to 'app.rb')
| -rw-r--r-- | app.rb | 157 |
1 files changed, 157 insertions, 0 deletions
@@ -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 |
