From 51ef51bbe3b741a76d1d44b8ce8c1d8a07427e1f Mon Sep 17 00:00:00 2001 From: Harald Eilertsen Date: Tue, 10 Oct 2023 21:10:19 +0200 Subject: Updates for Ramaskrik 2023 - Reorg, create lib dir and move room-schedule and events there - Add .ics template to generate program as an iCalendar stream. - Add some info to html template, and instructions to html template. - Add footer to html template, with timestamp for when the view was generated. - Fix bug that messed up the layout if the input data was not already sorted. - Update readme with usage instructions. --- README.md | 41 +++++++++ index.html.erb | 136 ++++++++++++++++++++++++++-- lib/events.rb | 28 ++++++ lib/room-schedule.rb | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++ program.ics.erb | 17 ++++ ramaskrik-program.rb | 106 +++++++++++++++------- room-schedule.rb | 248 --------------------------------------------------- 7 files changed, 541 insertions(+), 283 deletions(-) create mode 100644 lib/events.rb create mode 100644 lib/room-schedule.rb create mode 100644 program.ics.erb mode change 100644 => 100755 ramaskrik-program.rb delete mode 100644 room-schedule.rb diff --git a/README.md b/README.md index ed25d3c..e618284 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,47 @@ Et lite script jeg laget for å gjøre det enklere for meg selv å få oversikt Siden som genereres av scriptet krever en nettleser med SVG 1.1 støtte, noe [de fleste](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject#Browser_compatibility) skulle ha i dag. +## Bruksanvisning + +Programmet tar en `.json` fil med data som input. Dataene må være en json liste (array) hvor hvert object i listen er en visning. Hver visning må ha følgende felter: + +- title: tekst, Filmens tittel +- venue: telst, Sal/auditorium/rom for visningen +- start\_time: tid+dato i et noenlunde maskinlesbart standardformat (ISO8601 er fint!) +- duration: Visningens lengde i sekunder +- image: url til filmplakat (valgfritt) + +Her er et eksempel fra en fil: + +```json +[ + { + "title":"Satanic Hispanics", + "venue":"Storsalen", + "start_time":"2023-10-19 10:30:00 +0200", + "duration":"6300", + "image":"https://mff.dx.no/132216.jpeg?w=270&h=480&fit=crop&auto=compress" + }, + { + ... + } +] +``` + +Kjør programmet slik: + +``` +% ruby ramaskrik-program.rb +``` + +Den skal da generere en `index.html` og en `program.ics` (kalender) fil i samme katalog. + +Last opp disse til et egnet sted på en webserver, og du har ditt eget ramaskrik program. + +Programmet kan selvsagt brukes til å lage andre tilsvarende programoversikter også. Bare +tilpass koden og malene etter ønske. + + ## LISENS Copyright (C) 2018 [Harald Eilertsen](haraldei@anduin.net) diff --git a/index.html.erb b/index.html.erb index f1dfbf0..6b8630e 100644 --- a/index.html.erb +++ b/index.html.erb @@ -1,12 +1,138 @@ - Ramaskrik 2018 programoversikt + + + <%= title %> + -

Ramaskrik 2018 programoversikt

- <% generate_graphs do |graph| %> - <%= graph.burn_svg_only() %> - <% end %> +

<%= title %>

+
+

Dette er en annen visning av programmet for skrekkfilmfestivalen + Ramaskrik 2023. Jeg laget det for + meg selv, for å få en bedre oversikt over hvilke filmer som går hvor og når. Offisielt program finner du + på Ramaskrik sine egne websider. Denne siden ble generert + <%= Date.today.strftime('%d.%m.%Y') %>, evt. endringer i programmet etter det er ikke tatt med.

+ +

Du kan merke hvilke filmer du ønsker å se ved å klikke/tappe på de. Siden vil huske hvilke filmer du + har valgt, så du vil kunne bruke denne siden til å planlegge ruten din igjennom festivalen hvis du vil. + (Ingen data sendes til websiden, hvilke filmer du har valgt vil kun lagres i din egen nettleser.)

+ +

Programmet er også tilgjengelig som en kalenderfil. Klikk her + for å legge den til i kalenderen din.

+ +

Har du spørsmål, ta kontakt med meg via epost.

+
+
+ <% eventlist.each do |date, events| %> +

<%= date.strftime("%A %d.%m.%Y") %>

+
+ <% start_time = events.start_time.to_i %> + <% end_time = events.end_time.to_i %> + <% events.venues.sort.each do |venue| %> +
+

Kl.

+
+ <% (start_time..end_time).step(3600 / 4) do |time| %> +
+ <%= Time.at(time).strftime('%H:%M') %> +
+ <% end %> +
+
+
+

<%= venue %>

+
+ <% events.events.select{ |e| e.venue == venue }.each do |e| %> +
+
+ <% if e.image_url %> + + <% end %> +
+
+
<%= e.title %>
+
+ + - + +
+
+
+ <% end %> +
+
+ <% end %> +
+ <% end %> +
+ + diff --git a/lib/events.rb b/lib/events.rb new file mode 100644 index 0000000..e647930 --- /dev/null +++ b/lib/events.rb @@ -0,0 +1,28 @@ +require 'time' + +module Events + class Event + + attr_reader :start_time + attr_reader :duration + attr_reader :title + attr_reader :venue + attr_reader :image_url + + def initialize(attrs) + @start_time = Time.parse(attrs['start_time']) + @duration = attrs['duration'].to_i + @title = attrs['title'] + @venue = attrs['venue'] + @image_url = attrs['image'] unless attrs['image'].length < 10 + end + + def date + @start_time.to_date + end + + def slug + "#{start_time.to_i}-#{title.downcase.gsub(/[^a-z0-9_-]/, '')}" + end + end +end diff --git a/lib/room-schedule.rb b/lib/room-schedule.rb new file mode 100644 index 0000000..fd4e282 --- /dev/null +++ b/lib/room-schedule.rb @@ -0,0 +1,248 @@ +require 'date' +require 'time' +require 'SVG/Graph/Plot' + +module SVG + module Graph + class RoomSchedule < Graph + # In addition to the defaults set by Graph::initialize and + # Plot::set_defaults, sets: + # [x_label_format] '%Y-%m-%d %H:%M:%S' + # [popup_format] '%Y-%m-%d %H:%M:%S' + def set_defaults + init_with( + :x_label_format => '%Y-%m-%d %H:%M:%S', + :popup_format => '%Y-%m-%d %H:%M:%S', + :scale_x_divisions => false, + :scale_x_integers => false, + :bar_gap => true + ) + end + + # The format string use do format the X axis labels. + # See Time::strformat + attr_accessor :x_label_format + # Use this to set the spacing between dates on the axis. The value + # must be of the form + # "\d+ ?(days|weeks|months|years|hours|minutes|seconds)?" + # + # EG: + # + # graph.timescale_divisions = "2 weeks" + # + # will cause the chart to try to divide the X axis up into segments of + # two week periods. + attr_accessor :timescale_divisions + # The formatting used for the popups. See x_label_format + attr_accessor :popup_format + attr_accessor :scale_x_divisions + attr_accessor :scale_x_integers + attr_accessor :bar_gap + + protected + + def format x, y + Time.at( x ).strftime( popup_format ) + end + + def get_x_labels + rv = get_x_values.collect { |v| v.strftime( x_label_format ) } + end + + def y_label_offset( height ) + height / -2.0 + end + + def get_y_labels + @data.map {|room| room[:title] } + end + + def draw_data + bargap = bar_gap ? (field_height < 10 ? field_height / 2 : 10) : 0 + subbar_height = field_height - bargap + + field_count = 1 + y_mod = (subbar_height / 2) + (font_size / 2) + min,max,div = x_range + scale = (@graph_width) / (max - min).to_f + @data.each do |room| + room[:data].each do |entry| + x_start = (entry.start_time - min).to_f + x_end = (entry.end_time - min).to_f + y = @graph_height - (field_height * field_count) + bar_width = (x_end - x_start) * scale + bar_start = x_start * scale + + event = @graph.add_element( "rect", { + "x" => bar_start.to_s, + "y" => y.to_s, + "width" => bar_width.to_s, + "height" => subbar_height.to_s, + "class" => "fill#{field_count+1}" + }) + switch = @graph.add_element("switch") + fo = switch.add_element("foreignObject", { + "x" => (bar_start + @font_size / 2).to_s, + "y" => y.to_s, + "width" => bar_width.to_s, + "height" => subbar_height.to_s, + "requiredExtensions" => "http://www.w3.org/1999/xhtml", + }) + body = fo.add_element("body", { + "xmlns" => "http://www.w3.org/1999/xhtml", + }) + p = body.add_element("p", { + "class" => "event-title", + }) + if entry.link + p = p.add_element("a", { "href" => entry.link }) + end + p.add_text(entry.title) + t = switch.add_element("text", { + "x" => (bar_start + @font_size / 2).to_s, + "y" => (y + 2 * @font_size).to_s, + }) + t.add_text(entry.title) + end + field_count += 1 + end + end + + def get_css + return < +<% eventlist.each do |date, events| %> +<% events.events.each do |e| %> +BEGIN:VEVENT +UUID:<%= e.slug %> +DTSTAMP;TZID=Europe/Oslo:<%= DateTime.now.strftime('%Y%m%dT%H%M%S') %> +DTSTART;TZID=Europe/Oslo:<%= e.start_time.strftime('%Y%m%dT%H%M%S') %> +DURATION:PT<%= e.duration %>S +SUMMARY:<%= e.title %> +LOCATION:<%= e.venue %> +END:VEVENT +<% end %> +<% end %> +END:VCALENDAR diff --git a/ramaskrik-program.rb b/ramaskrik-program.rb old mode 100644 new mode 100755 index 3c56edc..5dfe5d5 --- a/ramaskrik-program.rb +++ b/ramaskrik-program.rb @@ -1,3 +1,5 @@ +#!/usr/bin/env ruby + # Ramaskrik Program Schedule plotter. # Copyright (C) 2018 Harald Eilertsen # @@ -14,42 +16,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +$LOAD_PATH << File.join(File.dirname( __FILE__ ), "lib") + require 'date' +require 'delegate' require 'erb' +require 'events' +require 'json' +require 'scrapers/ramaskrik' require 'nokogiri' require 'open-uri' -require 'room-schedule' +#require 'room-schedule' require 'uri' -class Movie - attr_reader :title, :link, :start_time, :end_time, :venue - - def initialize(node) - @title = node.css("h4").text.strip - @link = URI.join("https://ramaskrik.no", node.css("h4 a").attribute('href').value) - @start_time = DateTime.parse(node.css(".date").attribute('title').value) - @end_time = DateTime.parse(@start_time.strftime('%F') + node.css(".prgtype-endTime").text.strip.sub(/\w+/, '')) - @end_time += 1 if @end_time < @start_time - @venue = node.css(".place").text.strip - end - - def to_s - "#{title} #{start_time} - #{end_time}, #{venue}" - end -end - -def scrape_program - doc = Nokogiri::HTML(open("https://ramaskrik.no/program/")) - doc.css(".kultur-type-movie").map { |movie| Movie.new(movie) } -end - -def generate_graphs - days = scrape_program.group_by { |movie| movie.start_time.strftime("%A %d.%m.%Y") } - days.map do |d, movies| - rooms = movies.group_by { |m| m.venue }.delete_if { |t, _| t == "Ramaskrik" } +def generate_graphs(events) + days = events.group_by { |event| event.start_time.strftime("%A %d.%m.%Y") } + days.map do |day, events| + rooms = events.group_by { |e| e.venue } graph = SVG::Graph::RoomSchedule.new({ - graph_title: d, + graph_title: day, show_graph_title: true, show_x_guidelines: true, width: 1280, @@ -67,6 +53,66 @@ def generate_graphs end end -t = ERB.new(IO.read("index.html.erb")) -IO.write("index.html", t.result(binding)) +class EventDecorator < SimpleDelegator + attr_reader :offset + attr_reader :height + + def calc_offsets(time_offset) + @offset = (start_time - time_offset).to_i / 50 + @height = duration / 50; + start_time + duration + end +end + +class SortedEventList + attr_reader :venues + attr_reader :events + attr_reader :start_time + attr_reader :end_time + + def initialize(events) + @venues = events.map{ |e| e.venue }.uniq + + @events = events + .sort{ |a,b| a.start_time - b.start_time } + .map{ |e| EventDecorator.new(e) } + + @start_time = @events.first.start_time - (@events.first.start_time.min * 60) + + @events.group_by{|e| e.venue}.each do |venue, events| + time_offset = start_time + time_end = start_time + events.each do |event| + time_end = event.calc_offsets(time_offset) + end + + @end_time = time_end if @end_time.nil? || @end_time < time_end + end + end +end + +def import_events_from_json(input) + JSON + .parse(input) + .map{ |obj| Events::Event.new(obj) } + .group_by{ |e| e.date } +end + +def make_sorted_event_lists_by_date(events_by_date) + new_list = {} + events_by_date.each do |date, events| + new_list[date] = SortedEventList.new(events) + end + + new_list +end + +title = "Ramaskrik 2023 - Program" + +eventlist = make_sorted_event_lists_by_date(import_events_from_json(IO.read(ARGV[0]))) + +t_html = ERB.new(IO.read("index.html.erb"), trim_mode: '>') +IO.write("index.html", t_html.result(binding)) +t_ics = ERB.new(IO.read("program.ics.erb"), trim_mode: '<>') +IO.write("program.ics", t_ics.result(binding).gsub(/\n/, "\r\n")) diff --git a/room-schedule.rb b/room-schedule.rb deleted file mode 100644 index fd4e282..0000000 --- a/room-schedule.rb +++ /dev/null @@ -1,248 +0,0 @@ -require 'date' -require 'time' -require 'SVG/Graph/Plot' - -module SVG - module Graph - class RoomSchedule < Graph - # In addition to the defaults set by Graph::initialize and - # Plot::set_defaults, sets: - # [x_label_format] '%Y-%m-%d %H:%M:%S' - # [popup_format] '%Y-%m-%d %H:%M:%S' - def set_defaults - init_with( - :x_label_format => '%Y-%m-%d %H:%M:%S', - :popup_format => '%Y-%m-%d %H:%M:%S', - :scale_x_divisions => false, - :scale_x_integers => false, - :bar_gap => true - ) - end - - # The format string use do format the X axis labels. - # See Time::strformat - attr_accessor :x_label_format - # Use this to set the spacing between dates on the axis. The value - # must be of the form - # "\d+ ?(days|weeks|months|years|hours|minutes|seconds)?" - # - # EG: - # - # graph.timescale_divisions = "2 weeks" - # - # will cause the chart to try to divide the X axis up into segments of - # two week periods. - attr_accessor :timescale_divisions - # The formatting used for the popups. See x_label_format - attr_accessor :popup_format - attr_accessor :scale_x_divisions - attr_accessor :scale_x_integers - attr_accessor :bar_gap - - protected - - def format x, y - Time.at( x ).strftime( popup_format ) - end - - def get_x_labels - rv = get_x_values.collect { |v| v.strftime( x_label_format ) } - end - - def y_label_offset( height ) - height / -2.0 - end - - def get_y_labels - @data.map {|room| room[:title] } - end - - def draw_data - bargap = bar_gap ? (field_height < 10 ? field_height / 2 : 10) : 0 - subbar_height = field_height - bargap - - field_count = 1 - y_mod = (subbar_height / 2) + (font_size / 2) - min,max,div = x_range - scale = (@graph_width) / (max - min).to_f - @data.each do |room| - room[:data].each do |entry| - x_start = (entry.start_time - min).to_f - x_end = (entry.end_time - min).to_f - y = @graph_height - (field_height * field_count) - bar_width = (x_end - x_start) * scale - bar_start = x_start * scale - - event = @graph.add_element( "rect", { - "x" => bar_start.to_s, - "y" => y.to_s, - "width" => bar_width.to_s, - "height" => subbar_height.to_s, - "class" => "fill#{field_count+1}" - }) - switch = @graph.add_element("switch") - fo = switch.add_element("foreignObject", { - "x" => (bar_start + @font_size / 2).to_s, - "y" => y.to_s, - "width" => bar_width.to_s, - "height" => subbar_height.to_s, - "requiredExtensions" => "http://www.w3.org/1999/xhtml", - }) - body = fo.add_element("body", { - "xmlns" => "http://www.w3.org/1999/xhtml", - }) - p = body.add_element("p", { - "class" => "event-title", - }) - if entry.link - p = p.add_element("a", { "href" => entry.link }) - end - p.add_text(entry.title) - t = switch.add_element("text", { - "x" => (bar_start + @font_size / 2).to_s, - "y" => (y + 2 * @font_size).to_s, - }) - t.add_text(entry.title) - end - field_count += 1 - end - end - - def get_css - return <