feat: allow editing collaborative tags (fix #270)

pull/461/head
Varun Patil 2023-03-07 20:14:42 -08:00
parent 2b0afe8db6
commit 6ad37a4812
8 changed files with 185 additions and 23 deletions

View File

@ -5,6 +5,7 @@ This file is manually updated. Please file an issue if something is missing.
## v4.12.0, v3.12.0 ## v4.12.0, v3.12.0
- **Feature**: Allow bulk editing of EXIF attributes other than date/time - **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**: Show list of tags in sidebar
- **Feature**: Configurable album sorting order ([#377](https://github.com/pulsejet/memories/issues/377)) - **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)) - **Feature**: Allow archiving photos throw folder view ([#350](https://github.com/pulsejet/memories/issues/350))

View File

@ -57,6 +57,7 @@ return [
['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'], ['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'],
['name' => 'Tags#preview', 'url' => '/api/tags/preview/{tag}', '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#recognizePeople', 'url' => '/api/recognize/people', 'verb' => 'GET'],
['name' => 'People#recognizePeoplePreview', 'url' => '/api/recognize/people/preview/{id}', 'verb' => 'GET'], ['name' => 'People#recognizePeoplePreview', 'url' => '/api/recognize/people/preview/{id}', 'verb' => 'GET'],

View File

@ -96,4 +96,39 @@ class TagsController extends ApiBase
return (int) $item['fileid']; return (int) $item['fileid'];
}, $list)); }, $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);
}
} }

View File

@ -141,7 +141,6 @@ export default defineComponent({
title: title || this.t("memories", "No title"), title: title || this.t("memories", "No title"),
subtitle: [desc || this.t("memories", "No description")], subtitle: [desc || this.t("memories", "No description")],
icon: InfoIcon, icon: InfoIcon,
edit: () => globalThis.editMetadata([globalThis.currentViewerPhoto]),
}); });
} }

View File

@ -100,7 +100,7 @@ export default defineComponent({
}, },
methods: { methods: {
changes() { result() {
const diff = {}; const diff = {};
for (const field of this.fields) { for (const field of this.fields) {
if (this.dirty[field.field]) { if (this.dirty[field.field]) {

View File

@ -17,15 +17,26 @@
</template> </template>
<div v-if="photos"> <div v-if="photos">
<div class="title-text"> <div>
{{ t("memories", "Date / Time") }} <div class="title-text">
{{ t("memories", "Date / Time") }}
</div>
<EditDate ref="editDate" :photos="photos" />
</div> </div>
<EditDate ref="editDate" :photos="photos" />
<div class="title-text"> <div v-if="config_tagsEnabled">
{{ t("memories", "EXIF Fields") }} <div class="title-text">
{{ t("memories", "Collaborative Tags") }}
</div>
<EditTags ref="editTags" :photos="photos" />
</div>
<div>
<div class="title-text">
{{ t("memories", "EXIF Fields") }}
</div>
<EditExif ref="editExif" :photos="photos" />
</div> </div>
<EditExif ref="editExif" :photos="photos" />
</div> </div>
<div v-if="processing"> <div v-if="processing">
@ -38,6 +49,7 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { IPhoto } from "../../types"; import { IPhoto } from "../../types";
import UserConfig from "../../mixins/UserConfig";
import NcButton from "@nextcloud/vue/dist/Components/NcButton"; import NcButton from "@nextcloud/vue/dist/Components/NcButton";
const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField"); const NcTextField = () => import("@nextcloud/vue/dist/Components/NcTextField");
const NcProgressBar = () => const NcProgressBar = () =>
@ -46,6 +58,7 @@ import Modal from "./Modal.vue";
import EditExif from "./EditExif.vue"; import EditExif from "./EditExif.vue";
import EditDate from "./EditDate.vue"; import EditDate from "./EditDate.vue";
import EditTags from "./EditTags.vue";
import { showError } from "@nextcloud/dialogs"; import { showError } from "@nextcloud/dialogs";
import { emit } from "@nextcloud/event-bus"; import { emit } from "@nextcloud/event-bus";
@ -63,8 +76,11 @@ export default defineComponent({
EditExif, EditExif,
EditDate, EditDate,
EditTags,
}, },
mixins: [UserConfig],
data: () => ({ data: () => ({
photos: null as IPhoto[], photos: null as IPhoto[],
show: false, show: false,
@ -89,7 +105,8 @@ export default defineComponent({
// Load metadata for all photos // Load metadata for all photos
const calls = photos.map((p) => async () => { const calls = photos.map((p) => async () => {
try { try {
const res = await axios.get<any>(API.IMAGE_INFO(p.fileid)); const url = API.Q(API.IMAGE_INFO(p.fileid), "tags=1");
const res = await axios.get<any>(url);
// Validate response // Validate response
p.imageInfo = null; p.imageInfo = null;
@ -145,7 +162,10 @@ export default defineComponent({
} }
// Get exif fields diff // Get exif fields diff
const exifChanges = (<any>this.$refs.editExif).changes(); const exifResult = (<any>this.$refs.editExif).result();
const tagsResult = this.config_tagsEnabled
? (<any>this.$refs.editTags).result()
: null;
// Start processing // Start processing
let done = 0; let done = 0;
@ -155,10 +175,11 @@ export default defineComponent({
// Update exif fields // Update exif fields
const calls = this.photos.map((p) => async () => { const calls = this.photos.map((p) => async () => {
try { try {
let dirty = false;
const fileid = p.fileid; const fileid = p.fileid;
// Basic EXIF fields // Basic EXIF fields
const raw = JSON.parse(JSON.stringify(exifChanges)); const raw = JSON.parse(JSON.stringify(exifResult));
// Date // Date
const date = (<any>this.$refs.editDate).result(p); const date = (<any>this.$refs.editDate).result(p);
@ -166,22 +187,25 @@ export default defineComponent({
raw.DateTimeOriginal = date; raw.DateTimeOriginal = date;
} }
if (Object.keys(raw).length === 0) { // Update EXIF if required
console.log("No changes for", p.fileid); if (Object.keys(raw).length > 0) {
return; await axios.patch<any>(API.IMAGE_SETEXIF(fileid), { raw });
} else { dirty = true;
console.log("Saving EXIF info for", p.fileid, raw);
} }
await axios.patch<any>(API.IMAGE_SETEXIF(fileid), { raw }); // Update tags if required
if (tagsResult) {
await axios.patch<any>(API.TAG_SET(fileid), tagsResult);
dirty = true;
}
// Clear imageInfo in photo // Refresh UX
p.imageInfo = null; if (dirty) {
p.imageInfo = null;
// Emit event to update photo emit("files:file:updated", { fileid });
emit("files:file:updated", { fileid }); }
} catch (e) { } 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) { if (e.response?.data?.message) {
showError(e.response.data.message); showError(e.response.data.message);
} else { } else {

View File

@ -0,0 +1,98 @@
<template>
<div class="outer">
<NcSelectTags
class="nc-comp"
v-model="tagSelection"
:limit="null"
:options-filter="tagFilter"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { IPhoto } from "../../types";
const NcSelectTags = () =>
import("@nextcloud/vue/dist/Components/NcSelectTags");
export default defineComponent({
name: "EditTags",
components: {
NcSelectTags,
},
props: {
photos: {
type: Array<IPhoto>,
required: true,
},
},
data: () => ({
origIds: new Set<number>(),
tagSelection: [] as number[],
}),
mounted() {
this.init();
},
methods: {
init() {
let tagIds: number[] = null;
// Find common tags in all selected photos
for (const photo of this.photos) {
const s = new Set<number>();
for (const tag of Object.keys(photo.imageInfo?.tags || {}).map(
Number
)) {
s.add(tag);
}
tagIds = tagIds ? [...tagIds].filter((x) => s.has(x)) : [...s];
}
this.tagSelection = tagIds;
this.origIds = new Set(this.tagSelection);
},
tagFilter(element, index) {
return (
element.id >= 2 &&
element.displayName !== "" &&
element.canAssign &&
element.userAssignable &&
element.userVisible
);
},
result() {
const add = this.tagSelection.filter((x) => !this.origIds.has(x));
const remove = [...this.origIds].filter(
(x) => !this.tagSelection.includes(x)
);
if (add.length === 0 && remove.length === 0) {
return null;
}
return { add, remove };
},
},
});
</script>
<style scoped lang="scss">
.outer {
margin-top: 10px;
.nc-comp {
width: 100%;
:deep ul {
max-height: 200px;
}
}
}
</style>

View File

@ -63,6 +63,10 @@ export class API {
return gen(`${BASE}/tags/preview/{tag}`, { tag }); 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") { static FACE_LIST(app: "recognize" | "facerecognition") {
return gen(`${BASE}/${app}/people`); return gen(`${BASE}/${app}/people`);
} }