neurolibre 1.5.6 → 1.5.7
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/neurolibre.rb +1 -0
- data/lib/theoj/myst_frontmatter.rb +250 -0
- data/lib/theoj/paper.rb +25 -2
- data/lib/theoj/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5b0fdff71e32a79ca2bd3d2ecfe6b8f670dcc0e1c509cc05d4f2c2c99123dc6
|
|
4
|
+
data.tar.gz: a65f2aedad14e0e4ca0d3152662a3376f53948bcc4fa1a23e6d8135955acdea8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7697da4e5b9007bc625499662ebd58b54f1e6bf1c36b1d25dc66f0753b4b02b1fd652c66021d2ebaa8bb8e434e668cbefb47df9c5b244247f006643ae57b9a41
|
|
7
|
+
data.tar.gz: 1c006318aaa671cedff181134ca7a100b4c82dd12925172f7182eb322f76b32eef1e00261183c04d26c27b02e589903662e6e4ceb21c956b3a7b149cef983988
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.7 (2026-08-19)
|
|
4
|
+
|
|
5
|
+
- Paper metadata falls back to myst.yml when a key is absent from the paper's
|
|
6
|
+
own front matter. A MyST submission declares its title, authors and
|
|
7
|
+
affiliations in myst.yml for the living preprint, so paper.md need not repeat
|
|
8
|
+
them; depositing such a paper raised
|
|
9
|
+
`undefined method 'each' for nil` in `parse_affiliations`.
|
|
10
|
+
- `parse_affiliations` tolerates a paper that names authors and no affiliations
|
|
11
|
+
instead of raising, and `parse_authors` reports a paper with no authors as a
|
|
12
|
+
failure rather than a NoMethodError.
|
|
13
|
+
|
|
3
14
|
## 1.5.2 (2022-10-29)
|
|
4
15
|
|
|
5
16
|
- Updated journal data for JuliaCon
|
data/lib/neurolibre.rb
CHANGED
|
@@ -2,6 +2,7 @@ require_relative "theoj/version"
|
|
|
2
2
|
require_relative "theoj/git"
|
|
3
3
|
require_relative "theoj/github"
|
|
4
4
|
require_relative "theoj/orcid"
|
|
5
|
+
require_relative "theoj/myst_frontmatter"
|
|
5
6
|
require_relative "theoj/published_paper"
|
|
6
7
|
require_relative "theoj/submission"
|
|
7
8
|
require_relative "theoj/journal"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
4
|
+
module Theoj
|
|
5
|
+
# Fill gaps in a paper's front matter from the project's myst.yml.
|
|
6
|
+
#
|
|
7
|
+
# A NeuroLibre submission declares its title, authors, and affiliations in
|
|
8
|
+
# myst.yml for the living preprint, so paper.md need not repeat them. The
|
|
9
|
+
# deposit path still wants them in the shape paper.md front matter uses:
|
|
10
|
+
# affiliations numbered by index, and each author's affiliations as a
|
|
11
|
+
# comma-joined string of those indices.
|
|
12
|
+
#
|
|
13
|
+
# This is a port of inara's data/filters/myst-frontmatter.lua, which is the
|
|
14
|
+
# canonical statement of the mapping; full-stack-server's
|
|
15
|
+
# api/myst_frontmatter.py mirrors the same rules on the Python side. Keep the
|
|
16
|
+
# three in step -- an author whose affiliations resolve differently depending
|
|
17
|
+
# on which service is looking is worse than one that fails outright.
|
|
18
|
+
#
|
|
19
|
+
# The fallback is best-effort by design: a missing, unreadable, or malformed
|
|
20
|
+
# myst.yml leaves the metadata exactly as it was. It must never itself be the
|
|
21
|
+
# reason a deposit fails.
|
|
22
|
+
module MystFrontmatter
|
|
23
|
+
|
|
24
|
+
MYST_FILE = "myst.yml".freeze
|
|
25
|
+
|
|
26
|
+
# Parts of a myst.yml affiliation, joined into one name string. Department
|
|
27
|
+
# precedes institution to match the convention in existing NeuroLibre front
|
|
28
|
+
# matter.
|
|
29
|
+
NAME_PARTS = %w[
|
|
30
|
+
department institution address city region postal_code country
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# MyST accepts these aliases for two of the parts.
|
|
34
|
+
ALIASES = { "institution" => "name", "region" => "state" }.freeze
|
|
35
|
+
|
|
36
|
+
# Keys that fill individually, unlike authors and affiliations.
|
|
37
|
+
SCALAR_KEYS = %w[title date tags bibliography].freeze
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
|
|
41
|
+
# Returns the paper's metadata with any gap filled from myst.yml.
|
|
42
|
+
#
|
|
43
|
+
# front_matter - the already-parsed paper.md front matter, or nil for a
|
|
44
|
+
# paper that has none.
|
|
45
|
+
# myst_text - the raw contents of myst.yml, or nil. Parsed here rather
|
|
46
|
+
# than in the caller so a malformed file is tolerated in
|
|
47
|
+
# one place.
|
|
48
|
+
def merge(front_matter, myst_text)
|
|
49
|
+
metadata = front_matter.is_a?(Hash) ? front_matter.dup : {}
|
|
50
|
+
return metadata if myst_text.to_s.strip.empty?
|
|
51
|
+
|
|
52
|
+
fallback = project_metadata(parse_project(myst_text))
|
|
53
|
+
|
|
54
|
+
# Authors and affiliations are filled as a pair. An affiliation index
|
|
55
|
+
# only means something relative to the list that defines it, so mixing
|
|
56
|
+
# front matter authors with myst.yml affiliations would silently attach
|
|
57
|
+
# authors to the wrong institutions.
|
|
58
|
+
if blank?(metadata["authors"]) || blank?(metadata["affiliations"])
|
|
59
|
+
unless blank?(fallback["authors"])
|
|
60
|
+
unless blank?(metadata["authors"])
|
|
61
|
+
warn "[neurolibre] #{MYST_FILE}: the paper names authors but no " \
|
|
62
|
+
"affiliations, so its author list is replaced by the one in " \
|
|
63
|
+
"#{MYST_FILE} rather than merged -- an affiliation index only " \
|
|
64
|
+
"means something relative to the list that defines it."
|
|
65
|
+
end
|
|
66
|
+
metadata["authors"] = fallback["authors"]
|
|
67
|
+
metadata["affiliations"] = fallback["affiliations"] || []
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
SCALAR_KEYS.each do |key|
|
|
72
|
+
metadata[key] = fallback[key] if blank?(metadata[key]) && fallback.key?(key)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
metadata
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns paper metadata derived from a myst.yml `project` mapping,
|
|
79
|
+
# holding only the keys the project actually defines so the caller can
|
|
80
|
+
# treat it as a set of defaults. Junk input yields an empty hash.
|
|
81
|
+
def project_metadata(project)
|
|
82
|
+
return {} unless project.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
metadata = {}
|
|
85
|
+
metadata["title"] = project["title"] unless blank?(project["title"])
|
|
86
|
+
unless blank?(project["date"])
|
|
87
|
+
# `date: 2024-01-15` -- unquoted ISO, the MyST-canonical form --
|
|
88
|
+
# parses to a Date. This value ends up in a deposit payload, and
|
|
89
|
+
# nothing downstream reads it structurally, so the string form is the
|
|
90
|
+
# right shape.
|
|
91
|
+
date = project["date"]
|
|
92
|
+
metadata["date"] = date.is_a?(String) ? date : date.to_s
|
|
93
|
+
end
|
|
94
|
+
metadata["tags"] = project["keywords"] unless blank?(project["keywords"])
|
|
95
|
+
metadata["bibliography"] = project["bibliography"] unless blank?(project["bibliography"])
|
|
96
|
+
|
|
97
|
+
affiliations, index_of = build_affiliations(project)
|
|
98
|
+
authors = build_authors(project, affiliations, index_of)
|
|
99
|
+
|
|
100
|
+
metadata["authors"] = authors unless authors.empty?
|
|
101
|
+
metadata["affiliations"] = affiliations unless affiliations.empty?
|
|
102
|
+
metadata
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The contents of the nearest myst.yml at or above the paper, or nil.
|
|
106
|
+
#
|
|
107
|
+
# paper_path - path to the paper (paper.md, paper.tex ...).
|
|
108
|
+
# search_root - the directory the walk may climb to, normally the root of
|
|
109
|
+
# a cloned repository. When nil the walk stops at the first
|
|
110
|
+
# directory holding a .git, which is the repository root for
|
|
111
|
+
# a plain checkout; failing that, at the paper's own
|
|
112
|
+
# directory. myst.yml sits at the project root while the
|
|
113
|
+
# paper is often nested (content/paper.md), so the walk has
|
|
114
|
+
# to happen -- but it must never wander out of the tree the
|
|
115
|
+
# caller meant.
|
|
116
|
+
def config_text(paper_path, search_root: nil)
|
|
117
|
+
path = config_path(paper_path, search_root)
|
|
118
|
+
path.nil? ? nil : File.read(path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def config_path(paper_path, search_root)
|
|
124
|
+
return nil if paper_path.to_s.strip.empty?
|
|
125
|
+
|
|
126
|
+
directory = File.expand_path(File.dirname(paper_path))
|
|
127
|
+
root = search_root.nil? ? nil : File.expand_path(search_root)
|
|
128
|
+
|
|
129
|
+
loop do
|
|
130
|
+
candidate = File.join(directory, MYST_FILE)
|
|
131
|
+
return candidate if File.file?(candidate)
|
|
132
|
+
|
|
133
|
+
break if root.nil? && File.directory?(File.join(directory, ".git"))
|
|
134
|
+
break if !root.nil? && (directory == root || !directory.start_with?(root))
|
|
135
|
+
|
|
136
|
+
parent = File.dirname(directory)
|
|
137
|
+
break if parent == directory
|
|
138
|
+
|
|
139
|
+
directory = parent
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_project(myst_text)
|
|
146
|
+
data = YAML.safe_load(myst_text, permitted_classes: [Date, Time], aliases: true)
|
|
147
|
+
data.is_a?(Hash) ? data["project"] : nil
|
|
148
|
+
rescue Psych::Exception, ArgumentError => error
|
|
149
|
+
warn "[neurolibre] could not parse #{MYST_FILE}: #{error.message}"
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Builds the indexed affiliation list and an id => index map.
|
|
154
|
+
def build_affiliations(project)
|
|
155
|
+
affiliations = []
|
|
156
|
+
index_of = {}
|
|
157
|
+
|
|
158
|
+
as_list(project["affiliations"]).each do |source|
|
|
159
|
+
index = (affiliations.length + 1).to_s
|
|
160
|
+
if source.is_a?(Hash)
|
|
161
|
+
affiliations << { "index" => index, "name" => affiliation_name(source) }
|
|
162
|
+
index_of[source["id"].to_s] = index unless source["id"].nil?
|
|
163
|
+
else
|
|
164
|
+
# MyST's validator accepts a bare string where an affiliation
|
|
165
|
+
# mapping is expected. It becomes an affiliation named after that
|
|
166
|
+
# string, with no id, and it still consumes its index position --
|
|
167
|
+
# the Lua filter and the Python port apply the same rule, so all
|
|
168
|
+
# three agree on every author's index.
|
|
169
|
+
affiliations << { "index" => index, "name" => source.to_s.strip }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
[affiliations, index_of]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Builds the author list, resolving affiliation ids to indices. Appends to
|
|
177
|
+
# affiliations for any token matching no declared id: MyST permits ad-hoc
|
|
178
|
+
# affiliations, and inventing an entry beats dropping the author's.
|
|
179
|
+
def build_authors(project, affiliations, index_of)
|
|
180
|
+
as_list(project["authors"]).map do |source|
|
|
181
|
+
# Same MyST rule for authors: `authors: [Ada Lovelace]` is valid. A
|
|
182
|
+
# bare string becomes a named author with no affiliations.
|
|
183
|
+
next { "name" => source.to_s.strip } unless source.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
author = { "name" => source["name"] }
|
|
186
|
+
{ "email" => "email",
|
|
187
|
+
"orcid" => "orcid",
|
|
188
|
+
"corresponding" => "corresponding",
|
|
189
|
+
"equal-contrib" => "equal_contributor" }.each do |target, key|
|
|
190
|
+
author[target] = source[key] unless source[key].nil?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
indices = affiliation_tokens(source["affiliations"] || source["affiliation"]).map do |token|
|
|
194
|
+
index = index_of[token]
|
|
195
|
+
if index.nil?
|
|
196
|
+
index = (affiliations.length + 1).to_s
|
|
197
|
+
affiliations << { "index" => index, "name" => token }
|
|
198
|
+
index_of[token] = index
|
|
199
|
+
end
|
|
200
|
+
index
|
|
201
|
+
end
|
|
202
|
+
author["affiliation"] = indices.join(",") unless indices.empty?
|
|
203
|
+
|
|
204
|
+
author
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Joins an affiliation's parts into a single display string.
|
|
209
|
+
def affiliation_name(affiliation)
|
|
210
|
+
NAME_PARTS.map { |key|
|
|
211
|
+
value = affiliation[key]
|
|
212
|
+
value = affiliation[ALIASES[key]] if blank?(value) && ALIASES.key?(key)
|
|
213
|
+
blank?(value) ? nil : value.to_s.strip
|
|
214
|
+
}.compact.join(", ")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Normalises an author's `affiliations` value to a list of tokens. MyST
|
|
218
|
+
# accepts a list, a single id, or several ids in one ';'-separated string.
|
|
219
|
+
def affiliation_tokens(value)
|
|
220
|
+
return [] if blank?(value)
|
|
221
|
+
return value.map { |entry| entry.to_s.strip }.reject(&:empty?) if value.is_a?(Array)
|
|
222
|
+
|
|
223
|
+
value.to_s.split(";").map(&:strip).reject(&:empty?)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Normalises a myst.yml sequence to an Array. `affiliations: harvard` is
|
|
227
|
+
# legal MyST; without this, iterating the string would walk its
|
|
228
|
+
# characters.
|
|
229
|
+
def as_list(value)
|
|
230
|
+
return [] if value.nil?
|
|
231
|
+
return value if value.is_a?(Array)
|
|
232
|
+
|
|
233
|
+
[value]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Is a value absent, or present but carrying nothing?
|
|
237
|
+
#
|
|
238
|
+
# A front matter of `title:` parses to nil, not to a missing key, and
|
|
239
|
+
# "", [] and {} say the same thing. All of them must count as absent or a
|
|
240
|
+
# key that was merely typed out defeats the fallback.
|
|
241
|
+
def blank?(value)
|
|
242
|
+
return true if value.nil?
|
|
243
|
+
return value.strip.empty? if value.is_a?(String)
|
|
244
|
+
return value.empty? if value.respond_to?(:empty?)
|
|
245
|
+
|
|
246
|
+
false
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
data/lib/theoj/paper.rb
CHANGED
|
@@ -88,6 +88,10 @@ module Theoj
|
|
|
88
88
|
if path.to_s.strip.empty?
|
|
89
89
|
setup_local_repo
|
|
90
90
|
@paper_path = Theoj::Paper.find_paper_path(local_path)
|
|
91
|
+
# myst.yml lives at the project root while the paper is often nested
|
|
92
|
+
# (content/paper.md), so the lookup may walk up -- but no further
|
|
93
|
+
# than the tree we cloned.
|
|
94
|
+
@metadata_search_root = local_path
|
|
91
95
|
else
|
|
92
96
|
@paper_path = path
|
|
93
97
|
end
|
|
@@ -107,7 +111,19 @@ module Theoj
|
|
|
107
111
|
def load_metadata
|
|
108
112
|
@paper_metadata ||= if paper_path.nil?
|
|
109
113
|
{}
|
|
110
|
-
|
|
114
|
+
else
|
|
115
|
+
# A NeuroLibre submission may declare its title, authors and
|
|
116
|
+
# affiliations in myst.yml instead of repeating them in the paper's
|
|
117
|
+
# own front matter, so anything absent here is filled from there.
|
|
118
|
+
Theoj::MystFrontmatter.merge(
|
|
119
|
+
front_matter_metadata,
|
|
120
|
+
Theoj::MystFrontmatter.config_text(paper_path, search_root: @metadata_search_root)
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def front_matter_metadata
|
|
126
|
+
if paper_path.include?('.tex')
|
|
111
127
|
YAML.load_file(paper_path.gsub('.tex', '.yml'))
|
|
112
128
|
else
|
|
113
129
|
YAML.load_file(paper_path)
|
|
@@ -119,6 +135,8 @@ module Theoj
|
|
|
119
135
|
authors_metadata = @paper_metadata['authors']
|
|
120
136
|
affiliations_metadata = parse_affiliations(@paper_metadata['affiliations'])
|
|
121
137
|
|
|
138
|
+
failure "Cannot find the authors of this paper" if authors_metadata.nil?
|
|
139
|
+
|
|
122
140
|
# Loop through the authors block and build up the affiliation
|
|
123
141
|
authors_metadata.each do |author|
|
|
124
142
|
author['name'] = author.dup if author['name'].nil?
|
|
@@ -138,7 +156,12 @@ module Theoj
|
|
|
138
156
|
def parse_affiliations(affiliations_yaml)
|
|
139
157
|
affiliations_metadata = {}
|
|
140
158
|
|
|
141
|
-
|
|
159
|
+
# A paper may legitimately name authors and no affiliations: myst.yml
|
|
160
|
+
# permits it and so does a hand-written paper.md. Iterating nil here
|
|
161
|
+
# raised NoMethodError and took the whole deposit down with it.
|
|
162
|
+
Array(affiliations_yaml).each do |affiliation|
|
|
163
|
+
next unless affiliation.is_a?(Hash)
|
|
164
|
+
|
|
142
165
|
affiliations_metadata[affiliation['index']] = affiliation['name']
|
|
143
166
|
end
|
|
144
167
|
|
data/lib/theoj/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: neurolibre
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.5.
|
|
4
|
+
version: 1.5.7
|
|
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:
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: octokit
|
|
@@ -152,6 +152,7 @@ files:
|
|
|
152
152
|
- lib/theoj/github.rb
|
|
153
153
|
- lib/theoj/journal.rb
|
|
154
154
|
- lib/theoj/journals_data.rb
|
|
155
|
+
- lib/theoj/myst_frontmatter.rb
|
|
155
156
|
- lib/theoj/orcid.rb
|
|
156
157
|
- lib/theoj/paper.rb
|
|
157
158
|
- lib/theoj/published_paper.rb
|
|
@@ -185,7 +186,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
185
186
|
- !ruby/object:Gem::Version
|
|
186
187
|
version: '0'
|
|
187
188
|
requirements: []
|
|
188
|
-
rubygems_version: 3.
|
|
189
|
+
rubygems_version: 3.5.11
|
|
189
190
|
signing_key:
|
|
190
191
|
specification_version: 4
|
|
191
192
|
summary: Editorial objects used by NeuroLibre
|