diff --git a/src/src/app/app.component.ts b/src/src/app/app.component.ts index 1cffbaa9..af944eb9 100644 --- a/src/src/app/app.component.ts +++ b/src/src/app/app.component.ts @@ -40,9 +40,7 @@ export class AppComponent extends SubscriberBase implements AfterViewInit { ) { super(); - this.storageService.initSavedPages(); - this.storageService.initSettings(); - this.storageService.initNotes(); + this.storageService.initLocal(); this.addSubscription( this.error$.subscribe((err) => { diff --git a/src/src/app/common/helpers.ts b/src/src/app/common/helpers.ts new file mode 100644 index 00000000..da69cb7a --- /dev/null +++ b/src/src/app/common/helpers.ts @@ -0,0 +1,3 @@ +export function isNullOrUndefined(obj: any) { + return obj === null || obj === undefined; +} diff --git a/src/src/app/common/storable.ts b/src/src/app/common/storable.ts index e57c09c6..271aff18 100644 --- a/src/src/app/common/storable.ts +++ b/src/src/app/common/storable.ts @@ -12,3 +12,7 @@ export class Storable implements IStorable { createdOn: string; value: T; } + +export interface UserVersion { + version: number; +} diff --git a/src/src/app/components/passage/passage-card.component.ts b/src/src/app/components/passage/passage-card.component.ts index a3022c56..3df1cfda 100644 --- a/src/src/app/components/passage/passage-card.component.ts +++ b/src/src/app/components/passage/passage-card.component.ts @@ -7,6 +7,7 @@ import { BibleReference, Overlap } from 'src/app/common/bible-reference'; import { AppService } from 'src/app/services/app.service'; import { Paragraph, BiblePassageResult } from 'src/app/models/passage-state'; import { CardItem } from 'src/app/models/card-state'; +import { isNullOrUndefined } from 'src/app/common/helpers'; @Component({ selector: 'app-passage-card', @@ -21,7 +22,7 @@ export class PassageCardComponent extends CardComponent implements OnInit { // whenever the notes changes, look for any notes that reference this passage. notes$ = this.appService.select((state) => - state.notes.value !== null && this.ref !== undefined + !isNullOrUndefined(state.notes) && !isNullOrUndefined(state.notes.value) && !isNullOrUndefined(this.ref) ? state.notes.value.filter((o) => { const refs = o.xref .split(';') diff --git a/src/src/app/models/app-state.ts b/src/src/app/models/app-state.ts index 51a0ab2f..4054710c 100644 --- a/src/src/app/models/app-state.ts +++ b/src/src/app/models/app-state.ts @@ -7,7 +7,7 @@ import { HashTable } from '../common/hashtable'; export interface AppState { readonly currentSavedPage: SavedPage; - readonly currentCards: readonly DataReference[]; + readonly currentCards: IStorable; readonly cardCache: HashTable; diff --git a/src/src/app/pages/search/search.page.ts b/src/src/app/pages/search/search.page.ts index e0ed40f6..90ac0c8b 100644 --- a/src/src/app/pages/search/search.page.ts +++ b/src/src/app/pages/search/search.page.ts @@ -17,7 +17,7 @@ import { getFromCardCache } from 'src/app/common/card-cache-operations'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchPage extends SubscriberBase implements OnInit { - cards$ = this.appService.select((state) => state.currentCards.map((o) => getFromCardCache(o, state.cardCache))); + cards$ = this.appService.select((state) => state.currentCards.value.map((o) => getFromCardCache(o, state.cardCache))); suggestions$ = this.appService.select((state) => state.autocomplete); savedPagedLoaded = false; diff --git a/src/src/app/services/app-state-actions.ts b/src/src/app/services/app-state-actions.ts index dc1c441d..c6741191 100644 --- a/src/src/app/services/app-state-actions.ts +++ b/src/src/app/services/app-state-actions.ts @@ -89,7 +89,7 @@ export class AppActionFactory { } as AppAction; } - static newUpdateCards(cards: CardItem[]): AppAction { + static newUpdateCards(cards: IStorable): AppAction { return { type: 'UPDATE_CARDS', cards, @@ -234,7 +234,7 @@ export type AppAction = } | { type: 'UPDATE_CARDS'; - cards: CardItem[]; + cards: IStorable; } | { type: 'UPDATE_ERROR'; diff --git a/src/src/app/services/app-state-initial-state.ts b/src/src/app/services/app-state-initial-state.ts index a283411f..fa5cddd9 100644 --- a/src/src/app/services/app-state-initial-state.ts +++ b/src/src/app/services/app-state-initial-state.ts @@ -3,7 +3,10 @@ import { Overlap } from '../common/bible-reference'; export const initialState: AppState = { user: null, - currentCards: [], + currentCards: { + createdOn: new Date(0).toISOString(), + value: [], + }, cardCache: {}, autocomplete: [], currentSavedPage: null, diff --git a/src/src/app/services/app-state-reducer.spec.ts b/src/src/app/services/app-state-reducer.spec.ts index 30cc0cbd..6c095bab 100644 --- a/src/src/app/services/app-state-reducer.spec.ts +++ b/src/src/app/services/app-state-reducer.spec.ts @@ -32,7 +32,10 @@ describe('getNewestStorable', () => { describe('AppService Reducer', () => { const preState = { user: null, - currentCards: [], + currentCards: { + createdOn: new Date(0).toISOString(), + value: [], + }, cardCache: {}, autocomplete: [], currentSavedPage: { @@ -250,7 +253,7 @@ describe('AppService Reducer', () => { const action1 = AppActionFactory.newAddCard(card1, null); const testState = reducer(preState, action1); - expect(testState.currentCards[0]).toBe(card1, 'Failed to add first card to empty list'); + expect(testState.currentCards.value[0]).toBe(card1, 'Failed to add first card to empty list'); const action = AppActionFactory.newUpdateCurrentPage(); const testState2 = reducer(testState, action); @@ -311,7 +314,7 @@ describe('AppService Reducer', () => { const action1 = AppActionFactory.newAddCard(card1, null); const testState = reducer(preState, action1); - expect(testState.currentCards[0]).toBe(card1, 'Failed to add first card to empty list'); + expect(testState.currentCards.value[0]).toBe(card1, 'Failed to add first card to empty list'); const card2: CardItem = { qry: 'G123', @@ -321,8 +324,8 @@ describe('AppService Reducer', () => { const action2 = AppActionFactory.newAddCard(card2, null); const testState2 = reducer(testState, action2); - expect(testState2.currentCards.length).toBe(2, 'Failed to add second card to list with 1 item'); - expect(testState2.currentCards[1]).toBe(card2); + expect(testState2.currentCards.value.length).toBe(2, 'Failed to add second card to list with 1 item'); + expect(testState2.currentCards.value[1]).toBe(card2); const card3: CardItem = { qry: 'G1234', @@ -357,8 +360,8 @@ describe('AppService Reducer', () => { const action3 = AppActionFactory.newAddCard(card3, card2); const testState3 = reducer(setState, action3); - expect(testState3.currentCards.length).toBe(3, 'Failed to add third card'); - expect(testState3.currentCards[1]).toBe(card3, 'Failed to insert card above the second card'); + expect(testState3.currentCards.value.length).toBe(3, 'Failed to add third card'); + expect(testState3.currentCards.value[1]).toBe(card3, 'Failed to insert card above the second card'); // append to bottom, insert next to card @@ -387,8 +390,8 @@ describe('AppService Reducer', () => { const action4 = AppActionFactory.newAddCard(card3, card1); const testState4 = reducer(setState, action4); - expect(testState4.currentCards.length).toBe(3, 'Failed to add third card'); - expect(testState4.currentCards[1]).toBe(card3, 'Failed to insert card below the first card'); + expect(testState4.currentCards.value.length).toBe(3, 'Failed to add third card'); + expect(testState4.currentCards.value[1]).toBe(card3, 'Failed to insert card below the first card'); // append to bottom, do not insert next to card @@ -417,8 +420,8 @@ describe('AppService Reducer', () => { const action5 = AppActionFactory.newAddCard(card3, card1); const testState5 = reducer(setState, action5); - expect(testState5.currentCards.length).toBe(3, 'Failed to add third card'); - expect(testState5.currentCards[2]).toBe(card3, 'Failed to insert card at end of the list'); + expect(testState5.currentCards.value.length).toBe(3, 'Failed to add third card'); + expect(testState5.currentCards.value[2]).toBe(card3, 'Failed to insert card at end of the list'); // append to top, do not insert next to card @@ -447,8 +450,8 @@ describe('AppService Reducer', () => { const action6 = AppActionFactory.newAddCard(card3, card1); const testState6 = reducer(setState, action6); - expect(testState6.currentCards.length).toBe(3, 'Failed to add third card'); - expect(testState6.currentCards[0]).toBe(card3, 'Failed to insert card at start of the list'); + expect(testState6.currentCards.value.length).toBe(3, 'Failed to add third card'); + expect(testState6.currentCards.value[0]).toBe(card3, 'Failed to insert card at start of the list'); }); it('UPDATE_CARD', () => { @@ -470,7 +473,7 @@ describe('AppService Reducer', () => { const action2 = AppActionFactory.newUpdateCard(newCard, oldCard); const testState = reducer(preState1, action2); - expect(testState.currentCards[0].qry).toBe('H88', 'Should update the card'); + expect(testState.currentCards.value[0].qry).toBe('H88', 'Should update the card'); expect(testState.cardCache[getCardCacheKey(newCard)].qry).toBe('H88', 'Should exist in card cache'); expect(testState.cardCache[getCardCacheKey(oldCard)]).toBeUndefined( 'Should be undefined, having been removed from card cache' @@ -490,8 +493,8 @@ describe('AppService Reducer', () => { const action2 = AppActionFactory.newRemoveCard(card); const testState = reducer(preState1, action2); - expect(preState1.currentCards.length).toBe(1, 'Should have added the card in preparation for removing the card'); - expect(testState.currentCards.length).toBe(0, 'Should have removed the card'); + expect(preState1.currentCards.value.length).toBe(1, 'Should have added the card in preparation for removing the card'); + expect(testState.currentCards.value.length).toBe(0, 'Should have removed the card'); expect(testState.cardCache[getCardCacheKey(card)]).toBeUndefined( 'Should be undefined, having been removed from card cache' ); @@ -514,25 +517,25 @@ describe('AppService Reducer', () => { }; const action2 = AppActionFactory.newAddCard(card2, null); const preState2 = reducer(preState1, action2); - expect(preState2.currentCards.length).toBe(2, 'Should have two cards'); - expect(preState2.currentCards[0].qry).toBe('G123'); - expect(preState2.currentCards[1].qry).toBe('H88'); + expect(preState2.currentCards.value.length).toBe(2, 'Should have two cards'); + expect(preState2.currentCards.value[0].qry).toBe('G123'); + expect(preState2.currentCards.value[1].qry).toBe('H88'); const action3 = AppActionFactory.newMoveCard(card2, MoveDirection.Up); const testState1 = reducer(preState2, action3); - expect(testState1.currentCards[0].qry).toBe('H88'); - expect(testState1.currentCards[1].qry).toBe('G123'); + expect(testState1.currentCards.value[0].qry).toBe('H88'); + expect(testState1.currentCards.value[1].qry).toBe('G123'); const testState4 = reducer(preState2, action3); - expect(testState4.currentCards[0].qry).toBe('H88'); - expect(testState4.currentCards[1].qry).toBe('G123'); + expect(testState4.currentCards.value[0].qry).toBe('H88'); + expect(testState4.currentCards.value[1].qry).toBe('G123'); const action4 = AppActionFactory.newMoveCard(card1, MoveDirection.Down); const testState2 = reducer(preState2, action4); - expect(testState2.currentCards[0].qry).toBe('H88'); - expect(testState2.currentCards[1].qry).toBe('G123'); + expect(testState2.currentCards.value[0].qry).toBe('H88'); + expect(testState2.currentCards.value[1].qry).toBe('G123'); const testState3 = reducer(preState2, action4); - expect(testState3.currentCards[0].qry).toBe('H88'); - expect(testState3.currentCards[1].qry).toBe('G123'); + expect(testState3.currentCards.value[0].qry).toBe('H88'); + expect(testState3.currentCards.value[1].qry).toBe('G123'); }); //#endregion @@ -575,11 +578,11 @@ describe('AppService Reducer', () => { const action3 = AppActionFactory.newGetNote('123456789', null); const preState3 = reducer(preState2, action3); - expect(preState3.currentCards.length).toBe(1, 'Should have added the note card'); + expect(preState3.currentCards.value.length).toBe(1, 'Should have added the note card'); const action4 = AppActionFactory.newGetNote('1234567890', null); const preState4 = reducer(preState3, action4); - expect(preState4.currentCards.length).toBe(2, 'Should have added the note card'); + expect(preState4.currentCards.value.length).toBe(2, 'Should have added the note card'); }); it('NOTES: Update Notes, Delete Notes, Find Notes, ', () => { @@ -602,11 +605,11 @@ describe('AppService Reducer', () => { const action2 = AppActionFactory.newFindNotes('note', null); const preState2 = reducer(preState1, action2); - expect(preState2.currentCards.length).toBe(2, 'Should have found two notes card'); + expect(preState2.currentCards.value.length).toBe(2, 'Should have found two notes card'); const action3 = AppActionFactory.newDeleteNote(note1); const preState3 = reducer(preState2, action3); - expect(preState3.currentCards.length).toBe(1, 'Should have deleted the note card'); + expect(preState3.currentCards.value.length).toBe(1, 'Should have deleted the note card'); expect(preState3.notes.value.length).toBe(1, 'Should have added the notes'); }); diff --git a/src/src/app/services/app-state-reducer.ts b/src/src/app/services/app-state-reducer.ts index 186cb6bd..034a2cf0 100644 --- a/src/src/app/services/app-state-reducer.ts +++ b/src/src/app/services/app-state-reducer.ts @@ -126,7 +126,7 @@ export function reducer(state: AppState, action: AppAction): AppState { ...state, // if the currentSavedPage was loaded, replace it with the info from the // new savedPages array, as it might have changed. - currentCards: hasCurrentSavedPage ? currentSavedPage.queries : state.currentCards, + currentCards: hasCurrentSavedPage ? new Storable(currentSavedPage.queries) : state.currentCards, currentSavedPage: hasCurrentSavedPage ? currentSavedPage : state.currentSavedPage, savedPagesLoaded: true, savedPages, // update the savedPages @@ -162,7 +162,7 @@ export function reducer(state: AppState, action: AppAction): AppState { const current = { id: state.currentSavedPage.id, title: state.currentSavedPage.title, - queries: [...mergeCardList(state.currentCards, state.settings.value.pageSettings.mergeStrategy)], + queries: [...mergeCardList(state.currentCards.value, state.settings.value.pageSettings.mergeStrategy)], }; const savedPages = new Storable([ @@ -186,7 +186,7 @@ export function reducer(state: AppState, action: AppAction): AppState { // create a new saved page object title: action.title, id: UUID.UUID().toString(), - queries: [...mergeCardList(state.currentCards, state.settings.value.pageSettings.mergeStrategy)], + queries: [...mergeCardList(state.currentCards.value, state.settings.value.pageSettings.mergeStrategy)], }, ]); @@ -239,28 +239,28 @@ export function reducer(state: AppState, action: AppAction): AppState { let cards = []; if (action.nextToItem && state.settings.value.displaySettings.insertCardNextToItem) { - const idx = state.currentCards.indexOf(action.nextToItem); + const idx = state.currentCards.value.indexOf(action.nextToItem); if (state.settings.value.displaySettings.appendCardToBottom) { - const before = state.currentCards.slice(0, idx + 1); - const after = state.currentCards.slice(idx + 1); + const before = state.currentCards.value.slice(0, idx + 1); + const after = state.currentCards.value.slice(idx + 1); cards = [...before, action.card, ...after]; } else { - const before = state.currentCards.slice(0, idx); - const after = state.currentCards.slice(idx); + const before = state.currentCards.value.slice(0, idx); + const after = state.currentCards.value.slice(idx); cards = [...before, action.card, ...after]; } } else { if (state.settings.value.displaySettings.appendCardToBottom) { - cards = [...state.currentCards, action.card]; + cards = [...state.currentCards.value, action.card]; } else { - cards = [action.card, ...state.currentCards]; + cards = [action.card, ...state.currentCards.value]; } } return { ...state, - currentCards: cards, + currentCards: new Storable(cards), cardCache: updateInCardCache(action.card, state.cardCache), }; } @@ -268,12 +268,14 @@ export function reducer(state: AppState, action: AppAction): AppState { case 'UPDATE_CARD': { return { ...state, - currentCards: state.currentCards.map((c) => { - if (c === action.oldCard) { - return action.newCard; - } - return c; - }), + currentCards: new Storable( + state.currentCards.value.map((c) => { + if (c === action.oldCard) { + return action.newCard; + } + return c; + }) + ), cardCache: updateInCardCache(action.newCard, removeFromCardCache(action.oldCard, state.cardCache)), }; } @@ -304,23 +306,23 @@ export function reducer(state: AppState, action: AppAction): AppState { ...state, currentSavedPage, savedPages, - currentCards: [...state.currentCards.filter((c) => c !== action.card)], + currentCards: new Storable([...state.currentCards.value.filter((c) => c !== action.card)]), cardCache: removeFromCardCache(action.card, state.cardCache), }; } case 'MOVE_CARD': { - const cards = moveItemUpOrDown(state.currentCards, action.card, action.direction); + const cards = moveItemUpOrDown(state.currentCards.value, action.card, action.direction); return { ...state, - currentCards: cards, + currentCards: new Storable(cards), }; } case 'UPDATE_CARDS': { let cardCache = { ...state.cardCache }; - for (const card of action.cards) { + for (const card of action.cards.value) { cardCache = updateInCardCache(card, cardCache); } return { @@ -355,22 +357,22 @@ export function reducer(state: AppState, action: AppAction): AppState { let cards = [] as DataReference[]; if (action.nextToItem && state.settings.value.displaySettings.insertCardNextToItem) { - const idx = state.currentCards.indexOf(action.nextToItem); + const idx = state.currentCards.value.indexOf(action.nextToItem); if (state.settings.value.displaySettings.appendCardToBottom) { - const before = state.currentCards.slice(0, idx + 1); - const after = state.currentCards.slice(idx + 1); + const before = state.currentCards.value.slice(0, idx + 1); + const after = state.currentCards.value.slice(idx + 1); cards = [...before, ...notes, ...after]; } else { - const before = state.currentCards.slice(0, idx); - const after = state.currentCards.slice(idx); + const before = state.currentCards.value.slice(0, idx); + const after = state.currentCards.value.slice(idx); cards = [...before, ...notes, ...after]; } } else { if (state.settings.value.displaySettings.appendCardToBottom) { - cards = [...state.currentCards, ...notes]; + cards = [...state.currentCards.value, ...notes]; } else { - cards = [...notes, ...state.currentCards]; + cards = [...notes, ...state.currentCards.value]; } } @@ -381,7 +383,7 @@ export function reducer(state: AppState, action: AppAction): AppState { return { ...state, - currentCards: cards, + currentCards: new Storable(cards), cardCache: cache, }; } @@ -404,7 +406,7 @@ export function reducer(state: AppState, action: AppAction): AppState { case 'UPDATE_NOTES': { return { ...state, - notes: action.notes, + notes: action.notes ? action.notes : new Storable([]), }; } @@ -424,7 +426,7 @@ export function reducer(state: AppState, action: AppAction): AppState { const newState = { ...state, - currentCards: [...state.currentCards], // you want to trigger an update to the cards if a card update is different. + currentCards: new Storable([...state.currentCards.value]), // you want to trigger an update to the cards if a card update is different. notes, }; @@ -439,15 +441,15 @@ export function reducer(state: AppState, action: AppAction): AppState { // so iterate through all of them and if you find the note // in any of them, remove it - const card = state.currentCards.find((o) => o.qry === action.note.id); + const card = state.currentCards.value.find((o) => o.qry === action.note.id); const cards = card ? [ - ...state.currentCards.filter((o) => { + ...state.currentCards.value.filter((o) => { return o.type !== CardType.Note || o.qry !== action.note.id; }), ] - : state.currentCards; // if card is undefined, then it wasn't in the current card list. + : state.currentCards.value; // if card is undefined, then it wasn't in the current card list. const notes = new Storable([...state.notes.value.filter((o) => o.id !== action.note.id)]); @@ -464,7 +466,7 @@ export function reducer(state: AppState, action: AppAction): AppState { return { ...state, - currentCards: cards, + currentCards: new Storable(cards), notes, savedPages, cardCache: card diff --git a/src/src/app/services/app.service.ts b/src/src/app/services/app.service.ts index 1aac0e1f..34b8457b 100644 --- a/src/src/app/services/app.service.ts +++ b/src/src/app/services/app.service.ts @@ -28,7 +28,7 @@ import { HashTable } from '../common/hashtable'; import { MoveDirection } from '../common/move-direction'; import { reducer } from './app-state-reducer'; import { initialState } from './app-state-initial-state'; -import { CardItem, CardType } from '../models/card-state'; +import { CardItem, CardType, DataReference } from '../models/card-state'; import { SavedPage } from '../models/page-state'; import { AppActionFactory } from './app-state-actions'; @@ -85,6 +85,20 @@ export class AppService extends createStateService(reducer, initialState) { this.dispatch(AppActionFactory.newAddCard(card, nextToItem)); } + async updateCards(queries: IStorable) { + const cards: CardItem[] = []; + for (const q of queries.value) { + const card = await this.getCardByQuery(q.qry); + cards.push(card); + } + this.dispatch( + AppActionFactory.newUpdateCards({ + createdOn: queries.createdOn, + value: cards, + }) + ); + } + private async getCardByQuery(qry: string): Promise { if (qry.startsWith('note:')) { const id = qry.replace('note:', ''); @@ -133,7 +147,7 @@ export class AppService extends createStateService(reducer, initialState) { cards.push(await this.getCardByQuery(ref.qry)); } - this.dispatch(AppActionFactory.newUpdateCards(cards)); + this.dispatch(AppActionFactory.newUpdateCards(new Storable(cards))); } savePage(title: string) { diff --git a/src/src/app/services/migration0to1.service.ts b/src/src/app/services/migration0to1.service.ts new file mode 100644 index 00000000..7da45187 --- /dev/null +++ b/src/src/app/services/migration0to1.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@angular/core'; +import { AngularFireDatabase, AngularFireObject } from '@angular/fire/database'; +import { DataSnapshot } from '@angular/fire/database/interfaces'; +import { UUID } from 'angular2-uuid'; +import { AppService } from './app.service'; +import { Overlap } from '../common/bible-reference'; +import { Settings, User } from '../models/app-state'; +import { SavedPage } from '../models/page-state'; +import { CardType, DataReference } from '../models/card-state'; +import { StorageService } from './storage.service'; +import { Storable } from '../common/storable'; +import { NoteItem } from '../models/note-state'; + +@Injectable({ + providedIn: 'root', +}) +export class MigrationVersion0to1 { + private settingsPath = 'settings'; // the very first settings + private settingsRemoteObject: AngularFireObject; + + constructor(private remote: AngularFireDatabase, private appService: AppService) {} + + migrate(user: User, storage: StorageService) { + this.settingsRemoteObject = this.remote.object(`/${this.settingsPath}/${user.uid}`); + this.settingsRemoteObject.query.once('value').then((data: DataSnapshot) => { + if (!data.exists()) { + return; + } + const v: Version0Settings = data.val(); + if (v === null || v === undefined) { + return; // no settings to migrate. + } + + const settings = this.toDisplaySettings(v); + const savedPages = this.toSavedPages(v); + const currentCards = this.toCurrentCards(v); + + storage.setRemote( + settings, + savedPages, + { + createdOn: new Date(0).toISOString(), + value: [], + }, + currentCards + ); + }); + } + + private toDisplaySettings(v0: Version0Settings) { + return new Storable({ + displaySettings: { + showStrongsAsModal: v0.strongs_modal ? v0.strongs_modal : false, + appendCardToBottom: v0.append_to_bottom ? v0.append_to_bottom : true, + insertCardNextToItem: v0.insert_next_to_item ? v0.insert_next_to_item : true, + clearSearchAfterQuery: v0.clear_search_after_query ? v0.clear_search_after_query : true, + cardFontSize: v0.font_size ? v0.font_size : 12, + cardFontFamily: v0.font_family ? v0.font_family : 'PT Serif', + showVersesOnNewLine: v0.verses_on_new_line ? v0.verses_on_new_line : false, + showVerseNumbers: v0.show_verse_numbers ? v0.show_verse_numbers : false, + showParagraphs: v0.show_paragraphs ? v0.show_paragraphs : true, + showParagraphHeadings: v0.show_paragraph_headings ? v0.show_paragraph_headings : true, + syncCardsAcrossDevices: v0.sync_search_items ? v0.sync_search_items : false, + }, + pageSettings: { + mergeStrategy: Overlap.Equal, + }, + cardIcons: { + words: 'font_download', + passage: 'menu_book', + strongs: 'speaker_notes', + note: 'note', + savedPage: 'inbox', + }, + } as Settings); + } + + private toSavedPages(v0: Version0Settings) { + return new Storable( + v0.saved_pages + ? v0.saved_pages.map((p) => { + return { + queries: p.queries.map((q) => { + return { + qry: q.qry, + type: CardType[q.type], + } as DataReference; + }), + title: p.title, + id: UUID.UUID().toString(), + } as SavedPage; + }) + : [] + ); + } + + private toCurrentCards(v0: Version0Settings) { + return new Storable( + v0.items + ? v0.items.map((o) => { + return { + qry: o.qry, + type: CardType[o.type], + }; + }) + : [] + ); + } +} + +interface Version0Settings { + append_to_bottom: boolean; + clear_search_after_query: boolean; + font_size: number; + font_family: string; + insert_next_to_item: boolean; + show_paragraph_headings: boolean; + show_paragraphs: boolean; + show_verse_numbers: boolean; + strongs_modal: boolean; + sync_search_items: boolean; + uid: string; + username: string; + verses_on_new_line: boolean; + saved_pages: Version0SavedPage[]; + items: Version0Item[]; +} + +interface Version0SavedPage { + queries: Version0Item[]; + title: string; +} + +interface Version0Item { + dict: string; + qry: string; + type: string; +} diff --git a/src/src/app/services/storage.service.ts b/src/src/app/services/storage.service.ts index dc575cee..2fd0fa2d 100644 --- a/src/src/app/services/storage.service.ts +++ b/src/src/app/services/storage.service.ts @@ -1,184 +1,275 @@ import { Injectable } from '@angular/core'; import { StorageMap } from '@ngx-pwa/local-storage'; import { AngularFireDatabase, AngularFireObject } from '@angular/fire/database'; -import { IStorable } from '../common/storable'; -import { AppService } from './app.service'; -import { DisplaySettings, User, Settings } from '../models/app-state'; +import { DataSnapshot } from '@angular/fire/database/interfaces'; import { SubscriberBase } from '../common/subscriber-base'; +import { IStorable, UserVersion } from '../common/storable'; +import { AppService } from './app.service'; +import { MigrationVersion0to1 } from './migration0to1.service'; + +import { User, Settings, AppState } from '../models/app-state'; import { NoteItem } from '../models/note-state'; import { SavedPage } from '../models/page-state'; +import { Observable } from 'rxjs'; +import { isNullOrUndefined } from '../common/helpers'; +import { DataReference } from '../models/card-state'; +import { createSelector } from 'reselect'; @Injectable({ providedIn: 'root', }) export class StorageService extends SubscriberBase { + private version = 1; + private storeVersion = `v${this.version}/`; + private userVersionPath = `userVersion`; + private userVersionRemoteObject: AngularFireObject; + private settingsState$ = this.appService.select((state) => state.settings); - private settingsPath = 'settings'; + private settingsPath = `${this.storeVersion}settings`; private settingsRemoteObject: AngularFireObject>; private savedPagesState$ = this.appService.select((state) => state.savedPages); - private savedPagesPath = 'savedPages'; + private savedPagesPath = `${this.storeVersion}savedPages`; private savedPagesRemoteObject: AngularFireObject>; private noteItemsState$ = this.appService.select((state) => state.notes); - private noteItemsPath = 'noteItems'; + private noteItemsPath = `${this.storeVersion}noteItems`; private noteItemsRemoteObject: AngularFireObject>; - constructor(private local: StorageMap, private remote: AngularFireDatabase, private appService: AppService) { + private cardsPath = `${this.storeVersion}currentCards`; + private cardsRemoteObject: AngularFireObject>; + + private syncCurrentItems = false; + private syncCurrentItems$ = this.appService.select( + createSelector( + (state: AppState) => state.settings.value.displaySettings.syncCardsAcrossDevices, + (state: AppState) => state.currentCards, + (sync, cards) => ({ + syncCardsAcrossDevices: sync, + currentCards: { + createdOn: cards.createdOn, + value: cards.value.map((o) => { + return { + qry: o.qry, + type: o.type, + } as DataReference; + }), + }, + }) + ) + ); + + constructor( + private local: StorageMap, + private remote: AngularFireDatabase, + private appService: AppService, + private v0to1: MigrationVersion0to1 + ) { super(); - //#region handle remote and local storage + // handle remote and local storage // when the specific items change in the state, // store them locally and remotely, if you're logged in. + this.observeStorable('Display Settings', this.settingsState$, this.settingsPath, this.settingsRemoteObject); + this.observeStorable('Page', this.savedPagesState$, this.savedPagesPath, this.savedPagesRemoteObject); + this.observeStorable('Note', this.noteItemsState$, this.noteItemsPath, this.noteItemsRemoteObject); + // whenever the user setting or the current cards change, save the cards + // and if syncing is turned on, send them to the remote store. this.addSubscription( - this.settingsState$.subscribe((settings) => { - if (!settings || settings.createdOn === new Date(0).toISOString()) { - return; - } + this.syncCurrentItems$.subscribe((v) => { + // set a local sync variable, so that the remote events know whether to + // accept remote state changes. + this.syncCurrentItems = v.syncCardsAcrossDevices; // update local - this.local.set(this.settingsPath, settings).subscribe( + this.local.set(this.cardsPath, v.currentCards).subscribe( () => { // nop }, // error () => { // tslint:disable-next-line: quotemark - this.appService.dispatchError("Something went wrong and the display settings weren't saved. :("); + this.appService.dispatchError(`Something went wrong and the current items weren't saved. :(`); } ); - // update remote - if (this.settingsRemoteObject) { - this.settingsRemoteObject.set(settings); + // since you updated the local variable above, this remote update will + // get picked up by the remote subscription below. + if (v.syncCardsAcrossDevices) { + this.cardsRemoteObject.set(v.currentCards); } }) ); - - this.addSubscription( - this.savedPagesState$.subscribe((savedPages) => { - if (!savedPages) { - return; - } - - // update local - this.local.set(this.savedPagesPath, savedPages).subscribe( - () => { - // nop - }, - // error - () => { - // tslint:disable-next-line: quotemark - this.appService.dispatchError("Something went wrong and the page wasn't saved. :("); - } - ); - - // update remote - if (this.savedPagesRemoteObject) { - this.savedPagesRemoteObject.set(savedPages); - } - }) - ); - - this.addSubscription( - this.noteItemsState$.subscribe((notes) => { - if (!notes) { - return; - } - - // update local - this.local.set(this.noteItemsPath, notes).subscribe( - () => { - // nop - }, - // error - () => { - // tslint:disable-next-line: quotemark - this.appService.dispatchError("Something went wrong and the note wasn't saved. :("); - } - ); - - // update remote - if (this.noteItemsRemoteObject) { - this.noteItemsRemoteObject.set(notes); - } - }) - ); - - //#endregion } + observeStorable( + name: string, + state$: Observable>, + path: string, + remoteObject: AngularFireObject> + ) { + this.addSubscription( + state$.subscribe((data) => { + if (!data) { + return; + } + + // update local + this.local.set(path, data).subscribe( + () => { + // nop + }, + // error + () => { + // tslint:disable-next-line: quotemark + this.appService.dispatchError(`Something went wrong and the ${name} wasn't saved. :(`); + } + ); + + // update remote + if (remoteObject) { + remoteObject.set(data); + } + }) + ); + } + + //#region Remote + initRemote(user: User) { - this.settingsRemoteObject = this.remote.object>(`/${this.settingsPath}/${user.uid}`); - this.savedPagesRemoteObject = this.remote.object>(`/${this.savedPagesPath}/${user.uid}`); - this.noteItemsRemoteObject = this.remote.object>(`/${this.noteItemsPath}/${user.uid}`); + // do the necessary migration steps. + this.handleMigration(user); + + // initialize the remote store monitoring + this.updateRemote( + this.settingsPath, + user, + (ro) => { + this.settingsRemoteObject = ro; + }, + (data) => { + this.appService.updateSettings(data); + } + ); + + this.updateRemote( + this.savedPagesPath, + user, + (ro) => { + this.savedPagesRemoteObject = ro; + }, + (data) => { + this.appService.updateSavedPages(data); + } + ); + + this.updateRemote( + this.noteItemsPath, + user, + (ro) => { + this.noteItemsRemoteObject = ro; + }, + (data) => { + this.appService.updateNotes(data); + } + ); + + this.cardsRemoteObject = this.remote.object>(`/${this.cardsPath}/${user.uid}`); + this.addSubscription( + this.cardsRemoteObject + .valueChanges() // when the value changes + .subscribe((remoteData) => { + if (!isNullOrUndefined(remoteData) && !isNullOrUndefined(remoteData.value) && this.syncCurrentItems) { + // update the app state with remote data, but only if syncing is turned on. + this.appService.updateCards(remoteData); + } + }) + ); + } + + private updateRemote( + path: string, + user: User, + setRemoteObject: (remoteObject: AngularFireObject>) => void, + action: (storable) => void + ) { + const remoteObject = this.remote.object>(`/${path}/${user.uid}`); + setRemoteObject(remoteObject); // display settings this.addSubscription( - this.settingsRemoteObject + remoteObject .valueChanges() // when the value changes - .subscribe((remoteSettings) => { - if (remoteSettings) { - // update the display settings locally from remote if it isn't null - this.appService.updateSettings(remoteSettings); - } - }) - ); - - // saved pages - this.addSubscription( - this.savedPagesRemoteObject - .valueChanges() // when the saved pages have changed - .subscribe((remoteSavedPages) => { - if (remoteSavedPages) { - // update the saved pages locally from remote if it isn't null - this.appService.updateSavedPages(remoteSavedPages); - } - }) - ); - - // note items - this.addSubscription( - this.noteItemsRemoteObject - .valueChanges() // when the saved pages have changed - .subscribe((remoteNoteItems) => { - if (remoteNoteItems) { - // update the saved pages locally from remote if it isn't null - this.appService.updateNotes(remoteNoteItems); + .subscribe((remoteData) => { + if (!isNullOrUndefined(remoteData) && !isNullOrUndefined(remoteData.value)) { + // update the app state with remote data if it isn't null + action(remoteData); } }) ); } - async initSettings() { - const hasDisplaySettings = await this.local.has(this.settingsPath).toPromise(); + //#endregion - if (hasDisplaySettings) { - const settings = (await this.local.get(this.settingsPath).toPromise()) as IStorable; + //#region Local - this.appService.updateSettings(settings); + /* + initialize the local stores + */ + async initLocal() { + this.updateLocal(this.settingsPath, (data) => { + this.appService.updateSettings(data); + }); + this.updateLocal(this.savedPagesPath, (data) => { + this.appService.updateSavedPages(data); + }); + this.updateLocal(this.noteItemsPath, (data) => { + this.appService.updateNotes(data); + }); + this.updateLocal(this.cardsPath, (data) => { + this.appService.updateCards(data); + }); + } + + private async updateLocal(path: string, action: (storable: IStorable) => void) { + const hasStorable = await this.local.has(path).toPromise(); + if (hasStorable) { + const storable = (await this.local.get(path).toPromise()) as IStorable; + action(storable); } } - async initSavedPages() { - const exists = await this.local.has(this.savedPagesPath).toPromise(); + //#endregion - if (exists) { - const savedPages = (await this.local.get(this.savedPagesPath).toPromise()) as IStorable; + //#region Migrations - this.appService.updateSavedPages(savedPages); - } + private handleMigration(user: User) { + this.userVersionRemoteObject = this.remote.object(`/${this.userVersionPath}/${user.uid}`); + this.userVersionRemoteObject.query.once('value').then((data: DataSnapshot) => { + const v: UserVersion = data.val(); + if (v === null || v === undefined || v.version < this.version) { + // perform the migration. + this.v0to1.migrate(user, this); + + // update the version so the migration doesn't happen again. + this.userVersionRemoteObject.set({ version: 1 }); + } + }); } - async initNotes() { - const exists = await this.local.has(this.noteItemsPath).toPromise(); - - if (exists) { - const notes = (await this.local.get(this.noteItemsPath).toPromise()) as IStorable; - - this.appService.updateNotes(notes); - } + public setRemote( + settings: IStorable, + savedPages: IStorable, + notes: IStorable, + cards: IStorable + ) { + this.settingsRemoteObject.set(settings); + this.savedPagesRemoteObject.set(savedPages); + this.noteItemsRemoteObject.set(notes); + this.cardsRemoteObject.set(cards); } + + //#endregion }