estimating-tools 0.1.87__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.
- estimating_tools/__init__.py +7 -0
- estimating_tools/artifact_app/__init__.py +1 -0
- estimating_tools/artifact_app/app.html +195 -0
- estimating_tools/artifact_app/browser.py +194 -0
- estimating_tools/artifact_app/server.py +311 -0
- estimating_tools/artifact_common.py +56 -0
- estimating_tools/artifacts.py +28 -0
- estimating_tools/artifacts_html.py +391 -0
- estimating_tools/assemblies/__init__.py +27 -0
- estimating_tools/assemblies/artifact.py +420 -0
- estimating_tools/assemblies/cli.py +54 -0
- estimating_tools/assemblies/excel.py +293 -0
- estimating_tools/assemblies/load_cli.py +72 -0
- estimating_tools/assemblies/load_model.py +69 -0
- estimating_tools/assemblies/model.py +42 -0
- estimating_tools/assemblies/plan.py +396 -0
- estimating_tools/auth/__init__.py +0 -0
- estimating_tools/auth/cli.py +113 -0
- estimating_tools/auth/m2m.py +55 -0
- estimating_tools/calcbook/__init__.py +36 -0
- estimating_tools/calcbook/formula.py +57 -0
- estimating_tools/calcbook/recalc.py +45 -0
- estimating_tools/calcbook/writer.py +148 -0
- estimating_tools/costcodes/__init__.py +16 -0
- estimating_tools/costcodes/cli.py +71 -0
- estimating_tools/costcodes/excel.py +213 -0
- estimating_tools/costcodes/model.py +51 -0
- estimating_tools/costcodes/resolver.py +163 -0
- estimating_tools/costcodes/strategies.py +131 -0
- estimating_tools/directory/__init__.py +12 -0
- estimating_tools/directory/cli.py +52 -0
- estimating_tools/directory/diff.py +225 -0
- estimating_tools/directory/excel.py +141 -0
- estimating_tools/directory/model.py +63 -0
- estimating_tools/directory/sync_cli.py +57 -0
- estimating_tools/docgen/__init__.py +0 -0
- estimating_tools/docgen/docxtpl_engine.py +103 -0
- estimating_tools/docgen/letterhead_cli.py +111 -0
- estimating_tools/docgen/report_engine.py +445 -0
- estimating_tools/docgen/xlsx_populate.py +40 -0
- estimating_tools/docgen/yaml_tree.py +50 -0
- estimating_tools/estimate_spec/__init__.py +0 -0
- estimating_tools/estimate_spec/_shared.py +20 -0
- estimating_tools/estimate_spec/apply.py +286 -0
- estimating_tools/estimate_spec/artifact.py +878 -0
- estimating_tools/estimate_spec/build.py +709 -0
- estimating_tools/estimate_spec/cip.py +518 -0
- estimating_tools/estimate_spec/cli.py +496 -0
- estimating_tools/estimate_spec/compact.py +525 -0
- estimating_tools/estimate_spec/format_pack.py +359 -0
- estimating_tools/estimate_spec/packs/README.md +173 -0
- estimating_tools/estimate_spec/packs/demo/examples/demo-pipeline.estimate.yaml +91 -0
- estimating_tools/estimate_spec/packs/demo/format.yaml +23 -0
- estimating_tools/estimate_spec/packs/demo/recipes.yaml +61 -0
- estimating_tools/estimate_spec/packs/demo/seed.yaml +45 -0
- estimating_tools/estimate_spec/packs/demo/tokens.yaml +121 -0
- estimating_tools/estimate_spec/packs/demo/units.yaml +218 -0
- estimating_tools/estimate_spec/packs/demo/vocabulary.yaml +51 -0
- estimating_tools/estimate_spec/recipe.py +207 -0
- estimating_tools/estimate_spec/scaffold.py +506 -0
- estimating_tools/estimate_spec/scaffold_save.py +98 -0
- estimating_tools/estimate_spec/schema/skeleton.schema.json +208 -0
- estimating_tools/estimate_spec/summarize.py +433 -0
- estimating_tools/estimate_spec/sync.py +380 -0
- estimating_tools/estimate_spec/sync_executor.py +405 -0
- estimating_tools/estimate_spec/validate.py +75 -0
- estimating_tools/excel/__init__.py +0 -0
- estimating_tools/excel/excel_reader.py +119 -0
- estimating_tools/historicals/__init__.py +0 -0
- estimating_tools/historicals/cli.py +188 -0
- estimating_tools/historicals/estimates.py +86 -0
- estimating_tools/historicals/model.py +141 -0
- estimating_tools/historicals/suggest.py +151 -0
- estimating_tools/historicals/tabs.py +182 -0
- estimating_tools/importers/__init__.py +11 -0
- estimating_tools/importers/cli.py +51 -0
- estimating_tools/importers/hcss.py +347 -0
- estimating_tools/importers/mapping_artifact.py +184 -0
- estimating_tools/importers/model.py +49 -0
- estimating_tools/importers/pricing.py +102 -0
- estimating_tools/importers/push.py +137 -0
- estimating_tools/importers/registry.py +35 -0
- estimating_tools/importers/schema/canonical-estimate.schema.json +47 -0
- estimating_tools/importers/validate.py +20 -0
- estimating_tools/intake/__init__.py +4 -0
- estimating_tools/intake/bid_frame.py +124 -0
- estimating_tools/intake/briefing.py +87 -0
- estimating_tools/intake/cli.py +237 -0
- estimating_tools/intake/manifest.py +111 -0
- estimating_tools/pdf/__init__.py +0 -0
- estimating_tools/pdf/extract_text.py +157 -0
- estimating_tools/profiles/__init__.py +15 -0
- estimating_tools/profiles/build.py +109 -0
- estimating_tools/profiles/cli.py +73 -0
- estimating_tools/profiles/loader.py +94 -0
- estimating_tools/profiles/merge.py +25 -0
- estimating_tools/profiles/model.py +22 -0
- estimating_tools/profiles/scaffold.py +82 -0
- estimating_tools/profiles/schema/profile.schema.json +69 -0
- estimating_tools/profiles/trees/work-bid-layout.yaml +14 -0
- estimating_tools/profiles/trees/work-project-layout.yaml +9 -0
- estimating_tools/profiles/wires.py +57 -0
- estimating_tools/quotes/__init__.py +16 -0
- estimating_tools/quotes/model.py +32 -0
- estimating_tools/quotes/plan.py +121 -0
- estimating_tools/quotes/revision.py +58 -0
- estimating_tools/quotespace/__init__.py +0 -0
- estimating_tools/quotespace/cli.py +277 -0
- estimating_tools/quotespace/conditions.py +111 -0
- estimating_tools/quotespace/coverage.py +177 -0
- estimating_tools/quotespace/itb.py +64 -0
- estimating_tools/quotespace/model.py +143 -0
- estimating_tools/rates/__init__.py +13 -0
- estimating_tools/rates/cli.py +52 -0
- estimating_tools/rates/excel.py +151 -0
- estimating_tools/rates/model.py +56 -0
- estimating_tools/rebar/__init__.py +4 -0
- estimating_tools/rebar/cli.py +202 -0
- estimating_tools/rebar/detailing.py +68 -0
- estimating_tools/rebar/develop.py +54 -0
- estimating_tools/rebar/ifc.py +223 -0
- estimating_tools/rebar/model.py +69 -0
- estimating_tools/rebar/schedule.py +205 -0
- estimating_tools/reconcile/__init__.py +22 -0
- estimating_tools/reconcile/diff.py +229 -0
- estimating_tools/schedule/__init__.py +0 -0
- estimating_tools/schedule/cli.py +227 -0
- estimating_tools/schedule/costload.py +180 -0
- estimating_tools/schedule/gantt_to_ganttwarrior.py +327 -0
- estimating_tools/schedule/importers.py +13 -0
- estimating_tools/schedule/model.py +9 -0
- estimating_tools/schedule/render.py +73 -0
- estimating_tools/schedule/scope.py +225 -0
- estimating_tools/schedule/xer.py +371 -0
- estimating_tools/spreads/__init__.py +16 -0
- estimating_tools/spreads/award.py +184 -0
- estimating_tools/spreads/conditions.py +251 -0
- estimating_tools/spreads/item_quotes.py +96 -0
- estimating_tools/spreads/level.py +139 -0
- estimating_tools/spreads/model.py +83 -0
- estimating_tools/spreads/render.py +156 -0
- estimating_tools/takeoff/__init__.py +0 -0
- estimating_tools/takeoff/audit.py +556 -0
- estimating_tools/takeoff/audit_xlsx.py +127 -0
- estimating_tools/takeoff/cli.py +180 -0
- estimating_tools/takeoff/core.py +165 -0
- estimating_tools/takeoff/earthwork.py +159 -0
- estimating_tools/takeoff/model.py +79 -0
- estimating_tools/takeoff/push.py +409 -0
- estimating_tools/xlsx_determinism.py +38 -0
- estimating_tools-0.1.87.dist-info/METADATA +72 -0
- estimating_tools-0.1.87.dist-info/RECORD +156 -0
- estimating_tools-0.1.87.dist-info/WHEEL +5 -0
- estimating_tools-0.1.87.dist-info/entry_points.txt +24 -0
- estimating_tools-0.1.87.dist-info/licenses/LICENSE +202 -0
- estimating_tools-0.1.87.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Deterministic construction estimating and engineering tools."""
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
__version__ = version("estimating_tools")
|
|
6
|
+
except PackageNotFoundError: # running from source without an install
|
|
7
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP App surface: stdio server + static editor app (io.modelcontextprotocol/ui)."""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Estimating artifact editor</title>
|
|
7
|
+
<style>
|
|
8
|
+
body{font:14px/1.45 system-ui,"Segoe UI",sans-serif;margin:0;color:#1c2422;background:#fff}
|
|
9
|
+
main{max-width:1100px;margin:0 auto;padding:0 20px 60px}
|
|
10
|
+
header{position:sticky;top:0;background:#fff;border-bottom:1px solid #e2e6e4;padding:10px 20px;display:flex;gap:12px;align-items:center;z-index:2}
|
|
11
|
+
header h1{font-size:17px;margin:0;flex:1}
|
|
12
|
+
h2{font-size:15px;margin:18px 0 6px}
|
|
13
|
+
h3,h4,h5,h6{font-size:14px;margin:14px 0 4px}
|
|
14
|
+
table{border-collapse:collapse;margin:10px 0;width:100%}
|
|
15
|
+
th,td{border:1px solid #d8dedb;padding:4px 8px;text-align:left;font-variant-numeric:tabular-nums;font-size:13px}
|
|
16
|
+
th{background:#eef1ef;font-size:12px}
|
|
17
|
+
td[contenteditable]:focus{outline:2px solid #0e6e63;background:#f4faf9}
|
|
18
|
+
.ext{background:#f7f4ec;color:#6b5d33;font-style:italic}
|
|
19
|
+
th.ext span{display:block;font-size:9px;font-weight:400;text-transform:uppercase;letter-spacing:.05em}
|
|
20
|
+
td.ctl,th.ctl{border:none;background:none;width:26px;padding:0;text-align:center}
|
|
21
|
+
.del{border:none;background:none;color:#b04a3a;cursor:pointer;font-size:14px}
|
|
22
|
+
.add{margin:0 0 14px;border:1px dashed #9aa5a1;background:none;border-radius:4px;padding:2px 10px;cursor:pointer;color:#55605c;font-size:12px}
|
|
23
|
+
#copy{background:#0e6e63;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600}
|
|
24
|
+
#out{display:none;width:100%;height:150px;margin:10px 0;font:12px/1.4 Consolas,monospace}
|
|
25
|
+
blockquote{border-left:3px solid #0e6e63;margin:8px 0;padding:4px 10px;background:#f2f5f4}
|
|
26
|
+
.hint{color:#55605c;font-size:12px;font-style:italic}
|
|
27
|
+
tfoot td{font-weight:600}
|
|
28
|
+
ul{margin:6px 0;padding-left:22px}
|
|
29
|
+
#save{background:#0e6e63;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600}
|
|
30
|
+
#copy{background:none;color:#0e6e63;border:1px solid #0e6e63}
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<header><h1 id="title">Artifact editor</h1><span id="toolbar"></span></header>
|
|
35
|
+
<main>
|
|
36
|
+
<textarea id="out" readonly></textarea>
|
|
37
|
+
<div id="body"><p class="hint">Waiting for the artifact…</p></div>
|
|
38
|
+
</main>
|
|
39
|
+
<script>
|
|
40
|
+
/* ---- MCP Apps postMessage bridge (app <-> host) ---- */
|
|
41
|
+
let DOC = {lines: [], tables: []};
|
|
42
|
+
let nextId = 1;
|
|
43
|
+
const pending = new Map();
|
|
44
|
+
function send(msg) { window.parent.postMessage(msg, "*"); }
|
|
45
|
+
function request(method, params) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const id = nextId++;
|
|
48
|
+
pending.set(id, {resolve, reject});
|
|
49
|
+
send({jsonrpc: "2.0", id, method, params});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function notify(method, params) { send({jsonrpc: "2.0", method, params}); }
|
|
53
|
+
window.addEventListener("message", ev => {
|
|
54
|
+
if (ev.source !== window.parent) return;
|
|
55
|
+
const m = ev.data;
|
|
56
|
+
if (!m || m.jsonrpc !== "2.0") return;
|
|
57
|
+
if (m.id !== undefined && m.method === undefined) { /* response */
|
|
58
|
+
const p = pending.get(m.id); if (!p) return;
|
|
59
|
+
pending.delete(m.id);
|
|
60
|
+
if (m.error) p.reject(m.error); else p.resolve(m.result);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (m.method === "ui/notifications/tool-input") return; /* render comes from tool-result */
|
|
64
|
+
if (m.method === "ui/notifications/tool-result") {
|
|
65
|
+
const sc = m.params && m.params.structuredContent;
|
|
66
|
+
if (sc) initDoc(sc);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (m.method === "ui/resource-teardown" && m.id !== undefined) {
|
|
70
|
+
send({jsonrpc: "2.0", id: m.id, result: {}});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
request("ui/initialize", {
|
|
74
|
+
appInfo: {name: "estimating-artifact-editor", version: "1.0.0"},
|
|
75
|
+
appCapabilities: {},
|
|
76
|
+
protocolVersion: "2026-01-26",
|
|
77
|
+
}).then(res => {
|
|
78
|
+
const theme = res && res.hostContext && res.hostContext.theme;
|
|
79
|
+
if (theme) document.documentElement.dataset.theme = theme;
|
|
80
|
+
notify("ui/notifications/initialized", {});
|
|
81
|
+
}, () => {});
|
|
82
|
+
function initDoc(sc) {
|
|
83
|
+
DOC = {lines: sc.lines, tables: sc.tables};
|
|
84
|
+
document.title = sc.title || "Artifact";
|
|
85
|
+
document.getElementById("title").textContent = sc.title || "Artifact";
|
|
86
|
+
document.getElementById("body").innerHTML = sc.body; /* server-escaped fragment */
|
|
87
|
+
const tb = document.getElementById("toolbar");
|
|
88
|
+
tb.textContent = "";
|
|
89
|
+
if (sc.editable) {
|
|
90
|
+
const save = document.createElement("button");
|
|
91
|
+
save.id = "save"; save.textContent = "Save & send to chat";
|
|
92
|
+
const copy = document.createElement("button");
|
|
93
|
+
copy.id = "copy"; copy.textContent = "Copy edited artifact";
|
|
94
|
+
tb.append(save, copy);
|
|
95
|
+
}
|
|
96
|
+
recompute();
|
|
97
|
+
}
|
|
98
|
+
document.addEventListener("click", e => {
|
|
99
|
+
if (e.target.id === "save") {
|
|
100
|
+
const md = exportMarkdown();
|
|
101
|
+
e.target.disabled = true;
|
|
102
|
+
request("ui/message", {role: "user", content: [{type: "text", text: md}]}).then(
|
|
103
|
+
res => {
|
|
104
|
+
if (res && res.isError) { /* McpUiMessageResult soft failure */
|
|
105
|
+
e.target.disabled = false; copyOut(e.target, md); return;
|
|
106
|
+
}
|
|
107
|
+
e.target.textContent = "Sent ✓ — check the chat";
|
|
108
|
+
e.target.disabled = false; /* repeat Saves are supported */
|
|
109
|
+
},
|
|
110
|
+
() => { e.target.disabled = false; copyOut(e.target, md); });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
/* GRID-CORE begin — byte-identical across surfaces (test-enforced) */
|
|
114
|
+
function dataCells(tr) {
|
|
115
|
+
return [...tr.querySelectorAll("td")].filter(
|
|
116
|
+
td => !td.classList.contains("ext") && !td.classList.contains("ctl"));
|
|
117
|
+
}
|
|
118
|
+
function cellText(td) { return td.innerText.replace(/\n/g, " ").trim(); }
|
|
119
|
+
function serializeRow(cells) {
|
|
120
|
+
return "| " + cells.map(c => c.replace(/\|/g, "\\|")).join(" | ") + " |";
|
|
121
|
+
}
|
|
122
|
+
function exportMarkdown() {
|
|
123
|
+
const lines = DOC.lines.slice();
|
|
124
|
+
[...DOC.tables].sort((a, b) => b.start - a.start).forEach(t => {
|
|
125
|
+
const body = [...document.querySelectorAll(
|
|
126
|
+
'table[data-t="' + t.t + '"] tbody tr')].map(
|
|
127
|
+
tr => serializeRow(dataCells(tr).map(cellText)));
|
|
128
|
+
lines.splice(t.start + 2, t.end - t.start - 2, ...body);
|
|
129
|
+
});
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
function money(x) { return isFinite(x) ? x.toFixed(2) : ""; }
|
|
133
|
+
function recompute() {
|
|
134
|
+
DOC.tables.forEach(t => {
|
|
135
|
+
if (!t.computed) return;
|
|
136
|
+
let total = 0;
|
|
137
|
+
document.querySelectorAll('table[data-t="' + t.t + '"] tbody tr').forEach(tr => {
|
|
138
|
+
const cells = dataCells(tr);
|
|
139
|
+
const ext = tr.querySelector("td.ext");
|
|
140
|
+
const v = parseFloat(cellText(cells[t.computed[0]]).replace(/,/g, "")) *
|
|
141
|
+
parseFloat(cellText(cells[t.computed[1]]).replace(/,/g, ""));
|
|
142
|
+
if (isFinite(v)) { ext.textContent = money(v); total += v; }
|
|
143
|
+
else ext.textContent = "";
|
|
144
|
+
});
|
|
145
|
+
const tot = document.querySelector('table[data-t="' + t.t + '"] tfoot .total');
|
|
146
|
+
if (tot) tot.textContent = money(total);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function copyOut(btn, md) {
|
|
150
|
+
const out = document.getElementById("out");
|
|
151
|
+
out.value = md; out.style.display = "block";
|
|
152
|
+
const done = () => { btn.textContent = "Copied ✓ — paste into the chat"; };
|
|
153
|
+
const fallback = () => {
|
|
154
|
+
btn.textContent = "Copy the block below, then paste into the chat";
|
|
155
|
+
out.select();
|
|
156
|
+
};
|
|
157
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
158
|
+
navigator.clipboard.writeText(md).then(done, fallback);
|
|
159
|
+
} else { fallback(); }
|
|
160
|
+
}
|
|
161
|
+
document.addEventListener("input", recompute);
|
|
162
|
+
document.addEventListener("focusin", e => {
|
|
163
|
+
/* select the cell's contents on focus: typing replaces the value (so the
|
|
164
|
+
add-row "-" placeholder never concatenates), tabbing through loses nothing */
|
|
165
|
+
const td = e.target;
|
|
166
|
+
if (!td.isContentEditable || td.tagName !== "TD") return;
|
|
167
|
+
const sel = window.getSelection(), r = document.createRange();
|
|
168
|
+
r.selectNodeContents(td); sel.removeAllRanges(); sel.addRange(r);
|
|
169
|
+
});
|
|
170
|
+
document.addEventListener("click", e => {
|
|
171
|
+
if (e.target.classList.contains("del")) {
|
|
172
|
+
e.target.closest("tr").remove(); recompute(); return;
|
|
173
|
+
}
|
|
174
|
+
if (e.target.classList.contains("add")) {
|
|
175
|
+
const t = DOC.tables.find(x => String(x.t) === e.target.dataset.t);
|
|
176
|
+
const tr = document.createElement("tr");
|
|
177
|
+
for (let i = 0; i < t.width; i++) {
|
|
178
|
+
const td = document.createElement("td");
|
|
179
|
+
td.contentEditable = "true"; td.textContent = "-"; tr.appendChild(td);
|
|
180
|
+
}
|
|
181
|
+
if (t.computed) {
|
|
182
|
+
const td = document.createElement("td"); td.className = "ext"; tr.appendChild(td);
|
|
183
|
+
}
|
|
184
|
+
const ctl = document.createElement("td"); ctl.className = "ctl";
|
|
185
|
+
ctl.innerHTML = '<button class="del" title="delete row">×</button>';
|
|
186
|
+
tr.appendChild(ctl);
|
|
187
|
+
document.querySelector('table[data-t="' + t.t + '"] tbody').appendChild(tr);
|
|
188
|
+
recompute(); return;
|
|
189
|
+
}
|
|
190
|
+
if (e.target.id === "copy") { copyOut(e.target, exportMarkdown()); }
|
|
191
|
+
});
|
|
192
|
+
/* GRID-CORE end */
|
|
193
|
+
</script>
|
|
194
|
+
</body>
|
|
195
|
+
</html>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Loopback browser surface for the artifact editor (non-Apps hosts).
|
|
2
|
+
|
|
3
|
+
One process-lifetime HTTP server on 127.0.0.1:<random port>. Each
|
|
4
|
+
``open_session`` mints a token-guarded session and supersedes the previous one
|
|
5
|
+
(stale saves answer 409 so the page's copy fallback engages). Loopback bind +
|
|
6
|
+
random URL token + Host-header check keep other local processes, other tabs,
|
|
7
|
+
and DNS-rebinding pages out. Pure stdlib; the async MCP server awaits the
|
|
8
|
+
threading.Event via ``anyio.to_thread``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
import threading
|
|
14
|
+
import webbrowser
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
|
|
17
|
+
_MAX_SAVE_BYTES = 4 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EditSession:
|
|
21
|
+
def __init__(self, page_html: str) -> None:
|
|
22
|
+
self.token = secrets.token_urlsafe(16)
|
|
23
|
+
self.page_html = page_html
|
|
24
|
+
self.url = "" # set by open_session
|
|
25
|
+
self.saved_markdown: str | None = None
|
|
26
|
+
self._saved = threading.Event()
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def record_save(self, markdown: str) -> None:
|
|
30
|
+
with self._lock:
|
|
31
|
+
self.saved_markdown = markdown # repeat saves: latest wins
|
|
32
|
+
self._saved.set()
|
|
33
|
+
|
|
34
|
+
def wait_for_save(self, timeout: float) -> str | None:
|
|
35
|
+
if self._saved.wait(timeout):
|
|
36
|
+
with self._lock:
|
|
37
|
+
return self.saved_markdown
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_lock = threading.Lock()
|
|
42
|
+
_server: ThreadingHTTPServer | None = None
|
|
43
|
+
_session: EditSession | None = None
|
|
44
|
+
# Single-generation memory: only the immediately-previous session gets the
|
|
45
|
+
# tailored 409; older tabs fall through to 404 and the page's copy fallback.
|
|
46
|
+
_prev_token: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
50
|
+
def log_message(self, *args) -> None: # stdio server: keep stderr quiet
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def _reply(self, code: int, body: str, ctype: str = "text/plain") -> None:
|
|
54
|
+
data = body.encode("utf-8")
|
|
55
|
+
self.send_response(code)
|
|
56
|
+
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
|
|
57
|
+
self.send_header("Content-Length", str(len(data)))
|
|
58
|
+
self.send_header("Cache-Control", "no-store")
|
|
59
|
+
self.end_headers()
|
|
60
|
+
self.wfile.write(data)
|
|
61
|
+
|
|
62
|
+
def _drain_body(self) -> None:
|
|
63
|
+
"""Read and discard the declared request body before an error reply.
|
|
64
|
+
|
|
65
|
+
Windows resets the connection if we close with unread data, so the
|
|
66
|
+
client would see ECONNABORTED instead of our status code. Bounded by
|
|
67
|
+
the declared Content-Length; chunked to avoid one giant allocation.
|
|
68
|
+
Capped at 2 * _MAX_SAVE_BYTES: legitimate clients (even a 413-bound
|
|
69
|
+
save just over the limit) stay under the cap and get clean replies;
|
|
70
|
+
a liar declaring more gets a fail-fast close instead of streaming
|
|
71
|
+
forever through the 403/404/409 paths.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
remaining = int(self.headers.get("Content-Length") or 0)
|
|
75
|
+
except ValueError:
|
|
76
|
+
self.close_connection = True # undrainable: unknown length
|
|
77
|
+
return
|
|
78
|
+
if remaining > 2 * _MAX_SAVE_BYTES:
|
|
79
|
+
self.close_connection = True
|
|
80
|
+
return
|
|
81
|
+
while remaining > 0:
|
|
82
|
+
chunk = self.rfile.read(min(remaining, 65536))
|
|
83
|
+
if not chunk:
|
|
84
|
+
break
|
|
85
|
+
remaining -= len(chunk)
|
|
86
|
+
|
|
87
|
+
def _gate(self) -> EditSession | None:
|
|
88
|
+
"""Return the live session for this request path, else reply and None."""
|
|
89
|
+
host = (self.headers.get("Host") or "").rsplit(":", 1)[0]
|
|
90
|
+
if host not in ("127.0.0.1", "localhost"):
|
|
91
|
+
self._drain_body()
|
|
92
|
+
self._reply(403, "forbidden")
|
|
93
|
+
return None
|
|
94
|
+
with _lock:
|
|
95
|
+
session, prev = _session, _prev_token
|
|
96
|
+
token = self.path.split("/", 2)[1] if self.path.count("/") >= 2 else ""
|
|
97
|
+
# compare_digest: constant-time; bytes form tolerates non-ASCII paths.
|
|
98
|
+
token_b = token.encode("utf-8")
|
|
99
|
+
if session is not None and secrets.compare_digest(token_b, session.token.encode("utf-8")):
|
|
100
|
+
return session
|
|
101
|
+
self._drain_body()
|
|
102
|
+
if prev is not None and secrets.compare_digest(token_b, prev.encode("utf-8")):
|
|
103
|
+
self._reply(409, "session superseded — reopen the editor from the conversation")
|
|
104
|
+
else:
|
|
105
|
+
self._reply(404, "not found")
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def do_GET(self) -> None:
|
|
109
|
+
session = self._gate()
|
|
110
|
+
if session is None:
|
|
111
|
+
return
|
|
112
|
+
if self.path == f"/{session.token}/":
|
|
113
|
+
self._reply(200, session.page_html, ctype="text/html")
|
|
114
|
+
else:
|
|
115
|
+
self._drain_body()
|
|
116
|
+
self._reply(404, "not found")
|
|
117
|
+
|
|
118
|
+
def do_POST(self) -> None:
|
|
119
|
+
session = self._gate()
|
|
120
|
+
if session is None:
|
|
121
|
+
return
|
|
122
|
+
if self.path != f"/{session.token}/save":
|
|
123
|
+
self._drain_body()
|
|
124
|
+
self._reply(404, "not found")
|
|
125
|
+
return
|
|
126
|
+
try:
|
|
127
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
128
|
+
except ValueError:
|
|
129
|
+
# Unknown length: can't drain meaningfully — reply and hard-close.
|
|
130
|
+
self.close_connection = True
|
|
131
|
+
self._reply(400, "bad request")
|
|
132
|
+
return
|
|
133
|
+
if length < 0:
|
|
134
|
+
self.close_connection = True
|
|
135
|
+
self._reply(400, "bad request")
|
|
136
|
+
return
|
|
137
|
+
if length > _MAX_SAVE_BYTES:
|
|
138
|
+
self._drain_body() # drain before the reply (RST fix)
|
|
139
|
+
self._reply(413, "artifact too large")
|
|
140
|
+
return
|
|
141
|
+
try:
|
|
142
|
+
markdown = self.rfile.read(length).decode("utf-8")
|
|
143
|
+
except UnicodeDecodeError:
|
|
144
|
+
self._reply(400, "bad request") # body fully read — no drain needed
|
|
145
|
+
return
|
|
146
|
+
session.record_save(markdown)
|
|
147
|
+
self._reply(200, "saved")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _start_server() -> ThreadingHTTPServer:
|
|
151
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
152
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
153
|
+
return server
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def open_session(page_html: str) -> tuple[EditSession, str]:
|
|
157
|
+
"""Serve ``page_html`` under a fresh token, superseding any prior session."""
|
|
158
|
+
global _server, _session, _prev_token
|
|
159
|
+
with _lock:
|
|
160
|
+
if _server is None:
|
|
161
|
+
_server = _start_server()
|
|
162
|
+
if _session is not None:
|
|
163
|
+
_prev_token = _session.token
|
|
164
|
+
session = EditSession(page_html)
|
|
165
|
+
port = _server.server_address[1]
|
|
166
|
+
session.url = f"http://127.0.0.1:{port}/{session.token}/"
|
|
167
|
+
_session = session
|
|
168
|
+
return session, session.url
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def current_session() -> EditSession | None:
|
|
172
|
+
with _lock:
|
|
173
|
+
return _session
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def await_save(session: EditSession, timeout: float) -> str | None:
|
|
177
|
+
return session.wait_for_save(timeout)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def open_browser(url: str) -> bool:
|
|
181
|
+
try:
|
|
182
|
+
return webbrowser.open(url)
|
|
183
|
+
except Exception:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def shutdown() -> None:
|
|
188
|
+
"""Tear down server + sessions (tests; harmless if never started)."""
|
|
189
|
+
global _server, _session, _prev_token
|
|
190
|
+
with _lock:
|
|
191
|
+
server, _server, _session, _prev_token = _server, None, None, None
|
|
192
|
+
if server is not None:
|
|
193
|
+
server.shutdown()
|
|
194
|
+
server.server_close()
|