• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

benwbrum / fromthepage / 18598020689

17 Oct 2025 03:55PM UTC coverage: 58.32% (+0.07%) from 58.252%
18598020689

push

github

web-flow
4965 - Subject counts are negative (#4970)

* 4965 - Subject counts are negative

* 4965 - Add migration to remove db column

* 4976 - Fix broken specs

* 4965 - Add missing migration file

* 4965 - Optimizations

* 4965 - Fix broken sorting

* 4965 - Fix CI

* 4965 - Fix specs

---------

Co-authored-by: WillNigel23 <wcjdesus@up.edu.ph>
Co-authored-by: Ben W. Brumfield <benwbrum@gmail.com>

1913 of 3917 branches covered (48.84%)

Branch coverage included in aggregate %.

43 of 46 new or added lines in 6 files covered. (93.48%)

8489 of 13919 relevant lines covered (60.99%)

96.27 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.87
/app/controllers/article_controller.rb
1
class ArticleController < ApplicationController
1✔
2
  include AbstractXmlController
1✔
3
  include AbstractXmlHelper
1✔
4

5
  skip_before_action :verify_authenticity_token, only: [:relationship_graph]
1✔
6
  before_action :authorized?, except: [:list, :show, :tooltip, :graph, :relationship_graph]
1✔
7

8
  def tooltip
1✔
9
    render partial: 'tooltip'
8✔
10
  end
11

12
  def list
1✔
13
    articles = @collection.articles.includes(:categories)
33✔
14
    @uncategorized_articles = articles.where(categories: { id: nil })
33✔
15
    @categories = Category.recursive_tree_for(@collection.is_a?(DocumentSet) ? @collection.collection_id : @collection.id)
33✔
16
    @categories_tree = @categories.group_by(&:parent_id)
33✔
17

18
    if params[:selected_category_id] == 'uncategorized'
33✔
19
      @selected_category = 'uncategorized'
1✔
20
      @articles = @uncategorized_articles.reorder(:title)
1✔
21
      @ancestor_ids = []
1✔
22
    elsif params[:selected_category_id].present?
32✔
23
      @selected_category = @categories.find { |category| category.id == params[:selected_category_id].to_i }
49✔
24
      @articles = articles.where(categories: { id: @selected_category.id }).reorder(:title)
15✔
25
      @ancestor_ids = Category.ancestors_for(@selected_category.id).pluck(:id)
15✔
26
    else
17✔
27
      @selected_category = @categories_tree.dig(nil).first
17✔
28
      if @selected_category.present?
17✔
29
        @articles = articles.where(categories: { id: @selected_category.id }).reorder(:title)
17✔
30
        @ancestor_ids = Category.ancestors_for(@selected_category.id).pluck(:id)
17✔
31
      else
×
NEW
32
        @articles = articles.none
×
NEW
33
        @ancestor_ids = []
×
34
      end
35
    end
36

37
    @pages_count_map = @articles.left_joins(:page_article_links)
33✔
38
                                .group('articles.id')
39
                                .pluck('articles.id, COUNT(page_article_links.id)')
40
                                .to_h
41
    @articles = Article.sort_vertically(@articles)
33✔
42

43
    respond_to do |format|
33✔
44
      format.html
33✔
45
      format.turbo_stream
33✔
46
    end
47
  end
48

49
  def delete
1✔
50
    result = Article::Destroy.new(
2✔
51
      article: @article,
52
      collection: @collection,
53
      user: current_user
54
    ).call
55

56
    if result.success?
2✔
57
      redirect_to collection_subjects_path(@collection.owner, @collection)
2✔
58
    else
×
59
      flash.alert = result.message
×
60
      redirect_to collection_article_show_path(@collection.owner, @collection, @article.id)
×
61
    end
62
  end
63

64
  def edit
1✔
65
    @sex_autocomplete=@collection.articles.distinct.pluck(:sex).select { |e| !e.blank? }
24✔
66
    @race_description_autocomplete=@collection.articles.distinct.pluck(:race_description).select { |e| !e.blank? }
24✔
67
  end
68

69
  def update
1✔
70
    if params[:save]
8✔
71
      result = Article::Update.new(
5✔
72
        article: @article,
73
        article_params: article_params,
74
        user: current_user
75
      ).call
76

77
      if result.success?
5✔
78
        record_deed
4✔
79

80
        flash[:notice] = result.notice
4✔
81
        redirect_to collection_article_edit_path(@collection.owner, @collection, @article)
4✔
82
      else
1✔
83
        @article = result.article
1✔
84
        render :edit, status: :unprocessable_entity
1✔
85
      end
3✔
86
    elsif params[:autolink]
3✔
87
      @article.source_text = autolink(@article.source_text)
2✔
88

89
      flash[:notice] = t('.subjects_auto_linking')
2✔
90
      render :edit
2✔
91
    else
92
      # Default to redirect
1✔
93
      redirect_to collection_article_edit_path(@collection.owner, @collection, @article)
1✔
94
    end
95
  end
96

97
  def article_category
1✔
98
    categories = Category.where(id: params[:category_ids])
1✔
99
    @article.categories = categories
1✔
100
    @article.save!
1✔
101

102
    respond_to(&:turbo_stream)
1✔
103
  end
104

105
  def combine_duplicate
1✔
106
    Article::Combine.new(
3✔
107
      article: @article,
108
      from_article_ids: params[:from_article_ids],
109
      user: current_user
110
    ).call
111

112
    flash[:notice] = t('.selected_subjects_combined', title: @article.title)
3✔
113
    redirect_to collection_article_edit_path(@collection.owner, @collection, @article)
3✔
114
  end
115

116
  def graph
1✔
117
    redirect_to action: :show, article_id: @article.id
×
118
  end
119

120
  def relationship_graph
1✔
121
    unless File.exist? @article.d3js_file
2!
122
      article_links=[]
2✔
123
      article_nodes=[]
2✔
124
      # get all the source article links
125
      @article.source_article_links.each do |link|
2✔
126
        article_nodes << link.target_article
2✔
127
        article_links << link.target_article_id
2✔
128
      end
129
      # get all the source article links
130
      @article.target_article_links.each do |link|
2✔
131
        article_nodes << link.source_article
×
132
        article_links << link.source_article_id
×
133
      end
134
      document_nodes=[]
2✔
135
      work_nodes=[]
2✔
136
      center_article_to_document_links=[]
2✔
137
      second_document_to_article_links=[]
2✔
138
      # get all the pages and works linking to this article
139
      if @collection.pages_are_meaningful?
2✔
140
        document_association = @article.pages
2✔
141
      else
×
142
        document_association = @article.works
×
143
      end
144

145
      document_association.each do |document|
2✔
146
        document_nodes << document
2✔
147
        center_article_to_document_links << document.id
2✔
148
        document.articles.each do |article|
2✔
149
          article_nodes << article
4✔
150
          second_document_to_article_links << [document.id, article.id]
4✔
151
        end
152
      end
153

154
      # now construct the JSON response
155
      nodes=[]
2✔
156
      nodes << {
2✔
157
        'id' => "S#{@article.id}",
158
        'title' => @article.title,
159
        'group' => @article.title,
160
        'link' => collection_article_show_url(@collection.owner, @collection, @article) }
161
      article_nodes.uniq.each do |article|
2✔
162
        if article != @article
4✔
163
          nodes << {
2✔
164
            'id' => "S#{article.id}",
165
            'title' => article.title,
166
            'group' => article.categories.first&.title,
2!
167
            'link' => collection_article_show_url(@collection.owner, @collection, article)
168
          }
169
        end
170
      end
171
      if @collection.pages_are_meaningful?
2✔
172
        document_nodes.uniq.each do |page|
2✔
173
          nodes << {
2✔
174
            'id' => "D#{page.id}",
175
            'title' => page.title + ' in ' + page.work.title,
176
            'group' => 'Documents',
177
            'link' => collection_display_page_path(@collection.owner, @collection, page.work, page)
178
          }
179
        end
180
      else
×
181
        document_nodes.uniq.each do |work|
×
182
          nodes << {
×
183
            'id' => "D#{work.id}",
184
            'title' => work.title,
185
            'group' => 'Documents',
186
            'link' => collection_read_work_url(@collection.owner, @collection, work)
187
          }
188
        end
189
      end
190

191
      links=[]
2✔
192
      article_links.tally.each do |article_id, link_count|
2✔
193
        links << {
2✔
194
          'source'=>"S#{@article.id}",
195
          'target'=>"S#{article_id}",
196
          'value'=>link_count,
197
          'group'=>'direct'
198
        }
199
      end
200
      center_article_to_document_links.tally.each do |work_id, link_count|
2✔
201
        links << {
2✔
202
          'source'=>"S#{@article.id}",
203
          'target'=>"D#{work_id}",
204
          'value'=>link_count,
205
          'group'=>'mentioned in'
206
        }
207
      end
208
      second_document_to_article_links.tally.each do |link_pair, link_count|
2✔
209
        work_id, second_article_id = link_pair
4✔
210
        links << {
4✔
211
          'source'=>"D#{work_id}",
212
          'target'=>"S#{second_article_id}",
213
          'value'=>link_count,
214
          'group'=>'mentions'
215
        }
216
      end
217

218
      doc={ 'nodes' => nodes, 'links' => links }
2✔
219
      File.write(@article.d3js_file, doc.to_json)
2✔
220
    end
221

222
    # now render the d3js file
223
    render file: @article.d3js_file, type: 'application/javascript; charset=utf-8', layout: false
2✔
224
  end
225

226
  def show
1✔
227
    sql =
228
      'SELECT count(*) as link_count, '+
13✔
229
      'a.title as title, '+
230
      'a.id as article_id '+
231
      'FROM page_article_links to_links '+
232
      'INNER JOIN page_article_links from_links '+
233
      '  ON to_links.page_id = from_links.page_id '+
234
      'INNER JOIN articles a '+
235
      '  ON from_links.article_id = a.id '+
236
      "WHERE to_links.article_id = #{@article.id} "+
237
      " AND from_links.article_id != #{@article.id} "
238
    sql += 'GROUP BY a.title, a.id '
13✔
239
    logger.debug(sql)
13✔
240
    article_links = Article.connection.select_all(sql)
13✔
241
    link_total = 0
13✔
242
    link_max = 0
13✔
243
    count_per_rank = { 0 => 0 }
13✔
244
    article_links.each do |l|
13✔
245
      link_count = l['link_count'].to_i
2✔
246
      link_total += link_count
2✔
247
      link_max = [link_count, link_max].max
2✔
248

249
      count_per_rank[link_count] ||= 0
2✔
250
      count_per_rank[link_count] += 1
2✔
251
    end
252

253
    min_rank = 0
13✔
254
    # now we know how many articles each link count has, as well as the size
255
    if params[:min_rank]
13✔
256
      # use the min rank from the params
×
257
      min_rank = params[:min_rank].to_i
×
258
    else
259
      # calculate whether we should reduce the rank
13✔
260
      num_articles = article_links.count
13✔
261
      while num_articles > DEFAULT_ARTICLES_PER_GRAPH && min_rank < link_max
13✔
262
        # remove the outer rank
×
263
        num_articles -= count_per_rank[min_rank] || 0 # hash is sparse
×
264
        min_rank += 1
×
265
        logger.debug("DEBUG: \tnum articles now #{num_articles}\n")
×
266
      end
267
    end
268

269
    dot_source = render_to_string(
13✔
270
      partial: 'graph',
271
      layout: false,
272
      locals: {
273
        article_links: article_links,
274
        link_total: link_total,
275
        link_max: link_max,
276
        min_rank: min_rank
277
      },
278
      formats: [:dot]
279
    )
280

281
    dot_file = "#{Rails.root}/public/images/working/dot/#{@article.id}.dot"
13✔
282
    File.open(dot_file, 'w') do |f|
13✔
283
      f.write(dot_source)
13✔
284
    end
285
    dot_out = "#{Rails.root}/public/images/working/dot/#{@article.id}.png"
13✔
286
    dot_out_map = "#{Rails.root}/public/images/working/dot/#{@article.id}.map"
13✔
287

288
    system "#{Rails.application.config.neato} -Tcmapx -o#{dot_out_map} -Tpng #{dot_file} -o #{dot_out}"
13✔
289

290
    @map = File.read(dot_out_map)
13✔
291
    @article.graph_image = dot_out
13✔
292
    @article.save!
13✔
293
    session[:col_id] = @collection.slug
13✔
294
  end
295

296
  # display the article upload form
297
  def upload_form
1✔
298
  end
299

300
  # actually process the uploaded CSV
301
  def subject_upload
1✔
302
    @collection = Collection.find params[:upload][:collection_id]
×
303
    # read the file
304
    file = params[:upload][:file].tempfile
×
305

306
    # csv = CSV.read(params[:upload][:file].tempfile, :headers => true)
307
    begin
308
      csv = CSV.read(params[:upload][:file].tempfile, headers: true)
×
309
    rescue
310
      contents = File.read(params[:upload][:file].tempfile)
×
311
      detection = CharlockHolmes::EncodingDetector.detect(contents)
×
312

313
      csv = CSV.read(params[:upload][:file].tempfile,
×
314
                      encoding: "bom|#{detection[:encoding]}",
315
                      liberal_parsing: true,
316
                      headers: true)
317
    end
318

319
    provenance = params[:upload][:file].original_filename + " (uploaded #{Time.now} UTC)"
×
320

321
    # check the values
322
    if csv.headers.include?('HEADING') && csv.headers.include?('URI') && csv.headers.include?('ARTICLE') && csv.headers.include?('CATEGORY')
×
323
      # create subjects if heading checks out
×
324
      csv.each do |row|
×
325
        title = row['HEADING']
×
326
        article = @collection.articles.where(title: title).first || Article.new(title: title, provenance: provenance)
×
327
        article.collection = @collection
×
328
        article.source_text = row['ARTICLE']
×
329
        article.uri = row['URI']
×
330
        article.categories << find_or_create_category(@collection, row['CATEGORY'])
×
331
        article.save!
×
332
      end
333
      # redirect to subject list
334
      redirect_to collection_subjects_path(@collection.owner, @collection)
×
335
    else
336
      # flash message and redirect to upload form on problems
×
337
      flash[:error] = t('.csv_file_must_contain_headers')
×
338
      redirect_to article_upload_form_path(@collection)
×
339
    end
340
  end
341

342
  def upload_example
1✔
343
    example = File.read(File.join(Rails.root, 'app', 'views', 'static', 'subject_example.csv'))
×
344
    send_data example, filename: 'subject_example.csv'
×
345
  end
346

347
  protected
1✔
348

349
  def record_deed
1✔
350
    deed = Deed.new
4✔
351
    deed.article = @article
4✔
352
    deed.deed_type = DeedType::ARTICLE_EDIT
4✔
353
    deed.collection = @article.collection
4✔
354
    deed.user = current_user
4✔
355
    deed.save!
4✔
356
    update_search_attempt_contributions
4✔
357
  end
358

359
  private
1✔
360

361
  def authorized?
1✔
362
    redirect_to dashboard_path unless user_signed_in?
30✔
363
  end
364

365
  def article_params
1✔
366
    params.require(:article).permit(:title, :uri, :short_summary, :source_text, :latitude, :longitude, :birth_date, :death_date, :race_description, :sex, :bibliography, :begun, :ended, category_ids: [])
5✔
367
  end
368
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc