diff --git a/appinfo/routes.php b/appinfo/routes.php
index 8b118c2b..be5641a9 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -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'],
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index e00ae44b..19a4db7e 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -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
*
diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php
index c2dca5dd..0ffd279a 100644
--- a/lib/Db/TimelineQuery.php
+++ b/lib/Db/TimelineQuery.php
@@ -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'],
+ ];
+ }
}
\ No newline at end of file
diff --git a/lib/Exif.php b/lib/Exif.php
index d690cdef..90933da9 100644
--- a/lib/Exif.php
+++ b/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);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/components/EditDate.vue b/src/components/EditDate.vue
new file mode 100644
index 00000000..9a7af468
--- /dev/null
+++ b/src/components/EditDate.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+ {{ t('memories', 'Edit Date/Time') }}
+
+
+
+ {{ longDateStr }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('memories', 'Save') }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index 80896a9e..5610fd1b 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -111,8 +111,16 @@
{{ t('memories', 'Favorite') }}
+
+ {{ t('memories', 'Edit Date/Time') }}
+
+
+
+
@@ -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() {
+ (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
diff --git a/src/services/Utils.ts b/src/services/Utils.ts
index 39ab1109..8127ba3b 100644
--- a/src/services/Utils.ts
+++ b/src/services/Utils.ts
@@ -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,
});
}
\ No newline at end of file