feat: allow editing collaborative tags (fix #270)
parent
2b0afe8db6
commit
6ad37a4812
|
@ -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))
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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]) {
|
||||||
|
|
|
@ -17,16 +17,27 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="photos">
|
<div v-if="photos">
|
||||||
|
<div>
|
||||||
<div class="title-text">
|
<div class="title-text">
|
||||||
{{ t("memories", "Date / Time") }}
|
{{ t("memories", "Date / Time") }}
|
||||||
</div>
|
</div>
|
||||||
<EditDate ref="editDate" :photos="photos" />
|
<EditDate ref="editDate" :photos="photos" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="config_tagsEnabled">
|
||||||
|
<div class="title-text">
|
||||||
|
{{ t("memories", "Collaborative Tags") }}
|
||||||
|
</div>
|
||||||
|
<EditTags ref="editTags" :photos="photos" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<div class="title-text">
|
<div class="title-text">
|
||||||
{{ t("memories", "EXIF Fields") }}
|
{{ t("memories", "EXIF Fields") }}
|
||||||
</div>
|
</div>
|
||||||
<EditExif ref="editExif" :photos="photos" />
|
<EditExif ref="editExif" :photos="photos" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="processing">
|
<div v-if="processing">
|
||||||
<NcProgressBar :value="progress" :error="true" />
|
<NcProgressBar :value="progress" :error="true" />
|
||||||
|
@ -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
|
||||||
|
if (dirty) {
|
||||||
p.imageInfo = null;
|
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 {
|
||||||
|
|
|
@ -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>
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue