Add batch editing #271

This commit is contained in:
Vidar Tysse 2023-08-20 17:52:31 +02:00
parent 80c10bb35c
commit cf285db49b
14 changed files with 602 additions and 44 deletions

View file

@ -33,6 +33,7 @@ import Clipboard from "common/clipboard";
import Components from "component/components";
import icons from "component/icons";
import Dialogs from "dialog/dialogs";
import Directives from "directive/directives";
import Event from "pubsub-js";
import GetTextPlugin from "vue-gettext";
import Log from "common/log";
@ -118,6 +119,7 @@ config.update().finally(() => {
Vue.use(Components);
Vue.use(Dialogs);
Vue.use(Router);
Vue.use(Directives);
// make scroll-pos-restore compatible with bfcache
// this is required to make scroll-pos-restore work on iOS in PWA-mode

View file

@ -27,6 +27,7 @@ import PServiceAddDialog from "dialog/service/add.vue";
import PServiceRemoveDialog from "dialog/service/remove.vue";
import PServiceEditDialog from "dialog/service/edit.vue";
import PPhotoArchiveDialog from "dialog/photo/archive.vue";
import PPhotoBatchEditDialog from "dialog/photo/edit/batch-edit.vue";
import PPhotoAlbumDialog from "dialog/photo/album.vue";
import PPhotoEditDialog from "dialog/photo/edit.vue";
import PPhotoDeleteDialog from "dialog/photo/delete.vue";
@ -52,6 +53,7 @@ dialogs.install = (Vue) => {
Vue.component("PServiceRemoveDialog", PServiceRemoveDialog);
Vue.component("PServiceEditDialog", PServiceEditDialog);
Vue.component("PPhotoArchiveDialog", PPhotoArchiveDialog);
Vue.component("PPhotoBatchEditDialog", PPhotoBatchEditDialog);
Vue.component("PPhotoAlbumDialog", PPhotoAlbumDialog);
Vue.component("PPhotoEditDialog", PPhotoEditDialog);
Vue.component("PPhotoDeleteDialog", PPhotoDeleteDialog);

View file

@ -83,7 +83,8 @@
<v-tabs-items touchless>
<v-tab-item lazy>
<p-tab-photo-details :key="uid" ref="details" :model="model" :uid="uid"
@close="close" @prev="prev" @next="next"></p-tab-photo-details>
@close="close" @prev="prev" @next="next"
@updateAllSelected="updateAllSelected"></p-tab-photo-details>
</v-tab-item>
<v-tab-item lazy>
@ -204,6 +205,14 @@ export default {
break;
}
},
updateAllSelected() {
if (!(this.$clipboard.selection?.length > 0)) return;
this.model.updateAllSelected(this.$clipboard.selection).then((updated) => {
if (updated) {
this.find(this.selected);
}
});
},
close() {
this.$emit('close');
},

View file

@ -0,0 +1,48 @@
<template>
<v-dialog :value="show" lazy persistent max-width="350" class="p-photo-batch-edit-dialog" @keydown.esc="cancel">
<v-card raised elevation="24">
<v-container fluid class="pb-2 pr-2 pl-2">
<v-layout row wrap>
<v-flex xs3 text-xs-center>
<v-icon size="54" color="secondary-dark lighten-1">edit</v-icon>
</v-flex>
<v-flex xs9 text-xs-left align-self-center>
<div class="subheading pr-1">
<translate>Are you sure you want to make changes to all {{ photoCount }} photos in the selection?</translate>
</div>
</v-flex>
<v-flex xs12 text-xs-right class="pt-3">
<v-btn depressed color="secondary-light" class="action-cancel" @click.stop="cancel">
<translate>No</translate>
</v-btn>
<v-btn color="primary-button" depressed dark class="action-confirm"
@click.stop="confirm">
<translate>Yes</translate>
</v-btn>
</v-flex>
</v-layout>
</v-container>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: 'PPhotoBatchEditDialog',
props: {
show: Boolean,
},
data() {
return {
photoCount: this.$clipboard.selection?.length,
};
},
methods: {
cancel() {
this.$emit('cancel');
},
confirm() {
this.$emit('confirm');
},
}
};
</script>

View file

@ -389,12 +389,14 @@
<translate>Close</translate>
</v-btn>
<v-btn color="primary-button" depressed dark class="compact action-apply action-approve"
@click.stop="save(false)">
@click.stop="save(false)"
v-longpress:[allowbatchedit]="confirmSaveAllSelected">
<span v-if="$config.feature('review') && model.Quality < 3"><translate>Approve</translate></span>
<span v-else><translate>Apply</translate></span>
</v-btn>
<v-btn color="primary-button" depressed dark class="compact action-done hidden-xs-only"
@click.stop="save(true)">
@click.stop="save(true)"
v-longpress:[allowbatchedit]="confirmSaveAllSelectedAndClose">
<translate>Done</translate>
</v-btn>
</v-flex>
@ -403,6 +405,8 @@
</v-layout>
<div class="mt-1 clear"></div>
</v-form>
<p-photo-batch-edit-dialog :show="dialog.batchEdit" @cancel="dialog.batchEdit = false"
@confirm="batchEdit"></p-photo-batch-edit-dialog>
</div>
</template>
@ -440,7 +444,11 @@ export default {
time: "",
textRule: v => v.length <= this.$config.get('clip') || this.$gettext("Text too long"),
rtl: this.$rtl,
};
allowbatchedit: (this.$clipboard.selection.length > 0),
batchEditFunction: null,
dialog: {
batchEdit: false,
}, };
},
computed: {
cameraOptions() {
@ -566,6 +574,33 @@ export default {
close() {
this.$emit('close');
},
batchEdit() {
this.dialog.batchEdit = false;
this.batchEditFunction();
this.batchEditFunction = null;
},
confirmSaveAllSelectedAndClose() {
this.batchEditFunction = this.saveAllSelectedAndClose;
this.dialog.batchEdit = true;
},
confirmSaveAllSelected() {
this.batchEditFunction = this.saveAllSelected;
this.dialog.batchEdit = true;
},
saveAllSelectedAndClose() {
if (!this.saveAllSelected()) return;
this.close();
},
saveAllSelected() {
if (this.invalidDate) {
this.$notify.error(this.$gettext("Invalid date"));
return false;
}
this.updateModel();
this.$emit('updateAllSelected');
this.updateTime();
return true;
},
},
};
</script>

View file

@ -0,0 +1,34 @@
/*
Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
import PLongPress from "directive/longpress.vue";
const directives = {};
directives.install = (Vue) => {
Vue.directive("longpress", PLongPress);
};
export default directives;

View file

@ -0,0 +1,60 @@
<script>
// v-longpress: Bind to a function to be executed after long-pressing.
// Set arg = true or false to enable or disable long-pressing.
export default {
name: "PLongPress",
bind: function (el, binding, vNode) {
if (typeof (binding.value) !== 'function') {
console.warn(`v-longpress: Must be bound to a function. Expression in error: '${binding.expression}'.`);
}
el.longPressActive = (binding.arg === true);
let longPressTimer = null
el.startLongPressTimer = (event) => {
if (!event.isPrimary) return;
if (!el.longPressActive) return;
if (longPressTimer !== null) return; // Already counting down
longPressTimer = setTimeout(() => {
el.style.boxShadow = "";
binding.value(event);
}, 1500); // Call the bound function after 1.5 seconds
setTimeout(() => {
if (longPressTimer !== null) {
el.style.boxShadow = "0px 0px 70px 40px " + window.getComputedStyle(el).backgroundColor;
}
}, 300); // Visual feedback for long-press after 0.3 seconds
}
el.cancelLongPressTimer = (event) => {
el.style.boxShadow = "";
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Start the countdown on pointer down
el.addEventListener("pointerdown", el.startLongPressTimer);
// Cancel the countdown if the pointer does not stay down over the element for 1.5 seconds
el.addEventListener("click", el.cancelLongPressTimer);
el.addEventListener("pointerup", el.cancelLongPressTimer);
el.addEventListener("pointercancel", el.cancelLongPressTimer);
el.addEventListener("pointerleave", el.cancelLongPressTimer);
el.addEventListener("pointerout", el.cancelLongPressTimer);
el.addEventListener("mouseup", el.cancelLongPressTimer);
},
update: function (el, binding, vNode) {
el.longPressActive = (binding.arg === true);
},
unbind: function (el, binding, vNode) {
el.removeEventListener("pointerdown", el.startLongPressTimer);
el.removeEventListener("click", el.cancelLongPressTimer);
el.removeEventListener("pointerup", el.cancelLongPressTimer);
el.removeEventListener("pointercancel", el.cancelLongPressTimer);
el.removeEventListener("pointerleave", el.cancelLongPressTimer);
el.removeEventListener("pointerout", el.cancelLongPressTimer);
el.removeEventListener("mouseup", el.cancelLongPressTimer);
},
}
</script>

View file

@ -52,43 +52,63 @@ export class Model {
return this;
}
getValues(changed) {
const result = {};
const defaults = this.getDefaults();
/**
* Returns the values of this model, all of them or only changed values, with changed
* subobjects traversed for changed fields or simply included as complete objects
*
* @param {boolean} changed - get only changed values
* @param {boolean} traverseSubObjects - get only changed values for subobjects too (parameter {@link changed} must also be `true`)
* @return {object}
*/
getValues(changed, traverseSubObjects) {
return getObjectValues(this, this.__originalValues, this.getDefaults());
for (let key in this.__originalValues) {
if (this.__originalValues.hasOwnProperty(key) && key !== "__originalValues") {
let val;
if (defaults.hasOwnProperty(key)) {
switch (typeof defaults[key]) {
case "string":
if (this[key] === null || this[key] === undefined) {
val = "";
} else {
val = this[key];
}
break;
case "bigint":
case "number":
val = parseFloat(this[key]);
break;
case "boolean":
val = !!this[key];
break;
default:
val = this[key];
function getObjectValues(obj, originalValues, defaults) {
const result = {};
for (let key in originalValues) {
if (originalValues.hasOwnProperty(key) && key !== "__originalValues") {
let val;
if (defaults.hasOwnProperty(key)) {
val = getTypedValue(key);
} else {
val = obj[key];
}
} else {
val = this[key];
}
if (!changed || JSON.stringify(val) !== JSON.stringify(this.__originalValues[key])) {
result[key] = val;
if (!changed || JSON.stringify(val) !== JSON.stringify(originalValues[key])) {
if (changed && traverseSubObjects && typeof val === "object") {
result[key] = getObjectValues(val, originalValues[key], defaults[key]);
} else {
result[key] = val;
}
}
}
}
}
return result;
return result;
function getTypedValue(key) {
let typedVal;
switch (typeof defaults[key]) {
case "string":
if (obj[key] === null || obj[key] === undefined) {
typedVal = "";
} else {
typedVal = obj[key];
}
break;
case "bigint":
case "number":
typedVal = parseFloat(obj[key]);
break;
case "boolean":
typedVal = !!obj[key];
break;
default:
typedVal = obj[key];
}
return typedVal;
}
}
}
originalValue(key) {

View file

@ -1014,8 +1014,38 @@ export class Photo extends RestModel {
return result;
}
updateAllSelected(selection) {
const values = this.getUpdatedValues(true);
if (Object.keys(values).length === 0) {
return Promise.resolve(false);
}
return Api.post("batch/photos/edit", { ...values, photos: selection }).then(() => {
if (values.Type || values.Lat) {
config.update();
}
return Promise.resolve(true);
});
}
update() {
const values = this.getValues(true);
const values = this.getUpdatedValues();
if (values.Details) {
values.Details.PhotoID = this.ID; // Send PhotoID to identify Details.
}
return Api.put(this.getEntityResource(), values).then((resp) => {
if (values.Type || values.Lat) {
config.update();
}
return Promise.resolve(this.setValues(resp.data));
});
}
getUpdatedValues(traverseSubObjects) {
const values = this.getValues(true, traverseSubObjects);
if (values.Title) {
values.TitleSrc = src.Manual;
@ -1081,14 +1111,7 @@ export class Photo extends RestModel {
values.Details.LicenseSrc = src.Manual;
}
}
return Api.put(this.getEntityResource(), values).then((resp) => {
if (values.Type || values.Lat) {
config.update();
}
return Promise.resolve(this.setValues(resp.data));
});
return values;
}
static batchSize() {

View file

@ -8,6 +8,7 @@ import (
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
@ -21,6 +22,11 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
)
var (
photoDbColumns = entity.GetDbFieldMap(entity.Photo{})
detailsDbColumns = entity.GetDbFieldMap(entity.Details{})
)
// BatchPhotosArchive moves multiple photos to the archive.
//
// POST /api/v1/batch/photos/archive
@ -432,3 +438,94 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPermanentlyDeleted))
})
}
// BatchPhotosEdit edit fields in multiple photos.
//
// POST /api/v1/batch/photos/edit
func BatchPhotosEdit(router *gin.RouterGroup) {
router.POST("/batch/photos/edit", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.AccessPrivate)
if s.Abort(c) {
return
}
var photoSelection struct {
Photos []string `json:"photos"`
}
if err := c.ShouldBindBodyWith(&photoSelection, binding.JSON); err != nil {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
return
}
photoUids := photoSelection.Photos
if len(photoUids) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
var photoChanges map[string]interface{}
if err := c.ShouldBindBodyWith(&photoChanges, binding.JSON); err != nil {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
return
}
if len(photoChanges) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoFieldsChanged)
return
}
delete(photoChanges, "photos")
log.Infof("photos: updating %d fields for %d photos", len(photoChanges), len(photoUids))
if err := entity.Db().Transaction(func(tx *gorm.DB) error {
var photoIds []uint64
if err := tx.Model(entity.Photo{}).Where("photo_uid IN (?)", photoUids).Pluck("id", &photoIds).Error; err != nil {
return err
}
if err := tx.Model(entity.Photo{}).Where("id IN (?)", photoIds).Omit("Details").UpdateColumns(entity.SubstDbFields(photoChanges, photoDbColumns)).Error; err != nil {
return err
}
detailsChanges, ok := photoChanges["Details"].(map[string]interface{})
if ok {
if err := tx.Model(entity.Details{}).Where("photo_id IN (?)", photoIds).UpdateColumns(entity.SubstDbFields(detailsChanges, detailsDbColumns)).Error; err != nil {
return err
}
}
return nil
}); err != nil {
log.Errorf("batch edit photos: %s", err)
AbortSaveFailed(c)
return
}
var updatedPhotos entity.Photos
for _, sel := range photoUids {
uid := clean.UID(sel)
p, err := query.PhotoPreloadByUID(uid)
if err != nil {
AbortEntityNotFound(c)
return
}
updatedPhotos = append(updatedPhotos, p)
SavePhotoAsYaml(p)
}
// Update precalculated photo and file counts.
logWarn("index", entity.UpdateCounts())
event.EntitiesUpdated("photos", updatedPhotos)
UpdateClientConfig()
FlushCoverCache()
event.SuccessMsg(i18n.MsgPhotosUpdated, len(photoUids))
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPhotosUpdated, len(photoUids)))
})
}

View file

@ -0,0 +1,124 @@
package entity
import (
"reflect"
"strings"
"time"
)
type DbFieldInfo struct {
FieldName string
FieldType string
}
// Returns a map["jsonFieldName"]"database_field_name" for all json-tagged fields in the given entity type
func GetDbFieldMap(entityType interface{}) map[string]DbFieldInfo {
m := make(map[string]DbFieldInfo)
for _, field := range reflect.VisibleFields(reflect.TypeOf(entityType)) {
jsonFieldName := strings.Split(field.Tag.Get("json"), ",")[0]
if len(jsonFieldName) > 0 {
columnName := getGormColumnName(field)
if len(columnName) == 0 {
columnName = getDbFieldName(field.Name)
}
m[jsonFieldName] = DbFieldInfo{FieldName: columnName, FieldType: field.Type.String()}
}
}
return m
}
func getGormColumnName(field reflect.StructField) string {
for _, v := range strings.Split(field.Tag.Get("gorm"), ";") {
if strings.HasPrefix(v, "column:") {
return v[7:]
}
}
return ""
}
// Return entity map with substituted field names and parsed values
func SubstDbFields(entity map[string]interface{}, substituteMap map[string]DbFieldInfo) map[string]interface{} {
ret := make(map[string]interface{}, len(entity))
for key, val := range entity {
if dbFieldInfo, ok := substituteMap[key]; ok {
ret[dbFieldInfo.FieldName] = parseDbFieldValue(val, dbFieldInfo.FieldType)
}
}
return ret
}
// Parse field values that are not automatially unmarshalled from JSON to DB field compatible types
func parseDbFieldValue(value interface{}, dbFieldType string) interface{} {
switch dbFieldType {
case "time.Time":
if v, ok := value.(string); ok {
if val, err := time.Parse(time.RFC3339, v); err == nil {
return val
}
}
}
return value
}
// Code below is borrowed from a newer GORM's schema package: https://github.com/go-gorm/gorm/blob/master/schema/naming.go (MIT License)
var (
// https://github.com/golang/lint/blob/master/lint.go#L770
commonInitialisms = []string{"API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "LHS", "QPS", "RAM", "RHS", "RPC", "SLA", "SMTP", "SSH", "TLS", "TTL", "UID", "UI", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XSRF", "XSS"}
commonInitialismsReplacer *strings.Replacer
)
func init() {
commonInitialismsForReplacer := make([]string, 0, len(commonInitialisms))
for _, initialism := range commonInitialisms {
commonInitialismsForReplacer = append(commonInitialismsForReplacer, initialism, strings.Title(strings.ToLower(initialism)))
}
commonInitialismsReplacer = strings.NewReplacer(commonInitialismsForReplacer...)
}
// Do GORM's snake_case mapping to get the database column name.
func getDbFieldName(name string) string {
if name == "" {
return ""
}
var (
value = commonInitialismsReplacer.Replace(name)
buf strings.Builder
lastCase, nextCase, nextNumber bool // upper case == true
curCase = value[0] <= 'Z' && value[0] >= 'A'
)
for i, v := range value[:len(value)-1] {
nextCase = value[i+1] <= 'Z' && value[i+1] >= 'A'
nextNumber = value[i+1] >= '0' && value[i+1] <= '9'
if curCase {
if lastCase && (nextCase || nextNumber) {
buf.WriteRune(v + 32)
} else {
if i > 0 && value[i-1] != '_' && value[i+1] != '_' {
buf.WriteByte('_')
}
buf.WriteRune(v + 32)
}
} else {
buf.WriteRune(v)
}
lastCase = curCase
curCase = nextCase
}
if curCase {
if !lastCase && len(value) > 1 {
buf.WriteByte('_')
}
buf.WriteByte(value[len(value)-1] + 32)
} else {
buf.WriteByte(value[len(value)-1])
}
ret := buf.String()
return ret
}

View file

@ -0,0 +1,99 @@
package entity
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestGetDbFieldNameMap_ExistingFields(t *testing.T) {
type MyEntity struct {
ID uint `gorm:"primary_key" yaml:"-"`
UUID string `gorm:"type:VARBINARY(64);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"`
LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"`
Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"`
Albums []Album `json:"Albums" yaml:"-"`
Files []File `yaml:"-"`
Labels []PhotoLabel `yaml:"-"`
PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"`
PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"`
SomeDbTime time.Time `json:"SomeTime" yaml:"SomeTime,omitempty"`
SomeGormField time.Time `gorm:"column:a_gorm_field;" json:"TheGormField" yaml:"SomeTime,omitempty"`
TypedGormField string `gorm:"column:a_typed_gorm_field;type:VARBINARY(64);" json:"TheTypedGormField" yaml:"SomeTime,omitempty"`
TypedGormField2 string `gorm:"type:VARBINARY(64);column:second_typed_gorm_field;" json:"TheSecondTypedGormField" yaml:"SomeTime,omitempty"`
TypedGormField3 string `gorm:"type:VARBINARY(64);column:third_typed_gorm_field;index" json:"TheThirdypedGormField" yaml:"SomeTime,omitempty"`
}
t.Run("get db fieldname map for typical entity struct", func(t *testing.T) {
m := GetDbFieldMap(MyEntity{})
assert.Equal(t, "uuid", m["DocumentID"].FieldName)
assert.Equal(t, "lens_id", m["LensID"].FieldName)
assert.Equal(t, "details", m["Details"].FieldName)
assert.Equal(t, "albums", m["Albums"].FieldName)
assert.Equal(t, "photo_f_number", m["FNumber"].FieldName)
assert.Equal(t, "photo_focal_length", m["FocalLength"].FieldName)
assert.Equal(t, "some_db_time", m["SomeTime"].FieldName)
assert.Equal(t, "a_gorm_field", m["TheGormField"].FieldName)
assert.Equal(t, "a_typed_gorm_field", m["TheTypedGormField"].FieldName)
assert.Equal(t, "second_typed_gorm_field", m["TheSecondTypedGormField"].FieldName)
assert.Equal(t, "third_typed_gorm_field", m["TheThirdypedGormField"].FieldName)
})
}
func TestGetDbFieldNameMap_FieldsWithoutJsonTag(t *testing.T) {
type MyEntity struct {
Files []File `yaml:"-"`
Labels []PhotoLabel `yaml:"-"`
PhotoFNumber float32 `gorm:"type:FLOAT;" yaml:"FNumber,omitempty"`
PhotoFocalLength int `yaml:"FocalLength,omitempty"`
}
t.Run("get db fieldname map for entity struct without JSON tags", func(t *testing.T) {
m := GetDbFieldMap(MyEntity{})
assert.Zero(t, len(m))
})
}
func TestSubstDbFields_ExistingFields(t *testing.T) {
type MyEntity struct {
ID uint `gorm:"primary_key" yaml:"-"`
UUID string `gorm:"type:VARBINARY(64);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"`
LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"`
Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"`
Albums []Album `json:"Albums" yaml:"-"`
TakenAtLocal time.Time `gorm:"type:DATETIME;" json:"TakenAtLocal" yaml:"TakenAtLocal"`
PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"`
PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"`
SomeBoolColumn bool `json:"TheBoolean" yaml:"YesTheBoolean,omitempty"`
}
changesMap := map[string]interface{}{"DocumentID": "98upqwe89", "TakenAtLocal": "2023-02-28T14:15:16Z", "LensID": 1, "FNumber": 1.234, "TheBoolean": true}
t.Run("substitute db field names and values", func(t *testing.T) {
m := GetDbFieldMap(MyEntity{})
s := SubstDbFields(changesMap, m)
assert.Equal(t, "98upqwe89", s["uuid"])
assert.Equal(t, time.Date(2023, time.Month(02), 28, 14, 15, 16, 0, time.UTC), s["taken_at_local"])
assert.Equal(t, 1, s["lens_id"])
assert.Equal(t, 1.234, s["photo_f_number"])
assert.Equal(t, true, s["some_bool_column"])
})
}
func TestSubstDbFields_UnknownFields(t *testing.T) {
type MyEntity struct {
ID uint `gorm:"primary_key" yaml:"-"`
UUID string `gorm:"type:VARBINARY(64);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"`
LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"`
Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"`
Albums []Album `json:"Albums" yaml:"-"`
TakenAtLocal time.Time `gorm:"type:DATETIME;" json:"TakenAtLocal" yaml:"TakenAtLocal"`
PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"`
PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"`
SomeBoolColumn bool `json:"TheBoolean" yaml:"YesTheBoolean,omitempty"`
}
changesMap := map[string]interface{}{"Unknown1": "98upqwe89", "Unknown2": "2023-02-28T14:15:16Z", "Unknown3": 1, "Unknown4": 1.234, "Unknown5": true}
t.Run("substitute db field names and values - no fields known", func(t *testing.T) {
m := GetDbFieldMap(MyEntity{})
s := SubstDbFields(changesMap, m)
assert.NotZero(t, len(m))
assert.Zero(t, len(s))
})
}

View file

@ -42,6 +42,7 @@ const (
ErrBusy
ErrWakeupInterval
ErrAccountConnect
ErrNoFieldsChanged
MsgChangesSaved
MsgAlbumCreated
@ -83,6 +84,7 @@ const (
MsgSelectionArchived
MsgSelectionRestored
MsgSelectionProtected
MsgPhotosUpdated
MsgAlbumsDeleted
MsgZipCreatedIn
MsgPermanentlyDeleted
@ -132,6 +134,7 @@ var Messages = MessageMap{
ErrBusy: gettext("Busy, please try again later"),
ErrWakeupInterval: gettext("The wakeup interval is %s, but must be 1h or less"),
ErrAccountConnect: gettext("Your account could not be connected"),
ErrNoFieldsChanged: gettext("No fields were changed"),
// Info and confirmation messages:
MsgChangesSaved: gettext("Changes successfully saved"),
@ -174,6 +177,7 @@ var Messages = MessageMap{
MsgSelectionArchived: gettext("Selection archived"),
MsgSelectionRestored: gettext("Selection restored"),
MsgSelectionProtected: gettext("Selection marked as private"),
MsgPhotosUpdated: gettext("%d photos updated"),
MsgAlbumsDeleted: gettext("Albums deleted"),
MsgZipCreatedIn: gettext("Zip created in %d s"),
MsgPermanentlyDeleted: gettext("Permanently deleted"),

View file

@ -154,6 +154,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.BatchPhotosArchive(APIv1)
api.BatchPhotosRestore(APIv1)
api.BatchPhotosPrivate(APIv1)
api.BatchPhotosEdit(APIv1)
api.BatchPhotosDelete(APIv1)
api.BatchAlbumsDelete(APIv1)
api.BatchLabelsDelete(APIv1)