mirror of https://github.com/perkeep/perkeep.git
568 lines
15 KiB
Go
568 lines
15 KiB
Go
//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(`<div id='nav-%s' class='camlifile'></div>`, fit.pn)
|
|
jQuery(ficDiv).Append(navDiv)
|
|
if fic.prev != nil {
|
|
prevNav := `[<a id='prev' href='javascript:;'>prev</a>]`
|
|
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 := `[<a id='next' href='javascript:;'>next</a>]`
|
|
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(`<div id='%s'>File: %s, %d bytes, type %s</div>`, fit.pn, html.EscapeString(fit.fileName), fit.size, fit.mimeType)
|
|
jQuery(ficDiv).Append(fileInfo)
|
|
anchor := fmt.Sprintf("<a id='%s' href='%s'><img src='%s'></a>", fit.pn, fit.download, fit.thumb)
|
|
jQuery(ficDiv).Append(anchor)
|
|
downloadDiv := fmt.Sprintf(`<div id='camli-%s' class='camlifile'>[<a href='%s'>download</a>]</div>`, fit.contentRef, fit.download)
|
|
jQuery(ficDiv).Append(downloadDiv)
|
|
}
|