From 25f844da9b40cbff3f88b9517614079a0d1de79d Mon Sep 17 00:00:00 2001 From: Thibaut Date: Wed, 31 Dec 2014 15:53:35 -0500 Subject: [PATCH] Add ability to cache complete documntations in IndexedDB --- assets/javascripts/app/app.coffee | 13 +- assets/javascripts/app/config.coffee.erb | 1 + assets/javascripts/app/db.coffee | 115 ++++++++++++++++++ assets/javascripts/app/router.coffee | 5 + assets/javascripts/models/doc.coffee | 39 ++++++ assets/javascripts/models/entry.coffee | 6 +- .../templates/pages/offline_tmpl.coffee | 16 +++ .../javascripts/views/content/content.coffee | 11 +- .../views/content/offline_page.coffee | 63 ++++++++++ assets/stylesheets/components/_content.scss | 45 +++++++ lib/app.rb | 2 +- 11 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 assets/javascripts/app/db.coffee create mode 100644 assets/javascripts/templates/pages/offline_tmpl.coffee create mode 100644 assets/javascripts/views/content/offline_page.coffee diff --git a/assets/javascripts/app/app.coffee b/assets/javascripts/app/app.coffee index f12604bb..8e32a8b2 100644 --- a/assets/javascripts/app/app.coffee +++ b/assets/javascripts/app/app.coffee @@ -61,7 +61,7 @@ bootOne: -> @doc = new app.models.Doc @DOC @docs.reset [@doc] - @doc.load @start.bind(@), @onBootError.bind(@), readCache: true + @doc.load @bootDB.bind(@), @onBootError.bind(@), readCache: true new app.views.Notice 'singleDoc', @doc delete @DOC return @@ -72,20 +72,25 @@ (if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc) @docs.sort() @disabledDocs.sort() - @docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true + @docs.load @bootDB.bind(@), @onBootError.bind(@), readCache: true, writeCache: true delete @DOCS return - start: -> + bootDB: -> for doc in @docs.all() @entries.add doc.toEntry() @entries.add type.toEntry() for type in doc.types.all() @entries.add doc.entries.all() + + @db = new app.DB() + @db.init(@start.bind(@)) + return + + start: -> @trigger 'ready' @router.start() @hideLoading() @welcomeBack() unless @doc - @removeEvent 'ready bootError' return diff --git a/assets/javascripts/app/config.coffee.erb b/assets/javascripts/app/config.coffee.erb index 06cf7aa0..6621d16f 100644 --- a/assets/javascripts/app/config.coffee.erb +++ b/assets/javascripts/app/config.coffee.erb @@ -8,3 +8,4 @@ app.config = production_host: 'devdocs.io' search_param: 'q' sentry_dsn: '<%= App.sentry_dsn %>' + version: '<%= Time.now.to_i %>' diff --git a/assets/javascripts/app/db.coffee b/assets/javascripts/app/db.coffee new file mode 100644 index 00000000..e8f15612 --- /dev/null +++ b/assets/javascripts/app/db.coffee @@ -0,0 +1,115 @@ +class app.DB + NAME = 'docs' + + constructor: -> + @useIndexedDB = @useIndexedDB() + + init: (@_callback) -> + if @useIndexedDB + @initIndexedDB() + else + @callback() + return + + initIndexedDB: -> + try + req = indexedDB.open(NAME, @indexedDBVersion()) + req.onerror = @callback + req.onsuccess = @onOpenSuccess + req.onupgradeneeded = @onUpgradeNeeded + catch + @callback() + return + + isEnabled: -> + !!@db + + callback: => + @_callback?() + @_callback = null + return + + onOpenSuccess: (event) => + try + @db = event.target.result + @db.transaction(['docs', app.docs.all()[0].slug], 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937 + catch + @db = null + + @callback() + return + + onUpgradeNeeded: (event) => + db = event.target.result + + unless db.objectStoreNames.contains('docs') + db.createObjectStore('docs') + + for doc in app.docs.all() when not db.objectStoreNames.contains(doc.slug) + db.createObjectStore(doc.slug) + + for doc in app.disabledDocs.all() when not db.objectStoreNames.contains(doc.slug) + db.createObjectStore(doc.slug) + return + + store: (doc, data, onSuccess, onError) -> + txn = @db.transaction ['docs', doc.slug], 'readwrite' + txn.oncomplete = -> if txn.error then onError() else onSuccess() + + store = txn.objectStore(doc.slug) + store.clear() + store.add(content, path) for path, content of data + + store = txn.objectStore('docs') + store.put(doc.mtime, doc.slug) + return + + unstore: (doc, onSuccess, onError) -> + txn = @db.transaction ['docs', doc.slug], 'readwrite' + txn.oncomplete = -> if txn.error then onError() else onSuccess() + + store = txn.objectStore(doc.slug) + store.clear() + + store = txn.objectStore('docs') + store.delete(doc.slug) + return + + version: (doc, callback) -> + txn = @db.transaction ['docs'], 'readonly' + store = txn.objectStore('docs') + + req = store.get(doc.slug) + req.onsuccess = -> callback(!!req.result) + req.onerror = -> callback(false) + return + + load: (entry, onSuccess, onError) -> + if @isEnabled() + onError = @loadWithXHR.bind(@, entry, onSuccess, onError) + @loadWithIDB(entry, onSuccess, onError) + else + @loadWithXHR(entry, onSuccess, onError) + + loadWithXHR: (entry, onSuccess, onError) -> + ajax + url: entry.fileUrl() + dataType: 'html' + success: onSuccess + error: onError + + loadWithIDB: (entry, onSuccess, onError) -> + txn = @db.transaction [entry.doc.slug], 'readonly' + store = txn.objectStore(entry.doc.slug) + + req = store.get(entry.path) + req.onsuccess = -> if req.result then onSuccess(req.result) else onError() + req.onerror = onError + + txn + + useIndexedDB: -> + !app.isSingleDoc() and !!window.indexedDB + + indexedDBVersion: -> + if app.config.env is 'production' then app.config.version else Date.now() / 1000 diff --git a/assets/javascripts/app/router.coffee b/assets/javascripts/app/router.coffee index 58de2f75..9497e33c 100644 --- a/assets/javascripts/app/router.coffee +++ b/assets/javascripts/app/router.coffee @@ -4,6 +4,7 @@ class app.Router @routes: [ ['*', 'before' ] ['/', 'root' ] + ['/offline', 'offline' ] ['/about', 'about' ] ['/news', 'news' ] ['/help', 'help' ] @@ -75,6 +76,10 @@ class app.Router @triggerRoute 'root' return + offline: -> + @triggerRoute 'offline' + return + about: (context) -> context.page = 'about' @triggerRoute 'page' diff --git a/assets/javascripts/models/doc.coffee b/assets/javascripts/models/doc.coffee index 3af515c3..8d1e6b47 100644 --- a/assets/javascripts/models/doc.coffee +++ b/assets/javascripts/models/doc.coffee @@ -27,6 +27,9 @@ class app.models.Doc extends app.Model fileUrl: (path) -> "#{app.config.docs_host}#{@fullPath(path)}" + dbUrl: -> + "#{app.config.docs_host}/#{@db_path}?#{@mtime}" + indexUrl: -> "#{app.indexHost()}/#{@index_path}?#{@mtime}" @@ -83,3 +86,39 @@ class app.models.Doc extends app.Model _setCache: (data) -> app.store.set @slug, [@mtime, data] return + + download: (onSuccess, onError) -> + return if @downloading + @downloading = true + + error = => + @downloading = null + onError() + + success = (data) => + @downloading = null + app.db.store @, data, onSuccess, error + + ajax + url: @dbUrl() + success: success + error: error + return + + undownload: (onSuccess, onError) -> + return if @downloading + @downloading = true + + success = => + @downloading = null + onSuccess() + + error = => + @downloading = null + onError() + + app.db.unstore @, success, error + + getDownloadStatus: (callback) -> + app.db.version @, (value) -> + callback downloaded: !!value, version: value diff --git a/assets/javascripts/models/entry.coffee b/assets/javascripts/models/entry.coffee index db4fabce..28a72a87 100644 --- a/assets/javascripts/models/entry.coffee +++ b/assets/javascripts/models/entry.coffee @@ -41,8 +41,4 @@ class app.models.Entry extends app.Model @doc.types.findBy 'name', @type loadFile: (onSuccess, onError) -> - ajax - url: @fileUrl() - dataType: 'html' - success: onSuccess - error: onError + app.db.load(@, onSuccess, onError) diff --git a/assets/javascripts/templates/pages/offline_tmpl.coffee b/assets/javascripts/templates/pages/offline_tmpl.coffee new file mode 100644 index 00000000..9bf59969 --- /dev/null +++ b/assets/javascripts/templates/pages/offline_tmpl.coffee @@ -0,0 +1,16 @@ +app.templates.offlinePage = -> + """

Offline

+ + #{app.templates.render 'offlineDoc', app.docs.all()} +
""" + +app.templates.offlineDoc = (doc) -> + """""" + +app.templates.offlineDocContent = (doc, status) -> + html = """#{doc.name}""" + html += if status.downloaded + """Delete""" + else + """Download""" + html diff --git a/assets/javascripts/views/content/content.coffee b/assets/javascripts/views/content/content.coffee index e6616f13..3eb8fd1a 100644 --- a/assets/javascripts/views/content/content.coffee +++ b/assets/javascripts/views/content/content.coffee @@ -23,10 +23,11 @@ class app.views.Content extends app.View @scrollMap = {} @scrollStack = [] - @rootPage = new app.views.RootPage - @staticPage = new app.views.StaticPage - @typePage = new app.views.TypePage - @entryPage = new app.views.EntryPage + @rootPage = new app.views.RootPage + @staticPage = new app.views.StaticPage + @offlinePage = new app.views.OfflinePage + @typePage = new app.views.TypePage + @entryPage = new app.views.EntryPage @entryPage .on 'loading', @onEntryLoading @@ -137,6 +138,8 @@ class app.views.Content extends app.View @show @entryPage when 'type' @show @typePage + when 'offline' + @show @offlinePage else @show @staticPage diff --git a/assets/javascripts/views/content/offline_page.coffee b/assets/javascripts/views/content/offline_page.coffee new file mode 100644 index 00000000..7034e4ae --- /dev/null +++ b/assets/javascripts/views/content/offline_page.coffee @@ -0,0 +1,63 @@ +class app.views.OfflinePage extends app.View + @className: '_static' + + @events: + click: 'onClick' + + @elements: + list: '_._docs' + + deactivate: -> + if super + @empty() + return + + render: -> + @html @tmpl('offlinePage') + @refreshElements() + app.docs.each(@renderDoc) + return + + renderDoc: (doc) => + doc.getDownloadStatus (status) => + html = app.templates.render('offlineDocContent', doc, status) + el = @docEl(doc) + el.className = '' + el.innerHTML = html + return + + getTitle: -> + 'Offline' + + getDoc: (el) -> + el = el.parentNode until slug = el.getAttribute('data-slug') + app.docs.findBy('slug', slug) + + docEl: (doc) -> + @find("[data-slug='#{doc.slug}']") + + onRoute: -> + @render() + return + + onClick: (event) => + if event.target.hasAttribute('data-dl') + action = 'download' + else if event.target.hasAttribute('data-del') + action = 'undownload' + + if action + $.stopEvent(event) + doc = @getDoc(event.target) + doc[action](@onDownloadSuccess.bind(@, doc), @onDownloadError.bind(@, doc)) + @docEl(doc).classList.add("#{action}ing") + return + + onDownloadSuccess: (doc) -> + @renderDoc(doc) + return + + onDownloadError: (doc) -> + el = @docEl(doc) + el.className = '' + el.classList.add('error') diff --git a/assets/stylesheets/components/_content.scss b/assets/stylesheets/components/_content.scss index 7fcd782d..48d6d0fc 100644 --- a/assets/stylesheets/components/_content.scss +++ b/assets/stylesheets/components/_content.scss @@ -242,6 +242,51 @@ td:first-child, td:last-child { white-space: nowrap; } } +// +// Doc table +// + +._docs { + width: 100%; + line-height: 1.5rem; + + th { + max-width: 0; + padding-left: .5rem; + padding-right: .5rem; + white-space: nowrap; + font-weight: normal; + + &:before { + float: left; + margin: .25rem .5rem .25rem 0; + @extend %icon; + } + } + + td:last-child { text-align: right; } + td > a { cursor: pointer; } + + tr.downloading > td:last-child { + > a { display: none; } + &:before { content: 'Downloading…' } + } + + tr.undownloading > td:last-child { + > a { display: none; } + &:before { content: 'Deleting…' } + } + + tr.error > td:last-child { + > a { display: none; } + + &:before { + content: 'Error'; + color: red; + } + } +} + // // News // diff --git a/lib/app.rb b/lib/app.rb index db5a8831..ac7f294c 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -122,7 +122,7 @@ class App < Sinatra::Application erb :index end - %w(about news help).each do |page| + %w(offline about news help).each do |page| get "/#{page}" do redirect "/#/#{page}", 302 end