mikancli 0.1.8__tar.gz → 0.2.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.
- {mikancli-0.1.8 → mikancli-0.2.0}/PKG-INFO +24 -3
- {mikancli-0.1.8 → mikancli-0.2.0}/README.md +22 -2
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/__init__.py +1 -1
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/entrypoint.py +6 -6
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/input_parsing.py +3 -2
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/qbittorrent_flow.py +32 -25
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/save_path_flow.py +4 -3
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/search_flow.py +15 -16
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/display.py +70 -32
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/i18n.py +10 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/PKG-INFO +24 -3
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/SOURCES.txt +2 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/requires.txt +1 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/pyproject.toml +2 -1
- mikancli-0.2.0/tests/test_cli_json.py +30 -0
- mikancli-0.2.0/tests/test_display.py +67 -0
- mikancli-0.2.0/tests/test_feed_preview.py +76 -0
- mikancli-0.1.8/tests/test_feed_preview.py +0 -38
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/__main__.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/__init__.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/prompts.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/config.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/__init__.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/models.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/normalize.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/rules.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/__init__.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/mikan.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/mikan_parsers.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/mikan_urls.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/qbittorrent.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/qbittorrent_client.py +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/dependency_links.txt +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/entry_points.txt +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/top_level.txt +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/setup.cfg +0 -0
- {mikancli-0.1.8 → mikancli-0.2.0}/tests/test_qbittorrent_episode_filter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mikancli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: CLI foundation for building qBittorrent RSS rules from anime search input.
|
|
5
5
|
Author: metails
|
|
6
6
|
Project-URL: Repository, https://github.com/JianHua-Deng/MikanCli
|
|
@@ -20,6 +20,7 @@ Classifier: Topic :: Utilities
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
Requires-Dist: InquirerPy>=0.3
|
|
23
|
+
Requires-Dist: rich>=14.0
|
|
23
24
|
|
|
24
25
|
# MikanCli
|
|
25
26
|
|
|
@@ -34,6 +35,7 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
|
|
|
34
35
|
- Search Mikan by anime title or keyword
|
|
35
36
|
- Choose from matching Bangumi results and subgroup-specific RSS feeds
|
|
36
37
|
- Preview recent RSS feed items with pagination before confirming a feed
|
|
38
|
+
- Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
|
|
37
39
|
- Build qBittorrent RSS rules with include and exclude filters
|
|
38
40
|
- Filter anime-style episode numbers at or above a minimum episode
|
|
39
41
|
- Choose and save a default download folder
|
|
@@ -88,7 +90,26 @@ python -m pip install -e .
|
|
|
88
90
|
python -m mikancli
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
|
|
93
|
+
Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
|
|
94
|
+
|
|
95
|
+
After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
|
|
96
|
+
|
|
97
|
+
## Terminal Output
|
|
98
|
+
|
|
99
|
+
Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
|
|
100
|
+
|
|
101
|
+
Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
|
|
102
|
+
|
|
103
|
+
To disable Rich colors in PowerShell:
|
|
104
|
+
|
|
105
|
+
```powershell
|
|
106
|
+
$env:NO_COLOR = "1"
|
|
107
|
+
mikancli
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
|
|
111
|
+
|
|
112
|
+
The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
|
|
92
113
|
|
|
93
114
|
## Guided Flow
|
|
94
115
|
|
|
@@ -169,7 +190,7 @@ mikancli/
|
|
|
169
190
|
core/ dataclasses, normalization, and rule-building logic
|
|
170
191
|
integrations/ Mikan and qBittorrent adapters
|
|
171
192
|
config.py user config and folder selection helpers
|
|
172
|
-
display.py
|
|
193
|
+
display.py Rich summaries, feed previews, feedback, and loading indicators
|
|
173
194
|
```
|
|
174
195
|
|
|
175
196
|
The console command is declared in `pyproject.toml`:
|
|
@@ -11,6 +11,7 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
|
|
|
11
11
|
- Search Mikan by anime title or keyword
|
|
12
12
|
- Choose from matching Bangumi results and subgroup-specific RSS feeds
|
|
13
13
|
- Preview recent RSS feed items with pagination before confirming a feed
|
|
14
|
+
- Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
|
|
14
15
|
- Build qBittorrent RSS rules with include and exclude filters
|
|
15
16
|
- Filter anime-style episode numbers at or above a minimum episode
|
|
16
17
|
- Choose and save a default download folder
|
|
@@ -65,7 +66,26 @@ python -m pip install -e .
|
|
|
65
66
|
python -m mikancli
|
|
66
67
|
```
|
|
67
68
|
|
|
68
|
-
Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
|
|
69
|
+
Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
|
|
70
|
+
|
|
71
|
+
After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
|
|
72
|
+
|
|
73
|
+
## Terminal Output
|
|
74
|
+
|
|
75
|
+
Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
|
|
76
|
+
|
|
77
|
+
Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
|
|
78
|
+
|
|
79
|
+
To disable Rich colors in PowerShell:
|
|
80
|
+
|
|
81
|
+
```powershell
|
|
82
|
+
$env:NO_COLOR = "1"
|
|
83
|
+
mikancli
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
|
|
87
|
+
|
|
88
|
+
The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
|
|
69
89
|
|
|
70
90
|
## Guided Flow
|
|
71
91
|
|
|
@@ -146,7 +166,7 @@ mikancli/
|
|
|
146
166
|
core/ dataclasses, normalization, and rule-building logic
|
|
147
167
|
integrations/ Mikan and qBittorrent adapters
|
|
148
168
|
config.py user config and folder selection helpers
|
|
149
|
-
display.py
|
|
169
|
+
display.py Rich summaries, feed previews, feedback, and loading indicators
|
|
150
170
|
```
|
|
151
171
|
|
|
152
172
|
The console command is declared in `pyproject.toml`:
|
|
@@ -31,7 +31,7 @@ from mikancli.config import get_config_path, load_config, save_config
|
|
|
31
31
|
from mikancli.core.models import AppConfig, RuleDraft, SearchRequest
|
|
32
32
|
from mikancli.core.normalize import collapse_spaces
|
|
33
33
|
from mikancli.core.rules import build_rule_draft
|
|
34
|
-
from mikancli.display import print_text_summary
|
|
34
|
+
from mikancli.display import print_message, print_text_summary
|
|
35
35
|
from mikancli.i18n import (
|
|
36
36
|
LANGUAGE_LABELS,
|
|
37
37
|
SUPPORTED_LANGUAGES,
|
|
@@ -165,7 +165,7 @@ def run_language_configuration_flow(config: AppConfig, config_path: Path) -> App
|
|
|
165
165
|
set_language(selected_language)
|
|
166
166
|
updated_config = replace(config, language=selected_language)
|
|
167
167
|
save_config(config_path, updated_config)
|
|
168
|
-
|
|
168
|
+
print_message(t("language.saved", language=LANGUAGE_LABELS[selected_language]), style="success")
|
|
169
169
|
return updated_config
|
|
170
170
|
|
|
171
171
|
|
|
@@ -247,7 +247,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
247
247
|
try:
|
|
248
248
|
return setup_qbittorrent(config, config_path)
|
|
249
249
|
except ExitRequested:
|
|
250
|
-
|
|
250
|
+
print_message(t("common.exited"))
|
|
251
251
|
return 0
|
|
252
252
|
|
|
253
253
|
if args.json:
|
|
@@ -307,7 +307,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
307
307
|
config_path=config_path,
|
|
308
308
|
)
|
|
309
309
|
except ExitRequested:
|
|
310
|
-
|
|
310
|
+
print_message(t("common.exited"))
|
|
311
311
|
return 0
|
|
312
312
|
|
|
313
313
|
summary_exit_code = print_text_summary(draft)
|
|
@@ -316,7 +316,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
316
316
|
try:
|
|
317
317
|
submission_exit_code = prompt_to_submit_rule_to_qbittorrent(config, draft)
|
|
318
318
|
if submission_exit_code == QBITTORRENT_NOT_CONFIGURED:
|
|
319
|
-
|
|
319
|
+
print_message(t("qb.submit.not_configured"), style="warning")
|
|
320
320
|
setup_exit_code = prompt_for_qbittorrent_setup_if_needed(config, config_path)
|
|
321
321
|
if setup_exit_code not in {
|
|
322
322
|
QBITTORRENT_SETUP_SUCCESS,
|
|
@@ -331,7 +331,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
331
331
|
continue
|
|
332
332
|
|
|
333
333
|
except ExitRequested:
|
|
334
|
-
|
|
334
|
+
print_message(t("common.exited"))
|
|
335
335
|
return 0
|
|
336
336
|
|
|
337
337
|
if has_startup_menu and submission_exit_code == QBITTORRENT_SUBMISSION_SKIPPED:
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from mikancli.cli.prompts import prompt_text
|
|
4
4
|
from mikancli.core.normalize import collapse_spaces
|
|
5
|
+
from mikancli.display import print_message
|
|
5
6
|
from mikancli.i18n import t
|
|
6
7
|
|
|
7
8
|
|
|
@@ -10,7 +11,7 @@ def prompt_required_text(prompt: str) -> str:
|
|
|
10
11
|
entered = collapse_spaces(prompt_text(prompt, allow_exit=True))
|
|
11
12
|
if entered:
|
|
12
13
|
return entered
|
|
13
|
-
|
|
14
|
+
print_message(t("common.value_required"), style="warning")
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def parse_word_list(value: str) -> tuple[str, ...]:
|
|
@@ -62,4 +63,4 @@ def prompt_optional_positive_int(prompt: str, *, field_name: str) -> int | None:
|
|
|
62
63
|
try:
|
|
63
64
|
return parse_optional_positive_int(entered, field_name=field_name)
|
|
64
65
|
except ValueError as exc:
|
|
65
|
-
|
|
66
|
+
print_message(str(exc), style="warning")
|
|
@@ -6,6 +6,7 @@ from mikancli.cli.prompts import confirm_choice, prompt_password, prompt_text
|
|
|
6
6
|
from mikancli.config import save_config
|
|
7
7
|
from mikancli.core.models import AppConfig, QBittorrentSettings, RuleDraft
|
|
8
8
|
from mikancli.core.normalize import collapse_spaces, sanitize_folder_name
|
|
9
|
+
from mikancli.display import loading, print_message
|
|
9
10
|
from mikancli.i18n import t
|
|
10
11
|
from mikancli.integrations.qbittorrent import (
|
|
11
12
|
MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
|
|
@@ -49,7 +50,7 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
|
|
|
49
50
|
or "http://localhost:8080"
|
|
50
51
|
)
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
print_message(t("qb.setup.auth_hint"))
|
|
53
54
|
username = (
|
|
54
55
|
collapse_spaces(
|
|
55
56
|
prompt_text(
|
|
@@ -72,10 +73,10 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
|
|
|
72
73
|
)
|
|
73
74
|
|
|
74
75
|
try:
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
with loading(t("qb.setup.verifying")):
|
|
77
|
+
version = check_connection(settings)
|
|
77
78
|
except QBittorrentError as exc:
|
|
78
|
-
|
|
79
|
+
print_message(str(exc), style="error")
|
|
79
80
|
return QBITTORRENT_ERROR
|
|
80
81
|
|
|
81
82
|
save_config(
|
|
@@ -90,7 +91,7 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
|
|
|
90
91
|
qbittorrent_add_paused=config.qbittorrent_add_paused,
|
|
91
92
|
),
|
|
92
93
|
)
|
|
93
|
-
|
|
94
|
+
print_message(t("qb.setup.verified", version=version), style="success")
|
|
94
95
|
return QBITTORRENT_SETUP_SUCCESS
|
|
95
96
|
|
|
96
97
|
|
|
@@ -160,7 +161,8 @@ def prompt_to_raise_rss_article_limit_if_needed(
|
|
|
160
161
|
if draft.min_episode is None:
|
|
161
162
|
return
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
with loading(t("qb.submit.checking")):
|
|
165
|
+
current_limit = get_rss_max_articles_per_feed(settings)
|
|
164
166
|
if (
|
|
165
167
|
current_limit is None
|
|
166
168
|
or current_limit >= MIN_RSS_ARTICLES_FOR_EPISODE_FILTER
|
|
@@ -177,23 +179,26 @@ def prompt_to_raise_rss_article_limit_if_needed(
|
|
|
177
179
|
allow_exit=True,
|
|
178
180
|
)
|
|
179
181
|
if not should_update:
|
|
180
|
-
|
|
182
|
+
print_message(
|
|
181
183
|
t(
|
|
182
184
|
"qb.submit.rss_limit_skipped",
|
|
183
185
|
current=current_limit,
|
|
184
|
-
)
|
|
186
|
+
),
|
|
187
|
+
style="warning",
|
|
185
188
|
)
|
|
186
189
|
return
|
|
187
190
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
with loading(t("qb.submit.updating_limit")):
|
|
192
|
+
set_rss_max_articles_per_feed(
|
|
193
|
+
settings,
|
|
194
|
+
MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
|
|
195
|
+
)
|
|
196
|
+
print_message(
|
|
193
197
|
t(
|
|
194
198
|
"qb.submit.rss_limit_updated",
|
|
195
199
|
minimum=MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
|
|
196
|
-
)
|
|
200
|
+
),
|
|
201
|
+
style="success",
|
|
197
202
|
)
|
|
198
203
|
|
|
199
204
|
|
|
@@ -218,7 +223,9 @@ def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -
|
|
|
218
223
|
)
|
|
219
224
|
|
|
220
225
|
try:
|
|
221
|
-
|
|
226
|
+
with loading(t("qb.submit.checking")):
|
|
227
|
+
rule_exists = qbittorrent_rule_exists(settings, draft.rule_name)
|
|
228
|
+
if rule_exists:
|
|
222
229
|
should_replace = confirm_choice(
|
|
223
230
|
t("qb.submit.replace_existing", rule_name=draft.rule_name),
|
|
224
231
|
default=False,
|
|
@@ -229,17 +236,17 @@ def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -
|
|
|
229
236
|
|
|
230
237
|
prompt_to_raise_rss_article_limit_if_needed(settings, draft)
|
|
231
238
|
feed_path = prompt_for_rss_feed_name(draft)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
239
|
+
with loading(t("qb.submit.submitting")):
|
|
240
|
+
submit_rule_draft(
|
|
241
|
+
settings,
|
|
242
|
+
draft,
|
|
243
|
+
add_paused=config.qbittorrent_add_paused,
|
|
244
|
+
assigned_category=config.qbittorrent_category,
|
|
245
|
+
feed_path=feed_path,
|
|
246
|
+
)
|
|
240
247
|
except QBittorrentError as exc:
|
|
241
|
-
|
|
248
|
+
print_message(t("qb.submit.failed", error=str(exc)), style="error")
|
|
242
249
|
return QBITTORRENT_ERROR
|
|
243
250
|
|
|
244
|
-
|
|
251
|
+
print_message(t("qb.submit.success"), style="success")
|
|
245
252
|
return QBITTORRENT_SETUP_SUCCESS
|
|
@@ -10,6 +10,7 @@ from mikancli.config import (
|
|
|
10
10
|
)
|
|
11
11
|
from mikancli.core.models import AppConfig
|
|
12
12
|
from mikancli.core.normalize import collapse_spaces, sanitize_folder_name
|
|
13
|
+
from mikancli.display import print_message
|
|
13
14
|
from mikancli.i18n import t
|
|
14
15
|
|
|
15
16
|
|
|
@@ -61,7 +62,7 @@ def prompt_for_content_folder_name(default_name: str, base_path: str | None) ->
|
|
|
61
62
|
):
|
|
62
63
|
return folder_name
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
print_message(t("save.choose_different"), style="warning")
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
def build_content_save_path(base_path: str | None, folder_name: str) -> str | None:
|
|
@@ -104,12 +105,12 @@ def prompt_for_save_path(config: AppConfig, config_path: Path) -> str:
|
|
|
104
105
|
initial_dir=config.default_save_path or downloads_path
|
|
105
106
|
)
|
|
106
107
|
if not selected_path:
|
|
107
|
-
|
|
108
|
+
print_message(t("save.no_folder_selected"), style="warning")
|
|
108
109
|
continue
|
|
109
110
|
else:
|
|
110
111
|
selected_path = prompt_for_manual_save_path()
|
|
111
112
|
if not selected_path:
|
|
112
|
-
|
|
113
|
+
print_message(t("save.no_path_entered"), style="warning")
|
|
113
114
|
continue
|
|
114
115
|
|
|
115
116
|
if should_save_as_default(selected_path, config):
|
|
@@ -11,7 +11,9 @@ from mikancli.core.models import (
|
|
|
11
11
|
from mikancli.core.normalize import collapse_spaces
|
|
12
12
|
from mikancli.display import (
|
|
13
13
|
FEED_PREVIEW_PAGE_SIZE,
|
|
14
|
-
|
|
14
|
+
loading,
|
|
15
|
+
print_feed_preview_page,
|
|
16
|
+
print_message,
|
|
15
17
|
)
|
|
16
18
|
from mikancli.i18n import t
|
|
17
19
|
from mikancli.integrations.mikan import (
|
|
@@ -96,13 +98,7 @@ def confirm_subgroup_selection(
|
|
|
96
98
|
)
|
|
97
99
|
|
|
98
100
|
while True:
|
|
99
|
-
|
|
100
|
-
build_feed_preview_page_text(
|
|
101
|
-
subgroup,
|
|
102
|
-
feed_items,
|
|
103
|
-
page=page,
|
|
104
|
-
)
|
|
105
|
-
)
|
|
101
|
+
print_feed_preview_page(subgroup, feed_items, page=page)
|
|
106
102
|
|
|
107
103
|
options = [(CONFIRM_SUBGROUP, t("common.yes"))]
|
|
108
104
|
if page < total_pages:
|
|
@@ -184,14 +180,15 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
|
|
|
184
180
|
|
|
185
181
|
while True:
|
|
186
182
|
try:
|
|
187
|
-
|
|
183
|
+
with loading(t("search.searching")):
|
|
184
|
+
candidates = search_mikan_bangumi(keyword)
|
|
188
185
|
except MikanLookupError as exc:
|
|
189
|
-
|
|
186
|
+
print_message(str(exc), style="error")
|
|
190
187
|
keyword = prompt_required_text(search_prompt(retry=True))
|
|
191
188
|
continue
|
|
192
189
|
|
|
193
190
|
if not candidates:
|
|
194
|
-
|
|
191
|
+
print_message(t("search.no_results", keyword=keyword), style="warning")
|
|
195
192
|
keyword = prompt_required_text(search_prompt(retry=True))
|
|
196
193
|
continue
|
|
197
194
|
|
|
@@ -204,13 +201,14 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
|
|
|
204
201
|
|
|
205
202
|
while True:
|
|
206
203
|
try:
|
|
207
|
-
|
|
204
|
+
with loading(t("search.loading_subgroups")):
|
|
205
|
+
subgroups = fetch_mikan_subgroups(bangumi.bangumi_id)
|
|
208
206
|
except MikanLookupError as exc:
|
|
209
|
-
|
|
207
|
+
print_message(str(exc), style="error")
|
|
210
208
|
break
|
|
211
209
|
|
|
212
210
|
if not subgroups:
|
|
213
|
-
|
|
211
|
+
print_message(t("search.no_subgroup"), style="warning")
|
|
214
212
|
break
|
|
215
213
|
|
|
216
214
|
selected_subgroup = select_subgroup_or_navigate(
|
|
@@ -227,9 +225,10 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
|
|
|
227
225
|
subgroup = selected_subgroup
|
|
228
226
|
|
|
229
227
|
try:
|
|
230
|
-
|
|
228
|
+
with loading(t("search.loading_feed")):
|
|
229
|
+
feed_items = fetch_mikan_feed_items(subgroup.feed_url)
|
|
231
230
|
except MikanLookupError as exc:
|
|
232
|
-
|
|
231
|
+
print_message(str(exc), style="error")
|
|
233
232
|
continue
|
|
234
233
|
|
|
235
234
|
decision = confirm_subgroup_selection(subgroup, feed_items)
|
|
@@ -1,48 +1,65 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich.theme import Theme
|
|
10
|
+
|
|
5
11
|
from mikancli.core.models import MikanFeedItem, MikanSubgroup, RuleDraft
|
|
6
12
|
from mikancli.i18n import t
|
|
7
13
|
|
|
8
14
|
FEED_PREVIEW_PAGE_SIZE = 12
|
|
15
|
+
console = Console(
|
|
16
|
+
markup=False, highlight=False, emoji=False,
|
|
17
|
+
theme=Theme({
|
|
18
|
+
"heading": "bold cyan", "label": "cyan", "note": "dim",
|
|
19
|
+
"success": "green", "warning": "yellow", "error": "bold red",
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_message(message: str, *, style: str = "note") -> None:
|
|
25
|
+
"""Print literal feedback with wrapping in terminals and plain redirected text."""
|
|
26
|
+
console.print(Text(message), style=style, soft_wrap=not console.is_terminal)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def loading(message: str) -> AbstractContextManager[object]:
|
|
30
|
+
"""Animate terminal requests, or print one static message when redirected."""
|
|
31
|
+
if console.is_terminal and console.is_interactive:
|
|
32
|
+
return console.status(Text(message), spinner="line")
|
|
33
|
+
print_message(message)
|
|
34
|
+
return nullcontext()
|
|
9
35
|
|
|
10
36
|
|
|
11
37
|
def print_text_summary(draft: RuleDraft) -> int:
|
|
12
38
|
"""Print a human-readable summary of a rule draft. Returns 0 after writing the summary and any next-step notes to stdout."""
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
t(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
print(
|
|
35
|
-
t(
|
|
36
|
-
"display.min_episode",
|
|
37
|
-
value=draft.min_episode if draft.min_episode is not None else t("common.none"),
|
|
38
|
-
)
|
|
39
|
-
)
|
|
40
|
-
print(t("display.save_path", value=draft.save_path or t("common.not_set")))
|
|
41
|
-
print(t("display.rule_footer"))
|
|
42
|
-
print()
|
|
43
|
-
|
|
39
|
+
fields = {
|
|
40
|
+
"keyword": draft.keyword,
|
|
41
|
+
"normalized_keyword": draft.normalized_keyword,
|
|
42
|
+
"rule_name": draft.rule_name,
|
|
43
|
+
"mikan_title": draft.mikan_title or t("common.not_found"),
|
|
44
|
+
"mikan_subgroup": draft.mikan_subgroup or t("common.not_found"),
|
|
45
|
+
"mikan_page": draft.mikan_page_url or t("common.not_found"),
|
|
46
|
+
"feed_url": draft.feed_url or t("common.not_found"),
|
|
47
|
+
"must_contain": ", ".join(draft.must_contain) or t("common.none"),
|
|
48
|
+
"must_not_contain": ", ".join(draft.must_not_contain) or t("common.none"),
|
|
49
|
+
"min_episode": draft.min_episode if draft.min_episode is not None else t("common.none"),
|
|
50
|
+
"save_path": draft.save_path or t("common.not_set"),
|
|
51
|
+
}
|
|
52
|
+
table = Table.grid(padding=(0, 2))
|
|
53
|
+
table.add_column(style="label")
|
|
54
|
+
table.add_column(overflow="fold")
|
|
55
|
+
for key, value in fields.items():
|
|
56
|
+
table.add_row(Text(t(f"display.{key}", value="").rstrip()), Text(str(value)))
|
|
57
|
+
console.print()
|
|
58
|
+
console.rule(Text(t("display.rule_header").strip("- ")), style="heading")
|
|
59
|
+
console.print(table)
|
|
60
|
+
console.print()
|
|
44
61
|
for note in draft.notes:
|
|
45
|
-
print(t("display.next_step", value=note))
|
|
62
|
+
console.print(Text(t("display.next_step", value=note)), style="note")
|
|
46
63
|
|
|
47
64
|
return 0
|
|
48
65
|
|
|
@@ -88,6 +105,27 @@ def build_feed_preview_text(
|
|
|
88
105
|
)
|
|
89
106
|
|
|
90
107
|
|
|
108
|
+
def print_feed_preview_page(
|
|
109
|
+
subgroup: MikanSubgroup,
|
|
110
|
+
feed_items: tuple[MikanFeedItem, ...],
|
|
111
|
+
*,
|
|
112
|
+
page: int,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Render a feed page with literal titles and wrapping terminal output."""
|
|
115
|
+
content = build_feed_preview_page_text(subgroup, feed_items, page=page)
|
|
116
|
+
if not console.is_terminal:
|
|
117
|
+
console.print(Text(content), soft_wrap=True)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
preview = Text()
|
|
121
|
+
for index, line in enumerate(content.splitlines(keepends=True)):
|
|
122
|
+
style = "heading" if index == 0 else ""
|
|
123
|
+
if index in (1, 2) or line.startswith(" "):
|
|
124
|
+
style = "note"
|
|
125
|
+
preview.append(line, style=style)
|
|
126
|
+
console.print(preview, overflow="fold")
|
|
127
|
+
|
|
128
|
+
|
|
91
129
|
def build_feed_preview_page_text(
|
|
92
130
|
subgroup: MikanSubgroup,
|
|
93
131
|
feed_items: tuple[MikanFeedItem, ...],
|
|
@@ -50,6 +50,9 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
50
50
|
"filters.min_episode_field": "minimum episode",
|
|
51
51
|
"filters.positive_int_required": "{field} must be a positive whole number.",
|
|
52
52
|
"draft.review_note": "Review the draft before submitting it to qBittorrent.",
|
|
53
|
+
"search.searching": "Searching Mikan...",
|
|
54
|
+
"search.loading_subgroups": "Loading subgroup feeds...",
|
|
55
|
+
"search.loading_feed": "Fetching RSS feed preview...",
|
|
53
56
|
"search.prompt": "Enter anime title or search keyword (or type 'exit' to quit): ",
|
|
54
57
|
"search.prompt_retry": "Enter another anime title or search keyword (or type 'exit' to quit): ",
|
|
55
58
|
"search.choose_candidate": "Choose the Mikan entry for '{keyword}'",
|
|
@@ -88,6 +91,8 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
88
91
|
"qb.submit.rss_limit_offer": "qBittorrent is currently keeping only {current} RSS articles per feed. Minimum-episode filters may need at least {minimum}. Increase it now?",
|
|
89
92
|
"qb.submit.rss_limit_skipped": "Continuing with qBittorrent's current RSS article limit of {current}.",
|
|
90
93
|
"qb.submit.rss_limit_updated": "qBittorrent RSS article limit updated to {minimum}.",
|
|
94
|
+
"qb.submit.checking": "Checking qBittorrent RSS configuration...",
|
|
95
|
+
"qb.submit.updating_limit": "Updating qBittorrent RSS article limit...",
|
|
91
96
|
"qb.submit.submitting": "Submitting RSS feed and download rule to qBittorrent...",
|
|
92
97
|
"qb.submit.failed": "Failed to submit to qBittorrent: {error}",
|
|
93
98
|
"qb.submit.success": "qBittorrent feed and download rule submitted and verified successfully.",
|
|
@@ -159,6 +164,9 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
159
164
|
"filters.min_episode_field": "最低集数",
|
|
160
165
|
"filters.positive_int_required": "{field}必须是正整数。",
|
|
161
166
|
"draft.review_note": "提交到 qBittorrent 前请检查规则草稿。",
|
|
167
|
+
"search.searching": "正在搜索 Mikan...",
|
|
168
|
+
"search.loading_subgroups": "正在加载字幕组订阅...",
|
|
169
|
+
"search.loading_feed": "正在获取 RSS 订阅预览...",
|
|
162
170
|
"search.prompt": "输入动画标题或搜索关键词(输入 'exit' 退出):",
|
|
163
171
|
"search.prompt_retry": "输入另一个动画标题或搜索关键词(输入 'exit' 退出):",
|
|
164
172
|
"search.choose_candidate": "为 '{keyword}' 选择 Mikan 条目",
|
|
@@ -197,6 +205,8 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
|
197
205
|
"qb.submit.rss_limit_offer": "qBittorrent 当前每个 RSS 订阅只保留 {current} 个条目。最低集数过滤可能至少需要 {minimum} 个。现在增加吗?",
|
|
198
206
|
"qb.submit.rss_limit_skipped": "将继续使用 qBittorrent 当前的 RSS 条目上限:{current}。",
|
|
199
207
|
"qb.submit.rss_limit_updated": "qBittorrent RSS 条目上限已更新为 {minimum}。",
|
|
208
|
+
"qb.submit.checking": "正在检查 qBittorrent RSS 配置...",
|
|
209
|
+
"qb.submit.updating_limit": "正在更新 qBittorrent RSS 条目上限...",
|
|
200
210
|
"qb.submit.submitting": "正在向 qBittorrent 提交 RSS 订阅和下载规则...",
|
|
201
211
|
"qb.submit.failed": "提交到 qBittorrent 失败:{error}",
|
|
202
212
|
"qb.submit.success": "qBittorrent 订阅和下载规则已提交并验证成功。",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mikancli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: CLI foundation for building qBittorrent RSS rules from anime search input.
|
|
5
5
|
Author: metails
|
|
6
6
|
Project-URL: Repository, https://github.com/JianHua-Deng/MikanCli
|
|
@@ -20,6 +20,7 @@ Classifier: Topic :: Utilities
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
Requires-Dist: InquirerPy>=0.3
|
|
23
|
+
Requires-Dist: rich>=14.0
|
|
23
24
|
|
|
24
25
|
# MikanCli
|
|
25
26
|
|
|
@@ -34,6 +35,7 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
|
|
|
34
35
|
- Search Mikan by anime title or keyword
|
|
35
36
|
- Choose from matching Bangumi results and subgroup-specific RSS feeds
|
|
36
37
|
- Preview recent RSS feed items with pagination before confirming a feed
|
|
38
|
+
- Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
|
|
37
39
|
- Build qBittorrent RSS rules with include and exclude filters
|
|
38
40
|
- Filter anime-style episode numbers at or above a minimum episode
|
|
39
41
|
- Choose and save a default download folder
|
|
@@ -88,7 +90,26 @@ python -m pip install -e .
|
|
|
88
90
|
python -m mikancli
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
|
|
93
|
+
Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
|
|
94
|
+
|
|
95
|
+
After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
|
|
96
|
+
|
|
97
|
+
## Terminal Output
|
|
98
|
+
|
|
99
|
+
Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
|
|
100
|
+
|
|
101
|
+
Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
|
|
102
|
+
|
|
103
|
+
To disable Rich colors in PowerShell:
|
|
104
|
+
|
|
105
|
+
```powershell
|
|
106
|
+
$env:NO_COLOR = "1"
|
|
107
|
+
mikancli
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
|
|
111
|
+
|
|
112
|
+
The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
|
|
92
113
|
|
|
93
114
|
## Guided Flow
|
|
94
115
|
|
|
@@ -169,7 +190,7 @@ mikancli/
|
|
|
169
190
|
core/ dataclasses, normalization, and rule-building logic
|
|
170
191
|
integrations/ Mikan and qBittorrent adapters
|
|
171
192
|
config.py user config and folder selection helpers
|
|
172
|
-
display.py
|
|
193
|
+
display.py Rich summaries, feed previews, feedback, and loading indicators
|
|
173
194
|
```
|
|
174
195
|
|
|
175
196
|
The console command is declared in `pyproject.toml`:
|
|
@@ -28,5 +28,7 @@ mikancli/integrations/mikan_parsers.py
|
|
|
28
28
|
mikancli/integrations/mikan_urls.py
|
|
29
29
|
mikancli/integrations/qbittorrent.py
|
|
30
30
|
mikancli/integrations/qbittorrent_client.py
|
|
31
|
+
tests/test_cli_json.py
|
|
32
|
+
tests/test_display.py
|
|
31
33
|
tests/test_feed_preview.py
|
|
32
34
|
tests/test_qbittorrent_episode_filter.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mikancli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "CLI foundation for building qBittorrent RSS rules from anime search input."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -27,6 +27,7 @@ classifiers = [
|
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
29
|
"InquirerPy>=0.3",
|
|
30
|
+
"rich>=14.0",
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
[tool.setuptools.packages.find]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from contextlib import redirect_stdout
|
|
3
|
+
from io import StringIO
|
|
4
|
+
from unittest import TestCase
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from mikancli.cli import entrypoint, search_flow
|
|
8
|
+
from mikancli.core.models import AppConfig, MikanBangumi, MikanSubgroup
|
|
9
|
+
from mikancli.i18n import get_language, set_language
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JsonOutputTests(TestCase):
|
|
13
|
+
def test_json_draft_bypasses_rich_feedback_and_loading(self) -> None:
|
|
14
|
+
self.addCleanup(set_language, get_language())
|
|
15
|
+
title = "[bold]Anime[/bold]"
|
|
16
|
+
bangumi = MikanBangumi(1, title, "https://example.test/anime", "https://example.test/rss")
|
|
17
|
+
subgroup = MikanSubgroup(534, "Group", "https://example.test/rss")
|
|
18
|
+
output = StringIO()
|
|
19
|
+
with (
|
|
20
|
+
patch.object(entrypoint, "load_config", return_value=AppConfig()),
|
|
21
|
+
patch.object(search_flow, "search_mikan_bangumi", return_value=(bangumi,)),
|
|
22
|
+
patch.object(search_flow, "fetch_mikan_subgroups", return_value=(subgroup,)),
|
|
23
|
+
patch.object(search_flow, "loading", side_effect=AssertionError("JSON invoked loading UI")),
|
|
24
|
+
patch.object(entrypoint, "print_message", side_effect=AssertionError("JSON invoked feedback UI")),
|
|
25
|
+
redirect_stdout(output),
|
|
26
|
+
):
|
|
27
|
+
self.assertEqual(entrypoint.main([title, "--json", "--save-path", "C:/Anime"]), 0)
|
|
28
|
+
result = json.loads(output.getvalue())
|
|
29
|
+
self.assertEqual(result["keyword"], title)
|
|
30
|
+
self.assertEqual(result["feed_url"], subgroup.feed_url)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from contextlib import redirect_stdout
|
|
2
|
+
from io import StringIO
|
|
3
|
+
from unittest import TestCase
|
|
4
|
+
from unittest.mock import ANY, MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.theme import Theme
|
|
8
|
+
|
|
9
|
+
from mikancli.core.models import RuleDraft
|
|
10
|
+
from mikancli.display import loading, print_message, print_text_summary
|
|
11
|
+
from mikancli.i18n import get_language, set_language
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DisplayTests(TestCase):
|
|
15
|
+
def test_loading_gates_animation_and_cleans_up_when_requests_fail(self) -> None:
|
|
16
|
+
literal = "[bold]正在加载[/bold] :smile:"
|
|
17
|
+
for terminal, interactive in ((False, False), (False, True), (True, False), (True, True)):
|
|
18
|
+
with self.subTest(terminal=terminal, interactive=interactive):
|
|
19
|
+
target = MagicMock(is_terminal=terminal, is_interactive=interactive)
|
|
20
|
+
failure = ValueError("request failed")
|
|
21
|
+
with patch("mikancli.display.console", target), patch("mikancli.display.print_message") as message:
|
|
22
|
+
with self.assertRaises(ValueError), loading(literal):
|
|
23
|
+
raise failure
|
|
24
|
+
if terminal and interactive:
|
|
25
|
+
target.status.assert_called_once()
|
|
26
|
+
self.assertEqual(target.status.call_args.args[0].plain, literal)
|
|
27
|
+
target.status.return_value.__exit__.assert_called_once_with(ValueError, failure, ANY)
|
|
28
|
+
message.assert_not_called()
|
|
29
|
+
else:
|
|
30
|
+
target.status.assert_not_called()
|
|
31
|
+
message.assert_called_once_with(literal)
|
|
32
|
+
|
|
33
|
+
def test_messages_preserve_literal_text_in_terminals_and_redirects(self) -> None:
|
|
34
|
+
literal = r"[bold]错误[/bold] C:\Anime\:smile: https://example.test/[title]"
|
|
35
|
+
for style in ("note", "success", "warning", "error"):
|
|
36
|
+
for interactive in (False, True):
|
|
37
|
+
with self.subTest(style=style, interactive=interactive):
|
|
38
|
+
output = StringIO()
|
|
39
|
+
terminal = Console(file=output, width=40, force_terminal=interactive,
|
|
40
|
+
color_system=None, theme=Theme({style: "red"}))
|
|
41
|
+
with patch("mikancli.display.console", terminal):
|
|
42
|
+
print_message(literal, style=style)
|
|
43
|
+
rendered = output.getvalue()
|
|
44
|
+
self.assertEqual("".join(rendered.split()), "".join(literal.split()))
|
|
45
|
+
self.assertNotIn("\x1b", rendered)
|
|
46
|
+
if not interactive:
|
|
47
|
+
self.assertEqual(rendered, literal + "\n")
|
|
48
|
+
|
|
49
|
+
def test_summary_preserves_literal_values_and_language_when_redirected(self) -> None:
|
|
50
|
+
self.addCleanup(set_language, get_language())
|
|
51
|
+
literal = r"[bold]动画[/bold] C:\Anime\:smile:"
|
|
52
|
+
url = "https://example.test/rss?title=[OnePiece]&group=534"
|
|
53
|
+
draft = RuleDraft(literal, literal, literal, (), (), feed_url=url, save_path=literal, notes=(literal,))
|
|
54
|
+
for language, label in (("en", "Keyword:"), ("zh-CN", "关键词:")):
|
|
55
|
+
with self.subTest(language=language):
|
|
56
|
+
set_language(language)
|
|
57
|
+
output = StringIO()
|
|
58
|
+
with redirect_stdout(output), patch(
|
|
59
|
+
"mikancli.display.console",
|
|
60
|
+
Console(width=120, force_terminal=False, theme=Theme({"heading": "cyan", "label": "cyan", "note": "dim"})),
|
|
61
|
+
):
|
|
62
|
+
self.assertEqual(print_text_summary(draft), 0)
|
|
63
|
+
rendered = output.getvalue()
|
|
64
|
+
self.assertEqual(rendered.count(literal), 5)
|
|
65
|
+
self.assertIn(url, rendered)
|
|
66
|
+
self.assertIn(label, rendered)
|
|
67
|
+
self.assertNotIn("\x1b", rendered)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from io import StringIO
|
|
4
|
+
from unittest import TestCase
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.theme import Theme
|
|
9
|
+
|
|
10
|
+
from mikancli.core.models import MikanFeedItem, MikanSubgroup
|
|
11
|
+
from mikancli.display import build_feed_preview_page_text, print_feed_preview_page
|
|
12
|
+
from mikancli.i18n import get_language, set_language, t
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FeedPreviewTests(TestCase):
|
|
16
|
+
def test_rich_preview_preserves_pages_and_literal_content(self) -> None:
|
|
17
|
+
self.addCleanup(set_language, get_language())
|
|
18
|
+
subgroup = MikanSubgroup(534, "[bold]天月动漫[/bold]" * 5,
|
|
19
|
+
"https://example.test/rss?title=[OnePiece]&group=534" * 2)
|
|
20
|
+
items = tuple(MikanFeedItem(title=f"[bold]动画[/bold][{i}]:smile:", content_length=1536)
|
|
21
|
+
for i in range(1, 14))
|
|
22
|
+
theme = Theme({"heading": "cyan", "note": "dim"})
|
|
23
|
+
for language in ("en", "zh-CN"):
|
|
24
|
+
set_language(language)
|
|
25
|
+
for width in (40, 120):
|
|
26
|
+
for page in (1, 2):
|
|
27
|
+
with self.subTest(language=language, width=width, page=page):
|
|
28
|
+
output = StringIO()
|
|
29
|
+
terminal = Console(file=output, width=width, force_terminal=True,
|
|
30
|
+
color_system=None, theme=theme)
|
|
31
|
+
with patch("mikancli.display.console", terminal):
|
|
32
|
+
print_feed_preview_page(subgroup, items, page=page)
|
|
33
|
+
rendered = "".join(output.getvalue().split())
|
|
34
|
+
expected = build_feed_preview_page_text(subgroup, items, page=page)
|
|
35
|
+
self.assertEqual(rendered, "".join(expected.split()))
|
|
36
|
+
self.assertEqual("[13]" in rendered, page == 2)
|
|
37
|
+
self.assertEqual("[1]" in rendered, page == 1)
|
|
38
|
+
self.assertNotIn("\x1b", rendered)
|
|
39
|
+
|
|
40
|
+
def test_redirected_empty_preview_keeps_plain_text(self) -> None:
|
|
41
|
+
subgroup = MikanSubgroup(534, "[bold]Group[/bold]", "https://example.test/rss")
|
|
42
|
+
output = StringIO()
|
|
43
|
+
with patch("mikancli.display.console", Console(file=output, width=20, force_terminal=False)):
|
|
44
|
+
print_feed_preview_page(subgroup, (), page=1)
|
|
45
|
+
self.assertEqual(output.getvalue(), build_feed_preview_page_text(subgroup, (), page=1) + "\n")
|
|
46
|
+
self.assertIn(t("display.feed_empty"), output.getvalue())
|
|
47
|
+
|
|
48
|
+
def test_feed_preview_paginates_items(self) -> None:
|
|
49
|
+
subgroup = MikanSubgroup(
|
|
50
|
+
subgroup_id=534,
|
|
51
|
+
title="天月动漫&发布组",
|
|
52
|
+
feed_url="https://example.test/rss",
|
|
53
|
+
)
|
|
54
|
+
items = tuple(
|
|
55
|
+
MikanFeedItem(title=f"[Skymoon-Raws][One Piece][{episode}][1080p]")
|
|
56
|
+
for episode in range(1126, 1141)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
first_page = build_feed_preview_page_text(
|
|
60
|
+
subgroup,
|
|
61
|
+
items,
|
|
62
|
+
page=1,
|
|
63
|
+
page_size=12,
|
|
64
|
+
)
|
|
65
|
+
second_page = build_feed_preview_page_text(
|
|
66
|
+
subgroup,
|
|
67
|
+
items,
|
|
68
|
+
page=2,
|
|
69
|
+
page_size=12,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.assertIn("Items: 15 | Page 1/2", first_page)
|
|
73
|
+
self.assertIn("[1126]", first_page)
|
|
74
|
+
self.assertNotIn("[1138]", first_page)
|
|
75
|
+
self.assertIn("Items: 15 | Page 2/2", second_page)
|
|
76
|
+
self.assertIn("[1138]", second_page)
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from unittest import TestCase
|
|
4
|
-
|
|
5
|
-
from mikancli.core.models import MikanFeedItem, MikanSubgroup
|
|
6
|
-
from mikancli.display import build_feed_preview_page_text
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class FeedPreviewTests(TestCase):
|
|
10
|
-
def test_feed_preview_paginates_items(self) -> None:
|
|
11
|
-
subgroup = MikanSubgroup(
|
|
12
|
-
subgroup_id=534,
|
|
13
|
-
title="天月动漫&发布组",
|
|
14
|
-
feed_url="https://example.test/rss",
|
|
15
|
-
)
|
|
16
|
-
items = tuple(
|
|
17
|
-
MikanFeedItem(title=f"[Skymoon-Raws][One Piece][{episode}][1080p]")
|
|
18
|
-
for episode in range(1126, 1141)
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
first_page = build_feed_preview_page_text(
|
|
22
|
-
subgroup,
|
|
23
|
-
items,
|
|
24
|
-
page=1,
|
|
25
|
-
page_size=12,
|
|
26
|
-
)
|
|
27
|
-
second_page = build_feed_preview_page_text(
|
|
28
|
-
subgroup,
|
|
29
|
-
items,
|
|
30
|
-
page=2,
|
|
31
|
-
page_size=12,
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
self.assertIn("Items: 15 | Page 1/2", first_page)
|
|
35
|
-
self.assertIn("[1126]", first_page)
|
|
36
|
-
self.assertNotIn("[1138]", first_page)
|
|
37
|
-
self.assertIn("Items: 15 | Page 2/2", second_page)
|
|
38
|
-
self.assertIn("[1138]", second_page)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|