clip2table 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 (57) hide show
  1. clip2table/__init__.py +11 -0
  2. clip2table/__main__.py +11 -0
  3. clip2table/capabilities.py +86 -0
  4. clip2table/cards/__init__.py +1 -0
  5. clip2table/cards/builtin/admin-order-list.toml +55 -0
  6. clip2table/cards/builtin/generic-item-block.toml +57 -0
  7. clip2table/cards/builtin/job-list.toml +54 -0
  8. clip2table/cards/builtin/notice-list.toml +51 -0
  9. clip2table/cards/builtin/numbered-list.toml +47 -0
  10. clip2table/cards/builtin/samples/admin-order-list/orders.tsv.expect.tsv +6 -0
  11. clip2table/cards/builtin/samples/admin-order-list/orders.tsv.txt +6 -0
  12. clip2table/cards/builtin/samples/generic-item-block/gov-list.expect.tsv +4 -0
  13. clip2table/cards/builtin/samples/generic-item-block/gov-list.txt +20 -0
  14. clip2table/cards/builtin/samples/job-list/hr-roles.expect.tsv +5 -0
  15. clip2table/cards/builtin/samples/job-list/hr-roles.txt +23 -0
  16. clip2table/cards/builtin/samples/notice-list/list-page.expect.tsv +4 -0
  17. clip2table/cards/builtin/samples/notice-list/list-page.txt +17 -0
  18. clip2table/cards/builtin/samples/numbered-list/project-list.expect.tsv +5 -0
  19. clip2table/cards/builtin/samples/numbered-list/project-list.txt +4 -0
  20. clip2table/cards/loader.py +50 -0
  21. clip2table/cards/regress.py +184 -0
  22. clip2table/cards/schema.py +299 -0
  23. clip2table/channels/__init__.py +1 -0
  24. clip2table/channels/cdp.py +299 -0
  25. clip2table/channels/cfhtml.py +144 -0
  26. clip2table/channels/clipboard.py +137 -0
  27. clip2table/channels/http.py +169 -0
  28. clip2table/channels/keyboard.py +492 -0
  29. clip2table/channels/ws.py +147 -0
  30. clip2table/cli.py +1106 -0
  31. clip2table/core.py +135 -0
  32. clip2table/dataset.py +1358 -0
  33. clip2table/errors.py +35 -0
  34. clip2table/flow.py +224 -0
  35. clip2table/harvest.py +982 -0
  36. clip2table/ladder.py +229 -0
  37. clip2table/models.py +205 -0
  38. clip2table/pipeline/__init__.py +1 -0
  39. clip2table/pipeline/assemble.py +116 -0
  40. clip2table/pipeline/extract.py +524 -0
  41. clip2table/pipeline/htmltable.py +243 -0
  42. clip2table/pipeline/infer.py +461 -0
  43. clip2table/pipeline/normalize.py +61 -0
  44. clip2table/pipeline/segment.py +539 -0
  45. clip2table/pipeline/sniff.py +80 -0
  46. clip2table/render/__init__.py +1 -0
  47. clip2table/render/base.py +65 -0
  48. clip2table/render/text.py +77 -0
  49. clip2table/render/xlsx.py +213 -0
  50. clip2table/report.py +267 -0
  51. clip2table-0.1.0.dist-info/METADATA +220 -0
  52. clip2table-0.1.0.dist-info/RECORD +57 -0
  53. clip2table-0.1.0.dist-info/WHEEL +5 -0
  54. clip2table-0.1.0.dist-info/entry_points.txt +2 -0
  55. clip2table-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. clip2table-0.1.0.dist-info/licenses/NOTICE.md +114 -0
  57. clip2table-0.1.0.dist-info/top_level.txt +1 -0
clip2table/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """clip2table - turn any screen you can select into a table.
2
+
3
+ Copy with a real browser or a real program, read the clipboard, cut the text
4
+ into records, write a table. Nothing is guessed: a value that is not in the
5
+ source stays empty.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = ["__version__"]
clip2table/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """`python -m clip2table` - the path that works before install.
2
+
3
+ The console script is the normal way in, but a cloned repo has no console
4
+ script yet, and `python -m clip2table` failing with "No module named
5
+ clip2table.__main__" is a bad first minute.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .cli import main
10
+
11
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ """One capability table, consulted by validation, dispatch and help text.
2
+
3
+ The rule this module exists to enforce: **a card may not declare something the
4
+ program cannot execute.** Before this table, `kind = "dom"` and `kind = "json"`
5
+ passed validation, ran, and returned None -- so "the page has no such element",
6
+ "the regex found nothing" and "this build cannot do that at all" all arrived at
7
+ the user as the same empty cell. An empty cell that means three different things
8
+ is not a missing value, it is a missing explanation.
9
+
10
+ Keeping the table in one place means the three consumers cannot drift: the schema
11
+ rejects with the same reason the dispatcher would give, and `cards show` prints
12
+ the same reason a help screen would.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import NamedTuple
17
+
18
+ from .errors import InputError
19
+
20
+
21
+ class Capability(NamedTuple):
22
+ kind: str
23
+ channels: tuple[str, ...] # channels that can actually fill this kind
24
+ note: str # what to use instead, or how it behaves
25
+ implemented: bool = True
26
+
27
+
28
+ # Kinds that appear in KINDS but are not wired to any producer. They stay in the
29
+ # vocabulary so old cards fail loudly rather than silently producing empties;
30
+ # when a producer lands, flip `implemented` and add the channels.
31
+ CAPABILITIES: dict[str, Capability] = {
32
+ "cell": Capability("cell", ("http", "browser", "desktop"),
33
+ "表格的一列(HTML 表格或 TSV),列边界是事实"),
34
+ "line": Capability("line", ("http", "browser", "desktop"),
35
+ "记录的第 n 行,负数从末尾数"),
36
+ "search": Capability("search", ("http", "browser", "desktop"),
37
+ "匹配正则的那一行,再取捕获组"),
38
+ "span": Capability("span", ("http", "browser", "desktop"),
39
+ "两个标记之间的文本,可跨行"),
40
+ "const": Capability("const", ("http", "browser", "desktop"),
41
+ "固定值,只用于标注来源,从不拿来填数据"),
42
+ "dom": Capability(
43
+ "dom", (), "还没实现。现在是空值,但空值和「页面上没有这个元素」"
44
+ "看起来一模一样。改用 line/search/span/cell —— 复制的文本里"
45
+ "一样有这些字段", implemented=False),
46
+ "json": Capability(
47
+ "json", (), "还没实现。现在是空值。若目标是 JSON 接口,先把它存成文件,"
48
+ "用 run --text-file 走文本路径", implemented=False),
49
+ }
50
+
51
+ CHANNEL_CAPS: dict[str, tuple[str, ...]] = {
52
+ "http": ("cell", "line", "search", "span", "const"),
53
+ "browser": ("cell", "line", "search", "span", "const"),
54
+ "desktop": ("cell", "line", "search", "span", "const"),
55
+ }
56
+
57
+
58
+ def check_kind(kind: str, channel: str | None, where: str) -> None:
59
+ """Refuse a field the runtime could not fill. Raises with a way forward."""
60
+ cap = CAPABILITIES.get(kind)
61
+ if cap is None:
62
+ raise InputError(f"{where}: kind={kind!r} 不在可用原语里")
63
+ if not cap.implemented:
64
+ raise InputError(
65
+ f"{where}: kind={kind!r} 还没实现,声明它只会得到空值",
66
+ hint=cap.note)
67
+ if channel and kind not in CHANNEL_CAPS.get(channel, CHANNEL_CAPS["http"]):
68
+ usable = "、".join(CHANNEL_CAPS.get(channel, ()))
69
+ raise InputError(
70
+ f"{where}: kind={kind!r} 在 {channel} 通道上不可用",
71
+ hint=f"{channel} 通道能用的原语是 {usable};{cap.note}")
72
+
73
+
74
+ def unsupported_kinds() -> list[str]:
75
+ """Kinds declared by the vocabulary but not wired up yet."""
76
+ return [k for k, c in CAPABILITIES.items() if not c.implemented]
77
+
78
+
79
+ def describe() -> list[str]:
80
+ """Human/agent-facing capability list, shared by doctor and cards show."""
81
+ out = []
82
+ for kind, cap in CAPABILITIES.items():
83
+ where = "、".join(cap.channels) if cap.channels else "(无)"
84
+ state = "" if cap.implemented else " ← 未实现,卡的校验会直接拒绝"
85
+ out.append(f"{kind:7s} {where:26s} {cap.note}{state}")
86
+ return out
@@ -0,0 +1 @@
1
+ """Card loading and validation."""
@@ -0,0 +1,55 @@
1
+ schema = 1
2
+ name = "admin-order-list"
3
+ channel = "browser"
4
+
5
+ # 后台列表页:表格是事实,列边界就是字段边界。电商/招聘/财务后台结构一致。
6
+ #
7
+ # 为什么 mode = "lines":后台表格复制出来时,行已经是分开的(HTML 的 <tr>
8
+ # 变成一行 tab 分隔文本,Excel 区域粘贴也是每行一条)。再按锚点分组不是规则,
9
+ # 是 bug——`single` 曾把 11 行表格粘成 1 条记录,第一条字段是表头文字,
10
+ # 还报了两个字段 100% 填充。
11
+
12
+ samples = "samples/admin-order-list"
13
+
14
+ [fetch]
15
+ url = "https://example.com/admin/orders"
16
+ ready = "load"
17
+ scroll = { until = "stable", max = 40, pause_ms = 600 }
18
+
19
+ [segment]
20
+ mode = "lines"
21
+ # 后台表格复制出来时第一行通常是列名(「订单号 / 下单时间 / 金额 …」)。
22
+ # 不跳过它就会多出一条「order_no=订单号、amount=空」的记录,amount 的
23
+ # 填充率也因此被拉低——一份干净的表看起来像规则失效。
24
+ skip_header = true
25
+
26
+ [fields.order_no]
27
+ kind = "cell"
28
+ col = 0
29
+
30
+ [fields.created]
31
+ kind = "cell"
32
+ col = 1
33
+
34
+ [fields.amount]
35
+ kind = "cell"
36
+ col = 2
37
+ type = "float"
38
+
39
+ [fields.status]
40
+ kind = "cell"
41
+ col = 3
42
+
43
+ [fields.note]
44
+ kind = "cell"
45
+ col = 4
46
+
47
+ [dedupe]
48
+ key = ["order_no"]
49
+ on_collision = "report_only"
50
+
51
+ [paging]
52
+ style = "click"
53
+ next = "a.next"
54
+ max_pages = 50
55
+ pause_ms = 1200
@@ -0,0 +1,57 @@
1
+ schema = 1
2
+ name = "generic-item-block"
3
+ channel = "desktop"
4
+
5
+ # 通用「条目块」:一条短标题行,紧跟一行日期,然后是正文/规格。
6
+ # 电商列表、政务名录、文献列表、房源条目都是这个形状,改的只是字段名。
7
+ #
8
+ # 它要求的形状很具体:日期必须是记录里的**第二行**。
9
+ # 招聘列表那种「标题/公司/地点/薪资/日期」日期在末尾的版式**不适用**本卡——
10
+ # 硬套会把薪资行当成标题。那种版式直接用 `clip2table infer`,S1 会正确切出
11
+ # 五列(已用 fixtures/sample-hr.txt 验证)。用错卡不是数据问题,是选错工具;
12
+ # 报告里的「未识别 N 行」就是它给你的信号。
13
+
14
+ samples = "samples/generic-item-block"
15
+
16
+ [segment]
17
+ mode = "anchor"
18
+ anchor = '^(?!\s).{1,40}$'
19
+ # The date line must be a BARE date (optionally with a time) and nothing else.
20
+ # A loose date prefix ('^\d{4}-\d{1,2}') also matches a body line that merely
21
+ # begins with a date - "2026-03-12 补记:扶手略窄" - and then that body line
22
+ # becomes a record start, splitting one record into two.
23
+ anchor_next = '^\d{4}\s*[-/.年]\s*\d{1,2}\s*(?:[-/.月]\s*\d{1,2}\s*[日号]?)?(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?\s*$'
24
+ min_lines = 2
25
+
26
+ [fields.title]
27
+ kind = "line"
28
+ index = 0
29
+
30
+ [fields.date]
31
+ kind = "search"
32
+ pattern = '(?P<v>\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{4}年\d{1,2}月\d{1,2}日)'
33
+ scope = "head"
34
+ window = 2
35
+ type = "date"
36
+
37
+ [fields.body]
38
+ kind = "span"
39
+ from = "after:date"
40
+ # The `?` makes the stop marker optional: cut before 规格 when the record has
41
+ # one, run to the end of the record when it does not. Strict (no `?`) is the
42
+ # default because a vanished marker usually means the page changed under the
43
+ # card -- and a strict bound that cannot be found returns empty, not the whole
44
+ # record. This card serves both shapes, so it has to declare that explicitly.
45
+ to = "before?:^规格[::]"
46
+ type = "text"
47
+
48
+ [fields.spec]
49
+ kind = "search"
50
+ pattern = '^规格[::]\s*(?P<v>.+)$'
51
+
52
+ [dedupe]
53
+ key = ["title", "date", "body"]
54
+ on_collision = "report_only"
55
+
56
+ [paging]
57
+ style = "none"
@@ -0,0 +1,54 @@
1
+ schema = 1
2
+ name = "job-list"
3
+ channel = "desktop"
4
+
5
+ # 招聘列表:一条五段——职位 / 公司 / 地点经验 / 薪资 / 发布日期,日期在**最后**。
6
+ #
7
+ # 这张卡的日期锚点是反向的:记录以「日期行」结束,下一条从日期行的下一行开始。
8
+ # 用 notice-list 那种「第二行是日期」的卡去套它,会把薪资行当成标题——所以
9
+ # 这里给的是分开的一张卡,而不是把两张卡揉成一个万能正则。
10
+ #
11
+ # 采集方式:浏览器里 F12 → Elements 之外最省事的形态是「全选复制」,
12
+ # 或者用 desktop 通道对一个只显示招聘列表的窗口按 Ctrl+A。
13
+
14
+ samples = "samples/job-list"
15
+
16
+ [harvest]
17
+ select_all = true
18
+ timeout_ms = 5000
19
+
20
+ [segment]
21
+ # 空行分段。为什么不用 anchor:锚点机制只能把「命中行」当作记录的**起点**,
22
+ # 而这张卡的日期在末尾——写成 anchor 会让每一行都成为起点,然后 min_lines
23
+ # 把单行记录全部滤掉,结果是 0 行、全部未识别(试过,就是这样)。
24
+ # 招聘站复制出来的块之间通常有空行,所以用 blank;没有空行就先用 infer。
25
+ mode = "blank"
26
+
27
+ [fields.title]
28
+ kind = "line"
29
+ index = 0
30
+
31
+ [fields.company]
32
+ kind = "line"
33
+ index = 1
34
+
35
+ [fields.location]
36
+ kind = "line"
37
+ index = 2
38
+
39
+ [fields.salary]
40
+ kind = "line"
41
+ index = 3
42
+
43
+ [fields.date]
44
+ kind = "line"
45
+ index = -1
46
+ type = "date"
47
+ pattern = '(?P<v>\d{4}[-/.]\d{1,2}[-/.]\d{1,2})'
48
+
49
+ [dedupe]
50
+ key = ["title", "company"]
51
+ on_collision = "report_only"
52
+
53
+ [paging]
54
+ style = "none"
@@ -0,0 +1,51 @@
1
+ schema = 1
2
+ name = "notice-list"
3
+ channel = "browser"
4
+
5
+ # 列表页:一屏多条,每条是「标题 / 日期 / 正文」三行一段,还带一个可选的规格行。
6
+ #
7
+ # 这是最通用的列表形状:采购公告、招标信息、政策文件、活动报名、文章列表
8
+ # 都是它。字段名按你的业务改,规则不用动。
9
+ #
10
+ # 分页交给 [paging]:点「下一页」链接,连续 2 页没有新增就自动停。
11
+
12
+ samples = "samples/notice-list"
13
+
14
+ [harvest]
15
+ select_all = true
16
+ timeout_ms = 5000
17
+
18
+ [segment]
19
+ mode = "anchor"
20
+ anchor = '^(?!\s).{1,60}$'
21
+ # 第二行必须是**裸日期**,不能是「以日期开头的正文」——否则正文里提到
22
+ # 2026-03-12 的那一行会被当成新记录的开头,一条记录被切成两条。
23
+ anchor_next = '^\d{4}\s*[-/.年]\s*\d{1,2}\s*(?:[-/.月]\s*\d{1,2}\s*[日号]?)?\s*$'
24
+ min_lines = 3
25
+
26
+ [fields.title]
27
+ kind = "line"
28
+ index = 0
29
+
30
+ [fields.date]
31
+ kind = "line"
32
+ index = 1
33
+ type = "date"
34
+
35
+ [fields.body]
36
+ kind = "line"
37
+ index = 2
38
+
39
+ [fields.spec]
40
+ kind = "search"
41
+ pattern = '^规格[::]\s*(?P<v>.+)$'
42
+
43
+ [dedupe]
44
+ key = ["title"]
45
+ on_collision = "report_only"
46
+
47
+ [paging]
48
+ style = "click"
49
+ next = "a.next"
50
+ max_pages = 5
51
+ pause_ms = 1200
@@ -0,0 +1,47 @@
1
+ schema = 1
2
+ name = "numbered-list"
3
+ channel = "desktop"
4
+
5
+ # 编号清单:一行一条,「1. 事项名 —— 进度/时间」。
6
+ #
7
+ # 这是台账、任务清单、问题列表、验收项清单最常见的形状——每条只占一行,
8
+ # 和上面那些「多行一段」的卡完全不同。这里靠行首编号切分,不靠空行也不靠
9
+ # 日期,因为这类清单通常把编号当唯一的分界信号。
10
+
11
+ samples = "samples/numbered-list"
12
+
13
+ [target]
14
+ # 从任何窗口里复制都行:改成你那个窗口标题里的一段文字。
15
+ # 比如 "工作台账 - Notepad"、"任务列表 - Excel"。
16
+ title = "工作台账"
17
+ restore = true
18
+
19
+ [harvest]
20
+ select_all = true
21
+ timeout_ms = 5000
22
+
23
+ [segment]
24
+ mode = "anchor"
25
+ # 行首 1. / 2. / 1、/ (1) 都算一条新记录。行内的「——」不当分界:
26
+ # 它就是这条记录里的一个分隔符,切成两条反而会把一条记录拆散。
27
+ anchor = '^\s*(?:\d{1,3}[.、))]|[((]\d{1,3}[))])'
28
+ min_lines = 1
29
+
30
+ [fields.no]
31
+ kind = "search"
32
+ pattern = '^\s*(?P<v>\d{1,3})'
33
+
34
+ [fields.item]
35
+ kind = "search"
36
+ pattern = '^\s*\d{1,3}[.、))]\s*(?P<v>.+?)\s*(?:——|--|—)\s*'
37
+
38
+ [fields.progress]
39
+ kind = "search"
40
+ pattern = '(?:——|--|—)\s*(?P<v>.+)$'
41
+
42
+ [dedupe]
43
+ key = ["no"]
44
+ on_collision = "report_only"
45
+
46
+ [paging]
47
+ style = "none"
@@ -0,0 +1,6 @@
1
+ order_no created amount status note
2
+ SO-2026-0001 2026-03-01 09:12 129.0 已付款 加急
3
+ SO-2026-0002 2026-03-01 10:04 368.5 已发货 无
4
+ SO-2026-0003 2026-03-02 14:33 42.0 待付款 无
5
+ SO-2026-0004 2026-03-02 16:20 1280.0 已完成 开发票
6
+ SO-2026-0005 2026-03-03 08:55 76.8 已退款 客户取消
@@ -0,0 +1,6 @@
1
+ 订单号 下单时间 金额 状态 备注
2
+ SO-2026-0001 2026-03-01 09:12 129.00 已付款 加急
3
+ SO-2026-0002 2026-03-01 10:04 368.50 已发货 无
4
+ SO-2026-0003 2026-03-02 14:33 42.00 待付款 无
5
+ SO-2026-0004 2026-03-02 16:20 1,280.00 已完成 开发票
6
+ SO-2026-0005 2026-03-03 08:55 76.80 已退款 客户取消
@@ -0,0 +1,4 @@
1
+ title date body spec
2
+ 临江区市场监督管理局后勤服务中心 2026-01-15 统一社会信用代码 91330100MA2XXXX1X\n法定代表人:周某\n登记状态:存续\n注册资本:500 万元
3
+ 滨河区行政审批服务中心 2026-01-09 统一社会信用代码 91330100MA2XXXX2X\n法定代表人:李某\n登记状态:注销\n注册资本:120 万元
4
+ 新桥镇农业农村办公室 2025-12-28 统一社会信用代码 91330100MA2XXXX3X\n法定代表人:吴某\n登记状态:存续\n注册资本:80 万元
@@ -0,0 +1,20 @@
1
+ 临江区市场监督管理局后勤服务中心
2
+ 2026-01-15
3
+ 统一社会信用代码 91330100MA2XXXX1X
4
+ 法定代表人:周某
5
+ 登记状态:存续
6
+ 注册资本:500 万元
7
+
8
+ 滨河区行政审批服务中心
9
+ 2026-01-09
10
+ 统一社会信用代码 91330100MA2XXXX2X
11
+ 法定代表人:李某
12
+ 登记状态:注销
13
+ 注册资本:120 万元
14
+
15
+ 新桥镇农业农村办公室
16
+ 2025-12-28
17
+ 统一社会信用代码 91330100MA2XXXX3X
18
+ 法定代表人:吴某
19
+ 登记状态:存续
20
+ 注册资本:80 万元
@@ -0,0 +1,5 @@
1
+ title company location salary date
2
+ 后端开发工程师(Python) 星野科技 杭州 · 3-5 年 25-40K · 14薪 2026-03-09
3
+ 数据分析师 青禾数据 上海 · 1-3 年 18-25K · 13薪 2026-03-08
4
+ 产品经理(B端) 同衡软件 深圳 · 5-10 年 30-50K · 15薪 2026-03-06
5
+ 测试开发工程师 临江智能 成都 · 3-5 年 20-30K · 13薪 2026-03-05
@@ -0,0 +1,23 @@
1
+ 后端开发工程师(Python)
2
+ 星野科技
3
+ 杭州 · 3-5 年
4
+ 25-40K · 14薪
5
+ 2026-03-09
6
+
7
+ 数据分析师
8
+ 青禾数据
9
+ 上海 · 1-3 年
10
+ 18-25K · 13薪
11
+ 2026-03-08
12
+
13
+ 产品经理(B端)
14
+ 同衡软件
15
+ 深圳 · 5-10 年
16
+ 30-50K · 15薪
17
+ 2026-03-06
18
+
19
+ 测试开发工程师
20
+ 临江智能
21
+ 成都 · 3-5 年
22
+ 20-30K · 13薪
23
+ 2026-03-05
@@ -0,0 +1,4 @@
1
+ title date body spec
2
+ 星海无线耳机 Pro 2026-03-14 包装完好,降噪比上一代强不少,通勤地铁里基本听不到报站 黑色 / 降噪版
3
+ 轻羽人体工学椅 2026-03-11 腰托支撑到位,坐了三天没腰疼,就是扶手略窄 灰 / 带脚托
4
+ 山雾便携咖啡机 2026-03-08 出液稳定,清洗麻烦,旅行箱里占地方 银 / 单杯
@@ -0,0 +1,17 @@
1
+ 星海无线耳机 Pro
2
+ 2026-03-14
3
+ 包装完好,降噪比上一代强不少,通勤地铁里基本听不到报站
4
+ 规格:黑色 / 降噪版
5
+ 有用(128)
6
+
7
+ 轻羽人体工学椅
8
+ 2026-03-11
9
+ 腰托支撑到位,坐了三天没腰疼,就是扶手略窄
10
+ 规格:灰 / 带脚托
11
+ 有用(56)
12
+
13
+ 山雾便携咖啡机
14
+ 2026-03-08
15
+ 出液稳定,清洗麻烦,旅行箱里占地方
16
+ 规格:银 / 单杯
17
+ 有用(37)
@@ -0,0 +1,5 @@
1
+ no item progress
2
+ 1 射频识别门禁改造 2026-02 完成招标
3
+ 2 机房动环监控扩容 2026-03 进场施工
4
+ 3 无线覆盖二期 预算待批
5
+ 4 视频会议终端替换 已验收
@@ -0,0 +1,4 @@
1
+ 1. 射频识别门禁改造 —— 2026-02 完成招标
2
+ 2. 机房动环监控扩容 —— 2026-03 进场施工
3
+ 3. 无线覆盖二期 —— 预算待批
4
+ 4. 视频会议终端替换 —— 已验收
@@ -0,0 +1,50 @@
1
+ """Card loader: TOML in, Card out. No magic lookup order beyond the obvious."""
2
+ from __future__ import annotations
3
+
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ from ..errors import InputError
8
+ from .schema import Card, parse_card
9
+
10
+
11
+ def load_card(path: str | Path) -> Card:
12
+ p = Path(path)
13
+ if not p.exists():
14
+ raise InputError(f"找不到卡:{p}", hint="用 `clip2table cards list` 看内置卡")
15
+ if p.suffix.lower() != ".toml":
16
+ raise InputError(f"卡必须是 .toml 文件:{p}")
17
+ try:
18
+ raw = p.read_bytes()
19
+ except OSError as e:
20
+ raise InputError(f"读不了卡 {p}:{e}") from None
21
+ try:
22
+ data = tomllib.loads(raw.decode("utf-8"))
23
+ except UnicodeDecodeError:
24
+ raise InputError(f"卡不是 UTF-8:{p}") from None
25
+ except tomllib.TOMLDecodeError as e:
26
+ raise InputError(f"卡不是合法 TOML:{p}\n {e}") from None
27
+ return parse_card(data, str(p))
28
+
29
+
30
+ def builtin_dir() -> Path:
31
+ return Path(__file__).with_name("builtin")
32
+
33
+
34
+ def list_builtin() -> list[Path]:
35
+ d = builtin_dir()
36
+ if not d.is_dir():
37
+ return []
38
+ return sorted(d.glob("*.toml"))
39
+
40
+
41
+ def resolve_card(name_or_path: str) -> Card:
42
+ """A path, or a builtin card name without the .toml suffix."""
43
+ p = Path(name_or_path)
44
+ if p.exists():
45
+ return load_card(p)
46
+ candidate = builtin_dir() / f"{name_or_path}.toml"
47
+ if candidate.exists():
48
+ return load_card(candidate)
49
+ raise InputError(f"找不到卡 {name_or_path!r}",
50
+ hint="给一个 .toml 路径,或用 `clip2table cards list` 里的名字")