rails-contact 0.1.7 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48540c1897658b886292f1fafe8157fa2aa7fed817d9b9ebb64213456ac958e3
4
- data.tar.gz: 3329ba043c33922528e3736e8a77d739a71151c30d471bc300ef832131ab09fd
3
+ metadata.gz: 50cd2c0740daf7405d8085d2956f810cf0bf8883388ff12afb72ee07a6497b6c
4
+ data.tar.gz: 64db2836ff0701a348e215ea294dae2cef1326ef3fb633a4a69676c4b5e477ef
5
5
  SHA512:
6
- metadata.gz: 34f85c62bf91b52739ad8833e884a44ac142c44b507eda5c1c1044320cda3fba787c1d20afa3d4eb196df81cf02dd806b593ef1b8d06d383790741bd80b055e1
7
- data.tar.gz: 4ddb497c06c1fc0fed8ad0b5a636f5ff640afe425e1d1fe0b40c2acc9ab9500bc0b473eab23be1bde9eacfb29c972b63bf3e10095db7ed2f19cd3bdf169d7bbb
6
+ metadata.gz: 6b8653a3e39f49d9d6aa1bb04463df1f4b5b10ac31d759af6b5bb465b8b2b4ecfbf9a83ee8cbf60a70296d805b5df91e942031ea85f0d530075d00e18bb5a4bc
7
+ data.tar.gz: e42099d4322b591d5ae7646560afda7178e5ce1674512efab58ef172d34fe74b99b46583553e6cad43ff2a47c96cf95efe34bdd9d2e68958fc77a3a164585812
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.11
4
+
5
+ - Default **Google sync panel** back on the engine index via `_google_sync_panel` when `google_sync_enabled` and `google_sync_ui_on_index` (default true). Host apps can override the partial or disable with `google_sync_ui_on_index = false`.
6
+
7
+ ## 0.1.10
8
+
9
+ - Short-lived: engine index omitted the Google panel (use **0.1.11** instead).
10
+
11
+ ## 0.1.9
12
+
13
+ - `POST /google_sync_rolling_window` → `ContactsController#google_sync_rolling_window` → `GoogleSyncJob` (re-sync rolling window: creates + updates).
14
+
15
+ ## 0.1.8
16
+
17
+ - Contacts index: paginated search (total count, previous/next, page indicator) with Elasticsearch and database backends.
18
+ - `POST /google_sync_unsynced` and **Sync … not yet in Google** button when sync is enabled; `GoogleSyncUnsyncedJob` runs `SyncService#sync_unsynced!` for contacts with no `google_resource_name`.
19
+
3
20
  ## 0.1.7
4
21
 
5
22
  - Google People API: `updateContact` sends required `updatePersonFields`; update payloads include `resourceName` and `etag`; sync preloads emails, phones, and addresses.
data/README.md CHANGED
@@ -134,6 +134,22 @@ Rails::Contact.configure do |config|
134
134
  end
135
135
  ```
136
136
 
137
+ ### Google sync UI: gem default vs host override
138
+
139
+ **Recommendation:** keep the **default panel in the gem** so every app gets working buttons when `google_sync_enabled` is true. You avoid copy-paste and stay aligned with new endpoints.
140
+
141
+ - The index template renders `rails/contact/_google_sync_panel` when `google_sync_ui_on_index` is **true** (default).
142
+ - **Customize without forking the engine:** add `app/views/rails/contact/_google_sync_panel.html.erb` in the host app; Rails resolves that file instead of the gem’s partial.
143
+ - **Hide the default panel:** set `config.google_sync_ui_on_index = false` and render your own UI anywhere (same `POST` targets below).
144
+ - **Custom index only:** override `rails/contact/index` and `<%= render "rails/contact/google_sync_panel" %>` wherever it fits (e.g. after a CSV upload section).
145
+
146
+ Endpoints (used by the default partial):
147
+
148
+ - `google_sync_rolling_window_contacts_path` — `GoogleSyncJob` (rolling window re-sync).
149
+ - `google_sync_unsynced_contacts_path` — `GoogleSyncUnsyncedJob` (contacts with no `google_resource_name`).
150
+
151
+ `ContactsController#index` sets `@google_contacts_pending_sync` when sync is enabled.
152
+
137
153
  ---
138
154
 
139
155
  ## Rake tasks
@@ -10,10 +10,41 @@ module Rails
10
10
 
11
11
  def index
12
12
  @query = params[:q].to_s.strip
13
- @contacts = Search::Query.new(@query, filters: normalized_filters(filter_params)).call
13
+ result = Search::Query.new(
14
+ @query,
15
+ filters: normalized_filters(filter_params),
16
+ page: params[:page]
17
+ ).call
18
+ @contacts = result.records
19
+ @total_count = result.total_count
20
+ @page = result.page
21
+ @per_page = result.per_page
22
+ @total_pages = result.total_pages
23
+ @google_contacts_pending_sync = google_pending_sync_count
14
24
  render "rails/contact/index"
15
25
  end
16
26
 
27
+ def google_sync_unsynced
28
+ unless Rails::Contact.configuration.google_sync_enabled
29
+ redirect_to contacts_path, alert: "Google Contacts sync is disabled."
30
+ return
31
+ end
32
+
33
+ GoogleSyncUnsyncedJob.perform_later
34
+ redirect_to contacts_path, notice: "Google sync has been queued for contacts not yet linked."
35
+ end
36
+
37
+ def google_sync_rolling_window
38
+ unless Rails::Contact.configuration.google_sync_enabled
39
+ redirect_to contacts_path, alert: "Google Contacts sync is disabled."
40
+ return
41
+ end
42
+
43
+ GoogleSyncJob.perform_later
44
+ redirect_to contacts_path,
45
+ notice: "Google re-sync has been queued for contacts in the recent window (creates and updates)."
46
+ end
47
+
17
48
  def show
18
49
  render "rails/contact/show"
19
50
  end
@@ -99,6 +130,12 @@ module Rails
99
130
  params.permit(:city, :region, :sync_eligible, :starred)
100
131
  end
101
132
 
133
+ def google_pending_sync_count
134
+ return 0 unless Rails::Contact.configuration.google_sync_enabled
135
+
136
+ Contact.where("google_resource_name IS NULL OR google_resource_name = ?", "").count
137
+ end
138
+
102
139
  def enqueue_index(contact_id)
103
140
  IndexContactJob.perform_later(contact_id)
104
141
  end
@@ -0,0 +1,13 @@
1
+ module Rails
2
+ module Contact
3
+ class GoogleSyncUnsyncedJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform
7
+ return unless Rails::Contact.configuration.google_sync_enabled
8
+
9
+ Google::SyncService.new.sync_unsynced!
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ <% if Rails::Contact.configuration.google_sync_enabled && Rails::Contact.configuration.google_sync_ui_on_index %>
2
+ <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm space-y-4">
3
+ <h2 class="text-sm font-semibold text-gray-900">Google Contacts</h2>
4
+
5
+ <div>
6
+ <%= form_with url: google_sync_rolling_window_contacts_path, method: :post, local: true, class: "flex flex-wrap items-center gap-3" do %>
7
+ <%= submit_tag "Re-sync recent contacts with Google",
8
+ class: "inline-flex rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700",
9
+ data: { confirm: "Queue Google sync for the recent contact window? This updates contacts already in Google and creates any that are missing. Runs in the background." } %>
10
+ <% end %>
11
+ <p class="mt-2 text-xs text-gray-500">
12
+ Pushes the most recently updated contacts (up to your configured limit) to Google—new rows and changes to names, emails, phones, etc.
13
+ </p>
14
+ </div>
15
+
16
+ <% if @google_contacts_pending_sync.to_i.positive? %>
17
+ <div class="border-t border-gray-100 pt-4">
18
+ <%= form_with url: google_sync_unsynced_contacts_path, method: :post, local: true, class: "flex flex-wrap items-center gap-3" do %>
19
+ <%= submit_tag "Sync #{@google_contacts_pending_sync} contact#{'s' if @google_contacts_pending_sync != 1} with no Google link yet (any age)",
20
+ class: "inline-flex rounded-md border border-emerald-700 bg-white px-4 py-2 text-sm font-semibold text-emerald-800 hover:bg-emerald-50",
21
+ data: { confirm: "Queue Google sync for #{@google_contacts_pending_sync} contact(s) that are not linked yet? This runs in the background." } %>
22
+ <% end %>
23
+ <p class="mt-2 text-xs text-gray-500">For contacts outside the recent window or older imports that never received a Google <code class="rounded bg-gray-100 px-1">resourceName</code>.</p>
24
+ </div>
25
+ <% end %>
26
+ </div>
27
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <% if defined?(@total_count) && @total_count.to_i > 0 %>
2
+ <div class="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
3
+ <p class="text-sm text-gray-600">
4
+ <% start_i = (@page - 1) * @per_page + 1 %>
5
+ <% end_i = [ @page * @per_page, @total_count ].min %>
6
+ Showing <span class="font-medium text-gray-900"><%= start_i %>–<%= end_i %></span>
7
+ of <span class="font-medium text-gray-900"><%= number_with_delimiter(@total_count) %></span>
8
+ <% if @per_page.present? && @per_page > 0 %>
9
+ <span class="text-gray-500">(<%= @per_page %> per page)</span>
10
+ <% end %>
11
+ </p>
12
+ <% if defined?(@total_pages) && @total_pages > 1 %>
13
+ <div class="flex flex-wrap items-center gap-2">
14
+ <% qp = request.query_parameters.except(:page, :action, :controller) %>
15
+ <% if @page > 1 %>
16
+ <%= link_to "Previous", url_for(qp.merge(page: @page - 1)), class: "inline-flex rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50" %>
17
+ <% end %>
18
+ <span class="text-sm text-gray-600">Page <%= @page %> / <%= @total_pages %></span>
19
+ <% if @page < @total_pages %>
20
+ <%= link_to "Next", url_for(qp.merge(page: @page + 1)), class: "inline-flex rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50" %>
21
+ <% end %>
22
+ </div>
23
+ <% end %>
24
+ </div>
25
+ <% end %>
@@ -26,6 +26,8 @@
26
26
  <% end %>
27
27
  </div>
28
28
 
29
+ <%= render "rails/contact/google_sync_panel" %>
30
+
29
31
  <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
30
32
  <%= form_with url: bulk_destroy_contacts_path, method: :post, local: true, class: "inline-flex items-center gap-2" do |f| %>
31
33
  <%= hidden_field_tag :ids, "", id: "bulk-ids-field" %>
@@ -76,6 +78,7 @@
76
78
  <% end %>
77
79
  </tbody>
78
80
  </table>
81
+ <%= render "rails/contact/index_pagination" %>
79
82
  </div>
80
83
  </div>
81
84
 
data/config/routes.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  Rails::Contact::Engine.routes.draw do
2
2
  post "/bulk_destroy", to: "contacts#bulk_destroy", as: :bulk_destroy_contacts
3
3
  post "/merge", to: "contacts#merge", as: :merge_contacts
4
+ post "/google_sync_unsynced", to: "contacts#google_sync_unsynced", as: :google_sync_unsynced_contacts
5
+ post "/google_sync_rolling_window", to: "contacts#google_sync_rolling_window", as: :google_sync_rolling_window_contacts
4
6
  get "/", to: "contacts#index", as: :contacts
5
7
  get "/new", to: "contacts#new", as: :new_contact
6
8
  post "/", to: "contacts#create"
@@ -4,6 +4,8 @@ Rails::Contact.configure do |config|
4
4
  config.elasticsearch_url = ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200")
5
5
 
6
6
  config.google_sync_enabled = ENV.fetch("RAILS_CONTACT_GOOGLE_SYNC_ENABLED", "false") == "true"
7
+ # When true (default), contacts index renders the gem’s Google sync panel; set false to supply your own UI.
8
+ # config.google_sync_ui_on_index = false
7
9
  config.google_max_contacts = 25_000
8
10
  config.rolling_window_sort = :updated_at
9
11
 
@@ -2,7 +2,7 @@ module Rails
2
2
  module Contact
3
3
  class Configuration
4
4
  attr_accessor :contact_class_name, :elasticsearch_url, :search_backend,
5
- :google_sync_enabled, :google_max_contacts, :rolling_window_sort,
5
+ :google_sync_enabled, :google_sync_ui_on_index, :google_max_contacts, :rolling_window_sort,
6
6
  :google_client_id, :google_client_secret, :google_redirect_uri,
7
7
  :google_token_path, :reset_index_on_boot, :default_per_page,
8
8
  :inherit_host_layout,
@@ -13,6 +13,8 @@ module Rails
13
13
  @elasticsearch_url = ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200")
14
14
  @search_backend = :elasticsearch
15
15
  @google_sync_enabled = false
16
+ # When true with google_sync_enabled, the default index renders _google_sync_panel (host can override that partial).
17
+ @google_sync_ui_on_index = true
16
18
  @google_max_contacts = 25_000
17
19
  @rolling_window_sort = :updated_at
18
20
  @google_client_id = ENV["GOOGLE_CLIENT_ID"]
@@ -13,6 +13,22 @@ module Rails
13
13
  end
14
14
  end
15
15
 
16
+ def unsynced_contacts_scope
17
+ Rails::Contact::Contact.where("google_resource_name IS NULL OR google_resource_name = ?", "")
18
+ end
19
+
20
+ def sync_unsynced!
21
+ unsynced_contacts_scope.in_batches(of: 100) do |batch|
22
+ batch.preload(:emails, :phones, :addresses).each do |contact|
23
+ sync_contact(contact)
24
+ rescue StandardError => e
25
+ Rails.logger.error(
26
+ "[Rails::Contact] Google sync_unsynced failed for contact #{contact.id}: #{e.class}: #{e.message}"
27
+ )
28
+ end
29
+ end
30
+ end
31
+
16
32
  def sync_contact(contact)
17
33
  payload = PayloadMapper.new(contact).to_people_payload
18
34
  response = if contact.google_resource_name.present?
@@ -3,13 +3,19 @@ module Rails
3
3
  module Search
4
4
  module Backends
5
5
  class Database
6
- def search(query, filters)
6
+ def search(query, filters, page:, per_page:)
7
+ offset = (page - 1) * per_page
7
8
  scope = Contact.includes(:emails, :phones, :labels).recent_first
8
9
  scope = apply_filters(scope, filters)
9
- return scope.limit(limit) if query.blank?
10
+
11
+ if query.blank?
12
+ total = scope.count
13
+ records = scope.offset(offset).limit(per_page).to_a
14
+ return Search::Result.new(records: records, total_count: total, page: page, per_page: per_page)
15
+ end
10
16
 
11
17
  wildcard = "%#{query.downcase}%"
12
- scope.left_joins(:emails, :phones, :labels).where(
18
+ filtered = scope.left_joins(:emails, :phones, :labels).where(
13
19
  "LOWER(rails_contact_contacts.given_name) LIKE :q OR "\
14
20
  "LOWER(rails_contact_contacts.family_name) LIKE :q OR "\
15
21
  "LOWER(COALESCE(rails_contact_contacts.metadata->>'company', '')) LIKE :q OR "\
@@ -19,7 +25,10 @@ module Rails
19
25
  "LOWER(rails_contact_labels.name) LIKE :q",
20
26
  q: wildcard,
21
27
  raw: "%#{query}%"
22
- ).distinct.limit(limit)
28
+ ).distinct
29
+ total = filtered.count(:id)
30
+ records = filtered.offset(offset).limit(per_page).to_a
31
+ Search::Result.new(records: records, total_count: total, page: page, per_page: per_page)
23
32
  end
24
33
 
25
34
  private
@@ -34,10 +43,6 @@ module Rails
34
43
  end
35
44
  scoped
36
45
  end
37
-
38
- def limit
39
- Rails::Contact.configuration.default_per_page
40
- end
41
46
  end
42
47
  end
43
48
  end
@@ -23,13 +23,16 @@ module Rails
23
23
  @client = client || self.class.default_client
24
24
  end
25
25
 
26
- def search(query, filters)
27
- response = @client.search(index: INDEX, body: search_body(query, filters))
28
- ids = response.fetch("hits", {}).fetch("hits", []).map { |doc| doc["_id"] }
26
+ def search(query, filters, page:, per_page:)
27
+ response = @client.search(index: INDEX, body: search_body(query, filters, page: page, per_page: per_page))
28
+ hits = response.fetch("hits", {})
29
+ ids = hits.fetch("hits", []).map { |doc| doc["_id"] }
30
+ total_count = extract_total_hits(hits["total"])
29
31
  records_by_id = Contact.where(id: ids).index_by { |record| record.id.to_s }
30
- ids.filter_map { |id| records_by_id[id.to_s] }
32
+ records = ids.filter_map { |id| records_by_id[id.to_s] }
33
+ Search::Result.new(records: records, total_count: total_count, page: page, per_page: per_page)
31
34
  rescue StandardError
32
- Search::Backends::Database.new.search(query, filters)
35
+ Search::Backends::Database.new.search(query, filters, page: page, per_page: per_page)
33
36
  end
34
37
 
35
38
  def upsert(contact)
@@ -81,9 +84,12 @@ module Rails
81
84
  }
82
85
  end
83
86
 
84
- def search_body(query, filters)
87
+ def search_body(query, filters, page:, per_page:)
88
+ from = (page - 1) * per_page
85
89
  {
86
- size: Rails::Contact.configuration.default_per_page,
90
+ from: from,
91
+ size: per_page,
92
+ track_total_hits: true,
87
93
  sort: [ { updated_at: { order: "desc" } }, { id: { order: "desc" } } ],
88
94
  query: {
89
95
  bool: {
@@ -94,6 +100,12 @@ module Rails
94
100
  }
95
101
  end
96
102
 
103
+ def extract_total_hits(raw)
104
+ return 0 if raw.nil?
105
+
106
+ raw.is_a?(Hash) ? raw.fetch("value", 0).to_i : raw.to_i
107
+ end
108
+
97
109
  def search_clause(query)
98
110
  return [ { match_all: {} } ] if query.blank?
99
111
 
@@ -2,13 +2,17 @@ module Rails
2
2
  module Contact
3
3
  module Search
4
4
  class Query
5
- def initialize(query, filters: {})
5
+ MAX_PER_PAGE = 100
6
+
7
+ def initialize(query, filters: {}, page: nil, per_page: nil)
6
8
  @query = query
7
9
  @filters = filters.to_h.compact_blank
10
+ @page = page.to_i < 1 ? 1 : page.to_i
11
+ @per_page = resolve_per_page(per_page)
8
12
  end
9
13
 
10
14
  def call
11
- backend.search(@query, @filters)
15
+ backend.search(@query, @filters, page: @page, per_page: @per_page)
12
16
  end
13
17
 
14
18
  private
@@ -21,6 +25,12 @@ module Rails
21
25
  Search::Backends::Database.new
22
26
  end
23
27
  end
28
+
29
+ def resolve_per_page(per_page)
30
+ p = per_page.to_i
31
+ p = Rails::Contact.configuration.default_per_page if p <= 0
32
+ [ p, MAX_PER_PAGE ].min
33
+ end
24
34
  end
25
35
  end
26
36
  end
@@ -0,0 +1,13 @@
1
+ module Rails
2
+ module Contact
3
+ module Search
4
+ Result = Struct.new(:records, :total_count, :page, :per_page, keyword_init: true) do
5
+ def total_pages
6
+ return 0 if total_count.zero? || per_page.zero?
7
+
8
+ (total_count.to_f / per_page).ceil
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Contact
3
- VERSION = "0.1.7"
3
+ VERSION = "0.1.11"
4
4
  end
5
5
  end
data/lib/rails/contact.rb CHANGED
@@ -4,6 +4,7 @@ require "rails/contact/engine"
4
4
  require "rails/contact/configuration"
5
5
  require "rails/contact/orm"
6
6
  require "rails/contact/routing"
7
+ require "rails/contact/search/result"
7
8
  require "rails/contact/search/query"
8
9
  require "rails/contact/search/backends/database"
9
10
  require "rails/contact/search/backends/elasticsearch"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-contact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -119,6 +119,7 @@ files:
119
119
  - app/helpers/rails/contact/application_helper.rb
120
120
  - app/jobs/rails/contact/application_job.rb
121
121
  - app/jobs/rails/contact/google_sync_job.rb
122
+ - app/jobs/rails/contact/google_sync_unsynced_job.rb
122
123
  - app/jobs/rails/contact/index_contact_job.rb
123
124
  - app/mailers/rails/contact/application_mailer.rb
124
125
  - app/mailers/rails/contact/contact_mailer.rb
@@ -137,6 +138,8 @@ files:
137
138
  - app/views/rails/contact/_email_fields.html.erb
138
139
  - app/views/rails/contact/_event_fields.html.erb
139
140
  - app/views/rails/contact/_form.html.erb
141
+ - app/views/rails/contact/_google_sync_panel.html.erb
142
+ - app/views/rails/contact/_index_pagination.html.erb
140
143
  - app/views/rails/contact/_nested_fields_script.html.erb
141
144
  - app/views/rails/contact/_phone_fields.html.erb
142
145
  - app/views/rails/contact/_stylesheet.html.erb
@@ -181,6 +184,7 @@ files:
181
184
  - lib/rails/contact/search/backends/database.rb
182
185
  - lib/rails/contact/search/backends/elasticsearch.rb
183
186
  - lib/rails/contact/search/query.rb
187
+ - lib/rails/contact/search/result.rb
184
188
  - lib/rails/contact/version.rb
185
189
  - lib/tasks/rails/contact_tasks.rake
186
190
  - spec/concerns/filterable_spec.rb