Make the code prettier

old-stable24
Varun Patil 2022-10-28 12:08:34 -07:00
parent b93d88abde
commit c54bcd8451
51 changed files with 7276 additions and 6467 deletions

View File

@ -1,221 +1,254 @@
<template>
<FirstStart v-if="isFirstStart" />
<FirstStart v-if="isFirstStart" />
<NcContent app-name="memories" v-else :class="{
'remove-gap': removeOuterGap,
}">
<NcAppNavigation>
<template id="app-memories-navigation" #list>
<NcAppNavigationItem :to="{name: 'timeline'}"
:title="t('memories', 'Timeline')"
exact>
<ImageMultiple slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'folders'}"
:title="t('memories', 'Folders')">
<FolderIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'favorites'}"
:title="t('memories', 'Favorites')">
<Star slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'videos'}"
:title="t('memories', 'Videos')">
<Video slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'albums'}"
:title="t('memories', 'Albums')" v-if="showAlbums">
<AlbumIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'people'}"
:title="t('memories', 'People')" v-if="showPeople">
<PeopleIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'archive'}"
:title="t('memories', 'Archive')">
<ArchiveIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'thisday'}"
:title="t('memories', 'On this day')">
<CalendarIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'tags'}" v-if="config_tagsEnabled"
:title="t('memories', 'Tags')">
<TagsIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem :to="{name: 'maps'}" v-if="config_mapsEnabled"
:title="t('memories', 'Maps')">
<MapIcon slot="icon" :size="20" />
</NcAppNavigationItem>
</template>
<template #footer>
<NcAppNavigationSettings :title="t('memories', 'Settings')">
<Settings />
</NcAppNavigationSettings>
</template>
</NcAppNavigation>
<NcContent
app-name="memories"
v-else
:class="{
'remove-gap': removeOuterGap,
}"
>
<NcAppNavigation>
<template id="app-memories-navigation" #list>
<NcAppNavigationItem
:to="{ name: 'timeline' }"
:title="t('memories', 'Timeline')"
exact
>
<ImageMultiple slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'folders' }"
:title="t('memories', 'Folders')"
>
<FolderIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'favorites' }"
:title="t('memories', 'Favorites')"
>
<Star slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'videos' }"
:title="t('memories', 'Videos')"
>
<Video slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'albums' }"
:title="t('memories', 'Albums')"
v-if="showAlbums"
>
<AlbumIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'people' }"
:title="t('memories', 'People')"
v-if="showPeople"
>
<PeopleIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'archive' }"
:title="t('memories', 'Archive')"
>
<ArchiveIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'thisday' }"
:title="t('memories', 'On this day')"
>
<CalendarIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'tags' }"
v-if="config_tagsEnabled"
:title="t('memories', 'Tags')"
>
<TagsIcon slot="icon" :size="20" />
</NcAppNavigationItem>
<NcAppNavigationItem
:to="{ name: 'maps' }"
v-if="config_mapsEnabled"
:title="t('memories', 'Maps')"
>
<MapIcon slot="icon" :size="20" />
</NcAppNavigationItem>
</template>
<template #footer>
<NcAppNavigationSettings :title="t('memories', 'Settings')">
<Settings />
</NcAppNavigationSettings>
</template>
</NcAppNavigation>
<NcAppContent>
<div class="outer">
<router-view />
</div>
</NcAppContent>
</NcContent>
<NcAppContent>
<div class="outer">
<router-view />
</div>
</NcAppContent>
</NcContent>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { Component, Mixins } from "vue-property-decorator";
import {
NcContent, NcAppContent, NcAppNavigation,
NcAppNavigationItem, NcAppNavigationSettings,
} from '@nextcloud/vue';
import { generateUrl } from '@nextcloud/router';
import { getCurrentUser } from '@nextcloud/auth';
NcContent,
NcAppContent,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationSettings,
} from "@nextcloud/vue";
import { generateUrl } from "@nextcloud/router";
import { getCurrentUser } from "@nextcloud/auth";
import Timeline from './components/Timeline.vue'
import Settings from './components/Settings.vue'
import FirstStart from './components/FirstStart.vue'
import GlobalMixin from './mixins/GlobalMixin';
import UserConfig from './mixins/UserConfig';
import Timeline from "./components/Timeline.vue";
import Settings from "./components/Settings.vue";
import FirstStart from "./components/FirstStart.vue";
import GlobalMixin from "./mixins/GlobalMixin";
import UserConfig from "./mixins/UserConfig";
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import Star from 'vue-material-design-icons/Star.vue'
import Video from 'vue-material-design-icons/Video.vue'
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue';
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
import PeopleIcon from 'vue-material-design-icons/AccountBoxMultiple.vue';
import TagsIcon from 'vue-material-design-icons/Tag.vue';
import MapIcon from 'vue-material-design-icons/Map.vue';
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
import FolderIcon from "vue-material-design-icons/Folder.vue";
import Star from "vue-material-design-icons/Star.vue";
import Video from "vue-material-design-icons/Video.vue";
import AlbumIcon from "vue-material-design-icons/ImageAlbum.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import CalendarIcon from "vue-material-design-icons/Calendar.vue";
import PeopleIcon from "vue-material-design-icons/AccountBoxMultiple.vue";
import TagsIcon from "vue-material-design-icons/Tag.vue";
import MapIcon from "vue-material-design-icons/Map.vue";
@Component({
components: {
NcContent,
NcAppContent,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationSettings,
components: {
NcContent,
NcAppContent,
NcAppNavigation,
NcAppNavigationItem,
NcAppNavigationSettings,
Timeline,
Settings,
FirstStart,
Timeline,
Settings,
FirstStart,
ImageMultiple,
FolderIcon,
Star,
Video,
AlbumIcon,
ArchiveIcon,
CalendarIcon,
PeopleIcon,
TagsIcon,
MapIcon,
},
ImageMultiple,
FolderIcon,
Star,
Video,
AlbumIcon,
ArchiveIcon,
CalendarIcon,
PeopleIcon,
TagsIcon,
MapIcon,
},
})
export default class App extends Mixins(GlobalMixin, UserConfig) {
// Outer element
// Outer element
get ncVersion() {
const version = (<any>window.OC).config.version.split('.');
return Number(version[0]);
get ncVersion() {
const version = (<any>window.OC).config.version.split(".");
return Number(version[0]);
}
get showPeople() {
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
}
get isFirstStart() {
return this.config_timelinePath === "EMPTY";
}
get showAlbums() {
return this.config_albumsEnabled;
}
get removeOuterGap() {
return this.ncVersion >= 25;
}
async beforeMount() {
if ("serviceWorker" in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener("load", async () => {
try {
const url = generateUrl("/apps/memories/service-worker.js");
const registration = await navigator.serviceWorker.register(url, {
scope: generateUrl("/apps/memories"),
});
console.log("SW registered: ", registration);
} catch (error) {
console.error("SW registration failed: ", error);
}
});
} else {
console.debug("Service Worker is not enabled on this browser.");
}
get showPeople() {
return this.config_recognizeEnabled || getCurrentUser()?.isAdmin;
}
get isFirstStart() {
return this.config_timelinePath === 'EMPTY';
}
get showAlbums() {
return this.config_albumsEnabled;
}
get removeOuterGap() {
return this.ncVersion >= 25;
}
async beforeMount() {
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', async () => {
try {
const url = generateUrl('/apps/memories/service-worker.js');
const registration = await navigator.serviceWorker.register(url, { scope: generateUrl('/apps/memories') });
console.log('SW registered: ', registration);
} catch (error) {
console.error('SW registration failed: ', error);
}
})
} else {
console.debug('Service Worker is not enabled on this browser.')
}
}
}
}
</script>
<style scoped lang="scss">
.outer {
padding: 0 0 0 44px;
height: 100%;
width: 100%;
padding: 0 0 0 44px;
height: 100%;
width: 100%;
}
@media (max-width: 768px) {
.outer {
padding: 0px;
.outer {
padding: 0px;
// Get rid of padding on img-outer (1px on mobile)
// Also need to make sure we don't end up with a scrollbar -- see below
margin-left: -1px;
width: calc(100% + 3px); // 1px extra here because ... reasons
}
// Get rid of padding on img-outer (1px on mobile)
// Also need to make sure we don't end up with a scrollbar -- see below
margin-left: -1px;
width: calc(100% + 3px); // 1px extra here because ... reasons
}
}
</style>
<style lang="scss">
body {
overflow: hidden;
overflow: hidden;
}
// Nextcloud 25+: get rid of gap and border radius at right
#content-vue.remove-gap {
// was var(--body-container-radius)
border-top-right-radius: 0;
border-bottom-right-radius: 0;
// was var(--body-container-radius)
border-top-right-radius: 0;
border-bottom-right-radius: 0;
width: calc(100% - var(--body-container-margin)*1); // was *2
width: calc(100% - var(--body-container-margin) * 1); // was *2
}
// Prevent content overflow on NC <25
#content-vue {
max-height: 100vh;
max-height: 100vh;
}
// Patch viewer to remove the title and
// make the image fill the entire screen
.viewer {
.modal-title {
display: none;
}
.modal-wrapper .modal-container {
top: 0 !important;
bottom: 0 !important;
}
.modal-title {
display: none;
}
.modal-wrapper .modal-container {
top: 0 !important;
bottom: 0 !important;
}
}
// Hide horizontal scrollbar on mobile
// For the padding removal above
#app-content-vue {
overflow-x: hidden;
overflow-x: hidden;
}
// Fill all available space
.fill-block {
width: 100%;
height: 100%;
display: block;
width: 100%;
height: 100%;
display: block;
}
</style>

View File

@ -1,176 +1,189 @@
<template>
<NcContent app-name="memories">
<NcAppContent>
<div class="outer fill-block" :class="{ show }">
<div class="title">
<img :src="banner" />
</div>
<NcContent app-name="memories">
<NcAppContent>
<div class="outer fill-block" :class="{ show }">
<div class="title">
<img :src="banner" />
</div>
<div class="text">
{{ t('memories', 'A better photos experience awaits you') }} <br/>
{{ t('memories', 'Choose the root folder of your timeline to begin') }}
</div>
<div class="text">
{{ t("memories", "A better photos experience awaits you") }} <br />
{{
t("memories", "Choose the root folder of your timeline to begin")
}}
</div>
<div class="admin-text" v-if="isAdmin">
{{ t('memories', 'If you just installed Memories, run:') }}
<br/>
<code>occ memories:index</code>
</div>
<div class="admin-text" v-if="isAdmin">
{{ t("memories", "If you just installed Memories, run:") }}
<br />
<code>occ memories:index</code>
</div>
<div class="error" v-if="error">
{{ error }}
</div>
<div class="error" v-if="error">
{{ error }}
</div>
<div class="info" v-if="info">
{{ info }} <br/>
<div class="info" v-if="info">
{{ info }} <br />
<NcButton @click="finish" class="button" type="primary">
{{ t('memories', 'Continue to Memories') }}
</NcButton>
</div>
<NcButton @click="finish" class="button" type="primary">
{{ t("memories", "Continue to Memories") }}
</NcButton>
</div>
<NcButton @click="begin" class="button" v-if="info">
{{ t('memories', 'Choose again') }}
</NcButton>
<NcButton @click="begin" class="button" type="primary" v-else>
{{ t('memories', 'Click here to start') }}
</NcButton>
<NcButton @click="begin" class="button" v-if="info">
{{ t("memories", "Choose again") }}
</NcButton>
<NcButton @click="begin" class="button" type="primary" v-else>
{{ t("memories", "Click here to start") }}
</NcButton>
<div class="footer">
{{ t('memories', 'You can always change this later in settings') }}
</div>
</div>
</NcAppContent>
</NcContent>
<div class="footer">
{{ t("memories", "You can always change this later in settings") }}
</div>
</div>
</NcAppContent>
</NcContent>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { NcContent, NcAppContent, NcButton } from '@nextcloud/vue';
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import { generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth';
import axios from '@nextcloud/axios'
import { Component, Mixins } from "vue-property-decorator";
import { NcContent, NcAppContent, NcButton } from "@nextcloud/vue";
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import { generateUrl } from "@nextcloud/router";
import { getCurrentUser } from "@nextcloud/auth";
import axios from "@nextcloud/axios";
import GlobalMixin from '../mixins/GlobalMixin';
import UserConfig from '../mixins/UserConfig';
import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from "../mixins/UserConfig";
import banner from "../assets/banner.svg";
import { IDay } from '../types';
import { IDay } from "../types";
@Component({
components: {
NcContent,
NcAppContent,
NcButton,
},
components: {
NcContent,
NcAppContent,
NcButton,
},
})
export default class FirstStart extends Mixins(GlobalMixin, UserConfig) {
banner = banner;
error = '';
info = ''
show = false;
chosenPath = '';
banner = banner;
error = "";
info = "";
show = false;
chosenPath = "";
mounted() {
window.setTimeout(() => {
this.show = true;
}, 300);
mounted() {
window.setTimeout(() => {
this.show = true;
}, 300);
}
get isAdmin() {
return getCurrentUser().isAdmin;
}
async begin() {
const path = await this.chooseFolder(
this.t("memories", "Choose the root of your timeline"),
"/"
);
// Get folder days
this.error = "";
this.info = "";
const query = new URLSearchParams();
query.set("timelinePath", path);
let url = generateUrl("/apps/memories/api/days?" + query.toString());
const res = await axios.get<IDay[]>(url);
// Check response
if (res.status !== 200) {
this.error = this.t(
"memories",
"The selected folder does not seem to be valid. Try again."
);
return;
}
get isAdmin() {
return getCurrentUser().isAdmin;
}
// Count total photos
const total = res.data.reduce((acc, day) => acc + day.count, 0);
this.info = this.t("memories", "Found {total} photos in {path}", {
total,
path,
});
this.chosenPath = path;
}
async begin() {
const path = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), '/');
async finish() {
this.show = false;
await new Promise((resolve) => setTimeout(resolve, 500));
this.config_timelinePath = this.chosenPath;
await this.updateSetting("timelinePath");
}
// Get folder days
this.error = '';
this.info = '';
const query = new URLSearchParams();
query.set('timelinePath', path);
let url = generateUrl('/apps/memories/api/days?' + query.toString());
const res = await axios.get<IDay[]>(url);
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter("httpd/unix-directory")
.allowDirectories()
.startAt(initial)
.build();
// Check response
if (res.status !== 200) {
this.error = this.t('memories', 'The selected folder does not seem to be valid. Try again.');
return;
}
// Count total photos
const total = res.data.reduce((acc, day) => acc + day.count, 0);
this.info = this.t('memories', 'Found {total} photos in {path}', { total, path });
this.chosenPath = path;
}
async finish() {
this.show = false;
await new Promise(resolve => setTimeout(resolve, 500));
this.config_timelinePath = this.chosenPath;
await this.updateSetting('timelinePath');
}
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter('httpd/unix-directory')
.allowDirectories()
.startAt(initial)
.build();
return await picker.pick();
}
return await picker.pick();
}
}
</script>
<style lang="scss" scoped>
.outer {
padding: 20px;
text-align: center;
padding: 20px;
text-align: center;
transition: opacity 1s ease;
opacity: 0;
&.show { opacity: 1; }
transition: opacity 1s ease;
opacity: 0;
&.show {
opacity: 1;
}
.title {
font-size: 2.8em;
line-height: 1.1em;
font-family: cursive;
font-weight: 500;
margin-top: 10px;
margin-bottom: 20px;
width: 100%;
filter: var(--background-invert-if-dark);
.title {
font-size: 2.8em;
line-height: 1.1em;
font-family: cursive;
font-weight: 500;
margin-top: 10px;
margin-bottom: 20px;
width: 100%;
filter: var(--background-invert-if-dark);
> img {
max-width: calc(100vw - 40px);
}
> img {
max-width: calc(100vw - 40px);
}
}
.admin-text {
margin-top: 10px;
}
.admin-text {
margin-top: 10px;
}
.error {
color: red;
}
.error {
color: red;
}
.info {
margin-top: 10px;
font-weight: bold;
}
.info {
margin-top: 10px;
font-weight: bold;
}
.button {
display: inline-block;
margin: 15px;
}
.button {
display: inline-block;
margin: 15px;
}
.footer {
font-size: 0.8em;
}
.footer {
font-size: 0.8em;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,444 +1,513 @@
<template>
<div>
<div v-if="selection.size > 0" class="top-bar">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Cancel')"
@click="clearSelection()">
{{ t('memories', 'Cancel') }}
<template #icon> <CloseIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<div>
<div v-if="selection.size > 0" class="top-bar">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Cancel')"
@click="clearSelection()"
>
{{ t("memories", "Cancel") }}
<template #icon> <CloseIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<div class="text">
{{ n("memories", "{n} selected", "{n} selected", selection.size, { n: selection.size }) }}
</div>
<div class="text">
{{
n("memories", "{n} selected", "{n} selected", selection.size, {
n: selection.size,
})
}}
</div>
<NcActions :inline="1">
<NcActionButton v-for="action of getActions()" :key="action.name"
:aria-label="action.name" close-after-click
@click="click(action)">
{{ action.name }}
<template #icon> <component :is="action.icon" :size="20" /> </template>
</NcActionButton>
</NcActions>
</div>
<!-- Selection Modals -->
<EditDate ref="editDate" @refresh="refresh" />
<FaceMoveModal ref="faceMoveModal" @moved="deletePhotos" :updateLoading="updateLoading" />
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" />
<NcActions :inline="1">
<NcActionButton
v-for="action of getActions()"
:key="action.name"
:aria-label="action.name"
close-after-click
@click="click(action)"
>
{{ action.name }}
<template #icon>
<component :is="action.icon" :size="20" />
</template>
</NcActionButton>
</NcActions>
</div>
<!-- Selection Modals -->
<EditDate ref="editDate" @refresh="refresh" />
<FaceMoveModal
ref="faceMoveModal"
@moved="deletePhotos"
:updateLoading="updateLoading"
/>
<AddToAlbumModal ref="addToAlbumModal" @added="clearSelection" />
</div>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
import GlobalMixin from '../mixins/GlobalMixin';
import UserConfig from '../mixins/UserConfig';
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import UserConfig from "../mixins/UserConfig";
import { showError } from '@nextcloud/dialogs'
import { generateUrl } from '@nextcloud/router'
import { NcActions, NcActionButton } from '@nextcloud/vue';
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { IHeadRow, IPhoto, ISelectionAction } from '../types';
import { getCurrentUser } from '@nextcloud/auth';
import { showError } from "@nextcloud/dialogs";
import { generateUrl } from "@nextcloud/router";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { IHeadRow, IPhoto, ISelectionAction } from "../types";
import { getCurrentUser } from "@nextcloud/auth";
import * as dav from "../services/DavRequests";
import EditDate from "./modal/EditDate.vue"
import FaceMoveModal from "./modal/FaceMoveModal.vue"
import AddToAlbumModal from "./modal/AddToAlbumModal.vue"
import EditDate from "./modal/EditDate.vue";
import FaceMoveModal from "./modal/FaceMoveModal.vue";
import AddToAlbumModal from "./modal/AddToAlbumModal.vue";
import StarIcon from 'vue-material-design-icons/Star.vue';
import DownloadIcon from 'vue-material-design-icons/Download.vue';
import DeleteIcon from 'vue-material-design-icons/Delete.vue';
import EditIcon from 'vue-material-design-icons/ClockEdit.vue';
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
import UnarchiveIcon from 'vue-material-design-icons/PackageUp.vue';
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue';
import CloseIcon from 'vue-material-design-icons/Close.vue';
import MoveIcon from 'vue-material-design-icons/ImageMove.vue';
import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue';
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
import StarIcon from "vue-material-design-icons/Star.vue";
import DownloadIcon from "vue-material-design-icons/Download.vue";
import DeleteIcon from "vue-material-design-icons/Delete.vue";
import EditIcon from "vue-material-design-icons/ClockEdit.vue";
import ArchiveIcon from "vue-material-design-icons/PackageDown.vue";
import UnarchiveIcon from "vue-material-design-icons/PackageUp.vue";
import OpenInNewIcon from "vue-material-design-icons/OpenInNew.vue";
import CloseIcon from "vue-material-design-icons/Close.vue";
import MoveIcon from "vue-material-design-icons/ImageMove.vue";
import AlbumsIcon from "vue-material-design-icons/ImageAlbum.vue";
import AlbumRemoveIcon from "vue-material-design-icons/BookRemove.vue";
type Selection = Map<number, IPhoto>;
@Component({
components: {
NcActions,
NcActionButton,
EditDate,
FaceMoveModal,
AddToAlbumModal,
components: {
NcActions,
NcActionButton,
EditDate,
FaceMoveModal,
AddToAlbumModal,
CloseIcon,
},
CloseIcon,
},
})
export default class SelectionHandler extends Mixins(GlobalMixin, UserConfig) {
@Prop() public selection: Selection;
@Prop() public heads: { [dayid: number]: IHeadRow };
@Prop() public selection: Selection;
@Prop() public heads: { [dayid: number]: IHeadRow };
private readonly defaultActions: ISelectionAction[];
private readonly defaultActions: ISelectionAction[];
@Emit('refresh')
refresh() {}
@Emit("refresh")
refresh() {}
@Emit('delete')
deletePhotos(photos: IPhoto[]) {}
@Emit("delete")
deletePhotos(photos: IPhoto[]) {}
@Emit('updateLoading')
updateLoading(delta: number) {}
@Emit("updateLoading")
updateLoading(delta: number) {}
constructor() {
super();
constructor() {
super();
// Make default actions
this.defaultActions = [
{ // This is at the top because otherwise it is confusing
name: t('memories', 'Remove from album'),
icon: AlbumRemoveIcon,
callback: this.removeFromAlbum.bind(this),
if: () => this.$route.name === 'albums',
},
{
name: t('memories', 'Delete'),
icon: DeleteIcon,
callback: this.deleteSelection.bind(this),
},
{
name: t('memories', 'Download'),
icon: DownloadIcon,
callback: this.downloadSelection.bind(this),
},
{
name: t('memories', 'Favorite'),
icon: StarIcon,
callback: this.favoriteSelection.bind(this),
},
{
name: t('memories', 'Archive'),
icon: ArchiveIcon,
callback: this.archiveSelection.bind(this),
if: () => this.allowArchive() && !this.routeIsArchive(),
},
{
name: t('memories', 'Unarchive'),
icon: UnarchiveIcon,
callback: this.archiveSelection.bind(this),
if: () => this.allowArchive() && this.routeIsArchive(),
},
{
name: t('memories', 'Edit Date/Time'),
icon: EditIcon,
callback: this.editDateSelection.bind(this),
},
{
name: t('memories', 'View in folder'),
icon: OpenInNewIcon,
callback: this.viewInFolder.bind(this),
if: () => this.selection.size === 1,
},
{
name: t('memories', 'Add to album'),
icon: AlbumsIcon,
callback: this.addToAlbum.bind(this),
if: (self: any) => self.config_albumsEnabled,
},
{
name: t('memories', 'Move to another person'),
icon: MoveIcon,
callback: this.moveSelectionToPerson.bind(this),
if: () => this.$route.name === 'people',
},
{
name: t('memories', 'Remove from person'),
icon: CloseIcon,
callback: this.removeSelectionFromPerson.bind(this),
if: () => this.$route.name === 'people',
},
];
// Make default actions
this.defaultActions = [
{
// This is at the top because otherwise it is confusing
name: t("memories", "Remove from album"),
icon: AlbumRemoveIcon,
callback: this.removeFromAlbum.bind(this),
if: () => this.$route.name === "albums",
},
{
name: t("memories", "Delete"),
icon: DeleteIcon,
callback: this.deleteSelection.bind(this),
},
{
name: t("memories", "Download"),
icon: DownloadIcon,
callback: this.downloadSelection.bind(this),
},
{
name: t("memories", "Favorite"),
icon: StarIcon,
callback: this.favoriteSelection.bind(this),
},
{
name: t("memories", "Archive"),
icon: ArchiveIcon,
callback: this.archiveSelection.bind(this),
if: () => this.allowArchive() && !this.routeIsArchive(),
},
{
name: t("memories", "Unarchive"),
icon: UnarchiveIcon,
callback: this.archiveSelection.bind(this),
if: () => this.allowArchive() && this.routeIsArchive(),
},
{
name: t("memories", "Edit Date/Time"),
icon: EditIcon,
callback: this.editDateSelection.bind(this),
},
{
name: t("memories", "View in folder"),
icon: OpenInNewIcon,
callback: this.viewInFolder.bind(this),
if: () => this.selection.size === 1,
},
{
name: t("memories", "Add to album"),
icon: AlbumsIcon,
callback: this.addToAlbum.bind(this),
if: (self: any) => self.config_albumsEnabled,
},
{
name: t("memories", "Move to another person"),
icon: MoveIcon,
callback: this.moveSelectionToPerson.bind(this),
if: () => this.$route.name === "people",
},
{
name: t("memories", "Remove from person"),
icon: CloseIcon,
callback: this.removeSelectionFromPerson.bind(this),
if: () => this.$route.name === "people",
},
];
}
/** Click on an action */
private async click(action: ISelectionAction) {
try {
this.updateLoading(1);
await action.callback(this.selection);
} catch (error) {
console.error(error);
} finally {
this.updateLoading(-1);
}
}
/** Click on an action */
private async click(action: ISelectionAction) {
try {
this.updateLoading(1);
await action.callback(this.selection);
} catch (error) {
console.error(error);
} finally {
this.updateLoading(-1);
/** Get the actions list */
private getActions(): ISelectionAction[] {
return this.defaultActions.filter((a) => !a.if || a.if(this));
}
/** Clear all selected photos */
public clearSelection(only?: IPhoto[]) {
const heads = new Set<IHeadRow>();
const toClear = only || this.selection.values();
Array.from(toClear).forEach((photo: IPhoto) => {
photo.flag &= ~this.c.FLAG_SELECTED;
heads.add(this.heads[photo.d.dayid]);
this.selection.delete(photo.fileid);
});
heads.forEach(this.updateHeadSelected);
this.$forceUpdate();
}
/** Check if the day for a photo is selected entirely */
private updateHeadSelected(head: IHeadRow) {
let selected = true;
// Check if all photos are selected
for (const row of head.day.rows) {
for (const photo of row.photos) {
if (!(photo.flag & this.c.FLAG_SELECTED)) {
selected = false;
break;
}
}
}
/** Get the actions list */
private getActions(): ISelectionAction[] {
return this.defaultActions.filter(a => !a.if || a.if(this));
// Update head
head.selected = selected;
}
/** Add a photo to selection list */
public selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) {
if (
photo.flag & this.c.FLAG_PLACEHOLDER ||
photo.flag & this.c.FLAG_IS_FOLDER ||
photo.flag & this.c.FLAG_IS_TAG
) {
return; // ignore placeholders
}
/** Clear all selected photos */
public clearSelection(only?: IPhoto[]) {
const heads = new Set<IHeadRow>();
const toClear = only || this.selection.values();
Array.from(toClear).forEach((photo: IPhoto) => {
photo.flag &= ~this.c.FLAG_SELECTED;
heads.add(this.heads[photo.d.dayid]);
this.selection.delete(photo.fileid);
});
heads.forEach(this.updateHeadSelected);
this.$forceUpdate();
const nval = val ?? !this.selection.has(photo.fileid);
if (nval) {
photo.flag |= this.c.FLAG_SELECTED;
this.selection.set(photo.fileid, photo);
} else {
photo.flag &= ~this.c.FLAG_SELECTED;
this.selection.delete(photo.fileid);
}
/** Check if the day for a photo is selected entirely */
private updateHeadSelected(head: IHeadRow) {
let selected = true;
if (!noUpdate) {
this.updateHeadSelected(this.heads[photo.d.dayid]);
this.$forceUpdate();
}
}
// Check if all photos are selected
for (const row of head.day.rows) {
for (const photo of row.photos) {
if (!(photo.flag & this.c.FLAG_SELECTED)) {
selected = false;
break;
}
}
/** Select or deselect all photos in a head */
public selectHead(head: IHeadRow) {
head.selected = !head.selected;
for (const row of head.day.rows) {
for (const photo of row.photos) {
this.selectPhoto(photo, head.selected, true);
}
}
this.$forceUpdate();
}
/**
* Download the currently selected files
*/
private async downloadSelection(selection: Selection) {
if (selection.size >= 100) {
if (
!confirm(
this.t(
"memories",
"You are about to download a large number of files. Are you sure?"
)
)
) {
return;
}
}
await dav.downloadFilesByIds(Array.from(selection.keys()));
}
/**
* Check if all files selected currently are favorites
*/
private allSelectedFavorites(selection: Selection) {
return Array.from(selection.values()).every(
(p) => p.flag & this.c.FLAG_IS_FAVORITE
);
}
/**
* Favorite the currently selected photos
*/
private async favoriteSelection(selection: Selection) {
const val = !this.allSelectedFavorites(selection);
for await (const favIds of dav.favoriteFilesByIds(
Array.from(selection.keys()),
val
)) {
favIds.forEach((id) => {
const photo = selection.get(id);
if (!photo) {
return;
}
// Update head
head.selected = selected;
}
/** Add a photo to selection list */
public selectPhoto(photo: IPhoto, val?: boolean, noUpdate?: boolean) {
if (photo.flag & this.c.FLAG_PLACEHOLDER ||
photo.flag & this.c.FLAG_IS_FOLDER ||
photo.flag & this.c.FLAG_IS_TAG
) {
return; // ignore placeholders
}
const nval = val ?? !this.selection.has(photo.fileid);
if (nval) {
photo.flag |= this.c.FLAG_SELECTED;
this.selection.set(photo.fileid, photo);
if (val) {
photo.flag |= this.c.FLAG_IS_FAVORITE;
} else {
photo.flag &= ~this.c.FLAG_SELECTED;
this.selection.delete(photo.fileid);
photo.flag &= ~this.c.FLAG_IS_FAVORITE;
}
});
}
this.clearSelection();
}
if (!noUpdate) {
this.updateHeadSelected(this.heads[photo.d.dayid]);
this.$forceUpdate();
}
/**
* Delete the currently selected photos
*/
private async deleteSelection(selection: Selection) {
if (selection.size >= 100) {
if (
!confirm(
this.t(
"memories",
"You are about to delete a large number of files. Are you sure?"
)
)
) {
return;
}
}
/** Select or deselect all photos in a head */
public selectHead(head: IHeadRow) {
head.selected = !head.selected;
for (const row of head.day.rows) {
for (const photo of row.photos) {
this.selectPhoto(photo, head.selected, true);
}
}
this.$forceUpdate();
for await (const delIds of dav.deleteFilesByIds(
Array.from(selection.keys())
)) {
const delPhotos = delIds.map((id) => selection.get(id));
this.deletePhotos(delPhotos);
}
}
/**
* Open the edit date dialog
*/
private async editDateSelection(selection: Selection) {
(<any>this.$refs.editDate).open(Array.from(selection.values()));
}
/**
* Open the files app with the selected file (one)
* Opens a new window.
*/
private async viewInFolder(selection: Selection) {
if (selection.size !== 1) return;
const photo: IPhoto = selection.values().next().value;
const f = await dav.getFiles([photo.fileid]);
if (f.length === 0) return;
const file = f[0];
const dirPath = file.filename.split("/").slice(0, -1).join("/");
const url = generateUrl(
`/apps/files/?dir=${dirPath}&scrollto=${file.fileid}&openfile=${file.fileid}`
);
window.open(url, "_blank");
}
/**
* Archive the currently selected photos
*/
private async archiveSelection(selection: Selection) {
if (selection.size >= 100) {
if (
!confirm(
this.t(
"memories",
"You are about to touch a large number of files. Are you sure?"
)
)
) {
return;
}
}
/**
* Download the currently selected files
*/
private async downloadSelection(selection: Selection) {
if (selection.size >= 100) {
if (!confirm(this.t("memories", "You are about to download a large number of files. Are you sure?"))) {
return;
}
}
await dav.downloadFilesByIds(Array.from(selection.keys()));
for await (let delIds of dav.archiveFilesByIds(
Array.from(selection.keys()),
!this.routeIsArchive()
)) {
delIds = delIds.filter((x) => x);
if (delIds.length === 0) {
continue;
}
const delPhotos = delIds.map((id) => selection.get(id));
this.deletePhotos(delPhotos);
}
}
/** Archive is not allowed only on folder routes */
private allowArchive() {
return this.$route.name !== "folders";
}
/** Is archive route */
private routeIsArchive() {
return this.$route.name === "archive";
}
/**
* Move selected photos to album
*/
private async addToAlbum(selection: Selection) {
(<any>this.$refs.addToAlbumModal).open(Array.from(selection.values()));
}
/**
* Remove selected photos from album
*/
private async removeFromAlbum(selection: Selection) {
try {
this.updateLoading(1);
const user = this.$route.params.user;
const name = this.$route.params.name;
const gen = dav.removeFromAlbum(user, name, Array.from(selection.keys()));
for await (const delIds of gen) {
const delPhotos = delIds
.filter((p) => p)
.map((id) => selection.get(id));
this.deletePhotos(delPhotos);
}
} catch (e) {
console.error(e);
showError(
e?.message || this.t("memories", "Could not remove photos from album")
);
} finally {
this.updateLoading(-1);
}
}
/**
* Move selected photos to another person
*/
private async moveSelectionToPerson(selection: Selection) {
if (!this.config_showFaceRect) {
showError(
this.t(
"memories",
'You must enable "Mark person in preview" to use this feature'
)
);
return;
}
(<any>this.$refs.faceMoveModal).open(Array.from(selection.values()));
}
/**
* Remove currently selected photos from person
*/
private async removeSelectionFromPerson(selection: Selection) {
// Make sure route is valid
const { user, name } = this.$route.params;
if (this.$route.name !== "people" || !user || !name) {
return;
}
/**
* Check if all files selected currently are favorites
*/
private allSelectedFavorites(selection: Selection) {
return Array.from(selection.values()).every(p => p.flag & this.c.FLAG_IS_FAVORITE);
// Check photo ownership
if (this.$route.params.user !== getCurrentUser().uid) {
showError(
this.t("memories", 'Only user "{user}" can update this person', {
user,
})
);
return;
}
/**
* Favorite the currently selected photos
*/
private async favoriteSelection(selection: Selection) {
const val = !this.allSelectedFavorites(selection);
for await (const favIds of dav.favoriteFilesByIds(Array.from(selection.keys()), val)) {
favIds.forEach(id => {
const photo = selection.get(id);
if (!photo) {
return;
}
if (val) {
photo.flag |= this.c.FLAG_IS_FAVORITE;
} else {
photo.flag &= ~this.c.FLAG_IS_FAVORITE;
}
});
}
this.clearSelection();
}
/**
* Delete the currently selected photos
*/
private async deleteSelection(selection: Selection) {
if (selection.size >= 100) {
if (!confirm(this.t("memories", "You are about to delete a large number of files. Are you sure?"))) {
return;
}
}
for await (const delIds of dav.deleteFilesByIds(Array.from(selection.keys()))) {
const delPhotos = delIds.map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
}
/**
* Open the edit date dialog
*/
private async editDateSelection(selection: Selection) {
(<any>this.$refs.editDate).open(Array.from(selection.values()));
}
/**
* Open the files app with the selected file (one)
* Opens a new window.
*/
private async viewInFolder(selection: Selection) {
if (selection.size !== 1) return;
const photo: IPhoto = selection.values().next().value;
const f = await dav.getFiles([photo.fileid]);
if (f.length === 0) return;
const file = f[0];
const dirPath = file.filename.split('/').slice(0, -1).join('/')
const url = generateUrl(`/apps/files/?dir=${dirPath}&scrollto=${file.fileid}&openfile=${file.fileid}`);
window.open(url, '_blank');
}
/**
* Archive the currently selected photos
*/
private async archiveSelection(selection: Selection) {
if (selection.size >= 100) {
if (!confirm(this.t("memories", "You are about to touch a large number of files. Are you sure?"))) {
return;
}
}
for await (let delIds of dav.archiveFilesByIds(Array.from(selection.keys()), !this.routeIsArchive())) {
delIds = delIds.filter(x => x);
if (delIds.length === 0) {
continue
}
const delPhotos = delIds.map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
}
/** Archive is not allowed only on folder routes */
private allowArchive() {
return this.$route.name !== 'folders';
}
/** Is archive route */
private routeIsArchive() {
return this.$route.name === 'archive';
}
/**
* Move selected photos to album
*/
private async addToAlbum(selection: Selection) {
(<any>this.$refs.addToAlbumModal).open(Array.from(selection.values()));
}
/**
* Remove selected photos from album
*/
private async removeFromAlbum(selection: Selection) {
try {
this.updateLoading(1);
const user = this.$route.params.user;
const name = this.$route.params.name;
const gen = dav.removeFromAlbum(user, name, Array.from(selection.keys()));
for await (const delIds of gen) {
const delPhotos = delIds.filter(p => p).map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
} catch (e) {
console.error(e);
showError(e?.message || this.t("memories", "Could not remove photos from album"));
} finally {
this.updateLoading(-1);
}
}
/**
* Move selected photos to another person
*/
private async moveSelectionToPerson(selection: Selection) {
if (!this.config_showFaceRect) {
showError(this.t('memories', 'You must enable "Mark person in preview" to use this feature'));
return;
}
(<any>this.$refs.faceMoveModal).open(Array.from(selection.values()));
}
/**
* Remove currently selected photos from person
*/
private async removeSelectionFromPerson(selection: Selection) {
// Make sure route is valid
const { user, name } = this.$route.params;
if (this.$route.name !== "people" || !user || !name) {
return;
}
// Check photo ownership
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
return;
}
// Run query
for await (let delIds of dav.removeFaceImages(user, name, Array.from(selection.keys()))) {
const delPhotos = delIds.filter(x => x).map(id => selection.get(id));
this.deletePhotos(delPhotos);
}
// Run query
for await (let delIds of dav.removeFaceImages(
user,
name,
Array.from(selection.keys())
)) {
const delPhotos = delIds.filter((x) => x).map((id) => selection.get(id));
this.deletePhotos(delPhotos);
}
}
}
</script>
<style lang="scss" scoped>
.top-bar {
position: absolute;
top: 10px; right: 60px;
padding: 8px;
width: 400px;
max-width: calc(100vw - 30px);
background-color: var(--color-main-background);
box-shadow: 0 0 2px gray;
border-radius: 10px;
opacity: 0.95;
display: flex;
vertical-align: middle;
z-index: 100;
position: absolute;
top: 10px;
right: 60px;
padding: 8px;
width: 400px;
max-width: calc(100vw - 30px);
background-color: var(--color-main-background);
box-shadow: 0 0 2px gray;
border-radius: 10px;
opacity: 0.95;
display: flex;
vertical-align: middle;
z-index: 100;
> .text {
flex-grow: 1;
line-height: 40px;
padding-left: 8px;
}
> .text {
flex-grow: 1;
line-height: 40px;
padding-left: 8px;
}
@media (max-width: 768px) {
top: 35px; right: 15px;
}
@media (max-width: 768px) {
top: 35px;
right: 15px;
}
}
</style>

View File

@ -21,89 +21,103 @@
-->
<template>
<div>
<label for="timeline-path">{{ t('memories', 'Timeline Path') }}</label>
<input id="timeline-path"
@click="chooseTimelinePath"
v-model="config_timelinePath"
type="text">
<div>
<label for="timeline-path">{{ t("memories", "Timeline Path") }}</label>
<input
id="timeline-path"
@click="chooseTimelinePath"
v-model="config_timelinePath"
type="text"
/>
<label for="folders-path">{{ t('memories', 'Folders Path') }}</label>
<input id="folders-path"
@click="chooseFoldersPath"
v-model="config_foldersPath"
type="text">
<label for="folders-path">{{ t("memories", "Folders Path") }}</label>
<input
id="folders-path"
@click="chooseFoldersPath"
v-model="config_foldersPath"
type="text"
/>
<NcCheckboxRadioSwitch :checked.sync="config_showHidden"
@update:checked="updateShowHidden"
type="switch">
{{ t('memories', 'Show hidden folders') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:checked.sync="config_showHidden"
@update:checked="updateShowHidden"
type="switch"
>
{{ t("memories", "Show hidden folders") }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked.sync="config_squareThumbs"
@update:checked="updateSquareThumbs"
type="switch">
{{ t('memories', 'Square grid mode') }}
</NcCheckboxRadioSwitch>
</div>
<NcCheckboxRadioSwitch
:checked.sync="config_squareThumbs"
@update:checked="updateSquareThumbs"
type="switch"
>
{{ t("memories", "Square grid mode") }}
</NcCheckboxRadioSwitch>
</div>
</template>
<style scoped>
input[type=text] {
width: 100%;
input[type="text"] {
width: 100%;
}
</style>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import GlobalMixin from '../mixins/GlobalMixin';
import { Component, Mixins } from "vue-property-decorator";
import GlobalMixin from "../mixins/GlobalMixin";
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import UserConfig from '../mixins/UserConfig'
import { getFilePickerBuilder } from "@nextcloud/dialogs";
import UserConfig from "../mixins/UserConfig";
import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
import { NcCheckboxRadioSwitch } from "@nextcloud/vue";
@Component({
components: {
NcCheckboxRadioSwitch,
},
components: {
NcCheckboxRadioSwitch,
},
})
export default class Settings extends Mixins(UserConfig, GlobalMixin) {
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter('httpd/unix-directory')
.allowDirectories()
.startAt(initial)
.build()
async chooseFolder(title: string, initial: string) {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter("httpd/unix-directory")
.allowDirectories()
.startAt(initial)
.build();
return await picker.pick();
}
return await picker.pick();
}
async chooseTimelinePath() {
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root of your timeline'), this.config_timelinePath);
if (newPath !== this.config_timelinePath) {
this.config_timelinePath = newPath;
await this.updateSetting('timelinePath');
}
async chooseTimelinePath() {
const newPath = await this.chooseFolder(
this.t("memories", "Choose the root of your timeline"),
this.config_timelinePath
);
if (newPath !== this.config_timelinePath) {
this.config_timelinePath = newPath;
await this.updateSetting("timelinePath");
}
}
async chooseFoldersPath() {
const newPath = await this.chooseFolder(this.t('memories', 'Choose the root for the folders view'), this.config_foldersPath);
if (newPath !== this.config_foldersPath) {
this.config_foldersPath = newPath;
await this.updateSetting('foldersPath');
}
async chooseFoldersPath() {
const newPath = await this.chooseFolder(
this.t("memories", "Choose the root for the folders view"),
this.config_foldersPath
);
if (newPath !== this.config_foldersPath) {
this.config_foldersPath = newPath;
await this.updateSetting("foldersPath");
}
}
async updateSquareThumbs() {
await this.updateSetting('squareThumbs');
}
async updateSquareThumbs() {
await this.updateSetting("squareThumbs");
}
async updateShowHidden() {
await this.updateSetting('showHidden');
}
async updateShowHidden() {
await this.updateSetting("showHidden");
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@ -1,202 +1,238 @@
<template>
<router-link class="folder fill-block" :class="{
hasPreview: previewFileInfos.length > 0,
onePreview: previewFileInfos.length === 1,
hasError: error,
<router-link
class="folder fill-block"
:class="{
hasPreview: previewFileInfos.length > 0,
onePreview: previewFileInfos.length === 1,
hasError: error,
}"
:to="target">
<div class="big-icon fill-block">
<FolderIcon class="icon" />
<div class="name">{{ data.name }}</div>
</div>
:to="target"
>
<div class="big-icon fill-block">
<FolderIcon class="icon" />
<div class="name">{{ data.name }}</div>
</div>
<div class="previews fill-block">
<div class="img-outer" v-for="info of previewFileInfos" :key="info.fileid">
<img
class="fill-block"
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }"
:key="'fpreview-' + info.fileid"
:src="getPreviewUrl(info.fileid, info.etag, true, 256)"
@error="info.flag |= c.FLAG_LOAD_FAIL" />
</div>
</div>
</router-link>
<div class="previews fill-block">
<div
class="img-outer"
v-for="info of previewFileInfos"
:key="info.fileid"
>
<img
class="fill-block"
:class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
:key="'fpreview-' + info.fileid"
:src="getPreviewUrl(info.fileid, info.etag, true, 256)"
@error="info.flag |= c.FLAG_LOAD_FAIL"
/>
</div>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Watch, Mixins } from 'vue-property-decorator';
import { IFileInfo, IFolder } from '../../types';
import GlobalMixin from '../../mixins/GlobalMixin';
import UserConfig from '../../mixins/UserConfig';
import { Component, Prop, Watch, Mixins } from "vue-property-decorator";
import { IFileInfo, IFolder } from "../../types";
import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig";
import * as dav from "../../services/DavRequests";
import { getPreviewUrl } from "../../services/FileUtils";
import FolderIcon from 'vue-material-design-icons/Folder.vue';
import FolderIcon from "vue-material-design-icons/Folder.vue";
@Component({
components: {
FolderIcon,
},
components: {
FolderIcon,
},
})
export default class Folder extends Mixins(GlobalMixin, UserConfig) {
@Prop() data: IFolder;
@Prop() data: IFolder;
// Separate property because the one on data isn't reactive
private previewFileInfos: IFileInfo[] = [];
// Separate property because the one on data isn't reactive
private previewFileInfos: IFileInfo[] = [];
// Error occured fetching thumbs
private error = false;
// Error occured fetching thumbs
private error = false;
/** Passthrough */
private getPreviewUrl = getPreviewUrl;
/** Passthrough */
private getPreviewUrl = getPreviewUrl;
mounted() {
this.refreshPreviews();
mounted() {
this.refreshPreviews();
}
@Watch("data")
dataChanged() {
this.refreshPreviews();
}
/** Refresh previews */
refreshPreviews() {
// Reset state
this.error = false;
// Check if valid path present
if (!this.data.path) {
this.error = true;
return;
}
@Watch('data')
dataChanged() {
this.refreshPreviews();
// Get preview infos
if (!this.data.previewFileInfos) {
const folderPath = this.data.path.split("/").slice(3).join("/");
dav
.getFolderPreviewFileIds(folderPath, 4)
.then((fileInfos) => {
fileInfos = fileInfos.filter((f) => f.hasPreview);
fileInfos.forEach((f) => (f.flag = 0));
if (fileInfos.length > 0 && fileInfos.length < 4) {
fileInfos = [fileInfos[0]];
}
this.data.previewFileInfos = fileInfos;
this.previewFileInfos = fileInfos;
})
.catch(() => {
this.data.previewFileInfos = [];
this.previewFileInfos = [];
// Something is wrong with the folder
// e.g. external storage not available
this.error = true;
});
} else {
this.previewFileInfos = this.data.previewFileInfos;
}
}
/** Open folder */
get target() {
const path = this.data.path
.split("/")
.filter((x) => x)
.slice(2) as string[];
// Remove base path if present
const basePath = this.config_foldersPath.split("/").filter((x) => x);
if (
path.length >= basePath.length &&
path.slice(0, basePath.length).every((x, i) => x === basePath[i])
) {
path.splice(0, basePath.length);
}
/** Refresh previews */
refreshPreviews() {
// Reset state
this.error = false;
// Check if valid path present
if (!this.data.path) {
this.error = true;
return;
}
// Get preview infos
if (!this.data.previewFileInfos) {
const folderPath = this.data.path.split('/').slice(3).join('/');
dav.getFolderPreviewFileIds(folderPath, 4).then(fileInfos => {
fileInfos = fileInfos.filter(f => f.hasPreview);
fileInfos.forEach(f => f.flag = 0);
if (fileInfos.length > 0 && fileInfos.length < 4) {
fileInfos = [fileInfos[0]];
}
this.data.previewFileInfos = fileInfos;
this.previewFileInfos = fileInfos;
}).catch(() => {
this.data.previewFileInfos = [];
this.previewFileInfos = [];
// Something is wrong with the folder
// e.g. external storage not available
this.error = true;
});
} else {
this.previewFileInfos = this.data.previewFileInfos;
}
}
/** Open folder */
get target() {
const path = this.data.path.split('/').filter(x => x).slice(2) as string[];
// Remove base path if present
const basePath = this.config_foldersPath.split('/').filter(x => x);
if (path.length >= basePath.length && path.slice(0, basePath.length).every((x, i) => x === basePath[i])) {
path.splice(0, basePath.length);
}
return { name: 'folders', params: { path: path as any }};
}
return { name: "folders", params: { path: path as any } };
}
}
</script>
<style lang="scss" scoped>
.folder {
cursor: pointer;
cursor: pointer;
}
.big-icon {
cursor: pointer;
z-index: 100;
position: absolute;
top: 0;
left: 0;
transition: opacity 0.2s ease-in-out;
:deep .material-design-icon__svg {
width: 50%;
height: 50%;
}
> .name {
cursor: pointer;
z-index: 100;
width: 100%;
padding: 0 5%;
text-align: center;
font-size: 1.08em;
word-wrap: break-word;
text-overflow: ellipsis;
max-height: 35%;
line-height: 1em;
position: absolute;
top: 0; left: 0;
transition: opacity 0.2s ease-in-out;
top: 65%;
}
:deep .material-design-icon__svg {
width: 50%; height: 50%;
// Make it white if there is a preview
.folder.hasPreview > & {
.folder-icon {
opacity: 1;
filter: invert(1) brightness(100);
}
> .name {
cursor: pointer;
width: 100%;
padding: 0 5%;
text-align: center;
font-size: 1.08em;
word-wrap: break-word;
text-overflow: ellipsis;
max-height: 35%;
line-height: 1em;
position: absolute;
top: 65%;
.name {
color: white;
}
}
// Make it white if there is a preview
.folder.hasPreview > & {
.folder-icon {
opacity: 1;
filter: invert(1) brightness(100);
}
.name { color: white; }
// Show it on hover if not a preview
.folder:hover > & > .folder-icon {
opacity: 0.8;
}
.folder.hasPreview:hover > & {
opacity: 0;
}
// Make it red if has an error
.folder.hasError > & {
.folder-icon {
filter: invert(12%) sepia(62%) saturate(5862%) hue-rotate(8deg)
brightness(103%) contrast(128%);
}
// Show it on hover if not a preview
.folder:hover > & > .folder-icon { opacity: 0.8; }
.folder.hasPreview:hover > & { opacity: 0; }
// Make it red if has an error
.folder.hasError > & {
.folder-icon {
filter: invert(12%) sepia(62%) saturate(5862%) hue-rotate(8deg) brightness(103%) contrast(128%);
}
.name { color: #bb0000; }
.name {
color: #bb0000;
}
}
> .folder-icon {
cursor: pointer;
height: 90%; width: 100%;
opacity: 0.3;
}
> .folder-icon {
cursor: pointer;
height: 90%;
width: 100%;
opacity: 0.3;
}
}
.previews {
z-index: 3;
line-height: 0;
position: absolute;
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) { padding: 1px; }
z-index: 3;
line-height: 0;
position: absolute;
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) {
padding: 1px;
}
> .img-outer {
background-color: var(--color-background-dark);
padding: 0;
margin: 0;
width: 50%;
height: 50%;
display: inline-block;
> .img-outer {
background-color: var(--color-background-dark);
padding: 0;
margin: 0;
width: 50%;
height: 50%;
display: inline-block;
.folder.onePreview > & {
width: 100%; height: 100%;
}
> img {
object-fit: cover;
padding: 0;
filter: brightness(50%);
transition: filter 0.2s ease-in-out;
&.error { display: none; }
.folder:hover & { filter: brightness(100%); }
}
.folder.onePreview > & {
width: 100%;
height: 100%;
}
> img {
object-fit: cover;
padding: 0;
filter: brightness(50%);
transition: filter 0.2s ease-in-out;
&.error {
display: none;
}
.folder:hover & {
filter: brightness(100%);
}
}
}
}
</style>

View File

@ -1,194 +1,210 @@
<template>
<div class="p-outer fill-block"
:class="{
'selected': (data.flag & c.FLAG_SELECTED),
'placeholder': (data.flag & c.FLAG_PLACEHOLDER),
'leaving': (data.flag & c.FLAG_LEAVING),
'error': (data.flag & c.FLAG_LOAD_FAIL),
}">
<div
class="p-outer fill-block"
:class="{
selected: data.flag & c.FLAG_SELECTED,
placeholder: data.flag & c.FLAG_PLACEHOLDER,
leaving: data.flag & c.FLAG_LEAVING,
error: data.flag & c.FLAG_LOAD_FAIL,
}"
>
<Check
:size="15"
class="select"
v-if="!(data.flag & c.FLAG_PLACEHOLDER)"
@click="toggleSelect"
/>
<Check :size="15" class="select"
v-if="!(data.flag & c.FLAG_PLACEHOLDER)"
@click="toggleSelect" />
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
<Video :size="20" v-if="data.flag & c.FLAG_IS_VIDEO" />
<Star :size="20" v-if="data.flag & c.FLAG_IS_FAVORITE" />
<div class="img-outer fill-block"
@click="emitClick"
@contextmenu="contextmenu"
@touchstart="touchstart"
@touchmove="touchend"
@touchend="touchend"
@touchcancel="touchend" >
<img
ref="img"
class="fill-block"
:src="src"
:key="data.fileid"
@load="load"
@error="error" />
</div>
<div
class="img-outer fill-block"
@click="emitClick"
@contextmenu="contextmenu"
@touchstart="touchstart"
@touchmove="touchend"
@touchend="touchend"
@touchcancel="touchend"
>
<img
ref="img"
class="fill-block"
:src="src"
:key="data.fileid"
@load="load"
@error="error"
/>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Emit, Mixins, Watch } from 'vue-property-decorator';
import { Component, Prop, Emit, Mixins, Watch } from "vue-property-decorator";
import { IDay, IPhoto } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils";
import errorsvg from "../../assets/error.svg";
import GlobalMixin from '../../mixins/GlobalMixin';
import GlobalMixin from "../../mixins/GlobalMixin";
import Check from 'vue-material-design-icons/Check.vue';
import Video from 'vue-material-design-icons/Video.vue';
import Star from 'vue-material-design-icons/Star.vue';
import Check from "vue-material-design-icons/Check.vue";
import Video from "vue-material-design-icons/Video.vue";
import Star from "vue-material-design-icons/Star.vue";
@Component({
components: {
Check,
Video,
Star,
},
components: {
Check,
Video,
Star,
},
})
export default class Photo extends Mixins(GlobalMixin) {
private touchTimer = 0;
private src = null;
private hasFaceRect = false;
private touchTimer = 0;
private src = null;
private hasFaceRect = false;
@Prop() data: IPhoto;
@Prop() day: IDay;
@Prop() data: IPhoto;
@Prop() day: IDay;
@Emit('select') emitSelect(data: IPhoto) {}
@Emit('click') emitClick() {}
@Emit("select") emitSelect(data: IPhoto) {}
@Emit("click") emitClick() {}
@Watch('data')
onDataChange(newData: IPhoto, oldData: IPhoto) {
// Copy flags relevant to this component
if (oldData && newData) {
newData.flag |= oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL);
}
@Watch("data")
onDataChange(newData: IPhoto, oldData: IPhoto) {
// Copy flags relevant to this component
if (oldData && newData) {
newData.flag |=
oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL);
}
}
mounted() {
this.hasFaceRect = false;
this.refresh();
}
async refresh() {
this.src = await this.getSrc();
}
/** Get src for image to show */
async getSrc() {
if (this.data.flag & this.c.FLAG_PLACEHOLDER) {
return null;
} else if (this.data.flag & this.c.FLAG_LOAD_FAIL) {
return errorsvg;
} else {
return this.url();
}
}
/** Get url of the photo */
url() {
let base = 256;
// Check if displayed size is larger than the image
if (this.data.dispH > base * 0.9 && this.data.dispW > base * 0.9) {
// Get a bigger image
// 1. No trickery here, just get one size bigger. This is to
// ensure that the images can be cached even after reflow.
// 2. Nextcloud only allows 4**x sized images, so technically
// this ends up being equivalent to 1024x1024.
base = 512;
}
mounted() {
this.hasFaceRect = false;
this.refresh();
// Make the shorter dimension equal to base
let size = base;
if (this.data.w && this.data.h) {
size =
Math.floor(
(base * Math.max(this.data.w, this.data.h)) /
Math.min(this.data.w, this.data.h)
) - 1;
}
async refresh() {
this.src = await this.getSrc();
}
/** Get src for image to show */
async getSrc() {
if (this.data.flag & this.c.FLAG_PLACEHOLDER) {
return null;
} else if (this.data.flag & this.c.FLAG_LOAD_FAIL) {
return errorsvg;
} else {
return this.url();
}
}
/** Get url of the photo */
url() {
let base = 256;
// Check if displayed size is larger than the image
if (this.data.dispH > base * 0.9 && this.data.dispW > base * 0.9) {
// Get a bigger image
// 1. No trickery here, just get one size bigger. This is to
// ensure that the images can be cached even after reflow.
// 2. Nextcloud only allows 4**x sized images, so technically
// this ends up being equivalent to 1024x1024.
base = 512;
}
// Make the shorter dimension equal to base
let size = base;
if (this.data.w && this.data.h) {
size = Math.floor(base * Math.max(this.data.w, this.data.h) / Math.min(this.data.w, this.data.h)) - 1;
}
return getPreviewUrl(this.data.fileid, this.data.etag, false, size)
}
/** Set src with overlay face rect */
async addFaceRect() {
if (!this.data.facerect || this.hasFaceRect) return;
this.hasFaceRect = true;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const img = this.$refs.img as HTMLImageElement;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
context.drawImage(img, 0, 0);
context.strokeStyle = '#00ff00';
context.lineWidth = 2;
context.strokeRect(
this.data.facerect.x * img.naturalWidth,
this.data.facerect.y * img.naturalHeight,
this.data.facerect.w * img.naturalWidth,
this.data.facerect.h * img.naturalHeight,
);
canvas.toBlob((blob) => {
this.src = URL.createObjectURL(blob);
}, 'image/jpeg', 0.95)
}
/** Post load tasks */
load() {
this.addFaceRect();
}
/** Error in loading image */
error(e: any) {
this.data.flag |= this.c.FLAG_LOAD_FAIL;
this.refresh();
}
/** Clear timers */
beforeUnmount() {
clearTimeout(this.touchTimer);
}
toggleSelect() {
if (this.data.flag & this.c.FLAG_PLACEHOLDER) return;
this.emitSelect(this.data);
}
touchstart() {
this.touchTimer = window.setTimeout(() => {
this.toggleSelect();
this.touchTimer = 0;
}, 600);
}
contextmenu(e: Event) {
e.preventDefault();
e.stopPropagation();
}
touchend() {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = 0;
}
return getPreviewUrl(this.data.fileid, this.data.etag, false, size);
}
/** Set src with overlay face rect */
async addFaceRect() {
if (!this.data.facerect || this.hasFaceRect) return;
this.hasFaceRect = true;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
const img = this.$refs.img as HTMLImageElement;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
context.drawImage(img, 0, 0);
context.strokeStyle = "#00ff00";
context.lineWidth = 2;
context.strokeRect(
this.data.facerect.x * img.naturalWidth,
this.data.facerect.y * img.naturalHeight,
this.data.facerect.w * img.naturalWidth,
this.data.facerect.h * img.naturalHeight
);
canvas.toBlob(
(blob) => {
this.src = URL.createObjectURL(blob);
},
"image/jpeg",
0.95
);
}
/** Post load tasks */
load() {
this.addFaceRect();
}
/** Error in loading image */
error(e: any) {
this.data.flag |= this.c.FLAG_LOAD_FAIL;
this.refresh();
}
/** Clear timers */
beforeUnmount() {
clearTimeout(this.touchTimer);
}
toggleSelect() {
if (this.data.flag & this.c.FLAG_PLACEHOLDER) return;
this.emitSelect(this.data);
}
touchstart() {
this.touchTimer = window.setTimeout(() => {
this.toggleSelect();
this.touchTimer = 0;
}, 600);
}
contextmenu(e: Event) {
e.preventDefault();
e.stopPropagation();
}
touchend() {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = 0;
}
}
}
</script>
<style lang="scss" scoped>
/* Container and selection */
.p-outer {
&.leaving {
transition: all 0.2s ease-in;
transform: scale(0.9);
opacity: 0;
}
&.leaving {
transition: all 0.2s ease-in;
transform: scale(0.9);
opacity: 0;
}
}
// Distance of icon from border
@ -196,56 +212,75 @@ $icon-dist: min(10px, 6%);
/* Extra icons */
.check-icon.select {
position: absolute;
top: $icon-dist; left: $icon-dist;
z-index: 100;
background-color: var(--color-main-background);
border-radius: 50%;
cursor: pointer;
display: none;
position: absolute;
top: $icon-dist;
left: $icon-dist;
z-index: 100;
background-color: var(--color-main-background);
border-radius: 50%;
cursor: pointer;
display: none;
.p-outer:hover > & { display: flex; }
.selected > & { display: flex; filter: invert(1); }
.p-outer:hover > & {
display: flex;
}
.selected > & {
display: flex;
filter: invert(1);
}
}
.video-icon, .star-icon {
position: absolute;
z-index: 100;
pointer-events: none;
filter: invert(1) brightness(100);
.video-icon,
.star-icon {
position: absolute;
z-index: 100;
pointer-events: none;
filter: invert(1) brightness(100);
}
.video-icon {
top: $icon-dist; right: $icon-dist;
top: $icon-dist;
right: $icon-dist;
}
.star-icon {
bottom: $icon-dist; left: $icon-dist;
bottom: $icon-dist;
left: $icon-dist;
}
/* Actual image */
div.img-outer {
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) { padding: 1px; }
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) {
padding: 1px;
}
transition: padding 0.1s ease;
background-clip: content-box, padding-box;
background-color: var(--color-background-dark);
transition: padding 0.1s ease;
background-clip: content-box, padding-box;
background-color: var(--color-background-dark);
.selected > & { padding: calc($icon-dist - 2px); }
.selected > & {
padding: calc($icon-dist - 2px);
}
> img {
filter: contrast(1.05); // most real world images are a bit overexposed
background-clip: content-box;
object-fit: cover;
cursor: pointer;
> img {
filter: contrast(1.05); // most real world images are a bit overexposed
background-clip: content-box;
object-fit: cover;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
transition: box-shadow 0.1s ease;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
transition: box-shadow 0.1s ease;
.selected > & { box-shadow: 0 0 4px 2px var(--color-primary); }
.p-outer.placeholder > & { display: none; }
.p-outer.error & { object-fit: contain; }
.selected > & {
box-shadow: 0 0 4px 2px var(--color-primary);
}
.p-outer.placeholder > & {
display: none;
}
.p-outer.error & {
object-fit: contain;
}
}
}
</style>

View File

@ -1,255 +1,276 @@
<template>
<router-link class="tag fill-block" :class="{
hasPreview: previews.length > 0,
onePreview: previews.length === 1,
hasError: error,
isFace: isFace,
<router-link
class="tag fill-block"
:class="{
hasPreview: previews.length > 0,
onePreview: previews.length === 1,
hasError: error,
isFace: isFace,
}"
:to="target"
@click.native="openTag(data)">
@click.native="openTag(data)"
>
<div class="bbl">
<NcCounterBubble> {{ data.count }} </NcCounterBubble>
</div>
<div class="name">
{{ data.name }}
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span>
</div>
<div class="bbl"> <NcCounterBubble> {{ data.count }} </NcCounterBubble> </div>
<div class="name">
{{ data.name }}
<span class="subtitle" v-if="subtitle"> {{ subtitle }} </span>
</div>
<div class="previews fill-block" ref="previews">
<div class="img-outer" v-for="info of previews" :key="info.fileid">
<img
class="fill-block"
:class="{ 'error': info.flag & c.FLAG_LOAD_FAIL }"
:key="'fpreview-' + info.fileid"
:src="getPreviewUrl(info.fileid, info.etag)"
@error="info.flag |= c.FLAG_LOAD_FAIL" />
</div>
</div>
</router-link>
<div class="previews fill-block" ref="previews">
<div class="img-outer" v-for="info of previews" :key="info.fileid">
<img
class="fill-block"
:class="{ error: info.flag & c.FLAG_LOAD_FAIL }"
:key="'fpreview-' + info.fileid"
:src="getPreviewUrl(info.fileid, info.etag)"
@error="info.flag |= c.FLAG_LOAD_FAIL"
/>
</div>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Watch, Mixins, Emit } from 'vue-property-decorator';
import { IAlbum, IPhoto, ITag } from '../../types';
import { generateUrl } from '@nextcloud/router'
import { Component, Prop, Watch, Mixins, Emit } from "vue-property-decorator";
import { IAlbum, IPhoto, ITag } from "../../types";
import { generateUrl } from "@nextcloud/router";
import { getPreviewUrl } from "../../services/FileUtils";
import { getCurrentUser } from '@nextcloud/auth';
import { getCurrentUser } from "@nextcloud/auth";
import { NcCounterBubble } from '@nextcloud/vue';
import axios from '@nextcloud/axios';
import { NcCounterBubble } from "@nextcloud/vue";
import axios from "@nextcloud/axios";
import * as utils from "../../services/Utils";
import GlobalMixin from '../../mixins/GlobalMixin';
import { constants } from '../../services/Utils';
import GlobalMixin from "../../mixins/GlobalMixin";
import { constants } from "../../services/Utils";
@Component({
components: {
NcCounterBubble,
},
components: {
NcCounterBubble,
},
})
export default class Tag extends Mixins(GlobalMixin) {
@Prop() data: ITag;
@Prop() noNavigate: boolean;
@Prop() data: ITag;
@Prop() noNavigate: boolean;
// Separate property because the one on data isn't reactive
private previews: IPhoto[] = [];
// Separate property because the one on data isn't reactive
private previews: IPhoto[] = [];
// Error occured fetching thumbs
private error = false;
// Error occured fetching thumbs
private error = false;
// Smaller subtitle
private subtitle = '';
// Smaller subtitle
private subtitle = "";
/**
* Open tag event
* Unless noNavigate is set, the tag will be opened
*/
@Emit('open')
openTag(tag: ITag) {}
/**
* Open tag event
* Unless noNavigate is set, the tag will be opened
*/
@Emit("open")
openTag(tag: ITag) {}
mounted() {
this.refreshPreviews();
mounted() {
this.refreshPreviews();
}
@Watch("data")
dataChanged() {
this.refreshPreviews();
}
getPreviewUrl(fileid: number, etag: string) {
if (this.isFace) {
return generateUrl(
"/apps/memories/api/faces/preview/" + this.data.fileid
);
}
return getPreviewUrl(fileid, etag, true, 256);
}
get isFace() {
return this.data.flag & constants.c.FLAG_IS_FACE;
}
get isAlbum() {
return this.data.flag & constants.c.FLAG_IS_ALBUM;
}
async refreshPreviews() {
// Reset state
this.error = false;
this.subtitle = "";
// Add dummy preview if face
if (this.isFace) {
this.previews = [{ fileid: 0, etag: "", flag: 0 }];
return;
}
@Watch('data')
dataChanged() {
this.refreshPreviews();
// Add preview from last photo if album
if (this.isAlbum) {
const album = this.data as IAlbum;
if (album.last_added_photo > 0) {
this.previews = [{ fileid: album.last_added_photo, etag: "", flag: 0 }];
}
if (album.user !== getCurrentUser()?.uid) {
this.subtitle = `(${album.user})`;
}
return;
}
getPreviewUrl(fileid: number, etag: string) {
if (this.isFace) {
return generateUrl('/apps/memories/api/faces/preview/' + this.data.fileid);
// Look for previews
if (!this.data.previews) {
try {
const todayDayId = utils.dateToDayId(new Date());
const url = generateUrl(
`/apps/memories/api/tag-previews?tag=${this.data.name}`
);
const cacheUrl = `${url}&today=${Math.floor(todayDayId / 10)}`;
const cache = await utils.getCachedData(cacheUrl);
if (cache) {
this.data.previews = cache as any;
} else {
const res = await axios.get(url);
this.data.previews = res.data;
// Cache only if >= 4 previews
if (this.data.previews.length >= 4) {
utils.cacheData(cacheUrl, res.data);
}
}
return getPreviewUrl(fileid, etag, true, 256);
} catch (e) {
this.error = true;
return;
}
}
get isFace() {
return this.data.flag & constants.c.FLAG_IS_FACE;
// Reset flag
this.data.previews.forEach((p) => (p.flag = 0));
// Get 4 or 1 preview(s)
let data = this.data.previews;
if (data.length < 4) {
data = data.slice(0, 1);
}
this.previews = data;
this.error = this.previews.length === 0;
}
/** Target URL to navigate to */
get target() {
if (this.noNavigate) return {};
if (this.isFace) {
const name = this.data.name || this.data.fileid.toString();
const user = this.data.user_id;
return { name: "people", params: { name, user } };
}
get isAlbum() {
return this.data.flag & constants.c.FLAG_IS_ALBUM;
if (this.isAlbum) {
const user = (<IAlbum>this.data).user;
const name = this.data.name;
return { name: "albums", params: { user, name } };
}
async refreshPreviews() {
// Reset state
this.error = false;
this.subtitle = '';
// Add dummy preview if face
if (this.isFace) {
this.previews = [{ fileid: 0, etag: '', flag: 0 }];
return;
}
// Add preview from last photo if album
if (this.isAlbum) {
const album = this.data as IAlbum;
if (album.last_added_photo > 0) {
this.previews = [{ fileid: album.last_added_photo, etag: '', flag: 0 }];
}
if (album.user !== getCurrentUser()?.uid) {
this.subtitle = `(${album.user})`;
}
return;
}
// Look for previews
if (!this.data.previews) {
try {
const todayDayId = utils.dateToDayId(new Date());
const url = generateUrl(`/apps/memories/api/tag-previews?tag=${this.data.name}`);
const cacheUrl = `${url}&today=${Math.floor(todayDayId / 10)}`;
const cache = await utils.getCachedData(cacheUrl);
if (cache) {
this.data.previews = cache as any;
} else {
const res = await axios.get(url);
this.data.previews = res.data;
// Cache only if >= 4 previews
if (this.data.previews.length >= 4) {
utils.cacheData(cacheUrl, res.data);
}
}
} catch (e) {
this.error = true;
return;
}
}
// Reset flag
this.data.previews.forEach((p) => p.flag = 0);
// Get 4 or 1 preview(s)
let data = this.data.previews;
if (data.length < 4) {
data = data.slice(0, 1);
}
this.previews = data;
this.error = this.previews.length === 0;
}
/** Target URL to navigate to */
get target() {
if (this.noNavigate) return {};
if (this.isFace) {
const name = this.data.name || this.data.fileid.toString();
const user = this.data.user_id;
return { name: 'people', params: { name, user }};
}
if (this.isAlbum) {
const user = (<IAlbum>this.data).user;
const name = this.data.name;
return { name: 'albums', params: { user, name }};
}
return { name: 'tags', params: { name: this.data.name }};
}
return { name: "tags", params: { name: this.data.name } };
}
}
</script>
<style lang="scss" scoped>
.tag, .name, .bubble, img {
cursor: pointer;
.tag,
.name,
.bubble,
img {
cursor: pointer;
}
// Get rid of color of the bubble
.tag .bbl :deep .counter-bubble__counter {
color: unset !important;
color: unset !important;
}
.name {
z-index: 100;
position: absolute;
top: 50%; width: 100%;
transform: translateY(-50%);
color: white;
padding: 0 5%;
text-align: center;
font-size: 1.2em;
word-wrap: break-word;
text-overflow: ellipsis;
line-height: 1em;
z-index: 100;
position: absolute;
top: 50%;
width: 100%;
transform: translateY(-50%);
color: white;
padding: 0 5%;
text-align: center;
font-size: 1.2em;
word-wrap: break-word;
text-overflow: ellipsis;
line-height: 1em;
> .subtitle {
font-size: 0.7em;
margin-top: 2px;
display: block;
}
> .subtitle {
font-size: 0.7em;
margin-top: 2px;
display: block;
}
.isFace > & {
top: unset;
bottom: 10%;
transform: unset;
}
.isFace > & {
top: unset;
bottom: 10%;
transform: unset;
}
}
.bbl {
z-index: 100;
position: absolute;
top: 6px; right: 5px;
z-index: 100;
position: absolute;
top: 6px;
right: 5px;
}
.previews {
z-index: 3;
line-height: 0;
position: absolute;
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) { padding: 1px; }
z-index: 3;
line-height: 0;
position: absolute;
padding: 2px;
box-sizing: border-box;
@media (max-width: 768px) {
padding: 1px;
}
.tag:not(.hasPreview) & {
background-color: #444;
background-clip: content-box;
.tag:not(.hasPreview) & {
background-color: #444;
background-clip: content-box;
}
> .img-outer {
background-color: var(--color-background-dark);
padding: 0;
margin: 0;
width: 50%;
height: 50%;
overflow: hidden;
display: inline-block;
cursor: pointer;
.tag.onePreview > & {
width: 100%;
height: 100%;
}
> .img-outer {
background-color: var(--color-background-dark);
padding: 0;
margin: 0;
width: 50%;
height: 50%;
overflow: hidden;
display: inline-block;
cursor: pointer;
> img {
object-fit: cover;
padding: 0;
filter: brightness(60%);
cursor: pointer;
transition: filter 0.2s ease-in-out;
.tag.onePreview > & {
width: 100%; height: 100%;
}
> img {
object-fit: cover;
padding: 0;
filter: brightness(60%);
cursor: pointer;
transition: filter 0.2s ease-in-out;
&.error { display: none; }
.tag:hover & { filter: brightness(100%); }
}
&.error {
display: none;
}
.tag:hover & {
filter: brightness(100%);
}
}
}
}
</style>

View File

@ -1,84 +1,92 @@
<template>
<Modal @close="close" size="normal" v-if="show">
<template #title>
{{ t('memories', 'Add to album') }}
</template>
<Modal @close="close" size="normal" v-if="show">
<template #title>
{{ t("memories", "Add to album") }}
</template>
<div class="outer">
<AlbumPicker @select="selectAlbum" />
<div class="outer">
<AlbumPicker @select="selectAlbum" />
<div v-if="processing" class="info-pad">
{{ t('memories', 'Processing … {n}/{m}', {
n: photosDone,
m: photos.length,
}) }}
</div>
</div>
</Modal>
<div v-if="processing" class="info-pad">
{{
t("memories", "Processing … {n}/{m}", {
n: photosDone,
m: photos.length,
})
}}
</div>
</div>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import * as dav from '../../services/DavRequests';
import { showInfo } from '@nextcloud/dialogs';
import { IAlbum, IPhoto } from '../../types';
import * as dav from "../../services/DavRequests";
import { showInfo } from "@nextcloud/dialogs";
import { IAlbum, IPhoto } from "../../types";
import AlbumPicker from './AlbumPicker.vue';
import Modal from './Modal.vue';
import AlbumPicker from "./AlbumPicker.vue";
import Modal from "./Modal.vue";
@Component({
components: {
Modal,
AlbumPicker,
}
components: {
Modal,
AlbumPicker,
},
})
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
private show = false;
private photos: IPhoto[] = [];
private photosDone: number = 0;
private processing: boolean = false;
private show = false;
private photos: IPhoto[] = [];
private photosDone: number = 0;
private processing: boolean = false;
public open(photos: IPhoto[]) {
this.photosDone = 0;
this.processing = false;
this.show = true;
this.photos = photos;
public open(photos: IPhoto[]) {
this.photosDone = 0;
this.processing = false;
this.show = true;
this.photos = photos;
}
@Emit("added")
public added(photos: IPhoto[]) {}
@Emit("close")
public close() {
this.photos = [];
this.processing = false;
this.show = false;
}
public async selectAlbum(album: IAlbum) {
const name = album.name || album.album_id.toString();
const gen = dav.addToAlbum(
album.user,
name,
this.photos.map((p) => p.fileid)
);
this.processing = true;
for await (const fids of gen) {
this.photosDone += fids.filter((f) => f).length;
this.added(this.photos.filter((p) => fids.includes(p.fileid)));
}
@Emit('added')
public added(photos: IPhoto[]) {}
@Emit('close')
public close() {
this.photos = [];
this.processing = false;
this.show = false;
}
public async selectAlbum(album: IAlbum) {
const name = album.name || album.album_id.toString();
const gen = dav.addToAlbum(album.user, name, this.photos.map(p => p.fileid));
this.processing = true;
for await (const fids of gen) {
this.photosDone += fids.filter(f => f).length;
this.added(this.photos.filter(p => fids.includes(p.fileid)));
}
showInfo(this.t('memories', '{n} photos added to album', { n: this.photosDone }));
this.close();
}
showInfo(
this.t("memories", "{n} photos added to album", { n: this.photosDone })
);
this.close();
}
}
</script>
<style lang="scss" scoped>
.outer {
margin-top: 15px;
margin-top: 15px;
}
.info-pad {
margin-top: 6px;
margin-top: 6px;
}
</style>

View File

@ -20,452 +20,533 @@
-
-->
<template>
<div class="manage-collaborators">
<div class="manage-collaborators__subtitle">
{{ t('photos', 'Add people or groups who can edit your album') }}
</div>
<div class="manage-collaborators">
<div class="manage-collaborators__subtitle">
{{ t("photos", "Add people or groups who can edit your album") }}
</div>
<form class="manage-collaborators__form" @submit.prevent>
<NcPopover ref="popover"
:auto-size="true"
:distance="0">
<label slot="trigger" class="manage-collaborators__form__input">
<NcTextField :value.sync="searchText"
autocomplete="off"
type="search"
name="search"
:aria-label="t('photos', 'Search for collaborators')"
aria-autocomplete="list"
:aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`"
:placeholder="t('photos', 'Search people or groups')"
@input="searchCollaborators">
<Magnify :size="16" />
</NcTextField>
<NcLoadingIcon v-if="loadingCollaborators" />
</label>
<form class="manage-collaborators__form" @submit.prevent>
<NcPopover ref="popover" :auto-size="true" :distance="0">
<label slot="trigger" class="manage-collaborators__form__input">
<NcTextField
:value.sync="searchText"
autocomplete="off"
type="search"
name="search"
:aria-label="t('photos', 'Search for collaborators')"
aria-autocomplete="list"
:aria-controls="`manage-collaborators__form__selection-${randomId} manage-collaborators__form__list-${randomId}`"
:placeholder="t('photos', 'Search people or groups')"
@input="searchCollaborators"
>
<Magnify :size="16" />
</NcTextField>
<NcLoadingIcon v-if="loadingCollaborators" />
</label>
<ul v-if="searchResults.length !== 0" :id="`manage-collaborators__form__list-${randomId}`" class="manage-collaborators__form__list">
<li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
class="manage-collaborators__form__list__result"
:title="availableCollaborators[collaboratorKey].id"
:search="searchText"
:user="availableCollaborators[collaboratorKey].id"
:display-name="availableCollaborators[collaboratorKey].label"
:aria-label="t('photos', 'Add {collaboratorLabel} to the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})"
@click="selectEntity(collaboratorKey)" />
</li>
</ul>
<NcEmptyContent v-else
key="emptycontent"
class="manage-collaborators__form__list--empty"
:title="t('photos', 'No collaborators available')">
<AccountGroup slot="icon" />
</NcEmptyContent>
</NcPopover>
</form>
<ul
v-if="searchResults.length !== 0"
:id="`manage-collaborators__form__list-${randomId}`"
class="manage-collaborators__form__list"
>
<li v-for="collaboratorKey of searchResults" :key="collaboratorKey">
<NcListItemIcon
:id="availableCollaborators[collaboratorKey].id"
class="manage-collaborators__form__list__result"
:title="availableCollaborators[collaboratorKey].id"
:search="searchText"
:user="availableCollaborators[collaboratorKey].id"
:display-name="availableCollaborators[collaboratorKey].label"
:aria-label="
t(
'photos',
'Add {collaboratorLabel} to the collaborators list',
{
collaboratorLabel:
availableCollaborators[collaboratorKey].label,
}
)
"
@click="selectEntity(collaboratorKey)"
/>
</li>
</ul>
<NcEmptyContent
v-else
key="emptycontent"
class="manage-collaborators__form__list--empty"
:title="t('photos', 'No collaborators available')"
>
<AccountGroup slot="icon" />
</NcEmptyContent>
</NcPopover>
</form>
<ul class="manage-collaborators__selection">
<li v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
:key="collaboratorKey"
class="manage-collaborators__selection__item">
<NcListItemIcon :id="availableCollaborators[collaboratorKey].id"
:display-name="availableCollaborators[collaboratorKey].label"
:title="availableCollaborators[collaboratorKey].id"
:user="availableCollaborators[collaboratorKey].id">
<NcButton type="tertiary"
:aria-label="t('photos', 'Remove {collaboratorLabel} from the collaborators list', {collaboratorLabel: availableCollaborators[collaboratorKey].label})"
@click="unselectEntity(collaboratorKey)">
<Close slot="icon" :size="20" />
</NcButton>
</NcListItemIcon>
</li>
</ul>
<ul class="manage-collaborators__selection">
<li
v-for="collaboratorKey of listableSelectedCollaboratorsKeys"
:key="collaboratorKey"
class="manage-collaborators__selection__item"
>
<NcListItemIcon
:id="availableCollaborators[collaboratorKey].id"
:display-name="availableCollaborators[collaboratorKey].label"
:title="availableCollaborators[collaboratorKey].id"
:user="availableCollaborators[collaboratorKey].id"
>
<NcButton
type="tertiary"
:aria-label="
t(
'photos',
'Remove {collaboratorLabel} from the collaborators list',
{
collaboratorLabel:
availableCollaborators[collaboratorKey].label,
}
)
"
@click="unselectEntity(collaboratorKey)"
>
<Close slot="icon" :size="20" />
</NcButton>
</NcListItemIcon>
</li>
</ul>
<div class="actions">
<div v-if="allowPublicLink" class="actions__public-link">
<template v-if="isPublicLinkSelected">
<NcButton class="manage-collaborators__public-link-button"
:aria-label="t('photos', 'Copy the public link')"
:disabled="publicLink.id === ''"
@click="copyPublicLink">
<template v-if="publicLinkCopied">
{{ t('photos', 'Public link copied!') }}
</template>
<template v-else>
{{ t('photos', 'Copy public link') }}
</template>
<template #icon>
<Check v-if="publicLinkCopied" />
<ContentCopy v-else />
</template>
</NcButton>
<NcButton type="tertiary"
:aria-label="t('photos', 'Delete the public link')"
:disabled="publicLink.id === ''"
@click="deletePublicLink">
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
<Close v-else slot="icon" />
</NcButton>
</template>
<NcButton v-else
class="manage-collaborators__public-link-button"
@click="createPublicLinkForAlbum">
<Earth slot="icon" />
{{ t('photos', 'Share via public link') }}
</NcButton>
</div>
<div class="actions">
<div v-if="allowPublicLink" class="actions__public-link">
<template v-if="isPublicLinkSelected">
<NcButton
class="manage-collaborators__public-link-button"
:aria-label="t('photos', 'Copy the public link')"
:disabled="publicLink.id === ''"
@click="copyPublicLink"
>
<template v-if="publicLinkCopied">
{{ t("photos", "Public link copied!") }}
</template>
<template v-else>
{{ t("photos", "Copy public link") }}
</template>
<template #icon>
<Check v-if="publicLinkCopied" />
<ContentCopy v-else />
</template>
</NcButton>
<NcButton
type="tertiary"
:aria-label="t('photos', 'Delete the public link')"
:disabled="publicLink.id === ''"
@click="deletePublicLink"
>
<NcLoadingIcon v-if="publicLink.id === ''" slot="icon" />
<Close v-else slot="icon" />
</NcButton>
</template>
<NcButton
v-else
class="manage-collaborators__public-link-button"
@click="createPublicLinkForAlbum"
>
<Earth slot="icon" />
{{ t("photos", "Share via public link") }}
</NcButton>
</div>
<div class="actions__slot">
<slot :collaborators="selectedCollaborators" />
</div>
</div>
</div>
<div class="actions__slot">
<slot :collaborators="selectedCollaborators" />
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Mixins, Prop, Watch } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import Magnify from 'vue-material-design-icons/Magnify.vue'
import Close from 'vue-material-design-icons/Close.vue'
import Check from 'vue-material-design-icons/Check.vue'
import ContentCopy from 'vue-material-design-icons/ContentCopy.vue'
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import Earth from 'vue-material-design-icons/Earth.vue'
import Magnify from "vue-material-design-icons/Magnify.vue";
import Close from "vue-material-design-icons/Close.vue";
import Check from "vue-material-design-icons/Check.vue";
import ContentCopy from "vue-material-design-icons/ContentCopy.vue";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
import Earth from "vue-material-design-icons/Earth.vue";
import axios from '@nextcloud/axios'
import * as dav from '../../services/DavRequests';
import { showError } from '@nextcloud/dialogs'
import { getCurrentUser } from '@nextcloud/auth'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import { NcButton, NcListItemIcon, NcLoadingIcon, NcPopover, NcTextField, NcEmptyContent } from '@nextcloud/vue'
import axios from "@nextcloud/axios";
import * as dav from "../../services/DavRequests";
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import { generateOcsUrl, generateUrl } from "@nextcloud/router";
import {
NcButton,
NcListItemIcon,
NcLoadingIcon,
NcPopover,
NcTextField,
NcEmptyContent,
} from "@nextcloud/vue";
import { Type } from "@nextcloud/sharing";
type Collaborator = {
id: string,
label: string,
type: Type,
}
id: string;
label: string;
type: Type;
};
@Component({
components: {
Magnify,
Close,
AccountGroup,
ContentCopy,
Check,
Earth,
NcLoadingIcon,
NcButton,
NcListItemIcon,
NcTextField,
NcPopover,
NcEmptyContent,
}
components: {
Magnify,
Close,
AccountGroup,
ContentCopy,
Check,
Earth,
NcLoadingIcon,
NcButton,
NcListItemIcon,
NcTextField,
NcPopover,
NcEmptyContent,
},
})
export default class AddToAlbumModal extends Mixins(GlobalMixin) {
@Prop() private albumName: string;
@Prop() collaborators: Collaborator[];
@Prop() allowPublicLink: boolean;
@Prop() private albumName: string;
@Prop() collaborators: Collaborator[];
@Prop() allowPublicLink: boolean;
private searchText = "";
private availableCollaborators: { [key: string]: Collaborator } = {};
private selectedCollaboratorsKeys: string[] = [];
private currentSearchResults = [];
private loadingAlbum = false;
private errorFetchingAlbum = null;
private loadingCollaborators = false;
private errorFetchingCollaborators = null;
private randomId = Math.random().toString().substring(2, 10);
private publicLinkCopied = false;
private config = {
minSearchStringLength:
parseInt(window.OC.config["sharing.minSearchStringLength"], 10) || 0,
};
private searchText = '';
private availableCollaborators: { [key: string]: Collaborator } = {};
private selectedCollaboratorsKeys: string[] = [];
private currentSearchResults = [];
private loadingAlbum = false;
private errorFetchingAlbum = null;
private loadingCollaborators = false;
private errorFetchingCollaborators = null;
private randomId = Math.random().toString().substring(2, 10);
private publicLinkCopied = false;
private config = {
minSearchStringLength: parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0,
get searchResults(): string[] {
return this.currentSearchResults
.filter(({ id }) => id !== getCurrentUser().uid)
.map(({ type, id }) => `${type}:${id}`)
.filter(
(collaboratorKey) =>
!this.selectedCollaboratorsKeys.includes(collaboratorKey)
);
}
get listableSelectedCollaboratorsKeys(): string[] {
return this.selectedCollaboratorsKeys.filter(
(collaboratorKey) =>
this.availableCollaborators[collaboratorKey].type !==
Type.SHARE_TYPE_LINK
);
}
get selectedCollaborators(): Collaborator[] {
return this.selectedCollaboratorsKeys.map(
(collaboratorKey) => this.availableCollaborators[collaboratorKey]
);
}
get isPublicLinkSelected(): boolean {
return this.selectedCollaboratorsKeys.includes(`${Type.SHARE_TYPE_LINK}`);
}
get publicLink(): Collaborator {
return this.availableCollaborators[Type.SHARE_TYPE_LINK];
}
@Watch("collaborators")
collaboratorsChanged(collaborators) {
this.populateCollaborators(collaborators);
}
mounted() {
this.searchCollaborators();
this.populateCollaborators(this.collaborators);
}
/**
* Fetch possible collaborators.
*/
async searchCollaborators() {
if (this.searchText.length >= 1) {
(<any>this.$refs.popover).$refs.popover.show();
}
try {
if (this.searchText.length < this.config.minSearchStringLength) {
return;
}
this.loadingCollaborators = true;
const response = await axios.get(
generateOcsUrl("core/autocomplete/get"),
{
params: {
search: this.searchText,
itemType: "share-recipients",
shareTypes: [Type.SHARE_TYPE_USER, Type.SHARE_TYPE_GROUP],
},
}
);
this.currentSearchResults = response.data.ocs.data.map((collaborator) => {
switch (collaborator.source) {
case "users":
return {
id: collaborator.id,
label: collaborator.label,
type: Type.SHARE_TYPE_USER,
};
case "groups":
return {
id: collaborator.id,
label: collaborator.label,
type: Type.SHARE_TYPE_GROUP,
};
default:
throw new Error(
`Invalid collaborator source ${collaborator.source}`
);
}
});
this.availableCollaborators = {
...this.availableCollaborators,
...this.currentSearchResults.reduce(this.indexCollaborators, {}),
};
} catch (error) {
this.errorFetchingCollaborators = error;
showError(this.t("photos", "Failed to fetch collaborators list."));
} finally {
this.loadingCollaborators = false;
}
}
/**
* Populate selectedCollaboratorsKeys and availableCollaborators.
*/
populateCollaborators(collaborators: Collaborator[]) {
const initialCollaborators = collaborators.reduce(
this.indexCollaborators,
{}
);
this.selectedCollaboratorsKeys = Object.keys(initialCollaborators);
this.availableCollaborators = {
3: {
id: "",
label: this.t("photos", "Public link"),
type: Type.SHARE_TYPE_LINK,
},
...this.availableCollaborators,
...initialCollaborators,
};
}
get searchResults(): string[] {
return this.currentSearchResults
.filter(({ id }) => id !== getCurrentUser().uid)
.map(({ type, id }) => `${type}:${id}`)
.filter(collaboratorKey => !this.selectedCollaboratorsKeys.includes(collaboratorKey))
}
get listableSelectedCollaboratorsKeys(): string[] {
return this.selectedCollaboratorsKeys
.filter(collaboratorKey => this.availableCollaborators[collaboratorKey].type !== Type.SHARE_TYPE_LINK)
}
get selectedCollaborators(): Collaborator[] {
return this.selectedCollaboratorsKeys
.map((collaboratorKey) => this.availableCollaborators[collaboratorKey])
}
get isPublicLinkSelected(): boolean {
return this.selectedCollaboratorsKeys.includes(`${Type.SHARE_TYPE_LINK}`)
}
get publicLink(): Collaborator {
return this.availableCollaborators[Type.SHARE_TYPE_LINK]
}
@Watch('collaborators')
collaboratorsChanged(collaborators) {
this.populateCollaborators(collaborators)
/**
* @param {Object<string, Collaborator>} collaborators - Index of collaborators
* @param {Collaborator} collaborator - A collaborator
*/
indexCollaborators(
collaborators: { [s: string]: Collaborator },
collaborator: Collaborator
) {
return {
...collaborators,
[`${collaborator.type}${
collaborator.type === Type.SHARE_TYPE_LINK ? "" : ":"
}${collaborator.type === Type.SHARE_TYPE_LINK ? "" : collaborator.id}`]:
collaborator,
};
}
mounted() {
this.searchCollaborators()
this.populateCollaborators(this.collaborators)
}
async createPublicLinkForAlbum() {
this.selectEntity(`${Type.SHARE_TYPE_LINK}`);
await this.updateAlbumCollaborators();
try {
this.loadingAlbum = true;
this.errorFetchingAlbum = null;
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbum = 404;
} else {
this.errorFetchingAlbum = error;
}
/**
* Fetch possible collaborators.
*/
async searchCollaborators() {
if (this.searchText.length >= 1) {
(<any>this.$refs.popover).$refs.popover.show()
}
showError(this.t("photos", "Failed to fetch album."));
} finally {
this.loadingAlbum = false;
}
}
try {
if (this.searchText.length < this.config.minSearchStringLength) {
return
}
async deletePublicLink() {
this.unselectEntity(`${Type.SHARE_TYPE_LINK}`);
this.availableCollaborators[3] = {
id: "",
label: this.t("photos", "Public link"),
type: Type.SHARE_TYPE_LINK,
};
this.publicLinkCopied = false;
await this.updateAlbumCollaborators();
}
this.loadingCollaborators = true
const response = await axios.get(generateOcsUrl('core/autocomplete/get'), {
params: {
search: this.searchText,
itemType: 'share-recipients',
shareTypes: [
Type.SHARE_TYPE_USER,
Type.SHARE_TYPE_GROUP,
],
},
})
async updateAlbumCollaborators() {
try {
const album = await dav.getAlbum(
getCurrentUser()?.uid.toString(),
this.albumName
);
await dav.updateAlbum(album, {
albumName: this.albumName,
properties: {
collaborators: this.selectedCollaborators,
},
});
} catch (error) {
showError(this.t("photos", "Failed to update album."));
} finally {
this.loadingAlbum = false;
}
}
this.currentSearchResults = response.data.ocs.data
.map(collaborator => {
switch (collaborator.source) {
case 'users':
return { id: collaborator.id, label: collaborator.label, type: Type.SHARE_TYPE_USER }
case 'groups':
return { id: collaborator.id, label: collaborator.label, type: Type.SHARE_TYPE_GROUP }
default:
throw new Error(`Invalid collaborator source ${collaborator.source}`)
}
})
async copyPublicLink() {
await navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}${generateUrl(
`apps/photos/public/${this.publicLink.id}`
)}`
);
this.publicLinkCopied = true;
setTimeout(() => {
this.publicLinkCopied = false;
}, 10000);
}
this.availableCollaborators = {
...this.availableCollaborators,
...this.currentSearchResults.reduce(this.indexCollaborators, {}),
}
} catch (error) {
this.errorFetchingCollaborators = error
showError(this.t('photos', 'Failed to fetch collaborators list.'))
} finally {
this.loadingCollaborators = false
}
selectEntity(collaboratorKey) {
if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) {
return;
}
/**
* Populate selectedCollaboratorsKeys and availableCollaborators.
*/
populateCollaborators(collaborators: Collaborator[]) {
const initialCollaborators = collaborators.reduce(this.indexCollaborators, {})
this.selectedCollaboratorsKeys = Object.keys(initialCollaborators)
this.availableCollaborators = {
3: {
id: '',
label: this.t('photos', 'Public link'),
type: Type.SHARE_TYPE_LINK,
},
...this.availableCollaborators,
...initialCollaborators,
}
(<any>this.$refs.popover).$refs.popover.hide();
this.selectedCollaboratorsKeys.push(collaboratorKey);
}
unselectEntity(collaboratorKey) {
const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey);
if (index === -1) {
return;
}
/**
* @param {Object<string, Collaborator>} collaborators - Index of collaborators
* @param {Collaborator} collaborator - A collaborator
*/
indexCollaborators(collaborators: { [s: string]: Collaborator; }, collaborator: Collaborator) {
return { ...collaborators, [`${collaborator.type}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : ':'}${collaborator.type === Type.SHARE_TYPE_LINK ? '' : collaborator.id}`]: collaborator }
}
async createPublicLinkForAlbum() {
this.selectEntity(`${Type.SHARE_TYPE_LINK}`)
await this.updateAlbumCollaborators()
try {
this.loadingAlbum = true
this.errorFetchingAlbum = null
} catch (error) {
if (error.response?.status === 404) {
this.errorFetchingAlbum = 404
} else {
this.errorFetchingAlbum = error
}
showError(this.t('photos', 'Failed to fetch album.'))
} finally {
this.loadingAlbum = false
}
}
async deletePublicLink() {
this.unselectEntity(`${Type.SHARE_TYPE_LINK}`)
this.availableCollaborators[3] = {
id: '',
label: this.t('photos', 'Public link'),
type: Type.SHARE_TYPE_LINK,
}
this.publicLinkCopied = false
await this.updateAlbumCollaborators()
}
async updateAlbumCollaborators() {
try {
const album = await dav.getAlbum(getCurrentUser()?.uid.toString(), this.albumName);
await dav.updateAlbum(album, {
albumName: this.albumName,
properties: {
collaborators: this.selectedCollaborators,
},
})
} catch (error) {
showError(this.t('photos', 'Failed to update album.'))
} finally {
this.loadingAlbum = false
}
}
async copyPublicLink() {
await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}${generateUrl(`apps/photos/public/${this.publicLink.id}`)}`)
this.publicLinkCopied = true
setTimeout(() => {
this.publicLinkCopied = false
}, 10000)
}
selectEntity(collaboratorKey) {
if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) {
return
}
(<any>this.$refs.popover).$refs.popover.hide()
this.selectedCollaboratorsKeys.push(collaboratorKey)
}
unselectEntity(collaboratorKey) {
const index = this.selectedCollaboratorsKeys.indexOf(collaboratorKey)
if (index === -1) {
return
}
this.selectedCollaboratorsKeys.splice(index, 1)
}
this.selectedCollaboratorsKeys.splice(index, 1);
}
}
</script>
<style lang="scss" scoped>
.manage-collaborators {
display: flex;
flex-direction: column;
height: 500px;
display: flex;
flex-direction: column;
height: 500px;
&__title {
font-weight: bold;
}
&__title {
font-weight: bold;
}
&__subtitle {
color: var(--color-text-lighter);
}
&__subtitle {
color: var(--color-text-lighter);
}
&__public-link-button {
margin: 4px 0;
}
&__public-link-button {
margin: 4px 0;
}
&__form {
margin-top: 4px 0;
display: flex;
flex-direction: column;
&__form {
margin-top: 4px 0;
display: flex;
flex-direction: column;
&__input {
position: relative;
display: block;
&__input {
position: relative;
display: block;
input {
width: 100%;
padding-left: 34px;
}
input {
width: 100%;
padding-left: 34px;
}
.loading-icon {
position: absolute;
top: calc(36px / 2 - 20px / 2);
right: 8px;
}
}
.loading-icon {
position: absolute;
top: calc(36px / 2 - 20px / 2);
right: 8px;
}
}
&__list {
padding: 8px;
height: 350px;
overflow: scroll;
&__list {
padding: 8px;
height: 350px;
overflow: scroll;
&__result {
padding: 8px;
border-radius: 100px;
box-sizing: border-box;
&__result {
padding: 8px;
border-radius: 100px;
box-sizing: border-box;
&, & * {
cursor: pointer !important;
}
&,
& * {
cursor: pointer !important;
}
&:hover {
background: var(--color-background-dark);
}
}
&:hover {
background: var(--color-background-dark);
}
}
&--empty {
margin: 100px 0;
}
}
}
&--empty {
margin: 100px 0;
}
}
}
&__selection {
display: flex;
flex-direction: column;
margin-top: 8px;
flex-grow: 1;
&__selection {
display: flex;
flex-direction: column;
margin-top: 8px;
flex-grow: 1;
&__item {
border-radius: var(--border-radius-pill);
padding: 0 8px;
&__item {
border-radius: var(--border-radius-pill);
padding: 0 8px;
&:hover {
background: var(--color-background-dark);
}
}
}
&:hover {
background: var(--color-background-dark);
}
}
}
.actions {
display: flex;
margin-top: 8px;
.actions {
display: flex;
margin-top: 8px;
&__public-link {
display: flex;
align-items: center;
&__public-link {
display: flex;
align-items: center;
button {
margin-left: 8px;
}
}
button {
margin-left: 8px;
}
}
&__slot {
flex-grow: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
&__slot {
flex-grow: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
}
</style>

View File

@ -1,87 +1,90 @@
<template>
<Modal @close="close" size="normal" v-if="show">
<template #title>
<template v-if="!album">
{{ t('memories', 'Create new album') }}
</template>
<template v-else>
{{ t('memories', 'Edit album details') }}
</template>
</template>
<Modal @close="close" size="normal" v-if="show">
<template #title>
<template v-if="!album">
{{ t("memories", "Create new album") }}
</template>
<template v-else>
{{ t("memories", "Edit album details") }}
</template>
</template>
<div class="outer">
<AlbumForm
:album="album"
:display-back-button="false"
:title="t('photos', 'New album')"
@done="done" />
</div>
</Modal>
<div class="outer">
<AlbumForm
:album="album"
:display-back-button="false"
:title="t('photos', 'New album')"
@done="done"
/>
</div>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { showError } from '@nextcloud/dialogs'
import * as dav from '../../services/DavRequests';
import { showError } from "@nextcloud/dialogs";
import * as dav from "../../services/DavRequests";
import Modal from './Modal.vue';
import AlbumForm from './AlbumForm.vue';
import Modal from "./Modal.vue";
import AlbumForm from "./AlbumForm.vue";
@Component({
components: {
Modal,
AlbumForm,
}
components: {
Modal,
AlbumForm,
},
})
export default class AlbumCreateModal extends Mixins(GlobalMixin) {
private show = false;
private album: any = null;
/**
* Open the modal
* @param edit If true, the modal will be opened in edit mode
*/
public async open(edit: boolean) {
if (edit) {
try {
this.album = await dav.getAlbum(this.$route.params.user, this.$route.params.name);
} catch (e) {
console.error(e);
showError(this.t('photos', 'Could not load the selected album'));
return;
}
} else {
this.album = null;
}
this.show = true;
private show = false;
private album: any = null;
/**
* Open the modal
* @param edit If true, the modal will be opened in edit mode
*/
public async open(edit: boolean) {
if (edit) {
try {
this.album = await dav.getAlbum(
this.$route.params.user,
this.$route.params.name
);
} catch (e) {
console.error(e);
showError(this.t("photos", "Could not load the selected album"));
return;
}
} else {
this.album = null;
}
@Emit('close')
public close() {
this.show = false;
}
this.show = true;
}
public done({ album }: any) {
if (!this.album || album.basename !== this.album.basename) {
const user = album.filename.split('/')[2];
const name = album.basename;
this.$router.push({ name: 'albums', params: { user, name } });
}
this.close();
@Emit("close")
public close() {
this.show = false;
}
public done({ album }: any) {
if (!this.album || album.basename !== this.album.basename) {
const user = album.filename.split("/")[2];
const name = album.basename;
this.$router.push({ name: "albums", params: { user, name } });
}
this.close();
}
}
</script>
<style lang="scss" scoped>
.outer {
margin-top: 15px;
margin-top: 15px;
}
.info-pad {
margin-top: 6px;
margin-top: 6px;
}
</style>

View File

@ -1,81 +1,91 @@
<template>
<Modal @close="close" v-if="show">
<template #title>
{{ t('memories', 'Remove Album') }}
</template>
<Modal @close="close" v-if="show">
<template #title>
{{ t("memories", "Remove Album") }}
</template>
<span>
{{ t('memories', 'Are you sure you want to permanently remove album "{name}"?', { name }) }}
</span>
<span>
{{
t(
"memories",
'Are you sure you want to permanently remove album "{name}"?',
{ name }
)
}}
</span>
<template #buttons>
<NcButton @click="save" class="button" type="error">
{{ t('memories', 'Delete') }}
</NcButton>
</template>
</Modal>
<template #buttons>
<NcButton @click="save" class="button" type="error">
{{ t("memories", "Delete") }}
</NcButton>
</template>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
import { NcButton, NcTextField } from '@nextcloud/vue';
import { showError } from '@nextcloud/dialogs';
import { getCurrentUser } from '@nextcloud/auth';
import Modal from './Modal.vue';
import GlobalMixin from '../../mixins/GlobalMixin';
import client from '../../services/DavClient';
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
@Component({
components: {
NcButton,
NcTextField,
Modal,
}
components: {
NcButton,
NcTextField,
Modal,
},
})
export default class AlbumDeleteModal extends Mixins(GlobalMixin) {
private user: string = "";
private name: string = "";
private show = false;
private user: string = "";
private name: string = "";
private show = false;
@Emit('close')
public close() {
this.show = false;
}
@Emit("close")
public close() {
this.show = false;
}
public open() {
const user = this.$route.params.user || '';
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can delete this album', { user }));
return;
}
this.show = true;
public open() {
const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) {
showError(
this.t("memories", 'Only user "{user}" can delete this album', { user })
);
return;
}
this.show = true;
}
@Watch('$route')
async routeChange(from: any, to: any) {
this.refreshParams();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
public refreshParams() {
this.user = this.$route.params.user || '';
this.name = this.$route.params.name || '';
}
public refreshParams() {
this.user = this.$route.params.user || "";
this.name = this.$route.params.name || "";
}
public async save() {
try {
await client.deleteFile(`/photos/${this.user}/albums/${this.name}`)
this.$router.push({ name: 'albums' });
this.close();
} catch (error) {
console.log(error);
showError(this.t('photos', 'Failed to delete {name}.', {
name: this.name,
}));
}
public async save() {
try {
await client.deleteFile(`/photos/${this.user}/albums/${this.name}`);
this.$router.push({ name: "albums" });
this.close();
} catch (error) {
console.log(error);
showError(
this.t("photos", "Failed to delete {name}.", {
name: this.name,
})
);
}
}
}
</script>

View File

@ -20,244 +20,274 @@
-
-->
<template>
<form v-if="!showCollaboratorView" class="album-form" @submit.prevent="submit">
<div class="form-inputs">
<NcTextField ref="nameInput"
:value.sync="albumName"
type="text"
name="name"
:required="true"
autofocus="true"
:placeholder="t('photos', 'Name of the album')" />
<label>
<NcTextField :value.sync="albumLocation"
name="location"
type="text"
:placeholder="t('photos', 'Location of the album')" />
</label>
</div>
<div class="form-buttons">
<span class="left-buttons">
<NcButton v-if="displayBackButton"
:aria-label="t('photos', 'Go back to the previous view.')"
type="tertiary"
@click="back">
{{ t('photos', 'Back') }}
</NcButton>
</span>
<span class="right-buttons">
<NcButton v-if="sharingEnabled && !editMode"
:aria-label="t('photos', 'Go to the add collaborators view.')"
type="secondary"
:disabled="albumName.trim() === '' || loading"
@click="showCollaboratorView = true">
<template #icon>
<AccountMultiplePlus />
</template>
{{ t('photos', 'Add collaborators') }}
</NcButton>
<NcButton :aria-label="editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')"
type="primary"
:disabled="albumName === '' || loading"
@click="submit()">
<template #icon>
<NcLoadingIcon v-if="loading" />
<Send v-else />
</template>
{{ editMode ? t('photos', 'Save') : t('photos', 'Create album') }}
</NcButton>
</span>
</div>
</form>
<AlbumCollaborators v-else
:album-name="albumName"
:allow-public-link="false"
:collaborators="[]">
<template slot-scope="{collaborators}">
<span class="left-buttons">
<NcButton :aria-label="t('photos', 'Back to the new album form.')"
type="tertiary"
@click="showCollaboratorView = false">
{{ t('photos', 'Back') }}
</NcButton>
</span>
<span class="right-buttons">
<NcButton :aria-label="editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')"
type="primary"
:disabled="albumName.trim() === '' || loading"
@click="submit(collaborators)">
<template #icon>
<NcLoadingIcon v-if="loading" />
<Send v-else />
</template>
{{ editMode ? t('photos', 'Save') : t('photos', 'Create album') }}
</NcButton>
</span>
</template>
</AlbumCollaborators>
<form
v-if="!showCollaboratorView"
class="album-form"
@submit.prevent="submit"
>
<div class="form-inputs">
<NcTextField
ref="nameInput"
:value.sync="albumName"
type="text"
name="name"
:required="true"
autofocus="true"
:placeholder="t('photos', 'Name of the album')"
/>
<label>
<NcTextField
:value.sync="albumLocation"
name="location"
type="text"
:placeholder="t('photos', 'Location of the album')"
/>
</label>
</div>
<div class="form-buttons">
<span class="left-buttons">
<NcButton
v-if="displayBackButton"
:aria-label="t('photos', 'Go back to the previous view.')"
type="tertiary"
@click="back"
>
{{ t("photos", "Back") }}
</NcButton>
</span>
<span class="right-buttons">
<NcButton
v-if="sharingEnabled && !editMode"
:aria-label="t('photos', 'Go to the add collaborators view.')"
type="secondary"
:disabled="albumName.trim() === '' || loading"
@click="showCollaboratorView = true"
>
<template #icon>
<AccountMultiplePlus />
</template>
{{ t("photos", "Add collaborators") }}
</NcButton>
<NcButton
:aria-label="
editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')
"
type="primary"
:disabled="albumName === '' || loading"
@click="submit()"
>
<template #icon>
<NcLoadingIcon v-if="loading" />
<Send v-else />
</template>
{{ editMode ? t("photos", "Save") : t("photos", "Create album") }}
</NcButton>
</span>
</div>
</form>
<AlbumCollaborators
v-else
:album-name="albumName"
:allow-public-link="false"
:collaborators="[]"
>
<template slot-scope="{ collaborators }">
<span class="left-buttons">
<NcButton
:aria-label="t('photos', 'Back to the new album form.')"
type="tertiary"
@click="showCollaboratorView = false"
>
{{ t("photos", "Back") }}
</NcButton>
</span>
<span class="right-buttons">
<NcButton
:aria-label="
editMode ? t('photos', 'Save.') : t('photos', 'Create the album.')
"
type="primary"
:disabled="albumName.trim() === '' || loading"
@click="submit(collaborators)"
>
<template #icon>
<NcLoadingIcon v-if="loading" />
<Send v-else />
</template>
{{ editMode ? t("photos", "Save") : t("photos", "Create album") }}
</NcButton>
</span>
</template>
</AlbumCollaborators>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { getCurrentUser } from '@nextcloud/auth'
import { NcButton, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
import moment from 'moment';
import * as dav from '../../services/DavRequests';
import { getCurrentUser } from "@nextcloud/auth";
import { NcButton, NcLoadingIcon, NcTextField } from "@nextcloud/vue";
import moment from "moment";
import * as dav from "../../services/DavRequests";
import AlbumCollaborators from './AlbumCollaborators.vue'
import Send from 'vue-material-design-icons/Send.vue'
import AccountMultiplePlus from 'vue-material-design-icons/AccountMultiplePlus.vue'
import AlbumCollaborators from "./AlbumCollaborators.vue";
import Send from "vue-material-design-icons/Send.vue";
import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.vue";
@Component({
components: {
NcButton,
NcLoadingIcon,
NcTextField,
AlbumCollaborators,
components: {
NcButton,
NcLoadingIcon,
NcTextField,
AlbumCollaborators,
Send,
AccountMultiplePlus,
},
Send,
AccountMultiplePlus,
},
})
export default class AlbumForm extends Mixins(GlobalMixin) {
@Prop() private album: any;
@Prop() private displayBackButton: boolean;
@Prop() private album: any;
@Prop() private displayBackButton: boolean;
private showCollaboratorView = false;
private albumName = '';
private albumLocation = '';
private loading = false;
private showCollaboratorView = false;
private albumName = "";
private albumLocation = "";
private loading = false;
/**
* @return Whether sharing is enabled.
*/
get editMode(): boolean {
return Boolean(this.album);
/**
* @return Whether sharing is enabled.
*/
get editMode(): boolean {
return Boolean(this.album);
}
/**
* @return Whether sharing is enabled.
*/
get sharingEnabled(): boolean {
return window.OC.Share !== undefined;
}
mounted() {
if (this.editMode) {
this.albumName = this.album.basename;
this.albumLocation = this.album.location;
}
this.$nextTick(() => {
(<any>this.$refs.nameInput).$el.getElementsByTagName("input")[0].focus();
});
}
/**
* @return Whether sharing is enabled.
*/
get sharingEnabled(): boolean {
return window.OC.Share !== undefined
submit(collaborators = []) {
if (this.albumName === "" || this.loading) {
return;
}
mounted() {
if (this.editMode) {
this.albumName = this.album.basename
this.albumLocation = this.album.location
}
this.$nextTick(() => {
(<any>this.$refs.nameInput).$el.getElementsByTagName('input')[0].focus()
})
}
submit(collaborators = []) {
if (this.albumName === '' || this.loading) {
return
}
if (this.editMode) {
this.handleUpdateAlbum()
} else {
this.handleCreateAlbum(collaborators)
}
if (this.editMode) {
this.handleUpdateAlbum();
} else {
this.handleCreateAlbum(collaborators);
}
}
async handleCreateAlbum(collaborators = []) {
try {
this.loading = true
let album = {
basename: this.albumName,
filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
nbItems: 0,
location: this.albumLocation,
lastPhoto: -1,
date: moment().format('MMMM YYYY'),
collaborators,
}
await dav.createAlbum(album.basename);
async handleCreateAlbum(collaborators = []) {
try {
this.loading = true;
let album = {
basename: this.albumName,
filename: `/photos/${getCurrentUser().uid}/albums/${this.albumName}`,
nbItems: 0,
location: this.albumLocation,
lastPhoto: -1,
date: moment().format("MMMM YYYY"),
collaborators,
};
await dav.createAlbum(album.basename);
if (this.albumLocation !== '' || collaborators.length !== 0) {
album = await dav.updateAlbum(album, {
albumName: this.albumName,
properties: {
location: this.albumLocation,
collaborators,
},
});
}
if (this.albumLocation !== "" || collaborators.length !== 0) {
album = await dav.updateAlbum(album, {
albumName: this.albumName,
properties: {
location: this.albumLocation,
collaborators,
},
});
}
this.$emit('done', { album })
} finally {
this.loading = false
}
this.$emit("done", { album });
} finally {
this.loading = false;
}
}
async handleUpdateAlbum() {
try {
this.loading = true
let album = { ...this.album }
if (this.album.basename !== this.albumName) {
album = await dav.renameAlbum(this.album, { currentAlbumName: this.album.basename, newAlbumName: this.albumName })
}
if (this.album.location !== this.albumLocation) {
album.location = await dav.updateAlbum(this.album, { albumName: this.albumName, properties: { location: this.albumLocation } })
}
this.$emit('done', { album })
} finally {
this.loading = false
}
async handleUpdateAlbum() {
try {
this.loading = true;
let album = { ...this.album };
if (this.album.basename !== this.albumName) {
album = await dav.renameAlbum(this.album, {
currentAlbumName: this.album.basename,
newAlbumName: this.albumName,
});
}
if (this.album.location !== this.albumLocation) {
album.location = await dav.updateAlbum(this.album, {
albumName: this.albumName,
properties: { location: this.albumLocation },
});
}
this.$emit("done", { album });
} finally {
this.loading = false;
}
}
@Emit('back')
back() {}
@Emit("back")
back() {}
}
</script>
<style lang="scss" scoped>
.album-form {
display: flex;
flex-direction: column;
height: 350px;
padding: 16px;
.form-title {
font-weight: bold;
}
.form-subtitle {
color: var(--color-text-lighter);
}
.form-inputs {
flex-grow: 1;
justify-items: flex-end;
input {
width: 100%;
}
label {
display: flex;
margin-top: 16px;
:deep svg {
margin-right: 12px;
}
}
}
.form-buttons {
display: flex;
justify-content: space-between;
.left-buttons, .right-buttons {
display: flex;
}
.right-buttons {
justify-content: flex-end;
}
button {
margin-right: 16px;
}
}
display: flex;
flex-direction: column;
height: 350px;
padding: 16px;
.form-title {
font-weight: bold;
}
.form-subtitle {
color: var(--color-text-lighter);
}
.form-inputs {
flex-grow: 1;
justify-items: flex-end;
input {
width: 100%;
}
label {
display: flex;
margin-top: 16px;
:deep svg {
margin-right: 12px;
}
}
}
.form-buttons {
display: flex;
justify-content: space-between;
.left-buttons,
.right-buttons {
display: flex;
}
.right-buttons {
justify-content: flex-end;
}
button {
margin-right: 16px;
}
}
}
.left-buttons {
flex-grow: 1;
flex-grow: 1;
}
</style>

View File

@ -20,168 +20,185 @@
-
-->
<template>
<div v-if="!showAlbumCreationForm" class="album-picker">
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
<div v-if="!showAlbumCreationForm" class="album-picker">
<NcLoadingIcon v-if="loadingAlbums" class="loading-icon" />
<ul class="albums-container">
<NcListItem v-for="album in albums"
:key="album.album_id"
class="album"
:title="getAlbumName(album)"
:aria-label="t('photos', 'Add selection to album {albumName}', {albumName: getAlbumName(album)})"
@click="pickAlbum(album)">
<template slot="icon">
<img v-if="album.last_added_photo !== -1" class="album__image" :src="album.last_added_photo | toCoverUrl">
<div v-else class="album__image album__image--placeholder">
<ImageMultiple :size="32" />
</div>
</template>
<ul class="albums-container">
<NcListItem
v-for="album in albums"
:key="album.album_id"
class="album"
:title="getAlbumName(album)"
:aria-label="
t('photos', 'Add selection to album {albumName}', {
albumName: getAlbumName(album),
})
"
@click="pickAlbum(album)"
>
<template slot="icon">
<img
v-if="album.last_added_photo !== -1"
class="album__image"
:src="album.last_added_photo | toCoverUrl"
/>
<div v-else class="album__image album__image--placeholder">
<ImageMultiple :size="32" />
</div>
</template>
<template slot="subtitle">
{{ n('photos', '%n item', '%n items', album.count) }}
<!-- TODO: finish collaboration -->
<!-- {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}-->
</template>
</NcListItem>
</ul>
<template slot="subtitle">
{{ n("photos", "%n item", "%n items", album.count) }}
<!-- TODO: finish collaboration -->
<!-- {{ n('photos', 'Share with %n user', 'Share with %n users', album.isShared) }}-->
</template>
</NcListItem>
</ul>
<NcButton :aria-label="t('photos', 'Create a new album.')"
class="new-album-button"
type="tertiary"
@click="showAlbumCreationForm = true">
<template #icon>
<Plus />
</template>
{{ t('photos', 'Create new album') }}
</NcButton>
</div>
<NcButton
:aria-label="t('photos', 'Create a new album.')"
class="new-album-button"
type="tertiary"
@click="showAlbumCreationForm = true"
>
<template #icon>
<Plus />
</template>
{{ t("photos", "Create new album") }}
</NcButton>
</div>
<AlbumForm v-else
:display-back-button="true"
:title="t('photos', 'New album')"
@back="showAlbumCreationForm = false"
@done="albumCreatedHandler" />
<AlbumForm
v-else
:display-back-button="true"
:title="t('photos', 'New album')"
@back="showAlbumCreationForm = false"
@done="albumCreatedHandler"
/>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { getCurrentUser } from '@nextcloud/auth';
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { getCurrentUser } from "@nextcloud/auth";
import AlbumForm from './AlbumForm.vue'
import Plus from 'vue-material-design-icons/Plus.vue'
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
import AlbumForm from "./AlbumForm.vue";
import Plus from "vue-material-design-icons/Plus.vue";
import ImageMultiple from "vue-material-design-icons/ImageMultiple.vue";
import { NcButton, NcListItem, NcLoadingIcon } from '@nextcloud/vue'
import { generateUrl } from '@nextcloud/router'
import { IAlbum } from '../../types';
import axios from '@nextcloud/axios'
import { NcButton, NcListItem, NcLoadingIcon } from "@nextcloud/vue";
import { generateUrl } from "@nextcloud/router";
import { IAlbum } from "../../types";
import axios from "@nextcloud/axios";
@Component({
components: {
AlbumForm,
Plus,
ImageMultiple,
NcButton,
NcListItem,
NcLoadingIcon,
components: {
AlbumForm,
Plus,
ImageMultiple,
NcButton,
NcListItem,
NcLoadingIcon,
},
filters: {
toCoverUrl(fileId: string) {
return generateUrl(
`/apps/photos/api/v1/preview/${fileId}?x=${256}&y=${256}`
);
},
filters: {
toCoverUrl(fileId: string) {
return generateUrl(`/apps/photos/api/v1/preview/${fileId}?x=${256}&y=${256}`)
}
}
},
})
export default class AlbumPicker extends Mixins(GlobalMixin) {
private showAlbumCreationForm = false;
private albums: IAlbum[] = [];
private loadingAlbums = true;
private showAlbumCreationForm = false;
private albums: IAlbum[] = [];
private loadingAlbums = true;
mounted() {
this.loadAlbums();
mounted() {
this.loadAlbums();
}
albumCreatedHandler() {
this.showAlbumCreationForm = false;
this.loadAlbums();
}
getAlbumName(album: IAlbum) {
if (album.user === getCurrentUser()?.uid) {
return album.name;
}
return `${album.name} (${album.user})`;
}
albumCreatedHandler() {
this.showAlbumCreationForm = false
this.loadAlbums();
async loadAlbums() {
try {
const res = await axios.get<IAlbum[]>(
generateUrl("/apps/memories/api/albums?t=3")
);
this.albums = res.data;
} catch (e) {
console.error(e);
} finally {
this.loadingAlbums = false;
}
}
getAlbumName(album: IAlbum) {
if (album.user === getCurrentUser()?.uid) {
return album.name
}
return `${album.name} (${album.user})`
}
async loadAlbums() {
try {
const res = await axios.get<IAlbum[]>(generateUrl('/apps/memories/api/albums?t=3'));
this.albums = res.data;
} catch (e) {
console.error(e);
} finally {
this.loadingAlbums = false;
}
}
@Emit('select')
pickAlbum(album: IAlbum) {}
@Emit("select")
pickAlbum(album: IAlbum) {}
}
</script>
<style lang="scss" scoped>
.album-picker {
h2 {
display: flex;
align-items: center;
height: 60px;
h2 {
display: flex;
align-items: center;
height: 60px;
.loading-icon {
margin-left: 32px;
}
}
.loading-icon {
margin-left: 32px;
}
}
.albums-container {
min-height: 150px;
max-height: 350px;
overflow-x: scroll;
padding: 2px;
.albums-container {
min-height: 150px;
max-height: 350px;
overflow-x: scroll;
padding: 2px;
.album {
.album {
:deep .list-item {
padding: 8px 16px;
box-sizing: border-box;
}
:deep .list-item {
padding: 8px 16px;
box-sizing: border-box;
}
&:not(:last-child) {
margin-bottom: 16px;
}
&:not(:last-child) {
margin-bottom: 16px;
}
&__image {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: var(--border-radius);
&__image {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: var(--border-radius);
&--placeholder {
background: var(--color-primary-light);
&--placeholder {
background: var(--color-primary-light);
:deep .material-design-icon {
width: 100%;
height: 100%;
:deep .material-design-icon {
width: 100%;
height: 100%;
.material-design-icon__svg {
fill: var(--color-primary);
}
}
}
}
}
}
.material-design-icon__svg {
fill: var(--color-primary);
}
}
}
}
}
}
.new-album-button {
margin-top: 32px;
}
.new-album-button {
margin-top: 32px;
}
}
</style>

View File

@ -1,76 +1,83 @@
<template>
<Modal @close="close" v-if="show">
<template #title>
{{ t('memories', 'Share Album') }}
</template>
<Modal @close="close" v-if="show">
<template #title>
{{ t("memories", "Share Album") }}
</template>
<AlbumCollaborators v-if="album"
:album-name="album.basename"
:collaborators="album.collaborators"
:public-link="album.publicLink">
<template slot-scope="{collaborators}">
<NcButton :aria-label="t('photos', 'Save collaborators for this album.')"
type="primary"
:disabled="loadingAddCollaborators"
@click="handleSetCollaborators(collaborators)">
<template #icon>
<NcLoadingIcon v-if="loadingAddCollaborators" />
</template>
{{ t('photos', 'Save') }}
</NcButton>
</template>
</AlbumCollaborators>
</Modal>
<AlbumCollaborators
v-if="album"
:album-name="album.basename"
:collaborators="album.collaborators"
:public-link="album.publicLink"
>
<template slot-scope="{ collaborators }">
<NcButton
:aria-label="t('photos', 'Save collaborators for this album.')"
type="primary"
:disabled="loadingAddCollaborators"
@click="handleSetCollaborators(collaborators)"
>
<template #icon>
<NcLoadingIcon v-if="loadingAddCollaborators" />
</template>
{{ t("photos", "Save") }}
</NcButton>
</template>
</AlbumCollaborators>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcButton, NcLoadingIcon } from '@nextcloud/vue';
import * as dav from '../../services/DavRequests';
import { NcButton, NcLoadingIcon } from "@nextcloud/vue";
import * as dav from "../../services/DavRequests";
import Modal from './Modal.vue';
import AlbumCollaborators from './AlbumCollaborators.vue';
import Modal from "./Modal.vue";
import AlbumCollaborators from "./AlbumCollaborators.vue";
@Component({
components: {
NcButton,
NcLoadingIcon,
Modal,
AlbumCollaborators,
}
components: {
NcButton,
NcLoadingIcon,
Modal,
AlbumCollaborators,
},
})
export default class AlbumShareModal extends Mixins(GlobalMixin) {
private album: any = null;
private show = false;
private loadingAddCollaborators = false;
private album: any = null;
private show = false;
private loadingAddCollaborators = false;
@Emit('close')
public close() {
this.show = false;
this.album = null;
}
@Emit("close")
public close() {
this.show = false;
this.album = null;
}
public async open() {
this.show = true;
this.loadingAddCollaborators = true;
const user = this.$route.params.user || '';
const name = this.$route.params.name || '';
this.album = await dav.getAlbum(user, name);
this.loadingAddCollaborators = false;
}
public async open() {
this.show = true;
this.loadingAddCollaborators = true;
const user = this.$route.params.user || "";
const name = this.$route.params.name || "";
this.album = await dav.getAlbum(user, name);
this.loadingAddCollaborators = false;
}
async handleSetCollaborators(collaborators: any[]) {
try {
this.loadingAddCollaborators = true
await dav.updateAlbum(this.album, { albumName: this.album.basename, properties: { collaborators } })
this.close();
} catch (error) {
console.error(error);
} finally {
this.loadingAddCollaborators = false
}
async handleSetCollaborators(collaborators: any[]) {
try {
this.loadingAddCollaborators = true;
await dav.updateAlbum(this.album, {
albumName: this.album.basename,
properties: { collaborators },
});
this.close();
} catch (error) {
console.error(error);
} finally {
this.loadingAddCollaborators = false;
}
}
}
</script>

View File

@ -1,425 +1,463 @@
<template>
<Modal
v-if="photos.length > 0"
@close="close">
<Modal v-if="photos.length > 0" @close="close">
<template #title>
{{ t("memories", "Edit Date/Time") }}
</template>
<template #title>
{{ t('memories', 'Edit Date/Time') }}
</template>
<template #buttons>
<NcButton @click="save" class="button" type="error" v-if="longDateStr">
{{ t("memories", "Update Exif") }}
</NcButton>
</template>
<template #buttons>
<NcButton @click="save" class="button" type="error" v-if="longDateStr">
{{ t('memories', 'Update Exif') }}
</NcButton>
</template>
<div v-if="longDateStr">
<span v-if="photos.length > 1"> [{{ t("memories", "Newest") }}] </span>
{{ longDateStr }}
<div v-if="longDateStr">
<span v-if="photos.length > 1">
[{{ t('memories', 'Newest') }}]
</span>
{{ longDateStr }}
<div class="fields">
<NcTextField
:value.sync="year"
class="field"
@input="newestChange()"
:label="t('memories', 'Year')"
:label-visible="true"
:placeholder="t('memories', 'Year')"
/>
<NcTextField
:value.sync="month"
class="field"
@input="newestChange()"
:label="t('memories', 'Month')"
:label-visible="true"
:placeholder="t('memories', 'Month')"
/>
<NcTextField
:value.sync="day"
class="field"
@input="newestChange()"
:label="t('memories', 'Day')"
:label-visible="true"
:placeholder="t('memories', 'Day')"
/>
<NcTextField
:value.sync="hour"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Time')"
:label-visible="true"
:placeholder="t('memories', 'Hour')"
/>
<NcTextField
:value.sync="minute"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Minute')"
:placeholder="t('memories', 'Minute')"
/>
</div>
<div class="fields">
<NcTextField :value.sync="year"
class="field"
@input="newestChange()"
:label="t('memories', 'Year')" :label-visible="true"
:placeholder="t('memories', 'Year')" />
<NcTextField :value.sync="month"
class="field"
@input="newestChange()"
:label="t('memories', 'Month')" :label-visible="true"
:placeholder="t('memories', 'Month')" />
<NcTextField :value.sync="day"
class="field"
@input="newestChange()"
:label="t('memories', 'Day')" :label-visible="true"
:placeholder="t('memories', 'Day')" />
<NcTextField :value.sync="hour"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Time')" :label-visible="true"
:placeholder="t('memories', 'Hour')" />
<NcTextField :value.sync="minute"
class="field"
@input="newestChange(true)"
:label="t('memories', 'Minute')"
:placeholder="t('memories', 'Minute')" />
</div>
<div v-if="photos.length > 1" class="oldest">
<span> [{{ t("memories", "Oldest") }}] </span>
{{ longDateStrLast }}
<div v-if="photos.length > 1" class="oldest">
<span>
[{{ t('memories', 'Oldest') }}]
</span>
{{ longDateStrLast }}
<div class="fields">
<NcTextField :value.sync="yearLast"
class="field"
:label="t('memories', 'Year')" :label-visible="true"
:placeholder="t('memories', 'Year')" />
<NcTextField :value.sync="monthLast"
class="field"
:label="t('memories', 'Month')" :label-visible="true"
:placeholder="t('memories', 'Month')" />
<NcTextField :value.sync="dayLast"
class="field"
:label="t('memories', 'Day')" :label-visible="true"
:placeholder="t('memories', 'Day')" />
<NcTextField :value.sync="hourLast"
class="field"
:label="t('memories', 'Time')" :label-visible="true"
:placeholder="t('memories', 'Hour')" />
<NcTextField :value.sync="minuteLast"
class="field"
:label="t('memories', 'Minute')"
:placeholder="t('memories', 'Minute')" />
</div>
</div>
<div v-if="processing" class="info-pad">
{{ t('memories', 'Processing … {n}/{m}', {
n: photosDone,
m: photos.length,
}) }}
</div>
<div class="info-pad warn">
{{ t('memories', 'This feature modifies files in your storage to update Exif data.') }}
{{ t('memories', 'Exercise caution and make sure you have backups.') }}
</div>
<div class="fields">
<NcTextField
:value.sync="yearLast"
class="field"
:label="t('memories', 'Year')"
:label-visible="true"
:placeholder="t('memories', 'Year')"
/>
<NcTextField
:value.sync="monthLast"
class="field"
:label="t('memories', 'Month')"
:label-visible="true"
:placeholder="t('memories', 'Month')"
/>
<NcTextField
:value.sync="dayLast"
class="field"
:label="t('memories', 'Day')"
:label-visible="true"
:placeholder="t('memories', 'Day')"
/>
<NcTextField
:value.sync="hourLast"
class="field"
:label="t('memories', 'Time')"
:label-visible="true"
:placeholder="t('memories', 'Hour')"
/>
<NcTextField
:value.sync="minuteLast"
class="field"
:label="t('memories', 'Minute')"
:placeholder="t('memories', 'Minute')"
/>
</div>
</div>
<div v-else>
{{ t('memories', 'Loading data … {n}/{m}', {
n: photosDone,
m: photos.length,
}) }}
</div>
</Modal>
<div v-if="processing" class="info-pad">
{{
t("memories", "Processing … {n}/{m}", {
n: photosDone,
m: photos.length,
})
}}
</div>
<div class="info-pad warn">
{{
t(
"memories",
"This feature modifies files in your storage to update Exif data."
)
}}
{{ t("memories", "Exercise caution and make sure you have backups.") }}
</div>
</div>
<div v-else>
{{
t("memories", "Loading data … {n}/{m}", {
n: photosDone,
m: photos.length,
})
}}
</div>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { IPhoto } from '../../types';
import { Component, Emit, Mixins } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { IPhoto } from "../../types";
import { NcButton, NcTextField } from '@nextcloud/vue';
import { showError } from '@nextcloud/dialogs'
import { generateUrl } from '@nextcloud/router'
import Modal from './Modal.vue';
import axios from '@nextcloud/axios'
import * as utils from '../../services/Utils';
import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from "@nextcloud/dialogs";
import { generateUrl } from "@nextcloud/router";
import Modal from "./Modal.vue";
import axios from "@nextcloud/axios";
import * as utils from "../../services/Utils";
import * as dav from "../../services/DavRequests";
const INFO_API_URL = '/apps/memories/api/info/{id}';
const EDIT_API_URL = '/apps/memories/api/edit/{id}';
const INFO_API_URL = "/apps/memories/api/info/{id}";
const EDIT_API_URL = "/apps/memories/api/edit/{id}";
@Component({
components: {
NcButton,
NcTextField,
Modal,
}
components: {
NcButton,
NcTextField,
Modal,
},
})
export default class EditDate extends Mixins(GlobalMixin) {
@Emit('refresh') emitRefresh(val: boolean) {}
@Emit("refresh") emitRefresh(val: boolean) {}
private photos: IPhoto[] = [];
private photosDone: number = 0;
private processing: boolean = false;
private photos: IPhoto[] = [];
private photosDone: number = 0;
private processing: boolean = false;
private longDateStr: string = '';
private year: string = "0";
private month: string = "0";
private day: string = "0";
private hour: string = "0";
private minute: string = "0";
private second: string = "0";
private longDateStr: string = "";
private year: string = "0";
private month: string = "0";
private day: string = "0";
private hour: string = "0";
private minute: string = "0";
private second: string = "0";
private longDateStrLast: string = '';
private yearLast: string = "0";
private monthLast: string = "0";
private dayLast: string = "0";
private hourLast: string = "0";
private minuteLast: string = "0";
private secondLast: string = "0";
private longDateStrLast: string = "";
private yearLast: string = "0";
private monthLast: string = "0";
private dayLast: string = "0";
private hourLast: string = "0";
private minuteLast: string = "0";
private secondLast: string = "0";
public async open(photos: IPhoto[]) {
this.photos = photos;
if (photos.length === 0) {
return;
public async open(photos: IPhoto[]) {
this.photos = photos;
if (photos.length === 0) {
return;
}
this.photosDone = 0;
this.longDateStr = "";
const calls = photos.map((p) => async () => {
try {
const res = await axios.get<any>(
generateUrl(INFO_API_URL, { id: p.fileid })
);
if (typeof res.data.datetaken !== "number") {
console.error("Invalid date for", p.fileid);
return;
}
this.photosDone = 0;
this.longDateStr = '';
p.datetaken = res.data.datetaken * 1000;
} catch (error) {
console.error("Failed to get date info for", p.fileid, error);
} finally {
this.photosDone++;
}
});
const calls = photos.map((p) => async () => {
try {
const res = await axios.get<any>(generateUrl(INFO_API_URL, { id: p.fileid }));
if (typeof res.data.datetaken !== "number") {
console.error("Invalid date for", p.fileid);
return;
}
p.datetaken = res.data.datetaken * 1000;
} catch (error) {
console.error('Failed to get date info for', p.fileid, error);
} finally {
this.photosDone++;
}
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
// Remove photos without datetaken
this.photos = this.photos.filter((p) => p.datetaken !== undefined);
// Sort photos by datetaken descending
this.photos.sort((a, b) => b.datetaken - a.datetaken);
// Get date of newest photo
let date = new Date(this.photos[0].datetaken);
this.year = date.getUTCFullYear().toString();
this.month = (date.getUTCMonth() + 1).toString();
this.day = date.getUTCDate().toString();
this.hour = date.getUTCHours().toString();
this.minute = date.getUTCMinutes().toString();
this.second = date.getUTCSeconds().toString();
this.longDateStr = utils.getLongDateStr(date, false, true);
// Get date of oldest photo
if (this.photos.length > 1) {
date = new Date(this.photos[this.photos.length - 1].datetaken);
this.yearLast = date.getUTCFullYear().toString();
this.monthLast = (date.getUTCMonth() + 1).toString();
this.dayLast = date.getUTCDate().toString();
this.hourLast = date.getUTCHours().toString();
this.minuteLast = date.getUTCMinutes().toString();
this.secondLast = date.getUTCSeconds().toString();
this.longDateStrLast = utils.getLongDateStr(date, false, true);
}
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
public newestChange(time=false) {
if (this.photos.length === 0) {
return;
}
// Remove photos without datetaken
this.photos = this.photos.filter((p) => p.datetaken !== undefined);
// Set the last date to have the same offset to newest date
try {
const date = new Date(this.photos[0].datetaken);
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
// Sort photos by datetaken descending
this.photos.sort((a, b) => b.datetaken - a.datetaken);
const dateNew = this.getDate();
const offset = dateNew.getTime() - date.getTime();
const dateLastNew = new Date(dateLast.getTime() + offset);
// Get date of newest photo
let date = new Date(this.photos[0].datetaken);
this.year = date.getUTCFullYear().toString();
this.month = (date.getUTCMonth() + 1).toString();
this.day = date.getUTCDate().toString();
this.hour = date.getUTCHours().toString();
this.minute = date.getUTCMinutes().toString();
this.second = date.getUTCSeconds().toString();
this.longDateStr = utils.getLongDateStr(date, false, true);
this.yearLast = dateLastNew.getUTCFullYear().toString();
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString();
this.dayLast = dateLastNew.getUTCDate().toString();
// Get date of oldest photo
if (this.photos.length > 1) {
date = new Date(this.photos[this.photos.length - 1].datetaken);
this.yearLast = date.getUTCFullYear().toString();
this.monthLast = (date.getUTCMonth() + 1).toString();
this.dayLast = date.getUTCDate().toString();
this.hourLast = date.getUTCHours().toString();
this.minuteLast = date.getUTCMinutes().toString();
this.secondLast = date.getUTCSeconds().toString();
this.longDateStrLast = utils.getLongDateStr(date, false, true);
}
}
if (time) {
this.hourLast = dateLastNew.getUTCHours().toString();
this.minuteLast = dateLastNew.getUTCMinutes().toString();
this.secondLast = dateLastNew.getUTCSeconds().toString();
}
} catch (error) {}
public newestChange(time = false) {
if (this.photos.length === 0) {
return;
}
public close() {
this.photos = [];
// Set the last date to have the same offset to newest date
try {
const date = new Date(this.photos[0].datetaken);
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
const dateNew = this.getDate();
const offset = dateNew.getTime() - date.getTime();
const dateLastNew = new Date(dateLast.getTime() + offset);
this.yearLast = dateLastNew.getUTCFullYear().toString();
this.monthLast = (dateLastNew.getUTCMonth() + 1).toString();
this.dayLast = dateLastNew.getUTCDate().toString();
if (time) {
this.hourLast = dateLastNew.getUTCHours().toString();
this.minuteLast = dateLastNew.getUTCMinutes().toString();
this.secondLast = dateLastNew.getUTCSeconds().toString();
}
} catch (error) {}
}
public close() {
this.photos = [];
}
public async saveOne() {
// Make PATCH request to update date
try {
this.processing = true;
const res = await axios.patch<any>(
generateUrl(EDIT_API_URL, { id: this.photos[0].fileid }),
{
date: this.getExifFormat(this.getDate()),
}
);
this.emitRefresh(true);
this.close();
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
}
} finally {
this.processing = false;
}
}
public async saveMany() {
if (this.processing) {
return;
}
public async saveOne() {
// Make PATCH request to update date
try {
this.processing = true;
const res = await axios.patch<any>(generateUrl(EDIT_API_URL, { id: this.photos[0].fileid }), {
date: this.getExifFormat(this.getDate()),
});
this.emitRefresh(true);
this.close();
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
}
} finally {
this.processing = false;
}
// Get difference between newest and oldest date
const date = new Date(this.photos[0].datetaken);
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
const diff = date.getTime() - dateLast.getTime();
// Get new difference between newest and oldest date
let dateNew: Date;
let dateLastNew: Date;
let diffNew: number;
try {
dateNew = this.getDate();
dateLastNew = this.getDateLast();
diffNew = dateNew.getTime() - dateLastNew.getTime();
} catch (e) {
showError(e);
return;
}
public async saveMany() {
if (this.processing) {
return;
}
// Get difference between newest and oldest date
const date = new Date(this.photos[0].datetaken);
const dateLast = new Date(this.photos[this.photos.length - 1].datetaken);
const diff = date.getTime() - dateLast.getTime();
// Get new difference between newest and oldest date
let dateNew: Date;
let dateLastNew: Date;
let diffNew: number;
try {
dateNew = this.getDate();
dateLastNew = this.getDateLast();
diffNew = dateNew.getTime() - dateLastNew.getTime();
} catch (e) {
showError(e);
return;
}
// Validate if the old is still old
if (diffNew < 0) {
showError("The newest date must be newer than the oldest date");
return;
}
// Mark processing
this.processing = true;
this.photosDone = 0;
// Create PATCH requests
const calls = this.photos.map((p) => async () => {
try {
let pDate = new Date(p.datetaken);
// Fallback to start date if invalid date
if (isNaN(pDate.getTime())) {
pDate = date;
}
const offset = date.getTime() - pDate.getTime();
const scale = diff > 0 ? (diffNew / diff) : 0;
const pDateNew = new Date(dateNew.getTime() - offset * scale);
const res = await axios.patch<any>(generateUrl(EDIT_API_URL, { id: p.fileid }), {
date: this.getExifFormat(pDateNew),
});
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
}
} finally {
this.photosDone++;
}
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
this.processing = false;
this.emitRefresh(true);
this.close();
// Validate if the old is still old
if (diffNew < 0) {
showError("The newest date must be newer than the oldest date");
return;
}
public async save() {
if (this.photos.length === 0) {
return;
// Mark processing
this.processing = true;
this.photosDone = 0;
// Create PATCH requests
const calls = this.photos.map((p) => async () => {
try {
let pDate = new Date(p.datetaken);
// Fallback to start date if invalid date
if (isNaN(pDate.getTime())) {
pDate = date;
}
if (this.photos.length === 1) {
return await this.saveOne();
const offset = date.getTime() - pDate.getTime();
const scale = diff > 0 ? diffNew / diff : 0;
const pDateNew = new Date(dateNew.getTime() - offset * scale);
const res = await axios.patch<any>(
generateUrl(EDIT_API_URL, { id: p.fileid }),
{
date: this.getExifFormat(pDateNew),
}
);
} catch (e) {
if (e.response?.data?.message) {
showError(e.response.data.message);
} else {
showError(e);
}
} finally {
this.photosDone++;
}
});
return await this.saveMany();
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
this.processing = false;
this.emitRefresh(true);
this.close();
}
public async save() {
if (this.photos.length === 0) {
return;
}
private getExifFormat(date: Date) {
const year = date.getUTCFullYear().toString().padStart(4, "0");
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
const day = date.getUTCDate().toString().padStart(2, "0");
const hour = date.getUTCHours().toString().padStart(2, "0");
const minute = date.getUTCMinutes().toString().padStart(2, "0");
const second = date.getUTCSeconds().toString().padStart(2, "0");
return `${year}:${month}:${day} ${hour}:${minute}:${second}`;
if (this.photos.length === 1) {
return await this.saveOne();
}
public getDate() {
const dateNew = new Date();
const year = parseInt(this.year, 10);
const month = parseInt(this.month, 10) - 1;
const day = parseInt(this.day, 10);
const hour = parseInt(this.hour, 10);
const minute = parseInt(this.minute, 10);
const second = parseInt(this.second, 10) || 0;
return await this.saveMany();
}
if (isNaN(year)) throw new Error("Invalid year");
if (isNaN(month)) throw new Error("Invalid month");
if (isNaN(day)) throw new Error("Invalid day");
if (isNaN(hour)) throw new Error("Invalid hour");
if (isNaN(minute)) throw new Error("Invalid minute");
if (isNaN(second)) throw new Error("Invalid second");
private getExifFormat(date: Date) {
const year = date.getUTCFullYear().toString().padStart(4, "0");
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
const day = date.getUTCDate().toString().padStart(2, "0");
const hour = date.getUTCHours().toString().padStart(2, "0");
const minute = date.getUTCMinutes().toString().padStart(2, "0");
const second = date.getUTCSeconds().toString().padStart(2, "0");
return `${year}:${month}:${day} ${hour}:${minute}:${second}`;
}
dateNew.setUTCFullYear(year);
dateNew.setUTCMonth(month);
dateNew.setUTCDate(day);
dateNew.setUTCHours(hour);
dateNew.setUTCMinutes(minute);
dateNew.setUTCSeconds(second);
return dateNew;
}
public getDate() {
const dateNew = new Date();
const year = parseInt(this.year, 10);
const month = parseInt(this.month, 10) - 1;
const day = parseInt(this.day, 10);
const hour = parseInt(this.hour, 10);
const minute = parseInt(this.minute, 10);
const second = parseInt(this.second, 10) || 0;
public getDateLast() {
const dateNew = new Date();
const year = parseInt(this.yearLast, 10);
const month = parseInt(this.monthLast, 10) - 1;
const day = parseInt(this.dayLast, 10);
const hour = parseInt(this.hourLast, 10);
const minute = parseInt(this.minuteLast, 10);
const second = parseInt(this.secondLast, 10) || 0;
if (isNaN(year)) throw new Error("Invalid year");
if (isNaN(month)) throw new Error("Invalid month");
if (isNaN(day)) throw new Error("Invalid day");
if (isNaN(hour)) throw new Error("Invalid hour");
if (isNaN(minute)) throw new Error("Invalid minute");
if (isNaN(second)) throw new Error("Invalid second");
if (isNaN(year)) throw new Error("Invalid last year");
if (isNaN(month)) throw new Error("Invalid last month");
if (isNaN(day)) throw new Error("Invalid last day");
if (isNaN(hour)) throw new Error("Invalid last hour");
if (isNaN(minute)) throw new Error("Invalid last minute");
if (isNaN(second)) throw new Error("Invalid last second");
dateNew.setUTCFullYear(year);
dateNew.setUTCMonth(month);
dateNew.setUTCDate(day);
dateNew.setUTCHours(hour);
dateNew.setUTCMinutes(minute);
dateNew.setUTCSeconds(second);
return dateNew;
}
dateNew.setUTCFullYear(year);
dateNew.setUTCMonth(month);
dateNew.setUTCDate(day);
dateNew.setUTCHours(hour);
dateNew.setUTCMinutes(minute);
dateNew.setUTCSeconds(second);
return dateNew;
}
public getDateLast() {
const dateNew = new Date();
const year = parseInt(this.yearLast, 10);
const month = parseInt(this.monthLast, 10) - 1;
const day = parseInt(this.dayLast, 10);
const hour = parseInt(this.hourLast, 10);
const minute = parseInt(this.minuteLast, 10);
const second = parseInt(this.secondLast, 10) || 0;
if (isNaN(year)) throw new Error("Invalid last year");
if (isNaN(month)) throw new Error("Invalid last month");
if (isNaN(day)) throw new Error("Invalid last day");
if (isNaN(hour)) throw new Error("Invalid last hour");
if (isNaN(minute)) throw new Error("Invalid last minute");
if (isNaN(second)) throw new Error("Invalid last second");
dateNew.setUTCFullYear(year);
dateNew.setUTCMonth(month);
dateNew.setUTCDate(day);
dateNew.setUTCHours(hour);
dateNew.setUTCMinutes(minute);
dateNew.setUTCSeconds(second);
return dateNew;
}
}
</script>
<style scoped lang="scss">
.fields {
.field {
width: 4.1em;
display: inline-block;
}
.field {
width: 4.1em;
display: inline-block;
}
:deep label {
font-size: 0.8em;
padding: 0 !important;
padding-left: 3px !important;
}
:deep label {
font-size: 0.8em;
padding: 0 !important;
padding-left: 3px !important;
}
}
.oldest {
margin-top: 10px;
margin-top: 10px;
}
.info-pad {
margin-top: 6px;
margin-bottom: 2px;
margin-top: 6px;
margin-bottom: 2px;
&.warn {
color: #f44336;
font-size: 0.8em;
line-height: 1em;
}
&.warn {
color: #f44336;
font-size: 0.8em;
line-height: 1em;
}
}
</style>

View File

@ -1,79 +1,87 @@
<template>
<Modal @close="close" v-if="show">
<template #title>
{{ t('memories', 'Remove person') }}
</template>
<Modal @close="close" v-if="show">
<template #title>
{{ t("memories", "Remove person") }}
</template>
<span>{{ t('memories', 'Are you sure you want to remove {name}?', { name }) }}</span>
<span>{{
t("memories", "Are you sure you want to remove {name}?", { name })
}}</span>
<template #buttons>
<NcButton @click="save" class="button" type="error">
{{ t('memories', 'Delete') }}
</NcButton>
</template>
</Modal>
<template #buttons>
<NcButton @click="save" class="button" type="error">
{{ t("memories", "Delete") }}
</NcButton>
</template>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
import { NcButton, NcTextField } from '@nextcloud/vue';
import { showError } from '@nextcloud/dialogs';
import { getCurrentUser } from '@nextcloud/auth';
import Modal from './Modal.vue';
import GlobalMixin from '../../mixins/GlobalMixin';
import client from '../../services/DavClient';
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
@Component({
components: {
NcButton,
NcTextField,
Modal,
}
components: {
NcButton,
NcTextField,
Modal,
},
})
export default class FaceDeleteModal extends Mixins(GlobalMixin) {
private user: string = "";
private name: string = "";
private show = false;
private user: string = "";
private name: string = "";
private show = false;
@Emit('close')
public close() {
this.show = false;
}
@Emit("close")
public close() {
this.show = false;
}
public open() {
const user = this.$route.params.user || '';
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can delete this person', { user }));
return;
}
this.show = true;
public open() {
const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) {
showError(
this.t("memories", 'Only user "{user}" can delete this person', {
user,
})
);
return;
}
this.show = true;
}
@Watch('$route')
async routeChange(from: any, to: any) {
this.refreshParams();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
public refreshParams() {
this.user = this.$route.params.user || '';
this.name = this.$route.params.name || '';
}
public refreshParams() {
this.user = this.$route.params.user || "";
this.name = this.$route.params.name || "";
}
public async save() {
try {
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`)
this.$router.push({ name: 'people' });
this.close();
} catch (error) {
console.log(error);
showError(this.t('photos', 'Failed to delete {name}.', {
name: this.name,
}));
}
public async save() {
try {
await client.deleteFile(`/recognize/${this.user}/faces/${this.name}`);
this.$router.push({ name: "people" });
this.close();
} catch (error) {
console.log(error);
showError(
this.t("photos", "Failed to delete {name}.", {
name: this.name,
})
);
}
}
}
</script>

View File

@ -1,97 +1,109 @@
<template>
<Modal @close="close" v-if="show">
<template #title>
{{ t('memories', 'Rename person') }}
</template>
<Modal @close="close" v-if="show">
<template #title>
{{ t("memories", "Rename person") }}
</template>
<div class="fields">
<NcTextField :value.sync="name"
class="field"
:label="t('memories', 'Name')" :label-visible="false"
:placeholder="t('memories', 'Name')"
@keypress.enter="save()" />
</div>
<div class="fields">
<NcTextField
:value.sync="name"
class="field"
:label="t('memories', 'Name')"
:label-visible="false"
:placeholder="t('memories', 'Name')"
@keypress.enter="save()"
/>
</div>
<template #buttons>
<NcButton @click="save" class="button" type="primary">
{{ t('memories', 'Update') }}
</NcButton>
</template>
</Modal>
<template #buttons>
<NcButton @click="save" class="button" type="primary">
{{ t("memories", "Update") }}
</NcButton>
</template>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
import { NcButton, NcTextField } from '@nextcloud/vue';
import { showError } from '@nextcloud/dialogs';
import { getCurrentUser } from '@nextcloud/auth';
import Modal from './Modal.vue';
import GlobalMixin from '../../mixins/GlobalMixin';
import client from '../../services/DavClient';
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
@Component({
components: {
NcButton,
NcTextField,
Modal,
}
components: {
NcButton,
NcTextField,
Modal,
},
})
export default class FaceEditModal extends Mixins(GlobalMixin) {
private user: string = "";
private name: string = "";
private oldName: string = "";
private show = false;
private user: string = "";
private name: string = "";
private oldName: string = "";
private show = false;
@Emit('close')
public close() {
this.show = false;
}
@Emit("close")
public close() {
this.show = false;
}
public open() {
const user = this.$route.params.user || '';
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
return;
}
this.show = true;
public open() {
const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) {
showError(
this.t("memories", 'Only user "{user}" can update this person', {
user,
})
);
return;
}
this.show = true;
}
@Watch('$route')
async routeChange(from: any, to: any) {
this.refreshParams();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
public refreshParams() {
this.user = this.$route.params.user || '';
this.name = this.$route.params.name || '';
this.oldName = this.$route.params.name || '';
}
public refreshParams() {
this.user = this.$route.params.user || "";
this.name = this.$route.params.name || "";
this.oldName = this.$route.params.name || "";
}
public async save() {
try {
await client.moveFile(
`/recognize/${this.user}/faces/${this.oldName}`,
`/recognize/${this.user}/faces/${this.name}`,
);
this.$router.push({ name: 'people', params: { user: this.user, name: this.name } });
this.close();
} catch (error) {
console.log(error);
showError(this.t('photos', 'Failed to rename {oldName} to {name}.', {
oldName: this.oldName,
name: this.name,
}));
}
public async save() {
try {
await client.moveFile(
`/recognize/${this.user}/faces/${this.oldName}`,
`/recognize/${this.user}/faces/${this.name}`
);
this.$router.push({
name: "people",
params: { user: this.user, name: this.name },
});
this.close();
} catch (error) {
console.log(error);
showError(
this.t("photos", "Failed to rename {oldName} to {name}.", {
oldName: this.oldName,
name: this.name,
})
);
}
}
}
</script>
<style lang="scss" scoped>
.fields {
margin-top: 8px;
margin-top: 8px;
}
</style>

View File

@ -1,83 +1,83 @@
<template>
<div class="outer" v-if="detail">
<div class="photo" v-for="photo of detail" :key="photo.fileid" >
<Tag :data="photo" :noNavigate="true" @open="clickFace" />
</div>
</div>
<div v-else>
{{ t('memories', 'Loading …') }}
<div class="outer" v-if="detail">
<div class="photo" v-for="photo of detail" :key="photo.fileid">
<Tag :data="photo" :noNavigate="true" @open="clickFace" />
</div>
</div>
<div v-else>
{{ t("memories", "Loading …") }}
</div>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Watch } from 'vue-property-decorator';
import { IPhoto, ITag } from '../../types';
import Tag from '../frame/Tag.vue';
import { Component, Emit, Mixins, Watch } from "vue-property-decorator";
import { IPhoto, ITag } from "../../types";
import Tag from "../frame/Tag.vue";
import GlobalMixin from '../../mixins/GlobalMixin';
import * as dav from '../../services/DavRequests';
import GlobalMixin from "../../mixins/GlobalMixin";
import * as dav from "../../services/DavRequests";
@Component({
components: {
Tag,
}
components: {
Tag,
},
})
export default class FaceMergeModal extends Mixins(GlobalMixin) {
private user: string = "";
private name: string = "";
private detail: IPhoto[] | null = null;
private user: string = "";
private name: string = "";
private detail: IPhoto[] | null = null;
@Emit('close')
public close() {}
@Emit("close")
public close() {}
@Watch('$route')
async routeChange(from: any, to: any) {
this.refreshParams();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
mounted() {
this.refreshParams();
}
public async refreshParams() {
this.user = this.$route.params.user || '';
this.name = this.$route.params.name || '';
this.detail = null;
public async refreshParams() {
this.user = this.$route.params.user || "";
this.name = this.$route.params.name || "";
this.detail = null;
const data = await dav.getPeopleData();
let detail = data[0].detail;
detail.forEach((photo: IPhoto) => {
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
});
detail = detail.filter((photo: ITag) => {
const pname = photo.name || photo.fileid.toString();
return photo.user_id !== this.user || pname !== this.name;
});
const data = await dav.getPeopleData();
let detail = data[0].detail;
detail.forEach((photo: IPhoto) => {
photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG;
});
detail = detail.filter((photo: ITag) => {
const pname = photo.name || photo.fileid.toString();
return photo.user_id !== this.user || pname !== this.name;
});
this.detail = detail;
}
this.detail = detail;
}
@Emit('select')
public async clickFace(face: ITag) {}
@Emit("select")
public async clickFace(face: ITag) {}
}
</script>
<style lang="scss" scoped>
.outer {
width: 100%;
max-height: calc(90vh - 80px - 4em);
overflow-x: hidden;
overflow-y: auto;
width: 100%;
max-height: calc(90vh - 80px - 4em);
overflow-x: hidden;
overflow-y: auto;
}
.photo {
display: inline-block;
position: relative;
cursor: pointer;
vertical-align: top;
font-size: 0.8em;
display: inline-block;
position: relative;
cursor: pointer;
vertical-align: top;
font-size: 0.8em;
max-width: 120px;
width: calc(33.33%);
aspect-ratio: 1/1;
max-width: 120px;
width: calc(33.33%);
aspect-ratio: 1/1;
}
</style>

View File

@ -1,136 +1,156 @@
<template>
<Modal @close="close" size="large" v-if="show">
<template #title>
{{ t('memories', 'Merge {name} with person', { name: $route.params.name }) }}
</template>
<Modal @close="close" size="large" v-if="show">
<template #title>
{{
t("memories", "Merge {name} with person", { name: $route.params.name })
}}
</template>
<div class="outer">
<FaceList @select="clickFace" />
<div class="outer">
<FaceList @select="clickFace" />
<div v-if="procesingTotal > 0" class="info-pad">
{{ t('memories', 'Processing … {n}/{m}', {
n: processing,
m: procesingTotal,
}) }}
</div>
</div>
<div v-if="procesingTotal > 0" class="info-pad">
{{
t("memories", "Processing … {n}/{m}", {
n: processing,
m: procesingTotal,
})
}}
</div>
</div>
<template #buttons>
<NcButton @click="close" class="button" type="error">
{{ t('memories', 'Cancel') }}
</NcButton>
</template>
</Modal>
<template #buttons>
<NcButton @click="close" class="button" type="error">
{{ t("memories", "Cancel") }}
</NcButton>
</template>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins } from 'vue-property-decorator';
import { NcButton, NcTextField } from '@nextcloud/vue';
import { showError } from '@nextcloud/dialogs';
import { getCurrentUser } from '@nextcloud/auth';
import { IFileInfo, ITag } from '../../types';
import Tag from '../frame/Tag.vue';
import FaceList from './FaceList.vue';
import { Component, Emit, Mixins } from "vue-property-decorator";
import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import { IFileInfo, ITag } from "../../types";
import Tag from "../frame/Tag.vue";
import FaceList from "./FaceList.vue";
import Modal from './Modal.vue';
import GlobalMixin from '../../mixins/GlobalMixin';
import client from '../../services/DavClient';
import * as dav from '../../services/DavRequests';
import Modal from "./Modal.vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import client from "../../services/DavClient";
import * as dav from "../../services/DavRequests";
@Component({
components: {
NcButton,
NcTextField,
Modal,
Tag,
FaceList,
}
components: {
NcButton,
NcTextField,
Modal,
Tag,
FaceList,
},
})
export default class FaceMergeModal extends Mixins(GlobalMixin) {
private processing = 0;
private procesingTotal = 0;
private show = false;
private processing = 0;
private procesingTotal = 0;
private show = false;
@Emit('close')
public close() {
this.show = false;
@Emit("close")
public close() {
this.show = false;
}
public open() {
const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) {
showError(
this.t("memories", 'Only user "{user}" can update this person', {
user,
})
);
return;
}
this.show = true;
}
public async clickFace(face: ITag) {
const user = this.$route.params.user || "";
const name = this.$route.params.name || "";
const newName = face.name || face.fileid.toString();
if (
!confirm(
this.t(
"memories",
"Are you sure you want to merge {name} with {newName}?",
{ name, newName }
)
)
) {
return;
}
public open() {
const user = this.$route.params.user || '';
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
return;
try {
// Get all files for current face
let res = (await client.getDirectoryContents(
`/recognize/${user}/faces/${name}`,
{ details: true }
)) as any;
let data: IFileInfo[] = res.data;
this.procesingTotal = data.length;
// Don't try too much
let failures = 0;
// Create move calls
const calls = data.map((p) => async () => {
// Short circuit if we have too many failures
if (failures === 10) {
showError(this.t("memories", "Too many failures, aborting"));
failures++;
}
this.show = true;
}
if (failures >= 10) return;
public async clickFace(face: ITag) {
const user = this.$route.params.user || '';
const name = this.$route.params.name || '';
const newName = face.name || face.fileid.toString();
if (!confirm(this.t('memories', 'Are you sure you want to merge {name} with {newName}?', { name, newName}))) {
return;
// Move to new face with webdav
try {
await client.moveFile(
`/recognize/${user}/faces/${name}/${p.basename}`,
`/recognize/${face.user_id}/faces/${newName}/${p.basename}`
);
} catch (e) {
console.error(e);
showError(this.t("memories", "Error while moving {basename}", p));
failures++;
} finally {
this.processing++;
}
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
try {
// Get all files for current face
let res = await client.getDirectoryContents(
`/recognize/${user}/faces/${name}`, { details: true }
) as any;
let data: IFileInfo[] = res.data;
this.procesingTotal = data.length;
// Don't try too much
let failures = 0;
// Create move calls
const calls = data.map((p) => async () => {
// Short circuit if we have too many failures
if (failures === 10) {
showError(this.t('memories', 'Too many failures, aborting'));
failures++;
}
if (failures >= 10) return;
// Move to new face with webdav
try {
await client.moveFile(
`/recognize/${user}/faces/${name}/${p.basename}`,
`/recognize/${face.user_id}/faces/${newName}/${p.basename}`
)
} catch (e) {
console.error(e);
showError(this.t('memories', 'Error while moving {basename}', p));
failures++;
} finally {
this.processing++;
}
});
for await (const _ of dav.runInParallel(calls, 10)) {
// nothing to do
}
// Go to new face
if (failures === 0) {
this.$router.push({ name: 'people', params: { user: face.user_id, name: newName } });
this.close();
}
} catch (error) {
console.error(error);
showError(this.t('photos', 'Failed to move {name}.', { name }));
}
// Go to new face
if (failures === 0) {
this.$router.push({
name: "people",
params: { user: face.user_id, name: newName },
});
this.close();
}
} catch (error) {
console.error(error);
showError(this.t("photos", "Failed to move {name}.", { name }));
}
}
}
</script>
<style lang="scss" scoped>
.outer {
margin-top: 15px;
margin-top: 15px;
}
.info-pad {
margin-top: 6px;
margin-bottom: 2px;
margin-top: 6px;
margin-bottom: 2px;
}
</style>

View File

@ -1,129 +1,141 @@
<template>
<Modal @close="close" size="large" v-if="show">
<template #title>
{{ t('memories', 'Move selected photos to person') }}
</template>
<Modal @close="close" size="large" v-if="show">
<template #title>
{{ t("memories", "Move selected photos to person") }}
</template>
<div class="outer">
<FaceList @select="clickFace" />
</div>
<div class="outer">
<FaceList @select="clickFace" />
</div>
<template #buttons>
<NcButton @click="close" class="button" type="error">
{{ t('memories', 'Cancel') }}
</NcButton>
</template>
</Modal>
<template #buttons>
<NcButton @click="close" class="button" type="error">
{{ t("memories", "Cancel") }}
</NcButton>
</template>
</Modal>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcButton, NcTextField } from '@nextcloud/vue';
import { showError } from '@nextcloud/dialogs';
import { getCurrentUser } from '@nextcloud/auth';
import { IPhoto, ITag } from '../../types';
import Tag from '../frame/Tag.vue';
import FaceList from './FaceList.vue';
import { NcButton, NcTextField } from "@nextcloud/vue";
import { showError } from "@nextcloud/dialogs";
import { getCurrentUser } from "@nextcloud/auth";
import { IPhoto, ITag } from "../../types";
import Tag from "../frame/Tag.vue";
import FaceList from "./FaceList.vue";
import Modal from './Modal.vue';
import client from '../../services/DavClient';
import * as dav from '../../services/DavRequests';
import Modal from "./Modal.vue";
import client from "../../services/DavClient";
import * as dav from "../../services/DavRequests";
@Component({
components: {
NcButton,
NcTextField,
Modal,
Tag,
FaceList,
}
components: {
NcButton,
NcTextField,
Modal,
Tag,
FaceList,
},
})
export default class FaceMoveModal extends Mixins(GlobalMixin) {
private show = false;
private photos: IPhoto[] = [];
private show = false;
private photos: IPhoto[] = [];
@Prop()
private updateLoading: (delta: number) => void;
@Prop()
private updateLoading: (delta: number) => void;
public open(photos: IPhoto[]) {
if (this.photos.length) {
// is processing
return;
}
// check ownership
const user = this.$route.params.user || '';
if (this.$route.params.user !== getCurrentUser().uid) {
showError(this.t('memories', 'Only user "{user}" can update this person', { user }));
return;
}
this.show = true;
this.photos = photos;
public open(photos: IPhoto[]) {
if (this.photos.length) {
// is processing
return;
}
@Emit('close')
public close() {
this.photos = [];
this.show = false;
// check ownership
const user = this.$route.params.user || "";
if (this.$route.params.user !== getCurrentUser().uid) {
showError(
this.t("memories", 'Only user "{user}" can update this person', {
user,
})
);
return;
}
@Emit('moved')
public moved(list: IPhoto[]) {}
this.show = true;
this.photos = photos;
}
public async clickFace(face: ITag) {
const user = this.$route.params.user || '';
const name = this.$route.params.name || '';
@Emit("close")
public close() {
this.photos = [];
this.show = false;
}
const newName = face.name || face.fileid.toString();
@Emit("moved")
public moved(list: IPhoto[]) {}
if (!confirm(this.t('memories', 'Are you sure you want to move the selected photos from {name} to {newName}?', { name, newName}))) {
return;
}
public async clickFace(face: ITag) {
const user = this.$route.params.user || "";
const name = this.$route.params.name || "";
try {
this.show = false;
this.updateLoading(1);
const newName = face.name || face.fileid.toString();
// Create map to return IPhoto later
let photoMap = new Map<number, IPhoto>();
for (const photo of this.photos) {
photoMap.set(photo.fileid, photo);
}
let data = await dav.getFiles(this.photos.map(p => p.fileid));
// Create move calls
const calls = data.map((p) => async () => {
try {
await client.moveFile(
`/recognize/${user}/faces/${name}/${p.fileid}-${p.basename}`,
`/recognize/${face.user_id}/faces/${newName}/${p.fileid}-${p.basename}`
)
return photoMap.get(p.fileid);
} catch (e) {
console.error(e);
showError(this.t('memories', 'Error while moving {basename}', p));
}
});
for await (const resp of dav.runInParallel(calls, 10)) {
this.moved(resp);
}
} catch (error) {
console.error(error);
showError(this.t('photos', 'Failed to move {name}.', { name }));
} finally {
this.updateLoading(-1);
this.close();
}
if (
!confirm(
this.t(
"memories",
"Are you sure you want to move the selected photos from {name} to {newName}?",
{ name, newName }
)
)
) {
return;
}
try {
this.show = false;
this.updateLoading(1);
// Create map to return IPhoto later
let photoMap = new Map<number, IPhoto>();
for (const photo of this.photos) {
photoMap.set(photo.fileid, photo);
}
let data = await dav.getFiles(this.photos.map((p) => p.fileid));
// Create move calls
const calls = data.map((p) => async () => {
try {
await client.moveFile(
`/recognize/${user}/faces/${name}/${p.fileid}-${p.basename}`,
`/recognize/${face.user_id}/faces/${newName}/${p.fileid}-${p.basename}`
);
return photoMap.get(p.fileid);
} catch (e) {
console.error(e);
showError(this.t("memories", "Error while moving {basename}", p));
}
});
for await (const resp of dav.runInParallel(calls, 10)) {
this.moved(resp);
}
} catch (error) {
console.error(error);
showError(this.t("photos", "Failed to move {name}.", { name }));
} finally {
this.updateLoading(-1);
this.close();
}
}
}
</script>
<style lang="scss" scoped>
.outer {
margin-top: 15px;
margin-top: 15px;
}
</style>

View File

@ -1,56 +1,53 @@
<template>
<NcModal
:size="size"
@close="close"
:outTransition="true">
<div class="container">
<div class="head">
<span> <slot name="title"></slot> </span>
</div>
<NcModal :size="size" @close="close" :outTransition="true">
<div class="container">
<div class="head">
<span> <slot name="title"></slot> </span>
</div>
<slot></slot>
<slot></slot>
<div class="buttons">
<slot name="buttons"></slot>
</div>
</div>
</NcModal>
<div class="buttons">
<slot name="buttons"></slot>
</div>
</div>
</NcModal>
</template>
<script lang="ts">
import { Component, Emit, Prop, Vue } from 'vue-property-decorator';
import { NcModal } from '@nextcloud/vue';
import { Component, Emit, Prop, Vue } from "vue-property-decorator";
import { NcModal } from "@nextcloud/vue";
@Component({
components: {
NcModal,
}
components: {
NcModal,
},
})
export default class Modal extends Vue {
@Prop({default: 'small'}) private size?: string;
@Prop({ default: "small" }) private size?: string;
@Emit('close')
public close() {}
@Emit("close")
public close() {}
}
</script>
<style lang="scss" scoped>
.container {
margin: 20px;
margin: 20px;
.head {
font-weight: 500;
font-size: 1.15em;
margin-bottom: 5px;
}
:deep .buttons {
margin-top: 10px;
text-align: right;
> button {
display: inline-block !important;
}
.head {
font-weight: 500;
font-size: 1.15em;
margin-bottom: 5px;
}
:deep .buttons {
margin-top: 10px;
text-align: right;
> button {
display: inline-block !important;
}
}
}
</style>

View File

@ -1,123 +1,139 @@
<template>
<div class="album-top-matter">
<NcActions v-if="!isAlbumList">
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t('memories', 'Back') }}
<template #icon> <BackIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<div class="album-top-matter">
<NcActions v-if="!isAlbumList">
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t("memories", "Back") }}
<template #icon> <BackIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<div class="name">{{ name }}</div>
<div class="name">{{ name }}</div>
<div class="right-actions">
<NcActions :inline="1">
<NcActionButton :aria-label="t('memories', 'Create new album')" @click="$refs.createModal.open(false)" close-after-click
v-if="isAlbumList">
{{ t('memories', 'Create new album') }}
<template #icon> <PlusIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Share album')" @click="$refs.shareModal.open(false)" close-after-click
v-if="!isAlbumList">
{{ t('memories', 'Share album') }}
<template #icon> <ShareIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Edit album details')" @click="$refs.createModal.open(true)" close-after-click
v-if="!isAlbumList">
{{ t('memories', 'Edit album details') }}
<template #icon> <EditIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Delete album')" @click="$refs.deleteModal.open()" close-after-click
v-if="!isAlbumList">
{{ t('memories', 'Delete album') }}
<template #icon> <DeleteIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</div>
<AlbumCreateModal ref="createModal" />
<AlbumDeleteModal ref="deleteModal" />
<AlbumShareModal ref="shareModal" />
<div class="right-actions">
<NcActions :inline="1">
<NcActionButton
:aria-label="t('memories', 'Create new album')"
@click="$refs.createModal.open(false)"
close-after-click
v-if="isAlbumList"
>
{{ t("memories", "Create new album") }}
<template #icon> <PlusIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Share album')"
@click="$refs.shareModal.open(false)"
close-after-click
v-if="!isAlbumList"
>
{{ t("memories", "Share album") }}
<template #icon> <ShareIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Edit album details')"
@click="$refs.createModal.open(true)"
close-after-click
v-if="!isAlbumList"
>
{{ t("memories", "Edit album details") }}
<template #icon> <EditIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Delete album')"
@click="$refs.deleteModal.open()"
close-after-click
v-if="!isAlbumList"
>
{{ t("memories", "Delete album") }}
<template #icon> <DeleteIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</div>
<AlbumCreateModal ref="createModal" />
<AlbumDeleteModal ref="deleteModal" />
<AlbumShareModal ref="shareModal" />
</div>
</template>
<script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig";
import AlbumCreateModal from '../modal/AlbumCreateModal.vue';
import AlbumDeleteModal from '../modal/AlbumDeleteModal.vue';
import AlbumShareModal from '../modal/AlbumShareModal.vue';
import AlbumCreateModal from "../modal/AlbumCreateModal.vue";
import AlbumDeleteModal from "../modal/AlbumDeleteModal.vue";
import AlbumShareModal from "../modal/AlbumShareModal.vue";
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue';
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
import EditIcon from 'vue-material-design-icons/Pencil.vue';
import DeleteIcon from 'vue-material-design-icons/Close.vue';
import PlusIcon from 'vue-material-design-icons/Plus.vue';
import ShareIcon from 'vue-material-design-icons/ShareVariant.vue';
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
import EditIcon from "vue-material-design-icons/Pencil.vue";
import DeleteIcon from "vue-material-design-icons/Close.vue";
import PlusIcon from "vue-material-design-icons/Plus.vue";
import ShareIcon from "vue-material-design-icons/ShareVariant.vue";
@Component({
components: {
NcActions,
NcActionButton,
NcActionCheckbox,
components: {
NcActions,
NcActionButton,
NcActionCheckbox,
AlbumCreateModal,
AlbumDeleteModal,
AlbumShareModal,
AlbumCreateModal,
AlbumDeleteModal,
AlbumShareModal,
BackIcon,
EditIcon,
DeleteIcon,
PlusIcon,
ShareIcon,
},
BackIcon,
EditIcon,
DeleteIcon,
PlusIcon,
ShareIcon,
},
})
export default class AlbumTopMatter extends Mixins(GlobalMixin, UserConfig) {
private name: string = '';
private name: string = "";
get isAlbumList() {
return !Boolean(this.$route.params.name);
}
get isAlbumList() {
return !Boolean(this.$route.params.name);
}
@Watch('$route')
async routeChange(from: any, to: any) {
this.createMatter();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.createMatter();
}
mounted() {
this.createMatter();
}
mounted() {
this.createMatter();
}
createMatter() {
this.name = this.$route.params.name || this.t('memories', 'Albums');
}
createMatter() {
this.name = this.$route.params.name || this.t("memories", "Albums");
}
back() {
this.$router.push({ name: 'albums' });
}
back() {
this.$router.push({ name: "albums" });
}
}
</script>
<style lang="scss" scoped>
.album-top-matter {
display: flex;
vertical-align: middle;
display: flex;
vertical-align: middle;
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 40px;
padding-left: 3px;
flex-grow: 1;
}
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 40px;
padding-left: 3px;
flex-grow: 1;
}
.right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
.right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
}
}
</style>

View File

@ -1,116 +1,135 @@
<template>
<div v-if="name" class="face-top-matter">
<NcActions>
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t('memories', 'Back') }}
<template #icon> <BackIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<div v-if="name" class="face-top-matter">
<NcActions>
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t("memories", "Back") }}
<template #icon> <BackIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<div class="name">{{ name }}</div>
<div class="name">{{ name }}</div>
<div class="right-actions">
<NcActions :inline="1">
<NcActionButton :aria-label="t('memories', 'Rename person')" @click="$refs.editModal.open()" close-after-click>
{{ t('memories', 'Rename person') }}
<template #icon> <EditIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Merge with different person')" @click="$refs.mergeModal.open()" close-after-click>
{{ t('memories', 'Merge with different person') }}
<template #icon> <MergeIcon :size="20" /> </template>
</NcActionButton>
<NcActionCheckbox :aria-label="t('memories', 'Mark person in preview')" :checked.sync="config_showFaceRect" @change="changeShowFaceRect">
{{ t('memories', 'Mark person in preview') }}
</NcActionCheckbox>
<NcActionButton :aria-label="t('memories', 'Remove person')" @click="$refs.deleteModal.open()" close-after-click>
{{ t('memories', 'Remove person') }}
<template #icon> <DeleteIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</div>
<FaceEditModal ref="editModal" />
<FaceDeleteModal ref="deleteModal" />
<FaceMergeModal ref="mergeModal" />
<div class="right-actions">
<NcActions :inline="1">
<NcActionButton
:aria-label="t('memories', 'Rename person')"
@click="$refs.editModal.open()"
close-after-click
>
{{ t("memories", "Rename person") }}
<template #icon> <EditIcon :size="20" /> </template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Merge with different person')"
@click="$refs.mergeModal.open()"
close-after-click
>
{{ t("memories", "Merge with different person") }}
<template #icon> <MergeIcon :size="20" /> </template>
</NcActionButton>
<NcActionCheckbox
:aria-label="t('memories', 'Mark person in preview')"
:checked.sync="config_showFaceRect"
@change="changeShowFaceRect"
>
{{ t("memories", "Mark person in preview") }}
</NcActionCheckbox>
<NcActionButton
:aria-label="t('memories', 'Remove person')"
@click="$refs.deleteModal.open()"
close-after-click
>
{{ t("memories", "Remove person") }}
<template #icon> <DeleteIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
</div>
<FaceEditModal ref="editModal" />
<FaceDeleteModal ref="deleteModal" />
<FaceMergeModal ref="mergeModal" />
</div>
</template>
<script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import UserConfig from "../../mixins/UserConfig";
import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue';
import FaceEditModal from '../modal/FaceEditModal.vue';
import FaceDeleteModal from '../modal/FaceDeleteModal.vue';
import FaceMergeModal from '../modal/FaceMergeModal.vue';
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
import EditIcon from 'vue-material-design-icons/Pencil.vue';
import DeleteIcon from 'vue-material-design-icons/Close.vue';
import MergeIcon from 'vue-material-design-icons/Merge.vue';
import { NcActions, NcActionButton, NcActionCheckbox } from "@nextcloud/vue";
import FaceEditModal from "../modal/FaceEditModal.vue";
import FaceDeleteModal from "../modal/FaceDeleteModal.vue";
import FaceMergeModal from "../modal/FaceMergeModal.vue";
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
import EditIcon from "vue-material-design-icons/Pencil.vue";
import DeleteIcon from "vue-material-design-icons/Close.vue";
import MergeIcon from "vue-material-design-icons/Merge.vue";
@Component({
components: {
NcActions,
NcActionButton,
NcActionCheckbox,
FaceEditModal,
FaceDeleteModal,
FaceMergeModal,
BackIcon,
EditIcon,
DeleteIcon,
MergeIcon,
},
components: {
NcActions,
NcActionButton,
NcActionCheckbox,
FaceEditModal,
FaceDeleteModal,
FaceMergeModal,
BackIcon,
EditIcon,
DeleteIcon,
MergeIcon,
},
})
export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
private name: string = '';
private name: string = "";
@Watch('$route')
async routeChange(from: any, to: any) {
this.createMatter();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.createMatter();
}
mounted() {
this.createMatter();
}
mounted() {
this.createMatter();
}
createMatter() {
this.name = this.$route.params.name || '';
}
createMatter() {
this.name = this.$route.params.name || "";
}
back() {
this.$router.push({ name: 'people' });
}
back() {
this.$router.push({ name: "people" });
}
changeShowFaceRect() {
localStorage.setItem('memories_showFaceRect', this.config_showFaceRect ? '1' : '0');
setTimeout(() => {
this.$router.go(0); // refresh page
}, 500);
}
changeShowFaceRect() {
localStorage.setItem(
"memories_showFaceRect",
this.config_showFaceRect ? "1" : "0"
);
setTimeout(() => {
this.$router.go(0); // refresh page
}, 500);
}
}
</script>
<style lang="scss" scoped>
.face-top-matter {
display: flex;
vertical-align: middle;
display: flex;
vertical-align: middle;
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 40px;
padding-left: 3px;
flex-grow: 1;
}
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 40px;
padding-left: 3px;
flex-grow: 1;
}
.right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
.right-actions {
margin-right: 40px;
z-index: 50;
@media (max-width: 768px) {
margin-right: 10px;
}
}
}
</style>

View File

@ -1,60 +1,66 @@
<template>
<NcBreadcrumbs v-if="topMatter">
<NcBreadcrumb title="Home" :to="{ name: 'folders' }">
<template #icon>
<HomeIcon :size="20" />
</template>
</NcBreadcrumb>
<NcBreadcrumb v-for="folder in topMatter.list" :key="folder.path" :title="folder.text"
:to="{ name: 'folders', params: { path: folder.path }}" />
</NcBreadcrumbs>
<NcBreadcrumbs v-if="topMatter">
<NcBreadcrumb title="Home" :to="{ name: 'folders' }">
<template #icon>
<HomeIcon :size="20" />
</template>
</NcBreadcrumb>
<NcBreadcrumb
v-for="folder in topMatter.list"
:key="folder.path"
:title="folder.text"
:to="{ name: 'folders', params: { path: folder.path } }"
/>
</NcBreadcrumbs>
</template>
<script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator';
import { Component, Mixins, Watch } from "vue-property-decorator";
import { TopMatterFolder, TopMatterType } from "../../types";
import { NcBreadcrumbs, NcBreadcrumb } from '@nextcloud/vue';
import GlobalMixin from '../../mixins/GlobalMixin';
import HomeIcon from 'vue-material-design-icons/Home.vue';
import { NcBreadcrumbs, NcBreadcrumb } from "@nextcloud/vue";
import GlobalMixin from "../../mixins/GlobalMixin";
import HomeIcon from "vue-material-design-icons/Home.vue";
@Component({
components: {
NcBreadcrumbs,
NcBreadcrumb,
HomeIcon,
}
components: {
NcBreadcrumbs,
NcBreadcrumb,
HomeIcon,
},
})
export default class FolderTopMatter extends Mixins(GlobalMixin) {
private topMatter?: TopMatterFolder = null;
private topMatter?: TopMatterFolder = null;
@Watch('$route')
async routeChange(from: any, to: any) {
this.createMatter();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.createMatter();
}
mounted() {
this.createMatter();
}
mounted() {
this.createMatter();
}
createMatter() {
if (this.$route.name === 'folders') {
let path: any = this.$route.params.path || '';
if (typeof path === 'string') {
path = path.split('/');
}
createMatter() {
if (this.$route.name === "folders") {
let path: any = this.$route.params.path || "";
if (typeof path === "string") {
path = path.split("/");
}
this.topMatter = {
type: TopMatterType.FOLDER,
list: path.filter(x => x).map((x, idx, arr) => {
return {
text: x,
path: arr.slice(0, idx + 1).join('/'),
}
}),
this.topMatter = {
type: TopMatterType.FOLDER,
list: path
.filter((x) => x)
.map((x, idx, arr) => {
return {
text: x,
path: arr.slice(0, idx + 1).join("/"),
};
} else {
this.topMatter = null;
}
}),
};
} else {
this.topMatter = null;
}
}
}
</script>

View File

@ -1,190 +1,210 @@
<template>
<div class="outer" v-show="years.length > 0">
<div class="inner" ref="inner">
<div v-for="year of years" class="group" :key="year.year" @click="click(year)">
<img class="fill-block"
:src="year.url" />
<div class="outer" v-show="years.length > 0">
<div class="inner" ref="inner">
<div
v-for="year of years"
class="group"
:key="year.year"
@click="click(year)"
>
<img class="fill-block" :src="year.url" />
<div class="overlay">
{{ year.text }}
</div>
</div>
</div>
<div class="left-btn dir-btn" v-if="hasLeft">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move left')"
@click="moveLeft">
{{ t('memories', 'Move left') }}
<template #icon> <LeftMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
</div>
<div class="right-btn dir-btn" v-if="hasRight">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move right')"
@click="moveRight">
{{ t('memories', 'Move right') }}
<template #icon> <RightMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
<div class="overlay">
{{ year.text }}
</div>
</div>
</div>
<div class="left-btn dir-btn" v-if="hasLeft">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move left')"
@click="moveLeft"
>
{{ t("memories", "Move left") }}
<template #icon> <LeftMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
</div>
<div class="right-btn dir-btn" v-if="hasRight">
<NcActions>
<NcActionButton
:aria-label="t('memories', 'Move right')"
@click="moveRight"
>
{{ t("memories", "Move right") }}
<template #icon> <RightMoveIcon :size="28" /> </template>
</NcActionButton>
</NcActions>
</div>
</div>
</template>
<script lang="ts">
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { NcActions, NcActionButton } from '@nextcloud/vue';
import { Component, Emit, Mixins, Prop } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcActions, NcActionButton } from "@nextcloud/vue";
import * as utils from "../../services/Utils";
import * as dav from '../../services/DavRequests';
import * as dav from "../../services/DavRequests";
import { ViewerManager } from "../../services/Viewer";
import { IPhoto } from '../../types';
import { IPhoto } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils";
import LeftMoveIcon from 'vue-material-design-icons/ChevronLeft.vue';
import RightMoveIcon from 'vue-material-design-icons/ChevronRight.vue';
import LeftMoveIcon from "vue-material-design-icons/ChevronLeft.vue";
import RightMoveIcon from "vue-material-design-icons/ChevronRight.vue";
interface IYear {
year: number;
url: string;
preview: IPhoto;
photos: IPhoto[];
text: string;
};
year: number;
url: string;
preview: IPhoto;
photos: IPhoto[];
text: string;
}
@Component({
name: 'OnThisDay',
components: {
NcActions,
NcActionButton,
LeftMoveIcon,
RightMoveIcon,
}
name: "OnThisDay",
components: {
NcActions,
NcActionButton,
LeftMoveIcon,
RightMoveIcon,
},
})
export default class OnThisDay extends Mixins(GlobalMixin) {
private getPreviewUrl = getPreviewUrl;
private getPreviewUrl = getPreviewUrl;
@Emit('load')
onload() {}
@Emit("load")
onload() {}
private years: IYear[] = []
private years: IYear[] = [];
private hasRight = false;
private hasLeft = false;
private scrollStack: number[] = [];
private hasRight = false;
private hasLeft = false;
private scrollStack: number[] = [];
/**
* Nextcloud viewer proxy
* Can't use the timeline instance because these photos
* might not be in view, so can't delete them
*/
@Prop()
private viewerManager!: ViewerManager;
/**
* Nextcloud viewer proxy
* Can't use the timeline instance because these photos
* might not be in view, so can't delete them
*/
@Prop()
private viewerManager!: ViewerManager;
mounted() {
const inner = this.$refs.inner as HTMLElement;
inner.addEventListener('scroll', this.onScroll.bind(this));
mounted() {
const inner = this.$refs.inner as HTMLElement;
inner.addEventListener("scroll", this.onScroll.bind(this));
this.refresh();
this.refresh();
}
async refresh() {
// Look for cache
const dayIdToday = utils.dateToDayId(new Date());
const cacheUrl = `/onthisday/${dayIdToday}`;
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
if (cache) this.process(cache);
// Network request
const photos = await dav.getOnThisDayRaw();
utils.cacheData(cacheUrl, photos);
// Check if exactly same as cache
if (
cache?.length === photos.length &&
cache.every((p, i) => p.fileid === photos[i].fileid)
)
return;
this.process(photos);
}
async process(photos: IPhoto[]) {
this.years = [];
let currentYear = 9999;
for (const photo of photos) {
const dateTaken = utils.dayIdToDate(photo.dayid);
const year = dateTaken.getUTCFullYear();
if (year !== currentYear) {
this.years.push({
year,
url: "",
preview: null,
photos: [],
text: utils.getFromNowStr(dateTaken),
});
currentYear = year;
}
const yearObj = this.years[this.years.length - 1];
yearObj.photos.push(photo);
}
async refresh() {
// Look for cache
const dayIdToday = utils.dateToDayId(new Date());
const cacheUrl = `/onthisday/${dayIdToday}`;
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
if (cache) this.process(cache);
// Network request
const photos = await dav.getOnThisDayRaw();
utils.cacheData(cacheUrl, photos);
// Check if exactly same as cache
if (cache?.length === photos.length &&
cache.every((p, i) => p.fileid === photos[i].fileid)) return;
this.process(photos);
// For each year, randomly choose 10 photos to display
for (const year of this.years) {
year.photos = utils.randomSubarray(year.photos, 10);
}
async process(photos: IPhoto[]) {
this.years = [];
// Choose preview photo
for (const year of this.years) {
// Try to prioritize landscape photos on desktop
if (window.innerWidth <= 600) {
const landscape = year.photos.filter((p) => p.w > p.h);
year.preview = utils.randomChoice(landscape);
}
let currentYear = 9999;
for (const photo of photos) {
const dateTaken = utils.dayIdToDate(photo.dayid);
const year = dateTaken.getUTCFullYear();
if (year !== currentYear) {
this.years.push({
year,
url: '',
preview: null,
photos: [],
text: utils.getFromNowStr(dateTaken),
});
currentYear = year;
}
const yearObj = this.years[this.years.length - 1];
yearObj.photos.push(photo);
}
// For each year, randomly choose 10 photos to display
for (const year of this.years) {
year.photos = utils.randomSubarray(year.photos, 10);
}
// Choose preview photo
for (const year of this.years) {
// Try to prioritize landscape photos on desktop
if (window.innerWidth <= 600) {
const landscape = year.photos.filter(p => p.w > p.h);
year.preview = utils.randomChoice(landscape)
}
// Get random photo
year.preview ||= utils.randomChoice(year.photos);
year.url = getPreviewUrl(year.preview.fileid, year.preview.etag, false, 512);
}
await this.$nextTick();
this.onScroll();
this.onload();
// Get random photo
year.preview ||= utils.randomChoice(year.photos);
year.url = getPreviewUrl(
year.preview.fileid,
year.preview.etag,
false,
512
);
}
moveLeft() {
const inner = this.$refs.inner as HTMLElement;
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
}
await this.$nextTick();
this.onScroll();
this.onload();
}
moveRight() {
const inner = this.$refs.inner as HTMLElement;
const innerRect = inner.getBoundingClientRect();
const nextChild = Array.from(inner.children).map(c => c.getBoundingClientRect()).find((rect) =>
rect.right > innerRect.right
);
moveLeft() {
const inner = this.$refs.inner as HTMLElement;
inner.scrollBy(-(this.scrollStack.pop() || inner.clientWidth), 0);
}
let scroll = nextChild ? (nextChild.left - innerRect.left) : inner.clientWidth;
scroll = Math.min(inner.scrollWidth - inner.scrollLeft - inner.clientWidth, scroll);
this.scrollStack.push(scroll);
inner.scrollBy(scroll, 0);
}
moveRight() {
const inner = this.$refs.inner as HTMLElement;
const innerRect = inner.getBoundingClientRect();
const nextChild = Array.from(inner.children)
.map((c) => c.getBoundingClientRect())
.find((rect) => rect.right > innerRect.right);
onScroll() {
const inner = this.$refs.inner as HTMLElement;
if (!inner) return;
this.hasLeft = inner.scrollLeft > 0;
this.hasRight = (inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20);
}
let scroll = nextChild
? nextChild.left - innerRect.left
: inner.clientWidth;
scroll = Math.min(
inner.scrollWidth - inner.scrollLeft - inner.clientWidth,
scroll
);
this.scrollStack.push(scroll);
inner.scrollBy(scroll, 0);
}
click(year: IYear) {
const allPhotos = this.years.flatMap(y => y.photos);
this.viewerManager.open(year.preview, allPhotos);
}
onScroll() {
const inner = this.$refs.inner as HTMLElement;
if (!inner) return;
this.hasLeft = inner.scrollLeft > 0;
this.hasRight =
inner.clientWidth + inner.scrollLeft < inner.scrollWidth - 20;
}
click(year: IYear) {
const allPhotos = this.years.flatMap((y) => y.photos);
this.viewerManager.open(year.preview, allPhotos);
}
}
</script>
@ -193,97 +213,107 @@ $height: 200px;
$mobHeight: 150px;
.outer {
width: calc(100% - 50px);
height: $height;
overflow: hidden;
position: relative;
padding: 0 calc(28px * 0.6);
width: calc(100% - 50px);
height: $height;
overflow: hidden;
position: relative;
padding: 0 calc(28px * 0.6);
// Sloppy: ideally this should be done in Timeline
// to put a gap between the title and this
margin-top: 10px;
// Sloppy: ideally this should be done in Timeline
// to put a gap between the title and this
margin-top: 10px;
.inner {
height: calc(100% + 20px);
white-space: nowrap;
overflow-x: scroll;
scroll-behavior: smooth;
border-radius: 10px;
}
:deep .dir-btn button {
transform: scale(0.6);
box-shadow: var(--color-main-text) 0 0 3px 0 !important;
background-color: var(--color-main-background) !important;
}
.left-btn {
position: absolute;
top: 50%;
left: 0;
transform: translate(-10%, -50%);
}
.right-btn {
position: absolute;
top: 50%;
right: 0;
transform: translate(10%, -50%);
}
@media (max-width: 768px) {
width: 98%;
padding: 0;
.inner {
height: calc(100% + 20px);
white-space: nowrap;
overflow-x: scroll;
scroll-behavior: smooth;
border-radius: 10px;
padding: 0 8px;
}
:deep .dir-btn button {
transform: scale(0.6);
box-shadow: var(--color-main-text) 0 0 3px 0 !important;
background-color: var(--color-main-background) !important;
}
.left-btn {
position: absolute;
top: 50%; left: 0;
transform: translate(-10%, -50%);
}
.right-btn {
position: absolute;
top: 50%; right: 0;
transform: translate(10%, -50%);
}
@media (max-width: 768px) {
width: 98%;
padding: 0;
.inner { padding: 0 8px; }
.dir-btn { display: none; }
}
@media (max-width: 600px) {
height: $mobHeight;
.dir-btn {
display: none;
}
}
@media (max-width: 600px) {
height: $mobHeight;
}
}
.group {
height: $height;
aspect-ratio: 4/3;
display: inline-block;
position: relative;
cursor: pointer;
height: $height;
aspect-ratio: 4/3;
display: inline-block;
position: relative;
cursor: pointer;
&:not(:last-of-type) { margin-right: 8px; }
&:not(:last-of-type) {
margin-right: 8px;
}
img {
cursor: inherit;
object-fit: cover;
border-radius: 10px;
background-color: var(--color-background-dark);
background-clip: padding-box, content-box;
}
img {
cursor: inherit;
object-fit: cover;
border-radius: 10px;
background-color: var(--color-background-dark);
background-clip: padding-box, content-box;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 10px;
display: flex;
align-items: end;
justify-content: center;
color: white;
font-size: 1.2em;
padding: 5%;
white-space: normal;
cursor: inherit;
transition: background-color 0.2s ease-in-out;
}
&:hover .overlay {
background-color: transparent;
}
@media (max-width: 600px) {
aspect-ratio: 3/4;
height: $mobHeight;
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 10px;
display: flex;
align-items: end;
justify-content: center;
color: white;
font-size: 1.2em;
padding: 5%;
white-space: normal;
cursor: inherit;
transition: background-color 0.2s ease-in-out;
}
&:hover .overlay {
background-color: transparent;
}
@media (max-width: 600px) {
aspect-ratio: 3/4;
height: $mobHeight;
.overlay { font-size: 1.1em; }
font-size: 1.1em;
}
}
}
</style>

View File

@ -1,63 +1,63 @@
<template>
<div v-if="name" class="tag-top-matter">
<NcActions>
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t('memories', 'Back') }}
<template #icon> <BackIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<span class="name">{{ name }}</span>
</div>
<div v-if="name" class="tag-top-matter">
<NcActions>
<NcActionButton :aria-label="t('memories', 'Back')" @click="back()">
{{ t("memories", "Back") }}
<template #icon> <BackIcon :size="20" /> </template>
</NcActionButton>
</NcActions>
<span class="name">{{ name }}</span>
</div>
</template>
<script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin';
import { Component, Mixins, Watch } from "vue-property-decorator";
import GlobalMixin from "../../mixins/GlobalMixin";
import { NcActions, NcActionButton } from '@nextcloud/vue';
import BackIcon from 'vue-material-design-icons/ArrowLeft.vue';
import { NcActions, NcActionButton } from "@nextcloud/vue";
import BackIcon from "vue-material-design-icons/ArrowLeft.vue";
@Component({
components: {
NcActions,
NcActionButton,
BackIcon,
},
components: {
NcActions,
NcActionButton,
BackIcon,
},
})
export default class TagTopMatter extends Mixins(GlobalMixin) {
private name: string = '';
private name: string = "";
@Watch('$route')
async routeChange(from: any, to: any) {
this.createMatter();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.createMatter();
}
mounted() {
this.createMatter();
}
mounted() {
this.createMatter();
}
createMatter() {
this.name = this.$route.params.name || '';
}
createMatter() {
this.name = this.$route.params.name || "";
}
back() {
this.$router.push({ name: 'tags' });
}
back() {
this.$router.push({ name: "tags" });
}
}
</script>
<style lang="scss" scoped>
.tag-top-matter {
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 42px;
display: inline-block;
vertical-align: top;
}
.name {
font-size: 1.3em;
font-weight: 400;
line-height: 42px;
display: inline-block;
vertical-align: top;
}
button {
display: inline-block;
}
button {
display: inline-block;
}
}
</style>

View File

@ -1,53 +1,62 @@
<template>
<div class="top-matter" v-if="type">
<FolderTopMatter v-if="type === 1" />
<TagTopMatter v-else-if="type === 2" />
<FaceTopMatter v-else-if="type === 3" />
<AlbumTopMatter v-else-if="type === 4" />
</div>
<div class="top-matter" v-if="type">
<FolderTopMatter v-if="type === 1" />
<TagTopMatter v-else-if="type === 2" />
<FaceTopMatter v-else-if="type === 3" />
<AlbumTopMatter v-else-if="type === 4" />
</div>
</template>
<script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator';
import { Component, Mixins, Watch } from "vue-property-decorator";
import FolderTopMatter from "./FolderTopMatter.vue";
import TagTopMatter from "./TagTopMatter.vue";
import FaceTopMatter from "./FaceTopMatter.vue";
import AlbumTopMatter from "./AlbumTopMatter.vue";
import GlobalMixin from '../../mixins/GlobalMixin';
import { TopMatterType } from '../../types';
import GlobalMixin from "../../mixins/GlobalMixin";
import { TopMatterType } from "../../types";
@Component({
components: {
FolderTopMatter,
TagTopMatter,
FaceTopMatter,
AlbumTopMatter,
},
components: {
FolderTopMatter,
TagTopMatter,
FaceTopMatter,
AlbumTopMatter,
},
})
export default class TopMatter extends Mixins(GlobalMixin) {
public type: TopMatterType = TopMatterType.NONE;
public type: TopMatterType = TopMatterType.NONE;
@Watch('$route')
async routeChange(from: any, to: any) {
this.setTopMatter();
}
@Watch("$route")
async routeChange(from: any, to: any) {
this.setTopMatter();
}
mounted() {
this.setTopMatter();
}
mounted() {
this.setTopMatter();
}
/** Create top matter */
setTopMatter() {
this.type = (() => {
switch (this.$route.name) {
case 'folders': return TopMatterType.FOLDER;
case 'tags': return this.$route.params.name ? TopMatterType.TAG : TopMatterType.NONE;
case 'people': return this.$route.params.name ? TopMatterType.FACE : TopMatterType.NONE;
case 'albums': return TopMatterType.ALBUM;
default: return TopMatterType.NONE;
}
})();
}
/** Create top matter */
setTopMatter() {
this.type = (() => {
switch (this.$route.name) {
case "folders":
return TopMatterType.FOLDER;
case "tags":
return this.$route.params.name
? TopMatterType.TAG
: TopMatterType.NONE;
case "people":
return this.$route.params.name
? TopMatterType.FACE
: TopMatterType.NONE;
case "albums":
return TopMatterType.ALBUM;
default:
return TopMatterType.NONE;
}
})();
}
}
</script>

View File

@ -19,30 +19,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import 'reflect-metadata'
import Vue from 'vue'
import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import "reflect-metadata";
import Vue from "vue";
import VueVirtualScroller from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import App from './App.vue'
import router from './router'
import App from "./App.vue";
import router from "./router";
Vue.use(VueVirtualScroller)
Vue.use(VueVirtualScroller);
// https://github.com/nextcloud/photos/blob/156f280c0476c483cb9ce81769ccb0c1c6500a4e/src/main.js
// TODO: remove when we have a proper fileinfo standalone library
// original scripts are loaded from
// https://github.com/nextcloud/server/blob/5bf3d1bb384da56adbf205752be8f840aac3b0c5/lib/private/legacy/template.php#L120-L122
window.addEventListener('DOMContentLoaded', () => {
if (!globalThis.OCA.Files) {
globalThis.OCA.Files = {}
}
// register unused client for the sidebar to have access to its parser methods
Object.assign(globalThis.OCA.Files, { App: { fileList: { filesClient: globalThis.OC.Files.getClient() } } }, globalThis.OCA.Files)
})
window.addEventListener("DOMContentLoaded", () => {
if (!globalThis.OCA.Files) {
globalThis.OCA.Files = {};
}
// register unused client for the sidebar to have access to its parser methods
Object.assign(
globalThis.OCA.Files,
{ App: { fileList: { filesClient: globalThis.OC.Files.getClient() } } },
globalThis.OCA.Files
);
});
export default new Vue({
el: '#content',
router,
render: h => h(App),
})
el: "#content",
router,
render: (h) => h(App),
});

View File

@ -1,13 +1,13 @@
import { Component, Vue } from 'vue-property-decorator';
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { constants } from '../services/Utils';
import { Component, Vue } from "vue-property-decorator";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { constants } from "../services/Utils";
@Component
export default class GlobalMixin extends Vue {
public readonly t = t;
public readonly n = n;
public readonly t = t;
public readonly n = n;
public readonly c = constants.c;
public readonly TagDayID = constants.TagDayID;
public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
public readonly c = constants.c;
public readonly TagDayID = constants.TagDayID;
public readonly TagDayIDValueSet = constants.TagDayIDValueSet;
}

View File

@ -20,60 +20,60 @@
*
*/
import { Component, Vue } from 'vue-property-decorator';
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
import { Component, Vue } from "vue-property-decorator";
import { emit, subscribe, unsubscribe } from "@nextcloud/event-bus";
import { generateUrl } from "@nextcloud/router";
import { loadState } from "@nextcloud/initial-state";
import axios from "@nextcloud/axios";
const eventName = 'memories:user-config-changed'
const localSettings = ['squareThumbs', 'showFaceRect'];
const eventName = "memories:user-config-changed";
const localSettings = ["squareThumbs", "showFaceRect"];
@Component
export default class UserConfig extends Vue {
config_timelinePath: string = loadState('memories', 'timelinePath') || '';
config_foldersPath: string = loadState('memories', 'foldersPath') || '/';
config_showHidden = loadState('memories', 'showHidden') === "true";
config_timelinePath: string = loadState("memories", "timelinePath") || "";
config_foldersPath: string = loadState("memories", "foldersPath") || "/";
config_showHidden = loadState("memories", "showHidden") === "true";
config_tagsEnabled = Boolean(loadState('memories', 'systemtags'));
config_recognizeEnabled = Boolean(loadState('memories', 'recognize'));
config_mapsEnabled = Boolean(loadState('memories', 'maps'));
config_albumsEnabled = Boolean(loadState('memories', 'albums'));
config_tagsEnabled = Boolean(loadState("memories", "systemtags"));
config_recognizeEnabled = Boolean(loadState("memories", "recognize"));
config_mapsEnabled = Boolean(loadState("memories", "maps"));
config_albumsEnabled = Boolean(loadState("memories", "albums"));
config_squareThumbs = localStorage.getItem('memories_squareThumbs') === '1';
config_showFaceRect = localStorage.getItem('memories_showFaceRect') === '1';
config_squareThumbs = localStorage.getItem("memories_squareThumbs") === "1";
config_showFaceRect = localStorage.getItem("memories_showFaceRect") === "1";
config_eventName = eventName;
config_eventName = eventName;
created() {
subscribe(eventName, this.updateLocalSetting)
created() {
subscribe(eventName, this.updateLocalSetting);
}
beforeDestroy() {
unsubscribe(eventName, this.updateLocalSetting);
}
updateLocalSetting({ setting, value }) {
this["config_" + setting] = value;
}
async updateSetting(setting: string) {
const value = this["config_" + setting];
if (localSettings.includes(setting)) {
if (typeof value === "boolean") {
localStorage.setItem("memories_" + setting, value ? "1" : "0");
} else {
localStorage.setItem("memories_" + setting, value);
}
} else {
// Long time save setting
await axios.put(generateUrl("apps/memories/api/config/" + setting), {
value: value.toString(),
});
}
beforeDestroy() {
unsubscribe(eventName, this.updateLocalSetting)
}
updateLocalSetting({ setting, value }) {
this['config_' + setting] = value
}
async updateSetting(setting: string) {
const value = this['config_' + setting]
if (localSettings.includes(setting)) {
if (typeof value === 'boolean') {
localStorage.setItem('memories_' + setting, value ? '1' : '0')
} else {
localStorage.setItem('memories_' + setting, value)
}
} else {
// Long time save setting
await axios.put(generateUrl('apps/memories/api/config/' + setting), {
value: value.toString(),
});
}
// Visible elements update setting
emit(eventName, { setting, value });
}
// Visible elements update setting
emit(eventName, { setting, value });
}
}

View File

@ -20,120 +20,120 @@
*
*/
import { generateUrl } from '@nextcloud/router'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Router from 'vue-router'
import Vue from 'vue'
import Timeline from './components/Timeline.vue';
import { generateUrl } from "@nextcloud/router";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import Router from "vue-router";
import Vue from "vue";
import Timeline from "./components/Timeline.vue";
Vue.use(Router)
Vue.use(Router);
/**
* Parse the path of a route : join the elements of the array and return a single string with slashes
* + always lead current path with a slash
*
* @param {string | Array} path path arguments to parse
* @return {string}
*/
const parsePathParams = (path) => {
return `/${Array.isArray(path) ? path.join('/') : path || ''}`
}
/**
* Parse the path of a route : join the elements of the array and return a single string with slashes
* + always lead current path with a slash
*
* @param {string | Array} path path arguments to parse
* @return {string}
*/
const parsePathParams = (path) => {
return `/${Array.isArray(path) ? path.join("/") : path || ""}`;
};
export default new Router({
mode: 'history',
// if index.php is in the url AND we got this far, then it's working:
// let's keep using index.php in the url
base: generateUrl('/apps/memories'),
linkActiveClass: 'active',
routes: [
{
path: '/',
component: Timeline,
name: 'timeline',
props: route => ({
rootTitle: t('memories', 'Timeline'),
}),
},
export default new Router({
mode: "history",
// if index.php is in the url AND we got this far, then it's working:
// let's keep using index.php in the url
base: generateUrl("/apps/memories"),
linkActiveClass: "active",
routes: [
{
path: "/",
component: Timeline,
name: "timeline",
props: (route) => ({
rootTitle: t("memories", "Timeline"),
}),
},
{
path: '/folders/:path*',
component: Timeline,
name: 'folders',
props: route => ({
rootTitle: t('memories', 'Folders'),
}),
},
{
path: "/folders/:path*",
component: Timeline,
name: "folders",
props: (route) => ({
rootTitle: t("memories", "Folders"),
}),
},
{
path: '/favorites',
component: Timeline,
name: 'favorites',
props: route => ({
rootTitle: t('memories', 'Favorites'),
}),
},
{
path: "/favorites",
component: Timeline,
name: "favorites",
props: (route) => ({
rootTitle: t("memories", "Favorites"),
}),
},
{
path: '/videos',
component: Timeline,
name: 'videos',
props: route => ({
rootTitle: t('memories', 'Videos'),
}),
},
{
path: "/videos",
component: Timeline,
name: "videos",
props: (route) => ({
rootTitle: t("memories", "Videos"),
}),
},
{
path: '/albums/:user?/:name?',
component: Timeline,
name: 'albums',
props: route => ({
rootTitle: t('memories', 'Albums'),
}),
},
{
path: "/albums/:user?/:name?",
component: Timeline,
name: "albums",
props: (route) => ({
rootTitle: t("memories", "Albums"),
}),
},
{
path: '/archive',
component: Timeline,
name: 'archive',
props: route => ({
rootTitle: t('memories', 'Archive'),
}),
},
{
path: "/archive",
component: Timeline,
name: "archive",
props: (route) => ({
rootTitle: t("memories", "Archive"),
}),
},
{
path: '/thisday',
component: Timeline,
name: 'thisday',
props: route => ({
rootTitle: t('memories', 'On this day'),
}),
},
{
path: "/thisday",
component: Timeline,
name: "thisday",
props: (route) => ({
rootTitle: t("memories", "On this day"),
}),
},
{
path: '/people/:user?/:name?',
component: Timeline,
name: 'people',
props: route => ({
rootTitle: t('memories', 'People'),
}),
},
{
path: "/people/:user?/:name?",
component: Timeline,
name: "people",
props: (route) => ({
rootTitle: t("memories", "People"),
}),
},
{
path: '/tags/:name*',
component: Timeline,
name: 'tags',
props: route => ({
rootTitle: t('memories', 'Tags'),
}),
},
{
path: "/tags/:name*",
component: Timeline,
name: "tags",
props: (route) => ({
rootTitle: t("memories", "Tags"),
}),
},
{
path: '/maps',
name: 'maps',
// router-link doesn't support external url, let's force the redirect
beforeEnter() {
window.open(generateUrl('/apps/maps'), '_blank')
},
},
],
})
{
path: "/maps",
name: "maps",
// router-link doesn't support external url, let's force the redirect
beforeEnter() {
window.open(generateUrl("/apps/maps"), "_blank");
},
},
],
});

View File

@ -20,26 +20,30 @@
*
*/
import * as webdav from 'webdav'
import axios from '@nextcloud/axios'
import parseUrl from 'url-parse'
import { generateRemoteUrl } from '@nextcloud/router'
import * as webdav from "webdav";
import axios from "@nextcloud/axios";
import parseUrl from "url-parse";
import { generateRemoteUrl } from "@nextcloud/router";
// Monkey business
import * as rq from 'webdav/dist/node/request';
(<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq);
(<any>rq).prepareRequestOptions = (function(requestOptions, context, userOptions) {
requestOptions.method = userOptions.method || requestOptions.method;
return this.prepareRequestOptionsOld(requestOptions, context, userOptions);
}).bind(rq);
// Monkey business
import * as rq from "webdav/dist/node/request";
(<any>rq).prepareRequestOptionsOld = rq.prepareRequestOptions.bind(rq);
(<any>rq).prepareRequestOptions = function (
requestOptions,
context,
userOptions
) {
requestOptions.method = userOptions.method || requestOptions.method;
return this.prepareRequestOptionsOld(requestOptions, context, userOptions);
}.bind(rq);
// force our axios
const patcher = webdav.getPatcher()
patcher.patch('request', axios)
// force our axios
const patcher = webdav.getPatcher();
patcher.patch("request", axios);
// init webdav client on default dav endpoint
const remote = generateRemoteUrl('dav')
const client = webdav.createClient(remote)
// init webdav client on default dav endpoint
const remote = generateRemoteUrl("dav");
const client = webdav.createClient(remote);
export const remotePath = parseUrl(remote).pathname
export default client
export const remotePath = parseUrl(remote).pathname;
export default client;

View File

@ -1,9 +1,9 @@
export * from './dav/base';
export * from './dav/albums';
export * from './dav/archive';
export * from './dav/download';
export * from './dav/face';
export * from './dav/favorites';
export * from './dav/folders';
export * from './dav/onthisday';
export * from './dav/tags';
export * from "./dav/base";
export * from "./dav/albums";
export * from "./dav/archive";
export * from "./dav/download";
export * from "./dav/face";
export * from "./dav/favorites";
export * from "./dav/folders";
export * from "./dav/onthisday";
export * from "./dav/tags";

View File

@ -19,113 +19,136 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import camelcase from 'camelcase'
import { isNumber } from './NumberUtils'
import { generateUrl } from '@nextcloud/router'
import camelcase from "camelcase";
import { isNumber } from "./NumberUtils";
import { generateUrl } from "@nextcloud/router";
/**
* Get an url encoded path
*
* @param {string} path the full path
* @return {string} url encoded file path
*/
const encodeFilePath = function(path) {
const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
let relativePath = ''
pathSections.forEach((section) => {
if (section !== '') {
relativePath += '/' + encodeURIComponent(section)
}
})
return relativePath
}
/**
* Get an url encoded path
*
* @param {string} path the full path
* @return {string} url encoded file path
*/
const encodeFilePath = function (path) {
const pathSections = (path.startsWith("/") ? path : `/${path}`).split("/");
let relativePath = "";
pathSections.forEach((section) => {
if (section !== "") {
relativePath += "/" + encodeURIComponent(section);
}
});
return relativePath;
};
/**
* Extract dir and name from file path
*
* @param {string} path the full path
* @return {string[]} [dirPath, fileName]
*/
const extractFilePaths = function(path) {
const pathSections = path.split('/')
const fileName = pathSections[pathSections.length - 1]
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
return [dirPath, fileName]
}
/**
* Extract dir and name from file path
*
* @param {string} path the full path
* @return {string[]} [dirPath, fileName]
*/
const extractFilePaths = function (path) {
const pathSections = path.split("/");
const fileName = pathSections[pathSections.length - 1];
const dirPath = pathSections.slice(0, pathSections.length - 1).join("/");
return [dirPath, fileName];
};
/**
* Sorting comparison function
*
* @param {object} fileInfo1 file 1 fileinfo
* @param {object} fileInfo2 file 2 fileinfo
* @param {string} key key to sort with
* @param {boolean} [asc=true] sort ascending?
* @return {number}
*/
const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) {
/**
* Sorting comparison function
*
* @param {object} fileInfo1 file 1 fileinfo
* @param {object} fileInfo2 file 2 fileinfo
* @param {string} key key to sort with
* @param {boolean} [asc=true] sort ascending?
* @return {number}
*/
const sortCompare = function (fileInfo1, fileInfo2, key, asc = true) {
// favorite always first
if (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
return -1;
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
return 1;
}
// favorite always first
if (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
return -1
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
return 1
}
// if this is a number, let's sort by integer
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
return asc
? Number(fileInfo2[key]) - Number(fileInfo1[key])
: Number(fileInfo1[key]) - Number(fileInfo2[key]);
}
// if this is a number, let's sort by integer
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
return asc
? Number(fileInfo2[key]) - Number(fileInfo1[key])
: Number(fileInfo1[key]) - Number(fileInfo2[key])
}
// else we sort by string, so let's sort directories first
if (fileInfo1.type !== "file" && fileInfo2.type === "file") {
return asc ? -1 : 1;
} else if (fileInfo1.type === "file" && fileInfo2.type !== "file") {
return asc ? 1 : -1;
}
// else we sort by string, so let's sort directories first
if (fileInfo1.type !== 'file' && fileInfo2.type === 'file') {
return asc ? -1 : 1
} else if (fileInfo1.type === 'file' && fileInfo2.type !== 'file') {
return asc ? 1 : -1
}
// if this is a date, let's sort by date
if (
isNumber(new Date(fileInfo1[key]).getTime()) &&
isNumber(new Date(fileInfo2[key]).getTime())
) {
return asc
? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime()
: new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime();
}
// if this is a date, let's sort by date
if (isNumber(new Date(fileInfo1[key]).getTime()) && isNumber(new Date(fileInfo2[key]).getTime())) {
return asc
? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime()
: new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime()
}
// finally sort by name
return asc
? fileInfo1[key]
?.toString()
?.localeCompare(
fileInfo2[key].toString(),
globalThis.OC.getLanguage()
) || 1
: -fileInfo1[key]
?.toString()
?.localeCompare(
fileInfo2[key].toString(),
globalThis.OC.getLanguage()
) || -1;
};
// finally sort by name
return asc
? fileInfo1[key]?.toString()?.localeCompare(fileInfo2[key].toString(), globalThis.OC.getLanguage()) || 1
: -fileInfo1[key]?.toString()?.localeCompare(fileInfo2[key].toString(), globalThis.OC.getLanguage()) || -1
}
const genFileInfo = function (obj) {
const fileInfo = {};
const genFileInfo = function(obj) {
const fileInfo = {}
Object.keys(obj).forEach((key) => {
const data = obj[key];
Object.keys(obj).forEach(key => {
const data = obj[key]
// flatten object if any
if (!!data && typeof data === "object") {
Object.assign(fileInfo, genFileInfo(data));
} else {
// format key and add it to the fileInfo
if (data === "false") {
fileInfo[camelcase(key)] = false;
} else if (data === "true") {
fileInfo[camelcase(key)] = true;
} else {
fileInfo[camelcase(key)] = isNumber(data) ? Number(data) : data;
}
}
});
return fileInfo;
};
// flatten object if any
if (!!data && typeof data === 'object') {
Object.assign(fileInfo, genFileInfo(data))
} else {
// format key and add it to the fileInfo
if (data === 'false') {
fileInfo[camelcase(key)] = false
} else if (data === 'true') {
fileInfo[camelcase(key)] = true
} else {
fileInfo[camelcase(key)] = isNumber(data)
? Number(data)
: data
}
}
})
return fileInfo
}
const getPreviewUrl = function (
fileid: number,
etag: string,
square: boolean,
size: number
): string {
const a = square ? "0" : "1";
return generateUrl(
`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`
);
};
const getPreviewUrl = function(fileid: number, etag: string, square: boolean, size: number): string {
const a = square ? '0' : '1'
return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=${size}&y=${size}&forceIcon=0&a=${a}`);
}
export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo, getPreviewUrl }
export {
encodeFilePath,
extractFilePaths,
sortCompare,
genFileInfo,
getPreviewUrl,
};

View File

@ -7,221 +7,236 @@ import justifiedLayout from "justified-layout";
* Otherwise, use flickr/justified-layout (at least for now).
*/
export function getLayout(
input: {
width: number,
height: number,
forceSquare: boolean,
}[],
opts: {
rowWidth: number,
rowHeight: number,
squareMode: boolean,
numCols: number,
allowBreakout: boolean,
seed: number,
}
input: {
width: number;
height: number;
forceSquare: boolean;
}[],
opts: {
rowWidth: number;
rowHeight: number;
squareMode: boolean;
numCols: number;
allowBreakout: boolean;
seed: number;
}
): {
top: number,
left: number,
width: number,
height: number,
rowHeight?: number,
top: number;
left: number;
width: number;
height: number;
rowHeight?: number;
}[] {
if (input.length === 0) return [];
if (input.length === 0) return [];
if (!opts.squareMode) {
return justifiedLayout((input), {
containerPadding: 0,
boxSpacing: 0,
containerWidth: opts.rowWidth,
targetRowHeight: opts.rowHeight,
targetRowHeightTolerance: 0.1,
}).boxes;
if (!opts.squareMode) {
return justifiedLayout(input, {
containerPadding: 0,
boxSpacing: 0,
containerWidth: opts.rowWidth,
targetRowHeight: opts.rowHeight,
targetRowHeightTolerance: 0.1,
}).boxes;
}
// RNG
const rand = mulberry32(opts.seed);
// Binary flags
const FLAG_USE = 1 << 0;
const FLAG_USED = 1 << 1;
const FLAG_USE4 = 1 << 2;
const FLAG_USE6 = 1 << 3;
const FLAG_BREAKOUT = 1 << 4;
// Create 2d matrix to work in
const matrix: number[][] = [];
// Fill in the matrix
let row = 0;
let col = 0;
let photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++;
col = 0;
}
// RNG
const rand = mulberry32(opts.seed);
// Binary flags
const FLAG_USE = 1 << 0;
const FLAG_USED = 1 << 1;
const FLAG_USE4 = 1 << 2;
const FLAG_USE6 = 1 << 3;
const FLAG_BREAKOUT = 1 << 4;
// Create 2d matrix to work in
const matrix: number[][] = [];
// Fill in the matrix
let row = 0;
let col = 0;
let photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++; col = 0;
}
// Make sure we have this and the next few rows
while (row + 3 >= matrix.length) {
matrix.push(new Array(opts.numCols).fill(0));
}
// Check if already used
if (matrix[row][col] & FLAG_USED) {
col++; continue;
}
// Use this slot
matrix[row][col] |= FLAG_USE;
// Check if previous row has something used
// or something beside this is used
// We don't do these one after another
if (!opts.allowBreakout ||
(row > 0 && matrix[row-1].some(v => v & FLAG_USED)) ||
(col > 0 && matrix[row][col-1] & FLAG_USED)
) {
photoId++; col++; continue;
}
// Number of photos left
const numLeft = input.length-photoId-1;
// Number of photos needed for perfect fill after using n
const needFill = (n: number) => ((opts.numCols-col-2) + (n/2-1)*(opts.numCols-2));
let canUse4 =
// We have enough space
(row + 1 < matrix.length && col+1 < opts.numCols) &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(4) || numLeft >= needFill(4)+opts.numCols);
let canUse6 =
// Image is portrait
input[photoId].height > input[photoId].width &&
// We have enough space
(row + 2 < matrix.length && col+1 < opts.numCols) &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(6) || numLeft >= needFill(6)+2*opts.numCols);
let canBreakout =
// First column only
col === 0 &&
// Image is landscape
input[photoId].width > input[photoId].height &&
// The next row gets filled
(numLeft === 0 || numLeft >= opts.numCols);
// Probably folders or tags or faces
if (input[photoId].forceSquare) {
// We are square already. Everything below is else-if.
}
// Full width breakout
else if (canBreakout && rand() < (input.length > 0 ? 0.25 : 0.1)) {
matrix[row][col] |= FLAG_BREAKOUT;
for (let i = 1; i < opts.numCols; i++) {
matrix[row][i] |= FLAG_USED;
}
}
// Use 6 vertically
else if (canUse6 && rand() < 0.2) {
matrix[row][col] |= FLAG_USE6;
matrix[row+1][col] |= FLAG_USED;
matrix[row+2][col] |= FLAG_USED;
matrix[row][col+1] |= FLAG_USED;
matrix[row+1][col+1] |= FLAG_USED;
matrix[row+2][col+1] |= FLAG_USED;
}
// Use 4 box
else if (canUse4 && rand() < 0.35) {
matrix[row][col] |= FLAG_USE4;
matrix[row+1][col] |= FLAG_USED;
matrix[row][col+1] |= FLAG_USED;
matrix[row+1][col+1] |= FLAG_USED;
}
// Go ahead
photoId++; col++;
// Make sure we have this and the next few rows
while (row + 3 >= matrix.length) {
matrix.push(new Array(opts.numCols).fill(0));
}
// Square layout matrix
const absMatrix: {
top: number,
left: number,
width: number,
height: number,
}[] = [];
let currTop = 0;
row = 0; col = 0; photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++; col = 0;
currTop += opts.rowHeight;
continue;
}
// Skip if used
if (!(matrix[row][col] & FLAG_USE)) {
col++; continue;
}
// Create basic object
const sqsize = opts.rowHeight;
const p = {
top: currTop,
left: col * sqsize,
width: sqsize,
height: sqsize,
rowHeight: opts.rowHeight,
}
// Use twice the space
const v = matrix[row][col];
if (v & FLAG_USE4) {
p.width *= 2;
p.height *= 2;
col += 2;
} else if (v & FLAG_USE6) {
p.width *= 2;
p.height *= 3;
col += 2;
} else if (v & FLAG_BREAKOUT) {
p.width *= opts.numCols;
p.height = input[photoId].height * p.width / input[photoId].width;
p.rowHeight = p.height;
col += opts.numCols;
} else {
col++;
}
absMatrix.push(p);
photoId++;
// Check if already used
if (matrix[row][col] & FLAG_USED) {
col++;
continue;
}
return absMatrix;
// Use this slot
matrix[row][col] |= FLAG_USE;
// Check if previous row has something used
// or something beside this is used
// We don't do these one after another
if (
!opts.allowBreakout ||
(row > 0 && matrix[row - 1].some((v) => v & FLAG_USED)) ||
(col > 0 && matrix[row][col - 1] & FLAG_USED)
) {
photoId++;
col++;
continue;
}
// Number of photos left
const numLeft = input.length - photoId - 1;
// Number of photos needed for perfect fill after using n
const needFill = (n: number) =>
opts.numCols - col - 2 + (n / 2 - 1) * (opts.numCols - 2);
let canUse4 =
// We have enough space
row + 1 < matrix.length &&
col + 1 < opts.numCols &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(4) || numLeft >= needFill(4) + opts.numCols);
let canUse6 =
// Image is portrait
input[photoId].height > input[photoId].width &&
// We have enough space
row + 2 < matrix.length &&
col + 1 < opts.numCols &&
// This cannot end up being a widow (conservative)
// Also make sure the next row gets fully filled, otherwise looks weird
(numLeft === needFill(6) || numLeft >= needFill(6) + 2 * opts.numCols);
let canBreakout =
// First column only
col === 0 &&
// Image is landscape
input[photoId].width > input[photoId].height &&
// The next row gets filled
(numLeft === 0 || numLeft >= opts.numCols);
// Probably folders or tags or faces
if (input[photoId].forceSquare) {
// We are square already. Everything below is else-if.
}
// Full width breakout
else if (canBreakout && rand() < (input.length > 0 ? 0.25 : 0.1)) {
matrix[row][col] |= FLAG_BREAKOUT;
for (let i = 1; i < opts.numCols; i++) {
matrix[row][i] |= FLAG_USED;
}
}
// Use 6 vertically
else if (canUse6 && rand() < 0.2) {
matrix[row][col] |= FLAG_USE6;
matrix[row + 1][col] |= FLAG_USED;
matrix[row + 2][col] |= FLAG_USED;
matrix[row][col + 1] |= FLAG_USED;
matrix[row + 1][col + 1] |= FLAG_USED;
matrix[row + 2][col + 1] |= FLAG_USED;
}
// Use 4 box
else if (canUse4 && rand() < 0.35) {
matrix[row][col] |= FLAG_USE4;
matrix[row + 1][col] |= FLAG_USED;
matrix[row][col + 1] |= FLAG_USED;
matrix[row + 1][col + 1] |= FLAG_USED;
}
// Go ahead
photoId++;
col++;
}
// Square layout matrix
const absMatrix: {
top: number;
left: number;
width: number;
height: number;
}[] = [];
let currTop = 0;
row = 0;
col = 0;
photoId = 0;
while (photoId < input.length) {
// Check if we reached the end of row
if (col >= opts.numCols) {
row++;
col = 0;
currTop += opts.rowHeight;
continue;
}
// Skip if used
if (!(matrix[row][col] & FLAG_USE)) {
col++;
continue;
}
// Create basic object
const sqsize = opts.rowHeight;
const p = {
top: currTop,
left: col * sqsize,
width: sqsize,
height: sqsize,
rowHeight: opts.rowHeight,
};
// Use twice the space
const v = matrix[row][col];
if (v & FLAG_USE4) {
p.width *= 2;
p.height *= 2;
col += 2;
} else if (v & FLAG_USE6) {
p.width *= 2;
p.height *= 3;
col += 2;
} else if (v & FLAG_BREAKOUT) {
p.width *= opts.numCols;
p.height = (input[photoId].height * p.width) / input[photoId].width;
p.rowHeight = p.height;
col += opts.numCols;
} else {
col++;
}
absMatrix.push(p);
photoId++;
}
return absMatrix;
}
function flagMatrixStr(matrix: number[][], numFlag: number) {
let str = '';
for (let i = 0; i < matrix.length; i++) {
const rstr = matrix[i].map(v => v.toString(2).padStart(numFlag, '0')).join(' ');
str += i.toString().padStart(2) + ' | ' + rstr + '\n';
}
return str;
let str = "";
for (let i = 0; i < matrix.length; i++) {
const rstr = matrix[i]
.map((v) => v.toString(2).padStart(numFlag, "0"))
.join(" ");
str += i.toString().padStart(2) + " | " + rstr + "\n";
}
return str;
}
function mulberry32(a: number) {
return function() {
var t = a += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
return function () {
var t = (a += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

View File

@ -20,11 +20,11 @@
*
*/
const isNumber = function(num: any) {
if (!num) {
return false
}
return Number(num).toString() === num.toString()
}
const isNumber = function (num: any) {
if (!num) {
return false;
}
return Number(num).toString() === num.toString();
};
export { isNumber }
export { isNumber };

View File

@ -1,8 +1,8 @@
import { getCanonicalLocale } from "@nextcloud/l10n";
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { getCurrentUser } from "@nextcloud/auth";
import { loadState } from "@nextcloud/initial-state";
import { IPhoto } from "../types";
import moment from 'moment';
import moment from "moment";
// Memoize the result of short date conversions
// These operations are surprisingly expensive
@ -10,49 +10,54 @@ import moment from 'moment';
const shortDateStrMemo = new Map<number, string>();
/** Get JS date object from dayId */
export function dayIdToDate(dayId: number){
return new Date(dayId*86400*1000);
export function dayIdToDate(dayId: number) {
return new Date(dayId * 86400 * 1000);
}
/** Get Day ID from JS date */
export function dateToDayId(date: Date){
return Math.floor(date.getTime() / (86400*1000));
export function dateToDayId(date: Date) {
return Math.floor(date.getTime() / (86400 * 1000));
}
/** Get month name from number */
export function getShortDateStr(date: Date) {
const dayId = dateToDayId(date);
if (!shortDateStrMemo.has(dayId)) {
shortDateStrMemo.set(dayId,
date.toLocaleDateString(getCanonicalLocale(), {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
}));
}
return shortDateStrMemo.get(dayId);
const dayId = dateToDayId(date);
if (!shortDateStrMemo.has(dayId)) {
shortDateStrMemo.set(
dayId,
date.toLocaleDateString(getCanonicalLocale(), {
month: "short",
year: "numeric",
timeZone: "UTC",
})
);
}
return shortDateStrMemo.get(dayId);
}
/** Get long date string with optional year if same as current */
export function getLongDateStr(date: Date, skipYear=false, time=false) {
return date.toLocaleDateString(getCanonicalLocale(), {
weekday: 'short',
month: 'short',
day: 'numeric',
year: (skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()) ? undefined : 'numeric',
timeZone: 'UTC',
hour: time ? 'numeric' : undefined,
minute: time ? 'numeric' : undefined,
});
export function getLongDateStr(date: Date, skipYear = false, time = false) {
return date.toLocaleDateString(getCanonicalLocale(), {
weekday: "short",
month: "short",
day: "numeric",
year:
skipYear && date.getUTCFullYear() === new Date().getUTCFullYear()
? undefined
: "numeric",
timeZone: "UTC",
hour: time ? "numeric" : undefined,
minute: time ? "numeric" : undefined,
});
}
/** Get text like "5 years ago" from a date */
export function getFromNowStr(date: Date) {
// Get fromNow in correct locale
const text = moment(date).locale(getCanonicalLocale()).fromNow();
// Get fromNow in correct locale
const text = moment(date).locale(getCanonicalLocale()).fromNow();
// Title case
return text.charAt(0).toUpperCase() + text.slice(1);
// Title case
return text.charAt(0).toUpperCase() + text.slice(1);
}
/**
@ -62,13 +67,13 @@ export function getFromNowStr(date: Date) {
* @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
*/
export function hashCode(str: string): number {
let hash = 0;
for (let i = 0, len = str.length; i < len; i++) {
let chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
let hash = 0;
for (let i = 0, len = str.length; i < len; i++) {
let chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
/**
@ -80,27 +85,25 @@ export function hashCode(str: string): number {
* @param key Key to use for comparison
*/
export function binarySearch(arr: any, elem: any, key?: string) {
let minIndex = 0;
let maxIndex = arr.length - 1;
let currentIndex: number;
let currentElement: any;
let minIndex = 0;
let maxIndex = arr.length - 1;
let currentIndex: number;
let currentElement: any;
while (minIndex <= maxIndex) {
currentIndex = (minIndex + maxIndex) / 2 | 0;
currentElement = key ? arr[currentIndex][key] : arr[currentIndex];
while (minIndex <= maxIndex) {
currentIndex = ((minIndex + maxIndex) / 2) | 0;
currentElement = key ? arr[currentIndex][key] : arr[currentIndex];
if (currentElement < elem) {
minIndex = currentIndex + 1;
}
else if (currentElement > elem) {
maxIndex = currentIndex - 1;
}
else {
return currentIndex;
}
if (currentElement < elem) {
minIndex = currentIndex + 1;
} else if (currentElement > elem) {
maxIndex = currentIndex - 1;
} else {
return currentIndex;
}
}
return minIndex;
return minIndex;
}
/**
@ -109,10 +112,10 @@ export function binarySearch(arr: any, elem: any, key?: string) {
* @param places Number of decimal places
* @param floor If true, round down instead of to nearest
*/
export function round(num: number, places: number, floor=false) {
const pow = Math.pow(10, places);
const int = num * pow;
return (floor ? Math.floor : Math.round)(int) / pow;
export function round(num: number, places: number, floor = false) {
const pow = Math.pow(10, places);
const int = num * pow;
return (floor ? Math.floor : Math.round)(int) / pow;
}
/**
@ -120,12 +123,12 @@ export function round(num: number, places: number, floor=false) {
* @param num Number to round
*/
export function roundHalf(num: number) {
return Math.round(num * 2) / 2;
return Math.round(num * 2) / 2;
}
/** Choose a random element from an array */
export function randomChoice(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
return arr[Math.floor(Math.random() * arr.length)];
}
/**
@ -133,15 +136,19 @@ export function randomChoice(arr: any[]) {
* https://stackoverflow.com/a/11935263/4745239
*/
export function randomSubarray(arr: any[], size: number) {
if (arr.length <= size) return arr;
var shuffled = arr.slice(0), i = arr.length, min = i - size, temp, index;
while (i-- > min) {
index = Math.floor((i + 1) * Math.random());
temp = shuffled[index];
shuffled[index] = shuffled[i];
shuffled[i] = temp;
}
return shuffled.slice(min);
if (arr.length <= size) return arr;
var shuffled = arr.slice(0),
i = arr.length,
min = i - size,
temp,
index;
while (i-- > min) {
index = Math.floor((i + 1) * Math.random());
temp = shuffled[index];
shuffled[index] = shuffled[i];
shuffled[i] = temp;
}
return shuffled.slice(min);
}
/**
@ -149,108 +156,114 @@ export function randomSubarray(arr: any[], size: number) {
* @param photo Photo to process
*/
export function convertFlags(photo: IPhoto) {
if (typeof photo.flag === "undefined") {
photo.flag = 0; // flags
}
if (typeof photo.flag === "undefined") {
photo.flag = 0; // flags
}
if (photo.isvideo) {
photo.flag |= constants.c.FLAG_IS_VIDEO;
delete photo.isvideo;
}
if (photo.isfavorite) {
photo.flag |= constants.c.FLAG_IS_FAVORITE;
delete photo.isfavorite;
}
if (photo.isfolder) {
photo.flag |= constants.c.FLAG_IS_FOLDER;
delete photo.isfolder;
}
if (photo.isface) {
photo.flag |= constants.c.FLAG_IS_FACE;
delete photo.isface;
}
if (photo.istag) {
photo.flag |= constants.c.FLAG_IS_TAG;
delete photo.istag;
}
if (photo.isalbum) {
photo.flag |= constants.c.FLAG_IS_ALBUM;
delete photo.isalbum;
}
if (photo.isvideo) {
photo.flag |= constants.c.FLAG_IS_VIDEO;
delete photo.isvideo;
}
if (photo.isfavorite) {
photo.flag |= constants.c.FLAG_IS_FAVORITE;
delete photo.isfavorite;
}
if (photo.isfolder) {
photo.flag |= constants.c.FLAG_IS_FOLDER;
delete photo.isfolder;
}
if (photo.isface) {
photo.flag |= constants.c.FLAG_IS_FACE;
delete photo.isface;
}
if (photo.istag) {
photo.flag |= constants.c.FLAG_IS_TAG;
delete photo.istag;
}
if (photo.isalbum) {
photo.flag |= constants.c.FLAG_IS_ALBUM;
delete photo.isalbum;
}
}
// Outside for set
const TagDayID = {
START: -(1 << 30),
FOLDERS: -(1 << 30) + 1,
TAGS: -(1 << 30) + 2,
FACES: -(1 << 30) + 3,
ALBUMS: -(1 << 30) + 4,
}
START: -(1 << 30),
FOLDERS: -(1 << 30) + 1,
TAGS: -(1 << 30) + 2,
FACES: -(1 << 30) + 3,
ALBUMS: -(1 << 30) + 4,
};
/** Global constants */
export const constants = {
c: {
FLAG_PLACEHOLDER: 1 << 0,
FLAG_LOAD_FAIL: 1 << 1,
FLAG_IS_VIDEO: 1 << 2,
FLAG_IS_FAVORITE: 1 << 3,
FLAG_IS_FOLDER: 1 << 4,
FLAG_IS_TAG: 1 << 5,
FLAG_IS_FACE: 1 << 6,
FLAG_IS_ALBUM: 1 << 7,
FLAG_SELECTED: 1 << 8,
FLAG_LEAVING: 1 << 9,
},
c: {
FLAG_PLACEHOLDER: 1 << 0,
FLAG_LOAD_FAIL: 1 << 1,
FLAG_IS_VIDEO: 1 << 2,
FLAG_IS_FAVORITE: 1 << 3,
FLAG_IS_FOLDER: 1 << 4,
FLAG_IS_TAG: 1 << 5,
FLAG_IS_FACE: 1 << 6,
FLAG_IS_ALBUM: 1 << 7,
FLAG_SELECTED: 1 << 8,
FLAG_LEAVING: 1 << 9,
},
TagDayID: TagDayID,
TagDayIDValueSet: new Set(Object.values(TagDayID)),
}
TagDayID: TagDayID,
TagDayIDValueSet: new Set(Object.values(TagDayID)),
};
/** Cache store */
let staticCache: Cache | null = null;
const cacheName = `memories-${loadState('memories', 'version')}-${getCurrentUser()!.uid}`;
openCache().then((cache) => { staticCache = cache });
const cacheName = `memories-${loadState("memories", "version")}-${
getCurrentUser()!.uid
}`;
openCache().then((cache) => {
staticCache = cache;
});
// Clear all caches except the current one
window.caches?.keys().then((keys) => {
keys.filter((key) => key.startsWith('memories-') && key !== cacheName).forEach((key) => {
window.caches.delete(key);
keys
.filter((key) => key.startsWith("memories-") && key !== cacheName)
.forEach((key) => {
window.caches.delete(key);
});
});
/** Open the cache */
export async function openCache() {
try {
return await window.caches?.open(cacheName);
} catch {
console.warn('Failed to get cache', cacheName);
return null;
}
try {
return await window.caches?.open(cacheName);
} catch {
console.warn("Failed to get cache", cacheName);
return null;
}
}
/** Get data from the cache */
export async function getCachedData<T>(url: string): Promise<T> {
if (!window.caches) return null;
const cache = staticCache || await openCache();
if (!cache) return null;
if (!window.caches) return null;
const cache = staticCache || (await openCache());
if (!cache) return null;
const cachedResponse = await cache.match(url);
if (!cachedResponse || !cachedResponse.ok) return undefined;
return await cachedResponse.json();
const cachedResponse = await cache.match(url);
if (!cachedResponse || !cachedResponse.ok) return undefined;
return await cachedResponse.json();
}
/** Store data in the cache */
export function cacheData(url: string, data: Object) {
if (!window.caches) return;
const str = JSON.stringify(data);
if (!window.caches) return;
const str = JSON.stringify(data);
(async () => {
const cache = staticCache || await openCache();
if (!cache) return;
(async () => {
const cache = staticCache || (await openCache());
if (!cache) return;
const response = new Response(str);
response.headers.set('Content-Type', 'application/json');
await cache.put(url, response);
})();
const response = new Response(str);
response.headers.set("Content-Type", "application/json");
await cache.put(url, response);
})();
}

View File

@ -1,87 +1,88 @@
import { IFileInfo, IPhoto } from "../types";
import { showError } from '@nextcloud/dialogs'
import { subscribe } from '@nextcloud/event-bus';
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { showError } from "@nextcloud/dialogs";
import { subscribe } from "@nextcloud/event-bus";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import * as dav from "./DavRequests";
// Key to store sidebar state
const SIDEBAR_KEY = 'memories:sidebar-open';
const SIDEBAR_KEY = "memories:sidebar-open";
export class ViewerManager {
/** Map from fileid to Photo */
private photoMap = new Map<number, IPhoto>();
/** Map from fileid to Photo */
private photoMap = new Map<number, IPhoto>();
constructor(
ondelete: (photos: IPhoto[]) => void,
private updateLoading: (delta: number) => void,
) {
subscribe('files:file:deleted', ({ fileid }: { fileid: number }) => {
const photo = this.photoMap.get(fileid);
ondelete([photo]);
});
constructor(
ondelete: (photos: IPhoto[]) => void,
private updateLoading: (delta: number) => void
) {
subscribe("files:file:deleted", ({ fileid }: { fileid: number }) => {
const photo = this.photoMap.get(fileid);
ondelete([photo]);
});
}
public async open(photo: IPhoto, list?: IPhoto[]) {
list = list || photo.d?.detail;
if (!list) return;
// Repopulate map
this.photoMap.clear();
for (const p of list) {
this.photoMap.set(p.fileid, p);
}
public async open(photo: IPhoto, list?: IPhoto[]) {
list = list || photo.d?.detail;
if (!list) return;
// Repopulate map
this.photoMap.clear();
for (const p of list) {
this.photoMap.set(p.fileid, p);
}
// Get file infos
let fileInfos: IFileInfo[];
const ids = list.map(p => p.fileid);
try {
this.updateLoading(1);
fileInfos = await dav.getFiles(ids);
} catch (e) {
console.error('Failed to load fileInfos', e);
showError('Failed to load fileInfos');
return;
} finally {
this.updateLoading(-1);
}
if (fileInfos.length === 0) {
return;
}
// Fix sorting of the fileInfos
const itemPositions = {};
for (const [index, id] of ids.entries()) {
itemPositions[id] = index;
}
fileInfos.sort(function (a, b) {
return itemPositions[a.fileid] - itemPositions[b.fileid];
});
// Get this photo in the fileInfos
const fInfo = fileInfos.find(d => Number(d.fileid) === photo.fileid);
if (!fInfo) {
showError(t('memories', 'Cannot find this photo anymore!'));
return;
}
// Open Nextcloud viewer
globalThis.OCA.Viewer.open({
path: fInfo.filename, // path
list: fileInfos, // file list
canLoop: false, // don't loop
onClose: () => { // on viewer close
if (globalThis.OCA.Files.Sidebar.file) {
localStorage.setItem(SIDEBAR_KEY, '1');
} else {
localStorage.removeItem(SIDEBAR_KEY);
}
globalThis.OCA.Files.Sidebar.close();
},
});
// Restore sidebar state
if (localStorage.getItem(SIDEBAR_KEY) === '1') {
globalThis.OCA.Files.Sidebar.open(fInfo.filename);
}
// Get file infos
let fileInfos: IFileInfo[];
const ids = list.map((p) => p.fileid);
try {
this.updateLoading(1);
fileInfos = await dav.getFiles(ids);
} catch (e) {
console.error("Failed to load fileInfos", e);
showError("Failed to load fileInfos");
return;
} finally {
this.updateLoading(-1);
}
if (fileInfos.length === 0) {
return;
}
// Fix sorting of the fileInfos
const itemPositions = {};
for (const [index, id] of ids.entries()) {
itemPositions[id] = index;
}
fileInfos.sort(function (a, b) {
return itemPositions[a.fileid] - itemPositions[b.fileid];
});
// Get this photo in the fileInfos
const fInfo = fileInfos.find((d) => Number(d.fileid) === photo.fileid);
if (!fInfo) {
showError(t("memories", "Cannot find this photo anymore!"));
return;
}
// Open Nextcloud viewer
globalThis.OCA.Viewer.open({
path: fInfo.filename, // path
list: fileInfos, // file list
canLoop: false, // don't loop
onClose: () => {
// on viewer close
if (globalThis.OCA.Files.Sidebar.file) {
localStorage.setItem(SIDEBAR_KEY, "1");
} else {
localStorage.removeItem(SIDEBAR_KEY);
}
globalThis.OCA.Files.Sidebar.close();
},
});
// Restore sidebar state
if (localStorage.getItem(SIDEBAR_KEY) === "1") {
globalThis.OCA.Files.Sidebar.open(fInfo.filename);
}
}
}

View File

@ -1,51 +1,58 @@
import * as base from "./base";
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { IAlbum, IDay, ITag } from '../../types';
import { constants } from '../Utils';
import axios from '@nextcloud/axios'
import client from '../DavClient';
import { getCurrentUser } from "@nextcloud/auth";
import { generateUrl } from "@nextcloud/router";
import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { IAlbum, IDay, ITag } from "../../types";
import { constants } from "../Utils";
import axios from "@nextcloud/axios";
import client from "../DavClient";
/**
* Get DAV path for album
*/
export function getAlbumPath(user: string, name: string) {
// Folder in the dav collection for user
const cuid = getCurrentUser().uid;
if (user === cuid) {
return `/photos/${cuid}/albums/${name}`;
} else {
return `/photos/${cuid}/sharedalbums/${name} (${user})`;
}
export function getAlbumPath(user: string, name: string) {
// Folder in the dav collection for user
const cuid = getCurrentUser().uid;
if (user === cuid) {
return `/photos/${cuid}/albums/${name}`;
} else {
return `/photos/${cuid}/sharedalbums/${name} (${user})`;
}
}
/**
* Get list of albums and convert to Days response
* @param type Type of albums to get; 1 = personal, 2 = shared, 3 = all
*/
export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> {
let data: IAlbum[] = [];
try {
const res = await axios.get<typeof data>(generateUrl(`/apps/memories/api/albums?t=${type}`));
data = res.data;
} catch (e) {
throw e;
}
export async function getAlbumsData(type: "1" | "2" | "3"): Promise<IDay[]> {
let data: IAlbum[] = [];
try {
const res = await axios.get<typeof data>(
generateUrl(`/apps/memories/api/albums?t=${type}`)
);
data = res.data;
} catch (e) {
throw e;
}
// Convert to days response
return [{
dayid: constants.TagDayID.ALBUMS,
count: data.length,
detail: data.map((album) => ({
// Convert to days response
return [
{
dayid: constants.TagDayID.ALBUMS,
count: data.length,
detail: data.map(
(album) =>
({
...album,
fileid: album.album_id,
flag: constants.c.FLAG_IS_TAG & constants.c.FLAG_IS_ALBUM,
istag: true,
isalbum: true,
} as ITag)),
}]
} as ITag)
),
},
];
}
/**
@ -56,34 +63,37 @@ export async function getAlbumsData(type: '1' | '2' | '3'): Promise<IDay[]> {
* @param fileIds List of file IDs to add
* @returns Generator
*/
export async function* addToAlbum(user: string, name: string, fileIds: number[]) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter(f => f));
export async function* addToAlbum(
user: string,
name: string,
fileIds: number[]
) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
const albumPath = getAlbumPath(user, name);
const albumPath = getAlbumPath(user, name);
// Add each file
const calls = fileInfos.map((f) => async () => {
try {
await client.copyFile(
f.originalFilename,
`${albumPath}/${f.basename}`,
)
return f.fileid;
} catch (e) {
if (e.response?.status === 409) {
// File already exists, all good
return f.fileid;
}
// Add each file
const calls = fileInfos.map((f) => async () => {
try {
await client.copyFile(f.originalFilename, `${albumPath}/${f.basename}`);
return f.fileid;
} catch (e) {
if (e.response?.status === 409) {
// File already exists, all good
return f.fileid;
}
showError(t('memories', 'Failed to add {filename} to album.', {
filename: f.filename,
}));
return 0;
}
});
showError(
t("memories", "Failed to add {filename} to album.", {
filename: f.filename,
})
);
return 0;
}
});
yield* base.runInParallel(calls, 10);
yield* base.runInParallel(calls, 10);
}
/**
@ -94,38 +104,46 @@ export async function* addToAlbum(user: string, name: string, fileIds: number[])
* @param fileIds List of file IDs to remove
* @returns Generator
*/
export async function* removeFromAlbum(user: string, name: string, fileIds: number[]) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter(f => f));
export async function* removeFromAlbum(
user: string,
name: string,
fileIds: number[]
) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
// Add each file
const calls = fileInfos.map((f) => async () => {
try {
await client.deleteFile(
`/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`,
)
return f.fileid;
} catch (e) {
showError(t('memories', 'Failed to remove {filename}.', {
filename: f.filename,
}));
return 0;
}
});
// Add each file
const calls = fileInfos.map((f) => async () => {
try {
await client.deleteFile(
`/photos/${user}/albums/${name}/${f.fileid}-${f.basename}`
);
return f.fileid;
} catch (e) {
showError(
t("memories", "Failed to remove {filename}.", {
filename: f.filename,
})
);
return 0;
}
});
yield* base.runInParallel(calls, 10);
yield* base.runInParallel(calls, 10);
}
/**
* Create an album.
*/
export async function createAlbum(albumName: string) {
try {
await client.createDirectory(`/photos/${getCurrentUser()?.uid}/albums/${albumName}`)
} catch (error) {
console.error(error);
showError(t('photos', 'Failed to create {albumName}.', { albumName }))
}
try {
await client.createDirectory(
`/photos/${getCurrentUser()?.uid}/albums/${albumName}`
);
} catch (error) {
console.error(error);
showError(t("photos", "Failed to create {albumName}.", { albumName }));
}
}
/**
@ -137,26 +155,23 @@ export async function createAlbum(albumName: string) {
* @param {object} data.properties - The properties to update.
*/
export async function updateAlbum(album: any, { albumName, properties }: any) {
const stringifiedProperties = Object
.entries(properties)
.map(([name, value]) => {
switch (typeof value) {
case 'string':
return `<nc:${name}>${value}</nc:${name}>`
case 'object':
return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>`
default:
return ''
}
})
.join()
const stringifiedProperties = Object.entries(properties)
.map(([name, value]) => {
switch (typeof value) {
case "string":
return `<nc:${name}>${value}</nc:${name}>`;
case "object":
return `<nc:${name}>${JSON.stringify(value)}</nc:${name}>`;
default:
return "";
}
})
.join();
try {
await client.customRequest(
album.filename,
{
method: 'PROPPATCH',
data: `<?xml version="1.0"?>
try {
await client.customRequest(album.filename, {
method: "PROPPATCH",
data: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns"
@ -167,15 +182,20 @@ export async function updateAlbum(album: any, { albumName, properties }: any) {
</d:prop>
</d:set>
</d:propertyupdate>`,
}
);
});
return album;
} catch (error) {
console.error(error);
showError(t('photos', 'Failed to update properties of {albumName} with {properties}.', { albumName, properties: JSON.stringify(properties) }))
return album
}
return album;
} catch (error) {
console.error(error);
showError(
t(
"photos",
"Failed to update properties of {albumName} with {properties}.",
{ albumName, properties: JSON.stringify(properties) }
)
);
return album;
}
}
/**
@ -184,7 +204,7 @@ export async function updateAlbum(album: any, { albumName, properties }: any) {
* @param name Name of album (or ID)
*/
export async function getAlbum(user: string, name: string, extraProps = {}) {
const req = `<?xml version="1.0"?>
const req = `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns"
@ -198,33 +218,41 @@ export async function getAlbum(user: string, name: string, extraProps = {}) {
${extraProps}
</d:prop>
</d:propfind>`;
let album = await client.stat(`/photos/${user}/albums/${name}`, {
data: req,
details: true,
}) as any;
let album = (await client.stat(`/photos/${user}/albums/${name}`, {
data: req,
details: true,
})) as any;
// Post processing
album = {
...album.data,
...album.data.props,
};
const c = album?.collaborators?.collaborator;
album.collaborators = c ? (Array.isArray(c) ? c : [c]) : [];
return album;
// Post processing
album = {
...album.data,
...album.data.props,
};
const c = album?.collaborators?.collaborator;
album.collaborators = c ? (Array.isArray(c) ? c : [c]) : [];
return album;
}
/** Rename an album */
export async function renameAlbum(album: any, { currentAlbumName, newAlbumName }) {
const newAlbum = { ...album, basename: newAlbumName }
try {
await client.moveFile(
`/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`,
`/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`,
)
return newAlbum
} catch (error) {
console.error(error);
showError(t('photos', 'Failed to rename {currentAlbumName} to {newAlbumName}.', { currentAlbumName, newAlbumName }))
return album
}
export async function renameAlbum(
album: any,
{ currentAlbumName, newAlbumName }
) {
const newAlbum = { ...album, basename: newAlbumName };
try {
await client.moveFile(
`/photos/${getCurrentUser()?.uid}/albums/${currentAlbumName}`,
`/photos/${getCurrentUser()?.uid}/albums/${newAlbumName}`
);
return newAlbum;
} catch (error) {
console.error(error);
showError(
t("photos", "Failed to rename {currentAlbumName} to {newAlbumName}.", {
currentAlbumName,
newAlbumName,
})
);
return album;
}
}

View File

@ -1,8 +1,8 @@
import * as base from './base';
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import * as base from "./base";
import { generateUrl } from "@nextcloud/router";
import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import axios from "@nextcloud/axios";
/**
* Archive or unarchive a single file
@ -10,8 +10,11 @@ import axios from '@nextcloud/axios'
* @param fileid File id
* @param archive Archive or unarchive
*/
export async function archiveFile(fileid: number, archive: boolean) {
return await axios.patch(generateUrl('/apps/memories/api/archive/{fileid}', { fileid }), { archive });
export async function archiveFile(fileid: number, archive: boolean) {
return await axios.patch(
generateUrl("/apps/memories/api/archive/{fileid}", { fileid }),
{ archive }
);
}
/**
@ -21,23 +24,24 @@ import axios from '@nextcloud/axios'
* @param archive Archive or unarchive
* @returns list of file ids that were deleted
*/
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
if (fileIds.length === 0) {
return;
export async function* archiveFilesByIds(fileIds: number[], archive: boolean) {
if (fileIds.length === 0) {
return;
}
// Archive each file
const calls = fileIds.map((id) => async () => {
try {
await archiveFile(id, archive);
return id as number;
} catch (error) {
console.error("Failed to (un)archive", id, error);
const msg =
error?.response?.data?.message || t("memories", "General Failure");
showError(t("memories", "Error: {msg}", { msg }));
return 0;
}
});
// Archive each file
const calls = fileIds.map((id) => async () => {
try {
await archiveFile(id, archive);
return id as number;
} catch (error) {
console.error('Failed to (un)archive', id, error);
const msg = error?.response?.data?.message || t('memories', 'General Failure');
showError(t('memories', 'Error: {msg}', { msg }));
return 0;
}
});
yield* base.runInParallel(calls, 10);
yield* base.runInParallel(calls, 10);
}

View File

@ -1,9 +1,9 @@
import { getCurrentUser } from '@nextcloud/auth';
import { showError } from '@nextcloud/dialogs';
import { translate as t } from '@nextcloud/l10n';
import { IFileInfo } from '../../types';
import client from '../DavClient';
import { genFileInfo } from '../FileUtils';
import { getCurrentUser } from "@nextcloud/auth";
import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n";
import { IFileInfo } from "../../types";
import client from "../DavClient";
import { genFileInfo } from "../FileUtils";
export const props = `
<oc:fileid />
@ -17,18 +17,18 @@ export const props = `
<d:resourcetype />`;
export const IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/heic',
'image/png',
'image/tiff',
'image/gif',
'image/bmp',
'video/mpeg',
'video/webm',
'video/mp4',
'video/quicktime',
'video/x-matroska',
"image/png",
"image/jpeg",
"image/heic",
"image/png",
"image/tiff",
"image/gif",
"image/bmp",
"video/mpeg",
"video/webm",
"video/mp4",
"video/quicktime",
"video/x-matroska",
];
const GET_FILE_CHUNK_SIZE = 50;
@ -38,17 +38,17 @@ const GET_FILE_CHUNK_SIZE = 50;
* @param fileIds list of file ids
* @returns list of file infos
*/
export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> {
// Divide fileIds into chunks of GET_FILE_CHUNK_SIZE
const chunks = [];
for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) {
chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE));
}
export async function getFiles(fileIds: number[]): Promise<IFileInfo[]> {
// Divide fileIds into chunks of GET_FILE_CHUNK_SIZE
const chunks = [];
for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) {
chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE));
}
// Get file infos for each chunk
const fileInfos = await Promise.all(chunks.map(getFilesInternal));
return fileInfos.flat();
}
// Get file infos for each chunk
const fileInfos = await Promise.all(chunks.map(getFilesInternal));
return fileInfos.flat();
}
/**
* Get file infos for list of files given Ids
@ -56,29 +56,33 @@ const GET_FILE_CHUNK_SIZE = 50;
* @returns list of file infos
*/
async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
const prefixPath = `/files/${getCurrentUser()!.uid}`;
const prefixPath = `/files/${getCurrentUser()!.uid}`;
// IMPORTANT: if this isn't there, then a blank
// returns EVERYTHING on the server!
if (fileIds.length === 0) {
return [];
}
// IMPORTANT: if this isn't there, then a blank
// returns EVERYTHING on the server!
if (fileIds.length === 0) {
return [];
}
const filter = fileIds.map(fileId => `
const filter = fileIds
.map(
(fileId) => `
<d:eq>
<d:prop>
<oc:fileid/>
</d:prop>
<d:literal>${fileId}</d:literal>
</d:eq>
`).join('');
`
)
.join("");
const options = {
method: 'SEARCH',
headers: {
'content-Type': 'text/xml',
},
data: `<?xml version="1.0" encoding="UTF-8"?>
const options = {
method: "SEARCH",
headers: {
"content-Type": "text/xml",
},
data: `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns"
@ -103,34 +107,39 @@ async function getFilesInternal(fileIds: number[]): Promise<IFileInfo[]> {
</d:where>
</d:basicsearch>
</d:searchrequest>`,
deep: true,
details: true,
responseType: 'text',
};
deep: true,
details: true,
responseType: "text",
};
let response: any = await client.getDirectoryContents('', options);
return response.data
.map((data: any) => genFileInfo(data))
.map((data: any) => Object.assign({}, data, {
originalFilename: data.filename,
filename: data.filename.replace(prefixPath, '')
}));
let response: any = await client.getDirectoryContents("", options);
return response.data
.map((data: any) => genFileInfo(data))
.map((data: any) =>
Object.assign({}, data, {
originalFilename: data.filename,
filename: data.filename.replace(prefixPath, ""),
})
);
}
/**
* Run promises in parallel, but only n at a time
* @param promises Array of promise generator funnction (async functions)
* @param n Number of promises to run in parallel
*/
export async function* runInParallel<T>(promises: (() => Promise<T>)[], n: number) {
while (promises.length > 0) {
const promisesToRun = promises.splice(0, n);
const resultsForThisBatch = await Promise.all(promisesToRun.map(p => p()));
yield resultsForThisBatch;
}
return;
export async function* runInParallel<T>(
promises: (() => Promise<T>)[],
n: number
) {
while (promises.length > 0) {
const promisesToRun = promises.splice(0, n);
const resultsForThisBatch = await Promise.all(
promisesToRun.map((p) => p())
);
yield resultsForThisBatch;
}
return;
}
/**
@ -139,8 +148,8 @@ export async function* runInParallel<T>(promises: (() => Promise<T>)[], n: numbe
* @param path path to the file
*/
export async function deleteFile(path: string) {
const prefixPath = `/files/${getCurrentUser()!.uid}`;
return await client.deleteFile(`${prefixPath}${path}`);
const prefixPath = `/files/${getCurrentUser()!.uid}`;
return await client.deleteFile(`${prefixPath}${path}`);
}
/**
@ -150,46 +159,34 @@ export async function deleteFile(path: string) {
* @returns list of file ids that were deleted
*/
export async function* deleteFilesByIds(fileIds: number[]) {
const fileIdsSet = new Set(fileIds);
const fileIdsSet = new Set(fileIds);
if (fileIds.length === 0) {
return;
}
if (fileIds.length === 0) {
return;
}
// Get files data
let fileInfos: any[] = [];
// Get files data
let fileInfos: any[] = [];
try {
fileInfos = await getFiles(fileIds.filter((f) => f));
} catch (e) {
console.error("Failed to get file info for files to delete", fileIds, e);
showError(t("memories", "Failed to delete files."));
return;
}
// Delete each file
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try {
fileInfos = await getFiles(fileIds.filter(f => f));
} catch (e) {
console.error('Failed to get file info for files to delete', fileIds, e);
showError(t('memories', 'Failed to delete files.'));
return;
await deleteFile(fileInfo.filename);
return fileInfo.fileid as number;
} catch (error) {
console.error("Failed to delete", fileInfo, error);
showError(t("memories", "Failed to delete {fileName}.", fileInfo));
return 0;
}
});
// Delete each file
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try {
await deleteFile(fileInfo.filename);
return fileInfo.fileid as number;
} catch (error) {
console.error('Failed to delete', fileInfo, error);
showError(t('memories', 'Failed to delete {fileName}.', fileInfo));
return 0;
}
});
yield* runInParallel(calls, 10);
yield* runInParallel(calls, 10);
}

View File

@ -1,5 +1,5 @@
import * as base from './base';
import { generateUrl } from '@nextcloud/router'
import * as base from "./base";
import { generateUrl } from "@nextcloud/router";
/**
* Download a file
@ -7,32 +7,32 @@ import { generateUrl } from '@nextcloud/router'
* @param fileNames - The file's names
*/
export async function downloadFiles(fileNames: string[]): Promise<boolean> {
const randomToken = Math.random().toString(36).substring(2)
const randomToken = Math.random().toString(36).substring(2);
const params = new URLSearchParams()
params.append('files', JSON.stringify(fileNames))
params.append('downloadStartSecret', randomToken)
const params = new URLSearchParams();
params.append("files", JSON.stringify(fileNames));
params.append("downloadStartSecret", randomToken);
const downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`)
const downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`);
window.location.href = `${downloadURL}downloadStartSecret=${randomToken}`
window.location.href = `${downloadURL}downloadStartSecret=${randomToken}`;
return new Promise((resolve) => {
const waitForCookieInterval = setInterval(
() => {
const cookieIsSet = document.cookie
.split(';')
.map(cookie => cookie.split('='))
.findIndex(([cookieName, cookieValue]) => cookieName === 'ocDownloadStarted' && cookieValue === randomToken)
return new Promise((resolve) => {
const waitForCookieInterval = setInterval(() => {
const cookieIsSet = document.cookie
.split(";")
.map((cookie) => cookie.split("="))
.findIndex(
([cookieName, cookieValue]) =>
cookieName === "ocDownloadStarted" && cookieValue === randomToken
);
if (cookieIsSet) {
clearInterval(waitForCookieInterval)
resolve(true)
}
},
50
)
})
if (cookieIsSet) {
clearInterval(waitForCookieInterval);
resolve(true);
}
}, 50);
});
}
/**
@ -40,11 +40,11 @@ export async function downloadFiles(fileNames: string[]): Promise<boolean> {
* @param fileIds list of file ids
*/
export async function downloadFilesByIds(fileIds: number[]) {
if (fileIds.length === 0) {
return;
}
if (fileIds.length === 0) {
return;
}
// Get files to download
const fileInfos = await base.getFiles(fileIds);
await downloadFiles(fileInfos.map(f => f.filename));
// Get files to download
const fileInfos = await base.getFiles(fileIds);
await downloadFiles(fileInfos.map((f) => f.filename));
}

View File

@ -1,44 +1,51 @@
import axios from '@nextcloud/axios';
import { showError } from '@nextcloud/dialogs';
import { translate as t } from '@nextcloud/l10n';
import { generateUrl } from '@nextcloud/router';
import { IDay, IPhoto } from '../../types';
import client from '../DavClient';
import { constants } from '../Utils';
import * as base from './base';
import axios from "@nextcloud/axios";
import { showError } from "@nextcloud/dialogs";
import { translate as t } from "@nextcloud/l10n";
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from "../../types";
import client from "../DavClient";
import { constants } from "../Utils";
import * as base from "./base";
/**
* Get list of tags and convert to Days response
*/
export async function getPeopleData(): Promise<IDay[]> {
// Query for photos
let data: {
id: number;
count: number;
name: string;
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/faces'));
data = res.data;
} catch (e) {
throw e;
}
// Query for photos
let data: {
id: number;
count: number;
name: string;
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(
generateUrl("/apps/memories/api/faces")
);
data = res.data;
} catch (e) {
throw e;
}
// Add flag to previews
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0));
// Add flag to previews
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
// Convert to days response
return [{
dayid: constants.TagDayID.FACES,
count: data.length,
detail: data.map((face) => ({
// Convert to days response
return [
{
dayid: constants.TagDayID.FACES,
count: data.length,
detail: data.map(
(face) =>
({
...face,
fileid: face.id,
istag: true,
isface: true,
} as any)),
}]
} as any)
),
},
];
}
/**
@ -49,23 +56,31 @@ export async function getPeopleData(): Promise<IDay[]> {
* @param fileIds List of file IDs to remove
* @returns Generator
*/
export async function* removeFaceImages(user: string, name: string, fileIds: number[]) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter(f => f));
export async function* removeFaceImages(
user: string,
name: string,
fileIds: number[]
) {
// Get files data
let fileInfos = await base.getFiles(fileIds.filter((f) => f));
// Remove each file
const calls = fileInfos.map((f) => async () => {
try {
await client.deleteFile(`/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`)
return f.fileid;
} catch (e) {
console.error(e)
showError(t('memories', 'Failed to remove {filename} from face.', {
filename: f.filename,
}));
return 0;
}
});
// Remove each file
const calls = fileInfos.map((f) => async () => {
try {
await client.deleteFile(
`/recognize/${user}/faces/${name}/${f.fileid}-${f.basename}`
);
return f.fileid;
} catch (e) {
console.error(e);
showError(
t("memories", "Failed to remove {filename} from face.", {
filename: f.filename,
})
);
return 0;
}
});
yield* base.runInParallel(calls, 10);
yield* base.runInParallel(calls, 10);
}

View File

@ -1,9 +1,9 @@
import * as base from './base';
import { generateUrl } from '@nextcloud/router'
import { encodePath } from '@nextcloud/paths'
import { showError } from '@nextcloud/dialogs'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import * as base from "./base";
import { generateUrl } from "@nextcloud/router";
import { encodePath } from "@nextcloud/paths";
import { showError } from "@nextcloud/dialogs";
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import axios from "@nextcloud/axios";
/**
* Favorite a file
@ -13,22 +13,22 @@ import axios from '@nextcloud/axios'
* @param favoriteState - The new favorite state
*/
export async function favoriteFile(fileName: string, favoriteState: boolean) {
let encodedPath = encodePath(fileName)
while (encodedPath[0] === '/') {
encodedPath = encodedPath.substring(1)
}
let encodedPath = encodePath(fileName);
while (encodedPath[0] === "/") {
encodedPath = encodedPath.substring(1);
}
try {
return axios.post(
`${generateUrl('/apps/files/api/v1/files/')}${encodedPath}`,
{
tags: favoriteState ? ['_$!<Favorite>!$_'] : [],
},
)
} catch (error) {
console.error('Failed to favorite', fileName, error)
showError(t('memories', 'Failed to favorite {fileName}.', { fileName }))
}
try {
return axios.post(
`${generateUrl("/apps/files/api/v1/files/")}${encodedPath}`,
{
tags: favoriteState ? ["_$!<Favorite>!$_"] : [],
}
);
} catch (error) {
console.error("Failed to favorite", fileName, error);
showError(t("memories", "Failed to favorite {fileName}.", { fileName }));
}
}
/**
@ -38,35 +38,38 @@ export async function favoriteFile(fileName: string, favoriteState: boolean) {
* @param favoriteState the new favorite state
* @returns generator of lists of file ids that were state-changed
*/
export async function* favoriteFilesByIds(fileIds: number[], favoriteState: boolean) {
const fileIdsSet = new Set(fileIds);
export async function* favoriteFilesByIds(
fileIds: number[],
favoriteState: boolean
) {
const fileIdsSet = new Set(fileIds);
if (fileIds.length === 0) {
return;
}
if (fileIds.length === 0) {
return;
}
// Get files data
let fileInfos: any[] = [];
// Get files data
let fileInfos: any[] = [];
try {
fileInfos = await base.getFiles(fileIds.filter((f) => f));
} catch (e) {
console.error("Failed to get file info", fileIds, e);
showError(t("memories", "Failed to favorite files."));
return;
}
// Favorite each file
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try {
fileInfos = await base.getFiles(fileIds.filter(f => f));
} catch (e) {
console.error('Failed to get file info', fileIds, e);
showError(t('memories', 'Failed to favorite files.'));
return;
await favoriteFile(fileInfo.filename, favoriteState);
return fileInfo.fileid as number;
} catch (error) {
console.error("Failed to favorite", fileInfo, error);
showError(t("memories", "Failed to favorite {fileName}.", fileInfo));
return 0;
}
});
// Favorite each file
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try {
await favoriteFile(fileInfo.filename, favoriteState);
return fileInfo.fileid as number;
} catch (error) {
console.error('Failed to favorite', fileInfo, error);
showError(t('memories', 'Failed to favorite {fileName}.', fileInfo));
return 0;
}
});
yield* base.runInParallel(calls, 10);
yield* base.runInParallel(calls, 10);
}

View File

@ -1,32 +1,37 @@
import * as base from './base';
import { getCurrentUser } from '@nextcloud/auth'
import { genFileInfo } from '../FileUtils'
import { IFileInfo } from '../../types';
import client from '../DavClient';
import * as base from "./base";
import { getCurrentUser } from "@nextcloud/auth";
import { genFileInfo } from "../FileUtils";
import { IFileInfo } from "../../types";
import client from "../DavClient";
/**
* Get file infos for files in folder path
* @param folderPath Path to folder
* @param limit Max number of files to return
*/
export async function getFolderPreviewFileIds(folderPath: string, limit: number): Promise<IFileInfo[]> {
const prefixPath = `/files/${getCurrentUser()!.uid}`;
export async function getFolderPreviewFileIds(
folderPath: string,
limit: number
): Promise<IFileInfo[]> {
const prefixPath = `/files/${getCurrentUser()!.uid}`;
const filter = base.IMAGE_MIME_TYPES.map(mime => `
const filter = base.IMAGE_MIME_TYPES.map(
(mime) => `
<d:like>
<d:prop>
<d:getcontenttype/>
</d:prop>
<d:literal>${mime}</d:literal>
</d:like>
`).join('');
`
).join("");
const options = {
method: 'SEARCH',
headers: {
'content-Type': 'text/xml',
},
data: `<?xml version="1.0" encoding="UTF-8"?>
const options = {
method: "SEARCH",
headers: {
"content-Type": "text/xml",
},
data: `<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns"
@ -54,16 +59,18 @@ export async function getFolderPreviewFileIds(folderPath: string, limit: number)
</d:limit>
</d:basicsearch>
</d:searchrequest>`,
deep: true,
details: true,
responseType: 'text',
};
deep: true,
details: true,
responseType: "text",
};
let response:any = await client.getDirectoryContents('', options);
return response.data
.map((data: any) => genFileInfo(data))
.map((data: any) => Object.assign({}, data, {
filename: data.filename.replace(prefixPath, ''),
etag: data.etag.replace(/&quot;/g, ''), // remove quotes
}));
let response: any = await client.getDirectoryContents("", options);
return response.data
.map((data: any) => genFileInfo(data))
.map((data: any) =>
Object.assign({}, data, {
filename: data.filename.replace(prefixPath, ""),
etag: data.etag.replace(/&quot;/g, ""), // remove quotes
})
);
}

View File

@ -1,30 +1,32 @@
import { generateUrl } from '@nextcloud/router'
import { IDay, IPhoto } from '../../types';
import axios from '@nextcloud/axios'
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto } from "../../types";
import axios from "@nextcloud/axios";
/**
* Get original onThisDay response.
*/
export async function getOnThisDayRaw() {
const dayIds: number[] = [];
const now = new Date();
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
const dayIds: number[] = [];
const now = new Date();
const nowUTC = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
// Populate dayIds
for (let i = 1; i <= 120; i++) {
// +- 3 days from this day
for (let j = -3; j <= 3; j++) {
const d = new Date(nowUTC);
d.setFullYear(d.getFullYear() - i);
d.setDate(d.getDate() + j);
const dayId = Math.floor(d.getTime() / 1000 / 86400)
dayIds.push(dayId);
}
// Populate dayIds
for (let i = 1; i <= 120; i++) {
// +- 3 days from this day
for (let j = -3; j <= 3; j++) {
const d = new Date(nowUTC);
d.setFullYear(d.getFullYear() - i);
d.setDate(d.getDate() + j);
const dayId = Math.floor(d.getTime() / 1000 / 86400);
dayIds.push(dayId);
}
}
return (await axios.post<IPhoto[]>(generateUrl('/apps/memories/api/days'), {
body_ids: dayIds.join(','),
})).data;
return (
await axios.post<IPhoto[]>(generateUrl("/apps/memories/api/days"), {
body_ids: dayIds.join(","),
})
).data;
}
/**
@ -32,30 +34,30 @@ export async function getOnThisDayRaw() {
* Query for last 120 years; should be enough
*/
export async function getOnThisDayData(): Promise<IDay[]> {
// Query for photos
let data = await getOnThisDayRaw();
// Query for photos
let data = await getOnThisDayRaw();
// Group photos by day
const ans: IDay[] = [];
let prevDayId = Number.MIN_SAFE_INTEGER;
for (const photo of data) {
if (!photo.dayid) continue;
// Group photos by day
const ans: IDay[] = [];
let prevDayId = Number.MIN_SAFE_INTEGER;
for (const photo of data) {
if (!photo.dayid) continue;
// This works because the response is sorted by date taken
if (photo.dayid !== prevDayId) {
ans.push({
dayid: photo.dayid,
count: 0,
detail: [],
});
prevDayId = photo.dayid;
}
// Add to last day
const day = ans[ans.length - 1];
day.detail.push(photo);
day.count++;
// This works because the response is sorted by date taken
if (photo.dayid !== prevDayId) {
ans.push({
dayid: photo.dayid,
count: 0,
detail: [],
});
prevDayId = photo.dayid;
}
return ans;
// Add to last day
const day = ans[ans.length - 1];
day.detail.push(photo);
day.count++;
}
return ans;
}

View File

@ -1,38 +1,45 @@
import { generateUrl } from '@nextcloud/router'
import { IDay, IPhoto, ITag } from '../../types';
import { constants, hashCode } from '../Utils';
import axios from '@nextcloud/axios'
import { generateUrl } from "@nextcloud/router";
import { IDay, IPhoto, ITag } from "../../types";
import { constants, hashCode } from "../Utils";
import axios from "@nextcloud/axios";
/**
* Get list of tags and convert to Days response
*/
export async function getTagsData(): Promise<IDay[]> {
// Query for photos
let data: {
id: number;
count: number;
name: string;
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(generateUrl('/apps/memories/api/tags'));
data = res.data;
} catch (e) {
throw e;
}
// Query for photos
let data: {
id: number;
count: number;
name: string;
previews: IPhoto[];
}[] = [];
try {
const res = await axios.get<typeof data>(
generateUrl("/apps/memories/api/tags")
);
data = res.data;
} catch (e) {
throw e;
}
// Add flag to previews
data.forEach(t => t.previews?.forEach((preview) => preview.flag = 0));
// Add flag to previews
data.forEach((t) => t.previews?.forEach((preview) => (preview.flag = 0)));
// Convert to days response
return [{
dayid: constants.TagDayID.TAGS,
count: data.length,
detail: data.map((tag) => ({
// Convert to days response
return [
{
dayid: constants.TagDayID.TAGS,
count: data.length,
detail: data.map(
(tag) =>
({
...tag,
fileid: hashCode(tag.name),
flag: constants.c.FLAG_IS_TAG,
istag: true,
} as ITag)),
}]
} as ITag)
),
},
];
}

View File

@ -1,201 +1,201 @@
import { VueConstructor } from "vue";
export type IFileInfo = {
/** Database file ID */
fileid: number;
/** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */
filename: string;
/** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */
originalFilename: string;
/** Base name of file e.g. Qx0dq7dvEXA.jpg */
basename: string;
/** Etag identifier */
etag: string;
/** File has preview available */
hasPreview: boolean;
/** File is marked favorite */
favorite: boolean;
/** Vue flags */
flag?: number;
}
/** Database file ID */
fileid: number;
/** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */
filename: string;
/** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */
originalFilename: string;
/** Base name of file e.g. Qx0dq7dvEXA.jpg */
basename: string;
/** Etag identifier */
etag: string;
/** File has preview available */
hasPreview: boolean;
/** File is marked favorite */
favorite: boolean;
/** Vue flags */
flag?: number;
};
export type IDay = {
/** Day ID */
dayid: number;
/** Number of photos in this day */
count: number;
/** Rows in the day */
rows?: IRow[];
/** List of photos for this day */
detail?: IPhoto[];
}
/** Day ID */
dayid: number;
/** Number of photos in this day */
count: number;
/** Rows in the day */
rows?: IRow[];
/** List of photos for this day */
detail?: IPhoto[];
};
export type IPhoto = {
/** Nextcloud ID of file */
fileid: number;
/** Etag from server */
etag?: string;
/** Bit flags */
flag: number;
/** DayID from server */
dayid?: number;
/** Width of full image */
w?: number;
/** Height of full image */
h?: number;
/** Nextcloud ID of file */
fileid: number;
/** Etag from server */
etag?: string;
/** Bit flags */
flag: number;
/** DayID from server */
dayid?: number;
/** Width of full image */
w?: number;
/** Height of full image */
h?: number;
/** Grid display width px */
dispW?: number;
/** Grid display height px */
dispH?: number;
/** Grid display X px */
dispX?: number;
/** Grid display Y px */
dispY?: number;
/** Grid display row id (relative to head) */
dispRowNum?: number;
/** Grid display width px */
dispW?: number;
/** Grid display height px */
dispH?: number;
/** Grid display X px */
dispX?: number;
/** Grid display Y px */
dispY?: number;
/** Grid display row id (relative to head) */
dispRowNum?: number;
/** Reference to day object */
d?: IDay;
/** Reference to day object */
d?: IDay;
/** Face dimensions */
facerect?: IFaceRect;
/** Face dimensions */
facerect?: IFaceRect;
/** Video flag from server */
isvideo?: boolean;
/** Favorite flag from server */
isfavorite?: boolean;
/** Is this a folder */
isfolder?: boolean;
/** Is this a tag */
istag?: boolean;
/** Is this an album */
isalbum?: boolean;
/** Is this a face */
isface?: boolean;
/** Optional datetaken epoch */
datetaken?: number;
}
/** Video flag from server */
isvideo?: boolean;
/** Favorite flag from server */
isfavorite?: boolean;
/** Is this a folder */
isfolder?: boolean;
/** Is this a tag */
istag?: boolean;
/** Is this an album */
isalbum?: boolean;
/** Is this a face */
isface?: boolean;
/** Optional datetaken epoch */
datetaken?: number;
};
export interface IFolder extends IPhoto {
/** Path to folder */
path: string;
/** FileInfos for preview images */
previewFileInfos?: IFileInfo[];
/** Name of folder */
name: string;
/** Path to folder */
path: string;
/** FileInfos for preview images */
previewFileInfos?: IFileInfo[];
/** Name of folder */
name: string;
}
export interface ITag extends IPhoto {
/** Name of tag */
name: string;
/** Number of images in this tag */
count: number;
/** User for face if face */
user_id?: string;
/** Cache of previews */
previews?: IPhoto[];
/** Name of tag */
name: string;
/** Number of images in this tag */
count: number;
/** User for face if face */
user_id?: string;
/** Cache of previews */
previews?: IPhoto[];
}
export interface IAlbum extends ITag {
/** ID of album */
album_id: number;
/** Owner of album */
user: string;
/** Created timestamp */
created: number;
/** Location string */
location: string;
/** File ID of last added photo */
last_added_photo: number;
/** ID of album */
album_id: number;
/** Owner of album */
user: string;
/** Created timestamp */
created: number;
/** Location string */
location: string;
/** File ID of last added photo */
last_added_photo: number;
}
export interface IFaceRect {
w: number;
h: number;
x: number;
y: number;
w: number;
h: number;
x: number;
y: number;
}
export type IRow = {
/** Vue Recycler identifier */
id?: string;
/** Row ID from head */
num: number;
/** Day ID */
dayId: number;
/** Refrence to day object */
day: IDay;
/** Whether this is a head row */
type: IRowType;
/** [Head only] Title of the header */
name?: string;
/** [Head only] Boolean if the entire day is selected */
selected?: boolean;
/** Main list of photo items */
photos?: IPhoto[];
/** Height in px of the row */
size?: number;
/** Count of placeholders to create */
pct?: number;
}
/** Vue Recycler identifier */
id?: string;
/** Row ID from head */
num: number;
/** Day ID */
dayId: number;
/** Refrence to day object */
day: IDay;
/** Whether this is a head row */
type: IRowType;
/** [Head only] Title of the header */
name?: string;
/** [Head only] Boolean if the entire day is selected */
selected?: boolean;
/** Main list of photo items */
photos?: IPhoto[];
/** Height in px of the row */
size?: number;
/** Count of placeholders to create */
pct?: number;
};
export type IHeadRow = IRow & {
type: IRowType.HEAD;
selected: boolean;
super?: string;
}
type: IRowType.HEAD;
selected: boolean;
super?: string;
};
export enum IRowType {
HEAD = 0,
PHOTOS = 1,
FOLDERS = 2,
HEAD = 0,
PHOTOS = 1,
FOLDERS = 2,
}
export type ITick = {
/** Day ID */
dayId: number;
/** Display top position */
topF: number;
/** Display top position (truncated to 1 decimal pt) */
top: number;
/** Y coordinate on recycler */
y: number;
/** Cumulative number of photos before this tick */
count: number;
/** Is a new month */
isMonth: boolean;
/** Text if any (e.g. year) */
text?: string | number;
/** Whether this tick should be shown */
s?: boolean;
/** Key for vue component */
key?: number
}
/** Day ID */
dayId: number;
/** Display top position */
topF: number;
/** Display top position (truncated to 1 decimal pt) */
top: number;
/** Y coordinate on recycler */
y: number;
/** Cumulative number of photos before this tick */
count: number;
/** Is a new month */
isMonth: boolean;
/** Text if any (e.g. year) */
text?: string | number;
/** Whether this tick should be shown */
s?: boolean;
/** Key for vue component */
key?: number;
};
export type TopMatter = {
type: TopMatterType;
}
type: TopMatterType;
};
export enum TopMatterType {
NONE = 0,
FOLDER = 1,
TAG = 2,
FACE = 3,
ALBUM = 4,
NONE = 0,
FOLDER = 1,
TAG = 2,
FACE = 3,
ALBUM = 4,
}
export type TopMatterFolder = TopMatter & {
type: TopMatterType.FOLDER;
list: {
text: string;
path: string;
}[];
}
type: TopMatterType.FOLDER;
list: {
text: string;
path: string;
}[];
};
export type ISelectionAction = {
/** Display text */
name: string;
/** Icon component */
icon: VueConstructor;
/** Action to perform */
callback: (selection: Map<number, IPhoto>) => Promise<void>;
/** Condition to check for including */
if?: (self?: any) => boolean;
}
/** Display text */
name: string;
/** Icon component */
icon: VueConstructor;
/** Action to perform */
callback: (selection: Map<number, IPhoto>) => Promise<void>;
/** Condition to check for including */
if?: (self?: any) => boolean;
};

12
src/vue-shims.d.ts vendored
View File

@ -1,10 +1,10 @@
declare module "*.vue" {
import Vue from "vue"
export default Vue
import Vue from "vue";
export default Vue;
}
declare module '*.svg' {
import Vue, {VueConstructor} from 'vue';
const content: VueConstructor<Vue>;
export default content;
declare module "*.svg" {
import Vue, { VueConstructor } from "vue";
const content: VueConstructor<Vue>;
export default content;
}