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.
- sci_report_analyzer/__init__.py +6 -0
- sci_report_analyzer/annotations.py +415 -0
- sci_report_analyzer/authors.py +81 -0
- sci_report_analyzer/categories.py +649 -0
- sci_report_analyzer/config.py +59 -0
- sci_report_analyzer/contribution.py +291 -0
- sci_report_analyzer/data/README.md +24 -0
- sci_report_analyzer/data/conferences.json +989 -0
- sci_report_analyzer/data/conferences.past.json +1274 -0
- sci_report_analyzer/datadir.py +49 -0
- sci_report_analyzer/db/__init__.py +0 -0
- sci_report_analyzer/db/backup.py +120 -0
- sci_report_analyzer/db/migrations/env.py +26 -0
- sci_report_analyzer/db/migrations/script.py.mako +25 -0
- sci_report_analyzer/db/migrations/versions/3b7c2d9e4a61_period_documents.py +49 -0
- sci_report_analyzer/db/migrations/versions/5d2a9c4e1f73_edited_volume_venues.py +31 -0
- sci_report_analyzer/db/migrations/versions/8e1f5a2c7b90_categories_excerpts.py +60 -0
- sci_report_analyzer/db/migrations/versions/9c4e7b2a5d18_report_cite_form.py +21 -0
- sci_report_analyzer/db/migrations/versions/a7e3c1f9b250_drop_report_cite_form.py +27 -0
- sci_report_analyzer/db/migrations/versions/b8e4c6f2d071_excerpt_groups.py +34 -0
- sci_report_analyzer/db/migrations/versions/c2d8f4a61e37_excerpt_years_rayonnement.py +32 -0
- sci_report_analyzer/db/migrations/versions/c2d9a7e4b153_excerpt_group_text.py +28 -0
- sci_report_analyzer/db/migrations/versions/d4e8b1c6f720_excerpt_original.py +27 -0
- sci_report_analyzer/db/migrations/versions/e5b1a9d73c42_excerpt_influence.py +25 -0
- sci_report_analyzer/db/migrations/versions/f14fa8f18280_initial_schema.py +382 -0
- sci_report_analyzer/db/migrations/versions/f3c7d2e8a915_excerpt_colour.py +26 -0
- sci_report_analyzer/db/models.py +672 -0
- sci_report_analyzer/db/session.py +104 -0
- sci_report_analyzer/documents.py +652 -0
- sci_report_analyzer/folders.py +214 -0
- sci_report_analyzer/i18n.py +33 -0
- sci_report_analyzer/keys.py +40 -0
- sci_report_analyzer/livereload.py +88 -0
- sci_report_analyzer/main.py +93 -0
- sci_report_analyzer/manual.py +151 -0
- sci_report_analyzer/merge.py +307 -0
- sci_report_analyzer/pdfs.py +343 -0
- sci_report_analyzer/pubview.py +1132 -0
- sci_report_analyzer/ranking/__init__.py +0 -0
- sci_report_analyzer/ranking/badge.py +427 -0
- sci_report_analyzer/ranking/datasets.py +412 -0
- sci_report_analyzer/ranking/kinds.py +300 -0
- sci_report_analyzer/ranking/matcher.py +198 -0
- sci_report_analyzer/ranking/normalize.py +276 -0
- sci_report_analyzer/ranking/service.py +528 -0
- sci_report_analyzer/reflist.py +201 -0
- sci_report_analyzer/reports.py +518 -0
- sci_report_analyzer/settings_io.py +466 -0
- sci_report_analyzer/source_settings.py +46 -0
- sci_report_analyzer/sources/__init__.py +33 -0
- sci_report_analyzer/sources/base.py +216 -0
- sci_report_analyzer/sources/dblp.py +241 -0
- sci_report_analyzer/sources/doi.py +480 -0
- sci_report_analyzer/sources/hal.py +296 -0
- sci_report_analyzer/sources/openalex.py +129 -0
- sci_report_analyzer/sources/orcid.py +105 -0
- sci_report_analyzer/sources/scholar.py +171 -0
- sci_report_analyzer/sources/semanticscholar.py +123 -0
- sci_report_analyzer/sources/thesesfr.py +95 -0
- sci_report_analyzer/stop.py +57 -0
- sci_report_analyzer/sync.py +567 -0
- sci_report_analyzer/theses.py +79 -0
- sci_report_analyzer/ui/__init__.py +0 -0
- sci_report_analyzer/ui/categories_editor.py +235 -0
- sci_report_analyzer/ui/documents_page.py +482 -0
- sci_report_analyzer/ui/help_page.py +333 -0
- sci_report_analyzer/ui/mdedit.py +173 -0
- sci_report_analyzer/ui/panel.py +1262 -0
- sci_report_analyzer/ui/pdf_viewer.py +933 -0
- sci_report_analyzer/ui/person.py +964 -0
- sci_report_analyzer/ui/persons.py +543 -0
- sci_report_analyzer/ui/pub_details.py +1813 -0
- sci_report_analyzer/ui/reflist.py +224 -0
- sci_report_analyzer/ui/report.py +475 -0
- sci_report_analyzer/ui/scimago_years.py +82 -0
- sci_report_analyzer/ui/settings.py +1197 -0
- sci_report_analyzer/ui/tags.py +271 -0
- sci_report_analyzer/ui/theme.py +297 -0
- sci_report_analyzer/ui/venues_page.py +1503 -0
- sci_report_analyzer/ui/viewer_side.py +1040 -0
- sci_report_analyzer/venue_match.py +372 -0
- sci_report_analyzer/venues.py +1121 -0
- sci_report_analyzer-1.0.0.dist-info/METADATA +126 -0
- sci_report_analyzer-1.0.0.dist-info/RECORD +87 -0
- sci_report_analyzer-1.0.0.dist-info/WHEEL +4 -0
- sci_report_analyzer-1.0.0.dist-info/entry_points.txt +3 -0
- sci_report_analyzer-1.0.0.dist-info/licenses/LICENSE +48 -0
|
@@ -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))
|