Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Rob Loranger
50986664e8
hide elements before they are displayed 2019-08-28 14:56:14 -07:00
Rob Loranger
02fb828934
prevent extra submissions and improve feedback
user feedback logic was updated to report if zero posts were found in a
zip and form submissions disable the submit button until the form input
for files changes again, preventing possible duplicate submissions on
large zip uploads.

updated to v0.2.1 wfimport to prevent early error returns when an
invalid file is present in a zip.
2019-08-26 16:16:21 -07:00
Rob Loranger
0b95b16e3c
trim white space 2019-08-26 15:10:37 -07:00
Rob Loranger
2deb6d0ee9
clarify zip warning message 2019-08-26 15:05:20 -07:00
Rob Loranger
44d2a9585b
add dynamic form contents with JS enabled
by default all content is enabled and displayed, this allows users
without javascript enabled to still use the form and understand what is
happening.

users with javascript enabled will see actions and options
disabled until the files selected allow, and show information and
warnings about zip files in needed.
2019-08-26 15:05:19 -07:00
Rob Loranger
232d6b56c9
allow importing from zip
this allows zip files in the import upload form

plain files are still uploaded to the selected collection but zip
contents are considered drafts with the exception of subdirectories,
these are added to the corresponding collections. if no collection is
found then it will be created.
2019-08-26 15:04:18 -07:00
7 changed files with 246 additions and 52 deletions

View file

@ -5,6 +5,7 @@ import (
"html/template"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"os"
"path/filepath"
@ -55,36 +56,60 @@ func viewImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error
func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
// limit 10MB per submission
// TODO: increase?
r.ParseMultipartForm(10 << 20)
files := r.MultipartForm.File["files"]
var fileErrs []error
filesSubmitted := len(files)
var filesImported int
var filesImported, collsImported int
var errs []error
// TODO: support multiple zip uploads at once
if filesSubmitted == 1 && files[0].Header.Get("Content-Type") == "application/zip" {
filesSubmitted, filesImported, collsImported, errs = importZipPosts(app, w, r, files[0], u)
} else {
filesImported, errs = importFilePosts(app, w, r, files, u)
}
if len(errs) != 0 {
_ = addSessionFlash(app, w, r, multierror.ListFormatFunc(errs), nil)
}
if filesImported == filesSubmitted && filesSubmitted != 0 {
postAdj := "posts"
if filesSubmitted == 1 {
postAdj = "post"
}
if collsImported != 0 {
collAdj := "collections"
if collsImported == 1 {
collAdj = "collection"
}
_ = addSessionFlash(app, w, r, fmt.Sprintf(
"SUCCESS: Import complete, %d %s imported across %d %s.",
filesImported,
postAdj,
collsImported,
collAdj,
), nil)
} else {
_ = addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: Import complete, %d %s imported.", filesImported, postAdj), nil)
}
} else if filesImported == 0 && filesSubmitted == 0 {
_ = addSessionFlash(app, w, r, "INFO: 0 valid posts found", nil)
} else if filesImported > 0 {
_ = addSessionFlash(app, w, r, fmt.Sprintf("INFO: %d of %d posts imported, see details below.", filesImported, filesSubmitted), nil)
}
return impart.HTTPError{http.StatusFound, "/me/import"}
}
func importFilePosts(app *App, w http.ResponseWriter, r *http.Request, files []*multipart.FileHeader, u *User) (int, []error) {
var fileErrs []error
var count int
for _, formFile := range files {
file, err := formFile.Open()
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("failed to open form file: %s", formFile.Filename))
log.Error("import textfile: open from form: %v", err)
if filepath.Ext(formFile.Filename) == ".zip" {
fileErrs = append(fileErrs, fmt.Errorf("zips are supported as a single upload only: %s", formFile.Filename))
log.Info("zip included in bulk files, skipping")
continue
}
defer file.Close()
tempFile, err := ioutil.TempFile("", "post-upload-*.txt")
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("failed to create temporary file for: %s", formFile.Filename))
log.Error("import textfile: create temp file: %v", err)
continue
}
defer tempFile.Close()
_, err = io.Copy(tempFile, file)
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("failed to copy file into temporary location: %s", formFile.Filename))
log.Error("import textfile: copy to temp: %v", err)
continue
}
info, err := tempFile.Stat()
info, err := formFileToTemp(formFile)
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("failed to get file info of: %s", formFile.Filename))
log.Error("import textfile: stat temp file: %v", err)
@ -142,20 +167,98 @@ func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) err
false,
)
}
filesImported++
count++
}
if len(fileErrs) != 0 {
_ = addSessionFlash(app, w, r, multierror.ListFormatFunc(fileErrs), nil)
return count, fileErrs
}
func importZipPosts(app *App, w http.ResponseWriter, r *http.Request, file *multipart.FileHeader, u *User) (filesSubmitted, importedPosts, importedColls int, errs []error) {
info, err := formFileToTemp(file)
if err != nil {
errs = append(errs, fmt.Errorf("upload temp file: %v", err))
return
}
if filesImported == filesSubmitted {
verb := "posts"
if filesSubmitted == 1 {
verb = "post"
postMap, err := wfimport.FromZipDirs(filepath.Join(os.TempDir(), info.Name()))
if err != nil {
errs = append(errs, fmt.Errorf("parse posts and collections from zip: %v", err))
return
}
_ = addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: Import complete, %d %s imported.", filesImported, verb), nil)
} else if filesImported > 0 {
_ = addSessionFlash(app, w, r, fmt.Sprintf("INFO: %d of %d posts imported, see details below.", filesImported, filesSubmitted), nil)
for collKey, posts := range postMap {
if len(posts) == 0 {
continue
}
return impart.HTTPError{http.StatusFound, "/me/import"}
collObj := CollectionObj{}
if collKey != wfimport.DraftsKey {
coll, err := app.db.GetCollection(collKey)
if err == ErrCollectionNotFound {
coll, err = app.db.CreateCollection(app.cfg, collKey, collKey, u.ID)
if err != nil {
errs = append(errs, fmt.Errorf("create non existent collection: %v", err))
continue
}
coll.hostName = app.cfg.App.Host
collObj.Collection = *coll
} else if err != nil {
errs = append(errs, fmt.Errorf("get collection: %v", err))
continue
}
collObj.Collection = *coll
importedColls++
}
for _, post := range posts {
if post != nil {
filesSubmitted++
created := post.Created.Format("2006-01-02T15:04:05Z")
submittedPost := SubmittedPost{
Title: &post.Title,
Content: &post.Content,
Font: "norm",
Created: &created,
}
rp, err := app.db.CreatePost(u.ID, collObj.Collection.ID, &submittedPost)
if err != nil {
errs = append(errs, fmt.Errorf("create post: %v", err))
continue
}
if collObj.Collection.ID != 0 && app.cfg.App.Federation {
go federatePost(
app,
&PublicPost{
Post: rp,
Collection: &collObj,
},
collObj.Collection.ID,
false,
)
}
importedPosts++
}
}
}
return
}
func formFileToTemp(formFile *multipart.FileHeader) (os.FileInfo, error) {
file, err := formFile.Open()
if err != nil {
return nil, fmt.Errorf("failed to open form file: %s", formFile.Filename)
}
defer file.Close()
tempFile, err := ioutil.TempFile("", fmt.Sprintf("upload-*%s", filepath.Ext(formFile.Filename)))
if err != nil {
return nil, fmt.Errorf("failed to create temporary file for: %s", formFile.Filename)
}
defer tempFile.Close()
_, err = io.Copy(tempFile, file)
if err != nil {
return nil, fmt.Errorf("failed to copy file into temporary location: %s", formFile.Filename)
}
return tempFile.Stat()
}

2
go.mod
View file

@ -57,7 +57,7 @@ require (
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
github.com/writeas/httpsig v1.0.0
github.com/writeas/impart v1.1.0
github.com/writeas/import v0.2.0
github.com/writeas/import v0.2.1
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
github.com/writeas/nerds v1.0.0
github.com/writeas/openssl-go v1.0.0 // indirect

5
go.sum
View file

@ -61,6 +61,7 @@ github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a/go.mod h1:TUIZ+29jo
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
@ -169,10 +170,14 @@ github.com/writeas/import v0.0.0-20190815214647-baae8acd8d06 h1:S6oKKP8GhSoyZUvV
github.com/writeas/import v0.0.0-20190815214647-baae8acd8d06/go.mod h1:f3K8z7YnJwKnPIT4h7980n9C6cQb4DIB2QcxVCTB7lE=
github.com/writeas/import v0.0.0-20190815235139-628d10daaa9e h1:31PkvDTWkjzC1nGzWw9uAE92ZfcVyFX/K9L9ejQjnEs=
github.com/writeas/import v0.0.0-20190815235139-628d10daaa9e/go.mod h1:f3K8z7YnJwKnPIT4h7980n9C6cQb4DIB2QcxVCTB7lE=
github.com/writeas/import v0.1.0 h1:ZbAOb6QL24tgZOEAmEJ/hk59fF4UGOX8sQLaYE+yNiA=
github.com/writeas/import v0.1.0/go.mod h1:f3K8z7YnJwKnPIT4h7980n9C6cQb4DIB2QcxVCTB7lE=
github.com/writeas/import v0.1.1 h1:SbYltT+nxrJBUe0xQWJqeKMHaupbxV0a6K3RtwcE4yY=
github.com/writeas/import v0.1.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY=
github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=

View file

@ -6,5 +6,6 @@
@import "effects";
@import "admin";
@import "pages/error";
@import "pages/import";
@import "lib/elements";
@import "lib/material";

18
less/pages/import.less vendored Normal file
View file

@ -0,0 +1,18 @@
form.import-form {
display: flex;
flex-direction: column;
align-items: center;
&span.row {
justify-content: space-around;
}
input[type=file] {
width: 100%;
align-self: center;
}
input[type=submit] {
width: 100%;
margin-top: .5rem;
}
}

55
static/js/import.js Normal file
View file

@ -0,0 +1,55 @@
const fileForm = document.querySelector('form.import-form');
const selectElem = document.querySelector('select[name="collection"]');
const submitElem = document.querySelector('input[type="submit"]');
const zipInfo = document.querySelector('span.zip > ul.info');
const zipWarning = document.querySelector('span.zip > p.error');
const fileInput = document.querySelector('input[type="file"]')
document.onreadystatechange = () => {
if ( document.readyState === "interactive") {
selectElem.disabled = true;
submitElem.disabled = true;
zipInfo.hidden = true;
zipWarning.hidden = true;
}
}
fileInput.onchange = function() {
if ( this.files.length === 1 ) {
if ( this.files[0].type === 'application/zip' ) {
selectElem.disabled = true;
submitElem.disabled = false;
zipInfo.hidden = false;
zipWarning.hidden = true;
} else {
selectElem.disabled = false;
submitElem.disabled = false;
zipInfo.hidden = true;
zipWarning.hidden = true;
}
}
if ( this.files.length > 1 ) {
selectElem.disabled = false;
submitElem.disabled = false;
var zips = 0;
Array.from(this.files).forEach(file => {
if ( file.name.endsWith(".zip") ) {
zips++;
}
})
if ( zips > 0 ) {
zipInfo.hidden = true;
zipWarning.hidden = false;
} else {
zipInfo.hidden = true;
zipWarning.hidden = true;
}
}
}
submitElem.addEventListener("click", (e) => {
e.preventDefault();
submitElem.disabled = true;
fileForm.submit();
});

View file

@ -8,12 +8,13 @@
</div>
{{end}}
<h2 id="posts-header">Import</h2>
<p>Upload text or markdown files to import as posts.</p>
<div class="formContainer">
<form id="importPosts" class="import" enctype="multipart/form-data" action="/api/me/import" method="POST">
<p>This form allows you to import posts from files on your computer.</p>
<p>Any number text or markdown files are supported, as well as zip archives of posts.</p>
<div>
<form class="import-form" enctype="multipart/form-data" action="/api/me/import" method="POST">
<label for="file" hidden>Browse files to upload</label>
<input class="fileInput" name="files" type="file" multiple accept="text/markdown, text/plain"/>
<br />
<input name="files" type="file" multiple accept="text/*, application/zip"/>
<span class="row">
<label for="collection">Select a blog to import the posts under.</label>
<select name="collection">
{{range $i, $el := .Collections}}
@ -23,7 +24,17 @@
{{end}}
<option value="" selected>drafts</option>
</select>
<br />
</span>
<span class="row zip">
<p class="error">
WARNING: zip files must be uploaded separately, selected zips will be <strong>skipped</strong>.
</p>
<ul class="info">
<li>Root level zip files are imported as drafts</li>
<li>ZIP sub-directories are imported as blog collections.<br/>
If no blog exists matching the sub-directory name, one will be created if possible.</li>
</ul>
</span>
<input type="submit" value="Import" />
</form>
</div>
@ -35,4 +46,5 @@
</div>
{{template "footer" .}}
<script src="/js/import.js"></script>
{{end}}