181 lines
6.4 KiB
JavaScript
181 lines
6.4 KiB
JavaScript
(function (root) {
|
|
'use strict';
|
|
|
|
function escapeText(value) {
|
|
return String(value || '')
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/\r?\n/g, '\\n')
|
|
.replace(/,/g, '\\,')
|
|
.replace(/;/g, '\\;');
|
|
}
|
|
|
|
function calendarDay(value) {
|
|
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
return match ? match.slice(1).join('') : '';
|
|
}
|
|
|
|
function nextDay(day) {
|
|
const date = new Date(Date.UTC(
|
|
Number(day.slice(0, 4)), Number(day.slice(4, 6)) - 1, Number(day.slice(6, 8)) + 1
|
|
));
|
|
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
|
}
|
|
|
|
function uid(item) {
|
|
const repository = String(item.repository || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
return `issue-${Number(item.number)}@${repository}`;
|
|
}
|
|
|
|
function foldLine(line) {
|
|
const encoder = new TextEncoder();
|
|
const chunks = [];
|
|
let chunk = '';
|
|
let bytes = 0;
|
|
let limit = 75;
|
|
for (const character of String(line)) {
|
|
const width = encoder.encode(character).length;
|
|
if (chunk && bytes + width > limit) {
|
|
chunks.push(chunk);
|
|
chunk = character;
|
|
bytes = width;
|
|
limit = 74;
|
|
} else {
|
|
chunk += character;
|
|
bytes += width;
|
|
}
|
|
}
|
|
chunks.push(chunk);
|
|
return chunks.join('\r\n ');
|
|
}
|
|
|
|
function serializeAgendaCalendar(items, { generatedOn } = {}) {
|
|
const stampDay = String(generatedOn || new Date().toISOString().slice(0, 10).replace(/-/g, ''));
|
|
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Stackchain//Agenda Snapshot//EN',
|
|
'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Stackchain Agenda'];
|
|
(items || []).forEach(item => {
|
|
const day = calendarDay(item.due_date);
|
|
if (!day || !Number.isInteger(Number(item.number)) || !item.repository) return;
|
|
lines.push(
|
|
'BEGIN:VEVENT',
|
|
`UID:${uid(item)}`,
|
|
`DTSTAMP:${stampDay}T000000Z`,
|
|
`DTSTART;VALUE=DATE:${day}`,
|
|
`DTEND;VALUE=DATE:${nextDay(day)}`,
|
|
`SUMMARY:${escapeText(item.title)}`,
|
|
`DESCRIPTION:${escapeText(`${item.repository}#${item.number} · Stackchain Agenda snapshot`)}`,
|
|
`URL:${String(item.url || '')}`,
|
|
'TRANSP:TRANSPARENT',
|
|
'END:VEVENT',
|
|
);
|
|
});
|
|
lines.push('END:VCALENDAR');
|
|
return lines.map(foldLine).join('\r\n') + '\r\n';
|
|
}
|
|
|
|
async function deliverCalendarSnapshot({
|
|
text,
|
|
filename,
|
|
navigator,
|
|
document,
|
|
urlApi,
|
|
FileCtor,
|
|
}) {
|
|
const file = new FileCtor([text], filename, { type: 'text/calendar;charset=utf-8' });
|
|
const sharePayload = { files: [file], title: 'Stackchain Agenda', text: 'Agenda calendar snapshot' };
|
|
if (typeof navigator?.share === 'function' && typeof navigator?.canShare === 'function' &&
|
|
navigator.canShare(sharePayload)) {
|
|
await navigator.share(sharePayload);
|
|
return 'shared';
|
|
}
|
|
const href = urlApi.createObjectURL(file);
|
|
try {
|
|
const anchor = document.createElement('a');
|
|
anchor.href = href;
|
|
anchor.download = filename;
|
|
anchor.click();
|
|
} finally {
|
|
urlApi.revokeObjectURL(href);
|
|
}
|
|
return 'downloaded';
|
|
}
|
|
|
|
function mountAgendaCalendarExport({
|
|
qs,
|
|
getItems,
|
|
escapeHtml,
|
|
onDone,
|
|
windowObject = root,
|
|
navigatorObject = root.navigator,
|
|
documentObject = root.document,
|
|
urlApi = root.URL,
|
|
FileCtor = root.File,
|
|
}) {
|
|
const sheet = qs('#agenda-export-sheet');
|
|
let items = [];
|
|
let scrollY = 0;
|
|
let trigger = null;
|
|
const selectedItems = () => Array.from(qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]'))
|
|
.filter(checkbox => checkbox.checked)
|
|
.map(checkbox => items[Number(checkbox.value)])
|
|
.filter(Boolean);
|
|
const updateSelection = () => {
|
|
const selected = selectedItems();
|
|
qs('#share-agenda-export').disabled = selected.length === 0;
|
|
qs('#agenda-export-status').textContent = selected.length + ' of ' + items.length +
|
|
(items.length === 1 ? ' deadline selected.' : ' deadlines selected.');
|
|
};
|
|
const close = () => {
|
|
sheet.close();
|
|
windowObject.scrollTo({ top:scrollY, behavior:'instant' });
|
|
trigger?.focus();
|
|
};
|
|
qs('#open-agenda-export').addEventListener('click', event => {
|
|
items = getItems();
|
|
scrollY = windowObject.scrollY;
|
|
trigger = event.currentTarget;
|
|
qs('#agenda-export-items').innerHTML = items.map((item, index) =>
|
|
'<label class="agenda-export-item"><input type="checkbox" value="' + index + '" checked> ' +
|
|
'<span><strong>' + escapeHtml(item.title) + '</strong><small>' +
|
|
escapeHtml(item.repository + '#' + item.number + ' · ' + item.due_date.slice(0, 10)) +
|
|
'</small></span></label>'
|
|
).join('');
|
|
qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]').forEach(checkbox =>
|
|
checkbox.addEventListener('change', updateSelection)
|
|
);
|
|
updateSelection();
|
|
sheet.showModal();
|
|
qs('#cancel-agenda-export').focus();
|
|
});
|
|
qs('#cancel-agenda-export').addEventListener('click', close);
|
|
sheet.addEventListener('cancel', event => {
|
|
event.preventDefault();
|
|
close();
|
|
});
|
|
qs('#share-agenda-export').addEventListener('click', async () => {
|
|
const selected = selectedItems();
|
|
if (!selected.length) return;
|
|
const button = qs('#share-agenda-export');
|
|
button.disabled = true;
|
|
qs('#agenda-export-status').textContent = 'Preparing calendar snapshot…';
|
|
const day = new Date().toISOString().slice(0, 10);
|
|
try {
|
|
const text = serializeAgendaCalendar(selected, { generatedOn:day.replace(/-/g, '') });
|
|
const result = await deliverCalendarSnapshot({
|
|
text, filename:'stackchain-agenda-' + day + '.ics',
|
|
navigator:navigatorObject, document:documentObject, urlApi, FileCtor,
|
|
});
|
|
onDone(result);
|
|
close();
|
|
} catch (error) {
|
|
qs('#agenda-export-status').textContent = error?.name === 'AbortError' ?
|
|
'Share cancelled. Nothing was exported.' : 'Calendar export failed. Retry without leaving Agenda.';
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
const api = { calendarDay, deliverCalendarSnapshot, escapeText, foldLine, mountAgendaCalendarExport, serializeAgendaCalendar };
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
else root.StackchainAgendaCalendar = api;
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|