particle-calendar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +305 -0
- data/assets/favicon.svg +14 -0
- data/config/availability.example.yml +29 -0
- data/exe/particle +6 -0
- data/lib/availability/application.rb +106 -0
- data/lib/availability/assets.rb +26 -0
- data/lib/availability/atomic_writer.rb +62 -0
- data/lib/availability/availability_calculator.rb +85 -0
- data/lib/availability/busy_period.rb +41 -0
- data/lib/availability/calendar_fetcher.rb +100 -0
- data/lib/availability/calendar_parser.rb +198 -0
- data/lib/availability/calendar_url.rb +12 -0
- data/lib/availability/cli.rb +184 -0
- data/lib/availability/config.rb +195 -0
- data/lib/availability/config_value_parser.rb +48 -0
- data/lib/availability/errors.rb +10 -0
- data/lib/availability/event_timezone_validator.rb +55 -0
- data/lib/availability/recurrence_expander.rb +66 -0
- data/lib/availability/renderer.rb +102 -0
- data/lib/availability/setup.rb +79 -0
- data/lib/availability/tolerant_icalendar_parser.rb +64 -0
- data/lib/availability/version.rb +5 -0
- data/lib/availability.rb +20 -0
- data/templates/index.html.erb +453 -0
- data/templates/nginx.conf.erb +34 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b9ce49a6213f463b1903aada3e6bdf5bcc28a0563617934738603e14e8d83e2c
|
|
4
|
+
data.tar.gz: f27791d9393c905ca3e29d46709866100de32a3033fc9aef326f2e48f6c0dda9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4b1abcf266c1caf0d698a9817506d81d10eb44f23bd7c640e2cf763e9e8e597b8ebfbd3398b70db5411174d1dc5a647ae8c4022189e213ee96bb83f228155c98
|
|
7
|
+
data.tar.gz: 006ab70996c606074c88668426be2ad0d185f2dc4c63d1cf389cfd758d4831a76cdfd922b11a0071b8c5a5a7e7e6a2081409e5a26cb148e0e186f6f0c7177e3e
|
data/README.md
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
<h1>
|
|
2
|
+
<img src="assets/favicon.svg" alt="" width="48" height="48" align="center">
|
|
3
|
+
Particle.
|
|
4
|
+
</h1>
|
|
5
|
+
|
|
6
|
+
Particle, from Party Cal, is a Ruby command-line static-site generator. It downloads one or more private iCalendar subscriptions, treats every valid event in every feed as busy, merges the busy periods, subtracts them from configured availability hours, and writes a mobile-friendly calendar view containing only free time.
|
|
7
|
+
|
|
8
|
+
There is no application server, database, browser API, booking flow, or Ruby process at request time. Nginx serves the files in `public/` directly.
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
private ICS feeds → Ruby generator → public/index.html → Nginx → HTTPS
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
See the [live sample calendar](https://mendab1e.github.io/particle/sample/) for a generated page showing split availability, busy days, unavailable weekdays, and different weekend hours. It uses only synthetic calendar data. The [HTML source](docs/sample/index.html) is also available in the repository.
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
## Privacy and failure model
|
|
19
|
+
|
|
20
|
+
Calendar URLs are used only by the generator. The generated HTML contains dates, calculated free intervals, and an update timestamp. It never renders titles, descriptions, locations, attendees, UIDs, calendar names, source URLs, or raw ICS. Logs refer to feeds only as `Calendar 1`, `Calendar 2`, and so on.
|
|
21
|
+
|
|
22
|
+
All configured feeds must download and be parseable as calendars. If any feed fails, generation exits non-zero before publishing. Individual malformed events are ignored, and logs report only a count such as `Calendar 1 ignored 2 malformed events`. No event values are logged. Skipping an event can display false free time if that event was intended to be busy. Output is prepared in temporary files on the same filesystem and `index.html` is atomically renamed only after the complete calculation and render succeed, preserving the previously known-good page.
|
|
23
|
+
|
|
24
|
+
The page includes `noindex, nofollow, noarchive, nosnippet, noimageindex` plus a no-referrer policy, and `public/robots.txt` disallows all crawling. The Nginx example reinforces those directives with response headers, disables shared/browser caching, and serves the robots policy from the required origin-wide `/robots.txt` location. These are requests to well-behaved crawlers, **not authentication or access control**. A long random path reduces accidental discovery but does not prevent a recipient from sharing the URL. Enable HTTP authentication in Nginx if non-discoverability must be enforced against arbitrary scrapers.
|
|
25
|
+
|
|
26
|
+
## Requirements and installation
|
|
27
|
+
|
|
28
|
+
- Ruby 3.4 or newer
|
|
29
|
+
- A normal Linux VPS for production
|
|
30
|
+
|
|
31
|
+
Install the gem to add the `particle` command:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
gem install particle-calendar
|
|
35
|
+
particle --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The core libraries are:
|
|
39
|
+
|
|
40
|
+
- [`icalendar`](https://github.com/icalendar/icalendar) to parse RFC 5545 data;
|
|
41
|
+
- [`icalendar-recurrence`](https://github.com/icalendar/icalendar-recurrence), backed by `ice_cube`, to expand only occurrences intersecting the output range;
|
|
42
|
+
- `ActiveSupport::TimeWithZone` and `tzinfo` to retain named time zones and wall-clock recurrence times across DST;
|
|
43
|
+
- standard-library `Net::HTTP`, ERB, YAML, and filesystem primitives;
|
|
44
|
+
- RSpec and WebMock for tests.
|
|
45
|
+
|
|
46
|
+
`icalendar` parses recurrence properties but does not itself produce occurrence instances. `icalendar-recurrence` supplies date-bounded `occurrences_between` expansion, including common `RRULE`, `RDATE`, and `EXDATE` behavior. Particle additionally chunks dense secondly/minutely rules and applies event/calendar occurrence-count limits before retaining expanded results. The generator also handles exact detached `RECURRENCE-ID` overrides and cancellations so a moved instance replaces its original occurrence.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Create a starter configuration, an Nginx server-block sample, and the static output directory:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
particle setup
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
By default this writes `particle.yml` with mode `0600`, `particle.nginx.conf`, and `public/` in the current directory. It generates a long random URL path for the Nginx sample and refuses to replace either setup file if it already exists.
|
|
57
|
+
|
|
58
|
+
Customize every destination when needed:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
particle setup \
|
|
62
|
+
--config /opt/particle/availability.yml \
|
|
63
|
+
--output /var/www/particle \
|
|
64
|
+
--nginx /opt/particle/particle.nginx.conf \
|
|
65
|
+
--server-name calendar.example.com \
|
|
66
|
+
--url-path /replace-with-a-long-random-value/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run `particle setup --help` for all options. Generation defaults to `particle.yml` and `public/` in the current directory. Select other locations with `--config` and `--output`, or with `PARTICLE_CONFIG` and `PARTICLE_OUTPUT`. `AVAILABILITY_CONFIG` remains accepted for compatibility.
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
enabled: true
|
|
73
|
+
timezone: Europe/Berlin
|
|
74
|
+
|
|
75
|
+
calendar_urls:
|
|
76
|
+
- "${CALENDAR_MAIN_URL}"
|
|
77
|
+
- "${CALENDAR_EXTRA_URL}"
|
|
78
|
+
|
|
79
|
+
days_to_show: 28
|
|
80
|
+
minimum_slot_minutes: 60
|
|
81
|
+
|
|
82
|
+
event_buffer:
|
|
83
|
+
before_minutes: 30
|
|
84
|
+
after_minutes: 30
|
|
85
|
+
|
|
86
|
+
availability:
|
|
87
|
+
default:
|
|
88
|
+
- start: "09:00"
|
|
89
|
+
end: "13:00"
|
|
90
|
+
- start: "14:00"
|
|
91
|
+
end: "00:00"
|
|
92
|
+
|
|
93
|
+
sunday:
|
|
94
|
+
- start: "10:00"
|
|
95
|
+
end: "18:00"
|
|
96
|
+
|
|
97
|
+
monday:
|
|
98
|
+
unavailable: true
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Calendar feeds and secrets
|
|
102
|
+
|
|
103
|
+
`calendar_urls` accepts HTTPS, HTTP, or `webcal://` subscription URLs. Webcal URLs are downloaded over HTTPS. Every feed contributes busy time with OR semantics: if any calendar is busy, the shared view is busy. Overlapping and adjacent periods are merged before subtraction.
|
|
104
|
+
|
|
105
|
+
Configuration keys are strict: unknown top-level, `event_buffer`, weekday, and window keys fail generation instead of silently applying defaults. At most 20 calendars and 366 displayed days are accepted. Calendar feeds are also limited to 10,000 events, 10,000 expanded occurrences per event, and 50,000 expanded occurrences per calendar. Exceeding a feed limit fails the run and preserves the known-good page.
|
|
106
|
+
|
|
107
|
+
A URL may be literal, or an exact `${UPPERCASE_ENV_NAME}` placeholder. No ERB is evaluated, so the YAML file cannot execute Ruby code.
|
|
108
|
+
|
|
109
|
+
For simple VPS use, put literal URLs only in the deployment configuration and keep it protected (`particle setup` applies this mode automatically):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
chmod 600 /opt/particle/particle.yml
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For environment-based configuration:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
export CALENDAR_MAIN_URL='https://calendar.example/private-a.ics?token=...'
|
|
119
|
+
export CALENDAR_EXTRA_URL='https://calendar.example/private-b.ics?token=...'
|
|
120
|
+
particle generate
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Do not paste private subscription URLs into source control, shell history, issue trackers, or Nginx configuration. The generator never prints them, even when a request fails.
|
|
124
|
+
|
|
125
|
+
### Availability windows
|
|
126
|
+
|
|
127
|
+
`availability.default` is required and defines potential availability. Times use strict local 24-hour `HH:MM` syntax. One mapping is accepted for convenience, but the documented array form supports split windows without redesign:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
availability:
|
|
131
|
+
default:
|
|
132
|
+
- start: "09:00"
|
|
133
|
+
end: "13:00"
|
|
134
|
+
- start: "14:00"
|
|
135
|
+
end: "00:00"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The 13:00–14:00 gap is intentionally unavailable. `00:00` is accepted as a window end and means midnight at the end of that calendar day, matching calendar UI conventions. Windows may touch but cannot overlap, and each start must be earlier than its end. Other overnight availability windows are deliberately not accepted; express availability on each calendar day separately.
|
|
139
|
+
|
|
140
|
+
Any weekday can replace the default for that entire weekday: `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`, or `sunday`. An override is a replacement, not a merge with `default`.
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
availability:
|
|
144
|
+
default:
|
|
145
|
+
- start: "09:00"
|
|
146
|
+
end: "00:00"
|
|
147
|
+
sunday:
|
|
148
|
+
- start: "10:00"
|
|
149
|
+
end: "18:00"
|
|
150
|
+
monday:
|
|
151
|
+
unavailable: true
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Minimum slots and event buffers
|
|
155
|
+
|
|
156
|
+
`minimum_slot_minutes` removes shorter free fragments after subtraction. The default is `0`.
|
|
157
|
+
|
|
158
|
+
`event_buffer.before_minutes` and `after_minutes` enlarge each event before busy periods are merged. Both default to `0`. Buffers are clipped during subtraction and can never create free time outside configured windows.
|
|
159
|
+
|
|
160
|
+
### Disabling the page
|
|
161
|
+
|
|
162
|
+
Set `enabled: false` to skip all network access and render a “Calendar not available” page. `calendar_urls` may be empty in this mode. This is useful when availability should be withdrawn without changing Nginx.
|
|
163
|
+
|
|
164
|
+
## Running
|
|
165
|
+
|
|
166
|
+
Generate manually:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
particle generate
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Useful options:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
particle generate --config /etc/particle.yml --output /var/www/particle
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Successful output looks like:
|
|
179
|
+
|
|
180
|
+
```text
|
|
181
|
+
[2026-08-26 09:17:01] Fetching 2 calendars
|
|
182
|
+
[2026-08-26 09:17:02] Calendar 1 fetched and parsed successfully
|
|
183
|
+
[2026-08-26 09:17:02] Calendar 2 ignored 1 malformed event
|
|
184
|
+
[2026-08-26 09:17:02] Calendar 2 fetched and parsed successfully
|
|
185
|
+
[2026-08-26 09:17:02] Calculating availability for 2026-08-26..2026-09-22
|
|
186
|
+
[2026-08-26 09:17:02] Generated /var/www/particle/index.html
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Development and testing
|
|
190
|
+
|
|
191
|
+
Source development uses the Ruby 3.4.8 version pinned in `.ruby-version` and Bundler 2.6 or newer. After cloning the repository:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
bundle install
|
|
195
|
+
bundle exec bin/generate
|
|
196
|
+
bundle exec bin/generate --config /path/to/config.yml --output /path/to/public
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Refresh the deterministic sample page with synthetic data:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
bundle exec bin/generate-sample
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Run the complete test suite:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
bundle exec rspec
|
|
209
|
+
bundle exec rubocop
|
|
210
|
+
|
|
211
|
+
# Runs both RSpec and RuboCop:
|
|
212
|
+
bundle exec rake
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
RuboCop targets Ruby 3.4 and loads the official RSpec, Rake, and performance plugins. New cops are enabled. To apply safe formatting corrections locally, run `bundle exec rubocop -a`; review behavior-changing corrections from `bundle exec rubocop -A` before keeping them.
|
|
216
|
+
|
|
217
|
+
Tests cover interval subtraction, overlap and adjacency merging, multiple-calendar semantics, availability boundaries, events spanning midnight, all-day events, weekday replacement/unavailable days, minimum duration, buffers, split windows, recurrence exclusions and detached overrides, UTC/source/custom-zone conversion, unresolved zones, parser isolation, recurrence limits, atomic publish failures, and the Europe/Berlin DST transition. Integration tests also assert that fixture metadata and source URL secrets never reach HTML or parser diagnostics.
|
|
218
|
+
|
|
219
|
+
## VPS deployment
|
|
220
|
+
|
|
221
|
+
The following example keeps the generator installation out of Nginx's document tree:
|
|
222
|
+
|
|
223
|
+
```text
|
|
224
|
+
Ruby generator
|
|
225
|
+
↓
|
|
226
|
+
/opt/particle/public
|
|
227
|
+
↓
|
|
228
|
+
Nginx
|
|
229
|
+
↓
|
|
230
|
+
HTTPS
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Create a dedicated account or deploy as an unprivileged service user, install Ruby 3.4, and install the gem. Create `/opt/particle` as a directory owned by that user, then initialize the deployment:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
gem install particle-calendar
|
|
237
|
+
cd /opt/particle
|
|
238
|
+
mkdir -p log
|
|
239
|
+
particle setup --server-name calendar.example.com
|
|
240
|
+
# Edit particle.yml and replace the example calendar placeholders.
|
|
241
|
+
particle generate --config /opt/particle/particle.yml --output /opt/particle/public
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Ensure the generator user can replace files in `public/`, while the Nginx worker can read them. Do not set Nginx `root` or `alias` to `/opt/particle`; only map the generated public files.
|
|
245
|
+
|
|
246
|
+
### Nginx and a random path
|
|
247
|
+
|
|
248
|
+
`particle setup` creates `particle.nginx.conf` with exact-match locations exposing `public/index.html` and its generated `public/favicon.svg` at a random path such as:
|
|
249
|
+
|
|
250
|
+
```text
|
|
251
|
+
https://example.com/a8f2c9e71d4b/
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Copy and edit it, then validate and reload:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
sudo cp /opt/particle/particle.nginx.conf /etc/nginx/sites-available/particle
|
|
258
|
+
sudo ln -s /etc/nginx/sites-available/particle /etc/nginx/sites-enabled/particle
|
|
259
|
+
sudo nginx -t
|
|
260
|
+
sudo systemctl reload nginx
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Configure TLS certificates separately. The random path belongs only in Nginx; calendar calculation and generated links do not depend on it. The example serves `public/robots.txt` at the origin-wide `/robots.txt`, which is the only standards-defined location for crawler policy. Use a dedicated hostname: on a shared hostname, this policy would also disallow crawling unrelated pages.
|
|
264
|
+
|
|
265
|
+
The generated directives cover compliant search engines, AI crawlers, and other robots through the wildcard `User-agent: *` rule. They cannot stop clients that ignore `robots.txt`, spoof a browser, follow a user-provided URL, or learn the URL elsewhere. To enforce privacy after a URL is discovered, create a password file and enable the commented `auth_basic` lines in the page location:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
sudo htpasswd -c /etc/nginx/particle.htpasswd availability
|
|
269
|
+
sudo chown root:www-data /etc/nginx/particle.htpasswd
|
|
270
|
+
sudo chmod 640 /etc/nginx/particle.htpasswd
|
|
271
|
+
sudo nginx -t
|
|
272
|
+
sudo systemctl reload nginx
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Replace `www-data` with the Nginx worker group used by your distribution. HTTP authentication is the protection boundary; the random path and crawler directives remain defense in depth.
|
|
276
|
+
|
|
277
|
+
### Hourly cron regeneration
|
|
278
|
+
|
|
279
|
+
Confirm the absolute executable path with `command -v particle`, then add a crontab entry for the unprivileged generator user. The example assumes `/usr/local/bin/particle`; replace it with the path reported on your server. It intentionally does not run at minute zero:
|
|
280
|
+
|
|
281
|
+
```cron
|
|
282
|
+
PATH=/usr/local/bin:/usr/bin:/bin
|
|
283
|
+
17 * * * * /usr/local/bin/particle generate --config /opt/particle/particle.yml --output /opt/particle/public >> /opt/particle/log/generator.log 2>&1
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
If the Ruby installation lives elsewhere, use the absolute `particle` path reported by `command -v particle`. Cron has a minimal environment. When YAML uses `${...}` placeholders, load protected environment values through a small root-owned wrapper or use a systemd timer with `EnvironmentFile=`; do not put secret URLs directly in the crontab.
|
|
287
|
+
|
|
288
|
+
The repository includes a [`deploy/availability.logrotate`](deploy/availability.logrotate) example that can be adapted to `/opt/particle/log/generator.log`, or send output to syslog/systemd instead. A failed cron run exits non-zero and leaves the last successfully generated page online; monitor the exit code or log rather than assuming hourly freshness.
|
|
289
|
+
|
|
290
|
+
## Troubleshooting
|
|
291
|
+
|
|
292
|
+
- **Configuration file not found / invalid YAML:** verify `PARTICLE_CONFIG` or `--config`, indentation, and that secrets are readable by the generator user.
|
|
293
|
+
- **Missing environment variable:** an exact `${NAME}` URL placeholder requires `NAME` in the generator process, including cron/systemd.
|
|
294
|
+
- **Unknown timezone:** use an IANA identifier such as `Europe/Berlin`, not an informal abbreviation.
|
|
295
|
+
- **Calendar download failed:** test outbound DNS/TLS access from the generator account and confirm the subscription was not revoked. Logs intentionally omit the URL and query token.
|
|
296
|
+
- **Calendar parse failed:** download the feed securely and validate that it is ICS, not an HTML sign-in/error page. Individual malformed events are ignored, but a feed that cannot be parsed as a calendar still fails. Never paste it into public diagnostics.
|
|
297
|
+
- **Calendar exceeded a safe limit:** reduce an unusually large displayed range or inspect the feed privately for excessive event counts or dense recurrence rules. The generator intentionally fails before publishing rather than risking resource exhaustion.
|
|
298
|
+
- **Old update timestamp:** inspect cron logs. A stale page usually means a later run failed safely.
|
|
299
|
+
- **Permission denied while publishing:** the generator needs write permission on `public/`; Nginx needs read permission only.
|
|
300
|
+
|
|
301
|
+
## Known limits
|
|
302
|
+
|
|
303
|
+
The generator supports normal timed, all-day, overnight, multi-day, recurring, `EXDATE`, `RDATE`, and exact detached `RECURRENCE-ID` events. Floating timed events without a `TZID` are interpreted in the configured timezone. Custom `VTIMEZONE` definitions are used when the parser can resolve them; events with unresolved timezone identifiers are treated as malformed and ignored. Rare recurrence features such as `RANGE=THISANDFUTURE` and malformed feed-level structure may still require feed-specific work.
|
|
304
|
+
|
|
305
|
+
HTTP validators (`ETag` and `Last-Modified`) are not persisted in this intentionally stateless version. Every successful run downloads every feed, prioritizing freshness and safe all-or-nothing generation.
|
data/assets/favicon.svg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect x="1" y="1" width="62" height="62" rx="14" fill="#eaf4fb"/>
|
|
3
|
+
<rect x="6" y="9" width="52" height="49" rx="11" fill="#0f172a"/>
|
|
4
|
+
<rect x="10" y="13" width="44" height="41" rx="7" fill="#f8fafc"/>
|
|
5
|
+
<rect x="8" y="23" width="48" height="4" fill="#0f172a"/>
|
|
6
|
+
<rect x="17" y="5" width="4" height="12" rx="2" fill="#0f172a"/>
|
|
7
|
+
<rect x="43" y="5" width="4" height="12" rx="2" fill="#0f172a"/>
|
|
8
|
+
<circle cx="18" cy="34" r="4" fill="#0284c7"/>
|
|
9
|
+
<circle cx="32" cy="34" r="4" fill="#0284c7"/>
|
|
10
|
+
<circle cx="46" cy="34" r="4" fill="#0284c7"/>
|
|
11
|
+
<circle cx="18" cy="46" r="4" fill="#0284c7"/>
|
|
12
|
+
<circle cx="32" cy="46" r="4" fill="#0284c7"/>
|
|
13
|
+
<rect x="42" y="42" width="8" height="8" rx="1.5" fill="#0284c7"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
enabled: true
|
|
2
|
+
timezone: Europe/Berlin
|
|
3
|
+
|
|
4
|
+
# Use literal private URLs locally, or exact environment placeholders in production.
|
|
5
|
+
calendar_urls:
|
|
6
|
+
- "${CALENDAR_MAIN_URL}"
|
|
7
|
+
- "${CALENDAR_EXTRA_URL}"
|
|
8
|
+
|
|
9
|
+
# Safety limits: at most 20 calendar URLs and 366 displayed days.
|
|
10
|
+
days_to_show: 28
|
|
11
|
+
minimum_slot_minutes: 60
|
|
12
|
+
|
|
13
|
+
event_buffer:
|
|
14
|
+
before_minutes: 0
|
|
15
|
+
after_minutes: 0
|
|
16
|
+
|
|
17
|
+
availability:
|
|
18
|
+
default:
|
|
19
|
+
- start: "09:00"
|
|
20
|
+
end: "13:00"
|
|
21
|
+
- start: "14:00"
|
|
22
|
+
end: "00:00"
|
|
23
|
+
|
|
24
|
+
sunday:
|
|
25
|
+
- start: "10:00"
|
|
26
|
+
end: "18:00"
|
|
27
|
+
|
|
28
|
+
# monday:
|
|
29
|
+
# unavailable: true
|
data/exe/particle
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Availability
|
|
4
|
+
# Coordinates configuration, calendar ingestion, calculation, and publishing.
|
|
5
|
+
class Application
|
|
6
|
+
DEFAULT_CLOCK = -> { Time.now.utc }
|
|
7
|
+
DEFAULT_FAVICON_PATH = Assets.favicon_path
|
|
8
|
+
|
|
9
|
+
def initialize(config_path:, output_dir:, template_path:, output: $stdout, clock: DEFAULT_CLOCK,
|
|
10
|
+
fetcher: CalendarFetcher.new, favicon_path: DEFAULT_FAVICON_PATH)
|
|
11
|
+
@config_path = config_path
|
|
12
|
+
@output_dir = output_dir
|
|
13
|
+
@template_path = template_path
|
|
14
|
+
@favicon_path = favicon_path
|
|
15
|
+
@output = output
|
|
16
|
+
@clock = clock
|
|
17
|
+
@fetcher = fetcher
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
config = Config.load(@config_path)
|
|
22
|
+
now = @clock.call.utc
|
|
23
|
+
today = config.timezone.to_local(now).to_date
|
|
24
|
+
days = config.enabled ? calculate_days(config, now, today) : disabled_days(config, now)
|
|
25
|
+
|
|
26
|
+
publish(config, now, today, days)
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def calculate_days(config, now, today)
|
|
33
|
+
log(config, now, "Fetching #{config.calendar_urls.length} calendars")
|
|
34
|
+
range_start, range_end, final_date = expansion_range(config, today)
|
|
35
|
+
parser = CalendarParser.new(timezone: config.timezone)
|
|
36
|
+
busy_periods = fetch_periods(config, parser, range_start, range_end, now)
|
|
37
|
+
|
|
38
|
+
log(config, now, "Calculating availability for #{today}..#{final_date - 1}")
|
|
39
|
+
AvailabilityCalculator.new(config).calculate(start_date: today, busy_periods: busy_periods)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def disabled_days(config, now)
|
|
43
|
+
log(config, now, 'Availability is disabled; calendars were not fetched')
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def expansion_range(config, today)
|
|
48
|
+
final_date = today + config.days_to_show
|
|
49
|
+
range_start = local_midnight(config, today) - (config.buffer_after_minutes * 60)
|
|
50
|
+
range_end = local_midnight(config, final_date) + (config.buffer_before_minutes * 60)
|
|
51
|
+
[range_start, range_end, final_date]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def local_midnight(config, date)
|
|
55
|
+
config.timezone.local_time(date.year, date.month, date.day, 0, 0, 0).utc
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def publish(config, now, today, days)
|
|
59
|
+
html = render_page(config, now, today, days)
|
|
60
|
+
AtomicWriter.write_all(@output_dir, generated_files(html))
|
|
61
|
+
log(config, now, "Generated #{File.join(@output_dir, 'index.html')}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render_page(config, now, today, days)
|
|
65
|
+
Renderer.new(template_path: @template_path).render(
|
|
66
|
+
days: days,
|
|
67
|
+
generated_at: now,
|
|
68
|
+
timezone: config.timezone,
|
|
69
|
+
enabled: config.enabled,
|
|
70
|
+
today: today,
|
|
71
|
+
days_to_show: config.days_to_show
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def generated_files(html)
|
|
76
|
+
{
|
|
77
|
+
'favicon.svg' => File.binread(@favicon_path),
|
|
78
|
+
'robots.txt' => Renderer::ROBOTS,
|
|
79
|
+
'index.html' => html
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fetch_periods(config, parser, range_start, range_end, now)
|
|
84
|
+
config.calendar_urls.each_with_index.flat_map do |url, index|
|
|
85
|
+
label = "Calendar #{index + 1}"
|
|
86
|
+
body = @fetcher.fetch(url, label: label)
|
|
87
|
+
periods = parser.parse(body, range_start: range_start, range_end: range_end, label: label)
|
|
88
|
+
log_ignored_events(config, now, label, parser.ignored_event_count)
|
|
89
|
+
log(config, now, "#{label} fetched and parsed successfully")
|
|
90
|
+
periods
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def log_ignored_events(config, now, label, count)
|
|
95
|
+
return if count.zero?
|
|
96
|
+
|
|
97
|
+
noun = count == 1 ? 'event' : 'events'
|
|
98
|
+
log(config, now, "#{label} ignored #{count} malformed #{noun}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def log(config, moment, message)
|
|
102
|
+
timestamp = config.timezone.to_local(moment).strftime('%Y-%m-%d %H:%M:%S')
|
|
103
|
+
@output.puts("[#{timestamp}] #{message}")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Availability
|
|
4
|
+
# Resolves runtime files both from a source checkout and an installed gem.
|
|
5
|
+
module Assets
|
|
6
|
+
ROOT = File.expand_path('../..', __dir__)
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def example_config_path
|
|
11
|
+
File.join(ROOT, 'config', 'availability.example.yml')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def favicon_path
|
|
15
|
+
File.join(ROOT, 'assets', 'favicon.svg')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def index_template_path
|
|
19
|
+
File.join(ROOT, 'templates', 'index.html.erb')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def nginx_template_path
|
|
23
|
+
File.join(ROOT, 'templates', 'nginx.conf.erb')
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module Availability
|
|
7
|
+
# Writes generated assets through same-filesystem temporary files and renames.
|
|
8
|
+
class AtomicWriter
|
|
9
|
+
def self.write_all(output_dir, files)
|
|
10
|
+
FileUtils.mkdir_p(output_dir)
|
|
11
|
+
temporary = prepare_files(output_dir, files)
|
|
12
|
+
publish_files(output_dir, files.keys, temporary)
|
|
13
|
+
ensure
|
|
14
|
+
cleanup(temporary)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.prepare_files(output_dir, files)
|
|
18
|
+
temporary = {}
|
|
19
|
+
files.each do |name, contents|
|
|
20
|
+
temporary[name] = prepare_file(output_dir, name, contents)
|
|
21
|
+
end
|
|
22
|
+
temporary
|
|
23
|
+
rescue StandardError
|
|
24
|
+
cleanup(temporary)
|
|
25
|
+
raise
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.prepare_file(output_dir, name, contents)
|
|
29
|
+
file = Tempfile.new([".#{name}", '.tmp'], output_dir)
|
|
30
|
+
write_contents(file, contents)
|
|
31
|
+
file
|
|
32
|
+
rescue StandardError
|
|
33
|
+
file&.close!
|
|
34
|
+
raise
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.write_contents(file, contents)
|
|
38
|
+
file.binmode
|
|
39
|
+
file.write(contents)
|
|
40
|
+
file.flush
|
|
41
|
+
file.fsync
|
|
42
|
+
file.chmod(0o644)
|
|
43
|
+
file.close
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.publish_files(output_dir, names, temporary)
|
|
47
|
+
# Publishing index last keeps the known-good page if preparation fails.
|
|
48
|
+
(names - ['index.html'] + ['index.html']).each do |name|
|
|
49
|
+
File.rename(temporary.fetch(name).path, File.join(output_dir, name))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.cleanup(temporary)
|
|
54
|
+
temporary&.each_value do |file|
|
|
55
|
+
file.close unless file.closed?
|
|
56
|
+
file.unlink if File.exist?(file.path)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :prepare_files, :prepare_file, :write_contents, :publish_files, :cleanup
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Availability
|
|
4
|
+
# Subtracts merged busy periods from configured per-day availability windows.
|
|
5
|
+
class AvailabilityCalculator
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def calculate(start_date:, busy_periods:)
|
|
11
|
+
expanded_busy = busy_periods.map { |period| apply_buffer(period) }
|
|
12
|
+
|
|
13
|
+
Array.new(@config.days_to_show) do |offset|
|
|
14
|
+
date = start_date + offset
|
|
15
|
+
DayAvailability.new(date, available_slots(date, expanded_busy))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def apply_buffer(period)
|
|
22
|
+
BusyPeriod.new(
|
|
23
|
+
period.starts_at - (@config.buffer_before_minutes * 60),
|
|
24
|
+
period.ends_at + (@config.buffer_after_minutes * 60)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def available_slots(date, busy_periods)
|
|
29
|
+
slots = @config.windows_for(date).flat_map do |window|
|
|
30
|
+
slots_for_window(date, window, busy_periods)
|
|
31
|
+
end
|
|
32
|
+
slots.select { |slot| long_enough?(slot) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def slots_for_window(date, window, busy_periods)
|
|
36
|
+
starts_at, ends_at = window_boundaries(date, window)
|
|
37
|
+
relevant = busy_periods.select { |period| period.intersects?(starts_at, ends_at) }
|
|
38
|
+
subtract(starts_at, ends_at, merge_and_clip(relevant, starts_at, ends_at))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def window_boundaries(date, window)
|
|
42
|
+
window.map do |minutes|
|
|
43
|
+
day_offset, minutes_within_day = minutes.divmod(24 * 60)
|
|
44
|
+
hour, minute = minutes_within_day.divmod(60)
|
|
45
|
+
boundary_date = date + day_offset
|
|
46
|
+
@config.timezone.local_time(boundary_date.year, boundary_date.month, boundary_date.day, hour, minute, 0).utc
|
|
47
|
+
end
|
|
48
|
+
rescue TZInfo::PeriodNotFound, TZInfo::AmbiguousTime => e
|
|
49
|
+
raise Error, "availability boundary is invalid on #{date} because of daylight saving time (#{e.class})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def merge_and_clip(periods, starts_at, ends_at)
|
|
53
|
+
clipped = periods.map { |period| clip(period, starts_at, ends_at) }.sort_by(&:first)
|
|
54
|
+
|
|
55
|
+
clipped.each_with_object([]) do |interval, merged|
|
|
56
|
+
merge_interval(merged, interval)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clip(period, starts_at, ends_at)
|
|
61
|
+
[[period.starts_at, starts_at].max, [period.ends_at, ends_at].min]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def merge_interval(merged, interval)
|
|
65
|
+
return merged << interval if merged.empty? || interval.first > merged.last.last
|
|
66
|
+
|
|
67
|
+
merged.last[1] = interval.last if interval.last > merged.last.last
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def subtract(starts_at, ends_at, busy)
|
|
71
|
+
cursor = starts_at
|
|
72
|
+
slots = []
|
|
73
|
+
busy.each do |busy_start, busy_end|
|
|
74
|
+
slots << Slot.new(cursor, busy_start) if busy_start > cursor
|
|
75
|
+
cursor = busy_end if busy_end > cursor
|
|
76
|
+
end
|
|
77
|
+
slots << Slot.new(cursor, ends_at) if cursor < ends_at
|
|
78
|
+
slots
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def long_enough?(slot)
|
|
82
|
+
(slot.ends_at - slot.starts_at) >= (@config.minimum_slot_minutes * 60)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|