Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## [v8.0.1] - 2026-04-16

- **Feature**: Compatibility with manual tagging in face recognition.


## [v8.0.0] - 2026-04-04

- **Update**: Compatibility with Nextcloud 33
Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Memories is a *batteries-included* photo management solution for Nextcloud with
1. Run `php occ memories:index` to generate metadata indices for existing photos.
1. Open the 📷 Memories app in Nextcloud and set the directory containing your photos.
]]></description>
<version>8.0.1</version>
<version>8.0.1.2</version>
<licence>agpl</licence>
<author mail="radialapps@gmail.com">Varun Patil</author>
<namespace>Memories</namespace>
Expand Down
68 changes: 61 additions & 7 deletions src/components/Metadata.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@
</div>
</div>

<div v-if="config.facerecognition_enabled" class="people face-recognition">
<div class="section-header">
<div class="section-title">{{ t('memories', 'Face Recognition') }}</div>
<NcActions :inline="1">
<NcActionButton :aria-label="t('memories', 'Add person')" @click="openManualAdd" close-after-click>
{{ t('memories', 'Add person') }}
<template #icon> <AddIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</div>
<template v-if="facerecognitionPeople.length">
<div class="container" v-for="face of facerecognitionPeople" :key="face.cluster_id">
<Cluster class="cluster--rounded" :data="face" :counters="false"> </Cluster>
</div>
</template>
<div v-else class="empty-hint">
{{ t('memories', 'No faces detected — click + to tag manually') }}
</div>
</div>

<FaceManualAddModal ref="manualAddModal" @added="refresh" />

<div v-if="albums.length">
<div class="section-title">{{ t('memories', 'Albums') }}</div>
<AlbumsList class="albums" :albums="albums" />
Expand Down Expand Up @@ -88,7 +110,9 @@ import { DateTime } from 'luxon';
import UserConfig from '@mixins/UserConfig';
import Cluster from '@components/frame/Cluster.vue';
import AlbumsList from '@components/modal/AlbumsList.vue';
import FaceManualAddModal from '@components/modal/FaceManualAddModal.vue';

import AddIcon from 'vue-material-design-icons/AccountPlus.vue';
import EditIcon from 'vue-material-design-icons/Pencil.vue';
import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
import CameraIrisIcon from 'vue-material-design-icons/CameraIris.vue';
Expand Down Expand Up @@ -119,6 +143,8 @@ export default defineComponent({
NcAvatar,
AlbumsList,
Cluster,
FaceManualAddModal,
AddIcon,
EditIcon,
},

Expand Down Expand Up @@ -389,14 +415,11 @@ export default defineComponent({
},

people(): IFace[] {
const clusters = this.baseInfo?.clusters;

// force face-recognition on its own route, or if recognize is disabled
if (this.routeIsFaceRecognition || !this.config.recognize_enabled) {
return clusters?.facerecognition ?? [];
}
return this.baseInfo?.clusters?.recognize ?? [];
},

return clusters?.recognize ?? [];
facerecognitionPeople(): IFace[] {
return this.baseInfo?.clusters?.facerecognition ?? [];
},

isShared(): boolean {
Expand Down Expand Up @@ -469,6 +492,21 @@ export default defineComponent({
_m.modals.editMetadata([_m.viewer.currentPhoto!], [4]);
},

openManualAdd() {
const modal = this.$refs.manualAddModal as InstanceType<typeof FaceManualAddModal> | undefined;
if (!modal) return;
if (this.fileid) {
modal.openForFile({
fileid: this.fileid,
etag: this.baseInfo?.etag,
w: this.baseInfo?.w,
h: this.baseInfo?.h,
});
} else {
modal.open();
}
},

handleFileUpdated({ fileid }: utils.BusEvent['files:file:updated']) {
if (fileid && this.fileid === fileid) {
this.refresh();
Expand Down Expand Up @@ -536,6 +574,17 @@ export default defineComponent({
> .section-title {
margin-bottom: 4px;
}
> .section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 4px;
margin-bottom: 4px;

> .section-title {
flex: 1;
}
}
> .container {
width: calc(100% / 3);
aspect-ratio: 1;
Expand All @@ -548,6 +597,11 @@ export default defineComponent({
font-size: 0.95em;
}
}
> .empty-hint {
padding: 6px 8px 10px;
font-size: 0.9em;
color: var(--color-text-lighter);
}
}

.albums {
Expand Down
28 changes: 28 additions & 0 deletions src/components/modal/FaceEditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
</template>

<div class="fields">
<!-- Suggestions of already-known person names for the name field -->
<datalist id="memories-known-person-names">
<option v-for="n in knownNames" :key="n" :value="n" />
</datalist>

<NcTextField
class="field"
:autofocus="true"
:value.sync="rawInput"
:label="t('memories', 'Name')"
:label-visible="false"
:placeholder="t('memories', 'Name')"
list="memories-known-person-names"
@keypress.enter="save()"
/>
</div>
Expand Down Expand Up @@ -52,6 +58,7 @@ export default defineComponent({

data: () => ({
rawInput: String(),
knownNames: [] as string[],
}),

computed: {
Expand Down Expand Up @@ -83,12 +90,33 @@ export default defineComponent({

this.rawInput = isNaN(Number(this.name)) ? this.name : String();
this.show = true;
this.loadKnownNames();
},

cleanup() {
this.show = false;
},

/**
* Load already-known person names from the active backend so the name field
* can offer them as autocompletion (same comfort as the manual-face modal).
* Failure is non-fatal: it just means no suggestions are shown.
*/
async loadKnownNames(): Promise<void> {
try {
const app = this.routeIsRecognize ? 'recognize' : 'facerecognition';
const faces = await dav.getFaceList(app);
const names = faces
.map((f) => f.name)
// Keep only real names; unnamed clusters expose a numeric id as their name.
.filter((n): n is string => !!n && Number.isNaN(Number(n)));
this.knownNames = Array.from(new Set(names)).sort((a, b) => a.localeCompare(b));
} catch (e) {
console.error(e);
this.knownNames = [];
}
},

async save() {
if (!this.canSave) return;

Expand Down
Loading