mirror of
https://github.com/quantum5/qcal.git
synced 2025-04-24 01:32:02 -04:00
Implement iCalendar export
This commit is contained in:
parent
7202b1e47c
commit
b5252506d7
|
@ -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<number | undefined>();
|
||||
|
||||
function goToGregorian(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!validYear || !validMonth || !validDay)
|
||||
return;
|
||||
|
||||
onJump(gregorianJDN(+year, +month, +day));
|
||||
jdn !== undefined && onJump(jdn);
|
||||
}
|
||||
|
||||
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"> Date</span></span>
|
||||
<input type="number" className={`form-control go-year ${validYear ? '' : 'is-invalid'}`}
|
||||
onChange={e => setYear(e.target.value)} value={year}
|
||||
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>
|
||||
<GregorianSelector onChange={setJDN} {...props}/>
|
||||
<button type="submit" className="form-control btn btn-primary">Go</button>
|
||||
</form>;
|
||||
}
|
||||
|
|
40
common/src/dateJump/GregorianSelector.tsx
Normal file
40
common/src/dateJump/GregorianSelector.tsx
Normal 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;
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<number, FrenchMonth> {
|
||||
override parseYearMonth(year: string, month: string) {
|
||||
|
@ -34,9 +35,14 @@ export default class App extends MonthBasedApp<number, FrenchMonth> {
|
|||
|
||||
<div className="navigate">
|
||||
<h4>Go to a date</h4>
|
||||
<GregorianJumper minJDN={frStartJD} maxJDN={frEndJD} todayJDN={todayJDN}
|
||||
<GregorianJumper minJDN={frStartJD} maxJDN={frEndJD} initialJDN={todayJDN}
|
||||
onJump={this.goToJDN}/>
|
||||
</div>
|
||||
|
||||
<div className="download">
|
||||
<h4>Export calendar</h4>
|
||||
<Export minJDN={frStartJD} maxJDN={frEndJD} initialJDN={todayJDN}/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
}
|
||||
|
|
92
frcal/src/Export.tsx
Normal file
92
frcal/src/Export.tsx
Normal 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
30
frcal/src/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -38,7 +38,7 @@ export default class App extends MonthBasedApp<number, JulianMonth> {
|
|||
|
||||
<div className="navigate">
|
||||
<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>
|
||||
</>;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue