Merge cf285db49b
into 8990d48381
This commit is contained in:
commit
a7946c5385
14 changed files with 602 additions and 44 deletions
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
|
|
48
frontend/src/dialog/photo/edit/batch-edit.vue
Normal file
48
frontend/src/dialog/photo/edit/batch-edit.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
34
frontend/src/directive/directives.js
Normal file
34
frontend/src/directive/directives.js
Normal 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;
|
60
frontend/src/directive/longpress.vue
Normal file
60
frontend/src/directive/longpress.vue
Normal 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>
|
|
@ -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) {
|
||||
|
|
|
@ -1155,8 +1155,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;
|
||||
|
@ -1222,14 +1252,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() {
|
||||
|
|
|
@ -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)))
|
||||
})
|
||||
}
|
||||
|
|
124
internal/entity/db_schema.go
Normal file
124
internal/entity/db_schema.go
Normal 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
|
||||
}
|
99
internal/entity/db_schema_test.go
Normal file
99
internal/entity/db_schema_test.go
Normal 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))
|
||||
})
|
||||
}
|
|
@ -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"),
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue