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