Implement iCalendar export

This commit is contained in:
Quantum 2023-07-15 23:09:42 -04:00
parent 7202b1e47c
commit b5252506d7
9 changed files with 198 additions and 46 deletions

View file

@ -1,43 +1,18 @@
import {DateJumperProps} from './base'; import {DateJumperProps} from './base';
import React from 'react'; import React from 'react';
import {gregorianJDN, jdnGregorian} from '../gregorian'; import GregorianSelector from './GregorianSelector';
export default function GregorianJumper({minJDN, maxJDN, todayJDN, onJump}: DateJumperProps): JSX.Element { export default function GregorianJumper({onJump, ...props}: DateJumperProps): JSX.Element {
const {todayYear, todayMonth, todayDay, startYear, endYear} = React.useMemo(() => { const [jdn, setJDN] = React.useState<number | undefined>();
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;
function goToGregorian(event: React.FormEvent) { function goToGregorian(event: React.FormEvent) {
event.preventDefault(); event.preventDefault();
jdn !== undefined && onJump(jdn);
if (!validYear || !validMonth || !validDay)
return;
onJump(gregorianJDN(+year, +month, +day));
} }
return <form className="input-group" onSubmit={goToGregorian}> return <form className="input-group gregorian-select" onSubmit={goToGregorian}>
<span className="input-group-text">Gregorian<span className="hide-small">&nbsp;Date</span></span> <span className="input-group-text">Gregorian<span className="hide-small">&nbsp;Date</span></span>
<input type="number" className={`form-control go-year ${validYear ? '' : 'is-invalid'}`} <GregorianSelector onChange={setJDN} {...props}/>
onChange={e => setYear(e.target.value)} value={year} <button type="submit" className="form-control btn btn-primary">Go</button>
min={startYear} max={endYear}/>
<input type="number" className={`form-control go-month ${validMonth ? '' : 'is-invalid'}`}
onChange={e => setMonth(e.target.value)} value={month}
min={1} max={12}/>
<input type="number" className={`form-control go-day ${validDay ? '' : 'is-invalid'}`}
onChange={e => setDay(e.target.value)} value={day}
min={1} max={31}/>
<button type="submit" className="form-control btn btn-primary go-button">Go</button>
</form>; </form>;
} }

View file

@ -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 <>
<input type="number" className={`form-control year${validYear ? '' : ' is-invalid'}`}
onChange={e => setYear(e.target.value)} value={year}
min={startYear} max={endYear}/>
<input type="number" className={`form-control month${validMonth ? '' : ' is-invalid'}`}
onChange={e => setMonth(e.target.value)} value={month}
min={1} max={12}/>
<input type="number" className={`form-control day${validDay ? '' : ' is-invalid'}`}
onChange={e => setDay(e.target.value)} value={day}
min={1} max={31}/>
</>;
}
export default GregorianSelector;

View file

@ -1,6 +1,13 @@
export type DateJumperProps = { export type BaseDateProps = {
minJDN: number; minJDN: number;
maxJDN: number; maxJDN: number;
todayJDN: number; initialJDN: number;
};
export type DateSelectorProps = BaseDateProps & {
onChange: (jdn: number | undefined) => void;
};
export type DateJumperProps = BaseDateProps & {
onJump: (jdn: number) => void; onJump: (jdn: number) => void;
}; };

View file

@ -43,20 +43,22 @@ nav.navbar {
} }
} }
.gregorian-select {
.year {
max-width: 7em;
}
.month, .day {
max-width: 5em;
}
}
.navigate { .navigate {
max-width: $calendar-width; max-width: $calendar-width;
margin-top: $spacer; margin-top: $spacer;
@include make-container(); @include make-container();
.go-year { .btn-primary {
max-width: 7em;
}
.go-month, .go-day {
max-width: 5em;
}
.go-button {
max-width: 3em; max-width: 3em;
} }
} }

View file

@ -5,6 +5,7 @@ import {JulianMonth} from '@common/gregorian';
import {TimeOfDay} from './TimeOfDay'; import {TimeOfDay} from './TimeOfDay';
import {GregorianJumper} from '@common/dateJump'; import {GregorianJumper} from '@common/dateJump';
import MonthBasedApp from '@common/ui/MonthBasedApp'; import MonthBasedApp from '@common/ui/MonthBasedApp';
import Export from './Export';
export default class App extends MonthBasedApp<number, FrenchMonth> { export default class App extends MonthBasedApp<number, FrenchMonth> {
override parseYearMonth(year: string, month: string) { override parseYearMonth(year: string, month: string) {
@ -34,9 +35,14 @@ export default class App extends MonthBasedApp<number, FrenchMonth> {
<div className="navigate"> <div className="navigate">
<h4>Go to a date</h4> <h4>Go to a date</h4>
<GregorianJumper minJDN={frStartJD} maxJDN={frEndJD} todayJDN={todayJDN} <GregorianJumper minJDN={frStartJD} maxJDN={frEndJD} initialJDN={todayJDN}
onJump={this.goToJDN}/> onJump={this.goToJDN}/>
</div> </div>
<div className="download">
<h4>Export calendar</h4>
<Export minJDN={frStartJD} maxJDN={frEndJD} initialJDN={todayJDN}/>
</div>
</>; </>;
} }
} }

92
frcal/src/Export.tsx Normal file
View file

@ -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<string> {
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<number | undefined>();
const [rangeEnd, setRangeEnd] = React.useState<number | undefined>();
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 <form onSubmit={doExport}>
<div className="input-group gregorian-select">
<span className="input-group-text">Start date</span>
<GregorianSelector onChange={setRangeStart} {...props}/>
</div>
<div className="input-group gregorian-select">
<span className="input-group-text">End date</span>
<GregorianSelector onChange={setRangeEnd} {...props} initialJDN={props.initialJDN + 365 * 5}/>
</div>
<button type="submit" className="form-control btn btn-primary"
title="Export calendar as .ics file (iCalendar format)">Export
</button>
</form>;
}

30
frcal/src/index.scss Normal file
View file

@ -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;
}
}
}

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import 'bootstrap/js/dist/collapse'; import 'bootstrap/js/dist/collapse';
import '@common/ui/index.scss'; import './index.scss';
import App from './App'; import App from './App';
import reportWebVitals from '@common/ui/reportWebVitals'; import reportWebVitals from '@common/ui/reportWebVitals';
import {MobileTooltipProvider} from '@common/ui/MobileTooltip'; import {MobileTooltipProvider} from '@common/ui/MobileTooltip';

View file

@ -38,7 +38,7 @@ export default class App extends MonthBasedApp<number, JulianMonth> {
<div className="navigate"> <div className="navigate">
<h4>Go to a date</h4> <h4>Go to a date</h4>
<GregorianJumper minJDN={START_JDN} maxJDN={END_JDN} todayJDN={todayJDN} onJump={this.goToJDN}/> <GregorianJumper minJDN={START_JDN} maxJDN={END_JDN} initialJDN={todayJDN} onJump={this.goToJDN}/>
</div> </div>
</>; </>;
} }