Browse Source

Add ability to cache complete documntations in IndexedDB

Thibaut 11 years ago
parent
commit
25f844da9b

+ 9 - 4
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
 

+ 1 - 0
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 %>'

+ 115 - 0
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

+ 5 - 0
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'

+ 39 - 0
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

+ 1 - 5
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)

+ 16 - 0
assets/javascripts/templates/pages/offline_tmpl.coffee

@@ -0,0 +1,16 @@
+app.templates.offlinePage = ->
+  """ <h1 class="_lined-heading">Offline</h1>
+      <table class="_docs">
+        #{app.templates.render 'offlineDoc', app.docs.all()}
+      </table> """
+
+app.templates.offlineDoc = (doc) ->
+  """<tr data-slug="#{doc.slug}"></tr>"""
+
+app.templates.offlineDocContent = (doc, status) ->
+  html = """<th class="_icon-#{doc.slug}">#{doc.name}</th>"""
+  html += if status.downloaded
+    """<td><a data-del>Delete</a></td>"""
+  else
+    """<td><a data-dl>Download</a></td>"""
+  html

+ 7 - 4
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
 

+ 63 - 0
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')

+ 45 - 0
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
 //

+ 1 - 1
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