diff --git a/docs/config_reference.md b/docs/config_reference.md
index 750a142..7899278 100644
--- a/docs/config_reference.md
+++ b/docs/config_reference.md
@@ -120,8 +120,10 @@ Copy `src/config_example.json` to `src/config.json` and customise. See the [Gett
- `PERMALINK.SHOW_PERMALINK` (boolean) — If true, display a "permalink" icon when each program item is expanded.
- `PERMALINK.PERMALINK_TITLE` (string) — "Title" text displayed when the mouse is hovered over the permalink icon.
- `CALENDARLINK` (object)
-- `CALENDARLINK.SHOW` (boolean) — If true, display a calendar download icon when each program item is expanded.
+- `CALENDARLINK.SHOW` (boolean) — If true, display a calendar download icon when each program item is expanded, and a button on the programme and My Schedule pages to download all listed items as a single calendar.
- `CALENDARLINK.TITLE` (string) — "Title" text displayed when the mouse is hovered over the calendar download icon.
+- `CALENDARLINK.DOWNLOAD_ALL_LABEL` (string) — Label for the button that downloads all listed items as a calendar. "@count" is replaced by the number of items. Defaults to "Download calendar (@count items)".
+- `CALENDARLINK.DOWNLOAD_ALL_TITLE` (string) — "Title" text displayed when the mouse is hovered over the download all button. Defaults to "Download an .ics file to import into your calendar app".
- `EXPAND` (object)
- `EXPAND.EXPAND_ALL_LABEL` (string) — Label text for the Expand All button.
- `EXPAND.COLLAPSE_ALL_LABEL` (string) — Label for the Collapse All button.
diff --git a/src/App.css b/src/App.css
index ee43411..2e4c2e6 100644
--- a/src/App.css
+++ b/src/App.css
@@ -992,6 +992,83 @@ div.switch-wrapper {
pointer-events: none;
}
+.result-controls {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-areas:
+ "total download"
+ "expand options";
+ align-items: center;
+ column-gap: 1rem;
+ margin: 0.5em;
+ margin-bottom: 1rem;
+}
+
+.result-controls .filter-total {
+ grid-area: total;
+}
+
+.result-controls .calendar-download {
+ grid-area: download;
+ justify-self: end;
+}
+
+.result-controls .filter-expand {
+ grid-area: expand;
+}
+
+.result-controls .filter-options {
+ grid-area: options;
+ justify-self: end;
+ margin: 0.5em 0 0;
+}
+
+.result-controls .switch-wrapper {
+ margin: 0;
+}
+
+@media (max-width: 32rem) {
+ .result-controls {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "total"
+ "download"
+ "expand"
+ "options";
+ }
+
+ .result-controls .calendar-download,
+ .result-controls .filter-options {
+ justify-self: start;
+ }
+}
+
+.calendar-download {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4em;
+ padding: 0.25rem 0;
+ border: none;
+ background: none;
+ color: var(--link-fg);
+ font: inherit;
+ font-size: smaller;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.calendar-download:hover,
+.calendar-download:focus {
+ color: var(--link-hover-fg);
+ text-decoration: underline;
+}
+
+.calendar-download:disabled {
+ color: var(--link-disabled);
+ text-decoration: none;
+ cursor: default;
+}
+
.reset-filters {
display: flex;
flex-direction: row;
diff --git a/src/components/CalendarDownloadButton.jsx b/src/components/CalendarDownloadButton.jsx
new file mode 100644
index 0000000..6a03554
--- /dev/null
+++ b/src/components/CalendarDownloadButton.jsx
@@ -0,0 +1,38 @@
+import PropTypes from "prop-types";
+import { HiCalendarDays } from "react-icons/hi2";
+import configData from "../config.json";
+import { calendarLinkEnabled, downloadListIcs } from "../utils/ICalendar";
+
+const CalendarDownloadButton = ({ items, calendar }) => {
+ if (!calendarLinkEnabled()) {
+ return null;
+ }
+ const label = (
+ configData.CALENDARLINK.DOWNLOAD_ALL_LABEL ??
+ "Download calendar (@count items)"
+ ).replace("@count", items.length);
+ const title =
+ configData.CALENDARLINK.DOWNLOAD_ALL_TITLE ??
+ "Download an .ics file to import into your calendar app";
+ return (
+
+ );
+};
+
+CalendarDownloadButton.propTypes = {
+ items: PropTypes.array.isRequired,
+ calendar: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ fileSuffix: PropTypes.string.isRequired,
+ }).isRequired,
+};
+
+export default CalendarDownloadButton;
diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx
index 11d62ea..5170409 100644
--- a/src/components/FilterableProgram.jsx
+++ b/src/components/FilterableProgram.jsx
@@ -8,6 +8,7 @@ import TagSelectors from "./TagSelectors";
import ResetButton from "./ResetButton";
import ProgramList from "./ProgramList";
import ShowPastItems from "./ShowPastItems";
+import CalendarDownloadButton from "./CalendarDownloadButton";
import { LocalTime } from "../utils/LocalTime";
import { buildLocationOptions, locationMatchesSelection } from "../utils/Venues";
@@ -339,17 +340,19 @@ const FilterableProgram = () => {
/>
{limitDropDown()}
-
-
-
{totalMessage}
-
-
-
-
+
+
{totalMessage}
+
+
+
+
diff --git a/src/components/MySchedule.jsx b/src/components/MySchedule.jsx
index 316f4f0..5c19e0b 100644
--- a/src/components/MySchedule.jsx
+++ b/src/components/MySchedule.jsx
@@ -3,6 +3,7 @@ import configData from "../config.json";
import ProgramList from "./ProgramList";
import ShowPastItems from "./ShowPastItems";
import ShareLink from "./ShareLink";
+import CalendarDownloadButton from "./CalendarDownloadButton";
import { LocalTime } from "../utils/LocalTime";
const MySchedule = () => {
@@ -44,16 +45,22 @@ const MySchedule = () => {
{configData.PROGRAM.MY_SCHEDULE.INTRO}
-
-
-
-
-
-
+
+
+
+
+
diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx
index 239955d..e31dd66 100644
--- a/src/components/ProgramItem.jsx
+++ b/src/components/ProgramItem.jsx
@@ -16,7 +16,7 @@ import PropTypes from "prop-types";
import { Temporal } from "@js-temporal/polyfill";
import { useState, useEffect } from "react";
import { LocalTime } from "../utils/LocalTime";
-import { downloadIcs } from "../utils/ICalendar";
+import { calendarLinkEnabled, downloadItemIcs } from "../utils/ICalendar";
import { venueForLocation } from "../utils/Venues";
const ProgramItem = ({ item, forceExpanded = false, now }) => {
@@ -73,14 +73,14 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => {
else locations.push(
);
const calendarLink =
- configData.CALENDARLINK && configData.CALENDARLINK.SHOW && configData.INTERACTIVE ? (
+ calendarLinkEnabled() ? (
{
event.preventDefault();
event.stopPropagation();
- downloadIcs(item);
+ downloadItemIcs(item);
}}
title={configData.CALENDARLINK.TITLE}
>
diff --git a/src/configSchema.json b/src/configSchema.json
index e6fc075..e53f29d 100644
--- a/src/configSchema.json
+++ b/src/configSchema.json
@@ -442,8 +442,10 @@
"type": "object",
"additionalProperties": false,
"properties": {
- "SHOW": { "type": "boolean", "description": "If true, display a calendar download icon when each program item is expanded." },
- "TITLE": { "type": "string", "description": "\"Title\" text displayed when the mouse is hovered over the calendar download icon." }
+ "SHOW": { "type": "boolean", "description": "If true, display a calendar download icon when each program item is expanded, and a button on the programme and My Schedule pages to download all listed items as a single calendar." },
+ "TITLE": { "type": "string", "description": "\"Title\" text displayed when the mouse is hovered over the calendar download icon." },
+ "DOWNLOAD_ALL_LABEL": { "type": "string", "description": "Label for the button that downloads all listed items as a calendar. \"@count\" is replaced by the number of items. Defaults to \"Download calendar (@count items)\"." },
+ "DOWNLOAD_ALL_TITLE": { "type": "string", "description": "\"Title\" text displayed when the mouse is hovered over the download all button. Defaults to \"Download an .ics file to import into your calendar app\"." }
}
},
"EXPAND": {
diff --git a/src/config_example.json b/src/config_example.json
index 5a35627..58d8a18 100644
--- a/src/config_example.json
+++ b/src/config_example.json
@@ -147,7 +147,9 @@
},
"CALENDARLINK": {
"SHOW": true,
- "TITLE": "Add To Calendar"
+ "TITLE": "Add To Calendar",
+ "DOWNLOAD_ALL_LABEL": "Download calendar (@count items)",
+ "DOWNLOAD_ALL_TITLE": "Download an .ics file to import into your calendar app"
},
"EXPAND": {
"EXPAND_ALL_LABEL": "Expand All",
diff --git a/src/utils/ICalendar.js b/src/utils/ICalendar.js
index 6b765c0..41f1f2a 100644
--- a/src/utils/ICalendar.js
+++ b/src/utils/ICalendar.js
@@ -1,4 +1,4 @@
-import { createEvent } from "ics";
+import { createEvent, createEvents } from "ics";
import configData from "../config.json";
const MAX_SLUG_LENGTH = 60;
@@ -18,55 +18,106 @@ function slugify(text) {
: slug.slice(0, MAX_SLUG_LENGTH).replace(/-[^-]*$/, "");
}
+function appPrefix() {
+ return slugify(configData.APP_ID) || "conclar";
+}
+
function filenameFor(item) {
- const prefix = slugify(configData.APP_ID) || "conclar";
const title = slugify(item.title) || item.id;
- return prefix + "-" + title + ".ics";
+ return appPrefix() + "-" + title + ".ics";
}
function describeItem(item) {
- let desc = item.desc;
+ const parts = [];
+ if (item.desc) {
+ parts.push(item.desc);
+ }
if (item.people && item.people.length > 0) {
- const names = item.people.map((person) => person.name.toString());
- desc = "\n\n" + names.join(", ");
+ parts.push(item.people.map((person) => person.name.toString()).join(", "));
+ }
+ if (parts.length === 0) {
+ return undefined;
}
- return new DOMParser().parseFromString(desc, "text/html").body.textContent;
+ return new DOMParser().parseFromString(parts.join("\n\n"), "text/html").body
+ .textContent;
}
-function buildIcsContent(item) {
- const { error, value } = createEvent({
+/**
+ * Whether items offer a download to add them to a calendar app.
+ *
+ * @returns {boolean}
+ */
+export function calendarLinkEnabled() {
+ return !!(configData.CALENDARLINK?.SHOW && configData.INTERACTIVE);
+}
+
+const PRODUCT_ID = "-//ConClar//ConClar//EN";
+
+function itemUrl(item) {
+ if (!configData.PERMALINK?.SHOW_PERMALINK || !configData.INTERACTIVE) {
+ return undefined;
+ }
+ return window.location.origin + window.publicUrl + "id/" + item.id;
+}
+
+function eventFor(item) {
+ return {
uid: configData.APP_ID + "-" + item.id + "@" + window.location.hostname,
- productId: "-//ConClar//ConClar//EN",
start: toDateArray(item.startDateAndTime),
startInputType: "utc",
end: toDateArray(item.endDateAndTime),
endInputType: "utc",
title: item.title,
- description: item.desc ? describeItem(item) : undefined,
+ description: describeItem(item),
location: item.loc ? String(item.loc) : undefined,
- });
-
- if (error) throw error;
- return value;
+ url: itemUrl(item),
+ };
}
/**
- * Trigger a browser download of an ICS event as a file, via a Blob and a
+ * Trigger a browser download of ICS content as a file, via a Blob and a
* temporary object URL. More reliable across browsers (notably Safari)
* than a `data:` URL with a `download` attribute.
*/
-export function downloadIcs(item) {
- const blob = new Blob([buildIcsContent(item)], {
+function downloadFile(content, filename) {
+ const blob = new Blob([content], {
type: "text/calendar;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
- link.download = filenameFor(item);
+ link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
+
+export function downloadItemIcs(item) {
+ const { error, value } = createEvent({
+ ...eventFor(item),
+ productId: PRODUCT_ID,
+ });
+ if (error) {
+ throw error;
+ }
+ downloadFile(value, filenameFor(item));
+}
+
+/**
+ * Download several items as a single calendar. `calendar.name` is used by
+ * calendar apps that offer to import into a new calendar; `calendar.fileSuffix`
+ * goes into the filename after the app prefix, so must already be a slug.
+ */
+export function downloadListIcs(items, calendar) {
+ const { error, value } = createEvents(items.map(eventFor), {
+ productId: PRODUCT_ID,
+ calName: calendar.name,
+ });
+ if (error) {
+ throw error;
+ }
+ downloadFile(value, appPrefix() + "-" + calendar.fileSuffix + ".ics");
+}