feedscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. feedscope/__init__.py +3 -0
  2. feedscope/__main__.py +4 -0
  3. feedscope/cli.py +154 -0
  4. feedscope/core/__init__.py +1 -0
  5. feedscope/core/browser.py +367 -0
  6. feedscope/core/config.py +367 -0
  7. feedscope/core/const.py +64 -0
  8. feedscope/core/display.py +62 -0
  9. feedscope/core/http.py +104 -0
  10. feedscope/core/i18n/__init__.py +202 -0
  11. feedscope/core/i18n/en.toml +447 -0
  12. feedscope/core/i18n/zh-CN.toml +444 -0
  13. feedscope/core/media.py +150 -0
  14. feedscope/core/models.py +86 -0
  15. feedscope/core/registry.py +53 -0
  16. feedscope/core/samples.py +68 -0
  17. feedscope/core/source.py +668 -0
  18. feedscope/core/text.py +54 -0
  19. feedscope/core/timefmt.py +53 -0
  20. feedscope/core/tree.py +134 -0
  21. feedscope/doctor.py +54 -0
  22. feedscope/login.py +168 -0
  23. feedscope/probe.py +131 -0
  24. feedscope/report.py +19 -0
  25. feedscope/sources/__init__.py +38 -0
  26. feedscope/sources/hn/__init__.py +66 -0
  27. feedscope/sources/hn/fetch.py +82 -0
  28. feedscope/sources/hn/parse.py +154 -0
  29. feedscope/sources/reddit/__init__.py +178 -0
  30. feedscope/sources/reddit/fetch.py +181 -0
  31. feedscope/sources/reddit/parse.py +318 -0
  32. feedscope/sources/rss/__init__.py +64 -0
  33. feedscope/sources/rss/fetch.py +94 -0
  34. feedscope/sources/rss/parse.py +116 -0
  35. feedscope/sources/telegram/__init__.py +61 -0
  36. feedscope/sources/telegram/fetch.py +79 -0
  37. feedscope/sources/telegram/parse.py +182 -0
  38. feedscope/sources/v2ex/__init__.py +64 -0
  39. feedscope/sources/v2ex/fetch.py +46 -0
  40. feedscope/sources/v2ex/parse.py +116 -0
  41. feedscope/sources/x/__init__.py +68 -0
  42. feedscope/sources/x/fetch.py +490 -0
  43. feedscope/sources/x/parse.py +535 -0
  44. feedscope/sources/zhihu/__init__.py +81 -0
  45. feedscope/sources/zhihu/fetch.py +402 -0
  46. feedscope/sources/zhihu/parse.py +235 -0
  47. feedscope/subscriptions.py +423 -0
  48. feedscope/ui/__init__.py +10 -0
  49. feedscope/ui/account.py +125 -0
  50. feedscope/ui/app.py +105 -0
  51. feedscope/ui/dialogs.py +127 -0
  52. feedscope/ui/feedback.py +64 -0
  53. feedscope/ui/focus_nav.py +113 -0
  54. feedscope/ui/labels.py +38 -0
  55. feedscope/ui/layout.py +66 -0
  56. feedscope/ui/list_render.py +42 -0
  57. feedscope/ui/screens/__init__.py +1 -0
  58. feedscope/ui/screens/answer.py +296 -0
  59. feedscope/ui/screens/detail.py +274 -0
  60. feedscope/ui/screens/language.py +59 -0
  61. feedscope/ui/screens/main.py +736 -0
  62. feedscope/ui/screens/main_setup.py +226 -0
  63. feedscope/ui/screens/reading.py +384 -0
  64. feedscope/ui/screens/source_setup.py +217 -0
  65. feedscope/ui/screens/subscriptions.py +196 -0
  66. feedscope/ui/theme.py +45 -0
  67. feedscope/ui/wrap.py +157 -0
  68. feedscope-0.1.0.dist-info/METADATA +302 -0
  69. feedscope-0.1.0.dist-info/RECORD +72 -0
  70. feedscope-0.1.0.dist-info/WHEEL +4 -0
  71. feedscope-0.1.0.dist-info/entry_points.txt +2 -0
  72. feedscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,202 @@
1
+ """国际化(i18n):文案目录的发现、加载与查找。
2
+
3
+ - 每种语言一个 TOML 文件(本包内 <code>.toml,如 zh-CN.toml / en.toml):
4
+ 文案按语言分文件维护,新增语言 = 放一个新文件([meta] name 声明自称),
5
+ 语言选择屏自动发现,零代码改动。
6
+ - 键为点分嵌套域(main.empty_no_source),占位符 {name} 走 str.format;
7
+ 文案里要出现字面花括号得双写 {{。
8
+ - 回退链:当前语言 -> zh-CN(基准语言,文案以它对账做全)-> 键名(缺键
9
+ 记 warning,交付前由奇偶校验测试清零,见 tests/test_i18n.py)。
10
+ - 语言随进程固定:启动时按配置设定(cli 组装点 / TUI 首启),此后全程
11
+ 只读。Reddit 详情解析等工作线程并发调 t() 只读已加载目录,无线程安全
12
+ 问题——这也是「修改语言重启生效」的依据:解析期烙进数据的数字/单位
13
+ 文案(如「4.5 万」)与界面文案天然同语言。
14
+
15
+ 本模块在 core 最底层(架构分层:sources/parse 只许 import core),供
16
+ core、sources、顶层业务、ui、cli 五层共用。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import re
23
+ import tomllib
24
+ from dataclasses import dataclass
25
+ from importlib.resources import files
26
+ from locale import getlocale
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ BASE_LANGUAGE = "zh-CN" # 基准语言:回退链的底、奇偶校验的对账方(文案最全的对账基准)
31
+ DEFAULT_LANGUAGE = "en" # 语言未设置时的缺省(默认英文;中文用户首启语言屏预选即 zh-CN)
32
+
33
+ _META = "meta" # 语言文件里的元信息表(name 等),不是文案,不参与奇偶校验
34
+
35
+ _current = DEFAULT_LANGUAGE
36
+ _loaded = False
37
+ _names: dict[str, str] = {} # code -> 自称(选择屏显示用)
38
+ _catalogs: dict[str, dict[str, str]] = {} # code -> 扁平点分键 -> 文案
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Language:
43
+ """一种可选语言:code 即语言文件名(去 .toml),name 为其自称。"""
44
+
45
+ code: str
46
+ name: str
47
+
48
+
49
+ def _language_files() -> list[tuple[str, dict]]:
50
+ """读包内全部语言 TOML:(code, 数据),基准语言排最前、其余按文件名稳定排序。
51
+
52
+ 坏文件记警告跳过——新增语言文件写坏不该挡启动(与 core.config 同一
53
+ 容错粒度)。
54
+ """
55
+ out: list[tuple[str, dict]] = []
56
+ for entry in files("feedscope.core.i18n").iterdir():
57
+ if not entry.name.endswith(".toml"):
58
+ continue
59
+ try:
60
+ with entry.open("rb") as f:
61
+ data = tomllib.load(f)
62
+ except (OSError, tomllib.TOMLDecodeError) as exc:
63
+ logger.warning("语言文件 %s 无法解析,已跳过:%s", entry.name, exc)
64
+ continue
65
+ out.append((entry.name[: -len(".toml")], data))
66
+ out.sort(key=lambda item: (item[0] != BASE_LANGUAGE, item[0]))
67
+ return out
68
+
69
+
70
+ def _flatten(table: dict, prefix: str = "") -> dict[str, str]:
71
+ """嵌套表 -> 扁平点分键 -> 文案;非字符串/非表的值不是文案,跳过。"""
72
+ out: dict[str, str] = {}
73
+ for key, value in table.items():
74
+ dotted = f"{prefix}.{key}" if prefix else key
75
+ if isinstance(value, str):
76
+ out[dotted] = value
77
+ elif isinstance(value, dict):
78
+ out.update(_flatten(value, dotted))
79
+ return out
80
+
81
+
82
+ def _ensure_loaded() -> None:
83
+ """首次使用时加载全部语言文件(之后只读;语言文件极小,全量载入最简)。"""
84
+ global _loaded
85
+ if _loaded:
86
+ return
87
+ for code, data in _language_files():
88
+ meta = data.get(_META)
89
+ name = meta.get("name") if isinstance(meta, dict) else None
90
+ if not isinstance(name, str) or not name:
91
+ logger.warning("语言文件 %s 缺 [meta] name,已跳过", code)
92
+ continue
93
+ _names[code] = name
94
+ _catalogs[code] = _flatten({k: v for k, v in data.items() if k != _META})
95
+ if BASE_LANGUAGE not in _catalogs:
96
+ logger.warning("基准语言 %s 缺失:回退链只剩键名", BASE_LANGUAGE)
97
+ _loaded = True
98
+
99
+
100
+ def available() -> list[Language]:
101
+ """全部可选语言(基准语言在前,其余按 code 排序):语言选择屏的数据源。"""
102
+ _ensure_loaded()
103
+ return [Language(code, name) for code, name in _names.items()]
104
+
105
+
106
+ def name_of(code: str) -> str:
107
+ """语言代码 -> 自称(如 zh-CN -> 简体中文);未知代码回退原码。"""
108
+ _ensure_loaded()
109
+ return _names.get(code, code)
110
+
111
+
112
+ def set_language(code: str) -> None:
113
+ """设定当前语言(进程内全局)。未知代码回退缺省并记警告(配置手写坏值
114
+ 不挡启动)。"""
115
+ global _current
116
+ _ensure_loaded()
117
+ if code in _names:
118
+ _current = code
119
+ else:
120
+ logger.warning("未知语言代码 %r,回退 %s", code, DEFAULT_LANGUAGE)
121
+ _current = DEFAULT_LANGUAGE
122
+
123
+
124
+ def language() -> str:
125
+ """当前语言代码(i18n 层不读配置:设定时机在组装点 / 首启流程)。"""
126
+ return _current
127
+
128
+
129
+ def t(key: str, /, **kwargs: object) -> str:
130
+ """查文案并填占位符:当前语言 -> 基准语言 -> 键名(缺键记 warning)。
131
+
132
+ 无参调用也走 format:{{ }} 转义与占位符同一条规则,行为不随调用方
133
+ 传不传参而变。不含点号的字符串按最终文案直通(不查不警)——文案键
134
+ 恒为点分域,直通语义给测试替身的裸文案与用户数据(订阅名等)留路:
135
+ 源词汇表的值可能是键(真源)也可能是裸文案(stub),统一走 t() 不分叉。
136
+ """
137
+ _ensure_loaded()
138
+ if "." not in key:
139
+ return key.format(**kwargs)
140
+ text = _catalogs.get(_current, {}).get(key)
141
+ if text is None and _current != BASE_LANGUAGE:
142
+ text = _catalogs.get(BASE_LANGUAGE, {}).get(key)
143
+ if text is None:
144
+ logger.warning("文案键缺失:%s", key)
145
+ return key
146
+ return text.format(**kwargs)
147
+
148
+
149
+ def catalog_keys(code: str) -> set[str]:
150
+ """某语言的全量文案键(奇偶校验 / 引用存在性测试用)。"""
151
+ _ensure_loaded()
152
+ return set(_catalogs.get(code, {}))
153
+
154
+
155
+ def raw(key: str) -> str:
156
+ """某键的未填充模板(占位符原样保留)。
157
+
158
+ 极少数「写出去再读回来」的场景用(知乎 meta 里的回答总数回读正则由
159
+ 模板生成,与写入同键同语言);日常展示一律走 t()。
160
+ """
161
+ _ensure_loaded()
162
+ text = _catalogs.get(_current, {}).get(key)
163
+ if text is None:
164
+ text = _catalogs.get(BASE_LANGUAGE, {}).get(key)
165
+ if text is None:
166
+ logger.warning("文案键缺失:%s", key)
167
+ return key
168
+ return text
169
+
170
+
171
+ def has(key: str) -> bool:
172
+ """键是否在当前或基准目录里。
173
+
174
+ BINDINGS 的键式描述解析用(ui/labels.py):description 不是键的(源
175
+ 单位词 relabel 后的最终文案)原样直通,不能当键硬翻。
176
+ """
177
+ _ensure_loaded()
178
+ return key in _catalogs.get(_current, {}) or key in _catalogs.get(BASE_LANGUAGE, {})
179
+
180
+
181
+ def messages(code: str) -> dict[str, str]:
182
+ """某语言的全量文案(键 -> 文本;测试与工具用,浅拷贝防误改目录)。"""
183
+ _ensure_loaded()
184
+ return dict(_catalogs.get(code, {}))
185
+
186
+
187
+ def default_language() -> str:
188
+ """按系统 locale 猜语言(语言屏的预选项):zh* -> zh-CN,其余 en;
189
+ 取不到 locale 用缺省(英文)。"""
190
+ try:
191
+ loc = getlocale()[0] or ""
192
+ except ValueError: # 个别环境 LANG 是坏值(getlocale 会抛)
193
+ loc = ""
194
+ if not loc:
195
+ return DEFAULT_LANGUAGE
196
+ return BASE_LANGUAGE if loc.startswith("zh") else "en"
197
+
198
+
199
+ def placeholders(text: str) -> set[str]:
200
+ """文案里的占位符名集合({{ }} 转义不算):奇偶校验用,非运行时路径。"""
201
+ stripped = text.replace("{{", "").replace("}}", "")
202
+ return set(re.findall(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", stripped))
@@ -0,0 +1,447 @@
1
+ # FeedScope message catalog · English
2
+ #
3
+ # - Dotted nested keys, looked up via core.i18n.t("domain.key", name=value)
4
+ # - Placeholders {name} use str.format; literal braces are doubled {{ }}
5
+ # - Every language file must keep an identical key tree (parity test in
6
+ # tests/test_i18n.py) — a missing translation turns the CI red
7
+ # - New language: copy this file to <code>.toml, set [meta] name, translate
8
+
9
+ [meta]
10
+ name = "English"
11
+
12
+ [common]
13
+ enum_comma = ", "
14
+
15
+ [app]
16
+ exit_onboarding_cancel = "Setup not completed — run feedscope again to pick sources"
17
+ exit_no_sources = "no sources registered"
18
+
19
+ title = "FeedScope"
20
+
21
+ [bindings]
22
+ language = "Language"
23
+
24
+ # The language picker is the meta-control ("choose a language" can't be said in a
25
+ # language yet): title/hint are bilingual and identical in every catalog
26
+ quit = "Quit"
27
+ cancel = "Cancel"
28
+ back = "Back"
29
+ add = "Add"
30
+ remove = "Remove"
31
+ refresh = "Refresh"
32
+ browser = "Browser"
33
+ manage_sources = "Sources"
34
+ account = "Account"
35
+ more = "More"
36
+ sidebar = "Sidebar"
37
+ switch_source = "Switch"
38
+
39
+ [language]
40
+ title = "选择语言 / Choose Language"
41
+ hint = "Enter 确认 · Esc 取消 / Enter to confirm · Esc to cancel"
42
+
43
+ [main]
44
+ # Bilingual by design (same value in zh-CN.toml): the reader may not read the
45
+ # current UI language, so the restart hint must land in both directions
46
+ language_saved_restart = "已保存,重启 feedscope 后生效 · Saved — restart feedscope to apply"
47
+ language_unchanged = "Language unchanged: already {name}"
48
+ hint_no_subscriptions = "{label} is enabled but has no subscriptions yet — pick it under F2 and press → to add one"
49
+
50
+ # Error exits of the source & request layers (SourceError messages, shown as-is)
51
+ sidebar_no_sources = "(no sources enabled — F2 to manage)"
52
+ sidebar_no_feeds = "(no feeds)"
53
+ empty_no_sources = "no sources enabled · press F2 to manage"
54
+ toast_no_sources = "no sources enabled · F2 to manage"
55
+ empty_no_content = "nothing here · F5 to refresh · Tab or click a top tab to switch sites"
56
+ empty_load_failed = "load failed: {text}"
57
+ status_signed_out = "signed out"
58
+ hint_login = "{error} · Ctrl+U to sign in"
59
+ status_count = "{n} items"
60
+ status_more_loading = "{n} items · loading more…"
61
+ status_more_failed = "{n} items · load more failed"
62
+ status_no_more = "{n} items · no more"
63
+ status_no_new = "{n} items · nothing new this round"
64
+ toast_no_more = "no more"
65
+ toast_no_new_retry = "nothing new this round — press Ctrl+N again later to retry"
66
+ toast_last_feed = "already on the last feed"
67
+ toast_no_selection = "no item selected"
68
+ toast_nothing_to_load = "nothing more to load in this list"
69
+ toast_cjk_input = "CJK input detected: all shortcuts are non-printing keys and unaffected by the IME"
70
+ hint_top_bar = "top bar: ←→ switch sites, ↓ into the sidebar"
71
+ hint_sidebar = "left sidebar: ↑↓ switch feeds, → back to the list"
72
+ saved_n_sources = "saved: {n} sources enabled"
73
+ saved_zero_sources = "saved: no sources enabled right now (F2 to re-enable)"
74
+ [errors]
75
+ zero_items = "{label} parsed 0 items — the site may have changed (feedscope probe can re-check)"
76
+ zero_items_with_feed = "{label} \"{feed}\": parsed 0 items — the site may have changed (feedscope probe can re-check)"
77
+ merge_one_failed = "{label}: {error}"
78
+ comments_unsupported = "{name} does not support the comment layer"
79
+ comment_out_of_range = "{label}: that comment is not loaded yet — press Ctrl+N to load more floors first"
80
+ login_required = "{label} requires login to view {what}"
81
+ unknown_feed = "unknown feed: {name}"
82
+ detail_unsupported = "{label} does not support in-terminal detail yet"
83
+ no_subscriptions_hint = "{label} has no {sub}s yet — select {label} under F2 and press → to add one"
84
+ all_feeds_failed = "{label} \"{feed}\": every subscription failed ({n} total): {errors}"
85
+ http_timeout = "{source} request timed out ({seconds}s): {url}"
86
+ http_status = "{source} request failed (HTTP {code}): {url}"
87
+ http_failed = "{source} request failed: {error}"
88
+ not_json = "{source} did not return JSON"
89
+ bad_structure = "{source} returned an unexpected structure — the API may have changed"
90
+ bad_structure_short = "{source} returned an unexpected structure"
91
+ bad_structure_not_object = "{source} returned an unexpected structure (not a JSON object)"
92
+ what_feed = "the \"{label}\" feed"
93
+ what_post_detail = "post details"
94
+ what_timeline = "the \"{label}\" timeline"
95
+ what_tweet_detail = "tweet details"
96
+ hn_no_hits = "HN \"{label}\": response has no hits — the API may have changed"
97
+ hn_no_id = "HN detail response has no id — the API may have changed"
98
+ reddit_status_login = "{source} request failed (HTTP {status}) — your login may have expired or hit risk control; Ctrl+U to sign in again"
99
+ reddit_subreddit_gone = "r/{name} does not exist or is not accessible"
100
+ reddit_home_empty = "Reddit \"Home\" aggregates the subreddits your account follows on reddit.com and it is currently empty — follow some subreddits on reddit.com, or add subreddit subscriptions via F2 → Reddit (each becomes its own feed)"
101
+ rss_304_no_cache = "RSS \"{label}\" returned 304 but there is no cached copy to use"
102
+ telegram_no_items = "Telegram @{key}: fetched nothing — the channel may not exist, be empty, or have its web preview disabled by the owner (the t.me/s/ page requires the owner to enable it)"
103
+ v2ex_no_context = "V2EX detail is missing its list context — go back to the list and reopen the topic"
104
+ x_no_shape = "{source}: no API request shape available (both harvest and seeds are missing) — please report this"
105
+ x_login_expired = "X login is no longer valid (the server rejected the local session) — Ctrl+U to sign in again"
106
+ x_403 = "{source} request failed (HTTP 403) — possibly risk control or an account restriction; signing in again won't help, retry later or switch networks"
107
+ x_status_retry = "{source} request failed (HTTP {status}): API throttling or a hiccup — retry later"
108
+ x_shape_stale = "{source} API error (the request shape may be stale and auto-harvest did not fix it — run scripts/x_probe.py to diagnose, or report this)"
109
+ x_detail_empty = "X detail came back empty (the tweet may have been deleted or hidden)"
110
+ zhihu_api_bad_data = "unexpected API payload: {kind}"
111
+ zhihu_api_status = "API request failed (HTTP {status})"
112
+ zhihu_api_failed = "API request failed: {error}"
113
+ zhihu_login_wall = "{label} requires login to view this content"
114
+ zhihu_session_expired = "{label} login has expired — sign in again"
115
+ zhihu_api_structure = "{label} question API returned an unexpected structure"
116
+ zhihu_question_gone = "{label}: the question may have been deleted or does not exist"
117
+ zhihu_answer_out_of_range = "{label}: that answer is not loaded yet — press Ctrl+N in the question to load more first"
118
+ zhihu_answer_no_id = "{label}: cannot get that answer's id, the comment layer is unavailable for now"
119
+ zhihu_comments_failed = "{label}: failed to open comments: {error}"
120
+ zhihu_no_question_id = "{label}: this item has no question id, cannot open detail"
121
+
122
+ # Shared vocabulary (source ClassVars point here; per-source words live in sources.<id>.*)
123
+ [units]
124
+ all = "All"
125
+ answer = "answer"
126
+ comment = "comment"
127
+ reply = "reply"
128
+ body = "body"
129
+ subscription = "subscription"
130
+ channel = "channel"
131
+
132
+ [source_setup]
133
+ not_subscribable = "{label} has built-in feeds only — nothing to manage as subscriptions"
134
+
135
+ onboarding_title = "Welcome to FeedScope"
136
+ onboarding_prompt = "Pick the sources to enable (space toggles / Esc cancels); numbers set the tab order"
137
+ onboarding_confirm = "Start"
138
+ manage_title = "Sources"
139
+ manage_prompt = "space/Enter toggles; numbers set the tab order, untick-then-tick = move to the end; → manage subscriptions"
140
+ manage_confirm = "Save"
141
+ n_feeds_more = " +{n} more feeds"
142
+ no_subs = "no subscriptions · → to add"
143
+
144
+ [media]
145
+ image = "[image]"
146
+ video = "[video]"
147
+ gallery = "[gallery]"
148
+ generic = "[media]"
149
+ images_n = "[image {n}]"
150
+
151
+
152
+ [display]
153
+ wan = "wan" # en 不走万折算(K/M 分支),键为奇偶校验而设
154
+ votes = "{n} likes"
155
+ comments_n = "{n} comments"
156
+ replies_n = "{n} replies"
157
+ replies_plain = "{n} replies"
158
+ favorites = "{n} likes"
159
+ visits = " · {n} views"
160
+ visits_wan = " · {n} views"
161
+
162
+
163
+ [config]
164
+ file_header = """FeedScope source config: language = UI language; enabled = sources in use; per-source hide = feeds hidden
165
+ feeds = subscriptions (key = {{ label, url }}): rss takes feeds, reddit subreddits,
166
+ telegram channels; maintained by feedscope (first-run setup / F2 manager / rss command),
167
+ but can also be edited by hand"""
168
+ describe_not_created = "not created (all built-in defaults)"
169
+ describe_unparsable = "unreadable ({error})"
170
+ describe_language = "language {lang}"
171
+ describe_enabled = "{n} sources enabled"
172
+ describe_enabled_with_ids = "{n} sources enabled ({ids})"
173
+ describe_overrides = "{n} sources overridden: {hidden} hidden"
174
+ describe_feeds = "{n} subscriptions"
175
+
176
+ [words]
177
+ deleted_author = "[deleted]"
178
+ deleted_body = "(deleted)"
179
+ points = "{n} pts"
180
+ comments_short = "{n} cmts"
181
+ no_title = "(untitled)"
182
+ no_body = "(no body)"
183
+ anonymous = "anonymous"
184
+ original = "[source]"
185
+ views = "{n} views"
186
+ zhihu_asked = "asked by {asker}"
187
+ zhihu_n_answers = " · {n} answers"
188
+ more_unexpanded = "{n} more collapsed"
189
+
190
+ [sources.zhihu]
191
+ label = "Zhihu"
192
+
193
+ [sources.zhihu.feeds]
194
+ hot = "Trending"
195
+ recommend = "Recommended"
196
+ follow = "Following"
197
+
198
+ [sources.hn]
199
+ detail = "HN detail"
200
+
201
+ [sources.hn.feeds]
202
+ front = "Front page"
203
+ new = "Newest"
204
+ ask = "Ask HN"
205
+ show = "Show HN"
206
+
207
+ [sources.v2ex]
208
+ with_label = "V2EX \"{label}\""
209
+ replies = "V2EX replies"
210
+
211
+ [sources.v2ex.feeds]
212
+ hot = "Popular"
213
+ new = "Latest"
214
+
215
+ [sources.x]
216
+ timeline = "X timeline"
217
+ detail = "X detail"
218
+
219
+ [sources.x.feeds]
220
+ foryou = "For you"
221
+ following = "Following"
222
+
223
+ [sources.reddit]
224
+ post = "Reddit post"
225
+
226
+ [sources.reddit.feeds]
227
+ home = "Home"
228
+ popular = "Popular"
229
+
230
+ [sources.rss]
231
+ with_label = "RSS \"{label}\""
232
+ [x]
233
+ translation_label = "**Translation** ({lang})"
234
+ translation_plain = "**Translation**"
235
+ translation_quote = "> {label}: {text}"
236
+ quote_line = " ▸ quoting {who}: {body}"
237
+ tweet_fallback = "tweet"
238
+ parent_tweet = "original tweet"
239
+ quoted_tweet = "quoted tweet"
240
+
241
+
242
+ [x_langs]
243
+ en = "English"
244
+ ja = "Japanese"
245
+ ko = "Korean"
246
+ ru = "Russian"
247
+ fr = "French"
248
+ de = "German"
249
+ es = "Spanish"
250
+ pt = "Portuguese"
251
+ it = "Italian"
252
+ ar = "Arabic"
253
+ tr = "Turkish"
254
+ th = "Thai"
255
+ vi = "Vietnamese"
256
+ id = "Indonesian"
257
+ hi = "Hindi"
258
+ uk = "Ukrainian"
259
+ nl = "Dutch"
260
+ pl = "Polish"
261
+ sv = "Swedish"
262
+ da = "Danish"
263
+ fi = "Finnish"
264
+ no = "Norwegian"
265
+ el = "Greek"
266
+ he = "Hebrew"
267
+ cs = "Czech"
268
+ hu = "Hungarian"
269
+ ro = "Romanian"
270
+ ms = "Malay"
271
+ fa = "Persian"
272
+ ur = "Urdu"
273
+ bn = "Bengali"
274
+ tl = "Filipino"
275
+
276
+ # Subscription management (SubsStore copy + SubscriptionError messages)
277
+ [subs]
278
+ dup_url = "this URL is already subscribed (key: {key})"
279
+ reserved_key = "{key} is a reserved aggregate-feed name and cannot be used as a subscription key"
280
+ key_taken = "key {key} is already taken (see feedscope rss list)"
281
+ empty_input = "empty input"
282
+ added_feed = "subscribed {key}: {label}"
283
+ imported = "imported {n} subscriptions{detail}"
284
+ imported_skip = ", skipped {n}"
285
+ opml_unreadable = "cannot read the OPML file: {error}"
286
+ opml_invalid = "not a valid OPML document: {error}"
287
+ opml_dup = "{name}: URL already subscribed"
288
+ opml_title = "FeedScope subscriptions export"
289
+ subreddit_unrecognized = "could not recognize a subreddit name (accepted: rust, r/rust, /r/rust/, or a site URL)"
290
+ subreddit_reserved = "{name} collides with a built-in feed (Home/Popular) and cannot be used as a subscription name"
291
+ subreddit_dup = "r/{name} is already subscribed"
292
+ subreddit_failed = "cannot add r/{name}: {error}"
293
+ added_subreddit = "subscribed r/{key}: {label}"
294
+ channel_unrecognized = "could not recognize a channel name (accepted: durov, @durov, t.me/durov, or a post link)"
295
+ channel_reserved = "{name} is a reserved aggregate-feed name and cannot be used as a subscription name"
296
+ channel_dup = "@{name} is already subscribed"
297
+ channel_no_items = "@{name}: fetched no posts — the channel may not exist, be empty, or have web preview disabled"
298
+ added_channel = "subscribed @{key}: {label}"
299
+ rss_placeholder = "RSS/Atom URL, site homepage, or an OPML file path (drop from Finder into the terminal)"
300
+ rss_hint = "↑↓ select · Enter rename · Insert add (URL / homepage / OPML path) · Delete remove"
301
+ rss_empty = "no subscriptions yet · Insert to add (OPML path can be pasted)"
302
+ reddit_placeholder = "subreddit name or URL (e.g. rust, r/rust, reddit.com/r/rust)"
303
+ hint_basic = "↑↓ select · Enter rename · Insert add · Delete remove"
304
+ reddit_empty = "no subscriptions yet · Insert to add (a subreddit name is enough)"
305
+ telegram_placeholder = "channel name or link (e.g. durov, @durov, t.me/durov)"
306
+ telegram_empty = "no subscriptions yet · Insert to add (channel name or link)"
307
+ no_store = "{source} has no subscription-store adapter (subscribable and SubsStore must come together)"
308
+
309
+ [doctor]
310
+ title = "FeedScope {version} environment self-check"
311
+ need_py = "requires 3.11+"
312
+ chrome_missing = "not found (standard macOS location, or google-chrome on PATH); channel=\"chrome\" depends on it"
313
+ deps_label = "dependencies textual / playwright"
314
+ importable = "importable"
315
+ import_failed = "import failed: {error} (run uv sync first)"
316
+ profile_label = "browser profile (info)"
317
+ profile_exists = "already exists"
318
+ profile_will_create = "created automatically on first run"
319
+ config_label = "user config sources.toml (info)"
320
+ all_ok = "all checks passed"
321
+ has_failures = "some checks failed — follow the hints and retry"
322
+
323
+ [probe]
324
+ unexpected = "unexpected exception: {exc}"
325
+ detail_label = "{label} detail"
326
+ n_items = "{n} items"
327
+ detail_empty = "title/body/floors all empty — the detail page may have changed"
328
+ detail_ok = "\"{title}\" {n} {unit}"
329
+ unknown_ids = "unknown source ids: {unknown} (available: {known})"
330
+ title = "FeedScope source probe (raw samples in {dir})"
331
+ summary = "{ok} passed / {fail} failed / {skip} skipped"
332
+ fail_hint = "raw samples of failed sources are in {dir} — fix parse.py against them and add fixtures"
333
+
334
+ [login]
335
+ unknown_source = "unknown source: {source} (registered: {ids})"
336
+ not_needed = "{label} does not require login (accessible anonymously)"
337
+ starting = "starting browser (profile: {dir})"
338
+ prompt = "Sign in to {label} in the Chrome window that just opened; this command exits on its own when done."
339
+ check_error = "login detection error: {error}"
340
+ browser_failed = "failed to start the browser or open the login page: {error}"
341
+ timeout = "timed out: no login detected within {seconds}s — run feedscope login again to retry."
342
+ success_state = "login state written to profile"
343
+ success_cookies = "{cookies} written to profile"
344
+ success = "signed in: {how}; the headless session reuses it from now on."
345
+
346
+ [cli]
347
+ description = "FeedScope — a terminal feed aggregator & reader"
348
+ help_tui = "start the TUI (default)"
349
+ help_login = "open a browser window to sign in to a source"
350
+ help_login_source = "source id (default zhihu)"
351
+ help_probe = "probe all sources (breakage diagnosis + raw sample dumps)"
352
+ help_probe_sources = "only probe these source ids (default all; signed-out sources report SKIP)"
353
+ help_doctor = "environment self-check (Chrome / profile / user config)"
354
+ help_rss = "RSS subscription management (list / add / remove / import / export)"
355
+ help_rss_list = "list all subscriptions"
356
+ help_rss_add = "add a subscription (display name defaults to the feed title)"
357
+ help_rss_add_url = "RSS/Atom URL, site homepage (auto-discovers the feed), or an OPML file path"
358
+ help_rss_add_key = "subscription key (default: derived from the domain)"
359
+ help_rss_add_label = "display name (default: the feed's own title)"
360
+ help_rss_remove = "remove a subscription"
361
+ help_rss_remove_key = "key of the subscription to remove (see feedscope rss list)"
362
+ help_rss_import = "bulk import from OPML (idempotent merge; already-subscribed are skipped)"
363
+ help_rss_import_path = "OPML file path"
364
+ help_rss_export = "export subscriptions as OPML 2.0"
365
+ help_rss_export_path = "output file path (default: print to stdout, redirectable)"
366
+ rss_list_empty = "no subscriptions yet: feedscope rss add <url> or import <OPML file>"
367
+ rss_list_total = "{n} subscriptions in total"
368
+ rss_added = "subscribed {key}: {label} <{url}>"
369
+ not_added = "not added: {error}"
370
+ restart_hint = " (restart the TUI to take effect)"
371
+ no_such_key = "no subscription with key {key} (see feedscope rss list)"
372
+ removed = "removed {key}: {label}"
373
+ import_failed = "import failed: {error}"
374
+ skipped_line = "skipped {line}"
375
+ more_skipped = "…{n} more skipped"
376
+ import_result = "imported {added}, skipped {skipped}"
377
+ exported = "exported {n} subscriptions to {path}"
378
+
379
+ [dialogs]
380
+ confirm = "Confirm"
381
+ cancel = "Cancel"
382
+ ok = "OK"
383
+ login = "Sign in"
384
+ logout = "Sign out"
385
+ login_prompt = "{label} requires sign-in"
386
+ logout_prompt = "Sign out of {label}?"
387
+
388
+ [account]
389
+ login_refused = "Cancelled — not asked again this run; Ctrl+U to sign in"
390
+ no_login_needed = "{label} does not require login"
391
+ login_in_progress = "sign-in in progress, one moment"
392
+ login_refresh_later = "sign-in in progress, refresh follows shortly"
393
+ title_login = "Sign in"
394
+ login_ok = "signed in to {label}"
395
+ login_incomplete = "{label} sign-in not completed — try again"
396
+ logged_out = "signed out of {label}"
397
+ not_logged_in = "{label} was not signed in"
398
+
399
+ [feedback]
400
+ failure = "{action}: {error}"
401
+ failure_with_source = "{source} {action}: {error}"
402
+ title_fetch_failed = "Fetch failed"
403
+ action_fetch = "request failed"
404
+ action_add_failed = "add failed"
405
+ action_logout_failed = "sign-out failed"
406
+ action_comments_failed = "comments failed to load"
407
+ action_more_failed = "load more failed"
408
+
409
+ [subscriptions]
410
+ title = "{label} subscriptions"
411
+ add = "Add"
412
+ rename = "Rename"
413
+ remove = "Remove"
414
+ add_title = "Add subscription"
415
+ rename_title = "Rename"
416
+ pick_first = "select a subscription first"
417
+ remove_confirm = "remove \"{label}\"?"
418
+ removed = "removed {label} (you can add it back anytime)"
419
+ rename_noop = "name unchanged (empty or the subscription no longer exists)"
420
+ renamed = "renamed to {name}"
421
+
422
+ [read]
423
+ loading = "Loading…"
424
+ loading_more = "loading more…"
425
+ status_failed = "load failed"
426
+ eof = "(end)"
427
+ no_more = "(no more {unit}s)"
428
+ no_more_plain = "no more {unit}s"
429
+ none = "(no {unit}s)"
430
+ unit_loading = "loading {unit}s…"
431
+ load_failed_retry = "load failed: {text} · Ctrl+N to retry"
432
+ comments_failed_retry = "comments failed to load: {text} · Ctrl+N to retry"
433
+ hint_more = "Ctrl+N loads more {unit}s"
434
+ hint_comments = "Enter opens the {cunit} layer of this {unit}"
435
+ floor_head = "{unit} {n}"
436
+ answer_title = "{unit} by {author}"
437
+ still_loading = "content is still loading"
438
+ no_floors_yet = "none to open yet"
439
+ single_floor_unsupported = "{label} does not support opening a single {unit} yet"
440
+ no_next_readable = "nothing readable ahead"
441
+ no_prev_readable = "nothing readable before this"
442
+ no_next_loaded = "no more loaded {unit}s ahead"
443
+ no_prev_unit = "no {unit}s before this"
444
+ binding_more_unit = "More {unit}s"
445
+ binding_next_unit = "Next {unit}"
446
+ binding_prev_unit = "Prev {unit}"
447
+