novel-downloader 1.5.0__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/__init__.py +1 -3
  3. novel_downloader/cli/clean.py +21 -88
  4. novel_downloader/cli/config.py +26 -21
  5. novel_downloader/cli/download.py +79 -66
  6. novel_downloader/cli/export.py +17 -21
  7. novel_downloader/cli/main.py +1 -1
  8. novel_downloader/cli/search.py +62 -65
  9. novel_downloader/cli/ui.py +156 -0
  10. novel_downloader/config/__init__.py +8 -5
  11. novel_downloader/config/adapter.py +206 -209
  12. novel_downloader/config/{loader.py → file_io.py} +53 -26
  13. novel_downloader/core/__init__.py +5 -5
  14. novel_downloader/core/archived/deqixs/fetcher.py +115 -0
  15. novel_downloader/core/archived/deqixs/parser.py +132 -0
  16. novel_downloader/core/archived/deqixs/searcher.py +89 -0
  17. novel_downloader/core/{searchers/qidian.py → archived/qidian/searcher.py} +12 -20
  18. novel_downloader/core/archived/wanbengo/searcher.py +98 -0
  19. novel_downloader/core/archived/xshbook/searcher.py +93 -0
  20. novel_downloader/core/downloaders/__init__.py +3 -24
  21. novel_downloader/core/downloaders/base.py +49 -23
  22. novel_downloader/core/downloaders/common.py +191 -137
  23. novel_downloader/core/downloaders/qianbi.py +187 -146
  24. novel_downloader/core/downloaders/qidian.py +187 -141
  25. novel_downloader/core/downloaders/registry.py +4 -2
  26. novel_downloader/core/downloaders/signals.py +46 -0
  27. novel_downloader/core/exporters/__init__.py +3 -20
  28. novel_downloader/core/exporters/base.py +33 -37
  29. novel_downloader/core/exporters/common/__init__.py +1 -2
  30. novel_downloader/core/exporters/common/epub.py +15 -10
  31. novel_downloader/core/exporters/common/main_exporter.py +19 -12
  32. novel_downloader/core/exporters/common/txt.py +17 -12
  33. novel_downloader/core/exporters/epub_util.py +59 -29
  34. novel_downloader/core/exporters/linovelib/__init__.py +1 -0
  35. novel_downloader/core/exporters/linovelib/epub.py +23 -25
  36. novel_downloader/core/exporters/linovelib/main_exporter.py +8 -12
  37. novel_downloader/core/exporters/linovelib/txt.py +20 -14
  38. novel_downloader/core/exporters/qidian.py +2 -8
  39. novel_downloader/core/exporters/registry.py +4 -2
  40. novel_downloader/core/exporters/txt_util.py +7 -7
  41. novel_downloader/core/fetchers/__init__.py +54 -48
  42. novel_downloader/core/fetchers/aaatxt.py +83 -0
  43. novel_downloader/core/fetchers/{biquge/session.py → b520.py} +6 -11
  44. novel_downloader/core/fetchers/{base/session.py → base.py} +37 -46
  45. novel_downloader/core/fetchers/{biquge/browser.py → biquyuedu.py} +12 -17
  46. novel_downloader/core/fetchers/dxmwx.py +110 -0
  47. novel_downloader/core/fetchers/eightnovel.py +139 -0
  48. novel_downloader/core/fetchers/{esjzone/session.py → esjzone.py} +19 -12
  49. novel_downloader/core/fetchers/guidaye.py +85 -0
  50. novel_downloader/core/fetchers/hetushu.py +92 -0
  51. novel_downloader/core/fetchers/{qianbi/browser.py → i25zw.py} +19 -28
  52. novel_downloader/core/fetchers/ixdzs8.py +113 -0
  53. novel_downloader/core/fetchers/jpxs123.py +101 -0
  54. novel_downloader/core/fetchers/lewenn.py +83 -0
  55. novel_downloader/core/fetchers/{linovelib/session.py → linovelib.py} +12 -13
  56. novel_downloader/core/fetchers/piaotia.py +105 -0
  57. novel_downloader/core/fetchers/qbtr.py +101 -0
  58. novel_downloader/core/fetchers/{qianbi/session.py → qianbi.py} +5 -10
  59. novel_downloader/core/fetchers/{qidian/session.py → qidian.py} +56 -64
  60. novel_downloader/core/fetchers/quanben5.py +92 -0
  61. novel_downloader/core/fetchers/{base/rate_limiter.py → rate_limiter.py} +2 -2
  62. novel_downloader/core/fetchers/registry.py +5 -16
  63. novel_downloader/core/fetchers/{sfacg/session.py → sfacg.py} +7 -10
  64. novel_downloader/core/fetchers/shencou.py +106 -0
  65. novel_downloader/core/fetchers/shuhaige.py +84 -0
  66. novel_downloader/core/fetchers/tongrenquan.py +84 -0
  67. novel_downloader/core/fetchers/ttkan.py +95 -0
  68. novel_downloader/core/fetchers/wanbengo.py +83 -0
  69. novel_downloader/core/fetchers/xiaoshuowu.py +106 -0
  70. novel_downloader/core/fetchers/xiguashuwu.py +177 -0
  71. novel_downloader/core/fetchers/xs63b.py +171 -0
  72. novel_downloader/core/fetchers/xshbook.py +85 -0
  73. novel_downloader/core/fetchers/{yamibo/session.py → yamibo.py} +19 -12
  74. novel_downloader/core/fetchers/yibige.py +114 -0
  75. novel_downloader/core/interfaces/__init__.py +1 -9
  76. novel_downloader/core/interfaces/downloader.py +6 -2
  77. novel_downloader/core/interfaces/exporter.py +7 -7
  78. novel_downloader/core/interfaces/fetcher.py +6 -19
  79. novel_downloader/core/interfaces/parser.py +7 -8
  80. novel_downloader/core/interfaces/searcher.py +9 -1
  81. novel_downloader/core/parsers/__init__.py +49 -12
  82. novel_downloader/core/parsers/aaatxt.py +132 -0
  83. novel_downloader/core/parsers/b520.py +116 -0
  84. novel_downloader/core/parsers/base.py +64 -12
  85. novel_downloader/core/parsers/biquyuedu.py +133 -0
  86. novel_downloader/core/parsers/dxmwx.py +162 -0
  87. novel_downloader/core/parsers/eightnovel.py +224 -0
  88. novel_downloader/core/parsers/esjzone.py +64 -69
  89. novel_downloader/core/parsers/guidaye.py +128 -0
  90. novel_downloader/core/parsers/hetushu.py +139 -0
  91. novel_downloader/core/parsers/i25zw.py +137 -0
  92. novel_downloader/core/parsers/ixdzs8.py +186 -0
  93. novel_downloader/core/parsers/jpxs123.py +137 -0
  94. novel_downloader/core/parsers/lewenn.py +142 -0
  95. novel_downloader/core/parsers/linovelib.py +48 -64
  96. novel_downloader/core/parsers/piaotia.py +189 -0
  97. novel_downloader/core/parsers/qbtr.py +136 -0
  98. novel_downloader/core/parsers/qianbi.py +48 -50
  99. novel_downloader/core/parsers/qidian/main_parser.py +756 -48
  100. novel_downloader/core/parsers/qidian/utils/__init__.py +3 -21
  101. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +1 -1
  102. novel_downloader/core/parsers/qidian/utils/node_decryptor.py +4 -4
  103. novel_downloader/core/parsers/quanben5.py +103 -0
  104. novel_downloader/core/parsers/registry.py +5 -16
  105. novel_downloader/core/parsers/sfacg.py +38 -45
  106. novel_downloader/core/parsers/shencou.py +215 -0
  107. novel_downloader/core/parsers/shuhaige.py +111 -0
  108. novel_downloader/core/parsers/tongrenquan.py +116 -0
  109. novel_downloader/core/parsers/ttkan.py +132 -0
  110. novel_downloader/core/parsers/wanbengo.py +191 -0
  111. novel_downloader/core/parsers/xiaoshuowu.py +173 -0
  112. novel_downloader/core/parsers/xiguashuwu.py +429 -0
  113. novel_downloader/core/parsers/xs63b.py +161 -0
  114. novel_downloader/core/parsers/xshbook.py +134 -0
  115. novel_downloader/core/parsers/yamibo.py +87 -131
  116. novel_downloader/core/parsers/yibige.py +166 -0
  117. novel_downloader/core/searchers/__init__.py +34 -3
  118. novel_downloader/core/searchers/aaatxt.py +107 -0
  119. novel_downloader/core/searchers/{biquge.py → b520.py} +29 -28
  120. novel_downloader/core/searchers/base.py +112 -36
  121. novel_downloader/core/searchers/dxmwx.py +105 -0
  122. novel_downloader/core/searchers/eightnovel.py +84 -0
  123. novel_downloader/core/searchers/esjzone.py +43 -25
  124. novel_downloader/core/searchers/hetushu.py +92 -0
  125. novel_downloader/core/searchers/i25zw.py +93 -0
  126. novel_downloader/core/searchers/ixdzs8.py +107 -0
  127. novel_downloader/core/searchers/jpxs123.py +107 -0
  128. novel_downloader/core/searchers/piaotia.py +100 -0
  129. novel_downloader/core/searchers/qbtr.py +106 -0
  130. novel_downloader/core/searchers/qianbi.py +74 -40
  131. novel_downloader/core/searchers/quanben5.py +144 -0
  132. novel_downloader/core/searchers/registry.py +24 -8
  133. novel_downloader/core/searchers/shuhaige.py +124 -0
  134. novel_downloader/core/searchers/tongrenquan.py +110 -0
  135. novel_downloader/core/searchers/ttkan.py +92 -0
  136. novel_downloader/core/searchers/xiaoshuowu.py +122 -0
  137. novel_downloader/core/searchers/xiguashuwu.py +95 -0
  138. novel_downloader/core/searchers/xs63b.py +104 -0
  139. novel_downloader/locales/en.json +34 -85
  140. novel_downloader/locales/zh.json +35 -86
  141. novel_downloader/models/__init__.py +21 -22
  142. novel_downloader/models/book.py +44 -0
  143. novel_downloader/models/config.py +4 -37
  144. novel_downloader/models/login.py +1 -1
  145. novel_downloader/models/search.py +5 -0
  146. novel_downloader/resources/config/settings.toml +8 -70
  147. novel_downloader/resources/json/xiguashuwu.json +718 -0
  148. novel_downloader/utils/__init__.py +13 -24
  149. novel_downloader/utils/chapter_storage.py +5 -5
  150. novel_downloader/utils/constants.py +4 -31
  151. novel_downloader/utils/cookies.py +38 -35
  152. novel_downloader/utils/crypto_utils/__init__.py +7 -0
  153. novel_downloader/utils/crypto_utils/aes_util.py +90 -0
  154. novel_downloader/utils/crypto_utils/aes_v1.py +619 -0
  155. novel_downloader/utils/crypto_utils/aes_v2.py +1143 -0
  156. novel_downloader/utils/crypto_utils/rc4.py +54 -0
  157. novel_downloader/utils/epub/__init__.py +3 -4
  158. novel_downloader/utils/epub/builder.py +6 -6
  159. novel_downloader/utils/epub/constants.py +62 -21
  160. novel_downloader/utils/epub/documents.py +95 -201
  161. novel_downloader/utils/epub/models.py +8 -22
  162. novel_downloader/utils/epub/utils.py +73 -106
  163. novel_downloader/utils/file_utils/__init__.py +2 -23
  164. novel_downloader/utils/file_utils/io.py +53 -188
  165. novel_downloader/utils/file_utils/normalize.py +1 -7
  166. novel_downloader/utils/file_utils/sanitize.py +4 -15
  167. novel_downloader/utils/fontocr/__init__.py +5 -14
  168. novel_downloader/utils/fontocr/core.py +216 -0
  169. novel_downloader/utils/fontocr/loader.py +50 -0
  170. novel_downloader/utils/logger.py +81 -65
  171. novel_downloader/utils/network.py +17 -41
  172. novel_downloader/utils/state.py +4 -90
  173. novel_downloader/utils/text_utils/__init__.py +1 -7
  174. novel_downloader/utils/text_utils/diff_display.py +5 -7
  175. novel_downloader/utils/text_utils/text_cleaner.py +39 -30
  176. novel_downloader/utils/text_utils/truncate_utils.py +3 -14
  177. novel_downloader/utils/time_utils/__init__.py +5 -11
  178. novel_downloader/utils/time_utils/datetime_utils.py +20 -29
  179. novel_downloader/utils/time_utils/sleep_utils.py +55 -49
  180. novel_downloader/web/__init__.py +13 -0
  181. novel_downloader/web/components/__init__.py +11 -0
  182. novel_downloader/web/components/navigation.py +35 -0
  183. novel_downloader/web/main.py +66 -0
  184. novel_downloader/web/pages/__init__.py +17 -0
  185. novel_downloader/web/pages/download.py +78 -0
  186. novel_downloader/web/pages/progress.py +147 -0
  187. novel_downloader/web/pages/search.py +329 -0
  188. novel_downloader/web/services/__init__.py +17 -0
  189. novel_downloader/web/services/client_dialog.py +164 -0
  190. novel_downloader/web/services/cred_broker.py +113 -0
  191. novel_downloader/web/services/cred_models.py +35 -0
  192. novel_downloader/web/services/task_manager.py +264 -0
  193. novel_downloader-2.0.1.dist-info/METADATA +172 -0
  194. novel_downloader-2.0.1.dist-info/RECORD +206 -0
  195. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/entry_points.txt +1 -1
  196. novel_downloader/core/downloaders/biquge.py +0 -29
  197. novel_downloader/core/downloaders/esjzone.py +0 -29
  198. novel_downloader/core/downloaders/linovelib.py +0 -29
  199. novel_downloader/core/downloaders/sfacg.py +0 -29
  200. novel_downloader/core/downloaders/yamibo.py +0 -29
  201. novel_downloader/core/exporters/biquge.py +0 -22
  202. novel_downloader/core/exporters/esjzone.py +0 -22
  203. novel_downloader/core/exporters/qianbi.py +0 -22
  204. novel_downloader/core/exporters/sfacg.py +0 -22
  205. novel_downloader/core/exporters/yamibo.py +0 -22
  206. novel_downloader/core/fetchers/base/__init__.py +0 -14
  207. novel_downloader/core/fetchers/base/browser.py +0 -422
  208. novel_downloader/core/fetchers/biquge/__init__.py +0 -14
  209. novel_downloader/core/fetchers/esjzone/__init__.py +0 -14
  210. novel_downloader/core/fetchers/esjzone/browser.py +0 -209
  211. novel_downloader/core/fetchers/linovelib/__init__.py +0 -14
  212. novel_downloader/core/fetchers/linovelib/browser.py +0 -198
  213. novel_downloader/core/fetchers/qianbi/__init__.py +0 -14
  214. novel_downloader/core/fetchers/qidian/__init__.py +0 -14
  215. novel_downloader/core/fetchers/qidian/browser.py +0 -326
  216. novel_downloader/core/fetchers/sfacg/__init__.py +0 -14
  217. novel_downloader/core/fetchers/sfacg/browser.py +0 -194
  218. novel_downloader/core/fetchers/yamibo/__init__.py +0 -14
  219. novel_downloader/core/fetchers/yamibo/browser.py +0 -234
  220. novel_downloader/core/parsers/biquge.py +0 -139
  221. novel_downloader/core/parsers/qidian/book_info_parser.py +0 -90
  222. novel_downloader/core/parsers/qidian/chapter_encrypted.py +0 -528
  223. novel_downloader/core/parsers/qidian/chapter_normal.py +0 -157
  224. novel_downloader/core/parsers/qidian/chapter_router.py +0 -68
  225. novel_downloader/core/parsers/qidian/utils/helpers.py +0 -114
  226. novel_downloader/models/chapter.py +0 -25
  227. novel_downloader/models/types.py +0 -13
  228. novel_downloader/tui/__init__.py +0 -7
  229. novel_downloader/tui/app.py +0 -32
  230. novel_downloader/tui/main.py +0 -17
  231. novel_downloader/tui/screens/__init__.py +0 -14
  232. novel_downloader/tui/screens/home.py +0 -198
  233. novel_downloader/tui/screens/login.py +0 -74
  234. novel_downloader/tui/styles/home_layout.tcss +0 -79
  235. novel_downloader/tui/widgets/richlog_handler.py +0 -24
  236. novel_downloader/utils/cache.py +0 -24
  237. novel_downloader/utils/crypto_utils.py +0 -71
  238. novel_downloader/utils/fontocr/hash_store.py +0 -280
  239. novel_downloader/utils/fontocr/hash_utils.py +0 -103
  240. novel_downloader/utils/fontocr/model_loader.py +0 -69
  241. novel_downloader/utils/fontocr/ocr_v1.py +0 -315
  242. novel_downloader/utils/fontocr/ocr_v2.py +0 -764
  243. novel_downloader/utils/fontocr/ocr_v3.py +0 -744
  244. novel_downloader-1.5.0.dist-info/METADATA +0 -196
  245. novel_downloader-1.5.0.dist-info/RECORD +0 -164
  246. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/WHEEL +0 -0
  247. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/licenses/LICENSE +0 -0
  248. {novel_downloader-1.5.0.dist-info → novel_downloader-2.0.1.dist-info}/top_level.txt +0 -0
@@ -18,7 +18,7 @@ from .search import register_search_subcommand
18
18
 
19
19
 
20
20
  def cli_main() -> None:
21
- parser = argparse.ArgumentParser(description=t("cli_help"))
21
+ parser = argparse.ArgumentParser(description=t("help_cli"))
22
22
  subparsers = parser.add_subparsers(dest="command", required=True)
23
23
 
24
24
  register_clean_subcommand(subparsers)
@@ -3,13 +3,18 @@
3
3
  novel_downloader.cli.search
4
4
  ---------------------------
5
5
 
6
+ Search across supported sites, let the user pick one result, then
7
+ hand off to the download flow.
6
8
  """
7
9
 
10
+ from __future__ import annotations
11
+
8
12
  import asyncio
9
13
  from argparse import Namespace, _SubParsersAction
10
14
  from collections.abc import Sequence
11
15
  from pathlib import Path
12
16
 
17
+ from novel_downloader.cli import ui
13
18
  from novel_downloader.cli.download import _download
14
19
  from novel_downloader.config import ConfigAdapter, load_config
15
20
  from novel_downloader.core import search
@@ -18,106 +23,98 @@ from novel_downloader.utils.i18n import t
18
23
 
19
24
 
20
25
  def register_search_subcommand(subparsers: _SubParsersAction) -> None: # type: ignore
26
+ """Register the `search` subcommand and its options."""
21
27
  parser = subparsers.add_parser("search", help=t("help_search"))
22
28
 
23
29
  parser.add_argument(
24
- "--site",
25
- "-s",
26
- action="append",
27
- metavar="SITE",
28
- help=t("help_search_sites"),
29
- )
30
- parser.add_argument(
31
- "keyword",
32
- help=t("help_search_keyword"),
30
+ "--site", "-s", action="append", metavar="SITE", help=t("search_sites_help")
33
31
  )
32
+ parser.add_argument("keyword", help=t("search_keyword_help"))
33
+ parser.add_argument("--config", type=str, help=t("help_config"))
34
34
  parser.add_argument(
35
- "--config",
36
- type=str,
37
- help=t("help_config"),
38
- )
39
- parser.add_argument(
40
- "--limit",
41
- "-l",
42
- type=int,
43
- default=10,
44
- metavar="N",
45
- help=t("help_search_limit"),
35
+ "--limit", "-l", type=int, default=20, metavar="N", help=t("search_limit_help")
46
36
  )
47
37
  parser.add_argument(
48
38
  "--site-limit",
49
39
  type=int,
50
40
  default=5,
51
41
  metavar="M",
52
- help=t("help_search_site_limit"),
42
+ help=t("search_site_limit_help"),
43
+ )
44
+ parser.add_argument(
45
+ "--timeout",
46
+ type=float,
47
+ default=5.0,
48
+ metavar="SECS",
49
+ help=t("search_timeout_help", secs="5.0"),
53
50
  )
54
51
 
55
52
  parser.set_defaults(func=handle_search)
56
53
 
57
54
 
58
55
  def handle_search(args: Namespace) -> None:
59
- """
60
- Handler for the `search` subcommand. Loads config, runs the search,
61
- prompts the user to pick one result, then kicks off download.
62
- """
56
+ """Handle the `search` subcommand."""
63
57
  sites: Sequence[str] | None = args.site or None
64
58
  keyword: str = args.keyword
65
59
  overall_limit = max(1, args.limit)
66
60
  per_site_limit = max(1, args.site_limit)
61
+ timeout = max(0.1, float(args.timeout))
67
62
  config_path: Path | None = Path(args.config) if args.config else None
68
63
 
69
64
  try:
70
65
  config_data = load_config(config_path)
71
66
  except Exception as e:
72
- print(t("download_config_load_fail", err=str(e)))
67
+ ui.error(t("download_config_load_fail", err=str(e)))
73
68
  return
74
69
 
75
- results = search(
76
- keyword=keyword,
77
- sites=sites,
78
- limit=overall_limit,
79
- per_site_limit=per_site_limit,
80
- )
70
+ async def _run() -> None:
71
+ results = await search(
72
+ keyword=keyword,
73
+ sites=sites,
74
+ limit=overall_limit,
75
+ per_site_limit=per_site_limit,
76
+ timeout=timeout,
77
+ )
81
78
 
82
- chosen = _prompt_user_select(results)
83
- if chosen is None:
84
- # user cancelled or no valid choice
85
- return
79
+ chosen = _prompt_user_select(results)
80
+ if chosen is None:
81
+ return
82
+
83
+ adapter = ConfigAdapter(config=config_data, site=chosen["site"])
84
+ books: list[BookConfig] = [{"book_id": chosen["book_id"]}]
85
+ await _download(adapter, chosen["site"], books)
86
86
 
87
- adapter = ConfigAdapter(config=config_data, site=chosen["site"])
88
- books: list[BookConfig] = [{"book_id": chosen["book_id"]}]
89
- asyncio.run(_download(adapter, chosen["site"], books))
87
+ asyncio.run(_run())
90
88
 
91
89
 
92
- def _prompt_user_select(
93
- results: Sequence[SearchResult],
94
- max_attempts: int = 3,
95
- ) -> SearchResult | None:
90
+ def _prompt_user_select(results: Sequence[SearchResult]) -> SearchResult | None:
96
91
  """
97
- Display a numbered list of results and prompt the user to pick one.
92
+ Show a Rich table of results and ask the user to pick one by index.
98
93
 
99
- :param results: A list of SearchResult dicts.
100
- :param max_attempts: How many bad inputs to tolerate before giving up.
101
- :return: The chosen SearchResult, or None if cancelled/failed.
94
+ :param results: A sequence of SearchResult dicts.
95
+ :return: The chosen SearchResult, or None if cancelled/no results.
102
96
  """
103
97
  if not results:
104
- print(t("no_results"))
98
+ ui.warn(t("no_results"))
105
99
  return None
106
100
 
107
- # Show choices
108
- for i, r in enumerate(results, start=1):
109
- print(f"[{i}] {r['title']} - {r['author']} ({r['site']}, id: {r['book_id']})")
110
-
111
- attempts = 0
112
- while attempts < max_attempts:
113
- choice = input(t("prompt_select_index")).strip()
114
- if choice == "":
115
- return None
116
- if choice.isdigit():
117
- idx = int(choice)
118
- if 1 <= idx <= len(results):
119
- return results[idx - 1]
120
- print(t("invalid_selection"))
121
- attempts += 1
122
-
123
- return None
101
+ columns = ["#", "Title", "Author", "Latest", "Updated", "Site", "Book ID"]
102
+ rows = []
103
+ for i, r in enumerate(results, 1):
104
+ rows.append(
105
+ [
106
+ str(i),
107
+ r["title"],
108
+ r["author"],
109
+ r["latest_chapter"],
110
+ r["update_date"],
111
+ r["site"],
112
+ r["book_id"],
113
+ ]
114
+ )
115
+ ui.render_table("Search Results", columns, rows)
116
+
117
+ idx = ui.select_index(t("prompt_select_index"), len(results))
118
+ if idx is None:
119
+ return None
120
+ return results[idx - 1]
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.cli.ui
4
+ -----------------------
5
+
6
+ A small set of Rich-based helpers to keep CLI presentation and prompts
7
+ consistent across subcommands.
8
+
9
+ Public API:
10
+ * info, success, warn, error
11
+ * confirm
12
+ * prompt, prompt_password
13
+ * render_table
14
+ * select_index
15
+ * print_progress
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable, Sequence
21
+
22
+ from rich.console import Console
23
+ from rich.prompt import Confirm, Prompt
24
+ from rich.table import Table
25
+
26
+ _CONSOLE = Console()
27
+
28
+
29
+ def info(message: str) -> None:
30
+ """Print a neutral informational message."""
31
+ _CONSOLE.print(message)
32
+
33
+
34
+ def success(message: str) -> None:
35
+ """Print a success message in a friendly color."""
36
+ _CONSOLE.print(f"[green]{message}[/]")
37
+
38
+
39
+ def warn(message: str) -> None:
40
+ """Print a warning message."""
41
+ _CONSOLE.print(f"[yellow]{message}[/]")
42
+
43
+
44
+ def error(message: str) -> None:
45
+ """Print an error message."""
46
+ _CONSOLE.print(f"[red]{message}[/]")
47
+
48
+
49
+ def confirm(message: str, *, default: bool = False) -> bool:
50
+ """
51
+ Ask a yes/no question.
52
+
53
+ :param message: The question to display (without [y/N] suffix).
54
+ :param default: Default choice (pressing Enter = Yes if True, No if False).
55
+ :return: True if user confirms (Yes), otherwise False.
56
+ """
57
+ try:
58
+ result: bool = Confirm.ask(f"[bold]{message}[/bold]", default=default)
59
+ return result
60
+ except (KeyboardInterrupt, EOFError):
61
+ warn("Cancelled.")
62
+ return False
63
+
64
+
65
+ def prompt(message: str, *, default: str | None = None) -> str:
66
+ """
67
+ Prompt user for a line of text.
68
+
69
+ :param message: Prompt message.
70
+ :param default: Default value if the user presses Enter.
71
+ :return: The user's input.
72
+ """
73
+ try:
74
+ result: str = Prompt.ask(message, default=default or "", show_default=False)
75
+ return result
76
+ except (KeyboardInterrupt, EOFError):
77
+ warn("Cancelled.")
78
+ return default or ""
79
+
80
+
81
+ def prompt_password(message: str) -> str:
82
+ """
83
+ Prompt user for a password/secret value (no echo).
84
+
85
+ :param message: Prompt message.
86
+ :return: The user's input (may be empty).
87
+ """
88
+ try:
89
+ result: str = Prompt.ask(message, password=True)
90
+ return result
91
+ except (KeyboardInterrupt, EOFError):
92
+ warn("Cancelled.")
93
+ return ""
94
+
95
+
96
+ def render_table(
97
+ title: str,
98
+ columns: Sequence[str],
99
+ rows: Iterable[Sequence[str]],
100
+ ) -> None:
101
+ """
102
+ Render a simple full-width table.
103
+
104
+ :param title: Table title.
105
+ :param columns: Column names.
106
+ :param rows: Row data; each row must have the same length as `columns`.
107
+ """
108
+ table = Table(title=title, show_lines=True, expand=True)
109
+ for col in columns:
110
+ table.add_column(col, overflow="fold")
111
+ for row in rows:
112
+ table.add_row(*[str(x) for x in row])
113
+ _CONSOLE.print(table)
114
+
115
+
116
+ def select_index(prompt_text: str, total: int) -> int | None:
117
+ """
118
+ Prompt user to select an index in [1..total]. Empty input cancels.
119
+
120
+ :param prompt_text: Displayed prompt (e.g., 'Select index').
121
+ :param total: Maximum valid index (minimum is 1).
122
+ :return: Selected 1-based index, or None if user cancels.
123
+ """
124
+ if total <= 0:
125
+ return None
126
+ valid_choices = [str(i) for i in range(1, total + 1)]
127
+ choice = Prompt.ask(
128
+ prompt_text,
129
+ choices=valid_choices + [""],
130
+ show_choices=False,
131
+ default="",
132
+ show_default=False,
133
+ ).strip()
134
+ if not choice:
135
+ return None
136
+ return int(choice)
137
+
138
+
139
+ def print_progress(
140
+ done: int,
141
+ total: int,
142
+ *,
143
+ prefix: str = "Progress",
144
+ unit: str = "item",
145
+ ) -> None:
146
+ """
147
+ Print a lightweight progress line.
148
+
149
+ :param done: Completed count.
150
+ :param total: Total count.
151
+ :param prefix: Text prefix shown before numbers.
152
+ :param unit: Logical unit name (e.g., 'item').
153
+ """
154
+ total = max(1, total)
155
+ pct = done / total * 100.0
156
+ _CONSOLE.print(f"[dim]{prefix}[/] {done}/{total} {unit} ({pct:.2f}%)")
@@ -4,17 +4,20 @@ novel_downloader.config
4
4
  -----------------------
5
5
 
6
6
  Unified interface for loading and adapting configuration files.
7
-
8
- This module provides:
9
- - load_config: loads YAML config from file path with fallback support
10
- - ConfigAdapter: maps raw config + site name to structured config models
11
7
  """
12
8
 
13
9
  __all__ = [
10
+ "get_config_value",
14
11
  "load_config",
12
+ "save_config",
15
13
  "save_config_file",
16
14
  "ConfigAdapter",
17
15
  ]
18
16
 
19
17
  from .adapter import ConfigAdapter
20
- from .loader import load_config, save_config_file
18
+ from .file_io import (
19
+ get_config_value,
20
+ load_config,
21
+ save_config,
22
+ save_config_file,
23
+ )