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

benwbrum / fromthepage / 26718072968

31 May 2026 04:28PM UTC coverage: 70.225% (-0.03%) from 70.252%
26718072968

push

github

web-flow
Merge pull request #5561 from benwbrum/feature/refactor-feature-specs-w-v-u

Feature/refactor feature specs w v u

2524 of 4128 branches covered (61.14%)

Branch coverage included in aggregate %.

10266 of 14085 relevant lines covered (72.89%)

169.29 hits per line

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

87.22
/app/controllers/article_controller.rb
1
class ArticleController < ApplicationController
1✔
2
  ARTICLES_BATCH_SIZE = 900
1✔
3

4
  include AbstractXmlController
1✔
5
  include AbstractXmlHelper
1✔
6

7
  skip_before_action :verify_authenticity_token, only: [:relationship_graph]
1✔
8
  before_action :authorized?, except: [:list, :items, :show, :tooltip, :graph, :relationship_graph]
1✔
9
  before_action :authorize_collection, only: [:upload_form, :subject_upload]
1✔
10

11
  def tooltip
1✔
12
    render partial: 'tooltip'
8✔
13
  end
14

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

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

40
    respond_to do |format|
35✔
41
      format.html
35✔
42
      format.turbo_stream
35✔
43
    end
44
  end
45

46
  def items
1✔
47
    articles_scope = @collection.articles.includes(:categories)
17✔
48
    @batch = params[:batch].to_i
17✔
49
    @timestamp = params[:timestamp]
17✔
50

51
    if params[:selected_category_id] == 'uncategorized'
17✔
52
      @category = 'uncategorized'
1✔
53
      articles_scope = articles_scope.where(categories: { id: nil })
1✔
54
    else
16✔
55
      @category = @collection.categories.find(params[:selected_category_id])
16✔
56
      articles_scope = articles_scope.where(categories: { id: @category.id })
16✔
57
    end
58

59
    @next_batch = @batch + 1 if articles_scope.count > (@batch + 1) * ARTICLES_BATCH_SIZE
17✔
60

61
    article_ids = Article.sort_vertically(articles_scope.pluck(:id))
17✔
62
    paged_article_ids = article_ids[@batch * ARTICLES_BATCH_SIZE, ARTICLES_BATCH_SIZE] || []
17✔
63
    @articles = Article.where(id: paged_article_ids)
17✔
64
                       .order(Arel.sql("FIELD(id, #{paged_article_ids.join(',')})"))
65

66
    render turbo_stream: turbo_stream.replace(
17✔
67
      "lazy_items_#{@timestamp}", partial: 'items', locals: { articles: @articles, category: @category, timestamp: @timestamp }
68
    )
69
  end
70

71
  def page_counts
1✔
72
    article = @collection.articles.find(params[:article_id])
20✔
73
    count = article.page_article_links.count
19✔
74

75
    render turbo_stream: turbo_stream.replace(
19✔
76
      "lazy_item_#{article.id}_#{params[:timestamp]}", partial: 'page_counts', locals: { count: count }
77
    )
78
  end
79

80
  def delete
1✔
81
    result = Article::Destroy.new(
2✔
82
      article: @article,
83
      collection: @collection,
84
      user: current_user
85
    ).call
86

87
    if result.success?
2✔
88
      redirect_to collection_subjects_path(@collection.owner, @collection)
2✔
89
    else
×
90
      flash[:alert] = result.message || t('errors.error')
×
91
      redirect_to collection_article_show_path(@collection.owner, @collection, @article.id)
×
92
    end
93
  end
94

95
  def edit
1✔
96
    @sex_autocomplete=@collection.articles.distinct.pluck(:sex).select { |e| !e.blank? }
40✔
97
    @race_description_autocomplete=@collection.articles.distinct.pluck(:race_description).select { |e| !e.blank? }
40✔
98
  end
99

100
  def update
1✔
101
    if params[:save]
12✔
102
      result = Article::Update.new(
9✔
103
        article: @article,
104
        article_params: article_params,
105
        user: current_user
106
      ).call
107

108
      if result.success?
9✔
109
        record_deed
8✔
110

111
        flash[:notice] = result.notice
8✔
112
        redirect_to collection_article_edit_path(@collection.owner, @collection, @article)
8✔
113
      else
1✔
114
        @article = result.article
1✔
115
        render :edit, status: :unprocessable_entity
1✔
116
      end
3✔
117
    elsif params[:autolink]
3✔
118
      @article.source_text = autolink(@article.source_text)
2✔
119

120
      flash[:notice] = t('.subjects_auto_linking')
2✔
121
      render :edit
2✔
122
    else
123
      # Default to redirect
1✔
124
      redirect_to collection_article_edit_path(@collection.owner, @collection, @article)
1✔
125
    end
126
  end
127

128
  def article_category
1✔
129
    categories = Category.where(id: params[:category_ids])
1✔
130
    @article.categories = categories
1✔
131
    @article.save!
1✔
132

133
    respond_to(&:turbo_stream)
1✔
134
  end
135

136
  def combine_duplicate
1✔
137
    Article::Combine.new(
3✔
138
      article: @article,
139
      from_article_ids: params[:from_article_ids],
140
      user: current_user
141
    ).call
142

143
    flash[:notice] = t('.selected_subjects_combined', title: @article.title)
3✔
144
    redirect_to collection_article_edit_path(@collection.owner, @collection, @article)
3✔
145
  end
146

147
  def graph
1✔
148
    redirect_to action: :show, article_id: @article.id
×
149
  end
150

151
  def relationship_graph
1✔
152
    unless File.exist? @article.d3js_file
4!
153
      article_links=[]
4✔
154
      article_nodes=[]
4✔
155
      # get all the source article links
156
      @article.source_article_links.each do |link|
4✔
157
        article_nodes << link.target_article
3✔
158
        article_links << link.target_article_id
3✔
159
      end
160
      # get all the source article links
161
      @article.target_article_links.each do |link|
4✔
162
        article_nodes << link.source_article
×
163
        article_links << link.source_article_id
×
164
      end
165
      document_nodes=[]
4✔
166
      work_nodes=[]
4✔
167
      center_article_to_document_links=[]
4✔
168
      second_document_to_article_links=[]
4✔
169
      # get all the pages and works linking to this article
170
      if @collection.pages_are_meaningful?
4✔
171
        document_association = @article.pages
4✔
172
      else
×
173
        document_association = @article.works
×
174
      end
175

176
      document_association.each do |document|
4✔
177
        document_nodes << document
4✔
178
        center_article_to_document_links << document.id
4✔
179
        document.articles.each do |article|
4✔
180
          article_nodes << article
7✔
181
          second_document_to_article_links << [document.id, article.id]
7✔
182
        end
183
      end
184

185
      # now construct the JSON response
186
      nodes=[]
4✔
187
      nodes << {
4✔
188
        'id' => "S#{@article.id}",
189
        'title' => @article.title,
190
        'group' => @article.title,
191
        'link' => collection_article_show_url(@collection.owner, @collection, @article) }
192
      article_nodes.uniq.each do |article|
4✔
193
        if article != @article
7✔
194
          nodes << {
3✔
195
            'id' => "S#{article.id}",
196
            'title' => article.title,
197
            'group' => article.categories.first&.title,
3!
198
            'link' => collection_article_show_url(@collection.owner, @collection, article)
199
          }
200
        end
201
      end
202
      if @collection.pages_are_meaningful?
4✔
203
        document_nodes.uniq.each do |page|
4✔
204
          nodes << {
4✔
205
            'id' => "D#{page.id}",
206
            'title' => page.title + ' in ' + page.work.title,
207
            'group' => 'Documents',
208
            'link' => collection_display_page_path(@collection.owner, @collection, page.work, page),
209
            'identifier' => page.work.identifier
210
          }
211
        end
212
      else
×
213
        document_nodes.uniq.each do |work|
×
214
          nodes << {
×
215
            'id' => "D#{work.id}",
216
            'title' => work.title,
217
            'group' => 'Documents',
218
            'link' => collection_read_work_url(@collection.owner, @collection, work),
219
            'identifier' => work.identifier
220
          }
221
        end
222
      end
223

224
      links=[]
4✔
225
      article_links.tally.each do |article_id, link_count|
4✔
226
        links << {
3✔
227
          'source'=>"S#{@article.id}",
228
          'target'=>"S#{article_id}",
229
          'value'=>link_count,
230
          'group'=>'direct'
231
        }
232
      end
233
      center_article_to_document_links.tally.each do |work_id, link_count|
4✔
234
        links << {
4✔
235
          'source'=>"S#{@article.id}",
236
          'target'=>"D#{work_id}",
237
          'value'=>link_count,
238
          'group'=>'mentioned in'
239
        }
240
      end
241
      second_document_to_article_links.tally.each do |link_pair, link_count|
4✔
242
        work_id, second_article_id = link_pair
7✔
243
        links << {
7✔
244
          'source'=>"D#{work_id}",
245
          'target'=>"S#{second_article_id}",
246
          'value'=>link_count,
247
          'group'=>'mentions'
248
        }
249
      end
250

251
      doc={ 'nodes' => nodes, 'links' => links }
4✔
252
      File.write(@article.d3js_file, doc.to_json)
4✔
253
    end
254

255
    # now render the d3js file
256
    render file: @article.d3js_file, type: 'application/javascript; charset=utf-8', layout: false
4✔
257
  end
258

259
  def show
1✔
260
    # Handle missing article_id parameter (e.g., from crawlers)
261
    if @article.nil?
22✔
262
      head :bad_request
1✔
263
      return
1✔
264
    end
265

266
    sql =
267
      'SELECT count(*) as link_count, '+
21✔
268
      'a.title as title, '+
269
      'a.id as article_id '+
270
      'FROM page_article_links to_links '+
271
      'INNER JOIN page_article_links from_links '+
272
      '  ON to_links.page_id = from_links.page_id '+
273
      'INNER JOIN articles a '+
274
      '  ON from_links.article_id = a.id '+
275
      "WHERE to_links.article_id = #{@article.id} "+
276
      " AND from_links.article_id != #{@article.id} "
277
    sql += 'GROUP BY a.title, a.id '
21✔
278
    logger.debug(sql)
21✔
279
    article_links = Article.connection.select_all(sql)
21✔
280
    link_total = 0
21✔
281
    link_max = 0
21✔
282
    count_per_rank = { 0 => 0 }
21✔
283
    article_links.each do |l|
21✔
284
      link_count = l['link_count'].to_i
×
285
      link_total += link_count
×
286
      link_max = [link_count, link_max].max
×
287

288
      count_per_rank[link_count] ||= 0
×
289
      count_per_rank[link_count] += 1
×
290
    end
291

292
    min_rank = 0
21✔
293
    # now we know how many articles each link count has, as well as the size
294
    if params[:min_rank]
21✔
295
      # use the min rank from the params
×
296
      min_rank = params[:min_rank].to_i
×
297
    else
298
      # calculate whether we should reduce the rank
21✔
299
      num_articles = article_links.count
21✔
300
      while num_articles > DEFAULT_ARTICLES_PER_GRAPH && min_rank < link_max
21✔
301
        # remove the outer rank
×
302
        num_articles -= count_per_rank[min_rank] || 0 # hash is sparse
×
303
        min_rank += 1
×
304
        logger.debug("DEBUG: \tnum articles now #{num_articles}\n")
×
305
      end
306
    end
307

308
    dot_source = render_to_string(
21✔
309
      partial: 'graph',
310
      layout: false,
311
      locals: {
312
        article_links: article_links,
313
        link_total: link_total,
314
        link_max: link_max,
315
        min_rank: min_rank
316
      },
317
      formats: [:dot]
318
    )
319

320
    dot_file = "#{Rails.root}/public/images/working/dot/#{@article.id}.dot"
21✔
321
    File.open(dot_file, 'w') do |f|
21✔
322
      f.write(dot_source)
21✔
323
    end
324
    dot_out = "#{Rails.root}/public/images/working/dot/#{@article.id}.png"
21✔
325
    dot_out_map = "#{Rails.root}/public/images/working/dot/#{@article.id}.map"
21✔
326

327
    system "#{Rails.application.config.neato} -Tcmapx -o#{dot_out_map} -Tpng #{dot_file} -o #{dot_out}"
21✔
328

329
    @map = File.read(dot_out_map)
21✔
330
    @article.graph_image = dot_out
21✔
331
    @article.save!
21✔
332
    session[:col_id] = @collection.slug
21✔
333
  end
334

335
  # display the article upload form
336
  def upload_form
1✔
337
  end
338

339
  # TODO: Move to async job if performance is slow
340
  def subject_upload
1✔
341
    file = params[:upload][:file]
2✔
342

343
    result = Article::ImportCsv.new(
2✔
344
      file: file.tempfile,
345
      collection: @collection,
346
      original_filename: file.original_filename
347
    ).call
348

349
    if result.success?
2✔
350
      redirect_to collection_subjects_path(@collection.owner, @collection)
1✔
351
    else
1✔
352
      flash[:error] = t('.csv_file_must_contain_headers')
1✔
353
      redirect_to article_upload_form_path(@collection)
1✔
354
    end
355
  end
356

357
  def upload_example
1✔
358
    example = File.read(File.join(Rails.root, 'app', 'views', 'static', 'subject_example.csv'))
×
359
    send_data example, filename: 'subject_example.csv'
×
360
  end
361

362
  protected
1✔
363

364
  def record_deed
1✔
365
    deed = Deed.new
8✔
366
    deed.article = @article
8✔
367
    deed.deed_type = DeedType::ARTICLE_EDIT
8✔
368
    deed.collection = @article.collection
8✔
369
    deed.user = current_user
8✔
370
    deed.save!
8✔
371
    update_search_attempt_contributions
8✔
372
  end
373

374
  private
1✔
375

376
  def authorized?
1✔
377
    redirect_to dashboard_path unless user_signed_in?
69✔
378
  end
379

380
  def article_params
1✔
381
    params.require(:article).permit(:title, :uri, :short_summary, :source_text, :latitude, :longitude, :birth_date, :death_date, :race_description, :sex, :bibliography, :begun, :ended, category_ids: [])
9✔
382
  end
383
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