Skip to content

Instantly share code, notes, and snippets.

@schl3ck
Forked from marco79cgn/vaccination-stats.js
Last active January 7, 2022 18:57
Show Gist options
  • Save schl3ck/570422d5f6ce4595c05fca951da067e5 to your computer and use it in GitHub Desktop.
Save schl3ck/570422d5f6ce4595c05fca951da067e5 to your computer and use it in GitHub Desktop.
A Scriptable widget that shows the amount of people who have received the corona vaccination in Austria
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: blue; icon-glyph: syringe;
/**************************
* Bitte ab hier kopieren *
**************************/
/**
* Scriptable Widget um die aktuellen Impfzahlen darzustellen
*
* Berechnet auch ein Datum ab dem eine Herdenimmunität gegeben wäre
* (Der Mensch hat keine lebenslange Immunität vor Coronaviren, deshalb wird es
* keine Herdenimmunität geben)
*
* Es gibt einen Konfigurator, um die am Widget agezeigten Elemente sowie auch
* die Bundesländer auszuwählen. Dazu einfach das Skript in der App starten und
* "Widgeteinstellungen konfigurieren" auswählen.
*
* Version: 2.1
* Source: https://gist.github.com/schl3ck/570422d5f6ce4595c05fca951da067e5
*
* Changelog
* v2.1 - 2021-12-25
* * Grenzen des Fortschrittbalkens angepasst, sodass die Werte nicht mehr zu nahe an den Grenzen liegen
* * Rechtschreibfehler korrigiert
*
* v2.0 - 2021-12-03
* + Widgetkonfigurator hinzugefügt
* * Widget kann nun auch die Zahlen für die Bundesländer anzeigen
* * Überprüft, ob ein Update des Skripts vorhanden ist
* + Changelog hinzugefügt
*
* v1.4 - 2021-11-21
* * Neue Datenquelle
* + Trendpfeil hinter der Zertifikatanzahl hinzugefügt
*
* v1.3 - 2021-04-18
* * Formatiert die Zahlen für bessere Lesbarkeit
* * Textgröße angepasst
*
* v1.2 - 2021-03-10
* * Neue Datenquelle
*
* v1.1 - 2021-01-30
* * Neue Datenquelle
* * Kleine Fehler behoben
*
* v1.0 - 2021-01-19
* * Widget für Österreich aktualisiert
*/
// Mit Caching und Fallback
const cacheMinutes = 60; // 60 min
const today = new Date();
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
// nur 80% werden für eine Herdenimmunität gebraucht
const herdImmunityFactor = 0.8;
const progressBarWidth = 100;
const progressBarHeight = 5;
const colors = [
new Color("#00ac00"),
new Color("#0000cc"),
new Color("#cc0000"),
new Color("#707070"),
new Color("#f4ac00"), // orange
new Color("#bd00a8"), // magenta
new Color("#7b5700"), // brown
new Color("#00d2d2"), // cyan
];
// maximum number of rows that can be shown on the widget
const maxRows = 8;
/**
* @typedef {object} WidgetSettings
* @property {number[]} stateIDs List of state IDs to show
* @property {boolean} showAverage if the 7 day average of each reagion should be shown
* @property {boolean} showStatusBar if the status bar should be shown
* @property {number} herdImmunityForStateID calculate the herd immunity for the state with this ID
*/
/** @type {WidgetSettings} */
const defaultWidgetSettings = {
stateIDs: [10], // show only of whole Austria
showAverage: true,
showStatusBar: true,
herdImmunityForStateID: 10,
};
const cacheFolder = (function () {
const name = Script.name().replace(/[/<>:"\\|?*]/g, "");
const fm = FileManager.local();
return `${fm.cacheDirectory()}/${name}`;
})();
const stateMap = [
"NoState",
"B",
"K",
"NÖ",
"OÖ",
"S",
"ST",
"T",
"V",
"W",
"Ö",
];
const stateMapFullNames = [
"NoState",
"Burgenland",
"Kärnten",
"Niederösterreich",
"Oberösterreich",
"Salzburg",
"Steiermark",
"Tirol",
"Vorarlberg",
"Wien",
"Österreich",
];
const changelogDateFormatter = new DateFormatter();
changelogDateFormatter.useShortDateStyle();
changelogDateFormatter.useNoTimeStyle();
const changelogRelativeFormatter = new RelativeDateTimeFormatter();
changelogRelativeFormatter.useNamedDateTimeStyle();
const htmlEscape = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
'"': "&quot;",
};
if (config.runsInWidget) {
/** @type {WidgetSettings} */
let settings;
let thrown = false;
try {
if (args.widgetParameter) settings = JSON.parse(args.widgetParameter);
} catch (err) {
const widget = new ListWidget();
const text = widget.addText("The passed parameter is not valid JSON");
text.textColor = Color.red();
Script.setWidget(widget);
Script.complete();
thrown = true;
}
if (!thrown) {
const widget = await createWidget(await getNumbers(), settings);
Script.setWidget(widget);
Script.complete();
}
} else {
await mainMenu();
}
/**
* @typedef { {
* nCitizens: any;
* states: {
* today: CSVItem;
* weekAgo: CSVItem;
* }[];
* daysBetweenLatest7DaysAgo: number;
* date: Date;
* } } CovidData
*/
/**
* @typedef {object} CSVItem
* @property {Date} date
* @property {number} state_id
* @property {string} state_name
* @property {string} age_group
* @property {"Female" | "Male" | "NonBinary"} gender
* @property {number} population
* @property {number} valid_certificates
* @property {number} valid_certificates_percent
*/
/**
* @typedef {T extends PromiseLike<infer U> ? Awaited<U> : T} Awaited
* @template T
*
*/
/**
* @param {Awaited<ReturnType<typeof getNumbers>>} data
* @param {WidgetSettings} settings
*/
async function createWidget(
{ data, update },
settings = defaultWidgetSettings,
) {
if (!settings) {
settings = defaultWidgetSettings;
}
const widget = new ListWidget();
widget.setPadding(4, 10, 0, 10);
const upperStack = widget.addStack();
upperStack.layoutHorizontally();
const upperTextStack = upperStack.addStack();
upperTextStack.layoutVertically();
let staticText1 = upperTextStack.addText("Aktuell gültige");
staticText1.font = Font.semiboldRoundedSystemFont(11);
let staticText2 = upperTextStack.addText("Impfzertifikate");
staticText2.font = Font.semiboldRoundedSystemFont(11);
upperStack.addSpacer();
let logoImage = upperStack.addImage(await getImage("vac-logo.png"));
logoImage.imageSize = new Size(30, 30);
widget.addSpacer(2);
for (let i = 0; i < settings.stateIDs.length; i++) {
const stateID = settings.stateIDs[i];
const color = colors[i % colors.length];
const state = data.states.find((r) => r.today.state_id === stateID);
if (!state) {
const text = widget.addText(`Unbekannte ID: ${stateID}`);
text.leftAlignText();
text.lineLimit = 1;
text.minimumScaleFactor = 0.7;
text.textColor = Color.red();
text.font = Font.boldSystemFont(11);
continue;
}
const titleDataStack = widget.addStack();
titleDataStack.layoutHorizontally();
const dataTitle = titleDataStack.addText(stateMap[stateID] + ":");
dataTitle.font = Font.boldSystemFont(10);
// dataTitle.minimumScaleFactor = 0.8;
titleDataStack.addSpacer();
const dataStack = titleDataStack.addStack();
dataStack.layoutVertically();
const percent = {
today: state.today.valid_certificates_percent,
weekAgo: state.weekAgo.valid_certificates_percent,
};
const trend =
percent.today > percent.weekAgo
? "↑"
: percent.today < percent.weekAgo
? "↓"
: "=";
let amountText = dataStack.addText(
`${state.today.valid_certificates.toLocaleString()} (${round(
state.today.valid_certificates_percent,
2,
).toLocaleString()}%) ${trend}`,
);
amountText.font = Font.boldSystemFont(11);
amountText.textColor = color;
amountText.minimumScaleFactor = 0.8;
amountText.lineLimit = 1;
if (settings.showAverage) {
const averageStack = widget.addStack();
averageStack.layoutHorizontally();
averageStack.addSpacer();
let description3 = averageStack.addText(
`(${data.daysBetweenLatest7DaysAgo}T. Ø: ${calculateDailyVac(
state,
data.daysBetweenLatest7DaysAgo,
).toLocaleString()} / Tag)`,
);
description3.font = Font.mediumSystemFont(9);
description3.lineLimit = 1;
}
}
if (settings.showStatusBar) {
widget.addSpacer(4);
const progressBar = createProgress(
settings.stateIDs.map((id) =>
data.states.find((s) => s.today.state_id === id),
),
);
let progressStack = widget.addStack();
progressStack.layoutVertically();
progressStack.addImage(progressBar.image);
let progressNumberStack = widget.addStack();
progressNumberStack.layoutHorizontally();
const progressText0 = progressNumberStack.addText(`${progressBar.min}%`);
progressText0.font = Font.mediumSystemFont(8);
progressNumberStack.addSpacer();
const progressText70 = progressNumberStack.addText(`${progressBar.max}%`);
progressText70.font = Font.mediumSystemFont(8);
}
if (
settings.herdImmunityForStateID >= 1
&& settings.herdImmunityForStateID <= 10
) {
widget.addSpacer(4);
const rowsLeft =
maxRows
- settings.stateIDs.length * (settings.showAverage ? 2 : 1)
- (settings.showStatusBar ? 1 : 0);
const enoughRowsLeft = rowsLeft >= 2;
let calendarStack = widget.addStack();
if (enoughRowsLeft) {
const calendarImage = calendarStack.addImage(
await getImage("calendar-icon.png"),
);
calendarImage.imageSize = new Size(23, 23);
calendarStack.addSpacer(6);
}
let calendarTextStack = calendarStack.addStack();
if (enoughRowsLeft) {
calendarTextStack.layoutVertically();
} else {
calendarTextStack.layoutHorizontally();
}
// calculate date
const state = data.states.find(
(s) => s.today.state_id === settings.herdImmunityForStateID,
);
if (state) {
const estimatedDate = new Date();
estimatedDate.setDate(
new Date().getDate()
+ calculateRemainingDays(state, data.daysBetweenLatest7DaysAgo),
);
let description = calendarTextStack.addText(
`"${enoughRowsLeft ? "Herdenimmunität" : "Herdenimm."}" ${
stateMap[settings.herdImmunityForStateID]
}:`,
);
description.font = Font.mediumSystemFont(9);
if (!enoughRowsLeft) {
calendarTextStack.addSpacer(4);
}
const description2 = calendarTextStack.addText(
estimatedDate > new Date() ? estimatedDate.toLocaleDateString() : "♾️",
);
description2.font = Font.boldSystemFont(9);
} else {
const text = calendarTextStack.addText(
`Unbekannte ID: ${settings.herdImmunityForStateID}`,
);
text.minimumScaleFactor = 0.7;
text.textColor = Color.red();
}
}
widget.addSpacer(2);
const lastUpdateDate = new Date(data.date);
lastUpdateDate.setSeconds(lastUpdateDate.getSeconds() + 1);
let lastUpdatedText = widget.addText(
"Stand: " + lastUpdateDate.toLocaleDateString(),
);
lastUpdatedText.font = Font.mediumMonospacedSystemFont(8);
lastUpdatedText.textOpacity = 0.7;
lastUpdatedText.centerAlignText();
if (update.updateAvailable) {
const updateText = widget.addText(
`Update: ${update.curVersion} ➞ ${update.newVersion}`,
);
updateText.font = Font.mediumMonospacedSystemFont(8);
updateText.textOpacity = 0.7;
updateText.centerAlignText();
}
return widget;
}
/** @typedef { {label: string, action: () => Promise<void>} } MenuOption */
async function mainMenu() {
const data = await getNumbers();
// log(JSON.stringify(data.update, null, 2));
/** @type {MenuOption[]} */
const options = [
{
label: "Standardwidget anzeigen",
async action() {
const widget = await createWidget(data);
await widget.presentSmall();
},
},
{
label: "Widgeteinstellungen konfigurieren",
action: configureWidget,
},
];
if (data.update.updateAvailable) {
options.push({
label: "Änderungen anzeigen",
action: showChanges.bind(undefined, data.update),
});
options.push({
label: "Aktualisieren",
action: updateScript.bind(undefined, data.update),
});
}
const a = new Alert();
a.addCancelAction("Abbrechen");
options.forEach((o) => a.addAction(o.label));
if (data.update.updateAvailable) {
const lines = data.update.changes
.map(
(change) =>
`v${change.version} - ${changelogDateFormatter.string(
change.date,
)}\n${change.changes}`,
)
.join("\n\n")
.split("\n");
a.title = "Update verfügbar!";
a.message = `${data.update.curVersion} ➞ ${data.update.newVersion}
${lines.length > 10 ? lines.slice(9).join("\n") + "\n..." : lines.join("\n")}`;
}
let res = await a.presentSheet();
if (res === -1) return;
return options[res].action();
}
function configureWidget() {
const ui = new UITable();
const settings = { ...defaultWidgetSettings };
let reorderState = -1;
/** counts down from 1 to 0 when the settings were copied */
let copied = 0;
refreshUI();
return ui.present();
function refreshUI() {
const backgroundColor = Color.dynamic(Color.white(), new Color("#1b1b1d"));
ui.removeAllRows();
let row, cell;
let rowsDisplayed = settings.stateIDs.length;
if (settings.showAverage) rowsDisplayed *= 2;
if (settings.showStatusBar) rowsDisplayed += 1;
if (settings.herdImmunityForStateID >= 0) rowsDisplayed += 1;
row = addRow();
row.height += 10;
if (rowsDisplayed > maxRows) {
row.backgroundColor = Color.orange();
cell = row.addText(
"Es wird am Widget zu viel dargestellt, sodass Text abgschnitten sein kann.",
);
cell.centerAligned();
} else {
row.isHeader = true;
cell = row.addText("Widget-Konfigurator");
cell.font = Font.boldSystemFont(20);
cell.centerAligned();
}
row = addRow();
row.height = 6;
row = addRow();
if (reorderState < 0) {
row.backgroundColor = adjustColor(
backgroundColor,
new Color("#00ac00"),
copied,
);
row.dismissOnSelect = false;
row.onSelect = () => {
Pasteboard.copyString(JSON.stringify(settings));
countDown();
};
cell = row.addText(copied > 0 ? "Kopiert!" : "Konfiguration kopieren", copied > 0 ? "" : "Kopiertes im Parameterfeld des Widgets einfügen");
if (copied > 0) {
cell.centerAligned();
}
}
row = addRow();
if (reorderState < 0) {
row.dismissOnSelect = false;
row.onSelect = async () => {
const widget = await createWidget(await getNumbers(), settings);
await widget.presentSmall();
};
cell = row.addText("Widgetvorschau");
}
row = addRow();
row.height = 2;
row.backgroundColor = Color.dynamic(Color.black(), Color.white());
row = addRow();
row.height = 6;
if (reorderState < 0) {
addSetting(
"7-Tages-Durchschnitt anzeigen",
settings.showAverage,
() => {
settings.showAverage = !settings.showAverage;
},
"für jedes Bundesland",
);
addSetting("Fortschrittsbalken anzeigen", settings.showStatusBar, () => {
settings.showStatusBar = !settings.showStatusBar;
});
} else {
row = addRow();
row.backgroundColor = Color.orange();
cell = row.addText(
"Sortiermodus",
"Tippe auf die Stelle, zu der das ausgewählte Bundesland verschoben werden soll. Zum Abbrechen tippe auf das ausgewählte Bundesland",
);
cell.centerAligned();
row.height *= 2;
}
row = addRow();
row.height += 10;
if (reorderState < 0) {
row.dismissOnSelect = false;
row.onSelect = async () => {
const a = new Alert();
a.addCancelAction("Abbrechen");
const options = stateMapFullNames.slice(1);
options.push("Nicht anzeigen");
options.forEach((o) => a.addAction(o));
const res = await a.presentSheet();
if (res === -1) return;
const choice = options[res];
if (choice === "Nicht anzeigen") {
settings.herdImmunityForStateID = -1;
} else {
settings.herdImmunityForStateID = stateMapFullNames.indexOf(choice);
}
refreshUI();
};
const herdImmunityState =
settings.herdImmunityForStateID >= 1
&& settings.herdImmunityForStateID <= 10
? stateMapFullNames[settings.herdImmunityForStateID]
: "";
cell = row.addText(
`"Herdenimmunität" ${herdImmunityState ? "" : "nicht "}anzeigen${
herdImmunityState ? " für" : ""
}`,
);
cell.widthWeight = 60;
cell = row.addText(herdImmunityState);
cell.widthWeight = 40;
cell.rightAligned();
}
const selectedStates = settings.stateIDs.map((s) => stateMapFullNames[s]);
const otherStates = [];
for (let i = 1; i < stateMapFullNames.length; i++) {
if (!settings.stateIDs.includes(i)) {
otherStates.push(stateMapFullNames[i]);
}
}
row = addRow();
row.height = 10;
row = addRow();
row.isHeader = true;
row.backgroundColor = Color.dynamic(new Color("#ddd"), new Color("#333"));
cell = row.addText(
"Ausgewählte Bundesländer",
"Tippe auf eines, um es zu entferen",
);
cell.centerAligned();
cell.subtitleFont = Font.systemFont(12);
for (const s of selectedStates) {
row = addRow();
row.dismissOnSelect = false;
row.onSelect = () => {
const id = stateMapFullNames.indexOf(s);
if (reorderState < 0) {
settings.stateIDs.splice(settings.stateIDs.indexOf(id), 1);
} else {
if (id !== reorderState) {
let old = settings.stateIDs.indexOf(reorderState);
let index = settings.stateIDs.indexOf(id);
if (index > old) {
index++;
}
settings.stateIDs.splice(index, 0, reorderState);
if (old > index) old++;
settings.stateIDs.splice(old, 1);
}
reorderState = -1;
}
refreshUI();
};
if (stateMapFullNames.indexOf(s) === reorderState) {
row.backgroundColor = Color.green();
}
cell = row.addText(s);
cell.widthWeight = 90;
if (reorderState < 0 && settings.stateIDs.length > 1) {
cell = row.addButton(" ↕️ ");
cell.widthWeight = 10;
cell.rightAligned();
cell.dismissOnTap = false;
cell.onTap = () => {
reorderState = stateMapFullNames.indexOf(s);
refreshUI();
};
}
}
row = addRow();
if (reorderState < 0) {
row.isHeader = true;
row.backgroundColor = Color.dynamic(new Color("#ddd"), new Color("#333"));
cell = row.addText(
"Verfügbare Bundesländer",
"Tippe auf eines, um es hinzuzufügen",
);
cell.centerAligned();
cell.subtitleFont = Font.systemFont(12);
}
for (const s of otherStates) {
row = addRow();
if (reorderState < 0) {
row.dismissOnSelect = false;
row.onSelect = () => {
settings.stateIDs.push(stateMapFullNames.indexOf(s));
refreshUI();
};
cell = row.addText(s);
}
}
ui.reload();
}
function addRow() {
const r = new UITableRow();
ui.addRow(r);
return r;
}
/**
* @param {string} title
* @param {boolean} currentState
* @param {() => void} onChange
* @param {string} [subtitle]
*/
function addSetting(title, currentState, onChange, subtitle = undefined) {
const row = addRow();
row.dismissOnSelect = false;
row.onSelect = () => {
onChange();
refreshUI();
};
let cell = row.addText(title, subtitle);
cell.widthWeight = 95;
cell = row.addText(currentState ? "✓" : "");
cell.widthWeight = 5;
cell.rightAligned();
}
async function countDown() {
copied = 1;
refreshUI();
const timer = Timer.schedule(10, true, () => {
copied -= 0.01;
if (copied <= 0) {
copied = 0;
timer.invalidate();
}
refreshUI();
});
}
}
/**
* @param {UpdateCheckResult} changes
*/
async function showChanges(changes) {
if (!changes.updateAvailable) return;
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<style>
:root {
font-family: -apple-system, sans-serif;
}
h2, h3 {
margin: 0;
}
p, ul {
margin-top: 0;
}
li {
margin-left: -1em;
list-style-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%220.6em%22%20height%3D%220.6em%22%3E%3Ccircle%20cx%3D%228%22%20cy%3D%228%22%20r%3D%224%22%20%2F%3E%3C%2Fsvg%3E%0D%0A");
}
li.plus {
list-style-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%220.6em%22%20height%3D%220.6em%22%3E%3Cpath%20d%3D%22M7%200h2v7h7v2h-7v7h-2v-7h-7v-2h7z%22%2F%3E%3C%2Fsvg%3E");
}
li.minus {
list-style-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%220.6em%22%20height%3D%220.6em%22%3E%3Cpath%20d%3D%22M0%207h16v2h-16z%22%2F%3E%3C%2Fsvg%3E%0D%0A");
}
pre, li {
font-family: inherit;
white-space: pre-line;
}
</style>
</head>
<body>
<h1>Änderungen</h1>
${formatChanges(changes.changes)}
<hr />
<h1>Alle Änderungen</h1>
${formatChanges(changes.fullChangelog)}
</body>
</html>`;
log(html);
const wv = new WebView();
wv.loadHTML(html);
await wv.present();
/** @type {MenuOption[]} */
const options = [
{
label: "Aktualisieren",
action: updateScript.bind(undefined, changes),
},
{
label: "Zurück",
action: mainMenu,
},
];
const a = new Alert();
a.addCancelAction("Abbrechen");
options.forEach((o) => a.addAction(o.label));
const res = await a.presentSheet();
if (res === -1) return;
return options[res].action();
/**
* @param {ChangelogItem[]} changes
* @returns {string}
*/
function formatChanges(changes) {
const isListRegex = /^([*:.<>|+-][ \t])/gim;
return changes
.map((change) => {
const isList = change.changes.match(isListRegex);
let notes = "";
if (isList) {
let lines = change.changes.replaceAll(
isListRegex,
(m, g1) =>
`</li><li${
g1.includes("+")
? ' class="plus"'
: g1.includes("-")
? ' class="minus"'
: ""
}>`,
);
if (lines.includes("<li")) lines += "</li>";
lines = lines.replace(/^\s*<\/li>/, "");
lines = lines.replace(/\n<\/li>/g, "</li>\n");
notes = `<ul>
${lines}
</ul>`;
} else {
notes = `<p><pre>${change.changes.replace(
/[<>&"]/g,
(m) => htmlEscape[m],
)}</pre></p>`;
}
return ` <h2>${change.version}</h2>
<h3>${changelogDateFormatter.string(change.date)},
${changelogRelativeFormatter.string(change.date, new Date())}</h3>
${notes}`;
})
.join("\n\n");
}
}
/**
* @param {UpdateCheckResult} changes
*/
async function updateScript(changes) {
if (!changes.updateAvailable) return;
const lang = {
updatedFiles: "Geänderte Dateien",
filesBeingImported:
"Diese Dateien werden im Scriptable-Ordner abgespeichert. Wähle die Dateien ab, die du nicht speichern möchtest.",
willOverwrite: "Überschreibt eine Datei",
update: "Aktualisieren",
cancel: "Abbrechen",
saved1File: "1 Datei wurde gespeichert",
savedNFiles: "$n Dateien wurden gespeichert",
};
/** @type {FileManager} */
let fm;
try {
fm = FileManager.iCloud();
} catch (err) {
fm = FileManager.local();
}
const files = changes.files;
// Check for files that are already there, but only with different content
const overridden = files.filter((f) => {
f.path = fm.joinPath(fm.documentsDirectory(), f.filename);
return fm.fileExists(f.path) && fm.readString(f.path) !== f.content;
});
const filesToSave = new Set(files);
const ui = new UITable();
refreshUI();
await ui.present();
function refreshUI() {
let row, cell;
ui.removeAllRows();
row = addRow();
row.isHeader = true;
cell = row.addText(lang.updatedFiles);
cell.centerAligned();
row = addRow();
row.height *= 2;
cell = row.addText(lang.filesBeingImported);
cell.centerAligned();
row = addRow();
for (const file of files) {
row = addRow();
row.dismissOnSelect = false;
row.onSelect = () => {
if (!filesToSave.delete(file)) filesToSave.add(file);
refreshUI();
};
cell = row.addText(
file.filename,
overridden.includes(file) ? lang.willOverwrite : "",
);
cell.widthWeight = 90;
cell = row.addText(filesToSave.has(file) ? "✅" : "");
cell.rightAligned();
cell.widthWeight = 10;
}
row = addRow();
row = addRow();
row.dismissOnSelect = true;
row.onSelect = saveFiles;
row.backgroundColor = new Color("#00ac00");
cell = row.addText(lang.update);
cell.centerAligned();
row = addRow();
row.dismissOnSelect = true;
// no-op
row.onSelect = () => {};
cell = row.addText(lang.cancel);
cell.centerAligned();
ui.reload();
}
function addRow() {
const row = new UITableRow();
ui.addRow(row);
return row;
}
/**
* @param {Set} filesToSave
*/
async function saveFiles(filesToSave) {
// save them to disk
filesToSave.forEach((file) => {
fm.writeString(file.path, file.content);
});
const cache = `${cacheFolder}/update.json`;
if (fm.fileExists(cache)) fm.remove(cache);
// notify the user
const n = new Notification();
n.title = Script.name();
n.body =
filesToSave.size === 1
? lang.saved1File
: lang.savedNFiles.replace("$n", filesToSave.size);
n.sound = null;
n.schedule();
// This is just to add some delay, before we remove the notification again
await new Promise((resolve) => {
Timer.schedule(500, false, resolve);
});
Notification.removeDelivered([n.identifier]);
}
}
/**
* get images from iCloud or download them once
* @param {string} image
* @returns {Image}
*/
async function getImage(image) {
let fm = FileManager.local();
let dir = fm.joinPath(fm.documentsDirectory(), "vaccination-stats-images");
fm.createDirectory(dir, true);
let path = fm.joinPath(dir, image);
if (fm.fileExists(path)) {
return fm.readImage(path);
} else {
// download once
let imageUrl;
switch (image) {
case "vac-logo.png":
imageUrl = "https://i.imgur.com/ZsBNT8E.png";
break;
case "calendar-icon.png":
imageUrl = "https://i.imgur.com/Qp8CEFf.png";
break;
default:
console.log(`Sorry, couldn't find ${image}.`);
}
let req = new Request(imageUrl);
let loadedImage = await req.loadImage();
fm.writeImage(path, loadedImage);
return loadedImage;
}
}
async function getNumbers() {
// Set up the file manager.
const fm = FileManager.local();
{
// delete old cache file if it exists
const oldCache = fm.joinPath(
fm.cacheDirectory(),
"vaccination-stats-data.csv",
);
if (fm.fileExists(oldCache)) {
fm.remove(oldCache);
}
}
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const formatter = new DateFormatter();
formatter.dateFormat = "yyyyMMdd";
/**
* @type { {
* debugName: string,
* cacheName: string,
* url?: string,
* method?: () => Promise<UpdateCheckResult>,
* type: "string" | "json",
* file: null | string | UpdateCheckResult | Promise<string | UpdateCheckResult>
* }[] }
*/
const files = [
{
debugName: "today",
cacheName: "today.csv",
url: "https://info.gesundheitsministerium.gv.at/data/COVID19_vaccination_certificates.csv",
type: "string",
file: null,
},
{
debugName: "week ago",
cacheName: "weeg-ago.csv",
url: `https://info.gesundheitsministerium.gv.at/data/archiv/COVID19_vaccination_certificates_${formatter.string(
weekAgo,
)}.csv`,
type: "string",
file: null,
},
{
debugName: "update check",
cacheName: "update.json",
method: checkForUpdate,
type: "json",
file: null,
},
];
log("checking folder at " + cacheFolder);
if (!fm.fileExists(cacheFolder)) fm.createDirectory(cacheFolder, true);
for (const file of files) {
// Set up cache
const cachePath = `${cacheFolder}/${file.cacheName}`;
const cacheExists = fm.fileExists(cachePath);
const cacheDate = cacheExists ? fm.modificationDate(cachePath) : 0;
// Get Data
try {
// If cache exists and it's been less than 60 minutes since last request, use cached data.
if (
cacheExists
&& today.getTime() - cacheDate.getTime() < cacheMinutes * 60 * 1000
) {
console.log(`Get data for ${file.debugName} from Cache`);
file.file = fm.readString(cachePath);
if (file.type === "json") {
file.file = JSON.parse(file.file, (key, value) => {
if (key === "date") return new Date(value);
return value;
});
}
} else {
console.log(`Get data for ${file.debugName} from API`);
if (file.url) {
const req = new Request(file.url);
file.file = req.loadString().then((content) => {
content = content.replace(/\r\n|\r/g, "\n");
console.log(`Write data for ${file.debugName} to Cache`);
try {
fm.writeString(cachePath, content);
} catch (e) {
console.error(
`Creating cache for data for ${file.debugName} failed!`,
);
console.error(e);
}
return content;
});
} else if (file.method) {
file.file = file.method().then((content) => {
if (!content) {
console.log(`Got no data for ${file.debugName}`);
return;
}
console.log(`Write data for ${file.debugName} to Cache`);
try {
fm.writeString(cachePath, JSON.stringify(content));
} catch (e) {
console.error(
`Creating cache for data for ${file.debugName} failed!`,
);
console.error(e);
}
return content;
});
}
}
} catch (e) {
console.error(`Error loading data for ${file.debugName}`);
console.error(e);
if (cacheExists) {
console.log(`Get data for ${file.debugName} from Cache`);
file.file = fm.readString(cachePath);
if (file.type === "json") {
file.file = JSON.parse(file.file, (key, value) => {
if (key === "date") return new Date(value);
return value;
});
}
} else {
console.log(
`No fallback to cache possible due to missing cache for data for ${file.debugName}.`,
);
}
}
}
await Promise.all(
files.map(async (file) => {
const content = await file.file;
file.file = content;
}),
);
return {
data: parseCSV(
...files.filter((file) => file.type === "string").map((file) => file.file),
),
/** @type {UpdateCheckResult} */
update: files.find((file) => file.type === "json")?.file || {
updateAvailable: false,
},
};
}
/**
* @param {string} todayCSV
* @param {string} weekAgoCSV */
function parseCSV(todayCSV, weekAgoCSV) {
const parse = (file) => {
const data = [];
let names = [];
for (const line of file.split("\n")) {
const tokens = line.split(";");
if (names.length === 0) {
names = tokens;
} else {
data.push(
Object.fromEntries(tokens.map((token, i) => [names[i], token])),
);
}
}
return data;
};
/**
* @type { {data: Record<keyof CSVItem, string>[], states: CSVItem[]}[] }
*/
const data = [
{
data: parse(todayCSV),
states: [],
},
{
data: parse(weekAgoCSV),
states: [],
},
];
for (const d of data) {
for (let i = 0; i < d.data.length; i++) {
const item = d.data[i];
if (
item.state_id === "0"
|| (item.age_group !== "All" && item.gender !== "All")
)
continue;
d.states.push({
date: new Date(item.date),
state_id: parseInt(item.state_id),
state_name: item.state_name,
age_group: item.age_group,
gender: item.gender,
population: parseInt(item.population),
valid_certificates: parseInt(item.valid_certificates),
valid_certificates_percent: parseFloat(item.valid_certificates_percent),
});
}
}
const latest = data[0].states[0].date;
const latestWeekAgo = data[1].states[0].date;
return {
nCitizens: latest.population,
states: data[0].states.map((state, index) => {
return {
today: state,
weekAgo: data[1].states[index],
};
}),
daysBetweenLatest7DaysAgo: Math.round(
(latest.getTime() - latestWeekAgo.getTime()) / 86400000, // 1000 * 3600 * 24 = 86400000
),
date: latest,
};
}
/**
* @param {(CovidData["states"][0] | undefined)[]} states
* @returns the rendered progress bar together with its minimum and maximum values
*/
function createProgress(states) {
const context = new DrawContext();
context.size = new Size(progressBarWidth, progressBarHeight);
context.opaque = false;
context.respectScreenScale = true;
context.setFillColor(new Color("#d2d2d7"));
const path = new Path();
path.addRoundedRect(
new Rect(0, 0, progressBarWidth, progressBarHeight),
3,
2,
);
context.addPath(path);
context.fillPath();
const minPercent = states.reduce(
(prev, cur) => Math.min(prev, cur.today.valid_certificates_percent),
100,
);
// calc lower boundary, but clamp it to 80 and below
let min = Math.floor(clamp(minPercent, 0, 80) / 10) * 10;
const maxPercent = states.reduce(
(prev, cur) => Math.max(prev, cur.today.valid_certificates_percent),
0,
);
// calc upper boundary, but clamp it to 80 and above
let max = Math.ceil(clamp(maxPercent, 80, 100) / 10) * 10;
// expand the boundaries if they're too close to the values
const threshold = 0.1;
if (min > 0 && (minPercent - min) / (max - min) < threshold) {
min -= 10;
}
if (max < 100 && (maxPercent - min) / (max - min) > 1 - threshold) {
max += 10;
}
const statesWithColor = states
.map((s, i) => {
return s
? {
...s,
color: colors[i % colors.length],
}
: undefined;
})
.filter((s) => s);
// sort descending => smallest gets drawn latest
statesWithColor.sort(
(a, b) =>
b.today.valid_certificates_percent - a.today.valid_certificates_percent,
);
for (let i = 0; i < statesWithColor.length; i++) {
const state = statesWithColor[i];
context.setFillColor(state.color);
const path2 = new Path();
const path2width = Math.max(
Math.min(
((state.today.valid_certificates_percent - min) / (max - min))
* progressBarWidth,
progressBarWidth,
),
2,
);
path2.addRoundedRect(new Rect(0, 0, path2width, progressBarHeight), 3, 2);
context.addPath(path2);
context.fillPath();
}
return {
image: context.getImage(),
min,
max,
};
}
/**
* @param {CovidData["states"][0]} data
* @param {number} daysBetween number of days between data from a week ago and latest data
*/
function calculateDailyVac(data, daysBetween) {
const dailyVacAmount = Math.round(
(data.today.valid_certificates - data.weekAgo.valid_certificates)
/ daysBetween,
);
return dailyVacAmount;
}
/**
* @param {CovidData["states"][0]} data
* @param {number} daysBetween number of days between data from a week ago and latest data
*/
function calculateRemainingDays(data, daysBetween) {
const daysRemaining = Math.round(
(data.today.population * herdImmunityFactor
- data.today.valid_certificates)
/ calculateDailyVac(data, daysBetween),
);
return daysRemaining;
}
/**
* @param {Color} color
* @param {Color} otherColor
* @param {number} amount
* @param {number | undefined} [alpha]
* @returns {Color}
*/
function adjustColor(color, otherColor, amount, alpha = undefined) {
let red = color.red * (1 - amount) + otherColor.red * amount;
let green = color.green * (1 - amount) + otherColor.green * amount;
let blue = color.blue * (1 - amount) + otherColor.blue * amount;
// let a = color.alpha * (1 - amount) + otherColor.alpha * amount;
if (red < 0) red = 0;
else if (red > 1) red = 1;
if (green < 0) green = 0;
else if (green > 1) green = 1;
if (blue < 0) blue = 0;
else if (blue > 1) blue = 1;
// if (a < 0) a = 0;
// else if (a > 1) a = 1;
red = Math.round(red * 255);
green = Math.round(green * 255);
blue = Math.round(blue * 255);
const hex =
red.toString(16).padStart(2, "0")
+ green.toString(16).padStart(2, "0")
+ blue.toString(16).padStart(2, "0");
return typeof alpha !== "undefined" ? new Color(hex, alpha) : new Color(hex);
}
/**
* Rounds `value` to a precision of `decimals`
* @param {number} value
* @param {number} decimals
* @returns {number}
*/
function round(value, decimals) {
return parseFloat(Math.round(value * 10 ** decimals) * 10 ** -decimals);
}
/**
* Clamp `value` so that `min <= value <= max`
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
/**
* @typedef { {version: string, date: Date, changes: string} } ChangelogItem
*/
/**
* @typedef { {updateAvailable: false}
* | {
* updateAvailable: true,
* curVersion: string,
* newVersion: string,
* changes: ChangelogItem[],
* fullChangelog: ChangelogItem[],
* files: { filename: string, content: string, path?: string }[]
* } } UpdateCheckResult
*/
/**
* @returns {Promise<UpdateCheckResult>}
*/
async function checkForUpdate() {
const regex = {
comments: /\/\*((?:(?!\*\/)[\s\S])+)\*\//g,
version: /^[ \t*+.:<>|-]*version\s*:?\s*(\d+(?:\.\d+)+)\s*$/im,
sourceUrl: /^[ *+.:<>|-]*source\s*:?\s*(https?:\/\/.+)\s*$/im,
gistUrl: /^https:\/\/gist.github.com\/[^/]+\/([0-9a-f]+)$/i,
changelog: /^[ \t*+.:<>|-]*changelog\s*:?\s*$/im,
changelogVersion:
/^(?<indent>[ \t*+.:<>|-]*)v(?<version>\d+(?:\.\d+)+)\s*-\s*(?<date>\d{4}-\d\d-\d\d)\s*$/gim,
escape: /[\\[\]+*?.|(){}^$]/g,
endsWhitespace: /[ \t]+$/,
};
let fm;
try {
fm = FileManager.iCloud();
} catch (err) {
fm = FileManager.local();
}
const self = fm.readString(module.filename);
const selfComments = self.matchAll(regex.comments);
let currentVersion = undefined;
let sourceUrl = undefined;
for (const comment of selfComments) {
const commentContent = comment[1];
if (!currentVersion) {
const v = commentContent.match(regex.version);
if (v) currentVersion = v[1];
}
if (!sourceUrl) {
const u = commentContent.match(regex.sourceUrl);
if (u) sourceUrl = u[1];
}
if (currentVersion && sourceUrl) break;
}
if (!currentVersion) {
console.error(
'[checkForUpdate] Could not find the current version. Make sure that it is written like "version: 1.0.1" in a multiline comment on its own line. Not checking for an update!',
);
return;
}
if (!sourceUrl) {
console.error(
'[checkForUpdate] Could not find a source url. Make sure that it is written like "source: https://myhost.com/my-script.js" in a multiline comment on its own line. It can also be a GitHub Gist URL. Not checking for an update!',
);
return;
}
const gist = sourceUrl.match(regex.gistUrl);
/** @type { {filename: string, content: string}[] } */
const files = [];
// files.push({
// filename: Script.name() + ".js",
// content: self.replace("Version: 1.3", "Version: 2.0"),
// });
if (gist) {
files.push(...(await downloadGist(gist[1])));
} else {
const req = new Request(sourceUrl);
const res = await req.loadString();
if ((req.response.statusCode < 200) | (req.response.statusCode >= 300)) {
console.error(`[checkForUpdate] There was an error while downloading the script source code. Status: ${
req.response.statusCode
}
${JSON.stringify(req.response, null, 2)}
Response: ${res}`);
} else {
files.push({
filename: sourceUrl.split("/").pop(),
content: res,
});
}
}
if (files.length === 0) {
console.log("[checkForUpdate] No files found");
return;
}
let remoteVersion = undefined;
/** @type { ChangelogItem[] } */
let remoteChangelog = undefined;
fileLoop: for (const file of files) {
const comments = Array.from(file.content.matchAll(regex.comments));
if (!comments || comments.length === 0) continue;
for (const comment of comments) {
const commentContent = comment[1];
if (!remoteVersion) {
const v = commentContent.match(regex.version);
if (v) {
remoteVersion = v[1];
const curParts = currentVersion.split(".").map((i) => parseInt(i));
const remoteParts = remoteVersion.split(".").map((i) => parseInt(i));
const length = Math.max(curParts.length, remoteParts.length);
for (let i = curParts.length; i < length; i++) {
curParts.push(0);
}
for (let i = remoteParts.length; i < length; i++) {
remoteParts.push(0);
}
let isNewer = false;
for (let i = 0; i < length; i++) {
if (remoteParts[i] > curParts[i]) {
isNewer = true;
break;
}
}
if (!isNewer) {
return { updateAvailable: false };
}
}
}
if (regex.changelog.test(commentContent)) {
const headings = Array.from(
commentContent.matchAll(regex.changelogVersion),
);
remoteChangelog = [];
for (let i = 0; i < headings.length; i++) {
const cur = headings[i];
const next =
i + 1 < headings.length
? headings[i + 1].index
: commentContent.length;
const start = cur.groups.indent;
const removeIndentRegex = new RegExp(
`^${start.replace(regex.escape, "\\$&")}${
regex.endsWhitespace.test(start) ? "?" : ""
}`,
"gm",
);
remoteChangelog.push({
version: cur.groups.version,
date: new Date(cur.groups.date),
changes: commentContent
.substring(cur.index + cur[0].length, next)
.replace(removeIndentRegex, "")
.trim()
.split("\n")
.map((s) => s.trim())
.join("\n"),
});
}
}
if (remoteVersion && remoteChangelog.length) break fileLoop;
}
}
if (!remoteVersion) return { updateAvailable: false };
return {
updateAvailable: true,
curVersion: currentVersion,
newVersion: remoteVersion,
changes: remoteChangelog.slice(
0,
remoteChangelog.findIndex((change) => change.version === currentVersion),
),
fullChangelog: remoteChangelog,
files,
};
}
/** @param {string} gistID */
async function downloadGist(gistID) {
// Get the data in the Gist
let req = new Request(`https://api.github.com/gists/${gistID}`);
req.headers = {
accept: "application/vnd.github.v3+json",
};
let res = await req.loadJSON();
if (req.response.statusCode === 403) {
console.error(
"[checkForUpdate] The source URL points to a private GitHub Gist. Cannot download from there.",
);
return [];
}
if (req.response.statusCode === 404) {
console.error(
"[checkForUpdate] The GitHub Gist was not found that is referenced by the source URL.",
);
return [];
}
let files = Object.values(res.files);
// await QuickLook.present(files);
if (files.length > 5) {
// more than 5 files? ask the user
let a = new Alert();
a.title = res.truncated ? "Zu viele Dateien" : `${files.length} Dateien`;
a.message = `Es werden ${
res.truncated ? "mehr als 300" : files.length
} Dateien heruntergeladen. ${
res.truncated ? "Nur die ersten 300 Dateien können geladen werden." : ""
}
Möchtest du fortfahren?`;
a.addCancelAction("Abbrechen");
a.addAction("Weiter");
if ((await a.presentAlert()) === -1) return [];
}
// Get the content of each file
let contents = await Promise.all(
files.map((file) => {
// if it is truncated, request its contents
if (file.truncated) {
return new Request(file.raw_url).loadString();
}
// otherwise just return them
return Promise.resolve(file.content);
}),
);
// update the content in the files array
contents.forEach((c, i) => (files[i].content = c));
return files.map((f) => {
return {
filename: f.filename,
content: f.content,
};
});
}
/***************************
* Bitte bis hier kopieren *
***************************/
@schl3ck
Copy link
Author

schl3ck commented Dec 2, 2021

Ich habe das Skript aktualisiert und einen Updater sowie einen Widget-Konfigurator hinzugefügt. Mit dem Konfigurator kann man auswählen, welche Bundesländer angezeigt werden sollen. Ebenso kann man festlegen, ob der Fortschrittsbalken, die Herdenimmunität und der 7-Tages-Durchschnitt angezeigt werden sollen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment