smarter_csv 1.17.1 → 1.17.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +246 -63
- data/CONTRIBUTORS.md +2 -1
- data/README.md +6 -3
- data/UPGRADING.md +251 -0
- data/docs/.nojekyll +0 -0
- data/docs/upgrade_path.json +175 -0
- data/docs/upgrade_wizard.html +498 -0
- data/ext/smarter_csv/smarter_csv.c +248 -323
- data/lib/smarter_csv/parser.rb +40 -12
- data/lib/smarter_csv/version.rb +1 -1
- data/smarter_csv.gemspec +7 -5
- metadata +8 -3
- data/TO_DO.md +0 -109
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
Wizard Behavior:
|
|
4
|
+
|
|
5
|
+
- On load, wizard fetches upgrade_path.json first (must succeed), then also fetches CHANGELOG.md from raw.githubusercontent.com. If the CHANGELOG fetch + parse succeed, the parsed {series → latest_patch} map overlays the latest_release fields in the data.
|
|
6
|
+
- Console message confirms which source won: "Latest patches loaded from CHANGELOG.md" (live) or "CHANGELOG.md fetch failed; using upgrade_path.json fallback values" (fallback).
|
|
7
|
+
- Graceful failure: if raw.githubusercontent.com is unreachable, parse fails, or returns nothing, the wizard silently uses the values already in upgrade_path.json — so the JSON's latest_release fields become a stale-but-safe fallback.
|
|
8
|
+
|
|
9
|
+
Maintainer workflow now:
|
|
10
|
+
|
|
11
|
+
- Ship a new patch (e.g. 1.14.5): edit CHANGELOG.md only. Wizard picks it up live on next load.
|
|
12
|
+
- Ship a new minor with new migration steps (e.g. 1.18): edit upgrade_path.json (chain + actions) + CHANGELOG.md.
|
|
13
|
+
- The latest_release fields in JSON can drift — only consulted when the live fetch fails. Refresh them whenever you want, no urgency.
|
|
14
|
+
-->
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8">
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
19
|
+
<title>smarter_csv upgrade wizard</title>
|
|
20
|
+
<style>
|
|
21
|
+
:root {
|
|
22
|
+
--fg: #1a1a1a;
|
|
23
|
+
--muted: #666;
|
|
24
|
+
--bg: #fff;
|
|
25
|
+
--accent: #c5392b;
|
|
26
|
+
--accent-soft: #fff0ee;
|
|
27
|
+
--green: #2c7a2c;
|
|
28
|
+
--green-soft: #e8f5e9;
|
|
29
|
+
--border: #ddd;
|
|
30
|
+
--card-bg: #fafafa;
|
|
31
|
+
}
|
|
32
|
+
* { box-sizing: border-box; }
|
|
33
|
+
body {
|
|
34
|
+
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
35
|
+
color: var(--fg);
|
|
36
|
+
background: var(--bg);
|
|
37
|
+
max-width: 740px;
|
|
38
|
+
margin: 2.5em auto;
|
|
39
|
+
padding: 0 1.25em;
|
|
40
|
+
}
|
|
41
|
+
h1 { font-size: 1.6em; margin: 0 0 0.5em; }
|
|
42
|
+
h2 { font-size: 1.25em; margin: 0 0 0.75em; }
|
|
43
|
+
p { margin: 0.5em 0; }
|
|
44
|
+
code, pre {
|
|
45
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
46
|
+
font-size: 0.93em;
|
|
47
|
+
}
|
|
48
|
+
code { background: #f4f4f4; padding: 0.1em 0.35em; border-radius: 3px; }
|
|
49
|
+
pre {
|
|
50
|
+
background: #f4f4f4;
|
|
51
|
+
padding: 0.85em 1em;
|
|
52
|
+
border-radius: 6px;
|
|
53
|
+
overflow-x: auto;
|
|
54
|
+
}
|
|
55
|
+
button {
|
|
56
|
+
font: inherit;
|
|
57
|
+
padding: 0.45em 1.1em;
|
|
58
|
+
border: 1px solid var(--border);
|
|
59
|
+
background: #fff;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
margin: 0 0.25em 0.25em 0;
|
|
63
|
+
}
|
|
64
|
+
button:hover { background: #f4f4f4; }
|
|
65
|
+
button.primary {
|
|
66
|
+
background: var(--green);
|
|
67
|
+
color: #fff;
|
|
68
|
+
border-color: var(--green);
|
|
69
|
+
}
|
|
70
|
+
button.primary:hover { background: #235e23; }
|
|
71
|
+
input[type="text"] {
|
|
72
|
+
font: inherit;
|
|
73
|
+
padding: 0.45em 0.6em;
|
|
74
|
+
border: 1px solid var(--border);
|
|
75
|
+
border-radius: 6px;
|
|
76
|
+
width: 12em;
|
|
77
|
+
}
|
|
78
|
+
.muted { color: var(--muted); }
|
|
79
|
+
.progress {
|
|
80
|
+
color: var(--muted);
|
|
81
|
+
font-size: 0.85em;
|
|
82
|
+
margin-bottom: 1em;
|
|
83
|
+
}
|
|
84
|
+
.hop {
|
|
85
|
+
background: var(--card-bg);
|
|
86
|
+
border: 1px solid var(--border);
|
|
87
|
+
border-radius: 8px;
|
|
88
|
+
padding: 1.25em 1.5em;
|
|
89
|
+
margin-bottom: 1em;
|
|
90
|
+
}
|
|
91
|
+
.dropin {
|
|
92
|
+
background: var(--green-soft);
|
|
93
|
+
border-color: #b6dab6;
|
|
94
|
+
color: var(--green);
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
}
|
|
97
|
+
.check {
|
|
98
|
+
padding: 0.85em 0;
|
|
99
|
+
border-top: 1px solid var(--border);
|
|
100
|
+
}
|
|
101
|
+
.check:first-of-type { border-top: none; padding-top: 0; }
|
|
102
|
+
.check .q { font-weight: 500; margin-bottom: 0.4em; }
|
|
103
|
+
.then {
|
|
104
|
+
display: none;
|
|
105
|
+
margin-top: 0.6em;
|
|
106
|
+
padding: 0.6em 0.85em;
|
|
107
|
+
background: var(--accent-soft);
|
|
108
|
+
border-left: 3px solid var(--accent);
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
}
|
|
111
|
+
.check.yes .then { display: block; }
|
|
112
|
+
.check.yes button.yes { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
113
|
+
.check.no button.no { background: var(--green); color: #fff; border-color: var(--green); }
|
|
114
|
+
.nav {
|
|
115
|
+
display: flex;
|
|
116
|
+
justify-content: space-between;
|
|
117
|
+
align-items: center;
|
|
118
|
+
margin-top: 1.25em;
|
|
119
|
+
gap: 0.75em;
|
|
120
|
+
}
|
|
121
|
+
.nav .nav-right { display: flex; align-items: center; gap: 0.75em; }
|
|
122
|
+
button:disabled,
|
|
123
|
+
button.primary:disabled {
|
|
124
|
+
background: #e6e6e6;
|
|
125
|
+
color: #999;
|
|
126
|
+
border-color: #d8d8d8;
|
|
127
|
+
cursor: not-allowed;
|
|
128
|
+
}
|
|
129
|
+
button.primary:disabled:hover { background: #e6e6e6; }
|
|
130
|
+
.next-hint { color: var(--muted); font-size: 0.85em; }
|
|
131
|
+
.reminder { color: var(--muted); font-style: italic; font-weight: 600; font-size: 1.1em; margin: 0.75em 0 0.25em; }
|
|
132
|
+
.done {
|
|
133
|
+
background: var(--green-soft);
|
|
134
|
+
border: 1px solid #b6dab6;
|
|
135
|
+
border-radius: 8px;
|
|
136
|
+
padding: 1.5em 1.75em;
|
|
137
|
+
}
|
|
138
|
+
.summary {
|
|
139
|
+
background: #fff;
|
|
140
|
+
border: 1px solid var(--border);
|
|
141
|
+
border-radius: 6px;
|
|
142
|
+
padding: 1em 1.25em;
|
|
143
|
+
margin: 1em 0 1.25em;
|
|
144
|
+
}
|
|
145
|
+
.summary h3 { margin: 0 0 0.5em; font-size: 1.05em; }
|
|
146
|
+
.summary-hop { margin: 0.85em 0; }
|
|
147
|
+
.summary-hop-heading { margin: 0 0 0.35em; }
|
|
148
|
+
.summary-hop ul { margin: 0.25em 0 0; padding-left: 1.3em; }
|
|
149
|
+
.summary-hop li { margin: 0.5em 0; }
|
|
150
|
+
.error { color: var(--accent); margin-top: 0.5em; }
|
|
151
|
+
</style>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
|
|
155
|
+
<h1>SmarterCSV Upgrade Wizard</h1>
|
|
156
|
+
<p class="muted">This wizard walks you from your current version to the latest, one hop at a time.<br><br>Only the questions where you answer "Yes" will show migration steps.<br>Question answered with "No" represent risk-free upgrades.</p><br><br>
|
|
157
|
+
|
|
158
|
+
<div id="app"></div>
|
|
159
|
+
|
|
160
|
+
<script>
|
|
161
|
+
// Data lives in upgrade_path.json (sibling file). The wizard fetches it on load
|
|
162
|
+
// and the JSON is the single source of truth — to add a new release or migration
|
|
163
|
+
// step, edit only that file. See upgrading-smarter_csv.md for the schema.
|
|
164
|
+
|
|
165
|
+
let UPGRADE_PATH = {}; // populated by loadData()
|
|
166
|
+
let LATEST = null; // series, e.g. "1.17", populated by loadData()
|
|
167
|
+
let LATEST_RELEASE = null; // specific patch version for display, e.g. "1.17.2"
|
|
168
|
+
let SERIES = []; // Object.keys(UPGRADE_PATH), populated by loadData()
|
|
169
|
+
let decisions = []; // accumulated per-hop results across the user's walk
|
|
170
|
+
|
|
171
|
+
const app = document.getElementById("app");
|
|
172
|
+
const params = new URLSearchParams(location.search);
|
|
173
|
+
const prefilled = params.get("from") || "";
|
|
174
|
+
|
|
175
|
+
function escapeHTML(s) {
|
|
176
|
+
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function seriesFromVersion(v) {
|
|
180
|
+
const parts = (v || "").trim().split(".");
|
|
181
|
+
if (parts.length < 2) return null;
|
|
182
|
+
const s = parts[0] + "." + parts[1];
|
|
183
|
+
return UPGRADE_PATH[s] ? s : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Returns the latest patch release for a given MAJOR.MINOR series.
|
|
187
|
+
// For the terminal series (LATEST) we read the top-level latest_release;
|
|
188
|
+
// for every other series we read it from inside its UPGRADE_PATH entry.
|
|
189
|
+
function latestReleaseFor(series) {
|
|
190
|
+
if (series === LATEST) return LATEST_RELEASE || series;
|
|
191
|
+
return (UPGRADE_PATH[series] && UPGRADE_PATH[series].latest_release) || series;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderStart(errorMsg) {
|
|
195
|
+
decisions = []; // reset accumulated decisions whenever we restart
|
|
196
|
+
app.innerHTML = `
|
|
197
|
+
<div class="hop">
|
|
198
|
+
<h2>What version are you on?</h2>
|
|
199
|
+
<p>Run <code>bundle show smarter_csv</code> in your project to check, or look at your <code>Gemfile.lock</code>.</p>
|
|
200
|
+
<p>
|
|
201
|
+
<input type="text" id="ver" placeholder="e.g. ${latestReleaseFor(SERIES[Math.floor(SERIES.length / 2)])}" value="${escapeHTML(prefilled)}" autofocus>
|
|
202
|
+
<button class="primary" id="start">Start</button>
|
|
203
|
+
</p>
|
|
204
|
+
${errorMsg ? `<p class="error">${escapeHTML(errorMsg)}</p>` : ""}
|
|
205
|
+
<p class="muted">Supported starting versions: ${SERIES[0]}.x through ${SERIES[SERIES.length - 1]}.x. If you're already on ${LATEST}.x, you can update to the latest.</p>
|
|
206
|
+
</div>
|
|
207
|
+
`;
|
|
208
|
+
document.getElementById("start").addEventListener("click", onStart);
|
|
209
|
+
document.getElementById("ver").addEventListener("keydown", e => { if (e.key === "Enter") onStart(); });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function onStart() {
|
|
213
|
+
const v = document.getElementById("ver").value;
|
|
214
|
+
if (v.startsWith(LATEST + ".") || v === LATEST) { renderDone(v); return; }
|
|
215
|
+
const s = seriesFromVersion(v);
|
|
216
|
+
if (!s) {
|
|
217
|
+
renderStart(`Please enter a version like ${latestReleaseFor(SERIES[Math.floor(SERIES.length / 2)])} (must start with ${SERIES[0]} through ${SERIES[SERIES.length - 1]}).`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
renderHop(s, v);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderHop(series, originalVersion) {
|
|
224
|
+
const hop = UPGRADE_PATH[series];
|
|
225
|
+
const idx = SERIES.indexOf(series);
|
|
226
|
+
const targetRelease = latestReleaseFor(hop.to);
|
|
227
|
+
|
|
228
|
+
let body;
|
|
229
|
+
if (hop.actions.length === 0) {
|
|
230
|
+
body = `<div class="hop dropin">
|
|
231
|
+
Drop-in hop: no code changes needed for ${series}.x → ${targetRelease}. Just continue.
|
|
232
|
+
</div>`;
|
|
233
|
+
} else {
|
|
234
|
+
body = `<div class="hop">
|
|
235
|
+
${hop.actions.map((a, i) => `
|
|
236
|
+
<div class="check" data-i="${i}">
|
|
237
|
+
<p class="q"><strong>If</strong> ${a["if"]}:</p>
|
|
238
|
+
<p>
|
|
239
|
+
<button class="yes">Yes, this applies to me</button>
|
|
240
|
+
<button class="no">No</button>
|
|
241
|
+
</p>
|
|
242
|
+
<div class="then"><strong>Then:</strong> ${a.then}</div>
|
|
243
|
+
</div>
|
|
244
|
+
`).join("")}
|
|
245
|
+
</div>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const nextLabel = hop.to === LATEST ? `Finish at ${targetRelease} →` : `Continue to ${targetRelease} →`;
|
|
249
|
+
const reminder = hop.actions.length === 0 ? `<p class="reminder">You can upgrade directly to the version showing on the "Continue" button. No changes needed.</p>` :
|
|
250
|
+
`<p class="reminder">If there are actions listed above, please ensure they are fixed before clicking "Continue".</p>`;
|
|
251
|
+
app.innerHTML = `
|
|
252
|
+
<p class="progress">Upgrading from ${series}.x → ${targetRelease}</p>
|
|
253
|
+
${body}
|
|
254
|
+
${reminder}
|
|
255
|
+
<div class="nav">
|
|
256
|
+
<button id="back">← Start over</button>
|
|
257
|
+
<div class="nav-right">
|
|
258
|
+
<span id="next-hint" class="next-hint"></span>
|
|
259
|
+
<button class="primary" id="next">${nextLabel}</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
`;
|
|
263
|
+
|
|
264
|
+
function updateNextState() {
|
|
265
|
+
const allChecks = document.querySelectorAll(".check");
|
|
266
|
+
const unanswered = document.querySelectorAll(".check:not(.yes):not(.no)");
|
|
267
|
+
const yesAnswers = document.querySelectorAll(".check.yes");
|
|
268
|
+
const nextBtn = document.getElementById("next");
|
|
269
|
+
const hint = document.getElementById("next-hint");
|
|
270
|
+
const reminderEl = document.querySelector(".reminder");
|
|
271
|
+
|
|
272
|
+
if (unanswered.length === 0) {
|
|
273
|
+
nextBtn.disabled = false;
|
|
274
|
+
hint.textContent = "";
|
|
275
|
+
} else {
|
|
276
|
+
nextBtn.disabled = true;
|
|
277
|
+
hint.textContent = unanswered.length === 1
|
|
278
|
+
? "Answer the question to continue."
|
|
279
|
+
: `Answer all ${unanswered.length} questions to continue.`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Swap reminder text based on the user's answers so far.
|
|
283
|
+
// Hidden while any question is unanswered.
|
|
284
|
+
if (reminderEl && allChecks.length > 0) {
|
|
285
|
+
if (unanswered.length > 0) {
|
|
286
|
+
// Some questions still unanswered — hide reminder entirely.
|
|
287
|
+
reminderEl.style.display = "none";
|
|
288
|
+
} else if (yesAnswers.length === 0) {
|
|
289
|
+
// All answered, all "No" — nothing applies, upgrade is direct.
|
|
290
|
+
reminderEl.style.display = "";
|
|
291
|
+
reminderEl.textContent = 'You can upgrade directly to the version showing on the "Continue" button. No changes needed.';
|
|
292
|
+
} else {
|
|
293
|
+
// All answered, at least one "Yes" — actions must be applied first.
|
|
294
|
+
reminderEl.style.display = "";
|
|
295
|
+
reminderEl.textContent = 'If there are actions listed above, please ensure they are fixed before clicking "Continue".';
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
document.querySelectorAll(".check").forEach(c => {
|
|
301
|
+
c.querySelector(".yes").addEventListener("click", () => {
|
|
302
|
+
c.classList.remove("no");
|
|
303
|
+
c.classList.add("yes");
|
|
304
|
+
updateNextState();
|
|
305
|
+
});
|
|
306
|
+
c.querySelector(".no").addEventListener("click", () => {
|
|
307
|
+
c.classList.remove("yes");
|
|
308
|
+
c.classList.add("no");
|
|
309
|
+
updateNextState();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
document.getElementById("back").addEventListener("click", () => renderStart());
|
|
314
|
+
document.getElementById("next").addEventListener("click", () => {
|
|
315
|
+
if (document.getElementById("next").disabled) return; // belt-and-braces
|
|
316
|
+
|
|
317
|
+
// Record this hop's decisions before navigating.
|
|
318
|
+
const matched = hop.actions.length === 0 ? [] :
|
|
319
|
+
Array.from(document.querySelectorAll(".check.yes")).map(c => {
|
|
320
|
+
const i = parseInt(c.dataset.i, 10);
|
|
321
|
+
return hop.actions[i];
|
|
322
|
+
});
|
|
323
|
+
decisions.push({
|
|
324
|
+
from: series,
|
|
325
|
+
to: hop.to,
|
|
326
|
+
dropIn: hop.actions.length === 0,
|
|
327
|
+
matched
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (hop.to === LATEST) {
|
|
331
|
+
renderDone(originalVersion);
|
|
332
|
+
} else {
|
|
333
|
+
renderHop(hop.to, originalVersion);
|
|
334
|
+
}
|
|
335
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
updateNextState(); // initial state: disabled if there are any checks, enabled if drop-in hop
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderDone(originalVersion) {
|
|
342
|
+
const fullVersion = LATEST_RELEASE || LATEST;
|
|
343
|
+
const seriesOnly = LATEST;
|
|
344
|
+
const summaryHTML = renderSummary();
|
|
345
|
+
|
|
346
|
+
app.innerHTML = `
|
|
347
|
+
<div class="done">
|
|
348
|
+
<h2>You're done</h2>
|
|
349
|
+
<p>You've walked all hops from ${escapeHTML(originalVersion || "your current version")} to ${fullVersion} (the latest patch in the ${seriesOnly}.x series).</p>
|
|
350
|
+
${summaryHTML}
|
|
351
|
+
<p>Update your <code>Gemfile</code> to:</p>
|
|
352
|
+
<pre><code>gem 'smarter_csv', '~> ${seriesOnly}.0'</code></pre>
|
|
353
|
+
<p>Then run:</p>
|
|
354
|
+
<pre><code>bundle update smarter_csv</code></pre>
|
|
355
|
+
<p>After that, run your test suite. If anything behaves unexpectedly, click "Start over" and walk back through the hops to find the migration step you might have missed.</p>
|
|
356
|
+
<p class="muted">Questions? Open an issue at <a href="https://github.com/tilo/smarter_csv/issues">github.com/tilo/smarter_csv/issues</a>.</p>
|
|
357
|
+
<p><button id="restart">Start over</button></p>
|
|
358
|
+
</div>
|
|
359
|
+
`;
|
|
360
|
+
document.getElementById("restart").addEventListener("click", () => renderStart());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderSummary() {
|
|
364
|
+
if (decisions.length === 0) return "";
|
|
365
|
+
|
|
366
|
+
const matchedCount = decisions.reduce((n, d) => n + d.matched.length, 0);
|
|
367
|
+
const dropInCount = decisions.filter(d => d.dropIn).length;
|
|
368
|
+
const intro = matchedCount === 0
|
|
369
|
+
? `<p>Good news: <strong>none of the per-hop conditions applied to your code</strong> across the ${decisions.length} hops you walked${dropInCount ? ` (${dropInCount} were drop-in hops)` : ""}. The upgrade should be a clean Gemfile bump.</p>`
|
|
370
|
+
: `<p>Across ${decisions.length} hops you walked, <strong>${matchedCount} migration step${matchedCount === 1 ? "" : "s"} apply</strong> to your code. Review and apply them before running <code>bundle update</code>:</p>`;
|
|
371
|
+
|
|
372
|
+
const list = decisions.map(d => {
|
|
373
|
+
const targetRelease = latestReleaseFor(d.to);
|
|
374
|
+
const heading = `<p class="summary-hop-heading"><strong>${d.from}.x → ${targetRelease}</strong></p>`;
|
|
375
|
+
if (d.dropIn) {
|
|
376
|
+
return `<div class="summary-hop">${heading}<p class="muted">Drop-in hop — no code changes needed.</p></div>`;
|
|
377
|
+
}
|
|
378
|
+
if (d.matched.length === 0) {
|
|
379
|
+
return `<div class="summary-hop">${heading}<p class="muted">None of the conditions on this hop applied to your code.</p></div>`;
|
|
380
|
+
}
|
|
381
|
+
const items = d.matched.map(a => `<li><strong>If</strong> ${a["if"]}<br>→ ${a.then}</li>`).join("");
|
|
382
|
+
return `<div class="summary-hop">${heading}<ul>${items}</ul></div>`;
|
|
383
|
+
}).join("");
|
|
384
|
+
|
|
385
|
+
return `
|
|
386
|
+
<div class="summary">
|
|
387
|
+
<h3>Summary of your upgrade path</h3>
|
|
388
|
+
${intro}
|
|
389
|
+
${list}
|
|
390
|
+
</div>
|
|
391
|
+
`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderLoading() {
|
|
395
|
+
app.innerHTML = `<div class="hop"><p class="muted">Loading upgrade path...</p></div>`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderLoadError(err) {
|
|
399
|
+
app.innerHTML = `
|
|
400
|
+
<div class="hop">
|
|
401
|
+
<h2 class="error">Could not load upgrade data</h2>
|
|
402
|
+
<p>The wizard failed to fetch <code>upgrade_path.json</code>. ${escapeHTML(err && err.message ? err.message : String(err))}</p>
|
|
403
|
+
<p class="muted">If you're viewing this file locally via <code>file://</code>, browsers block <code>fetch</code> for security. Serve it over HTTP (the GitHub Pages URL works), or open an issue at <a href="https://github.com/tilo/smarter_csv/issues">github.com/tilo/smarter_csv/issues</a>.</p>
|
|
404
|
+
</div>
|
|
405
|
+
`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function boot() {
|
|
409
|
+
if (prefilled) {
|
|
410
|
+
if (prefilled.startsWith(LATEST + ".") || prefilled === LATEST) {
|
|
411
|
+
renderDone(prefilled);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const s = seriesFromVersion(prefilled);
|
|
415
|
+
if (s) { renderHop(s, prefilled); return; }
|
|
416
|
+
}
|
|
417
|
+
renderStart();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Parses CHANGELOG.md headings like "## 1.17.2 (2026-05-21)" and returns
|
|
421
|
+
// a {series: latestPatch} map. Skips pre-releases (1.0.0.pre1), yanked,
|
|
422
|
+
// pulled, or replaced versions. Returns null on any failure.
|
|
423
|
+
async function fetchChangelogLatestPatches() {
|
|
424
|
+
try {
|
|
425
|
+
const url = "https://raw.githubusercontent.com/tilo/smarter_csv/main/CHANGELOG.md";
|
|
426
|
+
const res = await fetch(url, { cache: "no-cache" });
|
|
427
|
+
if (!res.ok) return null;
|
|
428
|
+
const text = await res.text();
|
|
429
|
+
return parseLatestPatchesFromChangelog(text);
|
|
430
|
+
} catch (_) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function parseLatestPatchesFromChangelog(text) {
|
|
436
|
+
const result = {};
|
|
437
|
+
const lineRe = /^##\s+(\S+)([^\n]*)/gm;
|
|
438
|
+
let m;
|
|
439
|
+
while ((m = lineRe.exec(text)) !== null) {
|
|
440
|
+
const version = m[1];
|
|
441
|
+
const rest = m[2] || "";
|
|
442
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) continue; // strict: rejects "1.0.0.pre1" or any non-numeric heading
|
|
443
|
+
if (/\b(YANKED|PULLED|replaced)\b/i.test(rest)) continue; // skip 1.2.9 / 1.4.1 / 1.9.1 / 1.11.1 / 1.7.0 etc.
|
|
444
|
+
const series = version.split(".").slice(0, 2).join(".");
|
|
445
|
+
if (!result[series] || cmpVersions(version, result[series]) > 0) {
|
|
446
|
+
result[series] = version;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function cmpVersions(a, b) {
|
|
453
|
+
const ap = a.split(".").map(Number);
|
|
454
|
+
const bp = b.split(".").map(Number);
|
|
455
|
+
for (let i = 0; i < 3; i++) {
|
|
456
|
+
if (ap[i] !== bp[i]) return ap[i] - bp[i];
|
|
457
|
+
}
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function loadData() {
|
|
462
|
+
renderLoading();
|
|
463
|
+
try {
|
|
464
|
+
const res = await fetch("upgrade_path.json", { cache: "no-cache" });
|
|
465
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
466
|
+
const data = await res.json();
|
|
467
|
+
if (!data || typeof data !== "object" || !data.path || !data.latest) {
|
|
468
|
+
throw new Error("upgrade_path.json is missing 'latest' or 'path'");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Live overlay: read the highest non-yanked patch per series from CHANGELOG.md.
|
|
472
|
+
// If fetch/parse fails, we silently keep whatever's in upgrade_path.json — those
|
|
473
|
+
// values become a stale-but-safe fallback.
|
|
474
|
+
const livePatches = await fetchChangelogLatestPatches();
|
|
475
|
+
if (livePatches) {
|
|
476
|
+
for (const series of Object.keys(data.path)) {
|
|
477
|
+
if (livePatches[series]) data.path[series].latest_release = livePatches[series];
|
|
478
|
+
}
|
|
479
|
+
if (livePatches[data.latest]) data.latest_release = livePatches[data.latest];
|
|
480
|
+
console.log("[upgrade_wizard] Latest patches loaded from CHANGELOG.md:", livePatches);
|
|
481
|
+
} else {
|
|
482
|
+
console.log("[upgrade_wizard] CHANGELOG.md fetch failed; using upgrade_path.json fallback values.");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
UPGRADE_PATH = data.path;
|
|
486
|
+
LATEST = data.latest;
|
|
487
|
+
LATEST_RELEASE = data.latest_release || data.latest;
|
|
488
|
+
SERIES = Object.keys(UPGRADE_PATH);
|
|
489
|
+
boot();
|
|
490
|
+
} catch (err) {
|
|
491
|
+
renderLoadError(err);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
loadData();
|
|
496
|
+
</script>
|
|
497
|
+
</body>
|
|
498
|
+
</html>
|