From b5252506d7f840678f88873ad2044a9f3a8aa956 Mon Sep 17 00:00:00 2001 From: Quantum Date: Sat, 15 Jul 2023 23:09:42 -0400 Subject: [PATCH] Implement iCalendar export --- common/src/dateJump/GregorianJumper.tsx | 39 ++-------- common/src/dateJump/GregorianSelector.tsx | 40 ++++++++++ common/src/dateJump/base.ts | 11 ++- common/src/ui/index.scss | 20 ++--- frcal/src/App.tsx | 8 +- frcal/src/Export.tsx | 92 +++++++++++++++++++++++ frcal/src/index.scss | 30 ++++++++ frcal/src/index.tsx | 2 +- jcal/src/App.tsx | 2 +- 9 files changed, 198 insertions(+), 46 deletions(-) create mode 100644 common/src/dateJump/GregorianSelector.tsx create mode 100644 frcal/src/Export.tsx create mode 100644 frcal/src/index.scss diff --git a/common/src/dateJump/GregorianJumper.tsx b/common/src/dateJump/GregorianJumper.tsx index 252589e..d83492f 100644 --- a/common/src/dateJump/GregorianJumper.tsx +++ b/common/src/dateJump/GregorianJumper.tsx @@ -1,43 +1,18 @@ import {DateJumperProps} from './base'; import React from 'react'; -import {gregorianJDN, jdnGregorian} from '../gregorian'; +import GregorianSelector from './GregorianSelector'; -export default function GregorianJumper({minJDN, maxJDN, todayJDN, onJump}: DateJumperProps): JSX.Element { - const {todayYear, todayMonth, todayDay, startYear, endYear} = React.useMemo(() => { - const [todayYear, todayMonth, todayDay] = jdnGregorian(todayJDN); - const [startYear] = jdnGregorian(minJDN); - const [endYear] = jdnGregorian(maxJDN); - return {todayYear, todayMonth, todayDay, startYear, endYear}; - }, [minJDN, maxJDN, todayJDN]); - - const [year, setYear] = React.useState(todayYear.toString()); - const [month, setMonth] = React.useState(todayMonth.toString()); - const [day, setDay] = React.useState(todayDay.toString()); - - const validYear = /^-?\d+$/.test(year) && startYear <= +year && +year <= endYear; - const validMonth = /^\d+$/.test(month) && 1 <= +month && +month <= 12; - const validDay = /^\d+$/.test(day) && 1 <= +day && +day <= 31; +export default function GregorianJumper({onJump, ...props}: DateJumperProps): JSX.Element { + const [jdn, setJDN] = React.useState(); function goToGregorian(event: React.FormEvent) { event.preventDefault(); - - if (!validYear || !validMonth || !validDay) - return; - - onJump(gregorianJDN(+year, +month, +day)); + jdn !== undefined && onJump(jdn); } - return
+ return Gregorian Date - setYear(e.target.value)} value={year} - min={startYear} max={endYear}/> - setMonth(e.target.value)} value={month} - min={1} max={12}/> - setDay(e.target.value)} value={day} - min={1} max={31}/> - + + ; } diff --git a/common/src/dateJump/GregorianSelector.tsx b/common/src/dateJump/GregorianSelector.tsx new file mode 100644 index 0000000..ea6fbfe --- /dev/null +++ b/common/src/dateJump/GregorianSelector.tsx @@ -0,0 +1,40 @@ +import {DateSelectorProps} from './base'; +import React from 'react'; +import {gregorianJDN, jdnGregorian} from '../gregorian'; + +function GregorianSelector({minJDN, maxJDN, initialJDN, onChange}: DateSelectorProps): JSX.Element { + const {todayYear, todayMonth, todayDay, startYear, endYear} = React.useMemo(() => { + const [todayYear, todayMonth, todayDay] = jdnGregorian(initialJDN); + const [startYear] = jdnGregorian(minJDN); + const [endYear] = jdnGregorian(maxJDN); + return {todayYear, todayMonth, todayDay, startYear, endYear}; + }, [minJDN, maxJDN, initialJDN]); + + const [year, setYear] = React.useState(todayYear.toString()); + const [month, setMonth] = React.useState(todayMonth.toString()); + const [day, setDay] = React.useState(todayDay.toString()); + + const validYear = /^-?\d+$/.test(year) && startYear <= +year && +year <= endYear; + const validMonth = /^\d+$/.test(month) && 1 <= +month && +month <= 12; + const validDay = /^\d+$/.test(day) && 1 <= +day && +day <= 31; + const valid = validYear && validMonth && validDay; + + React.useEffect( + () => onChange(valid ? gregorianJDN(+year, +month, +day) : undefined), + [onChange, year, month, day, valid], + ); + + return <> + setYear(e.target.value)} value={year} + min={startYear} max={endYear}/> + setMonth(e.target.value)} value={month} + min={1} max={12}/> + setDay(e.target.value)} value={day} + min={1} max={31}/> + ; +} + +export default GregorianSelector; diff --git a/common/src/dateJump/base.ts b/common/src/dateJump/base.ts index 958516f..f4a00f5 100644 --- a/common/src/dateJump/base.ts +++ b/common/src/dateJump/base.ts @@ -1,6 +1,13 @@ -export type DateJumperProps = { +export type BaseDateProps = { minJDN: number; maxJDN: number; - todayJDN: number; + initialJDN: number; +}; + +export type DateSelectorProps = BaseDateProps & { + onChange: (jdn: number | undefined) => void; +}; + +export type DateJumperProps = BaseDateProps & { onJump: (jdn: number) => void; }; \ No newline at end of file diff --git a/common/src/ui/index.scss b/common/src/ui/index.scss index ce7cf04..bd976dc 100644 --- a/common/src/ui/index.scss +++ b/common/src/ui/index.scss @@ -43,20 +43,22 @@ nav.navbar { } } +.gregorian-select { + .year { + max-width: 7em; + } + + .month, .day { + max-width: 5em; + } +} + .navigate { max-width: $calendar-width; margin-top: $spacer; @include make-container(); - .go-year { - max-width: 7em; - } - - .go-month, .go-day { - max-width: 5em; - } - - .go-button { + .btn-primary { max-width: 3em; } } diff --git a/frcal/src/App.tsx b/frcal/src/App.tsx index a855221..df78225 100644 --- a/frcal/src/App.tsx +++ b/frcal/src/App.tsx @@ -5,6 +5,7 @@ import {JulianMonth} from '@common/gregorian'; import {TimeOfDay} from './TimeOfDay'; import {GregorianJumper} from '@common/dateJump'; import MonthBasedApp from '@common/ui/MonthBasedApp'; +import Export from './Export'; export default class App extends MonthBasedApp { override parseYearMonth(year: string, month: string) { @@ -34,9 +35,14 @@ export default class App extends MonthBasedApp {

Go to a date

-
+ +
+

Export calendar

+ +
; } } diff --git a/frcal/src/Export.tsx b/frcal/src/Export.tsx new file mode 100644 index 0000000..1af7286 --- /dev/null +++ b/frcal/src/Export.tsx @@ -0,0 +1,92 @@ +import {dateName, jdnFrench} from '@common/french'; +import {jdnGregorian} from '@common/gregorian'; +import React from 'react'; +import GregorianSelector from '@common/dateJump/GregorianSelector'; +import {BaseDateProps} from '@common/dateJump/base'; + +function zeroPad(item: unknown, width: number) { + const n = item + ''; + return n.length >= width ? n : new Array(width - n.length + 1).join('0') + n; +} + + +export function* iCalStream(startJDN: number, endJDN: number): Generator { + yield `BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//hacksw/handcal//NONSGML v1.0//EN\r +`; + + for (let jdn = startJDN; jdn <= endJDN; ++jdn) { + const [gy, gm, gd] = jdnGregorian(jdn); + const {year: fy, month: fm, day: fd} = jdnFrench(jdn); + yield `BEGIN:VEVENT\r +UID:jdn-${jdn}@frcal.qt.ax\r +DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')}\r +DTSTART;VALUE=DATE:${zeroPad(gy, 4)}${zeroPad(gm, 2)}${zeroPad(gd, 2)}\r +SUMMARY:${dateName(fm, fd)} ${fy}\r +TRANSP:TRANSPARENT\r +END:VEVENT\r +`; + } + + yield 'END:VCALENDAR\r\n'; +} + +function iCalBlob(startJDN: number, endJDN: number): Blob { + return new Blob(iCalStream(startJDN, endJDN) as any, {type: 'text/calendar'}); +} + +function downloadAs(url: string, name: string) { + const a = document.createElement('a'); + a.href = url; + a.download = name; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +type Props = BaseDateProps; + +export default function Export(props: Props): JSX.Element { + const [rangeStart, setRangeStart] = React.useState(); + const [rangeEnd, setRangeEnd] = React.useState(); + + function doExport(event: React.FormEvent) { + event.preventDefault(); + + if (rangeStart === undefined || rangeEnd === undefined) { + alert('Date out of range!'); + return; + } + + const days = rangeEnd - rangeStart; + if (days > 36500 && !window.confirm( + `You are exporting ${days} days of calendar data. This may crash your computer. Do you want to continue?`, + )) + return; + + const blob = iCalBlob(rangeStart, rangeEnd); + const [sy, sm, sd] = jdnGregorian(rangeStart); + const [ey, em, ed] = jdnGregorian(rangeEnd); + const startDate = `${zeroPad(sy, 4)}-${zeroPad(sm, 2)}-${zeroPad(sd, 2)}`; + const endDate = `${zeroPad(ey, 4)}-${zeroPad(em, 2)}-${zeroPad(ed, 2)}`; + downloadAs(URL.createObjectURL(blob), `frcal-${startDate}-${endDate}.ics`); + } + + return
+
+ Start date + +
+ +
+ End date + +
+ + +
; +} diff --git a/frcal/src/index.scss b/frcal/src/index.scss new file mode 100644 index 0000000..40cbd86 --- /dev/null +++ b/frcal/src/index.scss @@ -0,0 +1,30 @@ +@import '@common/ui/index.scss'; + +.download { + max-width: $calendar-width; + margin-top: $spacer; + @include make-container(); + + .input-group-text { + width: 6em; + } + + .btn-primary { + max-width: 5em; + } +} + +@include media-breakpoint-up(md) { + .download form { + display: flex; + + .input-group { + width: fit-content; + margin-right: $spacer / 2; + } + + .input-group-text { + width: auto; + } + } +} diff --git a/frcal/src/index.tsx b/frcal/src/index.tsx index 8578ae3..11c07b2 100644 --- a/frcal/src/index.tsx +++ b/frcal/src/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import 'bootstrap/js/dist/collapse'; -import '@common/ui/index.scss'; +import './index.scss'; import App from './App'; import reportWebVitals from '@common/ui/reportWebVitals'; import {MobileTooltipProvider} from '@common/ui/MobileTooltip'; diff --git a/jcal/src/App.tsx b/jcal/src/App.tsx index 4fb5f00..6643c10 100644 --- a/jcal/src/App.tsx +++ b/jcal/src/App.tsx @@ -38,7 +38,7 @@ export default class App extends MonthBasedApp {

Go to a date

- +
; }