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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a4fdc63012c7af7f469f312d4130205b9596a9a8f5a2367ef3a24ce74be2b51
4
- data.tar.gz: 55d4caa8afc3e178c3b028d17ef9a2fb7b0282957026297e6706f3584975bd18
3
+ metadata.gz: d5b0fdff71e32a79ca2bd3d2ecfe6b8f670dcc0e1c509cc05d4f2c2c99123dc6
4
+ data.tar.gz: a65f2aedad14e0e4ca0d3152662a3376f53948bcc4fa1a23e6d8135955acdea8
5
5
  SHA512:
6
- metadata.gz: 36012134ea8415dfd76632e0cb66c43fa646a1e7ed7a57d448d5cb68ebfe50f803c5657fabf25da324842702c83e7657d0e9a9b8be5e94ade4c39dc41cccf9af
7
- data.tar.gz: 6e3233ed33201029b9d6efc7b45572c427b55d30c479ce546fc494da07fe9c92563832ff4f872b7ec2b684b5e484b5b906057309f5e5fde431eb147f95520933
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
- elsif paper_path.include?('.tex')
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
- affiliations_yaml.each do |affiliation|
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
@@ -1,3 +1,3 @@
1
1
  module Theoj
2
- VERSION = "1.5.6"
2
+ VERSION = "1.5.7"
3
3
  end
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.6
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: 2023-06-19 00:00:00.000000000 Z
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.4.6
189
+ rubygems_version: 3.5.11
189
190
  signing_key:
190
191
  specification_version: 4
191
192
  summary: Editorial objects used by NeuroLibre