diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a912c27..263cb147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This file is manually updated. Please file an issue if something is missing. ## v4.12.0, v3.12.0 - **Feature**: Allow bulk editing of EXIF attributes other than date/time +- **Feature**: Allow (optionally bulk) editing of collaborative tags - **Feature**: Show list of tags in sidebar - **Feature**: Configurable album sorting order ([#377](https://github.com/pulsejet/memories/issues/377)) - **Feature**: Allow archiving photos throw folder view ([#350](https://github.com/pulsejet/memories/issues/350)) diff --git a/appinfo/routes.php b/appinfo/routes.php index 89b1f481..e85cf5f3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -57,6 +57,7 @@ return [ ['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'], ['name' => 'Tags#preview', 'url' => '/api/tags/preview/{tag}', 'verb' => 'GET'], + ['name' => 'Tags#set', 'url' => '/api/tags/set/{id}', 'verb' => 'PATCH'], ['name' => 'People#recognizePeople', 'url' => '/api/recognize/people', 'verb' => 'GET'], ['name' => 'People#recognizePeoplePreview', 'url' => '/api/recognize/people/preview/{id}', 'verb' => 'GET'], diff --git a/lib/Controller/TagsController.php b/lib/Controller/TagsController.php index b6383d24..ad244384 100644 --- a/lib/Controller/TagsController.php +++ b/lib/Controller/TagsController.php @@ -96,4 +96,39 @@ class TagsController extends ApiBase return (int) $item['fileid']; }, $list)); } + + /** + * @NoAdminRequired + * + * Set tags for a file + */ + public function set(int $id, array $add, array $remove): JSONResponse + { + // Check tags enabled for this user + if (!$this->tagsIsEnabled()) { + return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED); + } + + // Check the user is allowed to edit the file + $file = $this->getUserFile($id); + if (null === $file) { + return new JSONResponse([ + 'message' => 'File not found', + ], Http::STATUS_NOT_FOUND); + } + + // Check the user is allowed to edit the file + if (!$file->isUpdateable() || !($file->getPermissions() & \OCP\Constants::PERMISSION_UPDATE)) { + return new JSONResponse(['message' => 'Cannot update this file'], Http::STATUS_FORBIDDEN); + } + + // Get mapper from tags to objects + $om = \OC::$server->get(\OCP\SystemTag\ISystemTagObjectMapper::class); + + // Add and remove tags + $om->assignTags((string) $id, 'files', $add); + $om->unassignTags((string) $id, 'files', $remove); + + return new JSONResponse([], Http::STATUS_OK); + } } diff --git a/src/components/Metadata.vue b/src/components/Metadata.vue index 0349e03c..5dec55af 100644 --- a/src/components/Metadata.vue +++ b/src/components/Metadata.vue @@ -141,7 +141,6 @@ export default defineComponent({ title: title || this.t("memories", "No title"), subtitle: [desc || this.t("memories", "No description")], icon: InfoIcon, - edit: () => globalThis.editMetadata([globalThis.currentViewerPhoto]), }); } diff --git a/src/components/modal/EditExif.vue b/src/components/modal/EditExif.vue index 64c714a2..37bf43b4 100644 --- a/src/components/modal/EditExif.vue +++ b/src/components/modal/EditExif.vue @@ -100,7 +100,7 @@ export default defineComponent({ }, methods: { - changes() { + result() { const diff = {}; for (const field of this.fields) { if (this.dirty[field.field]) { diff --git a/src/components/modal/EditMetadataModal.vue b/src/components/modal/EditMetadataModal.vue index 2cb34e79..66b0a342 100644 --- a/src/components/modal/EditMetadataModal.vue +++ b/src/components/modal/EditMetadataModal.vue @@ -17,15 +17,26 @@
-
- {{ t("memories", "Date / Time") }} +
+
+ {{ t("memories", "Date / Time") }} +
+
- -
- {{ t("memories", "EXIF Fields") }} +
+
+ {{ t("memories", "Collaborative Tags") }} +
+ +
+ +
+
+ {{ t("memories", "EXIF Fields") }} +
+
-
@@ -38,6 +49,7 @@ import { defineComponent } from "vue"; import { IPhoto } from "../../types"; +import UserConfig from "../../mixins/UserConfig"; import NcButton from "@nextcloud/vue/dist/Components/NcButton"; const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField"); const NcProgressBar = () => @@ -46,6 +58,7 @@ import Modal from "./Modal.vue"; import EditExif from "./EditExif.vue"; import EditDate from "./EditDate.vue"; +import EditTags from "./EditTags.vue"; import { showError } from "@nextcloud/dialogs"; import { emit } from "@nextcloud/event-bus"; @@ -63,8 +76,11 @@ export default defineComponent({ EditExif, EditDate, + EditTags, }, + mixins: [UserConfig], + data: () => ({ photos: null as IPhoto[], show: false, @@ -89,7 +105,8 @@ export default defineComponent({ // Load metadata for all photos const calls = photos.map((p) => async () => { try { - const res = await axios.get(API.IMAGE_INFO(p.fileid)); + const url = API.Q(API.IMAGE_INFO(p.fileid), "tags=1"); + const res = await axios.get(url); // Validate response p.imageInfo = null; @@ -145,7 +162,10 @@ export default defineComponent({ } // Get exif fields diff - const exifChanges = (this.$refs.editExif).changes(); + const exifResult = (this.$refs.editExif).result(); + const tagsResult = this.config_tagsEnabled + ? (this.$refs.editTags).result() + : null; // Start processing let done = 0; @@ -155,10 +175,11 @@ export default defineComponent({ // Update exif fields const calls = this.photos.map((p) => async () => { try { + let dirty = false; const fileid = p.fileid; // Basic EXIF fields - const raw = JSON.parse(JSON.stringify(exifChanges)); + const raw = JSON.parse(JSON.stringify(exifResult)); // Date const date = (this.$refs.editDate).result(p); @@ -166,22 +187,25 @@ export default defineComponent({ 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); + // Update EXIF if required + if (Object.keys(raw).length > 0) { + await axios.patch(API.IMAGE_SETEXIF(fileid), { raw }); + dirty = true; } - await axios.patch(API.IMAGE_SETEXIF(fileid), { raw }); + // Update tags if required + if (tagsResult) { + await axios.patch(API.TAG_SET(fileid), tagsResult); + dirty = true; + } - // Clear imageInfo in photo - p.imageInfo = null; - - // Emit event to update photo - emit("files:file:updated", { fileid }); + // Refresh UX + if (dirty) { + p.imageInfo = null; + emit("files:file:updated", { fileid }); + } } catch (e) { - console.error("Failed to save EXIF info for", p.fileid, e); + console.error("Failed to save metadata for", p.fileid, e); if (e.response?.data?.message) { showError(e.response.data.message); } else { diff --git a/src/components/modal/EditTags.vue b/src/components/modal/EditTags.vue new file mode 100644 index 00000000..eb775012 --- /dev/null +++ b/src/components/modal/EditTags.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/services/API.ts b/src/services/API.ts index 2d768cea..abecee89 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -63,6 +63,10 @@ export class API { return gen(`${BASE}/tags/preview/{tag}`, { tag }); } + static TAG_SET(fileid: string | number) { + return gen(`${BASE}/tags/set/{fileid}`, { fileid }); + } + static FACE_LIST(app: "recognize" | "facerecognition") { return gen(`${BASE}/${app}/people`); }