wikidata_position_history 1.3.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 43e80159fad0253e687e1dc8d52318079bdc2bf5
4
+ data.tar.gz: 26974422ae9f8584c6907d8531b63eeccee42231
5
+ SHA512:
6
+ metadata.gz: c1a7fb0eb3c293884452995d44dd7371ace248f48f676c15e64f87e1ed8604eedf0a8df53599186a5c978f8b93cafcd0d0f1282c8592296288d3ef5d7bfbf22f
7
+ data.tar.gz: 3dc935ea7ad2f67e9c1c8a4ca520a8a129fd05f2b78cb27d22f81e397fff81fcb311f543aa75949d824f5e565c25fdcce18bf478b9a7e3846110f4264036b1d2
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /.rubocop-https---raw-githubusercontent-com-everypolitician-everypolitician-data-master--rubocop-base-yml
@@ -0,0 +1,14 @@
1
+ ---
2
+ exclude_paths:
3
+ - test
4
+
5
+ detectors:
6
+ DuplicateMethodCall:
7
+ exclude:
8
+ - QueryService::WikidataDate#<=>
9
+ MissingSafeMethod:
10
+ exclude:
11
+ - WikidataPositionHistory::PageRewriter
12
+ UncommunicativeVariableName:
13
+ exclude:
14
+ - QueryService::Query#results
@@ -0,0 +1,54 @@
1
+ AllCops:
2
+ Exclude:
3
+ - 'Vagrantfile'
4
+ - 'vendor/**/*'
5
+ TargetRubyVersion: 2.4
6
+ NewCops: enable
7
+
8
+ Metrics/AbcSize:
9
+ Exclude:
10
+ - 'test/test_helper.rb'
11
+
12
+ Metrics/BlockLength:
13
+ Exclude:
14
+ - '**/*.gemspec'
15
+ - 'test/**/*.rb'
16
+
17
+ Layout/LineLength:
18
+ Max: 150
19
+ Exclude:
20
+ - 'test/**/*.rb'
21
+
22
+ Layout/HashAlignment:
23
+ EnforcedHashRocketStyle: table
24
+ EnforcedColonStyle: table
25
+
26
+ Lint/AssignmentInCondition:
27
+ Enabled: false
28
+
29
+ Naming/ClassAndModuleCamelCase:
30
+ Enabled: false
31
+
32
+ Style/CollectionMethods:
33
+ Enabled: true
34
+
35
+ Style/Documentation:
36
+ Enabled: false
37
+
38
+ Style/FormatStringToken:
39
+ Enabled: false
40
+
41
+ Style/HashSyntax:
42
+ EnforcedStyle: ruby19_no_mixed_keys
43
+
44
+ Style/RescueModifier:
45
+ Enabled: false
46
+
47
+ Style/SymbolArray:
48
+ Enabled: true
49
+
50
+ Style/TrailingCommaInHashLiteral:
51
+ EnforcedStyleForMultiline: consistent_comma
52
+
53
+ Style/TrailingCommaInArrayLiteral:
54
+ EnforcedStyleForMultiline: no_comma
@@ -0,0 +1,6 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.3
5
+ cache: bundler
6
+ before_install: gem install bundler -v 1.15.4
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ # [1.3.2] - 2020-08-29
4
+
5
+ ## Fixes
6
+
7
+ * Skip all date checks for items with no dates
8
+
9
+ # [1.3.1] - 2020-08-28
10
+
11
+ ## Fixes
12
+
13
+ * Skip the dates line if neither date is set
14
+
15
+ # [1.3.0] - 2020-08-17
16
+
17
+ ## Enhancements
18
+
19
+ * Display dates at more accurate levels of precision
20
+
21
+ # [1.2.0] - 2020-08-14
22
+
23
+ ## Enhancements
24
+
25
+ * Display inception and/or abolition dates for the position itself.
26
+
27
+ ## Fixes
28
+
29
+ * If an officeholder held multiple consecutive terms, do not warn that
30
+ they do not have 'replaces' or 'replaced by' statements pointing at
31
+ themselves
32
+
33
+ # [1.1.0] - 2020-08-14
34
+
35
+ ## Enhancements
36
+
37
+ * 'Acting' officeholders are visually differtiated in the output, and do
38
+ not require replaces/replaced-by statements
39
+ * If no officeholders are found, explicitly say so, rather than
40
+ displaying a completely empty table
41
+ * Position ID will be derived from Page name if not supplied
42
+
43
+ ## Fixes
44
+
45
+ * Deprecated P39 (position held) statements are no longer included
46
+
47
+ # [1.0.0] - 2017-11-03
48
+
49
+ Original release by mySociety
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in wikidata_position_history.gemspec
8
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Tony Bowden, 2017 mySociety
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,71 @@
1
+ # WikidataPositionHistory
2
+
3
+ Rewrites Mediawiki pages that include a `PositionHolderHistory`
4
+ template, to show a timeline of people who have held a particular
5
+ office, along with helpful diagnostic warnings for common errors.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'wikidata_position_history', github: 'tmtmtmtm/wikidata-position-history'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ WikidataPositionHistory::PageRewriter.new(
23
+ mediawiki_site: 'www.wikidata.org',
24
+ page_title: 'User:Mhl20/Prime_minister_test'
25
+ ).run!
26
+ ```
27
+
28
+ This looks for a Template call in that page of the form:
29
+
30
+ ```
31
+ {{PositionHolderHistory|id=Q14211}}
32
+ ```
33
+
34
+ If such a template is found, a table is inserted after it listing all
35
+ people who have held (i.e. have a relevant P39 "position held"
36
+ statement) position Q14211.
37
+
38
+ A sentinel HTML comment is also inserted, so that on subsequent runs
39
+ only the text between the template and that comment are rewritten.
40
+
41
+ ## Development
42
+
43
+ After checking out the repo, run `bin/setup` to install
44
+ dependencies. Then, run `rake test` to run the tests. You can
45
+ also run `bin/console` for an interactive prompt that will allow
46
+ you to experiment.
47
+
48
+ To install this gem onto your local machine, run `bundle exec
49
+ rake install`. To release a new version, update the version
50
+ number in `version.rb`, and then run `bundle exec rake release`,
51
+ which will create a git tag for the version, push git commits
52
+ and tags, and push the `.gem` file to
53
+ [rubygems.org](https://rubygems.org).
54
+
55
+ ## Contributing
56
+
57
+ Bug reports and pull requests are welcome on GitHub at
58
+ https://github.com/tmtmtmtm/wikidata-position-history
59
+
60
+ ## License
61
+
62
+ The gem is available as open source under the terms of the
63
+ [MIT License](http://opensource.org/licenses/MIT).
64
+
65
+ ## History
66
+
67
+ This was originally developed by Tony Bowden and Mark Longair at
68
+ mySociety as part of a [Wikimedia Foundation grant-funded
69
+ project](https://meta.wikimedia.org/wiki/Grants:Project/mySociety/EveryPolitician).
70
+
71
+ This version is now maintained independently by Tony Bowden.
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+ require 'reek/rake/task'
6
+ require 'rubocop/rake_task'
7
+
8
+ Rake::TestTask.new(:test) do |t|
9
+ t.libs << 'test'
10
+ t.libs << 'lib'
11
+ t.test_files = FileList['test/**/*_test.rb']
12
+ end
13
+
14
+ desc 'Run rubocop'
15
+ task :rubocop do
16
+ RuboCop::RakeTask.new
17
+ end
18
+
19
+ desc 'Run reek'
20
+ Reek::Rake::Task.new do |t|
21
+ t.fail_on_error = true
22
+ end
23
+
24
+ task default: %i[test rubocop reek]
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'wikidata_position_history'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'wikidata_position_history'
5
+
6
+ if ARGV.size != 1
7
+ abort "Usage: #{$PROGRAM_NAME} ITEM_ID
8
+ e.g. #{$PROGRAM_NAME} Q14211'"
9
+ end
10
+
11
+ puts WikidataPositionHistory::Report.new(ARGV.first).wikitext_with_header
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'wikidata_position_history'
5
+ require 'uri'
6
+
7
+ if ARGV.size != 1
8
+ abort "Usage: #{$PROGRAM_NAME} REFRESH_URL_OR_QUERYSTRING
9
+ e.g. wikidata_position_history 'mediawiki_site=www.wikidata.org&page_title=User%3AMhl20%2FPrime_minister_test'
10
+ or wikidata_position_history https://www.wikidata.org/wiki/User:Mhl20/Prime_minister_test"
11
+ end
12
+
13
+ if ARGV.first.start_with?('http')
14
+ uri = URI.parse ARGV.first
15
+ options = {
16
+ mediawiki_site: uri.hostname,
17
+ page_title: uri.path.gsub('/wiki/', ''), # with 2.5.1 we could use delete_prefix
18
+ }
19
+ else
20
+ options = URI.decode_www_form(ARGV.first).map { |k, v| [k.to_sym, v] }.to_h
21
+ end
22
+
23
+ warn "Running with options: #{options.inspect}" if ENV.key?('DEBUG')
24
+
25
+ rewriter = WikidataPositionHistory::PageRewriter.new(**options)
26
+ rewriter.run!
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rest-client'
5
+
6
+ module QueryService
7
+ # A SPARQL query against the Wikidata Query Service
8
+ class Query
9
+ WIKIDATA_SPARQL_URL = 'https://query.wikidata.org/sparql'
10
+
11
+ def initialize(query)
12
+ @query = query
13
+ end
14
+
15
+ def results
16
+ json
17
+ rescue RestClient::Exception => e
18
+ raise "Wikidata query #{query} failed: #{e.message}"
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :query
24
+
25
+ def result
26
+ @result ||= RestClient.get WIKIDATA_SPARQL_URL, accept: 'application/sparql-results+json', params: { query: query }
27
+ end
28
+
29
+ def json
30
+ JSON.parse(result, symbolize_names: true)[:results][:bindings]
31
+ end
32
+ end
33
+
34
+ # different views of a Wikidata item
35
+ class WikidataItem
36
+ def initialize(url)
37
+ @url = url
38
+ end
39
+
40
+ def id
41
+ url.split('/').last unless url.to_s.empty?
42
+ end
43
+
44
+ def qlink
45
+ "{{Q|#{id}}}" if id
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :url
51
+ end
52
+
53
+ # a Wikidata date of a given precision
54
+ class WikidataDate
55
+ include Comparable
56
+
57
+ def initialize(str, precision)
58
+ @str = str
59
+ @raw_precision = precision.to_s
60
+ end
61
+
62
+ def <=>(other)
63
+ return to_s <=> other.to_s if precision == other.precision
64
+ return year <=> other.year if year != other.year
65
+ return month <=> other.month if month && other.month
66
+ end
67
+
68
+ def to_s
69
+ return str if precision == '11'
70
+ return str[0..6] if precision == '10'
71
+ return str[0..3] if precision == '9'
72
+
73
+ warn "Cannot handle precision #{precision} for #{str}"
74
+ str
75
+ end
76
+
77
+ def empty?
78
+ str.to_s.empty?
79
+ end
80
+
81
+ def precision
82
+ return '11' if raw_precision.empty? # default to YYYY-MM-DD
83
+
84
+ raw_precision
85
+ end
86
+
87
+ def year
88
+ parts[0]
89
+ end
90
+
91
+ def month
92
+ parts[1]
93
+ end
94
+
95
+ private
96
+
97
+ attr_reader :str, :raw_precision
98
+
99
+ def parts
100
+ to_s.split('-')
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WikidataPositionHistory
4
+ module SPARQL
5
+ # Turn raw SPARQL into result objects
6
+ class ItemQuery
7
+ def initialize(itemid)
8
+ @itemid = itemid
9
+ end
10
+
11
+ def results_as(klass)
12
+ json.map { |result| klass.new(result) }
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :itemid
18
+
19
+ def sparql
20
+ raw_sparql % itemid
21
+ end
22
+
23
+ def json
24
+ @json ||= QueryService::Query.new(sparql).results
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WikidataPositionHistory
4
+ module SPARQL
5
+ # SPARQL for fetching all officeholdings of a position
6
+ class Mandates < ItemQuery
7
+ def raw_sparql
8
+ <<~SPARQL
9
+ # position-mandates
10
+ SELECT DISTINCT ?ordinal ?item ?start_date ?start_precision ?end_date ?end_precision ?prev ?next ?nature
11
+ WHERE {
12
+ ?item wdt:P31 wd:Q5 ; p:P39 ?posn .
13
+ ?posn ps:P39 wd:%s .
14
+ FILTER NOT EXISTS { ?posn wikibase:rank wikibase:DeprecatedRank }
15
+
16
+ OPTIONAL { ?posn pqv:P580 [ wikibase:timeValue ?start_date; wikibase:timePrecision ?start_precision ] }
17
+ OPTIONAL { ?posn pqv:P582 [ wikibase:timeValue ?end_date; wikibase:timePrecision ?end_precision ] }
18
+ OPTIONAL { ?posn pq:P1365|pq:P155 ?prev }
19
+ OPTIONAL { ?posn pq:P1366|pq:P156 ?next }
20
+ OPTIONAL { ?posn pq:P1545 ?ordinal }
21
+ OPTIONAL { ?posn pq:P5102 ?nature }
22
+ OPTIONAL { ?posn pq:P5102 ?nature }
23
+ }
24
+ ORDER BY DESC(?start_date)
25
+ SPARQL
26
+ end
27
+ end
28
+ end
29
+
30
+ # Represents a single row returned from the Mandates query
31
+ class Mandate
32
+ def initialize(row)
33
+ @row = row
34
+ end
35
+
36
+ def ordinal
37
+ row.dig(:ordinal, :value)
38
+ end
39
+
40
+ def item
41
+ QueryService::WikidataItem.new(row.dig(:item, :value)).qlink
42
+ end
43
+
44
+ def prev
45
+ QueryService::WikidataItem.new(row.dig(:prev, :value)).qlink
46
+ end
47
+
48
+ def next
49
+ QueryService::WikidataItem.new(row.dig(:next, :value)).qlink
50
+ end
51
+
52
+ def nature
53
+ QueryService::WikidataItem.new(row.dig(:nature, :value)).id
54
+ end
55
+
56
+ def acting?
57
+ nature == 'Q4676846'
58
+ end
59
+
60
+ def start_date
61
+ QueryService::WikidataDate.new(start_date_raw, start_date_precision)
62
+ end
63
+
64
+ def end_date
65
+ QueryService::WikidataDate.new(end_date_raw, end_date_precision)
66
+ end
67
+
68
+ def start_date_raw
69
+ row.dig(:start_date, :value).to_s[0..9]
70
+ end
71
+
72
+ def end_date_raw
73
+ row.dig(:end_date, :value).to_s[0..9]
74
+ end
75
+
76
+ def start_date_precision
77
+ row.dig(:start_precision, :value)
78
+ end
79
+
80
+ def end_date_precision
81
+ row.dig(:end_precision, :value)
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :row
87
+ end
88
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WikidataPositionHistory
4
+ module SPARQL
5
+ # SPARQL for fetching metadata about a position
6
+ class PositionData < ItemQuery
7
+ def raw_sparql
8
+ <<~SPARQL
9
+ # position-metadata
10
+
11
+ SELECT DISTINCT ?inception ?inception_precision ?abolition ?abolition_precision ?isPosition
12
+ WHERE {
13
+ VALUES ?item { wd:%s }
14
+ BIND(EXISTS { ?item wdt:P279+ wd:Q4164871 } as ?isPosition)
15
+ OPTIONAL { ?item p:P571/psv:P571 [ wikibase:timeValue ?inception; wikibase:timePrecision ?inception_precision ] }
16
+ OPTIONAL { ?item p:P576/psv:P576 [ wikibase:timeValue ?abolition; wikibase:timePrecision ?abolition_precision ] }
17
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en". }
18
+ }
19
+ SPARQL
20
+ end
21
+ end
22
+ end
23
+
24
+ # Represents a single row returned from the Position query
25
+ class PositionData
26
+ def initialize(row)
27
+ @row = row
28
+ end
29
+
30
+ def inception_date
31
+ QueryService::WikidataDate.new(inception_date_raw, inception_date_precision)
32
+ end
33
+
34
+ def abolition_date
35
+ QueryService::WikidataDate.new(abolition_date_raw, abolition_date_precision)
36
+ end
37
+
38
+ def position?
39
+ row.dig(:isPosition, :value) == 'true'
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :row
45
+
46
+ def inception_date_raw
47
+ row.dig(:inception, :value).to_s[0..9]
48
+ end
49
+
50
+ def abolition_date_raw
51
+ row.dig(:abolition, :value).to_s[0..9]
52
+ end
53
+
54
+ def inception_date_precision
55
+ row.dig(:inception_precision, :value)
56
+ end
57
+
58
+ def abolition_date_precision
59
+ row.dig(:abolition_precision, :value)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'query_service'
4
+ require 'sparql/item_query'
5
+ require 'sparql/position_data'
6
+ require 'sparql/mandates'
7
+ require 'wikidata_position_history/checks'
8
+ require 'wikidata_position_history/report'
9
+ require 'wikidata_position_history/version'
10
+
11
+ require 'date'
12
+
13
+ require 'mediawiki/client'
14
+ require 'mediawiki/page'
15
+
16
+ module WikidataPositionHistory
17
+ # Rewrites a Wiki page
18
+ class PageRewriter
19
+ WIKI_TEMPLATE_NAME = 'PositionHolderHistory'
20
+ WIKI_USERNAME = ENV['WIKI_USERNAME']
21
+ WIKI_PASSWORD = ENV['WIKI_PASSWORD']
22
+
23
+ def initialize(mediawiki_site:, page_title:)
24
+ @mediawiki_site = mediawiki_site
25
+ @page_title = page_title.tr('_', ' ')
26
+ end
27
+
28
+ def run!
29
+ section.replace_output(*new_content)
30
+ end
31
+
32
+ def new_content
33
+ return [NO_ID_ERROR, 'The id parameter was missing'] if position_id.empty?
34
+ return [MALFORMED_ID_ERROR, 'The id parameter was malformed'] unless position_id =~ /^Q\d+$/
35
+
36
+ [WikidataPositionHistory::Report.new(position_id).wikitext, "Successfully updated holders of #{position_id}"]
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :mediawiki_site, :page_title
42
+
43
+ NO_ID_ERROR = <<~EOERROR
44
+ '''#{WIKI_TEMPLATE_NAME} Error''': You must pass the <code>id</code>
45
+ parameter to the <code>#{WIKI_TEMPLATE_NAME}</code> template; e.g.
46
+ <nowiki>{{#{WIKI_TEMPLATE_NAME}|id=Q14211}}</nowiki>
47
+ EOERROR
48
+
49
+ MALFORMED_ID_ERROR = <<~EOERROR
50
+ '''#{WIKI_TEMPLATE_NAME} Error''': The <code>id</code> parameter was
51
+ malformed; it should be Q followed by a number of digits, e.g. as in:
52
+
53
+ <nowiki>{{#{WIKI_TEMPLATE_NAME}|id=Q14211}}</nowiki>
54
+ EOERROR
55
+
56
+ def position_id
57
+ return id_param unless id_param.empty?
58
+
59
+ derived_id
60
+ end
61
+
62
+ def id_param
63
+ section.params[:id].to_s.strip
64
+ end
65
+
66
+ def derived_id
67
+ page_title.scan(/Q\d+/).last.to_s
68
+ end
69
+
70
+ def client
71
+ abort 'You must set the WIKI_USERNAME and WIKI_PASSWORD environment variables' unless WIKI_USERNAME && WIKI_PASSWORD
72
+ @client ||= MediaWiki::Client.new(
73
+ site: mediawiki_site,
74
+ username: ENV['WIKI_USERNAME'],
75
+ password: ENV['WIKI_PASSWORD']
76
+ )
77
+ end
78
+
79
+ def section
80
+ @section ||= MediaWiki::Page::ReplaceableContent.new(
81
+ client: client,
82
+ title: page_title,
83
+ template: WIKI_TEMPLATE_NAME
84
+ )
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WikidataPositionHistory
4
+ # Checks if an Officeholder has any warning signs to report on
5
+ class Check
6
+ def initialize(later, current, earlier)
7
+ @later = later
8
+ @current = current
9
+ @earlier = earlier
10
+ end
11
+
12
+ def explanation
13
+ possible_explanation if problem?
14
+ end
15
+
16
+ protected
17
+
18
+ attr_reader :later, :current, :earlier
19
+
20
+ def successor
21
+ current.next
22
+ end
23
+
24
+ def predecessor
25
+ current.prev
26
+ end
27
+
28
+ def latest_holder?
29
+ !!later
30
+ end
31
+
32
+ def earliest_holder?
33
+ !!earlier
34
+ end
35
+ end
36
+
37
+ class Check
38
+ # Does the Officeholder have all the properties we expect?
39
+ class MissingFields < Check
40
+ def problem?
41
+ missing.any?
42
+ end
43
+
44
+ def headline
45
+ "Missing field#{missing.count > 1 ? 's' : ''}"
46
+ end
47
+
48
+ def possible_explanation
49
+ "#{current.item} is missing #{missing.map { |field| "{{P|#{field_map[field]}}}" }.join(', ')}"
50
+ end
51
+
52
+ def missing
53
+ expected.reject { |field| current.send(field) }
54
+ end
55
+
56
+ def field_map
57
+ {
58
+ start_date: 580,
59
+ prev: 1365,
60
+ end_date: 582,
61
+ next: 1366,
62
+ }
63
+ end
64
+
65
+ def expected
66
+ field_map.keys.select { |field| send("expect_#{field}?") }
67
+ end
68
+
69
+ def expect_start_date?
70
+ true
71
+ end
72
+
73
+ def expect_end_date?
74
+ later
75
+ end
76
+
77
+ def expect_prev?
78
+ return unless earlier
79
+ return if earlier.item == current.item # sucessive terms by same person
80
+
81
+ !current.acting?
82
+ end
83
+
84
+ def expect_next?
85
+ return unless later
86
+ return if later.item == current.item # sucessive terms by same person
87
+
88
+ !current.acting?
89
+ end
90
+ end
91
+
92
+ # Does the 'replaces' match the previous item in the list?
93
+ class WrongPredecessor < Check
94
+ def problem?
95
+ earliest_holder? && !!predecessor && (earlier.item != predecessor)
96
+ end
97
+
98
+ def headline
99
+ 'Inconsistent predecessor'
100
+ end
101
+
102
+ def possible_explanation
103
+ "#{current.item} has a {{P|1365}} of #{predecessor}, which differs from #{earlier.item}"
104
+ end
105
+ end
106
+
107
+ # Does the 'replaced by' match the next item in the list?
108
+ class WrongSuccessor < Check
109
+ def problem?
110
+ latest_holder? && !!successor && (later.item != successor)
111
+ end
112
+
113
+ def headline
114
+ 'Inconsistent successor'
115
+ end
116
+
117
+ def possible_explanation
118
+ "#{current.item} has a {{P|1366}} of #{successor}, which differs from #{later.item}"
119
+ end
120
+ end
121
+
122
+ # Does the end date overlap with the successor's start date?
123
+ class Overlap < Check
124
+ def problem?
125
+ return false unless later
126
+
127
+ ends = current.end_date
128
+ return false if ends.empty?
129
+
130
+ ends > later.start_date
131
+ rescue ArgumentError
132
+ true
133
+ end
134
+
135
+ def headline
136
+ comparable? ? 'Date overlap' : 'Date precision'
137
+ end
138
+
139
+ def possible_explanation
140
+ "#{current.item} has a {{P|582}} of #{current.end_date}, which #{overlap_explanation} the {{P|580}} of #{later.start_date} for #{later.item}"
141
+ end
142
+
143
+ protected
144
+
145
+ def comparable?
146
+ # Seems like there must be a better way to do this
147
+ [current.end_date, later.start_date].sort
148
+ rescue ArgumentError
149
+ false
150
+ end
151
+
152
+ def overlap_explanation
153
+ comparable? ? 'is later than' : 'may overlap with'
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WikidataPositionHistory
4
+ # A single output row of Wikitext for an officeholding
5
+ class MandateReport
6
+ def initialize(later, current, earlier)
7
+ @later = later
8
+ @current = current
9
+ @earlier = earlier
10
+ end
11
+
12
+ def output
13
+ [row_start, ordinal_cell, member_cell, warnings_cell].join("\n")
14
+ end
15
+
16
+ private
17
+
18
+ CHECKS = [Check::MissingFields, Check::WrongPredecessor, Check::WrongSuccessor, Check::Overlap].freeze
19
+
20
+ WARNING_LAYOUT = [
21
+ '<span style="display: block">[[File:Pictogram voting comment.svg|15px|link=]]&nbsp;',
22
+ '<span style="color: #d33; font-weight: bold; vertical-align: middle;">%s</span>&nbsp;',
23
+ '<ref>%s</ref></span>'
24
+ ].join
25
+
26
+ attr_reader :later, :current, :earlier
27
+
28
+ def row_start
29
+ '|-'
30
+ end
31
+
32
+ def ordinal_cell
33
+ %(| style="padding:0.5em 2em" | #{ordinal_string})
34
+ end
35
+
36
+ def ordinal_string
37
+ ordinal = current.ordinal or return ''
38
+ ordinal.concat('.')
39
+ end
40
+
41
+ def member_style
42
+ return 'font-size: 1.25em; display: block; font-style: italic;' if current.acting?
43
+
44
+ 'font-size: 1.5em; display: block;'
45
+ end
46
+
47
+ def member_cell
48
+ format('| style="padding:0.5em 2em" | <span style="%s">%s</span> %s',
49
+ member_style, membership_person, membership_dates)
50
+ end
51
+
52
+ def warnings_cell
53
+ format('| style="padding:0.5em 2em 0.5em 1em; border: none; background: #fff; text-align: left;" | %s',
54
+ combined_warnings)
55
+ end
56
+
57
+ def combined_warnings
58
+ CHECKS.map do |check_class|
59
+ check = check_class.new(later, current, earlier)
60
+ format(WARNING_LAYOUT, check.headline, check.explanation) if check.problem?
61
+ end.join
62
+ end
63
+
64
+ def membership_person
65
+ current.item
66
+ end
67
+
68
+ def membership_dates
69
+ dates = [current.start_date, current.end_date]
70
+ # compact doesn't work here, even if we add #nil? to WikidataDate
71
+ return '' if dates.reject(&:empty?).empty?
72
+
73
+ dates.join(' – ')
74
+ end
75
+ end
76
+
77
+ # The entire wikitext generated for this report
78
+ class Report
79
+ def initialize(subject_item_id)
80
+ @subject_item_id = subject_item_id
81
+ end
82
+
83
+ attr_reader :subject_item_id
84
+
85
+ def wikitext
86
+ return no_items_output if mandates.empty?
87
+
88
+ [table_header, table_rows, table_footer].compact.join("\n")
89
+ end
90
+
91
+ def header
92
+ "== {{Q|#{subject_item_id}}} officeholders #{position_dates} =="
93
+ end
94
+
95
+ def position_dates
96
+ dates = [metadata.inception_date, metadata.abolition_date]
97
+ return '' if dates.compact.empty?
98
+
99
+ format('(%s)', dates.join(' – '))
100
+ end
101
+
102
+ def wikitext_with_header
103
+ [header, wikitext].join("\n")
104
+ end
105
+
106
+ private
107
+
108
+ def metadata
109
+ # TODO: we might get more than one response, if a position has
110
+ # multiple dates
111
+ @metadata ||= SPARQL::PositionData.new(subject_item_id).results_as(PositionData).first
112
+ end
113
+
114
+ def padded_mandates
115
+ [nil, mandates, nil].flatten(1)
116
+ end
117
+
118
+ def mandates
119
+ @mandates ||= SPARQL::Mandates.new(subject_item_id).results_as(Mandate)
120
+ end
121
+
122
+ def no_items_output
123
+ "\n{{PositionHolderHistory/error_no_holders|id=#{subject_item_id}}}\n"
124
+ end
125
+
126
+ def table_header
127
+ '{| class="wikitable" style="text-align: center; border: none;"'
128
+ end
129
+
130
+ def table_footer
131
+ "|}\n"
132
+ end
133
+
134
+ def table_rows
135
+ padded_mandates.each_cons(3).map do |later, current, earlier|
136
+ MandateReport.new(later, current, earlier).output
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WikidataPositionHistory
4
+ VERSION = '1.3.3'
5
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'wikidata_position_history/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.required_ruby_version = '>= 2.4.0'
9
+ spec.name = 'wikidata_position_history'
10
+ spec.version = WikidataPositionHistory::VERSION
11
+ spec.authors = ['Tony Bowden', 'Mark Longair']
12
+ spec.email = ['tony@tmtm.com']
13
+
14
+ spec.summary = 'Generates a wikitext history of a holders of a position in Wikidata'
15
+ spec.homepage = 'https://github.com/everypolitician/wikidata-position-history/'
16
+ spec.license = 'MIT'
17
+
18
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
19
+
20
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
21
+ f.match(%r{^(test|spec|features)/})
22
+ end
23
+ spec.bindir = 'exe'
24
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ['lib']
26
+
27
+ spec.add_runtime_dependency 'mediawiki-page-replaceable_content', '0.1.3'
28
+ spec.add_runtime_dependency 'rest-client', '~> 2.0'
29
+
30
+ spec.add_development_dependency 'bundler', '~> 2.1'
31
+ spec.add_development_dependency 'minitest', '~> 5.0'
32
+ spec.add_development_dependency 'pry', '~> 0.10'
33
+ spec.add_development_dependency 'rake', '~> 13.0'
34
+ spec.add_development_dependency 'reek', '~> 6.0'
35
+ spec.add_development_dependency 'rubocop', '~> 0.89'
36
+ spec.add_development_dependency 'warning', '~> 1.1'
37
+ spec.add_development_dependency 'webmock', '~> 3.0.0'
38
+ end
metadata ADDED
@@ -0,0 +1,211 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wikidata_position_history
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.3.3
5
+ platform: ruby
6
+ authors:
7
+ - Tony Bowden
8
+ - Mark Longair
9
+ autorequire:
10
+ bindir: exe
11
+ cert_chain: []
12
+ date: 2020-08-29 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: mediawiki-page-replaceable_content
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - '='
19
+ - !ruby/object:Gem::Version
20
+ version: 0.1.3
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - '='
26
+ - !ruby/object:Gem::Version
27
+ version: 0.1.3
28
+ - !ruby/object:Gem::Dependency
29
+ name: rest-client
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '2.0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: bundler
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '2.1'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '2.1'
56
+ - !ruby/object:Gem::Dependency
57
+ name: minitest
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '5.0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '5.0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: pry
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '0.10'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '0.10'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rake
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '13.0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '13.0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: reek
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '6.0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '6.0'
112
+ - !ruby/object:Gem::Dependency
113
+ name: rubocop
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - "~>"
117
+ - !ruby/object:Gem::Version
118
+ version: '0.89'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - "~>"
124
+ - !ruby/object:Gem::Version
125
+ version: '0.89'
126
+ - !ruby/object:Gem::Dependency
127
+ name: warning
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - "~>"
131
+ - !ruby/object:Gem::Version
132
+ version: '1.1'
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - "~>"
138
+ - !ruby/object:Gem::Version
139
+ version: '1.1'
140
+ - !ruby/object:Gem::Dependency
141
+ name: webmock
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - "~>"
145
+ - !ruby/object:Gem::Version
146
+ version: 3.0.0
147
+ type: :development
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - "~>"
152
+ - !ruby/object:Gem::Version
153
+ version: 3.0.0
154
+ description:
155
+ email:
156
+ - tony@tmtm.com
157
+ executables:
158
+ - position-history-for-item
159
+ - update_wikidata_page
160
+ extensions: []
161
+ extra_rdoc_files: []
162
+ files:
163
+ - ".gitignore"
164
+ - ".reek.yml"
165
+ - ".rubocop.yml"
166
+ - ".travis.yml"
167
+ - CHANGELOG.md
168
+ - Gemfile
169
+ - Gemfile.lock
170
+ - LICENSE.txt
171
+ - README.md
172
+ - Rakefile
173
+ - bin/console
174
+ - bin/setup
175
+ - exe/position-history-for-item
176
+ - exe/update_wikidata_page
177
+ - lib/query_service.rb
178
+ - lib/sparql/item_query.rb
179
+ - lib/sparql/mandates.rb
180
+ - lib/sparql/position_data.rb
181
+ - lib/wikidata_position_history.rb
182
+ - lib/wikidata_position_history/checks.rb
183
+ - lib/wikidata_position_history/report.rb
184
+ - lib/wikidata_position_history/version.rb
185
+ - wikidata_position_history.gemspec
186
+ homepage: https://github.com/everypolitician/wikidata-position-history/
187
+ licenses:
188
+ - MIT
189
+ metadata:
190
+ allowed_push_host: https://rubygems.org
191
+ post_install_message:
192
+ rdoc_options: []
193
+ require_paths:
194
+ - lib
195
+ required_ruby_version: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - ">="
198
+ - !ruby/object:Gem::Version
199
+ version: 2.4.0
200
+ required_rubygems_version: !ruby/object:Gem::Requirement
201
+ requirements:
202
+ - - ">="
203
+ - !ruby/object:Gem::Version
204
+ version: '0'
205
+ requirements: []
206
+ rubyforge_project:
207
+ rubygems_version: 2.6.14.1
208
+ signing_key:
209
+ specification_version: 4
210
+ summary: Generates a wikitext history of a holders of a position in Wikidata
211
+ test_files: []