History

Add ability to cache complete documntations in IndexedDB

Thibaut committed 11 years ago

Commit 25f844d1 parent bc5488f11 files · +302 −14Browse files

Unified · Split

11 files
  1. assets/javascripts/app/app.coffee +9 −4
  2. assets/javascripts/app/config.coffee.erb +1 −0
  3. assets/javascripts/app/db.coffee +115 −0
  4. assets/javascripts/app/router.coffee +5 −0
  5. assets/javascripts/models/doc.coffee +39 −0
  6. assets/javascripts/models/entry.coffee +1 −5
  7. assets/javascripts/templates/pages/offline_tmpl.coffee +16 −0
  8. assets/javascripts/views/content/content.coffee +7 −4
  9. assets/javascripts/views/content/offline_page.coffee +63 −0
  10. assets/stylesheets/components/_content.scss +45 −0
  11. lib/app.rb +1 −1
assets/javascripts/app/app.coffee+9−4
@@ -61,7 +61,7 @@ · 60 unmodified lines
6161 bootOne: ->
6262 @doc = new app.models.Doc @DOC
6363 @docs.reset [@doc]
64− @doc.load @start.bind(@), @onBootError.bind(@), readCache: true
64+ @doc.load @bootDB.bind(@), @onBootError.bind(@), readCache: true
6565 new app.views.Notice 'singleDoc', @doc
6666 delete @DOC
6767 return
@@ -72,20 +72,25 @@ · 4 unmodified lines
7272 (if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc)
7373 @docs.sort()
7474 @disabledDocs.sort()
75− @docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true
75+ @docs.load @bootDB.bind(@), @onBootError.bind(@), readCache: true, writeCache: true
7676 delete @DOCS
7777 return
7878
79− start: ->
79+ bootDB: ->
8080 for doc in @docs.all()
8181 @entries.add doc.toEntry()
8282 @entries.add type.toEntry() for type in doc.types.all()
8383 @entries.add doc.entries.all()
84+
85+ @db = new app.DB()
86+ @db.init(@start.bind(@))
87+ return
88+
89+ start: ->
8490 @trigger 'ready'
8591 @router.start()
8692 @hideLoading()
8793 @welcomeBack() unless @doc
88−
8994 @removeEvent 'ready bootError'
9095 return
9196
assets/javascripts/app/config.coffee.erb+1−0
@@ -8,3 +8,4 @@ app.config = · 7 unmodified lines
88 production_host: 'devdocs.io'
99 search_param: 'q'
1010 sentry_dsn: '<%= App.sentry_dsn %>'
11+ version: '<%= Time.now.to_i %>'
assets/javascripts/app/db.coffee+115−0
@@ -0,0 +1,115 @@
1+class app.DB
2+ NAME = 'docs'
3+
4+ constructor: ->
5+ @useIndexedDB = @useIndexedDB()
6+
7+ init: (@_callback) ->
8+ if @useIndexedDB
9+ @initIndexedDB()
10+ else
11+ @callback()
12+ return
13+
14+ initIndexedDB: ->
15+ try
16+ req = indexedDB.open(NAME, @indexedDBVersion())
17+ req.onerror = @callback
18+ req.onsuccess = @onOpenSuccess
19+ req.onupgradeneeded = @onUpgradeNeeded
20+ catch
21+ @callback()
22+ return
23+
24+ isEnabled: ->
25+ !!@db
26+
27+ callback: =>
28+ @_callback?()
29+ @_callback = null
30+ return
31+
32+ onOpenSuccess: (event) =>
33+ try
34+ @db = event.target.result
35+ @db.transaction(['docs', app.docs.all()[0].slug], 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937
36+ catch
37+ @db = null
38+
39+ @callback()
40+ return
41+
42+ onUpgradeNeeded: (event) =>
43+ db = event.target.result
44+
45+ unless db.objectStoreNames.contains('docs')
46+ db.createObjectStore('docs')
47+
48+ for doc in app.docs.all() when not db.objectStoreNames.contains(doc.slug)
49+ db.createObjectStore(doc.slug)
50+
51+ for doc in app.disabledDocs.all() when not db.objectStoreNames.contains(doc.slug)
52+ db.createObjectStore(doc.slug)
53+ return
54+
55+ store: (doc, data, onSuccess, onError) ->
56+ txn = @db.transaction ['docs', doc.slug], 'readwrite'
57+ txn.oncomplete = -> if txn.error then onError() else onSuccess()
58+
59+ store = txn.objectStore(doc.slug)
60+ store.clear()
61+ store.add(content, path) for path, content of data
62+
63+ store = txn.objectStore('docs')
64+ store.put(doc.mtime, doc.slug)
65+ return
66+
67+ unstore: (doc, onSuccess, onError) ->
68+ txn = @db.transaction ['docs', doc.slug], 'readwrite'
69+ txn.oncomplete = -> if txn.error then onError() else onSuccess()
70+
71+ store = txn.objectStore(doc.slug)
72+ store.clear()
73+
74+ store = txn.objectStore('docs')
75+ store.delete(doc.slug)
76+ return
77+
78+ version: (doc, callback) ->
79+ txn = @db.transaction ['docs'], 'readonly'
80+ store = txn.objectStore('docs')
81+
82+ req = store.get(doc.slug)
83+ req.onsuccess = -> callback(!!req.result)
84+ req.onerror = -> callback(false)
85+ return
86+
87+ load: (entry, onSuccess, onError) ->
88+ if @isEnabled()
89+ onError = @loadWithXHR.bind(@, entry, onSuccess, onError)
90+ @loadWithIDB(entry, onSuccess, onError)
91+ else
92+ @loadWithXHR(entry, onSuccess, onError)
93+
94+ loadWithXHR: (entry, onSuccess, onError) ->
95+ ajax
96+ url: entry.fileUrl()
97+ dataType: 'html'
98+ success: onSuccess
99+ error: onError
100+
101+ loadWithIDB: (entry, onSuccess, onError) ->
102+ txn = @db.transaction [entry.doc.slug], 'readonly'
103+ store = txn.objectStore(entry.doc.slug)
104+
105+ req = store.get(entry.path)
106+ req.onsuccess = -> if req.result then onSuccess(req.result) else onError()
107+ req.onerror = onError
108+
109+ txn
110+
111+ useIndexedDB: ->
112+ !app.isSingleDoc() and !!window.indexedDB
113+
114+ indexedDBVersion: ->
115+ if app.config.env is 'production' then app.config.version else Date.now() / 1000
assets/javascripts/app/router.coffee+5−0
@@ -4,6 +4,7 @@ class app.Router · 3 unmodified lines
44 @routes: [
55 ['*', 'before' ]
66 ['/', 'root' ]
7+ ['/offline', 'offline' ]
78 ['/about', 'about' ]
89 ['/news', 'news' ]
910 ['/help', 'help' ]
@@ -75,6 +76,10 @@ class app.Router · 65 unmodified lines
7576 @triggerRoute 'root'
7677 return
7778
79+ offline: ->
80+ @triggerRoute 'offline'
81+ return
82+
7883 about: (context) ->
7984 context.page = 'about'
8085 @triggerRoute 'page'
assets/javascripts/models/doc.coffee+39−0
@@ -27,6 +27,9 @@ class app.models.Doc extends app.Model · 26 unmodified lines
2727 fileUrl: (path) ->
2828 "#{app.config.docs_host}#{@fullPath(path)}"
2929
30+ dbUrl: ->
31+ "#{app.config.docs_host}/#{@db_path}?#{@mtime}"
32+
3033 indexUrl: ->
3134 "#{app.indexHost()}/#{@index_path}?#{@mtime}"
3235
@@ -83,3 +86,39 @@ class app.models.Doc extends app.Model · 50 unmodified lines
8386 _setCache: (data) ->
8487 app.store.set @slug, [@mtime, data]
8588 return
89+
90+ download: (onSuccess, onError) ->
91+ return if @downloading
92+ @downloading = true
93+
94+ error = =>
95+ @downloading = null
96+ onError()
97+
98+ success = (data) =>
99+ @downloading = null
100+ app.db.store @, data, onSuccess, error
101+
102+ ajax
103+ url: @dbUrl()
104+ success: success
105+ error: error
106+ return
107+
108+ undownload: (onSuccess, onError) ->
109+ return if @downloading
110+ @downloading = true
111+
112+ success = =>
113+ @downloading = null
114+ onSuccess()
115+
116+ error = =>
117+ @downloading = null
118+ onError()
119+
120+ app.db.unstore @, success, error
121+
122+ getDownloadStatus: (callback) ->
123+ app.db.version @, (value) ->
124+ callback downloaded: !!value, version: value
assets/javascripts/models/entry.coffee+1−5
@@ -41,8 +41,4 @@ class app.models.Entry extends app.Model · 40 unmodified lines
4141 @doc.types.findBy 'name', @type
4242
4343 loadFile: (onSuccess, onError) ->
44− ajax
45− url: @fileUrl()
46− dataType: 'html'
47− success: onSuccess
48− error: onError
44+ app.db.load(@, onSuccess, onError)
assets/javascripts/templates/pages/offline_tmpl.coffee+16−0
@@ -0,0 +1,16 @@
1+app.templates.offlinePage = ->
2+ """ <h1 class="_lined-heading">Offline</h1>
3+ <table class="_docs">
4+ #{app.templates.render 'offlineDoc', app.docs.all()}
5+ </table> """
6+
7+app.templates.offlineDoc = (doc) ->
8+ """<tr data-slug="#{doc.slug}"></tr>"""
9+
10+app.templates.offlineDocContent = (doc, status) ->
11+ html = """<th class="_icon-#{doc.slug}">#{doc.name}</th>"""
12+ html += if status.downloaded
13+ """<td><a data-del>Delete</a></td>"""
14+ else
15+ """<td><a data-dl>Download</a></td>"""
16+ html
assets/javascripts/views/content/content.coffee+7−4
@@ -23,10 +23,11 @@ class app.views.Content extends app.View · 22 unmodified lines
2323 @scrollMap = {}
2424 @scrollStack = []
2525
26− @rootPage = new app.views.RootPage
27− @staticPage = new app.views.StaticPage
28− @typePage = new app.views.TypePage
29− @entryPage = new app.views.EntryPage
26+ @rootPage = new app.views.RootPage
27+ @staticPage = new app.views.StaticPage
28+ @offlinePage = new app.views.OfflinePage
29+ @typePage = new app.views.TypePage
30+ @entryPage = new app.views.EntryPage
3031
3132 @entryPage
3233 .on 'loading', @onEntryLoading
@@ -137,6 +138,8 @@ class app.views.Content extends app.View · 104 unmodified lines
137138 @show @entryPage
138139 when 'type'
139140 @show @typePage
141+ when 'offline'
142+ @show @offlinePage
140143 else
141144 @show @staticPage
142145
assets/javascripts/views/content/offline_page.coffee+63−0
@@ -0,0 +1,63 @@
1+class app.views.OfflinePage extends app.View
2+ @className: '_static'
3+
4+ @events:
5+ click: 'onClick'
6+
7+ @elements:
8+ list: '_._docs'
9+
10+ deactivate: ->
11+ if super
12+ @empty()
13+ return
14+
15+ render: ->
16+ @html @tmpl('offlinePage')
17+ @refreshElements()
18+ app.docs.each(@renderDoc)
19+ return
20+
21+ renderDoc: (doc) =>
22+ doc.getDownloadStatus (status) =>
23+ html = app.templates.render('offlineDocContent', doc, status)
24+ el = @docEl(doc)
25+ el.className = ''
26+ el.innerHTML = html
27+ return
28+
29+ getTitle: ->
30+ 'Offline'
31+
32+ getDoc: (el) ->
33+ el = el.parentNode until slug = el.getAttribute('data-slug')
34+ app.docs.findBy('slug', slug)
35+
36+ docEl: (doc) ->
37+ @find("[data-slug='#{doc.slug}']")
38+
39+ onRoute: ->
40+ @render()
41+ return
42+
43+ onClick: (event) =>
44+ if event.target.hasAttribute('data-dl')
45+ action = 'download'
46+ else if event.target.hasAttribute('data-del')
47+ action = 'undownload'
48+
49+ if action
50+ $.stopEvent(event)
51+ doc = @getDoc(event.target)
52+ doc[action](@onDownloadSuccess.bind(@, doc), @onDownloadError.bind(@, doc))
53+ @docEl(doc).classList.add("#{action}ing")
54+ return
55+
56+ onDownloadSuccess: (doc) ->
57+ @renderDoc(doc)
58+ return
59+
60+ onDownloadError: (doc) ->
61+ el = @docEl(doc)
62+ el.className = ''
63+ el.classList.add('error')
assets/stylesheets/components/_content.scss+45−0
@@ -242,6 +242,51 @@ · 241 unmodified lines
242242 td:first-child, td:last-child { white-space: nowrap; }
243243 }
244244
245+//
246+// Doc table
247+//
248+
249+._docs {
250+ width: 100%;
251+ line-height: 1.5rem;
252+
253+ th {
254+ max-width: 0;
255+ padding-left: .5rem;
256+ padding-right: .5rem;
257+ white-space: nowrap;
258+ font-weight: normal;
259+
260+ &:before {
261+ float: left;
262+ margin: .25rem .5rem .25rem 0;
263+ @extend %icon;
264+ }
265+ }
266+
267+ td:last-child { text-align: right; }
268+ td > a { cursor: pointer; }
269+
270+ tr.downloading > td:last-child {
271+ > a { display: none; }
272+ &:before { content: 'Downloading…' }
273+ }
274+
275+ tr.undownloading > td:last-child {
276+ > a { display: none; }
277+ &:before { content: 'Deleting…' }
278+ }
279+
280+ tr.error > td:last-child {
281+ > a { display: none; }
282+
283+ &:before {
284+ content: 'Error';
285+ color: red;
286+ }
287+ }
288+}
289+
245290 //
246291 // News
247292 //
lib/app.rb+1−1
@@ -122,7 +122,7 @@ class App < Sinatra::Application · 121 unmodified lines
122122 erb :index
123123 end
124124
125− %w(about news help).each do |page|
125+ %w(offline about news help).each do |page|
126126 get "/#{page}" do
127127 redirect "/#/#{page}", 302
128128 end