├── .gitattributes
├── LICENSE
├── README-FR.md
├── README.md
├── assets
├── config.png
└── screenshot.jpg
└── script.js
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.png filter=lfs diff=lfs merge=lfs -text
2 | *.jpg filter=lfs diff=lfs merge=lfs -text
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Johann Pardanaud
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README-FR.md:
--------------------------------------------------------------------------------
1 | # Rain Forecast Widget
2 |
3 | Un widget iOS pour afficher avec précision les prévisions de pluie dans l'heure à venir. Basé sur les données de [Météo France](https://meteofrance.com/). Ne fonctionne qu'en France.
4 |
5 | # Fonctionnalités
6 |
7 | - ☔️ Affichage avec précision des prévisions de pluie pour l'heure à venir
8 | - 📍 Peut exploiter votre position actuelle
9 | - 🏙 Permet de configurer une ville spécifique à afficher
10 | - 📏 Supporte les trois tailles de widget
11 | - 🌓 Affichage adapté au dark mode
12 | - 🏴 Disponible en plusieurs langues
13 |
14 |
15 |
16 |
17 |
18 |
19 | # Installation
20 |
21 | - Téléchargez l'app [Scriptable](https://scriptable.app/).
22 | - Créez un nouveau script dans l'app et collez le contenu du fichier [script.js](./script.js).
23 | - Ajoutez un nouveau widget sur l'accueil de votre iPhone et sélectionnez Scriptable.
24 | - Modifiez les paramètres du widget et choisissez le nom du script que vous avez créé.
25 | - Les prévisions de pluie pour votre position actuelle devrait alors s'afficher! 🌈
26 |
27 | # Configuration
28 |
29 | Vous pouvez choisir d'afficher les prévisions de pluie pour une ville spécifique en modifiant les paramètres du widget et tapant le nom de la ville souhaitée dans le champ `Parameter`:
30 |
31 |
32 |
33 |
34 |
35 | Si vous obtenez une erreur suite au renseignement de la ville, vérifiez [dans la recherche de Météo France](https://meteofrance.com/) (tout en haut) si la ville existe bien.
36 |
37 | # Remerciements
38 |
39 | - [Simon B. Støvring](https://twitter.com/simonbs) pour avoir créé Scriptable
40 | - [Sunrise-Sunset](https://sunrise-sunset.org/api) pour leur API gratuite
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Rain Forecast Widget
2 |
3 | [🇫🇷 Documentation en Français 🇫🇷](./README-FR.md)
4 |
5 | An iOS widget displaying precise rain forecast for the next hour. Relies on data provided by [Météo France](https://meteofrance.com/). Works only in France.
6 |
7 | # Features
8 |
9 | - ☔️ Display of precise rain forecast for the next hour
10 | - 📍 Can use your current location
11 | - 🏙 Allows to configure a specific town to display
12 | - 📏 Handles the three widget sizes
13 | - 🌓 Custom UI for light and dark modes
14 | - 🏴 Available in multiple languages
15 |
16 |
17 |
18 |
19 |
20 |
21 | # Installation
22 |
23 | - Download the [Scriptable](https://scriptable.app/) app.
24 | - Create a new script inside the app and past the contents of the [script.js](./script.js) file.
25 | - Add a new widget on your iPhone homescreen and select Scriptable.
26 | - Edit the widget parameters and select the name of the script you have created.
27 | - The rain forecast for your current location should displayed on your screen! 🌈
28 |
29 | # Configuration
30 |
31 | You can choose to display the rain forecast for a specific town by editing the widget parameters and by setting the name of the town you want in the `Parameter` field:
32 |
33 |
34 |
35 |
36 |
37 | If you get an error right after setting the town, check [in the search bar of Météo France](https://meteofrance.com/) (at the top) if the town exists.
38 |
39 | # Thanks to
40 |
41 | - [Simon B. Støvring](https://twitter.com/simonbs) for creating Scriptable
42 | - [Sunrise-Sunset](https://sunrise-sunset.org/api) for their free API
43 |
--------------------------------------------------------------------------------
/assets/config.png:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:595879331535e74e6ee97d6ad953793a6b534d9bbcfdd19a30537dbb631416f9
3 | size 315615
4 |
--------------------------------------------------------------------------------
/assets/screenshot.jpg:
--------------------------------------------------------------------------------
1 | version https://git-lfs.github.com/spec/v1
2 | oid sha256:254069674a4740102fa348ede17456736f102dd87ba2a39386575fc228871e46
3 | size 840733
4 |
--------------------------------------------------------------------------------
/script.js:
--------------------------------------------------------------------------------
1 | /**
2 | * French rain forecast inside a Scriptable widget.
3 | *
4 | * Created by Johann Pardanaud (https://johann.pardanaud.com/).
5 | *
6 | * Homepage: https://github.com/nesk/rain-forecast-widget
7 | */
8 |
9 | //================================================
10 | //=============== DEBUG ZONE START ===============
11 | //================================================
12 |
13 | /**
14 | * Defines the size of the widget in debug mode.
15 | *
16 | * @const {(null|"small"|"medium"|"large")}
17 | */
18 | const DEBUG_PRESENT_SIZE = null
19 |
20 | /**
21 | * Defines the town for which you want the forecast.
22 | *
23 | * @const {?string}
24 | */
25 | const DEBUG_TOWN = null
26 |
27 | //================================================
28 | //================ DEBUG ZONE END ================
29 | //================================================
30 |
31 | const lang = {
32 | translations: {
33 | 'title.full': {
34 | fr: 'Pluie à venir',
35 | en: 'Rain forecast',
36 | tr: 'Yağmur geliyor',
37 | de: 'Regenvorhersage',
38 | },
39 | 'title.short': {
40 | fr: 'Pluie',
41 | en: 'Rain',
42 | tr: 'Yağmur',
43 | de: 'Regen',
44 | },
45 | 'minutes': {
46 | fr: 'minutes',
47 | en: 'minutes',
48 | tr: 'dakika',
49 | de: 'minuten',
50 | },
51 | 'rain.none': {
52 | fr: 'Temps sec',
53 | en: 'Dry weather',
54 | tr: 'Yağmursuz',
55 | de: 'Trockenes Wetter',
56 | },
57 | 'rain.light': {
58 | fr: 'Pluie faible',
59 | en: 'Light rain',
60 | tr: 'Hafif yağmurlu',
61 | de: 'Leichter Regen',
62 | },
63 | 'rain.moderate': {
64 | fr: 'Pluie modérée',
65 | en: 'Moderate rain',
66 | tr: 'Orta şiddetli yağmur',
67 | de: 'Mittlerer Regen',
68 | },
69 | 'rain.heavy': {
70 | fr: 'Pluie forte',
71 | en: 'Heavy rain',
72 | tr: 'Yoğun yağış',
73 | de: 'Heftiger Regen',
74 | },
75 | 'rain.unknown': {
76 | fr: 'Aucune information disponible',
77 | en: 'No information available',
78 | tr: 'Bilgi bulunmamaktadır',
79 | de: 'Keine Informationen verfügbar',
80 | },
81 | 'error': {
82 | fr: 'Une erreur est survenue',
83 | en: 'An error occurred',
84 | tr: 'Bir hata oluştu',
85 | de: 'Es ist ein Fehler aufgetreten',
86 | },
87 | },
88 |
89 | translate(name) {
90 | const translation = this.translations[name] || {}
91 | const availableLanguages = Object.keys(translation)
92 | const preferredLanguages = Device.preferredLanguages().map(lang => lang.split('-')[0])
93 |
94 | for (const lang of preferredLanguages) {
95 | if (availableLanguages.includes(lang)) {
96 | return translation[lang]
97 | }
98 | }
99 |
100 | return translation.en || '%UNKNOWN_TRANSLATION%'
101 | },
102 | }
103 |
104 | const title = getWidgetSize() !== 'small' ? lang.translate('title.full') : lang.translate('title.short')
105 | const backgroundColors = [
106 | Color.dynamic(new Color('#3182CE'), new Color('#2A4365')),
107 | Color.dynamic(new Color('#4299E1'), new Color('#2C5282')),
108 | ]
109 |
110 | try {
111 | const sessionValue = await getSessionValue()
112 | const jwt = convertSessionValueToJwt(sessionValue)
113 |
114 | let town = args.widgetParameter || DEBUG_TOWN
115 | ? await getTownInfos(jwt, args.widgetParameter || DEBUG_TOWN)
116 | : null
117 |
118 | let isCurrentLocation = false
119 | let location = town
120 | if (location === null) {
121 | location = await getCurrentLocation()
122 | isCurrentLocation = true
123 | }
124 |
125 | const { update_time: updatedAt, properties: townWithForecast } = await getForecastInfos(jwt, location)
126 | town = Object.assign(town || {}, townWithForecast)
127 |
128 | if (town.url === undefined) {
129 | town.url = await getTownInfos(town.name).url
130 | }
131 |
132 | const { sunrise, sunset } = (await getSunHours(location)) || {}
133 | const forecast = town.forecast.map(dataPoint => forecastDataPointToSFSymbol(dataPoint, sunrise, sunset))
134 |
135 | applyWidget(createWidget(
136 | title,
137 | town.name,
138 | isCurrentLocation,
139 | forecast,
140 | backgroundColors,
141 | town.url,
142 | new Date(updatedAt)
143 | ))
144 | } catch (error) {
145 | console.error(error)
146 | applyWidget(createWidget(title, lang.translate('error'), false, [], backgroundColors))
147 | }
148 |
149 | function cache() {
150 | const disk = FileManager.local()
151 | return {
152 | disk,
153 | directory: disk.joinPath(disk.libraryDirectory(), "hourly-weather"),
154 | _setupDirectory() {
155 | if (!this.disk.isDirectory(this.directory)) {
156 | this.disk.createDirectory(this.directory)
157 | }
158 | },
159 | _getPath(key) {
160 | return `${this.directory}/${key}.json`
161 | },
162 | has(key) {
163 | return this.disk.fileExists(this._getPath(key))
164 | },
165 | get(key) {
166 | try {
167 | return JSON.parse(this.disk.readString(this._getPath(key)))
168 | } catch (err) {
169 | return null
170 | }
171 | },
172 | set(key, value) {
173 | this._setupDirectory()
174 | this.disk.writeString(this._getPath(key), JSON.stringify(value))
175 | },
176 | }
177 | }
178 |
179 | function assertRequestIsSuccessful(request) {
180 | const { statusCode } = request.response
181 | if (statusCode !== 200) {
182 | throw `Unexpected status code ${statusCode}`
183 | }
184 | }
185 |
186 | async function getSessionValue() {
187 | console.log('Requesting the session value...')
188 | const request = new Request("https://meteofrance.com")
189 | await request.load()
190 | assertRequestIsSuccessful(request)
191 |
192 | const cookie = request.response.cookies.find(({ name }) => name === 'mfsession')
193 | const sessionValue = cookie !== null ? cookie.value : null
194 |
195 | console.log(`Session value retrieved: ${sessionValue}`)
196 | return sessionValue
197 | }
198 |
199 | function convertSessionValueToJwt(sessionValue) {
200 | // Extracted from a script from Meteo France
201 | // See: https://meteofrance.com/sites/meteofrance.com/files/js/js_kzUxSvsVy7NKe1DoZyZTig_IUoshY06Pf7-ist1vhQc.js
202 | const jwt = sessionValue.replace(/[a-zA-Z]/g, function(e) {
203 | const t = e <= "Z" ? 65 : 97
204 | return String.fromCharCode(t + (e.charCodeAt(0) - t + 13) % 26)
205 | })
206 |
207 | console.log(`Session value converted to JWT: ${jwt}`)
208 | return jwt
209 | }
210 |
211 | async function getCurrentLocation() {
212 | console.log('Requesting current location...')
213 | let latitude, longitude = null
214 | try {
215 | ({ latitude, longitude } = await Location.current())
216 | cache().set('location', { latitude, longitude })
217 | } catch (err) {
218 | console.log(err)
219 | console.log('Unable to request the current location, trying the cache...')
220 | if (cache().has('location')) {
221 | ({ latitude, longitude } = cache().get('location'))
222 | } else {
223 | throw new Error('Unable to retrieve the current location.')
224 | }
225 | }
226 | console.log(`Location retrieved: latitude=${latitude} longitude=${longitude}`)
227 | return { latitude, longitude }
228 | }
229 |
230 | async function getTownInfos(authorizationToken, townName) {
231 | const endpoint = 'https://meteofrance.com/search/all'
232 |
233 | console.log(`Searching for town "${townName}"...`)
234 | const request = new Request(`${endpoint}?term=${encodeURIComponent(townName)}`)
235 | request.headers = { Authorization: `Bearer ${authorizationToken}` }
236 | const payload = await request.loadString()
237 | assertRequestIsSuccessful(request)
238 |
239 | const results = JSON.parse(payload)
240 | const town = results.find(({ type }) => type === 'VILLE_FRANCE')
241 | if (!town) {
242 | console.log(`Town "${town.name}" not found.`)
243 | return null
244 | }
245 |
246 | const url = `https://meteofrance.com${town.alias}`
247 | console.log(`Town "${town.name}" found: latitude=${town.lat} longitude=${town.lng} url=${url}`)
248 | return { latitude: town.lat, longitude: town.lng, url }
249 | }
250 |
251 | async function getForecastInfos(authorizationToken, { latitude, longitude }) {
252 | const endpoint = 'https://rpcache-aa.meteofrance.com/internet2018client/2.0/nowcast/rain'
253 |
254 | console.log('Requesting the latest forecast infos...')
255 | const request = new Request(`${endpoint}?lat=${latitude}&lon=${longitude}`)
256 | request.headers = { Authorization: `Bearer ${authorizationToken}` }
257 | const payload = await request.loadString()
258 | assertRequestIsSuccessful(request)
259 |
260 | const forecast = JSON.parse(payload)
261 | const town = forecast.properties
262 |
263 | // Fix naming for Paris (otherwise searching for this town will fail)
264 | if (town.french_department == 75) {
265 | const match = town.name.match(/^Paris(\d+)/)
266 | if (match !== null) {
267 | town.name = `Paris ${match[1]}e`
268 | }
269 | }
270 |
271 | const rainDebug = town.forecast.map(({ rain_intensity }) => rain_intensity)
272 | console.log(
273 | `Latest forecast infos retrieved: ` +
274 | `${town.name} (${town.french_department}) at ${forecast.update_time} = ${rainDebug}`
275 | )
276 |
277 | return forecast
278 | }
279 |
280 | async function getSunHours({ latitude, longitude }) {
281 | const endpoint = 'https://api.sunrise-sunset.org/json'
282 |
283 | console.log('Requesting the Sun hours...')
284 | const request = new Request(`${endpoint}?lat=${latitude}&lng=${longitude}&formatted=0`)
285 |
286 | try {
287 | const { results: { sunrise, sunset} } = await request.loadJSON()
288 | console.log(`Sun hours: sunrise=${sunrise} sunset=${sunset}`)
289 |
290 | return {
291 | sunrise: new Date(sunrise),
292 | sunset: new Date(sunset),
293 | }
294 | } catch (error) {
295 | console.error('Error while requesting the Sun hours.')
296 | console.error(error)
297 |
298 | return null
299 | }
300 | }
301 |
302 | function forecastDataPointToSFSymbol(dataPoint, sunrise = null, sunset = null) {
303 | const now = new Date()
304 | const isNight = sunrise && sunset ? now >= sunset || now < sunrise : false
305 |
306 | switch (dataPoint.rain_intensity) {
307 | case 1:
308 | return SFSymbol.named(!isNight ? 'cloud.sun.fill' : 'cloud.moon.fill')
309 | case 2:
310 | return SFSymbol.named(!isNight ? 'cloud.sun.rain.fill' : 'cloud.moon.rain.fill')
311 | case 3:
312 | return SFSymbol.named('cloud.rain.fill')
313 | case 4:
314 | return SFSymbol.named('cloud.heavyrain.fill')
315 | default:
316 | const sfSymbol = SFSymbol.named('questionmark.square.dashed')
317 | sfSymbol.suggestedColor = Color.white()
318 | return sfSymbol
319 | }
320 | }
321 |
322 | function getFormattedTime(date = new Date()) {
323 | const formatter = new DateFormatter()
324 | formatter.useShortTimeStyle()
325 | return formatter.string(date)
326 | }
327 |
328 | function getWidgetSize() {
329 | if (config.widgetFamily) {
330 | return config.widgetFamily
331 | }
332 |
333 | return DEBUG_PRESENT_SIZE
334 | }
335 |
336 | function createWidget(title, town, isCurrentLocation, forecast, backgroundColors, url = null, updatedAt = new Date()) {
337 | const widget = new ListWidget()
338 |
339 | const gradient = new LinearGradient()
340 | gradient.colors = backgroundColors
341 | gradient.locations = [0, 1]
342 | widget.backgroundGradient = gradient
343 |
344 | if (url) {
345 | widget.url = url
346 | }
347 |
348 | const headerStack = widget.addStack()
349 | headerStack.centerAlignContent()
350 |
351 | const titleText = headerStack.addText(title)
352 | titleText.textColor = Color.white()
353 | titleText.textOpacity = 0.9
354 | titleText.font = Font.systemFont(16)
355 |
356 | headerStack.addSpacer(null)
357 |
358 | const locationSymbol = SFSymbol.named(isCurrentLocation ? 'location.fill' : 'location.slash.fill')
359 | const locationImage = headerStack.addImage(locationSymbol.image)
360 | locationImage.tintColor = Color.white()
361 | locationImage.imageOpacity = 0.9
362 | locationImage.imageSize = new Size(12, 12)
363 |
364 | headerStack.addSpacer(8)
365 |
366 | const refreshImage = headerStack.addImage(SFSymbol.named('arrow.clockwise').image)
367 | refreshImage.tintColor = Color.white()
368 | refreshImage.imageOpacity = 0.9
369 | refreshImage.imageSize = new Size(12, 12)
370 |
371 | headerStack.addSpacer(4)
372 |
373 | const updateText = headerStack.addText(getFormattedTime(updatedAt))
374 | updateText.textColor = Color.white()
375 | updateText.textOpacity = 0.9
376 | updateText.font = Font.systemFont(12)
377 |
378 | widget.addSpacer(5)
379 |
380 | const townText = widget.addText(town)
381 | townText.textColor = Color.white()
382 | townText.font = Font.systemFont(22)
383 |
384 | widget.addSpacer(10)
385 |
386 | const timings = [' 5', '10', '15', '20', '25', '30', '40', '50', '60']
387 |
388 | if (getWidgetSize() === 'small') {
389 | forecast = forecast.slice(0, 4)
390 | }
391 |
392 | const forecastStack = widget.addStack()
393 | forecast.forEach((sfSymbol, index) => {
394 | const dataPointStack = forecastStack.addStack()
395 | dataPointStack.layoutVertically()
396 |
397 | const dataPointImage = dataPointStack.addImage(sfSymbol.image)
398 | dataPointImage.resizable = false
399 | dataPointImage.imageSize = new Size(25, 25)
400 |
401 | dataPointStack.addSpacer(5)
402 |
403 | const textStack = dataPointStack.addStack()
404 | textStack.addSpacer(5)
405 |
406 | const dataPointText = textStack.addText(timings[index])
407 | dataPointText.textColor = Color.white()
408 | dataPointText.textOpacity = 0.9
409 | dataPointText.font = Font.regularMonospacedSystemFont(getWidgetSize() !== 'small' ? 12 : 11)
410 | dataPointText.centerAlignText()
411 |
412 | textStack.addSpacer(5)
413 |
414 | if (index + 1 < forecast.length) {
415 | forecastStack.addSpacer(null)
416 | }
417 | })
418 |
419 | if (forecast.length > 0) {
420 | widget.addSpacer(5)
421 |
422 | const unitsText = widget.addText(lang.translate('minutes'))
423 | unitsText.textColor = Color.white()
424 | unitsText.textOpacity = 0.9
425 | unitsText.font = Font.regularMonospacedSystemFont(12)
426 | unitsText.centerAlignText()
427 | }
428 |
429 | if (getWidgetSize() === 'large') {
430 | widget.addSpacer(25)
431 |
432 | const descriptions = [
433 | 'rain.none',
434 | 'rain.light',
435 | 'rain.moderate',
436 | 'rain.heavy',
437 | 'rain.unknown',
438 | ]
439 |
440 | for (let i = 1 ; i <= 5 ; i++) {
441 | const legendStack = widget.addStack()
442 | legendStack.centerAlignContent()
443 |
444 | const sfSymbol = forecastDataPointToSFSymbol({ rain_intensity: i })
445 | const sfSymbolImage = legendStack.addImage(sfSymbol.image)
446 | sfSymbolImage.resizable = false
447 | sfSymbolImage.imageSize = new Size(25, 25)
448 | sfSymbolImage.tintColor = sfSymbol.suggestedColor
449 |
450 | legendStack.addSpacer(15)
451 |
452 | const descriptionText = legendStack.addText(lang.translate(descriptions[i - 1]))
453 | descriptionText.textColor = Color.white()
454 | descriptionText.textOpacity = 0.9
455 | descriptionText.font = Font.regularMonospacedSystemFont(12)
456 |
457 | widget.addSpacer(5)
458 | }
459 | }
460 |
461 | return widget
462 | }
463 |
464 | function applyWidget(widget) {
465 | Script.setWidget(widget)
466 |
467 | switch (DEBUG_PRESENT_SIZE) {
468 | case 'small':
469 | widget.presentSmall()
470 | break
471 | case 'medium':
472 | widget.presentMedium()
473 | break
474 | case 'large':
475 | widget.presentLarge()
476 | break
477 | }
478 | }
479 |
--------------------------------------------------------------------------------