-
-
Save schl3ck/570422d5f6ce4595c05fca951da067e5 to your computer and use it in GitHub Desktop.
// 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 = { | |
"<": "<", | |
">": ">", | |
"&": "&", | |
'"': """, | |
}; | |
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 * | |
***************************/ |
@martinellli Vielen Dank!
Hab das Skript aktualisiert. Die Zahlen werden mit der aktuellen Version nun mit dem systemweit eingestellten Format formatiert.
Jetzt ist es noch besser; danke für die schnelle Umsetzung! 👍
Skript an sich super! Allerdings werden meine Zahlen vorne abgeschnitten. Datenstand 5.7.2021 steht unter Teil "4.894.468" und Voll "3.389.946". Muss ich eine bestimmte Widget größe verwenden?
Die richtige Widgetgröße ist die kleinste verfügbare. Also 2x2 Apps groß.
Die Zahlen stimmen schon. Sind für Österreich und wir sind noch unter 10 Mio Einwohner, falls ich nichts verpasst hab. Außerdem sollte sie wenn dann hinten abgeschnitten werden und bevor das passiert, wird sie kleiner geschrieben, dass sich alles ausgeht.
Welche Zahlen hast du denn erwartet?
Ohje, das ist mir jetzt ja ultra peinlich. Ich bin davon ausgegangen dass das Widget für Deutschland ist, bin durch einen Google Link drauf gestoßen und hab nicht weiter drauf geachtet. Ich dachte irgendwie dass 4.894.468 und und 3.389.946 eigentlich 44.894.468 und 33.389.946 sind 😂
Kein Problem!
Ganz oben steht Forked from... Dieses ist für Deutschland.
Script ist top - habe allerdings ein Phänomen: funktioniert nur in der App (config.runsInApp) aber als Widget auf dem Homescreen tut sich gar nichts...
Script ist top - habe allerdings ein Phänomen: funktioniert nur in der App (config.runsInApp) aber als Widget auf dem Homescreen tut sich gar nichts...
Habe nur ich dieses Problem??
Nein, hast du nicht. Auch bei mir funktioniert es am Homescreen nicht, aber in der App schon. Ich hatte aber bis jetzt leider keine Zeit, mir das anzuschauen, hoffentlich in den nächsten Tagen. Ich melde mich, wenn ich die Ursache gefunden hab!
Es gibt auch ein neues Datenformat. Ich werde übers Wochenende versuchen, auf das neue Format umzusteigen.
Nein, hast du nicht. Auch bei mir funktioniert es am Homescreen nicht, aber in der App schon. Ich hatte aber bis jetzt leider keine Zeit, mir das anzuschauen, hoffentlich in den nächsten Tagen. Ich melde mich, wenn ich die Ursache gefunden hab!
Es gibt auch ein neues Datenformat. Ich werde übers Wochenede versuchen, auf das neue Format umzusteigen.
OK - dachte schon, ich bin ein Einzel-Problem :-)
Super, danke schon mal im Voraus für's Ansehen - vielleicht finde ich auch was.....
OK - dachte schon, ich bin ein Einzel-Problem :-) Super, danke schon mal im Voraus für's Ansehen - vielleicht finde ich auch was.....
Ich vermute stark, dass die Datenquelle zu groß ist fürs Widget. Sind immerhin 1,23 MB. Mit den neuen sollte es dann aber wieder funktionieren.
OK - dachte schon, ich bin ein Einzel-Problem :-) Super, danke schon mal im Voraus für's Ansehen - vielleicht finde ich auch was.....
Ich vermute stark, dass die Datenquelle zu groß ist fürs Widget. Sind immerhin 1,23 MB. Mit den neuen sollte es dann aber wieder funktionieren.
@schl3ck - Du hast richtig vermutet, die Datenquelle ist/war zu groß....
Ich habe es einmal quick&dirty gelöst, indem ich nur die letzten 200 Zeilen des CSV-Files verwende:
function parseCSV(csv) {
const data = [];
let names = [];
const lines = csv.split("\n");
for (const line of csv.split("\n")) {
const tokens = line.split(";");
if (names.length === 0) {
names = tokens;
} else if (lines.indexOf(line) > (lines.length - 200)){
data.push(
Object.fromEntries(tokens.map((token, i) => [names[i], token])),
);
}
}
@GitAxMan Ich habe das Skript schon aktualisiert, sodass es die neue Datenquelle verwendet, die die aktuell gültigen Impfzertifikate liefert. Die alte Datenquelle wird im Dezember eingestellt und dadurch, dass bald Impfungen "auslaufen", dachte ich mir, dass die neue Quelle die aktuelle Situation besser reflektiert.
Ein weiteres Update ist bereits in Arbeit, womit dann auch die Zahlen für die Bundesländer dargestellt bzw. die am Widget angezeigten Elemente konfiguriert werden können.
@GitAxMan Ich habe das Skript schon aktualisiert, sodass es die neue Datenquelle verwendet, die die aktuell gültigen Impfzertifikate liefert. Die alte Datenquelle wird im Dezember eingestellt und dadurch, dass bald Impfungen "auslaufen", dachte ich mir, dass die neue Quelle die aktuelle Situation besser reflektiert.
Ein weiteres Update ist bereits in Arbeit, womit dann auch die Zahlen für die Bundesländer dargestellt bzw. die am Widget angezeigten Elemente konfiguriert werden können.
@schl3ck Super - danke!
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.
Sehr gutes Script, ist täglich im Einsatz...😉
Schön wäre es, wenn die doch sehr großen Zahlen Tausenderpunkte hätten. Es würde die Lesbarkeit steigern.
Gruß
tm