docsweep 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.
- docsweep/__init__.py +3 -0
- docsweep/__main__.py +4 -0
- docsweep/activity.py +183 -0
- docsweep/aggregate_index.py +148 -0
- docsweep/archive.py +80 -0
- docsweep/atomic.py +181 -0
- docsweep/auto_triage.py +172 -0
- docsweep/brief/__init__.py +9 -0
- docsweep/brief/score.py +132 -0
- docsweep/brief/service.py +243 -0
- docsweep/capture/__init__.py +15 -0
- docsweep/capture/heuristics.py +69 -0
- docsweep/capture/llm.py +152 -0
- docsweep/capture/models.py +29 -0
- docsweep/capture/service.py +79 -0
- docsweep/claim.py +112 -0
- docsweep/cli.py +1771 -0
- docsweep/completion.py +157 -0
- docsweep/config.py +418 -0
- docsweep/context.py +160 -0
- docsweep/cross/__init__.py +11 -0
- docsweep/cross/service.py +207 -0
- docsweep/detect.py +328 -0
- docsweep/engine.py +312 -0
- docsweep/export.py +287 -0
- docsweep/find.py +133 -0
- docsweep/graph/__init__.py +8 -0
- docsweep/graph/service.py +106 -0
- docsweep/index.py +521 -0
- docsweep/inject.py +654 -0
- docsweep/interactive.py +344 -0
- docsweep/linkcheck.py +231 -0
- docsweep/mcp_server.py +471 -0
- docsweep/migrate.py +167 -0
- docsweep/models.py +94 -0
- docsweep/presets.py +56 -0
- docsweep/related.py +175 -0
- docsweep/reports.py +315 -0
- docsweep/resurrect/__init__.py +12 -0
- docsweep/resurrect/embedding.py +43 -0
- docsweep/resurrect/service.py +184 -0
- docsweep/resurrect/similarity.py +38 -0
- docsweep/review.py +69 -0
- docsweep/scan.py +431 -0
- docsweep/security/__init__.py +13 -0
- docsweep/security/path.py +65 -0
- docsweep/server/__init__.py +5 -0
- docsweep/server/app.py +319 -0
- docsweep/server/routes/__init__.py +11 -0
- docsweep/server/routes/board.py +456 -0
- docsweep/server/routes/brief.py +66 -0
- docsweep/server/routes/capture.py +108 -0
- docsweep/server/routes/cards.py +436 -0
- docsweep/server/routes/cross.py +62 -0
- docsweep/server/routes/graph.py +54 -0
- docsweep/server/routes/resurrect.py +64 -0
- docsweep/server/sanitize.py +144 -0
- docsweep/server/security.py +47 -0
- docsweep/server/static/board.css +640 -0
- docsweep/server/static/dnd.js +116 -0
- docsweep/server/static/edit.js +258 -0
- docsweep/server/static/htmx.min.js +1 -0
- docsweep/server/static/icons/apple-touch-icon.png +0 -0
- docsweep/server/static/icons/favicon.ico +0 -0
- docsweep/server/static/icons/favicon.svg +30 -0
- docsweep/server/static/icons/icon-192.png +0 -0
- docsweep/server/static/icons/icon-512.png +0 -0
- docsweep/server/static/keymap.js +1123 -0
- docsweep/server/templates/_board_body.html +102 -0
- docsweep/server/templates/_card.html +68 -0
- docsweep/server/templates/_change_picker.html +27 -0
- docsweep/server/templates/_due_picker.html +13 -0
- docsweep/server/templates/_edit_pane.html +73 -0
- docsweep/server/templates/_label_picker.html +14 -0
- docsweep/server/templates/_preview.html +15 -0
- docsweep/server/templates/_settings.html +68 -0
- docsweep/server/templates/board.html +111 -0
- docsweep/server/templates/brief.html +89 -0
- docsweep/server/templates/capture.html +121 -0
- docsweep/server/templates/cross.html +74 -0
- docsweep/server/templates/graph.html +99 -0
- docsweep/server/templates/resurrect.html +58 -0
- docsweep/services/__init__.py +11 -0
- docsweep/services/archive.py +240 -0
- docsweep/services/content.py +68 -0
- docsweep/services/due.py +189 -0
- docsweep/services/frontmatter.py +241 -0
- docsweep/services/status.py +157 -0
- docsweep/stale.py +117 -0
- docsweep/state.py +198 -0
- docsweep/states.py +165 -0
- docsweep/templates_gen.py +178 -0
- docsweep/timeline.py +177 -0
- docsweep-0.1.0.dist-info/METADATA +484 -0
- docsweep-0.1.0.dist-info/RECORD +99 -0
- docsweep-0.1.0.dist-info/WHEEL +4 -0
- docsweep-0.1.0.dist-info/entry_points.txt +2 -0
- docsweep-0.1.0.dist-info/licenses/LICENSE +21 -0
- docsweep-0.1.0.dist-info/licenses/NOTICES.md +47 -0
docsweep/__init__.py
ADDED
docsweep/__main__.py
ADDED
docsweep/activity.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""``docsweep activity`` — 過去に触ったもの/今後期限のものを日付でまとめる。
|
|
2
|
+
|
|
3
|
+
新規永続化は一切行わない。``scan_records()`` が既に返す ``mtime``(過去日軸)と
|
|
4
|
+
``due``(未来日軸)だけを使い、日付ごとにグルーピングする薄い読み取り専用ロジック。
|
|
5
|
+
today 自身は両軸を出す。plan_activity-summary.md C1 の主要 deliverable。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import date, datetime, timedelta, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .brief.service import _detect_cwd_project, _resolve_target_projects
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .engine import scan_records
|
|
17
|
+
from .models import FileRecord
|
|
18
|
+
from .services.due import DueParseError, resolve_relative_offset
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ActivityDateError(ValueError):
|
|
22
|
+
"""``--date``/``--since``/``--until`` の指定が解釈できないときに発生。"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_date_token(token: str, *, today: date) -> date:
|
|
26
|
+
"""``--date`` の 1 トークンを解決する(today/yesterday/tomorrow/YYYY-MM-DD)。"""
|
|
27
|
+
t = token.strip().lower()
|
|
28
|
+
if t == "today":
|
|
29
|
+
return today
|
|
30
|
+
if t == "yesterday":
|
|
31
|
+
return today - timedelta(days=1)
|
|
32
|
+
if t == "tomorrow":
|
|
33
|
+
return today + timedelta(days=1)
|
|
34
|
+
try:
|
|
35
|
+
return date.fromisoformat(token.strip())
|
|
36
|
+
except ValueError as e:
|
|
37
|
+
raise ActivityDateError(f"--date を解釈できません: {token!r}") from e
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_target_dates(
|
|
41
|
+
dates: list[str] | None,
|
|
42
|
+
*,
|
|
43
|
+
since: str | None,
|
|
44
|
+
until: str | None,
|
|
45
|
+
today: date,
|
|
46
|
+
) -> list[date]:
|
|
47
|
+
"""CLI 引数から対象日付の一覧(昇順・重複無し)を組み立てる。
|
|
48
|
+
|
|
49
|
+
``--since``/``--until`` と ``--date`` は和集合(両方指定されたら両方を含める)。
|
|
50
|
+
どちらも未指定なら既定(today + yesterday)。
|
|
51
|
+
"""
|
|
52
|
+
out: set[date] = set()
|
|
53
|
+
if dates:
|
|
54
|
+
for tok in dates:
|
|
55
|
+
out.add(_resolve_date_token(tok, today=today))
|
|
56
|
+
if since or until:
|
|
57
|
+
try:
|
|
58
|
+
start = resolve_relative_offset(since, today=today) if since else today
|
|
59
|
+
end = resolve_relative_offset(until, today=today) if until else today
|
|
60
|
+
except DueParseError as e:
|
|
61
|
+
raise ActivityDateError(str(e)) from e
|
|
62
|
+
if start > end:
|
|
63
|
+
start, end = end, start
|
|
64
|
+
cur = start
|
|
65
|
+
while cur <= end:
|
|
66
|
+
out.add(cur)
|
|
67
|
+
cur += timedelta(days=1)
|
|
68
|
+
if not out:
|
|
69
|
+
out = {today, today - timedelta(days=1)}
|
|
70
|
+
return sorted(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _axes_for_date(d: date, *, today: date) -> tuple[bool, bool]:
|
|
74
|
+
"""(mtime 軸を出すか, due 軸を出すか) を返す。today 自身は両方 True。"""
|
|
75
|
+
if d < today:
|
|
76
|
+
return (True, False)
|
|
77
|
+
if d > today:
|
|
78
|
+
return (False, True)
|
|
79
|
+
return (True, True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _short_record(rec: FileRecord) -> dict:
|
|
83
|
+
return {
|
|
84
|
+
"path": rec.path,
|
|
85
|
+
"rel": Path(rec.path).name,
|
|
86
|
+
"project": rec.project,
|
|
87
|
+
"type": rec.type,
|
|
88
|
+
"state": rec.state,
|
|
89
|
+
"state_label": rec.state_label,
|
|
90
|
+
"title": rec.title,
|
|
91
|
+
"due": rec.due,
|
|
92
|
+
"age_days": rec.age_days,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class DateBucket:
|
|
98
|
+
"""1 日分のグルーピング結果。"""
|
|
99
|
+
|
|
100
|
+
touched: list[dict] = field(default_factory=list) # mtime 軸(触ったもの)
|
|
101
|
+
due: list[dict] = field(default_factory=list) # due 軸(期限のもの)
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict:
|
|
104
|
+
return {"touched": list(self.touched), "due": list(self.due)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ActivityResult:
|
|
109
|
+
"""activity の最終出力。CLI/JSON 共通。"""
|
|
110
|
+
|
|
111
|
+
generated_at: str
|
|
112
|
+
today: str
|
|
113
|
+
dates: dict[str, DateBucket] = field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict:
|
|
116
|
+
return {
|
|
117
|
+
"generated_at": self.generated_at,
|
|
118
|
+
"today": self.today,
|
|
119
|
+
"dates": {k: v.to_dict() for k, v in self.dates.items()},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_activity(
|
|
124
|
+
config: Config,
|
|
125
|
+
*,
|
|
126
|
+
dates: list[str] | None = None,
|
|
127
|
+
since: str | None = None,
|
|
128
|
+
until: str | None = None,
|
|
129
|
+
project: str | None = None,
|
|
130
|
+
all_projects: bool = False,
|
|
131
|
+
today: date | None = None,
|
|
132
|
+
) -> ActivityResult:
|
|
133
|
+
"""activity を 1 回ぶん組み立てて返す。
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
config: ロード済み Config
|
|
137
|
+
dates: ``--date`` の複数指定(today/yesterday/tomorrow/YYYY-MM-DD)
|
|
138
|
+
since: ``--since``(絶対 or 符号付き相対オフセット)
|
|
139
|
+
until: ``--until``(同上)
|
|
140
|
+
project: 単一プロジェクト指定(``project_id`` 文字列)
|
|
141
|
+
all_projects: True で search_paths の全プロジェクトを束ねる
|
|
142
|
+
today: テスト用の日付固定。未指定なら ``date.today()``
|
|
143
|
+
"""
|
|
144
|
+
now = datetime.now(timezone.utc).astimezone()
|
|
145
|
+
today_date = today or now.date()
|
|
146
|
+
|
|
147
|
+
target_dates = resolve_target_dates(dates, since=since, until=until, today=today_date)
|
|
148
|
+
|
|
149
|
+
records = scan_records(config)
|
|
150
|
+
cwd_proj = _detect_cwd_project(config) if not (project or all_projects) else None
|
|
151
|
+
targets = _resolve_target_projects(
|
|
152
|
+
records, project=project, all_projects=all_projects, cwd_project=cwd_proj,
|
|
153
|
+
)
|
|
154
|
+
scoped = [r for r in records if r.project in targets]
|
|
155
|
+
|
|
156
|
+
buckets: dict[str, DateBucket] = {}
|
|
157
|
+
for d in target_dates:
|
|
158
|
+
want_mtime, want_due = _axes_for_date(d, today=today_date)
|
|
159
|
+
bucket = DateBucket()
|
|
160
|
+
if want_mtime:
|
|
161
|
+
bucket.touched = [
|
|
162
|
+
_short_record(r) for r in scoped
|
|
163
|
+
if r.mtime and datetime.fromtimestamp(r.mtime).astimezone().date() == d
|
|
164
|
+
]
|
|
165
|
+
bucket.touched.sort(key=lambda x: (x["project"] or "", x["rel"]))
|
|
166
|
+
if want_due:
|
|
167
|
+
for r in scoped:
|
|
168
|
+
if not r.due:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
due_date = date.fromisoformat(r.due)
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
if due_date == d:
|
|
175
|
+
bucket.due.append(_short_record(r))
|
|
176
|
+
bucket.due.sort(key=lambda x: (x["project"] or "", x["rel"]))
|
|
177
|
+
buckets[d.isoformat()] = bucket
|
|
178
|
+
|
|
179
|
+
return ActivityResult(
|
|
180
|
+
generated_at=now.isoformat(),
|
|
181
|
+
today=today_date.isoformat(),
|
|
182
|
+
dates=buckets,
|
|
183
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""横断集約 INDEX(develop 全体を 1 か所で把握)— C5。
|
|
2
|
+
|
|
3
|
+
実ファイルは各プロジェクトに残したまま、スキャンルート直下に論理集約の INDEX を生成する。
|
|
4
|
+
- INDEX.json: AI エージェント連携用(ステータス別・pending 常設・要判断/要修正)
|
|
5
|
+
- INDEX.md: 人間がエディタで一覧
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .engine import ScanResult, run_scan, scan_records
|
|
16
|
+
from .models import Flag
|
|
17
|
+
|
|
18
|
+
INDEX_DIRNAME = ".docsweep"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class IndexData:
|
|
23
|
+
roots: list[str]
|
|
24
|
+
counts: dict
|
|
25
|
+
by_state: dict[str, list[dict]]
|
|
26
|
+
pending: list[dict]
|
|
27
|
+
needs_decision: list[dict]
|
|
28
|
+
needs_fix: list[dict]
|
|
29
|
+
overdue_todo: list[dict] = None # type: ignore[assignment]
|
|
30
|
+
overdue_graduate: list[dict] = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
if self.overdue_todo is None:
|
|
34
|
+
self.overdue_todo = []
|
|
35
|
+
if self.overdue_graduate is None:
|
|
36
|
+
self.overdue_graduate = []
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_index(config: Config, result: ScanResult | None = None) -> IndexData:
|
|
43
|
+
# 引数で ScanResult が渡されていれば優先(書き込み系から呼ぶ古い経路)。
|
|
44
|
+
# 渡されなければ索引フォールバック付きの scan_records を使う(fast path)。
|
|
45
|
+
if result is not None:
|
|
46
|
+
recs = result.records
|
|
47
|
+
else:
|
|
48
|
+
recs = scan_records(config)
|
|
49
|
+
|
|
50
|
+
by_state: dict[str, list[dict]] = {}
|
|
51
|
+
for r in recs:
|
|
52
|
+
by_state.setdefault(r.state or "unknown", []).append(r.to_dict())
|
|
53
|
+
for v in by_state.values():
|
|
54
|
+
v.sort(key=lambda d: d["age_days"], reverse=True)
|
|
55
|
+
|
|
56
|
+
pending = [r.to_dict() for r in recs if r.state == "pending"]
|
|
57
|
+
needs_decision = sorted(
|
|
58
|
+
[r.to_dict() for r in recs if Flag.NEEDS_DECISION.value in r.flags],
|
|
59
|
+
key=lambda d: d["age_days"], reverse=True,
|
|
60
|
+
)
|
|
61
|
+
needs_fix = [r.to_dict() for r in recs if Flag.NEEDS_FIX.value in r.flags]
|
|
62
|
+
overdue_todo = sorted(
|
|
63
|
+
[r.to_dict() for r in recs if Flag.OVERDUE_TODO.value in r.flags],
|
|
64
|
+
key=lambda d: d.get("due") or "",
|
|
65
|
+
)
|
|
66
|
+
overdue_graduate = sorted(
|
|
67
|
+
[r.to_dict() for r in recs if Flag.OVERDUE_GRADUATE.value in r.flags],
|
|
68
|
+
key=lambda d: d.get("due") or "",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
counts = {
|
|
72
|
+
"total": len(recs),
|
|
73
|
+
"projects": len({r.project for r in recs}),
|
|
74
|
+
"needs_decision": len(needs_decision),
|
|
75
|
+
"needs_fix": len(needs_fix),
|
|
76
|
+
"pending": len(pending),
|
|
77
|
+
"archivable": sum(1 for r in recs if r.auto_movable and r.archivable),
|
|
78
|
+
"overdue_todo": len(overdue_todo),
|
|
79
|
+
"overdue_graduate": len(overdue_graduate),
|
|
80
|
+
}
|
|
81
|
+
return IndexData(
|
|
82
|
+
roots=[str(r) for r in config.roots],
|
|
83
|
+
counts=counts,
|
|
84
|
+
by_state=by_state,
|
|
85
|
+
pending=pending,
|
|
86
|
+
needs_decision=needs_decision,
|
|
87
|
+
needs_fix=needs_fix,
|
|
88
|
+
overdue_todo=overdue_todo,
|
|
89
|
+
overdue_graduate=overdue_graduate,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _render_row(d: dict) -> str:
|
|
94
|
+
name = Path(d["path"]).name
|
|
95
|
+
label = d.get("state_label") or "[?]"
|
|
96
|
+
summary = f" — {d['summary']}" if d.get("summary") else ""
|
|
97
|
+
flags = f" `{','.join(d['flags'])}`" if d.get("flags") else ""
|
|
98
|
+
return f"- {label} **{d['project']}**/{name} · {d['age_days']}d{flags}{summary}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_markdown(idx: IndexData, state_model) -> str:
|
|
102
|
+
c = idx.counts
|
|
103
|
+
lines: list[str] = [
|
|
104
|
+
"# docsweep INDEX",
|
|
105
|
+
"",
|
|
106
|
+
f"> 横断集約: {c['projects']} プロジェクト / {c['total']} 件 = "
|
|
107
|
+
f"要判断 {c['needs_decision']} · 要修正 {c['needs_fix']} · 保留 {c['pending']} · archive候補 {c['archivable']}",
|
|
108
|
+
"",
|
|
109
|
+
]
|
|
110
|
+
if idx.needs_decision:
|
|
111
|
+
lines += ["## ⚠ 要判断(陳腐化)", ""]
|
|
112
|
+
lines += [_render_row(d) for d in idx.needs_decision]
|
|
113
|
+
lines += [""]
|
|
114
|
+
if idx.pending:
|
|
115
|
+
lines += ["## 💤 保留(pending)", ""]
|
|
116
|
+
lines += [_render_row(d) for d in idx.pending]
|
|
117
|
+
lines += [""]
|
|
118
|
+
if idx.needs_fix:
|
|
119
|
+
lines += ["## 🔧 要修正(ラベル欠落・パース不能)", ""]
|
|
120
|
+
lines += [_render_row(d) for d in idx.needs_fix]
|
|
121
|
+
lines += [""]
|
|
122
|
+
|
|
123
|
+
lines += ["## ステータス別", ""]
|
|
124
|
+
for key in sorted(idx.by_state):
|
|
125
|
+
recs = idx.by_state[key]
|
|
126
|
+
st = state_model.by_key(key) if state_model else None
|
|
127
|
+
label = f"[{st.label()}]" if st else f"[{key}]"
|
|
128
|
+
lines += [f"### {label} ({len(recs)})", ""]
|
|
129
|
+
lines += [_render_row(d) for d in recs]
|
|
130
|
+
lines += [""]
|
|
131
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def index_dir(config: Config) -> Path:
|
|
135
|
+
base = config.roots[0] if config.roots else Path.cwd()
|
|
136
|
+
return Path(base) / INDEX_DIRNAME
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_index(config: Config, result: ScanResult | None = None) -> tuple[Path, Path]:
|
|
140
|
+
"""INDEX.json / INDEX.md をスキャンルート直下の .docsweep/ に書き出す。"""
|
|
141
|
+
idx = build_index(config, result)
|
|
142
|
+
out_dir = index_dir(config)
|
|
143
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
json_path = out_dir / "INDEX.json"
|
|
145
|
+
md_path = out_dir / "INDEX.md"
|
|
146
|
+
json_path.write_text(json.dumps(idx.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
147
|
+
md_path.write_text(render_markdown(idx, config.state_model), encoding="utf-8")
|
|
148
|
+
return json_path, md_path
|
docsweep/archive.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""archive 移送と移動ログ JSONL。
|
|
2
|
+
|
|
3
|
+
- 場所は config 可変(既定 archive/)。同名衝突は連番(_2)。
|
|
4
|
+
- 移動ログ {ts, op, project, status, src, dst} を JSONL 追記(eject/復元の土台)。
|
|
5
|
+
- 同一ボリューム前提に依存しない(shutil.move で吸収)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .models import MoveLogEntry
|
|
16
|
+
|
|
17
|
+
MOVE_LOG_NAME = "moves.jsonl"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _now_iso() -> str:
|
|
21
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def dedupe_path(dst: Path) -> Path:
|
|
25
|
+
"""衝突時に stem に _2, _3... を付けて空きパスを返す。"""
|
|
26
|
+
if not dst.exists():
|
|
27
|
+
return dst
|
|
28
|
+
stem, suffix, parent = dst.stem, dst.suffix, dst.parent
|
|
29
|
+
n = 2
|
|
30
|
+
while True:
|
|
31
|
+
cand = parent / f"{stem}_{n}{suffix}"
|
|
32
|
+
if not cand.exists():
|
|
33
|
+
return cand
|
|
34
|
+
n += 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def move_log_path(root: Path) -> Path:
|
|
38
|
+
return root / ".docsweep" / MOVE_LOG_NAME
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def append_move_log(root: Path, entry: MoveLogEntry) -> None:
|
|
42
|
+
p = move_log_path(root)
|
|
43
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
with p.open("a", encoding="utf-8") as fh:
|
|
45
|
+
fh.write(json.dumps(entry.to_dict(), ensure_ascii=False) + "\n")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def archive_file(
|
|
49
|
+
*,
|
|
50
|
+
src: Path,
|
|
51
|
+
project_dir: Path,
|
|
52
|
+
archive_dir: str,
|
|
53
|
+
root: Path,
|
|
54
|
+
project: str,
|
|
55
|
+
status: str | None,
|
|
56
|
+
op: str = "archive",
|
|
57
|
+
dry_run: bool = False,
|
|
58
|
+
batch_id: str | None = None,
|
|
59
|
+
) -> Path:
|
|
60
|
+
"""src を project_dir/<archive_dir>/ へ移送し、移動ログに記録する。移送先を返す。
|
|
61
|
+
|
|
62
|
+
``batch_id`` を与えると JSONL の同名フィールドに記録され、後で Undo で逆引きできる。
|
|
63
|
+
"""
|
|
64
|
+
src = src.resolve()
|
|
65
|
+
dest_dir = (project_dir / archive_dir).resolve()
|
|
66
|
+
dst = dedupe_path(dest_dir / src.name)
|
|
67
|
+
|
|
68
|
+
if dry_run:
|
|
69
|
+
return dst
|
|
70
|
+
|
|
71
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
shutil.move(str(src), str(dst))
|
|
73
|
+
append_move_log(
|
|
74
|
+
root,
|
|
75
|
+
MoveLogEntry(
|
|
76
|
+
ts=_now_iso(), op=op, project=project, status=status,
|
|
77
|
+
src=src.as_posix(), dst=dst.as_posix(), batch_id=batch_id,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
return dst
|
docsweep/atomic.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""アトミック書き込み + 楽観ロック(mtime check) + バックアップ。
|
|
2
|
+
|
|
3
|
+
全ての書き込み API はこのヘルパ経由で MD を更新する。Web UI と MCP どちらから書いても安全。
|
|
4
|
+
|
|
5
|
+
- アトミック書き込み: 同ディレクトリに一時ファイル作成 → ``os.replace`` で差し替え。
|
|
6
|
+
Windows でも ``os.replace`` は atomic(NTFS の ``MoveFileEx`` 経由)。
|
|
7
|
+
- 楽観ロック: ``expected_mtime`` を任意引数で受け取り、不一致なら ``ConflictError``。
|
|
8
|
+
Web UI からは必ず送る(読み込み時の mtime を保持)。CLI / MCP は省略可。
|
|
9
|
+
- バックアップ: 全書き込み前に ``.docsweep/backup/`` へコピー。既定 30 日保持・自動掃除。
|
|
10
|
+
- 行単位編集 vs 全置換:
|
|
11
|
+
- ``update_line`` は frontmatter `due:` 1 行や H1 ラベル先頭だけを置換(本文を触らない)
|
|
12
|
+
- ``write_atomic`` は全置換(Web UI の本文編集ペインから呼ぶ)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
BACKUP_DIR_NAME = "backup"
|
|
24
|
+
BACKUP_RETENTION_SECONDS = 30 * 24 * 60 * 60 # 30 日
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConflictError(Exception):
|
|
28
|
+
"""expected_mtime と実 mtime が一致しないときに発生(楽観ロックの不一致)。"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: Path, expected: float, actual: float) -> None:
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"mtime conflict at {path}: expected={expected!r} actual={actual!r}"
|
|
33
|
+
)
|
|
34
|
+
self.path = path
|
|
35
|
+
self.expected = expected
|
|
36
|
+
self.actual = actual
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mtime(path: Path) -> float:
|
|
40
|
+
return path.stat().st_mtime
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _project_root_for(path: Path) -> Path | None:
|
|
44
|
+
"""書き込み対象 path から ``.docsweep/`` を置くプロジェクト境界を辿る。
|
|
45
|
+
|
|
46
|
+
``backup`` の置き場決めにだけ使うので、見つからなければ path の親で代用する
|
|
47
|
+
(バックアップを取らないより取った方が安全という選択)。
|
|
48
|
+
"""
|
|
49
|
+
cur = path.parent.resolve()
|
|
50
|
+
while True:
|
|
51
|
+
for marker in (".docsweep.yaml", ".git", "pyproject.toml", "package.json"):
|
|
52
|
+
if (cur / marker).exists():
|
|
53
|
+
return cur
|
|
54
|
+
parent = cur.parent
|
|
55
|
+
if parent == cur:
|
|
56
|
+
return None
|
|
57
|
+
cur = parent
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def backup_dir_for(path: Path) -> Path:
|
|
61
|
+
root = _project_root_for(path) or path.parent
|
|
62
|
+
return root / ".docsweep" / BACKUP_DIR_NAME
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def backup(path: Path) -> Path | None:
|
|
66
|
+
"""書き込み直前に呼ぶ。``.docsweep/backup/<filename>.<unix_ts>`` へコピー。
|
|
67
|
+
|
|
68
|
+
対象が存在しない(新規作成)場合は何もしないで None を返す。古いバックアップは
|
|
69
|
+
呼び出しのついでに掃除する(30 日超で自動削除)。
|
|
70
|
+
"""
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
return None
|
|
73
|
+
dst_dir = backup_dir_for(path)
|
|
74
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
ts = int(time.time())
|
|
76
|
+
dst = dst_dir / f"{path.name}.{ts}"
|
|
77
|
+
shutil.copy2(str(path), str(dst))
|
|
78
|
+
_cleanup_backups(dst_dir)
|
|
79
|
+
return dst
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cleanup_backups(dst_dir: Path) -> None:
|
|
83
|
+
"""30 日経過したバックアップを削除する(best-effort・失敗無視)。"""
|
|
84
|
+
cutoff = time.time() - BACKUP_RETENTION_SECONDS
|
|
85
|
+
try:
|
|
86
|
+
for entry in dst_dir.iterdir():
|
|
87
|
+
try:
|
|
88
|
+
if entry.is_file() and entry.stat().st_mtime < cutoff:
|
|
89
|
+
entry.unlink()
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_atomic(
|
|
97
|
+
path: Path,
|
|
98
|
+
content: str,
|
|
99
|
+
*,
|
|
100
|
+
expected_mtime: float | None = None,
|
|
101
|
+
encoding: str = "utf-8",
|
|
102
|
+
take_backup: bool = True,
|
|
103
|
+
) -> float:
|
|
104
|
+
"""``path`` の内容を ``content`` で全置換する。書き込み後の新 mtime を返す。
|
|
105
|
+
|
|
106
|
+
``expected_mtime`` が指定され、実 mtime と一致しなければ ``ConflictError``。
|
|
107
|
+
``take_backup=True`` のとき、既存ファイルを `.docsweep/backup/` へ退避する。
|
|
108
|
+
"""
|
|
109
|
+
path = Path(path)
|
|
110
|
+
if expected_mtime is not None and path.is_file():
|
|
111
|
+
actual = _mtime(path)
|
|
112
|
+
if not _mtime_close(actual, expected_mtime):
|
|
113
|
+
raise ConflictError(path, expected_mtime, actual)
|
|
114
|
+
if take_backup:
|
|
115
|
+
backup(path)
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
118
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
with os.fdopen(fd, "w", encoding=encoding, newline="") as fh:
|
|
122
|
+
fh.write(content)
|
|
123
|
+
os.replace(tmp_name, str(path))
|
|
124
|
+
except Exception:
|
|
125
|
+
# 一時ファイルが残らないよう片付ける。
|
|
126
|
+
try:
|
|
127
|
+
os.unlink(tmp_name)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
raise
|
|
131
|
+
return _mtime(path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def update_line(
|
|
135
|
+
path: Path,
|
|
136
|
+
*,
|
|
137
|
+
transform,
|
|
138
|
+
expected_mtime: float | None = None,
|
|
139
|
+
take_backup: bool = True,
|
|
140
|
+
) -> float:
|
|
141
|
+
"""``transform(text) -> new_text`` を経由して 1 ファイルを書き換える。
|
|
142
|
+
|
|
143
|
+
呼び出し側の transform 関数が「frontmatter `due:` を 1 行だけ差し替える」「H1 行先頭の
|
|
144
|
+
ラベルだけ差し替える」のような行単位の正規表現置換を行うため、本ヘルパは
|
|
145
|
+
アトミック書き込み・楽観ロック・バックアップだけを担う。
|
|
146
|
+
"""
|
|
147
|
+
path = Path(path)
|
|
148
|
+
if expected_mtime is not None and path.is_file():
|
|
149
|
+
actual = _mtime(path)
|
|
150
|
+
if not _mtime_close(actual, expected_mtime):
|
|
151
|
+
raise ConflictError(path, expected_mtime, actual)
|
|
152
|
+
text = path.read_text(encoding="utf-8", newline="")
|
|
153
|
+
new_text = transform(text)
|
|
154
|
+
if new_text == text:
|
|
155
|
+
# 変更なし。書き込みも backup も省略する。
|
|
156
|
+
return _mtime(path)
|
|
157
|
+
if take_backup:
|
|
158
|
+
backup(path)
|
|
159
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
160
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
|
|
164
|
+
fh.write(new_text)
|
|
165
|
+
os.replace(tmp_name, str(path))
|
|
166
|
+
except Exception:
|
|
167
|
+
try:
|
|
168
|
+
os.unlink(tmp_name)
|
|
169
|
+
except OSError:
|
|
170
|
+
pass
|
|
171
|
+
raise
|
|
172
|
+
return _mtime(path)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _mtime_close(a: float, b: float, *, tol: float = 0.001) -> bool:
|
|
176
|
+
"""ファイルシステムの mtime 解像度(Windows FAT は 2 秒・NTFS は 100ns)を考慮した近似比較。
|
|
177
|
+
|
|
178
|
+
そのまま `==` で比較すると、JSON 経由で round-trip した mtime と実 mtime が
|
|
179
|
+
sub-millisecond オーダーで微妙にズレて誤検出する。1ms 以内なら同一とみなす。
|
|
180
|
+
"""
|
|
181
|
+
return abs(a - b) < tol
|