Add single file exif update (#42)
parent
df7866b876
commit
1e297f86f4
|
@ -17,6 +17,8 @@ return [
|
|||
// API
|
||||
['name' => 'api#days', 'url' => '/api/days', 'verb' => 'GET'],
|
||||
['name' => 'api#day', 'url' => '/api/days/{id}', 'verb' => 'GET'],
|
||||
['name' => 'api#imageInfo', 'url' => '/api/info/{id}', 'verb' => 'GET'],
|
||||
['name' => 'api#imageEdit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
|
||||
|
||||
// Config API
|
||||
['name' => 'api#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],
|
||||
|
|
|
@ -25,10 +25,9 @@ declare(strict_types=1);
|
|||
|
||||
namespace OCA\Memories\Controller;
|
||||
|
||||
use OC\Files\Search\SearchComparison;
|
||||
use OC\Files\Search\SearchQuery;
|
||||
use OCA\Memories\AppInfo\Application;
|
||||
use OCA\Memories\Db\TimelineQuery;
|
||||
use OCA\Memories\Db\TimelineWrite;
|
||||
use OCA\Memories\Exif;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
|
@ -40,7 +39,6 @@ use OCP\IRequest;
|
|||
use OCP\IUserSession;
|
||||
use OCP\Files\FileInfo;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\Search\ISearchComparison;
|
||||
|
||||
class ApiController extends Controller {
|
||||
private IConfig $config;
|
||||
|
@ -48,6 +46,7 @@ class ApiController extends Controller {
|
|||
private IDBConnection $connection;
|
||||
private IRootFolder $rootFolder;
|
||||
private TimelineQuery $timelineQuery;
|
||||
private TimelineWrite $timelineWrite;
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
|
@ -63,6 +62,7 @@ class ApiController extends Controller {
|
|||
$this->connection = $connection;
|
||||
$this->rootFolder = $rootFolder;
|
||||
$this->timelineQuery = new TimelineQuery($this->connection);
|
||||
$this->timelineWrite = new TimelineWrite($connection);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -232,6 +232,84 @@ class ApiController extends Controller {
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Get image info for one file
|
||||
* @param string fileid
|
||||
*/
|
||||
public function imageInfo(string $id): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (is_null($user)) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById(intval($id));
|
||||
if (count($file) === 0) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Get the image info
|
||||
$info = $this->timelineQuery->getInfoById($file->getId());
|
||||
|
||||
return new JSONResponse($info, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*
|
||||
* Change exif data for one file
|
||||
* @param string fileid
|
||||
*/
|
||||
public function imageEdit(string $id): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (is_null($user)) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById(intval($id));
|
||||
if (count($file) === 0) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// TODO: check permissions
|
||||
|
||||
// Get new date from body
|
||||
$body = $this->request->getParams();
|
||||
if (!isset($body['date'])) {
|
||||
return new JSONResponse(["message" => "Missing date"], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Make sure the date is valid
|
||||
try {
|
||||
Exif::parseExifDate($body['date']);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update date
|
||||
try {
|
||||
$res = Exif::updateExifDate($file, $body['date']);
|
||||
if ($res === false) {
|
||||
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(["message" => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Reprocess the file
|
||||
$this->timelineWrite->processFile($file, true);
|
||||
|
||||
return $this->imageInfo($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
|
|
|
@ -14,4 +14,21 @@ class TimelineQuery {
|
|||
public function __construct(IDBConnection $connection) {
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
public function getInfoById(int $id): array {
|
||||
$qb = $this->connection->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from('memories')
|
||||
->where($qb->expr()->eq('fileid', $qb->createNamedParameter($id, \PDO::PARAM_INT)));
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
$result->closeCursor();
|
||||
|
||||
return [
|
||||
'fileid' => intval($row['fileid']),
|
||||
'dayid' => intval($row['dayid']),
|
||||
'datetaken' => $row['datetaken'],
|
||||
];
|
||||
}
|
||||
}
|
171
lib/Exif.php
171
lib/Exif.php
|
@ -243,6 +243,30 @@ class Exif {
|
|||
return $json[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date from exif format and throw error if invalid
|
||||
*
|
||||
* @param string $dt
|
||||
* @return int unix timestamp
|
||||
*/
|
||||
public static function parseExifDate($date) {
|
||||
$dt = $date;
|
||||
if (isset($dt) && is_string($dt) && !empty($dt)) {
|
||||
$dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present
|
||||
$dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt);
|
||||
if (!$dt) {
|
||||
throw new \Exception("Invalid date: $date");
|
||||
}
|
||||
if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D.
|
||||
return $dt->getTimestamp();
|
||||
} else {
|
||||
throw new \Exception("Date too old: $date");
|
||||
}
|
||||
} else {
|
||||
throw new \Exception("No date provided");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date taken from either the file or exif data if available.
|
||||
* @param File $file
|
||||
|
@ -255,13 +279,9 @@ class Exif {
|
|||
}
|
||||
|
||||
// Check if found something
|
||||
if (isset($dt) && is_string($dt) && !empty($dt)) {
|
||||
$dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present
|
||||
$dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt, new \DateTimeZone("UTC"));
|
||||
if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D.
|
||||
return $dt->getTimestamp();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return self::parseExifDate($dt);
|
||||
} catch (\Exception $ex) {}
|
||||
|
||||
// Fall back to creation time
|
||||
$dateTaken = $file->getCreationTime();
|
||||
|
@ -272,4 +292,141 @@ class Exif {
|
|||
}
|
||||
return $dateTaken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update exif date using exiftool
|
||||
*
|
||||
* @param File $file
|
||||
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
|
||||
*/
|
||||
public static function updateExifDate(File &$file, string $newDate) {
|
||||
// Check for local files -- this is easier
|
||||
if (!$file->isEncrypted() && $file->getStorage()->isLocal()) {
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
if (is_string($path)) {
|
||||
return self::updateExifDateForLocalFile($path, $newDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Use a stream
|
||||
return self::updateExifDateForStreamFile($file, $newDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update exif date using exiftool for a local file
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
|
||||
* @return bool
|
||||
*/
|
||||
private static function updateExifDateForLocalFile(string $path, string $newDate) {
|
||||
$cmd = ['exiftool', '-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal=' . $newDate, $path];
|
||||
$proc = proc_open($cmd, [
|
||||
1 => array('pipe', 'w'),
|
||||
2 => array('pipe', 'w'),
|
||||
], $pipes);
|
||||
$stdout = self::readOrTimeout($pipes[1], 300000);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_terminate($proc);
|
||||
if (strpos($stdout, 'error') !== false) {
|
||||
error_log("Exiftool error: $stdout");
|
||||
throw new \Exception("Could not update exif date: " . $stdout);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update exif date for stream
|
||||
*
|
||||
* @param File $file
|
||||
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
|
||||
*/
|
||||
public static function updateExifDateForStreamFile(File &$file, string $newDate) {
|
||||
// Temp file for output, so we can compare sizes before writing to the actual file
|
||||
$tmpfile = tmpfile();
|
||||
|
||||
try {
|
||||
// Start exiftool and output to json
|
||||
$pipes = [];
|
||||
$proc = proc_open([
|
||||
'exiftool', '-api', 'QuickTimeUTC=1',
|
||||
'-overwrite_original', '-DateTimeOriginal=' . $newDate, '-'
|
||||
], [
|
||||
0 => array('pipe', 'rb'),
|
||||
1 => array('pipe', 'w'),
|
||||
2 => array('pipe', 'w'),
|
||||
], $pipes);
|
||||
|
||||
// Write the file to exiftool's stdin
|
||||
// Warning: this is slow for big files
|
||||
$in = $file->fopen('rb');
|
||||
if (!$in) {
|
||||
throw new \Exception('Could not open file');
|
||||
}
|
||||
$origLen = stream_copy_to_stream($in, $pipes[0]);
|
||||
fclose($in);
|
||||
fclose($pipes[0]);
|
||||
|
||||
// Get output from exiftool
|
||||
stream_set_blocking($pipes[1], false);
|
||||
$newLen = 0;
|
||||
|
||||
try {
|
||||
// Read and copy stdout of exiftool to the temp file
|
||||
$waitedMs = 0;
|
||||
$timeout = 300000;
|
||||
while ($waitedMs < $timeout && !feof($pipes[1])) {
|
||||
$r = stream_copy_to_stream($pipes[1], $tmpfile, 1024 * 1024);
|
||||
$newLen += $r;
|
||||
if ($r === 0) {
|
||||
$waitedMs++;
|
||||
usleep(1000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ($waitedMs >= $timeout) {
|
||||
throw new \Exception('Timeout');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
error_log("Exiftool timeout for file stream: " . $ex->getMessage());
|
||||
throw new \Exception("Could not read from Exiftool");
|
||||
} finally {
|
||||
// Close the pipes
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_terminate($proc);
|
||||
}
|
||||
|
||||
// Check the new length of the file
|
||||
// If the new length and old length are more different than 1KB, abort
|
||||
if (abs($newLen - $origLen) > 1024) {
|
||||
error_log("Exiftool error: new length $newLen, old length $origLen");
|
||||
throw new \Exception("Exiftool error: new length $newLen, old length $origLen");
|
||||
}
|
||||
|
||||
// Write the temp file to the actual file
|
||||
fseek($tmpfile, 0);
|
||||
$out = $file->fopen('wb');
|
||||
if (!$out) {
|
||||
throw new \Exception('Could not open file for writing');
|
||||
}
|
||||
$wroteBytes = 0;
|
||||
try {
|
||||
$wroteBytes = stream_copy_to_stream($tmpfile, $out);
|
||||
} finally {
|
||||
fclose($out);
|
||||
}
|
||||
if ($wroteBytes !== $newLen) {
|
||||
error_log("Exiftool error: wrote $r bytes, expected $newLen");
|
||||
throw new \Exception("Could not write to file");
|
||||
}
|
||||
|
||||
// All done at this point
|
||||
return true;
|
||||
} finally {
|
||||
// Close the temp file
|
||||
fclose($tmpfile);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,160 @@
|
|||
<template>
|
||||
<NcModal
|
||||
v-if="photos.length > 0"
|
||||
size="small"
|
||||
@close="close"
|
||||
:outTransition="true"
|
||||
:hasNext="false"
|
||||
:hasPrevious="false">
|
||||
|
||||
<div class="container">
|
||||
<div class="head">
|
||||
<span>{{ t('memories', 'Edit Date/Time') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="photos.length === 1 && longDateStr">
|
||||
{{ longDateStr }}
|
||||
|
||||
<div class="fields">
|
||||
<NcTextField :value.sync="year"
|
||||
class="field"
|
||||
:label="t('memories', 'Year')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Year')" />
|
||||
<NcTextField :value.sync="month"
|
||||
class="field"
|
||||
:label="t('memories', 'Month')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Month')" />
|
||||
<NcTextField :value.sync="day"
|
||||
class="field"
|
||||
:label="t('memories', 'Day')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Day')" />
|
||||
<NcTextField :value.sync="hour"
|
||||
class="field"
|
||||
:label="t('memories', 'Time')" :label-visible="true"
|
||||
:placeholder="t('memories', 'Hour')" />
|
||||
<NcTextField :value.sync="minute"
|
||||
class="field"
|
||||
:label="t('memories', 'Minute')"
|
||||
:placeholder="t('memories', 'Minute')" />
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<NcButton @click="save" class="button" type="primary">
|
||||
{{ t('memories', 'Save') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NcModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins } from 'vue-property-decorator';
|
||||
import GlobalMixin from '../mixins/GlobalMixin';
|
||||
import { IPhoto } from '../types';
|
||||
|
||||
import { NcButton, NcModal, NcTextField } from '@nextcloud/vue';
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import axios from '@nextcloud/axios'
|
||||
import * as utils from '../services/Utils';
|
||||
|
||||
const INFO_API_URL = '/apps/memories/api/info/{id}';
|
||||
const EDIT_API_URL = '/apps/memories/api/edit/{id}';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
NcButton,
|
||||
NcModal,
|
||||
NcTextField,
|
||||
}
|
||||
})
|
||||
export default class EditDate extends Mixins(GlobalMixin) {
|
||||
private photos: IPhoto[] = [];
|
||||
|
||||
private longDateStr: string = '';
|
||||
private year: string = "0";
|
||||
private month: string = "0";
|
||||
private day: string = "0";
|
||||
private hour: string = "0";
|
||||
private minute: string = "0";
|
||||
private second: string = "0";
|
||||
|
||||
public async open(photos: IPhoto[]) {
|
||||
this.photos = photos;
|
||||
if (photos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await axios.get<any>(generateUrl(INFO_API_URL, { id: this.photos[0].fileid }));
|
||||
if (typeof res.data.datetaken !== "string") {
|
||||
console.error("Invalid date");
|
||||
return;
|
||||
}
|
||||
|
||||
const utcEpoch = Date.parse(res.data.datetaken + " UTC");
|
||||
const date = new Date(utcEpoch);
|
||||
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);
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.photos = [];
|
||||
}
|
||||
|
||||
public async save() {
|
||||
// Pad zeros to the left
|
||||
this.year = this.year.padStart(4, '0');
|
||||
this.month = this.month.padStart(2, '0');
|
||||
this.day = this.day.padStart(2, '0');
|
||||
this.hour = this.hour.padStart(2, '0');
|
||||
this.minute = this.minute.padStart(2, '0');
|
||||
this.second = this.second.padStart(2, '0');
|
||||
|
||||
// Make PATCH request to update date
|
||||
try {
|
||||
const res = await axios.patch<any>(generateUrl(EDIT_API_URL, { id: this.photos[0].fileid }), {
|
||||
date: `${this.year}:${this.month}:${this.day} ${this.hour}:${this.minute}:${this.second}`,
|
||||
});
|
||||
this.close();
|
||||
} catch (e) {
|
||||
if (e.response?.data?.message) {
|
||||
showError(e.response.data.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
margin: 20px;
|
||||
|
||||
.head {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
margin-top: 5px;
|
||||
.field {
|
||||
width: 4.1em;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
|
||||
button {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -111,8 +111,16 @@
|
|||
{{ t('memories', 'Favorite') }}
|
||||
<template #icon> <Star :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('memories', 'Edit Date/Time')"
|
||||
@click="editDateSelection" close-after-click>
|
||||
{{ t('memories', 'Edit Date/Time') }}
|
||||
<template #icon> <EditIcon :size="20" /> </template>
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</div>
|
||||
|
||||
<EditDate ref="editDate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -129,6 +137,7 @@ import * as utils from "../services/Utils";
|
|||
import axios from '@nextcloud/axios'
|
||||
import Folder from "./Folder.vue";
|
||||
import Photo from "./Photo.vue";
|
||||
import EditDate from "./EditDate.vue";
|
||||
import FolderTopMatter from "./FolderTopMatter.vue";
|
||||
import UserConfig from "../mixins/UserConfig";
|
||||
|
||||
|
@ -137,6 +146,7 @@ import Download from 'vue-material-design-icons/Download.vue';
|
|||
import Delete from 'vue-material-design-icons/Delete.vue';
|
||||
import Close from 'vue-material-design-icons/Close.vue';
|
||||
import CheckCircle from 'vue-material-design-icons/CheckCircle.vue';
|
||||
import EditIcon from 'vue-material-design-icons/ClockEdit.vue';
|
||||
|
||||
const SCROLL_LOAD_DELAY = 100; // Delay in loading data when scrolling
|
||||
const MAX_PHOTO_WIDTH = 175; // Max width of a photo
|
||||
|
@ -155,6 +165,7 @@ for (const [key, value] of Object.entries(API_ROUTES)) {
|
|||
components: {
|
||||
Folder,
|
||||
Photo,
|
||||
EditDate,
|
||||
FolderTopMatter,
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
|
@ -165,6 +176,7 @@ for (const [key, value] of Object.entries(API_ROUTES)) {
|
|||
Delete,
|
||||
Close,
|
||||
CheckCircle,
|
||||
EditIcon,
|
||||
}
|
||||
})
|
||||
export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
||||
|
@ -1057,6 +1069,13 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit date dialog
|
||||
*/
|
||||
async editDateSelection() {
|
||||
(<any>this.$refs.editDate).open(Array.from(this.selection.values()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete elements from main view with some animation
|
||||
* This function looks horribly slow, probably isn't that bad
|
||||
|
|
|
@ -15,12 +15,14 @@ export function getShortDateStr(date: Date) {
|
|||
}
|
||||
|
||||
/** Get long date string with optional year if same as current */
|
||||
export function getLongDateStr(date: Date, skipYear=false) {
|
||||
export function getLongDateStr(date: Date, skipYear=false, time=false) {
|
||||
return date.toLocaleDateString(getCanonicalLocale(), {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: (skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()) ? undefined : 'numeric',
|
||||
timeZone: 'UTC',
|
||||
hour: time ? 'numeric' : undefined,
|
||||
minute: time ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue