Skip to content

Commit

Permalink
feat(helpers): add webdav sync service
Browse files Browse the repository at this point in the history
  • Loading branch information
crimx committed Oct 29, 2018
1 parent 31421b0 commit 64df7c3
Show file tree
Hide file tree
Showing 3 changed files with 296 additions and 0 deletions.
72 changes: 72 additions & 0 deletions src/_helpers/sync-manager/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { storage } from '@/_helpers/browser-api'
import { getWords, saveWords, Word } from '@/background/database'
import { MsgType } from '@/typings/message'

import { concat } from 'rxjs/observable/concat'
import { fromPromise } from 'rxjs/observable/fromPromise'
import { map } from 'rxjs/operators/map'

export interface NotebookFile {
timestamp: number
words: Word[]
}

/**
* Check server and create a Saladict Directory if not exist.
*/
export interface InitServer<C> {
(config: C): Promise<boolean>
}

/**
* Upload files to server.
*/
export interface Upload<C> {
(config: C, text: string): Promise<boolean>
}

/**
* Download files from server and filter out unchanged
*/
export interface DlChanged<C, M = { [k: string]: any }> {
(config: C, meta: M): Promise<{ json: NotebookFile, etag: string } | undefined>
}

export async function setSyncConfig<T = any> (serviceID: string, config: T): Promise<void> {
let { syncConfig } = await storage.sync.get('syncConfig')
if (!syncConfig) { syncConfig = {} }
syncConfig[serviceID] = config
await storage.sync.set({ syncConfig })
}

export async function getSyncConfig<T> (serviceID: string): Promise<T | undefined> {
const { syncConfig } = await storage.sync.get('syncConfig')
if (syncConfig) {
return syncConfig[serviceID]
}
}

/** Get a sync config and listen changes */
export function createSyncConfigStream () {
return concat(
fromPromise(storage.sync.get('syncConfig')).pipe(map(o => o.syncConfig)),
storage.sync.createStream('syncConfig').pipe(map(change => change.newValue)),
)
}

export async function setMeta<T = any> (serviceID: string, meta: T): Promise<void> {
await storage.sync.set({ ['sync' + serviceID]: meta })
}

export async function getMeta<T> (serviceID: string): Promise<T | undefined> {
const key = 'sync' + serviceID
return (await storage.local.get(key))[key]
}

export async function setNotebook (words: Word[]): Promise<void> {
await saveWords({ area: 'notebook', words })
}

export async function getNotebook (): Promise<Word[]> {
return (await getWords({ type: MsgType.GetWords, area: 'notebook' })).words
}
82 changes: 82 additions & 0 deletions src/_helpers/sync-manager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { fromPromise } from 'rxjs/observable/fromPromise'
import { switchMap } from 'rxjs/operators/switchMap'
import { delay } from 'rxjs/operators/delay'
import { repeat } from 'rxjs/operators/repeat'
import { empty } from 'rxjs/observable/empty'

import * as service from './services/webdav'
import { createSyncConfigStream, getMeta, setMeta, setNotebook, getNotebook, NotebookFile, getSyncConfig } from './helpers'

// Moniter sync configs and start interval
createSyncConfigStream().pipe(
switchMap(configs => {
if (!configs || !configs[service.serviceID]) {
if (process.env.DEV_BUILD) {
console.log('No Sync Service Conifg', configs, service.serviceID)
}
return empty<void>()
}

if (process.env.DEV_BUILD) {
console.log('Sync Service Conifg', configs, service.serviceID)
}

const config = configs[service.serviceID]

return fromPromise<void>(downlaod(config)).pipe(
delay(config.duration),
repeat(),
)
})
)

export async function upload () {
const words = await getNotebook()
if (!words || words.length <= 0) { return }

const config = await getSyncConfig<service.SyncConfig>(service.serviceID)
if (!config) {
if (process.env.DEV_BUILD) {
console.warn('Upload notebook failed. No Config.')
}
return
}

const timestamp = Date.now()

let text: string
try {
text = JSON.stringify({ timestamp, words } as NotebookFile)
} catch (e) {
if (process.env.DEV_BUILD) {
console.error('Stringify notebook failed', words)
}
return
}

const ok = await service.upload(config, text)
if (!ok) {
if (process.env.DEV_BUILD) {
console.error('Upload notebook failed. Network Error.')
}
return
}

await setMeta<Required<service.Meta>>(
service.serviceID,
{ timestamp, etag: '' },
)
}

async function downlaod (config) {
const meta = await getMeta<service.Meta>(service.serviceID)
const response = await service.dlChanged(config, meta || {})
if (!response) { return }

const { json } = response
await setMeta<Required<service.Meta>>(
service.serviceID,
{ timestamp: json.timestamp, etag: response.etag },
)
await setNotebook(json.words)
}
142 changes: 142 additions & 0 deletions src/_helpers/sync-manager/services/webdav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
NotebookFile,
InitServer,
Upload,
DlChanged,
setMeta,
getNotebook,
} from '../helpers'

export interface SyncConfig {
/** Server address. Ends with '/'. */
readonly url: string
readonly user: string
readonly passwd: string
/** In ms */
readonly duration: number
}

export interface Meta {
readonly etag?: string
readonly timestamp?: number
}

export const serviceID = 'webdav'

export const initServer: InitServer<SyncConfig> = async config => {
const text = await fetch(config.url, {
method: 'PROPFIND',
headers: {
'Authorization': 'Basic ' + window.btoa(`${config.user}:${config.passwd}`),
'Content-Type': 'application/xml; charset="utf-8"',
'Depth': '2',
},
}).then(r => r.text())

const doc = new DOMParser().parseFromString(text, 'application/xml')

const dir = Array.from(doc.querySelectorAll('response'))
.find(el => {
const href = el.querySelector('href')
if (href && href.textContent && href.textContent.endsWith('/Saladict/')) {
// is Saladict
if (el.querySelector('resourcetype collection')) {
// is collection
return true
}
}
return false
})

if (!dir) {
// create directory
const response = await fetch(config.url + 'Saladict', { method: 'MKCOL' })
if (!response.ok) {
// cannot create directory
return Promise.reject('mkcol')
}
return true
}

const file = await dlChanged(config, {})
if (file) {
// file exist
// remind use for overwriting
return Promise.reject('exist')
}

const words = await getNotebook()
return true
}

export const upload: Upload<SyncConfig> = async (config, text) => {
const response = await fetch(config.url + 'Saladict/notebook.json', {
method: 'PUT',
headers: {
'Authorization': 'Basic ' + window.btoa(`${config.user}:${config.passwd}`),
},
body: text,
})

return response.ok
}

export const dlChanged: DlChanged<SyncConfig, Meta> = async (
config, meta
) => {
const headers = {
'Authorization': 'Basic ' + window.btoa(`${config.user}:${config.passwd}`),
}
if (meta.etag != null) {
headers['If-None-Match'] = meta.etag
headers['If-Modified-Since'] = meta.etag
}

const response = await fetch(config.url + 'Saladict/notebook.json', {
method: 'GET',
headers,
})

if (response.status === 304) {
return
}

let json: NotebookFile
try {
json = await response.json()
} catch (e) {
if (process.env.DEV_BUILD) {
console.error('Fetch webdav notebook.json error', response)
}
return
}

if (!Array.isArray(json.words) || json.words.some(w => !w.date)) {
if (process.env.DEV_BUILD) {
console.error('Parse webdav notebook.json error: incorrect words', json)
}
return
}

if (meta.timestamp) {
if (!json.timestamp) {
if (process.env.DEV_BUILD) {
console.error('webdav notebook.json no timestamp', json)
}
return
}

if (json.timestamp <= meta.timestamp) {
// older file
return
}
}

if (process.env.DEV_BUILD) {
if (!response.headers.get('ETag')) {
console.warn('webdav notebook.json no etag', response)
}
}

return { json, etag: response.headers.get('ETag') || '' }
}

0 comments on commit 64df7c3

Please sign in to comment.