diff --git a/src/Admin.vue b/src/Admin.vue index 182d4a6d..2b36deb8 100644 --- a/src/Admin.vue +++ b/src/Admin.vue @@ -641,7 +641,7 @@ export default defineComponent({ loading: 0, - status: null as IStatus, + status: null as IStatus | null, }), mounted() { @@ -680,7 +680,7 @@ export default defineComponent({ } }, - async update(key: string, value = null) { + async update(key: string, value: any = null) { value = value ?? this[key]; const setting = settings[key]; @@ -727,7 +727,7 @@ export default defineComponent({ "This may also cause all photos to be re-indexed!" ); const msg = - (this.status.gis_count ? warnSetup : warnLong) + " " + warnReindex; + (this.status?.gis_count ? warnSetup : warnLong) + " " + warnReindex; if (!confirm(msg)) { event.preventDefault(); event.stopPropagation(); @@ -805,6 +805,8 @@ export default defineComponent({ }, gisStatus() { + if (!this.status) return ""; + if (typeof this.status.gis_type !== "number") { return this.status.gis_type; } @@ -825,7 +827,7 @@ export default defineComponent({ }, gisStatusType() { - return typeof this.status.gis_type !== "number" || + return typeof this.status?.gis_type !== "number" || this.status.gis_type <= 0 ? "error" : "success"; @@ -836,6 +838,8 @@ export default defineComponent({ }, vaapiStatusText(): string { + if (!this.status) return ""; + const dev = "/dev/dri/renderD128"; if (this.status.vaapi_dev === "ok") { return this.t("memories", "VA-API device ({dev}) is readable", { dev }); @@ -855,7 +859,7 @@ export default defineComponent({ }, vaapiStatusType(): string { - return this.status.vaapi_dev === "ok" ? "success" : "error"; + return this.status?.vaapi_dev === "ok" ? "success" : "error"; }, }, }); diff --git a/src/App.vue b/src/App.vue index afcd6139..36bebf09 100644 --- a/src/App.vue +++ b/src/App.vue @@ -89,6 +89,13 @@ import TagsIcon from "vue-material-design-icons/Tag.vue"; import MapIcon from "vue-material-design-icons/Map.vue"; import CogIcon from "vue-material-design-icons/Cog.vue"; +type NavItem = { + name: string; + title: string; + icon: any; + if?: any; +}; + export default defineComponent({ name: "App", components: { @@ -122,7 +129,7 @@ export default defineComponent({ mixins: [UserConfig], data: () => ({ - navItems: [], + navItems: [] as NavItem[], metadataComponent: null as any, settingsOpen: false, }), @@ -133,7 +140,7 @@ export default defineComponent({ return Number(version[0]); }, - recognize(): string | boolean { + recognize(): string | false { if (!this.config_recognizeEnabled) { return false; } @@ -145,7 +152,7 @@ export default defineComponent({ return t("memories", "People"); }, - facerecognition(): string | boolean { + facerecognition(): string | false { if (!this.config_facerecognitionInstalled) { return false; } @@ -189,7 +196,7 @@ export default defineComponent({ const onResize = () => { globalThis.windowInnerWidth = window.innerWidth; globalThis.windowInnerHeight = window.innerHeight; - emit("memories:window:resize", null); + emit("memories:window:resize", {}); }; window.addEventListener("resize", () => { utils.setRenewingTimeout(this, "resizeTimer", onResize, 100); @@ -258,7 +265,7 @@ export default defineComponent({ }, methods: { - navItemsAll() { + navItemsAll(): NavItem[] { return [ { name: "timeline", @@ -289,13 +296,13 @@ export default defineComponent({ { name: "recognize", icon: PeopleIcon, - title: this.recognize, + title: this.recognize || "", if: this.recognize, }, { name: "facerecognition", icon: PeopleIcon, - title: this.facerecognition, + title: this.facerecognition || "", if: this.facerecognition, }, { @@ -334,7 +341,7 @@ export default defineComponent({ }, doRouteChecks() { - if (this.$route.name.endsWith("-share")) { + if (this.$route.name?.endsWith("-share")) { this.putShareToken(this.$route.params.token); } }, diff --git a/src/components/ClusterGrid.vue b/src/components/ClusterGrid.vue index e7250cf6..77a0921c 100644 --- a/src/components/ClusterGrid.vue +++ b/src/components/ClusterGrid.vue @@ -23,7 +23,7 @@ \ No newline at end of file + diff --git a/src/components/modal/FaceEditModal.vue b/src/components/modal/FaceEditModal.vue index aaddbe4b..6d6fe225 100644 --- a/src/components/modal/FaceEditModal.vue +++ b/src/components/modal/FaceEditModal.vue @@ -97,7 +97,7 @@ export default defineComponent({ await dav.renamePeopleFaceRecognition(this.oldName, this.name); } this.$router.push({ - name: this.$route.name, + name: this.$route.name as string, params: { user: this.user, name: this.name }, }); this.close(); diff --git a/src/components/modal/FaceList.vue b/src/components/modal/FaceList.vue index 1068e568..0d27172d 100644 --- a/src/components/modal/FaceList.vue +++ b/src/components/modal/FaceList.vue @@ -48,7 +48,7 @@ export default defineComponent({ user: "", name: "", list: null as ICluster[] | null, - fuse: null as Fuse, + fuse: null as Fuse | null, search: "", }), diff --git a/src/components/modal/FaceMoveModal.vue b/src/components/modal/FaceMoveModal.vue index 795868ae..495bb84e 100644 --- a/src/components/modal/FaceMoveModal.vue +++ b/src/components/modal/FaceMoveModal.vue @@ -130,7 +130,8 @@ export default defineComponent({ } }); for await (const resp of dav.runInParallel(calls, 10)) { - this.moved(resp); + const valid = resp.filter((r): r is IPhoto => r !== undefined); + this.moved(valid); } } catch (error) { console.error(error); diff --git a/src/components/modal/Modal.vue b/src/components/modal/Modal.vue index aa5015c0..48f929e4 100644 --- a/src/components/modal/Modal.vue +++ b/src/components/modal/Modal.vue @@ -46,8 +46,8 @@ export default defineComponent({ data: () => ({ isSidebarShown: false, sidebarWidth: 400, - trapElements: [], - _mutationObserver: null, + trapElements: [] as HTMLElement[], + _mutationObserver: null! as MutationObserver, }), beforeMount() { @@ -87,7 +87,8 @@ export default defineComponent({ * That way we can adjust the focusTrap */ handleBodyMutation(mutations: MutationRecord[]) { - const test = (node: HTMLElement) => + const test = (node: Node): node is HTMLElement => + node instanceof HTMLElement && node?.classList?.contains("v-popper__popper"); mutations.forEach((mutation) => { diff --git a/src/components/modal/NodeShareModal.vue b/src/components/modal/NodeShareModal.vue index ec34894a..a8f33b2a 100644 --- a/src/components/modal/NodeShareModal.vue +++ b/src/components/modal/NodeShareModal.vue @@ -189,7 +189,7 @@ export default defineComponent({ }, getShareLabels(share: IShare): string { - const labels = []; + const labels: string[] = []; if (share.hasPassword) { labels.push(this.t("memories", "Password protected")); } diff --git a/src/components/modal/ShareModal.vue b/src/components/modal/ShareModal.vue index 11a82f47..4a429803 100644 --- a/src/components/modal/ShareModal.vue +++ b/src/components/modal/ShareModal.vue @@ -112,7 +112,7 @@ export default defineComponent({ data: () => { return { - photo: null as IPhoto, + photo: null as IPhoto | null, loading: 0, }; }, @@ -127,7 +127,7 @@ export default defineComponent({ isVideo() { return ( this.photo && - (this.photo.mimetype.startsWith("video/") || + (this.photo.mimetype?.startsWith("video/") || this.photo.flag & this.c.FLAG_IS_VIDEO) ); }, @@ -160,32 +160,32 @@ export default defineComponent({ }, async sharePreview() { - const src = utils.getPreviewUrl(this.photo, false, 2048); + const src = utils.getPreviewUrl(this.photo!, false, 2048); this.shareWithHref(src, true); }, async shareHighRes() { - const fileid = this.photo.fileid; + const fileid = this.photo!.fileid; const src = this.isVideo ? API.VIDEO_TRANSCODE(fileid, "max.mov") - : API.IMAGE_DECODABLE(fileid, this.photo.etag); + : API.IMAGE_DECODABLE(fileid, this.photo!.etag); this.shareWithHref(src, !this.isVideo); }, async shareOriginal() { - this.shareWithHref(dav.getDownloadLink(this.photo)); + this.shareWithHref(dav.getDownloadLink(this.photo!)); }, async shareLink() { this.l(async () => { - const fileInfo = (await dav.getFiles([this.photo]))[0]; + const fileInfo = (await dav.getFiles([this.photo!]))[0]; globalThis.shareNodeLink(fileInfo.filename, true); }); this.close(); }, async shareWithHref(href: string, replaceExt = false) { - let blob: Blob; + let blob: Blob | undefined; await this.l(async () => { const res = await axios.get(href, { responseType: "blob" }); blob = res.data; @@ -196,11 +196,11 @@ export default defineComponent({ return; } - let basename = this.photo.basename; + let basename = this.photo?.basename ?? "blank"; if (replaceExt) { // Fix basename extension - let targetExts = []; + let targetExts: string[] = []; if (blob.type === "image/png") { targetExts = ["png"]; } else { @@ -208,7 +208,8 @@ export default defineComponent({ } // Append extension if not found - if (!targetExts.includes(basename.split(".").pop().toLowerCase())) { + const baseExt = basename.split(".").pop()?.toLowerCase() ?? ""; + if (!targetExts.includes(baseExt)) { basename += "." + targetExts[0]; } } diff --git a/src/components/top-matter/ClusterTopMatter.vue b/src/components/top-matter/ClusterTopMatter.vue index 23b30054..a57b47cb 100644 --- a/src/components/top-matter/ClusterTopMatter.vue +++ b/src/components/top-matter/ClusterTopMatter.vue @@ -46,7 +46,7 @@ export default defineComponent({ methods: { back() { - this.$router.push({ name: this.$route.name }); + this.$router.push({ name: this.$route.name as string }); }, }, }); diff --git a/src/components/top-matter/FaceTopMatter.vue b/src/components/top-matter/FaceTopMatter.vue index f6410868..b77c3fc3 100644 --- a/src/components/top-matter/FaceTopMatter.vue +++ b/src/components/top-matter/FaceTopMatter.vue @@ -13,7 +13,7 @@ {{ t("memories", "Rename person") }} @@ -21,7 +21,7 @@ {{ t("memories", "Merge with different person") }} @@ -36,7 +36,7 @@ {{ t("memories", "Remove person") }} @@ -104,7 +104,7 @@ export default defineComponent({ }, back() { - this.$router.push({ name: this.$route.name }); + this.$router.push({ name: this.$route.name as string }); }, changeShowFaceRect() { diff --git a/src/components/top-matter/MapSplitMatter.vue b/src/components/top-matter/MapSplitMatter.vue index 017042bf..bb5b3a13 100644 --- a/src/components/top-matter/MapSplitMatter.vue +++ b/src/components/top-matter/MapSplitMatter.vue @@ -65,10 +65,10 @@ const OSM_ATTRIBUTION = const CLUSTER_TRANSITION_TIME = 300; type IMarkerCluster = { - id?: number; + id: number; center: [number, number]; count: number; - preview?: IPhoto; + preview: IPhoto; dummy?: boolean; }; diff --git a/src/components/top-matter/OnThisDay.vue b/src/components/top-matter/OnThisDay.vue index d37cc869..51e14804 100644 --- a/src/components/top-matter/OnThisDay.vue +++ b/src/components/top-matter/OnThisDay.vue @@ -82,7 +82,7 @@ export default defineComponent({ hasRight: false, hasLeft: false, scrollStack: [] as number[], - resizeObserver: null as ResizeObserver, + resizeObserver: null! as ResizeObserver, }), mounted() { @@ -146,7 +146,7 @@ export default defineComponent({ year, text, url: "", - preview: null, + preview: null!, photos: [], }); currentText = text; @@ -167,7 +167,7 @@ export default defineComponent({ for (const year of this.years) { // Try to prioritize landscape photos on desktop if (globalThis.windowInnerWidth <= 600) { - const landscape = year.photos.filter((p) => p.w > p.h); + const landscape = year.photos.filter((p) => (p.w ?? 0) > (p.h ?? 0)); year.preview = utils.randomChoice(landscape); } @@ -214,7 +214,7 @@ export default defineComponent({ click(year: IYear) { const allPhotos = this.years.flatMap((y) => y.photos); - this.viewer.openStatic(year.preview, allPhotos, 512); + this.viewer?.openStatic(year.preview, allPhotos, 512); }, }, }); diff --git a/src/components/viewer/ImageEditor.vue b/src/components/viewer/ImageEditor.vue index 23ae89da..c73c6cf2 100644 --- a/src/components/viewer/ImageEditor.vue +++ b/src/components/viewer/ImageEditor.vue @@ -45,8 +45,8 @@ export default defineComponent({ }, data: () => ({ - exif: null as any, - imageEditor: null as FilerobotImageEditor, + exif: null as Object | null, + imageEditor: null as FilerobotImageEditor | null, }), computed: { @@ -116,14 +116,14 @@ export default defineComponent({ }, defaultSavedImageName(): string { - return this.photo.basename; + return this.photo.basename || ""; }, defaultSavedImageType(): "jpeg" | "png" | "webp" { if ( - ["image/jpeg", "image/png", "image/webp"].includes(this.photo.mimetype) + ["image/jpeg", "image/png", "image/webp"].includes(this.photo.mimetype!) ) { - return this.photo.mimetype.split("/")[1] as any; + return this.photo.mimetype!.split("/")[1] as any; } return "jpeg"; }, @@ -169,7 +169,7 @@ export default defineComponent({ methods: { async getImage(): Promise { const img = new Image(); - img.name = this.photo.basename; + img.name = this.defaultSavedImageName; await new Promise(async (resolve) => { img.onload = resolve; @@ -200,11 +200,11 @@ export default defineComponent({ */ async onSave( data: { - name?: string; + name: string; + extension: string; width?: number; height?: number; quality?: number; - extension?: string; fullName?: string; imageBase64?: string; }, diff --git a/src/components/viewer/PsImage.ts b/src/components/viewer/PsImage.ts index 68e2311a..04f5f727 100644 --- a/src/components/viewer/PsImage.ts +++ b/src/components/viewer/PsImage.ts @@ -79,8 +79,8 @@ export default class ImageContentSetup { slideActivate() { const slide = this.lightbox.currSlide; - if (slide.data.highSrcCond === "always") { - this.loadFullImage(slide); + if (slide?.data.highSrcCond === "always") { + this.loadFullImage(slide as PsSlide); } } @@ -97,7 +97,7 @@ export default class ImageContentSetup { img.classList.add("ximg--full"); this.loading++; - this.lightbox.ui.updatePreloaderVisibility(); + this.lightbox.ui?.updatePreloaderVisibility(); fetchImage(slide.data.highSrc) .then((blobSrc) => { @@ -112,7 +112,7 @@ export default class ImageContentSetup { }) .finally(() => { this.loading--; - this.lightbox.ui.updatePreloaderVisibility(); + this.lightbox.ui?.updatePreloaderVisibility(); }); } } diff --git a/src/components/viewer/PsVideo.ts b/src/components/viewer/PsVideo.ts index 51d8fcc3..b8c0e5a0 100644 --- a/src/components/viewer/PsVideo.ts +++ b/src/components/viewer/PsVideo.ts @@ -6,17 +6,19 @@ import { getCurrentUser } from "@nextcloud/auth"; import axios from "@nextcloud/axios"; import { API } from "../../services/API"; -import { PsContent, PsEvent, PsSlide } from "./types"; +import type { PsContent, PsEvent } from "./types"; import Player from "video.js/dist/types/player"; import { QualityLevelList } from "videojs-contrib-quality-levels"; type VideoContent = PsContent & { - videoElement: HTMLVideoElement; - videojs: Player & { - qualityLevels?: () => QualityLevelList; - }; - plyr: globalThis.Plyr; + videoElement: HTMLVideoElement | null; + videojs: + | (Player & { + qualityLevels?: () => QualityLevelList; + }) + | null; + plyr: globalThis.Plyr | null; }; type PsVideoEvent = PsEvent & { @@ -32,7 +34,7 @@ const config_video_default_quality = Number( /** * Check if slide has video content */ -export function isVideoContent(content: PsSlide | PsContent): boolean { +export function isVideoContent(content: any): content is VideoContent { return content?.data?.type === "video"; } @@ -109,12 +111,12 @@ class VideoContentSetup { }); pswp.on("close", () => { - this.destroyVideo(pswp.currSlide.content as VideoContent); + this.destroyVideo(pswp.currSlide?.content as VideoContent); }); // Prevent closing when video fullscreen is active pswp.on("pointerMove", (e) => { - const plyr = (pswp.currSlide.content)?.plyr; + const plyr = (pswp.currSlide?.content)?.plyr; if (plyr?.fullscreen.active) { e.preventDefault(); } @@ -161,7 +163,7 @@ class VideoContentSetup { content.videoElement.setAttribute("playsinline", ""); // Add the video element to the actual container - content.element.appendChild(content.videoElement); + content.element?.appendChild(content.videoElement); // Create hls sources if enabled const sources: { @@ -202,7 +204,7 @@ class VideoContentSetup { let hlsFailed = false; vjs.on("error", () => { - if (vjs.src(undefined).includes("m3u8")) { + if (vjs.src(undefined)?.includes("m3u8")) { hlsFailed = true; console.warn("PsVideo: HLS stream could not be opened."); @@ -245,7 +247,7 @@ class VideoContentSetup { playWithDelay(); }); - content.videojs.qualityLevels()?.on("addqualitylevel", (e) => { + content.videojs.qualityLevels?.()?.on("addqualitylevel", (e) => { if (e.qualityLevel?.label?.includes("max.m3u8")) { // This is the highest quality level // and guaranteed to be the last one @@ -274,6 +276,7 @@ class VideoContentSetup { destroyVideo(content: VideoContent) { if (isVideoContent(content)) { // Destroy videojs + content.videojs?.pause?.(); content.videojs?.dispose?.(); content.videojs = null; @@ -283,9 +286,11 @@ class VideoContentSetup { content.plyr = null; // Clear the video element - const elem: HTMLDivElement = content.element; - while (elem.lastElementChild) { - elem.removeChild(elem.lastElementChild); + if (content.element instanceof HTMLDivElement) { + const elem = content.element; + while (elem.lastElementChild) { + elem.removeChild(elem.lastElementChild); + } } content.videoElement = null; @@ -301,17 +306,17 @@ class VideoContentSetup { if (!content.videoElement) return; // Retain original parent for video element - const origParent = content.videoElement.parentElement; + const origParent = content.videoElement.parentElement!; // Populate quality list - let qualityList = content.videojs?.qualityLevels(); - let qualityNums: number[]; + let qualityList = content.videojs?.qualityLevels?.(); + let qualityNums: number[] | undefined; if (qualityList && qualityList.length >= 1) { const s = new Set(); let hasMax = false; for (let i = 0; i < qualityList?.length; i++) { const { width, height, label } = qualityList[i]; - s.add(Math.min(width, height)); + s.add(Math.min(width!, height!)); if (label?.includes("max.m3u8")) { hasMax = true; @@ -351,10 +356,10 @@ class VideoContentSetup { options: qualityNums, forced: true, onChange: (quality: number) => { - qualityList = content.videojs?.qualityLevels(); + qualityList = content.videojs?.qualityLevels?.(); if (!qualityList || !content.videojs) return; - const isHLS = content.videojs.src(undefined).includes("m3u8"); + const isHLS = content.videojs.src(undefined)?.includes("m3u8"); if (quality === -2) { // Direct playback @@ -378,7 +383,7 @@ class VideoContentSetup { // Enable only the selected quality for (let i = 0; i < qualityList.length; ++i) { const { width, height, label } = qualityList[i]; - const pixels = Math.min(width, height); + const pixels = Math.min(width!, height!); qualityList[i].enabled = !quality || // auto pixels === quality || // exact match @@ -390,40 +395,42 @@ class VideoContentSetup { // Initialize Plyr and custom CSS const plyr = new Plyr(content.videoElement, opts); - plyr.elements.container.style.height = "100%"; - plyr.elements.container.style.width = "100%"; - plyr.elements.container + const container = plyr.elements.container!; + + container.style.height = "100%"; + container.style.width = "100%"; + container .querySelectorAll("button") .forEach((el) => el.classList.add("button-vue")); - plyr.elements.container + container .querySelectorAll("progress") .forEach((el) => el.classList.add("vue")); - plyr.elements.container.style.backgroundColor = "transparent"; - plyr.elements.wrapper.style.backgroundColor = "transparent"; + container.style.backgroundColor = "transparent"; + plyr.elements.wrapper!.style.backgroundColor = "transparent"; // Set the fullscreen element to the container - plyr.elements.fullscreen = content.slide.holderElement; + plyr.elements.fullscreen = content.slide?.holderElement || null; // Done with init content.plyr = plyr; // Wait for animation to end before showing Plyr - plyr.elements.container.style.opacity = "0"; + container.style.opacity = "0"; setTimeout(() => { - plyr.elements.container.style.opacity = "1"; + container.style.opacity = "1"; }, 250); // Restore original parent of video element origParent.appendChild(content.videoElement); // Move plyr to the slide container - content.slide.holderElement.appendChild(plyr.elements.container); + content.slide?.holderElement?.appendChild(container); // Add fullscreen orientation hooks if (screen.orientation?.lock) { // Store the previous orientation // This is because unlocking (at least on Chrome) does // not restore the previous orientation - let previousOrientation: OrientationLockType; + let previousOrientation: OrientationLockType | undefined; // Lock orientation when entering fullscreen plyr.on("enterfullscreen", async (event) => { @@ -461,25 +468,25 @@ class VideoContentSetup { } updateRotation(content: VideoContent, val?: number): boolean { - if (!content.videojs) return; + if (!content.videojs) return false; content.videoElement = content.videojs.el()?.querySelector("video"); - if (!content.videoElement) return; + if (!content.videoElement) return false; const photo = content.data.photo; const exif = photo.imageInfo?.exif; const rotation = val ?? Number(exif?.Rotation || 0); - const shouldRotate = content.videojs?.src(undefined).includes("m3u8"); + const shouldRotate = content.videojs?.src(undefined)?.includes("m3u8"); if (rotation && shouldRotate) { let transform = `rotate(${rotation}deg)`; const hasRotation = rotation === 90 || rotation === 270; if (hasRotation) { - content.videoElement.style.width = content.element.style.height; - content.videoElement.style.height = content.element.style.width; + content.videoElement.style.width = content.element!.style.height; + content.videoElement.style.height = content.element!.style.width; - transform = `translateY(-${content.element.style.width}) ${transform}`; + transform = `translateY(-${content.element!.style.width}) ${transform}`; content.videoElement.style.transformOrigin = "bottom left"; } diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index ff1eeab4..eea95c5f 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -237,7 +237,7 @@ export default defineComponent({ data: () => ({ isOpen: false, - originalTitle: null, + originalTitle: null as string | null, editorOpen: false, editorSrc: "", @@ -301,7 +301,7 @@ export default defineComponent({ /** Route is public */ routeIsPublic(): boolean { - return this.$route.name?.endsWith("-share"); + return this.$route.name?.endsWith("-share") ?? false; }, /** Route is album */ @@ -323,7 +323,7 @@ export default defineComponent({ /** Is the current slide a video */ isVideo(): boolean { - return Boolean(this.currentPhoto?.flag & this.c.FLAG_IS_VIDEO); + return Boolean((this.currentPhoto?.flag ?? 0) & this.c.FLAG_IS_VIDEO); }, /** Is the current slide a live photo */ @@ -354,12 +354,12 @@ export default defineComponent({ /** Show edit buttons */ canEdit(): boolean { - return this.currentPhoto?.imageInfo?.permissions?.includes("U"); + return this.currentPhoto?.imageInfo?.permissions?.includes("U") ?? false; }, /** Show delete button */ canDelete(): boolean { - return this.currentPhoto?.imageInfo?.permissions?.includes("D"); + return this.currentPhoto?.imageInfo?.permissions?.includes("D") ?? false; }, /** Show share button */ @@ -399,7 +399,7 @@ export default defineComponent({ const photo = this.currentPhoto; const isvideo = photo && photo.flag & this.c.FLAG_IS_VIDEO; if (photo && !isvideo && photo.fileid === fileid) { - this.photoswipe.refreshSlideContent(this.currIndex); + this.photoswipe?.refreshSlideContent(this.currIndex); } }, @@ -487,7 +487,7 @@ export default defineComponent({ ) { return; } - _onFocusIn.call(this.photoswipe.keyboard, e); + _onFocusIn.call(this.photoswipe!.keyboard, e); }; // Refresh sidebar on change @@ -556,7 +556,7 @@ export default defineComponent({ // Update vue route for deep linking this.photoswipe.on("slideActivate", (e) => { - this.currIndex = this.photoswipe.currIndex; + this.currIndex = this.photoswipe!.currIndex; const photo = e.slide?.data?.photo; this.setRouteHash(photo); this.updateTitle(photo); @@ -619,15 +619,20 @@ export default defineComponent({ }, /** Open using start photo and rows list */ - async open(anchorPhoto: IPhoto, rows?: IRow[]) { - this.list = [...anchorPhoto.d.detail]; - let startIndex = -1; + async open(anchorPhoto: IPhoto, rows: IRow[]) { + const detail = anchorPhoto.d?.detail; + if (!detail) { + console.error("Attempted to open viewer with no detail list!"); + return; + } + + this.list = [...detail]; + const startIndex = detail.indexOf(anchorPhoto); // Get days list and map for (const r of rows) { if (r.type === IRowType.HEAD) { - if (r.day.dayid == anchorPhoto.d.dayid) { - startIndex = r.day.detail.indexOf(anchorPhoto); + if (r.day.dayid == anchorPhoto.dayid) { this.globalAnchor = this.globalCount; } @@ -638,18 +643,18 @@ export default defineComponent({ } // Create basic viewer - await this.createBase({ + const photoswipe = await this.createBase({ index: this.globalAnchor + startIndex, }); // Lazy-generate item data. // Load the next two days in the timeline. - this.photoswipe.addFilter("itemData", (itemData, index) => { + photoswipe.addFilter("itemData", (itemData, index) => { // Get photo object from list let idx = index - this.globalAnchor; if (idx < 0) { // Load previous day - const firstDayId = this.list[0].d.dayid; + const firstDayId = this.list[0].dayid; const firstDayIdx = utils.binarySearch(this.dayIds, firstDayId); if (firstDayIdx === 0) { // No previous day @@ -657,7 +662,7 @@ export default defineComponent({ } const prevDayId = this.dayIds[firstDayIdx - 1]; const prevDay = this.days.get(prevDayId); - if (!prevDay.detail) { + if (!prevDay?.detail) { console.error("[BUG] No detail for previous day"); return {}; } @@ -665,7 +670,7 @@ export default defineComponent({ this.globalAnchor -= prevDay.count; } else if (idx >= this.list.length) { // Load next day - const lastDayId = this.list[this.list.length - 1].d.dayid; + const lastDayId = this.list[this.list.length - 1].dayid; const lastDayIdx = utils.binarySearch(this.dayIds, lastDayId); if (lastDayIdx === this.dayIds.length - 1) { // No next day @@ -673,7 +678,7 @@ export default defineComponent({ } const nextDayId = this.dayIds[lastDayIdx + 1]; const nextDay = this.days.get(nextDayId); - if (!nextDay.detail) { + if (!nextDay?.detail) { console.error("[BUG] No detail for next day"); return {}; } @@ -689,12 +694,12 @@ export default defineComponent({ } // Preload next and previous 3 days - const dayIdx = utils.binarySearch(this.dayIds, photo.d.dayid); + const dayIdx = utils.binarySearch(this.dayIds, photo.dayid); const preload = (idx: number) => { if ( idx > 0 && idx < this.dayIds.length && - !this.days.get(this.dayIds[idx]).detail + !this.days.get(this.dayIds[idx])?.detail ) { this.fetchDay(this.dayIds[idx]); } @@ -719,13 +724,13 @@ export default defineComponent({ }); // Get the thumbnail image - this.photoswipe.addFilter("thumbEl", (thumbEl, data, index) => { + photoswipe.addFilter("thumbEl", (thumbEl, data, index) => { const photo = this.list[index - this.globalAnchor]; - if (!photo || !photo.w || !photo.h) return thumbEl; - return this.thumbElem(photo) || thumbEl; + if (!photo || !photo.w || !photo.h) return thumbEl as HTMLElement; + return this.thumbElem(photo) ?? (thumbEl as HTMLElement); // bug in PhotoSwipe types }); - this.photoswipe.on("slideActivate", (e) => { + photoswipe.on("slideActivate", (e) => { // Scroll to keep the thumbnail in view const thumb = this.thumbElem(e.slide.data?.photo); if (thumb && this.fullyOpened) { @@ -741,13 +746,13 @@ export default defineComponent({ } // Remove active class from others and add to this one - this.photoswipe.element - .querySelectorAll(".pswp__item") + photoswipe.element + ?.querySelectorAll(".pswp__item") .forEach((el) => el.classList.remove("active")); e.slide.holderElement?.classList.add("active"); }); - this.photoswipe.init(); + photoswipe.init(); }, /** Close the viewer */ @@ -758,14 +763,14 @@ export default defineComponent({ /** Open with a static list of photos */ async openStatic(photo: IPhoto, list: IPhoto[], thumbSize?: number) { this.list = list; - await this.createBase({ + const photoswipe = await this.createBase({ index: list.findIndex((p) => p.fileid === photo.fileid), }); this.globalCount = list.length; this.globalAnchor = 0; - this.photoswipe.addFilter("itemData", (itemData, index) => ({ + photoswipe.addFilter("itemData", (itemData, index) => ({ ...this.getItemData(this.list[index]), msrc: thumbSize ? utils.getPreviewUrl(photo, false, thumbSize) @@ -773,7 +778,7 @@ export default defineComponent({ })); this.isOpen = true; - this.photoswipe.init(); + photoswipe!.init(); }, /** Get base data object */ @@ -849,7 +854,7 @@ export default defineComponent({ if (important.length > 0) return important[0] as HTMLImageElement; // Find element within 500px of the screen top - let elem: HTMLImageElement; + let elem: HTMLImageElement | undefined; elems.forEach((e) => { const rect = e.getBoundingClientRect(); if (rect.top > -500) { @@ -896,7 +901,7 @@ export default defineComponent({ if (!this.canEdit) return; // Prevent editing Live Photos - if (this.currentPhoto.liveid) { + if (this.isLivePhoto) { showError( this.t("memories", "Editing is currently disabled for Live Photos") ); @@ -909,7 +914,7 @@ export default defineComponent({ /** Share the current photo externally */ async shareCurrent() { - globalThis.sharePhoto(this.currentPhoto); + globalThis.sharePhoto(this.currentPhoto!); }, /** Key press events */ @@ -925,7 +930,7 @@ export default defineComponent({ /** Delete this photo and refresh */ async deleteCurrent() { - let idx = this.photoswipe.currIndex - this.globalAnchor; + let idx = this.photoswipe!.currIndex - this.globalAnchor; const photo = this.list[idx]; if (!photo) return; @@ -950,22 +955,24 @@ export default defineComponent({ // If this is the last photo, move to the previous photo first // https://github.com/pulsejet/memories/issues/269 if (idx === this.list.length - 1) { - this.photoswipe.prev(); + this.photoswipe!.prev(); // Some photos might lazy load, so recompute idx for the next element - idx = this.photoswipe.currIndex + 1 - this.globalAnchor; + idx = this.photoswipe!.currIndex + 1 - this.globalAnchor; } this.list.splice(idx, 1); this.globalCount--; for (let i = idx - 3; i <= idx + 3; i++) { - this.photoswipe.refreshSlideContent(i + this.globalAnchor); + this.photoswipe!.refreshSlideContent(i + this.globalAnchor); } }, /** Play the current live photo */ playLivePhoto() { - this.psLivePhoto.onContentActivate(this.photoswipe.currSlide as PsSlide); + this.psLivePhoto?.onContentActivate( + this.photoswipe!.currSlide as PsSlide + ); }, /** Is the current photo a favorite */ @@ -977,7 +984,7 @@ export default defineComponent({ /** Favorite the current photo */ async favoriteCurrent() { - const photo = this.currentPhoto; + const photo = this.currentPhoto!; const val = !this.isFavorite(); try { this.updateLoading(1); @@ -1014,7 +1021,7 @@ export default defineComponent({ /** Open the sidebar */ async openSidebar(photo?: IPhoto) { globalThis.mSidebar.setTab("memories-metadata"); - photo ||= this.currentPhoto; + photo ??= this.currentPhoto!; if (this.routeIsPublic) { globalThis.mSidebar.open(photo.fileid); @@ -1052,7 +1059,7 @@ export default defineComponent({ closeSidebar() { this.hideSidebar(); this.sidebarOpen = false; - this.photoswipe.updateSize(); + this.photoswipe?.updateSize(); }, /** Toggle the sidebar visibility */ @@ -1101,7 +1108,7 @@ export default defineComponent({ // If this is a video, wait for it to finish if (this.isVideo) { // Get active video element - const video: HTMLVideoElement = this.photoswipe?.element?.querySelector( + const video = this.photoswipe?.element?.querySelector( ".pswp__item.active video" ); @@ -1114,7 +1121,7 @@ export default defineComponent({ } } - this.photoswipe.next(); + this.photoswipe?.next(); // no need to set the timer again, since next // calls resetSlideshowTimer anyway }, diff --git a/src/components/viewer/types.ts b/src/components/viewer/types.ts index 831ca7a8..2388ef46 100644 --- a/src/components/viewer/types.ts +++ b/src/components/viewer/types.ts @@ -4,10 +4,15 @@ import { IPhoto } from "../../types"; type PsAugment = { data: _SlideData & { - photo?: IPhoto; + src: string; + msrc: string; + photo: IPhoto; }; }; -export type PsSlide = Slide & PsAugment; +export type PsSlide = Slide & + PsAugment & { + content: PsContent; + }; export type PsContent = Content & PsAugment; export type PsEvent = { content: PsContent; diff --git a/src/main.ts b/src/main.ts index 6530a2d1..c06e55a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,3 @@ -/// - import "reflect-metadata"; import Vue from "vue"; import VueVirtualScroller from "vue-virtual-scroller"; @@ -10,11 +8,11 @@ import GlobalMixin from "./mixins/GlobalMixin"; import App from "./App.vue"; import Admin from "./Admin.vue"; import router from "./router"; -import { Route } from "vue-router"; import { generateFilePath } from "@nextcloud/router"; import { getRequestToken } from "@nextcloud/auth"; -import { IPhoto } from "./types"; +import type { Route } from "vue-router"; +import type { IPhoto } from "./types"; import type PlyrType from "plyr"; import type videojsType from "video.js"; @@ -61,7 +59,7 @@ globalThis.windowInnerWidth = window.innerWidth; globalThis.windowInnerHeight = window.innerHeight; // CSP config for webpack dynamic chunk loading -__webpack_nonce__ = window.btoa(getRequestToken()); +__webpack_nonce__ = window.btoa(getRequestToken() ?? ""); // Correct the root of the app for chunk loading // OC.linkTo matches the apps folders @@ -71,21 +69,17 @@ __webpack_public_path__ = generateFilePath("memories", "", "js/"); // Generate client id for this instance // Does not need to be cryptographically secure -const getClientId = () => +const getClientId = (): string => Math.random().toString(36).substring(2, 15).padEnd(12, "0"); globalThis.videoClientId = getClientId(); -globalThis.videoClientIdPersistent = localStorage.getItem( - "videoClientIdPersistent" +globalThis.videoClientIdPersistent = + localStorage.getItem("videoClientIdPersistent") ?? getClientId(); +localStorage.setItem( + "videoClientIdPersistent", + globalThis.videoClientIdPersistent ); -if (!globalThis.videoClientIdPersistent) { - globalThis.videoClientIdPersistent = getClientId(); - localStorage.setItem( - "videoClientIdPersistent", - globalThis.videoClientIdPersistent - ); -} -Vue.mixin(GlobalMixin); +Vue.mixin(GlobalMixin as any); Vue.use(VueVirtualScroller); Vue.component("XImg", XImg); diff --git a/src/router.ts b/src/router.ts index f48faedc..840c84f4 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ import { generateUrl } from "@nextcloud/router"; -import { translate as t, translatePlural as n } from "@nextcloud/l10n"; +import { translate as t } from "@nextcloud/l10n"; import Router from "vue-router"; import Vue from "vue"; import Timeline from "./components/Timeline.vue"; diff --git a/src/service-worker.js b/src/service-worker.js index 3903a0be..43a08b07 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -1,32 +1,35 @@ import { precacheAndRoute } from "workbox-precaching"; -import { NetworkFirst, CacheFirst, NetworkOnly } from "workbox-strategies"; +import { NetworkFirst, CacheFirst } from "workbox-strategies"; import { registerRoute } from "workbox-routing"; import { ExpirationPlugin } from "workbox-expiration"; precacheAndRoute(self.__WB_MANIFEST); -registerRoute(/^.*\/apps\/memories\/api\/video\/livephoto\/.*/, new CacheFirst({ - cacheName: "livephotos", - plugins: [ - new ExpirationPlugin({ - maxAgeSeconds: 3600 * 24 * 7, // days - maxEntries: 1000, // 1k videos - }), - ], -})); +registerRoute( + /^.*\/apps\/memories\/api\/video\/livephoto\/.*/, + new CacheFirst({ + cacheName: "livephotos", + plugins: [ + new ExpirationPlugin({ + maxAgeSeconds: 3600 * 24 * 7, // days + maxEntries: 1000, // 1k videos + }), + ], + }) +); // Important: Using the NetworkOnly strategy and not registering // a route are NOT equivalent. The NetworkOnly strategy will // strip certain headers such as HTTP-Range, which is required // for proper playback of videos. -const networkOnly = [ - /^.*\/apps\/memories\/api\/.*/, -]; +const networkOnly = [/^.*\/apps\/memories\/api\/.*/]; // Cache pages for same-origin requests only registerRoute( - ({ url }) => url.origin === self.location.origin && !networkOnly.some((regex) => regex.test(url.href)), + ({ url }) => + url.origin === self.location.origin && + !networkOnly.some((regex) => regex.test(url.href)), new NetworkFirst({ cacheName: "pages", plugins: [ diff --git a/src/services/API.ts b/src/services/API.ts index 35837638..2b047db3 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -45,11 +45,11 @@ export class API { if (typeof query === "object") { // Clean up undefined and null - Object.keys(query).forEach((key) => { + for (const key of Object.keys(query)) { if (query[key] === undefined || query[key] === null) { delete query[key]; } - }); + } // Check if nothing in query if (!Object.keys(query).length) return url; @@ -138,7 +138,7 @@ export class API { return gen(`${BASE}/image/set-exif/{id}`, { id }); } - static IMAGE_DECODABLE(id: number, etag: string) { + static IMAGE_DECODABLE(id: number, etag?: string) { return tok(API.Q(gen(`${BASE}/image/decodable/{id}`, { id }), { etag })); } diff --git a/src/services/dav/albums.ts b/src/services/dav/albums.ts index 5ba4a8f4..d095c699 100644 --- a/src/services/dav/albums.ts +++ b/src/services/dav/albums.ts @@ -104,7 +104,7 @@ export async function* removeFromAlbum( } catch (e) { showError( t("memories", "Failed to remove {filename}.", { - filename: f.basename, + filename: f.basename ?? f.fileid, }) ); return 0; diff --git a/src/services/dav/base.ts b/src/services/dav/base.ts index abedf158..1edcea7a 100644 --- a/src/services/dav/base.ts +++ b/src/services/dav/base.ts @@ -60,7 +60,7 @@ export async function getFiles(photos: IPhoto[]): Promise { const fileIds = photos.map((photo) => photo.fileid); // Divide fileIds into chunks of GET_FILE_CHUNK_SIZE - const chunks = []; + const chunks: number[][] = []; for (let i = 0; i < fileIds.length; i += GET_FILE_CHUNK_SIZE) { chunks.push(fileIds.slice(i, i + GET_FILE_CHUNK_SIZE)); } diff --git a/src/services/dav/face.ts b/src/services/dav/face.ts index 31d1e003..c0e6f3aa 100644 --- a/src/services/dav/face.ts +++ b/src/services/dav/face.ts @@ -70,7 +70,7 @@ export async function* removeFaceImages( console.error(e); showError( t("memories", "Failed to remove {filename} from face.", { - filename: f.basename, + filename: f.basename ?? f.fileid, }) ); return 0; diff --git a/src/services/dav/favorites.ts b/src/services/dav/favorites.ts index 5f774c40..4af68260 100644 --- a/src/services/dav/favorites.ts +++ b/src/services/dav/favorites.ts @@ -62,7 +62,7 @@ export async function* favoritePhotos( const calls = fileInfos.map((fileInfo) => async () => { try { await favoriteFile(fileInfo.originalFilename, favoriteState); - const photo = photos.find((p) => p.fileid === fileInfo.fileid); + const photo = photos.find((p) => p.fileid === fileInfo.fileid)!; if (favoriteState) { photo.flag |= utils.constants.c.FLAG_IS_FAVORITE; } else { diff --git a/src/services/dav/onthisday.ts b/src/services/dav/onthisday.ts index 71d162d0..7c69798c 100644 --- a/src/services/dav/onthisday.ts +++ b/src/services/dav/onthisday.ts @@ -57,7 +57,7 @@ export async function getOnThisDayData(): Promise { // Add to last day const day = ans[ans.length - 1]; - day.detail.push(photo); + day.detail!.push(photo); day.count++; } diff --git a/src/services/dav/single-item.ts b/src/services/dav/single-item.ts index 0d3f7652..fc3293a4 100644 --- a/src/services/dav/single-item.ts +++ b/src/services/dav/single-item.ts @@ -1,7 +1,7 @@ import { IDay } from "../../types"; import { loadState } from "@nextcloud/initial-state"; -let singleItem = null; +let singleItem: any; try { singleItem = loadState("memories", "single_item", {}); } catch (e) { diff --git a/src/services/strings.ts b/src/services/strings.ts index 0e5f5dd3..e64b5d1d 100644 --- a/src/services/strings.ts +++ b/src/services/strings.ts @@ -5,7 +5,9 @@ const config_facerecognitionEnabled = Boolean( loadState("memories", "facerecognitionEnabled", "") ); -export function emptyDescription(routeName: string): string { +type RouteNameType = string | null | undefined; + +export function emptyDescription(routeName: RouteNameType): string { switch (routeName) { case "timeline": return t( @@ -45,7 +47,7 @@ export function emptyDescription(routeName: string): string { } } -export function viewName(routeName: string): string { +export function viewName(routeName: RouteNameType): string { switch (routeName) { case "timeline": return t("memories", "Your Timeline"); diff --git a/src/services/utils/algo.ts b/src/services/utils/algo.ts index 332538d7..fc2be7d3 100644 --- a/src/services/utils/algo.ts +++ b/src/services/utils/algo.ts @@ -86,7 +86,7 @@ export function randomSubarray(arr: any[], size: number) { export function setRenewingTimeout( ctx: any, name: string, - callback: () => void | null, + callback: (() => void) | null, delay: number ) { if (ctx[name]) window.clearTimeout(ctx[name]); diff --git a/src/services/utils/cache.ts b/src/services/utils/cache.ts index 1e049d69..4f8ffa72 100644 --- a/src/services/utils/cache.ts +++ b/src/services/utils/cache.ts @@ -35,13 +35,13 @@ export async function openCache() { } /** Get data from the cache */ -export async function getCachedData(url: string): Promise { +export async function getCachedData(url: string): Promise { 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; + if (!cachedResponse || !cachedResponse.ok) return null; return await cachedResponse.json(); } diff --git a/src/types.ts b/src/types.ts index a11c39d5..764c6ad0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,7 @@ export type IFileInfo = { /** Full file name, e.g. /pi/test/Qx0dq7dvEXA.jpg */ filename: string; /** Original file name, e.g. /files/admin/pi/test/Qx0dq7dvEXA.jpg */ - originalFilename?: string; + originalFilename: string; /** Base name of file e.g. Qx0dq7dvEXA.jpg */ basename: string; }; @@ -36,7 +36,7 @@ export type IPhoto = { /** Bit flags */ flag: number; /** DayID from server */ - dayid?: number; + dayid: number; /** Width of full image */ w?: number; /** Height of full image */ @@ -58,7 +58,7 @@ export type IPhoto = { /** Reference to day object */ d?: IDay; /** Reference to exif object */ - imageInfo?: IImageInfo; + imageInfo?: IImageInfo | null; /** Face detection ID */ faceid?: number; @@ -174,7 +174,7 @@ export type IRow = { photos?: IPhoto[]; /** Height in px of the row */ - size?: number; + size: number; /** Count of placeholders to create */ pct?: number; /** Don't remove dom element */ diff --git a/src/vue-globals.d.ts b/src/vue-globals.d.ts index 9483b803..f53ebcff 100644 --- a/src/vue-globals.d.ts +++ b/src/vue-globals.d.ts @@ -1,11 +1,11 @@ -import { constants } from "./services/Utils"; -import { translate as t, translatePlural as n } from "@nextcloud/l10n"; +import { type constants } from "./services/Utils"; +import type { translate, translatePlural } from "@nextcloud/l10n"; declare module "vue" { interface ComponentCustomProperties { // GlobalMixin.ts - t: typeof t; - n: typeof n; + t: typeof translate; + n: typeof translatePlural; c: typeof constants.c; @@ -31,14 +31,8 @@ declare module "vue" { config_albumListSort: 1 | 2; config_eventName: string; - updateSetting(setting: string): Promise; - updateLocalSetting({ - setting, - value, - }: { - setting: string; - value: any; - }): void; + updateSetting: (setting: string) => Promise; + updateLocalSetting: (opts: { setting: string; value: any }) => void; } } diff --git a/src/vue-shims.d.ts b/src/vue-shims.d.ts index aebd6dbd..ebfab37a 100644 --- a/src/vue-shims.d.ts +++ b/src/vue-shims.d.ts @@ -1,5 +1,5 @@ declare module "*.vue" { - import { defineComponent } from "vue"; + import type { defineComponent } from "vue"; const Component: ReturnType; export default Component; } diff --git a/src/worker.ts b/src/worker.ts index 36710f4c..d904d4c6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,9 +1,17 @@ /** Set the receiver function for a worker */ -export function workerExport(handlers: { - [key: string]: (...data: any) => Promise; -}) { +export function workerExport( + handlers: Record Promise> +): void { /** Promise API for web worker */ - self.onmessage = async ({ data }) => { + self.onmessage = async ({ + data, + }: { + data: { + id: number; + name: string; + args: any[]; + }; + }) => { try { const handler = handlers[data.name]; if (!handler) throw new Error(`No handler for type ${data.name}`); @@ -23,22 +31,23 @@ export function workerExport(handlers: { /** Get the CALL function for a worker. Call this only once. */ export function workerImporter(worker: Worker) { - const promises: { [id: string]: any } = {}; + const promises = new Map(); + worker.onmessage = ({ data }: { data: any }) => { const { id, resolve, reject } = data; - if (resolve) promises[id].resolve(resolve); - if (reject) promises[id].reject(reject); - delete promises[id]; + if (resolve) promises.get(id)?.resolve(resolve); + if (reject) promises.get(id)?.reject(reject); + promises.delete(id); }; - return function importer Promise>( - name: string - ): (...args: Parameters) => ReturnType { - return function fun(...args: any) { - return new Promise((resolve, reject) => { + + type PromiseFun = (...args: any) => Promise; + return function importer(name: string) { + return async function fun(...args: Parameters) { + return await new Promise>>((resolve, reject) => { const id = Math.random(); - promises[id] = { resolve, reject }; + promises.set(id, { resolve, reject }); worker.postMessage({ id, name, args }); }); - } as any; + }; }; } diff --git a/tsconfig.json b/tsconfig.json index 0477ade7..9d6e063f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "jsx": "preserve", "useDefineForClassFields": true, "noImplicitThis": true, - "esModuleInterop": true + "esModuleInterop": true, + "strictNullChecks": true }, "vueCompilerOptions": { "target": 2.7