summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Schlaeger <chris@linux.com>2016-01-03 21:24:36 +0100
committerChris Schlaeger <chris@linux.com>2016-01-03 21:24:36 +0100
commit998bc46bdf995963a237fccf51e75317d08cd608 (patch)
treefe57a6ab39f0a2dbad209ceb5e6ec95324a9a720
parent54719ca934e1b01dca10344bd3b392c686528a89 (diff)
downloadpostrunner-998bc46bdf995963a237fccf51e75317d08cd608.zip
Big refactoring to switch to PEROBS database.
-rw-r--r--lib/postrunner/ActivitiesDB.rb23
-rw-r--r--lib/postrunner/ActivityLink.rb2
-rw-r--r--lib/postrunner/ActivityListView.rb19
-rw-r--r--lib/postrunner/ActivityView.rb15
-rw-r--r--lib/postrunner/DirUtils.rb33
-rw-r--r--lib/postrunner/EventList.rb2
-rw-r--r--lib/postrunner/FFS_Activity.rb308
-rw-r--r--lib/postrunner/FFS_Device.rb106
-rw-r--r--lib/postrunner/FitFileStore.rb349
-rw-r--r--lib/postrunner/Main.rb142
-rw-r--r--lib/postrunner/PersonalRecords.rb322
-rw-r--r--lib/postrunner/RecordListPageView.rb12
-rw-r--r--spec/ActivitySummary_spec.rb19
-rw-r--r--spec/FitFileStore_spec.rb133
-rw-r--r--spec/FlexiTable_spec.rb2
-rw-r--r--spec/PersonalRecords_spec.rb206
-rw-r--r--spec/PostRunner_spec.rb81
-rw-r--r--spec/View_spec.rb2
-rw-r--r--spec/spec_helper.rb71
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