wikidata_position_history 1.3.3

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.
@@ -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: []