archive.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. /*
  2. * Gallery archive builder — the admin's "Rebuild now" button.
  3. *
  4. * The archive normally rebuilds itself in the background, but the photographer
  5. * often wants it finished before sending the link out. Building a multi-gigabyte
  6. * ZIP cannot happen in one request on a 60 s execution limit, so this loops:
  7. * POST step, get back {done, total, finished}, repeat until finished.
  8. *
  9. * Every piece of state lives on the server, so this loop is disposable — closing
  10. * the tab pauses the build, "Resume" picks it up at the photo it stopped on, and
  11. * the background worker would eventually finish it regardless.
  12. */
  13. (function () {
  14. 'use strict';
  15. var card = document.getElementById('archive-card');
  16. if (!card) return;
  17. var api = card.dataset.api;
  18. var slug = card.dataset.slug;
  19. var csrf = card.dataset.csrf;
  20. var buildBtn = document.getElementById('archive-build');
  21. var cancelBtn = document.getElementById('archive-cancel');
  22. var bar = document.getElementById('archive-bar');
  23. var fill = bar.querySelector('span');
  24. var status = document.getElementById('archive-status');
  25. var running = false;
  26. var stopped = false;
  27. var startedAt = 0;
  28. var startedFrom = 0;
  29. function post(action) {
  30. var body = new FormData();
  31. body.append('slug', slug);
  32. body.append('action', action);
  33. return fetch(api, {
  34. method: 'POST',
  35. headers: { 'X-CSRF-Token': csrf },
  36. body: body
  37. }).then(function (response) {
  38. return response.json().catch(function () {
  39. return { error: 'Server returned a non-JSON response (HTTP ' + response.status + ')' };
  40. });
  41. });
  42. }
  43. /* Rough finish time from the rate this run has actually achieved. */
  44. function eta(done, total) {
  45. var elapsed = (Date.now() - startedAt) / 1000;
  46. var processed = done - startedFrom;
  47. if (processed < 1 || elapsed < 1) return '';
  48. var remaining = Math.round((total - done) * (elapsed / processed));
  49. if (remaining < 60) return ' · about a minute left';
  50. return ' · about ' + Math.round(remaining / 60) + ' min left';
  51. }
  52. function show(done, total, suffix) {
  53. bar.hidden = false;
  54. fill.style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
  55. status.textContent = done + ' of ' + total + ' photos' + (suffix || '');
  56. }
  57. function fail(message, done, total) {
  58. running = false;
  59. bar.hidden = total > 0 ? false : true;
  60. status.innerHTML = '';
  61. status.appendChild(document.createTextNode(message + ' '));
  62. var resume = document.createElement('button');
  63. resume.className = 'btn-ghost';
  64. resume.style.margin = '0';
  65. resume.textContent = 'Resume';
  66. resume.addEventListener('click', function () { run('step'); });
  67. status.appendChild(resume);
  68. buildBtn.disabled = false;
  69. cancelBtn.hidden = true;
  70. }
  71. function run(action) {
  72. running = true;
  73. stopped = false;
  74. buildBtn.disabled = true;
  75. cancelBtn.hidden = false;
  76. startedAt = Date.now();
  77. startedFrom = -1;
  78. var retried = false;
  79. function step(which) {
  80. post(which).then(function (result) {
  81. if (stopped) return;
  82. if (result.error && !result.total) {
  83. // A transient S3 hiccup is worth one silent retry; anything
  84. // else needs the photographer to see it.
  85. if (!retried) {
  86. retried = true;
  87. step('step');
  88. return;
  89. }
  90. fail(result.error, 0, 0);
  91. return;
  92. }
  93. retried = false;
  94. if (startedFrom < 0) startedFrom = result.done;
  95. if (result.finished) {
  96. running = false;
  97. show(result.total, result.total, ' · done');
  98. // Reload so the status line, size and share-side button all
  99. // reflect the archive that now exists.
  100. window.location.reload();
  101. return;
  102. }
  103. if (result.error) {
  104. show(result.done, result.total, '');
  105. fail(result.error, result.done, result.total);
  106. return;
  107. }
  108. show(result.done, result.total, eta(result.done, result.total));
  109. step('step');
  110. }).catch(function (err) {
  111. if (!stopped) fail('Network error: ' + err.message, 0, 0);
  112. });
  113. }
  114. step(action);
  115. }
  116. buildBtn.addEventListener('click', function () {
  117. if (running) return;
  118. // "start" discards any half-finished attempt and opens a fresh upload.
  119. run(buildBtn.dataset.resume ? 'step' : 'start');
  120. });
  121. cancelBtn.addEventListener('click', function () {
  122. stopped = true;
  123. running = false;
  124. post('cancel').then(function () { window.location.reload(); });
  125. });
  126. })();