bible270 1.11.0 → 1.12.0
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 +63 -2
- data/README.md +27 -1
- data/app/controllers/bible270/admin_controller.rb +11 -14
- data/app/controllers/bible270/profiles_controller.rb +5 -7
- data/app/helpers/bible270/plan_helper.rb +14 -8
- data/app/models/bible270/comment.rb +2 -2
- data/app/models/bible270/reader.rb +22 -10
- data/app/views/bible270/admin/show.html.erb +2 -1
- data/app/views/bible270/profiles/edit.html.erb +2 -1
- data/app/views/bible270/shared/_avatar_ui.html.erb +85 -0
- data/app/views/bible270/shared/_styles.html.erb +49 -6
- data/app/views/bible270/shared/_theme_scenery.html.erb +74 -67
- data/app/views/bible270/shared/_theme_ui.html.erb +15 -1
- data/app/views/layouts/bible270/application.html.erb +12 -2
- data/lib/bible270/avatar_backfill.rb +93 -0
- data/lib/bible270/avatar_images.rb +159 -0
- data/lib/bible270/avatar_optimizer.rb +257 -0
- data/lib/bible270/avatars.rb +2 -2
- data/lib/bible270/version.rb +1 -1
- data/lib/bible270.rb +2 -0
- data/lib/tasks/bible270_avatars.rake +21 -0
- metadata +34 -1
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
<path d="M0 181Q150 174 294 181T601 184M728 182Q874 175 1028 181T1370 180M1442 183H1600" stroke-width="1.4" />
|
|
49
49
|
<path d="M43 216Q193 203 353 214T691 213M821 220Q970 208 1128 218T1510 215" stroke-width="2" />
|
|
50
50
|
</g>
|
|
51
|
-
<g fill="none" stroke="url(#b270-ocean-shimmer)" stroke-linecap="round">
|
|
51
|
+
<g id="b270-ocean-glints" fill="none" stroke="url(#b270-ocean-shimmer)" stroke-linecap="round">
|
|
52
52
|
<path d="M880 170H1360M808 196H1434M751 230H1507" stroke-width="3" />
|
|
53
53
|
<path d="M685 269Q1050 253 1560 272" stroke-width="5" />
|
|
54
54
|
</g>
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
<path d="M0 292C208 270 381 310 593 289S973 254 1181 266 1444 288 1600 257" stroke-width="2" opacity=".38" />
|
|
62
62
|
<path d="M50 365Q174 352 282 366M370 381Q499 389 611 374M745 352Q887 326 1012 328M1194 339Q1335 359 1450 344" stroke-width="1.4" opacity=".38" />
|
|
63
63
|
</g>
|
|
64
|
-
<g fill="none" stroke="#b4a88c" stroke-linecap="round" opacity=".17">
|
|
64
|
+
<g id="b270-ocean-sand-detail" fill="none" stroke="#b4a88c" stroke-linecap="round" opacity=".17">
|
|
65
65
|
<path d="M105 414Q228 401 347 413M530 424Q669 412 777 417M1007 386Q1110 379 1227 391M1337 420Q1450 406 1562 411" />
|
|
66
66
|
</g>
|
|
67
67
|
</svg>
|
|
@@ -238,12 +238,12 @@
|
|
|
238
238
|
<path d="M372 0 359 104 371 193 356 283 369 369 358 460 372 547 359 635 373 723 362 815 372 900H385L375 814 387 723 373 635 386 547 372 459 383 369 370 282 385 193 373 105 385 0Z" />
|
|
239
239
|
<path d="M529 0 538 92 523 184 536 276 522 362 533 451 518 542 531 636 520 726 534 813 529 900H540L547 813 533 726 544 636 532 542 546 452 535 362 549 276 537 184 551 92 540 0Z" />
|
|
240
240
|
</g>
|
|
241
|
-
<g fill="none" stroke="#ad8359" stroke-linecap="round" opacity=".17">
|
|
241
|
+
<g id="b270-cedar-fine-grain" fill="none" stroke="#ad8359" stroke-linecap="round" opacity=".17">
|
|
242
242
|
<path stroke-width="2" d="M75 0C51 134 85 209 65 330S79 525 62 653 82 811 75 900M241 0C260 112 231 202 250 314S229 511 247 629 225 802 241 900M402 0C383 127 413 223 393 345S418 534 398 665 414 810 402 900M565 0C581 123 552 223 574 348S552 548 571 677 552 807 565 900" />
|
|
243
243
|
<path d="M97 51 88 147 99 232M95 468 84 556 94 631 85 710M272 79 264 164 276 245 267 320M272 502 261 588 273 667 264 757 272 830M455 43 446 132 458 219 449 295M471 408 459 486 471 568M472 767 461 842" />
|
|
244
244
|
<path d="M163 47C150 163 167 221 150 271M150 416C165 481 151 539 160 617M316 171 307 262 318 347 308 429M311 671 302 753 313 845M491 109 481 195 492 281 482 359" />
|
|
245
245
|
</g>
|
|
246
|
-
<g fill="none" stroke="#9b744e" stroke-width="1.5" opacity=".2">
|
|
246
|
+
<g id="b270-cedar-knots" fill="none" stroke="#9b744e" stroke-width="1.5" opacity=".2">
|
|
247
247
|
<path d="M143 261C113 290 108 342 126 385L143 425C158 390 175 346 162 302Z" />
|
|
248
248
|
<path d="M142 288C126 310 124 340 135 365L144 387C152 359 158 329 149 307Z" />
|
|
249
249
|
<path d="M141 315Q134 335 144 352Q150 332 141 315Z" />
|
|
@@ -264,11 +264,11 @@
|
|
|
264
264
|
<rect width="100%" height="100%" fill="url(#b270-cedar-grain)" mask="url(#b270-cedar-mask)" />
|
|
265
265
|
</svg>
|
|
266
266
|
|
|
267
|
+
<%# Direct paths avoid SVG use-instance repainting when a hidden theme is revealed. %>
|
|
268
|
+
<% spruce_tree_path = "M0 0-9 29-5 27-20 55-11 52-29 85-18 81-39 119-26 113-48 154-32 146-57 194-6 181-6 218H6V181L57 194 32 146 48 154 26 113 39 119 18 81 29 85 11 52 20 55 5 27 9 29Z" %>
|
|
267
269
|
<svg class="b270-theme-scenery" data-scenery="dark_green" viewBox="0 0 1600 440" preserveAspectRatio="xMidYMax slice"
|
|
268
270
|
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
|
269
271
|
<defs>
|
|
270
|
-
<path id="b270-spruce-tree"
|
|
271
|
-
d="M0 0-9 29-5 27-20 55-11 52-29 85-18 81-39 119-26 113-48 154-32 146-57 194-6 181-6 218H6V181L57 194 32 146 48 154 26 113 39 119 18 81 29 85 11 52 20 55 5 27 9 29Z" />
|
|
272
272
|
<linearGradient id="b270-spruce-fade" x1="0" y1="0" x2="0" y2="1">
|
|
273
273
|
<stop offset="0" stop-color="white" />
|
|
274
274
|
<stop offset=".75" stop-color="white" stop-opacity=".9" />
|
|
@@ -281,70 +281,70 @@
|
|
|
281
281
|
<g id="b270-spruce-treeline" transform="translate(0 -24)">
|
|
282
282
|
<g id="b270-spruce-distant" fill="#66816b" opacity=".19" mask="url(#b270-spruce-mask)">
|
|
283
283
|
<rect id="b270-spruce-distant-ground" y="300" width="1600" height="164" />
|
|
284
|
-
<
|
|
285
|
-
<
|
|
286
|
-
<
|
|
287
|
-
<
|
|
288
|
-
<
|
|
289
|
-
<
|
|
290
|
-
<
|
|
291
|
-
<
|
|
292
|
-
<
|
|
293
|
-
<
|
|
294
|
-
<
|
|
295
|
-
<
|
|
296
|
-
<
|
|
297
|
-
<
|
|
298
|
-
<
|
|
299
|
-
<
|
|
300
|
-
<
|
|
301
|
-
<
|
|
302
|
-
<
|
|
303
|
-
<
|
|
304
|
-
<
|
|
305
|
-
<
|
|
306
|
-
<
|
|
307
|
-
<
|
|
308
|
-
<
|
|
309
|
-
<
|
|
310
|
-
<
|
|
311
|
-
<
|
|
312
|
-
<
|
|
313
|
-
<
|
|
284
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(15 145) scale(.64 .95)" />
|
|
285
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(90 171) scale(.55 .82)" />
|
|
286
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(164 112) scale(.7 1.1)" />
|
|
287
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(244 157) scale(.6 .92)" />
|
|
288
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(321 137) scale(.66 1.02)" />
|
|
289
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(405 186) scale(.55 .82)" />
|
|
290
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(478 163) scale(.67 .96)" />
|
|
291
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(560 194) scale(.58 .83)" />
|
|
292
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(643 180) scale(.6 .91)" />
|
|
293
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(719 212) scale(.5 .77)" />
|
|
294
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(790 192) scale(.58 .88)" />
|
|
295
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(875 211) scale(.52 .78)" />
|
|
296
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(950 174) scale(.62 .96)" />
|
|
297
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1030 193) scale(.54 .84)" />
|
|
298
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1110 148) scale(.7 1.02)" />
|
|
299
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1195 166) scale(.58 .91)" />
|
|
300
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1270 108) scale(.7 1.15)" />
|
|
301
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1350 153) scale(.6 .94)" />
|
|
302
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1435 129) scale(.64 1.05)" />
|
|
303
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1515 168) scale(.58 .88)" />
|
|
304
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1590 139) scale(.7 1)" />
|
|
305
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(53 237) scale(.3 .36)" />
|
|
306
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(205 225) scale(.34 .43)" />
|
|
307
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(365 247) scale(.28 .31)" />
|
|
308
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(520 231) scale(.33 .4)" />
|
|
309
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(681 241) scale(.29 .34)" />
|
|
310
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(834 218) scale(.36 .47)" />
|
|
311
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1068 237) scale(.31 .37)" />
|
|
312
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1235 244) scale(.28 .33)" />
|
|
313
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1394 228) scale(.34 .42)" />
|
|
314
314
|
</g>
|
|
315
315
|
<%# Keep the foreground outside the distant haze mask so no trees show through it. %>
|
|
316
316
|
<g id="b270-spruce-near" fill="#0c2017">
|
|
317
317
|
<rect id="b270-spruce-near-ground" y="350" width="1600" height="114" />
|
|
318
|
-
<
|
|
319
|
-
<
|
|
320
|
-
<
|
|
321
|
-
<
|
|
322
|
-
<
|
|
323
|
-
<
|
|
324
|
-
<
|
|
325
|
-
<
|
|
326
|
-
<
|
|
327
|
-
<
|
|
328
|
-
<
|
|
329
|
-
<
|
|
330
|
-
<
|
|
331
|
-
<
|
|
332
|
-
<
|
|
333
|
-
<
|
|
334
|
-
<
|
|
335
|
-
<
|
|
336
|
-
<
|
|
337
|
-
<
|
|
338
|
-
<
|
|
339
|
-
<
|
|
340
|
-
<
|
|
341
|
-
<
|
|
342
|
-
<
|
|
343
|
-
<
|
|
344
|
-
<
|
|
345
|
-
<
|
|
346
|
-
<
|
|
347
|
-
<
|
|
318
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(-15 104) scale(1.1 1.65)" />
|
|
319
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(115 64) scale(1.08 1.78)" />
|
|
320
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(228 153) scale(.87 1.4)" />
|
|
321
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(343 121) scale(1 1.56)" />
|
|
322
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(455 189) scale(.83 1.26)" />
|
|
323
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(561 161) scale(.94 1.4)" />
|
|
324
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(676 227) scale(.75 1.12)" />
|
|
325
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(781 202) scale(.88 1.25)" />
|
|
326
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(890 231) scale(.76 1.1)" />
|
|
327
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(998 182) scale(.89 1.32)" />
|
|
328
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1112 153) scale(.98 1.46)" />
|
|
329
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1227 106) scale(1.03 1.65)" />
|
|
330
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1343 162) scale(.9 1.38)" />
|
|
331
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1460 73) scale(1.12 1.78)" />
|
|
332
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1580 133) scale(.97 1.5)" />
|
|
333
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(55 213) scale(.62 1.08)" />
|
|
334
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(394 245) scale(.58 .94)" />
|
|
335
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(622 132) scale(.78 1.52)" />
|
|
336
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(944 263) scale(.55 .88)" />
|
|
337
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1171 230) scale(.65 1.04)" />
|
|
338
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1524 201) scale(.7 1.17)" />
|
|
339
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(173 282) scale(.34 .4)" />
|
|
340
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(283 265) scale(.4 .49)" />
|
|
341
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(506 290) scale(.32 .35)" />
|
|
342
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(727 271) scale(.38 .46)" />
|
|
343
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(835 297) scale(.3 .31)" />
|
|
344
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1054 277) scale(.36 .43)" />
|
|
345
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1284 288) scale(.33 .36)" />
|
|
346
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1398 260) scale(.42 .52)" />
|
|
347
|
+
<path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1552 294) scale(.31 .33)" />
|
|
348
348
|
</g>
|
|
349
349
|
</g>
|
|
350
350
|
</svg>
|
|
@@ -398,6 +398,11 @@
|
|
|
398
398
|
</g>
|
|
399
399
|
</g>
|
|
400
400
|
</mask>
|
|
401
|
+
<radialGradient id="b270-moon-mobile-glow">
|
|
402
|
+
<stop offset="0" stop-color="#c6d6e9" stop-opacity=".18" />
|
|
403
|
+
<stop offset=".5" stop-color="#abc3df" stop-opacity=".07" />
|
|
404
|
+
<stop offset="1" stop-color="#abc3df" stop-opacity="0" />
|
|
405
|
+
</radialGradient>
|
|
401
406
|
<clipPath id="b270-lake-clip">
|
|
402
407
|
<rect y="240" width="1600" height="200" />
|
|
403
408
|
</clipPath>
|
|
@@ -416,7 +421,9 @@
|
|
|
416
421
|
<ellipse cx="1510" cy="305" rx="145" ry="125" fill="url(#b270-moon-halo)" opacity=".6" />
|
|
417
422
|
<rect id="b270-lake-reflection" fill="url(#b270-moon-reflection)"
|
|
418
423
|
x="0" y="240" width="1600" height="200" mask="url(#b270-greek-reflection)" />
|
|
419
|
-
<%#
|
|
424
|
+
<%# Phones use a quiet pool of moonlight instead of the reflected scripture. %>
|
|
425
|
+
<ellipse class="b270-mobile-scenery" cx="1510" cy="332" rx="65" ry="92" fill="url(#b270-moon-mobile-glow)" />
|
|
426
|
+
<%# The rippled letters supply the desktop water texture without straight lines crossing the reflections. %>
|
|
420
427
|
</g>
|
|
421
428
|
</svg>
|
|
422
429
|
|
|
@@ -16,7 +16,18 @@
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function syncColorScheme() {
|
|
20
|
+
const mode = document.body.dataset.displayMode || "system";
|
|
21
|
+
document.documentElement.dataset.b270DisplayMode = mode;
|
|
22
|
+
const meta = document.getElementById("b270-color-scheme");
|
|
23
|
+
if (meta) {
|
|
24
|
+
meta.content = ["light", "sage", "mist"].includes(mode) ? "only light" :
|
|
25
|
+
["dark", "dark_green", "dark_blue"].includes(mode) ? "dark" : "light dark";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
function initialize() {
|
|
30
|
+
syncColorScheme();
|
|
20
31
|
document.querySelectorAll("[data-b270-theme-fallback]").forEach((button) => { button.hidden = true; });
|
|
21
32
|
}
|
|
22
33
|
|
|
@@ -65,7 +76,10 @@
|
|
|
65
76
|
other.dataset.b270SavedTheme = mode;
|
|
66
77
|
});
|
|
67
78
|
if (!select.isConnected) return;
|
|
68
|
-
if (select.dataset.b270ThemeApply === "true")
|
|
79
|
+
if (select.dataset.b270ThemeApply === "true") {
|
|
80
|
+
document.body.dataset.displayMode = mode;
|
|
81
|
+
syncColorScheme();
|
|
82
|
+
}
|
|
69
83
|
status(select, "Theme saved.");
|
|
70
84
|
} catch (_error) {
|
|
71
85
|
group.forEach(({ select: other, previous }) => { other.value = previous; });
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
|
|
2
|
+
<%
|
|
3
|
+
display_mode = current_reader&.effective_display_mode || Bible270::Reader::DEFAULT_DISPLAY_MODE
|
|
4
|
+
color_scheme = case display_mode
|
|
5
|
+
when "light", "sage", "mist" then "only light"
|
|
6
|
+
when "system" then "light dark"
|
|
7
|
+
else "dark"
|
|
8
|
+
end
|
|
9
|
+
%>
|
|
10
|
+
<html lang="en" data-b270-display-mode="<%= display_mode %>">
|
|
3
11
|
<head>
|
|
4
12
|
<meta charset="utf-8">
|
|
13
|
+
<meta id="b270-color-scheme" name="color-scheme" content="<%= color_scheme %>">
|
|
5
14
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
15
|
<title><%= content_for?(:title) ? yield(:title) : Bible270.config.app_name %></title>
|
|
7
16
|
<%= csrf_meta_tags %>
|
|
@@ -13,7 +22,7 @@
|
|
|
13
22
|
<%= render "bible270/shared/styles" %>
|
|
14
23
|
<%= yield :head %>
|
|
15
24
|
</head>
|
|
16
|
-
<body class="b270" data-display-mode="<%=
|
|
25
|
+
<body class="b270" data-display-mode="<%= display_mode %>">
|
|
17
26
|
<%= render "bible270/shared/theme_scenery" %>
|
|
18
27
|
<div class="b270-wrap">
|
|
19
28
|
<%= render "bible270/shared/header" %>
|
|
@@ -24,6 +33,7 @@
|
|
|
24
33
|
<%= render 'bible270/shared/day_index', expanded: content_for?(:expand_day_index) unless content_for?(:hide_day_index) %>
|
|
25
34
|
<%= b270_footer %>
|
|
26
35
|
</div>
|
|
36
|
+
<%= render "bible270/shared/avatar_ui" %>
|
|
27
37
|
<%= render "bible270/shared/theme_ui" %>
|
|
28
38
|
<%= render "bible270/shared/scripture_popups" %>
|
|
29
39
|
<%= render "bible270/shared/video_embeds" %>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bible270
|
|
4
|
+
class AvatarBackfill
|
|
5
|
+
DEFAULT_BATCH_SIZE = 100
|
|
6
|
+
MAX_BATCH_SIZE = 1000
|
|
7
|
+
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
def initialize(dry_run: false, force: false, batch_size: DEFAULT_BATCH_SIZE, output: $stdout)
|
|
11
|
+
@dry_run = dry_run
|
|
12
|
+
@force = force
|
|
13
|
+
@batch_size = validate_batch_size(batch_size)
|
|
14
|
+
@output = output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# processed counts successful optimizations, not all visited readers. Byte
|
|
18
|
+
# totals cover successful/current derivatives only, never failed or raced work.
|
|
19
|
+
def run
|
|
20
|
+
check_storage!
|
|
21
|
+
summary = { processed: 0, skipped: 0, changed: 0, failed: 0, eligible: 0,
|
|
22
|
+
original_bytes: 0, optimized_bytes: 0 }
|
|
23
|
+
|
|
24
|
+
Reader.with_avatar.joins(:avatar_attachment).find_each(batch_size: @batch_size) do |reader|
|
|
25
|
+
if @dry_run
|
|
26
|
+
eligible = @force || !reader.avatar_optimized?
|
|
27
|
+
summary[eligible ? :eligible : :skipped] += 1
|
|
28
|
+
@output.puts "reader_id=#{reader.id} #{eligible ? 'eligible' : 'skipped'}"
|
|
29
|
+
else
|
|
30
|
+
process(reader, summary)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if @dry_run
|
|
35
|
+
@output.puts "DRY RUN: eligible=#{summary[:eligible]} skipped=#{summary[:skipped]} " \
|
|
36
|
+
'processed=0 changed=0 failed=0'
|
|
37
|
+
else
|
|
38
|
+
@output.puts "processed=#{summary[:processed]} skipped=#{summary[:skipped]} " \
|
|
39
|
+
"changed=#{summary[:changed]} failed=#{summary[:failed]}"
|
|
40
|
+
@output.puts "Display bytes (optimized/skipped readers): original=#{summary[:original_bytes]} " \
|
|
41
|
+
"optimized=#{summary[:optimized_bytes]}. Originals retained; not total storage savings."
|
|
42
|
+
end
|
|
43
|
+
summary
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_batch_size(value)
|
|
49
|
+
valid = value.is_a?(Integer) || (value.is_a?(String) && value.match?(%r{\A[0-9]+\z}))
|
|
50
|
+
size = Integer(value, 10) if valid && value.is_a?(String)
|
|
51
|
+
size = value if value.is_a?(Integer)
|
|
52
|
+
return size if valid && size.between?(1, MAX_BATCH_SIZE)
|
|
53
|
+
|
|
54
|
+
raise Error, "BATCH_SIZE must be a positive integer between 1 and #{MAX_BATCH_SIZE}."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def check_storage!
|
|
58
|
+
return if Reader.avatar_uploads? && defined?(ActiveStorage::Blob) &&
|
|
59
|
+
defined?(ActiveStorage::Attachment) && ActiveStorage::Blob.table_exists? &&
|
|
60
|
+
ActiveStorage::Attachment.table_exists?
|
|
61
|
+
|
|
62
|
+
raise Error, 'Avatar backfill requires Active Storage. Enable active_storage/engine, ' \
|
|
63
|
+
'configure a storage service, and run bin/rails active_storage:install and bin/rails db:migrate in the host app.'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def process(reader, summary)
|
|
67
|
+
result = AvatarImages.optimize_existing!(reader, force: @force)
|
|
68
|
+
case result.fetch(:status)
|
|
69
|
+
when :optimized, :skipped
|
|
70
|
+
summary[result[:status] == :optimized ? :processed : :skipped] += 1
|
|
71
|
+
summary[:original_bytes] += result.fetch(:before_bytes)
|
|
72
|
+
summary[:optimized_bytes] += result.fetch(:after_bytes)
|
|
73
|
+
when :changed
|
|
74
|
+
summary[:changed] += 1
|
|
75
|
+
else
|
|
76
|
+
raise Error, 'Avatar optimizer returned an unsupported status.'
|
|
77
|
+
end
|
|
78
|
+
@output.puts "reader_id=#{reader.id} #{result[:status]}"
|
|
79
|
+
rescue AvatarOptimizer::Error
|
|
80
|
+
record_failure(reader, summary, 'image processing failed; check the source image and libvips codec support')
|
|
81
|
+
rescue AvatarImages::Error
|
|
82
|
+
record_failure(reader, summary, 'storage failed; check storage service configuration and access')
|
|
83
|
+
rescue ActiveRecord::ActiveRecordError
|
|
84
|
+
record_failure(reader, summary, 'database failed; check database availability and record integrity')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def record_failure(reader, summary, reason)
|
|
88
|
+
summary[:failed] += 1
|
|
89
|
+
# Exception messages, causes and record inspections can expose private data.
|
|
90
|
+
@output.puts "reader_id=#{reader.id} failed: #{reason}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'avatar_optimizer'
|
|
4
|
+
|
|
5
|
+
module Bible270
|
|
6
|
+
# Originals and their display thumbnails are separate, immutable blobs. All
|
|
7
|
+
# storage work finishes before attachment changes become visible to readers.
|
|
8
|
+
class AvatarImages
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def update!(reader, attributes, upload: nil)
|
|
13
|
+
raise Error, 'Picture uploads require Active Storage.' if upload && !reader.class.avatar_uploads?
|
|
14
|
+
|
|
15
|
+
require_own_transaction!(reader) if upload
|
|
16
|
+
|
|
17
|
+
reader.assign_attributes(attributes)
|
|
18
|
+
raise ActiveRecord::RecordInvalid, reader unless reader.valid?
|
|
19
|
+
return reader.save! unless upload
|
|
20
|
+
|
|
21
|
+
blobs = []
|
|
22
|
+
AvatarOptimizer.open(upload.tempfile.path, content_type: upload.content_type, byte_size: upload.size) do |output|
|
|
23
|
+
# Do not let Active Storage decode the unsanitized original again in
|
|
24
|
+
# an analysis job. Validation/conversion already ran through our safe
|
|
25
|
+
# pipeline, and originals are retained for reprocessing, not variants.
|
|
26
|
+
original = store_blob(blobs, io: upload.tempfile, filename: upload.original_filename,
|
|
27
|
+
content_type: upload.content_type.to_s.downcase.split(';').first.to_s.strip,
|
|
28
|
+
metadata: { 'analyzed' => true })
|
|
29
|
+
thumbnail = store_thumbnail(blobs, output, original)
|
|
30
|
+
pending_attributes = reader.changes.transform_values(&:last)
|
|
31
|
+
reader.class.transaction do
|
|
32
|
+
# Reload under the row lock: cached (even nil) attachments may have
|
|
33
|
+
# changed during processing. Replacing a stale association can leave
|
|
34
|
+
# multiple attachments with the same has_one_attached name.
|
|
35
|
+
reader.class.lock.find(reader.id)
|
|
36
|
+
reader.attachment_changes.delete('avatar')
|
|
37
|
+
reader.attachment_changes.delete('avatar_thumbnail')
|
|
38
|
+
reader.reload
|
|
39
|
+
reader.assign_attributes(pending_attributes)
|
|
40
|
+
reader.avatar = original
|
|
41
|
+
reader.avatar_thumbnail = thumbnail
|
|
42
|
+
reader.save!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
discard_unattached(blobs)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def optimize_existing!(reader, force: false)
|
|
50
|
+
return result(:skipped) unless reader.avatar_uploaded?
|
|
51
|
+
|
|
52
|
+
require_own_transaction!(reader)
|
|
53
|
+
original = reader.avatar.blob
|
|
54
|
+
if !force && reader.avatar_optimized?
|
|
55
|
+
return result(:skipped, original.byte_size, reader.avatar_thumbnail.blob.byte_size)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
problem = Avatars.problem_with(content_type: original.content_type, byte_size: original.byte_size)
|
|
59
|
+
raise AvatarOptimizer::Error, "Avatar #{problem}." if problem
|
|
60
|
+
|
|
61
|
+
blobs = []
|
|
62
|
+
thumbnail = download_original(original) do |input|
|
|
63
|
+
AvatarOptimizer.open(input.path, content_type: original.content_type, byte_size: original.byte_size) do |output|
|
|
64
|
+
store_thumbnail(blobs, output, original)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
reader.with_lock do
|
|
68
|
+
unless reader.avatar_uploaded? && reader.avatar.blob.id == original.id
|
|
69
|
+
return result(:changed, original.byte_size)
|
|
70
|
+
end
|
|
71
|
+
if !force && reader.avatar_optimized?
|
|
72
|
+
return result(:skipped, original.byte_size, reader.avatar_thumbnail.blob.byte_size)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
reader.avatar_thumbnail = thumbnail
|
|
76
|
+
reader.save!
|
|
77
|
+
end
|
|
78
|
+
result(:optimized, original.byte_size, thumbnail.byte_size)
|
|
79
|
+
ensure
|
|
80
|
+
discard_unattached(blobs)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def remove!(reader)
|
|
84
|
+
return unless reader.class.avatar_uploads?
|
|
85
|
+
|
|
86
|
+
# Detach atomically; Active Storage purges both old blobs after commit.
|
|
87
|
+
reader.with_lock do
|
|
88
|
+
reader.avatar = nil
|
|
89
|
+
reader.avatar_thumbnail = nil
|
|
90
|
+
reader.save!
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def require_own_transaction!(reader)
|
|
97
|
+
return unless [reader.class, ActiveStorage::Blob].any? do |model|
|
|
98
|
+
model.connection_pool.with_connection(&:transaction_open?)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Files cannot roll back with an outer database transaction. Reject that
|
|
102
|
+
# usage before creating blobs rather than leaking files on a later abort.
|
|
103
|
+
raise Error, 'Avatar uploads and optimization must run outside an existing database transaction.'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def download_original(original, &)
|
|
107
|
+
original.open(&)
|
|
108
|
+
rescue AvatarOptimizer::Error, Error
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
Rails.logger.error("Bible270 avatar download failed (#{e.class}).")
|
|
112
|
+
raise Error, 'The original picture could not be read. Check storage availability and file integrity.'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def result(status, before_bytes = 0, after_bytes = 0)
|
|
116
|
+
{ status: status, before_bytes: before_bytes, after_bytes: after_bytes }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def store_thumbnail(blobs, output, original)
|
|
120
|
+
store_blob(blobs, io: output, filename: 'avatar.webp', content_type: 'image/webp',
|
|
121
|
+
service_name: original.service_name,
|
|
122
|
+
metadata: { 'bible270_avatar_version' => AvatarOptimizer::VERSION,
|
|
123
|
+
'bible270_avatar_source_id' => original.id,
|
|
124
|
+
'width' => AvatarOptimizer::SIZE, 'height' => AvatarOptimizer::SIZE,
|
|
125
|
+
'analyzed' => true })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def store_blob(blobs, **attributes)
|
|
129
|
+
attributes.fetch(:io).rewind
|
|
130
|
+
# Register the blob before uploading so a partial storage failure can be
|
|
131
|
+
# cleaned up too. create_and_upload! would not return that failed blob.
|
|
132
|
+
blob = ActiveStorage::Blob.create_after_unfurling!(**attributes, identify: false)
|
|
133
|
+
blobs << blob
|
|
134
|
+
blob.upload_without_unfurling(attributes.fetch(:io))
|
|
135
|
+
blob
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
Rails.logger.error("Bible270 avatar storage failed (#{e.class}).")
|
|
138
|
+
raise Error, 'Your picture could not be stored. Please try again.'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def discard_unattached(blobs)
|
|
142
|
+
Array(blobs).each do |blob|
|
|
143
|
+
# An after-commit callback may raise after a successful save. Never
|
|
144
|
+
# remove blobs that have already become somebody's live attachment.
|
|
145
|
+
blob.with_lock do
|
|
146
|
+
next if blob.attachments.exists?
|
|
147
|
+
|
|
148
|
+
# Unlike purge, delete storage first. If deletion fails, retain the
|
|
149
|
+
# unattached row (service/key) so maintenance can retry cleanup.
|
|
150
|
+
blob.delete
|
|
151
|
+
blob.destroy!
|
|
152
|
+
end
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
Rails.logger.error("Bible270 unused avatar blob #{blob.id} cleanup failed (#{e.class}); retry unattached-blob cleanup.")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|