Author: Adam <git@apiote.tk>
Merge branch 'v0.3.0'
CHANGELOG.adoc | 37 + README.adoc | 18 accounts/common.go | 29 + accounts/login.go | 180 +++++++ accounts/signup.go | 92 +++ datastructure/error.go | 9 datastructure/experiences.go | 42 + datastructure/item.go | 53 ++ datastructure/list.go | 6 datastructure/tvqueue.go | 74 +++ datastructure/watchlist.go | 39 + db/db.go | 903 ++++++++++++++++++++++++++++++++++++++ front/capnproto.go | 38 + front/html.go | 230 ++++----- front/renderer.go | 15 go.mod | 27 go.sum | 98 ++++ i18n/check_translation | 42 + i18n/en-GB.toml | 136 +++++ i18n/i18n.go | 75 ++ i18n/pl-PL.toml | 139 +++++ libamuse/about.go | 8 libamuse/account.go | 288 ++++++++++++ libamuse/book.go | 7 libamuse/bookserie.go | 7 libamuse/common.go | 83 +++ libamuse/db.go | 25 - libamuse/experiences.go | 71 ++ libamuse/film.go | 63 ++ libamuse/index.go | 7 libamuse/login.go | 58 ++ libamuse/manage.go | 9 libamuse/person.go | 7 libamuse/search.go | 17 libamuse/serie.go | 136 +++++ libamuse/signup.go | 152 ++++++ libamuse/tvqueue.go | 55 ++ libamuse/user.go | 145 ++++++ libamuse/watchlist.go | 55 ++ main.go | 37 + mkfile | 2 router.go | 410 ++++++++++++++++ static/style/style.css | 337 ++++++++++++- templates/about.html | 31 + templates/book.html | 33 + templates/bookserie.html | 35 + templates/error.html | 14 templates/experiences.html | 109 ++++ templates/film.html | 72 ++ templates/index.html | 35 + templates/loggedout.html | 29 + templates/login.html | 42 + templates/person.html | 33 + templates/search.html | 41 + templates/serie.html | 176 ------- templates/signedup.html | 36 + templates/signup.html | 54 ++ templates/tvqueue.html | 118 ++++ templates/tvserie.html | 309 +++++++++++++ templates/watchlist.html | 115 ++++ tmdb/common.go | 85 +-- tmdb/film.go | 71 ++ tmdb/genres.go | 76 +++ tmdb/serie.go | 161 +++++-
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 0e5e075d774b81fddea55c4bd3c5ab8b200ce4a4..8125da169f7f931ea44ab97c41fe7a5f6c88f2cb 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -9,10 +9,8 @@ == Unreleased === Added -* User account -* Watchlist, Readlist -* Experience logging (watched, read) -* Next episode to watch in series +* Readlist +* Experience logging (read) * Upcoming episodes and films from watchlist * Film and series discovery * Manpages @@ -31,11 +29,38 @@ * Search pagination based on films *and* books * Show books not available in Wikidata, from Inventaire * Further optimisation (minify css, caching) * Improve based-on -* Limit tv series episode description length + +== [0.3.0] — 2020-05-23 + +=== Added + +* Watchlist (with searching) +* Showing that film in watchlist has previous, not watched part +* Marking films watched (also on another date) +* TV Queue +* Marking episodes skipped or watched +* Showing progress in TV series +* Skipping whole season 0 in TV series +* Experiences (of films and TV series) +* User account (signup and login) +* Next episode to watch in TV series +* New verbs (%a, %b, %h) in date translations + +=== Changed + +* Placeholder on the front page is cut with ellipsis +* Episode description in last and next episode are cut with ellipsis +* Updated translations +* Year is hidden if it’s unknown +* Cancelled TV series are treated like ended ones + +=== Fixed + +* Years in TV series if end year is unknown == [0.2.0] - 2020-02-10 -== Added +=== Added * Searching books (from Wikidata only). * Showing books (from Wikidata only). diff --git a/README.adoc b/README.adoc index 7ea0619e6212e41acd2f0a52e4a445bf49246319..4c4e0c57cb2c40ee7e79036cf8ce63c6a5b5cea9 100644 --- a/README.adoc +++ b/README.adoc @@ -1,6 +1,6 @@ = a·muse apiote <me@apiote.tk> -v0.2.0 (Agrajag) 2020-02-10 +v0.3.0 (Colin) 2020-03-23 :toc: a·muse is a no-JavaScript frontend for The Movie Database. It is also a system that connects films with books which the former are based on, thanks to using data from Wikidata. Finally, a·muse is also a place to collect ideas which films, books, and series watch or read next, and which of those have already been watched or read. @@ -11,26 +11,22 @@ The name of the system is ‘a·muse.’ The name of the program, command, any package is ‘amuse.’ Both are pronounced the same–[æˈʔmjuːz]. -It’s a play of words—amuse as a verb, and a muse as a goddess. +It’s a play of words—‘amuse’ as a verb, and ‘a muse’ as a goddess. == Building -To build version 0.2.x, You need: +To build version 0.3.x, You need: * `go>=1.11` * `resvg` * `cwebp` * `mk` -=== Python router - -Because `apiote.tk` runs currently on a shared hosting with no possibility of running arbitrary binaries, but with wsgi, a·muse can be built as a Python module and run with a Python router. This only works with `glibc` (https://github.com/golang/go/issues/13492[Github issue]). Furthermore, the shared hosting uses `python==3.7` so a friendly `Dockerfile` is provided. +Then, all You have to do is run `mk`, and—optionally—`mk install` -Build script is only provided in the `mkfile`, not in the `makefile`. +=== Python router -*This is a hack. You are on Your own.* - -*This is currently NOT supported.* +WARNING: Python router is deprecated == Contribute @@ -50,7 +46,7 @@ == Licence ---- -amuse Copyright (C) 2019 apiote +amuse Copyright (C) 2019–2020 apiote This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by diff --git a/accounts/common.go b/accounts/common.go new file mode 100644 index 0000000000000000000000000000000000000000..848c6340c82a277be82ef3be4af26fe73f934ebb --- /dev/null +++ b/accounts/common.go @@ -0,0 +1,29 @@ +package accounts + +type User struct { + Username string + IsAdmin bool + Session string + Timezone string +} + +func (u User) IsEmpty() bool { + return u.Username == "" +} + +type Authentication struct { + Token string + Necessary bool +} + +type AuthError struct { + Err error +} + +func (e AuthError) Error() string { + return "Auth error: " + e.Err.Error() +} + +func (e AuthError) Unwrap() error { + return e.Err +} diff --git a/accounts/login.go b/accounts/login.go new file mode 100644 index 0000000000000000000000000000000000000000..2961e7574d97838870ac0b1a11319a508b91b454 --- /dev/null +++ b/accounts/login.go @@ -0,0 +1,180 @@ +package accounts + +import ( + "notabug.org/apiote/amuse/db" + + "bytes" + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/pquerna/otp/totp" + "golang.org/x/crypto/argon2" + "notabug.org/apiote/gott" +) + +type AuthData struct { + username string + password string + sfa string + remember bool +} + +type AuthResult struct { + user *db.User + passwordHash string + sfaSecret string + recoveryCodesRaw string + recoveryCodes []string + token string +} + +type Argon struct { + password string + argon string + parts []string + memory uint32 + time uint32 + threads uint8 + salt []byte + hash []byte + keyLen uint32 +} + +func findUser(args ...interface{}) (interface{}, error) { + authData := args[0].(*AuthData) + authResult := args[1].(*AuthResult) + user, err := db.GetUser(authData.username) + authResult.user = user + if empty, ok := err.(db.EmptyError); ok { + err = AuthError{Err: empty} + } + return gott.Tuple(args), err +} + +func unmarshalUser(args ...interface{}) interface{} { + authResult := args[1].(*AuthResult) + authResult.passwordHash = authResult.user.PasswordHash + authResult.sfaSecret = authResult.user.Sfa + authResult.recoveryCodesRaw = authResult.user.RecoveryCodes + authResult.recoveryCodes = strings.Split(authResult.recoveryCodesRaw, ",") + return gott.Tuple(args) +} + +func splitArgon(args ...interface{}) interface{} { + argon := args[0].(*Argon) + argon.parts = strings.Split(argon.argon, "$") + return gott.Tuple(args) +} + +func decodeArgonParams(args ...interface{}) (interface{}, error) { + argon := args[0].(*Argon) + _, err := fmt.Sscanf(argon.parts[3], "m=%d,t=%d,p=%d", &argon.memory, + &argon.time, &argon.threads) + return gott.Tuple(args), err +} + +func decodeSalt(args ...interface{}) (interface{}, error) { + argon := args[0].(*Argon) + salt, err := base64.RawStdEncoding.DecodeString(argon.parts[4]) + argon.salt = salt + return gott.Tuple(args), err +} + +func decodeHash(args ...interface{}) (interface{}, error) { + argon := args[0].(*Argon) + hash, err := base64.RawStdEncoding.DecodeString(argon.parts[5]) + argon.hash = hash + argon.keyLen = uint32(len(hash)) + return gott.Tuple(args), err +} + +func compareArgon(args ...interface{}) (interface{}, error) { + argon := args[0].(*Argon) + comparisonHash := argon2.IDKey([]byte(argon.password), argon.salt, argon.time, + argon.memory, argon.threads, argon.keyLen) + if bytes.Compare(comparisonHash, argon.hash) != 0 { + return gott.Tuple(args), AuthError{Err: errors.New("Password does not match")} + } else { + return gott.Tuple(args), nil + } +} + +func checkPassword(args ...interface{}) (interface{}, error) { + authData := args[0].(*AuthData) + authResult := args[1].(*AuthResult) + _, err := gott. + NewResult(gott.Tuple{&Argon{argon: authResult.passwordHash, + password: authData.password}}). + Map(splitArgon). + Bind(decodeArgonParams). + Bind(decodeSalt). + Bind(decodeHash). + Bind(compareArgon). + Finish() + return gott.Tuple(args), err +} + +func checkSfa(args ...interface{}) (interface{}, error) { + authData := args[0].(*AuthData) + authResult := args[1].(*AuthResult) + if authResult.sfaSecret == "" { + return gott.Tuple(args), nil + } + + for i, code := range authResult.recoveryCodes { + if authData.sfa == code { + authResult.recoveryCodes = append(authResult.recoveryCodes[:i], + authResult.recoveryCodes[i+1:]...) + authResult.recoveryCodesRaw = strings.Join(authResult.recoveryCodes, ",") + return gott.Tuple(args), nil + } + } + + authData.sfa = strings.ReplaceAll(authData.sfa, " ", "") + if totp.Validate(authData.sfa, authResult.sfaSecret) { + return gott.Tuple(args), nil + } + + return gott.Tuple(args), AuthError{Err: errors.New("Wrong TOTP token")} +} + +func updateSfa(args ...interface{}) (interface{}, error) { + authData := args[0].(*AuthData) + authResult := args[1].(*AuthResult) + err := db.UpdateRecoveryCodes(authData.username, authResult.recoveryCodesRaw) + return gott.Tuple(args), err +} + +func createSession(args ...interface{}) (interface{}, error) { + authData := args[0].(*AuthData) + authResult := args[1].(*AuthResult) + session, err := db.CreateSession(authData.username, false) // todo long session + authResult.token = session.Id + return gott.Tuple(args), err +} + +func clearSessions(args ...interface{}) (interface{}, error) { + result := args[1].(*AuthResult) + err := db.ClearSessions(result.user.Username) + return gott.Tuple(args), err +} + +func Login(username, password, sfa string, remember bool) (string, error) { + r, err := gott. + NewResult(gott.Tuple{&AuthData{username: username, password: password, + sfa: sfa, remember: remember}, &AuthResult{}}). + Bind(findUser). + Bind(clearSessions). + Map(unmarshalUser). + Bind(checkPassword). + Bind(checkSfa). + Bind(updateSfa). + Bind(createSession). + Finish() + if err != nil { + return "", err + } + return r.(gott.Tuple)[1].(*AuthResult).token, err +} diff --git a/accounts/signup.go b/accounts/signup.go new file mode 100644 index 0000000000000000000000000000000000000000..c622a36960bf4f2331d36f3bfbebee75488df22d --- /dev/null +++ b/accounts/signup.go @@ -0,0 +1,92 @@ +package accounts + +import ( + "notabug.org/apiote/amuse/db" + + "encoding/base64" + "errors" + "fmt" + "math/rand" + "strings" + + "golang.org/x/crypto/argon2" + "notabug.org/apiote/gott" +) + +func findNoUser(args ...interface{}) (interface{}, error) { + authData := args[0].(*AuthData) + authResult := args[1].(*AuthResult) + user, err := db.GetUser(authData.username) + authResult.user = user + if _, ok := err.(db.EmptyError); ok { + err = nil + } else if err == nil { + err = AuthError{ + Err: errors.New("user_exists"), + } + } + return gott.Tuple(args), err +} + +func prepareSalt(args ...interface{}) (interface{}, error) { + argon := args[2].(*Argon) + salt := make([]byte, 16) + _, err := rand.Read(salt) + argon.salt = salt + return gott.Tuple(args), err +} + +func hashPassword(args ...interface{}) interface{} { + authData := args[0].(*AuthData) + argon := args[2].(*Argon) + password := authData.password + + hash := argon2.IDKey([]byte(password), argon.salt, 1, 64*1024, 4, 32) + b64Salt := base64.RawStdEncoding.EncodeToString(argon.salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + format := "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s" + fullHash := fmt.Sprintf(format, argon2.Version, 64*1024, 1, 4, b64Salt, b64Hash) + authData.password = fullHash + return gott.Tuple(args) +} + +func createRecoveryCodes(args ...interface{}) interface{} { + authData := args[0].(*AuthData) + sfaSecret := authData.sfa + if sfaSecret != "" { + result := args[1].(*AuthResult) + codes := []string{} + for i := 0; i < 12; i++ { + code := rand.Int63n(999999999999) + codeStr := fmt.Sprintf("%012d", code) + codes = append(codes, codeStr) + } + result.recoveryCodesRaw = strings.Join(codes, ",") + } + return gott.Tuple(args) +} + +func insertUser(args ...interface{}) (interface{}, error) { + authData := args[0].(*AuthData) + result := args[1].(*AuthResult) + sfaSecret := authData.sfa + err := db.InsertUser(authData.username, authData.password, sfaSecret, result.recoveryCodesRaw) + return gott.Tuple(args), err +} + +func Signup(username, password, sfaSecret string) (string, error) { + r, err := gott. + NewResult(gott.Tuple{&AuthData{username: username, password: password, + sfa: sfaSecret}, &AuthResult{}, &Argon{}}). + Bind(findNoUser). + Bind(prepareSalt). + Map(hashPassword). + Map(createRecoveryCodes). + Bind(insertUser). + Finish() + + if err != nil { + return "", err + } + return r.(gott.Tuple)[1].(*AuthResult).recoveryCodesRaw, err +} diff --git a/datastructure/error.go b/datastructure/error.go new file mode 100644 index 0000000000000000000000000000000000000000..39363f86a87f076c5d3041721e3696d836c15277 --- /dev/null +++ b/datastructure/error.go @@ -0,0 +1,9 @@ +package datastructure + +type ValueError struct { + Message string +} + +func (e ValueError) Error() string { + return e.Message +} diff --git a/datastructure/experiences.go b/datastructure/experiences.go new file mode 100644 index 0000000000000000000000000000000000000000..486faa631d4fe09faf244a86d79632bf5181a34e --- /dev/null +++ b/datastructure/experiences.go @@ -0,0 +1,42 @@ +package datastructure + +import ( + "notabug.org/apiote/amuse/i18n" + + "time" +) + +type ExperiencesEntry struct { + ItemInfo + Type string + Id string + Code string + Datetime time.Time +} + +type Experiences struct { + List []ExperiencesEntry + Page int + Pages int + Query string +} + +func (e ExperiencesEntry) FormatDatetime(strings i18n.Translation) string { + return i18n.FormatDate(e.Datetime, strings.Global["date_format_time"], strings.Global) +} + +func (e Experiences) NextPage() int { + if e.Page < e.Pages { + return e.Page + 1 + } else { + return e.Page + } +} + +func (e Experiences) PrevPage() int { + if e.Page > 1 { + return e.Page - 1 + } else { + return e.Page + } +} diff --git a/datastructure/item.go b/datastructure/item.go new file mode 100644 index 0000000000000000000000000000000000000000..49049f609c6edc1ded63cd8fa5abdb1417e47353 --- /dev/null +++ b/datastructure/item.go @@ -0,0 +1,53 @@ +package datastructure + +import ( + "strconv" + "strings" +) + +type ItemInfo struct { + Cover string + Status string + Title string + YearStart int + YearEnd int + BasedOn string + Genres string + Runtime int + Collection int + Part int + Episodes int +} + +func (i ItemInfo) IsUnreleased(itemType ItemType) bool { + return (itemType == ItemTypeFilm && i.Status != "Released") || + (itemType == ItemTypeTvserie && i.Status != "Returning Series" && i.Status != "Ended") +} + +func (i ItemInfo) GetGenres(genres map[int]string) string { + genreIds := strings.Split(i.Genres, ",") + genreNames := []string{} + for _, genreId := range genreIds { + if genreId != "" { + genreIdInt, _ := strconv.ParseInt(genreId, 10, 64) + if genres[int(genreIdInt)] != "" { + genreNames = append(genreNames, genres[int(genreIdInt)]) + } + } + } + return strings.Join(genreNames, ", ") +} + +type Item interface { + GetItemInfo() ItemInfo + GetItemType() ItemType +} + +type ItemType string + +const ( + ItemTypeBook ItemType = "book" + ItemTypeFilm = "film" + ItemTypeTvserie = "tvserie" + ItemTypeUnkown = "unknown" +) diff --git a/datastructure/list.go b/datastructure/list.go new file mode 100644 index 0000000000000000000000000000000000000000..4ba072a0e226c2456e3450178332a51ac7fe4ea3 --- /dev/null +++ b/datastructure/list.go @@ -0,0 +1,6 @@ +package datastructure + +type List interface { + SetGenres(map[int]string) + GetType() ItemType +} diff --git a/datastructure/tvqueue.go b/datastructure/tvqueue.go new file mode 100644 index 0000000000000000000000000000000000000000..116c866779774a09bb2d2d5b100d9d009eb3a1b0 --- /dev/null +++ b/datastructure/tvqueue.go @@ -0,0 +1,74 @@ +package datastructure + +import ( + "strconv" +) + +func min(a, b int) int { + if a > b { + return b + } else { + return a + } +} // todo replicated code from libamuse/serie.go + +type TvQueueEntry struct { + ItemInfo + Id string + HasPrevious bool + WatchedEpisodes int + SkippedEpisodes int +} + +func (e TvQueueEntry) GetYears() string { + if e.YearStart == 0 { + return "" + } else if e.Status == "Ended" || e.Status == "Canceled" { + if e.YearEnd == e.YearStart || e.YearEnd == 0 { + return strconv.FormatInt(int64(e.YearStart), 10) + } else { + return strconv.FormatInt(int64(e.YearStart), 10) + "–" + strconv.FormatInt(int64(e.YearEnd), 10) + } + } else { + return strconv.FormatInt(int64(e.YearStart), 10) + "–" + } +} + +func (e TvQueueEntry) CalculateProgress() int { // todo replicated code from libamuse/serie.go + if e.Episodes-e.SkippedEpisodes == 0 { + return 0 + } + return min(e.WatchedEpisodes*100/(e.Episodes-e.SkippedEpisodes), 100) +} + +type TvQueue struct { + List []TvQueueEntry + Page int + Pages int + Genres map[int]string + Query string +} + +func (q *TvQueue) SetGenres(m map[int]string) { + q.Genres = m +} + +func (q *TvQueue) GetType() ItemType { + return ItemTypeTvserie +} + +func (q TvQueue) NextPage() int { + if q.Page < q.Pages { + return q.Page + 1 + } else { + return q.Page + } +} + +func (q TvQueue) PrevPage() int { + if q.Page > 1 { + return q.Page - 1 + } else { + return q.Page + } +} diff --git a/datastructure/watchlist.go b/datastructure/watchlist.go new file mode 100644 index 0000000000000000000000000000000000000000..ac12a25f399487ca3f68bf76969a97d219812715 --- /dev/null +++ b/datastructure/watchlist.go @@ -0,0 +1,39 @@ +package datastructure + +type WatchlistEntry struct { + ItemInfo + Id string + HasPrevious bool +} + +type Watchlist struct { + List []WatchlistEntry + Page int + Pages int + Genres map[int]string + Query string +} + +func (w *Watchlist) SetGenres(m map[int]string) { + w.Genres = m +} + +func (w *Watchlist) GetType() ItemType { + return ItemTypeFilm +} + +func (w Watchlist) NextPage() int { + if w.Page < w.Pages { + return w.Page + 1 + } else { + return w.Page + } +} + +func (w Watchlist) PrevPage() int { + if w.Page > 1 { + return w.Page - 1 + } else { + return w.Page + } +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000000000000000000000000000000000000..a9ebc7135c0c3a8d70f40840f84d62d2aca6f413 --- /dev/null +++ b/db/db.go @@ -0,0 +1,903 @@ +package db + +import ( + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/utils" + + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "math" + "os" + "sort" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type CacheEntry struct { + Etag string + Data []byte +} + +type EmptyError struct { + message string +} + +func (e EmptyError) Error() string { + return e.message +} + +type User struct { + Username string + PasswordHash string + Sfa string + Avatar []byte + AvatarSmall []byte + IsAdmin bool + RecoveryCodes string + Timezone string +} + +type Session struct { + Id string + Username string + Expiry time.Time + IsLong bool +} + +func Migrate() error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + return err + } + defer db.Close() + + _, err = db.Exec(`create table cache(uri text primary key, etag text, date date, response blob, last_hit date)`) + if err != nil && err.Error() != "table cache already exists" { + return err + } + _, err = db.Exec(`create table users(username text primary key, password text, sfa text, is_admin bool, recovery_codes text, avatar blob, avatar_small blob, timezone text)`) + if err != nil && err.Error() != "table users already exists" { + return err + } + _, err = db.Exec(`create table sessions(id text primary key, username text, expiry datetime, is_long boolean, foreign key(username) references users(username))`) + if err != nil && err.Error() != "table sessions already exists" { + return err + } + _, err = db.Exec(`create table wantlist(username text, item_type text, item_id text, primary key(username, item_type, item_id), foreign key(username) references users(username))`) + if err != nil && err.Error() != "table wantlist already exists" { + return err + } + _, err = db.Exec(`create table experiences(username text, item_type text, item_id text, time datetime, foreign key(username) references users(username), primary key(username, item_type, item_id, time))`) + if err != nil && err.Error() != "table experiences already exists" { + return err + } + _, err = db.Exec(`create table item_cache (item_type text, item_id text, cover text, status text, title text, year_start int, year_end int, based_on text, genres text, runtime int, collection int, part int, ref_count int, episodes int, primary key(item_type, item_id))`) + if err != nil && err.Error() != "table item_cache already exists" { + return err + } + return nil +} + +func MakeAdmin(username string) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + _, err = db.Exec("update users set is_admin = 1 where username = ?", username) + if err != nil { + fmt.Fprintf(os.Stderr, "Update err %v\n", err) + return err + } + rows, err := db.Query(`select is_admin from users where username = ?`, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Select err %v\n", err) + return err + } + defer rows.Close() + + if !rows.Next() { + fmt.Fprintf(os.Stderr, "User %s does not exist\n", username) + return errors.New("User does not exist") + } + var isAdmin bool + err = rows.Scan(&isAdmin) + if err != nil { + fmt.Fprintf(os.Stderr, "Scan err %v\n", err) + return err + } + fmt.Println(isAdmin) + return nil +} + +func InsertUser(username, password, sfaSecret, recoveryCodes string) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + _, err = db.Exec("insert into users values(?, ?, ?, 0, ?, '', '', 'UTC')", username, password, sfaSecret, recoveryCodes) + if err != nil { + fmt.Fprintf(os.Stderr, "Inert err %v\n", err) + return err + } + + return nil +} + +func GetUser(username string) (*User, error) { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return nil, err + } + defer db.Close() + rows, err := db.Query(`select password, sfa, recovery_codes, is_admin, avatar, avatar_small, timezone from users where username = ?`, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Select err %v\n", err) + return nil, err + } + defer rows.Close() + if !rows.Next() { + return nil, EmptyError{message: "User does not exist"} + } + user := User{Username: username} + err = rows.Scan(&user.PasswordHash, &user.Sfa, &user.RecoveryCodes, &user.IsAdmin, &user.Avatar, &user.AvatarSmall, &user.Timezone) + if err != nil { + fmt.Fprintf(os.Stderr, "Scan err %v\n", err) + return nil, err + } + return &user, nil +} + +func UpdateRecoveryCodes(username, recoveryCodes string) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + _, err = db.Exec(`update users set recovery_codes = ? where username = ?`, recoveryCodes, username) + if err != nil { + return err + } + + return nil +} + +func CreateSession(username string, long bool) (Session, error) { + sessionIdRaw := make([]byte, 64) + rand.Read(sessionIdRaw) + sessionId := hex.EncodeToString(sessionIdRaw) + + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return Session{}, err + } + defer db.Close() + + var length string + if long { + length = "30 days" + } else { + length = "1 day" + } + _, err = db.Exec(`insert into sessions values(?, ?, datetime('now', '`+length+`'), ?)`, sessionId, username, long) + if err != nil { + return Session{}, err + } + + return Session{Id: sessionId, Username: username, IsLong: long}, nil +} + +func GetSession(token string) (*Session, error) { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return nil, err + } + defer db.Close() + + rows, err := db.Query(`select username, expiry, is_long from sessions where id = ?`, token) + if err != nil { + fmt.Fprintf(os.Stderr, "Select err %v\n", err) + return nil, err + } + defer rows.Close() + if !rows.Next() { + return nil, EmptyError{message: "Session does not exist"} + } + session := Session{Id: token} + err = rows.Scan(&session.Username, &session.Expiry, &session.IsLong) + if err != nil { + fmt.Fprintf(os.Stderr, "Scan err %v\n", err) + return nil, err + } + return &session, nil +} + +func ClearSessions(username string) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + _, err = db.Exec(`delete from sessions where username = ? and expiry < datetime('now')`, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Delete err %v\n", err) + return err + } + + return nil +} + +func RemoveSession(username, token string) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + rows, err := db.Exec(`delete from sessions where id = ? and username = ?`, token, username) + affected, _ := rows.RowsAffected() + if affected == 0 { + return EmptyError{ + message: "No session " + token + " for user " + username, + } + } + + return err +} + +func GetItemExperiences(username, itemId string, itemType datastructure.ItemType) (map[string][]time.Time, error) { + times := map[string][]time.Time{} + user, err := GetUser(username) + if err != nil { + fmt.Fprintf(os.Stderr, "Get user err: %v\n", err) + return times, err + } + location, err := time.LoadLocation(user.Timezone) + if err != nil { + fmt.Fprintf(os.Stderr, "Load location err: %v\n", err) + return times, err + } + + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return times, err + } + defer db.Close() + rows, err := db.Query(`select time, item_id from experiences where username = ? and item_type = ? and (item_id = ? or item_id like ?)`, username, itemType, itemId, itemId+"/%") + if err != nil { + fmt.Fprintf(os.Stderr, "Select err %v\n", err) + return times, err + } + defer rows.Close() + + for rows.Next() { + var ( + t time.Time + id string + ) + err := rows.Scan(&t, &id) + if err != nil { + fmt.Fprintf(os.Stderr, "Scan err %v\n", err) + return times, err + } + t = t.In(location) + times[id] = append(times[id], t) + } + + for k, v := range times { + sort.Slice(v, func(i, j int) bool { + return v[i].After(v[j]) + }) + times[k] = v + } + return times, nil +} + +func AddToExperiences(username, itemId string, itemType datastructure.ItemType, datetime time.Time) (int, error) { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return 0, err + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + fmt.Fprintf(os.Stderr, "Transaction err %s\n", err) + return 0, err + } + defer tx.Rollback() + + rows, err := tx.Query(`select time from experiences where item_type = ? and item_id like ? and username = ?`, itemType, itemId, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Select err %v\n", err) + return 0, err + } + defer rows.Close() + + watchedTimes := []time.Time{} + for rows.Next() { + var t time.Time + err := rows.Scan(&t) + if err != nil { + fmt.Fprintf(os.Stderr, "Scan err %v\n", err) + return 0, err + } + watchedTimes = append(watchedTimes, t) + } + + if datetime.IsZero() && len(watchedTimes) > 0 { + return 0, datastructure.ValueError{Message: "Cannot skip watched item"} + } + + deletedRows, err := tx.Exec(`delete from experiences where username = ? and item_type = ? and item_id = ? and time = '0001-01-01 00:00:00+00:00'`, username, itemType, itemId) + if err != nil { + fmt.Fprintf(os.Stderr, "Delete err %v\n", err) + return 0, err + } + deletedRowsNumber, err := deletedRows.RowsAffected() + if err != nil { + fmt.Fprintf(os.Stderr, "Delete err %v\n", err) + return 0, err + } + + insertedRows, err := tx.Exec(`insert into experiences values(?, ?, ?, ?)`, username, itemType, itemId, datetime) + if err != nil { + fmt.Fprintf(os.Stderr, "Insert err %v\n", err) + return 0, err + } + insertedRowsNumber, err := insertedRows.RowsAffected() + if err != nil { + fmt.Fprintf(os.Stderr, "Insert err %v\n", err) + return 0, err + } + + tx.Commit() + + return int(insertedRowsNumber - deletedRowsNumber), nil +} + +func SkipSpecials(username, itemId string, episodes []string, itemType datastructure.ItemType) (int, error) { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return 0, err + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + fmt.Fprintf(os.Stderr, "Transaction err %s\n", err) + return 0, err + } + defer tx.Rollback() + + rows, err := tx.Query(`select item_id from experiences where item_type = ? and item_id like ? || '/%' and username = ?`, itemType, itemId, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Select err %v\n", err) + return 0, err + } + defer rows.Close() + + watched := map[string]int{} + for rows.Next() { + var watchedId string + err := rows.Scan(&watchedId) + if err != nil { + fmt.Fprintf(os.Stderr, "Scan err %v\n", err) + return 0, err + } + watched[watchedId]++ + } + + modifiedRows := 0 + + for _, episodeId := range episodes { + if watched[episodeId] > 0 { + continue + } + _, err = tx.Exec(`insert into experiences values(?, ?, ?, ?)`, username, itemType, episodeId, "0001-01-01 00:00:00+00:00") + if err != nil { + if err.Error()[:6] != "UNIQUE" { + fmt.Fprintf(os.Stderr, "Insert err %v\n", err) + return 0, err + } else { + fmt.Fprintf(os.Stderr, "WARNING: Insert err: Unique constraint violation\n") + } + } + modifiedRows++ + } + + err = tx.Commit() + if err != nil { + fmt.Fprintf(os.Stderr, "Commit err %v\n", err) + return 0, err + } + + return modifiedRows, nil +} + +func ClearSpecials(username, itemId string, episodes []string, itemType datastructure.ItemType) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + tx, err := db.Begin() + if err != nil { + fmt.Fprintf(os.Stderr, "Transaction err %s\n", err) + return err + } + defer tx.Rollback() + + rows, err := tx.Query(`select item_id from experiences where item_type = ? and item_id like ? || '/S00E%' and username = ? and time = "0001-01-01 00:00:00+00:00"`, itemType, itemId, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Select err %v\n", err) + return err + } + defer rows.Close() + + watched := []string{} + for rows.Next() { + var watchedId string + err := rows.Scan(&watchedId) + if err != nil { + fmt.Fprintf(os.Stderr, "Scan err %v\n", err) + return err + } + watched = append(watched, watchedId) + } + + seriesEpisodes := map[string]int{} + for _, episode := range episodes { + seriesEpisodes[episode]++ + } + + for _, episode := range watched { + if seriesEpisodes[episode] == 0 { + _, err = tx.Exec(`delete from experiences where item_type = ? and item_id = ? and username = ?`, itemType, episode, username) + if err != nil { + fmt.Fprintf(os.Stderr, "Delete err %v\n", err) + return err + } + } + } + + err = tx.Commit() + if err != nil { + fmt.Fprintf(os.Stderr, "Commit err %v\n", err) + return err + } + return nil +} + +func AddToWantList(username, itemId string, itemType datastructure.ItemType) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + _, err = db.Exec(`insert into wantlist values(?, ?, ?)`, username, itemType, itemId) + if err != nil { + fmt.Fprintf(os.Stderr, "Insert err %v\n", err) + return err + } + return nil +} + +func RemoveFromWantList(username, itemId string, itemType datastructure.ItemType) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + result, err := db.Exec(`delete from wantlist where username = ? and item_type = ? and item_id = ?`, username, itemType, itemId) + if err != nil { + fmt.Fprintf(os.Stderr, "Delete err %v\n", err) + return err + } + rows, err := result.RowsAffected() + if err != nil { + fmt.Fprintf(os.Stderr, "Delete err %v\n", err) + return err + } + if rows == 0 { + return EmptyError{ + message: "Empty delete", + } + } + return nil +} + +func IsOnWantList(username, itemId string, itemType datastructure.ItemType) (bool, error) { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return false, err + } + defer db.Close() + + rows, err := db.Query(`select 1 from wantlist where username = ? and item_id = ? and item_type = ?`, username, itemId, string(itemType)) + if err != nil { + fmt.Fprintf(os.Stderr, "Select err: %v\n", err) + return false, err + } + defer rows.Close() + isOnlist := rows.Next() + + return isOnlist, nil +} + +func SaveCacheItem(itemType datastructure.ItemType, itemId string, itemInfo datastructure.ItemInfo, refs int) error { + if refs == 0 { + return nil + } + + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + _, err = db.Exec(`insert into item_cache values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(item_type, item_id) do update set ref_count = ref_count + ?`, + itemType, itemId, itemInfo.Cover, itemInfo.Status, itemInfo.Title, itemInfo.YearStart, itemInfo.YearEnd, itemInfo.BasedOn, itemInfo.Genres, itemInfo.Runtime, itemInfo.Collection, itemInfo.Part, refs, itemInfo.Episodes, refs) + if err != nil { + return err + } + return nil +} + +func UpdateCacheItem(itemType datastructure.ItemType, itemId string, itemInfo datastructure.ItemInfo) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + db.Exec(`update item_cache set cover = ?, status = ?, title = ?, year_start = ?, year_end = ?, based_on = ?, genres = ?, runtime = ?, collection = ?, part = ?, episodes = ? where item_type = ? and item_id = ?`, itemInfo.Cover, itemInfo.Status, itemInfo.Title, itemInfo.YearStart, itemInfo.YearEnd, itemInfo.BasedOn, itemInfo.Genres, itemInfo.Runtime, itemInfo.Collection, itemInfo.Part, itemInfo.Episodes, itemType, itemId) + + return nil +} + +func RemoveCacheItem(itemType datastructure.ItemType, itemId string) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + _, err = db.Exec(`update item_cache set ref_count = ref_count - 1 where item_id = ?`, itemId) + + return err +} + +func CleanItemCache() error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + _, err = db.Exec(`delete from item_cache where ref_count <= 0`) + + return err +} + +func GetCacheItem(itemType datastructure.ItemType, itemId string) (*datastructure.ItemInfo, error) { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return nil, err + } + defer db.Close() + + var ( + itemInfo datastructure.ItemInfo + itemTypeDb datastructure.ItemType + itemIdDb string + refCount int + ) + + row := db.QueryRow(`select * from cache where item_type = ? and item_id = ?`, itemType, itemId) + + err = row.Scan(&itemTypeDb, &itemIdDb, &itemInfo.Cover, &itemInfo.Status, &itemInfo.Title, &itemInfo.YearStart, &itemInfo.YearEnd, &itemInfo.BasedOn, &itemInfo.Genres, &itemInfo.Runtime, &itemInfo.Collection, &itemInfo.Part, refCount) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } else { + return nil, err + } + } + return &itemInfo, nil +} + +// ==== + +func GetCacheEntry(uri string) (*CacheEntry, error) { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return nil, err + } + defer db.Close() + + row := db.QueryRow(`select etag, response from cache where uri = ?`, uri) + + var cacheEntry CacheEntry + err = row.Scan(&cacheEntry.Etag, &cacheEntry.Data) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } else { + return nil, err + } + } + + return &cacheEntry, err +} + +func CleanCache() error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + row := db.QueryRow(`select count(*) from cache`) + + var count int + err = row.Scan(&count) + if err != nil { + return err + } + + for count > 10000 { + _, err = db.Exec(`delete from cache where last_update = (select min(last_update) from cache)`) + if err != nil { + return err + } + count-- + } + + return nil +} + +func SaveCacheEntry(uri, etag string, data []byte) error { + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return err + } + defer db.Close() + + _, err = db.Exec(`insert into cache values(?, ?, null, ?, datetime('now')) + on conflict(uri) do update set etag = excluded.etag, response = excluded.response, last_hit = excluded.last_hit`, uri, etag, data) + return err +} + +func GetWatchlist(username, filter string, page int) (datastructure.Watchlist, error) { + watchlist := datastructure.Watchlist{} + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return watchlist, err + } + defer db.Close() + + if page <= 0 { + page = 1 + } + + var pages float64 + row := db.QueryRow(`select count(*) from wantlist where item_type = 'film' and username = ?`, username) + err = row.Scan(&pages) + if err != nil { + return watchlist, err + } + watchlist.Pages = int(math.Ceil(pages / 18)) + + offset := (page - 1) * 18 + + //todo filter, order by + + var whereClause string + if filter != "" { + whereClause = "and c1.title like '%" + filter + "%'" + } + + rows, err := db.Query(`select distinct c1.item_id, c1.cover, c1.status, c1.title, c1.year_start, c1.based_on, c1.genres, c1.runtime, c1.part, c2.part from (wantlist w natural join item_cache c1) left join (experiences e natural join item_cache c2) on(c1.part-1 = c2.part and c1.collection = c2.collection and e.username = w.username) where c1.item_type = 'film' and w.username = ? `+whereClause+` order by c1.title limit ?,18`, username, offset) + + if err != nil { + fmt.Fprintf(os.Stderr, "Select err: %v\n", err) + return watchlist, err + } + defer rows.Close() + + for rows.Next() { + var ( + entry datastructure.WatchlistEntry + prevPart *int + ) + err := rows.Scan(&entry.Id, &entry.Cover, &entry.Status, &entry.Title, &entry.YearStart, &entry.BasedOn, &entry.Genres, &entry.Runtime, &entry.Part, &prevPart) + if err != nil { + fmt.Println("Scan error") + return datastructure.Watchlist{}, err + } + + if entry.Part > 0 && prevPart == nil { + entry.HasPrevious = true + } + watchlist.List = append(watchlist.List, entry) + } + + return watchlist, nil +} + +func GetTvQueue(username, filter string, page int) (datastructure.TvQueue, error) { + tvQueue := datastructure.TvQueue{} + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return tvQueue, err + } + defer db.Close() + + if page <= 0 { + page = 1 + } + + var pages float64 + row := db.QueryRow(`select count(*) from wantlist where item_type = 'tvserie' and username = ?`, username) + err = row.Scan(&pages) + if err != nil { + return tvQueue, err + } + tvQueue.Pages = int(math.Ceil(pages / 18)) + + offset := (page - 1) * 18 + + //todo filter, order by + + var whereClause string + if filter != "" { + whereClause = "and c1.title like '%" + filter + "%'" + } + + rows, err := db.Query(`select item_id, cover, status, based_on, genres, title, year_start, year_end, substr(e.id, 1, pos-1) as series_id, episodes from wantlist w left join (select item_id as id, instr(item_id, '/') as pos from experiences where item_type = 'tvserie' group by substr(id, 1, pos-1)) e on item_id = series_id natural join item_cache c where item_type = 'tvserie' and username = ? `+whereClause+` order by title limit ?,18`, username, offset) + + if err != nil { + fmt.Fprintf(os.Stderr, "Select err: %v\n", err) + return tvQueue, err + } + defer rows.Close() + + for rows.Next() { + var ( + entry datastructure.TvQueueEntry + episodes_watched *int + episodes_skipped *int + series_id *int + ) + err := rows.Scan(&entry.Id, &entry.Cover, &entry.Status, &entry.BasedOn, &entry.Genres, &entry.Title, &entry.YearStart, &entry.YearEnd, &series_id, &entry.Episodes) + if err != nil { + fmt.Println("Scan error") + return datastructure.TvQueue{}, err + } + + if series_id != nil { + row := db.QueryRow(`select count(time) from experiences where item_type = 'tvserie' and username = ? and item_id like ? || '/%' and time != '0001-01-01 00:00:00+00:00'`, username, *series_id) + err = row.Scan(&episodes_watched) + if err != nil { + fmt.Println("Scan error") + return datastructure.TvQueue{}, err + } + row = db.QueryRow(`select count(time) from experiences where item_type = 'tvserie' and username = ? and item_id like ? || '/%' and time == '0001-01-01 00:00:00+00:00'`, username, *series_id) + err = row.Scan(&episodes_skipped) + if err != nil { + fmt.Println("Scan error") + return datastructure.TvQueue{}, err + } + + if episodes_watched == nil { + entry.WatchedEpisodes = 0 + } else { + entry.WatchedEpisodes = *episodes_watched + } + if episodes_skipped == nil { + entry.SkippedEpisodes = 0 + } else { + entry.SkippedEpisodes = *episodes_skipped + } + } + + tvQueue.List = append(tvQueue.List, entry) + } + + return tvQueue, nil +} + +func GetUserExperiences(username, filter string, page int) (datastructure.Experiences, error) { + experiences := datastructure.Experiences{} + db, err := sql.Open("sqlite3", utils.DataHome+"/amuse.db") + if err != nil { + fmt.Fprintf(os.Stderr, "DB open err\n") + return experiences, err + } + defer db.Close() + + if page <= 0 { + page = 1 + } + + var pages float64 + row := db.QueryRow(`select count(*) from experiences where username = ? and time != '0001-01-01 00:00:00+00:00'`, username) + err = row.Scan(&pages) + if err != nil { + return experiences, err + } + experiences.Pages = int(math.Ceil(pages / 18)) + + offset := (page - 1) * 18 + + //todo filter, order by + + var whereClause string + if filter != "" { + whereClause = "and c1.title like '%" + filter + "%'" + } + + rows, err := db.Query(`select case when substr(e.item_id, 1, pos-1) = '' then e.item_id else substr(e.item_id, 1, pos-1) end as id, substr(e.item_id, pos+1) as code, e.item_type, time, title, year_start, collection, part from (select *, instr(item_id, '/') as pos from experiences) e join item_cache c on id = c.item_id and e.item_type = c.item_type where username = ? `+whereClause+` order by time desc limit ?,18;`, username, offset) + + if err != nil { + fmt.Fprintf(os.Stderr, "Select err: %v\n", err) + return experiences, err + } + defer rows.Close() + + for rows.Next() { + var ( + entry datastructure.ExperiencesEntry + ) + err := rows.Scan(&entry.Id, &entry.Code, &entry.Type, &entry.Datetime, &entry.Title, &entry.YearStart, &entry.Collection, &entry.Part) + entry.Part += 1 + if err != nil { + fmt.Println("Scan error") + return datastructure.Experiences{}, err + } + + if !entry.Datetime.IsZero() { + experiences.List = append(experiences.List, entry) + } + } + + return experiences, nil + +} diff --git a/front/capnproto.go b/front/capnproto.go index 85e0707f23ea5e9a8d9180fc46683811fc4e558c..d722f33eac2fc1462da7af45894dc824575f6699 100644 --- a/front/capnproto.go +++ b/front/capnproto.go @@ -3,8 +3,11 @@ import ( "notabug.org/apiote/amuse/tmdb" "notabug.org/apiote/amuse/wikidata" + "notabug.org/apiote/amuse/datastructure" "golang.org/x/text/language" + + "github.com/pquerna/otp" ) func TODO(message string) interface{} { @@ -36,9 +39,8 @@ func (CapnprotoRenderer) RenderBook(book wikidata.Book, languages []language.Tag) string { return TODO("implement CapnprotoRenderer.RenderBook").(string) } - func (CapnprotoRenderer) RenderBookSerie(bookSerie wikidata.BookSerie, languages []language.Tag) string { - return TODO("implement CapnprotoRenderer.RenderBook").(string) + return TODO("implement CapnprotoRenderer.RenderBookSerie").(string) } func (CapnprotoRenderer) RenderAbout(languages []language.Tag) string { @@ -48,3 +50,35 @@ func (CapnprotoRenderer) RenderErrorPage(code int, languages []language.Tag) string { return TODO("implement CapnprotoRenderer.RenderErrorPage").(string) } + +func (CapnprotoRenderer) RenderLogin(languages []language.Tag, err error, target string) string { + // todo throw Wrong Accept + return TODO("implement CapnprotoRenderer.RenderLogin").(string) +} + +func (CapnprotoRenderer) RenderLoggedOut(languages []language.Tag) string { + // todo throw Wrong Accept + return TODO("implement CapnprotoRenderer.RenderLogin").(string) +} + +func (CapnprotoRenderer) RenderSignup(languages []language.Tag, err error, otp *otp.Key, sfaEnabled bool, username, qr string) string { + // todo throw Wrong Accept + return TODO("implement CapnprotoRenderer.RenderSignup").(string) +} + +func (CapnprotoRenderer) RenderSignedup(languages []language.Tag, recoveryCodes []string) string { + // todo throw Wrong Accept + return TODO("implement CapnprotoRenderer.RenderSignedup").(string) +} + +func (CapnprotoRenderer) RenderWatchlist(watchlist datastructure.Watchlist, languages []language.Tag) string { + return TODO("implement CapnprotoRenderer.RenderWatchlist").(string) +} + +func (CapnprotoRenderer) RenderTvQueue(watchlist datastructure.TvQueue, languages []language.Tag) string { + return TODO("implement CapnprotoRenderer.RenderTvQueue").(string) +} + +func (CapnprotoRenderer) RenderExperiences(experiences datastructure.Experiences, languages []language.Tag) string { + return TODO("implement CapnprotoRenderer.RenderExperiences").(string) +} diff --git a/front/html.go b/front/html.go index 04e28d6406992d9cda1f8b8836941152463e51a0..6a97673d243210f588454ba3391f2ee228c738bc 100644 --- a/front/html.go +++ b/front/html.go @@ -1,21 +1,29 @@ package front import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/datastructure" "notabug.org/apiote/amuse/i18n" "notabug.org/apiote/amuse/tmdb" - "notabug.org/apiote/amuse/wikidata" "notabug.org/apiote/amuse/utils" + "notabug.org/apiote/amuse/wikidata" "bytes" "golang.org/x/text/language" "html/template" "strings" "time" + + "github.com/pquerna/otp" ) type RenderData struct { Data interface{} Strings i18n.Translation + State struct { + Error error + User accounts.User + } } func (d RenderData) LetAmuse0() string { @@ -34,6 +42,10 @@ func (d RenderData) FormatDate(date time.Time) string { return i18n.FormatDate(date, d.Strings.Global["date_format"], d.Strings.Global) } +func (d RenderData) FormatDateNice(date time.Time, timezone string) string { + return i18n.FormatDateNice(date, d.Strings, timezone) +} + func (d RenderData) RenderAsciiDoc(s string) template.HTML { return i18n.RenderAsciiDoc(s) } @@ -42,17 +54,21 @@ func (d RenderData) GetErrorData(code int, kind string) string { return i18n.GetErrorData(code, d.Strings, kind) } -type HtmlRenderer struct{} +type HtmlRenderer struct { + user accounts.User +} -func (HtmlRenderer) RenderFilm(film *tmdb.Film, languages []language.Tag) string { +func render(languages []language.Tag, data RenderData, file string) string { i18n.LoadServerLangs() language := i18n.Match(languages) strings, err := i18n.LoadStrings(language) if err != nil { // todo return http:500 } - data := RenderData{film, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/film.html") + + data.Strings = strings + + t, _ := template.ParseFiles(utils.DataHome + "/templates/" + file + ".html") b := bytes.NewBuffer([]byte{}) err = t.Execute(b, data) if err != nil { @@ -61,146 +77,106 @@ } return b.String() } -func (HtmlRenderer) RenderSearch(tmdbResults *tmdb.SearchResults, inventaireResults *wikidata.SearchResults, languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } +func (r HtmlRenderer) RenderFilm(film *tmdb.Film, languages []language.Tag) string { + data := RenderData{Data: film} + data.State.User = r.user + return render(languages, data, "film") +} +func (r HtmlRenderer) RenderSearch(tmdbResults *tmdb.SearchResults, inventaireResults *wikidata.SearchResults, languages []language.Tag) string { results := struct { T *tmdb.SearchResults I *wikidata.SearchResults }{tmdbResults, inventaireResults} - - data := RenderData{results, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/search.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() + data := RenderData{Data: results} + data.State.User = r.user + return render(languages, data, "search") } -func (HtmlRenderer) RenderIndex(randomComedy string, languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } - data := RenderData{randomComedy, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/index.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() +func (r HtmlRenderer) RenderIndex(randomComedy string, languages []language.Tag) string { + data := RenderData{Data: randomComedy} + data.State.User = r.user + return render(languages, data, "index") } -func (HtmlRenderer) RenderTvSerie(serie *tmdb.TvSerie, languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } - data := RenderData{serie, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/serie.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() +func (r HtmlRenderer) RenderTvSerie(tvSerie *tmdb.TvSerie, languages []language.Tag) string { + data := RenderData{Data: tvSerie} + data.State.User = r.user + return render(languages, data, "tvserie") } -func (HtmlRenderer) RenderPerson(person *tmdb.Person, languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } - data := RenderData{person, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/person.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() +func (r HtmlRenderer) RenderPerson(person *tmdb.Person, languages []language.Tag) string { + data := RenderData{Data: person} + data.State.User = r.user + return render(languages, data, "person") } -func (HtmlRenderer) RenderBook(book wikidata.Book, languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } - data := RenderData{book, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/book.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() +func (r HtmlRenderer) RenderBook(book wikidata.Book, languages []language.Tag) string { + data := RenderData{Data: book} + data.State.User = r.user + return render(languages, data, "book") } -func (HtmlRenderer) RenderBookSerie(bookSerie wikidata.BookSerie, languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } - data := RenderData{bookSerie, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/bookserie.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() +func (r HtmlRenderer) RenderBookSerie(bookSerie wikidata.BookSerie, languages []language.Tag) string { + data := RenderData{Data: bookSerie} + data.State.User = r.user + return render(languages, data, "bookserie") } -func (HtmlRenderer) RenderAbout(languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } - i18n.RenderAsciiDoc(strings.About["doc"]) - data := RenderData{nil, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/about.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() +func (r HtmlRenderer) RenderAbout(languages []language.Tag) string { + data := RenderData{} + data.State.User = r.user + return render(languages, data, "about") } func (HtmlRenderer) RenderErrorPage(code int, languages []language.Tag) string { - i18n.LoadServerLangs() - language := i18n.Match(languages) - strings, err := i18n.LoadStrings(language) - if err != nil { - // todo return http:500 - } - i18n.RenderAsciiDoc(strings.About["doc"]) - data := RenderData{code, strings} - t, _ := template.ParseFiles(utils.DataHome + "/templates/error.html") - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, data) - if err != nil { - // todo return http:500 - } - return b.String() + data := RenderData{Data: code} + return render(languages, data, "error") +} + +func (HtmlRenderer) RenderLogin(languages []language.Tag, authError error, target string) string { + data := RenderData{Data: target} + data.State.Error = authError + return render(languages, data, "login") +} + +func (HtmlRenderer) RenderLoggedOut(languages []language.Tag) string { + data := RenderData{} + return render(languages, data, "loggedout") +} + +func (HtmlRenderer) RenderSignup(languages []language.Tag, authError error, key *otp.Key, sfaEnabled bool, username, qr string) string { + secret := struct { + Secret string + SfaEnabled bool + Username string + Qr template.URL + }{key.Secret(), sfaEnabled, username, template.URL(qr)} + data := RenderData{Data: secret} + data.State.Error = authError + return render(languages, data, "signup") +} + +func (r HtmlRenderer) RenderSignedup(languages []language.Tag, recoveryCodes []string) string { + data := RenderData{Data: recoveryCodes} + data.State.User = r.user + return render(languages, data, "signedup") +} + +func (r HtmlRenderer) RenderWatchlist(watchlist datastructure.Watchlist, languages []language.Tag) string { + data := RenderData{Data: watchlist} + data.State.User = r.user + return render(languages, data, "watchlist") +} + +func (r HtmlRenderer) RenderTvQueue(tvqueue datastructure.TvQueue, languages []language.Tag) string { + data := RenderData{Data: tvqueue} + data.State.User = r.user + return render(languages, data, "tvqueue") +} + +func (r HtmlRenderer) RenderExperiences(experiences datastructure.Experiences, languages []language.Tag) string { + data := RenderData{Data: experiences} + data.State.User = r.user + return render(languages, data, "experiences") } diff --git a/front/renderer.go b/front/renderer.go index 807d2903e0de2aa67be7e6624be0aab45280c589..df1c27130a500d7744ebdd5daa4a9b1161b7c02a 100644 --- a/front/renderer.go +++ b/front/renderer.go @@ -1,10 +1,14 @@ package front import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/datastructure" "notabug.org/apiote/amuse/tmdb" "notabug.org/apiote/amuse/wikidata" "golang.org/x/text/language" + + "github.com/pquerna/otp" ) type NoSuchRendererError struct { @@ -25,12 +29,19 @@ RenderBook(wikidata.Book, []language.Tag) string RenderBookSerie(wikidata.BookSerie, []language.Tag) string RenderAbout([]language.Tag) string RenderErrorPage(int, []language.Tag) string + RenderLogin([]language.Tag, error, string) string + RenderLoggedOut([]language.Tag) string + RenderSignup([]language.Tag, error, *otp.Key, bool, string, string) string + RenderSignedup([]language.Tag, []string) string + RenderWatchlist(datastructure.Watchlist, []language.Tag) string + RenderTvQueue(datastructure.TvQueue, []language.Tag) string + RenderExperiences(datastructure.Experiences, []language.Tag) string } -func NewRenderer(mimetype string) (Renderer, error) { +func NewRenderer(mimetype string, user accounts.User) (Renderer, error) { switch mimetype { case "text/html": - return HtmlRenderer{}, nil + return HtmlRenderer{user: user}, nil case "application/capnproto": return CapnprotoRenderer{}, nil default: diff --git a/go.mod b/go.mod index 60251dbdbbb5988ccf9892d4d7c182b5846cc1a0..b72aafa49b3fae1a42889bd08d3d0eded0001da6 100644 --- a/go.mod +++ b/go.mod @@ -4,20 +4,27 @@ go 1.13 require ( github.com/BurntSushi/toml v0.3.1 - github.com/bytesparadise/libasciidoc v0.2.0 + github.com/alecthomas/chroma v0.7.2 // indirect + github.com/bytesparadise/libasciidoc v0.4.0 + github.com/chai2010/webp v1.1.0 + github.com/dlclark/regexp2 v1.2.0 // indirect github.com/go-python/gopy v0.3.1 github.com/knakk/digest v0.0.0-20160404164910-fd45becddc49 // indirect github.com/knakk/rdf v0.0.0-20190304171630-8521bf4c5042 // indirect - github.com/knakk/sparql v0.0.0-20190415133729-e66682c662f6 - github.com/mattn/go-sqlite3 v2.0.2+incompatible - github.com/onsi/ginkgo v1.10.3 // indirect + github.com/knakk/sparql v0.0.0-20191213045353-fd0bd0e76475 + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/mattn/go-sqlite3 v2.0.3+incompatible + github.com/onsi/ginkgo v1.12.0 // indirect github.com/onsi/gomega v1.7.1 // indirect - github.com/sirupsen/logrus v1.4.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pquerna/otp v1.2.0 + github.com/sirupsen/logrus v1.5.0 // indirect github.com/stretchr/testify v1.4.0 // indirect - golang.org/x/net v0.0.0-20191116160921-f9c825593386 // indirect - golang.org/x/text v0.3.0 - golang.org/x/tools v0.0.0-20191204011308-9611592c72f6 // indirect - gopkg.in/yaml.v2 v2.2.7 // indirect - notabug.org/apiote/gott v1.0.1 + golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5 + golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f // indirect + golang.org/x/text v0.3.2 + golang.org/x/tools v0.0.0-20200423205358-59e73619c742 // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect + notabug.org/apiote/gott v1.1.2 zombiezen.com/go/capnproto2 v2.17.0+incompatible ) diff --git a/go.sum b/go.sum index 08eab8a4cdbe5ad0b42b9da3a9c86ef0d4220821..4011a704db57a6ac454fdb8116494b3507666825 100644 --- a/go.sum +++ b/go.sum @@ -1,74 +1,172 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= +github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ= +github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc= +github.com/alecthomas/chroma v0.7.2 h1:B76NU/zbQYIUhUowbi4fmvREmDUJLsUzKWTZmQd3ABY= +github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= +github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytesparadise/libasciidoc v0.2.0 h1:W+Yh4cXehuQvFA+Ncs4tIgwBXiH8ie+KhHmMXkBhIcc= github.com/bytesparadise/libasciidoc v0.2.0/go.mod h1:CZX8GIEkxy/LHrDZjPbNrE16RQFDrnG6hBjnjXcD34Y= +github.com/bytesparadise/libasciidoc v0.4.0 h1:fse9nKBTZ1OcAltOhf5XJUxctakbiaDT3Jw6qCPaM7Y= +github.com/bytesparadise/libasciidoc v0.4.0/go.mod h1:fNxeS06tJufiBEyZJXnO0ng4xv8EdlswK/tKStNz/MA= +github.com/chai2010/webp v1.1.0 h1:4Ei0/BRroMF9FaXDG2e4OxwFcuW2vcXd+A6tyqTJUQQ= +github.com/chai2010/webp v1.1.0/go.mod h1:LP12PG5IFmLGHUU26tBiCBKnghxx3toZFwDjOYvd3Ow= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg= +github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= +github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-python/gopy v0.3.1 h1:l0zBAjU89xGoFBR12NTK+JGj6O2dCqRB/rDTN44APBY= github.com/go-python/gopy v0.3.1/go.mod h1:gQ2Itc84itA1AjrVqnMnv7HLkfmNObRXlR1co7CXpbk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/gonuts/commander v0.1.0 h1:EcDTiVw9oAVORFjQOEOuHQqcl6OXMyTgELocTq6zJ0I= github.com/gonuts/commander v0.1.0/go.mod h1:qkb5mSlcWodYgo7vs8ulLnXhfinhZsZcm6+H/z1JjgY= github.com/gonuts/flag v0.1.0 h1:fqMv/MZ+oNGu0i9gp0/IQ/ZaPIDoAZBOBaJoV7viCWM= github.com/gonuts/flag v0.1.0/go.mod h1:ZTmTGtrSPejTo/SRNhCqwLTmiAgyBdCkLYhHrAoBdz4= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/knakk/digest v0.0.0-20160404164910-fd45becddc49 h1:P6Mw09IOeKKS4klYhjzHzaEx2RcNshynjfDhzCQ8BoE= github.com/knakk/digest v0.0.0-20160404164910-fd45becddc49/go.mod h1:dQr9I8Xw26daWGE/crxUleRxmpFI5uhfedWqRNHHq0c= github.com/knakk/rdf v0.0.0-20190304171630-8521bf4c5042 h1:Vzdm5hdlLdpJOKK+hKtkV5u7xGZmNW6aUBjGcTfwx84= github.com/knakk/rdf v0.0.0-20190304171630-8521bf4c5042/go.mod h1:fYE0718xXI13XMYLc6iHtvXudfyCGMsZ9hxSM1Ommpg= github.com/knakk/sparql v0.0.0-20190415133729-e66682c662f6 h1:/9NsggFoqFNblbAcHDeeAX9tiYnT6TteCUS80zanCGA= github.com/knakk/sparql v0.0.0-20190415133729-e66682c662f6/go.mod h1:vxUbHrxs7JHQF6LITj9Rp9yf2bqyz+5JZzPZkEkS3MA= +github.com/knakk/sparql v0.0.0-20191213045353-fd0bd0e76475 h1:J75ktE0AJuhyTqS6V8cBHNLeCEv5XbW58g9r3Zpyz4k= +github.com/knakk/sparql v0.0.0-20191213045353-fd0bd0e76475/go.mod h1:vxUbHrxs7JHQF6LITj9Rp9yf2bqyz+5JZzPZkEkS3MA= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kuangchanglang/graceful v1.0.0 h1:EPcA4vV75CkLi9+tW1+cd6KpfULYRTxTm1MO8USa49k= github.com/kuangchanglang/graceful v1.0.0/go.mod h1:fQkb+p3PRjvdiAsa65Qv78lm9CsYc4M+yhiuU1rOVtg= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-sqlite3 v2.0.2+incompatible h1:qzw9c2GNT8UFrgWNDhCTqRqYUSmu/Dav/9Z58LGpk7U= github.com/mattn/go-sqlite3 v2.0.2+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= +github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mna/pigeon v1.0.1-0.20190909211542-7ee56e19b15c h1:QRaadf9Fu8xAfNDS8PvaM0VmY2FnYHlddtnIExKj68k= +github.com/mna/pigeon v1.0.1-0.20190909211542-7ee56e19b15c/go.mod h1:rkFeDZ0gc+YbnrXPw0q2RlI0QRuKBBPu67fgYIyGRNg= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5 h1:8Q0qkMVC/MmWkpIdlvZgcv2o2jrlF6zqVOh7W5YHdMA= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.3 h1:OoxbjfXVZyod1fmWYhI7SEyaD8B00ynP3T+D5GiyHOY= github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU= +github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1 h1:K0jcRCwNQM3vFGh1ppMtDh/+7ApJrjldlX8fA0jDTLQ= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok= +github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= +github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/sozorogami/gover v0.0.0-20171022184752-b58185e213c5 h1:TAPeDBsd52dRWoWzf5trgBzxzMYHTYjYI+4xNyCdoCU= +github.com/sozorogami/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:nHNlDYIQZn44RvqH0kCpl/dMMVWXkav0QIgzGxV1Ab4= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a h1:y6sBfNd1b9Wy08a6K1Z1DZc4aXABUN5TKjkYhz7UKmo= +golang.org/x/crypto v0.0.0-20200420201142-3c4aac89819a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5 h1:Q7tZBpemrlsc2I7IyODzhtallWRSm4Q0d09pL6XbQtU= +golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190909003024-a7b16738d86b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386 h1:ktbWvQrW08Txdxno1PiDpSxPXG6ndGsfnJjRRtkM0LQ= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190910064555-bbd175535a8b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190830223141-573d9926052a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191204011308-9611592c72f6 h1:BP62y4oUl8+/CvHuvVqHIPmVRixgDl6y6a+tR7pXXIA= golang.org/x/tools v0.0.0-20191204011308-9611592c72f6/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191220234730-f13409bbebaf h1:K7C8vSrr0PeD/cgNkkjpByDFJqzjr2YDmm3VPRjGfJM= +golang.org/x/tools v0.0.0-20191220234730-f13409bbebaf/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200422022333-3d57cf2e726e h1:3Dzrrxi54Io7Aoyb0PYLsI47K2TxkRQg+cqUn+m04do= +golang.org/x/tools v0.0.0-20200422022333-3d57cf2e726e/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200423205358-59e73619c742 h1:9OGWpORUXvk8AsaBJlpzzDx7Srv/rSK6rvjcsJq4rJo= +golang.org/x/tools v0.0.0-20200423205358-59e73619c742/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= notabug.org/apiote/gott v1.0.1 h1:yfq2z3WM0lYFSu6xFvh1sWBKgg6yaXwF9/2wqJiKky8= notabug.org/apiote/gott v1.0.1/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4= +notabug.org/apiote/gott v1.1.0 h1:RGGbJo9ON5Qsk/lsw0oF1tiyFeogORINGILqizbdkC8= +notabug.org/apiote/gott v1.1.0/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4= +notabug.org/apiote/gott v1.1.1 h1:BFKdZnZPCTZa8BrEGVSmMYhkgXD30aR9JBPcxMw1Rnc= +notabug.org/apiote/gott v1.1.1/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4= +notabug.org/apiote/gott v1.1.2 h1:Z22X9/8XrK5M5oARoE2fh3sJGPAJ84GuyGg2nKOjweQ= +notabug.org/apiote/gott v1.1.2/go.mod h1:Z9hFvCdzZkFSegBkLa6n0X6AuUiw2BwgG4MFLgBMjD4= zombiezen.com/go/capnproto2 v2.17.0+incompatible h1:sIoKPFGNlM38Qh+PBLa9Wzg1j99oInS/Qlk+5N/CHa4= zombiezen.com/go/capnproto2 v2.17.0+incompatible/go.mod h1:XO5Pr2SbXgqZwn0m0Ru54QBqpOf4K5AYBO+8LAOBQEQ= diff --git a/i18n/check_translation b/i18n/check_translation new file mode 100755 index 0000000000000000000000000000000000000000..56d02d26d4be7d9664233e45f68f831c2a73db39 --- /dev/null +++ b/i18n/check_translation @@ -0,0 +1,42 @@ +#!/bin/sh + +if [ "$1" = '' ] +then + echo "usage check_translation <file>" + exit 1 +fi + +if [ ! -f "$1" ] +then + echo "Translation $1 does not exist" + exit 1 +fi + +default_all=$(grep -c '=' < default.toml) +default_untranslatable=$(grep -c '^#untranslatable' < default.toml) +default=$(( default_all - default_untranslatable )) + +translated_all=$(grep -c '=' < "$1") +translated_untranslatable=$(grep -c '^#untranslatable' < "$1") +translated=$(( translated_all - translated_untranslatable )) + +left=$(( default - translated )) + +if [ "$translated_untranslatable" -ne 0 ] +then + echo "Untranslatable strings in $1" + status=1 +fi + +percent=$((translated * 100 / default)) +echo "$percent% translated" + +if [ $left -gt 0 ] +then + echo "There are $left strings left to translate" +elif [ $left -eq 0 ] +then + echo "All strings translated" +fi + +exit $status diff --git a/i18n/en-GB.toml b/i18n/en-GB.toml index c7c49a3dc87e4395216f8019a990a4e5b9db7211..a1f788e304daf1e4d851b82047acf4c0ba2db411 100644 --- a/i18n/en-GB.toml +++ b/i18n/en-GB.toml @@ -10,6 +10,15 @@ [global] # format as per POSIX date(1p) without %c, %r, %x, %X date_format = "%d %B %Y" +date_format_full = "%d %B %Y, %H:%M" +date_format_time = "%H:%M" + +experience_format_today = "today" +experience_format_yesterday = "yesterday" +experience_format_ereyester = "ereyester" +experience_format_week = "last %A" +experience_format_year = "on %b %d" +experience_format_earlier = "on %d %B %Y" Monday = "Monday" Tuesday = "Tuesday" @@ -18,6 +27,13 @@ Thursday = "Thursday" Friday = "Friday" Saturday = "Saturday" Sunday = "Sunday" +Monday_short = "Mon" +Tuesday_short = "Tue" +Wednesday_short = "Wed" +Thursday_short = "Thu" +Friday_short = "Fri" +Saturday_short = "Sat" +Sunday_short = "Sun" January = "January" February = "February" @@ -31,9 +47,43 @@ September = "September" October = "October" November = "November" December = "December" +January_short = "Jan" +February_short = "Feb" +March_short = "Mar" +April_short = "Apr" +May_short = "May" +June_short = "Jun" +July_short = "Jul" +August_short = "Aug" +September_short = "Sep" +October_short = "Oct" +November_short = "Nov" +December_short = "Dec" search = "search…" +unknown = "unknown" + +log_in = "Log in" +sign_up = "Sign up" +log_out = "Log out" + +account = "Account" +watchlist = "Watchlist" +tv_queue = "TV Queue" +readlist = "Readlist" +experiences = "Experiences" + +empty_quote = "‘Nothing to see here! Please disperse!’" +empty_character = "Lt. Frank Drebin" +empty_title = "The Naked Gun: From the Files of Police Squad!" + +too_far_quote = "‘Grunkle Stan, you’ve gone too far this time!’" +too_far_character = "Mabel Pines" +too_far_title = "Gravity Falls" +too_far_code = "S01E13" +too_far_episode = "Boss Mabel" + [index] # `{}` is replaced with search field let_amuse_you = "Let {} amuse You" @@ -74,6 +124,10 @@ source = "Source" runtime = "Runtime" status = "Status" empty_payroll = "Empty payroll" +watched = "Watched" +Watchlist = "Watchlist" +onWatchlist = "You want to watch this film" +want_watch = "Want to watch" [serie] season = "Season" @@ -89,6 +143,12 @@ based_on = "Based on" latest_episode = "Latest episode" empty_payroll = "Empty payroll" no_episodes = "This season is empty" +watched = "Watched" +next_episode = "Next episode" +episodes = "Episodes" +skipped = "Skipped" +want_watch = "Want to watch" +skip_specials = "Skip all specials" [person] cast = "Cast" @@ -143,21 +203,97 @@ license_paragraph2 = "Now go, host Your own instance." license_title = "The Amazing Spider-Man" license_character = "Peter Parker" +[signup] +title = "a·muse — sign in" +swear = "I solemnly swear that I am up to no good." +user_exists = "Username already taken" +passwords_dont_match = "Passwords don’t match" +sfa_not_confirmed = "2FA not confirmed" +sfa_code_not_correct = "Wrong 2FA code" +required_info_missing = "Required data missing" +username = "Username" +password = "Password" +confirm_pass = "Confirm password" +enable_sfa = "Enable second factor authentication" +use_totp_app = "Use Your favourite TOTP app" +confirm_sfa = "Confirm second factor authentication" +sign_up = "Sign up" +already_have_account = "Already have an account?" +log_in = "Log in" + +[signedup] +title = "a·muse — signed up" +welcome = "‘Welcome to Rivendell, Frodo Baggins.’" +sfa_codes = "Your 2FA recovery codes are:" +copy_and_keep = "Copy them and keep safe." +youll_need = "You’ll need them if You lose Your 2FA device" +now_you_can = "Now, You can" +log_in = "log in" +and_be_amused = "and be amused." + +[login] +title = "a·muse — log in" +alohomora = "Alohomora!" +error = "Authentication error" +username = "Username" +password = "Password" +sfa = "Second factor" +sfa_description = "Required if You have enabled during signup" # or later in Your account +log_in = "Log in" +doesnt_have_account = "Doesn’t have an account?" +sign_up = "Sign up" + +[loggedout] +title = "a·muse — logged out" +mischief = "‘Mischief managed’" +see_you = "See You next time…" + +[watchlist] +title = "Watchilst — a·muse" +filter = "filter watchlist" + +[tvqueue] +title = "TV queue — a·muse" +filter = "filter TV queue" + +[experiences] +title = "Experiences — a·muse" +filter = "filter experiences" + [error] error = "Error" 400_quote ="‘Wenk wenk.’" 400_character = "Gunter" 400_title = "Adventure Time" +#untranslatable 400_name = "Bad request" +401_quote = "‘You shall not pass!’" +401_character = "Gandalf" +401_title = "The Lord of the Rings: The Fellowship of the Ring" +#untranslatable +401_name = "Unauthorized" +403_quote = "‘Who do you think you are, Pilgrim?’" +403_character = "Gideon Gordon Graves" +403_title = "Scott Pilgrim vs. the World" +#untranslatable +403_name = "Forbidden" 404_quote = "‘I couln’t find my Buzz. I know I left him right there.’" 404_character = "Andy" 404_title = "Toy Story" +#untranslatable 404_name = "Not found" +410_quote = "‘It’s not here, Jack." +410_character = "Lord Cutler Beckett" +410_title = "Pirates of the Caribbean: At World's End" +#untranslatable +410_name = "Gone" 422_quote = "‘I don’t know. I don’t understand.’" 422_character = "The Narrator" 422_title = "Fight Club" +#untranslatable 422_name = "Unprocessable Entity" 500_quote = "‘Houston, we may have a problem’" 500_character = "Henry Brown" 500_title = "Paddington" +#untranslatable 500_name = "Server error" diff --git a/i18n/i18n.go b/i18n/i18n.go index f9d121dc5c037410e5905340c1bb16bd65865c05..91a4b87dbd2f8dabfba142903506a83ac4b57eed 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -3,11 +3,7 @@ import ( "notabug.org/apiote/amuse/utils" - "github.com/BurntSushi/toml" - "github.com/bytesparadise/libasciidoc" - "bytes" - "context" "fmt" "golang.org/x/text/language" "html/template" @@ -17,19 +13,30 @@ "reflect" "regexp" "strings" "time" + + "github.com/BurntSushi/toml" + "github.com/bytesparadise/libasciidoc" + "github.com/bytesparadise/libasciidoc/pkg/configuration" ) type Translation struct { - Global map[string]string - Index map[string]string - Search map[string]string - Film map[string]string - Serie map[string]string - Person map[string]string - Book map[string]string - BookSerie map[string]string - About map[string]string - Error map[string]string + Global map[string]string + Index map[string]string + Search map[string]string + Film map[string]string + Serie map[string]string + Person map[string]string + Book map[string]string + BookSerie map[string]string + About map[string]string + Signup map[string]string + Signedup map[string]string + Login map[string]string + Loggedout map[string]string + Watchlist map[string]string + Tvqueue map[string]string + Experiences map[string]string + Error map[string]string } var serverLangs []language.Tag @@ -98,16 +105,16 @@ return strings, err } func FormatDate(date time.Time, format string, translation map[string]string) string { - // todo %a + format = strings.ReplaceAll(format, "%a", translation[date.Weekday().String()+"_short"]) format = strings.ReplaceAll(format, "%A", translation[date.Weekday().String()]) - // todo %b + format = strings.ReplaceAll(format, "%b", translation[date.Month().String()+"_short"]) format = strings.ReplaceAll(format, "%B", translation[date.Month().String()]) // %c intentionally ommitted format = strings.ReplaceAll(format, "%C", fmt.Sprintf("%d", date.Year()/100)) format = strings.ReplaceAll(format, "%d", fmt.Sprintf("%02d", date.Day())) format = strings.ReplaceAll(format, "%D", fmt.Sprintf("%02d/%02d/%02d", date.Month(), date.Day(), date.Year()%100)) format = strings.ReplaceAll(format, "%e", fmt.Sprintf("%2d", date.Day())) - // todo %h == %b + format = strings.ReplaceAll(format, "%h", translation[date.Month().String()+"_short"]) format = strings.ReplaceAll(format, "%H", fmt.Sprintf("%02d", date.Hour())) hour := date.Hour() % 12 if hour == 0 { @@ -142,11 +149,41 @@ format = strings.ReplaceAll(format, "%%", "%") return format } +func FormatDateNice(datetime time.Time, strings Translation, timezone string) string { + t := time.Now() + location, err := time.LoadLocation(timezone) + if err != nil { + return strings.Global["unknown"] + } + midnightToday := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, location) + midnightYester := midnightToday.Add(-24 * time.Hour) + midnightEreyester := midnightYester.Add(-24 * time.Hour) + midnightWeek := midnightToday.Add(-24 * 7 * time.Hour) + midnightYear := time.Date(t.Year(), 1, 1, 0, 0, 0, 0, location) + var dateFormat string + if datetime.After(midnightToday) { + dateFormat = strings.Global["experience_format_today"] + } else if datetime.After(midnightYester) { + dateFormat = strings.Global["experience_format_yesterday"] + } else if datetime.After(midnightEreyester) { + dateFormat = strings.Global["experience_format_ereyester"] + } else if datetime.After(midnightWeek) { + dateFormat = strings.Global["experience_format_week"] + } else if datetime.After(midnightYear) { + dateFormat = strings.Global["experience_format_year"] + } else { + dateFormat = strings.Global["experience_format_earlier"] + } + date := FormatDate(datetime, dateFormat, strings.Global) + return date +} + func RenderAsciiDoc(asciidoc string) template.HTML { r := strings.NewReader(asciidoc) w := bytes.NewBuffer([]byte{}) - ctx := context.Background() - libasciidoc.ConvertToHTML(ctx, r, w) + config := configuration.NewConfiguration() + + libasciidoc.ConvertToHTML(r, w, config) output := bytes.ReplaceAll(w.Bytes(), []byte("\n"), []byte("")) divRegex, err := regexp.Compile("<\\/?div[^>]*>") diff --git a/i18n/pl-PL.toml b/i18n/pl-PL.toml index 504f2a1e23bcfa2d23c8de407f50409cc5424e32..81e3224759b995a4777944a119e8b08252b8258b 100644 --- a/i18n/pl-PL.toml +++ b/i18n/pl-PL.toml @@ -1,5 +1,14 @@ [global] date_format = "%d %B %Y" +date_format_full = "%d %B %Y, %H:%M" +date_format_time = "%H:%M" + +experience_format_today = "dzisaj" +experience_format_yesterday = "wczoraj" +experience_format_ereyester = "przedwczoraj" +experience_format_week = "w %A" +experience_format_year = "%d %B" +experience_format_earlier = "%d %B %Y" Monday = "poniedziałek" Tuesday = "wtorek" @@ -8,6 +17,13 @@ Thursday = "czwartek" Friday = "piątek" Saturday = "sobota" Sunday = "niedziela" +Monday_short = "Pn" +Tuesday_short = "Wt" +Wednesday_short = "Śr" +Thursday_short = "Czw" +Friday_short = "Pt" +Saturday_short = "Sb" +Sunday_short = "Nd" January = "stycznia" February = "lutego" @@ -21,9 +37,43 @@ September = "września" October = "października" November = "listopada" December = "grudnia" +January_short = "sty" +February_short = "lut" +March_short = "mar" +April_short = "kwi" +May_short = "maj" +June_short = "cze" +July_short = "lip" +August_short = "sie" +September_short = "wrz" +October_short = "paź" +November_short = "lis" +December_short = "gru" search = "szukaj…" +unknown = "nieznane" + +log_in = "zaloguj" +sign_up = "załóż konto" +log_out = "wyloguj" + +account = "Konto" +watchlist = "Lista filmów" +tv_queue = "Kolejka seriali" +readlist = "Biblioteczka" +experiences = "Doświadczenia" + +empty_quote = "„Proszę się rozejść, nie ma tu nic ciekawego do oglądania.”" +empty_character = "Frank Drebin" +empty_title = "Naga broń: Z akt Wydziału Specjalnego" + +too_far_quote = "„Wujku Stan, tym razem posunąłeś się za daleko!" +too_far_character = "Mabel Pines" +too_far_title = "Wodogrzmoty Małe" +too_far_code = "S01E13" +too_far_episode = "Szef Mabel" + [index] let_amuse_you = "Niech {} Cię zabawi" source_link_title = "kod źródłowy" @@ -35,6 +85,7 @@ prev_link_title = "poprzednia strona" not_found_quote = "‘Puste!’" not_found_character = "Maurice Moss" not_found_title = "Technicy-magicy" +not_found_code = "S01E02" not_found_episode = "‘Panna katastrofa’" too_far_quote = "„Posunęłaś się za daleko.”" too_far_character = "Moriticia Addams" @@ -61,6 +112,10 @@ source = "Źródło" runtime = "Czas trwania" status = "Status" empty_payroll = "Pusta lista płac" +watched = "Obejrzany" +Watchlist = "Lista filmów" +onWatchlist = "Chcesz obejrzeć ten film" +want_watch = "Chcę obejrzeć" [serie] season = "Sezon" @@ -76,6 +131,12 @@ based_on = "Oparte na" latest_episode = "Ostatni odcinek" empty_payroll = "Pusta lista płac" no_episodes = "Ten sezon jest pusty" +watched = "Obejrzany" +next_episode = "Następny odcinek" +episodes = "Odcinki" +skipped = "Pominięte" +want_watch = "Chcę obejrzeć" +skip_specials = "Pomiń wszystkie odcinki specjalne" [person] cast = "Obsada" @@ -102,6 +163,10 @@ in_this_collection = "W tej serii" [about] title = "O a·muse" +explain_header = '‘EXPLAIN!’' +explain_character = "Dalek Caan" +explain_title = "Doctor Who" +explain_code = "S03E04" explain_episode_title = "„Dalekowie na Manhattanie”" explain_paragraph ="a·muse to frontend dla The Movie Database, który nie używa JavaScript-u. Jest to także system, który łączy filmy i ksiązki na których filmy są oparte dzięki danym z Wikidata. W końcu a·muse to także miejsce, w którym można zbierać idee, jakie książki, filmy i seriale obejrzeć lub przeczytać, a które zostały już obejrzane i przeczytane." name_header = "„Czemże jest nazwa?”" @@ -111,24 +176,98 @@ name_paragraph1 = "Nazwa systemu to „a·muse”. Nazwa programu, komendy oraz jakiejkolwiek paczki to „amuse”." name_paragraph2 = "Obydwie nazwy wymawiane są tak samo – [æˈʔmjuːz]." name_paragraph3 = "Jest to gra słów — amuse jako czasownik zabawiać i a muse jako muza." sources_header = "‘Where did it come from?’" +sources_character = "Shrek" +sources_title = "Shrek" sources_paragraph1 = "Informacje o filmach, osobach i serialach pochodzą z The Movie Database; wymaga się ode mnie, abym zaznaczył, że this product uses the TMDb API but is not endorsed or certified by TMDb. Jeżeli czegoś brakuje, albo jest niepoprawne, https://www.themoviedb.org/bible[poprawcie TMDb]." sources_paragraph2 = "Informacje o książkach i relacjach między nimi, a filmami lub serialami pochodzą z Wikidata. Dane są dostępne na zasadach https://creativecommons.org/publicdomain/zero/1.0/[Przekazanie do Domeny Publicznej (CC0)]. Gdyby czegoś brakowało lub było niepoprawne, https://www.wikidata.org/wiki/Special:MyLanguage/Wikidata:Contribute[poprawcie Wikidata]." +sources_paragraph3 = "Book covers … (something, something, Inventaire)" credits_header = "‘Give yourself some credit, please.’" credits_paragraph = "Poza bezcennymi żródłami danych ten system nie istniałby bez" credits_title = "Avengers" +credits_character = "Tony Stark" license_header = "‘Can I have that’" license_paragraph1 = "Jasne! a·muse jest dostępne na zasadach https://www.gnu.org/licenses/agpl-3.0.en.html[GNU AGPL] w wersji 3 lub dowolnej późniejszej. Możecie robić cokolwiek, ale musicie udostępnić kod żródłowy" license_paragraph2 = "A teraz hostujcie swoje instancje." license_title = "Niesamowity Spider-Man" +license_character = "Peter Parker" + +[signup] +title = "a·muse — załóż konto" +swear = "Przysięgam uroczyście, że knuję coś niedobrego." +user_exists = "Nazwa użytkownika zajęta" +passwords_dont_match = "Hasła nie zgadzają się" +sfa_not_confirmed = "2FA nie potwierdzone" +sfa_code_not_correct = "Zły kod 2FA" +required_info_missing = "Brakujące niezbędne dane" +username = "Nazwa użytkownika" +password = "Hasło" +confirm_pass = "Potwierdź hasło" +enable_sfa = "Włącz dwuskładnikowe uwierzytelnianie" +use_totp_app = "Użyj ulubionej aplikacji TOTP" +confirm_sfa = "Potwierdź kod z aplikacji" +sign_up = "Załóż konto" +already_have_account = "Masz już konto?" +log_in = "Zaloguj się" + +[signedup] +title = "a·muse — konto założone" +welcome = "„Witaj w Rivendell, Frodo Baggins.”" +sfa_codes = "Twoje kody ratunkowe 2FA" +copy_and_keep = "Skopiuj je i trzymaj w bezpiecznym miejscu." +youll_need = "Będziesz ich potrzebować, jeśli stracisz dostęp do urządzenia 2FA" +now_you_can = "Teraz możesz" +log_in = "zalogować się" +and_be_amused = "i dać się zabawić." + +[login] +title = "a·muse — zaloguj" +alohomora = "Alohomora!" +error = "Błąd uwierzytelniania" +username = "Nazwa użytkownika" +password = "Hasło" +sfa = "Drugi składnik" +sfa_description = "Wymagany jeśli 2FA zostało włączone przy zakładaniu konta" # lub później w Twoim koncie +log_in = "Zaloguj" +doesnt_have_account = "Nie masz konta?" +sign_up = "Załóż konto" + +[loggedout] +title = "a·muse — wylogowano" +mischief = "„Koniec psot”" +see_you = "Do zobaczenia następnym razem…" + +[watchlist] +title = "Lista filmów — a·muse" +filter = "filtruj listę filmów" + +[tvqueue] +title = "Kolejka seriali — a·muse" +filter = "filtruj kolejkę seriali" + +[experiences] +title = "Doświadczenia — a·muse" +filter = "filtruj doświadczenia" [error] error = "Błąd" +400_quote ="‘Wenk wenk.’" +400_character = "Gunter" 400_title = "Pora na przygodę" +401_quote = "„Nie przejdziesz!”" +401_character = "Gandalf" +401_title = "Władca pierścieni: Drużyna pierścienia" +403_quote = "„Kim, jak ci się zdaje jesteś, Pilgrimie?”" +403_character = "Gideon Gordon Graves" +403_title = "Scott Pilgrim kontra świat" 404_quote = "„Nie mogę znaleźć mojego Buzza. Przecież położyłem go właśnie tutaj.”" 404_character = "Andy" 404_title = "Toy Story" +410_quote = "„Nie ma go tutaj, Jack”" +410_character = "Lord Cutler Beckett" +410_title = "Piraci z Karaibów: Na krańcu świata" 422_quote = "„Nie wiem. Nie rozumiem”" 422_character = "Narrator" +422_title = "Fight Club" 500_quote = "„Houston, chyba mamy problem.”" 500_character = "Henry Brown" 500_title = "Paddington" diff --git a/libamuse/about.go b/libamuse/about.go index 2e0fe42ecb4d8b2d43f15e28448f622f7043b92d..438dfb5238f8f555cdd23ce7ba2c086dd31f572f 100644 --- a/libamuse/about.go +++ b/libamuse/about.go @@ -1,6 +1,8 @@ package libamuse import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/gott" ) @@ -12,10 +14,12 @@ result.page = renderer.RenderAbout(languages) return gott.Tuple(args) } -func ShowAbout(language, mimetype string) (string, error) { +func ShowAbout(language, mimetype string, auth accounts.Authentication) (string, error) { + auth.Necessary = false r, err := gott. - NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype}, &Result{}}). + NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype, auth: auth}, &Result{}}). Bind(parseLanguage). + Bind(verifyToken). Bind(createRenderer). Map(renderAbout). Finish() diff --git a/libamuse/account.go b/libamuse/account.go new file mode 100644 index 0000000000000000000000000000000000000000..9f8ea3ac0c2575b28b4607c0da682bab1e8ffcbb --- /dev/null +++ b/libamuse/account.go @@ -0,0 +1,288 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/db" + "notabug.org/apiote/amuse/tmdb" + + "errors" + "fmt" + "os" + "strings" + "time" + + "notabug.org/apiote/gott" +) + +func VerifyAuthToken(token accounts.Authentication) (accounts.User, error) { + if token.Token == "" { + return accounts.User{}, accounts.AuthError{Err: errors.New("401")} + } + session, err := db.GetSession(token.Token) + if err != nil { + if _, ok := err.(db.EmptyError); ok { + err = accounts.AuthError{Err: err} + } + fmt.Fprintf(os.Stderr, "Get session err: %v", err) + return accounts.User{}, err + } + now := time.Now() + if session.Expiry.Before(now) { + return accounts.User{}, accounts.AuthError{Err: errors.New("Session expired")} + } + dbUser, err := db.GetUser(session.Username) + if err != nil { + if _, ok := err.(db.EmptyError); ok { + err = accounts.AuthError{Err: err} + } + fmt.Fprintf(os.Stderr, "Get user err: %v", err) + return accounts.User{}, err + } + user := accounts.User{ + Username: dbUser.Username, + IsAdmin: dbUser.IsAdmin, + Session: token.Token, + Timezone: dbUser.Timezone, + } + return user, nil +} + +func addToWantlist(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + itemType := args[2].(string) + + err := db.AddToWantList(result.user.Username, data.id, datastructure.ItemType(itemType)) + result.result2 = 1 + + return gott.Tuple(args), err +} + +func getItem(args ...interface{}) (interface{}, error) { + itemType := args[2].(string) + var arg interface{} + var err error + switch itemType { + case datastructure.ItemTypeFilm: + arg, err = gott. + NewResult(gott.Tuple(args)). + Bind(getFilm). + Bind(getCollection). + Finish() + if err == nil { + args = arg.(gott.Tuple) + } + case datastructure.ItemTypeTvserie: + arg, err = gott. + NewResult(gott.Tuple(args)). + Bind(getTvSerie). + Finish() + if err == nil { + args = arg.(gott.Tuple) + } + default: + err = errors.New("Wrong ItemType: " + itemType) + } + return gott.Tuple(args), err +} + +func cacheItem(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + refs := result.result2.(int) + + item := result.result.(datastructure.Item) + + itemInfo := item.GetItemInfo() + + err := db.SaveCacheItem(item.GetItemType(), data.id, itemInfo, refs) + return gott.Tuple(args), err +} + +func AddToWantlist(username string, auth accounts.Authentication, itemId, itemType, language, mimetype string) error { + auth.Necessary = true + _, err := gott. + NewResult(gott.Tuple{&RequestData{id: itemId, language: language, mimetype: mimetype, auth: auth, username: username}, &Result{}, itemType}). + Bind(parseLanguage). + Bind(verifyToken). + Bind(verifyUser). + Bind(getItem). + Bind(addToWantlist). + Bind(cacheItem). + Finish() + + return err +} + +func splitItemId(args ...interface{}) interface{} { + data := args[0].(*RequestData) + itemId := args[3].(string) + id := strings.Split(itemId, "/") + data.id = id[0] + return gott.Tuple(args) +} + +func parseExperienceDate(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + datetime := args[4].(string) + var t time.Time + var err error = nil + if datetime == "" { + t = time.Now() + } else { + var location *time.Location + if datetime != "0001-01-01T00:00:00" { + location, _ = time.LoadLocation(result.user.Timezone) + } else { + location = time.UTC + } + t, err = time.ParseInLocation("2006-01-02T15:04:05", datetime, location) + } + t = t.In(time.UTC) + result.result2 = t + return gott.Tuple(args), err +} + +func getSpecials(args ...interface{}) (interface{}, error) { + itemId := args[3].(string) + var err error + + id := strings.Split(itemId, "/") + if len(id) > 1 && id[1][3] == 'A' { + arg, err := gott. + NewResult(gott.Tuple(args)). + Bind(getSeason0). + Finish() + if err == nil { + args = arg.(gott.Tuple) + } + } + return gott.Tuple(args), err +} + +func addToExperiences(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + t := result.result2.(time.Time) + itemType := args[2].(string) + itemId := args[3].(string) + var ( + err error = nil + refs int + ) + + id := strings.Split(itemId, "/") + if len(id) > 1 && id[1][3] == 'A' { + serie := result.result.(*tmdb.TvSerie) + episodes := []string{} + for _, episode := range serie.Seasons[0].Episodes { + episodes = append(episodes, data.id+"/"+episode.Episode_code) + } + refs, err = db.SkipSpecials(result.user.Username, id[0], episodes, datastructure.ItemType(itemType)) + } else { + refs, err = db.AddToExperiences(result.user.Username, itemId, datastructure.ItemType(itemType), t) + } + result.result2 = refs + + if len(id) > 1 { + return gott.Tuple(args), errors.New("Skip") + } + + return gott.Tuple(args), err +} + +func clearSpecials(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + itemType := args[2].(string) + itemId := args[3].(string) + id := strings.Split(itemId, "/") + + var err error + if len(id) > 1 && id[1][3] == 'A' { + serie := result.result.(*tmdb.TvSerie) + episodes := []string{} + for _, episode := range serie.Seasons[0].Episodes { + episodes = append(episodes, data.id+"/"+episode.Episode_code) + } + err = db.ClearSpecials(result.user.Username, id[0], episodes, datastructure.ItemType(itemType)) + } + return gott.Tuple(args), err +} + +func removeFromWantList(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + itemType := args[2].(string) + + err := db.RemoveFromWantList(result.user.Username, data.id, datastructure.ItemType(itemType)) + + return gott.Tuple(args), err +} + +func removeCacheItem(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + itemType := args[2].(string) + + err := db.RemoveCacheItem(datastructure.ItemType(itemType), data.id) + + return gott.Tuple(args), err +} + +func AddToExperiences(username string, auth accounts.Authentication, itemId, itemType, datetime, language, mimetype string) error { + auth.Necessary = true + _, err := gott. + NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype, auth: auth, username: username}, &Result{}, itemType, itemId, datetime}). + Map(splitItemId). + Bind(parseLanguage). + Bind(verifyToken). + Bind(verifyUser). + Bind(parseExperienceDate). + Bind(getItem). + Bind(getSpecials). + Bind(clearSpecials). + Bind(addToExperiences). + Bind(cacheItem). + Bind(removeFromWantList). + Bind(removeCacheItem). + Finish() + + if err != nil { + if err.Error() == "Skip" { + err = nil + } + + if emptyErr, ok := err.(db.EmptyError); ok && emptyErr.Error() == "Empty delete" { + err = nil + } + } + + return err +} + +func removeSession(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + var token string + if data.id == "0" { + token = data.auth.Token + } else { + token = data.id + } + username := data.username + err := db.RemoveSession(username, token) + return gott.Tuple(args), err +} + +func SessionDelete(username string, auth accounts.Authentication, session, languages, mimetype string) error { + auth.Necessary = true + _, err := gott. + NewResult(gott.Tuple{&RequestData{id: session, language: languages, mimetype: mimetype, auth: auth, username: username}, &Result{}}). + Bind(parseLanguage). + Bind(verifyToken). + Bind(verifyUser). + Bind(removeSession). + Finish() + + return err +} diff --git a/libamuse/book.go b/libamuse/book.go index 051b5068b85c45c4ee12d9c71a61893e8c762e4b..7d19e434bba573f5b1650f0f20fc5384a4bd0d10 100644 --- a/libamuse/book.go +++ b/libamuse/book.go @@ -2,6 +2,7 @@ package libamuse import ( "notabug.org/apiote/amuse/wikidata" + "notabug.org/apiote/amuse/accounts" "notabug.org/apiote/gott" ) @@ -32,10 +33,12 @@ result.page = result.renderer.RenderBook(*book, result.languages) return gott.Tuple(args) } -func ShowBook(id, language, mimetype string) (string, error) { +func ShowBook(id, language, mimetype string, auth accounts.Authentication) (string, error) { + auth.Necessary = false r, err := gott. - NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype}, &Result{}}). + NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype, auth: auth}, &Result{}}). Bind(parseLanguage). + Bind(verifyToken). Bind(getBook). Bind(getDescription). Bind(getCover). diff --git a/libamuse/bookserie.go b/libamuse/bookserie.go index bee0d843acb9bd4a9878deac563109560551e6c2..361177a772cb035150a1a3b1bcae977b196c89eb 100644 --- a/libamuse/bookserie.go +++ b/libamuse/bookserie.go @@ -3,6 +3,7 @@ import ( "notabug.org/apiote/amuse/wikidata" "notabug.org/apiote/amuse/utils" + "notabug.org/apiote/amuse/accounts" "strings" @@ -50,10 +51,12 @@ result.page = result.renderer.RenderBookSerie(*bookSerie, result.languages) return gott.Tuple(args) } -func ShowBookSerie(id, language, mimetype string) (string, error) { +func ShowBookSerie(id, language, mimetype string, auth accounts.Authentication) (string, error) { + auth.Necessary = false r, err := gott. - NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype}, &Result{}}). + NewResult(gott.Tuple{&RequestData{id: id, language: language, mimetype: mimetype, auth: auth}, &Result{}}). Bind(parseLanguage). + Bind(verifyToken). Bind(getBookSerie). Bind(getOrdinals). Bind(getDescription). diff --git a/libamuse/common.go b/libamuse/common.go index e470d88c5c465c032515b5a85cc24080e9955963..8605e85dc1ff7fa0764b9f715babab9aa01e994e 100644 --- a/libamuse/common.go +++ b/libamuse/common.go @@ -1,11 +1,15 @@ package libamuse import ( + "notabug.org/apiote/amuse/accounts" "notabug.org/apiote/amuse/front" + "notabug.org/apiote/amuse/tmdb" "notabug.org/apiote/amuse/wikidata" - "notabug.org/apiote/amuse/tmdb" + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/db" "database/sql" + "errors" "golang.org/x/text/language" "notabug.org/apiote/gott" @@ -14,6 +18,28 @@ type Data interface { getLanguage() string getMimeType() string + getAuth() accounts.Authentication + getReqUsername() string +} + +func verifyToken(args ...interface{}) (interface{}, error) { + data := args[0].(Data) + result := args[1].(*Result) + user, err := VerifyAuthToken(data.getAuth()) + if _, ok := err.(accounts.AuthError); ok && !data.getAuth().Necessary { + err = nil + } + result.user = user + return gott.Tuple(args), err +} + +func verifyUser(args ...interface{}) (interface{}, error) { + data := args[0].(Data) + result := args[1].(*Result) + if result.user.Username != data.getReqUsername() { + return gott.Tuple(args), accounts.AuthError{Err: errors.New("403")} + } + return gott.Tuple(args), nil } func createDbConnection(args ...interface{}) (interface{}, error) { @@ -43,7 +69,7 @@ func createRenderer(args ...interface{}) (interface{}, error) { data := args[0].(Data) result := args[1].(*Result) - renderer, err := front.NewRenderer(data.getMimeType()) + renderer, err := front.NewRenderer(data.getMimeType(), result.user) result.renderer = renderer return gott.Tuple(args), err } @@ -68,17 +94,60 @@ show.AddBasedOn(book) return gott.Tuple(args), err } +func getGenres(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + list := result.result.(datastructure.List) + genres, err := tmdb.GetGenres(result.languages[0].String(), list.GetType()) + list.SetGenres(genres) + result.result = list + + return gott.Tuple(args), err +} + +func isOnWantList(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + show := result.result.(tmdb.Show) + + if result.user.IsEmpty() { + return gott.Tuple(args), nil + } + + itemType := tmdb.GetItemTypeFromShow(show) + + isOnList, err := db.IsOnWantList(result.user.Username, data.id, itemType) + show.SetOnWantList(isOnList) + return gott.Tuple(args), err +} + +func updateCache(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + + item := result.result.(datastructure.Item) + + itemInfo := item.GetItemInfo() + + err := db.UpdateCacheItem(item.GetItemType(), data.id, itemInfo) + return gott.Tuple(args), err +} + + type RequestData struct { id string + etag string connection *sql.DB language string mimetype string - code int + code int + auth accounts.Authentication + username string } type Result struct { languages []language.Tag renderer front.Renderer + user accounts.User result interface{} result2 interface{} // todo this is ugly -> to []interface{} with .result page string @@ -91,3 +160,11 @@ func (d RequestData) getMimeType() string { return d.mimetype } + +func (d RequestData) getAuth() accounts.Authentication { + return d.auth +} + +func (d RequestData) getReqUsername() string { + return d.username +} diff --git a/libamuse/db.go b/libamuse/db.go deleted file mode 100644 index 810e588e79cd14d958651ff01f0c6f1e110a490d..0000000000000000000000000000000000000000 --- a/libamuse/db.go +++ /dev/null @@ -1,25 +0,0 @@ -package libamuse - -import ( - "notabug.org/apiote/amuse/utils" - - "database/sql" - - _ "github.com/mattn/go-sqlite3" -) - -func InitDB() error { - db, err := sql.Open("sqlite3", utils.DataHome + "/amuse.db") - if err != nil { - return err - } - - db.Exec(`create table cache(uri text primary key, etag text, date date, response blob, last_hit date)`) - - err = db.Close() - if err != nil { - return err - } - - return nil -} diff --git a/libamuse/experiences.go b/libamuse/experiences.go new file mode 100644 index 0000000000000000000000000000000000000000..fecffbc08697ad7310e7f2472693fb62f08eb8fd --- /dev/null +++ b/libamuse/experiences.go @@ -0,0 +1,71 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/db" + + "notabug.org/apiote/gott" + + "time" +) + +func getExperiences(args ...interface{}) (interface{}, error) { + request := args[0].(*RequestData) + result := args[1].(*Result) + page := args[2].(int) + experiences, err := db.GetUserExperiences(result.user.Username, request.id, page) + result.result = experiences + + return gott.Tuple(args), err +} + +func parseExperienceDates(args ...interface{}) interface{} { + result := args[1].(*Result) + experiences := result.result.(datastructure.Experiences) + location, _ := time.LoadLocation(result.user.Timezone) + for i, experience := range experiences.List { + experience.Datetime = experience.Datetime.In(location) + experiences.List[i] = experience + } + result.result = experiences + + return gott.Tuple(args) +} + +func renderExperiences(args ...interface{}) interface{} { + request := args[0].(*RequestData) + result := args[1].(*Result) + page := args[2].(int) + experiences := result.result.(datastructure.Experiences) + experiences.Page = page + experiences.Query = request.id + result.page = result.renderer.RenderExperiences(experiences, result.languages) + + return gott.Tuple(args) +} + +func ShowExperiences(username string, auth accounts.Authentication, languages, mimetype, filter string, page int) (string, error) { + auth.Necessary = true + if page <= 0 { + page = 1 + } + + request := &RequestData{id: filter, language: languages, mimetype: mimetype, auth: auth, username: username} + r, err := gott. + NewResult(gott.Tuple{request, &Result{}, page}). + Bind(parseLanguage). + Bind(verifyToken). + Bind(verifyUser). + Bind(getExperiences). + Map(parseExperienceDates). + Bind(createRenderer). + Map(renderExperiences). + Finish() + + if err != nil { + return "", err + } else { + return r.(gott.Tuple)[1].(*Result).page, nil + } +} diff --git a/libamuse/film.go b/libamuse/film.go index 5fa55f9f034c95f5c30218b5c18164e9ded2d581..fef437b32c4f0be1bb962247ce33bd91e2f713f5 100644 --- a/libamuse/film.go +++ b/libamuse/film.go @@ -1,24 +1,26 @@ package libamuse import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/db" "notabug.org/apiote/amuse/tmdb" - - "notabug.org/apiote/gott" + "notabug.org/apiote/amuse/datastructure" "strconv" + + "notabug.org/apiote/gott" ) func getFilm(args ...interface{}) (interface{}, error) { data := args[0].(*RequestData) result := args[1].(*Result) languages := result.languages - film, err := tmdb.GetFilm(data.id, languages[0].String(), data.connection) + film, err := tmdb.GetFilm(data.id, languages[0].String()) result.result = film return gott.Tuple(args), err } func getCollection(args ...interface{}) (interface{}, error) { - data := args[0].(*RequestData) result := args[1].(*Result) film := result.result.(*tmdb.Film) languages := result.languages @@ -26,14 +28,34 @@ var err error if film.Collection.Id != 0 { collection, e := tmdb.GetCollection( strconv.FormatInt(int64(film.Collection.Id), 10), - languages[0].String(), - data.connection) + languages[0].String()) film.Collection = *collection err = e } return gott.Tuple(args), err } +func getCollectionWatches(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + film := result.result.(*tmdb.Film) + + if result.user.IsEmpty() { + return gott.Tuple(args), nil + } + + for i, part := range film.Collection.Parts { + experiences, err := db.GetItemExperiences(result.user.Username, strconv.FormatInt(int64(part.Id), 10), datastructure.ItemTypeFilm) + if err != nil { + return gott.Tuple(args), err + } + if len(experiences) > 0 { + part.IsWatched = true + film.Collection.Parts[i] = part + } + } + return gott.Tuple(args), nil +} + func renderFilm(args ...interface{}) interface{} { result := args[1].(*Result) film := result.result.(*tmdb.Film) @@ -41,22 +63,37 @@ result.page = result.renderer.RenderFilm(film, result.languages) return gott.Tuple(args) } -func ShowFilm(id, language, mimetype string) (string, error) { - request := &RequestData{id: id, language: language, mimetype: mimetype} +func getFilmExperiences(args ...interface{}) (interface{}, error) { + data := args[0].(*RequestData) + result := args[1].(*Result) + film := result.result.(*tmdb.Film) + + if result.user.IsEmpty() { + return gott.Tuple(args), nil + } + + exp, err := db.GetItemExperiences(result.user.Username, data.id, datastructure.ItemTypeFilm) + film.Experiences = exp[data.id] + return gott.Tuple(args), err +} + +func ShowFilm(id, language, mimetype string, auth accounts.Authentication) (string, error) { + auth.Necessary = false + request := &RequestData{id: id, language: language, mimetype: mimetype, auth: auth} r, err := gott. NewResult(gott.Tuple{request, &Result{}}). - Bind(createDbConnection). Bind(parseLanguage). + Bind(verifyToken). Bind(getFilm). Bind(getCollection). + Bind(getCollectionWatches). Bind(getBasedOn). + Bind(updateCache). + Bind(getFilmExperiences). + Bind(isOnWantList). Bind(createRenderer). Map(renderFilm). Finish() - - if request.connection != nil { - request.connection.Close() - } if err != nil { return "", err diff --git a/libamuse/index.go b/libamuse/index.go index a0a40ee2f8ceec7f3db041c39b307b24b9134093..58e3c522d605dbd6402801c6d65c3a223273ab7b 100644 --- a/libamuse/index.go +++ b/libamuse/index.go @@ -1,6 +1,7 @@ package libamuse import ( + "notabug.org/apiote/amuse/accounts" "notabug.org/apiote/amuse/tmdb" "notabug.org/apiote/gott" @@ -21,10 +22,12 @@ result.page = result.renderer.RenderIndex(randomTitle, result.languages) return gott.Tuple(args) } -func ShowIndex(language, mimetype string) (string, error) { +func ShowIndex(language, mimetype string, authentication accounts.Authentication) (string, error) { + authentication.Necessary = false r, err := gott. - NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype}, &Result{}}). + NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype, auth: authentication}, &Result{}}). Bind(parseLanguage). + Bind(verifyToken). Bind(getRandomTitle). Bind(createRenderer). Map(renderIndex). diff --git a/libamuse/login.go b/libamuse/login.go new file mode 100644 index 0000000000000000000000000000000000000000..b91dbee3f273376b5cb48319c4685a5654ce6019 --- /dev/null +++ b/libamuse/login.go @@ -0,0 +1,58 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/accounts" + + "notabug.org/apiote/gott" +) + +func renderLogin(args ...interface{}) interface{} { + result := args[1].(*Result) + target := args[3].(string) + var authError error + if args[2] != nil { + authError = args[2].(error) + } + result.page = result.renderer.RenderLogin(result.languages, authError, target) + return gott.Tuple(args) +} + +func ShowLogin(language, mimetype string, authErr *accounts.AuthError, target string) (string, error) { + r, err := gott. + NewResult(gott.Tuple{&RequestData{language: language, mimetype: mimetype}, &Result{}, authErr, target}). + Bind(parseLanguage). + Bind(createRenderer). + Map(renderLogin). + Finish() + + if err != nil { + return "", err + } else { + return r.(gott.Tuple)[1].(*Result).page, nil + } +} + +func DoLogin(username, password, sfa string, remember bool) (string, error) { + return accounts.Login(username, password, sfa, remember) +} + +func renderLoggedOut(args ...interface{}) interface{} { + result := args[1].(*Result) + result.page = result.renderer.RenderLoggedOut(result.languages) + return gott.Tuple(args) +} + +func ShowLoggedOut(languages, mimetype string) (string, error) { + r, err := gott. + NewResult(gott.Tuple{&RequestData{language: languages, mimetype: mimetype}, &Result{}}). + Bind(parseLanguage). + Bind(createRenderer). + Map(renderLoggedOut). + Finish() + + if err != nil { + return "", err + } else { + return r.(gott.Tuple)[1].(*Result).page, nil + } +} diff --git a/libamuse/manage.go b/libamuse/manage.go new file mode 100644 index 0000000000000000000000000000000000000000..7fa56f2d726ebdb52692c543e040d01ce67fd8c9 --- /dev/null +++ b/libamuse/manage.go @@ -0,0 +1,9 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/db" +) + +func MakeAdmin(username string) error { + return db.MakeAdmin(username) +} diff --git a/libamuse/person.go b/libamuse/person.go index 32c4d5519793ec1a331c7b2a7689c353acfe6f07..492f7cb57f910c5b9c672feeb62a56f942920f97 100644 --- a/libamuse/person.go +++ b/libamuse/person.go @@ -2,6 +2,7 @@ package libamuse import ( "notabug.org/apiote/amuse/tmdb" + "notabug.org/apiote/amuse/accounts" "notabug.org/apiote/gott" ) @@ -22,10 +23,12 @@ result.page = result.renderer.RenderPerson(person, result.languages) return gott.Tuple(args) } -func ShowPerson(id, etag, language, mimetype string) (string, error) { +func ShowPerson(id, etag, language, mimetype string, auth accounts.Authentication) (string, error) { + auth.Necessary = false r, err := gott. - NewResult(gott.Tuple{&RequestData{id: id,language: language, mimetype: mimetype}, &Result{}}). + NewResult(gott.Tuple{&RequestData{id: id,language: language, mimetype: mimetype, auth: auth}, &Result{}}). Bind(parseLanguage). + Bind(verifyToken). Bind(getPerson). Bind(createRenderer). Map(renderPerson). diff --git a/libamuse/search.go b/libamuse/search.go index 4397a7851ba556f7df186e831ffbff399c34803c..25a5fa78799bd96789270a1ac399ae3a0f422886 100644 --- a/libamuse/search.go +++ b/libamuse/search.go @@ -1,6 +1,7 @@ package libamuse import ( + "notabug.org/apiote/amuse/accounts" "notabug.org/apiote/amuse/tmdb" "notabug.org/apiote/amuse/wikidata" @@ -14,6 +15,8 @@ query string language string mimetype string page string + auth accounts.Authentication + username string } func (d QueryData) getLanguage() string { @@ -24,6 +27,14 @@ func (d QueryData) getMimeType() string { return d.mimetype } +func (d QueryData) getAuth() accounts.Authentication { + return d.auth +} + +func (d QueryData) getReqUsername() string { + return d.username +} + func searchTmdb(args ...interface{}) (interface{}, error) { data := args[0].(*QueryData) result := args[1].(*Result) @@ -51,10 +62,12 @@ result.page = result.renderer.RenderSearch(tmdbResults, inventaireResults, result.languages) return gott.Tuple(args) } -func PerformSearch(query, language, mimetype, page string) (string, error) { +func PerformSearch(query, language, mimetype, page string, auth accounts.Authentication) (string, error) { + auth.Necessary = false r, err := gott. - NewResult(gott.Tuple{&QueryData{query: query, language: language, mimetype: mimetype, page: page}, &Result{}}). + NewResult(gott.Tuple{&QueryData{query: query, language: language, mimetype: mimetype, page: page, auth: auth}, &Result{}}). Bind(parseLanguage). + Bind(verifyToken). Bind(searchTmdb). Bind(searchInventaire). Bind(createRenderer). diff --git a/libamuse/serie.go b/libamuse/serie.go index 8432e027dce74ffc65af1bca42b942dec15116b8..521d830267da953390b8db839e9c2667c9cac83e 100644 --- a/libamuse/serie.go +++ b/libamuse/serie.go @@ -1,28 +1,47 @@ package libamuse import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/db" "notabug.org/apiote/amuse/tmdb" + "notabug.org/apiote/amuse/datastructure" + + "sort" "notabug.org/apiote/gott" +) - "sort" -) +func min(a, b int) int { + if a > b { + return b + } else { + return a + } +} func getTvSerie(args ...interface{}) (interface{}, error) { data := args[0].(*RequestData) result := args[1].(*Result) languages := result.languages - tvSerie, err := tmdb.GetSerie(data.id, languages[0].String(), data.connection) + tvSerie, err := tmdb.GetSerie(data.id, languages[0].String()) result.result = tvSerie return gott.Tuple(args), err } func getSeasons(args ...interface{}) (interface{}, error) { - data := args[0].(*RequestData) + result := args[1].(*Result) + tvSerie := result.result.(*tmdb.TvSerie) + languages := result.languages + seasons, err := tmdb.GetSeasons(tvSerie, languages[0].String()) + tvSerie.Seasons = seasons + return gott.Tuple(args), err +} + +func getSeason0(args ...interface{}) (interface{}, error) { result := args[1].(*Result) tvSerie := result.result.(*tmdb.TvSerie) languages := result.languages - seasons, err := tmdb.GetSeasons(tvSerie, languages[0].String(), data.connection) + seasons, err := tmdb.GetSeason0(tvSerie, languages[0].String()) tvSerie.Seasons = seasons return gott.Tuple(args), err } @@ -43,11 +62,11 @@ } } for _, person := range tvSerie.Credits.Crew { - mergedCrew [person.Name+person.Job] = person + mergedCrew[person.Name+person.Job] = person } for _, season := range tvSerie.Seasons { for _, person := range season.Credits.Crew { - mergedCrew [person.Name+person.Job] = person + mergedCrew[person.Name+person.Job] = person } } @@ -66,7 +85,7 @@ } sort.Slice(tvSerie.Credits.Crew, func(i, j int) bool { return tvSerie.Credits.Crew[i].Job < tvSerie.Credits.Crew[j].Job }) - + return gott.Tuple(args), nil } @@ -77,23 +96,110 @@ result.page = result.renderer.RenderTvSerie(tvSerie, result.languages) return gott.Tuple(args) } -func ShowTvSerie(id, etag, language, mimetype string) (string, error) { - request := &RequestData{id: id, language: language, mimetype: mimetype} +func countAllEpisodes(args ...interface{}) interface{} { + result := args[1].(*Result) + tvSerie := result.result.(*tmdb.TvSerie) + for _, season := range tvSerie.Seasons { + tvSerie.AllEpisodes += len(season.Episodes) + } + return gott.Tuple(args) +} + +func calculateProgress(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + tvSerie := result.result.(*tmdb.TvSerie) + if result.user.IsEmpty() { + return gott.Tuple(args), nil + } + + experiences, err := db.GetItemExperiences(result.user.Username, tvSerie.Id, datastructure.ItemTypeTvserie) + var ( + watched int + skipped int + watchedAndSkipped int + ) + for _, e := range experiences { + isWatched := false + for _, t := range e { + if !t.IsZero() { + isWatched = true + break + } + } + if isWatched { + watched += 1 + } else { + skipped += 1 + } + watchedAndSkipped += 1 + } + if tvSerie.AllEpisodes > 0 { + tvSerie.Progress = min(watched * 100 / (tvSerie.AllEpisodes - skipped), 100) + } + tvSerie.WatchedEpisodes = watched + tvSerie.SkippedEpisodes = skipped + return gott.Tuple(args), err +} + +func findNextEpisode(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + tvSerie := result.result.(*tmdb.TvSerie) + if result.user.IsEmpty() { + return gott.Tuple(args), nil + } + experiences, err := db.GetItemExperiences(result.user.Username, tvSerie.Id, datastructure.ItemTypeTvserie) + + for _, season := range tvSerie.Seasons { + for _, episode := range season.Episodes { + id := tvSerie.Id + "/" + episode.Episode_code + if len(experiences[id]) == 0 { + tvSerie.Next_episode_to_watch = episode + return gott.Tuple(args), err + } + } + } + + return gott.Tuple(args), err +} + +func getEpisodesExperiences(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + tvSerie := result.result.(*tmdb.TvSerie) + if result.user.IsEmpty() { + return gott.Tuple(args), nil + } + experiences, err := db.GetItemExperiences(result.user.Username, tvSerie.Id, datastructure.ItemTypeTvserie) + + for s, season := range tvSerie.Seasons { + for e, episode := range season.Episodes { + id := tvSerie.Id + "/" + episode.Episode_code + tvSerie.Seasons[s].Episodes[e].Experiences = experiences[id] + } + } + + return gott.Tuple(args), err +} + +func ShowTvSerie(id, etag, language, mimetype string, auth accounts.Authentication) (string, error) { + auth.Necessary = false + request := &RequestData{id: id, language: language, mimetype: mimetype, auth: auth} r, err := gott. NewResult(gott.Tuple{request, &Result{}}). - Bind(createDbConnection). Bind(parseLanguage). + Bind(verifyToken). Bind(getTvSerie). Bind(getSeasons). Bind(getBasedOn). Bind(mergeCredits). + Bind(isOnWantList). + Bind(updateCache). + Map(countAllEpisodes). + Bind(calculateProgress). + Bind(findNextEpisode). + Bind(getEpisodesExperiences). Bind(createRenderer). Map(renderSerie). Finish() - - if request.connection != nil { - request.connection.Close() - } if err != nil { return "", err diff --git a/libamuse/signup.go b/libamuse/signup.go new file mode 100644 index 0000000000000000000000000000000000000000..3836e2c41c2538aabac6f037c7707eca8a4f9c39 --- /dev/null +++ b/libamuse/signup.go @@ -0,0 +1,152 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/accounts" + + "bytes" + "encoding/base32" + "encoding/base64" + "errors" + "image" + "strings" + + "github.com/chai2010/webp" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "notabug.org/apiote/gott" +) + +func createSecret(args ...interface{}) (interface{}, error) { + var ( + err error + secretB []byte + ) + + result := args[1].(*Result) + secret := args[4].(string) + host := args[6].(string) + + if len(secret) > 0 { + secretB, err = base32.StdEncoding.DecodeString(secret) + } + opts := totp.GenerateOpts{ + Issuer: host, + AccountName: "nearly_headless_nick@" + host, + Secret: secretB, + } + key, err := totp.Generate(opts) + result.result = key + return gott.Tuple(args), err +} + +func createImage(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + secret := result.result.(*otp.Key) + image, err := secret.Image(256, 256) + result.result2 = image + return gott.Tuple(args), err +} + +func encodeWebp(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + image := result.result2.(image.Image) + var buf bytes.Buffer + err := webp.Encode(&buf, image, &webp.Options{Lossless: true}) + + data := "data:image/webp;base64," + data += base64.StdEncoding.EncodeToString(buf.Bytes()) + result.result2 = data + + return gott.Tuple(args), err +} + +func renderSignup(args ...interface{}) interface{} { + result := args[1].(*Result) + secret := result.result.(*otp.Key) + qr := result.result2.(string) + sfaEnabled := args[3].(bool) + username := args[5].(string) + + var authError error + if args[2] != nil { + authError = args[2].(error) + } + result.page = result.renderer.RenderSignup(result.languages, authError, secret, sfaEnabled, username, qr) + return gott.Tuple(args) +} + +func ShowSignup(acceptLanguages, mimetype string, err error, sfaEnabled bool, sfaSecret, username, host string) (string, error) { + r, err := gott. + NewResult(gott.Tuple{&RequestData{language: acceptLanguages, mimetype: mimetype}, &Result{}, err, sfaEnabled, sfaSecret, username, host}). + Bind(parseLanguage). + Bind(createSecret). + Bind(createImage). + Bind(encodeWebp). + Bind(createRenderer). + Map(renderSignup). + Finish() + + if err != nil { + return "", err + } else { + return r.(gott.Tuple)[1].(*Result).page, nil + } +} + +func DoSignup(username, password, passwordConfirm string, sfaEnabled bool, sfaSecret, sfa string) (string, error) { + if password != passwordConfirm { + return "", accounts.AuthError{ + Err: errors.New("passwords_dont_match"), + } + } + if sfaEnabled { + if sfa == "" { + return "", accounts.AuthError{ + Err: errors.New("sfa_not_confirmed"), + } + } + sfa = strings.ReplaceAll(sfa, " ", "") + if !totp.Validate(sfa, sfaSecret) { + return "", accounts.AuthError{ + Err: errors.New("sfa_code_not_correct"), + } + } + } + if username == "" || password == "" || sfaSecret == "" { + return "", accounts.AuthError{ + Err: errors.New("required_info_missing"), + } + } + + if !sfaEnabled { + sfaSecret = "" + } + + return accounts.Signup(username, password, sfaSecret) +} + +func renderSignedup(args ...interface{}) interface{} { + result := args[1].(*Result) + recoveryCodes := args[2].(string) + codes := []string{} + if recoveryCodes != "" { + codes = strings.Split(recoveryCodes, ",") + } + result.page = result.renderer.RenderSignedup(result.languages, codes) + return gott.Tuple(args) +} + +func ShowSignedup(acceptLanguages, mimetype, recoveryCodes string) (string, error) { + r, err := gott. + NewResult(gott.Tuple{&RequestData{language: acceptLanguages, mimetype: mimetype}, &Result{}, recoveryCodes}). + Bind(parseLanguage). + Bind(createRenderer). + Map(renderSignedup). + Finish() + + if err != nil { + return "", err + } else { + return r.(gott.Tuple)[1].(*Result).page, nil + } +} diff --git a/libamuse/tvqueue.go b/libamuse/tvqueue.go new file mode 100644 index 0000000000000000000000000000000000000000..cd0e5913f37efbddac4bdc3eea4ba35e5fec0856 --- /dev/null +++ b/libamuse/tvqueue.go @@ -0,0 +1,55 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/db" + + "notabug.org/apiote/gott" +) + +func getTvQueue(args ...interface{}) (interface{}, error) { + request := args[0].(*RequestData) + result := args[1].(*Result) + page := args[2].(int) + tvQueue, err := db.GetTvQueue(result.user.Username, request.id, page) + result.result = &tvQueue + + return gott.Tuple(args), err +} + +func renderTvQueue(args ...interface{}) interface{} { + request := args[0].(*RequestData) + result := args[1].(*Result) + page := args[2].(int) + tvQueue := result.result.(*datastructure.TvQueue) + tvQueue.Page = page + tvQueue.Query = request.id + result.page = result.renderer.RenderTvQueue(*tvQueue, result.languages) + + return gott.Tuple(args) +} + +func ShowTvQueue(username string, auth accounts.Authentication, languages, mimetype, filter string, page int) (string, error) { + auth.Necessary = true + if page <= 0 { + page = 1 + } + request := &RequestData{id: filter, language: languages, mimetype: mimetype, auth: auth, username: username} + r, err := gott. + NewResult(gott.Tuple{request, &Result{}, page}). + Bind(parseLanguage). + Bind(verifyToken). + Bind(verifyUser). + Bind(getTvQueue). + Bind(getGenres). + Bind(createRenderer). + Map(renderTvQueue). + Finish() + + if err != nil { + return "", err + } else { + return r.(gott.Tuple)[1].(*Result).page, nil + } +} diff --git a/libamuse/user.go b/libamuse/user.go new file mode 100644 index 0000000000000000000000000000000000000000..3c26306818b7c84cfae7179c873a43b9b234ee8a --- /dev/null +++ b/libamuse/user.go @@ -0,0 +1,145 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/db" + "notabug.org/apiote/amuse/network" + + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + + "notabug.org/apiote/gott" +) + +type Avatar struct { + Data []byte + Mimetype string + Etag string +} + +func getUser(args ...interface{}) (interface{}, error) { + request := args[0].(*RequestData) + result := args[1].(*Result) + user, err := db.GetUser(request.id) + result.result = user + return gott.Tuple(args), err +} + +func getAvatar(args ...interface{}) (interface{}, error) { + result := args[1].(*Result) + small := args[2].(bool) + user := result.result.(*db.User) + if small { + if string(user.AvatarSmall) == "" { + return gott.Tuple(args), errors.New("No avatar") + } else { + result.result = user.AvatarSmall + } + } else { + if string(user.Avatar) == "" { + return gott.Tuple(args), errors.New("No avatar") + } else { + result.result = user.Avatar + } + } + result.result2 = "image/webp" + return gott.Tuple(args), nil +} + +func checkEtag(args ...interface{}) (interface{}, error) { + request := args[0].(*RequestData) + result := args[1].(*Result) + h := sha256.New() + _, err := h.Write([]byte(result.page)) + if err != nil { + return gott.Tuple(args), err + } + etag := base64.StdEncoding.EncodeToString(h.Sum(nil)) + if etag == request.etag { + result.result = []byte{} + } + result.page = etag + return gott.Tuple(args), nil +} + +func createPlaceholderRequest(args ...interface{}) (interface{}, error) { + request := args[0].(*network.Request) + result := args[1].(*network.Result) + small := args[2].(bool) + result.Client = &http.Client{} + size := "512" + if small { + size = "40" + } + httpRequest, err := http.NewRequest("GET", "https://api.adorable.io/avatars/"+size+"/"+request.Id+".png", nil) + result.Request = httpRequest + return gott.Tuple(args), err +} + +func unmarshalPlaceholder(args ...interface{}) interface{} { + result := args[1].(*network.Result) + result.Result = result.Body + return gott.Tuple(args) +} + +func recovery(args ...interface{}) (interface{}, error) { + err := args[3].(error) + switch err.Error() { + case "No avatar": + return getPlaceholder(args...) + default: + return gott.Tuple(args), err + } +} + +func getPlaceholder(args ...interface{}) (interface{}, error) { + request := args[0].(*RequestData) + result := args[1].(*Result) + small := args[2].(bool) + r, err := gott. + NewResult(gott.Tuple{&network.Request{Id: request.id, Etag: request.etag}, &network.Result{}, small}). + Bind(createPlaceholderRequest). + Map(network.AddHeaders). + Bind(network.DoRequest). + Bind(network.HandleRequestError). + Bind(network.ReadResponse). + Map(unmarshalPlaceholder). + // todo get etag + Finish() + + if err != nil { + return gott.Tuple(args), err + } + + result.result = r.(gott.Tuple)[1].(*network.Result).Result.([]byte) + result.result2 = "image/png" + + return gott.Tuple(args), nil +} + +func ShowUserAvatar(username, etagReq string, auth accounts.Authentication, small bool) (Avatar, error) { + auth.Necessary = true + avatar := Avatar{} + + r, err := gott. + NewResult(gott.Tuple{&RequestData{id: username, etag: etagReq, auth: auth, username: username}, &Result{}, small}). + Bind(verifyToken). + Bind(verifyUser). + Bind(getUser). + Bind(getAvatar). + Bind(checkEtag). + Recover(recovery). + Finish() + + if err != nil { + return avatar, err + } + r = r.(gott.Tuple)[1] + avatar.Data = r.(*Result).result.([]byte) + avatar.Mimetype = r.(*Result).result2.(string) + avatar.Etag = r.(*Result).page + + return avatar, nil +} diff --git a/libamuse/watchlist.go b/libamuse/watchlist.go new file mode 100644 index 0000000000000000000000000000000000000000..31eb870d150a2cc732c856e61e1e07b3b507a64e --- /dev/null +++ b/libamuse/watchlist.go @@ -0,0 +1,55 @@ +package libamuse + +import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/db" + "notabug.org/apiote/amuse/datastructure" + + "notabug.org/apiote/gott" +) + +func getWatchlist(args ...interface{}) (interface{}, error) { + request := args[0].(*RequestData) + result := args[1].(*Result) + page := args[2].(int) + watchlist, err := db.GetWatchlist(result.user.Username, request.id, page) + result.result = &watchlist + + return gott.Tuple(args), err +} + +func renderWatchlist(args ...interface{}) interface{} { + request := args[0].(*RequestData) + result := args[1].(*Result) + page := args[2].(int) + watchlist := result.result.(*datastructure.Watchlist) + watchlist.Page = page + watchlist.Query = request.id + result.page = result.renderer.RenderWatchlist(*watchlist, result.languages) + + return gott.Tuple(args) +} + +func ShowWatchlist(username string, auth accounts.Authentication, languages, mimetype, filter string, page int) (string, error) { + auth.Necessary = true + if page <= 0 { + page = 1 + } + request := &RequestData{id: filter, language: languages, mimetype: mimetype, auth: auth, username: username} + r, err := gott. + NewResult(gott.Tuple{request, &Result{}, page}). + Bind(parseLanguage). + Bind(verifyToken). + Bind(verifyUser). + Bind(getWatchlist). + Bind(getGenres). + Bind(createRenderer). + Map(renderWatchlist). + Finish() + + if err != nil { + return "", err + } else { + return r.(gott.Tuple)[1].(*Result).page, nil + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000000000000000000000000000000000000..fbbf1b767e1eaaa9d7ab4bd3aa4ecc416810fcf6 --- /dev/null +++ b/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "notabug.org/apiote/amuse/libamuse" + "notabug.org/apiote/amuse/utils" + "notabug.org/apiote/amuse/db" + + "flag" + "fmt" + "os" +) + +func main() { + port := flag.Int("p", 5008, "port to run amuse on") + dataHome := flag.String("d", "/usr/local/share/amuse", "data directory") + manage := flag.String("m", "", "manage command") + flag.Parse() + + utils.DataHome = *dataHome + + db.Migrate() + + switch *manage { + case "makeadmin": + var username string + fmt.Printf("Username: ") + fmt.Scanf("%s", &username) + err := libamuse.MakeAdmin(username) + if err != nil { + os.Exit(1) + } else { + return + } + } + + route(*port) +} diff --git a/mkfile b/mkfile index 863b5a579030863688599e7271cf408bc3459c5e..e4a9d8db74dfa544e2c6e6f178acf076c5ea6931 100644 --- a/mkfile +++ b/mkfile @@ -3,7 +3,7 @@ all:V: $ALL reallyall:V: $ALL pymodule -amuse: router.go go.mod go.sum `echo front/*.go i18n/*.go libamuse/*.go protocol/*.go tmdb/*.go utils/*.go wikidata/*.go` +amuse: main.go router.go go.mod go.sum `echo front/*.go i18n/*.go libamuse/*.go protocol/*.go tmdb/*.go utils/*.go wikidata/*.go db/*.go datastructure/*.go network/*.go accounts/*.go` go build -ldflags "-s -w -linkmode external -extldflags -static" static/img/%.webp: static/img/%.svg rendersvg static/img/$stem.svg static/img/$stem.png diff --git a/router.go b/router.go index f68634655530c768e514b8a5f8dde4a08b6aa62c..1b0d39dec9c4039d2f14d50614d94bf7e611190a 100644 --- a/router.go +++ b/router.go @@ -1,6 +1,8 @@ package main import ( + "notabug.org/apiote/amuse/accounts" + "notabug.org/apiote/amuse/db" "notabug.org/apiote/amuse/front" "notabug.org/apiote/amuse/libamuse" "notabug.org/apiote/amuse/network" @@ -9,21 +11,22 @@ "crypto/sha256" "encoding/base64" "errors" - "flag" "fmt" "io" "mime" "net/http" + "net/url" "os" - //"path/filepath" "strconv" "strings" + "time" ) func person(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") etag := r.Header.Get("Etag") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) @@ -36,7 +39,7 @@ } else if len(path) > 2 { renderError(404, w, nil, acceptLanguages, mimetype) return } - person, err := libamuse.ShowPerson(path[1], etag, acceptLanguages, mimetype) + person, err := libamuse.ShowPerson(path[1], etag, acceptLanguages, mimetype, auth) render(person, err, w, acceptLanguages, mimetype) } @@ -44,6 +47,7 @@ func tvSerie(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") etag := r.Header.Get("Etag") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) @@ -56,13 +60,14 @@ } else if len(path) > 2 { renderError(404, w, nil, acceptLanguages, mimetype) return } - tvSerie, err := libamuse.ShowTvSerie(path[1], etag, acceptLanguages, mimetype) + tvSerie, err := libamuse.ShowTvSerie(path[1], etag, acceptLanguages, mimetype, auth) render(tvSerie, err, w, acceptLanguages, mimetype) } func film(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) @@ -75,13 +80,14 @@ } else if len(path) > 2 { renderError(404, w, nil, acceptLanguages, mimetype) return } - film, err := libamuse.ShowFilm(path[1], acceptLanguages, mimetype) + film, err := libamuse.ShowFilm(path[1], acceptLanguages, mimetype, auth) render(film, err, w, acceptLanguages, mimetype) } func book(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) path := strings.Split(r.URL.Path[1:], "/") @@ -89,13 +95,14 @@ if len(path) > 2 { renderError(404, w, nil, acceptLanguages, mimetype) return } - book, err := libamuse.ShowBook(path[1], acceptLanguages, mimetype) + book, err := libamuse.ShowBook(path[1], acceptLanguages, mimetype, auth) render(book, err, w, acceptLanguages, mimetype) } func bookSerie(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) path := strings.Split(r.URL.Path[1:], "/") @@ -103,13 +110,14 @@ if len(path) > 2 { renderError(404, w, nil, acceptLanguages, mimetype) return } - bookSerie, err := libamuse.ShowBookSerie(path[1], acceptLanguages, mimetype) + bookSerie, err := libamuse.ShowBookSerie(path[1], acceptLanguages, mimetype, auth) render(bookSerie, err, w, acceptLanguages, mimetype) } func search(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) @@ -120,13 +128,14 @@ return } query := r.URL.Query().Get("q") page := r.URL.Query().Get("page") - results, err := libamuse.PerformSearch(query, acceptLanguages, mimetype, page) + results, err := libamuse.PerformSearch(query, acceptLanguages, mimetype, page, auth) render(results, err, w, acceptLanguages, mimetype) } func index(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) @@ -135,20 +144,162 @@ if path[0] != "" { renderError(404, w, nil, acceptLanguages, mimetype) return } - index, err := libamuse.ShowIndex(acceptLanguages, mimetype) + index, err := libamuse.ShowIndex(acceptLanguages, mimetype, auth) render(index, err, w, acceptLanguages, mimetype) } func about(w http.ResponseWriter, r *http.Request) { acceptLanguages := r.Header.Get("Accept-Language") mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) defer recovery(acceptLanguages, mimetype, w) - about, err := libamuse.ShowAbout(acceptLanguages, mimetype) + about, err := libamuse.ShowAbout(acceptLanguages, mimetype, auth) render(about, err, w, acceptLanguages, mimetype) } +func loginGet(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) { + referer := r.Header.Get("Referer") + target := getTarget(referer, r.Host) + if target == "/signedup" { + target = "/" + } + auth := getAuthToken(r) + user, _ := libamuse.VerifyAuthToken(auth) + if !user.IsEmpty() { + w.Header().Add("Location", target) + w.WriteHeader(303) + return + } + login, err := libamuse.ShowLogin(acceptLanguages, mimetype, nil, target) + render(login, err, w, acceptLanguages, mimetype) +} + +func loginPost(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) { + // todo check mimetype (html,capnproto) + r.ParseForm() + username := r.PostForm.Get("username") + password := r.PostForm.Get("password") + target := r.PostForm.Get("target") + if target == "" { + target = "/" + } + sfa := r.PostForm.Get("sfa") + remember := r.PostForm.Get("remember") == "true" + + token, err := libamuse.DoLogin(username, password, sfa, remember) + if err != nil { + fmt.Println(err) + if authErr, ok := err.(accounts.AuthError); ok { + var login string + var err error + if mimetype == "text/html" { + login, err = libamuse.ShowLogin(acceptLanguages, mimetype, &authErr, target) + } else { + // todo send capnproto not authed + } + render(login, err, w, acceptLanguages, mimetype) + } else { + render("", err, w, acceptLanguages, mimetype) + } + } else { + if mimetype == "text/html" { + setAuthCookie(remember, token, w) + w.Header().Add("Location", target) + w.WriteHeader(303) + } else { + // todo send capnproto authed + } + } +} + +func login(w http.ResponseWriter, r *http.Request) { + acceptLanguages := r.Header.Get("Accept-Language") + mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + + defer recovery(acceptLanguages, mimetype, w) + + if r.Method == "" || r.Method == "GET" { + loginGet(w, r, acceptLanguages, mimetype) + } else if r.Method == "POST" { + loginPost(w, r, acceptLanguages, mimetype) + } +} + +func signupGet(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) { + auth := getAuthToken(r) + user, _ := libamuse.VerifyAuthToken(auth) + host := r.Host + if !user.IsEmpty() { + w.Header().Add("Location", "/") + w.WriteHeader(303) + return + } + signup, err := libamuse.ShowSignup(acceptLanguages, mimetype, nil, false, "", "", host) + render(signup, err, w, acceptLanguages, mimetype) +} + +func signupPost(w http.ResponseWriter, r *http.Request, acceptLanguages, mimetype string) { + // todo check mimetype (html,capnproto) + r.ParseForm() + username := r.PostForm.Get("username") + password := r.PostForm.Get("password") + passwordConfirm := r.PostForm.Get("password2") + sfaEnabled := r.PostForm.Get("sfaEnabled") == "true" + sfaSecret := r.PostForm.Get("sfaSecret") + sfa := r.PostForm.Get("sfa") + host := r.Host + + recoveryCodes, err := libamuse.DoSignup(username, password, passwordConfirm, sfaEnabled, sfaSecret, sfa) + if err != nil { + fmt.Println(err) + if authErr, ok := err.(accounts.AuthError); ok { + var signup string + var err error + if mimetype == "text/html" { + signup, err = libamuse.ShowSignup(acceptLanguages, mimetype, &authErr, sfaEnabled, sfaSecret, username, host) + } else { + // todo send capnproto not authed + } + render(signup, err, w, acceptLanguages, mimetype) + } else { + render("", err, w, acceptLanguages, mimetype) + } + } else { + if mimetype == "text/html" { + w.Header().Add("Location", "/signedup?recoveryCodes="+recoveryCodes) + w.WriteHeader(303) + } else { + // todo send capnproto authed + } + } +} + +func signup(w http.ResponseWriter, r *http.Request) { + acceptLanguages := r.Header.Get("Accept-Language") + mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + + defer recovery(acceptLanguages, mimetype, w) + + if r.Method == "" || r.Method == "GET" { + signupGet(w, r, acceptLanguages, mimetype) + } else if r.Method == "POST" { + signupPost(w, r, acceptLanguages, mimetype) + } +} + +func signedup(w http.ResponseWriter, r *http.Request) { + acceptLanguages := r.Header.Get("Accept-Language") + mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + recoveryCodes := r.URL.Query().Get("recoveryCodes") + + defer recovery(acceptLanguages, mimetype, w) + + signedup, err := libamuse.ShowSignedup(acceptLanguages, mimetype, recoveryCodes) + render(signedup, err, w, acceptLanguages, mimetype) +} + func static(w http.ResponseWriter, r *http.Request) { etagReq := r.Header.Get("If-None-Match") f, err := os.Open(utils.DataHome + "/" + r.URL.Path[1:]) @@ -181,14 +332,186 @@ } } } -func main() { - port := flag.Int("p", 5008, "port to run amuse on") - dataHome := flag.String("d", "/usr/local/share/amuse", "data directory") - flag.Parse() - portStr := fmt.Sprintf(":%d", *port) - utils.DataHome = *dataHome +func user(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages, mimetype string) { + // todo user profile + renderError(404, w, nil, acceptLanguages, mimetype) +} - libamuse.InitDB() +func userAvatar(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) { + etagReq := r.Header.Get("If-None-Match") + r.ParseForm() + size := r.Form.Get("size") + avatar, err := libamuse.ShowUserAvatar(username, etagReq, auth, size == "small") + if err != nil { + render("", err, w, acceptLanguages, mimetype) + } + if string(avatar.Data) == "" { + w.WriteHeader(304) + return + } + w.Header().Set("Content-Type", avatar.Mimetype) + w.Header().Set("ETag", avatar.Etag) + w.Write(avatar.Data) +} + +func userWatchlist(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) { + if r.Method == "" || r.Method == "GET" { + var page int + r.ParseForm() + filter := r.Form.Get("filter") + fmt.Sscanf(r.Form.Get("page"), "%d", &page) + watchlist, err := libamuse.ShowWatchlist(username, auth, acceptLanguages, mimetype, filter, page) + render(watchlist, err, w, acceptLanguages, mimetype) + } else if r.Method == "POST" { + r.ParseForm() + itemId := r.PostForm.Get("itemId") + itemType := r.PostForm.Get("itemType") + target := "/" + itemType + "s/" + itemId + err := libamuse.AddToWantlist(username, auth, itemId, itemType, acceptLanguages, mimetype) + if err != nil { + render("", err, w, acceptLanguages, mimetype) + } else { + w.Header().Add("Location", target) + w.WriteHeader(303) + } + } +} + +func userTvQueue(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) { + if r.Method == "" || r.Method == "GET" { + var page int + r.ParseForm() + filter := r.Form.Get("filter") + fmt.Sscanf(r.Form.Get("page"), "%d", &page) + tvQueue, err := libamuse.ShowTvQueue(username, auth, acceptLanguages, mimetype, filter, page) + render(tvQueue, err, w, acceptLanguages, mimetype) + } else if r.Method == "POST" { + r.ParseForm() + itemId := r.PostForm.Get("itemId") + itemType := r.PostForm.Get("itemType") + masterItemId := strings.Split(itemId, "/")[0] + target := "/" + itemType + "s/" + masterItemId + err := libamuse.AddToWantlist(username, auth, itemId, itemType, acceptLanguages, mimetype) + if err != nil { + render("", err, w, acceptLanguages, mimetype) + } else { + w.Header().Add("Location", target) + w.WriteHeader(303) + } + } +} + +func userExperiences(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages string, mimetype string) { + if r.Method == "" || r.Method == "GET" { + var page int + r.ParseForm() + filter := r.Form.Get("filter") + fmt.Sscanf(r.Form.Get("page"), "%d", &page) + experiences, err := libamuse.ShowExperiences(username, auth, acceptLanguages, mimetype, filter, page) + render(experiences, err, w, acceptLanguages, mimetype) + } else if r.Method == "POST" { + r.ParseForm() + itemId := r.PostForm.Get("itemId") + itemType := r.PostForm.Get("itemType") + isOtherTime := r.PostForm.Get("isOtherTime") == "true" + + var datetime string + if isOtherTime { + date := r.PostForm.Get("watchedDate") + time := r.PostForm.Get("watchedTime") + datetime = date + "T" + time + ":00" + } else { + datetime = "" + } + + masterItemId := strings.Split(itemId, "/")[0] + target := "/" + itemType + "s/" + masterItemId + err := libamuse.AddToExperiences(username, auth, itemId, itemType, datetime, acceptLanguages, mimetype) + if err != nil { + render("", err, w, acceptLanguages, mimetype) + } else { + w.Header().Add("Location", target) + w.WriteHeader(303) + } + } +} + +func sessionDelete(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, session, acceptLanguages, mimetype string) { + err := libamuse.SessionDelete(username, auth, session, acceptLanguages, mimetype) + if err != nil { + render("", err, w, acceptLanguages, mimetype) + } else { + w.Header().Add("Location", "/loggedout") + w.WriteHeader(303) + } +} + +func userSessions(w http.ResponseWriter, r *http.Request, username string, auth accounts.Authentication, acceptLanguages, mimetype string) { + path := strings.Split(r.URL.Path[1:], "/") + if len(path) == 3 { + // todo show sessions + renderError(404, w, nil, acceptLanguages, mimetype) + } else if len(path) == 4 { + if r.Method == "POST" { + r.ParseForm() + method := r.PostForm.Get("method") + session := path[3] + if method == "DELETE" { + sessionDelete(w, r, username, auth, session, acceptLanguages, mimetype) + } + } else if r.Method == "DELETE" { + session := path[3] + sessionDelete(w, r, username, auth, session, acceptLanguages, mimetype) + } + } +} + +func userRouter(w http.ResponseWriter, r *http.Request) { + path := strings.Split(r.URL.Path[1:], "/") + acceptLanguages := r.Header.Get("Accept-Language") + mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + auth := getAuthToken(r) + defer recovery(acceptLanguages, mimetype, w) + + if path[1] == "" { + renderError(404, w, nil, acceptLanguages, mimetype) + return + } + + username := path[1] + if len(path) == 2 { + user(w, r, username, auth, acceptLanguages, mimetype) + } else { + switch path[2] { + case "avatar": + userAvatar(w, r, username, auth, acceptLanguages, mimetype) + case "watchlist": + userWatchlist(w, r, username, auth, acceptLanguages, mimetype) + case "tvqueue": + userTvQueue(w, r, username, auth, acceptLanguages, mimetype) + case "experiences": + userExperiences(w, r, username, auth, acceptLanguages, mimetype) + case "sessions": + userSessions(w, r, username, auth, acceptLanguages, mimetype) + default: + renderError(404, w, nil, acceptLanguages, mimetype) + } + } +} + +func loggedout(w http.ResponseWriter, r *http.Request) { + acceptLanguages := r.Header.Get("Accept-Language") + mimetype := strings.Split(r.Header.Get("Accept"), ",")[0] + + defer recovery(acceptLanguages, mimetype, w) + + loggedout, err := libamuse.ShowLoggedOut(acceptLanguages, mimetype) + setAuthCookie(false, "", w) + render(loggedout, err, w, acceptLanguages, mimetype) +} + +func route(port int) { + portStr := fmt.Sprintf(":%d", port) http.HandleFunc("/", index) http.HandleFunc("/static/", static) @@ -199,6 +522,12 @@ http.HandleFunc("/tvseries/", tvSerie) http.HandleFunc("/persons/", person) http.HandleFunc("/books/", book) http.HandleFunc("/bookseries/", bookSerie) + http.HandleFunc("/users/", userRouter) + + http.HandleFunc("/login", login) + http.HandleFunc("/signup", signup) + http.HandleFunc("/signedup", signedup) + http.HandleFunc("/loggedout", loggedout) fmt.Printf("running on %s\n", portStr) e := http.ListenAndServe(portStr, nil) if e != nil { @@ -206,6 +535,44 @@ fmt.Println(e) } } +func getTarget(referer, host string) string { + url, err := url.Parse(referer) + if err != nil { + fmt.Println(err) + return "/" + } + target := url.EscapedPath() + if target == "" || url.Host != host { + target = "/" + } + return target +} + +func setAuthCookie(remember bool, token string, w http.ResponseWriter) { + cookie := http.Cookie{ + Name: "auth", Value: token, HttpOnly: true, + //SameSite: http.SameSiteStrictMode, Secure: true, // note turn on in prod (https) + } + if remember { + cookie.Expires = time.Now().Add(1000000000 * 60 * 60 * 24 * 30) + } else { + cookie.Expires = time.Now().Add(1000000000 * 60 * 60 * 24) + } + http.SetCookie(w, &cookie) +} + +func getAuthToken(r *http.Request) accounts.Authentication { + cookie, err := r.Cookie("auth") + if err == nil { + return accounts.Authentication{ + Token: cookie.Value, + } + } + return accounts.Authentication{ + Token: r.Header.Get("Authorization"), + } +} + func recovery(languages, mimetype string, w http.ResponseWriter) { if r := recover(); r != nil { renderError(500, w, errors.New(r.(string)), languages, mimetype) @@ -219,6 +586,15 @@ if _, ok := e.(front.NoSuchRendererError); ok { renderError(406, w, e, languages, mimetype) } else if httpError, ok := e.(network.HttpError); ok { renderError(httpError.Status, w, httpError, languages, mimetype) + } else if _, ok := e.(db.EmptyError); ok { + renderError(410, w, e, languages, mimetype) + } else if authError, ok := e.(accounts.AuthError); ok { + if authError.Err.Error() == "401" { + w.Header().Add("WWW-Authenticate", "Bearer") + renderError(401, w, e, languages, mimetype) + } else { + renderError(403, w, e, languages, mimetype) + } } else { renderError(500, w, e, languages, mimetype) } diff --git a/static/style/style.css b/static/style/style.css index f28c27d190787114dfa41ec48decd0a5305539a6..f5a6f83d9d6485ba4cb4f70fd6cd2ec92d31131e 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -30,6 +30,10 @@ .phone-max-width-8 { max-width: 6rem; } + + .phone-bottom-_4 { + bottom: .4rem !important; + } } @media (min-width:1159px) { @@ -65,24 +69,14 @@ :root { --primary: #301934; --primary-semi-transparent: #30193488; - --accent: #d4af37; - --accent-dark: #8b6b04; + --accent: #8b6b04; + --accent-dark: #d4af37; --text: #000000; --bg: #ffffff; + --black: #121212; --grey: #888888; --unimportant: #ffffffb2; - --shadow1: rgba(0,0,0,0.14); - --shadow2: rgba(0,0,0,0.12); - --shadow3: rgba(0,0,0,0.20); -} - -@keyframes shadow { - from { - box-shadow: none; - } - to { - box-shadow: 0 8px 10px 1px var(--shadow1), 0 3px 14px 2px var(--shadow2), 0 5px 5px -3px var(--shadow3); - } + --error: #892b30; } @media (prefers-color-scheme: dark) { @@ -90,17 +84,43 @@ :root { --primary: #301934; --primary-semi-transparent: #30193488; --accent: #d4af37; - --accent-dark: #d4af37; + --accent-dark: #8b6b04; --text: #ffffff; --bg: #121212; + --black: #121212; --grey: #888888; --unimportant: #ffffffb2; - --shadow1: rgba(255,255,255,0.14); - --shadow2: rgba(255,255,255,0.12); - --shadow3: rgba(255,255,255,0.20); + --error: #892b30; } } +nav ul { + display: none; +} +nav input:checked ~ ul { + display: block; +} + +.watched-box, .watched-box-flex { + display: none; +} + +.watched-datetime-check:checked ~ .watched-box { + display: block; +} + +.watched-datetime-check:checked ~ .watched-box-flex { + display: flex; +} + +#sfa-box { + display: none; +} + +#sfa-enabled:checked ~ #sfa-box { + display: block; +} + * { box-sizing: border-box; } @@ -126,7 +146,7 @@ font-family: IBM Plex Serif, serif; } a { - color: var(--accent-dark); + color: var(--accent); } hr { @@ -163,6 +183,24 @@ } ::placeholder { font-weight: 200; + text-overflow: ellipsis; +} + +.list-style-none { + list-style-type: none; +} + +.border-radius-25 { + border-radius: 25%; +} + +.clamp { + display: -webkit-box; + overflow : hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + margin-bottom: 0; } /* TEXT COLOUR */ @@ -176,23 +214,66 @@ } .text-white { color: white; +} + +.text-black { + color: black; } .text-grey { color: var(--grey); } +.text-accent { + color: var(--accent); +} + /* PADDING */ +.padding-tb-_25 { + padding-top: .25rem; + padding-bottom: .25rem; +} + +.padding-tb-_5 { + padding-top: .5rem; + padding-bottom: .5rem; +} + +.padding-tb-1 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.padding-lr-0 { + padding-left: 0; + padding-right: 0; +} + +.padding-lr-_5 { + padding-left: .5rem; + padding-right: .5rem; +} + .padding-bottom-_25 { padding-bottom: .25rem; } +.padding-lr-_25 { + padding-left: .25rem; + padding-right: .25rem; +} + .padding-lr-_1 { padding-left: .1rem; padding-right: .1rem; } +.padding-lr-1 { + padding-left: 1rem; + padding-right: 1rem; +} + .padding-lr-2 { padding-left: 2rem; padding-right: 2rem; @@ -204,6 +285,10 @@ } /* MARGINS */ +.margin-auto { + margin: auto +} + .margin-lr-1 { margin-left: 1rem; margin-right: 1rem; @@ -216,6 +301,10 @@ } .margin-top-0 { margin-top: 0; +} + +.margin-top-_25 { + margin-top: .25vvrem; } .margin-top-1 { @@ -230,6 +319,10 @@ .margin-bottom-_5 { margin-bottom: .5rem; } +.margin-bottom-1 { + margin-bottom: 1rem; +} + .margin-bottom-2 { margin-bottom: 2rem; } @@ -247,6 +340,11 @@ .margin-tb-1 { margin-top: 1rem; margin-bottom: 1rem; +} + +.margin-tb-2 { + margin-top: 2rem; + margin-bottom: 2rem; } /* WIDTH */ @@ -321,7 +419,15 @@ min-width: 18rem; max-width: 18rem; } +.width-1_5 { + width: 1.5rem; +} + /* DISPLAY */ + +.display-none { + display: none; +} .inline { display: inline; @@ -340,6 +446,10 @@ display: block; } /* background */ + +.bg { + background: var(--bg); +} .bg-none { background: none; @@ -350,21 +460,64 @@ background: var(--primary); } .bg-primary-semi-transparent { - background-color: var(--primary-semi-transparent) + background-color: var(--primary-semi-transparent); +} + +.bg-accent { + background-color: var(--accent); +} + +.bg-error { + background-color: var(--error); +} + +.bg-grey { + background-color: var(--grey); +} + +.hover-bg-grey { + outline: 0; + background-color: transparent; + transition: background-color 0.3s ease-in-out; +} + +.hover-bg-grey:hover, .hover-bg-grey:active, .hover-bg-grey:focus { + outline: 0; + background-color: var(--grey); +} + +.hover-bg-dark-accent { + outline: 0; + background-color: var(--accent); + transition: background-color 0.3s ease-in-out; +} + +.hover-bg-dark-accent:hover, .hover-bg-dark-accent:active, .hover-bg-dark-accent:focus { + outline: 0; + background-color: var(--accent-dark); } .bg-gradient { - background-image: linear-gradient(transparent, black); + background-image: linear-gradient(transparent, var(--black)); +} + +.bg-gradient-down { + background-image: linear-gradient(black, transparent); } .cover { object-fit: cover; + object-position: top; } /* FONT SIZE */ .font-_875 { font-size: .875rem +} + +.font-1 { + font-size: 1rem; } .font-1_5 { @@ -389,8 +542,20 @@ .spoler:hover { filter: blur(0) !important; } +.bw { + filter: grayscale(100%); +} + +.bw:hover { + filter: grayscale(0) !important; +} + /* POSITION */ +.right { + right: 0; +} + .bottom { bottom: 0; } @@ -399,14 +564,34 @@ .bottom-4 { bottom: 3.9rem; } +.top-m_05 { + top: -.05rem; +} + +.top-m_3 { + top: -.3rem; +} + .top { top: 0; } +.top-1 { + top: 1rem; +} + .left { left: 0; } +.clear-float:after { + content: "."; + display: block; + height: 0; + clear: both; + visibility: hidden; +} + .move-centre { top: 50%; transform: translateY(-50%); @@ -422,6 +607,10 @@ .move-100 { transform: translateY(50%); } +.moveX-m50 { + transform: translateX(-50%); +} + .absolute { position: absolute; } @@ -436,14 +625,28 @@ } /* ALIGN */ +.flex-content { + flex: 0 1 auto; +} + +.flex-fill { + flex: 1 1 auto; +} + +.flex-column { + flex-direction: column; +} + +.flex-row { + flex-direction: row; +} + .flex-centre { justify-content: center; } -.flex-flow { - flex-flow: row wrap; +.flex-justify-space { justify-content: space-between; - align-content: flex-start; } .flex-force-50 { @@ -455,13 +658,21 @@ flex: 0 0 40%; } .flex-wrap { - flex-flow: row wrap; + flex-wrap: wrap; } .flex-align-bottom { align-items: flex-end; } +.flex-align-centre { + align-items: center; +} + +.flex-align-start { + align-content: flex-start +} + .indent-2 { text-indent: 2rem; } @@ -478,6 +689,10 @@ .centre { text-align: center; } +.align-right { + text-align: right; +} + /* CURSOR */ .cursor-hand { @@ -485,6 +700,15 @@ cursor: pointer; } /* BORDER */ + +.border-solid { + border-style: solid; +} + +.border-tb-transparent { + border-top-color: transparent; + border-bottom-color: transparent; +} .border-none { border: none; @@ -494,6 +718,10 @@ .border-bottom-white { border-bottom: 1px solid white; } +.border-text { + border: 1px solid var(--text); +} + .border-bottom { border-bottom: 1px solid var(--text); } @@ -502,26 +730,67 @@ .border-bottom:active, .border-bottom:focus, .border-bottom:focus-within { border-bottom: 1px solid var(--accent); } +.border-_5 { + border-width: .5px; +} + +.border-grey { + border-color: var(--grey); +} + +.border-gradient { + border-image: linear-gradient(to bottom, transparent, var(--grey), transparent) 1 100%; +} + .no-outline { outline: 0; } -.no-outline:hover, .no-outline:active, .no-outline:focus { +.no-outline:hover, .no-outline:focus { + border: solid .5px var(--grey); +} + +.no-outline:active{ outline: 0; - animation-duration: 200ms; - animation-name: shadow; - animation-fill-mode: forwards; - animation-timing-function: ease-in-out; + border: solid .5px var(--accent); } /* HEIGHT */ +.height-2_8 { + height: 2.8rem +} + +.height-3_3 { + height: 3.3rem +} + .height-30 { height: 30rem; } +.height-_25 { + height: .25rem; +} + +.height-_1 { + height: .1rem; +} + +.height-fill { + height: 100%; +} + +.height-all { + height: 100vh; +} + /* FONT STYLE */ +.monospace { + font-family: monospace; +} + .sans { font-family: Fira Sans, sans-serif; } @@ -538,8 +807,16 @@ .font-thin { font-weight: 200; } +.font-normal { + font-weight: normal; +} + .italic { font-style: italic; +} + +.bold { + font-weight: 600; } .hyphenate { diff --git a/templates/about.html b/templates/about.html index fd48678ee6e42d1c318e68b373e07578f284659a..0bbda468b00d0ff5f4479384505817f4b1145b0a 100644 --- a/templates/about.html +++ b/templates/about.html @@ -9,10 +9,37 @@ <link rel="stylesheet" href="/static/style/style.css" /> </head> <body> - <header class="bg-primary padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> </a> + <div class="margin-lr-1"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5"></span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <main class="margin-lr-5 flex"> <article class="desktop-w6 phone-w12"> diff --git a/templates/book.html b/templates/book.html index 2bdf2cd4fa0cb0ebf211fb5f1a6cee6cd390786f..a447848ae017aebffd852ffb62a2480d0f47a42a 100644 --- a/templates/book.html +++ b/templates/book.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary-semi-transparent padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> <a href="/" class="decoration-none"> <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav class="text"> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5 text-white"></span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <div class="absolute top behind w12"> <img src="/static/img/book_backdrop.webp" class="w12 cover height-30" /> <!-- Photo by [Janko Ferlic](https://www.pexels.com/@thepoorphotographer) from [Pexels](https://www.pexels.com/photo/blur-book-stack-books-bookshelves-590493/) --> @@ -26,7 +53,7 @@ ({{.Data.Year}}) {{end}} </div> </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow margin-bottom-2"> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start margin-bottom-2"> <div class="desktop-w3 phone-w12 margin-bottom-2"> {{if .Data.Cover}} <img src="{{.Data.Cover}}" class="block width-154px"> diff --git a/templates/bookserie.html b/templates/bookserie.html index 570caa5f1f50d160df10a9213d957d4f53815c24..9bc78b5024735089dd2a6336d68ba9f7871b96d8 100644 --- a/templates/bookserie.html +++ b/templates/bookserie.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary-semi-transparent padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> <a href="/" class="decoration-none"> <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav class="text"> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5 text-white"></span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <div class="absolute top behind w12"> <img src="/static/img/book_backdrop.webp" class="w12 cover height-30" /> <!-- Photo by [Janko Ferlic](https://www.pexels.com/@thepoorphotographer) from [Pexels](https://www.pexels.com/photo/blur-book-stack-books-bookshelves-590493/) --> @@ -23,7 +50,7 @@<span class="text-white">{{.Data.Title}}</span> </div> </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow margin-bottom-2"> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start margin-bottom-2"> <div class="desktop-w3 phone-w12 margin-bottom-2"> {{if .Data.Cover}} <img src="{{.Data.Cover}}" class="block width-154px"> @@ -47,7 +74,7 @@{{.Data.Description}}
<hr class="material-icon text-grey hr-book"/> {{if .Data.SortedParts}} <p class="sans font-2">{{.Strings.BookSerie.in_this_collection}}:</p> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> {{range .Data.SortedParts}} <a href="{{.Uri}}" class="decoration-none margin-tb-1 margin-lr-1 force-width-16 no-outline"> <div class="flex"> diff --git a/templates/error.html b/templates/error.html index 8f9518ba6518fc760ad151f81a523607227a066f..22073721108a0bced9eace140fa9aa3c8b586f94 100644 --- a/templates/error.html +++ b/templates/error.html @@ -9,13 +9,10 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> - <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> - </form> </header> <main class="margin-lr-1"> <div class="font-2 w12 flex flex-centre margin-top-10"> @@ -28,6 +25,13 @@<p>{{.GetErrorData .Data "quote"}}</p> <p class="indent-2 sans">—{{.GetErrorData .Data "character"}} (<span class="italic sans">{{.GetErrorData .Data "title"}}</span>)</p> </div> + {{if eq .Data 401}} + <div> + <a href="/login" class="sans">{{.Strings.Global.login}}</a> + — + <a href="/signup" class="sans">{{.Strings.Global.signup}}</a> + </div> + {{end}} </div> </div> </main> diff --git a/templates/experiences.html b/templates/experiences.html new file mode 100644 index 0000000000000000000000000000000000000000..bda1abf22ed2e1f4c8dcbf026d532bad93a61142 --- /dev/null +++ b/templates/experiences.html @@ -0,0 +1,109 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Experiences.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + <div class="margin-lr-1 text"> + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5"></span> + </li> + </ul> + </nav> + </div> + </header> + <main class="margin-lr-1"> + <!-- search, filter, order --> + <!--<div class="flex flex-row flex-wrap flex-centre flex-align-start margin-top-1"> + <form method="GET" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="filter" class="border-none bg-none sans text" placeholder="{{.Strings.Experiences.filter}}" value="{{.Data.Query}}" /> + </form> + </div>--> + {{if .Data.List}} + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + </div> + <div class="flex flex-column w12 flex-align-start"> + {{- $lastDate:="" -}} + {{- range .Data.List -}} + {{- if and (ne $lastDate ($.FormatDate .Datetime)) (ne $lastDate "")}} + </div> + {{end}} + {{- if not .Datetime.IsZero -}} + {{- if ne $lastDate ($.FormatDate .Datetime)}} + <div class="margin-lr-5"> + <span class="sans">{{$.FormatDateNice .Datetime $.State.User.Timezone}}</span><hr class="margin-top-_25 margin-bottom-1"/> + {{- end}} + <p> + <span class="sans">{{.FormatDatetime $.Strings}}</span> + <a href="/{{.Type}}s/{{.Id}}" class="sans decoration-none">{{.Title}} ({{.YearStart}})</a> + {{if eq .Type "tvserie"}}<span class="sans">{{.Code}}</span>{{end}} + {{- if gt .Collection 0 -}}<span class="sans">(<!--{{.Collection}} <!-- collection name and link -->#{{.Part}})</span>{{- end}} + </p> + {{- end -}} + {{$lastDate = ($.FormatDate .Datetime) -}} + {{end}} + </div> + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/experiences?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + </div> + {{else if eq .Data.Pages 0}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.empty_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.empty_character}} (<span class="italic sans">{{.Strings.Global.empty_title}}</span>)</p> + </div> + </div> + {{else if gt .Data.Page .Data.Pages}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.too_far_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.too_far_character}} (<span class="italic sans">{{.Strings.Global.too_far_title}}</span>, {{.Strings.Global.too_far_code}} {{.Strings.Global.too_far_episode}})</p> + </div> + </div> + {{end}} + </main> + </body> +</html> diff --git a/templates/film.html b/templates/film.html index b3cefa60768117320fddfa0b244806de798d23a3..30ed05a0c2344e6d1cbbb3d5dc5e836f928e04ce 100644 --- a/templates/film.html +++ b/templates/film.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary-semi-transparent padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> <a href="/" class="decoration-none"> <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav class="text"> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5 text-white"></span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <div class="absolute top behind w12"> {{if .Data.Backdrop_path}} @@ -30,7 +57,7 @@ ({{.Data.Release_date.Year}}) {{end}} </div> </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow margin-bottom-2"> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start margin-bottom-2"> <div class="desktop-w3 phone-w12 margin-bottom-2"> {{if .Data.Poster_path}} <img src="https://image.tmdb.org/t/p/w154{{.Data.Poster_path}}" class="block width-154px"> @@ -67,14 +94,48 @@ {{ end }} <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{.Strings.Film.source}}</p> <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.Source}}">TheMovieDB</a></p> + {{if and (.Data.IsOnWantList) (not .State.User.IsEmpty)}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{.Strings.Film.Watchlist}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{.Strings.Film.onWatchlist}}<br/> + {{end}} + {{if .Data.Experiences}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{.Strings.Film.watched}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{len .Data.Experiences}} times<br/> + last time <span title="{{.Data.GetLastExperienceFull .Strings}}">{{.Data.GetLastExperience .Strings .State.User.Timezone}}</p> + {{end}} </div> <div class="desktop-w6 phone-w12 margin-top-10 padding-lr-2 margin-bottom-2"> + <div> + {{if not .State.User.IsEmpty}} + <div class="flex flex-row flex-wrap flex-centre flex-align-centre"> + {{if and (not .Data.IsOnWantList) (not .Data.Experiences)}} + <form action="/users/{{.State.User.Username}}/watchlist/" method="POST" class="margin-tb-_5 margin-lr-1"> + <input type="hidden" name="itemId" value="{{.Data.Id}}" /> + <input type="hidden" name="itemType" value="film" /> + <button type="submit" class="border-text hover-bg-grey padding-tb-_25 cursor-hand text font-2"><span class="padding-lr-_5 material-icon font-2"></span><span class="sans padding-lr-_5">{{.Strings.Film.want_watch}}</span></button> + </form> + {{end}} + <form action="/users/{{.State.User.Username}}/experiences/" method="POST" class="margin-tb-_5 margin-lr-1"> + <input type="hidden" name="itemId" value="{{.Data.Id}}" /> + <input type="hidden" name="itemType" value="film" /> + <button type="submit" class="border-text hover-bg-dark-accent padding-tb-_25 cursor-hand text-black font-2"><span class="padding-lr-_5 material-icon font-2"></span><span class="sans padding-lr-_5">{{.Strings.Film.watched}}</span></button><label for="watched-datetime-check" class="cursor-hand bg-accent inline-block font-2 relative top-m_3 height-3_3 text-black"> + <span class="material-icon"></span> + </label> + <input type="checkbox" id="watched-datetime-check" class="display-none watched-datetime-check" name="isOtherTime" value="true"/> + <div class="watched-box absolute"> + <input type="date" name="watchedDate" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + <input type="time" name="watchedTime" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + </div> + </form> + </div> + {{end}} + </div> <p class="italic serif">{{.Data.Tagline}}</p> <p class="serif justify hyphenate">{{.Data.Overview}}</p> <hr class="material-icon text-grey hr-film" /> {{if .Data.Collection.Parts}} <p class="sans font-2">{{.Strings.Film.in_this_collection}}:</p> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap felx-justify-space flex-align-start"> {{range .Data.Collection.Parts}} <a href="/films/{{.Id}}" class="decoration-none no-outline margin-tb-1 margin-lr-1"> <div class="flex force-width-16"> @@ -88,8 +149,9 @@<div class="margin-lr-1"> <p class="sans">{{.Title}}</p> {{if not .Release_date.IsZero}} - <span class="sans font-_875 text-grey">{{.Release_date.Year}}</span> + <p class="sans font-_875 text-grey">{{.Release_date.Year}}</p> {{end}} + {{if .IsWatched}}<p class="material-icon font-_875 text-grey"></p>{{end}} </div> </div> </a> diff --git a/templates/index.html b/templates/index.html index e41c46509fb22a6d2ba45b762f32442b79658b0f..4482091a8c1345ca945b885c4c417b05736c23de 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> </a> + <div class="margin-lr-1 text"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5"></span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <main class="margin-lr-1"> - <div class="absolute move-centre sans font-3 w12 left border-box flex flex-wrap"> + <div class="absolute move-centre sans font-3 w12 left border-box flex flex-row flex-wrap"> {{.LetAmuse0}} <form action="/items/" method="get" class="phome-max-width-8 border-bottom inline margin-lr-1"> <input type="search" name="q" placeholder="{{.Data}}" class="border-none text bg-none sans font-3 phone-max-width-20"/> @@ -31,7 +58,7 @@ <span class="text-grey material-icon"></span> </a> <!-- here goes translation link --> - <code class="margin-lr-1 font-_875 text-grey">v0.2.0</code> + <code class="margin-lr-1 font-_875 text-grey">v0.3.0</code> </footer> </body> </html> diff --git a/templates/loggedout.html b/templates/loggedout.html new file mode 100644 index 0000000000000000000000000000000000000000..1b0b4c50fb6b2fde5d745d867ae9a7c936b0c09a --- /dev/null +++ b/templates/loggedout.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Loggedout.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Loggedout.mischief}}</div> + <hr/> + <p class="sans">{{.Strings.Loggedout.see_you}}</p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..3b3036aca4e2710bc491186935783ad5f988313e --- /dev/null +++ b/templates/login.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Login.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Login.alohomora}}</div> + <hr/> + {{if .State.Error}} + <div class="sans bg-error centre bold margin-tb-1 padding-tb-1">{{.Strings.Login.error}}</div> + {{ end }} + <form action="/login" method="POST" class="clear-float"> + <label for="username" class="sans block font-1 margin-top-1">{{.Strings.Login.username}}</label> + <input autofocus type="text" required id="username" name="username" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="password" class="sans block font-1 margin-top-1">{{.Strings.Login.Password}}</label> + <input type="password" required id="password" name="password" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="sfa" class="sans block font-1 margin-top-1">{{.Strings.Login.sfa}} <span title="{{.Strings.Login.sfa_description}}" class="material-icon"></span></label> + <input type="text" pattern="[0-9 ]*" inputmode="numeric" autocomplete="off" id="sfa" name="sfa" class="block bg-none border-none border-bottom text font-1_5" /> + <input type="hidden" value="{{.Data}}" name="target" /> + <input type="submit" class="margin-tb-1 right-float bg-accent border-text padding-lr-_5 padding-tb-_25 cursor-hand no-outline" value="{{.Strings.Login.log_in}}"/> + </form> + <p class="sans font-_875">{{.Strings.Login.doesnt_have_account}} <a href="/signup">{{.Strings.Login.sign_up}}</a></p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/person.html b/templates/person.html index cbd436263baf49a0bce8989489f17718839f477e..b2c618114641a6ee40604e4438ab96ef84ebd54e 100644 --- a/templates/person.html +++ b/templates/person.html @@ -9,13 +9,40 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary-semi-transparent paddin-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> <a href="/" class="decoration-none"> <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5"></span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <div class="absolute top behind w12"> <img src="/static/img/person_backdrop.webp" class="w12 cover height-30" /> <!-- Photo by [Pixbay](https://www.pexels.com/@pixabay) from [Pexels](https://www.pexels.com/photo/time-lapse-photo-of-lights-220118/) --> @@ -23,7 +50,7 @@<span class="text-white">{{.Data.Name}}</span> </div> </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow"> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start"> <div class="desktop-w3 phone-w12 margin-bottom-2"> {{if .Data.Profile_path}} <img src="https://image.tmdb.org/t/p/w154{{.Data.Profile_path}}" class="block"> diff --git a/templates/search.html b/templates/search.html index ffa18520694bd727111b0a9153f20099393025c0..14b9caca708c549f0602b2c63b4bd30a8350d3f7 100644 --- a/templates/search.html +++ b/templates/search.html @@ -9,16 +9,43 @@ <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> </head> <body> - <header class="w12 bg-primary padding-bottom-_25"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> - <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text" placeholder="{{.Strings.Global.search}}" value="{{ .Data.T.Query }}" /> </form> + <div class="margin-lr-1 text"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5"></span> + </li> + </ul> + </nav> + {{ end }} + </div> </header> <main class="margin-lr-1"> - <div class="flex flex-flow margin-top-1"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> <div> {{if gt .Data.T.Page 1}} <a href="/items/?q={{.Data.T.Query}}&page={{.Data.T.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> @@ -30,7 +57,7 @@ {{end}} </div> </div> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> {{if or .Data.T.Results .Data.I.Results}} {{range .Data.T.Results}} {{if eq .Media_type "movie"}} @@ -105,7 +132,7 @@<div class="centre"> <a href="/about#sources">{{$.Strings.Search.droids}}</a> </div> - <div class="flex flex-flow"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> <div> {{if gt .Data.T.Page 1}} <a href="/items/?q={{.Data.T.Query}}&page={{.Data.T.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> diff --git a/templates/serie.html b/templates/serie.html deleted file mode 100644 index 4b731e5181e968825dfa7612471f825fb0585521..0000000000000000000000000000000000000000 --- a/templates/serie.html +++ /dev/null @@ -1,176 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{{.Data.Name}} {{if not .Data.First_air_date.IsZero}}({{.Data.GetYears}}){{end}} — a·muse</title> - <link rel="stylesheet" href="/static/style/style.css" /> - <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> - <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> - </head> - <body> - <header class="w12 bg-primary-semi-transparent padding-bottom-_25"> - <a href="/" class="decoration-none"> - <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> - </a> - <form action="/items/" method="get" class="flex inline right-float move-100 margin-lr-1 border-bottom-white"> - <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> - </form> - </header> - <div class="absolute top behind w12"> - {{if .Data.Backdrop_path}} - <img src="https://image.tmdb.org/t/p/original{{.Data.Backdrop_path}}" class="w12 cover height-30" /> - {{else}} - <img src="/static/img/serie_backdrop.webp" class="w12 cover height-30" /><!-- Photo by [Image Catalog](https://www.flickr.com/photos/image-catalog/) from [Flickr](https://www.flickr.com/photos/132795455@N08/21741652275/) --> - {{end}} - <div class="on-desktop relative bottom-4 sans inline-block w12 padding-l-16 font-3 bg-gradient border-box"> - <span class="text-white">{{.Data.Name}}</span> - {{if not .Data.First_air_date.IsZero}} - <span class="text-unimportant font-thin font-2">({{.Data.GetYears}})</span> - {{end}} - </div> - </div> - <main class="margin-top-20 margin-lr-5 flex flex-flow"> - <div class="desktop-w3 phone-w12 margin-bottom-2"> - {{if .Data.Poster_path}} - <img src="https://image.tmdb.org/t/p/w154{{.Data.Poster_path}}" class="block width-154px"> - {{else}} - <img src="/static/img/tv_empty.webp" class="block width-154px" /> - {{end}} - <div class="on-phone"> - <span class="sans font-3">{{.Data.Name}}</span> - {{if not .Data.First_air_date.IsZero}} - <span class="sans text-unimportant font-thin font-2">({{.Data.GetYears}})</span> - {{end}} - </div> - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.rating}}</p> - {{if eq .Data.Vote_count 0}} - <p class="margin-lr-1 sans margin-tb-_5">{{$.Strings.Serie.no_rating}}</p> - {{else}} - <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Vote_average}}/10 ({{$.Strings.Serie.votes}}: {{.Data.Vote_count}})</p> - {{end}} - {{if .Data.Genres}} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.genre}}</p> - <p class="margin-lr-1 sans margin-tb-_5">{{range .Data.Genres}} {{.Name}}<br/> {{end}}</span></p> - {{end}} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.status}}</p> - <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Status}}</p> - {{ if .Data.BasedOn.Title }} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{.Strings.Serie.based_on}}</p> - <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.BasedOn.Uri}}">{{.Data.BasedOn.Title}}</a></p> - {{ end }} - <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.source}}</p> - <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.Source}}">TheMovieDB</a></p> - </div> - <div class="desktop-w6 phone-w12 margin-top-10 padding-lr-2 margin-bottom-2"> - <div class="flex flex-flow"> - <div class="flex-force-50"> - {{if .Data.Last_episode_to_air.Name}} - <span class="sans font-1_5">{{.Strings.Serie.latest_episode}}</span> - <div class="flex margin-tb-1"> - <div class="margin-lr-1"> - {{if .Data.Last_episode_to_air.Still_path}} - <img src="https://image.tmdb.org/t/p/w185{{.Data.Last_episode_to_air.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> - {{else}} - <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> - {{end}} - </div> - <div> - <p class="sans text-grey margin-tb-0">{{.Data.Last_episode_to_air.Episode_code}}</p> - <p class="sans margin-bottom-_5 margin-top-0">{{.Data.Last_episode_to_air.Name}}</p> - <p class="sans margin-tb-_5 text-grey">{{.Data.Last_episode_to_air.Air_date_str}}</p> - <p class="font-_875">{{.Data.Last_episode_to_air.Overview}}</p> - </div> - </div> - {{end}} - </div> - <div class="flex-force-50"> - </div> - </div> - <p class="serif justify hyphenate">{{.Data.Overview}}</p> - <hr class="material-icon text-grey hr-tv" /> - {{range .Data.Seasons}} - <details> - <summary class="cursor-hand sans">{{$.Strings.Serie.season}} {{.Season_number}}</summary> - {{if not .Episodes}} - <p class="sans indent-2 margin-top-1">{{$.Strings.Serie.no_episodes}}</p> - {{end}} - {{range .Episodes}} - <div class="flex margin-tb-1"> - <div class="margin-lr-1"> - {{if .Still_path}} - <img src="https://image.tmdb.org/t/p/w185{{.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> - {{else}} - <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> - {{end}} - </div> - <div> - <p class="sans text-grey margin-tb-0">{{.Episode_code}}</p> - <p class="sans margin-bottom-_5 margin-top-0">{{.Name}}</p> - <p class="sans margin-tb-_5 text-grey">{{.Air_date_str}}</p> - <p class="font-_875">{{.Overview}}</p> - </div> - </div> - {{end}} - </details> - {{end}} - </div> - <div class="desktop-w3 phone-w12 margin-top-10 flex phone-flex-flow margin-bottom-2"> - <details class="min-width-13_5 margin-lr-1 flex-force-40"> - <summary class="cursor-hand"> - <span class="material-icon font-1_5"></span> <span class="sans font-1_5">{{.Strings.Serie.cast}}</span> - </summary> - <div> - {{if not .Data.Credits.Cast}} - <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> - {{end}} - {{range .Data.Credits.Cast}} - <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> - <div class="flex"> - <div> - {{if .Profile_path}} - <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> - {{else}} - <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> - {{end}} - </div> - <div class="margin-lr-1"> - <p class="sans text">{{.Character}}</p> - <p class="sans font-_875">{{.Name}}</p> - </div> - </div> - </a> - {{end}} - </div> - </details> - <details class="min-width-13_5 margin-lr-1 flex-force-40"> - <summary class="cursor-hand"> - <span class="material-icon font-1_5"></span> <span class="sans font-1_5">{{.Strings.Serie.crew}}</span> - </summary> - <div> - {{if not .Data.Credits.Crew}} - <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> - {{end}} - {{range .Data.Credits.Crew}} - <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> - <div class="flex"> - <div> - {{if .Profile_path}} - <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> - {{else}} - <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> - {{end}} - </div> - <div class="margin-lr-1"> - <p class="sans text">{{.Job}}</p> - <p class="sans font-_875">{{.Name}}</p> - </div> - </div> - </a> - {{end}} - </div> - </details> - </div> - </main> - </body> -</html> diff --git a/templates/signedup.html b/templates/signedup.html new file mode 100644 index 0000000000000000000000000000000000000000..28076983e97e5f9fc0bf65dc2d1467254cc4f7b8 --- /dev/null +++ b/templates/signedup.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Signedup.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Signedup.welcome}}</div> + <hr/> + {{if gt (len .Data) 0}} + <p class="sans">{{.Strings.Signedup.sfa_codes}}<br/><br/> + {{range .Data}}<span class="monospace font-1">{{.}}</span><br/>{{end}} + <br/> + {{.Strings.Signedup.copy_and_keep}}<br/> + {{.Strings.Signedup.youll_need}}</p> + {{end}} + <p class="sans">{{.Strings.Signedup.now_you_can}} <a href="/login">{{.Strings.Signedup.log_in}}</a> {{.Strings.Signedup.and_be_amused}}</p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000000000000000000000000000000000000..5c6645e041aecec880c5244f389af7df9e8f7dfd --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Signup.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body class="flex flex-column height-all"> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre flex-content"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + </header> + <main class="margin-lr-1 flex-fill"> + <div class="flex flex-column height-fill flex-centre"> + <div class="w12 flex flex-centre border-box left"> + <div> + <div class="sans italic centre">{{.Strings.Signup.swear}}</div> + <hr/> + {{if .State.Error}} + <div class="sans bg-error centre bold margin-tb-1 padding-tb-1">Error: {{index .Strings.Signup .State.Error.Err.Error}}</div> + {{ end }} + <form action="/signup" method="POST" class="clear-float"> + <label for="username" class="sans block font-1 margin-top-1">{{.Strings.Signup.username}}</label> + <input autofocus type="text" required id="username" name="username" value="{{.Data.Username}}" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="password" class="sans block font-1 margin-top-1">{{.Strings.Signup.password}}</label> + <input type="password" required id="password" name="password" class="block bg-none border-none border-bottom text font-1_5" /> + <label for="password2" class="sans block font-1 margin-top-1">{{.Strings.Signup.confirm_pass}}</label> + <input type="password" required id="password2" name="password2" class="block bg-none border-none border-bottom text font-1_5" /> + <div class="margin-tb-2 border-_5 border-grey padding-tb-_5 padding-lr-_25 border-solid"> + <label for="sfa-enabled" class="sans font-1 margin-top-1">{{.Strings.Signup.enable_sfa}} <span title="{{.Strings.Signup.use_totp_app}}" class="material-icon"></span></label> + <input type="checkbox" id="sfa-enabled" class="" name="sfaEnabled" value="true" {{if .Data.SfaEnabled}}checked{{end}}/> + <div class="" id="sfa-box"> + <input type="hidden" name="sfaSecret" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" value="{{.Data.Secret}}" /> + <div class="margin-tb-_5"> + <img src="{{.Data.Qr}}" class="block margin-auto"/> + </div> + <span class="sans text-unimportant">{{.Data.Secret}}</span> + <label for="sfa" class="sans block font-1 margin-top-1">{{.Strings.Signup.confirm_sfa}}</label> + <input type="text" pattern="[0-9 ]*" inputmode="numeric" autocomplete="off" id="sfa" name="sfa" class="block bg-none border-none border-bottom text font-1_5" /> + </div> + </div> + <input type="submit" class="margin-tb-1 right-float bg-accent border-text padding-lr-_5 padding-tb-_25 cursor-hand no-outline" value="{{.Strings.Signup.sign_up}}"/> + </form> + <p class="sans font-_875">{{.Strings.Signup.already_have_account}} <a href="/login">{{.Strings.Signup.log_in}}</a></p> + </div> + </div> + </div> + </main> + </body> +</html> diff --git a/templates/tvqueue.html b/templates/tvqueue.html new file mode 100644 index 0000000000000000000000000000000000000000..259071a2220c73032386870c4bdb095116ac0bbc --- /dev/null +++ b/templates/tvqueue.html @@ -0,0 +1,118 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Tvqueue.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + <div class="margin-lr-1 text"> + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5"></span> + </li> + </ul> + </nav> + </div> + </header> + <main class="margin-lr-1"> + <!-- search, filter, order --> + <!--<div class="flex flex-row flex-wrap flex-centre flex-align-start margin-top-1"> + <form method="GET" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="filter" class="border-none bg-none sans text" placeholder="{{.Strings.Tvqueue.filter}}" value="{{.Data.Query}}" /> + </form> + </div>--> + {{if .Data.List}} + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> + {{range .Data.List}} + <a href="/tvseries/{{.Id}}" class="decoration-none force-width-18 margin-tb-1 no-outline border-solid border-gradient border-tb-transparent border-_5 padding-tb-_25 padding-lr-_25 border-tb-transparent"> + <div> + <div class="flex"> + <div> + {{if .Cover}} + <img src="https://image.tmdb.org/t/p/w154{{.Cover}}" class="width-154px{{if .IsUnreleased "tvseries"}} bw{{end}}" /> + {{else}} + <img src="/static/img/tv_empty.webp" class="width-154px" /> + {{end}} + <!-- todo progress --> + </div> + <div class="margin-lr-1"> + <p class="sans">{{.Title}}</p> + <p class="sans font-_875 text-grey">{{.GetYears}}</p> + <p class="sans font-_875"> + {{.GetGenres $.Data.Genres}} + </p> + <p class="font-_875 text-grey"> + <!-- todo based on --> + </p> + <!-- todo if last episode < week old then NEW --> + </div> + </div> + <div class="bg-accent height-_1" style="width: {{.CalculateProgress}}%"> + </div> + </div> + </a> + {{end}} + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/tvqueue?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + </div> + {{else if eq .Data.Pages 0}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.empty_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.empty_character}} (<span class="italic sans">{{.Strings.Global.empty_title}}</span>)</p> + </div> + </div> + {{else if gt .Data.Page .Data.Pages}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.too_far_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.too_far_character}} (<span class="italic sans">{{.Strings.Global.too_far_title}}</span>, {{.Strings.Global.too_far_code}} {{.Strings.Global.too_far_episode}})</p> + </div> + </div> + {{end}} + </main> + </body> +</html> diff --git a/templates/tvserie.html b/templates/tvserie.html new file mode 100644 index 0000000000000000000000000000000000000000..292ecb4b1140156bdafff23b2527d4c532ccd8f6 --- /dev/null +++ b/templates/tvserie.html @@ -0,0 +1,309 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Data.Name}} {{if not .Data.First_air_date.IsZero}}({{.Data.GetYears}}){{end}} — a·muse</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre bg-primary"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text-white sans margin-lr-1">a·muse</h1> + </a> + <form action="/items/" method="get" class="flex inline margin-lr-1 border-bottom-white"> + <input type="search" name="q" class="phone-max-width-8 border-none bg-none sans text-white" placeholder="{{.Strings.Global.search}}" /> + </form> + <div class="margin-lr-1 text-white"> + {{ if .State.User.IsEmpty }} + <a href="/login" class="decoration-none sans">{{.Strings.Global.log_in}}</a> + — + <a href="/signup" class="decoration-none sans">{{.Strings.Global.sign_up}}</a> + {{ else }} + <nav class="text"> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5 text-white"></span> + </li> + </ul> + </nav> + {{ end }} + </div> + </header> + <div class="absolute top behind w12"> + {{if .Data.Backdrop_path}} + <img src="https://image.tmdb.org/t/p/original{{.Data.Backdrop_path}}" class="w12 cover height-30" /> + {{else}} + <img src="/static/img/serie_backdrop.webp" class="w12 cover height-30" /><!-- Photo by [Image Catalog](https://www.flickr.com/photos/image-catalog/) from [Flickr](https://www.flickr.com/photos/132795455@N08/21741652275/) --> + {{end}} + <div class="on-desktop relative bottom-4 sans inline-block w12 padding-l-16 font-3 bg-gradient border-box"> + <span class="text-white">{{.Data.Name}}</span> + {{if not .Data.First_air_date.IsZero}} + <span class="text-unimportant font-thin font-2">({{.Data.GetYears}})</span> + {{end}} + </div> + {{if and .State.User .Data.IsOnWantList}} + <div style="width: {{.Data.Progress}}%;" class="relative bottom-4 phone-bottom-_4 height-_25 bg-accent"> + {{end}} + </div> + </div> + <main class="margin-top-20 margin-lr-5 flex flex-row flex-wrap flex-justify-space flex-align-start"> + <div class="desktop-w3 phone-w12 margin-bottom-2"> + {{if .Data.Poster_path}} + <img src="https://image.tmdb.org/t/p/w154{{.Data.Poster_path}}" class="block width-154px"> + {{else}} + <img src="/static/img/tv_empty.webp" class="block width-154px" /> + {{end}} + <div class="on-phone"> + <span class="sans font-3">{{.Data.Name}}</span> + {{if not .Data.First_air_date.IsZero}} + <span class="sans text-unimportant font-thin font-2">({{.Data.GetYears}})</span> + {{end}} + </div> + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.rating}}</p> + {{if eq .Data.Vote_count 0}} + <p class="margin-lr-1 sans margin-tb-_5">{{$.Strings.Serie.no_rating}}</p> + {{else}} + <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Vote_average}}/10 ({{$.Strings.Serie.votes}}: {{.Data.Vote_count}})</p> + {{end}} + {{if .Data.Genres}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.genre}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{range .Data.Genres}} {{.Name}}<br/> {{end}}</span></p> + {{end}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.status}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{.Data.Status}}</p> + {{ if .Data.BasedOn.Title }} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{.Strings.Serie.based_on}}</p> + <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.BasedOn.Uri}}">{{.Data.BasedOn.Title}}</a></p> + {{ end }} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.source}}</p> + <p class="margin-lr-1 sans margin-tb-_5"><a href="{{.Data.Source}}">TheMovieDB</a></p> + {{if and .State.User .Data.IsOnWantList}} + <p class="sans text-grey margin-top-1 margin-bottom-_5"><span class="material-icon"></span> {{$.Strings.Serie.watched}}</p> + <p class="margin-lr-1 sans margin-tb-_5">{{.Strings.Serie.episodes}}: {{.Data.WatchedEpisodes}}/{{.Data.AllEpisodes}}</p> + <p class="margin-lr-1 sans margin-tb-_5">({{.Strings.Serie.skipped}}: {{.Data.SkippedEpisodes}})</p> + {{end}} + </div> + <div class="desktop-w6 phone-w12 margin-top-10 padding-lr-2 margin-bottom-2"> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> + <div class="flex-force-50"> + {{if .Data.Last_episode_to_air.Name}} + <span class="sans font-1_5">{{.Strings.Serie.latest_episode}}</span> + <div class="flex margin-tb-1"> + <div class="margin-lr-1"> + {{if .Data.Last_episode_to_air.Still_path}} + <img src="https://image.tmdb.org/t/p/w185{{.Data.Last_episode_to_air.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> + {{else}} + <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> + {{end}} + </div> + <div> + <p class="sans text-grey margin-tb-0">{{.Data.Last_episode_to_air.Episode_code}}</p> + <p class="sans margin-bottom-_5 margin-top-0">{{.Data.Last_episode_to_air.Name}}</p> + <p class="sans margin-tb-_5 text-grey">{{.Data.Last_episode_to_air.Air_date_str}}</p> + <p class="font-_875 clamp">{{.Data.Last_episode_to_air.Overview}}</p> + </div> + </div> + {{end}} + </div> + <div class="flex-force-50"> + {{if and (not .State.User.IsEmpty) .Data.IsOnWantList}} + {{if .Data.Next_episode_to_watch.Episode_code}} + <span class="sans font-1_5">{{.Strings.Serie.next_episode}}</span> + <div class="flex margin-tb-1"> + <div class="margin-lr-1"> + {{if .Data.Next_episode_to_watch.Still_path}} + <img src="https://image.tmdb.org/t/p/w185{{.Data.Next_episode_to_watch.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> + {{else}} + <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> + {{end}} + </div> + <div> + <div class="flex flex-row flex-justify-space flex-align-start"> + <div> + <p class="sans text-grey margin-tb-0">{{.Data.Next_episode_to_watch.Episode_code}}</p> + <p class="sans margin-bottom-_5 margin-top-0">{{.Data.Next_episode_to_watch.Name}}</p> + <p class="sans margin-tb-_5 text-grey">{{.Data.Next_episode_to_watch.Air_date_str}}</p> + </div> + <div class="flex flex-row"> + {{if and (not $.State.User.IsEmpty) $.Data.IsOnWantList}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Data.Next_episode_to_watch.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <input type="hidden" name="watchedDate" value="0001-01-01"/> + <input type="hidden" name="watchedTime" value="00:00"/> + <input type="checkbox" class="display-none" name="isOtherTime" value="true" checked /> + <button type="submit" class="border-text hover-bg-grey cursor-hand text font-1_5"><span class="material-icon"></span></button> + </form> + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Data.Next_episode_to_watch.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <button type="submit" class="border-text hover-bg-dark-accent cursor-hand text-black font-1_5"><span class="material-icon"></span></button> + </form> + {{end}} + </div> + </div> + <p class="font-_875 clamp">{{.Data.Next_episode_to_watch.Overview}}</p> + </div> + </div> + {{end}} + {{end}} + {{if and (not .State.User.IsEmpty) (not .Data.IsOnWantList)}} + <form action="/users/{{.State.User.Username}}/tvqueue/" method="POST" class="margin-tb-_5 margin-lr-1"> + <input type="hidden" name="itemId" value="{{.Data.Id}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <button type="submit" class="border-text hover-bg-grey padding-tb-_25 cursor-hand text font-2"><span class="padding-lr-_5 material-icon font-2"></span><span class="sans padding-lr-_5">{{.Strings.Serie.want_watch}}</span></button> + </form> + {{end}} + </div> + </div> + <p class="serif justify hyphenate">{{.Data.Overview}}</p> + <hr class="material-icon text-grey hr-tv" /> + {{range .Data.Seasons}} + <details> + <summary class="cursor-hand sans">{{$.Strings.Serie.season}} {{.Season_number}}</summary> + {{if not .Episodes}} + <p class="sans indent-2 margin-top-1">{{$.Strings.Serie.no_episodes}}</p> + {{end}} + {{if eq .Season_number 0}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST" class="flex flex-centre"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/S00A{{len .Episodes}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <input type="hidden" name="watchedDate" value="0001-01-01"/> + <input type="hidden" name="watchedTime" value="00:00"/> + <input type="checkbox" class="display-none" name="isOtherTime" value="true" checked /> + <button type="submit" class="border-text hover-bg-grey padding-tb-_25 cursor-hand text font-1_5"><span class="padding-lr-_5 material-icon font-1_5"></span><span class="sans padding-lr-_5">{{$.Strings.Serie.skip_specials}}</span></button> + </form> + {{end}} + {{range .Episodes}} + <div class="flex margin-tb-1"> + <div class="margin-lr-1"> + {{if .Still_path}} + <img src="https://image.tmdb.org/t/p/w185{{.Still_path}}" decoding="async" loading="lazy" class="width-185px"/> + {{else}} + <img src="/static/img/still_empty.webp" decoding="async" loading="lazy" class="width-185px"/> <!-- Photo by [Mahdi Leader](https://www.pexels.com/@mahdi-leader-415984) from [Pexels](https://www.pexels.com/photo/walpaper-firewatch-1090640) --> + {{end}} + </div> + <div class="w12"> + <div class="flex flex-row flex-justify-space flex-align-start"> + <div> + <p class="sans text-grey margin-tb-0">{{.Episode_code}}</p> + <p class="sans margin-bottom-_5 margin-top-0">{{.Name}}</p> + <p class="sans margin-tb-_5 text-grey">{{.Air_date_str}}</p> + {{if .IsWatched}} + <p class="sans margin-tb-_5 text-grey">{{$.Strings.Serie.watched}} {{.GetLastExperience $.Strings $.State.User.Timezone}}</p> + {{end}} + </div> + <div class="flex flex-row"> + {{if and (not $.State.User.IsEmpty) $.Data.IsOnWantList}} + {{if eq (len .Experiences) 0}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <input type="hidden" name="watchedDate" value="0001-01-01"/> + <input type="hidden" name="watchedTime" value="00:00"/> + <input type="checkbox" class="display-none" name="isOtherTime" value="true" checked /> + <button type="submit" class="border-text hover-bg-grey cursor-hand text font-2"><span class="material-icon font-2"></span></button> + </form> + {{end}} + <form action="/users/{{$.State.User.Username}}/experiences/" method="POST"> + <input type="hidden" name="itemId" value="{{$.Data.Id}}/{{.Episode_code}}" /> + <input type="hidden" name="itemType" value="tvserie" /> + <button type="submit" class="border-text hover-bg-dark-accent cursor-hand text-black font-2"> + {{if .IsWatched}} + <span class="material-icon font-2"></span> + {{else}} + <span class="material-icon font-2"></span> + {{end}} + </button><label for="watched-datetime-check-{{.Episode_code}}" class="cursor-hand bg-accent inline-block font-2 relative top-m_05 height-2_8 text-black"> + <span class="material-icon"></span> + </label> + <input type="checkbox" id="watched-datetime-check-{{.Episode_code}}" class="display-none watched-datetime-check" name="isOtherTime" value="true"/> + <div class="watched-box-flex absolute moveX-m50" id="watched-box-{{.Episode_code}}"> + <input type="date" name="watchedDate" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + <input type="time" name="watchedTime" class="margin-lr-_5 margin-tb-_5 text bg-none border-none" /> + </div> + </form> + {{end}} + </div> + </div> + <p class="font-_875">{{.Overview}}</p> + </div> + </div> + {{end}} + </details> + {{end}} + </div> + <div class="desktop-w3 phone-w12 margin-top-10 flex phone-flex-flow margin-bottom-2"> + <details class="min-width-13_5 margin-lr-1 flex-force-40"> + <summary class="cursor-hand"> + <span class="material-icon font-1_5"></span> <span class="sans font-1_5">{{.Strings.Serie.cast}}</span> + </summary> + <div> + {{if not .Data.Credits.Cast}} + <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> + {{end}} + {{range .Data.Credits.Cast}} + <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> + <div class="flex"> + <div> + {{if .Profile_path}} + <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> + {{else}} + <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> + {{end}} + </div> + <div class="margin-lr-1"> + <p class="sans text">{{.Character}}</p> + <p class="sans font-_875">{{.Name}}</p> + </div> + </div> + </a> + {{end}} + </div> + </details> + <details class="min-width-13_5 margin-lr-1 flex-force-40"> + <summary class="cursor-hand"> + <span class="material-icon font-1_5"></span> <span class="sans font-1_5">{{.Strings.Serie.crew}}</span> + </summary> + <div> + {{if not .Data.Credits.Crew}} + <p class="sans indent-2 margin-top-1">{{.Strings.Serie.empty_payroll}}</p> + {{end}} + {{range .Data.Credits.Crew}} + <a href="/persons/{{.Id}}" class="decoration-none no-outline inline-block margin-tb-1"> + <div class="flex"> + <div> + {{if .Profile_path}} + <img src="https://image.tmdb.org/t/p/w92{{.Profile_path}}" class="width-92px" decoding="async" loading="lazy" /> + {{else}} + <img src="/static/img/person_empty.webp" class="width-92px" decoding="async" loading="lazy" /> + {{end}} + </div> + <div class="margin-lr-1"> + <p class="sans text">{{.Job}}</p> + <p class="sans font-_875">{{.Name}}</p> + </div> + </div> + </a> + {{end}} + </div> + </details> + </div> + </main> + </body> +</html> diff --git a/templates/watchlist.html b/templates/watchlist.html new file mode 100644 index 0000000000000000000000000000000000000000..813a1e609cd5cf0b59e1f01c462338b318d82abb --- /dev/null +++ b/templates/watchlist.html @@ -0,0 +1,115 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{.Strings.Watchlist.title}}</title> + <link rel="stylesheet" href="/static/style/style.css" /> + <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> + <link rel="apple-touch-icon" type="image/svg+xml" href="/static/img/logo.svg"> + </head> + <body> + <header class="w12 padding-bottom-_25 flex flex-row flex-justify-space flex-align-centre"> + <a href="/" class="decoration-none"> + <h1 class="inline valign-mid text sans margin-lr-1">a·muse</h1> + </a> + <div class="margin-lr-1 text"> + <nav> + <label for="hamburger" class="cursor-hand"> + <img src="/users/{{.State.User.Username}}/avatar?size=small" class="border-radius-25 width-1_5"/> + </label> + <input type="checkbox" id="hamburger" class="display-none" /> + <ul class="absolute right top-1 padding-lr-1 padding-tb-_5 bg align-right list-style-none sans"> + <!--<li><a href="/users/{{.State.User.Username}}" class="decoration-none text-accent">{{.Strings.Global.account}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/watchlist" class="decoration-none text-accent">{{.Strings.Global.watchlist}}</a><span class="material-icon padding-lr-_5"></span></li> + <li><a href="/users/{{.State.User.Username}}/tvqueue" class="decoration-none text-accent">{{.Strings.Global.tv_queue}}</a><span class="material-icon padding-lr-_5"></span></li> + <!--<li><a href="/users/{{.State.User.Username}}/readlist" class="decoration-none text-accent">{{.Strings.Global.readlist}}</a><span class="material-icon padding-lr-_5"></span></li>--> + <li><a href="/users/{{.State.User.Username}}/experiences" class="decoration-none text-accent">{{.Strings.Global.experiences}}</a><span class="material-icon padding-lr-_5"></span></li> + <li class="bg-error"> + <form action="/users/{{.State.User.Username}}/sessions/{{.State.User.Session}}" method="POST" class="inline"> + <input type="hidden" value="DELETE" name="method" /> + <input type="submit" value="{{.Strings.Global.log_out}}" class="border-none bg-none font-normal text-accent padding-lr-0 cursor-hand font-1" /> + </form><span class="material-icon padding-lr-_5"></span> + </li> + </ul> + </nav> + </div> + </header> + <main class="margin-lr-1"> + <!-- search, filter, order --> + <div class="flex flex-row flex-wrap flex-centre flex-align-start margin-top-1"> + <form method="GET" class="flex inline margin-lr-1 border-bottom"> + <input type="search" name="filter" class="border-none bg-none sans text" placeholder="{{.Strings.Watchlist.filter}}" value="{{.Data.Query}}" /> + </form> + </div> + {{if .Data.List}} + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start"> + {{range .Data.List}} + <a href="/films/{{.Id}}" class="decoration-none force-width-18 margin-tb-1 no-outline"> + <div class="flex"> + <div> + {{if .Cover}} + <img src="https://image.tmdb.org/t/p/w154{{.Cover}}" class="width-154px{{if .IsUnreleased "film"}} bw{{end}}" /> + {{else}} + <img src="/static/img/poster_empty.webp" class="width-154px" /> + {{end}} + </div> + <div class="margin-lr-1"> + <p class="sans">{{.Title}}</p> + {{if gt .YearStart 1}} + <p class="sans font-_875 text-grey">{{.YearStart}}</p> + {{end}} + <p class="sans font-_875"> + {{.GetGenres $.Data.Genres}} + </p> + <p class="font-_875 text-grey"> + {{if .HasPrevious}}<span class="material-icon" title="Watch previous part first"></span>{{end}} + <!-- todo based on --> + </p> + </div> + </div> + </a> + {{end}} + </div> + <div class="flex flex-row flex-wrap flex-justify-space flex-align-start margin-top-1"> + <div> + {{if gt .Data.Page 1}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.PrevPage}}" class="decoration-none" title="{{.Strings.Search.prev_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + <div> + {{if lt .Data.Page .Data.Pages}} + <a href="/users/{{.State.User.Username}}/watchlist?filter={{.Data.Query}}&page={{.Data.NextPage}}" class="decoration-none" title="{{.Strings.Search.next_link_title}}"><span class="material-icon font-2"></span></a> + {{end}} + </div> + </div> + {{else if and (gt .Data.Page .Data.Pages) (gt .Data.Pages 0)}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.too_far_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.too_far_character}} (<span class="italic sans">{{.Strings.Global.too_far_title}}</span>, {{.Strings.Global.too_far_code}} {{.Strings.Global.too_far_episode}})</p> + </div> + </div> + {{else}} + <div class="font-2 w12 flex flex-centre margin-top-10"> + <div> + <p>{{.Strings.Global.empty_quote}}</p> + <p class="indent-2 sans">—{{.Strings.Global.empty_character}} (<span class="italic sans">{{.Strings.Global.empty_title}}</span>)</p> + </div> + </div> + {{end}} + </main> + </body> +</html> diff --git a/tmdb/common.go b/tmdb/common.go index a30b7ee50ee656d5d2b83a8f9cacb77c90a82eb5..4aaf0db87d91724e66bff8a17fb10e2bc9c58da1 100644 --- a/tmdb/common.go +++ b/tmdb/common.go @@ -1,9 +1,11 @@ package tmdb import ( - "notabug.org/apiote/amuse/network" "notabug.org/apiote/amuse/wikidata" - + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/db" + "notabug.org/apiote/amuse/network" + "notabug.org/apiote/gott" ) @@ -13,6 +15,7 @@ ) type Show interface { AddBasedOn(book wikidata.Book) + SetOnWantList(isOnList bool) } type ShowCastEntry struct { @@ -34,74 +37,38 @@ Cast []ShowCastEntry Crew []ShowCrewEntry } +func GetItemTypeFromShow(show Show) datastructure.ItemType { + if _, ok := show.(*Film); ok { + return datastructure.ItemTypeFilm + } else if _, ok := show.(*TvSerie); ok { + return datastructure.ItemTypeTvserie + } else { + return datastructure.ItemTypeUnkown + } +} + func getCacheEntry(args ...interface{}) (interface{}, error) { - request := args[0].(*network.Request) result := args[1].(*network.Result) uri := result.Request.URL.String() - - rows, err := request.Connection.Query(`select etag, response from cache where uri = ?`, uri) - if err != nil { + entry, err := db.GetCacheEntry(uri) + if err != nil || entry == nil { return gott.Tuple(args), err } - defer rows.Close() - var ( - etag string - data []byte - ) - if rows.Next() { - if err = rows.Scan(&etag, &data); err != nil { - return gott.Tuple(args), err - } - } - request.Etag = etag - result.Body = data + result.Etag = entry.Etag + result.Body = entry.Data return gott.Tuple(args), nil } -func cleanCache(args ...interface{}) (interface{}, error) { - request := args[0].(*network.Request) - rows, err := request.Connection.Query(`select count(*) from cache`) - if err != nil { - return gott.Tuple(args), err - } - defer rows.Close() - rows.Next() - var n int - err = rows.Scan(&n) - if err != nil { - return gott.Tuple(args), err - } - for n > 10000 { - _, err = request.Connection.Exec(`delete from cache where last_hit = (select min(last_hit) from cache)`) - if err != nil { - return gott.Tuple(args), err - } - n-- - } - return gott.Tuple(args), nil +func cleanCache(args ...interface{}) error { + err := db.CleanCache() + return err } -func saveCacheEntry(args ...interface{}) (interface{}, error) { - request := args[0].(*network.Request) +func saveCacheEntry(args ...interface{}) error { result := args[1].(*network.Result) uri := result.Request.URL.String() - if result.Etag != "" { - body := []byte(result.Body) - etag := result.Etag + err := db.SaveCacheEntry(uri, result.Etag, result.Body) + return err +} - _, err := request.Connection.Exec(`insert into cache values(?, ?, null, ?, datetime('now')) - on conflict(uri) do update set etag = ?, response = ?`, uri, etag, body, etag, body) - if err != nil { - return gott.Tuple(args), err - } - } else { - etag := request.Etag - _, err := request.Connection.Exec(`update cache set last_hit = datetime('now') - where uri = ? and etag = ?`, uri, etag) - if err != nil { - return gott.Tuple(args), err - } - } - return gott.Tuple(args), nil -} diff --git a/tmdb/film.go b/tmdb/film.go index c730b426fbe2b0550ff3730f05d1b2c864528db6..c633f2564a9d395d41aeda0556e15a2b7bf1970c 100644 --- a/tmdb/film.go +++ b/tmdb/film.go @@ -1,15 +1,17 @@ package tmdb import ( + "notabug.org/apiote/amuse/i18n" "notabug.org/apiote/amuse/network" "notabug.org/apiote/amuse/utils" "notabug.org/apiote/amuse/wikidata" + "notabug.org/apiote/amuse/datastructure" - "database/sql" "encoding/json" "net/http" "sort" "time" + "fmt" "notabug.org/apiote/gott" ) @@ -23,14 +25,17 @@ Release_date_str string `json:"release_date"` Release_date time.Time Poster_path string `json:"poster_path"` Title string + IsWatched bool } } type Film struct { + Id int Etag string Backdrop_path string `json:"backdrop_path"` Collection Collection `json:"belongs_to_collection"` Genres []struct { + Id int Name string } Original_title string `json:"original_title"` @@ -47,12 +52,57 @@ Vote_average float32 `json:"vote_average"` Source string Credits ShowCredits BasedOn wikidata.Book + Experiences []time.Time + IsOnWantList bool +} + +func (f *Film) GetItemInfo() datastructure.ItemInfo { + part := 0 + for i, p := range f.Collection.Parts { + if p.Title == f.Title { + part = i + break + } + } + genres := "" + for _, genre := range f.Genres { + genres += fmt.Sprintf("%d", genre.Id) + "," + } + + itemInfo := datastructure.ItemInfo{ + Cover: f.Poster_path, + Status: f.Status, + Title: f.Original_title, + YearStart: f.Release_date.Year(), + // todo BasedOn: + Genres: genres, + Runtime: f.Runtime, + Collection: f.Collection.Id, + Part: part, + } + return itemInfo +} + +func (f *Film) GetItemType() datastructure.ItemType { + return datastructure.ItemTypeFilm } func (f *Film) AddBasedOn(book wikidata.Book) { f.BasedOn = book } +func (f *Film) SetOnWantList(isOnList bool) { + f.IsOnWantList = isOnList +} + +func (f Film) GetLastExperience(strings i18n.Translation, timezone string) string { + return i18n.FormatDateNice(f.Experiences[0], strings, timezone) +} + +func (f Film) GetLastExperienceFull(strings i18n.Translation) string { + return i18n.FormatDate(f.Experiences[0], strings.Global["date_format_full"], strings.Global) +} + func createFilmRequest(args ...interface{}) (interface{}, error) { request := args[0].(*network.Request) result := args[1].(*network.Result) @@ -68,12 +118,13 @@ result := args[1].(*network.Result) film := &Film{} err := json.Unmarshal(result.Body, film) film.Source = "https://www.themoviedb.org/movie/" + id + film.Etag = result.Etag result.Result = film return gott.Tuple(args), err } func convertFilmDate(args ...interface{}) (interface{}, error) { - result := args[1].(*network.Result) + result := args[1].(*network.Result) film := result.Result.(*Film) if film.Release_date_str != "" { date, err := time.Parse("2006-01-02", film.Release_date_str) @@ -125,17 +176,17 @@ }) return gott.Tuple(args) } -func GetFilm(id, language string, connection *sql.DB) (*Film, error) { +func GetFilm(id, language string) (*Film, error) { film, err := gott. - NewResult(gott.Tuple{&network.Request{Id: id,Language: language, Connection: connection}, &network.Result{}}). + NewResult(gott.Tuple{&network.Request{Id: id, Language: language}, &network.Result{}}). Bind(createFilmRequest). Bind(getCacheEntry). Map(network.AddHeaders). Bind(network.DoRequest). Bind(network.HandleRequestError). Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). + Tee(cleanCache). + Tee(saveCacheEntry). Bind(unmarshalFilm). Bind(convertFilmDate). Finish() @@ -147,17 +198,17 @@ return film.(gott.Tuple)[1].(*network.Result).Result.(*Film), nil } } -func GetCollection(id, language string, connection *sql.DB) (*Collection, error) { +func GetCollection(id, language string) (*Collection, error) { collection, err := gott. - NewResult(gott.Tuple{&network.Request{Id: id, Language: language, Connection: connection}, &network.Result{}}). + NewResult(gott.Tuple{&network.Request{Id: id, Language: language}, &network.Result{}}). Bind(createCollectionRequest). Bind(getCacheEntry). Map(network.AddHeaders). Bind(network.DoRequest). Bind(network.HandleRequestError). Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). + Tee(cleanCache). + Tee(saveCacheEntry). Bind(unmarshalCollection). Bind(convertCollectionDates). Map(sortCollection). diff --git a/tmdb/genres.go b/tmdb/genres.go new file mode 100644 index 0000000000000000000000000000000000000000..5de0e5f0f20b1f481a36b90c6871b1f59e1c2961 --- /dev/null +++ b/tmdb/genres.go @@ -0,0 +1,76 @@ +package tmdb + +import ( + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/network" + + "encoding/json" + "errors" + "net/http" + + "notabug.org/apiote/gott" +) + +type Genres struct { + Genres []struct { + Id int + Name string + } +} + +func createGenresRequest(args ...interface{}) (interface{}, error) { + request := args[0].(*network.Request) + result := args[1].(*network.Result) + result.Client = &http.Client{} + + var itemType string + if request.Id == datastructure.ItemTypeFilm { + itemType = "movie" + } else if request.Id == datastructure.ItemTypeTvserie { + itemType = "tv" + } else { + return gott.Tuple(args), errors.New("Wrong itemType: " + request.Id) + } + + httpRequest, err := http.NewRequest("GET", "https://api.themoviedb.org/3/genre/"+itemType+"/list?api_key="+API_KEY+"&language="+request.Language, nil) + result.Request = httpRequest + return gott.Tuple(args), err +} + +func unmarshalGenres(args ...interface{}) (interface{}, error) { + result := args[1].(*network.Result) + genres := &Genres{} + err := json.Unmarshal(result.Body, genres) + if err != nil { + return gott.Tuple(args), err + } + + genreMap := map[int]string{} + for _, genre := range genres.Genres { + genreMap[genre.Id] = genre.Name + } + + result.Result = genreMap + return gott.Tuple(args), nil +} + +func GetGenres(language string, itemType datastructure.ItemType) (map[int]string, error) { + genres, err := gott. + NewResult(gott.Tuple{&network.Request{Id: string(itemType), Language: language}, &network.Result{}}). + Bind(createGenresRequest). + Bind(getCacheEntry). + Map(network.AddHeaders). + Bind(network.DoRequest). + Bind(network.HandleRequestError). + Bind(network.ReadResponse). + Tee(cleanCache). + Tee(saveCacheEntry). + Bind(unmarshalGenres). + Finish() + + if err != nil { + return map[int]string{}, err + } else { + return genres.(gott.Tuple)[1].(*network.Result).Result.(map[int]string), nil + } +} diff --git a/tmdb/serie.go b/tmdb/serie.go index 0f2d01331cd99cf865794ab59c62e3ce3570d0bd..dc33ebcef5bf1d6d597a06da72c04877119a7203 100644 --- a/tmdb/serie.go +++ b/tmdb/serie.go @@ -1,15 +1,17 @@ package tmdb import ( + "notabug.org/apiote/amuse/datastructure" + "notabug.org/apiote/amuse/i18n" "notabug.org/apiote/amuse/network" "notabug.org/apiote/amuse/utils" "notabug.org/apiote/amuse/wikidata" - "database/sql" "encoding/json" "fmt" "net/http" "strconv" + "strings" "time" "notabug.org/apiote/gott" @@ -26,6 +28,15 @@ Still_path string `json:"still_path"` Vote_count int `json:"vote_count"` Vote_average float32 `json:"vote_average"` Episode_code string + Experiences []time.Time +} + +func (e Episode) IsWatched() bool { + return len(e.Experiences) > 0 && !e.Experiences[0].IsZero() +} + +func (e Episode) GetLastExperience(strings i18n.Translation, timezone string) string { + return i18n.FormatDateNice(e.Experiences[0], strings, timezone) } type Season struct { @@ -42,35 +53,86 @@ Episode_run_time []int `json:"episode_run_time"` First_air_date_str string `json:"first_air_date"` First_air_date time.Time Genres []struct { + Id int Name string } - Last_air_date_str string `json:"last_air_date"` - Last_air_date time.Time - Last_episode_to_air Episode `json:"last_episode_to_air"` - Name string - Number_of_episodes int `json:"number_of_episodes"` - Original_name string `json:"original_name"` - Overview string - Poster_path string `json:"poster_path"` - Seasons []Season - Status string - Source string - Type string - Vote_count int `json:"vote_count"` - Vote_average float32 `json:"vote_average"` - Credits ShowCredits - BasedOn wikidata.Book + Last_air_date_str string `json:"last_air_date"` + Last_air_date time.Time + Last_episode_to_air Episode `json:"last_episode_to_air"` + Name string + Number_of_episodes int `json:"number_of_episodes"` + Original_name string `json:"original_name"` + Overview string + Poster_path string `json:"poster_path"` + Seasons []Season + Status string + Source string + Type string + Vote_count int `json:"vote_count"` + Vote_average float32 `json:"vote_average"` + Credits ShowCredits + BasedOn wikidata.Book + IsOnWantList bool + Next_episode_to_watch Episode + Progress int + WatchedEpisodes int + AllEpisodes int + SkippedEpisodes int +} + +func (s *TvSerie) GetItemInfo() datastructure.ItemInfo { + genres := "" + for _, genre := range s.Genres { + genres += fmt.Sprintf("%d", genre.Id) + "," + } + + years := strings.Split(s.GetYears(), "–") + var ( + yearStart int64 = 0 + yearEnd int64 = 0 + ) + + yearStart, _ = strconv.ParseInt(years[0], 10, 32) + if len(years) > 1 { + yearEnd, _ = strconv.ParseInt(years[1], 10, 32) + } + + episodes := 0 + for _, season := range s.Seasons { + episodes += len(season.Episodes) + } + + itemInfo := datastructure.ItemInfo{ + Cover: s.Poster_path, + Status: s.Status, + Title: s.Original_name, + YearStart: int(yearStart), + YearEnd: int(yearEnd), + Genres: genres, + Episodes: episodes, + // BasedOn + } + + return itemInfo +} + +func (s *TvSerie) GetItemType() datastructure.ItemType { + return datastructure.ItemTypeTvserie } func (s *TvSerie) AddBasedOn(book wikidata.Book) { s.BasedOn = book +} + +func (s *TvSerie) SetOnWantList(isOnList bool) { + s.IsOnWantList = isOnList } func (s TvSerie) GetYears() string { if s.First_air_date.IsZero() { return "" - } else if s.Status == "Ended" { - if s.Last_air_date.Year() == s.First_air_date.Year() { + } else if s.Status == "Ended" || s.Status == "Canceled" { + if s.Last_air_date.Year() == s.First_air_date.Year() || s.Last_air_date.IsZero() { return strconv.FormatInt(int64(s.First_air_date.Year()), 10) } else { return strconv.FormatInt(int64(s.First_air_date.Year()), 10) + "–" + strconv.FormatInt(int64(s.Last_air_date.Year()), 10) @@ -158,17 +220,17 @@ } return gott.Tuple(args), err } -func GetSerie(id, language string, connection *sql.DB) (*TvSerie, error) { +func GetSerie(id, language string) (*TvSerie, error) { serie, err := gott. - NewResult(gott.Tuple{&network.Request{Id: id, Language: language, Connection: connection}, &network.Result{}}). + NewResult(gott.Tuple{&network.Request{Id: id, Language: language}, &network.Result{}}). Bind(createSerieRequest). Bind(getCacheEntry). Map(network.AddHeaders). Bind(network.DoRequest). Bind(network.HandleRequestError). Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). + Tee(cleanCache). + Tee(saveCacheEntry). Bind(unmarshalSerie). Bind(convertSerieDates). Finish() @@ -180,26 +242,45 @@ return serie.(gott.Tuple)[1].(*network.Result).Result.(*TvSerie), nil } } -func GetSeasons(serie *TvSerie, language string, connection *sql.DB) ([]Season, error) { +func getSeason(serie *TvSerie, language string, seasonNumber int) (Season, error) { + seasonNumberS := strconv.FormatInt(int64(seasonNumber), 10) + s, err := gott. + NewResult(gott.Tuple{&network.Request{Id: serie.Id, Language: language, Subid: seasonNumberS}, &network.Result{}}). + Bind(createSeasonRequest). + Bind(getCacheEntry). + Map(network.AddHeaders). + Bind(network.DoRequest). + Bind(network.HandleRequestError). + Bind(network.ReadResponse). + Tee(cleanCache). + Tee(saveCacheEntry). + Bind(unmarshalSeason). + Bind(convertSeasonDates). + Finish() + season := *s.(gott.Tuple)[1].(*network.Result).Result.(*Season) + return season, err +} + +func GetSeason0(serie *TvSerie, language string) ([]Season, error) { + seasons := []Season{} + var ( + err error + season Season + ) + if serie.Seasons[0].Season_number == 0 { + season, err = getSeason(serie, language, 0) + seasons = append(seasons, season) + } + return seasons, err +} + +func GetSeasons(serie *TvSerie, language string) ([]Season, error) { var err error var seasons []Season - for _, season := range serie.Seasons { - seasonNumber := strconv.FormatInt(int64(season.Season_number), 10) - s, err2 := gott. - NewResult(gott.Tuple{&network.Request{Id: serie.Id, Language: language, Subid: seasonNumber, Connection: connection}, &network.Result{}}). - Bind(createSeasonRequest). - Bind(getCacheEntry). - Map(network.AddHeaders). - Bind(network.DoRequest). - Bind(network.HandleRequestError). - Bind(network.ReadResponse). - Bind(cleanCache). - Bind(saveCacheEntry). - Bind(unmarshalSeason). - Bind(convertSeasonDates). - Finish() + for _, serieSeason := range serie.Seasons { + season, err2 := getSeason(serie, language, serieSeason.Season_number) err = utils.Or(err, err2) - seasons = append(seasons, *s.(gott.Tuple)[1].(*network.Result).Result.(*Season)) + seasons = append(seasons, season) } return seasons, err }