risforge 0.3.2__tar.gz → 0.3.3__tar.gz
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.
- {risforge-0.3.2/src/risforge.egg-info → risforge-0.3.3}/PKG-INFO +5 -5
- {risforge-0.3.2 → risforge-0.3.3}/pyproject.toml +12 -5
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge/__init__.py +7 -7
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge/cleaning.py +2 -8
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge/cli.py +2 -6
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge/enrichment.py +10 -11
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge/merging.py +1 -3
- {risforge-0.3.2 → risforge-0.3.3/src/risforge.egg-info}/PKG-INFO +5 -5
- risforge-0.3.3/src/risforge_gui/app.py +48 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/file_counter.py +11 -2
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/main_window.py +22 -10
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/models.py +20 -5
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/theme.py +43 -41
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/config_panel.py +5 -2
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/input_panel.py +14 -6
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/log_panel.py +8 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/output_panel.py +1 -1
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/progress_panel.py +18 -1
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/results_panel.py +9 -1
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/worker.py +20 -5
- {risforge-0.3.2 → risforge-0.3.3}/tests/test_cli.py +3 -9
- {risforge-0.3.2 → risforge-0.3.3}/tests/test_enrichment.py +1 -4
- {risforge-0.3.2 → risforge-0.3.3}/tests/test_merging.py +1 -5
- {risforge-0.3.2 → risforge-0.3.3}/tests/test_pipeline.py +2 -6
- risforge-0.3.2/src/risforge_gui/app.py +0 -34
- {risforge-0.3.2 → risforge-0.3.3}/LICENSE +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/README.md +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/setup.cfg +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge/exceptions.py +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge/pipeline.py +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/SOURCES.txt +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/dependency_links.txt +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/entry_points.txt +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/requires.txt +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/top_level.txt +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/__init__.py +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/dialogs.py +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/__init__.py +0 -0
- {risforge-0.3.2 → risforge-0.3.3}/tests/test_cleaning.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: risforge
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews.
|
|
5
5
|
Author-email: Amyr <amyrhexa@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/
|
|
8
|
-
Project-URL: Repository, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
10
|
-
Project-URL: Changelog, https://github.com/
|
|
7
|
+
Project-URL: Homepage, https://github.com/amyr/risforge
|
|
8
|
+
Project-URL: Repository, https://github.com/amyr/risforge
|
|
9
|
+
Project-URL: Issues, https://github.com/amyr/risforge/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/amyr/risforge/blob/main/CHANGELOG.md
|
|
11
11
|
Keywords: ris,bibliography,systematic-review,deduplication,crossref,openalex,citation-management,prisma
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Science/Research
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "risforge"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -59,10 +59,10 @@ gui-dev = [
|
|
|
59
59
|
]
|
|
60
60
|
|
|
61
61
|
[project.urls]
|
|
62
|
-
Homepage = "https://github.com/
|
|
63
|
-
Repository = "https://github.com/
|
|
64
|
-
Issues = "https://github.com/
|
|
65
|
-
Changelog = "https://github.com/
|
|
62
|
+
Homepage = "https://github.com/amyr/risforge"
|
|
63
|
+
Repository = "https://github.com/amyr/risforge"
|
|
64
|
+
Issues = "https://github.com/amyr/risforge/issues"
|
|
65
|
+
Changelog = "https://github.com/amyr/risforge/blob/main/CHANGELOG.md"
|
|
66
66
|
|
|
67
67
|
[project.scripts]
|
|
68
68
|
risforge = "risforge.cli:main"
|
|
@@ -81,3 +81,10 @@ testpaths = ["tests"]
|
|
|
81
81
|
|
|
82
82
|
[tool.coverage.run]
|
|
83
83
|
concurrency = ["thread"]
|
|
84
|
+
|
|
85
|
+
[tool.black]
|
|
86
|
+
line-length = 100
|
|
87
|
+
target-version = ["py310", "py311", "py312"]
|
|
88
|
+
|
|
89
|
+
[tool.ruff]
|
|
90
|
+
line-length = 100
|
|
@@ -51,17 +51,17 @@ from risforge.exceptions import RisForgeError, RisParsingError
|
|
|
51
51
|
from risforge.merging import MergeResult, merge_ris_files
|
|
52
52
|
from risforge.pipeline import PipelineResult, risforge, run_pipeline
|
|
53
53
|
|
|
54
|
-
__version__ = "0.3.
|
|
54
|
+
__version__ = "0.3.3"
|
|
55
55
|
|
|
56
56
|
__all__ = [
|
|
57
|
-
"clean_ris_file",
|
|
58
|
-
"process_ris_file",
|
|
59
|
-
"RisEnricher",
|
|
60
|
-
"merge_ris_files",
|
|
61
57
|
"MergeResult",
|
|
62
|
-
"risforge",
|
|
63
|
-
"run_pipeline",
|
|
64
58
|
"PipelineResult",
|
|
59
|
+
"RisEnricher",
|
|
65
60
|
"RisForgeError",
|
|
66
61
|
"RisParsingError",
|
|
62
|
+
"clean_ris_file",
|
|
63
|
+
"merge_ris_files",
|
|
64
|
+
"process_ris_file",
|
|
65
|
+
"risforge",
|
|
66
|
+
"run_pipeline",
|
|
67
67
|
]
|
|
@@ -147,11 +147,7 @@ def merge_cluster(cluster: list[RisRecord]) -> RisRecord:
|
|
|
147
147
|
for item in value:
|
|
148
148
|
if item not in merged[key]:
|
|
149
149
|
merged[key].append(item)
|
|
150
|
-
elif (
|
|
151
|
-
isinstance(merged[key], str)
|
|
152
|
-
and not merged[key]
|
|
153
|
-
and isinstance(value, str)
|
|
154
|
-
):
|
|
150
|
+
elif isinstance(merged[key], str) and not merged[key] and isinstance(value, str):
|
|
155
151
|
merged[key] = value
|
|
156
152
|
|
|
157
153
|
return merged
|
|
@@ -346,9 +342,7 @@ def clean_ris_file(
|
|
|
346
342
|
for index in range(total_records):
|
|
347
343
|
clusters[union_find.find(index)].append(index)
|
|
348
344
|
|
|
349
|
-
final_records = [
|
|
350
|
-
merge_cluster([records[i] for i in indices]) for indices in clusters.values()
|
|
351
|
-
]
|
|
345
|
+
final_records = [merge_cluster([records[i] for i in indices]) for indices in clusters.values()]
|
|
352
346
|
|
|
353
347
|
total_duplicates_removed = doi_duplicates_removed + title_author_duplicates_removed
|
|
354
348
|
|
|
@@ -48,9 +48,7 @@ def _configure_logging(verbose: bool) -> None:
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
51
|
-
parser.add_argument(
|
|
52
|
-
"-v", "--verbose", action="store_true", help="Enable debug-level logging."
|
|
53
|
-
)
|
|
51
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug-level logging.")
|
|
54
52
|
|
|
55
53
|
|
|
56
54
|
def _cmd_merge(args: argparse.Namespace) -> int:
|
|
@@ -188,9 +186,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
188
186
|
_add_common_args(merge_parser)
|
|
189
187
|
merge_parser.set_defaults(func=_cmd_merge)
|
|
190
188
|
|
|
191
|
-
clean_parser = subparsers.add_parser(
|
|
192
|
-
"clean", help="Deduplicate and normalize a RIS file."
|
|
193
|
-
)
|
|
189
|
+
clean_parser = subparsers.add_parser("clean", help="Deduplicate and normalize a RIS file.")
|
|
194
190
|
clean_parser.add_argument("input", help="Input RIS file path.")
|
|
195
191
|
clean_parser.add_argument("output", help="Output cleaned RIS file path.")
|
|
196
192
|
clean_parser.add_argument(
|
|
@@ -74,6 +74,7 @@ from risforge.cleaning import parse_ris_records
|
|
|
74
74
|
|
|
75
75
|
TITLE_MATCH_THRESHOLD = 0.90
|
|
76
76
|
CACHE_EXPIRE_DAYS = 7
|
|
77
|
+
REQUEST_TIMEOUT_SECONDS = 10 # Applied to every provider HTTP call; retries/backoff are separate.
|
|
77
78
|
|
|
78
79
|
logger = logging.getLogger(__name__)
|
|
79
80
|
|
|
@@ -155,9 +156,7 @@ class RisEnricher:
|
|
|
155
156
|
}
|
|
156
157
|
self.failed_records: list[dict[str, Any]] = []
|
|
157
158
|
self.session = session or _build_default_session(str(cache_name))
|
|
158
|
-
self.session.headers.update(
|
|
159
|
-
{"User-Agent": f"risforge/1.0 (mailto:{self.email})"}
|
|
160
|
-
)
|
|
159
|
+
self.session.headers.update({"User-Agent": f"risforge/1.0 (mailto:{self.email})"})
|
|
161
160
|
|
|
162
161
|
# --- Core identification -------------------------------------------------
|
|
163
162
|
|
|
@@ -191,7 +190,7 @@ class RisEnricher:
|
|
|
191
190
|
f"&select=DOI,title&rows=3&mailto={self.email}"
|
|
192
191
|
)
|
|
193
192
|
try:
|
|
194
|
-
response = self.session.get(url, timeout=
|
|
193
|
+
response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
195
194
|
self.stats["api_calls"]["crossref"] += 1
|
|
196
195
|
|
|
197
196
|
if response.status_code == 200:
|
|
@@ -216,7 +215,7 @@ class RisEnricher:
|
|
|
216
215
|
"""Fetch authoritative bibliographic metadata from Crossref."""
|
|
217
216
|
url = f"https://api.crossref.org/works/{quote(doi)}?mailto={self.email}"
|
|
218
217
|
try:
|
|
219
|
-
response = self.session.get(url, timeout=
|
|
218
|
+
response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
220
219
|
self.stats["api_calls"]["crossref"] += 1
|
|
221
220
|
|
|
222
221
|
if response.status_code == 200:
|
|
@@ -273,15 +272,15 @@ class RisEnricher:
|
|
|
273
272
|
"""Fetch open-access status and subject concepts from OpenAlex."""
|
|
274
273
|
url = f"https://api.openalex.org/works/doi:{quote(doi)}?mailto={self.email}"
|
|
275
274
|
try:
|
|
276
|
-
response = self.session.get(url, timeout=
|
|
275
|
+
response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
277
276
|
self.stats["api_calls"]["openalex"] += 1
|
|
278
277
|
|
|
279
278
|
if response.status_code == 200:
|
|
280
279
|
payload = response.json()
|
|
281
280
|
concepts = [
|
|
282
|
-
|
|
283
|
-
for
|
|
284
|
-
if
|
|
281
|
+
concept.get("display_name")
|
|
282
|
+
for concept in payload.get("concepts", [])
|
|
283
|
+
if concept.get("level", 99) <= 1 and concept.get("display_name")
|
|
285
284
|
]
|
|
286
285
|
oa_url = payload.get("open_access", {}).get("oa_url")
|
|
287
286
|
return {
|
|
@@ -302,7 +301,7 @@ class RisEnricher:
|
|
|
302
301
|
f"?fields=abstract,referenceCount,citationCount"
|
|
303
302
|
)
|
|
304
303
|
try:
|
|
305
|
-
response = self.session.get(url, timeout=
|
|
304
|
+
response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
306
305
|
self.stats["api_calls"]["semanticscholar"] += 1
|
|
307
306
|
|
|
308
307
|
if response.status_code == 200:
|
|
@@ -318,7 +317,7 @@ class RisEnricher:
|
|
|
318
317
|
"""Fetch the best open-access PDF location from Unpaywall."""
|
|
319
318
|
url = f"https://api.unpaywall.org/v2/{quote(doi)}?email={self.email}"
|
|
320
319
|
try:
|
|
321
|
-
response = self.session.get(url, timeout=
|
|
320
|
+
response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
322
321
|
self.stats["api_calls"]["unpaywall"] += 1
|
|
323
322
|
|
|
324
323
|
if response.status_code == 200:
|
|
@@ -106,9 +106,7 @@ def merge_ris_files(
|
|
|
106
106
|
len(errors),
|
|
107
107
|
path,
|
|
108
108
|
)
|
|
109
|
-
all_errors.extend(
|
|
110
|
-
(path, block_number, message) for block_number, message in errors
|
|
111
|
-
)
|
|
109
|
+
all_errors.extend((path, block_number, message) for block_number, message in errors)
|
|
112
110
|
all_records.extend(records)
|
|
113
111
|
logger.info("%s: %d record(s) parsed.", path, len(records))
|
|
114
112
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: risforge
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews.
|
|
5
5
|
Author-email: Amyr <amyrhexa@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/
|
|
8
|
-
Project-URL: Repository, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
10
|
-
Project-URL: Changelog, https://github.com/
|
|
7
|
+
Project-URL: Homepage, https://github.com/amyr/risforge
|
|
8
|
+
Project-URL: Repository, https://github.com/amyr/risforge
|
|
9
|
+
Project-URL: Issues, https://github.com/amyr/risforge/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/amyr/risforge/blob/main/CHANGELOG.md
|
|
11
11
|
Keywords: ris,bibliography,systematic-review,deduplication,crossref,openalex,citation-management,prisma
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Science/Research
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Entry point for the ``risforge-gui`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
"""Launch the risforge desktop application.
|
|
13
|
+
|
|
14
|
+
Registered as the ``risforge-gui`` console entry point. The GUI
|
|
15
|
+
extra (PySide6) is optional at the package level, so this is the
|
|
16
|
+
one place that has to tolerate it being absent -- everything past
|
|
17
|
+
the import guard below assumes it's installed.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
from PySide6.QtWidgets import QApplication
|
|
21
|
+
except ImportError:
|
|
22
|
+
# No logging handler is configured yet at this point (that's
|
|
23
|
+
# normally the CLI's or the GUI's own job, and we haven't
|
|
24
|
+
# reached either), but an unconfigured logger's "last resort"
|
|
25
|
+
# handler still writes ERROR+ to stderr, so this is exactly as
|
|
26
|
+
# visible to the user as a plain print() would have been.
|
|
27
|
+
logger.error(
|
|
28
|
+
"risforge-gui requires the GUI extras, which aren't installed.\n\n"
|
|
29
|
+
' pip install "risforge[gui]"\n'
|
|
30
|
+
)
|
|
31
|
+
raise SystemExit(1) from None
|
|
32
|
+
|
|
33
|
+
from risforge_gui.main_window import MainWindow
|
|
34
|
+
from risforge_gui.theme import Theme, apply_theme
|
|
35
|
+
|
|
36
|
+
app = QApplication(sys.argv)
|
|
37
|
+
app.setApplicationName("risforge")
|
|
38
|
+
apply_theme(app, Theme.SYSTEM)
|
|
39
|
+
|
|
40
|
+
window = MainWindow()
|
|
41
|
+
window.resize(1100, 780)
|
|
42
|
+
window.show()
|
|
43
|
+
|
|
44
|
+
sys.exit(app.exec())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
@@ -30,12 +30,21 @@ class RecordCounter(QRunnable):
|
|
|
30
30
|
self._path = path
|
|
31
31
|
self.signals = _CounterSignals()
|
|
32
32
|
|
|
33
|
-
def run(self) -> None:
|
|
33
|
+
def run(self) -> None:
|
|
34
|
+
"""Parse the file and report success/failure via signals.
|
|
35
|
+
|
|
36
|
+
Catches ValueError alongside OSError specifically because
|
|
37
|
+
``parse_ris_records`` raises ``RisParsingError`` (a ValueError
|
|
38
|
+
subclass) for files that can't be decoded as text -- without
|
|
39
|
+
this, such a file would raise out of a QThreadPool worker
|
|
40
|
+
uncaught, and its row would be stuck at "Counting..." forever
|
|
41
|
+
with no visible error at all.
|
|
42
|
+
"""
|
|
34
43
|
try:
|
|
35
44
|
records, errors = parse_ris_records(self._path)
|
|
36
45
|
except FileNotFoundError:
|
|
37
46
|
self.signals.failed.emit(self._path, "File not found.")
|
|
38
|
-
except OSError as error:
|
|
47
|
+
except (OSError, ValueError) as error:
|
|
39
48
|
self.signals.failed.emit(self._path, f"Could not read file: {error}")
|
|
40
49
|
else:
|
|
41
50
|
self.signals.finished.emit(self._path, len(records), len(errors))
|
|
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
from PySide6.QtCore import Qt
|
|
15
14
|
from PySide6.QtWidgets import (
|
|
16
15
|
QComboBox,
|
|
17
16
|
QGroupBox,
|
|
@@ -201,9 +200,16 @@ class MainWindow(QMainWindow):
|
|
|
201
200
|
if input_count > 1
|
|
202
201
|
else "Deduplicates and enriches your input file."
|
|
203
202
|
),
|
|
204
|
-
OperationMode.MERGE:
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
OperationMode.MERGE: (
|
|
204
|
+
"Combines every input file's records into one file. Does not deduplicate or enrich."
|
|
205
|
+
),
|
|
206
|
+
OperationMode.CLEAN: (
|
|
207
|
+
"Deduplicates a single RIS file. Requires exactly one input file."
|
|
208
|
+
),
|
|
209
|
+
OperationMode.ENRICH: (
|
|
210
|
+
"Fills in missing metadata for a single, already-clean RIS file. "
|
|
211
|
+
"Requires exactly one input file."
|
|
212
|
+
),
|
|
207
213
|
}
|
|
208
214
|
self.mode_hint_label.setText(hints[mode])
|
|
209
215
|
|
|
@@ -238,15 +244,17 @@ class MainWindow(QMainWindow):
|
|
|
238
244
|
input_paths = self.input_panel.model.paths()
|
|
239
245
|
|
|
240
246
|
if not input_paths:
|
|
241
|
-
QMessageBox.warning(
|
|
247
|
+
QMessageBox.warning(
|
|
248
|
+
self, "No input files", "Add at least one .ris file before starting."
|
|
249
|
+
)
|
|
242
250
|
return None
|
|
243
251
|
|
|
244
252
|
if mode is OperationMode.CLEAN and len(input_paths) != 1:
|
|
245
253
|
QMessageBox.warning(
|
|
246
254
|
self,
|
|
247
255
|
"Too many input files",
|
|
248
|
-
"
|
|
249
|
-
|
|
256
|
+
'"Clean only" works on exactly one file. Either remove the extra files, '
|
|
257
|
+
'or use "Merge only" first to combine them into one.',
|
|
250
258
|
)
|
|
251
259
|
return None
|
|
252
260
|
|
|
@@ -254,7 +262,7 @@ class MainWindow(QMainWindow):
|
|
|
254
262
|
QMessageBox.warning(
|
|
255
263
|
self,
|
|
256
264
|
"Too many input files",
|
|
257
|
-
"
|
|
265
|
+
'"Enrich only" works on exactly one file. Remove the extra files first.',
|
|
258
266
|
)
|
|
259
267
|
return None
|
|
260
268
|
|
|
@@ -280,11 +288,15 @@ class MainWindow(QMainWindow):
|
|
|
280
288
|
)
|
|
281
289
|
return None
|
|
282
290
|
|
|
283
|
-
result_name =
|
|
291
|
+
result_name = (
|
|
292
|
+
self.output_panel.final_name_edit.text().strip() or _DEFAULT_NAME_BY_MODE[mode]
|
|
293
|
+
)
|
|
284
294
|
result_path = output_dir / result_name
|
|
285
295
|
merge_path = output_dir / "merged.ris"
|
|
286
296
|
dedup_path = output_dir / "clean.ris"
|
|
287
|
-
fail_report_path = _resolve_relative_to(
|
|
297
|
+
fail_report_path = _resolve_relative_to(
|
|
298
|
+
self.enrichment_panel.fail_report_name(), output_dir
|
|
299
|
+
)
|
|
288
300
|
|
|
289
301
|
outputs_to_check = {result_path}
|
|
290
302
|
if mode is OperationMode.PIPELINE:
|
|
@@ -16,6 +16,8 @@ from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
|
|
|
16
16
|
|
|
17
17
|
@dataclass
|
|
18
18
|
class InputFileEntry:
|
|
19
|
+
"""One row's state: a file path plus its (possibly still-pending) record count."""
|
|
20
|
+
|
|
19
21
|
path: Path
|
|
20
22
|
record_count: int | None = None # None while counting is in progress.
|
|
21
23
|
status: str = "Counting..."
|
|
@@ -33,18 +35,30 @@ class InputFilesModel(QAbstractTableModel):
|
|
|
33
35
|
|
|
34
36
|
# --- Qt model interface -----------------------------------------------
|
|
35
37
|
|
|
36
|
-
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa:
|
|
38
|
+
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: B008
|
|
39
|
+
# QModelIndex() as a default is the standard Qt Model/View idiom
|
|
40
|
+
# (used verbatim in Qt's own documentation): it's an immutable
|
|
41
|
+
# "invalid index" value, not mutable state that could leak
|
|
42
|
+
# between calls, so constructing it once at import time is safe.
|
|
37
43
|
return 0 if parent.isValid() else len(self._entries)
|
|
38
44
|
|
|
39
|
-
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa:
|
|
45
|
+
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: B008
|
|
40
46
|
return 0 if parent.isValid() else len(self.COLUMNS)
|
|
41
47
|
|
|
42
|
-
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
|
48
|
+
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
|
43
49
|
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
|
44
50
|
return self.COLUMNS[section]
|
|
45
51
|
return None
|
|
46
52
|
|
|
47
53
|
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
|
54
|
+
"""Return display/tooltip/alignment data for one cell.
|
|
55
|
+
|
|
56
|
+
Branches on ``role`` because Qt's view asks the model the same
|
|
57
|
+
question ("what goes in this cell?") several times with
|
|
58
|
+
different intents -- text to show, tooltip to show, how to
|
|
59
|
+
align it -- rather than the model exposing separate methods
|
|
60
|
+
per concern.
|
|
61
|
+
"""
|
|
48
62
|
if not index.isValid() or not (0 <= index.row() < len(self._entries)):
|
|
49
63
|
return None
|
|
50
64
|
entry = self._entries[index.row()]
|
|
@@ -66,7 +80,8 @@ class InputFilesModel(QAbstractTableModel):
|
|
|
66
80
|
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
67
81
|
return None
|
|
68
82
|
|
|
69
|
-
def flags(self, index: QModelIndex):
|
|
83
|
+
def flags(self, index: QModelIndex):
|
|
84
|
+
"""Selectable but never editable -- this table is for review, not inline editing."""
|
|
70
85
|
if not index.isValid():
|
|
71
86
|
return Qt.ItemFlag.NoItemFlags
|
|
72
87
|
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
|
@@ -129,7 +144,7 @@ class InputFilesModel(QAbstractTableModel):
|
|
|
129
144
|
return
|
|
130
145
|
|
|
131
146
|
def total_known_records(self) -> int | None:
|
|
132
|
-
"""Sum of
|
|
147
|
+
"""Sum of counted records, or None while any entry is still pending/errored."""
|
|
133
148
|
if any(entry.record_count is None for entry in self._entries):
|
|
134
149
|
return None
|
|
135
150
|
return sum(entry.record_count or 0 for entry in self._entries)
|
|
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import QApplication
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class Theme(str, Enum):
|
|
22
|
+
"""Selectable appearance modes; SYSTEM defers to the OS light/dark preference."""
|
|
23
|
+
|
|
22
24
|
LIGHT = "light"
|
|
23
25
|
DARK = "dark"
|
|
24
26
|
SYSTEM = "system"
|
|
@@ -61,7 +63,7 @@ DARK_PALETTE: dict[str, str] = {
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
|
|
64
|
-
def _build_stylesheet(
|
|
66
|
+
def _build_stylesheet(palette: dict[str, str]) -> str:
|
|
65
67
|
"""Render a palette dict into a Qt stylesheet.
|
|
66
68
|
|
|
67
69
|
Kept as one template so the two palettes can never drift into
|
|
@@ -69,18 +71,18 @@ def _build_stylesheet(p: dict[str, str]) -> str:
|
|
|
69
71
|
"""
|
|
70
72
|
return f"""
|
|
71
73
|
QWidget {{
|
|
72
|
-
background-color: {
|
|
73
|
-
color: {
|
|
74
|
+
background-color: {palette["bg"]};
|
|
75
|
+
color: {palette["text"]};
|
|
74
76
|
font-size: 13px;
|
|
75
77
|
}}
|
|
76
78
|
|
|
77
79
|
QMainWindow, #SetupPage, #ProgressPage, #ResultsPage {{
|
|
78
|
-
background-color: {
|
|
80
|
+
background-color: {palette["bg"]};
|
|
79
81
|
}}
|
|
80
82
|
|
|
81
83
|
QGroupBox {{
|
|
82
|
-
background-color: {
|
|
83
|
-
border: 1px solid {
|
|
84
|
+
background-color: {palette["surface"]};
|
|
85
|
+
border: 1px solid {palette["border"]};
|
|
84
86
|
border-radius: 6px;
|
|
85
87
|
margin-top: 14px;
|
|
86
88
|
padding: 12px;
|
|
@@ -90,7 +92,7 @@ def _build_stylesheet(p: dict[str, str]) -> str:
|
|
|
90
92
|
subcontrol-origin: margin;
|
|
91
93
|
left: 10px;
|
|
92
94
|
padding: 0 4px;
|
|
93
|
-
color: {
|
|
95
|
+
color: {palette["text"]};
|
|
94
96
|
}}
|
|
95
97
|
|
|
96
98
|
QLabel#HeaderTitle {{
|
|
@@ -98,10 +100,10 @@ def _build_stylesheet(p: dict[str, str]) -> str:
|
|
|
98
100
|
font-weight: 700;
|
|
99
101
|
}}
|
|
100
102
|
QLabel#HeaderSubtitle, QLabel#MutedLabel, QLabel#HelperText {{
|
|
101
|
-
color: {
|
|
103
|
+
color: {palette["text_muted"]};
|
|
102
104
|
}}
|
|
103
105
|
QLabel#VersionLabel {{
|
|
104
|
-
color: {
|
|
106
|
+
color: {palette["text_muted"]};
|
|
105
107
|
font-size: 11px;
|
|
106
108
|
}}
|
|
107
109
|
QLabel#SectionHeading {{
|
|
@@ -110,82 +112,82 @@ def _build_stylesheet(p: dict[str, str]) -> str:
|
|
|
110
112
|
}}
|
|
111
113
|
|
|
112
114
|
QPushButton {{
|
|
113
|
-
background-color: {
|
|
114
|
-
border: 1px solid {
|
|
115
|
+
background-color: {palette["surface"]};
|
|
116
|
+
border: 1px solid {palette["border"]};
|
|
115
117
|
border-radius: 5px;
|
|
116
118
|
padding: 6px 14px;
|
|
117
119
|
}}
|
|
118
120
|
QPushButton:hover {{
|
|
119
|
-
border-color: {
|
|
121
|
+
border-color: {palette["accent"]};
|
|
120
122
|
}}
|
|
121
123
|
QPushButton:pressed {{
|
|
122
|
-
background-color: {
|
|
124
|
+
background-color: {palette["surface_alt"]};
|
|
123
125
|
}}
|
|
124
126
|
QPushButton:disabled {{
|
|
125
|
-
color: {
|
|
127
|
+
color: {palette["text_muted"]};
|
|
126
128
|
}}
|
|
127
129
|
QPushButton#PrimaryButton {{
|
|
128
|
-
background-color: {
|
|
129
|
-
color: {
|
|
130
|
+
background-color: {palette["accent"]};
|
|
131
|
+
color: {palette["accent_text"]};
|
|
130
132
|
border: none;
|
|
131
133
|
font-weight: 600;
|
|
132
134
|
padding: 8px 20px;
|
|
133
135
|
}}
|
|
134
136
|
QPushButton#PrimaryButton:hover {{
|
|
135
|
-
background-color: {
|
|
137
|
+
background-color: {palette["accent_hover"]};
|
|
136
138
|
}}
|
|
137
139
|
QPushButton#PrimaryButton:disabled {{
|
|
138
|
-
background-color: {
|
|
139
|
-
color: {
|
|
140
|
+
background-color: {palette["border"]};
|
|
141
|
+
color: {palette["text_muted"]};
|
|
140
142
|
}}
|
|
141
143
|
|
|
142
144
|
QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{
|
|
143
|
-
background-color: {
|
|
144
|
-
border: 1px solid {
|
|
145
|
+
background-color: {palette["surface"]};
|
|
146
|
+
border: 1px solid {palette["border"]};
|
|
145
147
|
border-radius: 4px;
|
|
146
148
|
padding: 4px 6px;
|
|
147
|
-
selection-background-color: {
|
|
149
|
+
selection-background-color: {palette["accent"]};
|
|
148
150
|
}}
|
|
149
151
|
QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus {{
|
|
150
|
-
border: 1px solid {
|
|
152
|
+
border: 1px solid {palette["focus"]};
|
|
151
153
|
}}
|
|
152
154
|
QLineEdit:read-only {{
|
|
153
|
-
color: {
|
|
155
|
+
color: {palette["text_muted"]};
|
|
154
156
|
}}
|
|
155
157
|
|
|
156
158
|
QTableView {{
|
|
157
|
-
background-color: {
|
|
158
|
-
alternate-background-color: {
|
|
159
|
-
gridline-color: {
|
|
160
|
-
border: 1px solid {
|
|
159
|
+
background-color: {palette["surface"]};
|
|
160
|
+
alternate-background-color: {palette["surface_alt"]};
|
|
161
|
+
gridline-color: {palette["border"]};
|
|
162
|
+
border: 1px solid {palette["border"]};
|
|
161
163
|
border-radius: 4px;
|
|
162
|
-
selection-background-color: {
|
|
163
|
-
selection-color: {
|
|
164
|
+
selection-background-color: {palette["accent"]};
|
|
165
|
+
selection-color: {palette["accent_text"]};
|
|
164
166
|
}}
|
|
165
167
|
QHeaderView::section {{
|
|
166
|
-
background-color: {
|
|
167
|
-
color: {
|
|
168
|
+
background-color: {palette["surface_alt"]};
|
|
169
|
+
color: {palette["text"]};
|
|
168
170
|
border: none;
|
|
169
|
-
border-bottom: 1px solid {
|
|
171
|
+
border-bottom: 1px solid {palette["border"]};
|
|
170
172
|
padding: 4px 6px;
|
|
171
173
|
font-weight: 600;
|
|
172
174
|
}}
|
|
173
175
|
|
|
174
176
|
QProgressBar {{
|
|
175
|
-
background-color: {
|
|
176
|
-
border: 1px solid {
|
|
177
|
+
background-color: {palette["surface_alt"]};
|
|
178
|
+
border: 1px solid {palette["border"]};
|
|
177
179
|
border-radius: 4px;
|
|
178
180
|
text-align: center;
|
|
179
181
|
height: 18px;
|
|
180
182
|
}}
|
|
181
183
|
QProgressBar::chunk {{
|
|
182
|
-
background-color: {
|
|
184
|
+
background-color: {palette["accent"]};
|
|
183
185
|
border-radius: 3px;
|
|
184
186
|
}}
|
|
185
187
|
|
|
186
188
|
QPlainTextEdit#LogPanel {{
|
|
187
|
-
background-color: {
|
|
188
|
-
border: 1px solid {
|
|
189
|
+
background-color: {palette["surface"]};
|
|
190
|
+
border: 1px solid {palette["border"]};
|
|
189
191
|
border-radius: 4px;
|
|
190
192
|
font-family: "Menlo", "Consolas", monospace;
|
|
191
193
|
font-size: 12px;
|
|
@@ -193,10 +195,10 @@ def _build_stylesheet(p: dict[str, str]) -> str:
|
|
|
193
195
|
|
|
194
196
|
QToolButton {{
|
|
195
197
|
border: none;
|
|
196
|
-
color: {
|
|
198
|
+
color: {palette["text_muted"]};
|
|
197
199
|
}}
|
|
198
200
|
QToolButton:hover {{
|
|
199
|
-
color: {
|
|
201
|
+
color: {palette["text"]};
|
|
200
202
|
}}
|
|
201
203
|
|
|
202
204
|
QScrollArea {{
|
|
@@ -208,7 +210,7 @@ def _build_stylesheet(p: dict[str, str]) -> str:
|
|
|
208
210
|
outline: none;
|
|
209
211
|
}}
|
|
210
212
|
QPushButton:focus, QComboBox:focus, QCheckBox:focus {{
|
|
211
|
-
border: 2px solid {
|
|
213
|
+
border: 2px solid {palette["focus"]};
|
|
212
214
|
}}
|
|
213
215
|
"""
|
|
214
216
|
|
|
@@ -29,7 +29,7 @@ def default_cache_path() -> Path:
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class EnrichmentConfigPanel(QGroupBox):
|
|
32
|
-
""""Enrichment settings" group box."""
|
|
32
|
+
""" "Enrichment settings" group box."""
|
|
33
33
|
|
|
34
34
|
changed = Signal()
|
|
35
35
|
|
|
@@ -119,7 +119,10 @@ class EnrichmentConfigPanel(QGroupBox):
|
|
|
119
119
|
|
|
120
120
|
def _browse_fail_report(self) -> None:
|
|
121
121
|
path, _filter = QFileDialog.getSaveFileName(
|
|
122
|
-
self,
|
|
122
|
+
self,
|
|
123
|
+
"Choose failure report location",
|
|
124
|
+
self.fail_report_edit.text(),
|
|
125
|
+
"JSON files (*.json)",
|
|
123
126
|
)
|
|
124
127
|
if path:
|
|
125
128
|
self.fail_report_edit.setText(path)
|
|
@@ -25,7 +25,7 @@ from risforge_gui.models import InputFilesModel
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class InputPanel(QGroupBox):
|
|
28
|
-
""""Input files" group box: table + add/remove controls + drag-and-drop."""
|
|
28
|
+
""" "Input files" group box: table + add/remove controls + drag-and-drop."""
|
|
29
29
|
|
|
30
30
|
files_changed = Signal()
|
|
31
31
|
|
|
@@ -57,8 +57,12 @@ class InputPanel(QGroupBox):
|
|
|
57
57
|
self.table.setAlternatingRowColors(True)
|
|
58
58
|
self.table.horizontalHeader().setStretchLastSection(False)
|
|
59
59
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
|
60
|
-
self.table.horizontalHeader().setSectionResizeMode(
|
|
61
|
-
|
|
60
|
+
self.table.horizontalHeader().setSectionResizeMode(
|
|
61
|
+
1, QHeaderView.ResizeMode.ResizeToContents
|
|
62
|
+
)
|
|
63
|
+
self.table.horizontalHeader().setSectionResizeMode(
|
|
64
|
+
2, QHeaderView.ResizeMode.ResizeToContents
|
|
65
|
+
)
|
|
62
66
|
self.table.verticalHeader().setVisible(False)
|
|
63
67
|
self.table.setMinimumHeight(140)
|
|
64
68
|
self.table.setAccessibleName("Input files table")
|
|
@@ -103,7 +107,9 @@ class InputPanel(QGroupBox):
|
|
|
103
107
|
folder = QFileDialog.getExistingDirectory(self, "Add folder of RIS files", str(Path.home()))
|
|
104
108
|
if not folder:
|
|
105
109
|
return
|
|
106
|
-
found = sorted(
|
|
110
|
+
found = sorted(
|
|
111
|
+
p for p in Path(folder).iterdir() if p.is_file() and p.suffix.lower() == ".ris"
|
|
112
|
+
)
|
|
107
113
|
if not found:
|
|
108
114
|
QMessageBox.information(
|
|
109
115
|
self, "No RIS files found", f"No .ris files were found directly inside:\n{folder}"
|
|
@@ -150,11 +156,13 @@ class InputPanel(QGroupBox):
|
|
|
150
156
|
|
|
151
157
|
# --- Drag and drop -------------------------------------------------------------
|
|
152
158
|
|
|
153
|
-
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
|
159
|
+
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
|
160
|
+
"""Accept any drag containing file URLs; actual .ris filtering happens on drop."""
|
|
154
161
|
if event.mimeData().hasUrls():
|
|
155
162
|
event.acceptProposedAction()
|
|
156
163
|
|
|
157
|
-
def dropEvent(self, event: QDropEvent) -> None:
|
|
164
|
+
def dropEvent(self, event: QDropEvent) -> None:
|
|
165
|
+
"""Route dropped files through add_dropped_paths() for consistent .ris filtering."""
|
|
158
166
|
paths = [Path(url.toLocalFile()) for url in event.mimeData().urls() if url.isLocalFile()]
|
|
159
167
|
if paths:
|
|
160
168
|
self.add_dropped_paths(paths)
|
|
@@ -27,6 +27,14 @@ class LogPanel(QPlainTextEdit):
|
|
|
27
27
|
self.setAccessibleName("Activity log")
|
|
28
28
|
|
|
29
29
|
def append_log(self, level: str, message: str) -> None:
|
|
30
|
+
"""Append one timestamped, level-colored line.
|
|
31
|
+
|
|
32
|
+
Renders as HTML (not plain text) so WARNING/ERROR lines can be
|
|
33
|
+
colored -- ``message`` is escaped first since it may contain
|
|
34
|
+
arbitrary file paths or error text from risforge itself.
|
|
35
|
+
``setMaximumBlockCount`` above caps memory use on a very long
|
|
36
|
+
or noisy run by silently dropping the oldest lines.
|
|
37
|
+
"""
|
|
30
38
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
31
39
|
safe_message = html.escape(message)
|
|
32
40
|
color = _LEVEL_COLORS.get(level.upper())
|
|
@@ -35,7 +35,7 @@ def default_output_dir() -> Path:
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
class OutputConfigPanel(QGroupBox):
|
|
38
|
-
""""Output" group box: destination directory, final filename, optional intermediates."""
|
|
38
|
+
""" "Output" group box: destination directory, final filename, optional intermediates."""
|
|
39
39
|
|
|
40
40
|
changed = Signal()
|
|
41
41
|
|
|
@@ -81,11 +81,19 @@ class ProgressPanel(QWidget):
|
|
|
81
81
|
|
|
82
82
|
def _toggle_log(self, checked: bool) -> None:
|
|
83
83
|
self.log_panel.setVisible(checked)
|
|
84
|
-
self.log_toggle.setText(
|
|
84
|
+
self.log_toggle.setText(
|
|
85
|
+
"Hide activity log \u25b4" if checked else "Show activity log \u25be"
|
|
86
|
+
)
|
|
85
87
|
|
|
86
88
|
# --- Public update API, called from MainWindow's signal-connected slots ---
|
|
87
89
|
|
|
88
90
|
def reset(self, stages_in_use: list[str]) -> None:
|
|
91
|
+
"""Prepare the panel for a new run.
|
|
92
|
+
|
|
93
|
+
Only the stages actually applicable to this run's mode are
|
|
94
|
+
made visible (e.g. no "Merge" bar for a single-file pipeline)
|
|
95
|
+
so the layout doesn't imply a step that isn't going to happen.
|
|
96
|
+
"""
|
|
89
97
|
for stage_key, bar in self._bars.items():
|
|
90
98
|
in_use = stage_key in stages_in_use
|
|
91
99
|
bar.setVisible(in_use)
|
|
@@ -98,6 +106,7 @@ class ProgressPanel(QWidget):
|
|
|
98
106
|
self.current_activity_label.setText("")
|
|
99
107
|
|
|
100
108
|
def set_stage_status(self, stage: str, status: str) -> None:
|
|
109
|
+
"""Reflect a merge/clean/enrich stage's started/completed transition."""
|
|
101
110
|
bar = self._bars.get(stage)
|
|
102
111
|
if bar is None:
|
|
103
112
|
return
|
|
@@ -115,6 +124,7 @@ class ProgressPanel(QWidget):
|
|
|
115
124
|
self.current_activity_label.setText(f"{title} complete.")
|
|
116
125
|
|
|
117
126
|
def set_enrichment_progress(self, processed: int, total: int) -> None:
|
|
127
|
+
"""Show real, determinate progress -- unlike merge/clean, enrichment reports per-record."""
|
|
118
128
|
bar = self._bars.get("enrich")
|
|
119
129
|
if bar is None or total <= 0:
|
|
120
130
|
return
|
|
@@ -124,6 +134,13 @@ class ProgressPanel(QWidget):
|
|
|
124
134
|
self.current_activity_label.setText(f"Enriching record {processed:,} of {total:,}...")
|
|
125
135
|
|
|
126
136
|
def update_stats(self, stats: dict) -> None:
|
|
137
|
+
"""Update whichever stat labels this run's stats dict has values for.
|
|
138
|
+
|
|
139
|
+
Silently ignores keys the panel doesn't display and ``None``
|
|
140
|
+
values (used for stats that don't apply to the current mode,
|
|
141
|
+
e.g. "merged records" for a single-file run) rather than
|
|
142
|
+
overwriting the placeholder dash with "None".
|
|
143
|
+
"""
|
|
127
144
|
for key, value in stats.items():
|
|
128
145
|
label = self._stat_value_labels.get(key)
|
|
129
146
|
if label is None or value is None:
|
|
@@ -94,6 +94,12 @@ class ResultsPanel(QWidget):
|
|
|
94
94
|
final_ris_path: Path | None,
|
|
95
95
|
fail_report_path: Path | None,
|
|
96
96
|
) -> None:
|
|
97
|
+
"""Populate the summary and wire up the action buttons to real, existing files.
|
|
98
|
+
|
|
99
|
+
A path is only offered to "Open ..." if it actually exists on
|
|
100
|
+
disk -- e.g. no failure-report button when nothing failed, so
|
|
101
|
+
the buttons can't lead to a broken/missing-file open attempt.
|
|
102
|
+
"""
|
|
97
103
|
for key, label in self._value_labels.items():
|
|
98
104
|
value = summary.get(key)
|
|
99
105
|
label.setText(f"{value:,}" if isinstance(value, int) else "\u2014")
|
|
@@ -107,7 +113,9 @@ class ResultsPanel(QWidget):
|
|
|
107
113
|
self._summary_form.setRowVisible(row, bool(skipped))
|
|
108
114
|
|
|
109
115
|
self._output_dir = output_dir
|
|
110
|
-
self._final_ris_path =
|
|
116
|
+
self._final_ris_path = (
|
|
117
|
+
final_ris_path if final_ris_path and final_ris_path.exists() else None
|
|
118
|
+
)
|
|
111
119
|
self._fail_report_path = (
|
|
112
120
|
fail_report_path if fail_report_path and fail_report_path.exists() else None
|
|
113
121
|
)
|
|
@@ -23,7 +23,6 @@ import logging
|
|
|
23
23
|
from dataclasses import dataclass, field
|
|
24
24
|
from enum import Enum
|
|
25
25
|
from pathlib import Path
|
|
26
|
-
from typing import Any
|
|
27
26
|
|
|
28
27
|
from PySide6.QtCore import QThread, Signal
|
|
29
28
|
|
|
@@ -69,9 +68,18 @@ class _LogBridge(logging.Handler):
|
|
|
69
68
|
self._log_signal = log_signal
|
|
70
69
|
|
|
71
70
|
def emit(self, record: logging.LogRecord) -> None:
|
|
72
|
-
level
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
"""Map a stdlib log level to the three-tier severity the GUI displays.
|
|
72
|
+
|
|
73
|
+
The log panel only distinguishes INFO/WARNING/ERROR (matching
|
|
74
|
+
the UI's three status colors); DEBUG-and-below is never
|
|
75
|
+
forwarded at all (the handler is installed at level INFO).
|
|
76
|
+
"""
|
|
77
|
+
if record.levelno >= logging.ERROR:
|
|
78
|
+
level = "ERROR"
|
|
79
|
+
elif record.levelno >= logging.WARNING:
|
|
80
|
+
level = "WARNING"
|
|
81
|
+
else:
|
|
82
|
+
level = "INFO"
|
|
75
83
|
self._log_signal.emit(level, record.getMessage())
|
|
76
84
|
|
|
77
85
|
|
|
@@ -89,7 +97,14 @@ class PipelineWorker(QThread):
|
|
|
89
97
|
super().__init__(parent)
|
|
90
98
|
self._config = config
|
|
91
99
|
|
|
92
|
-
def run(self) -> None:
|
|
100
|
+
def run(self) -> None:
|
|
101
|
+
"""Execute the configured operation and always emit a terminal signal.
|
|
102
|
+
|
|
103
|
+
Every exception type risforge's own API documents raising is
|
|
104
|
+
translated into a human-readable error_occurred signal instead
|
|
105
|
+
of propagating out of the thread (which would terminate it
|
|
106
|
+
silently, with nothing visible to the user at all).
|
|
107
|
+
"""
|
|
93
108
|
log_bridge = _LogBridge(self.log_message)
|
|
94
109
|
risforge_logger = logging.getLogger("risforge")
|
|
95
110
|
risforge_logger.addHandler(log_bridge)
|
|
@@ -60,9 +60,7 @@ class TestBuildParser:
|
|
|
60
60
|
|
|
61
61
|
def test_enrich_requires_email(self) -> None:
|
|
62
62
|
parser = build_parser()
|
|
63
|
-
args = parser.parse_args(
|
|
64
|
-
["enrich", "in.ris", "out.ris", "--email", "you@example.com"]
|
|
65
|
-
)
|
|
63
|
+
args = parser.parse_args(["enrich", "in.ris", "out.ris", "--email", "you@example.com"])
|
|
66
64
|
assert args.email == "you@example.com"
|
|
67
65
|
|
|
68
66
|
def test_pipeline_defaults_are_none(self) -> None:
|
|
@@ -96,9 +94,7 @@ class TestBuildParser:
|
|
|
96
94
|
|
|
97
95
|
def test_merge_subcommand_parses_inputs_and_trailing_output(self) -> None:
|
|
98
96
|
parser = build_parser()
|
|
99
|
-
args = parser.parse_args(
|
|
100
|
-
["merge", "scopus.ris", "pubmed.ris", "wos.ris", "merged.ris"]
|
|
101
|
-
)
|
|
97
|
+
args = parser.parse_args(["merge", "scopus.ris", "pubmed.ris", "wos.ris", "merged.ris"])
|
|
102
98
|
assert args.command == "merge"
|
|
103
99
|
assert args.inputs == ["scopus.ris", "pubmed.ris", "wos.ris"]
|
|
104
100
|
assert args.output == "merged.ris"
|
|
@@ -167,9 +163,7 @@ class TestMergeCommandExecution:
|
|
|
167
163
|
|
|
168
164
|
|
|
169
165
|
class TestPipelineCommandExecution:
|
|
170
|
-
def test_single_input_pipeline_runs(
|
|
171
|
-
self, sample_ris_path, mock_enrichment, tmp_path
|
|
172
|
-
) -> None:
|
|
166
|
+
def test_single_input_pipeline_runs(self, sample_ris_path, mock_enrichment, tmp_path) -> None:
|
|
173
167
|
parser = build_parser()
|
|
174
168
|
dedup_path = tmp_path / "clean.ris"
|
|
175
169
|
enriched_path = tmp_path / "enriched.ris"
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
from typing import Any
|
|
5
4
|
|
|
6
5
|
import rispy
|
|
@@ -56,9 +55,7 @@ OPENALEX_PAYLOAD = {
|
|
|
56
55
|
"open_access": {"oa_url": "https://example.org/paper.pdf"},
|
|
57
56
|
}
|
|
58
57
|
SEMANTIC_SCHOLAR_PAYLOAD = {"abstract": "An abstract from Semantic Scholar."}
|
|
59
|
-
UNPAYWALL_PAYLOAD = {
|
|
60
|
-
"best_oa_location": {"url_for_pdf": "https://example.org/oa-version.pdf"}
|
|
61
|
-
}
|
|
58
|
+
UNPAYWALL_PAYLOAD = {"best_oa_location": {"url_for_pdf": "https://example.org/oa-version.pdf"}}
|
|
62
59
|
|
|
63
60
|
|
|
64
61
|
def _make_enricher() -> tuple[RisEnricher, _MockSession]:
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
3
|
import pytest
|
|
6
4
|
import rispy
|
|
7
5
|
|
|
@@ -173,9 +171,7 @@ class TestDeterministicOrdering:
|
|
|
173
171
|
merge_ris_files(multi_source_paths, forward_path)
|
|
174
172
|
merge_ris_files(list(reversed(multi_source_paths)), reversed_path)
|
|
175
173
|
|
|
176
|
-
forward_titles = [
|
|
177
|
-
r.get("title") for r in rispy.load(open(forward_path, encoding="utf-8"))
|
|
178
|
-
]
|
|
174
|
+
forward_titles = [r.get("title") for r in rispy.load(open(forward_path, encoding="utf-8"))]
|
|
179
175
|
reversed_titles = [
|
|
180
176
|
r.get("title") for r in rispy.load(open(reversed_path, encoding="utf-8"))
|
|
181
177
|
]
|
|
@@ -99,9 +99,7 @@ class TestMultiInputPipeline:
|
|
|
99
99
|
)
|
|
100
100
|
|
|
101
101
|
cleaned_records = list(rispy.load(open(dedup_path, encoding="utf-8")))
|
|
102
|
-
shared_paper = next(
|
|
103
|
-
r for r in cleaned_records if "Tractography" in (r.get("title") or "")
|
|
104
|
-
)
|
|
102
|
+
shared_paper = next(r for r in cleaned_records if "Tractography" in (r.get("title") or ""))
|
|
105
103
|
# abstract came from pubmed.ris, keywords came from wos.ris --
|
|
106
104
|
# merge_cluster() is what combines them into the one surviving record.
|
|
107
105
|
assert shared_paper["abstract"] == (
|
|
@@ -129,9 +127,7 @@ class TestMultiInputPipeline:
|
|
|
129
127
|
)
|
|
130
128
|
|
|
131
129
|
enriched_records = list(rispy.load(open(enriched_path, encoding="utf-8")))
|
|
132
|
-
shared_paper = next(
|
|
133
|
-
r for r in enriched_records if "Tractography" in (r.get("title") or "")
|
|
134
|
-
)
|
|
130
|
+
shared_paper = next(r for r in enriched_records if "Tractography" in (r.get("title") or ""))
|
|
135
131
|
# Kept from the original cleaned record, not overwritten by the
|
|
136
132
|
# mocked Crossref payload's different title/journal:
|
|
137
133
|
assert shared_paper["title"] == "Deep Learning for Diffusion MRI Tractography"
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"""Entry point for the ``risforge-gui`` command."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def main() -> None:
|
|
9
|
-
try:
|
|
10
|
-
from PySide6.QtWidgets import QApplication
|
|
11
|
-
except ImportError:
|
|
12
|
-
print(
|
|
13
|
-
"risforge-gui requires the GUI extras, which aren't installed.\n\n"
|
|
14
|
-
' pip install "risforge[gui]"\n',
|
|
15
|
-
file=sys.stderr,
|
|
16
|
-
)
|
|
17
|
-
raise SystemExit(1) from None
|
|
18
|
-
|
|
19
|
-
from risforge_gui.main_window import MainWindow
|
|
20
|
-
from risforge_gui.theme import Theme, apply_theme
|
|
21
|
-
|
|
22
|
-
app = QApplication(sys.argv)
|
|
23
|
-
app.setApplicationName("risforge")
|
|
24
|
-
apply_theme(app, Theme.SYSTEM)
|
|
25
|
-
|
|
26
|
-
window = MainWindow()
|
|
27
|
-
window.resize(1100, 780)
|
|
28
|
-
window.show()
|
|
29
|
-
|
|
30
|
-
sys.exit(app.exec())
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if __name__ == "__main__":
|
|
34
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|