Add single file exif update (#42)

pull/62/head
Varun Patil 2022-09-25 06:21:40 -07:00
parent df7866b876
commit 1e297f86f4
7 changed files with 446 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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