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
|
||||
|
||||
- **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))
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -100,7 +100,7 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
methods: {
|
||||
changes() {
|
||||
result() {
|
||||
const diff = {};
|
||||
for (const field of this.fields) {
|
||||
if (this.dirty[field.field]) {
|
||||
|
|
|
@ -17,16 +17,27 @@
|
|||
</template>
|
||||
|
||||
<div v-if="photos">
|
||||
<div>
|
||||
<div class="title-text">
|
||||
{{ t("memories", "Date / Time") }}
|
||||
</div>
|
||||
<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">
|
||||
{{ t("memories", "EXIF Fields") }}
|
||||
</div>
|
||||
<EditExif ref="editExif" :photos="photos" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processing">
|
||||
<NcProgressBar :value="progress" :error="true" />
|
||||
|
@ -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<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
|
||||
p.imageInfo = null;
|
||||
|
@ -145,7 +162,10 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
// 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
|
||||
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 = (<any>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<any>(API.IMAGE_SETEXIF(fileid), { raw });
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Emit event to update photo
|
||||
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 {
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
static TAG_SET(fileid: string | number) {
|
||||
return gen(`${BASE}/tags/set/{fileid}`, { fileid });
|
||||
}
|
||||
|
||||
static FACE_LIST(app: "recognize" | "facerecognition") {
|
||||
return gen(`${BASE}/${app}/people`);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue