diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml new file mode 100644 index 0000000..42beb8d --- /dev/null +++ b/.github/workflows/firebase-hosting-merge.yml @@ -0,0 +1,22 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting on merge +'on': + push: + branches: + - master +jobs: + build_and_deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: 'npm ci && npm run build:firebase:hosting' + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: '${{ secrets.GITHUB_TOKEN }}' + firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_FB2ICAL_3051B }}' + channelId: live + projectId: fb2ical-3051b + env: + FIREBASE_CLI_PREVIEWS: hostingchannels diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml new file mode 100644 index 0000000..9236a08 --- /dev/null +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -0,0 +1,18 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting on PR +'on': pull_request +jobs: + build_and_preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: 'npm ci && npm run build:firebase:hosting' + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: '${{ secrets.GITHUB_TOKEN }}' + firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_FB2ICAL_3051B }}' + projectId: fb2ical-3051b + env: + FIREBASE_CLI_PREVIEWS: hostingchannels diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..6aa168b --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,25 @@ +name: base + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build_and_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2-beta + with: + node-version: 12.7 + - name: Install, build and test + timeout-minutes: 10 + run: | + npm ci --no-color --no-progress + npm run build + npm run test + env: + CI: true + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6005f4c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,3 +0,0 @@ -language: node_js -node_js: - - 10.15.0 diff --git a/functions/logger.js b/functions/logger.js index b3b189e..f7d719c 100644 --- a/functions/logger.js +++ b/functions/logger.js @@ -14,8 +14,8 @@ class FirebaseTransport extends Transport { callback(null, info) this.emit('logged', info) } catch (err) { - callback(error) - this.emit('error', error) + callback(err) + this.emit('error', err) } return info diff --git a/lib/frontend/actions/events.js b/lib/frontend/actions/events.js new file mode 100644 index 0000000..f89bcfa --- /dev/null +++ b/lib/frontend/actions/events.js @@ -0,0 +1,68 @@ +import { postURL } from '../services' +import { eventStore, parseStatusStore, requestStore } from '../stores' +import { Request } from '../records' +import { uuidv4, parseStartTimeFromiCalString, promptDownload } from '../utils' +import { extractEventDataFromHTML } from '../../../lib/services/ics-retriever' +import generateICS from '../../../lib/services/ics-generator' + +const getEventHTML = async (url) => { + const formData = new URLSearchParams() + formData.set('url', url) + + try { + const request = new Request({ + id: uuidv4(), + url, + }) + requestStore.set(request) + const response = await postURL(formData) + const text = await response.text() + + requestStore.set(null) + return text + } catch (error) { + requestStore.update((prevRequest) => { + prevRequest.error = error + return prevRequest + }) + return null + } +} + +const createICS = async (html, url, { logger }) => { + try { + parseStatusStore.set('Parsing event data...') + + const eventData = extractEventDataFromHTML(html, url, { logger }) + const text = await generateICS(eventData) + const dataUri = encodeURIComponent(text) + const uri = `data:text/calendar;charset=utf-8,${dataUri}` + + const summaryMatch = text.match(/SUMMARY:.*/)[0] + const summary = summaryMatch ? summaryMatch.replace(/SUMMARY:/, '') : '' + const startTimeMatches = text.match(/DTSTART:.*/) + const startTimeMatch = text.length > 0 ? + (startTimeMatches[0] || '').replace(/DTSTART:/, '') : + '' + const startTime = parseStartTimeFromiCalString(startTimeMatch) + + eventStore.setCalculation({ + id: uuidv4(), + link: uri, + createdAt: new Date(), + startTime, + title: summary, + }) + + parseStatusStore.set(null) + + promptDownload(uri) + } catch (err) { + parseStatusStore.set(err) + } +} + +export const createEvent = async (url, { logger }) => { + const html = await getEventHTML(url) + const ics = await createICS(html, url, { logger }) +} diff --git a/lib/frontend/actions/index.js b/lib/frontend/actions/index.js new file mode 100644 index 0000000..66b41ed --- /dev/null +++ b/lib/frontend/actions/index.js @@ -0,0 +1,5 @@ +import { createEvent } from './events' + +export { + createEvent, +} diff --git a/lib/frontend/components/App.svelte b/lib/frontend/components/App.svelte new file mode 100644 index 0000000..208abda --- /dev/null +++ b/lib/frontend/components/App.svelte @@ -0,0 +1,5 @@ + + + diff --git a/lib/frontend/components/AppContainer.svelte b/lib/frontend/components/AppContainer.svelte new file mode 100644 index 0000000..0e290e0 --- /dev/null +++ b/lib/frontend/components/AppContainer.svelte @@ -0,0 +1,21 @@ + + +{#if showTrackingPanel} + +{/if} + + +{#if showEventList} + +{/if} + diff --git a/lib/frontend/components/EventList.svelte b/lib/frontend/components/EventList.svelte new file mode 100644 index 0000000..213c698 --- /dev/null +++ b/lib/frontend/components/EventList.svelte @@ -0,0 +1,83 @@ + + + + +
+ + + + + + + + + + {#each $eventStore.events as event (event.id)} + + + + + + {/each} + +
DateName
+ {event.startTime + ? new Date(event.startTime).toLocaleString() + : 'N/A\xa0\xa0\xa0\xa0\xa0' + } + + {event.title} + +
handleRecordDelete(event.id)} + > + ✖︎ +
+
+
diff --git a/lib/frontend/components/Input.svelte b/lib/frontend/components/Input.svelte new file mode 100644 index 0000000..76ef703 --- /dev/null +++ b/lib/frontend/components/Input.svelte @@ -0,0 +1,78 @@ + + + + +
+ + +
diff --git a/lib/frontend/components/InputContainer.svelte b/lib/frontend/components/InputContainer.svelte new file mode 100644 index 0000000..f0d75c2 --- /dev/null +++ b/lib/frontend/components/InputContainer.svelte @@ -0,0 +1,26 @@ + + +
+ + +
diff --git a/lib/frontend/components/Status.svelte b/lib/frontend/components/Status.svelte new file mode 100644 index 0000000..eaded9d --- /dev/null +++ b/lib/frontend/components/Status.svelte @@ -0,0 +1,54 @@ + + + + +
+ {#if error} +
+ {error.toString()} +
+ {/if} + {#if pending && pendingRequest} +
+ Fetching event {pendingRequest.url} +
+ {/if} + {#if status} +
+ {status} +
+ {/if} + {#if swStatus} +
+ {swStatus} +
+ {/if} +
diff --git a/lib/frontend/components/TrackingPanel.svelte b/lib/frontend/components/TrackingPanel.svelte new file mode 100644 index 0000000..4f0e9fb --- /dev/null +++ b/lib/frontend/components/TrackingPanel.svelte @@ -0,0 +1,46 @@ + + + + +
+

+ Can we store anonymous logs? This data is only saved to our internal database and is used to debug the parsing of the web pages. We'll ask you only this time and won't bother you again. +

+ + + +
diff --git a/lib/frontend/constants.js b/lib/frontend/constants.js new file mode 100644 index 0000000..2926e14 --- /dev/null +++ b/lib/frontend/constants.js @@ -0,0 +1,5 @@ +export const STORAGE_KEYS = { + CONFIG: 'fb-to-ical-config', + EVENTS: 'fb-to-ical-events', +} + diff --git a/lib/frontend/index.js b/lib/frontend/index.js new file mode 100644 index 0000000..20ffc08 --- /dev/null +++ b/lib/frontend/index.js @@ -0,0 +1,27 @@ +import App from './components/App.svelte' + +import * as stores from './stores' +import * as services from './services' +import serviceWorkerBoot from './sw-boot' +import loggerBoot from './logger-boot' + +const boot = () => { + + services.storageListener.register() + + serviceWorkerBoot() + loggerBoot() + + new App({ + target: document.querySelector('#root'), + }) + + if (process.env.NODE_ENV === 'development') { + window._fb2ical = { + ...stores, + ...services, + } + } +} + +boot() diff --git a/lib/frontend/logger-boot.js b/lib/frontend/logger-boot.js new file mode 100644 index 0000000..472f8b1 --- /dev/null +++ b/lib/frontend/logger-boot.js @@ -0,0 +1,8 @@ +import logger from './services/logger' +import { configStore } from './stores' + +export default () => { + const enableTracking = configStore.track + + logger.setRemoteLogging(enableTracking) +} diff --git a/lib/frontend/records/index.js b/lib/frontend/records/index.js new file mode 100644 index 0000000..5903ca9 --- /dev/null +++ b/lib/frontend/records/index.js @@ -0,0 +1,5 @@ +import Request from './request' + +export { + Request, +} diff --git a/lib/frontend/records/request.js b/lib/frontend/records/request.js new file mode 100644 index 0000000..320411d --- /dev/null +++ b/lib/frontend/records/request.js @@ -0,0 +1,11 @@ +export default class Request { + constructor({ + id, + url, + error, + }) { + this.id = id + this.url = url + this.error = error + } +} diff --git a/lib/frontend/services/index.js b/lib/frontend/services/index.js new file mode 100644 index 0000000..46dfa35 --- /dev/null +++ b/lib/frontend/services/index.js @@ -0,0 +1,7 @@ +import { postURL } from './network' +import storageListener from './storageListener' + +export { + postURL, + storageListener, +} diff --git a/lib/static/app/logger.js b/lib/frontend/services/logger.js similarity index 100% rename from lib/static/app/logger.js rename to lib/frontend/services/logger.js diff --git a/lib/frontend/services/network.js b/lib/frontend/services/network.js new file mode 100644 index 0000000..8a4b814 --- /dev/null +++ b/lib/frontend/services/network.js @@ -0,0 +1,23 @@ +export const postURL = (data) => { + return new Promise((resolve, reject) => { + fetch('/download/html/', { + method: 'POST', + headers: { + 'Accept': 'text/html, application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data, + }).then((response) => { + if (response.status !== 200) { + if (response.body.constructor === ReadableStream) { + response.json().then((json) => reject(json.error || response.statusText)) + return + } + reject(response.statusText) + return + } + + resolve(response) + }).catch(reject) + }) +} diff --git a/lib/frontend/services/storageListener.js b/lib/frontend/services/storageListener.js new file mode 100644 index 0000000..15d3697 --- /dev/null +++ b/lib/frontend/services/storageListener.js @@ -0,0 +1,51 @@ +import { STORAGE_KEYS } from '../constants' +import { configStore, eventStore } from '../stores' + +class StorageListener { + constructor() { + this._storeSubscribers = new Set() + } + + register() { + window.addEventListener('storage', this._handleStorageChange) + + const unsubscribeConfigStore = configStore.subscribe(this._handleConfigStoreChange) + const unsubscribeEventStore = eventStore.subscribe(this._handleEventStoreChange) + + this._storeSubscribers = new Set([ + ...this._storeSubscribers, + unsubscribeConfigStore, + unsubscribeEventStore, + ]) + } + + unregister() { + window.removeEventListener('storage', this._handleStorageChange) + + this._storeSubscribers.forEach((unsubscribe) => unsubscribe()) + this._storeSubscribers.clear() + } + + _handleStorageChange(event) { + switch (event.key) { + case STORAGE_KEYS.CONFIG: + configStore.set(JSON.parse(event.newValue)) + break + case STORAGE_KEYS.EVENTS: + eventStore.set({ events: JSON.parse(event.newValue) }) + break + default: + return + } + } + + _handleConfigStoreChange(value) { + localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(value)) + } + + _handleEventStoreChange(value) { + localStorage.setItem(STORAGE_KEYS.EVENTS, JSON.stringify(value.events || [])) + } +} + +export default new StorageListener() diff --git a/lib/frontend/stores/configStore.js b/lib/frontend/stores/configStore.js new file mode 100644 index 0000000..792a10b --- /dev/null +++ b/lib/frontend/stores/configStore.js @@ -0,0 +1,26 @@ +import { writable } from 'svelte/store' + +import { STORAGE_KEYS } from '../constants' + +const createConfigStore = () => { + const state = JSON.parse(localStorage.getItem(STORAGE_KEYS.CONFIG) || '{}') + + const { subscribe, set, update } = writable(state) + + const setValue = (key, value) => { + update((prevState) => ({ ...prevState, [key]: value })) + } + + const getState = () => state + + return { + ...state, + subscribe, + set, + update, + setValue, + getState, + } +} + +export default createConfigStore() diff --git a/lib/frontend/stores/eventStore.js b/lib/frontend/stores/eventStore.js new file mode 100644 index 0000000..1a5877e --- /dev/null +++ b/lib/frontend/stores/eventStore.js @@ -0,0 +1,65 @@ +import { writable } from 'svelte/store' + +import { STORAGE_KEYS } from '../constants' +import { migrateRecord, sortRecord } from '../utils' + +const createEventStore = () => { + const storedState = JSON.parse(localStorage.getItem(STORAGE_KEYS.EVENTS) || '[]') + .map(migrateRecord) + .sort(sortRecord) + + let state = { events: storedState } + + const getState = () => state + + const { subscribe, set, update } = writable(state) + + const setCalculation = ({ id, link, createdAt, startTime, title }) => { + update((prevState) => { + const nextState = { + ...prevState, + events: [ + ...prevState.events, + { + id, + link, + createdAt: createdAt.toString(), + startTime: startTime.toString(), + title, + }, + ].sort(sortRecord) + } + + state = nextState + return nextState + }) + } + + const clearCalculation = (id) => { + const calculationIndex = getState().events.findIndex((event) => event.id === id) + + if (calculationIndex === -1) { + return + } + + const nextEvents = [ ...getState().events ] + nextEvents.splice(calculationIndex, 1) + + const nextState = { ...getState(), events: nextEvents } + state = nextState + + set(nextState) + } + + return { + ...state, + subscribe, + set, + update, + setCalculation, + clearCalculation, + getState, + } +} + +export default createEventStore() diff --git a/lib/frontend/stores/index.js b/lib/frontend/stores/index.js new file mode 100644 index 0000000..5a72cca --- /dev/null +++ b/lib/frontend/stores/index.js @@ -0,0 +1,13 @@ +import configStore from './configStore' +import eventStore from './eventStore' +import parseStatusStore from './parseStatusStore' +import requestStore from './requestStore' +import swStatusStore from './swStatusStore' + +export { + configStore, + eventStore, + parseStatusStore, + requestStore, + swStatusStore, +} diff --git a/lib/frontend/stores/parseStatusStore.js b/lib/frontend/stores/parseStatusStore.js new file mode 100644 index 0000000..8a3735d --- /dev/null +++ b/lib/frontend/stores/parseStatusStore.js @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store' + +export default writable(null) diff --git a/lib/frontend/stores/requestStore.js b/lib/frontend/stores/requestStore.js new file mode 100644 index 0000000..8a3735d --- /dev/null +++ b/lib/frontend/stores/requestStore.js @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store' + +export default writable(null) diff --git a/lib/frontend/stores/swStatusStore.js b/lib/frontend/stores/swStatusStore.js new file mode 100644 index 0000000..8a3735d --- /dev/null +++ b/lib/frontend/stores/swStatusStore.js @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store' + +export default writable(null) diff --git a/lib/frontend/sw-boot.js b/lib/frontend/sw-boot.js new file mode 100644 index 0000000..8ad68d2 --- /dev/null +++ b/lib/frontend/sw-boot.js @@ -0,0 +1,40 @@ +import { swStatusStore } from './stores' + +export default () => { + if (!window.navigator || !window.navigator.serviceWorker) { + return + } + + const { serviceWorker } = window.navigator + + serviceWorker.register('sw.js', { + scope: './', + }).then((registration) => { + swStatusStore.set(`Service worker registered with scope ${registration.scope}`) + setTimeout(() => { + swStatusStore.set(null) + }, 4500) + + registration.addEventListener('updatefound', () => { + console.info('Service worker will be updated...') + const newWorker = registration.installing + + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed') { + newWorker.postMessage({ action: 'skipWaiting' }) + } + }) + }) + }).catch((err) => { + swStatusStore.set(`Service worker error: ${err.toString()}`) + }) + + let refreshing + serviceWorker.addEventListener('controllerchange', () => { + if (refreshing) { + return + } + window.location.reload() + refreshing = true + }) +} diff --git a/lib/frontend/utils.js b/lib/frontend/utils.js new file mode 100644 index 0000000..9abeb83 --- /dev/null +++ b/lib/frontend/utils.js @@ -0,0 +1,57 @@ +export const migrateRecord = (record) => { + // NOTE: v3 records + const id = record.id || record.order + const startTime = record.startTime || null + + return { + ...record, + id, + startTime, + } +} + +export const sortRecord = (a, b) => { + const aDate = new Date(a.createdAt) + const bDate = new Date(b.createdAt) + + if (aDate < bDate) { + return 1 + } + if (aDate > bDate) { + return -1 + } + return 0 +} + +// NOTE: Generate random IDs: https://stackoverflow.com/a/2117523/3056783 +export const uuidv4 = () => { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ) +} + +export const parseStartTimeFromiCalString = (text = '') => { + const [ dateStr, timeStr ] = text.split('T') + const rawDate = dateStr || '' + const rawTime = timeStr || '' + + const year = Number(rawDate.slice(0, 4)) + const month = Number(Math.max(rawDate.slice(4, 6) - 1), 0) + const date = Number(rawDate.slice(6, 8)) + const hour = Number(rawTime.slice(0, 2)) + const minutes = Number(rawTime.slice(2, 4)) + const seconds = Number(rawTime.slice(4, 6)) + + const parsedDate = new Date(year, month, date, hour, minutes, seconds) + return parsedDate.toString() +} + +export const promptDownload = (uri) => { + const link = document.getElementById('current-download') + + link.setAttribute('href', uri) + link.setAttribute('download', 'download.ics') + link.click() + + link.setAttribute('href', '') +} diff --git a/lib/static/app/crawler.js b/lib/static/app/crawler.js deleted file mode 100644 index 4c1024a..0000000 --- a/lib/static/app/crawler.js +++ /dev/null @@ -1,20 +0,0 @@ -const crawl = async (url, { logger }) => { - if (logger) { - logger.log({ - message: `Crawl started for url: ${url}`, - level: 'info', - service: 'parser', - }) - } - - return new Promise((resolve, reject) => { - fetch(url, { - method: 'GET', - }).then((response) => { - console.log(response) - resolve() - }).catch(reject) - }) -} - -export default crawl diff --git a/lib/static/app/storage.js b/lib/static/app/storage.js deleted file mode 100644 index bb60519..0000000 --- a/lib/static/app/storage.js +++ /dev/null @@ -1,113 +0,0 @@ -import { useStorage } from './utils' - -const migrateRecord = (record) => { - // NOTE: v3 records - const id = record.id || record.order - const startTime = record.startTime || null - - return { - ...record, - id, - startTime, - } -} - -const getStorage = () => { - if (!useStorage()) { - return null - } - - const storage = localStorage.getItem('fb-to-ical-events') - - if (!storage) { - localStorage.setItem('fb-to-ical-events', JSON.stringify([])) - return "[]" - } - - return storage -} - -const getConfigStorage = () => { - if (!useStorage()) { - return null - } - - const storage = localStorage.getItem('fb-to-ical-config') - - if (!storage) { - localStorage.setItem('fb-to-ical-config', JSON.stringify({})) - return "{}" - } - - return storage -} - -const getStorageContents = (storage) => { - return JSON.parse(storage) -} - -const updateStorage = (storageContents) => { - const encodedStorage = JSON.stringify(storageContents) - - localStorage.setItem('fb-to-ical-events', encodedStorage) -} - -const updateConfigStorage = (storageContents, key, value) => { - const encodedStorage = JSON.stringify({ - ...storageContents, - [key]: value, - }) - - localStorage.setItem('fb-to-ical-config', encodedStorage) -} - -const saveRecord = ({ id, link, createdAt, startTime, title }) => { - if (!useStorage()) { - return - } - - const storage = getStorage() - const storageContents = getStorageContents(storage) - - const record = { - id, - link, - createdAt: createdAt.toString(), - startTime: startTime.toString(), - title, - } - - updateStorage([ ...storageContents, record ]) -} - -const deleteRecord = (id) => { - if (!useStorage()) { - return - } - - const storage = getStorage() - const storageContents = getStorageContents(storage) - const index = storageContents.findIndex((record) => { - return record.id === id - }) - - if (!Number.isFinite(index)) { - return - } - - const nextStorage = [ ...storageContents ] - nextStorage.splice(index, 1) - - updateStorage(nextStorage) -} - -export { - migrateRecord, - getStorage, - getConfigStorage, - getStorageContents, - updateStorage, - updateConfigStorage, - saveRecord, - deleteRecord, -} diff --git a/lib/static/app/utils.js b/lib/static/app/utils.js deleted file mode 100644 index bdd0189..0000000 --- a/lib/static/app/utils.js +++ /dev/null @@ -1,30 +0,0 @@ -const useStorage = () => Boolean(window.localStorage) - -// NOTE: Generate random IDs: https://stackoverflow.com/a/2117523/3056783 -const uuidv4 = () => { - return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => - (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) - ) -} - -const parseStartTimeFromiCalString = (text = '') => { - const [ dateStr, timeStr ] = text.split('T') - const rawDate = dateStr || '' - const rawTime = timeStr || '' - - const year = Number(rawDate.slice(0, 4)) - const month = Number(Math.max(rawDate.slice(4, 6) - 1), 0) - const date = Number(rawDate.slice(6, 8)) - const hour = Number(rawTime.slice(0, 2)) - const minutes = Number(rawTime.slice(2, 4)) - const seconds = Number(rawTime.slice(4, 6)) - - const parsedDate = new Date(year, month, date, hour, minutes, seconds) - return parsedDate.toString() -} - -export { - uuidv4, - parseStartTimeFromiCalString, - useStorage, -} diff --git a/lib/static/index.html b/lib/static/index.html index 4f70a40..21687e0 100644 --- a/lib/static/index.html +++ b/lib/static/index.html @@ -23,59 +23,32 @@ file that you can import into your calendar. Only public events are supported.

-
- - -
- -
-
- Fetching file... -
-
- Parsing data... -
-
-
-
-
-
- -
- -
- - - - - - - - - - - -
+
- +