dsh-task-ledger 0.0.101 → 0.0.102
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.
- package/dist/client.js +2 -2
- package/dist/host/repositories/runner.d.ts +1 -1
- package/dist/host/repositories/runner.js +10 -7
- package/dist/host/skill/registration.d.ts +2 -1
- package/dist/host/skill/registration.js +4 -3
- package/package.json +1 -1
- package/skill/README.md +4 -2
- package/skill/SKILL.md +5 -3
- package/skill/assets/worklog.md +1 -1
- package/skill/package.json +1 -1
- package/skill/references/init-identity.md +3 -3
- package/skill/references/log-note.md +1 -1
- package/skill/references/troubleshooting.md +1 -1
- package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
- package/skill/scripts/bin/ledger-linux-amd64 +0 -0
- package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
- package/skill/scripts/progress +44 -0
- package/skill/scripts/ledgerkit/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/atom.py +0 -154
- package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
- package/skill/scripts/ledgerkit/actions/claim.py +0 -88
- package/skill/scripts/ledgerkit/actions/close_task.py +0 -191
- package/skill/scripts/ledgerkit/actions/guard.py +0 -158
- package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
- package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
- package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
- package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
- package/skill/scripts/ledgerkit/actions/new_task.py +0 -129
- package/skill/scripts/ledgerkit/actions/query.py +0 -35
- package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
- package/skill/scripts/ledgerkit/actions/registry.py +0 -82
- package/skill/scripts/ledgerkit/actions/relabel.py +0 -89
- package/skill/scripts/ledgerkit/actions/release.py +0 -76
- package/skill/scripts/ledgerkit/actions/result.py +0 -90
- package/skill/scripts/ledgerkit/actions/set_task.py +0 -71
- package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
- package/skill/scripts/ledgerkit/actions/validate.py +0 -189
- package/skill/scripts/ledgerkit/core/__init__.py +0 -0
- package/skill/scripts/ledgerkit/core/history_md.py +0 -152
- package/skill/scripts/ledgerkit/core/identity.py +0 -54
- package/skill/scripts/ledgerkit/core/ids.py +0 -58
- package/skill/scripts/ledgerkit/core/labels.py +0 -89
- package/skill/scripts/ledgerkit/core/paths.py +0 -92
- package/skill/scripts/ledgerkit/core/roots.py +0 -74
- package/skill/scripts/ledgerkit/core/time.py +0 -103
- package/skill/scripts/ledgerkit/core/worklog_md.py +0 -435
- package/skill/scripts/ledgerkit/env.py +0 -94
- package/skill/scripts/ledgerkit/errors.py +0 -15
- package/skill/scripts/ledgerkit/notices.py +0 -28
- package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
- package/skill/scripts/ledgerkit/shell/cli.py +0 -532
- package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
- package/skill/scripts/ledgerkit/shell/merge.py +0 -266
- package/skill/scripts/ledgerkit/shell/presync.py +0 -23
- package/skill/scripts/ledgerkit/shell/render.py +0 -216
- package/skill/scripts/ledgerkit/shell/runner.py +0 -232
- package/skill/scripts/ledgerkit/store/__init__.py +0 -0
- package/skill/scripts/ledgerkit/store/atomic.py +0 -130
- package/skill/scripts/ledgerkit/store/dirty.py +0 -108
- package/skill/scripts/ledgerkit/store/history_store.py +0 -134
- package/skill/scripts/ledgerkit/store/json_store.py +0 -166
- package/skill/scripts/ledgerkit/store/locks.py +0 -64
- package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
- package/skill/scripts/ledgerkit/store/task_store.py +0 -28
- package/skill/scripts/ledgerkit/store/worklog_store.py +0 -178
- package/skill/scripts/progress.py +0 -20
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"""分类与标签:校验、拼行、拆行。只做文本换算。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
from collections.abc import Iterable
|
|
7
|
-
|
|
8
|
-
from ..errors import Fail
|
|
9
|
-
|
|
10
|
-
STATUSES = ("open", "doing")
|
|
11
|
-
FORMAT_VERSION = "20261022000000"
|
|
12
|
-
|
|
13
|
-
LABEL_MAX_CHARS = 4 # 含 CJK 时按「字」算
|
|
14
|
-
LABEL_MAX_WORDS_EN = 3 # 纯 ASCII 时按「词」算
|
|
15
|
-
TAGS_MAX_COUNT = 5
|
|
16
|
-
|
|
17
|
-
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
18
|
-
LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||·]+)")
|
|
19
|
-
LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
|
|
20
|
-
_SPLIT_RE = re.compile(r"[,,、]")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def normalize_label(value: str, *, field: str) -> str:
|
|
24
|
-
"""分类 / 单个标签的校验:去空白、限长。空串直接 Fail(给了就要有意义)。"""
|
|
25
|
-
text = (value or "").strip()
|
|
26
|
-
if not text:
|
|
27
|
-
raise Fail(f"{field} 不能为空。")
|
|
28
|
-
if CJK_RE.search(text):
|
|
29
|
-
if len(text) > LABEL_MAX_CHARS:
|
|
30
|
-
raise Fail(f"{field} {text!r} 太长:含中文时最多 {LABEL_MAX_CHARS} 个字。")
|
|
31
|
-
elif len(text.split()) > LABEL_MAX_WORDS_EN:
|
|
32
|
-
raise Fail(f"{field} {text!r} 太长:英文最多 {LABEL_MAX_WORDS_EN} 个词。")
|
|
33
|
-
return text
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def parse_tags(raw: str | Iterable[str] | None) -> tuple[str, ...]:
|
|
37
|
-
"""标签串(「、」「,」「,」分隔)→ 去重保序的元组;超上限报错。"""
|
|
38
|
-
if raw is None:
|
|
39
|
-
return ()
|
|
40
|
-
items = list(raw) if not isinstance(raw, str) else _SPLIT_RE.split(raw)
|
|
41
|
-
out: list[str] = []
|
|
42
|
-
for item in items:
|
|
43
|
-
text = normalize_label(str(item).strip(), field="标签")
|
|
44
|
-
if text not in out:
|
|
45
|
-
out.append(text)
|
|
46
|
-
if len(out) > TAGS_MAX_COUNT:
|
|
47
|
-
raise Fail(f"标签最多 {TAGS_MAX_COUNT} 个,收到 {len(out)} 个。")
|
|
48
|
-
return tuple(out)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def label_line(tags: Iterable[str] | None) -> str:
|
|
52
|
-
"""过程文档头部的「标签」行;标签为空返回空串(此行不写)。
|
|
53
|
-
|
|
54
|
-
分类**不进过程文档**(2026-10-27 用户拍板):它只活在分片字段与历史表里,
|
|
55
|
-
标签可涵盖分类(「建设」「修改」这类词直接当标签写)。
|
|
56
|
-
"""
|
|
57
|
-
tag_list = [t for t in (tags or ()) if t]
|
|
58
|
-
return f"- **标签**:{'、'.join(tag_list)}" if tag_list else ""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def parse_label_line(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
62
|
-
"""从过程文档头部(第一个 `## ` 小节之前)解析分类与标签。
|
|
63
|
-
|
|
64
|
-
跳过 HTML 注释块:模板注释里的示例不是数据。老文档的分类段照样能解析出来。
|
|
65
|
-
"""
|
|
66
|
-
in_comment = False
|
|
67
|
-
for line in text.splitlines():
|
|
68
|
-
if line.startswith("## "):
|
|
69
|
-
break
|
|
70
|
-
stripped = line.lstrip()
|
|
71
|
-
if in_comment:
|
|
72
|
-
if "-->" in stripped:
|
|
73
|
-
in_comment = False
|
|
74
|
-
continue
|
|
75
|
-
if stripped.startswith("<!--"):
|
|
76
|
-
in_comment = "-->" not in stripped
|
|
77
|
-
continue
|
|
78
|
-
mc = LABEL_CAT_RE.search(line)
|
|
79
|
-
mt = LABEL_TAG_RE.search(line)
|
|
80
|
-
if not (mc or mt):
|
|
81
|
-
continue
|
|
82
|
-
category = mc.group(1).strip() if mc else None
|
|
83
|
-
tags = (
|
|
84
|
-
tuple(t.strip() for t in _SPLIT_RE.split(mt.group(1).strip()) if t.strip())
|
|
85
|
-
if mt
|
|
86
|
-
else ()
|
|
87
|
-
)
|
|
88
|
-
return (category or None, tags)
|
|
89
|
-
return (None, ())
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"""路径与文件名怎么拼:只做换算,一个文件都不碰。
|
|
2
|
-
|
|
3
|
-
**基准只有两个**:
|
|
4
|
-
- 磁盘与 JSON 里存的(`task["worklog"]` 等)一律**相对 `ledger/`**;
|
|
5
|
-
- 输出给人或给界面的(`Result.where`、`payload` 里的 `*_path`)一律**相对 repo**。
|
|
6
|
-
|
|
7
|
-
换算只在这两个函数里做,任何层都不许自己拼 `..`。
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import hashlib
|
|
13
|
-
import re
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
LEDGER_DIR = Path("ledger")
|
|
17
|
-
|
|
18
|
-
POOL_REL = Path("pool.json")
|
|
19
|
-
OWNERS_REL = Path("owners.json")
|
|
20
|
-
OWNERS_DIR = Path("owners")
|
|
21
|
-
HISTORY_DIR = Path("history")
|
|
22
|
-
WORKLOG_DIR = Path("worklog")
|
|
23
|
-
|
|
24
|
-
SHA_LEN = 12
|
|
25
|
-
|
|
26
|
-
# 模板与资产的唯一定位基准:按自身位置算绝对路径,不依赖调用方 cwd。
|
|
27
|
-
# ledgerkit/core/paths.py → parents: [core, ledgerkit, scripts, skill]
|
|
28
|
-
SKILL_DIR = Path(__file__).resolve().parents[3]
|
|
29
|
-
WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def to_repo_rel(ledger_rel: str | Path) -> Path:
|
|
33
|
-
"""相对 `ledger/` 的路径 → 相对 repo 的路径。"""
|
|
34
|
-
return LEDGER_DIR / Path(ledger_rel)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def to_ledger_rel(repo_rel: str | Path) -> str:
|
|
38
|
-
"""相对 repo 的路径 → 相对 `ledger/` 的路径(已经是 `ledger/` 内的原样返回)。"""
|
|
39
|
-
p = Path(repo_rel)
|
|
40
|
-
if p.parts and p.parts[0] == LEDGER_DIR.name:
|
|
41
|
-
return Path(*p.parts[1:]).as_posix()
|
|
42
|
-
return p.as_posix()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def to_history_rel(ledger_rel: str | Path) -> str:
|
|
46
|
-
"""`ledger/` 内的路径 → 从 `ledger/history/<ym>.md` 出发的链接串。
|
|
47
|
-
|
|
48
|
-
深度固定(`history/` 与 `worklog/` 都在 `ledger/` 下),拼 `..` 只许在这一处。
|
|
49
|
-
"""
|
|
50
|
-
return ("../" / Path(ledger_rel)).as_posix()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def shard_rel(identity: str) -> Path:
|
|
54
|
-
"""某人分片相对 `ledger/` 的路径:sha256(归一化身份)[:12] + .json。"""
|
|
55
|
-
digest = hashlib.sha256(identity.strip().lower().encode("utf-8")).hexdigest()
|
|
56
|
-
return OWNERS_DIR / f"{digest[:SHA_LEN]}.json"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def month_of(stamp: str, *, fallback_now: str | None = None) -> str:
|
|
60
|
-
"""存盘时间 → `YYYYMM`;取不到时用 `fallback_now`,再没有就抛错由调用方定夺。"""
|
|
61
|
-
month = re.sub(r"[^0-9]", "", (stamp or "")[:7])[:6]
|
|
62
|
-
if len(month) == 6:
|
|
63
|
-
return month
|
|
64
|
-
if fallback_now:
|
|
65
|
-
return re.sub(r"[^0-9]", "", fallback_now[:7])[:6]
|
|
66
|
-
return ""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def worklog_dir_for(created: str) -> Path:
|
|
70
|
-
"""过程文档的按月目录(相对 `ledger/`):`worklog/<YYYYMM>/`。"""
|
|
71
|
-
return WORKLOG_DIR / month_of(created)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def worklog_name(tid: str) -> str:
|
|
75
|
-
"""过程文档的文件名:任务号剥掉前缀年月日,随机码保留。
|
|
76
|
-
|
|
77
|
-
父目录 `worklog/<YYYYMM>/` 已带年月,文件名里再写一遍是冗余;
|
|
78
|
-
自定义 `--id` 不以「8 位数字-」开头时,文件名就是任务号本身。
|
|
79
|
-
"""
|
|
80
|
-
if len(tid) > 9 and tid[:8].isdigit() and tid[8] == "-":
|
|
81
|
-
return f"{tid[6:]}.md"
|
|
82
|
-
return f"{tid}.md"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def worklog_rel(tid: str, created: str) -> str:
|
|
86
|
-
"""过程文档相对 `ledger/` 的路径:任务对象与历史末列共用同一口径。"""
|
|
87
|
-
return (worklog_dir_for(created) / worklog_name(tid)).as_posix()
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def history_path_for(finished: str) -> Path:
|
|
91
|
-
"""结案时间 → 历史文件相对 `ledger/` 的路径:`history/<YYYYMM>.md`。"""
|
|
92
|
-
return HISTORY_DIR / f"{month_of(finished)}.md"
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
"""工作区根的判定:哪个目录才是「直接含有 `ledger/` 的那个根」。
|
|
2
|
-
|
|
3
|
-
全仓唯一实现 —— `shell/` 与 `actions/` 都调这里,不各写一份(否则口径会漂)。
|
|
4
|
-
判据是**结构**(含不含 `ledger/`、自己在不在 `ledger/` 里),不看目录里有哪些文件:
|
|
5
|
-
项目根自带 `pool.json`、`history/` + `worklog/`、`owners.json` + `owners/` 这类同名文件
|
|
6
|
-
或目录都很常见,按「里面有什么」判会把正常项目根误当成台账目录(2026-10-25 改)。
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
from ..errors import Fail
|
|
14
|
-
from .paths import LEDGER_DIR
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _under_ledger(p: Path) -> bool:
|
|
18
|
-
"""`p` 自己叫 `ledger`,或它落在某个 `ledger/` 目录里面。"""
|
|
19
|
-
if p.name == LEDGER_DIR.name:
|
|
20
|
-
return True
|
|
21
|
-
return any(anc.name == LEDGER_DIR.name for anc in p.parents)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def as_root(repo: str | Path, *, source: str = "--repo") -> Path:
|
|
25
|
-
"""校验「工作区根」,只看结构:
|
|
26
|
-
|
|
27
|
-
- 直接含 `ledger/` ⇒ 就是根。哪怕项目根自己叫 `ledger`、哪怕它自己也有 `pool.json`;
|
|
28
|
-
- 不含 `ledger/`、但自己叫 `ledger` 或落在某个 `ledger/` 下 ⇒ 是台账目录 / 台账里面,拒;
|
|
29
|
-
- 其余(含全新空目录)⇒ 收,第一条写命令会在它下面建 `ledger/`。
|
|
30
|
-
"""
|
|
31
|
-
p = Path(repo).expanduser()
|
|
32
|
-
if not p.is_dir():
|
|
33
|
-
raise Fail(
|
|
34
|
-
f"{source} 指向的不是一个目录:{p}\n"
|
|
35
|
-
f" 处理:指向工作区根(直接含有 ledger/ 的那个目录,不是 ledger/ 本身)。"
|
|
36
|
-
)
|
|
37
|
-
if (p / LEDGER_DIR).is_dir():
|
|
38
|
-
return p.resolve()
|
|
39
|
-
if _under_ledger(p):
|
|
40
|
-
anc = next(a for a in (p, *p.parents) if a.name == LEDGER_DIR.name)
|
|
41
|
-
root = anc.parent
|
|
42
|
-
if anc is p:
|
|
43
|
-
what = f"指到了台账目录本身:{p}"
|
|
44
|
-
why = f"它的上一级 {root} 才是工作区根 —— 在它下面跑会再建出 {p / LEDGER_DIR}。"
|
|
45
|
-
else:
|
|
46
|
-
what = f"指到了台账目录里面:{p}"
|
|
47
|
-
why = f"它已经在 {anc} 这个台账目录底下 —— 工作区根是 {anc.parent}。"
|
|
48
|
-
raise Fail(
|
|
49
|
-
f"{source} {what}\n"
|
|
50
|
-
f" {why}\n"
|
|
51
|
-
f" 处理:把 {source} 指到 {root}\n"
|
|
52
|
-
f" 例:progress.py list --repo {root}"
|
|
53
|
-
)
|
|
54
|
-
return p.resolve()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def root_of(start: str | Path) -> Path | None:
|
|
58
|
-
"""从 `start` 往上定位工作区根;找不到返回 None。
|
|
59
|
-
|
|
60
|
-
① 自己在台账目录里(名字叫 `ledger`、且里面没有再套一层 `ledger/`)⇒ 根是它的上一级。
|
|
61
|
-
按位置判,不看里面有什么:项目根自带 `history/` + `worklog/` 不会被误当成台账目录。
|
|
62
|
-
② 兜底:从**自己**开始往上,取最近的、直接含 `ledger/` 的目录。`start` 必须优先于
|
|
63
|
-
祖先,否则工作区根恰好叫 `ledger` 时会被父目录抢走(2026-10-25 改)。
|
|
64
|
-
"""
|
|
65
|
-
here = Path(start).resolve()
|
|
66
|
-
for anc in (here, *here.parents):
|
|
67
|
-
if anc.name == LEDGER_DIR.name and not (anc / LEDGER_DIR).is_dir():
|
|
68
|
-
root = anc.parent
|
|
69
|
-
if (root / LEDGER_DIR).is_dir():
|
|
70
|
-
return root
|
|
71
|
-
for cand in (here, *here.parents):
|
|
72
|
-
if (cand / LEDGER_DIR).is_dir():
|
|
73
|
-
return cand
|
|
74
|
-
return None
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""时间:本地墙钟 ↔ UTC 的换算、格式化、排序键。
|
|
2
|
-
|
|
3
|
-
进门规则:不读文件、不写文件、不打印;只依赖 errors。
|
|
4
|
-
所有**存盘**时间都是 UTC(`YYYY-MM-DD HH:MM:SS`,不带偏移量);
|
|
5
|
-
所有**对外输出**(人话表格与 `--json`)的时间字段都过 `fmt_local` 转本机时区,
|
|
6
|
-
内部筛选 / 排序一律用存盘的 UTC —— 调用方拿到的时间不用再转。
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import re
|
|
12
|
-
from datetime import UTC, datetime
|
|
13
|
-
|
|
14
|
-
from ..errors import Fail
|
|
15
|
-
|
|
16
|
-
_UTC = UTC
|
|
17
|
-
|
|
18
|
-
TIME_RE = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?\Z")
|
|
19
|
-
_TIME_IN_RE = re.compile(
|
|
20
|
-
r"(\d{4})-(\d{1,2})-(\d{1,2})(?: +(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?\Z"
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def now() -> str:
|
|
25
|
-
"""当前时刻(UTC,到秒)。"""
|
|
26
|
-
return datetime.now(_UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def normalize_time(
|
|
30
|
-
value: str | datetime,
|
|
31
|
-
*,
|
|
32
|
-
field: str = "时间",
|
|
33
|
-
tid: str | None = None,
|
|
34
|
-
date_only_ref: str | None = None,
|
|
35
|
-
) -> str:
|
|
36
|
-
"""把「正确但不完整」的写法归一化到 `YYYY-MM-DD HH:MM:SS`。
|
|
37
|
-
|
|
38
|
-
接受 `YYYY-MM-DD`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD HH:MM:SS`,容忍 `/` 与 `T`。
|
|
39
|
-
`date_only_ref`:只给了日期时用这个时刻顶替(`--until` 想含整天传 `23:59:59`)。
|
|
40
|
-
"""
|
|
41
|
-
where = f"{tid}: " if tid else ""
|
|
42
|
-
if isinstance(value, datetime):
|
|
43
|
-
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
44
|
-
if not isinstance(value, str) or not value.strip():
|
|
45
|
-
raise Fail(f"{where}{field} 要是一个时间字符串,收到 {value!r}")
|
|
46
|
-
s = " ".join(
|
|
47
|
-
value.strip().replace("/", "-").replace("T", " ").replace("t", " ").split()
|
|
48
|
-
)
|
|
49
|
-
m = _TIME_IN_RE.fullmatch(s)
|
|
50
|
-
if not m:
|
|
51
|
-
raise Fail(
|
|
52
|
-
f"{where}{field} 时间格式不对:{value!r}"
|
|
53
|
-
"(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)"
|
|
54
|
-
)
|
|
55
|
-
y, mo, d, hh, mm, ss = m.groups()
|
|
56
|
-
if date_only_ref is not None and hh is None:
|
|
57
|
-
out = f"{int(y):04d}-{int(mo):02d}-{int(d):02d} {date_only_ref}"
|
|
58
|
-
else:
|
|
59
|
-
out = (
|
|
60
|
-
f"{int(y):04d}-{int(mo):02d}-{int(d):02d} "
|
|
61
|
-
f"{int(hh or 0):02d}:{int(mm or 0):02d}:{int(ss or 0):02d}"
|
|
62
|
-
)
|
|
63
|
-
try:
|
|
64
|
-
datetime.strptime(out, "%Y-%m-%d %H:%M:%S")
|
|
65
|
-
except ValueError as exc:
|
|
66
|
-
raise Fail(f"{where}{field} 不是真实时间:{value!r}({exc})") from exc
|
|
67
|
-
return out
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def to_utc(
|
|
71
|
-
value: str,
|
|
72
|
-
*,
|
|
73
|
-
field: str,
|
|
74
|
-
tid: str | None = None,
|
|
75
|
-
date_only_ref: str | None = None,
|
|
76
|
-
) -> str:
|
|
77
|
-
"""用户按本地墙钟给的时间 → 存盘用的 UTC 串。只由外壳调用。"""
|
|
78
|
-
wall = normalize_time(value, field=field, tid=tid, date_only_ref=date_only_ref)
|
|
79
|
-
naive = datetime.strptime(wall, "%Y-%m-%d %H:%M:%S")
|
|
80
|
-
return naive.astimezone(_UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def fmt_local(stamp: object) -> str:
|
|
84
|
-
"""存盘的 UTC → 查看者本地时间,只用于人看的输出。
|
|
85
|
-
|
|
86
|
-
存量老数据(2026-10-22 前写的是写入者本地时间、无标记)无法区分,
|
|
87
|
-
按 UTC 转会显示偏后若干小时 —— 这是已知例外,不在脚本里猜。
|
|
88
|
-
"""
|
|
89
|
-
key = time_key(stamp)
|
|
90
|
-
if key is None:
|
|
91
|
-
return "" if stamp is None else str(stamp)
|
|
92
|
-
try:
|
|
93
|
-
dt = datetime.strptime(key, "%Y-%m-%d %H:%M:%S").replace(tzinfo=_UTC)
|
|
94
|
-
except ValueError:
|
|
95
|
-
return str(stamp)
|
|
96
|
-
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def time_key(value: object) -> str | None:
|
|
100
|
-
"""已存时间(可能只到分)→ 秒精度串,用于比较/排序;不合法返回 None。"""
|
|
101
|
-
if not isinstance(value, str) or not TIME_RE.fullmatch(value):
|
|
102
|
-
return None
|
|
103
|
-
return value if len(value) == 19 else value + ":00"
|