@liturgical-calendar/components-js
Version:
Liturgical calendar components for javascript: an html select populated with liturgical calendars supported by the Liturgical Calendar API; form controls for parameters that are supported by the Liturgical Calendar API; a webcalendar; and liturgy of the d
525 lines (497 loc) • 20.7 kB
JavaScript
import ApiOptions from '../ApiOptions/ApiOptions.js';
import CalendarSelect from '../CalendarSelect/CalendarSelect.js';
import EventEmitter from './EventEmitter.js';
import { YearType } from '../Enums.js';
/**
* A client for interacting with the Liturgical Calendar API.
* This class provides methods to fetch and manage liturgical calendar data,
* including the General Roman Calendar, National Calendars, and Diocesan Calendars.
*
* @class
* @description The ApiClient handles all API interactions for retrieving liturgical calendar data.
* It supports fetching calendar metadata, managing calendar settings, and retrieving specific calendar types
* (General Roman, National, or Diocesan). The class maintains internal state for calendar data and request parameters,
* and provides methods to listen to UI component changes.
*
* @example
* const client = new ApiClient();
* // Initialize with default API URL
* await ApiClient.init();
* // Fetch General Roman Calendar
* const calendarData = await client.fetchCalendar();
*
* @example
* // Fetch a National Calendar
* const client = new ApiClient();
* const nationalCalendarData = client.fetchNationalCalendar('IT').then( data => {
* // Handle the response data
* });
*/
export default class ApiClient {
/**
* @type {string}
* @private
* @static
* @default 'https://litcal.johnromanodorazio.com/api/dev'
*/
static #apiUrl = 'https://litcal.johnromanodorazio.com/api/dev';
/**
* @type {{calendars: '/calendars', calendar: '/calendar', events: '/events', easter: '/easter', decrees: '/decrees', data: '/data', missals: '/missals', tests: '/tests', schemas: '/schemas'}}
* @private
* @constant
*/
static #paths = Object.freeze({
calendars: '/calendars',
calendar: '/calendar',
events: '/events',
easter: '/easter',
decrees: '/decrees',
data: '/data',
missals: '/missals',
tests: '/tests',
schemas: '/schemas'
});
/**
* @type {import('../typedefs.js').CalendarMetadata | null}
* @private
* @static
* Response object from the API /calendars path
*/
static #metadata = null;
/**
* @type {{litcal: import('../typedefs.js').CalendarEvent[], settings: import('../typedefs.js').CalendarSettings, metadata: import('../typedefs.js').CalendarMetadata, messages: string[]}}
* @private
* @static
*/
#calendarData = {};
/**
* @type {{'Content-Type': 'application/json', Accept: 'application/json', ['Accept-Language']: string}}
*/
#fetchCalendarHeaders = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
/**
* Parameters for the API request sent as a JSON object representing key - value pairs, in the body of the request
* @type {{year: number, epiphany: string, ascension: string, corpus_christi: string, year_type: string, eternal_high_priest: boolean}}
*/
#params = {
year: new Date().getFullYear(),
epiphany: 'JAN6',
ascension: 'THURSDAY',
corpus_christi: 'THURSDAY',
eternal_high_priest: false,
year_type: YearType.LITURGICAL
};
/**
* An empty value for category means the General Roman Calendar.
* A value of 'national' means a national calendar, based on a Roman Missal as published in the given country / nation.
* A value of 'diocesan' means a diocesan calendar, which is based on the national calendar,
* with the addition of a few local celebrations.
* @type {'' | 'national' | 'diocesan'}
* @private
*/
#currentCategory = '';
/**
* The current calendar ID, which is used to fetch the corresponding calendar data.
* @type {string}
* @private
*/
#currentCalendarId = '';
/**
* The event bus that can be used to subscribe to events emitted by the ApiClient.
* @type {EventEmitter}
* @private
*/
#eventBus = null;
/**
* Initializes the ApiClient with an optional API URL.
* If a URL is provided, it sets the internal API URL to the given value.
* Then, it fetches the available liturgical calendars from the API.
*
* @param {string|null} url - Optional API URL to override the default URL.
* @returns {Promise<ApiClient|boolean>} A promise that resolves to an `ApiClient` instance when the calendar metadata has been fetched, or `false` if an error occurs.
* @static
*/
static init( url = null ) {
if ( url ) {
this.#apiUrl = url;
}
return ApiClient.#fetchCalendars();
}
/**
* Fetches metadata about available liturgical calendars from the API.
*
* This method sends a GET request to the API endpoint for calendars metadata when the `#metadata` property is null, and processes the response.
* If the request is successful, it extracts the `litcal_metadata` from the response data
* and assigns it to the `#metadata` property of the `ApiClient` class.
* If the `#metadata` property is not null, it returns a resolved promise with the `ApiClient` instance.
* This way, if the static init method is called more than once, initialization is only performed once, and only one fetch request is made to the API.
*
* @returns {Promise<ApiClient|boolean>} A promise that resolves to an `ApiClient` instance if the request is successful, or `false` if an error occurs.
*/
static #fetchCalendars() {
if ( null === this.#metadata ) {
return fetch( `${this.#apiUrl}${this.#paths.calendars}` ).then(response => {
if ( response.ok ) {
return response.json();
}
}).then(data => {
const { litcal_metadata } = data;
ApiClient.#metadata = litcal_metadata;
return new ApiClient();
}).catch(error => {
console.error( error );
return false;
});
} else {
return Promise.resolve(new ApiClient());
}
}
/**
* Instantiates a new instance of the ApiClient class.
*
* The constructor does not perform any specific actions, but it provides
* access to instance methods and private properties of the class.
* This allows the client to interact with the Liturgical Calendar API,
* possibly listening to changes in the UI components.
*/
constructor() {
this.#eventBus = new EventEmitter();
}
/**
* Refetches calendar data based on the current category and calendar ID.
*
* This method determines the current category of the calendar (national, diocesan, or general)
* and fetches the corresponding calendar data. It logs the fetched calendar type and the
* calendar data to the console once the data is retrieved.
*
* If the current category is 'national', it fetches the national calendar using the current
* calendar ID. If the category is 'diocesan', it fetches the diocesan calendar. For any other
* category, it fetches the General Roman Calendar.
*/
refetchCalendarData() {
if ( this.#currentCategory === 'national' ) {
this.fetchNationalCalendar( this.#currentCalendarId );
} else if ( this.#currentCategory === 'diocesan' ) {
this.fetchDiocesanCalendar( this.#currentCalendarId );
} else {
this.fetchCalendar();
}
}
/**
* Fetches the General Roman Calendar data from the API for a given year.
*
* @param {string|null} locale The locale for the General Roman Calendar. If null, the default or last set locale is used.
*
* This method sends a POST request to the calendar endpoint with the configured parameters.
* The year parameter is extracted from the request body and placed in the URL path.
* The remaining parameters are sent in the request body as JSON.
*
*/
fetchCalendar(locale = null) {
// Since the year parameter will be placed in the path, we extract it from the body params.
const { year, ...params } = this.#params;
if (locale !== null) {
if (typeof locale !== 'string') {
throw new Error('ApiClient.fetchCalendar: locale must be a string');
}
if (locale === '') {
throw new Error('ApiClient.fetchCalendar: Invalid locale identifier, cannot be an empty string');
}
locale = locale.replace(/_/g, '-');
try {
const testLocale = new Intl.Locale(locale);
if (ApiClient.#metadata.locales.includes(testLocale.language)) {
this.#fetchCalendarHeaders['Accept-Language'] = locale;
};
} catch (e) {
console.error(e);
}
}
fetch(`${ApiClient.#apiUrl}${ApiClient.#paths.calendar}${year ? `/${year}` : ''}`, {
method: 'POST',
headers: this.#fetchCalendarHeaders,
body: JSON.stringify( params )
}).then( response => {
if ( response.ok ) {
return response.json();
}
}).then( data => {
this.#calendarData = data;
this.#eventBus.emit( 'calendarFetched', data );
return this.#calendarData;
}).catch( error => {
console.error( error );
return false;
});
}
/**
* Fetches a national liturgical calendar from the API
* @param {string} calendar_id - The identifier for the national calendar to fetch
* @param {string} [locale] - The locale for the national calendar
* @throws {Error} When network request fails
* @description This method fetches a national liturgical calendar by its ID, and optionally a supported locale. It extracts the year from params
* to use in the URL path and sends other relevant parameters in the request body. Parameters that determine the dates for
* epiphany, ascension, corpus_christi, eternal_high_priest are excluded from the request parameters,
* as these options are built into the National calendar being requested.
*/
fetchNationalCalendar( calendar_id, locale = '' ) {
// Since the year parameter will be placed in the path, we extract it from the body params.
// However, the only body param we need in this case is year_type,
// so we also extract out all other params in order to discard them.
const { year, epiphany, ascension, corpus_christi, eternal_high_priest, ...params } = this.#params;
this.#currentCategory = 'national';
this.#currentCalendarId = calendar_id;
if (
typeof locale === 'string'
&& locale !== ''
) {
const phpLocale = locale.replace(/-/g, '_');
const jsLocale = phpLocale.replace(/_/g, '-');
const nationalCalendarMetadata = ApiClient.#metadata.national_calendars.filter(
calendar => calendar.calendar_id === calendar_id
)[0];
if ( nationalCalendarMetadata.locales.includes(phpLocale) ) {
this.#fetchCalendarHeaders['Accept-Language'] = jsLocale;
}
}
fetch(`${ApiClient.#apiUrl}${ApiClient.#paths.calendar}/nation/${calendar_id}${year ? `/${year}` : ''}`, {
method: 'POST',
headers: this.#fetchCalendarHeaders,
body: JSON.stringify( params )
}).then( response => {
if ( response.ok ) {
return response.json();
}
}).then( data => {
this.#calendarData = data;
this.#eventBus.emit( 'calendarFetched', data );
return this.#calendarData;
}).catch( error => {
console.error( error );
return false;
});
}
/**
* Fetches a diocesan liturgical calendar from the API
* @param {string} calendar_id - The identifier for the diocesan calendar to fetch
* @throws {Error} When network request fails
* @description This method fetches a diocesan liturgical calendar by its ID. It extracts the year from params
* to use in the URL path and sends other relevant parameters in the request body. Parameters that determine the dates for
* epiphany, ascension, corpus_christi, eternal_high_priest are excluded from the request parameters,
* as these options are built into the Diocesan calendar being requested.
*/
fetchDiocesanCalendar( calendar_id ) {
// Since the year parameter will be placed in the path, we extract it from the body params.
// However, the only body param we need in this case is year_type,
// so we also extract out all other params in order to discard them.
const { year, epiphany, ascension, corpus_christi, eternal_high_priest, ...params } = this.#params;
this.#currentCategory = 'diocesan';
this.#currentCalendarId = calendar_id;
fetch(`${ApiClient.#apiUrl}${ApiClient.#paths.calendar}/diocese/${calendar_id}${year ? `/${year}` : ''}`, {
method: 'POST',
headers: this.#fetchCalendarHeaders,
body: JSON.stringify( params )
}).then( response => {
if ( response.ok ) {
return response.json();
}
}).then( data => {
this.#calendarData = data;
this.#eventBus.emit( 'calendarFetched', data );
return this.#calendarData;
}).catch( error => {
console.error( error );
return false;
});
}
listenTo( uiComponent = null ) {
if ( false === uiComponent instanceof CalendarSelect && false === uiComponent instanceof ApiOptions ) {
throw new Error( 'ApiClient.listenTo(): Expected an instance of CalendarSelect or ApiOptions' );
}
if (uiComponent instanceof CalendarSelect) {
return this.#listenToCalendarSelect( uiComponent );
} else if (uiComponent instanceof ApiOptions) {
return this.#listenToApiOptions( uiComponent );
}
}
/**
* Listens to changes in the CalendarSelect instance and fetches the corresponding calendar from the API.
* @param {CalendarSelect} calendarSelect - The CalendarSelect instance to listen to
* @throws {Error} If the provided argument is not an instance of CalendarSelect
* @returns {ApiClient} The current instance
*/
#listenToCalendarSelect( calendarSelect = null ) {
if ( false === calendarSelect instanceof CalendarSelect ) {
throw new Error( 'Expected an instance of CalendarSelect' );
}
if ( null === calendarSelect ) {
throw new Error( 'Expected an instance of CalendarSelect' );
}
calendarSelect._domElement.addEventListener( 'change', () => {
const selectedOption = calendarSelect._domElement.selectedOptions[0];
this.#currentCalendarId = selectedOption.value;
this.#currentCategory = selectedOption.dataset.calendartype ?? '';
if ( this.#currentCategory === 'national' ) {
this.fetchNationalCalendar( this.#currentCalendarId );
} else if ( this.#currentCategory === 'diocesan' ) {
this.fetchDiocesanCalendar( this.#currentCalendarId );
} else {
this.fetchCalendar();
}
});
return this;
}
/**
* Listens to changes in the API options and updates the parameters accordingly.
*
* This function attaches event listeners to various inputs within the ApiOptions instance.
* When the user changes the value of these inputs, the corresponding parameter in the
* request configuration is updated. If the current category is not set, it triggers
* a refetch of the calendar data.
*
* @param {ApiOptions} apiOptions - The ApiOptions instance containing inputs to listen to
* @throws {Error} If the provided argument is not an instance of ApiOptions
* @returns {ApiClient} The current instance
*/
#listenToApiOptions(apiOptions = null) {
if (false === apiOptions instanceof ApiOptions) {
throw new Error('Expected an instance of ApiOptions');
}
if (null === apiOptions) {
throw new Error('Expected an instance of ApiOptions');
}
apiOptions._epiphanyInput._domElement.addEventListener( 'change', event => {
this.#params.epiphany = event.target.value;
console.log(`updated epiphany to ${this.#params.epiphany}`);
if (this.#currentCategory === '') {
this.refetchCalendarData();
}
});
apiOptions._ascensionInput._domElement.addEventListener( 'change', event => {
this.#params.ascension = event.target.value;
console.log(`updated ascension to ${this.#params.ascension}`);
if (this.#currentCategory === '') {
this.refetchCalendarData();
}
});
apiOptions._corpusChristiInput._domElement.addEventListener( 'change', event => {
this.#params.corpus_christi = event.target.value;
console.log(`updated corpus_christi to ${this.#params.corpus_christi}`);
if (this.#currentCategory === '') {
this.refetchCalendarData();
}
});
apiOptions._eternalHighPriestInput._domElement.addEventListener( 'change', event => {
this.#params.eternal_high_priest = event.target.value === 'true';
console.log(`updated eternal_high_priest to ${this.#params.eternal_high_priest}`);
if (this.#currentCategory === '') {
this.refetchCalendarData();
}
});
apiOptions._yearInput._domElement.addEventListener( 'change', event => {
this.#params.year = event.target.value;
console.log(`updated year to ${this.#params.year}`);
this.refetchCalendarData();
});
apiOptions._yearTypeInput._domElement.addEventListener( 'change', event => {
this.#params.year_type = event.target.value;
console.log(`updated year_type to ${this.#params.year_type}`);
this.refetchCalendarData();
});
apiOptions._localeInput._domElement.addEventListener( 'change', event => {
this.#fetchCalendarHeaders['Accept-Language'] = event.target.value;
console.log(`updated locale to ${this.#fetchCalendarHeaders['Accept-Language']}`);
this.refetchCalendarData();
});
return this;
}
/**
* Set the year for which the calendar is to be retrieved.
* @param {number} year - The year for which to retrieve the calendar. Must be a number and be between 1970 and 9999.
* @throws {Error} If no year is given, or if the year is not a number, or if the year is not between 1970 and 9999.
*/
setYear( year ) {
if (year !== undefined) {
if (typeof year !== 'number' || !Number.isInteger(year) || year < 1970 || year > 9999) {
throw new Error('year must be a number and be between 1970 and 9999');
}
this.#params.year = year;
} else {
throw new Error('year parameter is required');
}
return this;
}
/**
* Set the type of the year for which the calendar is to be retrieved.
* @param {YearType} year_type - The type of the year for which to retrieve the calendar. Must be either LITURGICAL or CIVIL.
* @throws {Error} If no year_type is given, or if the year_type is not either LITURGICAL or CIVIL.
*/
setYearType( year_type ) {
if (year_type !== undefined) {
if (year_type !== YearType.LITURGICAL && year_type !== YearType.CIVIL) {
throw new Error('year_type must be either LITURGICAL or CIVIL');
}
this.#params.year_type = year_type;
}
return this;
}
/**
* This static getter provides access to the metadata object that contains information
* about the available liturgical calendars, including national and diocesan calendars.
* The metadata is initially fetched from the API during the client initialization.
*
* @returns {import('../typedefs.js').CalendarMetadata} An object containing the metadata of the liturgical calendars.
*/
static get _metadata() {
return ApiClient.#metadata;
}
/**
* Static getter provides access to the internal API URL
* used by the ApiClient to make requests to the liturgical calendar API.
*
* @returns {string} The API URL.
*/
static get _apiUrl() {
return ApiClient.#apiUrl;
}
/**
* The metadata object that contains information about the available liturgical
* calendars, including national and diocesan calendars.
* The metadata is initially fetched from the API during static ApiClient initialization.
*
* @type {import('../typedefs.js').CalendarMetadata}
*/
get _metadata() {
return ApiClient.#metadata;
}
/**
* The internal API URL used by the ApiClient to make requests to the liturgical calendar API.
*
* @returns {string} The API URL.
*/
get _apiUrl() {
return ApiClient.#apiUrl;
}
/**
* @returns {import('../typedefs.js').CalendarData} The currently cached calendar data.
* This property can be used to retrieve the current liturgical calendar data.
* Note that the data is only available after `fetchCalendar()`, `fetchNationalCalendar()`,
* or `fetchDiocesanCalendar()` has been called.
*/
get _calendarData() {
return this.#calendarData;
}
/**
* The event bus that can be used to subscribe to events emitted by the ApiClient.
*
* The event bus emits events of type `calendarFetched` when a new calendar is fetched
* from the API. The event detail is an object of type `CalendarData` containing the
* liturgical events of the fetched calendar.
* @type {EventEmitter}
*/
get _eventBus() {
return this.#eventBus;
}
}