theoj 0.0.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/lib/theoj/author.rb +83 -5
- data/lib/theoj/git.rb +2 -2
- data/lib/theoj/journal.rb +9 -0
- data/lib/theoj/journals_data.rb +6 -4
- data/lib/theoj/orcid.rb +96 -0
- data/lib/theoj/paper.rb +69 -14
- data/lib/theoj/submission.rb +70 -0
- data/lib/theoj/version.rb +1 -1
- data/lib/theoj.rb +2 -0
- metadata +60 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 984f9feb0bdcfeaf7d9533aa94c09dde7877ca2381e75e60b0f05d73cb390eb7
|
4
|
+
data.tar.gz: 749c5b08e99824f720c5ed4e820d45b7d2b772c345f820d4332393376b215512
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5fa3741bb127abcfb1c590c197fa8ea9631b510624b25b615ccfc60131cdf7ce68af2d400ab16f339cc6705a5aae1129403ac0c1d24e41e04209704bdfbcb076
|
7
|
+
data.tar.gz: 246621ddee2c0495eccad79723727205b724ec36b4b401b24ae60f22c1e1cb7a8ece320ab2ccefb6a5074e4423a4eabcbf401272d40e137b0eb831bf0dafd662
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,16 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## 1.0.0 (2021-10-20)
|
4
|
+
|
5
|
+
- Added method to Journal to create paper_id from issue_id
|
6
|
+
- Added method to Journal to get a DOI based on a paper id
|
7
|
+
- Added languages to Paper
|
8
|
+
- Added authors info to Paper
|
9
|
+
- Author object
|
10
|
+
- Added ORCID validation
|
11
|
+
- Added Submission object, grouping a paper, a review issue and a journal
|
12
|
+
- Added paper depositing
|
13
|
+
|
3
14
|
## 0.0.3 (2021-10-08)
|
4
15
|
|
5
16
|
- Added metadata methods to Paper
|
data/lib/theoj/author.rb
CHANGED
@@ -1,16 +1,94 @@
|
|
1
|
+
require "nameable"
|
2
|
+
|
1
3
|
module Theoj
|
2
4
|
class Author
|
3
5
|
attr_accessor :name
|
4
6
|
attr_accessor :orcid
|
5
7
|
attr_accessor :affiliation
|
6
8
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
9
|
+
AUTHOR_FOOTNOTE_REGEX = /^[^\^]*/
|
10
|
+
|
11
|
+
# Initialized with authors & affiliations block in the YAML header from an Open Journal paper
|
12
|
+
# e.g. https://joss.readthedocs.io/en/latest/submitting.html#example-paper-and-bibliography
|
13
|
+
def initialize(name, orcid, index, affiliations_hash)
|
14
|
+
parse_name name
|
15
|
+
@orcid = validate_orcid orcid
|
16
|
+
@affiliation = build_affiliation_string(index, affiliations_hash)
|
17
|
+
end
|
18
|
+
|
19
|
+
def given_name
|
20
|
+
@parsed_name.first
|
21
|
+
end
|
22
|
+
|
23
|
+
def middle_name
|
24
|
+
@parsed_name.middle
|
25
|
+
end
|
26
|
+
|
27
|
+
def last_name
|
28
|
+
@parsed_name.last
|
29
|
+
end
|
30
|
+
|
31
|
+
def initials
|
32
|
+
[@parsed_name.first, @parsed_name.middle].compact.map {|v| v[0] + "."} * ' '
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_h
|
36
|
+
{
|
37
|
+
given_name: given_name,
|
38
|
+
middle_name: middle_name,
|
39
|
+
last_name: last_name,
|
40
|
+
orcid: orcid,
|
41
|
+
affiliation: affiliation
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def parse_name(author_name)
|
48
|
+
@parsed_name = Nameable::Latin.new.parse(strip_footnotes(author_name))
|
49
|
+
@name = @parsed_name.to_nameable
|
50
|
+
end
|
51
|
+
|
52
|
+
# Input: Arfon Smith^[Corresponding author: arfon@example.com]
|
53
|
+
# Output: Arfon Smith
|
54
|
+
def strip_footnotes(author_name)
|
55
|
+
author_name.to_s[AUTHOR_FOOTNOTE_REGEX]
|
56
|
+
end
|
57
|
+
|
58
|
+
def validate_orcid(author_orcid)
|
59
|
+
return nil if author_orcid.to_s.strip.empty?
|
60
|
+
|
61
|
+
validator = Theoj::Orcid.new(author_orcid)
|
62
|
+
if validator.valid?
|
63
|
+
return author_orcid.strip
|
64
|
+
else
|
65
|
+
raise "Problem with ORCID (#{author_orcid}) for #{self.name}. #{validator.error}"
|
66
|
+
end
|
11
67
|
end
|
12
68
|
|
13
|
-
|
69
|
+
# Takes the author affiliation index and a hash of all affiliations and
|
70
|
+
# associates them. Then builds the author affiliation string
|
71
|
+
def build_affiliation_string(index, affiliations_hash)
|
72
|
+
return nil if index.nil? # Some authors don't have an affiliation
|
73
|
+
|
74
|
+
# If multiple affiliations, parse each one and build the affiliation string
|
75
|
+
author_affiliations = []
|
76
|
+
|
77
|
+
# Turn YAML keys into strings so that mixed integer and string affiliations work
|
78
|
+
affiliations_hash.transform_keys!(&:to_s)
|
79
|
+
|
80
|
+
affiliations = index.to_s.split(',').map(&:strip)
|
81
|
+
|
82
|
+
# Raise if we can't parse the string, might be because of this bug :-(
|
83
|
+
# https://bugs.ruby-lang.org/issues/12451
|
84
|
+
affiliations.each do |a|
|
85
|
+
raise "Problem with affiliations for #{self.name}, perhaps the " +
|
86
|
+
"affiliations index need quoting?" unless affiliations_hash.has_key?(a)
|
87
|
+
|
88
|
+
author_affiliations << affiliations_hash[a].strip
|
89
|
+
end
|
90
|
+
|
91
|
+
author_affiliations.join(', ')
|
14
92
|
end
|
15
93
|
|
16
94
|
end
|
data/lib/theoj/git.rb
CHANGED
data/lib/theoj/journal.rb
CHANGED
@@ -25,6 +25,15 @@ module Theoj
|
|
25
25
|
data[:current_issue] || (1 + ((Time.new.year * 12 + Time.new.month) - (launch_year * 12 + launch_month)))
|
26
26
|
end
|
27
27
|
|
28
|
+
def paper_id_from_issue(review_issue_id)
|
29
|
+
id = "%05d" % review_issue_id
|
30
|
+
"#{@alias}.#{id}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def paper_doi_for_id(paper_id)
|
34
|
+
"#@doi_prefix/#{paper_id}"
|
35
|
+
end
|
36
|
+
|
28
37
|
private
|
29
38
|
|
30
39
|
def set_data(custom_data)
|
data/lib/theoj/journals_data.rb
CHANGED
@@ -6,8 +6,9 @@ module Theoj
|
|
6
6
|
name: "Journal of Open Source Software",
|
7
7
|
alias: "joss",
|
8
8
|
launch_date: "2016-05-05",
|
9
|
-
|
10
|
-
reviews_repository: "openjournals/joss-reviews"
|
9
|
+
papers_repository: "openjournals/joss-papers",
|
10
|
+
reviews_repository: "openjournals/joss-reviews",
|
11
|
+
deposit_url: "https://joss.theoj.org/papers/api_deposit"
|
11
12
|
},
|
12
13
|
jose: {
|
13
14
|
doi_prefix: "10.21105",
|
@@ -15,8 +16,9 @@ module Theoj
|
|
15
16
|
name: "Journal of Open Source Education",
|
16
17
|
alias: "jose",
|
17
18
|
launch_date: "2018-03-07",
|
18
|
-
|
19
|
-
reviews_repository: "openjournals/jose-reviews"
|
19
|
+
papers_repository: "openjournals/jose-papers",
|
20
|
+
reviews_repository: "openjournals/jose-reviews",
|
21
|
+
deposit_url: "https://joss.theoj.org/papers/api_deposit"
|
20
22
|
}
|
21
23
|
}
|
22
24
|
end
|
data/lib/theoj/orcid.rb
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
module Theoj
|
2
|
+
class Orcid
|
3
|
+
attr_reader :orcid, :error
|
4
|
+
|
5
|
+
def initialize(orcid)
|
6
|
+
@orcid = orcid.strip
|
7
|
+
@error = nil
|
8
|
+
end
|
9
|
+
|
10
|
+
def valid?
|
11
|
+
@error = nil
|
12
|
+
return false unless check_structure
|
13
|
+
return false unless check_length
|
14
|
+
return false unless check_chars
|
15
|
+
|
16
|
+
return false unless correct_checksum?
|
17
|
+
|
18
|
+
true
|
19
|
+
end
|
20
|
+
|
21
|
+
def packed_orcid
|
22
|
+
orcid.gsub('-', '')
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
# Returns the last character of the string
|
28
|
+
def checksum_char
|
29
|
+
packed_orcid[-1]
|
30
|
+
end
|
31
|
+
|
32
|
+
def first_11
|
33
|
+
packed_orcid.chop
|
34
|
+
end
|
35
|
+
|
36
|
+
def check_structure
|
37
|
+
groups = orcid.split('-')
|
38
|
+
if groups.size == 4
|
39
|
+
return true
|
40
|
+
else
|
41
|
+
@error = "ORCID looks malformed"
|
42
|
+
return false
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def check_length
|
47
|
+
if packed_orcid.length == 16
|
48
|
+
return true
|
49
|
+
else
|
50
|
+
@error = "ORCID looks to be the wrong length"
|
51
|
+
return false
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def check_chars
|
56
|
+
valid = true
|
57
|
+
first_11.each_char do |c|
|
58
|
+
if !numeric?(c)
|
59
|
+
@error = "Invalid ORCID digit (#{c})"
|
60
|
+
valid = false
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
return valid
|
65
|
+
end
|
66
|
+
|
67
|
+
def correct_checksum?
|
68
|
+
validate_against = checksum_char.to_i
|
69
|
+
validate_against = 10 if (checksum_char == "X" || checksum_char == "x")
|
70
|
+
|
71
|
+
if checksum == validate_against
|
72
|
+
return true
|
73
|
+
else
|
74
|
+
@error = "Invalid ORCID"
|
75
|
+
return false
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# https://support.orcid.org/knowledgebase/articles/116780-structure-of-the-orcid-identifier
|
80
|
+
def checksum
|
81
|
+
total = 0
|
82
|
+
first_11.each_char do |c|
|
83
|
+
total = (total + c.to_i) * 2
|
84
|
+
end
|
85
|
+
|
86
|
+
remainder = total % 11
|
87
|
+
result = (12 - remainder) % 11
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
def numeric?(s)
|
92
|
+
Float(s) != nil rescue false
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
end
|
data/lib/theoj/paper.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "find"
|
2
|
+
require "yaml"
|
3
|
+
require "rugged"
|
4
|
+
require "linguist"
|
3
5
|
|
4
6
|
module Theoj
|
5
7
|
class Paper
|
@@ -19,22 +21,18 @@ module Theoj
|
|
19
21
|
end
|
20
22
|
|
21
23
|
def authors
|
22
|
-
|
24
|
+
@authors ||= parse_authors
|
23
25
|
end
|
24
26
|
|
25
|
-
def
|
26
|
-
|
27
|
+
def citation_author
|
28
|
+
surname = authors.first.last_name
|
29
|
+
initials = authors.first.initials
|
27
30
|
|
28
|
-
if
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
break
|
33
|
-
end
|
34
|
-
end
|
31
|
+
if authors.size > 1
|
32
|
+
return "#{surname} et al."
|
33
|
+
else
|
34
|
+
return "#{surname}, #{initials}"
|
35
35
|
end
|
36
|
-
|
37
|
-
paper_path
|
38
36
|
end
|
39
37
|
|
40
38
|
def title
|
@@ -49,6 +47,10 @@ module Theoj
|
|
49
47
|
@paper_metadata["date"]
|
50
48
|
end
|
51
49
|
|
50
|
+
def languages
|
51
|
+
@languages ||= detect_languages
|
52
|
+
end
|
53
|
+
|
52
54
|
def bibliography_path
|
53
55
|
@paper_metadata["bibliography"]
|
54
56
|
end
|
@@ -61,6 +63,21 @@ module Theoj
|
|
61
63
|
FileUtils.rm_rf(local_path) if Dir.exist?(local_path)
|
62
64
|
end
|
63
65
|
|
66
|
+
def self.find_paper_path(search_path)
|
67
|
+
paper_path = nil
|
68
|
+
|
69
|
+
if Dir.exist? search_path
|
70
|
+
Find.find(search_path).each do |path|
|
71
|
+
if path =~ /paper\.tex$|paper\.md$/
|
72
|
+
paper_path = path
|
73
|
+
break
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
paper_path
|
79
|
+
end
|
80
|
+
|
64
81
|
def self.from_repo(repository_url, branch = "")
|
65
82
|
Paper.new(repository_url, branch, nil)
|
66
83
|
end
|
@@ -95,6 +112,44 @@ module Theoj
|
|
95
112
|
end
|
96
113
|
end
|
97
114
|
|
115
|
+
def parse_authors
|
116
|
+
parsed_authors = []
|
117
|
+
authors_metadata = @paper_metadata['authors']
|
118
|
+
affiliations_metadata = parse_affiliations(@paper_metadata['affiliations'])
|
119
|
+
|
120
|
+
# Loop through the authors block and build up the affiliation
|
121
|
+
authors_metadata.each do |author|
|
122
|
+
affiliation_index = author['affiliation']
|
123
|
+
failure "Author (#{author['name']}) is missing affiliation" if affiliation_index.nil?
|
124
|
+
begin
|
125
|
+
parsed_author = Author.new(author['name'], author['orcid'], affiliation_index, affiliations_metadata)
|
126
|
+
rescue Exception => e
|
127
|
+
failure(e.message)
|
128
|
+
end
|
129
|
+
parsed_authors << parsed_author
|
130
|
+
end
|
131
|
+
|
132
|
+
parsed_authors
|
133
|
+
end
|
134
|
+
|
135
|
+
def parse_affiliations(affliations_yaml)
|
136
|
+
affliations_metadata = {}
|
137
|
+
|
138
|
+
affliations_yaml.each do |affiliation|
|
139
|
+
affliations_metadata[affiliation['index']] = affiliation['name']
|
140
|
+
end
|
141
|
+
|
142
|
+
affliations_metadata
|
143
|
+
end
|
144
|
+
|
145
|
+
def detect_languages
|
146
|
+
repo = Rugged::Repository.discover(paper_path)
|
147
|
+
project = Linguist::Repository.new(repo, repo.head.target_id)
|
148
|
+
|
149
|
+
# Take top five languages from Linguist
|
150
|
+
project.languages.keys.take(5)
|
151
|
+
end
|
152
|
+
|
98
153
|
def failure(msg)
|
99
154
|
cleanup
|
100
155
|
raise(msg)
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require "json"
|
2
|
+
require "base64"
|
3
|
+
require "faraday"
|
4
|
+
|
5
|
+
module Theoj
|
6
|
+
class Submission
|
7
|
+
attr_accessor :journal
|
8
|
+
attr_accessor :review_issue
|
9
|
+
attr_accessor :paper
|
10
|
+
|
11
|
+
def initialize(journal, review_issue, paper=nil)
|
12
|
+
@journal = journal
|
13
|
+
@review_issue = review_issue
|
14
|
+
@paper = paper || @review_issue.paper
|
15
|
+
end
|
16
|
+
|
17
|
+
# Create the payload to use to post for depositing with Open Journals
|
18
|
+
def deposit_payload
|
19
|
+
{
|
20
|
+
id: review_issue.issue_id,
|
21
|
+
metadata: Base64.encode64(metadata_payload),
|
22
|
+
doi: paper_doi,
|
23
|
+
archive_doi: review_issue.archive,
|
24
|
+
citation_string: citation_string,
|
25
|
+
title: paper.title
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
# Create a metadata json payload
|
30
|
+
def metadata_payload
|
31
|
+
metadata = {
|
32
|
+
paper: {
|
33
|
+
title: paper.title,
|
34
|
+
tags: paper.tags,
|
35
|
+
languages: paper.languages,
|
36
|
+
authors: paper.authors.collect { |a| a.to_h },
|
37
|
+
doi: paper_doi,
|
38
|
+
archive_doi: review_issue.archive,
|
39
|
+
repository_address: review_issue.target_repository,
|
40
|
+
editor: review_issue.editor,
|
41
|
+
reviewers: review_issue.reviewers.collect(&:strip),
|
42
|
+
volume: journal.current_volume,
|
43
|
+
issue: journal.current_issue,
|
44
|
+
year: journal.current_year,
|
45
|
+
page: review_issue.issue_id,
|
46
|
+
}
|
47
|
+
}
|
48
|
+
|
49
|
+
metadata.to_json
|
50
|
+
end
|
51
|
+
|
52
|
+
def deposit!(secret)
|
53
|
+
parameters = deposit_payload.merge(secret: secret)
|
54
|
+
Faraday.post(journal.data[:deposit_url], parameters.to_json, {"Content-Type" => "application/json"})
|
55
|
+
end
|
56
|
+
|
57
|
+
def citation_string
|
58
|
+
paper_year = Time.now.strftime('%Y')
|
59
|
+
"#{paper.citation_author}, (#{paper_year}). #{paper.title}. #{journal.name}, #{journal.current_volume}(#{journal.current_issue}), #{review_issue.issue_id}, https://doi.org/#{paper_doi}"
|
60
|
+
end
|
61
|
+
|
62
|
+
def paper_id
|
63
|
+
journal.paper_id_from_issue(review_issue.issue_id)
|
64
|
+
end
|
65
|
+
|
66
|
+
def paper_doi
|
67
|
+
journal.paper_doi_for_id(paper_id)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/theoj/version.rb
CHANGED
data/lib/theoj.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require_relative "theoj/version"
|
2
2
|
require_relative "theoj/git"
|
3
3
|
require_relative "theoj/github"
|
4
|
+
require_relative "theoj/orcid"
|
5
|
+
require_relative "theoj/submission"
|
4
6
|
require_relative "theoj/journal"
|
5
7
|
require_relative "theoj/review_issue"
|
6
8
|
require_relative "theoj/paper"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: theoj
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Juanjo Bazán
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-10-
|
11
|
+
date: 2021-10-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: octokit
|
@@ -24,6 +24,62 @@ dependencies:
|
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '4.21'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: faraday
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: openjournals-nameable
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: github-linguist
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rugged
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
27
83
|
- !ruby/object:Gem::Dependency
|
28
84
|
name: rake
|
29
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -68,8 +124,10 @@ files:
|
|
68
124
|
- lib/theoj/github.rb
|
69
125
|
- lib/theoj/journal.rb
|
70
126
|
- lib/theoj/journals_data.rb
|
127
|
+
- lib/theoj/orcid.rb
|
71
128
|
- lib/theoj/paper.rb
|
72
129
|
- lib/theoj/review_issue.rb
|
130
|
+
- lib/theoj/submission.rb
|
73
131
|
- lib/theoj/version.rb
|
74
132
|
homepage: http://github.com/xuanxu/theoj
|
75
133
|
licenses:
|