From 10c5cc5bbcb74e0e9d9ad610b1ca74edcbab546f Mon Sep 17 00:00:00 2001 From: Max Wofford Date: Mon, 3 Mar 2025 23:13:39 -0500 Subject: [PATCH] First version of bulk uploads --- .../api/hackatime/v1/hackatime_controller.rb | 137 ++++++++++++++++++ app/controllers/hackatime_controller.rb | 46 ------ app/helpers/application_helper.rb | 23 +++ app/models/heartbeat.rb | 4 + app/models/user.rb | 18 +++ config/routes.rb | 10 +- .../20250303180842_create_heartbeats.rb | 2 +- ...032720_add_extension_text_type_to_users.rb | 5 + db/schema.rb | 5 +- 9 files changed, 197 insertions(+), 53 deletions(-) create mode 100644 app/controllers/api/hackatime/v1/hackatime_controller.rb delete mode 100644 app/controllers/hackatime_controller.rb create mode 100644 db/migrate/20250304032720_add_extension_text_type_to_users.rb diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb new file mode 100644 index 0000000..ddefa4b --- /dev/null +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -0,0 +1,137 @@ +class Api::Hackatime::V1::HackatimeController < ApplicationController + before_action :set_user, except: [ :index ] + # before_action :set_json_format + skip_before_action :verify_authenticity_token + + def index + redirect_to root_path + end + + def push_heartbeats + puts "Raw params: #{params.inspect}" + puts "Creating heartbeat with params: #{heartbeat_params}" + attrs = heartbeat_params.merge({ user: @user }) + puts "Merged attrs: #{attrs}" + new_heartbeat = Heartbeat.new(attrs) + + if new_heartbeat.save! + render json: { responses: [ { heartbeat: new_heartbeat.attributes, status: 201 } ] }, status: :created + else + render json: { error: "Failed to create heartbeat: #{new_heartbeat.errors.full_messages}" }, status: :unprocessable_entity + end + end + + def push_heartbeats_bulk + new_heartbeats = [] + + ActiveRecord::Base.transaction do + new_heartbeats = heartbeat_bulk_params.map do |heartbeat| + attrs = heartbeat.merge({ user_id: @user.id }) + Heartbeat.create(attrs) + end + end + + responses = [] + new_heartbeats.each do |heartbeat| + responses << [ heartbeat.attributes, heartbeat.persisted? ? 201 : 422 ] + end + + render json: { responses: responses }, status: :success + end + + def status_bar_today + hbt = @user.heartbeats.today + + render json: { + "data": { + "grand_total": { + "decimal": "yolo", + "digital": "wahoo", + "hours": hbt.duration_seconds / 3600, + "minutes": (hbt.duration_seconds % 3600) / 60, + "text": @user.format_extension_text(hbt.duration_seconds), + "total_seconds": hbt.duration_seconds + }, + "categories": hbt.distinct.pluck(:category), + "dependencies": hbt.distinct.pluck(:dependencies), + "editors": hbt.distinct.pluck(:editor), + "languages": hbt.distinct.pluck(:language), + "machines": hbt.distinct.pluck(:machine), + "operating_systems": hbt.distinct.pluck(:operating_system), + "projects": hbt.distinct.pluck(:project), + "range": { + "text": "Today", + "timezone": "UTC" + } + } + } + end + + private + + # def set_json_format + # request.format = :json + # end + + def set_user + api_header = request.headers["Authorization"] + raw_token = api_header&.split(" ")&.last + header_type = api_header&.split(" ")&.first + if header_type == "Bearer" + api_token = raw_token + elsif header_type == "Basic" + api_token = Base64.decode64(raw_token) + end + return render json: { error: "Unauthorized" }, status: :unauthorized unless api_token.present? + valid_key = ApiKey.find_by(token: api_token) + return render json: { error: "Unauthorized" }, status: :unauthorized unless valid_key.present? + + @user = valid_key.user + render json: { error: "Unauthorized" }, status: :unauthorized unless @user + end + + def heartbeat_keys + [ + :branch, + :category, + :created_at, + :cursorpos, + :dependencies, + :editor, + :entity, + :is_write, + :language, + :line_additions, + :line_deletions, + :lineno, + :lines, + :machine, + :operating_system, + :project, + :project_root_count, + :time, + :type, + :user_agent + ] + end + + # allow either heartbeat or heartbeats + def heartbeat_bulk_params + params.require(:hackatime).permit( + heartbeats: [ + *heartbeat_keys + ] + ) + end + + def heartbeat_params + # Handle both direct params and _json format from WakaTime + if params[:_json].present? + params[:_json].first.permit(*heartbeat_keys) + elsif params[:hackatime].present? + params.require(:hackatime).permit(*heartbeat_keys) + else + params.permit(*heartbeat_keys) + end + end +end diff --git a/app/controllers/hackatime_controller.rb b/app/controllers/hackatime_controller.rb deleted file mode 100644 index 90942ea..0000000 --- a/app/controllers/hackatime_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -class HackatimeController < ApplicationController - before_action :set_user - - def push_heartbeats - @user.heartbeats.create(heartbeat_params) - end - - def push_heartbeats_bulk - @user.heartbeats.create(heartbeat_params) - end - - private - - def set_user - # each user has a Hackatime::User with an api_key - # the api_key is sent in the Authorization header as a Bearer token - api_key = request.headers["Authorization"].split(" ")[1] - @user = Hackatime::User.find_by(api_key: api_key) - render json: { error: "Unauthorized" }, status: :unauthorized unless @user - end - - def heartbeat_params - params.require(:heartbeat).permit( - :branch, - :category, - :created_at, - :cursorpos, - :dependencies, - :editor, - :entity, - :is_write, - :language, - :line_additions, - :line_deletions, - :lineno, - :lines, - :machine, - :operating_system, - :project, - :project_root_count, - :time, - :type, - :user_agent - ) - end -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index eeb1f38..10cd865 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -21,6 +21,9 @@ module ApplicationHelper end def short_time_detailed(time) + # ie. 5h 10m 10s + # ie. 10m 10s + # ie. 10m hours = time.to_i / 3600 minutes = (time.to_i % 3600) / 60 seconds = time.to_i % 60 @@ -31,4 +34,24 @@ module ApplicationHelper time_parts << "#{seconds}s" if seconds.positive? time_parts.join(" ") end + + def time_in_emoji(duration) + # ie. 15.hours => "🕒" + half_hours = duration.to_i / 1800 + clocks = [ + "🕛", "🕧", + "🕐", "🕜", + "🕑", "🕝", + "🕒", "🕞", + "🕓", "🕟", + "🕔", "🕠", + "🕕", "🕡", + "🕖", "🕢", + "🕗", "🕣", + "🕘", "🕤", + "🕙", "🕥", + "🕚", "🕦" + ] + clocks[half_hours % clocks.length] + end end diff --git a/app/models/heartbeat.rb b/app/models/heartbeat.rb index 0c6c4c0..4a1cdfd 100644 --- a/app/models/heartbeat.rb +++ b/app/models/heartbeat.rb @@ -1,5 +1,9 @@ class Heartbeat < ApplicationRecord + # This is to prevent Rails from trying to use STI even though we have a "type" column + self.inheritance_column = nil + belongs_to :user validates :time, presence: true + validates :time, uniqueness: { scope: [ :user_id, :project, :branch, :language, :dependencies, :lineno, :cursorpos, :is_write, :entity, :type, :category, :project_root_count ] } end diff --git a/app/models/user.rb b/app/models/user.rb index 905f4e5..a8f8b08 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,24 @@ class User < ApplicationRecord has_many :api_keys + enum :hackatime_extension_text_type, { + simple_text: 0, + clock_emoji: 1, + compliment_text: 2 + } + + def format_extension_text(duration) + case hackatime_extension_text_type + when :simple_text + return "Start coding to track your time" if duration.zero? + ::ApplicationController.helpers.short_time_simple(duration) + when :clock_emoji + ::ApplicationController.helpers.time_in_emoji(duration) + when :compliment_text + "You're doing great!" + end + end + def admin? is_admin end diff --git a/config/routes.rb b/config/routes.rb index ebf5665..aea98d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -48,15 +48,17 @@ Rails.application.routes.draw do get "my/settings", to: "users#edit", as: :my_settings patch "my/settings", to: "users#update" - post "/sailors_log/slack/commands", to: "slack#create" post "/timedump/slack/commands", to: "slack#create" + # API routes namespace :api do - namespace :wakatime do + namespace :hackatime do namespace :v1 do - post "/heartbeats", to: "hackatime#push_heartbeats" - post "/heartbeats/bulk", to: "hackatime#push_heartbeats_bulk" + get "/", to: "hackatime#index" + get "/users/:id/statusbar/today", to: "hackatime#status_bar_today" + post "/users/:id/heartbeats", to: "hackatime#push_heartbeats" + post "/users/:id/heartbeats.bulk", to: "hackatime#push_heartbeats_bulk" end end end diff --git a/db/migrate/20250303180842_create_heartbeats.rb b/db/migrate/20250303180842_create_heartbeats.rb index f4a3098..2c0869b 100644 --- a/db/migrate/20250303180842_create_heartbeats.rb +++ b/db/migrate/20250303180842_create_heartbeats.rb @@ -22,7 +22,7 @@ class CreateHeartbeats < ActiveRecord::Migration[8.0] t.integer :cursorpos t.integer :project_root_count - t.datetime :time + t.float :time, null: false t.boolean :is_write diff --git a/db/migrate/20250304032720_add_extension_text_type_to_users.rb b/db/migrate/20250304032720_add_extension_text_type_to_users.rb new file mode 100644 index 0000000..59cff78 --- /dev/null +++ b/db/migrate/20250304032720_add_extension_text_type_to_users.rb @@ -0,0 +1,5 @@ +class AddExtensionTextTypeToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :hackatime_extension_text_type, :integer, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 107065d..2857bad 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_03_03_180842) do +ActiveRecord::Schema[8.0].define(version: 2025_03_04_032720) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -134,7 +134,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_03_180842) do t.integer "lines" t.integer "cursorpos" t.integer "project_root_count" - t.datetime "time" + t.float "time", null: false t.boolean "is_write" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -250,6 +250,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_03_180842) do t.boolean "uses_slack_status", default: false, null: false t.string "slack_scopes", default: [], array: true t.text "slack_access_token" + t.integer "hackatime_extension_text_type", default: 0, null: false t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true end