From 7a54fc9bc94e99850b2ef2a0297afe85adcf34cb Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Sun, 21 Jul 2019 17:16:43 +0200
Subject: [PATCH] Move photourl to database

---
 api/.env                                   |   1 +
 api/src/Scanner.js                         | 140 +++++++++++++++------
 api/src/config.js                          |   1 +
 api/src/index.js                           |  15 +--
 api/src/resolvers/photos.js                |  76 +----------
 api/src/schema.graphql                     |   8 +-
 ui/src/Pages/AlbumPage/AlbumPage.js        |  11 +-
 ui/src/Pages/AlbumPage/AlbumSidebar.js     |   4 +-
 ui/src/Pages/AllAlbumsPage/AlbumBox.js     |  39 ++++++
 ui/src/Pages/AllAlbumsPage/AlbumGallery.js |  36 ++++++
 ui/src/Pages/AllAlbumsPage/Albums.js       |  93 --------------
 ui/src/Pages/AllAlbumsPage/AlbumsPage.js   |  28 ++++-
 ui/src/Pages/PhotosPage/PhotosPage.js      |   2 +-
 ui/src/PhotoGallery.js                     |   2 +-
 14 files changed, 239 insertions(+), 217 deletions(-)
 create mode 100644 ui/src/Pages/AllAlbumsPage/AlbumBox.js
 create mode 100644 ui/src/Pages/AllAlbumsPage/AlbumGallery.js
 delete mode 100644 ui/src/Pages/AllAlbumsPage/Albums.js

diff --git a/api/.env b/api/.env
index 5da48f2..dd906d0 100644
--- a/api/.env
+++ b/api/.env
@@ -3,5 +3,6 @@ NEO4J_USER=neo4j
 NEO4J_PASSWORD=letmein
 GRAPHQL_LISTEN_PORT=4001
 GRAPHQL_URI=http://localhost:4001/graphql
+HOST=http://localhost:4001
 
 JWT_SECRET=topSecretEpicJWTKEYThing
\ No newline at end of file
diff --git a/api/src/Scanner.js b/api/src/Scanner.js
index d4b5627..5861644 100644
--- a/api/src/Scanner.js
+++ b/api/src/Scanner.js
@@ -7,8 +7,11 @@ import { exiftool } from 'exiftool-vendored'
 import sharp from 'sharp'
 import readChunk from 'read-chunk'
 import imageType from 'image-type'
+import { promisify } from 'util'
 import config from './config'
 
+const imageSize = promisify(require('image-size'))
+
 export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS'
 
 const isImage = async path => {
@@ -125,10 +128,11 @@ class PhotoScanner {
 
     let foundAlbumIds = []
 
-    async function scanPath(path) {
+    async function scanPath(path, parentAlbum) {
       const list = fs.readdirSync(path)
 
       let foundImage = false
+      let newAlbums = []
 
       for (const item of list) {
         const itemPath = pathResolve(path, item)
@@ -136,34 +140,39 @@ class PhotoScanner {
         const stat = fs.statSync(itemPath)
 
         if (stat.isDirectory()) {
-          // console.log(`Entering directory ${itemPath}`)
-          const imagesInDirectory = await scanPath(itemPath)
+          const session = driver.session()
+          let nextParentAlbum = null
+
+          const findAlbumResult = await session.run(
+            'MATCH (a:Album { path: {path} }) RETURN a',
+            {
+              path: itemPath,
+            }
+          )
+
+          session.close()
+
+          if (findAlbumResult.records.length != 0) {
+            const album = findAlbumResult.records[0].toObject().a.properties
+            console.log('Found existing album', album.title)
+
+            foundAlbumIds.push(album.id)
+
+            nextParentAlbum = album.id
+            scanAlbum(album)
+
+            continue
+          }
+
+          const {
+            foundImage: imagesInDirectory,
+            newAlbums: childAlbums,
+          } = await scanPath(itemPath, nextParentAlbum)
 
           if (imagesInDirectory) {
-            console.log(`Found album at ${itemPath}`)
+            console.log(`Found new album at ${itemPath}`)
             const session = driver.session()
 
-            const findAlbumResult = await session.run(
-              'MATCH (a:Album { path: {path} }) RETURN a',
-              {
-                path: itemPath,
-              }
-            )
-
-            console.log('FIND ALBUM RESULT', findAlbumResult.records)
-
-            if (findAlbumResult.records.length != 0) {
-              console.log('Album already exists')
-
-              const album = findAlbumResult.records[0].toObject().a.properties
-
-              foundAlbumIds.push(album.id)
-
-              scanAlbum(album)
-
-              continue
-            }
-
             console.log('Adding album')
             const albumId = uuid()
             const albumResult = await session.run(
@@ -179,7 +188,36 @@ class PhotoScanner {
               }
             )
 
+            foundAlbumIds.push(albumId)
+            newAlbums.push(albumId)
             const album = albumResult.records[0].toObject().a.properties
+
+            if (parentAlbum) {
+              console.log('Linking parent album for', album.title)
+              await session.run(
+                `MATCH (parent:Album { id: {parentId} })
+                MATCH (child:Album { id: {childId} })
+                CREATE (parent)-[:SUBALBUM]->(child)`,
+                {
+                  childId: albumId,
+                  parentId: parentAlbum,
+                }
+              )
+            }
+
+            console.log(`Linking ${childAlbums.length} child albums`)
+            for (let childAlbum of childAlbums) {
+              await session.run(
+                `MATCH (parent:Album { id: {parentId} })
+                MATCH (child:Album { id: {childId} })
+                CREATE (parent)-[:SUBALBUM]->(child)`,
+                {
+                  parentId: albumId,
+                  childId: childAlbum,
+                }
+              )
+            }
+
             scanAlbum(album)
 
             session.close()
@@ -193,7 +231,7 @@ class PhotoScanner {
         }
       }
 
-      return foundImage
+      return { foundImage, newAlbums }
     }
 
     await scanPath(user.rootPath)
@@ -203,7 +241,7 @@ class PhotoScanner {
     const session = this.driver.session()
 
     const userAlbumsResult = await session.run(
-      'MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album) WHERE NOT a.id IN {foundAlbums} DETACH DELETE a return a',
+      'MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album)-[:CONTAINS]->(p:Photo) WHERE NOT a.id IN {foundAlbums} DETACH DELETE a, p RETURN a',
       { userId: user.id, foundAlbums: foundAlbumIds }
     )
 
@@ -276,22 +314,29 @@ class PhotoScanner {
   }
 
   async processImage(id) {
-    console.log('Processing image')
     const session = this.driver.session()
 
-    const result = await session.run('MATCH (p:Photo { id: {id} }) return p', {
+    const result = await session.run(`MATCH (p:Photo { id: {id} }) RETURN p`, {
       id,
     })
+
+    await session.run(
+      `MATCH (p:Photo { id: {id} })-[rel]->(url:PhotoURL) DELETE url, rel`,
+      { id }
+    )
+
+    console.log('PROCESS IMAGE RESULT', result)
+
     const photo = result.records[0].get('p').properties
 
-    console.log('PHOTO', photo.path)
+    console.log('Processing photo', photo.path)
 
     const imagePath = path.resolve(config.cachePath, 'images', id)
 
     await fs.remove(imagePath)
     await fs.mkdirp(imagePath)
 
-    let resizeBaseImg = photo.path
+    let originalPath = photo.path
 
     if (await isRawImage(photo.path)) {
       console.log('Processing RAW image')
@@ -299,15 +344,40 @@ class PhotoScanner {
       const extractedPath = path.resolve(imagePath, 'extracted.jpg')
       await exiftool.extractPreview(photo.path, extractedPath)
 
-      resizeBaseImg = extractedPath
+      originalPath = extractedPath
     }
 
     // Resize image
-    console.log('Resizing image', resizeBaseImg)
-    await sharp(resizeBaseImg)
+    const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg')
+    await sharp(originalPath)
       .jpeg({ quality: 80 })
       .resize(1440, 1080, { fit: 'inside', withoutEnlargement: true })
-      .toFile(path.resolve(imagePath, 'thumbnail.jpg'))
+      .toFile(thumbnailPath)
+
+    const { width: originalWidth, height: originalHeight } = await imageSize(
+      originalPath
+    )
+    const { width: thumbnailWidth, height: thumbnailHeight } = await imageSize(
+      thumbnailPath
+    )
+
+    await session.run(
+      `MATCH (p:Photo { id: {id} })
+      CREATE (thumbnail:PhotoURL { url: {thumbnailUrl}, width: {thumbnailWidth}, height: {thumbnailHeight} })
+      CREATE (original:PhotoURL { url: {originalUrl}, width: {originalWidth}, height: {originalHeight} })
+      CREATE (p)-[:THUMBNAIL_URL]->(thumbnail)
+      CREATE (p)-[:ORIGINAL_URL]->(original)
+      `,
+      {
+        id,
+        thumbnailUrl: `/images/${id}/${path.basename(thumbnailPath)}`,
+        thumbnailWidth,
+        thumbnailHeight,
+        originalUrl: `/images/${id}/${path.basename(originalPath)}`,
+        originalWidth,
+        originalHeight,
+      }
+    )
 
     session.close()
 
diff --git a/api/src/config.js b/api/src/config.js
index c6fbf59..d708f69 100644
--- a/api/src/config.js
+++ b/api/src/config.js
@@ -2,4 +2,5 @@ import path from 'path'
 
 export default {
   cachePath: path.resolve(__dirname, 'cache'),
+  host: process.env.HOST || 'http://localhost:4001/',
 }
diff --git a/api/src/index.js b/api/src/index.js
index ec939d7..fadb10e 100644
--- a/api/src/index.js
+++ b/api/src/index.js
@@ -126,10 +126,6 @@ app.use('/images/:id/:image', async function(req, res) {
 
   console.log('image', image)
 
-  if (image != 'original.jpg' && image != 'thumbnail.jpg') {
-    return res.status(404).send('Image not found')
-  }
-
   let user = null
 
   try {
@@ -142,7 +138,7 @@ app.use('/images/:id/:image', async function(req, res) {
   const session = driver.session()
 
   const result = await session.run(
-    'MATCH (p:Photo { id: {id} }) MATCH (p)<-[:CONTAINS]-(:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId',
+    'MATCH (p:Photo { id: {id} })<-[:CONTAINS]-(:Album)<-[:OWNS]-(u:User) RETURN p as photo, u.id as userId',
     {
       id,
     }
@@ -153,7 +149,7 @@ app.use('/images/:id/:image', async function(req, res) {
   }
 
   const userId = result.records[0].get('userId')
-  const photo = result.records[0].get('photo')
+  const photo = result.records[0].get('photo').properties
 
   if (userId != user.id) {
     return res.status(401).send(`Image not owned by you`)
@@ -161,7 +157,12 @@ app.use('/images/:id/:image', async function(req, res) {
 
   session.close()
 
-  const imagePath = path.resolve(config.cachePath, 'images', id, image)
+  let imagePath = path.resolve(config.cachePath, 'images', id, image)
+
+  if (image != 'extracted.jpg' && image != 'thumbnail.jpg') {
+    imagePath = photo.path
+  }
+
   const imageFound = await fs.exists(imagePath)
 
   if (!imageFound) {
diff --git a/api/src/resolvers/photos.js b/api/src/resolvers/photos.js
index 20b6871..bee03ea 100644
--- a/api/src/resolvers/photos.js
+++ b/api/src/resolvers/photos.js
@@ -1,11 +1,5 @@
 import { cypherQuery } from 'neo4j-graphql-js'
-import { promisify } from 'util'
-import fs from 'fs-extra'
-import path from 'path'
 import config from '../config'
-import { isRawImage } from '../Scanner'
-
-const imageSize = promisify(require('image-size'))
 
 function injectAt(query, index, injection) {
   return query.substr(0, index) + injection + query.substr(index)
@@ -130,75 +124,13 @@ const Query = {
   },
 }
 
-function photoResolver(image) {
-  return async (root, args, ctx, info) => {
-    const imgPath = path.resolve(config.cachePath, 'images', root.id, image)
-
-    if (!(await fs.exists(imgPath))) {
-      await ctx.scanner.processImage(root.id)
-    }
-
-    const { width, height } = await imageSize(imgPath)
-    return {
-      path: `${ctx.endpoint}/images/${root.id}/${image}`,
-      width,
-      height,
-    }
-  }
-}
-
-const Photo = {
-  // TODO: Make original point to the right path
-  original: async (root, args, ctx, info) => {
-    async function getPath(retryAfterScan = false) {
-      let imgPath = path.resolve(
-        config.cachePath,
-        'images',
-        root.id,
-        'extracted.jpg'
-      )
-
-      if (!(await fs.exists(imgPath))) {
-        imgPath = root.path
-
-        if (!imgPath) {
-          const session = ctx.driver.session()
-
-          const result = await session.run(
-            'MATCH (p:Photo { id: {id} }) return p.path as path',
-            {
-              id: root.id,
-            }
-          )
-
-          imgPath = result.get('path')
-          session.close()
-        }
-
-        if (!(await fs.exists(imgPath)) || (await isRawImage(imgPath))) {
-          if (retryAfterScan)
-            throw new Error('Could not find image after rescan')
-          await ctx.scanner.processImage(root.id)
-          return getPath(true)
-        }
-      }
-
-      return imgPath
-    }
-
-    const imgPath = await getPath()
-
-    const { width, height } = await imageSize(imgPath)
-    return {
-      path: `${ctx.endpoint}/images/${root.id}/${path.basename(imgPath)}`,
-      width,
-      height,
-    }
+const PhotoURL = {
+  url(root, args, ctx, info) {
+    return new URL(root.url, config.host).href
   },
-  thumbnail: photoResolver('thumbnail.jpg'),
 }
 
 export default {
   Query,
-  Photo,
+  PhotoURL,
 }
diff --git a/api/src/schema.graphql b/api/src/schema.graphql
index 13374c2..a7495cb 100644
--- a/api/src/schema.graphql
+++ b/api/src/schema.graphql
@@ -16,13 +16,15 @@ type Album {
   id: ID!
   title: String
   photos: [Photo] @relation(name: "CONTAINS", direction: "OUT")
+  subAlbums: [Album] @relation(name: "SUBALBUM", direction: "OUT")
+  parentAlbum: Album @relation(name: "SUBALBUM", direction: "IN")
   owner: User! @relation(name: "OWNS", direction: "IN")
   path: String
 }
 
 type PhotoURL {
   # URL for the image
-  path: String
+  url: String
   # Width of the image in pixels
   width: Int
   # Height of the image in pixels
@@ -35,9 +37,9 @@ type Photo {
   # Local filepath for the photo
   path: String
   # URL to display the photo in full resolution
-  original: PhotoURL @neo4j_ignore
+  original: PhotoURL @relation(name: "ORIGINAL_URL", direction: "OUT")
   # URL to display the photo in a smaller resolution
-  thumbnail: PhotoURL @neo4j_ignore
+  thumbnail: PhotoURL @relation(name: "THUMBNAIL_URL", direction: "OUT")
   # The album that holds the photo
   album: Album! @relation(name: "CONTAINS", direction: "IN")
 }
diff --git a/ui/src/Pages/AlbumPage/AlbumPage.js b/ui/src/Pages/AlbumPage/AlbumPage.js
index fd41384..4724272 100644
--- a/ui/src/Pages/AlbumPage/AlbumPage.js
+++ b/ui/src/Pages/AlbumPage/AlbumPage.js
@@ -9,10 +9,19 @@ const albumQuery = gql`
   query albumQuery($id: ID) {
     album(id: $id) {
       title
+      subAlbums {
+        id
+        title
+        photos {
+          thumbnail {
+            url
+          }
+        }
+      }
       photos {
         id
         thumbnail {
-          path
+          url
           width
           height
         }
diff --git a/ui/src/Pages/AlbumPage/AlbumSidebar.js b/ui/src/Pages/AlbumPage/AlbumSidebar.js
index 65a2a5f..60a91d5 100644
--- a/ui/src/Pages/AlbumPage/AlbumSidebar.js
+++ b/ui/src/Pages/AlbumPage/AlbumSidebar.js
@@ -8,7 +8,7 @@ const photoQuery = gql`
     photo(id: $id) {
       title
       original {
-        path
+        url
         width
         height
       }
@@ -57,7 +57,7 @@ class AlbumSidebar extends Component {
 
             return (
               <div>
-                <PreviewImage src={photo.original.path} />
+                <PreviewImage src={photo.original.url} />
                 <Name>{photo.title}</Name>
               </div>
             )
diff --git a/ui/src/Pages/AllAlbumsPage/AlbumBox.js b/ui/src/Pages/AllAlbumsPage/AlbumBox.js
new file mode 100644
index 0000000..1bdf180
--- /dev/null
+++ b/ui/src/Pages/AllAlbumsPage/AlbumBox.js
@@ -0,0 +1,39 @@
+import React from 'react'
+import styled from 'styled-components'
+import { Link } from 'react-router-dom'
+
+const AlbumBoxLink = styled(Link)`
+  width: 240px;
+  height: 240px;
+  display: inline-block;
+  text-align: center;
+  color: #222;
+`
+
+const AlbumBoxImage = styled.div`
+  width: 220px;
+  height: 220px;
+  margin: auto;
+  border-radius: 4%;
+  background-image: url('${props => props.image}');
+  background-color: #eee;
+  background-size: cover;
+  background-position: center;
+`
+
+export const AlbumBox = ({ album, ...props }) => {
+  if (!album) {
+    return (
+      <AlbumBoxLink {...props} to="#">
+        <AlbumBoxImage />
+      </AlbumBoxLink>
+    )
+  }
+
+  return (
+    <AlbumBoxLink {...props} to={`/album/${album.id}`}>
+      <AlbumBoxImage image={album.photos[0] && album.photos[0].thumbnail.url} />
+      <p>{album.title}</p>
+    </AlbumBoxLink>
+  )
+}
diff --git a/ui/src/Pages/AllAlbumsPage/AlbumGallery.js b/ui/src/Pages/AllAlbumsPage/AlbumGallery.js
new file mode 100644
index 0000000..b10b081
--- /dev/null
+++ b/ui/src/Pages/AllAlbumsPage/AlbumGallery.js
@@ -0,0 +1,36 @@
+import React from 'react'
+import styled from 'styled-components'
+import { Loader } from 'semantic-ui-react'
+import { AlbumBox } from './AlbumBox'
+
+const Container = styled.div`
+  margin: -10px;
+  margin-top: 20px;
+  position: relative;
+  min-height: 500px;
+`
+
+const AlbumGallery = ({ loading, error, albums }) => {
+  if (error) return <div>Error {error.message}</div>
+
+  let albumElements = []
+
+  if (albums) {
+    albumElements = albums.map(album => (
+      <AlbumBox key={album.id} album={album} />
+    ))
+  } else {
+    for (let i = 0; i < 8; i++) {
+      albumElements.push(<AlbumBox key={i} />)
+    }
+  }
+
+  return (
+    <Container>
+      <Loader active={loading}>Loading albums</Loader>
+      {albumElements}
+    </Container>
+  )
+}
+
+export default AlbumGallery
diff --git a/ui/src/Pages/AllAlbumsPage/Albums.js b/ui/src/Pages/AllAlbumsPage/Albums.js
deleted file mode 100644
index be866b5..0000000
--- a/ui/src/Pages/AllAlbumsPage/Albums.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import React, { Component } from 'react'
-import gql from 'graphql-tag'
-import { Query } from 'react-apollo'
-import styled from 'styled-components'
-import { Link } from 'react-router-dom'
-import { Loader } from 'semantic-ui-react'
-
-const getAlbumsQuery = gql`
-  query getMyAlbums {
-    myAlbums {
-      id
-      title
-      photos {
-        thumbnail {
-          path
-        }
-      }
-    }
-  }
-`
-
-const Container = styled.div`
-  margin: -10px;
-  margin-top: 20px;
-  position: relative;
-  min-height: 500px;
-`
-
-const AlbumBoxLink = styled(Link)`
-  width: 240px;
-  height: 240px;
-  display: inline-block;
-  text-align: center;
-  color: #222;
-`
-
-const AlbumBoxImage = styled.div`
-  width: 220px;
-  height: 220px;
-  margin: auto;
-  border-radius: 4%;
-  background-image: url('${props => props.image}');
-  background-color: #eee;
-  background-size: cover;
-  background-position: center;
-`
-
-class Albums extends Component {
-  render() {
-    return (
-      <Container>
-        <Query query={getAlbumsQuery}>
-          {({ loading, error, data }) => {
-            // if (loading) return <Loader active />
-            if (error) return <div>Error {error.message}</div>
-
-            let albums
-
-            if (data && data.myAlbums) {
-              albums = data.myAlbums.map(album => (
-                <AlbumBoxLink key={album.id} to={`/album/${album.id}`}>
-                  <AlbumBoxImage
-                    image={album.photos[0] && album.photos[0].thumbnail.path}
-                  />
-                  <p>{album.title}</p>
-                </AlbumBoxLink>
-              ))
-            } else {
-              albums = []
-              for (let i = 0; i < 8; i++) {
-                albums.push(
-                  <AlbumBoxLink key={i} to="#">
-                    <AlbumBoxImage />
-                  </AlbumBoxLink>
-                )
-              }
-            }
-
-            return (
-              <div>
-                {' '}
-                <Loader active={loading}>Loading images</Loader>
-                {albums}
-              </div>
-            )
-          }}
-        </Query>
-      </Container>
-    )
-  }
-}
-
-export default Albums
diff --git a/ui/src/Pages/AllAlbumsPage/AlbumsPage.js b/ui/src/Pages/AllAlbumsPage/AlbumsPage.js
index 008656e..5ae06af 100644
--- a/ui/src/Pages/AllAlbumsPage/AlbumsPage.js
+++ b/ui/src/Pages/AllAlbumsPage/AlbumsPage.js
@@ -1,13 +1,37 @@
 import React, { Component } from 'react'
-import Albums from './Albums'
+import AlbumGallery from './AlbumGallery'
 import Layout from '../../Layout'
+import gql from 'graphql-tag'
+import { Query } from 'react-apollo'
+
+const getAlbumsQuery = gql`
+  query getMyAlbums {
+    myAlbums {
+      id
+      title
+      photos {
+        thumbnail {
+          url
+        }
+      }
+    }
+  }
+`
 
 class AlbumsPage extends Component {
   render() {
     return (
       <Layout>
         <h1>Albums</h1>
-        <Albums />
+        <Query query={getAlbumsQuery}>
+          {({ loading, error, data }) => (
+            <AlbumGallery
+              loading={loading}
+              error={error}
+              albums={data && data.myAlbums}
+            />
+          )}
+        </Query>
       </Layout>
     )
   }
diff --git a/ui/src/Pages/PhotosPage/PhotosPage.js b/ui/src/Pages/PhotosPage/PhotosPage.js
index 3b377fd..0ee4c3d 100644
--- a/ui/src/Pages/PhotosPage/PhotosPage.js
+++ b/ui/src/Pages/PhotosPage/PhotosPage.js
@@ -10,7 +10,7 @@ const photoQuery = gql`
       id
       title
       thumbnail {
-        path
+        url
         width
         height
       }
diff --git a/ui/src/PhotoGallery.js b/ui/src/PhotoGallery.js
index 9933df3..4821989 100644
--- a/ui/src/PhotoGallery.js
+++ b/ui/src/PhotoGallery.js
@@ -87,7 +87,7 @@ const PhotoGallery = ({
             onSelectImage && onSelectImage(index)
           }}
         >
-          <Photo src={photo.thumbnail.path} />
+          <Photo src={photo.thumbnail.url} />
           <PhotoOverlay active={active} />
         </PhotoContainer>
       )
