From 53415714c27a553a8061cd41f2e49dfb81c84adc Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Wed, 24 Jul 2019 20:56:15 +0200
Subject: [PATCH] Add EXIF to sideview

---
 api/src/scanner/Scanner.js                |   2 +-
 api/src/scanner/processImage.js           |  74 +++++++++++--
 api/src/scanner/scanAlbum.js              |   6 +-
 api/src/scanner/scanUser.js               |   2 +-
 api/src/schema.graphql                    |  15 +++
 ui/src/Pages/AlbumPage/AlbumPage.js       |   4 +-
 ui/src/Pages/AlbumPage/AlbumSidebar.js    |  71 ------------
 ui/src/Pages/AllAlbumsPage/AlbumBox.js    |   8 +-
 ui/src/Pages/PhotosPage/PhotosPage.js     |   4 +-
 ui/src/PhotoGallery.js                    |   2 +-
 ui/src/components/sidebar/PhotoSidebar.js | 127 ++++++++++++++++++++++
 ui/src/components/sidebar/SidebarItem.js  |  23 ++++
 12 files changed, 245 insertions(+), 93 deletions(-)
 delete mode 100644 ui/src/Pages/AlbumPage/AlbumSidebar.js
 create mode 100644 ui/src/components/sidebar/PhotoSidebar.js
 create mode 100644 ui/src/components/sidebar/SidebarItem.js

diff --git a/api/src/scanner/Scanner.js b/api/src/scanner/Scanner.js
index 9d45e91..e5cdeaf 100644
--- a/api/src/scanner/Scanner.js
+++ b/api/src/scanner/Scanner.js
@@ -64,7 +64,7 @@ class PhotoScanner {
     await _processImage(
       {
         driver: this.driver,
-        addFinishedImage: this.markFinishedImage,
+        markFinishedImage: this.markFinishedImage,
       },
       id
     )
diff --git a/api/src/scanner/processImage.js b/api/src/scanner/processImage.js
index 038c4e5..bb588e1 100644
--- a/api/src/scanner/processImage.js
+++ b/api/src/scanner/processImage.js
@@ -3,8 +3,48 @@ import path from 'path'
 import { exiftool } from 'exiftool-vendored'
 import sharp from 'sharp'
 import { isRawImage, imageSize, getImageCachePath } from './utils'
+import { DateTime as NeoDateTime } from 'neo4j-driver/lib/v1/temporal-types.js'
 
-export default async function processImage({ driver, addFinishedImage }, id) {
+async function addExifTags({ session, photo }) {
+  const exifResult = await session.run(
+    'MATCH (p:Photo { id: {id} })-[:EXIF]->(exif:PhotoEXIF) RETURN exif',
+    {
+      id: photo.id,
+    }
+  )
+
+  if (exifResult.records.length > 0) return
+
+  const rawTags = await exiftool.read(photo.path)
+
+  const photoExif = {
+    camera: rawTags.Model,
+    maker: rawTags.Make,
+    lens: rawTags.LensType,
+    dateShot:
+      rawTags.DateTimeOriginal &&
+      NeoDateTime.fromStandardDate(rawTags.DateTimeOriginal.toDate()),
+    fileSize: rawTags.FileSize,
+    exposure: rawTags.ShutterSpeedValue,
+    aperture: rawTags.ApertureValue,
+    iso: rawTags.ISO,
+    focalLength: rawTags.FocalLength,
+    flash: rawTags.Flash,
+  }
+
+  const result = await session.run(
+    `MATCH (p:Photo { id: {id} })
+      CREATE (p)-[:EXIF]->(exif:PhotoEXIF {exifProps})`,
+    {
+      id: photo.id,
+      exifProps: photoExif,
+    }
+  )
+
+  console.log('Added exif tags to photo', photo.path)
+}
+
+export default async function processImage({ driver, markFinishedImage }, id) {
   const session = driver.session()
 
   const result = await session.run(
@@ -14,18 +54,32 @@ export default async function processImage({ driver, addFinishedImage }, id) {
     }
   )
 
+  const photo = result.records[0].get('p').properties
+  const albumId = result.records[0].get('a.id')
+
+  const imagePath = getImageCachePath(id, albumId)
+
+  // Verify that processing is needed
+  if (await fs.exists(path.resolve(imagePath, 'thumbnail.jpg'))) {
+    const urlResult = await session.run(
+      `MATCH (p:Photo { id: {id} })-->(urls:PhotoURL) RETURN urls`,
+      { id }
+    )
+
+    if (urlResult.records.length == 2) {
+      markFinishedImage()
+
+      console.log('Skipping image', photo.path)
+      return
+    }
+  }
+
+  // Begin processing
   await session.run(
     `MATCH (p:Photo { id: {id} })-->(urls:PhotoURL) DETACH DELETE urls`,
     { id }
   )
 
-  const photo = result.records[0].get('p').properties
-  const albumId = result.records[0].get('a.id')
-
-  // console.log('Processing photo', photo.path)
-
-  const imagePath = getImageCachePath(id, albumId)
-
   await fs.remove(imagePath)
   await fs.mkdirp(imagePath)
 
@@ -106,7 +160,9 @@ export default async function processImage({ driver, addFinishedImage }, id) {
     console.log('Create photo url failed', e)
   }
 
+  await addExifTags({ session, photo })
+
   session.close()
 
-  addFinishedImage()
+  markFinishedImage()
 }
diff --git a/api/src/scanner/scanAlbum.js b/api/src/scanner/scanAlbum.js
index 17ef148..e0da59a 100644
--- a/api/src/scanner/scanAlbum.js
+++ b/api/src/scanner/scanAlbum.js
@@ -42,11 +42,7 @@ export default async function scanAlbum(
           'thumbnail.jpg'
         )
 
-        if (!(await fs.exists(thumbnailPath))) {
-          processingImagePromises.push(processImage(photoId))
-        } else {
-          markFinishedImage()
-        }
+        processingImagePromises.push(processImage(photoId))
       } else {
         console.log(`Found new image at ${itemPath}`)
         const imageId = uuid()
diff --git a/api/src/scanner/scanUser.js b/api/src/scanner/scanUser.js
index 28c9bf8..3152f0b 100644
--- a/api/src/scanner/scanUser.js
+++ b/api/src/scanner/scanUser.js
@@ -134,7 +134,7 @@ export default async function scanUser({ driver, scanAlbum }, user) {
     OPTIONAL MATCH (a)-[:CONTAINS]->(p:Photo)-->(photoTail)
     WITH a, p, photoTail, a.id AS albumId
     DETACH DELETE a, p, photoTail
-    RETURN albumId`,
+    RETURN DISTINCT albumId`,
     { userId: user.id, foundAlbums: foundAlbumIds }
   )
 
diff --git a/api/src/schema.graphql b/api/src/schema.graphql
index a9f2ebe..4892eb0 100644
--- a/api/src/schema.graphql
+++ b/api/src/schema.graphql
@@ -31,6 +31,20 @@ type PhotoURL {
   height: Int
 }
 
+type PhotoEXIF {
+  photo: Photo @relation(name: "EXIF", direction: "IN")
+  camera: String
+  maker: String
+  lens: String
+  dateShot: DateTime
+  fileSize: String
+  exposure: String
+  aperture: Float
+  iso: Int
+  focalLength: String
+  flash: String
+}
+
 type Photo {
   id: ID!
   title: String
@@ -42,6 +56,7 @@ type Photo {
   thumbnail: PhotoURL @relation(name: "THUMBNAIL_URL", direction: "OUT")
   # The album that holds the photo
   album: Album! @relation(name: "CONTAINS", direction: "IN")
+  exif: PhotoEXIF @relation(name: "EXIF", direction: "OUT")
 }
 
 type SiteInfo {
diff --git a/ui/src/Pages/AlbumPage/AlbumPage.js b/ui/src/Pages/AlbumPage/AlbumPage.js
index f2540d1..71ae6d1 100644
--- a/ui/src/Pages/AlbumPage/AlbumPage.js
+++ b/ui/src/Pages/AlbumPage/AlbumPage.js
@@ -2,7 +2,7 @@ import React, { Component } from 'react'
 import gql from 'graphql-tag'
 import { Query } from 'react-apollo'
 import Layout from '../../Layout'
-import AlbumSidebar from './AlbumSidebar'
+import PhotoSidebar from '../../components/sidebar/PhotoSidebar'
 import PhotoGallery from '../../PhotoGallery'
 import AlbumGallery from '../AllAlbumsPage/AlbumGallery'
 
@@ -92,7 +92,7 @@ class AlbumPage extends Component {
                     this.setActiveImage(index, data.album.photos[index].id)
                   }}
                 />
-                <AlbumSidebar imageId={this.state.activeImageId} />
+                <PhotoSidebar imageId={this.state.activeImageId} />
               </div>
             )
           }}
diff --git a/ui/src/Pages/AlbumPage/AlbumSidebar.js b/ui/src/Pages/AlbumPage/AlbumSidebar.js
deleted file mode 100644
index 60a91d5..0000000
--- a/ui/src/Pages/AlbumPage/AlbumSidebar.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React, { Component } from 'react'
-import styled from 'styled-components'
-import { Query } from 'react-apollo'
-import gql from 'graphql-tag'
-
-const photoQuery = gql`
-  query sidebarPhoto($id: ID) {
-    photo(id: $id) {
-      title
-      original {
-        url
-        width
-        height
-      }
-    }
-  }
-`
-
-const RightSidebar = styled.div`
-  height: 100%;
-  width: 500px;
-  position: fixed;
-  right: 0;
-  top: 60px;
-  background-color: white;
-  padding: 12px;
-  border-left: 1px solid #eee;
-`
-
-const PreviewImage = styled.img`
-  width: 100%;
-  height: 333px;
-  object-fit: contain;
-`
-
-const Name = styled.div`
-  text-align: center;
-  font-size: 16px;
-`
-
-class AlbumSidebar extends Component {
-  render() {
-    const { imageId } = this.props
-
-    if (!imageId) {
-      return <RightSidebar />
-    }
-
-    return (
-      <RightSidebar>
-        <Query query={photoQuery} variables={{ id: imageId }}>
-          {({ loading, error, data }) => {
-            if (loading) return 'Loading...'
-            if (error) return error
-
-            const { photo } = data
-
-            return (
-              <div>
-                <PreviewImage src={photo.original.url} />
-                <Name>{photo.title}</Name>
-              </div>
-            )
-          }}
-        </Query>
-      </RightSidebar>
-    )
-  }
-}
-
-export default AlbumSidebar
diff --git a/ui/src/Pages/AllAlbumsPage/AlbumBox.js b/ui/src/Pages/AllAlbumsPage/AlbumBox.js
index 1bdf180..8f6f2ec 100644
--- a/ui/src/Pages/AllAlbumsPage/AlbumBox.js
+++ b/ui/src/Pages/AllAlbumsPage/AlbumBox.js
@@ -32,7 +32,13 @@ export const AlbumBox = ({ album, ...props }) => {
 
   return (
     <AlbumBoxLink {...props} to={`/album/${album.id}`}>
-      <AlbumBoxImage image={album.photos[0] && album.photos[0].thumbnail.url} />
+      <AlbumBoxImage
+        image={
+          album.photos[0] &&
+          album.photos[0].thumbnail &&
+          album.photos[0].thumbnail.url
+        }
+      />
       <p>{album.title}</p>
     </AlbumBoxLink>
   )
diff --git a/ui/src/Pages/PhotosPage/PhotosPage.js b/ui/src/Pages/PhotosPage/PhotosPage.js
index 1277dbe..2363b76 100644
--- a/ui/src/Pages/PhotosPage/PhotosPage.js
+++ b/ui/src/Pages/PhotosPage/PhotosPage.js
@@ -3,7 +3,7 @@ import Layout from '../../Layout'
 import gql from 'graphql-tag'
 import { Query } from 'react-apollo'
 import PhotoGallery from '../../PhotoGallery'
-import AlbumSidebar from '../AlbumPage/AlbumSidebar'
+import PhotoSidebar from '../../components/sidebar/PhotoSidebar'
 
 const photoQuery = gql`
   query allPhotosPage {
@@ -79,7 +79,7 @@ class PhotosPage extends Component {
             return (
               <div>
                 {galleryGroups}
-                <AlbumSidebar imageId={activeImage} />
+                <PhotoSidebar imageId={activeImage} />
               </div>
             )
           }}
diff --git a/ui/src/PhotoGallery.js b/ui/src/PhotoGallery.js
index bad85ab..9fa9667 100644
--- a/ui/src/PhotoGallery.js
+++ b/ui/src/PhotoGallery.js
@@ -131,7 +131,7 @@ class PhotoGallery extends React.Component {
               onSelectImage && onSelectImage(index)
             }}
           >
-            <Photo src={photo.thumbnail.url} />
+            <Photo src={photo.thumbnail && photo.thumbnail.url} />
             <PhotoOverlay active={active} />
           </PhotoContainer>
         )
diff --git a/ui/src/components/sidebar/PhotoSidebar.js b/ui/src/components/sidebar/PhotoSidebar.js
new file mode 100644
index 0000000..36e3eb7
--- /dev/null
+++ b/ui/src/components/sidebar/PhotoSidebar.js
@@ -0,0 +1,127 @@
+import React, { Component } from 'react'
+import styled from 'styled-components'
+import { Query } from 'react-apollo'
+import gql from 'graphql-tag'
+import { SidebarItem } from './SidebarItem'
+
+const photoQuery = gql`
+  query sidebarPhoto($id: ID) {
+    photo(id: $id) {
+      title
+      original {
+        url
+        width
+        height
+      }
+      exif {
+        camera
+        maker
+        lens
+        dateShot {
+          formatted
+        }
+        fileSize
+        exposure
+        aperture
+        iso
+        focalLength
+        flash
+      }
+    }
+  }
+`
+
+const RightSidebar = styled.div`
+  height: 100%;
+  width: 500px;
+  position: fixed;
+  right: 0;
+  top: 60px;
+  background-color: white;
+  padding: 12px;
+  border-left: 1px solid #eee;
+`
+
+const PreviewImage = styled.img`
+  width: 100%;
+  height: 333px;
+  object-fit: contain;
+`
+
+const Name = styled.div`
+  text-align: center;
+  font-size: 16px;
+  margin-bottom: 12px;
+`
+
+const exifNameLookup = {
+  camera: 'Camera',
+  maker: 'Maker',
+  lens: 'Lens',
+  dateShot: 'Date Shot',
+  fileSize: 'File Size',
+  exposure: 'Exposure',
+  aperture: 'Aperture',
+  iso: 'ISO',
+  focalLength: 'Focal Length',
+  flash: 'Flash',
+}
+
+class AlbumSidebar extends Component {
+  render() {
+    const { imageId } = this.props
+
+    if (!imageId) {
+      return <RightSidebar />
+    }
+
+    return (
+      <RightSidebar>
+        <Query query={photoQuery} variables={{ id: imageId }}>
+          {({ loading, error, data }) => {
+            if (loading) return 'Loading...'
+            if (error) return error
+
+            const { photo } = data
+
+            let exifItems = []
+
+            if (photo.exif) {
+              let exifKeys = Object.keys(photo.exif).filter(
+                x => !!photo.exif[x] && x != '__typename'
+              )
+
+              let exif = exifKeys.reduce(
+                (prev, curr) => ({
+                  ...prev,
+                  [curr]: photo.exif[curr],
+                }),
+                {}
+              )
+
+              exif.dateShot = exif.dateShot.formatted
+
+              exifItems = exifKeys.map(key => (
+                <SidebarItem
+                  key={key}
+                  name={exifNameLookup[key]}
+                  value={exif[key]}
+                />
+              ))
+            }
+
+            return (
+              <div>
+                <PreviewImage src={photo.original && photo.original.url} />
+                <Name>{photo.title}</Name>
+                <div>{exifItems}</div>
+              </div>
+            )
+          }}
+        </Query>
+      </RightSidebar>
+    )
+  }
+}
+
+export default AlbumSidebar
diff --git a/ui/src/components/sidebar/SidebarItem.js b/ui/src/components/sidebar/SidebarItem.js
new file mode 100644
index 0000000..7849301
--- /dev/null
+++ b/ui/src/components/sidebar/SidebarItem.js
@@ -0,0 +1,23 @@
+import React from 'react'
+import styled from 'styled-components'
+
+const ItemName = styled.div`
+  display: inline-block;
+  width: 100px;
+  font-weight: 600;
+  font-size: 12px;
+  color: #888;
+  text-align: right;
+  margin-right: 8px;
+`
+
+const ItemValue = styled.div`
+  display: inline-block;
+`
+
+export const SidebarItem = ({ name, value }) => (
+  <div>
+    <ItemName>{name}</ItemName>
+    <ItemValue>{value}</ItemValue>
+  </div>
+)
