mirror of
https://github.com/comatory/fb2iCal
synced 2025-06-05 22:09:25 +02:00
Merge pull request #6 from comatory/feature-use-svelte-framework
Feature use svelte framework
This commit is contained in:
22
.github/workflows/firebase-hosting-merge.yml
vendored
Normal file
22
.github/workflows/firebase-hosting-merge.yml
vendored
Normal 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
|
18
.github/workflows/firebase-hosting-pull-request.yml
vendored
Normal file
18
.github/workflows/firebase-hosting-pull-request.yml
vendored
Normal 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
25
.github/workflows/main.yml
vendored
Normal 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
|
||||
|
@@ -1,3 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 10.15.0
|
@@ -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
|
||||
|
68
lib/frontend/actions/events.js
Normal file
68
lib/frontend/actions/events.js
Normal 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 })
|
||||
}
|
5
lib/frontend/actions/index.js
Normal file
5
lib/frontend/actions/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createEvent } from './events'
|
||||
|
||||
export {
|
||||
createEvent,
|
||||
}
|
5
lib/frontend/components/App.svelte
Normal file
5
lib/frontend/components/App.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import AppContainer from './AppContainer.svelte'
|
||||
</script>
|
||||
|
||||
<AppContainer />
|
21
lib/frontend/components/AppContainer.svelte
Normal file
21
lib/frontend/components/AppContainer.svelte
Normal 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}
|
||||
|
83
lib/frontend/components/EventList.svelte
Normal file
83
lib/frontend/components/EventList.svelte
Normal 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>
|
78
lib/frontend/components/Input.svelte
Normal file
78
lib/frontend/components/Input.svelte
Normal 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>
|
26
lib/frontend/components/InputContainer.svelte
Normal file
26
lib/frontend/components/InputContainer.svelte
Normal 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>
|
54
lib/frontend/components/Status.svelte
Normal file
54
lib/frontend/components/Status.svelte
Normal 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>
|
46
lib/frontend/components/TrackingPanel.svelte
Normal file
46
lib/frontend/components/TrackingPanel.svelte
Normal 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>
|
5
lib/frontend/constants.js
Normal file
5
lib/frontend/constants.js
Normal 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
27
lib/frontend/index.js
Normal 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()
|
8
lib/frontend/logger-boot.js
Normal file
8
lib/frontend/logger-boot.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import logger from './services/logger'
|
||||
import { configStore } from './stores'
|
||||
|
||||
export default () => {
|
||||
const enableTracking = configStore.track
|
||||
|
||||
logger.setRemoteLogging(enableTracking)
|
||||
}
|
5
lib/frontend/records/index.js
Normal file
5
lib/frontend/records/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import Request from './request'
|
||||
|
||||
export {
|
||||
Request,
|
||||
}
|
11
lib/frontend/records/request.js
Normal file
11
lib/frontend/records/request.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export default class Request {
|
||||
constructor({
|
||||
id,
|
||||
url,
|
||||
error,
|
||||
}) {
|
||||
this.id = id
|
||||
this.url = url
|
||||
this.error = error
|
||||
}
|
||||
}
|
7
lib/frontend/services/index.js
Normal file
7
lib/frontend/services/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { postURL } from './network'
|
||||
import storageListener from './storageListener'
|
||||
|
||||
export {
|
||||
postURL,
|
||||
storageListener,
|
||||
}
|
23
lib/frontend/services/network.js
Normal file
23
lib/frontend/services/network.js
Normal 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)
|
||||
})
|
||||
}
|
51
lib/frontend/services/storageListener.js
Normal file
51
lib/frontend/services/storageListener.js
Normal 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()
|
26
lib/frontend/stores/configStore.js
Normal file
26
lib/frontend/stores/configStore.js
Normal 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()
|
65
lib/frontend/stores/eventStore.js
Normal file
65
lib/frontend/stores/eventStore.js
Normal 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()
|
13
lib/frontend/stores/index.js
Normal file
13
lib/frontend/stores/index.js
Normal 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,
|
||||
}
|
3
lib/frontend/stores/parseStatusStore.js
Normal file
3
lib/frontend/stores/parseStatusStore.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export default writable(null)
|
3
lib/frontend/stores/requestStore.js
Normal file
3
lib/frontend/stores/requestStore.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export default writable(null)
|
3
lib/frontend/stores/swStatusStore.js
Normal file
3
lib/frontend/stores/swStatusStore.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export default writable(null)
|
40
lib/frontend/sw-boot.js
Normal file
40
lib/frontend/sw-boot.js
Normal 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
57
lib/frontend/utils.js
Normal 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', '')
|
||||
}
|
@@ -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
|
@@ -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,
|
||||
}
|
@@ -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,
|
||||
}
|
@@ -23,6 +23,10 @@
|
||||
file that you can import into your calendar. <strong>Only public events are supported.</strong>
|
||||
</p>
|
||||
|
||||
<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
|
||||
@@ -35,47 +39,16 @@
|
||||
<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>
|
||||
</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>
|
||||
|
@@ -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)
|
||||
})
|
||||
})
|
||||
})()
|
@@ -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()
|
||||
}
|
||||
})
|
@@ -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;
|
||||
}
|
||||
|
5978
package-lock.json
generated
5978
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -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
89
webpack.common.js
Normal 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,
|
||||
})
|
||||
],
|
||||
}
|
@@ -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
11
webpack.dev.js
Normal 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
7
webpack.prod.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { merge } = require('webpack-merge')
|
||||
|
||||
const baseConfig = require('./webpack.common')
|
||||
|
||||
module.exports = merge(baseConfig, {
|
||||
mode: 'production',
|
||||
})
|
Reference in New Issue
Block a user