diff options
-rw-r--r-- | lib/postrunner/ActivitiesDB.rb | 23 | ||||
-rw-r--r-- | lib/postrunner/ActivityLink.rb | 2 | ||||
-rw-r--r-- | lib/postrunner/ActivityListView.rb | 19 | ||||
-rw-r--r-- | lib/postrunner/ActivityView.rb | 15 | ||||
-rw-r--r-- | lib/postrunner/DirUtils.rb | 33 | ||||
-rw-r--r-- | lib/postrunner/EventList.rb | 2 | ||||
-rw-r--r-- | lib/postrunner/FFS_Activity.rb | 308 | ||||
-rw-r--r-- | lib/postrunner/FFS_Device.rb | 106 | ||||
-rw-r--r-- | lib/postrunner/FitFileStore.rb | 349 | ||||
-rw-r--r-- | lib/postrunner/Main.rb | 142 | ||||
-rw-r--r-- | lib/postrunner/PersonalRecords.rb | 322 | ||||
-rw-r--r-- | lib/postrunner/RecordListPageView.rb | 12 | ||||
-rw-r--r-- | spec/ActivitySummary_spec.rb | 19 | ||||
-rw-r--r-- | spec/FitFileStore_spec.rb | 133 | ||||
-rw-r--r-- | spec/FlexiTable_spec.rb | 2 | ||||
-rw-r--r-- | spec/PersonalRecords_spec.rb | 206 | ||||
-rw-r--r-- | spec/PostRunner_spec.rb | 81 | ||||
-rw-r--r-- | spec/View_spec.rb | 2 | ||||
-rw-r--r-- | spec/spec_helper.rb | 71 |
19 files changed, 1589 insertions, 258 deletions
diff --git a/lib/postrunner/ActivitiesDB.rb b/lib/postrunner/ActivitiesDB.rb index 608a0e4..c98af2d 100644 --- a/lib/postrunner/ActivitiesDB.rb +++ b/lib/postrunner/ActivitiesDB.rb @@ -3,7 +3,7 @@ # # = ActivitiesDB.rb -- PostRunner - Manage the data from your Garmin sport devices. # -# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org> +# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -68,7 +68,6 @@ module PostRunner NavButtonDef.new('record.png', "records-0.html") ]) - @records = PersonalRecords.new(self) sync if sync_needed end @@ -140,7 +139,6 @@ module PostRunner succ = successor(activity) @activities.delete(activity) - @records.delete_activity(activity) # The HTML activity views contain links to their predecessors and # successors. After deleting an activity, we need to re-generate these @@ -167,16 +165,6 @@ module PostRunner sync end - def check - @records.delete_all_records - @activities.sort do |a1, a2| - a1.timestamp <=> a2.timestamp - end.each { |a| a.check } - @records.sync - # Ensure that HTML index is up-to-date. - ActivityListView.new(self).update_index_pages - end - def ref_by_fit_file(fit_file) i = 1 @activities.each do |activity| @@ -278,14 +266,6 @@ module PostRunner puts ActivityListView.new(self).to_s end - def show_records - puts @records.to_s - end - - def activity_records(activity) - @records.activity_records(activity) - end - # Launch a web browser and show an HTML file. # @param html_file [String] file name of the HTML file to show def show_in_browser(html_file) @@ -336,7 +316,6 @@ module PostRunner Log.fatal "Cannot write archive file '#{@archive_file}': #{$!}" end - @records.sync ActivityListView.new(self).update_index_pages end diff --git a/lib/postrunner/ActivityLink.rb b/lib/postrunner/ActivityLink.rb index 9c368b0..77898e6 100644 --- a/lib/postrunner/ActivityLink.rb +++ b/lib/postrunner/ActivityLink.rb @@ -30,7 +30,7 @@ module PostRunner doc.unique(:activitylink_style) { doc.style(style) } doc.a(@activity.name, { :class => 'activity_link', - :href => @activity.fit_file[0..-5] + '.html' }) + :href => @activity.html_file_name(false) }) if @show_record_icon && @activity.has_records? doc.img(nil, { :src => 'icons/record-small.png', :style => 'vertical-align:middle' }) diff --git a/lib/postrunner/ActivityListView.rb b/lib/postrunner/ActivityListView.rb index 8e38b93..11ae0b9 100644 --- a/lib/postrunner/ActivityListView.rb +++ b/lib/postrunner/ActivityListView.rb @@ -24,12 +24,12 @@ module PostRunner include Fit4Ruby::Converters - def initialize(db) - @db = db - @unit_system = @db.cfg[:unit_system] + def initialize(ffs) + @ffs = ffs + @unit_system = @ffs.store['config']['unit_system'] @page_size = 20 @page_no = -1 - @last_page = (@db.activities.length - 1) / @page_size + @last_page = (@ffs.activities.length - 1) / @page_size end def update_index_pages @@ -46,7 +46,7 @@ module PostRunner private def generate_html_index_page(page_index) - views = @db.views + views = @ffs.views views.current_page = 'index.html' pages = PagingButtons.new((0..@last_page).map do |i| @@ -59,7 +59,8 @@ module PostRunner @view.doc.head { @view.doc.style(style) } body(@view.doc) - output_file = File.join(@db.cfg[:html_dir], pages.current_page) + output_file = File.join(@ffs.store['config']['html_dir'], + pages.current_page) @view.write(output_file) end @@ -85,9 +86,9 @@ module PostRunner { :halign => :right } ]) t.body - activities = @page_no == -1 ? @db.activities : - @db.activities[(@page_no * @page_size).. - ((@page_no + 1) * @page_size - 1)] + activities = @page_no == -1 ? @ffs.activities : + @ffs.activities[(@page_no * @page_size).. + ((@page_no + 1) * @page_size - 1)] activities.each do |a| t.row([ i += 1, diff --git a/lib/postrunner/ActivityView.rb b/lib/postrunner/ActivityView.rb index 9474089..4f2477e 100644 --- a/lib/postrunner/ActivityView.rb +++ b/lib/postrunner/ActivityView.rb @@ -27,26 +27,25 @@ module PostRunner def initialize(activity, unit_system) @activity = activity - db = @activity.db + ffs = @activity.store['file_store'] @unit_system = unit_system - views = db.views + views = ffs.views views.current_page = nil # Sort activities in reverse order so the newest one is considered the # last report by the pagin buttons. - activities = db.activities.sort do |a1, a2| + activities = ffs.activities.sort do |a1, a2| a1.timestamp <=> a2.timestamp end - pages = PagingButtons.new(activities.map do |a| - "#{a.fit_file[0..-5]}.html" - end, false) - pages.current_page = "#{@activity.fit_file[0..-5]}.html" + pages = PagingButtons.new( + activities.map { |a| a.html_file_name(false) }, false) + pages.current_page = @activity.html_file_name(false) super("PostRunner Activity: #{@activity.name}", views, pages) generate_html(@doc) - write(File.join(db.cfg[:html_dir], pages.current_page)) + write(@activity.html_file_name) end private diff --git a/lib/postrunner/DirUtils.rb b/lib/postrunner/DirUtils.rb new file mode 100644 index 0000000..a37af7a --- /dev/null +++ b/lib/postrunner/DirUtils.rb @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = DirUtils.rb -- PostRunner - Manage the data from your Garmin sport devices. +# +# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'fileutils' + +module PostRunner + + module DirUtils + + def create_directory(dir, name) + return if Dir.exists?(dir) + + Log.info "Creating #{name} directory #{dir}" + begin + FileUtils.mkdir_p(dir) + rescue StandardError + Log.fatal "Cannot create #{name} directory #{dir}: #{$!}" + end + end + + end + +end + diff --git a/lib/postrunner/EventList.rb b/lib/postrunner/EventList.rb index 06100ce..3ebb757 100644 --- a/lib/postrunner/EventList.rb +++ b/lib/postrunner/EventList.rb @@ -3,7 +3,7 @@ # # = EventList.rb -- PostRunner - Manage the data from your Garmin sport devices. # -# Copyright (c) 2015 by Chris Schlaeger <cs@taskjuggler.org> +# Copyright (c) 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as diff --git a/lib/postrunner/FFS_Activity.rb b/lib/postrunner/FFS_Activity.rb new file mode 100644 index 0000000..309db1e --- /dev/null +++ b/lib/postrunner/FFS_Activity.rb @@ -0,0 +1,308 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = FFS_Activity.rb -- PostRunner - Manage the data from your Garmin sport devices. +# +# Copyright (c) 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'fit4ruby' +require 'perobs' + +require 'postrunner/ActivitySummary' +require 'postrunner/DataSources' +require 'postrunner/EventList' +require 'postrunner/ActivityView' +require 'postrunner/Schema' +require 'postrunner/QueryResult' +require 'postrunner/DirUtils' + +module PostRunner + + # The FFS_Activity objects can store a reference to the FIT file data and + # caches some frequently used values. In some cases the cached values can be + # used to overwrite the data from the FIT file. + class FFS_Activity < PEROBS::Object + + include DirUtils + + @@Schemata = { + 'long_date' => Schema.new('long_date', 'Date', + { :func => 'timestamp', + :column_alignment => :left, + :format => 'date_with_weekday' }), + 'sub_type' => Schema.new('sub_type', 'Subtype', + { :func => 'activity_sub_type', + :column_alignment => :left }), + 'type' => Schema.new('type', 'Type', + { :func => 'activity_type', + :column_alignment => :left }) + } + + ActivityTypes = { + 'generic' => 'Generic', + 'running' => 'Running', + 'cycling' => 'Cycling', + 'transition' => 'Transition', + 'fitness_equipment' => 'Fitness Equipment', + 'swimming' => 'Swimming', + 'basketball' => 'Basketball', + 'soccer' => 'Soccer', + 'tennis' => 'Tennis', + 'american_football' => 'American Football', + 'walking' => 'Walking', + 'cross_country_skiing' => 'Cross Country Skiing', + 'alpine_skiing' => 'Alpine Skiing', + 'snowboarding' => 'Snowboarding', + 'rowing' => 'Rowing', + 'mountaineering' => 'Mountaneering', + 'hiking' => 'Hiking', + 'multisport' => 'Multisport', + 'paddling' => 'Paddling', + 'all' => 'All' + } + ActivitySubTypes = { + 'generic' => 'Generic', + 'treadmill' => 'Treadmill', + 'street' => 'Street', + 'trail' => 'Trail', + 'track' => 'Track', + 'spin' => 'Spin', + 'indoor_cycling' => 'Indoor Cycling', + 'road' => 'Road', + 'mountain' => 'Mountain', + 'downhill' => 'Downhill', + 'recumbent' => 'Recumbent', + 'cyclocross' => 'Cyclocross', + 'hand_cycling' => 'Hand Cycling', + 'track_cycling' => 'Track Cycling', + 'indoor_rowing' => 'Indoor Rowing', + 'elliptical' => 'Elliptical', + 'stair_climbing' => 'Stair Climbing', + 'lap_swimming' => 'Lap Swimming', + 'open_water' => 'Open Water', + 'flexibility_training' => 'Flexibility Training', + 'strength_training' => 'Strength Training', + 'warm_up' => 'Warm up', + 'match' => 'Match', + 'exercise' => 'Excersize', + 'challenge' => 'Challenge', + 'indoor_skiing' => 'Indoor Skiing', + 'cardio_training' => 'Cardio Training', + 'all' => 'All' + } + + po_attr :device, :fit_file_name, :norecord, :name, :sport, :sub_sport, + :timestamp, :total_distance, :total_timer_time, :avg_speed + attr_reader :fit_activity + + # Create a new FFS_Activity object. + # @param store [PEROBS::Store] The data base + # @param fit_file_name [String] The fully qualified file name of the FIT + # file to add + # @param fit_entity [Fit4Ruby::FitEntity] The content of the loaded FIT + # file + def initialize(store, device = nil, fit_file_name = nil, fit_entity = nil) + super(store) + init_attr(:device, device) + init_attr(:fit_file_name, fit_file_name ? + File.basename(fit_file_name) : nil) + init_attr(:name, fit_file_name ? File.basename(fit_file_name) : nil) + init_attr(:norecord, false) + if (@fit_activity = fit_entity) + init_attr(:timestamp, fit_entity.timestamp) + init_attr(:total_timer_time, fit_entity.total_timer_time) + init_attr(:sport, fit_entity.sport) + init_attr(:sub_sport, fit_entity.sub_sport) + init_attr(:total_distance, fit_entity.total_distance) + init_attr(:avg_speed, fit_entity.avg_speed) + end + end + + def post_restore + raise RuntimeError unless @device.is_a?(PEROBS::ObjectBase) + end + + # Store a copy of the given FIT file in the corresponding directory. + # @param fit_file_name [String] Fully qualified name of the FIT file. + def store_fit_file(fit_file_name) + # Get the right target directory for this particular FIT file. + dir = fit_file_dir(File.basename(fit_file_name)) + # Create the necessary directories if they don't exist yet. + create_directory(dir, 'Device activity diretory') + + # Copy the file into the target directory. + begin + FileUtils.cp(fit_file_name, dir) + rescue StandardError + Log.fatal "Cannot copy #{fit_file_name} into #{dir}: #{$!}" + end + end + + # FFS_Activity objects are sorted by their timestamp values and then by + # their device long_uids. + def <=>(a) + @timestamp == a.timestamp ? a.device.long_uid <=> self.device.long_uid : + a.timestamp <=> @timestamp + end + + def check + generate_html_report + Log.info "FIT file #{@fit_file_name} is OK" + end + + def dump(filter) + load_fit_file(filter) + end + + def dump(filter) + load_fit_file(filter) + end + + def query(key) + unless @@Schemata.include?(key) + raise ArgumentError, "Unknown key '#{key}' requested in query" + end + + schema = @@Schemata[key] + + if schema.func + value = send(schema.func) + else + unless instance_variable_defined?(key) + raise ArgumentError, "Don't know how to query '#{key}'" + end + value = instance_variable_get(key) + end + + QueryResult.new(value, schema) + end + + def events + load_fit_file + puts EventList.new(self, @store['config']['unit_system']).to_s + end + + def show + html_file = html_file_name + + generate_html_report #unless File.exists?(html_file) + + @store['file_store'].show_in_browser(html_file) + end + + def sources + load_fit_file + puts DataSources.new(self, @store['config']['unit_system']).to_s + end + + def summary + load_fit_file + puts ActivitySummary.new(self, @store['config']['unit_system'], + { :name => @name, + :type => activity_type, + :sub_type => activity_sub_type }).to_s + end + + def set(attribute, value) + case attribute + when 'name' + self.name = value + when 'type' + load_fit_file + unless ActivityTypes.values.include?(value) + Log.fatal "Unknown activity type '#{value}'. Must be one of " + + ActivityTypes.values.join(', ') + end + self.sport = ActivityTypes.invert[value] + when 'subtype' + unless ActivitySubTypes.values.include?(value) + Log.fatal "Unknown activity subtype '#{value}'. Must be one of " + + ActivitySubTypes.values.join(', ') + end + self.sub_sport = ActivitySubTypes.invert[value] + when 'norecord' + unless %w( true false).include?(value) + Log.fatal "norecord must either be 'true' or 'false'" + end + self.norecord = value == 'true' + else + Log.fatal "Unknown activity attribute '#{attribute}'. Must be one of " + + 'name, type or subtype' + end + generate_html_report + end + + # Return true if this activity generated any personal records. + def has_records? + !@store['records'].activity_records(self).empty? + end + + def html_file_name(full_path = true) + fn = "#{@device.short_uid}_#{@fit_file_name[0..-5]}.html" + full_path ? File.join(@store['config']['html_dir'], fn) : fn + end + + def generate_html_report + load_fit_file + ActivityView.new(self, @store['config']['unit_system']) + end + + def activity_type + ActivityTypes[@sport] || 'Undefined' + end + + def activity_sub_type + ActivitySubTypes[@sub_sport] || 'Undefined' + end + + def distance(timestamp, unit_system) + load_fit_file + + @fit_activity.records.each do |record| + if record.timestamp >= timestamp + unit = { :metric => 'km', :statute => 'mi'}[unit_system] + value = record.get_as('distance', unit) + return '-' unless value + return "#{'%.2f %s' % [value, unit]}" + end + end + + '-' + end + + def load_fit_file(filter = nil) + return if @fit_activity + + fit_file = File.join(fit_file_dir(@fit_file_name), @fit_file_name) + begin + @fit_activity = Fit4Ruby.read(fit_file, filter) + rescue Fit4Ruby::Error + Log.fatal "#{@fit_file_name} corrupted: #{$!}" + end + + unless @fit_activity + Log.fatal "#{fit_file} does not contain any activity records" + end + end + + private + + # Determine the right directory for the given FIT file. The resulting path + # looks something like /home/user/.postrunner/devices/garmin-fenix3-1234/ + # activity/5A. + def fit_file_dir(fit_file_base_name) + # The first letter of the FIT file specifies the creation year. + # The second letter of the FIT file specifies the creation month. + dir = File.join(@store['config']['devices_dir'], @device.long_uid, + 'activity', fit_file_base_name[0..1]) + end + + end + +end + diff --git a/lib/postrunner/FFS_Device.rb b/lib/postrunner/FFS_Device.rb new file mode 100644 index 0000000..47c473b --- /dev/null +++ b/lib/postrunner/FFS_Device.rb @@ -0,0 +1,106 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = FFS_Device.rb -- PostRunner - Manage the data from your Garmin sport devices. +# +# Copyright (c) 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'perobs' +require 'postrunner/FFS_Activity' + +module PostRunner + + # Objects of this class can store the activities and monitoring data of a + # specific device. The device gets a random number assigned as a unique but + # anonymous ID. It also gets a long ID assigned that is a String of the + # manufacturer, the product name and the serial number concatenated by + # dashes. All objects are transparently stored in the PEROBS::Store. + class FFS_Device < PEROBS::Object + + po_attr :activities, :monitors, :short_uid, :long_uid + + # Create a new FFS_Device object. + # @param store [PEROBS::Store] The store to persist the data + # @param short_uid [Fixnum] A random number used a unique ID + # @param long_uid [String] A string consisting of the manufacturer and + # product name and the serial number. + def initialize(store, short_uid = nil, long_uid = nil) + super(store) + init_attr(:short_uid, short_uid) + init_attr(:long_uid, long_uid) + init_attr(:activities, @store.new(PEROBS::Array)) + init_attr(:monitorings, @store.new(PEROBS::Array)) + end + + # Add a new FIT file for this device. + # @param fit_file_name [String] The full path to the FIT file + # @param fit_entity [Fit4Ruby::FitEntity] The content of the FIT file + # @param overwrite [Boolean] A flag to indicate if an existing file should + # be replaced with the new one. + # @return [FFS_Activity or FFS_Monitoring] Corresponding entry in the + # FitFileStore or nil if file could not be added. + def add_fit_file(fit_file_name, fit_entity, overwrite) + case fit_entity.class + when Fit4Ruby::Activity.class + entity = activity_by_file_name(File.basename(fit_file_name)) + entities = @activities + new_entity_class = FFS_Activity + when Fit4Ruby::Monitoring.class + entity = monitoring_by_file_name(File.basename(fit_file_name)) + entities = @monitorings + new_entity_class = FFS_Monitoring + else + Log.fatal "Unsupported FIT entity #{fit_entity.class}" + end + + if entity + if overwrite + # Replace the old file. All meta-information will be lost. + entities.delete_if { |e| e.fit_file_name == fit_file_name } + entity = @store.new(new_entity_class, myself, fit_file_name, + fit_entity) + else + # Refuse to replace the file. + return nil + end + else + # Add the new file to the list. + entity = @store.new(new_entity_class, myself, fit_file_name, fit_entity) + end + entity.store_fit_file(fit_file_name) + entities << entity + entities.sort! + + # Scan the activity for any potential new personal records and register + # them. + if entity.is_a?(FFS_Activity) + records = @store['records'] + records.scan_activity_for_records(entity, true) + end + + entity + end + + # Return the activity with the given file name. + # @param file_name [String] Base name of the fit file. + # @return [FFS_Activity] Corresponding FFS_Activity or nil. + def activity_by_file_name(file_name) + @activities.find { |a| a.fit_file_name == file_name } + end + + # Return the monitoring with the given file name. + # @param file_name [String] Base name of the fit file. + # @return [FFS_Activity] Corresponding FFS_Monitoring or nil. + def monitoring_by_file_name(file_name) + @monitorings.find { |a| a.fit_file_name == file_name } + end + + end + +end + diff --git a/lib/postrunner/FitFileStore.rb b/lib/postrunner/FitFileStore.rb new file mode 100644 index 0000000..c52d5a5 --- /dev/null +++ b/lib/postrunner/FitFileStore.rb @@ -0,0 +1,349 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = FitFileStore.rb -- PostRunner - Manage the data from your Garmin sport devices. +# +# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'fit4ruby' +require 'perobs' + +require 'postrunner/Log' +require 'postrunner/DirUtils' +require 'postrunner/FFS_Device' +require 'postrunner/ActivityListView' +require 'postrunner/ViewButtons' + +module PostRunner + + # The FitFileStore stores all FIT file and provides access to the contained + # data. + class FitFileStore < PEROBS::Object + + include DirUtils + + po_attr :devices + + attr_reader :store, :views + + # Create a new FIT file store. + # @param store [PEROBS::Store] Data store + # @param cfg [RuntimeConfig] Runtime configuration data + def initialize(store) + super + @data_dir = store['config']['data_dir'] + # Ensure that we have an Array in the store to hold all known devices. + @store['devices'] = @store.new(PEROBS::Hash) unless @store['devices'] + + @devices_dir = File.join(@data_dir, 'devices') + # It's generally not a good idea to store absolute file names in the + # database. We'll make an exception here as this is the only way to + # propagate this path to FFS_Activity or FFS_Monitoring objects. The + # store entry is updated on each program run, so the DB can be moved + # safely to another directory. + @store['config']['devices_dir'] = @devices_dir + create_directory(@devices_dir, 'devices') + + # Define which View objects the HTML output will consist of. This + # doesn't really belong in this class but for now it's the best place + # to put it. + @views = ViewButtons.new([ + NavButtonDef.new('activities.png', 'index.html'), + NavButtonDef.new('record.png', "records-0.html") + ]) + end + + # Version upgrade logic. + def handle_version_update + # Nothing here so far. + end + + # Add a file to the store. + # @param fit_file_name [String] Name of the FIT file + # @param overwrite [TrueClass, FalseClass] If true, an existing file will + # be replaced. + # @return [FFS_Activity or FFS_Monitoring] Corresponding entry in the + # FitFileStore or nil if file could not be added. + def add_fit_file(fit_file_name, fit_entity = nil, overwrite = false) + # If we the file hasn't been read yet, read it in as a + # Fit4Ruby::Activity or Fit4Ruby::Monitoring entity. + unless fit_entity + return nil unless (fit_entity = read_fit_file(fit_file_name)) + end + + unless [ Fit4Ruby::Activity, + Fit4Ruby::Monitoring ].include?(fit_entity.class) + Log.critical "Unsupported FIT file type #{fit_entity.class}" + end + + # Generate a String that uniquely identifies the device that generated + # the FIT file. + id = extract_fit_file_id(fit_entity) + long_uid = "#{id[:manufacturer]}-#{id[:product]}-#{id[:serial_number]}" + + # Make sure the device that created the FIT file is properly registered. + device = register_device(long_uid) + # Store the FIT entity with the device. + activity = device.add_fit_file(fit_file_name, fit_entity, overwrite) + + # The activity might be already stored or invalid. In that case we + # abort this method. + return nil unless activity + + @store['records'].scan_activity_for_records(activity) + + # Generate HTML file for this activity. + activity.generate_html_report + + # The HTML activity views contain links to their predecessors and + # successors. After inserting a new activity, we need to re-generate + # these views as well. + if (pred = predecessor(activity)) + pred.generate_html_report + end + if (succ = successor(activity)) + succ.generate_html_report + end + + Log.info "#{fit_file_name} successfully added to archive" + + activity + end + + # Delete an activity from the database. It will only delete the entry in + # the database. The original activity file will not be deleted from the + # file system. + # @param activity [FFS_Activity] Activity to delete + def delete_activity(activity) + pred = predecessor(activity) + succ = successor(activity) + + activity.device.activities.delete(activity) + + # The HTML activity views contain links to their predecessors and + # successors. After deleting an activity, we need to re-generate these + # views. + pred.generate_html_report if pred + succ.generate_html_report if succ + + generate_html_index_pages + end + + # Rename the specified activity and update all HTML pages that contain the + # name. + # @param activity [FFS_Activity] Activity to rename + # @param name [String] New name + def rename_activity(activity, name) + activity.set('name', name) + generate_html_index_pages + end + + # Set the specified attribute of the given activity to a new value. + # @param activity [FFS_Activity] Activity to rename + # @param attribute [String] name of the attribute to change + # @param value [any] new value of the attribute + def set_activity_attribute(activity, attribute, value) + activity.set(attribute, value) + case attribute + when 'norecord', 'type' + # If we have changed a norecord setting or an activity type, we need + # to regenerate all reports and re-collect the record list since we + # don't know which Activity needs to replace the changed one. + check + end + generate_html_index_pages + end + + # Perform the necessary report updates after the unit system has been + # changed. + def change_unit_system + # If we have changed the unit system we need to re-generate all HTML + # reports. + activities.each do |activity| + activity.generate_html_report + end + @store['records'].generate_html_reports + generate_html_index_pages + end + + + # @return [Array of FFS_Device] List of registered devices. + def devices + @store['devices'] + end + + # @return [Array of FFS_Activity] List of stored activities. + def activities + list = [] + @store['devices'].each do |id, device| + list += device.activities + end + list.sort + end + + # Return the reference index of the given FFS_Activity. + # @param activity [FFS_Activity] + # @return [Fixnum] Reference index as used in the UI + def ref_by_activity(activity) + return nil unless (idx = activities.index(activity)) + + idx + 1 + end + + # Return the next Activity after the provided activity. Note that this has + # a lower index. If none is found, return nil. + def successor(activity) + all_activities = activities + idx = all_activities.index(activity) + return nil if idx.nil? || idx == 0 + all_activities[idx - 1] + end + + # Return the previous Activity before the provided activity. + # If none is found, return nil. + def predecessor(activity) + all_activities = activities + idx = all_activities.index(activity) + return nil if idx.nil? + # Activities indexes are reversed. The predecessor has a higher index. + all_activities[idx + 1] + end + + # Find a specific subset of the activities based on their index. + # @param query [String] + def find(query) + case query + when /\A-?\d+$\z/ + index = query.to_i + # The UI counts the activities from 1 to N. Ruby counts from 0 - + # (N-1). + if index <= 0 + Log.error 'Index must be larger than 0' + return [] + end + # The UI counts the activities from 1 to N. Ruby counts from 0 - + # (N-1). + if (a = activities[index - 1]) + return [ a ] + end + when /\A-?\d+--?\d+\z/ + idxs = query.match(/(?<sidx>-?\d+)-(?<eidx>-?[0-9]+)/) + if (sidx = idxs['sidx'].to_i) <= 0 + Log.error 'Start index must be larger than 0' + return [] + end + if (eidx = idxs['eidx'].to_i) <= 0 + Log.error 'End index must be larger than 0' + return [] + end + if eidx < sidx + Log.error 'Start index must be smaller than end index' + return [] + end + # The UI counts the activities from 1 to N. Ruby counts from 0 - + # (N-1). + unless (as = activities[(sidx - 1)..(eidx - 1)]).empty? + return as + end + else + Log.error "Invalid activity query: #{query}" + end + + [] + end + + # This methods checks all stored FIT files for correctness, updates all + # indexes and re-generates all HTML reports. + def check + records = @store['records'] + records.delete_all_records + activities.sort do |a1, a2| + a1.timestamp <=> a2.timestamp + end.each do |a| + a.check + records.scan_activity_for_records(a) + end + records.generate_html_reports + generate_html_index_pages + end + + # Show the activity list in a web browser. + def show_list_in_browser + generate_html_index_pages + @store['records'].generate_html_reports + show_in_browser(File.join(@store['config']['html_dir'], 'index.html')) + end + + def list_activities + puts ActivityListView.new(self).to_s + end + + # Launch a web browser and show an HTML file. + # @param html_file [String] file name of the HTML file to show + def show_in_browser(html_file) + cmd = "#{ENV['BROWSER'] || 'firefox'} \"#{html_file}\" &" + + unless system(cmd) + Log.fatal "Failed to execute the following shell command: #{$cmd}\n" + + "#{$!}" + end + end + + private + + def read_fit_file(fit_file_name) + begin + return Fit4Ruby.read(fit_file_name) + rescue Fit4Ruby::Error + Log.error $! + return nil + end + end + + def extract_fit_file_id(fit_entity) + fit_entity.device_infos.each do |di| + if di.device_index == 0 + return { + :manufacturer => di.manufacturer, + :product => di.garmin_product || di.product, + :serial_number => di.serial_number + } + end + end + Log.fatal "Fit entity has no device info for 0" + end + + def register_device(long_uid) + unless @store['devices'].include?(long_uid) + Log.info "New device registered: #{long_uid}" + + # Generate a unique ID for the device that does not allow any insight + # on the number of and type of managed devices. + begin + short_uid = rand(2**32) + end while @store['devices'].find { |luid, d| d.short_uid == short_uid } + + @store['devices'][long_uid] = + @store.new(FFS_Device, short_uid, long_uid) + + # Create the directory to store the FIT files of this device. + create_directory(File.join(@devices_dir, long_uid), long_uid) + end + + @store['devices'][long_uid] + end + + def generate_html_index_pages + # Ensure that HTML index is up-to-date. + ActivityListView.new(self).update_index_pages + end + + end + +end + diff --git a/lib/postrunner/Main.rb b/lib/postrunner/Main.rb index c388910..29b8e47 100644 --- a/lib/postrunner/Main.rb +++ b/lib/postrunner/Main.rb @@ -3,7 +3,7 @@ # # = Main.rb -- PostRunner - Manage the data from your Garmin sport devices. # -# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org> +# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -16,7 +16,10 @@ require 'perobs' require 'postrunner/version' require 'postrunner/Log' +require 'postrunner/DirUtils' require 'postrunner/RuntimeConfig' +require 'postrunner/FitFileStore' +require 'postrunner/PersonalRecords' require 'postrunner/ActivitiesDB' require 'postrunner/MonitoringDB' require 'postrunner/EPO_Downloader' @@ -25,20 +28,33 @@ module PostRunner class Main + include DirUtils + def initialize(args) @filter = nil @name = nil @attribute = nil @value = nil - @activities = nil - @monitoring = nil @db_dir = File.join(ENV['HOME'], '.postrunner') return if (args = parse_options(args)).nil? - @cfg = RuntimeConfig.new(@db_dir) + create_directory(@db_dir, 'PostRunner data') @db = PEROBS::Store.new(File.join(@db_dir, 'database')) + # Create a hash to store configuration data in the store unless it + # exists already. + unless @db['config'] + @db['config'] = @db.new(PEROBS::Hash) + @db['config']['unit_system'] = :metric + @db['config']['html_dir'] = File.join(@db_dir, 'html') + @db['config']['version'] = VERSION + end + @db['config']['data_dir'] = @db_dir + + setup_directories execute_command(args) + + @db.sync end private @@ -104,7 +120,7 @@ EOT return nil end opts.on('--version', 'Show version number') do - $stderr.puts VERSION + puts VERSION return nil end @@ -190,16 +206,50 @@ EOT end end + def setup_directories + create_directory(@db['config']['html_dir'], 'HTML output') + + %w( icons jquery flot openlayers postrunner ).each do |dir| + # This file should be in lib/postrunner. The 'misc' directory should be + # found in '../../misc'. + misc_dir = File.realpath(File.join(File.dirname(__FILE__), + '..', '..', 'misc')) + unless Dir.exists?(misc_dir) + Log.fatal "Cannot find 'misc' directory under '#{misc_dir}': #{$!}" + end + src_dir = File.join(misc_dir, dir) + unless Dir.exists?(src_dir) + Log.fatal "Cannot find '#{src_dir}': #{$!}" + end + dst_dir = @db['config']['html_dir'] + + begin + FileUtils.cp_r(src_dir, dst_dir) + rescue IOError + Log.fatal "Cannot copy auxilliary data directory '#{dst_dir}': #{$!}" + end + end + end + def execute_command(args) - @activities = ActivitiesDB.new(@db_dir, @cfg) - @monitoring = MonitoringDB.new(@db, @cfg) + # Create or load the FitFileStore data. + unless (@ffs = @db['file_store']) + @ffs = @db['file_store'] = @db.new(FitFileStore) + end + # Create or load the PersonalRecords data. + unless (@records = @db['records']) + @records = @db['records'] = @db.new(PersonalRecords) + end handle_version_update + import_legacy_archive case (cmd = args.shift) when 'check' if args.empty? - @activities.check - @activities.generate_all_html_reports + @ffs.check + Log.info "Datebase cleanup started. Please wait ..." + @db.gc + Log.info "Database cleanup finished" else process_files_or_activities(args, :check) end @@ -214,19 +264,19 @@ EOT if args.empty? # If we have no file or directory for the import command, we get the # most recently used directory from the runtime config. - process_files([ @cfg.get_option(:import_dir) ], :import) + process_files([ @db['config']['import_dir'] ], :import) else process_files(args, :import) if args.length == 1 && Dir.exists?(args[0]) # If only one directory was specified as argument we store the # directory for future use. - @cfg.set_option(:import_dir, args[0]) + @db['config']['import_dir'] = args[0] end end when 'list' - @activities.list + @ffs.list_activities when 'records' - @activities.show_records + @ffs.show_records when 'rename' unless (@name = args.shift) Log.fatal 'You must provide a new name for the activity' @@ -242,7 +292,7 @@ EOT process_activities(args, :set) when 'show' if args.empty? - @activities.show_list_in_browser + @ffs.show_list_in_browser else process_activities(args, :show) end @@ -282,7 +332,7 @@ EOT activity_refs.each do |a_ref| if a_ref[0] == ':' - activities = @activities.find(a_ref[1..-1]) + activities = @ffs.find(a_ref[1..-1]) if activities.empty? Log.warn "No matching activities found for '#{a_ref}'" return @@ -292,6 +342,8 @@ EOT Log.fatal "Activity references must start with ':': #{a_ref}" end end + + nil end def process_files(files_or_dirs, command) @@ -339,9 +391,9 @@ EOT end if fit_entity.is_a?(Fit4Ruby::Activity) - return @activities.add(fit_file_name, fit_entity) - elsif fit_entity.is_a?(Fit4Ruby::Monitoring_B) - return @monitoring.add(fit_file_name, fit_entity) + return @ffs.add_fit_file(fit_file_name, fit_entity) + #elsif fit_entity.is_a?(Fit4Ruby::Monitoring_B) + # return @monitoring.add(fit_file_name, fit_entity) else Log.error "#{fit_file_name} is not a recognized FIT file" return false @@ -353,15 +405,15 @@ EOT when :check activity.check when :delete - @activities.delete(activity) + @ffs.delete_activity(activity) when :dump activity.dump(@filter) when :events activity.events when :rename - @activities.rename(activity, @name) + @ffs.rename_activity(activity, @name) when :set - @activities.set(activity, @attribute, @value) + @ffs.set_activity_attribute(activity, @attribute, @value) when :show activity.show when :sources @@ -382,9 +434,9 @@ EOT Log.fatal("You must specify 'metric' or 'statute' as unit system.") end - if @cfg[:unit_system].to_s != args[0] - @cfg.set_option(:unit_system, args[0].to_sym) - @activities.generate_all_html_reports + if @db['config']['unit_system'].to_s != args[0] + @db['config']['unit_system'] = args[0].to_sym + @ffs.change_unit_system end end @@ -393,16 +445,16 @@ EOT Log.fatal('You must specify a directory') end - if @cfg[:html_dir] != args[0] - @cfg.set_option(:html_dir, args[0]) - @activities.create_directories - @activities.generate_all_html_reports + if @db['config']['html_dir'] != args[0] + @db['config']['html_dir'] = args[0] + @ffs.create_directories + @ffs.generate_all_html_reports end end def update_gps_data epo_dir = File.join(@db_dir, 'epo') - @cfg.create_directory(epo_dir, 'GPS Data Cache') + create_directory(epo_dir, 'GPS Data Cache') epo_file = File.join(epo_dir, 'EPO.BIN') if !File.exists?(epo_file) || @@ -410,7 +462,7 @@ EOT # The EPO file only changes every 6 hours. No need to download it more # frequently if it already exists. if EPO_Downloader.new.download(epo_file) - unless (remotesw_dir = @cfg[:import_dir]) + unless (remotesw_dir = @db['config']['import_dir']) Log.error "No device directory set. Please import an activity " + "from your device first." return @@ -433,14 +485,38 @@ EOT end def handle_version_update - if @cfg.get_option(:version) != VERSION + if @db['config']['version'] != VERSION Log.warn "PostRunner version upgrade detected." - @activities.handle_version_update - @cfg.set_option(:version, VERSION) + @ffs.handle_version_update + @db['config']['version'] = VERSION Log.info "Version upgrade completed." end end + # Earlier versions of PostRunner used a YAML file to store the activity + # data. This method transfers the data from the old storage to the new + # FitFileStore based database. + def import_legacy_archive + old_fit_dir = File.join(@db_dir, 'old_fit_dir') + create_directory(old_fit_dir, 'Old Fit') + + cfg = RuntimeConfig.new(@db_dir) + ActivitiesDB.new(@db_dir, cfg).activities.each do |activity| + file_name = File.join(@db_dir, 'fit', activity.fit_file) + next unless File.exists?(file_name) + + Log.info "Converting #{activity.fit_file} to new DB format" + @db.transaction do + new_activity = @ffs.add_fit_file(file_name) + new_activity.sport = activity.sport + new_activity.sub_sport = activity.sub_sport + new_activity.name = activity.name + new_activity.norecord = activity.norecord + FileUtils.move(file_name, File.join(old_fit_dir, activity.fit_file)) + end + end + end + end end diff --git a/lib/postrunner/PersonalRecords.rb b/lib/postrunner/PersonalRecords.rb index bfa4a57..a509abb 100644 --- a/lib/postrunner/PersonalRecords.rb +++ b/lib/postrunner/PersonalRecords.rb @@ -1,28 +1,22 @@ #!/usr/bin/env ruby -w # encoding: UTF-8 # -# = PersonalRecords.rb -- PostRunner - Manage the data from your Garmin sport devices. +# = FitFileStore.rb -- PostRunner - Manage the data from your Garmin sport devices. # -# Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org> +# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. # -require 'fileutils' -require 'yaml' - -require 'fit4ruby' -require 'postrunner/BackedUpFile' require 'postrunner/RecordListPageView' -require 'postrunner/ActivityLink' module PostRunner # The PersonalRecords class stores the various records. Records are grouped # by specific year or all-time records. - class PersonalRecords + class PersonalRecords < PEROBS::Object include Fit4Ruby::Converters @@ -78,15 +72,14 @@ module PostRunner } } - # The Record class stores a single speed or longest distance record. It - # also stores a reference to the Activity that contains the record. - class Record + po_attr :sport_records - include Fit4Ruby::Converters + class ActivityResult - attr_accessor :activity, :sport, :distance, :duration, :start_time + attr_reader :activity, :sport, :distance, :duration, :start_time - def initialize(activity, sport, distance, duration, start_time) + def initialize(activity = nil, sport = nil, distance = nil, + duration = nil, start_time = nil) @activity = activity @sport = sport @distance = distance @@ -94,32 +87,56 @@ module PostRunner @start_time = start_time end + end + + # The Record class stores a single speed or longest distance record. It + # also stores a reference to the Activity that contains the record. + class Record < PEROBS::Object + + include Fit4Ruby::Converters + + po_attr :activity, :sport, :distance, :duration, :start_time + + def initialize(store, result = nil) + super(store) + if result + init_attr(:activity, result.activity) + init_attr(:sport, result.sport) + init_attr(:distance, result.distance) + init_attr(:duration, result.duration) + init_attr(:start_time, result.start_time) + end + end + def to_table_row(t) t.row((@duration.nil? ? [ 'Longest Distance', '%.3f km' % (@distance / 1000.0), '-' ] : [ PersonalRecords::SpeedRecordDistances[@sport][@distance], secsToHMS(@duration), speedToPace(@distance / @duration) ]) + - [ @activity.db.ref_by_fit_file(@activity.fit_file), - ActivityLink.new(@activity, false), - @start_time.strftime("%Y-%m-%d") ]) + [ @store['file_store'].ref_by_activity(@activity), + ActivityLink.new(@activity, false), + @start_time.strftime("%Y-%m-%d") ]) end end - class RecordSet + class RecordSet < PEROBS::Object include Fit4Ruby::Converters - attr_reader :year - - def initialize(sport, year) - @sport = sport - @year = year - @distance_record = nil - @speed_records = {} - PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist| - @speed_records[dist] = nil + po_attr :sport, :year, :distance_record, :speed_records + + def initialize(store, sport = nil, year = nil) + super(store) + init_attr(:sport, sport) + init_attr(:year, year) + init_attr(:distance_record, nil) + init_attr(:speed_records, @store.new(PEROBS::Hash)) + if sport + PersonalRecords::SpeedRecordDistances[sport].each_key do |dist| + @speed_records[dist.to_s] = nil + end end end @@ -131,9 +148,9 @@ module PostRunner Log.fatal "Unknown record distance #{result.distance}" end - old_record = @speed_records[result.distance] + old_record = @speed_records[result.distance.to_s] if old_record.nil? || old_record.duration > result.duration - @speed_records[result.distance] = result + @speed_records[result.distance.to_s] = @store.new(Record, result) Log.info "New #{@year ? @year.to_s : 'all-time'} " + "#{result.sport} speed record for " + "#{PersonalRecords::SpeedRecordDistances[@sport][ @@ -145,7 +162,8 @@ module PostRunner # We have a potential distance record. if @distance_record.nil? || @distance_record.distance < result.distance - @distance_record = result + self.distance_record = @store.new(Record, result) + raise RuntimeError if @distance_record.is_a?(String) Log.info "New #{@year ? @year.to_s : 'all-time'} " + "#{result.sport} distance record: #{result.distance} m" return true @@ -156,14 +174,20 @@ module PostRunner end def delete_activity(activity) + record_deleted = false if @distance_record && @distance_record.activity == activity - @distance_record = nil + self.distance_record = nil + record_deleted = true end PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist| + dist = dist.to_s if @speed_records[dist] && @speed_records[dist].activity == activity @speed_records[dist] = nil + record_deleted = true end end + + record_deleted end # Return true if no Record is stored in this RecordSet object. @@ -210,33 +234,32 @@ module PostRunner ]) t.body - records = @speed_records.values.delete_if { |r| r.nil? } + records = @speed_records.values.delete_if { |r| r.nil? }. + sort { |r1, r2| r1.distance <=> r2.distance } records << @distance_record if @distance_record - records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r| - r.to_table_row(t) - end + records.each { |r| r.to_table_row(t) } t end - end - class SportRecords + class SportRecords < PEROBS::Object - attr_reader :sport, :all_time, :yearly + po_attr :sport, :all_time, :yearly - def initialize(sport) - @sport = sport - @all_time = RecordSet.new(@sport, nil) - @yearly = {} + def initialize(store, sport = nil) + super(store) + init_attr(:sport, sport) + init_attr(:all_time, @store.new(RecordSet, sport, nil)) + init_attr(:yearly, @store.new(PEROBS::Hash)) end def register_result(result) - year = result.start_time.year + year = result.start_time.year.to_s unless @yearly[year] - @yearly[year] = RecordSet.new(@sport, year) + @yearly[year] = @store.new(RecordSet, @sport, year) end new_at = @all_time.register_result(result) @@ -246,9 +269,12 @@ module PostRunner end def delete_activity(activity) + record_deleted = false ([ @all_time ] + @yearly.values).each do |r| - r.delete_activity(activity) + record_deleted = true if r.delete_activity(activity) end + + record_deleted end # Return true if no record is stored in this SportRecords object. @@ -285,8 +311,9 @@ module PostRunner doc.div { doc.h3('All-time records') @all_time.to_html(doc) - @yearly.values.sort{ |r1, r2| r2.year <=> r1.year }.each do |record| - puts record.year + @yearly.values.sort do |r1, r2| + r2.year.to_i <=> r1.year.to_i + end.each do |record| unless record.empty? doc.h3("Records of #{record.year}") record.to_html(doc) @@ -297,47 +324,160 @@ module PostRunner end - def initialize(activities) - @activities = activities - @db_dir = activities.db_dir - @records_file = File.join(@db_dir, 'records.yml') - delete_all_records + def initialize(store) + super + init_attr(:sport_records, @store.new(PEROBS::Hash)) + delete_all_records if @sport_records.empty? + end + + def scan_activity_for_records(activity, report_update_requested = false) + # If we have the @norecord flag set, we ignore this Activity for the + # record collection. + return if activity.norecord + + activity.load_fit_file + + distance_record = 0.0 + distance_record_sport = nil + # Array with popular distances (in meters) in ascending order. + record_distances = nil + # Speed records for popular distances (seconds hashed by distance in + # meters) + speed_records = {} + + segment_start_time = activity.fit_activity.sessions[0].start_time + segment_start_distance = 0.0 + + sport = nil + last_timestamp = nil + last_distance = nil + + activity.fit_activity.records.each do |record| + if record.distance.nil? + # All records must have a valid distance mark or the activity does + # not qualify for a personal record. + Log.warn "Found a record without a valid distance" + return + end + if record.timestamp.nil? + Log.warn "Found a record without a valid timestamp" + return + end + + unless sport + # If the Activity has sport set to 'multisport' or 'all' we pick up + # the sport from the FIT records. Otherwise, we just use whatever + # sport the Activity provides. + if activity.sport == 'multisport' || activity.sport == 'all' + sport = record.activity_type + else + sport = activity.sport + end + return unless SpeedRecordDistances.include?(sport) + + record_distances = SpeedRecordDistances[sport]. + keys.sort + end + + segment_start_distance = record.distance unless segment_start_distance + segment_start_time = record.timestamp unless segment_start_time + + # Total distance covered in this segment so far + segment_distance = record.distance - segment_start_distance + # Check if we have reached the next popular distance. + if record_distances.first && + segment_distance >= record_distances.first + segment_duration = record.timestamp - segment_start_time + # The distance may be somewhat larger than a popular distance. We + # normalize the time to the norm distance. + norm_duration = segment_duration / segment_distance * + record_distances.first + # Save the time for this distance. + speed_records[record_distances.first] = { + :time => norm_duration, :sport => sport + } + # Switch to the next popular distance. + record_distances.shift + end + + # We've reached the end of a segment if the sport type changes, we + # detect a pause of more than 30 seconds or when we've reached the + # last record. + if (record.activity_type && sport && record.activity_type != sport) || + (last_timestamp && (record.timestamp - last_timestamp) > 30) || + record.equal?(activity.fit_activity.records.last) + + # Check for a total distance record + if segment_distance > distance_record + distance_record = segment_distance + distance_record_sport = sport + end + + # Prepare for the next segment in this Activity + segment_start_distance = nil + segment_start_time = nil + sport = nil + end + + last_timestamp = record.timestamp + last_distance = record.distance + end + + # Store the found records + start_time = activity.fit_activity.sessions[0].timestamp + update_reports = false + if distance_record_sport + if register_result(activity, distance_record_sport, distance_record, + nil, start_time) + update_reports = true + end + end + speed_records.each do |dist, info| + if register_result(activity, info[:sport], dist, info[:time], + start_time) + update_reports = true + end + end - load_records + generate_html_reports if update_reports && report_update_requested end def register_result(activity, sport, distance, duration, start_time) unless @sport_records.include?(sport) Log.info "Ignoring records for activity type '#{sport}' in " + - "#{activity.fit_file}" + "#{activity.fit_file_name}" return false end - result = Record.new(activity, sport, distance, duration, start_time) + result = ActivityResult.new(activity, sport, distance, duration, + start_time) @sport_records[sport].register_result(result) end def delete_all_records - @sport_records = {} + @sport_records.clear SpeedRecordDistances.keys.each do |sport| - @sport_records[sport] = SportRecords.new(sport) + @sport_records[sport] = @store.new(SportRecords, sport) end end def delete_activity(activity) - @sport_records.each_value { |r| r.delete_activity(activity) } - end + record_deleted = false + @sport_records.each_value do |r| + record_deleted = true if r.delete_activity(activity) + end - def sync - save_records + record_deleted + end + def generate_html_reports non_empty_records = @sport_records.select { |s, r| !r.empty? } max = non_empty_records.length i = 0 non_empty_records.each do |sport, record| - output_file = File.join(@activities.cfg[:html_dir], + output_file = File.join(@store['config']['html_dir'], "records-#{i}.html") - RecordListPageView.new(@activities, record, max, i). + RecordListPageView.new(@store['file_store'], record, max, i). write(output_file) i += 1 end @@ -362,8 +502,7 @@ module PostRunner def activity_records(activity) records = [] each do |record| - # puts record.activity - if record.activity.equal?(activity) && !records.include?(record) + if record.activity.equal?(activity) records << record end end @@ -371,57 +510,6 @@ module PostRunner records end - private - - def load_records - begin - if File.exists?(@records_file) - @sport_records = YAML.load_file(@records_file) - else - Log.info "No records file found at '#{@records_file}'" - end - rescue IOError - Log.fatal "Cannot load records file '#{@records_file}': #{$!}" - end - - unless @sport_records.is_a?(Hash) - Log.fatal "The personal records file '#{@records_file}' is corrupted" - end - fit_file_names_to_activity_refs - end - - def save_records - activity_refs_to_fit_file_names - begin - BackedUpFile.open(@records_file, 'w') do |f| - f.write(@sport_records.to_yaml) - end - rescue IOError - Log.fatal "Cannot write records file '#{@records_file}': #{$!}" - end - fit_file_names_to_activity_refs - end - - # Convert FIT file names in all Record objects into Activity references. - def fit_file_names_to_activity_refs - each do |record| - # Record objects can be referenced multiple times. - if record.activity.is_a?(String) - record.activity = @activities.activity_by_fit_file(record.activity) - end - end - end - - # Convert Activity references in all Record objects into FIT file names. - def activity_refs_to_fit_file_names - each do |record| - # Record objects can be referenced multiple times. - unless record.activity.is_a?(String) - record.activity = record.activity.fit_file - end - end - end - end end diff --git a/lib/postrunner/RecordListPageView.rb b/lib/postrunner/RecordListPageView.rb index 01dafb3..7161cb5 100644 --- a/lib/postrunner/RecordListPageView.rb +++ b/lib/postrunner/RecordListPageView.rb @@ -17,6 +17,7 @@ require 'postrunner/View' require 'postrunner/ViewFrame' require 'postrunner/ViewButtons' require 'postrunner/PagingButtons' +require 'postrunner/Activity' module PostRunner @@ -27,16 +28,15 @@ module PostRunner include Fit4Ruby::Converters # Create a RecordListPageView object. - # @param db [ActivityDB] Activity database + # @param ffs [FitFileStore] Activity database # @param records [PersonalRecords] Database with personal records # @param page_count [Fixnum] Number of total pages # @param page_index [Fixnum] Index of the page - def initialize(db, records, page_count, page_index) - @db = db - @unit_system = @db.cfg[:unit_system] + def initialize(ffs, records, page_count, page_index) + #@unit_system = ffs.store['config']['unit_system'] @records = records - views = @db.views + views = ffs.views views.current_page = "records-0.html" pages = PagingButtons.new((0..(page_count - 1)).map do |i| @@ -55,7 +55,7 @@ module PostRunner ViewFrame.new("All-time #{@sport_name} Records", frame_width, @records.all_time).to_html(@doc) - @records.yearly.sort{ |y1, y2| y2[0] <=> y1[0] }. + @records.yearly.sort{ |y1, y2| y2[0].to_i <=> y1[0].to_i }. each do |year, record| next if record.empty? ViewFrame.new("#{year} #{@sport_name} Records", diff --git a/spec/ActivitySummary_spec.rb b/spec/ActivitySummary_spec.rb index b3d77b0..f949a8d 100644 --- a/spec/ActivitySummary_spec.rb +++ b/spec/ActivitySummary_spec.rb @@ -18,16 +18,27 @@ end describe PostRunner::ActivitySummary do + before(:all) do + capture_stdio + create_working_dirs + create_fit_file_store + end + before(:each) do - fa = create_fit_activity('2014-08-26-19:00', 30) - a = Activity.new(fa, 'running') - @as = PostRunner::ActivitySummary.new(a, :metric, + acfg = { :t => '2014-08-26T19:00', :duration => 30, :serial => 123456790 } + fn = create_fit_activity_file(@fit_dir, acfg) + fa = @ffs.add_fit_file(fn) + @as = PostRunner::ActivitySummary.new(fa, :metric, { :name => 'test', :type => 'Running', :sub_type => 'Street' }) end + after(:all) do + cleanup + end + it 'should create a metric summary' do - puts @as.to_s #TODO: Fix aggregation first + @as.to_s #TODO: Fix aggregation first end end diff --git a/spec/FitFileStore_spec.rb b/spec/FitFileStore_spec.rb new file mode 100644 index 0000000..91797aa --- /dev/null +++ b/spec/FitFileStore_spec.rb @@ -0,0 +1,133 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = PostRunner_spec.rb -- PostRunner - Manage the data from your Garmin sport devices. +# +# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'spec_helper' + +require 'fit4ruby/FileNameCoder' +require 'postrunner/RuntimeConfig' +require 'postrunner/FitFileStore' +require 'postrunner/PersonalRecords' + +describe PostRunner::FitFileStore do + + before(:all) do + capture_stdio + create_working_dirs + create_fit_file_store + + # Create some test fit files + @fit_file_names = [] + [ + { :t => '2015-10-21T21:00', :duration => 10, :serial => 123456790 }, + { :t => '2015-10-22T08:10', :duration => 15, :serial => 123456791 }, + { :t => '2015-11-01T13:30', :duration => 20, :serial => 123456790 } + ].each do |config| + f = create_fit_activity_file(@fit_dir, config) + @fit_file_names << f + end + @activities = [] + end + + after(:all) do + cleanup + end + + it 'should be empty at start' do + expect(@ffs.devices.length).to eq(0) + expect(@ffs.activities.length).to eq(0) + end + + it 'should store a FIT file' do + @activities << @ffs.add_fit_file(@fit_file_names[0]) + expect(@activities[-1]).not_to be_nil + + expect(@ffs.devices.length).to eq(1) + expect(@ffs.devices.include?('garmin-fenix3-123456790')).to be true + expect(@ffs.activities.length).to eq(1) + expect(@ffs.ref_by_activity(@activities[0])).to eq(1) + end + + it 'should not store the same FIT file twice' do + expect(@ffs.add_fit_file(@fit_file_names[0])).to be_nil + + expect(@ffs.devices.length).to eq(1) + expect(@ffs.devices.include?('garmin-fenix3-123456790')).to be true + expect(@ffs.activities.length).to eq(1) + end + + it 'should store another FIT file as 2nd device' do + @activities << @ffs.add_fit_file(@fit_file_names[1]) + expect(@activities[-1]).not_to be_nil + + expect(@ffs.devices.length).to eq(2) + expect(@ffs.devices.include?('garmin-fenix3-123456790')).to be true + expect(@ffs.devices.include?('garmin-fenix3-123456791')).to be true + expect(@ffs.activities.length).to eq(2) + expect(@ffs.ref_by_activity(@activities[1])).to eq(1) + end + + it 'should store another activity of a known device' do + @activities << @ffs.add_fit_file(@fit_file_names[2]) + expect(@activities[-1]).not_to be_nil + + expect(@ffs.devices.length).to eq(2) + expect(@ffs.devices.include?('garmin-fenix3-123456790')).to be true + expect(@ffs.devices.include?('garmin-fenix3-123456791')).to be true + expect(@ffs.activities.length).to eq(3) + expect(@ffs.ref_by_activity(@activities[2])).to eq(1) + end + + it 'should find activities by index' do + expect(@ffs.find('0')).to eq([]) + expect(@ffs.find('1')).to eq([ @activities[2] ]) + expect(@ffs.find('2')).to eq([ @activities[1] ]) + expect(@ffs.find('3')).to eq([ @activities[0] ]) + expect(@ffs.find('1-2')).to eq([ @activities[2], @activities[1] ]) + expect(@ffs.find('2-1')).to eq([]) + expect(@ffs.find('')).to eq([]) + end + + it 'should check all stored fit files' do + @ffs.check + end + + it 'should know the successor of each activity' do + expect(@ffs.successor(@activities[2])).to be_nil + expect(@ffs.successor(@activities[1])).to eq(@activities[2]) + expect(@ffs.successor(@activities[0])).to eq(@activities[1]) + end + + it 'should know the predecessor of each activity' do + expect(@ffs.predecessor(@activities[2])).to eq(@activities[1]) + expect(@ffs.predecessor(@activities[1])).to eq(@activities[0]) + expect(@ffs.predecessor(@activities[0])).to be_nil + end + + it 'should delete activities' do + @ffs.delete_activity(@activities[1]) + expect(@ffs.find('1')).to eq([ @activities[2] ]) + expect(@ffs.find('2')).to eq([ @activities[0] ]) + expect(@ffs.find('3')).to eq([]) + + @ffs.delete_activity(@activities[2]) + expect(@ffs.find('1')).to eq([ @activities[0] ]) + expect(@ffs.find('2')).to eq([]) + end + + it 'should rename an activity' do + @ffs.rename_activity(@activities[0], 'new name') + expect(@activities[0].name).to eq('new name') + expect(@activities[0].fit_file_name).to eq(File.basename(@fit_file_names[0])) + end + +end + diff --git a/spec/FlexiTable_spec.rb b/spec/FlexiTable_spec.rb index c3357cd..9b67ec6 100644 --- a/spec/FlexiTable_spec.rb +++ b/spec/FlexiTable_spec.rb @@ -26,7 +26,7 @@ describe PostRunner::FlexiTable do |ccc|ddddd| +---+-----+ EOT - t.to_s.should == ref + expect(t.to_s).to eq(ref) end end diff --git a/spec/PersonalRecords_spec.rb b/spec/PersonalRecords_spec.rb new file mode 100644 index 0000000..c23010b --- /dev/null +++ b/spec/PersonalRecords_spec.rb @@ -0,0 +1,206 @@ +#!/usr/bin/env ruby -w +# encoding: UTF-8 +# +# = PostRunner_spec.rb -- PostRunner - Manage the data from your Garmin sport devices. +# +# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. +# + +require 'spec_helper' +require 'perobs' + +require 'fit4ruby/FileNameCoder' +require 'postrunner/FitFileStore' +require 'postrunner/PersonalRecords' + +describe PostRunner::PersonalRecords do + + class Mock_Activity < PEROBS::Object + + po_attr :name, :fit_file_name + + def initialize(store, name = nil) + super(store) + init_attr(:name, name) + init_attr(:fit_file_name, name) + end + + end + + class Mock_FitFileStore < PEROBS::Object + + po_attr :activities + + def initialize(store) + super + init_attr(:activities, @store.new(PEROBS::Array)) + end + + def add_activity(a) + @activities << a + end + + def ref_by_activity(a) + @activities.index(a) + 1 + end + + end + + before(:all) do + @log = StringIO.new + Fit4Ruby::Log.open(@log) + @work_dir = tmp_dir_name(__FILE__) + Dir.mkdir(@work_dir) + + # Create the FitFileStore + @store = PEROBS::Store.new(File.join(@work_dir, 'db')) + @store['config'] = @store.new(PEROBS::Hash) + @store['config']['data_dir'] = @work_dir + @ffs = @store['file_store'] = @store.new(Mock_FitFileStore) + @records = @store['records'] = @store.new(PostRunner::PersonalRecords) + end + + after(:all) do + FileUtils.rm_rf(@work_dir) + end + + it 'should initialize properly' do + expect(@records.to_s).to eq('') + end + + it 'should register a record' do + a = @store.new(Mock_Activity, 'Activity 1') + @ffs.add_activity(a) + t = Time.parse('2014-11-08T09:16:00') + expect(@records.register_result(a, 'running', 5000.0, 20 * 60, + t)).to be true + expect(@records.register_result(a, 'running', 5000.0, nil, t)).to be true + expect(@records.activity_records(a).length).to eq(4) + expect(tables_to_arrays(@records.to_s)).to eq([ + [["5 km", "0:20:00", "4:00", "1", "Activity 1", "2014-11-08"], + ["Longest Distance", "5.000 km", "-", "1", "Activity 1", "2014-11-08"]], + [["5 km", "0:20:00", "4:00", "1", "Activity 1", "2014-11-08"], + ["Longest Distance", "5.000 km", "-", "1", "Activity 1", "2014-11-08"]] + ]) + end + + it 'should register another record' do + a = @store.new(Mock_Activity, 'Activity 2') + @ffs.add_activity(a) + t = Time.parse('2014-11-09T09:16:00') + expect(@records.register_result(a, 'running', 10000.0, + 42 * 60, t)).to be true + expect(@records.register_result(a, 'running', 10000.0, + nil, t)).to be true + expect(@records.activity_records(a).length).to eq(4) + expect(tables_to_arrays(@records.to_s)).to eq([ + [["5 km", "0:20:00", "4:00", "1", "Activity 1", "2014-11-08"], + ["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + [["5 km", "0:20:00", "4:00", "1", "Activity 1", "2014-11-08"], + ["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + ]) + end + + it 'should replace an old record with a new one' do + a = @store.new(Mock_Activity, 'Activity 3') + @ffs.add_activity(a) + t = Time.parse('2014-11-11T09:16:00') + expect(@records.register_result(a, 'running', 5000.0, + 19 * 60, t)).to be true + expect(@records.activity_records(a).length).to eq (2) + expect(@records.activity_records(@ffs.activities[0]).length).to eq(0) + expect(tables_to_arrays(@records.to_s)).to eq([ + [["5 km", "0:19:00", "3:47", "3", "Activity 3", "2014-11-11"], + ["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + [["5 km", "0:19:00", "3:47", "3", "Activity 3", "2014-11-11"], + ["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + ]) + end + + it 'should add a new table for a new year' do + a = @store.new(Mock_Activity, 'Activity 4') + @ffs.add_activity(a) + t = Time.parse('2015-01-01T06:00:00') + expect(@records.register_result(a, 'running', 5000.0, + 21 * 60, t)).to be true + expect(@records.activity_records(a).length).to eq(1) + expect(tables_to_arrays(@records.to_s)).to eq([ + [["5 km", "0:19:00", "3:47", "3", "Activity 3", "2014-11-11"], + ["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + [["5 km", "0:21:00", "4:12", "4", "Activity 4", "2015-01-01"]], + [["5 km", "0:19:00", "3:47", "3", "Activity 3", "2014-11-11"], + ["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + ]) + end + + it 'should not add a new record for poor result' do + a = @store.new(Mock_Activity, 'Activity 5') + @ffs.add_activity(a) + t = Time.parse('2015-01-02T10:00:00') + expect(@records.register_result(a, 'running', 5000.0, 22 * 60, + t)).to be false + expect(@records.activity_records(a).length).to eq(0) + end + + it 'should not delete a record for non-record activity' do + expect(@records.delete_activity(@ffs.activities[0])).to be false + end + + it 'should delete a record for a record activity' do + expect(@records.delete_activity(@ffs.activities[2])).to be true + expect(tables_to_arrays(@records.to_s)).to eq([ + [["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + [["5 km", "0:21:00", "4:12", "4", "Activity 4", "2015-01-01"]], + [["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + ]) + end + + it 'should add a new distance record' do + a = @store.new(Mock_Activity, 'Activity 6') + @ffs.add_activity(a) + t = Time.parse('2015-01-10T07:00:00') + expect(@records.register_result(a, 'running', 15000.0, nil, t)).to be true + expect(@records.activity_records(a).length).to eq(2) + expect(tables_to_arrays(@records.to_s)).to eq([ + [ ["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "15.000 km", "-", "6", "Activity 6", "2015-01-10"]], + [["5 km", "0:21:00", "4:12", "4", "Activity 4", "2015-01-01"], + ["Longest Distance", "15.000 km", "-", "6", "Activity 6", "2015-01-10"]], + [["10 km", "0:42:00", "4:12", "2", "Activity 2", "2014-11-09"], + ["Longest Distance", "10.000 km", "-", "2", "Activity 2", "2014-11-09"]], + ]) + end + + it 'should not register a record for a bogus sport' do + a = @store.new(Mock_Activity, 'Activity 5') + @ffs.add_activity(a) + t = Time.parse('2015-04-01T11:11:11') + expect(@records.register_result(a, 'foobaring', 5000.0, 10 * 60, + t)).to be false + end + + it 'should not register a record for unknown distance' do + a = @store.new(Mock_Activity, 'Activity 6') + @ffs.add_activity(a) + expect { @records.register_result(a, 'cycling', 42.0, 10 * 60, + Time.parse('2015-04-01T11:11:11'))}.to raise_error(Fit4Ruby::Error) + end + + it 'should delete all records' do + @records.delete_all_records + expect(@records.to_s).to eq('') + end + +end diff --git a/spec/PostRunner_spec.rb b/spec/PostRunner_spec.rb index f4dd33b..24ae0fa 100644 --- a/spec/PostRunner_spec.rb +++ b/spec/PostRunner_spec.rb @@ -3,7 +3,7 @@ # # = PostRunner_spec.rb -- PostRunner - Manage the data from your Garmin sport devices. # -# Copyright (c) 2014 by Chris Schlaeger <cs@taskjuggler.org> +# Copyright (c) 2014, 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org> # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as @@ -21,14 +21,15 @@ describe PostRunner::Main do args = [ '--dbdir', @db_dir ] + args old_stdout = $stdout $stdout = (stdout = StringIO.new) - PostRunner::Main.new(args) + @postrunner = PostRunner::Main.new(args) $stdout = old_stdout stdout.string end before(:all) do - @work_dir = tmp_dir_name(__FILE__) - Dir.mkdir(@work_dir) + capture_stdio + create_working_dirs + @db_dir = File.join(@work_dir, '.postrunner') @file1 = File.join(@work_dir, 'FILE1.FIT') @file2 = File.join(@work_dir, 'FILE2.FIT') @@ -37,15 +38,15 @@ describe PostRunner::Main do end after(:all) do - FileUtils.rm_rf(@work_dir) + cleanup end it 'should abort without arguments' do - lambda { postrunner([]) }.should raise_error Fit4Ruby::Error + expect { postrunner([]) }.to raise_error(Fit4Ruby::Error) end it 'should abort with bad command' do - lambda { postrunner(%w( foobar)) }.should raise_error Fit4Ruby::Error + expect { postrunner(%w( foobar)) }.to raise_error(Fit4Ruby::Error) end it 'should support the -v option' do @@ -73,62 +74,53 @@ describe PostRunner::Main do end it 'should list the imported file' do - postrunner(%w( list )).index('FILE1').should be_a(Fixnum) + expect(postrunner(%w( list )).index('FILE1')).to be_a(Fixnum) end it 'should import the other FIT file' do postrunner([ 'import', @work_dir ]) list = postrunner(%w( list )) - list.index('FILE1.FIT').should be_a(Fixnum) - list.index('FILE2.FIT').should be_a(Fixnum) - rc = YAML::load_file(File.join(@db_dir, 'config.yml')) - rc[:import_dir].should == @work_dir - - template = "<a href=\"%s.html\"><img src=\"icons/%s.png\" " + - "class=\"active_button\">" - html1 = File.read(File.join(@db_dir, 'html', 'FILE1.html')) - html1.include?(template % ['FILE2', 'forward']).should be_true - html2 = File.read(File.join(@db_dir, 'html', 'FILE2.html')) - html2.include?(template % ['FILE1', 'back']).should be_true + expect(list.index('FILE1.FIT')).to be_a(Fixnum) + expect(list.index('FILE2.FIT')).to be_a(Fixnum) end it 'should delete the first file' do postrunner(%w( delete :2 )) list = postrunner(%w( list )) - list.index('FILE1.FIT').should be_nil - list.index('FILE2.FIT').should be_a(Fixnum) + expect(list.index('FILE1.FIT')).to be_nil + expect(list.index('FILE2.FIT')).to be_a(Fixnum) end it 'should not import the deleted file again' do postrunner(%w( import . )) list = postrunner(%w( list )) - list.index('FILE1.FIT').should be_nil - list.index('FILE2.FIT').should be_a(Fixnum) + expect(list.index('FILE1.FIT')).to be_nil + expect(list.index('FILE2.FIT')).to be_a(Fixnum) end it 'should rename FILE2.FIT activity' do postrunner(%w( rename foobar :1 )) list = postrunner(%w( list )) - list.index(@file2).should be_nil - list.index('foobar').should be_a(Fixnum) + expect(list.index('FILE2.FIT')).to be_nil + expect(list.index('foobar')).to be_a(Fixnum) end it 'should fail when setting bad attribute' do - lambda { postrunner(%w( set foo bar :1)) }.should raise_error Fit4Ruby::Error + expect { postrunner(%w( set foo bar :1)) }.to raise_error(Fit4Ruby::Error) end it 'should set name for FILE2.FIT activity' do postrunner(%w( set name foobar :1 )) list = postrunner(%w( list )) - list.index(@file2).should be_nil - list.index('foobar').should be_a(Fixnum) + expect(list.index(@file2)).to be_nil + expect(list.index('foobar')).to be_a(Fixnum) end it 'should set activity type for FILE2.FIT activity' do postrunner(%w( set type Cycling :1 )) list = postrunner(%w( summary :1 )) - list.index('Running').should be_nil - list.index('Cycling').should be_a(Fixnum) + expect(list.index('Running')).to be_nil + expect(list.index('Cycling')).to be_a(Fixnum) end it 'should list the events of an activity' do @@ -140,18 +132,18 @@ describe PostRunner::Main do end it 'should fail when setting bad activity type' do - lambda { postrunner(%w( set type foobar :1)) }.should raise_error Fit4Ruby::Error + expect { postrunner(%w( set type foobar :1)) }.to raise_error(Fit4Ruby::Error) end it 'should set activity subtype for FILE2.FIT activity' do postrunner(%w( set subtype Road :1 )) list = postrunner(%w( summary :1 )) - list.index('Generic').should be_nil - list.index('Road').should be_a(Fixnum) + expect(list.index('Generic')).to be_nil + expect(list.index('Road')).to be_a(Fixnum) end it 'should fail when setting bad activity subtype' do - lambda { postrunner(%w( set subtype foobar :1)) }.should raise_error Fit4Ruby::Error + expect { postrunner(%w( set subtype foobar :1)) }.to raise_error(Fit4Ruby::Error) end it 'should dump an activity from the archive' do @@ -170,26 +162,5 @@ describe PostRunner::Main do postrunner(%w( units metric )) end - it 'should properly upgrade to a new version' do - # Change version in config file to 0.0.0. - rc = PostRunner::RuntimeConfig.new(@db_dir) - rc.set_option(:version, '0.0.0') - # Check that the config file really was changed. - rc = PostRunner::RuntimeConfig.new(@db_dir) - rc.get_option(:version).should == '0.0.0' - - archive_file = File.join(@db_dir, 'archive.yml') - archive = YAML.load_file(archive_file) - archive.each { |a| a.remove_instance_variable:@sport } - File.write(archive_file, archive.to_yaml) - - # Run some command. - postrunner(%w( list )) - - # Check that version matches the current version again. - rc = PostRunner::RuntimeConfig.new(@db_dir) - rc.get_option(:version).should == PostRunner::VERSION - end - end diff --git a/spec/View_spec.rb b/spec/View_spec.rb index 538b71f..79c95a9 100644 --- a/spec/View_spec.rb +++ b/spec/View_spec.rb @@ -52,7 +52,7 @@ module PostRunner pages.current_page = file PostRunner::View.new("Test File: #{file}", views, pages).body. write(file) - File.exists?(file).should be true + expect(File.exists?(file)).to be true end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index dbb1e0e..dba6be6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,16 @@ require 'fileutils' $:.unshift(File.join(File.dirname(__FILE__), '..', '..', lib_dir, 'lib')) end +require 'fit4ruby' +require 'perobs' +require 'postrunner/FitFileStore' +require 'postrunner/PersonalRecords' + +def capture_stdio + @log = StringIO.new + Fit4Ruby::Log.open(@log) +end + def tmp_dir_name(caller_file) begin dir_name = File.join(Dir.tmpdir, @@ -28,6 +38,29 @@ def tmp_dir_name(caller_file) dir_name end +def create_working_dirs + @work_dir = tmp_dir_name(__FILE__) + Dir.mkdir(@work_dir) + @fit_dir = File.join(@work_dir, 'fit') + Dir.mkdir(@fit_dir) + @html_dir = File.join(@work_dir, 'html') + Dir.mkdir(@html_dir) +end + +def cleanup + FileUtils.rm_rf(@work_dir) +end + +def create_fit_file_store + store = PEROBS::Store.new(File.join(@work_dir, 'db')) + store['config'] = store.new(PEROBS::Hash) + store['config']['data_dir'] = @work_dir + store['config']['html_dir'] = @html_dir + store['config']['unit_system'] = :metric + @ffs = store['file_store'] = store.new(PostRunner::FitFileStore) + @records = store['records'] = store.new(PostRunner::PersonalRecords) +end + def create_fit_file(name, date, duration_minutes = 30) Fit4Ruby.write(name, create_fit_activity( { :t => date, :duration => duration_minutes })) @@ -112,4 +145,42 @@ def create_fit_activity(config) a end +def tables_to_arrays(str) + mode = :searching_table + arrays = [] + array = [] + str.each_line do |line| + case mode + when :searching_table + if line[0] == '+' + mode = :header + end + when :header + if line[0] == '|' + mode = :separation_line + else + mode = :searching_table + end + when :separation_line + if line[0] == '+' + mode = :body + else + mode = :searching_table + end + when :body + if line[0] == '|' + array << line[1..-3].split('|').map(&:strip) + elsif line[0] == '+' + arrays << array + array = [] + mode = :searching_table + else + array = [] + mode = :searching_table + end + end + end + + arrays +end |