smarter_csv 1.17.2 → 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 +235 -61
- data/README.md +4 -1
- 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 +176 -309
- data/lib/smarter_csv/parser.rb +4 -2
- data/lib/smarter_csv/version.rb +1 -1
- data/smarter_csv.gemspec +7 -5
- metadata +8 -2
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
{
|
|
2
|
+
"latest": "1.17",
|
|
3
|
+
"latest_release": "1.17.2",
|
|
4
|
+
"path": {
|
|
5
|
+
"1.0": {
|
|
6
|
+
"to": "1.1",
|
|
7
|
+
"latest_release": "1.0.19",
|
|
8
|
+
"actions": [
|
|
9
|
+
{
|
|
10
|
+
"if": "you set <code>headers_in_file: false</code>",
|
|
11
|
+
"then": "also provide <code>user_provided_headers:</code> — 1.1.0 now raises an error if you set the former without the latter."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"1.1": {
|
|
16
|
+
"to": "1.2",
|
|
17
|
+
"latest_release": "1.1.5",
|
|
18
|
+
"actions": [
|
|
19
|
+
{
|
|
20
|
+
"if": "your CSV files have duplicate header names",
|
|
21
|
+
"then": "rename the duplicates, or be ready to rescue <code>SmarterCSV::DuplicateHeaders</code> — 1.2.0 added default validation that each header appears only once and raises this exception when it doesn't."
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"1.2": {
|
|
26
|
+
"to": "1.3",
|
|
27
|
+
"latest_release": "1.2.8",
|
|
28
|
+
"actions": [
|
|
29
|
+
{
|
|
30
|
+
"if": "you use <code>key_mapping:</code>",
|
|
31
|
+
"then": "switch hash values to symbols (or update downstream reads to use string keys) — 1.3.0 stopped silently coercing values to symbols."
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"1.3": {
|
|
36
|
+
"to": "1.4",
|
|
37
|
+
"latest_release": "1.3.0",
|
|
38
|
+
"actions": []
|
|
39
|
+
},
|
|
40
|
+
"1.4": {
|
|
41
|
+
"to": "1.5",
|
|
42
|
+
"latest_release": "1.4.2",
|
|
43
|
+
"actions": [
|
|
44
|
+
{
|
|
45
|
+
"if": "you relied on lines starting with <code>#</code> being treated as comments",
|
|
46
|
+
"then": "pass <code>comment_regexp: /\\A#/</code> explicitly — 1.5.0 changed the default to <code>nil</code>."
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"1.5": {
|
|
51
|
+
"to": "1.6",
|
|
52
|
+
"latest_release": "1.5.2",
|
|
53
|
+
"actions": [
|
|
54
|
+
{
|
|
55
|
+
"if": "you rescue an exception when <code>key_mapping:</code> has an unused key",
|
|
56
|
+
"then": "remove that rescue clause — 1.6.1 changed this from an exception to a warning."
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"1.6": {
|
|
61
|
+
"to": "1.7",
|
|
62
|
+
"latest_release": "1.6.1",
|
|
63
|
+
"actions": []
|
|
64
|
+
},
|
|
65
|
+
"1.7": {
|
|
66
|
+
"to": "1.8",
|
|
67
|
+
"latest_release": "1.7.4",
|
|
68
|
+
"actions": [
|
|
69
|
+
{
|
|
70
|
+
"if": "you accept CSV files from users or other external sources where the column separator might not be a comma (e.g. locale-specific exports using <code>;</code> or tab), or where a file might have only one column",
|
|
71
|
+
"then": "wrap your <code>SmarterCSV.process</code> calls in <code>rescue SmarterCSV::NoColSepDetected</code> — 1.8.0 made <code>col_sep: :auto</code> and <code>row_sep: :auto</code> the new defaults, but in rare cases it raises when separators could not be found."
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"1.8": {
|
|
76
|
+
"to": "1.9",
|
|
77
|
+
"latest_release": "1.8.5",
|
|
78
|
+
"actions": [
|
|
79
|
+
{
|
|
80
|
+
"if": "you rescue <code>SmarterCSV::MissingHeaders</code>",
|
|
81
|
+
"then": "rename it to <code>SmarterCSV::MissingKeys</code> — 1.9.0 renamed the error."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"if": "you use <code>key_mapping:</code> and want to allow some mapped headers to be missing",
|
|
85
|
+
"then": "pass <code>silence_missing_keys: true</code> — 1.9.0 now raises <code>MissingKeys</code> for unmapped headers (this makes them optional)."
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
"1.9": {
|
|
90
|
+
"to": "1.10",
|
|
91
|
+
"latest_release": "1.9.3",
|
|
92
|
+
"actions": [
|
|
93
|
+
{
|
|
94
|
+
"if": "you use <code>user_provided_headers:</code>",
|
|
95
|
+
"then": "write the list in the exact final form you want (all symbols <em>or</em> all strings) — 1.10.0 stopped applying additional transformations. <code>strings_as_keys:</code> is ignored alongside it."
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"if": "your <code>user_provided_headers:</code> list contains duplicate entries",
|
|
99
|
+
"then": "remove the duplicates — 1.10.0 raises <code>SmarterCSV::DuplicateHeaders</code>."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"if": "you depended on duplicate-header detection failing fast",
|
|
103
|
+
"then": "pass <code>duplicate_header_suffix: nil</code> explicitly — 1.10.0 changed the default to <code>''</code> (it auto-disambiguates duplicates as <code>name</code>, <code>name2</code>, ...)."
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
"1.10": {
|
|
108
|
+
"to": "1.11",
|
|
109
|
+
"latest_release": "1.10.3",
|
|
110
|
+
"actions": []
|
|
111
|
+
},
|
|
112
|
+
"1.11": {
|
|
113
|
+
"to": "1.12",
|
|
114
|
+
"latest_release": "1.11.2",
|
|
115
|
+
"actions": [
|
|
116
|
+
{
|
|
117
|
+
"if": "you call <code>SmarterCSV.process</code> and need to inspect headers / warnings / errors after parsing",
|
|
118
|
+
"then": "switch to using <code>reader = SmarterCSV::Reader.new(file, options); reader.process</code>.<br>Version 1.11 class-level accessors <code>SmarterCSV.headers</code> / <code>SmarterCSV.raw_header</code> are gone in 1.12.0 — if you used those, see the next question."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"if": "you call <code>SmarterCSV.raw_headers</code> or <code>SmarterCSV.headers</code>",
|
|
122
|
+
"then": "switch to instantiating <code>SmarterCSV::Reader</code> and reading <code>reader.raw_headers</code> / <code>reader.headers</code> — 1.12.0 moved these off the class-level API."
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
"1.12": {
|
|
127
|
+
"to": "1.13",
|
|
128
|
+
"latest_release": "1.12.1",
|
|
129
|
+
"actions": [
|
|
130
|
+
{
|
|
131
|
+
"if": "your CSV rows can have more columns than the header AND your code expects only header-listed keys",
|
|
132
|
+
"then": "filter out the new auto-generated <code>:column_N</code> keys, or pass <code>strict: true</code> to raise on extras — 1.13.0 keeps extra columns instead of dropping them silently."
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"if": "any of your input files might have unbalanced quotes",
|
|
136
|
+
"then": "wrap calls in <code>rescue SmarterCSV::MalformedCSV</code> — 1.13.0 now raises instead of producing garbled output."
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"if": "you pass <code>user_provided_headers:</code> AND your file has a header line that should be skipped",
|
|
140
|
+
"then": "also pass <code>headers_in_file: true</code> explicitly — 1.13.0 made <code>user_provided_headers:</code> imply <code>headers_in_file: false</code> by default."
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
"1.13": {
|
|
145
|
+
"to": "1.14",
|
|
146
|
+
"latest_release": "1.13.1",
|
|
147
|
+
"actions": []
|
|
148
|
+
},
|
|
149
|
+
"1.14": {
|
|
150
|
+
"to": "1.15",
|
|
151
|
+
"latest_release": "1.14.4",
|
|
152
|
+
"actions": [
|
|
153
|
+
{
|
|
154
|
+
"if": "your Ruby version is 2.5 or older",
|
|
155
|
+
"then": "upgrade Ruby to 2.6 or newer — 1.15.0 dropped support for Ruby 2.5.<br><br>The migration is small: Ruby 2.5 reached end-of-life in March 2021 (no more security fixes anywhere), and Ruby 2.5 → 2.6 is API-compatible for nearly all code. Update your <code>.ruby-version</code> or the <code>ruby</code> line in your <code>Gemfile</code>, run <code>bundle install</code>, and you're done. Most users jump straight to a current Ruby (3.x)."
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
},
|
|
159
|
+
"1.15": {
|
|
160
|
+
"to": "1.16",
|
|
161
|
+
"latest_release": "1.15.3",
|
|
162
|
+
"actions": [
|
|
163
|
+
{
|
|
164
|
+
"if": "your CSV files contain stray <code>\"</code> characters in the middle of unquoted fields",
|
|
165
|
+
"then": "verify the output is now correct — 1.16.0 treats them as literal (RFC 4180). Output gets more correct for almost everyone; the temporary escape hatch <code>quote_boundary: :legacy</code> exists if your downstream code depended on the previously-corrupted output (not recommended for new code)."
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
"1.16": {
|
|
170
|
+
"to": "1.17",
|
|
171
|
+
"latest_release": "1.16.6",
|
|
172
|
+
"actions": []
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -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>
|