sci-report-analyzer 1.0.0__py3-none-any.whl

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.
Files changed (87) hide show
  1. sci_report_analyzer/__init__.py +6 -0
  2. sci_report_analyzer/annotations.py +415 -0
  3. sci_report_analyzer/authors.py +81 -0
  4. sci_report_analyzer/categories.py +649 -0
  5. sci_report_analyzer/config.py +59 -0
  6. sci_report_analyzer/contribution.py +291 -0
  7. sci_report_analyzer/data/README.md +24 -0
  8. sci_report_analyzer/data/conferences.json +989 -0
  9. sci_report_analyzer/data/conferences.past.json +1274 -0
  10. sci_report_analyzer/datadir.py +49 -0
  11. sci_report_analyzer/db/__init__.py +0 -0
  12. sci_report_analyzer/db/backup.py +120 -0
  13. sci_report_analyzer/db/migrations/env.py +26 -0
  14. sci_report_analyzer/db/migrations/script.py.mako +25 -0
  15. sci_report_analyzer/db/migrations/versions/3b7c2d9e4a61_period_documents.py +49 -0
  16. sci_report_analyzer/db/migrations/versions/5d2a9c4e1f73_edited_volume_venues.py +31 -0
  17. sci_report_analyzer/db/migrations/versions/8e1f5a2c7b90_categories_excerpts.py +60 -0
  18. sci_report_analyzer/db/migrations/versions/9c4e7b2a5d18_report_cite_form.py +21 -0
  19. sci_report_analyzer/db/migrations/versions/a7e3c1f9b250_drop_report_cite_form.py +27 -0
  20. sci_report_analyzer/db/migrations/versions/b8e4c6f2d071_excerpt_groups.py +34 -0
  21. sci_report_analyzer/db/migrations/versions/c2d8f4a61e37_excerpt_years_rayonnement.py +32 -0
  22. sci_report_analyzer/db/migrations/versions/c2d9a7e4b153_excerpt_group_text.py +28 -0
  23. sci_report_analyzer/db/migrations/versions/d4e8b1c6f720_excerpt_original.py +27 -0
  24. sci_report_analyzer/db/migrations/versions/e5b1a9d73c42_excerpt_influence.py +25 -0
  25. sci_report_analyzer/db/migrations/versions/f14fa8f18280_initial_schema.py +382 -0
  26. sci_report_analyzer/db/migrations/versions/f3c7d2e8a915_excerpt_colour.py +26 -0
  27. sci_report_analyzer/db/models.py +672 -0
  28. sci_report_analyzer/db/session.py +104 -0
  29. sci_report_analyzer/documents.py +652 -0
  30. sci_report_analyzer/folders.py +214 -0
  31. sci_report_analyzer/i18n.py +33 -0
  32. sci_report_analyzer/keys.py +40 -0
  33. sci_report_analyzer/livereload.py +88 -0
  34. sci_report_analyzer/main.py +93 -0
  35. sci_report_analyzer/manual.py +151 -0
  36. sci_report_analyzer/merge.py +307 -0
  37. sci_report_analyzer/pdfs.py +343 -0
  38. sci_report_analyzer/pubview.py +1132 -0
  39. sci_report_analyzer/ranking/__init__.py +0 -0
  40. sci_report_analyzer/ranking/badge.py +427 -0
  41. sci_report_analyzer/ranking/datasets.py +412 -0
  42. sci_report_analyzer/ranking/kinds.py +300 -0
  43. sci_report_analyzer/ranking/matcher.py +198 -0
  44. sci_report_analyzer/ranking/normalize.py +276 -0
  45. sci_report_analyzer/ranking/service.py +528 -0
  46. sci_report_analyzer/reflist.py +201 -0
  47. sci_report_analyzer/reports.py +518 -0
  48. sci_report_analyzer/settings_io.py +466 -0
  49. sci_report_analyzer/source_settings.py +46 -0
  50. sci_report_analyzer/sources/__init__.py +33 -0
  51. sci_report_analyzer/sources/base.py +216 -0
  52. sci_report_analyzer/sources/dblp.py +241 -0
  53. sci_report_analyzer/sources/doi.py +480 -0
  54. sci_report_analyzer/sources/hal.py +296 -0
  55. sci_report_analyzer/sources/openalex.py +129 -0
  56. sci_report_analyzer/sources/orcid.py +105 -0
  57. sci_report_analyzer/sources/scholar.py +171 -0
  58. sci_report_analyzer/sources/semanticscholar.py +123 -0
  59. sci_report_analyzer/sources/thesesfr.py +95 -0
  60. sci_report_analyzer/stop.py +57 -0
  61. sci_report_analyzer/sync.py +567 -0
  62. sci_report_analyzer/theses.py +79 -0
  63. sci_report_analyzer/ui/__init__.py +0 -0
  64. sci_report_analyzer/ui/categories_editor.py +235 -0
  65. sci_report_analyzer/ui/documents_page.py +482 -0
  66. sci_report_analyzer/ui/help_page.py +333 -0
  67. sci_report_analyzer/ui/mdedit.py +173 -0
  68. sci_report_analyzer/ui/panel.py +1262 -0
  69. sci_report_analyzer/ui/pdf_viewer.py +933 -0
  70. sci_report_analyzer/ui/person.py +964 -0
  71. sci_report_analyzer/ui/persons.py +543 -0
  72. sci_report_analyzer/ui/pub_details.py +1813 -0
  73. sci_report_analyzer/ui/reflist.py +224 -0
  74. sci_report_analyzer/ui/report.py +475 -0
  75. sci_report_analyzer/ui/scimago_years.py +82 -0
  76. sci_report_analyzer/ui/settings.py +1197 -0
  77. sci_report_analyzer/ui/tags.py +271 -0
  78. sci_report_analyzer/ui/theme.py +297 -0
  79. sci_report_analyzer/ui/venues_page.py +1503 -0
  80. sci_report_analyzer/ui/viewer_side.py +1040 -0
  81. sci_report_analyzer/venue_match.py +372 -0
  82. sci_report_analyzer/venues.py +1121 -0
  83. sci_report_analyzer-1.0.0.dist-info/METADATA +126 -0
  84. sci_report_analyzer-1.0.0.dist-info/RECORD +87 -0
  85. sci_report_analyzer-1.0.0.dist-info/WHEEL +4 -0
  86. sci_report_analyzer-1.0.0.dist-info/entry_points.txt +3 -0
  87. sci_report_analyzer-1.0.0.dist-info/licenses/LICENSE +48 -0
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("sci-report-analyzer")
5
+ except PackageNotFoundError: # running from the sources without installing
6
+ __version__ = "dev"
@@ -0,0 +1,415 @@
1
+ """User annotations on publications: flags, tags (global or per period, stars included),
2
+ notes, forced venue matches."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from sqlalchemy import select
9
+
10
+ from .db.models import (
11
+ STARRED,
12
+ AppSetting,
13
+ Flag,
14
+ Period,
15
+ PeriodNote,
16
+ PeriodTag,
17
+ Publication,
18
+ PublicationFlag,
19
+ PublicationTag,
20
+ Tag,
21
+ )
22
+ from .db.session import session_scope
23
+
24
+ DEFAULT_FLAGS = (
25
+ ("short", "#d4a72c", "short"),
26
+ ("demo", "#8a6fd0", "demo"),
27
+ ("tutorial", "#1a7f37", "tutorial"),
28
+ )
29
+
30
+
31
+ def seed_flags() -> None:
32
+ with session_scope() as s:
33
+ if s.scalar(select(Flag.id).limit(1)) is None:
34
+ for name, colour, track in DEFAULT_FLAGS:
35
+ s.add(Flag(name=name, colour=colour, track=track))
36
+
37
+
38
+ def all_flags() -> list[Flag]:
39
+ with session_scope() as s:
40
+ return list(s.scalars(select(Flag).order_by(Flag.name)))
41
+
42
+
43
+ def save_flag(name: str, colour: str, track: str | None, flag_id: int | None = None) -> None:
44
+ with session_scope() as s:
45
+ f = s.get(Flag, flag_id) if flag_id else Flag(name=name)
46
+ if flag_id is None:
47
+ s.add(f)
48
+ f.name, f.colour, f.track = name, colour, track or None
49
+
50
+
51
+ def delete_flag(flag_id: int) -> None:
52
+ with session_scope() as s:
53
+ if f := s.get(Flag, flag_id):
54
+ s.delete(f)
55
+
56
+
57
+ def toggle_flag(pub_id: int, flag_id: int) -> bool:
58
+ with session_scope() as s:
59
+ row = s.get(PublicationFlag, (pub_id, flag_id))
60
+ if row:
61
+ s.delete(row)
62
+ return False
63
+ s.add(PublicationFlag(publication_id=pub_id, flag_id=flag_id))
64
+ return True
65
+
66
+
67
+ # ---- tags and notes -------------------------------------------------------------------------
68
+
69
+
70
+ def all_tags() -> list[Tag]:
71
+ """Every tag: the global ones, then the per-period ones (by name)."""
72
+ with session_scope() as s:
73
+ return list(s.scalars(select(Tag).order_by(Tag.per_period, Tag.name)))
74
+
75
+
76
+ def starred_tag_id() -> int:
77
+ with session_scope() as s:
78
+ return s.scalar(select(Tag.id).where(Tag.key == STARRED))
79
+
80
+
81
+ def save_tag(
82
+ name: str, colour: str | None = None, per_period: bool = False, tag_id: int | None = None
83
+ ) -> int:
84
+ """Create a tag (or rename / recolour one; a tag keeps its kind). An existing name is
85
+ reused."""
86
+ name = name.strip()
87
+ with session_scope() as s:
88
+ t = s.get(Tag, tag_id) if tag_id else s.scalar(select(Tag).where(Tag.name == name))
89
+ if t is None:
90
+ t = Tag(name=name, per_period=per_period, colour=colour or "#0969da")
91
+ s.add(t)
92
+ t.name = name
93
+ if colour:
94
+ t.colour = colour
95
+ s.flush()
96
+ return t.id
97
+
98
+
99
+ def delete_tag(tag_id: int) -> None:
100
+ """Delete a tag (from every paper); the built-in ones are kept."""
101
+ with session_scope() as s:
102
+ if (t := s.get(Tag, tag_id)) and t.key is None:
103
+ s.delete(t)
104
+
105
+
106
+ def toggle_tag(pub_id: int, tag_id: int, period_id: int | None = None) -> bool:
107
+ """Put a tag on a paper (or take it off): a per-period tag within ``period_id``."""
108
+ with session_scope() as s:
109
+ if s.get(Tag, tag_id).per_period:
110
+ if period_id is None:
111
+ raise ValueError("a per-period tag needs a period")
112
+ row = s.get(PeriodTag, (period_id, pub_id, tag_id))
113
+ new = PeriodTag(period_id=period_id, publication_id=pub_id, tag_id=tag_id)
114
+ else:
115
+ row = s.get(PublicationTag, (pub_id, tag_id))
116
+ new = PublicationTag(publication_id=pub_id, tag_id=tag_id)
117
+ if row:
118
+ s.delete(row)
119
+ return False
120
+ s.add(new)
121
+ return True
122
+
123
+
124
+ def tag_numbered(tag_id: int, numbers: dict[int, int | None], period_id: int | None = None) -> None:
125
+ """Put a tag on papers (pub id -> their number in a list), keeping the tag where it is
126
+ and setting its number."""
127
+ with session_scope() as s:
128
+ per_period = s.get(Tag, tag_id).per_period
129
+ if per_period and period_id is None:
130
+ raise ValueError("a per-period tag needs a period")
131
+ for pub_id, number in numbers.items():
132
+ if per_period:
133
+ key = (period_id, pub_id, tag_id)
134
+ row = s.get(PeriodTag, key) or PeriodTag(
135
+ period_id=period_id, publication_id=pub_id, tag_id=tag_id
136
+ )
137
+ else:
138
+ row = s.get(PublicationTag, (pub_id, tag_id)) or PublicationTag(
139
+ publication_id=pub_id, tag_id=tag_id
140
+ )
141
+ row.number = number
142
+ s.add(row)
143
+
144
+
145
+ def _list_key(person_id: int, tag_id: int, period_id: int | None) -> str:
146
+ return f"ui.reflist.{person_id}.{period_id or 0}.{tag_id}"
147
+
148
+
149
+ def tag_list(person_id: int, tag_id: int, period_id: int | None = None) -> list[dict]:
150
+ """The last list a tag was put from (its items: number, text, the paper's id or None)."""
151
+ return list(ui_state(_list_key(person_id, tag_id, period_id), []))
152
+
153
+
154
+ def save_tag_list(person_id: int, tag_id: int, period_id: int | None, entries: list[dict]) -> None:
155
+ save_ui_state(_list_key(person_id, tag_id, period_id), entries)
156
+
157
+
158
+ def toggle_star(period_id: int, pub_id: int) -> bool:
159
+ return toggle_tag(pub_id, starred_tag_id(), period_id)
160
+
161
+
162
+ def set_note(pub_id: int, text: str | None, period_id: int | None = None) -> None:
163
+ """The paper's note (Markdown), or its note within a period; empty: none."""
164
+ text = (text or "").strip() or None
165
+ with session_scope() as s:
166
+ if period_id is None:
167
+ s.get(Publication, pub_id).note = text
168
+ return
169
+ row = s.get(PeriodNote, (period_id, pub_id))
170
+ if text is None:
171
+ if row:
172
+ s.delete(row)
173
+ elif row:
174
+ row.text = text
175
+ else:
176
+ s.add(PeriodNote(period_id=period_id, publication_id=pub_id, text=text))
177
+
178
+
179
+ def periods(person_id: int, *, include_hidden_folders: bool = False) -> list[Period]:
180
+ """The person's own periods (by start), then their folder periods (latest folder first)."""
181
+ from sqlalchemy.orm import selectinload
182
+
183
+ from .db.models import Folder
184
+
185
+ q = (
186
+ select(Period)
187
+ .outerjoin(Folder, Period.folder_id == Folder.id)
188
+ .where(Period.person_id == person_id)
189
+ .options(selectinload(Period.folder))
190
+ )
191
+ if not include_hidden_folders:
192
+ q = q.where(Period.folder_id.is_(None) | Folder.hidden.is_(False))
193
+ with session_scope() as s:
194
+ items = list(s.scalars(q))
195
+
196
+ def key(p: Period) -> tuple:
197
+ if p.folder is None:
198
+ return (0, p.start_year or 0, p.id)
199
+ day = p.folder.date.toordinal() if p.folder.date else 0
200
+ return (1, -day, p.id)
201
+
202
+ return sorted(items, key=key)
203
+
204
+
205
+ def save_period(
206
+ person_id: int, name: str, start: int | None, end: int | None, period_id: int | None = None
207
+ ) -> int:
208
+ with session_scope() as s:
209
+ p = s.get(Period, period_id) if period_id else Period(person_id=person_id)
210
+ if period_id is None:
211
+ s.add(p)
212
+ p.name, p.start_year, p.end_year = name, start, end
213
+ s.flush()
214
+ return p.id
215
+
216
+
217
+ def delete_period(period_id: int) -> None:
218
+ with session_scope() as s:
219
+ if p := s.get(Period, period_id):
220
+ s.delete(p)
221
+
222
+
223
+ def set_rank_override(
224
+ pub_id: int, override: dict[str, Any] | None, note: str | None = None
225
+ ) -> None:
226
+ """Rank of one publication set by hand: a level ``{"type", "rank", "name"}`` or a
227
+ ranking record ``{"record_key"}`` (None: back to its venue's rank)."""
228
+ with session_scope() as s:
229
+ pub = s.get(Publication, pub_id)
230
+ pub.rank_override = override
231
+ pub.rank_note = (note or None) if override else None
232
+
233
+
234
+ def set_venue_source(pub_id: int, source: str | None) -> None:
235
+ """Use this source's venue for the publication (when the sources disagree)."""
236
+ with session_scope() as s:
237
+ s.get(Publication, pub_id).venue_source = source
238
+
239
+
240
+ _OVERRIDES = ("year_override", "author_pos_override", "note")
241
+
242
+
243
+ def set_overrides(pub_id: int, **values: Any) -> None:
244
+ """Year / author position set by hand (None: from the sources), and a note."""
245
+ with session_scope() as s:
246
+ pub = s.get(Publication, pub_id)
247
+ for k, v in values.items():
248
+ if k not in _OVERRIDES:
249
+ raise ValueError(k)
250
+ setattr(pub, k, v if v not in ("", None) else None)
251
+
252
+
253
+ def set_doi(pub_id: int, doi: str | None) -> int:
254
+ """Give a publication its DOI by hand (None: from the sources). Returns its person."""
255
+ from .sources.base import normalize_doi
256
+
257
+ with session_scope() as s:
258
+ pub = s.get(Publication, pub_id)
259
+ pub.doi_manual = normalize_doi(doi)
260
+ return pub.person_id
261
+
262
+
263
+ def set_hidden(pub_id: int, hidden: bool) -> None:
264
+ with session_scope() as s:
265
+ s.get(Publication, pub_id).hidden = hidden
266
+
267
+
268
+ def ui_state(key: str, default: Any = None) -> Any:
269
+ with session_scope() as s:
270
+ row = s.get(AppSetting, key)
271
+ return row.value if row else default
272
+
273
+
274
+ def save_ui_state(key: str, value: Any) -> None:
275
+ with session_scope() as s:
276
+ s.merge(AppSetting(key=key, value=value))
277
+
278
+
279
+ def panel_state(person_id: int) -> dict[str, Any]:
280
+ with session_scope() as s:
281
+ row = s.get(AppSetting, f"ui.person.{person_id}")
282
+ return dict(row.value) if row else {}
283
+
284
+
285
+ def save_panel_state(person_id: int, state: dict[str, Any]) -> None:
286
+ with session_scope() as s:
287
+ s.merge(AppSetting(key=f"ui.person.{person_id}", value=state))
288
+
289
+
290
+ # ---- name aliases (the person and their PhD students) ---------------------------------------
291
+
292
+ OWNER = "owner"
293
+
294
+
295
+ def add_alias(person_id: int, name: str, student: str | None = None) -> None:
296
+ """Confirm ``name`` as a spelling of the person (or of PhD student ``student``)."""
297
+ from .db.models import Person
298
+
299
+ with session_scope() as s:
300
+ p = s.get(Person, person_id)
301
+ if student is None:
302
+ if name != p.name and name not in (p.aliases or []):
303
+ p.aliases = [*(p.aliases or []), name]
304
+ else:
305
+ aliases = dict(p.student_aliases or {})
306
+ if name != student and name not in aliases.get(student, []):
307
+ aliases[student] = [*aliases.get(student, []), name]
308
+ p.student_aliases = aliases
309
+ _unreject(p, student or OWNER, name)
310
+
311
+
312
+ def reject_alias(person_id: int, name: str, student: str | None = None) -> None:
313
+ """``name`` is *not* the person (or the student): stop suggesting it."""
314
+ from .db.models import Person
315
+
316
+ with session_scope() as s:
317
+ p = s.get(Person, person_id)
318
+ rejects = dict(p.name_rejects or {})
319
+ key = student or OWNER
320
+ if name not in rejects.get(key, []):
321
+ rejects[key] = [*rejects.get(key, []), name]
322
+ p.name_rejects = rejects
323
+
324
+
325
+ def _unreject(p, key: str, name: str) -> None:
326
+ rejects = dict(p.name_rejects or {})
327
+ if name in rejects.get(key, []):
328
+ rejects[key] = [n for n in rejects[key] if n != name]
329
+ p.name_rejects = rejects
330
+
331
+
332
+ def set_kind_override(pub_id: int, kind: str | None) -> None:
333
+ with session_scope() as s:
334
+ s.get(Publication, pub_id).kind_override = kind or None
335
+
336
+
337
+ def remove_alias(person_id: int, name: str, student: str | None = None) -> None:
338
+ """Undo a confirmed spelling: ``name`` is no longer the person (or the student)."""
339
+ from .db.models import Person
340
+
341
+ with session_scope() as s:
342
+ p = s.get(Person, person_id)
343
+ if student is None:
344
+ p.aliases = [a for a in (p.aliases or []) if a != name]
345
+ else:
346
+ aliases = dict(p.student_aliases or {})
347
+ aliases[student] = [a for a in aliases.get(student, []) if a != name]
348
+ p.student_aliases = aliases
349
+ reject_alias(person_id, name, student)
350
+
351
+
352
+ def set_student_outcome(person_id: int, student: str, note: str) -> None:
353
+ """Record what became of PhD student ``student`` after the PhD (empty: forget it)."""
354
+ from .db.models import Person
355
+
356
+ with session_scope() as s:
357
+ p = s.get(Person, person_id)
358
+ outcomes = dict(p.student_outcomes or {})
359
+ if note.strip():
360
+ outcomes[student] = note.strip()
361
+ else:
362
+ outcomes.pop(student, None)
363
+ p.student_outcomes = outcomes
364
+
365
+
366
+ # ---- author categories (e.g. "Intl. collaborators") ----------------------------------------
367
+
368
+
369
+ def author_categories() -> list:
370
+ from .db.models import AuthorCategory
371
+
372
+ with session_scope() as s:
373
+ return list(s.scalars(select(AuthorCategory).order_by(AuthorCategory.name)))
374
+
375
+
376
+ def save_author_category(name: str, colour: str, category_id: int | None = None) -> int:
377
+ from .db.models import AuthorCategory
378
+
379
+ with session_scope() as s:
380
+ c = s.get(AuthorCategory, category_id) if category_id else None
381
+ if c is None:
382
+ c = s.scalar(select(AuthorCategory).where(AuthorCategory.name == name))
383
+ if c is None:
384
+ c = AuthorCategory(name=name)
385
+ s.add(c)
386
+ c.name, c.colour = name, colour
387
+ s.flush()
388
+ return c.id
389
+
390
+
391
+ def delete_author_category(category_id: int) -> None:
392
+ from .db.models import AuthorCategory, Person
393
+
394
+ with session_scope() as s:
395
+ if c := s.get(AuthorCategory, category_id):
396
+ s.delete(c)
397
+ for p in s.scalars(select(Person)):
398
+ if str(category_id) in (p.author_categories or {}):
399
+ cats = dict(p.author_categories)
400
+ del cats[str(category_id)]
401
+ p.author_categories = cats
402
+
403
+
404
+ def set_author_category(person_id: int, name: str, category_id: int, on: bool = True) -> None:
405
+ """Put co-author ``name`` in (or out of) a category, for this person."""
406
+ from .db.models import Person
407
+
408
+ with session_scope() as s:
409
+ p = s.get(Person, person_id)
410
+ cats = dict(p.author_categories or {})
411
+ names = [n for n in cats.get(str(category_id), []) if n != name]
412
+ if on:
413
+ names.append(name)
414
+ cats[str(category_id)] = names
415
+ p.author_categories = cats
@@ -0,0 +1,81 @@
1
+ """Author-name utilities: name keys and same-author tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ from collections.abc import Iterable
8
+
9
+ _COMBINING = re.compile("[̀-ͯ]")
10
+
11
+
12
+ def natural_order(name: str) -> str:
13
+ """ "Given Surname" order for names written "SURNAME Given" or "Surname, Given".
14
+
15
+ A word in capitals (two letters or more) mixed with ordinary words is the surname, as in
16
+ French administrative style ("NEVEOL Aurélie" -> "Aurélie NEVEOL").
17
+ """
18
+ name = " ".join(name.split())
19
+ if name.count(",") == 1:
20
+ last, first = (x.strip() for x in name.split(","))
21
+ if last and first:
22
+ return f"{first} {last}"
23
+ words = name.split()
24
+
25
+ def caps(w: str) -> bool:
26
+ letters = [c for c in w if c.isalpha()]
27
+ return len(letters) >= 2 and all(c.isupper() for c in letters)
28
+
29
+ upper = [w for w in words if caps(w)]
30
+ if upper and len(upper) < len(words):
31
+ return " ".join([w for w in words if not caps(w)] + upper)
32
+ return name
33
+
34
+
35
+ def _fold(name: str) -> list[str]:
36
+ s = _COMBINING.sub("", unicodedata.normalize("NFD", natural_order(name))).lower()
37
+ return [p for p in re.sub(r"[^a-z\s]", " ", s).split() if p]
38
+
39
+
40
+ def name_key(name: str) -> tuple[str, str]:
41
+ """(surname, first initial), accent-/case-folded."""
42
+ parts = _fold(name)
43
+ if not parts:
44
+ return "", ""
45
+ return parts[-1], parts[0][0]
46
+
47
+
48
+ def same_author(a: str, b: str) -> bool:
49
+ sa, ia = name_key(a)
50
+ sb, ib = name_key(b)
51
+ if not sa or sa != sb:
52
+ return False
53
+ return not ia or not ib or ia == ib
54
+
55
+
56
+ def author_position(authors: Iterable[str], names: Iterable[str]) -> int | None:
57
+ """1-based position of the first author matching any of ``names``."""
58
+ names = [n for n in names if n]
59
+ for i, a in enumerate(authors, start=1):
60
+ if any(same_author(a, n) for n in names):
61
+ return i
62
+ return None
63
+
64
+
65
+ def name_similarity(query: str, candidate: str) -> float:
66
+ """Crude [0, 1] similarity between a searched name and a candidate profile name."""
67
+ q, c = _fold(query), _fold(candidate)
68
+ if not q or not c:
69
+ return 0.0
70
+ if q == c:
71
+ return 1.0
72
+ if same_author(query, candidate):
73
+ # Surname + initial agree; reward matching full given names.
74
+ return 0.9 if set(q[:-1]) & set(c[:-1]) else 0.75
75
+ shared = len(set(q) & set(c))
76
+ return 0.5 * shared / max(len(set(q)), len(set(c)))
77
+
78
+
79
+ def fold_name(name: str) -> str:
80
+ """Accent/case/punctuation-folded full name, for exact alias comparison."""
81
+ return " ".join(_fold(name))