edit-exif: combine dialogs
parent
538bca5bb4
commit
6b3eda89d1
|
@ -36,8 +36,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Selection Modals -->
|
||||
<EditDate ref="editDate" @refresh="refresh" />
|
||||
<EditExif ref="editExif" @refresh="refresh" />
|
||||
<EditMetadata ref="editMetadata" @refresh="refresh" />
|
||||
<FaceMoveModal
|
||||
ref="faceMoveModal"
|
||||
@moved="deletePhotos"
|
||||
|
@ -71,8 +70,7 @@ import { getCurrentUser } from "@nextcloud/auth";
|
|||
import * as dav from "../services/DavRequests";
|
||||
import * as utils from "../services/Utils";
|
||||
|
||||
import EditDate from "./modal/EditDate.vue";
|
||||
import EditExif from "./modal/EditExif.vue";
|
||||
import EditMetadata from "./modal/EditMetadataModal.vue";
|
||||
import FaceMoveModal from "./modal/FaceMoveModal.vue";
|
||||
import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
|
||||
import MoveToFolderModal from "./modal/MoveToFolderModal.vue";
|
||||
|
@ -98,8 +96,7 @@ export default defineComponent({
|
|||
components: {
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
EditDate,
|
||||
EditExif,
|
||||
EditMetadata,
|
||||
FaceMoveModal,
|
||||
AddToAlbumModal,
|
||||
MoveToFolderModal,
|
||||
|
@ -174,16 +171,9 @@ export default defineComponent({
|
|||
if: () => this.routeIsArchive(),
|
||||
},
|
||||
{
|
||||
name: t("memories", "Edit Date/Time"),
|
||||
icon: EditClockIcon,
|
||||
callback: this.editDateSelection.bind(this),
|
||||
if: () => !this.routeIsAlbum(),
|
||||
},
|
||||
{
|
||||
name: t("memories", "Edit EXIF Data"),
|
||||
name: t("memories", "Edit Metadata"),
|
||||
icon: EditFileIcon,
|
||||
callback: this.editExifSelection.bind(this),
|
||||
if: () => this.selection.size === 1 && !this.routeIsAlbum(),
|
||||
callback: this.editMetadataSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t("memories", "View in folder"),
|
||||
|
@ -224,10 +214,8 @@ export default defineComponent({
|
|||
sel.set(photo.fileid, photo);
|
||||
return sel;
|
||||
};
|
||||
globalThis.editDate = (photo: IPhoto) =>
|
||||
this.editDateSelection(getSel(photo));
|
||||
globalThis.editExif = (photo: IPhoto) =>
|
||||
this.editExifSelection(getSel(photo));
|
||||
globalThis.editMetadata = (photo: IPhoto) =>
|
||||
this.editMetadataSelection(getSel(photo));
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
@ -761,16 +749,8 @@ export default defineComponent({
|
|||
/**
|
||||
* Open the edit date dialog
|
||||
*/
|
||||
async editDateSelection(selection: Selection) {
|
||||
(<any>this.$refs.editDate).open(Array.from(selection.values()));
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the edit date dialog
|
||||
*/
|
||||
async editExifSelection(selection: Selection) {
|
||||
if (selection.size !== 1) return;
|
||||
(<any>this.$refs.editExif).open(selection.values().next().value);
|
||||
async editMetadataSelection(selection: Selection) {
|
||||
(<any>this.$refs.editMetadata).open(Array.from(selection.values()));
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,153 +1,129 @@
|
|||
<template>
|
||||
<Modal v-if="photos.length > 0" @close="close">
|
||||
<template #title>
|
||||
{{ t("memories", "Edit Date/Time") }}
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton @click="save" class="button" type="error" v-if="longDateStr">
|
||||
{{ t("memories", "Update Exif") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<div v-if="longDateStr">
|
||||
<div>
|
||||
<div class="title-text">
|
||||
<span v-if="photos.length > 1"> [{{ t("memories", "Newest") }}] </span>
|
||||
{{ longDateStr }}
|
||||
{{ newestDirty ? "*" : "" }}
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<NcTextField
|
||||
class="field"
|
||||
:value.sync="year"
|
||||
:label="t('memories', 'Year')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Year')"
|
||||
@input="newestChange()"
|
||||
/>
|
||||
<NcTextField
|
||||
class="field"
|
||||
:value.sync="month"
|
||||
:label="t('memories', 'Month')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Month')"
|
||||
@input="newestChange()"
|
||||
/>
|
||||
<NcTextField
|
||||
class="field"
|
||||
:value.sync="day"
|
||||
:label="t('memories', 'Day')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Day')"
|
||||
@input="newestChange()"
|
||||
/>
|
||||
<NcTextField
|
||||
class="field"
|
||||
:value.sync="hour"
|
||||
:label="t('memories', 'Time')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Hour')"
|
||||
@input="newestChange(true)"
|
||||
/>
|
||||
<NcTextField
|
||||
class="field"
|
||||
:value.sync="minute"
|
||||
:label="t('memories', 'Minute')"
|
||||
:placeholder="t('memories', 'Minute')"
|
||||
@input="newestChange(true)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="photos.length > 1" class="oldest">
|
||||
<div class="title-text">
|
||||
<span> [{{ t("memories", "Oldest") }}] </span>
|
||||
{{ longDateStrLast }}
|
||||
{{ oldestDirty ? "*" : "" }}
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<NcTextField
|
||||
:value.sync="year"
|
||||
class="field"
|
||||
@input="newestChange()"
|
||||
:value.sync="yearLast"
|
||||
:label="t('memories', 'Year')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Year')"
|
||||
@input="oldestChange()"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="month"
|
||||
class="field"
|
||||
@input="newestChange()"
|
||||
:value.sync="monthLast"
|
||||
:label="t('memories', 'Month')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Month')"
|
||||
@input="oldestChange()"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="day"
|
||||
class="field"
|
||||
@input="newestChange()"
|
||||
:value.sync="dayLast"
|
||||
:label="t('memories', 'Day')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Day')"
|
||||
@input="oldestChange()"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="hour"
|
||||
class="field"
|
||||
@input="newestChange(true)"
|
||||
:value.sync="hourLast"
|
||||
:label="t('memories', 'Time')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Hour')"
|
||||
@input="oldestChange()"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="minute"
|
||||
class="field"
|
||||
@input="newestChange(true)"
|
||||
:value.sync="minuteLast"
|
||||
:label="t('memories', 'Minute')"
|
||||
:placeholder="t('memories', 'Minute')"
|
||||
@input="oldestChange()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="photos.length > 1" class="oldest">
|
||||
<span> [{{ t("memories", "Oldest") }}] </span>
|
||||
{{ longDateStrLast }}
|
||||
|
||||
<div class="fields">
|
||||
<NcTextField
|
||||
:value.sync="yearLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Year')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Year')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="monthLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Month')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Month')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="dayLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Day')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Day')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="hourLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Time')"
|
||||
:label-visible="true"
|
||||
:placeholder="t('memories', 'Hour')"
|
||||
/>
|
||||
<NcTextField
|
||||
:value.sync="minuteLast"
|
||||
class="field"
|
||||
:label="t('memories', 'Minute')"
|
||||
:placeholder="t('memories', 'Minute')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processing" class="info-pad">
|
||||
{{
|
||||
t("memories", "Processing … {n}/{m}", {
|
||||
n: photosDone,
|
||||
m: photos.length,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
{{
|
||||
t("memories", "Loading data … {n}/{m}", {
|
||||
n: photosDone,
|
||||
m: photos.length,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { IPhoto } from "../../types";
|
||||
|
||||
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
|
||||
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
|
||||
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { emit } from "@nextcloud/event-bus";
|
||||
import Modal from "./Modal.vue";
|
||||
import axios from "@nextcloud/axios";
|
||||
import * as utils from "../../services/Utils";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
import { API } from "../../services/API";
|
||||
|
||||
export default defineComponent({
|
||||
name: "EditDate",
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
photos: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
photos: [] as IPhoto[],
|
||||
photosDone: 0,
|
||||
processing: false,
|
||||
sortedPhotos: [] as IPhoto[],
|
||||
|
||||
longDateStr: "",
|
||||
year: "0",
|
||||
month: "0",
|
||||
day: "0",
|
||||
|
@ -155,93 +131,164 @@ export default defineComponent({
|
|||
minute: "0",
|
||||
second: "0",
|
||||
|
||||
longDateStrLast: "",
|
||||
yearLast: "0",
|
||||
monthLast: "0",
|
||||
dayLast: "0",
|
||||
hourLast: "0",
|
||||
minuteLast: "0",
|
||||
secondLast: "0",
|
||||
|
||||
newestDirty: false,
|
||||
oldestDirty: false,
|
||||
}),
|
||||
|
||||
methods: {
|
||||
emitRefresh(val: boolean) {
|
||||
this.$emit("refresh", val);
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
|
||||
watch: {
|
||||
photos() {
|
||||
this.init();
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
date() {
|
||||
return this.makeDate(
|
||||
this.year,
|
||||
this.month,
|
||||
this.day,
|
||||
this.hour,
|
||||
this.minute,
|
||||
this.second
|
||||
);
|
||||
},
|
||||
|
||||
async open(photos: IPhoto[]) {
|
||||
this.photos = photos;
|
||||
if (photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.photosDone = 0;
|
||||
this.longDateStr = "";
|
||||
dateLast() {
|
||||
return this.makeDate(
|
||||
this.yearLast,
|
||||
this.monthLast,
|
||||
this.dayLast,
|
||||
this.hourLast,
|
||||
this.minuteLast,
|
||||
this.secondLast
|
||||
);
|
||||
},
|
||||
|
||||
const calls = photos.map((p) => async () => {
|
||||
try {
|
||||
const res = await axios.get<any>(
|
||||
API.Q(API.IMAGE_INFO(p.fileid), "basic=1")
|
||||
);
|
||||
if (typeof res.data.datetaken !== "number") {
|
||||
console.error("Invalid date for", p.fileid);
|
||||
return;
|
||||
}
|
||||
p.datetaken = res.data.datetaken * 1000;
|
||||
} catch (error) {
|
||||
console.error("Failed to get date info for", p.fileid, error);
|
||||
} finally {
|
||||
this.photosDone++;
|
||||
}
|
||||
});
|
||||
dateDiff() {
|
||||
return this.date.getTime() - this.dateLast.getTime();
|
||||
},
|
||||
|
||||
for await (const _ of dav.runInParallel(calls, 10)) {
|
||||
// nothing to do
|
||||
}
|
||||
origDateNewest() {
|
||||
return new Date(this.sortedPhotos[0].datetaken);
|
||||
},
|
||||
|
||||
// Remove photos without datetaken
|
||||
this.photos = this.photos.filter((p) => p.datetaken !== undefined);
|
||||
origDateOldest() {
|
||||
return new Date(
|
||||
this.sortedPhotos[this.sortedPhotos.length - 1].datetaken
|
||||
);
|
||||
},
|
||||
|
||||
origDateDiff() {
|
||||
return this.origDateNewest.getTime() - this.origDateOldest.getTime();
|
||||
},
|
||||
|
||||
scaleFactor() {
|
||||
return this.origDateDiff > 0 ? this.dateDiff / this.origDateDiff : 0;
|
||||
},
|
||||
|
||||
longDateStr() {
|
||||
return this.date
|
||||
? utils.getLongDateStr(this.date, false, true)
|
||||
: this.t("memories", "Invalid Date");
|
||||
},
|
||||
|
||||
longDateStrLast() {
|
||||
return this.dateLast
|
||||
? utils.getLongDateStr(this.dateLast, false, true)
|
||||
: this.t("memories", "Invalid Date");
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
init() {
|
||||
const photos = (this.sortedPhotos = [...this.photos] as IPhoto[]);
|
||||
|
||||
// Sort photos by datetaken descending
|
||||
this.photos.sort((a, b) => b.datetaken - a.datetaken);
|
||||
photos.sort((a, b) => b.datetaken - a.datetaken);
|
||||
|
||||
// Get date of newest photo
|
||||
let date = new Date(this.photos[0].datetaken);
|
||||
let date = new Date(photos[0].datetaken);
|
||||
this.year = date.getUTCFullYear().toString();
|
||||
this.month = (date.getUTCMonth() + 1).toString();
|
||||
this.day = date.getUTCDate().toString();
|
||||
this.hour = date.getUTCHours().toString();
|
||||
this.minute = date.getUTCMinutes().toString();
|
||||
this.second = date.getUTCSeconds().toString();
|
||||
this.longDateStr = utils.getLongDateStr(date, false, true);
|
||||
|
||||
// Get date of oldest photo
|
||||
if (this.photos.length > 1) {
|
||||
date = new Date(this.photos[this.photos.length - 1].datetaken);
|
||||
if (photos.length > 1) {
|
||||
date = new Date(photos[photos.length - 1].datetaken);
|
||||
this.yearLast = date.getUTCFullYear().toString();
|
||||
this.monthLast = (date.getUTCMonth() + 1).toString();
|
||||
this.dayLast = date.getUTCDate().toString();
|
||||
this.hourLast = date.getUTCHours().toString();
|
||||
this.minuteLast = date.getUTCMinutes().toString();
|
||||
this.secondLast = date.getUTCSeconds().toString();
|
||||
this.longDateStrLast = utils.getLongDateStr(date, false, true);
|
||||
}
|
||||
},
|
||||
|
||||
validate() {
|
||||
if (!this.date) {
|
||||
throw new Error(this.t("memories", "Invalid Date"));
|
||||
}
|
||||
|
||||
if (this.photos.length > 1) {
|
||||
if (!this.dateLast) {
|
||||
throw new Error(this.t("memories", "Invalid Date"));
|
||||
}
|
||||
|
||||
if (this.dateDiff < -60000) {
|
||||
// 1 minute
|
||||
throw new Error(
|
||||
this.t("memories", "Newest date is older than oldest date")
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
result(photo: IPhoto): undefined | string {
|
||||
if (!this.oldestDirty && !this.newestDirty) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.sortedPhotos.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.sortedPhotos.length === 1) {
|
||||
return this.getExifFormat(this.date);
|
||||
}
|
||||
|
||||
// Interpolate date
|
||||
const dT = this.date.getTime();
|
||||
const doT = this.origDateNewest.getTime();
|
||||
const offset = (photo.datetaken || doT) - doT;
|
||||
return this.getExifFormat(new Date(dT + offset * this.scaleFactor));
|
||||
},
|
||||
|
||||
newestChange(time = false) {
|
||||
if (this.photos.length === 0) {
|
||||
if (this.sortedPhotos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.newestDirty = true;
|
||||
|
||||
// Set the last date to have the same offset to newest date
|
||||
try {
|
||||
const date = new Date(this.photos[0].datetaken);
|
||||
const dateLast = new Date(
|
||||
this.photos[this.photos.length - 1].datetaken
|
||||
);
|
||||
|
||||
const dateNew = this.getDate();
|
||||
const offset = dateNew.getTime() - date.getTime();
|
||||
const dateLastNew = new Date(dateLast.getTime() + offset);
|
||||
const dateNew = this.date;
|
||||
const offset = dateNew.getTime() - this.origDateNewest.getTime();
|
||||
const dateLastNew = new Date(this.origDateOldest.getTime() + offset);
|
||||
|
||||
this.yearLast = dateLastNew.getUTCFullYear().toString();
|
||||
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString();
|
||||
|
@ -255,116 +302,8 @@ export default defineComponent({
|
|||
} catch (error) {}
|
||||
},
|
||||
|
||||
close() {
|
||||
this.photos = [];
|
||||
},
|
||||
|
||||
async saveOne() {
|
||||
// Make PATCH request to update date
|
||||
try {
|
||||
this.processing = true;
|
||||
const fileid = this.photos[0].fileid;
|
||||
await axios.patch<any>(API.IMAGE_SETEXIF(fileid), {
|
||||
raw: {
|
||||
DateTimeOriginal: this.getExifFormat(this.getDate()),
|
||||
},
|
||||
});
|
||||
emit("files:file:updated", { fileid });
|
||||
this.emitRefresh(true);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
if (e.response?.data?.message) {
|
||||
showError(e.response.data.message);
|
||||
} else {
|
||||
showError(e);
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveMany() {
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get difference between newest and oldest date
|
||||
const date = new Date(this.photos[0].datetaken);
|
||||
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
|
||||
const diff = date.getTime() - dateLast.getTime();
|
||||
|
||||
// Get new difference between newest and oldest date
|
||||
let dateNew: Date;
|
||||
let dateLastNew: Date;
|
||||
let diffNew: number;
|
||||
|
||||
try {
|
||||
dateNew = this.getDate();
|
||||
dateLastNew = this.getDateLast();
|
||||
diffNew = dateNew.getTime() - dateLastNew.getTime();
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate if the old is still old
|
||||
if (diffNew < 0) {
|
||||
showError("The newest date must be newer than the oldest date");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark processing
|
||||
this.processing = true;
|
||||
this.photosDone = 0;
|
||||
|
||||
// Create PATCH requests
|
||||
const calls = this.photos.map((p) => async () => {
|
||||
try {
|
||||
let pDate = new Date(p.datetaken);
|
||||
|
||||
// Fallback to start date if invalid date
|
||||
if (isNaN(pDate.getTime())) {
|
||||
pDate = date;
|
||||
}
|
||||
|
||||
const offset = date.getTime() - pDate.getTime();
|
||||
const scale = diff > 0 ? diffNew / diff : 0;
|
||||
const pDateNew = new Date(dateNew.getTime() - offset * scale);
|
||||
await axios.patch<any>(API.IMAGE_SETEXIF(p.fileid), {
|
||||
raw: {
|
||||
DateTimeOriginal: this.getExifFormat(pDateNew),
|
||||
},
|
||||
});
|
||||
emit("files:file:updated", { fileid: p.fileid });
|
||||
} catch (e) {
|
||||
if (e.response?.data?.message) {
|
||||
showError(e.response.data.message);
|
||||
} else {
|
||||
showError(e);
|
||||
}
|
||||
} finally {
|
||||
this.photosDone++;
|
||||
}
|
||||
});
|
||||
|
||||
for await (const _ of dav.runInParallel(calls, 10)) {
|
||||
// nothing to do
|
||||
}
|
||||
this.processing = false;
|
||||
this.emitRefresh(true);
|
||||
this.close();
|
||||
},
|
||||
|
||||
async save() {
|
||||
if (this.photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.photos.length === 1) {
|
||||
return await this.saveOne();
|
||||
}
|
||||
|
||||
return await this.saveMany();
|
||||
oldestChange() {
|
||||
this.oldestDirty = true;
|
||||
},
|
||||
|
||||
getExifFormat(date: Date) {
|
||||
|
@ -377,54 +316,36 @@ export default defineComponent({
|
|||
return `${year}:${month}:${day} ${hour}:${minute}:${second}`;
|
||||
},
|
||||
|
||||
getDate() {
|
||||
const dateNew = new Date();
|
||||
const year = parseInt(this.year, 10);
|
||||
const month = parseInt(this.month, 10) - 1;
|
||||
const day = parseInt(this.day, 10);
|
||||
const hour = parseInt(this.hour, 10);
|
||||
const minute = parseInt(this.minute, 10);
|
||||
const second = parseInt(this.second, 10) || 0;
|
||||
makeDate(
|
||||
yearS: string,
|
||||
monthS: string,
|
||||
dayS: string,
|
||||
hourS: string,
|
||||
minuteS: string,
|
||||
secondS: string
|
||||
) {
|
||||
const date = new Date();
|
||||
const year = parseInt(yearS, 10);
|
||||
const month = parseInt(monthS, 10) - 1;
|
||||
const day = parseInt(dayS, 10);
|
||||
const hour = parseInt(hourS, 10);
|
||||
const minute = parseInt(minuteS, 10);
|
||||
const second = parseInt(secondS, 10) || 0;
|
||||
|
||||
if (isNaN(year)) throw new Error("Invalid year");
|
||||
if (isNaN(month)) throw new Error("Invalid month");
|
||||
if (isNaN(day)) throw new Error("Invalid day");
|
||||
if (isNaN(hour)) throw new Error("Invalid hour");
|
||||
if (isNaN(minute)) throw new Error("Invalid minute");
|
||||
if (isNaN(second)) throw new Error("Invalid second");
|
||||
if (isNaN(year)) return null;
|
||||
if (isNaN(month)) return null;
|
||||
if (isNaN(day)) return null;
|
||||
if (isNaN(hour)) return null;
|
||||
if (isNaN(minute)) return null;
|
||||
if (isNaN(second)) return null;
|
||||
|
||||
dateNew.setUTCFullYear(year);
|
||||
dateNew.setUTCMonth(month);
|
||||
dateNew.setUTCDate(day);
|
||||
dateNew.setUTCHours(hour);
|
||||
dateNew.setUTCMinutes(minute);
|
||||
dateNew.setUTCSeconds(second);
|
||||
return dateNew;
|
||||
},
|
||||
|
||||
getDateLast() {
|
||||
const dateNew = new Date();
|
||||
const year = parseInt(this.yearLast, 10);
|
||||
const month = parseInt(this.monthLast, 10) - 1;
|
||||
const day = parseInt(this.dayLast, 10);
|
||||
const hour = parseInt(this.hourLast, 10);
|
||||
const minute = parseInt(this.minuteLast, 10);
|
||||
const second = parseInt(this.secondLast, 10) || 0;
|
||||
|
||||
if (isNaN(year)) throw new Error("Invalid last year");
|
||||
if (isNaN(month)) throw new Error("Invalid last month");
|
||||
if (isNaN(day)) throw new Error("Invalid last day");
|
||||
if (isNaN(hour)) throw new Error("Invalid last hour");
|
||||
if (isNaN(minute)) throw new Error("Invalid last minute");
|
||||
if (isNaN(second)) throw new Error("Invalid last second");
|
||||
|
||||
dateNew.setUTCFullYear(year);
|
||||
dateNew.setUTCMonth(month);
|
||||
dateNew.setUTCDate(day);
|
||||
dateNew.setUTCHours(hour);
|
||||
dateNew.setUTCMinutes(minute);
|
||||
dateNew.setUTCSeconds(second);
|
||||
return dateNew;
|
||||
date.setUTCFullYear(year);
|
||||
date.setUTCMonth(month);
|
||||
date.setUTCDate(day);
|
||||
date.setUTCHours(hour);
|
||||
date.setUTCMinutes(minute);
|
||||
date.setUTCSeconds(second);
|
||||
return date;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -444,18 +365,12 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 0.9em;
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
|
||||
.oldest {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.info-pad {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&.warn {
|
||||
color: #f44336;
|
||||
font-size: 0.8em;
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,64 +1,45 @@
|
|||
<template>
|
||||
<Modal v-if="show" @close="close">
|
||||
<template #title>
|
||||
{{ t("memories", "Edit EXIF Data") }}
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton
|
||||
@click="save"
|
||||
class="button"
|
||||
type="error"
|
||||
v-if="exif"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{ t("memories", "Update Exif") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<div v-if="exif">
|
||||
<div class="fields">
|
||||
<NcTextField
|
||||
v-for="field of fields"
|
||||
:key="field.field"
|
||||
:value.sync="exif[field.field]"
|
||||
class="field"
|
||||
:label="field.label"
|
||||
:label-visible="true"
|
||||
:placeholder="field.label"
|
||||
/>
|
||||
</div>
|
||||
<div class="fields" v-if="exif">
|
||||
<div v-for="field of fields" :key="field.field">
|
||||
<label :for="'exif-field-' + field.field">
|
||||
{{ label(field) }}
|
||||
</label>
|
||||
<NcTextField
|
||||
class="field"
|
||||
:id="'exif-field-' + field.field"
|
||||
:value.sync="exif[field.field]"
|
||||
:label-outside="true"
|
||||
:placeholder="placeholder(field)"
|
||||
@input="dirty[field.field] = true"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { IPhoto } from "../../types";
|
||||
|
||||
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
|
||||
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
|
||||
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { emit } from "@nextcloud/event-bus";
|
||||
import axios from "@nextcloud/axios";
|
||||
import { translate as t } from "@nextcloud/l10n";
|
||||
|
||||
import Modal from "./Modal.vue";
|
||||
import { API } from "../../services/API";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
photos: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
photo: null as IPhoto,
|
||||
show: false,
|
||||
exif: null as any,
|
||||
processing: false,
|
||||
dirty: {},
|
||||
|
||||
fields: [
|
||||
{
|
||||
field: "Title",
|
||||
|
@ -68,10 +49,6 @@ export default defineComponent({
|
|||
field: "Description",
|
||||
label: t("memories", "Description"),
|
||||
},
|
||||
{
|
||||
field: "DateTimeOriginal",
|
||||
label: t("memories", "Date Taken"),
|
||||
},
|
||||
{
|
||||
field: "Label",
|
||||
label: t("memories", "Label"),
|
||||
|
@ -95,67 +72,52 @@ export default defineComponent({
|
|||
],
|
||||
}),
|
||||
|
||||
methods: {
|
||||
emitRefresh(val: boolean) {
|
||||
this.$emit("refresh", val);
|
||||
},
|
||||
mounted() {
|
||||
let exif = {};
|
||||
for (const field of this.fields) {
|
||||
exif[field.field] = null;
|
||||
this.dirty[field.field] = false;
|
||||
}
|
||||
|
||||
async open(photo: IPhoto) {
|
||||
this.show = true;
|
||||
const res = await axios.get(API.IMAGE_INFO(photo.fileid));
|
||||
if (!res.data?.exif) return;
|
||||
const photos = this.photos as IPhoto[];
|
||||
for (const photo of photos) {
|
||||
if (!photo.imageInfo?.exif) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exif: any = {};
|
||||
for (const field of this.fields) {
|
||||
exif[field.field] = res.data.exif[field.field] || "";
|
||||
}
|
||||
|
||||
this.photo = photo;
|
||||
this.exif = exif;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.exif = null;
|
||||
this.photo = null;
|
||||
this.show = false;
|
||||
},
|
||||
|
||||
async saveOne() {
|
||||
try {
|
||||
// remove all null values from this.exif
|
||||
const exif = JSON.parse(JSON.stringify(this.exif));
|
||||
for (const key in exif) {
|
||||
if (!exif[key]) {
|
||||
delete exif[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Make PATCH request to update date
|
||||
this.processing = true;
|
||||
const fileid = this.photo.fileid;
|
||||
await axios.patch<any>(API.IMAGE_SETEXIF(fileid), {
|
||||
raw: exif,
|
||||
});
|
||||
emit("files:file:updated", { fileid });
|
||||
this.emitRefresh(true);
|
||||
this.close();
|
||||
} catch (e) {
|
||||
if (e.response?.data?.message) {
|
||||
showError(e.response.data.message);
|
||||
const ePhoto = photo.imageInfo?.exif[field.field];
|
||||
const eCurr = exif[field.field];
|
||||
if (ePhoto && (eCurr === null || ePhoto === eCurr)) {
|
||||
exif[field.field] = ePhoto;
|
||||
} else {
|
||||
showError(e);
|
||||
exif[field.field] = "";
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.exif = exif;
|
||||
},
|
||||
|
||||
methods: {
|
||||
changes() {
|
||||
const diff = {};
|
||||
for (const field of this.fields) {
|
||||
if (this.dirty[field.field]) {
|
||||
diff[field.field] = this.exif[field.field];
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
},
|
||||
|
||||
async save() {
|
||||
if (!this.photo) {
|
||||
return;
|
||||
}
|
||||
label(field: any) {
|
||||
return field.label + (this.dirty[field.field] ? "*" : "");
|
||||
},
|
||||
|
||||
return await this.saveOne();
|
||||
placeholder(field: any) {
|
||||
return this.dirty[field.field]
|
||||
? t("memories", "Empty")
|
||||
: t("memories", "Unchanged");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1,219 @@
|
|||
<template>
|
||||
<Modal v-if="show" @close="close">
|
||||
<template #title>
|
||||
{{ t("memories", "Edit Metadata") }}
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<NcButton
|
||||
@click="save"
|
||||
class="button"
|
||||
type="error"
|
||||
v-if="photos"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{ t("memories", "Save") }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<div v-if="photos">
|
||||
<div class="title-text">
|
||||
{{ t("memories", "Date / Time") }}
|
||||
</div>
|
||||
<EditDate ref="editDate" :photos="photos" />
|
||||
|
||||
<div class="title-text">
|
||||
{{ t("memories", "EXIF Fields") }}
|
||||
</div>
|
||||
<EditExif ref="editExif" :photos="photos" />
|
||||
</div>
|
||||
|
||||
<div v-if="processing">
|
||||
<NcProgressBar :value="progress" :error="true" />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { IPhoto } from "../../types";
|
||||
|
||||
import NcButton from "@nextcloud/vue/dist/Components/NcButton";
|
||||
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
|
||||
const NcProgressBar = () =>
|
||||
import("@nextcloud/vue/dist/Components/NcProgressBar");
|
||||
import Modal from "./Modal.vue";
|
||||
|
||||
import EditExif from "./EditExif.vue";
|
||||
import EditDate from "./EditDate.vue";
|
||||
|
||||
import { showError } from "@nextcloud/dialogs";
|
||||
import { emit } from "@nextcloud/event-bus";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
import * as dav from "../../services/DavRequests";
|
||||
import { API } from "../../services/API";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
NcProgressBar,
|
||||
Modal,
|
||||
|
||||
EditExif,
|
||||
EditDate,
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
photos: null as IPhoto[],
|
||||
show: false,
|
||||
processing: false,
|
||||
progress: 0,
|
||||
state: 0,
|
||||
}),
|
||||
|
||||
methods: {
|
||||
emitRefresh(val: boolean) {
|
||||
this.$emit("refresh", val);
|
||||
},
|
||||
|
||||
async open(photos: IPhoto[]) {
|
||||
const state = (this.state = Math.random());
|
||||
this.show = true;
|
||||
this.processing = true;
|
||||
|
||||
let done = 0;
|
||||
this.progress = 0;
|
||||
|
||||
// Load metadata for all photos
|
||||
const calls = photos.map((p) => async () => {
|
||||
try {
|
||||
const res = await axios.get<any>(API.IMAGE_INFO(p.fileid));
|
||||
|
||||
// Validate response
|
||||
p.imageInfo = null;
|
||||
if (typeof res.data.datetaken !== "number") {
|
||||
console.error("Invalid date for", p.fileid);
|
||||
return;
|
||||
}
|
||||
p.datetaken = res.data.datetaken * 1000;
|
||||
p.imageInfo = res.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to get date info for", p.fileid, error);
|
||||
} finally {
|
||||
done++;
|
||||
this.progress = Math.round((done * 100) / photos.length);
|
||||
}
|
||||
});
|
||||
|
||||
for await (const _ of dav.runInParallel(calls, 8)) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
// Check if already quit
|
||||
if (!this.show || this.state !== state) return;
|
||||
|
||||
// Check for anything invalid
|
||||
const invalid = photos.filter((p) => !p.imageInfo);
|
||||
if (invalid.length > 0) {
|
||||
showError(
|
||||
this.t("memories", "Failed to load metadata for {n} photos.", {
|
||||
n: invalid.length,
|
||||
})
|
||||
);
|
||||
photos = photos.filter((p) => p.imageInfo);
|
||||
}
|
||||
|
||||
this.photos = photos;
|
||||
this.processing = false;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.photos = null;
|
||||
this.show = false;
|
||||
},
|
||||
|
||||
async save() {
|
||||
// Perform validation
|
||||
try {
|
||||
(<any>this.$refs.editDate).validate();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get exif fields diff
|
||||
const exifChanges = (<any>this.$refs.editExif).changes();
|
||||
|
||||
// Start processing
|
||||
let done = 0;
|
||||
this.progress = 0;
|
||||
this.processing = true;
|
||||
|
||||
// Update exif fields
|
||||
const calls = this.photos.map((p) => async () => {
|
||||
try {
|
||||
const fileid = p.fileid;
|
||||
|
||||
// Basic EXIF fields
|
||||
const raw = JSON.parse(JSON.stringify(exifChanges));
|
||||
|
||||
// Date
|
||||
const date = (<any>this.$refs.editDate).result(p);
|
||||
if (date) {
|
||||
raw.DateTimeOriginal = date;
|
||||
}
|
||||
|
||||
if (Object.keys(raw).length === 0) {
|
||||
console.log("No changes for", p.fileid);
|
||||
return;
|
||||
} else {
|
||||
console.log("Saving EXIF info for", p.fileid, raw);
|
||||
}
|
||||
|
||||
await axios.patch<any>(API.IMAGE_SETEXIF(fileid), { raw });
|
||||
|
||||
// Clear imageInfo in photo
|
||||
p.imageInfo = null;
|
||||
|
||||
// Emit event to update photo
|
||||
emit("files:file:updated", { fileid });
|
||||
} catch (e) {
|
||||
console.error("Failed to save EXIF info for", p.fileid, e);
|
||||
if (e.response?.data?.message) {
|
||||
showError(e.response.data.message);
|
||||
} else {
|
||||
showError(e);
|
||||
}
|
||||
} finally {
|
||||
done++;
|
||||
this.progress = Math.round((done * 100) / this.photos.length);
|
||||
}
|
||||
});
|
||||
|
||||
for await (const _ of dav.runInParallel(calls, 8)) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
this.close();
|
||||
|
||||
this.emitRefresh(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.title-text {
|
||||
font-size: 1.05em;
|
||||
font-weight: 500;
|
||||
margin-top: 25px;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -132,12 +132,12 @@
|
|||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Edit EXIF Data')"
|
||||
:aria-label="t('memories', 'Edit Metadata')"
|
||||
v-if="!routeIsPublic"
|
||||
@click="editExif"
|
||||
@click="editMetadata"
|
||||
:close-after-click="true"
|
||||
>
|
||||
{{ t("memories", "Edit EXIF Data") }}
|
||||
{{ t("memories", "Edit Metadata") }}
|
||||
<template #icon>
|
||||
<EditFileIcon :size="24" />
|
||||
</template>
|
||||
|
@ -1165,10 +1165,10 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
/**
|
||||
* Edit EXIF data for current photo
|
||||
* Edit Metadata for current photo
|
||||
*/
|
||||
editExif() {
|
||||
globalThis.editExif(globalThis.currentViewerPhoto);
|
||||
editMetadata() {
|
||||
globalThis.editMetadata(globalThis.currentViewerPhoto);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue