Add /folders API to get directory lists for browsing #260

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer
2020-05-22 16:29:12 +02:00
parent 9b01cb864d
commit a7122ff4e1
38 changed files with 634 additions and 129 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -1,6 +1,6 @@
import RestModel from "model/rest";
class Link extends RestModel {
export class Link extends RestModel {
getDefaults() {
return {
LinkToken: "",

View File

@@ -1,4 +1,4 @@
class Model {
export class Model {
constructor(values) {
this.__originalValues = {};

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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]);
}

View File

@@ -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: "",

View File

@@ -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,

View File

@@ -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,19 +196,20 @@
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) {

View File

@@ -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.
</v-alert>
</v-container>
@@ -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,19 +205,20 @@
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) {

View File

@@ -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)

View File

@@ -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) {

39
internal/api/folder.go Normal file
View File

@@ -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())
}

166
internal/api/folder_test.go Normal file
View File

@@ -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)
})
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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)
})

View File

@@ -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()

View File

@@ -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

View File

@@ -19,12 +19,14 @@ const (
SortOrderOldest = "oldest"
SortOrderImported = "imported"
SortOrderSimilar = "similar"
SortOrderName = "name"
// unknown values
YearUnknown = -1
MonthUnknown = -1
TitleUnknown = "Unknown"
TypeDefault = ""
TypeImage = "image"
TypeLive = "live"
TypeVideo = "video"

View File

@@ -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{},

View File

@@ -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)

130
internal/entity/folder.go Normal file
View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

23
internal/form/folder.go Normal file
View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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())