diff --git a/api/.env b/api/.env index 5da48f2d..dd906d00 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 d4b56272..58616448 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 c6fbf599..d708f693 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 ec939d75..fadb10e6 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 20b68716..bee03eaa 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 13374c2a..a7495cb5 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 fd413844..47242726 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 65a2a5fb..60a91d5e 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 (
- + {photo.title}
) diff --git a/ui/src/Pages/AllAlbumsPage/AlbumBox.js b/ui/src/Pages/AllAlbumsPage/AlbumBox.js new file mode 100644 index 00000000..1bdf180d --- /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 ( + + + + ) + } + + return ( + + +

{album.title}

+
+ ) +} diff --git a/ui/src/Pages/AllAlbumsPage/AlbumGallery.js b/ui/src/Pages/AllAlbumsPage/AlbumGallery.js new file mode 100644 index 00000000..b10b081e --- /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
Error {error.message}
+ + let albumElements = [] + + if (albums) { + albumElements = albums.map(album => ( + + )) + } else { + for (let i = 0; i < 8; i++) { + albumElements.push() + } + } + + return ( + + Loading albums + {albumElements} + + ) +} + +export default AlbumGallery diff --git a/ui/src/Pages/AllAlbumsPage/Albums.js b/ui/src/Pages/AllAlbumsPage/Albums.js deleted file mode 100644 index be866b59..00000000 --- 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 ( - - - {({ loading, error, data }) => { - // if (loading) return - if (error) return
Error {error.message}
- - let albums - - if (data && data.myAlbums) { - albums = data.myAlbums.map(album => ( - - -

{album.title}

-
- )) - } else { - albums = [] - for (let i = 0; i < 8; i++) { - albums.push( - - - - ) - } - } - - return ( -
- {' '} - Loading images - {albums} -
- ) - }} -
-
- ) - } -} - -export default Albums diff --git a/ui/src/Pages/AllAlbumsPage/AlbumsPage.js b/ui/src/Pages/AllAlbumsPage/AlbumsPage.js index 008656e8..5ae06af5 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 (

Albums

- + + {({ loading, error, data }) => ( + + )} +
) } diff --git a/ui/src/Pages/PhotosPage/PhotosPage.js b/ui/src/Pages/PhotosPage/PhotosPage.js index 3b377fd0..0ee4c3d5 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 9933df39..4821989d 100644 --- a/ui/src/PhotoGallery.js +++ b/ui/src/PhotoGallery.js @@ -87,7 +87,7 @@ const PhotoGallery = ({ onSelectImage && onSelectImage(index) }} > - + )