//go:build js /* Copyright 2016 The Perkeep Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "encoding/json" "fmt" "html" "io" "net/http" "net/url" "path" "strings" "sync" "perkeep.org/pkg/blob" "github.com/gopherjs/gopherjs/js" "github.com/gopherjs/jquery" ) const ( ficDiv = "div#fileitemcontainer" // jquery matching for the top file container element fileThumbnailHeight = 600 ) var theFic *fileItemContainer // StartRenderFile displays a view of the template subject when the subject is a // file. It relies on the presence of a div with id "fileitemcontainer", to create // DOM elements as children of the mentioned div. As the actual rendering is run in // a goroutine, it is not guaranteed to be finished when StartRenderFile returns. func StartRenderFile() { // renderFile calls funcs that wait on http requests or channels, // which is not allowed in a javascript callback, so they have to be called // from within a goroutine. go renderFile() } // renderFile creates a fileItemContainer, populates it, renders it, and // binds the left and right arrow keys for to it for navigation. func renderFile() { var err error theFic, err = newFileItemContainer(fileThumbnailHeight) if err != nil { fmt.Printf("error creating file container: %v\n", err) return } if err := theFic.populate(); err != nil { fmt.Printf("Error initializing file container: %v", err) return } if err := theFic.render(); err != nil { fmt.Printf("Error rendering file container: %v", err) return } jQuery(js.Global).Call(jquery.KEYUP, func(e jquery.Event) { if e.Which == 37 { theFic.doPrev() go func() { if err := theFic.render(); err != nil { fmt.Printf("Error rendering file container: %v", err) } }() return } if e.Which == 39 { theFic.doNext() go func() { if err := theFic.render(); err != nil { fmt.Printf("Error rendering file container: %v", err) } }() return } }) } type fileItemContainer struct { mu sync.Mutex // guards the whole container parent blob.Ref basePath string host string scheme string pathPrefix string // app handler's prefix if it applies, e.g. "/pics/", or "/" otherwise. // isTopNode indicates whether we're a direct child of the publish root. // Because most of the URL paths change if that's the case. I think we // could test for !parent.Valid() instead but it does not seem very clean. isTopNode bool current *fileItem next *fileItem prev *fileItem items []*fileItem currentIdx int // pos of current in items beginningReached bool // Nothing left to fetch at the beginning endReached bool // Nothing left to fetch at the end thumbHeight int // 600 } type fileItem struct { pn blob.Ref // containing permanode contentRef blob.Ref fileName string size int64 mimeType string isImage bool thumb string download string } func newFileItemContainer(thumbHeight int) (*fileItemContainer, error) { host, err := host() if err != nil { return nil, err } scheme, err := scheme() if err != nil { return nil, err } basePath, err := subjectBasePath() if err != nil { return nil, err } root, err := publishedRoot() if err != nil { return nil, err } prefix, err := pathPrefix() if err != nil { return nil, err } var parent blob.Ref isTopNode := false if !strings.Contains(basePath, "/-") { isTopNode = true basePath += "/-" } else { basePath = path.Dir(basePath) if strings.HasSuffix(basePath, "/-") { parent = root } else { _, parentPrefixPath := path.Split(basePath) parentPrefix := strings.TrimPrefix(parentPrefixPath, "h") parent, err = getFullRef(scheme, host, prefix, parentPrefix) if err != nil { return nil, err } } } return &fileItemContainer{ basePath: basePath, parent: parent, host: host, scheme: scheme, pathPrefix: prefix, thumbHeight: thumbHeight, isTopNode: isTopNode, }, nil } func (fic *fileItemContainer) populate() error { if fic == nil { return fmt.Errorf("uninitialized file container") } pn, err := subject() if err != nil { return err } var sr *SearchResult if fic.isTopNode { sr, err = fic.describe(pn) } else { sr, err = fic.getPeers(pn, 3) } if err != nil { return err } // TODO(mpl): see if the code below can be refactored with updateItems. meta := sr.Describe.Meta itemIdx := 0 for _, v := range sr.Blobs { desbr, ok := meta[v.Blob.String()] if !ok { continue } if desbr.Permanode == nil { continue } camliContent := desbr.Permanode.Attr.Get("camliContent") if camliContent == "" { continue } contentRef := blob.MustParse(camliContent) cdes := meta[contentRef.String()] if cdes == nil { continue } file := cdes.File if file == nil { continue } item := &fileItem{ pn: desbr.BlobRef, fileName: file.FileName, size: file.Size, mimeType: file.MIMEType, isImage: file.IsImage(), contentRef: contentRef, } fic.items = append(fic.items, item) if item.pn == pn { fic.current = item fic.currentIdx = itemIdx } itemIdx++ } if fic.currentIdx > 0 { fic.prev = fic.items[fic.currentIdx-1] } if fic.currentIdx < len(fic.items)-1 { fic.next = fic.items[fic.currentIdx+1] } return nil } func getFullRef(scheme, host, pathPrefix, digestPrefix string) (blob.Ref, error) { var br blob.Ref ca := fmt.Sprintf(`{"blobRefPrefix":"sha224-%s"}`, digestPrefix) cb := fmt.Sprintf(`{"blobRefPrefix":"sha1-%s"}`, digestPrefix) query := fmt.Sprintf(`{"constraint":{"logical":{"op":"or","a":%s,"b":%s}}}`, ca, cb) resp, err := http.Post(fmt.Sprintf("%s://%s%ssearch", scheme, host, pathPrefix), "application/json", strings.NewReader(query)) if err != nil { return br, err } if resp.StatusCode != 200 { return br, fmt.Errorf("search error: %v", resp.Status) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return br, err } var sr SearchResult if err := json.Unmarshal(data, &sr); err != nil { return br, err } if len(sr.Blobs) == 0 { return br, fmt.Errorf("full blobref for prefix %v not found", digestPrefix) } return sr.Blobs[0].Blob, nil } func (fic fileItemContainer) describe(pn blob.Ref) (*SearchResult, error) { query := fmt.Sprintf(`{"constraint":{"blobRefPrefix": "%s"},"describe":{"depth":1,"rules":[{"attrs":["camliContent","camliContentImage"]}]}}`, pn) resp, err := http.Post(fmt.Sprintf("%s://%s%ssearch", fic.scheme, fic.host, fic.pathPrefix), "application/json", strings.NewReader(query)) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("search error: %v", resp.Status) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var sr SearchResult if err := json.Unmarshal(data, &sr); err != nil { return nil, err } return &sr, nil } func (fic fileItemContainer) getPeers(around blob.Ref, limit int) (*SearchResult, error) { // TODO(mpl): use types from search pkg instead of raw JSON if we ever import the search pkg. query := fmt.Sprintf(`{"sort":"-created","constraint":{"permanode":{"relation":{"relation": "parent", "any": {"blobRefPrefix": "%s"}}}},"describe":{"depth":1,"rules":[{"attrs":["camliContent","camliContentImage"]}]},"limit":%d, "around": "%s"}`, fic.parent, limit, around) resp, err := http.Post(fmt.Sprintf("%s://%s%ssearch", fic.scheme, fic.host, fic.pathPrefix), "application/json", strings.NewReader(query)) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("search error: %v", resp.Status) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var sr SearchResult if err := json.Unmarshal(data, &sr); err != nil { return nil, err } return &sr, nil } func (fic *fileItemContainer) doPrev() { fic.mu.Lock() defer fic.mu.Unlock() if fic.prev == nil { return } if fic.currentIdx > 0 { fic.currentIdx-- fic.current = fic.prev } } func (fic *fileItemContainer) doNext() { fic.mu.Lock() defer fic.mu.Unlock() if fic.next == nil { return } if fic.currentIdx < len(fic.items)-1 { fic.currentIdx++ fic.current = fic.next } } // No need for locking because only caller is render. func (fic *fileItemContainer) refreshTitle() { if fic.current == nil { return } // TODO(mpl): other title sources. or not? after all, we're a file // container, so should we use anything other than the file name (which // should always exist)? If/when the publisher supports anything other than // files, then reconsider (and probably make another, different container // anyway). jQuery("h1#title").SetText(html.EscapeString(fic.current.fileName)) } // No need for locking because only caller is render. func (fic *fileItemContainer) refreshLocation() { if fic.current == nil { return } if fic.isTopNode { return } hash := fic.current.pn.DigestPrefix(10) js.Global.Get("history").Call("pushState", hash, "", fic.basePath+"/h"+hash) } // No need for locking because only caller is render. func (fic *fileItemContainer) updateItems(sr *SearchResult, from blob.Ref, prepend bool) { // TODO(mpl): "forget" old items so we don't grow fic.items indefinitely? (sliding window) if sr == nil { return } var newBlobs []*SearchResultBlob fromIdx := 0 for k, v := range sr.Blobs { if v.Blob == from { fromIdx = k break } } if prepend { newBlobs = sr.Blobs[:fromIdx] } else { newBlobs = sr.Blobs[fromIdx+1:] } var newItems []*fileItem meta := sr.Describe.Meta for _, v := range newBlobs { desbr, ok := meta[v.Blob.String()] if !ok { continue } if desbr.Permanode == nil { continue } camliContent := desbr.Permanode.Attr.Get("camliContent") if camliContent == "" { continue } contentRef := blob.MustParse(camliContent) cdes := meta[contentRef.String()] if cdes == nil { continue } file := cdes.File if file == nil { continue } newItems = append(newItems, &fileItem{ pn: desbr.BlobRef, fileName: file.FileName, size: file.Size, mimeType: file.MIMEType, isImage: file.IsImage(), contentRef: contentRef, }) } if prepend { if len(newItems) == 0 { // Nothing left to fetch at the beginning fic.beginningReached = true return } fic.items = append(newItems, fic.items...) fic.currentIdx += len(newItems) return } if len(newItems) == 0 { // Nothing left to fetch at the end fic.endReached = true return } fic.items = append(fic.items, newItems...) } // No need for locking because only caller is render. func (fic *fileItemContainer) refreshNav() { fic.prev, fic.next = nil, nil fic.current = fic.items[fic.currentIdx] if fic.currentIdx > 0 { fic.prev = fic.items[fic.currentIdx-1] } if fic.currentIdx < len(fic.items)-1 { fic.next = fic.items[fic.currentIdx+1] } fit := fic.current // TODO(mpl): make sure the href='javascript:;' trick is ok. Also see if we can't // have a working href as a backup for users with no javascript? navDiv := fmt.Sprintf(``, fit.pn) jQuery(ficDiv).Append(navDiv) if fic.prev != nil { prevNav := `[]` jQuery(fmt.Sprintf("div#nav-%s", fit.pn)).Append(prevNav) jQuery("a#prev").Call(jquery.CLICK, func(e jquery.Event) { theFic.doPrev() go func() { theFic.render() }() }) } if fic.next != nil { nextNav := `[]` jQuery(fmt.Sprintf("div#nav-%s", fit.pn)).Append(nextNav) jQuery("a#next").Call(jquery.CLICK, func(e jquery.Event) { theFic.doNext() go func() { theFic.render() }() }) } } // TODO(mpl): optimization: it might be interesting to let render return as soon // as the actual rendering is done, and not have to wait for getPeers and // updateItems. But it would make locking more complicated, so later. func (fic *fileItemContainer) render() error { fic.mu.Lock() defer fic.mu.Unlock() if len(fic.items) == 0 { return nil } fit := fic.current needsUpdate := false prepend := false var around blob.Ref if !fic.endReached && fic.currentIdx+1 >= len(fic.items)-1 { needsUpdate = true around = fic.items[len(fic.items)-1].pn } else if !fic.beginningReached && fic.currentIdx <= 1 { needsUpdate = true prepend = true around = fic.items[0].pn } c := make(chan error) var sr *SearchResult go func() { var err error if needsUpdate { sr, err = fic.getPeers(around, 9) } c <- err }() // Do the main rendering work while waiting for getPeers fit.setThumb(fic) fit.setDownload(fic) jQuery(ficDiv).Empty() fic.refreshTitle() fic.refreshLocation() fit.render() err := <-c if err != nil { return fmt.Errorf("cannot get peers of %v: %v", fit.pn, err) } if needsUpdate { fic.updateItems(sr, around, prepend) } fic.refreshNav() return nil } func (fit *fileItem) setThumb(fic *fileItemContainer) { if !fit.isImage { fit.thumb = fmt.Sprintf("%s=s/file.png", fic.pathPrefix) return } if fic.isTopNode { fit.thumb = fmt.Sprintf("%s/h%s/=i/%s/?mw=%d&mh=%d", fic.basePath, fit.contentRef.DigestPrefix(10), url.QueryEscape(fit.fileName), maxThumbWidthRatio*fic.thumbHeight, fic.thumbHeight) return } fit.thumb = fmt.Sprintf("%s/h%s/h%s/=i/%s/?mw=%d&mh=%d", fic.basePath, fit.pn.DigestPrefix(10), fit.contentRef.DigestPrefix(10), url.QueryEscape(fit.fileName), maxThumbWidthRatio*fic.thumbHeight, fic.thumbHeight) } func (fit *fileItem) setDownload(fic *fileItemContainer) { if fic.isTopNode { fit.download = fmt.Sprintf("%s/h%s/=f/%s", fic.basePath, fit.contentRef.DigestPrefix(10), url.QueryEscape(fit.fileName)) return } fit.download = fmt.Sprintf("%s/h%s/h%s/=f/%s", fic.basePath, fit.pn.DigestPrefix(10), fit.contentRef.DigestPrefix(10), url.QueryEscape(fit.fileName)) } func (fit *fileItem) render() { fileInfo := fmt.Sprintf(`
File: %s, %d bytes, type %s
`, fit.pn, html.EscapeString(fit.fileName), fit.size, fit.mimeType) jQuery(ficDiv).Append(fileInfo) anchor := fmt.Sprintf("", fit.pn, fit.download, fit.thumb) jQuery(ficDiv).Append(anchor) downloadDiv := fmt.Sprintf(`
[download]
`, fit.contentRef, fit.download) jQuery(ficDiv).Append(downloadDiv) }