From a7122ff4e1da5b011f6400456790a79b16c17b07 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Fri, 22 May 2020 16:29:12 +0200 Subject: [PATCH] Add /folders API to get directory lists for browsing #260 Signed-off-by: Michael Mayer --- frontend/src/model/account.js | 2 +- frontend/src/model/album.js | 2 +- frontend/src/model/folder.js | 118 ++++++++++++++++ frontend/src/model/label.js | 2 +- frontend/src/model/link.js | 2 +- frontend/src/model/model.js | 2 +- frontend/src/model/photo.js | 14 +- frontend/src/model/rest.js | 2 +- frontend/src/model/settings.js | 2 +- frontend/src/model/thumb.js | 2 +- frontend/src/model/user.js | 2 +- frontend/src/pages/library/import.vue | 16 ++- frontend/src/pages/library/originals.vue | 19 +-- internal/api/account_test.go | 8 +- internal/api/album_test.go | 7 +- internal/api/folder.go | 39 ++++++ internal/api/folder_test.go | 166 +++++++++++++++++++++++ internal/api/import.go | 24 +--- internal/api/import_test.go | 15 +- internal/api/index.go | 19 --- internal/api/index_test.go | 10 -- internal/api/label_test.go | 4 +- internal/api/photo_search_test.go | 4 +- internal/entity/account.go | 2 +- internal/entity/album.go | 4 +- internal/entity/const.go | 12 +- internal/entity/entity.go | 1 + internal/entity/file.go | 4 +- internal/entity/folder.go | 130 ++++++++++++++++++ internal/entity/folder_test.go | 48 +++++++ internal/entity/label.go | 2 +- internal/entity/link.go | 2 +- internal/entity/photo.go | 8 +- internal/entity/photo_album.go | 4 +- internal/form/folder.go | 23 ++++ internal/server/routes.go | 5 +- pkg/txt/convert.go | 30 +++- pkg/txt/convert_test.go | 7 +- 38 files changed, 634 insertions(+), 129 deletions(-) create mode 100644 frontend/src/model/folder.js create mode 100644 internal/api/folder.go create mode 100644 internal/api/folder_test.go create mode 100644 internal/entity/folder.go create mode 100644 internal/entity/folder_test.go create mode 100644 internal/form/folder.go diff --git a/frontend/src/model/account.js b/frontend/src/model/account.js index d14433c14..6025801f6 100644 --- a/frontend/src/model/account.js +++ b/frontend/src/model/account.js @@ -1,7 +1,7 @@ import RestModel from "model/rest"; import Api from "../common/api"; -class Account extends RestModel { +export class Account extends RestModel { getDefaults() { return { ID: 0, diff --git a/frontend/src/model/album.js b/frontend/src/model/album.js index 6dd20fc60..6b22f5767 100644 --- a/frontend/src/model/album.js +++ b/frontend/src/model/album.js @@ -2,7 +2,7 @@ import RestModel from "model/rest"; import Api from "common/api"; import {DateTime} from "luxon"; -class Album extends RestModel { +export class Album extends RestModel { getDefaults() { return { ID: 0, diff --git a/frontend/src/model/folder.js b/frontend/src/model/folder.js new file mode 100644 index 000000000..68a6099f8 --- /dev/null +++ b/frontend/src/model/folder.js @@ -0,0 +1,118 @@ +import RestModel from "model/rest"; +import Api from "common/api"; +import {DateTime} from "luxon"; + +export const FolderRootOriginals = "originals" +export const FolderRootImport = "import" + +export class Folder extends RestModel { + getDefaults() { + return { + Root: "", + Path: "", + PPID: "", + Title: "", + Description: "", + Type: "", + Order: "", + Favorite: false, + Ignore: false, + Hidden: false, + Watch: false, + Links: [], + CreatedAt: "", + UpdatedAt: "", + }; + } + + getEntityName() { + return this.Root + "/" + this.Path; + } + + getId() { + return this.PPID; + } + + thumbnailUrl(type) { + return "/api/v1/folder/" + this.getId() + "/thumbnail/" + type; + } + + getDateString() { + return DateTime.fromISO(this.CreatedAt).toLocaleString(DateTime.DATETIME_MED); + } + + toggleLike() { + this.Favorite = !this.Favorite; + + if (this.Favorite) { + return Api.post(this.getEntityResource() + "/like"); + } else { + return Api.delete(this.getEntityResource() + "/like"); + } + } + + like() { + this.Favorite = true; + return Api.post(this.getEntityResource() + "/like"); + } + + unlike() { + this.Favorite = false; + return Api.delete(this.getEntityResource() + "/like"); + } + + static findAll(path) { + return this.search(path, {recursive: true}) + } + + static search(path, params) { + const options = { + params: params, + }; + + if (!path || path[0] !== "/") { + path = "/" + path + } + + return Api.get(this.getCollectionResource() + path, options).then((response) => { + let count = response.data.length; + let limit = 0; + let offset = 0; + + if (response.headers) { + if (response.headers["x-count"]) { + count = parseInt(response.headers["x-count"]); + } + + if (response.headers["x-limit"]) { + limit = parseInt(response.headers["x-limit"]); + } + + if (response.headers["x-offset"]) { + offset = parseInt(response.headers["x-offset"]); + } + } + + response.models = []; + response.count = count; + response.limit = limit; + response.offset = offset; + + for (let i = 0; i < response.data.length; i++) { + response.models.push(new this(response.data[i])); + } + + return Promise.resolve(response); + }); + } + + static getCollectionResource() { + return "folders"; + } + + static getModelName() { + return "Folder"; + } +} + +export default Folder; diff --git a/frontend/src/model/label.js b/frontend/src/model/label.js index da1519280..28dad149e 100644 --- a/frontend/src/model/label.js +++ b/frontend/src/model/label.js @@ -2,7 +2,7 @@ import RestModel from "model/rest"; import Api from "common/api"; import {DateTime} from "luxon"; -class Label extends RestModel { +export class Label extends RestModel { getDefaults() { return { ID: 0, diff --git a/frontend/src/model/link.js b/frontend/src/model/link.js index 76bfc7f54..6d40d8f2c 100644 --- a/frontend/src/model/link.js +++ b/frontend/src/model/link.js @@ -1,6 +1,6 @@ import RestModel from "model/rest"; -class Link extends RestModel { +export class Link extends RestModel { getDefaults() { return { LinkToken: "", diff --git a/frontend/src/model/model.js b/frontend/src/model/model.js index a120626b4..8c8110fcd 100644 --- a/frontend/src/model/model.js +++ b/frontend/src/model/model.js @@ -1,4 +1,4 @@ -class Model { +export class Model { constructor(values) { this.__originalValues = {}; diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index 3d42813ef..93e8208ec 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -3,14 +3,14 @@ import Api from "common/api"; import {DateTime} from "luxon"; import Util from "common/util"; -const SrcManual = "manual"; -const CodecAvc1 = "avc1"; -const TypeMP4 = "mp4"; -const TypeJpeg = "jpg"; -const YearUnknown = -1; -const MonthUnknown = -1; +export const SrcManual = "manual"; +export const CodecAvc1 = "avc1"; +export const TypeMP4 = "mp4"; +export const TypeJpeg = "jpg"; +export const YearUnknown = -1; +export const MonthUnknown = -1; -class Photo extends RestModel { +export class Photo extends RestModel { getDefaults() { return { ID: 0, diff --git a/frontend/src/model/rest.js b/frontend/src/model/rest.js index d72e0801b..36164fc1f 100644 --- a/frontend/src/model/rest.js +++ b/frontend/src/model/rest.js @@ -2,7 +2,7 @@ import Api from "common/api"; import Form from "common/form"; import Model from "./model"; -class Rest extends Model { +export class Rest extends Model { getId() { return this.ID; } diff --git a/frontend/src/model/settings.js b/frontend/src/model/settings.js index 7f881d1e7..3954935d9 100644 --- a/frontend/src/model/settings.js +++ b/frontend/src/model/settings.js @@ -1,7 +1,7 @@ import Api from "common/api"; import Model from "./model"; -class Settings extends Model { +export class Settings extends Model { changed(key) { return (this[key] !== this.__originalValues[key]); } diff --git a/frontend/src/model/thumb.js b/frontend/src/model/thumb.js index 3a14c30d5..896be0944 100644 --- a/frontend/src/model/thumb.js +++ b/frontend/src/model/thumb.js @@ -3,7 +3,7 @@ import Api from "../common/api"; const thumbs = window.clientConfig.thumbnails; -class Thumb extends Model { +export class Thumb extends Model { getDefaults() { return { uuid: "", diff --git a/frontend/src/model/user.js b/frontend/src/model/user.js index d7891eee4..eb64fbcbe 100644 --- a/frontend/src/model/user.js +++ b/frontend/src/model/user.js @@ -2,7 +2,7 @@ import RestModel from "model/rest"; import Form from "common/form"; import Api from "common/api"; -class User extends RestModel { +export class User extends RestModel { getDefaults() { return { ID: 0, diff --git a/frontend/src/pages/library/import.vue b/frontend/src/pages/library/import.vue index d8a851e91..6fba4e8c8 100644 --- a/frontend/src/pages/library/import.vue +++ b/frontend/src/pages/library/import.vue @@ -98,11 +98,12 @@ import Event from "pubsub-js"; import Settings from "model/settings"; import Util from "../../common/util"; + import {Folder, FolderRootImport} from "model/folder"; export default { name: 'p-tab-import', data() { - const root = {"name": "All files in import folder", "path": "/"} + const root = {"path": "/", "name": this.$gettext("All files in import folder")} return { settings: new Settings(this.$config.settings()), @@ -195,22 +196,23 @@ created() { this.subscriptionId = Event.subscribe('import', this.handleEvent); this.loading = true; - Api.get('import').then((r) => { - const subDirs = r.data.dirs ? r.data.dirs : []; + + Folder.findAll(FolderRootImport).then((r) => { + const folders = r.models ? r.models : []; const currentPath = this.settings.import.path; let found = currentPath === this.root.path; this.dirs = [this.root]; - for (let i = 0; i < subDirs.length; i++) { - if(currentPath === subDirs[i]) { + for (let i = 0; i < folders.length; i++) { + if (currentPath === folders[i].Path) { found = true; } - this.dirs.push({name: Util.truncate(subDirs[i], 100, "..."), path: subDirs[i]}); + this.dirs.push({path: folders[i].Path, name: "/" + Util.truncate(folders[i].Path, 100, "...")}); } - if(!found) { + if (!found) { this.settings.import.path = this.root.path; } }).finally(() => this.loading = false); diff --git a/frontend/src/pages/library/originals.vue b/frontend/src/pages/library/originals.vue index 2be0162ff..53f306645 100644 --- a/frontend/src/pages/library/originals.vue +++ b/frontend/src/pages/library/originals.vue @@ -75,7 +75,8 @@ outline v-if="config.count.hidden > 0" > - The index currently contains {{ config.count.hidden }} hidden files. Their format may not be supported, + The index currently contains {{ config.count.hidden }} hidden files. Their format may not be + supported, they haven't been converted to JPEG yet or there are duplicates. @@ -90,11 +91,12 @@ import Event from "pubsub-js"; import Settings from "model/settings"; import Util from "common/util"; + import { Folder, FolderRootOriginals } from "model/folder"; export default { name: 'p-tab-index', data() { - const root = {"name": "All originals", "path": "/"} + const root = {"path": "/", "name": this.$gettext("All originals")} return { settings: new Settings(this.$config.settings()), @@ -203,22 +205,23 @@ created() { this.subscriptionId = Event.subscribe('index', this.handleEvent); this.loading = true; - Api.get('index').then((r) => { - const subDirs = r.data.dirs ? r.data.dirs : []; + + Folder.findAll(FolderRootOriginals).then((r) => { + const folders = r.models ? r.models : []; const currentPath = this.settings.index.path; let found = currentPath === this.root.path; this.dirs = [this.root]; - for (let i = 0; i < subDirs.length; i++) { - if(currentPath === subDirs[i]) { + for (let i = 0; i < folders.length; i++) { + if (currentPath === folders[i].Path) { found = true; } - this.dirs.push({name: Util.truncate(subDirs[i], 100, "..."), path: subDirs[i]}); + this.dirs.push({path: folders[i].Path, name: "/" + Util.truncate(folders[i].Path, 100, "...")}); } - if(!found) { + if (!found) { this.settings.index.path = this.root.path; } }).finally(() => this.loading = false); diff --git a/internal/api/account_test.go b/internal/api/account_test.go index 90ee92853..dda423524 100644 --- a/internal/api/account_test.go +++ b/internal/api/account_test.go @@ -14,8 +14,8 @@ func TestGetAccounts(t *testing.T) { GetAccounts(router, conf) r := PerformRequest(app, "GET", "/api/v1/accounts?count=10") val := gjson.Get(r.Body.String(), "#(AccName=\"Test Account\").AccURL") - len := gjson.Get(r.Body.String(), "#") - assert.LessOrEqual(t, int64(1), len.Int()) + count := gjson.Get(r.Body.String(), "#") + assert.LessOrEqual(t, int64(1), count.Int()) assert.Equal(t, "http://webdav-dummy/", val.String()) assert.Equal(t, http.StatusOK, r.Code) }) @@ -51,8 +51,8 @@ func TestGetAccountDirs(t *testing.T) { app, router, conf := NewApiTest() GetAccountDirs(router, conf) r := PerformRequest(app, "GET", "/api/v1/accounts/1000000/dirs") - len := gjson.Get(r.Body.String(), "#") - assert.LessOrEqual(t, int64(2), len.Int()) + count := gjson.Get(r.Body.String(), "#") + assert.LessOrEqual(t, int64(2), count.Int()) val := gjson.Get(r.Body.String(), "0.abs") assert.Equal(t, "/Photos", val.String()) assert.Equal(t, http.StatusOK, r.Code) diff --git a/internal/api/album_test.go b/internal/api/album_test.go index 8e41e9cd7..185be6b5f 100644 --- a/internal/api/album_test.go +++ b/internal/api/album_test.go @@ -1,10 +1,11 @@ package api import ( - "github.com/tidwall/gjson" "net/http" "testing" + "github.com/tidwall/gjson" + "github.com/stretchr/testify/assert" ) @@ -13,8 +14,8 @@ func TestGetAlbums(t *testing.T) { app, router, conf := NewApiTest() GetAlbums(router, conf) r := PerformRequest(app, "GET", "/api/v1/albums?count=10") - len := gjson.Get(r.Body.String(), "#") - assert.LessOrEqual(t, int64(3), len.Int()) + count := gjson.Get(r.Body.String(), "#") + assert.LessOrEqual(t, int64(3), count.Int()) assert.Equal(t, http.StatusOK, r.Code) }) t.Run("invalid request", func(t *testing.T) { diff --git a/internal/api/folder.go b/internal/api/folder.go new file mode 100644 index 000000000..3ed51ab01 --- /dev/null +++ b/internal/api/folder.go @@ -0,0 +1,39 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" +) + +// GetFolders is a reusable request handler for directory listings (GET /api/v1/folders/*). +func GetFolders(router *gin.RouterGroup, conf *config.Config, root, pathName string) { + router.GET("/folders/" + root, func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + recursive := c.Query("recursive") != "" + + folders, err := entity.Folders(root, pathName, recursive) + + if err != nil { + log.Errorf("folder: %s", err) + } + + c.JSON(http.StatusOK, folders) + }) +} + +// GET /api/v1/folders/originals +func GetFoldersOriginals(router *gin.RouterGroup, conf *config.Config) { + GetFolders(router, conf, entity.FolderRootOriginals, conf.OriginalsPath()) +} + +// GET /api/v1/folders/import +func GetFoldersImport(router *gin.RouterGroup, conf *config.Config) { + GetFolders(router, conf, entity.FolderRootImport, conf.ImportPath()) +} diff --git a/internal/api/folder_test.go b/internal/api/folder_test.go new file mode 100644 index 000000000..0c5a17038 --- /dev/null +++ b/internal/api/folder_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/stretchr/testify/assert" +) + +func TestGetFoldersOriginals(t *testing.T) { + t.Run("flat", func(t *testing.T) { + app, router, conf := NewApiTest() + expected, err := fs.Dirs(conf.OriginalsPath(), false) + + if err != nil { + t.Fatal(err) + } + + GetFoldersOriginals(router, conf) + r := PerformRequest(app, "GET", "/api/v1/folders/originals") + + var folders []entity.Folder + err = json.Unmarshal(r.Body.Bytes(), &folders) + + if err != nil { + t.Fatal(err) + } + + if len(folders) != len(expected){ + t.Fatalf("response contains %d folders", len(folders)) + } + + if len(folders) == 0 { + // There are no existing folders, that's ok. + return + } + + for _, folder := range folders { + assert.Equal(t, "", folder.FolderDescription) + assert.Equal(t, entity.TypeDefault, folder.FolderType) + assert.Equal(t, entity.SortOrderName, folder.FolderOrder) + assert.Equal(t, entity.FolderRootOriginals, folder.Root) + assert.Equal(t, "", folder.FolderUUID) + assert.Equal(t, false, folder.FolderFavorite) + assert.Equal(t, false, folder.FolderHidden) + assert.Equal(t, false, folder.FolderIgnore) + assert.Equal(t, false, folder.FolderWatch) + } + + // t.Logf("ORIGINALS: %+v", folders) + }) + t.Run("recursive", func(t *testing.T) { + app, router, conf := NewApiTest() + expected, err := fs.Dirs(conf.OriginalsPath(), true) + + if err != nil { + t.Fatal(err) + } + GetFoldersOriginals(router, conf) + r := PerformRequest(app, "GET", "/api/v1/folders/originals?recursive=true") + var folders []entity.Folder + err = json.Unmarshal(r.Body.Bytes(), &folders) + + if err != nil { + t.Fatal(err) + } + + if len(folders) != len(expected){ + t.Fatalf("response contains %d folders", len(folders)) + } + + for _, folder := range folders { + assert.Equal(t, "", folder.FolderDescription) + assert.Equal(t, entity.TypeDefault, folder.FolderType) + assert.Equal(t, entity.SortOrderName, folder.FolderOrder) + assert.Equal(t, entity.FolderRootOriginals, folder.Root) + assert.Equal(t, "", folder.FolderUUID) + assert.Equal(t, false, folder.FolderFavorite) + assert.Equal(t, false, folder.FolderHidden) + assert.Equal(t, false, folder.FolderIgnore) + assert.Equal(t, false, folder.FolderWatch) + } + + // t.Logf("ORIGINALS RECURSIVE: %+v", folders) + }) +} + +func TestGetFoldersImport(t *testing.T) { + t.Run("flat", func(t *testing.T) { + app, router, conf := NewApiTest() + expected, err := fs.Dirs(conf.ImportPath(), false) + + if err != nil { + t.Fatal(err) + } + + GetFoldersImport(router, conf) + r := PerformRequest(app, "GET", "/api/v1/folders/import") + var folders []entity.Folder + err = json.Unmarshal(r.Body.Bytes(), &folders) + + if err != nil { + t.Fatal(err) + } + + if len(folders) != len(expected){ + t.Fatalf("response contains %d folders", len(folders)) + } + + // t.Logf("IMPORT FOLDERS: %+v", folders) + + if len(folders) == 0 { + // There are no existing folders, that's ok. + return + } + + for _, folder := range folders { + assert.Equal(t, "", folder.FolderDescription) + assert.Equal(t, entity.TypeDefault, folder.FolderType) + assert.Equal(t, entity.SortOrderName, folder.FolderOrder) + assert.Equal(t, entity.FolderRootImport, folder.Root) + assert.Equal(t, "", folder.FolderUUID) + assert.Equal(t, false, folder.FolderFavorite) + assert.Equal(t, false, folder.FolderHidden) + assert.Equal(t, false, folder.FolderIgnore) + assert.Equal(t, false, folder.FolderWatch) + } + + }) + t.Run("recursive", func(t *testing.T) { + app, router, conf := NewApiTest() + expected, err := fs.Dirs(conf.ImportPath(), true) + + if err != nil { + t.Fatal(err) + } + GetFoldersImport(router, conf) + r := PerformRequest(app, "GET", "/api/v1/folders/import?recursive=true") + var folders []entity.Folder + err = json.Unmarshal(r.Body.Bytes(), &folders) + + if err != nil { + t.Fatal(err) + } + + if len(folders) != len(expected){ + t.Fatalf("response contains %d folders", len(folders)) + } + + for _, folder := range folders { + assert.Equal(t, "", folder.FolderDescription) + assert.Equal(t, entity.TypeDefault, folder.FolderType) + assert.Equal(t, entity.SortOrderName, folder.FolderOrder) + assert.Equal(t, entity.FolderRootImport, folder.Root) + assert.Equal(t, "", folder.FolderUUID) + assert.Equal(t, false, folder.FolderFavorite) + assert.Equal(t, false, folder.FolderHidden) + assert.Equal(t, false, folder.FolderIgnore) + assert.Equal(t, false, folder.FolderWatch) + } + + // t.Logf("IMPORT FOLDERS RECURSIVE: %+v", folders) + }) +} diff --git a/internal/api/import.go b/internal/api/import.go index 737e2c21b..ddcb1f6d9 100644 --- a/internal/api/import.go +++ b/internal/api/import.go @@ -18,24 +18,6 @@ import ( "github.com/photoprism/photoprism/pkg/txt" ) -// GET /api/v1/import -func GetImportOptions(router *gin.RouterGroup, conf *config.Config) { - router.GET("/import", func(c *gin.Context) { - if Unauthorized(c, conf) { - c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) - return - } - - dirs, err := fs.Dirs(conf.ImportPath(), true) - - if err != nil { - log.Errorf("import: %s", err) - } - - c.JSON(http.StatusOK, gin.H{"dirs": dirs}) - }) -} - // POST /api/v1/import* func StartImport(router *gin.RouterGroup, conf *config.Config) { router.POST("/import/*path", func(c *gin.Context) { @@ -63,12 +45,10 @@ func StartImport(router *gin.RouterGroup, conf *config.Config) { if subPath = c.Param("path"); subPath != "" && subPath != "/" { subPath = strings.Replace(subPath, ".", "", -1) - log.Debugf("import sub path from url: %s", subPath) - path = path + subPath + path = filepath.Join(path, subPath) } else if f.Path != "" { subPath = strings.Replace(f.Path, ".", "", -1) - log.Debugf("import sub path from request: %s", subPath) - path = path + subPath + path = filepath.Join(path, subPath) } path = filepath.Clean(path) diff --git a/internal/api/import_test.go b/internal/api/import_test.go index a98b833d4..7d9700ff7 100644 --- a/internal/api/import_test.go +++ b/internal/api/import_test.go @@ -1,21 +1,12 @@ package api import ( - "github.com/stretchr/testify/assert" - "github.com/tidwall/gjson" "net/http" "testing" -) -func TestGetImportOptions(t *testing.T) { - t.Run("successful request", func(t *testing.T) { - app, router, conf := NewApiTest() - GetImportOptions(router, conf) - r := PerformRequest(app, "GET", "/api/v1/import") - assert.True(t, gjson.Get(r.Body.String(), "dirs").IsArray()) - assert.Equal(t, http.StatusOK, r.Code) - }) -} + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) func TestCancelImport(t *testing.T) { t.Run("successful request", func(t *testing.T) { diff --git a/internal/api/index.go b/internal/api/index.go index 1213f58d6..db8a26c7d 100644 --- a/internal/api/index.go +++ b/internal/api/index.go @@ -12,28 +12,9 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" ) -// GET /api/v1/index -func GetIndexingOptions(router *gin.RouterGroup, conf *config.Config) { - router.GET("/index", func(c *gin.Context) { - if Unauthorized(c, conf) { - c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) - return - } - - dirs, err := fs.Dirs(conf.OriginalsPath(), true) - - if err != nil { - log.Errorf("index: %s", err) - } - - c.JSON(http.StatusOK, gin.H{"dirs": dirs}) - }) -} - // POST /api/v1/index func StartIndexing(router *gin.RouterGroup, conf *config.Config) { router.POST("/index", func(c *gin.Context) { diff --git a/internal/api/index_test.go b/internal/api/index_test.go index ee2d88271..9611c86ae 100644 --- a/internal/api/index_test.go +++ b/internal/api/index_test.go @@ -7,16 +7,6 @@ import ( "testing" ) -func TestGetIndexingOptions(t *testing.T) { - t.Run("successful request", func(t *testing.T) { - app, router, conf := NewApiTest() - GetIndexingOptions(router, conf) - r := PerformRequest(app, "GET", "/api/v1/index") - assert.True(t, gjson.Get(r.Body.String(), "dirs").IsArray()) - assert.Equal(t, http.StatusOK, r.Code) - }) -} - func TestCancelIndex(t *testing.T) { t.Run("successful request", func(t *testing.T) { app, router, conf := NewApiTest() diff --git a/internal/api/label_test.go b/internal/api/label_test.go index a284ff9f6..5231bc1ef 100644 --- a/internal/api/label_test.go +++ b/internal/api/label_test.go @@ -13,8 +13,8 @@ func TestGetLabels(t *testing.T) { app, router, ctx := NewApiTest() GetLabels(router, ctx) r := PerformRequest(app, "GET", "/api/v1/labels?count=15") - len := gjson.Get(r.Body.String(), "#") - assert.LessOrEqual(t, int64(4), len.Int()) + count := gjson.Get(r.Body.String(), "#") + assert.LessOrEqual(t, int64(4), count.Int()) assert.Equal(t, http.StatusOK, r.Code) }) t.Run("invalid request", func(t *testing.T) { diff --git a/internal/api/photo_search_test.go b/internal/api/photo_search_test.go index 27016971b..d2dfa5254 100644 --- a/internal/api/photo_search_test.go +++ b/internal/api/photo_search_test.go @@ -14,8 +14,8 @@ func TestGetPhotos(t *testing.T) { GetPhotos(router, ctx) r := PerformRequest(app, "GET", "/api/v1/photos?count=10") - len := gjson.Get(r.Body.String(), "#") - assert.LessOrEqual(t, int64(2), len.Int()) + count := gjson.Get(r.Body.String(), "#") + assert.LessOrEqual(t, int64(2), count.Int()) assert.Equal(t, http.StatusOK, r.Code) }) diff --git a/internal/entity/account.go b/internal/entity/account.go index ae45f6bcb..8da4adfcb 100644 --- a/internal/entity/account.go +++ b/internal/entity/account.go @@ -64,7 +64,7 @@ func CreateAccount(form form.Account) (model *Account, err error) { return model, err } -// Save updates the entity using form data and stores it in the database. +// Saves the entity using form data and stores it in the database. func (m *Account) Save(form form.Account) error { db := Db() diff --git a/internal/entity/album.go b/internal/entity/album.go index 1003c2fec..7e830b7c9 100644 --- a/internal/entity/album.go +++ b/internal/entity/album.go @@ -30,7 +30,7 @@ type Album struct { DeletedAt *time.Time `sql:"index"` } -// BeforeCreate computes a random UUID when a new album is created in database +// BeforeCreate creates a random UUID if needed before inserting a new row to the database. func (m *Album) BeforeCreate(scope *gorm.Scope) error { if rnd.IsPPID(m.AlbumUUID, 'a') { return nil @@ -72,7 +72,7 @@ func (m *Album) SetName(name string) { } } -// Save updates the entity using form data and stores it in the database. +// Saves the entity using form data and stores it in the database. func (m *Album) Save(f form.Album) error { if err := deepcopier.Copy(m).From(f); err != nil { return err diff --git a/internal/entity/const.go b/internal/entity/const.go index 73e915c7c..f953bc701 100644 --- a/internal/entity/const.go +++ b/internal/entity/const.go @@ -19,15 +19,17 @@ const ( SortOrderOldest = "oldest" SortOrderImported = "imported" SortOrderSimilar = "similar" + SortOrderName = "name" // unknown values YearUnknown = -1 MonthUnknown = -1 TitleUnknown = "Unknown" - TypeImage = "image" - TypeLive = "live" - TypeVideo = "video" - TypeRaw = "raw" - TypeText = "text" + TypeDefault = "" + TypeImage = "image" + TypeLive = "live" + TypeVideo = "video" + TypeRaw = "raw" + TypeText = "text" ) diff --git a/internal/entity/entity.go b/internal/entity/entity.go index 5ee3cbbc3..52ea21ae6 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -31,6 +31,7 @@ type Types map[string]interface{} var Entities = Types{ "errors": &Error{}, "accounts": &Account{}, + "folders": &Folder{}, "files": &File{}, "files_share": &FileShare{}, "files_sync": &FileSync{}, diff --git a/internal/entity/file.go b/internal/entity/file.go index 5cbee53f0..2b919261b 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -75,7 +75,7 @@ func FirstFileByHash(fileHash string) (File, error) { return file, q.Error } -// BeforeCreate computes a random UUID when a new file is created in database +// BeforeCreate creates a random UUID if needed before inserting a new row to the database. func (m *File) BeforeCreate(scope *gorm.Scope) error { if rnd.IsPPID(m.FileUUID, 'f') { return nil @@ -141,7 +141,7 @@ func (m *File) AllFilesMissing() bool { return count == 0 } -// Save stored the file in the database using the default connection. +// Saves the file in the database. func (m *File) Save() error { if m.PhotoID == 0 { return fmt.Errorf("file: photo id is empty (%s)", m.FileUUID) diff --git a/internal/entity/folder.go b/internal/entity/folder.go new file mode 100644 index 000000000..09ba795ae --- /dev/null +++ b/internal/entity/folder.go @@ -0,0 +1,130 @@ +package entity + +import ( + "os" + "path" + "strings" + "time" + + "github.com/jinzhu/gorm" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/txt" + "github.com/ulule/deepcopier" +) + +const ( + FolderRootUnknown = "" + FolderRootOriginals = "originals" + FolderRootImport = "import" +) + +// Folder represents a file system directory. +type Folder struct { + Root string `gorm:"type:varbinary(255);unique_index:idx_folders_root_path;" json:"Root" yaml:"Root"` + Path string `gorm:"type:varbinary(1024);unique_index:idx_folders_root_path;" json:"Path" yaml:"Path"` + FolderUUID string `gorm:"type:varbinary(36);primary_key;" json:"PPID,omitempty" yaml:"PPID,omitempty"` + FolderTitle string `gorm:"type:varchar(255);" json:"Title" yaml:"Title,omitempty"` + FolderDescription string `gorm:"type:text;" json:"Description,omitempty" yaml:"Description,omitempty"` + FolderType string `gorm:"type:varbinary(16);" json:"Type" yaml:"Type,omitempty"` + FolderOrder string `gorm:"type:varbinary(32);" json:"Order" yaml:"Order,omitempty"` + FolderFavorite bool `json:"Favorite" yaml:"Favorite"` + FolderIgnore bool `json:"Ignore" yaml:"Ignore"` + FolderHidden bool `json:"Hidden" yaml:"Hidden"` + FolderWatch bool `json:"Watch" yaml:"Watch"` + Links []Link `gorm:"foreignkey:ShareUUID;association_foreignkey:FolderUUID" json:"-" yaml:"-"` + CreatedAt time.Time `json:"-" yaml:"-"` + UpdatedAt time.Time `json:"-" yaml:"-"` + ModifiedAt *time.Time `json:"ModifiedAt,omitempty" yaml:"-"` + DeletedAt *time.Time `sql:"index" json:"-"` +} + +// BeforeCreate creates a random UUID if needed before inserting a new row to the database. +func (m *Folder) BeforeCreate(scope *gorm.Scope) error { + if rnd.IsPPID(m.FolderUUID, 'd') { + return nil + } + + return scope.SetColumn("FolderUUID", rnd.PPID('d')) +} + +// NewFolder creates a new file system directory entity. +func NewFolder(root, pathName string, modTime *time.Time) Folder { + now := time.Now().UTC() + + pathName = strings.Trim(pathName, string(os.PathSeparator)) + + if pathName == "" { + pathName = "/" + } + + result := Folder{ + Root: root, + Path: pathName, + FolderType: TypeDefault, + FolderOrder: SortOrderName, + ModifiedAt: modTime, + CreatedAt: now, + UpdatedAt: now, + } + + result.SetTitleFromPath() + + return result +} + +// SetTitleFromPath updates the title based on the path name (e.g. when displaying it as an album). +func (m *Folder) SetTitleFromPath() { + s := m.Path + s = strings.TrimSpace(s) + + if s == "" || s == "/" { + s = m.Root + } else { + s = path.Base(s) + } + + if len(m.Path) >= 6 && txt.IsUInt(s) { + if date := txt.Time(m.Path); !date.IsZero() { + if date.Day() > 1 { + m.FolderTitle = date.Format("January 2, 2006") + } else { + m.FolderTitle = date.Format("January 2006") + } + return + } + } + + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "-", " ") + s = strings.Title(s) + + m.FolderTitle = txt.Clip(s, txt.ClipDefault) +} + +// Saves the entity using form data and stores it in the database. +func (m *Folder) Save(f form.Folder) error { + if err := deepcopier.Copy(m).From(f); err != nil { + return err + } + + return Db().Save(m).Error +} + +// Returns a slice of folders in a given directory incl sub directories in recursive mode. +func Folders(root, dir string, recursive bool) ([]Folder, error) { + dirs, err := fs.Dirs(dir, recursive) + + if err != nil { + return []Folder{}, err + } + + folders := make([]Folder, len(dirs)) + + for i, p := range dirs { + folders[i] = NewFolder(root, p, nil) + } + + return folders, nil +} diff --git a/internal/entity/folder_test.go b/internal/entity/folder_test.go new file mode 100644 index 000000000..35ce8f0fb --- /dev/null +++ b/internal/entity/folder_test.go @@ -0,0 +1,48 @@ +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewFolder(t *testing.T) { + t.Run("2020/05", func(t *testing.T) { + folder := NewFolder(FolderRootOriginals, "2020/05", nil) + assert.Equal(t, FolderRootOriginals, folder.Root) + assert.Equal(t, "2020/05", folder.Path) + assert.Equal(t, "May 2020", folder.FolderTitle) + assert.Equal(t, "", folder.FolderDescription) + assert.Equal(t, "", folder.FolderType) + assert.Equal(t, SortOrderName, folder.FolderOrder) + assert.Equal(t, "", folder.FolderUUID) + assert.Equal(t, false, folder.FolderFavorite) + assert.Equal(t, false, folder.FolderHidden) + assert.Equal(t, false, folder.FolderIgnore) + assert.Equal(t, false, folder.FolderWatch) + }) + + t.Run("/2020/05/01/", func(t *testing.T) { + folder := NewFolder(FolderRootOriginals, "/2020/05/01/", nil) + assert.Equal(t, "2020/05/01", folder.Path) + assert.Equal(t, "May 2020", folder.FolderTitle) + }) + + t.Run("/2020/05/23/", func(t *testing.T) { + folder := NewFolder(FolderRootImport, "/2020/05/23/", nil) + assert.Equal(t, "2020/05/23", folder.Path) + assert.Equal(t, "May 23, 2020", folder.FolderTitle) + }) + + t.Run("/2020/05/23 Birthday", func(t *testing.T) { + folder := NewFolder(FolderRootUnknown, "/2020/05/23 Birthday", nil) + assert.Equal(t, "2020/05/23 Birthday", folder.Path) + assert.Equal(t, "23 Birthday", folder.FolderTitle) + }) + + t.Run("name empty", func(t *testing.T) { + folder := NewFolder(FolderRootOriginals, "", nil) + assert.Equal(t, "/", folder.Path) + assert.Equal(t, "Originals", folder.FolderTitle) + }) +} diff --git a/internal/entity/label.go b/internal/entity/label.go index a528896ac..5f090b594 100644 --- a/internal/entity/label.go +++ b/internal/entity/label.go @@ -30,7 +30,7 @@ type Label struct { New bool `gorm:"-"` } -// BeforeCreate computes a random UUID when a new label is created in database +// BeforeCreate creates a random UUID if needed before inserting a new row to the database. func (m *Label) BeforeCreate(scope *gorm.Scope) error { if rnd.IsPPID(m.LabelUUID, 'l') { return nil diff --git a/internal/entity/link.go b/internal/entity/link.go index 15c300fe8..28cce241c 100644 --- a/internal/entity/link.go +++ b/internal/entity/link.go @@ -20,7 +20,7 @@ type Link struct { DeletedAt *time.Time `deepcopier:"skip" sql:"index"` } -// BeforeCreate creates a new URL token when a new link is created. +// BeforeCreate creates a random UUID if needed before inserting a new row to the database. func (m *Link) BeforeCreate(scope *gorm.Scope) error { if err := scope.SetColumn("LinkToken", rnd.Token(10)); err != nil { return err diff --git a/internal/entity/photo.go b/internal/entity/photo.go index f94d83113..f65e587a9 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -18,11 +18,11 @@ import ( // Photo represents a photo, all its properties, and link to all its images and sidecar files. type Photo struct { ID uint `gorm:"primary_key" yaml:"-"` - PhotoUUID string `gorm:"type:varbinary(36);unique_index;index:idx_photos_taken_uuid;" yaml:"PhotoID"` - PhotoType string `gorm:"type:varbinary(8);default:'image';" json:"PhotoType" yaml:"Type"` TakenAt time.Time `gorm:"type:datetime;index:idx_photos_taken_uuid;" json:"TakenAt" yaml:"Taken"` TakenAtLocal time.Time `gorm:"type:datetime;" yaml:"-"` TakenSrc string `gorm:"type:varbinary(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"` + PhotoUUID string `gorm:"type:varbinary(36);unique_index;index:idx_photos_taken_uuid;" yaml:"PPID"` + PhotoType string `gorm:"type:varbinary(8);default:'image';" json:"PhotoType" yaml:"Type"` PhotoTitle string `gorm:"type:varchar(255);" json:"PhotoTitle" yaml:"Title"` TitleSrc string `gorm:"type:varbinary(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"` PhotoDescription string `gorm:"type:text;" json:"PhotoDescription" yaml:"Description,omitempty"` @@ -67,7 +67,7 @@ type Photo struct { DeletedAt *time.Time `sql:"index" yaml:"Deleted,omitempty"` } -// SavePhotoForm updates a model using form data and persists it in the database. +// SavePhotoForm saves a model in the database using form data. func SavePhotoForm(model Photo, form form.Photo, geoApi string) error { db := Db() locChanged := model.PhotoLat != form.PhotoLat || model.PhotoLng != form.PhotoLng @@ -178,7 +178,7 @@ func (m *Photo) ClassifyLabels() classify.Labels { return result } -// BeforeCreate computes a unique UUID, and set a default takenAt before indexing a new photo +// BeforeCreate creates a random UUID if needed before inserting a new row to the database. func (m *Photo) BeforeCreate(scope *gorm.Scope) error { if m.TakenAt.IsZero() || m.TakenAtLocal.IsZero() { now := time.Now() diff --git a/internal/entity/photo_album.go b/internal/entity/photo_album.go index 70987949b..08dc4719b 100644 --- a/internal/entity/photo_album.go +++ b/internal/entity/photo_album.go @@ -12,8 +12,8 @@ type PhotoAlbum struct { Hidden bool CreatedAt time.Time UpdatedAt time.Time - Photo *Photo - Album *Album + Photo *Photo `gorm:"PRELOAD:false"` + Album *Album `gorm:"PRELOAD:true"` } // TableName returns PhotoAlbum table identifier "photos_albums" diff --git a/internal/form/folder.go b/internal/form/folder.go new file mode 100644 index 000000000..e89d858a8 --- /dev/null +++ b/internal/form/folder.go @@ -0,0 +1,23 @@ +package form + +import "github.com/ulule/deepcopier" + +// Folder represents a file system directory edit form. +type Folder struct { + Root string `json:"Root"` + Path string `json:"Path"` + FolderType string `json:"Type"` + FolderOrder string `json:"Order"` + FolderTitle string `json:"Title"` + FolderDescription string `json:"Description"` + FolderFavorite bool `json:"Favorite"` + FolderIgnore bool `json:"Ignore"` + FolderHidden bool `json:"Hidden"` + FolderWatch bool `json:"Watch"` +} + +func NewFolder(m interface{}) (f Folder, err error) { + err = deepcopier.Copy(m).To(&f) + + return f, err +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 87d7fc181..4af442c1d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -54,11 +54,12 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.DislikeLabel(v1, conf) api.LabelThumbnail(v1, conf) + api.GetFoldersOriginals(v1, conf) + api.GetFoldersImport(v1, conf) + api.Upload(v1, conf) - api.GetImportOptions(v1, conf) api.StartImport(v1, conf) api.CancelImport(v1, conf) - api.GetIndexingOptions(v1, conf) api.StartIndexing(v1, conf) api.CancelIndexing(v1, conf) diff --git a/pkg/txt/convert.go b/pkg/txt/convert.go index 8962a364f..a2626f5d9 100644 --- a/pkg/txt/convert.go +++ b/pkg/txt/convert.go @@ -3,13 +3,14 @@ package txt import ( "regexp" "strconv" + "strings" "time" ) -var DateRegexp = regexp.MustCompile("\\D\\d{4}[\\-_]\\d{2}[\\-_]\\d{2}\\D") +var DateRegexp = regexp.MustCompile("\\D\\d{4}[\\-_]\\d{2}[\\-_]\\d{2,}") var DateCanonicalRegexp = regexp.MustCompile("\\D\\d{8}_\\d{6}_\\w+\\.") -var DatePathRegexp = regexp.MustCompile("\\D\\d{4}\\/\\d{1,2}\\/?\\d{0,2}\\D") -var DateTimeRegexp = regexp.MustCompile("\\D\\d{4}[\\-_]\\d{2}[\\-_]\\d{2}.{1,4}\\d{2}\\D\\d{2}\\D\\d{2}\\D") +var DatePathRegexp = regexp.MustCompile("\\D\\d{4}\\/\\d{1,2}\\/?\\d*") +var DateTimeRegexp = regexp.MustCompile("\\D\\d{4}[\\-_]\\d{2}[\\-_]\\d{2}.{1,4}\\d{2}\\D\\d{2}\\D\\d{2,}") var DateIntRegexp = regexp.MustCompile("\\d{1,4}") var ( @@ -38,6 +39,14 @@ func Time(s string) (result time.Time) { } }() + if len(s) < 6 { + return time.Time{} + } + + if !strings.HasPrefix(s, "/"){ + s = "/" + s + } + b := []byte(s) if found := DateCanonicalRegexp.Find(b); len(found) > 0 { // Is it a canonical name like "20120727_093920_97425909.jpg"? @@ -154,3 +163,18 @@ func Int(s string) int { return int(result) } + +// IsUInt returns true if a string only contains an unsigned integer. +func IsUInt(s string) bool { + if s == "" { + return false + } + + for _, r := range s { + if r < 48 || r > 57 { + return false + } + } + + return true +} diff --git a/pkg/txt/convert_test.go b/pkg/txt/convert_test.go index 76b7665db..51a3f0e4c 100644 --- a/pkg/txt/convert_test.go +++ b/pkg/txt/convert_test.go @@ -87,6 +87,12 @@ func TestTime(t *testing.T) { t.Run("2019/05/21", func(t *testing.T) { result := Time("2019/05/21") + assert.False(t, result.IsZero()) + assert.Equal(t, "2019-05-21 00:00:00 +0000 UTC", result.String()) + }) + + t.Run("2019/05/2145", func(t *testing.T) { + result := Time("2019/05/2145") assert.True(t, result.IsZero()) assert.Equal(t, "0001-01-01 00:00:00 +0000 UTC", result.String()) }) @@ -151,7 +157,6 @@ func TestTime(t *testing.T) { assert.Equal(t, "0001-01-01 00:00:00 +0000 UTC", result.String()) }) - t.Run("545452019/1/3/foo.jpg", func(t *testing.T) { result := Time("/2019/1/3/foo.jpg") assert.False(t, result.IsZero())