risforge 0.2.0__tar.gz → 0.3.0__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.2.0/src/risforge.egg-info → risforge-0.3.0}/PKG-INFO +20 -1
- {risforge-0.2.0 → risforge-0.3.0}/README.md +14 -0
- {risforge-0.2.0 → risforge-0.3.0}/pyproject.toml +14 -1
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge/__init__.py +1 -1
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge/enrichment.py +14 -1
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge/pipeline.py +29 -1
- {risforge-0.2.0 → risforge-0.3.0/src/risforge.egg-info}/PKG-INFO +20 -1
- risforge-0.3.0/src/risforge.egg-info/SOURCES.txt +36 -0
- risforge-0.3.0/src/risforge.egg-info/entry_points.txt +5 -0
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge.egg-info/requires.txt +7 -0
- risforge-0.3.0/src/risforge.egg-info/top_level.txt +2 -0
- risforge-0.3.0/src/risforge_gui/__init__.py +21 -0
- risforge-0.3.0/src/risforge_gui/app.py +34 -0
- risforge-0.3.0/src/risforge_gui/dialogs.py +49 -0
- risforge-0.3.0/src/risforge_gui/file_counter.py +41 -0
- risforge-0.3.0/src/risforge_gui/main_window.py +373 -0
- risforge-0.3.0/src/risforge_gui/models.py +135 -0
- risforge-0.3.0/src/risforge_gui/theme.py +236 -0
- risforge-0.3.0/src/risforge_gui/widgets/__init__.py +1 -0
- risforge-0.3.0/src/risforge_gui/widgets/config_panel.py +139 -0
- risforge-0.3.0/src/risforge_gui/widgets/input_panel.py +161 -0
- risforge-0.3.0/src/risforge_gui/widgets/log_panel.py +36 -0
- risforge-0.3.0/src/risforge_gui/widgets/output_panel.py +112 -0
- risforge-0.3.0/src/risforge_gui/widgets/progress_panel.py +134 -0
- risforge-0.3.0/src/risforge_gui/widgets/results_panel.py +119 -0
- risforge-0.3.0/src/risforge_gui/worker.py +234 -0
- risforge-0.2.0/src/risforge.egg-info/SOURCES.txt +0 -21
- risforge-0.2.0/src/risforge.egg-info/entry_points.txt +0 -2
- risforge-0.2.0/src/risforge.egg-info/top_level.txt +0 -1
- {risforge-0.2.0 → risforge-0.3.0}/LICENSE +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/setup.cfg +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge/cleaning.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge/cli.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge/exceptions.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge/merging.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/src/risforge.egg-info/dependency_links.txt +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/tests/test_cleaning.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/tests/test_cli.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/tests/test_enrichment.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/tests/test_merging.py +0 -0
- {risforge-0.2.0 → risforge-0.3.0}/tests/test_pipeline.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: risforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -32,6 +32,11 @@ Requires-Dist: pytest>=8.0; extra == "dev"
|
|
|
32
32
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
33
|
Requires-Dist: build>=1.2; extra == "dev"
|
|
34
34
|
Requires-Dist: twine>=5.0; extra == "dev"
|
|
35
|
+
Provides-Extra: gui
|
|
36
|
+
Requires-Dist: PySide6>=6.5; extra == "gui"
|
|
37
|
+
Provides-Extra: gui-dev
|
|
38
|
+
Requires-Dist: risforge[gui]; extra == "gui-dev"
|
|
39
|
+
Requires-Dist: pytest-qt>=4.4; extra == "gui-dev"
|
|
35
40
|
Dynamic: license-file
|
|
36
41
|
|
|
37
42
|
# risforge
|
|
@@ -90,6 +95,8 @@ overlapping database exports — whether that's one export or several.
|
|
|
90
95
|
enriched.
|
|
91
96
|
- **Library or CLI** — use it as `import risforge` in a script/notebook,
|
|
92
97
|
or as a single `risforge` command.
|
|
98
|
+
- **Optional desktop GUI** — a cross-platform PySide6 app (`risforge-gui`)
|
|
99
|
+
for drag-and-drop use, with no command line required.
|
|
93
100
|
|
|
94
101
|
## Installation
|
|
95
102
|
|
|
@@ -99,6 +106,18 @@ pip install risforge
|
|
|
99
106
|
|
|
100
107
|
Requires Python 3.10+.
|
|
101
108
|
|
|
109
|
+
There's also an optional desktop GUI, for researchers who'd rather not use
|
|
110
|
+
the command line:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install "risforge[gui]"
|
|
114
|
+
risforge-gui
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It's a thin PySide6 interface over this same package -- see
|
|
118
|
+
[docs/gui.md](docs/gui.md) for the full walkthrough. The base install above
|
|
119
|
+
never pulls in GUI dependencies.
|
|
120
|
+
|
|
102
121
|
## Quick start
|
|
103
122
|
|
|
104
123
|
### Command line
|
|
@@ -54,6 +54,8 @@ overlapping database exports — whether that's one export or several.
|
|
|
54
54
|
enriched.
|
|
55
55
|
- **Library or CLI** — use it as `import risforge` in a script/notebook,
|
|
56
56
|
or as a single `risforge` command.
|
|
57
|
+
- **Optional desktop GUI** — a cross-platform PySide6 app (`risforge-gui`)
|
|
58
|
+
for drag-and-drop use, with no command line required.
|
|
57
59
|
|
|
58
60
|
## Installation
|
|
59
61
|
|
|
@@ -63,6 +65,18 @@ pip install risforge
|
|
|
63
65
|
|
|
64
66
|
Requires Python 3.10+.
|
|
65
67
|
|
|
68
|
+
There's also an optional desktop GUI, for researchers who'd rather not use
|
|
69
|
+
the command line:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install "risforge[gui]"
|
|
73
|
+
risforge-gui
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It's a thin PySide6 interface over this same package -- see
|
|
77
|
+
[docs/gui.md](docs/gui.md) for the full walkthrough. The base install above
|
|
78
|
+
never pulls in GUI dependencies.
|
|
79
|
+
|
|
66
80
|
## Quick start
|
|
67
81
|
|
|
68
82
|
### Command line
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "risforge"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -50,6 +50,13 @@ dev = [
|
|
|
50
50
|
"build>=1.2",
|
|
51
51
|
"twine>=5.0",
|
|
52
52
|
]
|
|
53
|
+
gui = [
|
|
54
|
+
"PySide6>=6.5",
|
|
55
|
+
]
|
|
56
|
+
gui-dev = [
|
|
57
|
+
"risforge[gui]",
|
|
58
|
+
"pytest-qt>=4.4",
|
|
59
|
+
]
|
|
53
60
|
|
|
54
61
|
[project.urls]
|
|
55
62
|
Homepage = "https://github.com/amyr/risforge"
|
|
@@ -60,6 +67,9 @@ Changelog = "https://github.com/amyr/risforge/blob/main/CHANGELOG.md"
|
|
|
60
67
|
[project.scripts]
|
|
61
68
|
risforge = "risforge.cli:main"
|
|
62
69
|
|
|
70
|
+
[project.gui-scripts]
|
|
71
|
+
risforge-gui = "risforge_gui.app:main"
|
|
72
|
+
|
|
63
73
|
[tool.setuptools.packages.find]
|
|
64
74
|
where = ["src"]
|
|
65
75
|
|
|
@@ -68,3 +78,6 @@ where = ["src"]
|
|
|
68
78
|
|
|
69
79
|
[tool.pytest.ini_options]
|
|
70
80
|
testpaths = ["tests"]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.run]
|
|
83
|
+
concurrency = ["thread"]
|
|
@@ -51,7 +51,7 @@ 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.
|
|
54
|
+
__version__ = "0.3.0"
|
|
55
55
|
|
|
56
56
|
__all__ = [
|
|
57
57
|
"clean_ris_file",
|
|
@@ -40,6 +40,7 @@ import json
|
|
|
40
40
|
import logging
|
|
41
41
|
import re
|
|
42
42
|
import time
|
|
43
|
+
from collections.abc import Callable
|
|
43
44
|
from difflib import SequenceMatcher
|
|
44
45
|
from pathlib import Path
|
|
45
46
|
from typing import Any
|
|
@@ -375,6 +376,7 @@ class RisEnricher:
|
|
|
375
376
|
output_path: str | Path,
|
|
376
377
|
fail_report_path: str | Path = "failed_records.json",
|
|
377
378
|
request_delay_seconds: float = 0.1,
|
|
379
|
+
progress_callback: Callable[[int, int], None] | None = None,
|
|
378
380
|
) -> dict[str, Any]:
|
|
379
381
|
"""Enrich every record in a RIS file and write the result.
|
|
380
382
|
|
|
@@ -387,6 +389,14 @@ class RisEnricher:
|
|
|
387
389
|
request_delay_seconds: Delay between records, to stay
|
|
388
390
|
within provider rate limits beyond the built-in retry
|
|
389
391
|
policy.
|
|
392
|
+
progress_callback: Optional callback invoked as
|
|
393
|
+
``progress_callback(processed_count, total_count)``
|
|
394
|
+
after each record is processed. Added for callers
|
|
395
|
+
(such as a GUI) that want to report determinate
|
|
396
|
+
progress during a potentially slow, network-bound
|
|
397
|
+
operation, without polling or duplicating this loop
|
|
398
|
+
elsewhere. Never called when omitted -- zero behavior
|
|
399
|
+
change for existing callers.
|
|
390
400
|
|
|
391
401
|
Returns:
|
|
392
402
|
The accumulated ``self.stats`` dict.
|
|
@@ -403,9 +413,12 @@ class RisEnricher:
|
|
|
403
413
|
logger.error("Failed to parse RIS file: %s", error)
|
|
404
414
|
return self.stats
|
|
405
415
|
|
|
416
|
+
total = len(records)
|
|
406
417
|
enriched_records = []
|
|
407
|
-
for record in records:
|
|
418
|
+
for index, record in enumerate(records):
|
|
408
419
|
enriched_records.append(self.enrich_record(record))
|
|
420
|
+
if progress_callback is not None:
|
|
421
|
+
progress_callback(index + 1, total)
|
|
409
422
|
if request_delay_seconds:
|
|
410
423
|
time.sleep(request_delay_seconds)
|
|
411
424
|
|
|
@@ -24,7 +24,7 @@ pre-0.2.0 API -- including callers who used the original
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import logging
|
|
27
|
-
from collections.abc import Sequence
|
|
27
|
+
from collections.abc import Callable, Sequence
|
|
28
28
|
from dataclasses import dataclass, field
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
from typing import Any
|
|
@@ -93,6 +93,8 @@ def risforge(
|
|
|
93
93
|
email: str,
|
|
94
94
|
merge_path: str | Path | None = None,
|
|
95
95
|
fail_report_path: str | Path = "failed_records.json",
|
|
96
|
+
on_stage: Callable[[str, str], None] | None = None,
|
|
97
|
+
enrichment_progress: Callable[[int, int], None] | None = None,
|
|
96
98
|
) -> PipelineResult:
|
|
97
99
|
"""Run the full merge(optional) -> clean -> enrich pipeline end to end.
|
|
98
100
|
|
|
@@ -118,6 +120,19 @@ def risforge(
|
|
|
118
120
|
Ignored for a single input, since no merge step runs.
|
|
119
121
|
fail_report_path: Where a JSON report of unresolved records is
|
|
120
122
|
written, if any.
|
|
123
|
+
on_stage: Optional callback invoked as ``on_stage(stage,
|
|
124
|
+
status)`` around each phase, with ``stage`` one of
|
|
125
|
+
``"merge"`` (only when multiple inputs are given),
|
|
126
|
+
``"clean"``, or ``"enrich"``, and ``status`` one of
|
|
127
|
+
``"started"`` or ``"completed"``. Added for callers (such
|
|
128
|
+
as a GUI) that want to reflect pipeline progress without
|
|
129
|
+
polling the filesystem or reimplementing this function's
|
|
130
|
+
control flow. Never called when omitted.
|
|
131
|
+
enrichment_progress: Optional callback forwarded to
|
|
132
|
+
:meth:`risforge.enrichment.RisEnricher.enrich_file` as
|
|
133
|
+
``progress_callback``, invoked as
|
|
134
|
+
``enrichment_progress(processed_count, total_count)``
|
|
135
|
+
after each record. Never called when omitted.
|
|
121
136
|
|
|
122
137
|
Returns:
|
|
123
138
|
A :class:`PipelineResult` summarizing every phase that ran.
|
|
@@ -139,7 +154,11 @@ def risforge(
|
|
|
139
154
|
Path(merge_path) if merge_path is not None else _default_merge_path(dedup_path)
|
|
140
155
|
)
|
|
141
156
|
logger.info("Phase 0: Merging %d input files into %s", len(paths), resolved_merge_path)
|
|
157
|
+
if on_stage is not None:
|
|
158
|
+
on_stage("merge", "started")
|
|
142
159
|
merge_result = merge_ris_files(paths, resolved_merge_path)
|
|
160
|
+
if on_stage is not None:
|
|
161
|
+
on_stage("merge", "completed")
|
|
143
162
|
logger.info(
|
|
144
163
|
"Phase 0 complete: merged %d record(s) from %d file(s).",
|
|
145
164
|
merge_result.record_count,
|
|
@@ -150,18 +169,27 @@ def risforge(
|
|
|
150
169
|
clean_input = paths[0]
|
|
151
170
|
|
|
152
171
|
logger.info("Phase 1: Deduplicating %s", clean_input)
|
|
172
|
+
if on_stage is not None:
|
|
173
|
+
on_stage("clean", "started")
|
|
153
174
|
records, errors = clean_ris_file(clean_input, dedup_path)
|
|
175
|
+
if on_stage is not None:
|
|
176
|
+
on_stage("clean", "completed")
|
|
154
177
|
logger.info("Phase 1 complete: generated %d clean records.", len(records))
|
|
155
178
|
if errors:
|
|
156
179
|
logger.warning("Encountered %d parsing errors during Phase 1.", len(errors))
|
|
157
180
|
|
|
158
181
|
logger.info("Phase 2: Initializing metadata enrichment via APIs")
|
|
182
|
+
if on_stage is not None:
|
|
183
|
+
on_stage("enrich", "started")
|
|
159
184
|
enricher = RisEnricher(email=email)
|
|
160
185
|
stats = enricher.enrich_file(
|
|
161
186
|
input_path=dedup_path,
|
|
162
187
|
output_path=enriched_path,
|
|
163
188
|
fail_report_path=fail_report_path,
|
|
189
|
+
progress_callback=enrichment_progress,
|
|
164
190
|
)
|
|
191
|
+
if on_stage is not None:
|
|
192
|
+
on_stage("enrich", "completed")
|
|
165
193
|
logger.info(
|
|
166
194
|
"Phase 2 complete: enriched %d/%d records.",
|
|
167
195
|
stats.get("enriched", 0),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: risforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -32,6 +32,11 @@ Requires-Dist: pytest>=8.0; extra == "dev"
|
|
|
32
32
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
33
|
Requires-Dist: build>=1.2; extra == "dev"
|
|
34
34
|
Requires-Dist: twine>=5.0; extra == "dev"
|
|
35
|
+
Provides-Extra: gui
|
|
36
|
+
Requires-Dist: PySide6>=6.5; extra == "gui"
|
|
37
|
+
Provides-Extra: gui-dev
|
|
38
|
+
Requires-Dist: risforge[gui]; extra == "gui-dev"
|
|
39
|
+
Requires-Dist: pytest-qt>=4.4; extra == "gui-dev"
|
|
35
40
|
Dynamic: license-file
|
|
36
41
|
|
|
37
42
|
# risforge
|
|
@@ -90,6 +95,8 @@ overlapping database exports — whether that's one export or several.
|
|
|
90
95
|
enriched.
|
|
91
96
|
- **Library or CLI** — use it as `import risforge` in a script/notebook,
|
|
92
97
|
or as a single `risforge` command.
|
|
98
|
+
- **Optional desktop GUI** — a cross-platform PySide6 app (`risforge-gui`)
|
|
99
|
+
for drag-and-drop use, with no command line required.
|
|
93
100
|
|
|
94
101
|
## Installation
|
|
95
102
|
|
|
@@ -99,6 +106,18 @@ pip install risforge
|
|
|
99
106
|
|
|
100
107
|
Requires Python 3.10+.
|
|
101
108
|
|
|
109
|
+
There's also an optional desktop GUI, for researchers who'd rather not use
|
|
110
|
+
the command line:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install "risforge[gui]"
|
|
114
|
+
risforge-gui
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It's a thin PySide6 interface over this same package -- see
|
|
118
|
+
[docs/gui.md](docs/gui.md) for the full walkthrough. The base install above
|
|
119
|
+
never pulls in GUI dependencies.
|
|
120
|
+
|
|
102
121
|
## Quick start
|
|
103
122
|
|
|
104
123
|
### Command line
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/risforge/__init__.py
|
|
5
|
+
src/risforge/cleaning.py
|
|
6
|
+
src/risforge/cli.py
|
|
7
|
+
src/risforge/enrichment.py
|
|
8
|
+
src/risforge/exceptions.py
|
|
9
|
+
src/risforge/merging.py
|
|
10
|
+
src/risforge/pipeline.py
|
|
11
|
+
src/risforge.egg-info/PKG-INFO
|
|
12
|
+
src/risforge.egg-info/SOURCES.txt
|
|
13
|
+
src/risforge.egg-info/dependency_links.txt
|
|
14
|
+
src/risforge.egg-info/entry_points.txt
|
|
15
|
+
src/risforge.egg-info/requires.txt
|
|
16
|
+
src/risforge.egg-info/top_level.txt
|
|
17
|
+
src/risforge_gui/__init__.py
|
|
18
|
+
src/risforge_gui/app.py
|
|
19
|
+
src/risforge_gui/dialogs.py
|
|
20
|
+
src/risforge_gui/file_counter.py
|
|
21
|
+
src/risforge_gui/main_window.py
|
|
22
|
+
src/risforge_gui/models.py
|
|
23
|
+
src/risforge_gui/theme.py
|
|
24
|
+
src/risforge_gui/worker.py
|
|
25
|
+
src/risforge_gui/widgets/__init__.py
|
|
26
|
+
src/risforge_gui/widgets/config_panel.py
|
|
27
|
+
src/risforge_gui/widgets/input_panel.py
|
|
28
|
+
src/risforge_gui/widgets/log_panel.py
|
|
29
|
+
src/risforge_gui/widgets/output_panel.py
|
|
30
|
+
src/risforge_gui/widgets/progress_panel.py
|
|
31
|
+
src/risforge_gui/widgets/results_panel.py
|
|
32
|
+
tests/test_cleaning.py
|
|
33
|
+
tests/test_cli.py
|
|
34
|
+
tests/test_enrichment.py
|
|
35
|
+
tests/test_merging.py
|
|
36
|
+
tests/test_pipeline.py
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""risforge_gui: a desktop GUI for the risforge package.
|
|
2
|
+
|
|
3
|
+
This is a thin PySide6 presentation layer over the ``risforge`` core
|
|
4
|
+
library -- it contains no merging, cleaning, deduplication, or
|
|
5
|
+
enrichment logic of its own. Every operation it performs is a direct
|
|
6
|
+
call into the public ``risforge`` API (:func:`risforge.risforge`,
|
|
7
|
+
:func:`risforge.merge_ris_files`, :func:`risforge.clean_ris_file`,
|
|
8
|
+
:class:`risforge.RisEnricher`).
|
|
9
|
+
|
|
10
|
+
Deliberately not imported here: PySide6. Importing this top-level
|
|
11
|
+
package should never require the GUI extra to be installed; only
|
|
12
|
+
:mod:`risforge_gui.app` (the entry point) and the widget modules it
|
|
13
|
+
pulls in do that. This keeps ``import risforge_gui`` itself cheap and
|
|
14
|
+
side-effect-free, consistent with how ``risforge`` (the core package)
|
|
15
|
+
never imports GUI dependencies.
|
|
16
|
+
|
|
17
|
+
Install with the GUI extra and launch via the console entry point::
|
|
18
|
+
|
|
19
|
+
pip install "risforge[gui]"
|
|
20
|
+
risforge-gui
|
|
21
|
+
"""
|
|
@@ -0,0 +1,34 @@
|
|
|
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()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Error and confirmation dialogs.
|
|
2
|
+
|
|
3
|
+
Errors are always shown with a plain-language message first. Technical
|
|
4
|
+
detail (exception repr / traceback) is available but hidden behind
|
|
5
|
+
Qt's built-in "Show Details..." expander -- never shown by default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from PySide6.QtWidgets import QMessageBox, QWidget
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def show_error_dialog(
|
|
16
|
+
parent: QWidget | None,
|
|
17
|
+
title: str,
|
|
18
|
+
message: str,
|
|
19
|
+
details: str = "",
|
|
20
|
+
affected_file: str = "",
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Show a human-readable error, with technical details available on demand."""
|
|
23
|
+
box = QMessageBox(parent)
|
|
24
|
+
box.setIcon(QMessageBox.Icon.Critical)
|
|
25
|
+
box.setWindowTitle("risforge")
|
|
26
|
+
box.setText(title)
|
|
27
|
+
|
|
28
|
+
informative = message
|
|
29
|
+
if affected_file:
|
|
30
|
+
informative = f"File: {affected_file}\n\n{message}"
|
|
31
|
+
box.setInformativeText(informative)
|
|
32
|
+
|
|
33
|
+
if details:
|
|
34
|
+
box.setDetailedText(details)
|
|
35
|
+
|
|
36
|
+
box.setStandardButtons(QMessageBox.StandardButton.Ok)
|
|
37
|
+
box.exec()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def confirm_overwrite(parent: QWidget | None, path: Path) -> bool:
|
|
41
|
+
"""Ask before overwriting an existing output file. Defaults to No (safe)."""
|
|
42
|
+
result = QMessageBox.question(
|
|
43
|
+
parent,
|
|
44
|
+
"File already exists",
|
|
45
|
+
f"A file already exists at:\n{path}\n\nOverwrite it?",
|
|
46
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
47
|
+
QMessageBox.StandardButton.No,
|
|
48
|
+
)
|
|
49
|
+
return result == QMessageBox.StandardButton.Yes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Asynchronous record counting for the input files table.
|
|
2
|
+
|
|
3
|
+
Counting records requires actually parsing the file, so it's done off
|
|
4
|
+
the GUI thread via QThreadPool. The parsing itself is not
|
|
5
|
+
reimplemented here -- it's the exact same
|
|
6
|
+
``risforge.cleaning.parse_ris_records`` used by cleaning and merging,
|
|
7
|
+
so a file's reported record count always matches what merge/clean
|
|
8
|
+
will actually do with it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import QObject, QRunnable, Signal
|
|
16
|
+
|
|
17
|
+
from risforge.cleaning import parse_ris_records
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _CounterSignals(QObject):
|
|
21
|
+
finished = Signal(Path, int, int) # path, record_count, malformed_block_count
|
|
22
|
+
failed = Signal(Path, str) # path, human-readable error message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RecordCounter(QRunnable):
|
|
26
|
+
"""Counts records in one RIS file on a background thread pool."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: Path) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self._path = path
|
|
31
|
+
self.signals = _CounterSignals()
|
|
32
|
+
|
|
33
|
+
def run(self) -> None: # noqa: D102 -- QRunnable interface
|
|
34
|
+
try:
|
|
35
|
+
records, errors = parse_ris_records(self._path)
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
self.signals.failed.emit(self._path, "File not found.")
|
|
38
|
+
except OSError as error:
|
|
39
|
+
self.signals.failed.emit(self._path, f"Could not read file: {error}")
|
|
40
|
+
else:
|
|
41
|
+
self.signals.finished.emit(self._path, len(records), len(errors))
|