Merge pull request #6 from comatory/feature-use-svelte-framework

Feature use svelte framework
This commit is contained in:
Ondrej Synacek 2020-12-25 13:51:44 +01:00 committed by GitHub
commit 1b0bb1c2a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 4130 additions and 3605 deletions

View File

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

View File

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

25
.github/workflows/main.yml vendored Normal file
View File

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

View File

@ -1,3 +0,0 @@
language: node_js
node_js:
- 10.15.0

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { createEvent } from './events'
export {
createEvent,
}

View File

@ -0,0 +1,5 @@
<script>
import AppContainer from './AppContainer.svelte'
</script>
<AppContainer />

View File

@ -0,0 +1,21 @@
<script>
import InputContainer from './InputContainer.svelte'
import EventList from './EventList.svelte'
import Status from './Status.svelte'
import TrackingPanel from './TrackingPanel.svelte'
import { configStore, eventStore } from '../stores'
$: showTrackingPanel = $configStore.track === undefined
$: showEventList = $eventStore.events.length > 0
</script>
{#if showTrackingPanel}
<TrackingPanel />
{/if}
<InputContainer />
{#if showEventList}
<EventList />
{/if}

View File

@ -0,0 +1,83 @@
<style>
.list-wrapper {
max-height: 50vh;
overflow: auto;
}
#list {
width: 100%;
}
thead {
font-weight: 800;
}
tbody tr:nth-child(odd) {
background-color: whitesmoke;
}
tbody tr:nth-child(even) {
background-color: #e8e8e8;
}
td.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
td.actions div {
margin: 2px;
text-decoration: none;
}
.delete-record {
font-size: 1.2rem;
cursor: pointer;
}
</style>
<script>
import { eventStore } from '../stores'
const handleRecordDelete = (id) => {
eventStore.clearCalculation(id)
}
</script>
<div class='list-wrapper'>
<table id="list">
<thead>
<tr>
<td>Date</td>
<td>Name</td>
<td></td>
</tr>
</thead>
<tbody>
{#each $eventStore.events as event (event.id)}
<tr>
<td>
{event.startTime
? new Date(event.startTime).toLocaleString()
: 'N/A\xa0\xa0\xa0\xa0\xa0'
}
</td>
<td>
<a href={event.link}>{event.title}</a>
</td>
<td class='actions'>
<div
class='delete-record'
role='button'
tabIndex={0}
on:click={() => handleRecordDelete(event.id)}
>
✖︎
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View File

@ -0,0 +1,78 @@
<style>
#form {
box-sizing: border-box;
flex: 1;
display: flex;
min-width: 300px;
}
#form input {
margin: 5px;
}
input#url {
flex: 1;
font-size: 1rem;
}
.input--error {
border: 1px solid firebrick;
}
</style>
<script>
import { createEvent } from '../actions'
import logger from '../services/logger'
export let error
export let pending
export let pendingRequest
let value = ''
$: value = error && pendingRequest && pendingRequest.url || ''
let form
let inputClasses = ''
$: inputClasses = [
error ? 'input--error' : '',
pending ? 'input--pending': '',
].join(' ')
const handleSubmit = (e) => {
if (!form.reportValidity()) {
return
}
e.preventDefault()
createEvent(value, { logger })
value = ''
}
const handleChange = (e) => {
value = e.currentTarget.value
}
</script>
<form id="form" on:submit={handleSubmit} bind:this={form}>
<input
required
pattern={String.raw`^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|\d+$`}
id="url"
name="url"
class={inputClasses}
disabled={pending}
placeholder="Paste / type FB event URL or event number..."
title="Please insert Facebook Event URL / Number"
on:change={handleChange}
value={value}
/>
<input
id="submit"
class={inputClasses}
type='submit'
value='Submit'
disabled={pending}
on:click={handleSubmit}
/>
</form>

View File

@ -0,0 +1,26 @@
<script>
import { parseStatusStore, requestStore, swStatusStore } from '../stores'
import Input from './Input.svelte'
import Status from './Status.svelte'
$: error = ($requestStore && $requestStore.error) ? $requestStore.error : null
$: pending = Boolean($requestStore && !$requestStore.error)
$: pendingRequest = $requestStore
$: status = $parseStatusStore
$: swStatus = $swStatusStore
</script>
<div class="input-container">
<Input
{pending}
{pendingRequest}
{error}
/>
<Status
{error}
{pending}
{pendingRequest}
{status}
{swStatus}
/>
</div>

View File

@ -0,0 +1,54 @@
<style>
#status {
flex: 1;
min-height: 1rem;
max-height: 3rem;
overflow: auto;
margin: 5px;
}
.status-item {
min-width: 200px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.status-item--error {
box-sizing: border-box;
border: 1px solid firebrick;
background-color: salmon;
color: darkred;
}
</style>
<script>
export let error
export let pending
export let pendingRequest
export let status
export let swStatus
</script>
<div id='status'>
{#if error}
<div class='status-item status-item--error'>
{error.toString()}
</div>
{/if}
{#if pending && pendingRequest}
<div class='status-item'>
Fetching event {pendingRequest.url}
</div>
{/if}
{#if status}
<div class='status-item'>
{status}
</div>
{/if}
{#if swStatus}
<div class='status-item'>
{swStatus}
</div>
{/if}
</div>

View File

@ -0,0 +1,46 @@
<style>
#tracking-panel {
position: fixed;
bottom: 0;
border: 3px solid navy;
background-color: lightyellow;
max-width: 600px;
margin: 5px;
padding: 5px;
}
#tracking-panel__yes-button,
#tracking-panel__no-button {
font-size: 1.2rem;
}
#tracking-panel__yes-button {
font-weight: 600;
margin-right: 5px;
}
</style>
<script>
import { configStore } from '../stores'
const handleYesButtonClick = () => {
configStore.setValue('track', true)
}
const handleNoButtonClick = () => {
configStore.setValue('track', false)
}
</script>
<div id="tracking-panel">
<p>
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.
</p>
<button on:click={handleYesButtonClick} id='tracking-panel__yes-button'>
Ok
</button>
<button on:click={handleNoButtonClick} id='tracking-panel__no-button'>
Nope
</button>
</div>

View File

@ -0,0 +1,5 @@
export const STORAGE_KEYS = {
CONFIG: 'fb-to-ical-config',
EVENTS: 'fb-to-ical-events',
}

27
lib/frontend/index.js Normal file
View File

@ -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()

View File

@ -0,0 +1,8 @@
import logger from './services/logger'
import { configStore } from './stores'
export default () => {
const enableTracking = configStore.track
logger.setRemoteLogging(enableTracking)
}

View File

@ -0,0 +1,5 @@
import Request from './request'
export {
Request,
}

View File

@ -0,0 +1,11 @@
export default class Request {
constructor({
id,
url,
error,
}) {
this.id = id
this.url = url
this.error = error
}
}

View File

@ -0,0 +1,7 @@
import { postURL } from './network'
import storageListener from './storageListener'
export {
postURL,
storageListener,
}

View File

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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

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

View File

@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export default writable(null)

View File

@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export default writable(null)

View File

@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export default writable(null)

40
lib/frontend/sw-boot.js Normal file
View File

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

57
lib/frontend/utils.js Normal file
View File

@ -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', '')
}

View File

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

View File

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

View File

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

View File

@ -23,59 +23,32 @@
file that you can import into your calendar. <strong>Only public events are supported.</strong>
</p>
<form action='/download' method='POST' id="form">
<input
required
pattern="^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|\d+$"
id="url"
name="url"
placeholder="Paste / type FB event URL or event number..."
title="Please insert Facebook Event URL / Number"
/>
<input id="submit" type='submit' value='Submit' />
</form>
<noscript>
<div id="nojs" class="notice">
🤚 JavaScript is <em>disabled</em>. Enable to get full experience and offline capabilities.
</div>
<form action='/download' method='POST' id="form">
<input
required
pattern="^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|\d+$"
id="url"
name="url"
placeholder="Paste / type FB event URL or event number..."
title="Please insert Facebook Event URL / Number"
/>
<input id="submit" type='submit' value='Submit' />
</form>
</noscript>
<div id="status">
<div class="status-item" id="network">
Fetching file...
</div>
<div class="status-item" id="parsing">
Parsing data...
</div>
<div class="status-item" id="error">
</div>
<div class="status-item" id="service-worker">
</div>
</div>
<br />
<div class="list-wrapper">
<table id="list" class="hidden">
<thead>
<tr>
<td>Date</td>
<td>Name</td>
<td></td>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div id="root"></div>
</article>
<footer>
v.<%= htmlWebpackPlugin.options.version %> ◆
<a href="https://ondrejsynacek.com" target="_blank">Ondrej Synacek</a> (2019) ◆
<a href="https://github.com/comatory/fb2iCal" target="_blank" title="Github">Source</a>
<a href="/about" target="_blank" title="About the project">About</about>
<a href="/about" target="_blank" title="About the project">About</a>
</footer>
<script>

View File

@ -1,365 +0,0 @@
import { uuidv4, parseStartTimeFromiCalString, useStorage } from './app/utils'
import {
migrateRecord,
getStorage,
getConfigStorage,
getStorageContents,
updateStorage,
updateConfigStorage,
saveRecord,
deleteRecord,
} from './app/storage'
import logger from './app/logger'
import { extractEventDataFromHTML } from '../../lib/services/ics-retriever'
import generateICS from '../../lib/services/ics-generator'
(() => {
if (!window.fetch || !window.Promise || !window.URLSearchParams || !window.crypto) {
console.warn('JS features not available.')
return
}
const showTable = () => {
list.classList.remove('hidden')
}
const deleteTableRow = (id, row) => {
deleteRecord(id)
if (!useStorage()) {
return
}
row.remove()
}
const renderTrackingPanel = (storageContents) => {
const trackingPanel = document.createElement('div')
trackingPanel.id = 'tracking-panel'
const text = document.createElement('p')
text.innerText = `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.\n\
We'll ask you only this time and won't bother you again.`
const yesButton = document.createElement('button')
yesButton.id = 'tracking-panel__yes-button'
yesButton.innerText = 'Ok'
yesButton.addEventListener('click', () => {
updateConfigStorage(storageContents, 'track', true)
logger.setRemoteLogging(true)
trackingPanel.remove()
})
const noButton = document.createElement('button')
noButton.id = 'tracking-panel__no-button'
noButton.innerText = 'Nope'
noButton.addEventListener('click', () => {
updateConfigStorage(storageContents, 'track', false)
logger.setRemoteLogging(false)
trackingPanel.remove()
})
trackingPanel.appendChild(text)
trackingPanel.appendChild(yesButton)
trackingPanel.appendChild(noButton)
document.body.appendChild(trackingPanel)
}
const insertTableRow = ({ id, link, createdAt, startTime, title }) => {
showTable()
const newRow = document.createElement('tr')
const startTimeCol = document.createElement('td')
startTimeCol.innerText = startTime ?
new Date(startTime).toLocaleString() :
'N/A\xa0\xa0\xa0\xa0\xa0'
const downloadEl = document.createElement('a')
downloadEl.setAttribute('href', link)
downloadEl.innerText = title
const titleCol = document.createElement('td')
titleCol.appendChild(downloadEl)
const deleteEl = document.createElement('a')
deleteEl.setAttribute('href', 'javascript:void(0)')
deleteEl.innerText = '✖︎'
deleteEl.classList.add('delete-record')
deleteEl.addEventListener('click', (event) => {
event.preventDefault()
deleteTableRow(id, newRow)
})
const actionCol = document.createElement('td')
actionCol.classList.add('actions')
actionCol.appendChild(deleteEl)
newRow.appendChild(startTimeCol)
newRow.appendChild(titleCol)
newRow.appendChild(actionCol)
tableBody.prepend(newRow)
}
const createRecord = (uri, summary, startTime) => {
const id = uuidv4()
const createdAt = new Date()
saveRecord({
id,
link: uri,
createdAt,
startTime,
title: summary,
})
insertTableRow({
id,
link: uri,
createdAt,
title: summary,
startTime
})
}
const hydrateList = () => {
if (!useStorage()) {
return
}
const prevStorage = getStorage()
const migratedStorageContents = getStorageContents(prevStorage).map((record) => {
return migrateRecord(record)
})
updateStorage(migratedStorageContents)
const storage = getStorage()
const storageContents = getStorageContents(storage)
if (storageContents.length > 0) {
showTable()
}
storageContents
.sort((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
})
.forEach((record) => {
insertTableRow(record)
})
}
const hydrateConfig = () => {
if (!useStorage()) {
return
}
const prevStorage = getConfigStorage()
const storageContents = getStorageContents(prevStorage)
const useTrackingSet = storageContents.track !== undefined
if (!useTrackingSet) {
renderTrackingPanel(storageContents)
}
}
const configureLogger = (logger) => {
if (!logger || !useStorage()) {
return
}
const prevStorage = getConfigStorage()
const storageContents = getStorageContents(prevStorage)
const shouldTrack = Boolean(storageContents.track)
logger.setRemoteLogging(shouldTrack)
}
const clearStatuses = () => {
document.querySelectorAll('.status-item').forEach((item) => {
item.classList.remove('show')
})
}
const setStatusDownloading = () => {
clearStatuses()
document.querySelector('#network').classList.add('show')
}
const setStatusParsing = () => {
clearStatuses()
document.querySelector('#parsing').classList.add('show')
}
const setStatusError = (err) => {
clearStatuses()
const error = document.querySelector('#error')
error.innerText = err.toString()
error.classList.add('show')
}
const setServiceWorkerStatus = (status) => {
clearStatuses()
const sw = document.querySelector('#service-worker')
sw.innerText = status
status ? sw.classList.add('show') : sw.classList.remove('show')
}
const pendingRequest = () => {
input.disabled = true
submitButton.disabled = true
setStatusDownloading()
}
const finishedRequest = () => {
input.disabled = false
submitButton.disabled = false
clearStatuses()
}
const handleError = (error) => {
finishedRequest()
setStatusError(error)
}
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
}
finishedRequest()
resolve(response)
}).catch(reject)
})
}
const form = document.querySelector('form')
const submitButton = document.querySelector("#submit")
const input = document.querySelector("#url")
const link = document.querySelector("#current-download")
const table = document.querySelector('#list')
const tableBody = table.querySelector('tbody')
if (window.navigator && window.navigator.serviceWorker) {
const serviceWorker = window.navigator.serviceWorker
serviceWorker.register('sw.js', {
scope: './',
}).then((registration) => {
setServiceWorkerStatus(`Service worker registered with scope ${registration.scope}`)
setTimeout(() => {
setServiceWorkerStatus('')
}, 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) => {
setServiceWorkerStatus(`Service worker error: ${err.toString()}`)
})
let refreshing
serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return
}
window.location.reload()
refreshing = true
})
}
hydrateList()
hydrateConfig()
configureLogger(logger)
const handleHTMLResponse = (html, url) => {
try {
setStatusParsing()
const eventData = extractEventDataFromHTML(html, url, { logger })
generateICS(eventData)
.then((text) => {
const dataUri = encodeURIComponent(text)
const uri = `data:text/calendar;charset=utf-8,${dataUri}`
link.setAttribute('href', uri)
link.setAttribute('download', 'download.ics')
link.click()
input.value = ''
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)
createRecord(uri, summary, startTime)
clearStatuses()
})
.catch((err) => {
handleError(err)
})
} catch (err) {
handleError(err)
}
}
submitButton.addEventListener('click', (event) => {
if (!form.reportValidity()) {
return
}
event.preventDefault()
const formData = new URLSearchParams()
formData.set('url', input.value)
pendingRequest()
postURL(formData)
.then((res) => {
res.text()
.then((response) => handleHTMLResponse(response, input.value))
.catch((err) => {
handleError(err)
})
})
.catch((err) => {
handleError(err)
})
})
})()

View File

@ -1,31 +0,0 @@
// Worker v14
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('fb-to-ical').then((cache) => {
return cache.addAll([
'/',
'/favicon.ico',
'/scripts.js?6',
'/style.css?9',
'/about?3',
'/icon-512.png',
])
})
)
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
return response || fetch(event.request)
})
)
})
self.addEventListener('message', (event) => {
if (event.data.action === 'skipWaiting') {
self.skipWaiting()
}
})

View File

@ -73,116 +73,7 @@ img#logo {
margin: 5px;
}
input {
font-size: 1rem;
}
#current-download {
display: none;
}
.list-wrapper {
max-height: 50vh;
overflow: auto;
}
#list {
width: 100%;
}
.row {
display: flex;
align-items: center;
flex-wrap: wrap;
}
input#url {
flex: 1;
}
#form {
flex: 1;
display: flex;
min-width: 300px;
}
#form input {
margin: 5px;
}
#status {
flex: 1;
height: 1rem;
margin: 5px;
}
.status-item {
display: none;
min-width: 200px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.show {
display: block;
}
.hidden {
display: none;
}
thead {
font-weight: 800;
}
tbody tr:nth-child(odd) {
background-color: whitesmoke;
}
tbody tr:nth-child(even) {
background-color: #e8e8e8;
}
td.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
td.actions a {
margin: 2px;
text-decoration: none;
}
a.delete-record {
font-size: 1.2rem;
}
.notice {
font-size: 0.9rem;
}
#nojs {
margin: 5px 0;
}
#tracking-panel {
position: fixed;
bottom: 0;
border: 3px solid navy;
background-color: lightyellow;
max-width: 600px;
margin: 5px;
padding: 5px;
}
#tracking-panel__yes-button,
#tracking-panel__no-button {
font-size: 1.2rem;
}
#tracking-panel__yes-button {
font-weight: 600;
margin-right: 5px;
}

6010
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
{
"name": "facebook-events-ical-converter",
"version": "1.3.2",
"version": "1.4.0",
"private": true,
"description": "App that converts events on Facebook event page to iCal file.",
"main": "lib/index.js",
"engines": {
@ -8,10 +9,10 @@
"node": "10.15.0"
},
"scripts": {
"build": "npm run clean:build && webpack --mode=production",
"build:dev": "npm run clean:build && webpack --mode=development",
"build:firebase:hosting": "npm run clean:build && NODE_ENV=production NODE_APP=firebase webpack --mode=production",
"build:firebase:hosting:dev": "npm run clean:build && NODE_ENV=development NODE_APP=firebase webpack --mode=development",
"build": "npm run clean:build && NODE_ENV=production webpack --config=./webpack.prod.js",
"build:dev": "npm run clean:build && NODE_ENV=development webpack --config=./webpack.dev.js",
"build:firebase:hosting": "npm run clean:build && NODE_ENV=production NODE_APP=firebase webpack --config=./webpack.prod.js",
"build:firebase:hosting:dev": "npm run clean:build && NODE_ENV=development NODE_APP=firebase webpack --config=./webpack.dev.js",
"clean:build": "rm dist/** || true",
"deploy:firebase": "npm run build:firebase:hosting && firebase deploy",
"start": "npm run build && node lib/index.js",
@ -31,6 +32,7 @@
"license": "ISC",
"dependencies": {
"body-parser": "^1.19.0",
"buffer": "^6.0.3",
"cheerio": "^1.0.0-rc.3",
"cors": "^2.8.5",
"dayjs": "^1.8.16",
@ -39,8 +41,12 @@
"express-rate-limit": "^5.0.0",
"express-winston": "^4.0.1",
"ics": "^2.22.1",
"path-browserify": "^1.0.1",
"request": "^2.88.0",
"serve-favicon": "^2.5.0",
"stream-browserify": "^3.0.0",
"svelte": "^3.31.0",
"util": "^0.12.3",
"winston": "^3.2.1",
"winston-daily-rotate-file": "^4.2.1",
"yargs": "^15.4.1"
@ -49,13 +55,19 @@
"chai": "^4.2.0",
"chai-sinon": "^2.8.1",
"concurrently": "^5.2.0",
"copy-webpack-plugin": "^6.0.3",
"html-webpack-plugin": "^4.3.0",
"copy-webpack-plugin": "^7.0.0",
"css-loader": "^5.0.1",
"html-webpack-plugin": "^4.5.0",
"jest": "^26.1.0",
"mini-css-extract-plugin": "^1.3.3",
"nodemon": "^1.19.3",
"sinon": "^9.0.2",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.12",
"workbox-webpack-plugin": "^5.1.3"
"svelte-loader": "^2.13.6",
"webpack": "^5.11.0",
"webpack-cli": "^4.2.0",
"webpack-merge": "^5.7.2",
"workbox-core": "^6.0.2",
"workbox-precaching": "^6.0.2",
"workbox-webpack-plugin": "^6.0.2"
}
}

89
webpack.common.js Normal file
View File

@ -0,0 +1,89 @@
const webpack = require('webpack')
const path = require('path')
const fs = require('fs')
const pkg = require('./package.json')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { GenerateSW } = require('workbox-webpack-plugin')
const destination = path.join(__dirname, 'dist')
const isFirebaseEnv = process.env.NODE_APP === 'firebase'
const firebaseConfigFilePath = path.join(__dirname, '.firebaserc')
const hasFirebaseConfig = fs.existsSync(firebaseConfigFilePath)
const isDev = process.env.NODE_ENV === 'development'
console.log(`Detected dev mode? ${isDev}`)
if (isFirebaseEnv && hasFirebaseConfig) {
console.info('Prepare build for Firebase hosting')
}
module.exports = {
entry: path.join(__dirname, 'lib', 'frontend', 'index.js'),
output: {
filename: '[name].[fullhash].js',
path: destination,
},
resolve: {
alias: {
svelte: path.resolve('node_modules', 'svelte'),
},
extensions: [ '.mjs', '.js', '.svelte' ],
mainFields: [ 'svelte', 'browser', 'module', 'main' ],
fallback: {
util: require.resolve('util/'),
stream: require.resolve('stream-browserify'),
path: require.resolve('path-browserify'),
buffer: require.resolve('buffer/'),
},
},
module: {
rules: [
{
test: /\.(svelte)$/,
exclude: /node_modules/,
use: {
loader: 'svelte-loader',
options: {
emitCss: false,
hotReload: isDev,
dev: isDev,
},
},
},
{
test: /\.css$/,
use: [ MiniCssExtractPlugin.loader, 'css-loader' ],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'lib', 'static', 'index.html'),
version: pkg.version,
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.NODE_DEBUG': JSON.stringify(process.env.NODE_DEBUG),
}),
new CopyWebpackPlugin({
patterns: [
{ from: path.join(__dirname, 'lib', 'static', 'favicon.ico'), to: destination },
{ from: path.join(__dirname, 'lib', 'static', 'manifest.json'), to: destination },
{ from: path.join(__dirname, 'lib', 'static', 'style.css'), to: destination },
{ from: path.join(__dirname, 'lib', 'static', 'icon-180.png'), to: destination },
{ from: path.join(__dirname, 'lib', 'static', 'icon-192.png'), to: destination },
{ from: path.join(__dirname, 'lib', 'static', 'icon-512.png'), to: destination },
],
}),
new MiniCssExtractPlugin({
filename: '[name].[fullhash].css',
}),
new GenerateSW({
swDest: 'sw.js',
clientsClaim: true,
skipWaiting: true,
})
],
}

View File

@ -1,56 +0,0 @@
const path = require('path')
const fs = require('fs')
const pkg = require('./package.json')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const { GenerateSW } = require('workbox-webpack-plugin')
const destination = path.join(__dirname, 'dist')
const isDevelopment = Boolean(process.argv[2] && process.argv[2].includes('mode=development'))
const isFirebaseEnv = process.env.NODE_APP === 'firebase'
const firebaseConfigFilePath = path.join(__dirname, '.firebaserc')
const hasFirebaseConfig = fs.existsSync(firebaseConfigFilePath)
if (isFirebaseEnv && hasFirebaseConfig) {
console.info('Prepare build for Firebase hosting')
}
const getFirebaseUrl = () => {
const contents = fs.readFileSync(firebaseConfigFilePath)
const rawContents = contents.toString()
const json = JSON.parse(rawContents)
const projectName = json.projects ? json.projects.default : null
if (isDevelopment) {
return `http://localhost:5001/${projectName}/uscentral-1/app`
}
return `${projectName}.web.app/app`
}
module.exports = {
entry: path.join(__dirname, 'lib', 'static', 'index.js'),
watch: isDevelopment,
output: {
filename: '[name].[hash].js',
path: destination,
},
plugins: [
new CopyWebpackPlugin({
patterns: [
{ from: path.join(__dirname, 'lib', 'static', '{*.ico,*.json,*.png,*.css}'), to: destination, flatten: true },
],
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, 'lib', 'static', 'index.html'),
version: pkg.version,
serverURL: (isFirebaseEnv && hasFirebaseConfig) ? getFirebaseUrl() : '',
inject: 'body',
}),
new GenerateSW({
swDest: 'sw.js',
clientsClaim: true,
skipWaiting: true,
}),
],
}

11
webpack.dev.js Normal file
View File

@ -0,0 +1,11 @@
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const isDevelopment = Boolean(process.argv[2] && process.argv[2].includes('mode=development'))
module.exports = merge(baseConfig, {
mode: 'development',
devtool: 'eval-source-map',
watch: true,
})

7
webpack.prod.js Normal file
View File

@ -0,0 +1,7 @@
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
module.exports = merge(baseConfig, {
mode: 'production',
})