Compare commits

..

No commits in common. 'master' and 'v2.12.0' have entirely different histories.

@ -1,6 +0,0 @@
ignores:
# dynamically loaded themes
- "@thumbsup/theme-cards"
- "@thumbsup/theme-classic"
- "@thumbsup/theme-mosaic"
- "@thumbsup/theme-flow"

@ -0,0 +1,40 @@
<!--
Hi!
Thanks for taking the time to submit a feature request or bug report. Please try to fill out the relevant fields, and remove any irrelevant section from this template.
-->
## Feature request
Please describe the new feature or behaviour you'd like to see.
If possible, give a concrete use case.
## Bug report
If running as an npm package:
```
Thumbsup version: __________
Node version: __________
NPM version: __________
Operating system: __________
```
If running as a Docker container:
```
Thumbsup image tag: __________
Docker version: __________
Operating system: __________
```
About the bug...
- Are you getting an error message?
- Have you tried running `DEBUG="*" thumbsup <args>` to get more troubleshooting info?
- Is the behavior different from expected?
- Can you provide steps to reproduce the issue?

@ -1,24 +0,0 @@
---
name: Bug report
about: Something went wrong? Create a bug report to help us improve.
labels: bug
---
<!--
Thanks for submitting a bug report.
Please fill out the sections below.
-->
## Bug description
<!--
Are you getting an error message?
Is the behavior different from expected?
Have you tried running `thumbsup <args> --log debug`?
-->
## Steps to reproduce
<!--
Please provide minimal steps to reproduce the issue.
-->

@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Documentation update
url: https://github.com/thumbsup/docs
about: Something missing in the documentation? Please open a pull-request over there.
- name: Ideas and questions
url: https://github.com/thumbsup/thumbsup/discussions
about: Have questions about Thumbsup? Want to discuss ideas for the project?

@ -1,15 +0,0 @@
---
name: Feature request
about: Suggest a new feature for Thumbsup.
labels: enhancement
---
## Feature name
<!--
Please describe the new feature or behaviour you'd like to see.
If possible, give concrete use cases.
If this is a high-level idea or a question like "how do I..."
then a Discussion might be a better choice.
-->

@ -1,24 +0,0 @@
---
name: Installation issues
about: Any problem installing Thumbsup?
labels: installation
---
If trying to install Thumbsup locally:
```
Thumbsup version: __________
Node version: __________
NPM version: __________
Operating system: __________
```
If trying to run the Docker image:
```
Thumbsup image tag: __________
Docker version: __________
Operating system: __________
```
What are the error messages?

@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
open-pull-requests-limit: 1
schedule:
interval: "monthly"

@ -1,38 +0,0 @@
name: Publish Docker base images
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
nodejs: [18, 20]
steps:
- uses: actions/checkout@v2
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
- uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- name: Publish thumbsup/runtime
uses: docker/build-push-action@v2
with:
context: docker
file: ./docker/Dockerfile.runtime
build-args: NODE_VERSION=${{ matrix.nodejs }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ghcr.io/thumbsup/runtime:node-${{ matrix.nodejs }}
push: true
- name: Publish thumbsup/build
uses: docker/build-push-action@v2
with:
context: docker
file: ./docker/Dockerfile.build
build-args: NODE_VERSION=${{ matrix.nodejs }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ghcr.io/thumbsup/build:node-${{ matrix.nodejs }}
push: true

@ -1,35 +0,0 @@
name: Publish Docker image
on:
workflow_dispatch:
workflow_run:
workflows: ["Publish NPM package"]
types:
- completed
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
- uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- id: version
name: Fetch latest NPM version
run: echo ::set-output name=version::$(npm show thumbsup version)
- name: Publish Docker image
uses: docker/build-push-action@v2
with:
context: docker
file: ./docker/Dockerfile.release
build-args: PACKAGE_VERSION=${{ steps.version.outputs.version }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/thumbsup/thumbsup:${{ steps.version.outputs.version }}
ghcr.io/thumbsup/thumbsup:latest
push: true

@ -1,23 +0,0 @@
name: Publish NPM package
# This workflow runs every time a version tag is pushed
on:
push:
tags:
- 'v*'
jobs:
# Publish the npm package
publish:
name: Publish package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
registry-url: 'https://registry.npmjs.org'
- run: npm publish --ignore-scripts
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

@ -1,19 +0,0 @@
name: Test on Linux
on:
workflow_dispatch:
push:
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
- uses: docker/build-push-action@v2
with:
context: .
file: ./docker/Dockerfile.test

@ -1,21 +0,0 @@
name: Test on Windows
on:
workflow_dispatch:
jobs:
test:
name: Test on Windows
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: choco install exiftool graphicsmagick
# GraphicsMagick must be manually added to the path
- run: |
$installPath = Get-ChildItem 'C:\Program Files\GraphicsMagick*' | Select-Object -First 1 | % { $_.FullName }
echo "$installPath" | Out-file -Append -FilePath $env:GITHUB_PATH -Encoding utf8
- run: npm install
- run: npm test

1
.gitignore vendored

@ -1,6 +1,5 @@
node_modules
coverage
.nyc_output
test-snapshot/output-actual
test-snapshot/output-expected/public
test-snapshot/output-expected/metadata.json

@ -1,6 +0,0 @@
recursive: true
file:
- test/helpers.js
- test/log.js
reporter: list
parallel: false

@ -1 +0,0 @@
v18

@ -1,5 +0,0 @@
{
"all": true,
"src": ["src"],
"reporter": ["html", "text-summary"]
}

@ -0,0 +1 @@
ignore-extra=@thumbsup/theme-cards,@thumbsup/theme-classic,@thumbsup/theme-mosaic,@thumbsup/theme-flow

@ -0,0 +1,36 @@
# Build steps need access to the Docker agent
sudo: required
services:
- docker
jobs:
include:
# Run all the tests inside Docker
- stage: Test
script: docker build -f Dockerfile.test .
# If this is a tagged commit, publish the package to npm
- stage: Release npm
script: echo "Deploying to npm"
deploy:
provider: npm
email: asyncadventures@gmail.com
api_key: $NPM_TOKEN
on:
tags: true
condition: $TRAVIS_TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+
# If this is a tagged commit, publish a new Docker image
- stage: Release Docker
script: echo "Deploying to DockerHub"
deploy:
provider: script
script: scripts/travis-release-docker
on:
tags: true
condition: $TRAVIS_TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+

@ -2,7 +2,7 @@
# Builder image
# ------------------------------------------------
FROM ghcr.io/thumbsup/build:node-18 as build
FROM thumbsupgallery/build:alpine as build
# Install thumbsup locally
WORKDIR /thumbsup
@ -18,7 +18,7 @@ RUN npm install thumbsup@${PACKAGE_VERSION}
# Runtime image
# ------------------------------------------------
FROM ghcr.io/thumbsup/runtime:node-18
FROM thumbsupgallery/runtime:alpine
# Use tini as an init process
# to ensure all child processes (ffmpeg...) are always terminated properly

@ -1,6 +1,5 @@
# Node.js + build dependencies + runtime dependencies
FROM ghcr.io/thumbsup/build:node-18
WORKDIR /app
FROM thumbsupgallery/build:alpine
# Switch to a non-root user
# So we can test edge-cases around file permissions
@ -9,8 +8,8 @@ RUN chown -R tester:tester /app
USER tester
# Install and cache dependencies
COPY --chown=tester package*.json /app
RUN npm ci
COPY --chown=tester package.json /app
RUN npm install
# Copy the entire source code
COPY --chown=tester . /app

@ -6,8 +6,10 @@
[![Standard - JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](http://standardjs.com/)
<!-- Build status and code analysis -->
![Linux Tests](https://github.com/thumbsup/thumbsup/actions/workflows/test-linux.yml/badge.svg)
![Dependencies](https://img.shields.io/librariesio/release/npm/thumbsup)
[![Travis CI](https://travis-ci.org/thumbsup/thumbsup.svg?branch=master)](https://travis-ci.org/thumbsup/thumbsup)
[![Docker Hub](https://img.shields.io/docker/build/thumbsupgallery/thumbsup.svg)](https://hub.docker.com/r/thumbsupgallery/thumbsup)
[![Dependencies](http://img.shields.io/david/thumbsup/thumbsup.svg?style=flat)](https://david-dm.org/thumbsup/thumbsup)
[![Dev dependencies](https://david-dm.org/thumbsup/thumbsup/dev-status.svg?style=flat)](https://david-dm.org/thumbsup/thumbsup?type=dev)
<!-- Social sharing -->
[![Twitter](https://img.shields.io/badge/share-Twitter-1CA8F5.svg)](https://twitter.com/intent/tweet?text=Need%20static%20photo%20and%20video%20galleries?%20Check%20out%20Thumbsup%20on%20Github&url=https://github.com/thumbsup/thumbsup&hashtags=selfhosted,static,gallery)
@ -62,10 +64,10 @@ And optionally:
- [dcraw](https://www.cybercom.net/~dcoffin/dcraw/) to process RAW photos: `brew install dcraw`
- [ImageMagick](https://imagemagick.org/) for HEIC support (needs to be compiled with `--with-heic`)
You can run thumbsup as a Docker container ([ghcr.io/thumbsup/thumbsup](https://github.com/thumbsup/thumbsup/pkgs/container/thumbsup)) which pre-packages all the dependencies above. Read the [thumbsup on Docker](https://thumbsup.github.io/docs/2-installation/docker/) documentation for more detail.
You can run thumbsup as a Docker container ([thumbsupgallery/thumbsup](https://hub.docker.com/r/thumbsupgallery/thumbsup/)) which pre-packages all the dependencies above. Read the [thumbsup on Docker](https://thumbsup.github.io/docs/2-installation/docker/) documentation for more detail.
```bash
docker run -v `pwd`:/work ghcr.io/thumbsup/thumbsup [...]
docker run -v `pwd`:/work thumbsupgallery/thumbsup [...]
```
## Command line arguments
@ -73,12 +75,13 @@ docker run -v `pwd`:/work ghcr.io/thumbsup/thumbsup [...]
This reflects the CLI for the latest code on `master`.
For the latest published version please refer to the [docs on the website](https://thumbsup.github.io).
<!--STARTCLI-->
<!-- START cli -->
```
Usages:
thumbsup [required] [options]
thumbsup --config config.json
Usages:
thumbsup [required] [options]
thumbsup --config config.json
Required:
@ -86,7 +89,6 @@ Required:
--output Output path for the static website [string] [required]
Input options:
--scan-mode How files are indexed [choices: "full", "partial", "incremental"] [default: "full"]
--include-photos Include photos in the gallery [boolean] [default: true]
--include-videos Include videos in the gallery [boolean] [default: true]
--include-raw-photos Include raw photos in the gallery [boolean] [default: false]
@ -95,22 +97,18 @@ Input options:
Output options:
--thumb-size Pixel size of the square thumbnails [number] [default: 120]
--small-size Pixel height of the small photos [number] [default: 300]
--large-size Pixel height of the fullscreen photos [number] [default: 1000]
--photo-quality Quality of the resized/converted photos [number] [default: 90]
--video-quality Quality of the converted video (percent) [number] [default: 75]
--video-bitrate Bitrate of the converted videos (e.g. 120k) [string] [default: null]
--video-format Video output format [choices: "mp4", "webm"] [default: "mp4"]
--video-hwaccel Use hardware acceleration (requires bitrate) [choices: "none", "vaapi"] [default: "none"]
--video-stills Where the video still frame is taken [choices: "seek", "middle"] [default: "seek"]
--video-stills-seek Number of seconds where the still frame is taken [number] [default: 1]
--photo-preview How lightbox photos are generated [choices: "resize", "copy", "symlink", "link"] [default: "resize"]
--video-preview How lightbox videos are generated [choices: "resize", "copy", "symlink", "link"] [default: "resize"]
--photo-download How downloadable photos are generated [choices: "resize", "copy", "symlink", "link"] [default: "resize"]
--video-download How downloadable videos are generated [choices: "resize", "copy", "symlink", "link"] [default: "resize"]
--link-prefix Path or URL prefix for "linked" photos and videos [string]
--cleanup Remove any output file that's no longer needed [boolean] [default: false]
--concurrency Number of parallel parsing/processing operations [number] [default: 2]
--concurrency Number of parallel parsing/processing operations [number] [default: 4]
--output-structure File and folder structure for output media [choices: "folders", "suffix"] [default: "folders"]
--gm-args Custom image processing arguments for GraphicsMagick [array]
--watermark Path to a transparent PNG to be overlaid on all images [string]
@ -123,13 +121,7 @@ Album options:
--sort-media-by How to sort photos and videos [choices: "filename", "date"] [default: "date"]
--sort-media-direction Media sorting direction [choices: "asc", "desc"] [default: "asc"]
--home-album-name Name of the top-level album [string] [default: "Home"]
--album-page-size Max number of files displayed on a page [number] [default: null]
--album-zip-files Create a ZIP file per album [boolean] [default: false]
--include-keywords Keywords to include in %keywords [array]
--exclude-keywords Keywords to exclude from %keywords [array]
--include-people Names to include in %people [array]
--exclude-people Names to exclude from %people [array]
--album-previews How previews are selected [choices: "first", "spread", "random"] [default: "first"]
Website options:
--index Filename of the home page [string] [default: "index.html"]
@ -143,24 +135,21 @@ Website options:
--google-analytics Code for Google Analytics tracking [string]
--embed-exif Embed the exif metadata for each image into the gallery page [boolean] [default: false]
--locale Locale for regional settings like dates [string] [default: "en"]
--seo-location Location where the site will be hosted. If provided, sitemap.xml and robots.txt will be created. [string] [default: null]
Misc options:
--config JSON config file (one key per argument) [string]
--database-file Path to the database file [string]
--log-file Path to the log file [string]
--log Print a detailed text log [choices: "default", "info", "debug", "trace"] [default: "default"]
--dry-run Update the index, but don't create the media files / website [boolean] [default: false]
--config JSON config file (one key per argument) [string]
--log Print a detailed text log [choices: "default", "info", "debug", "trace"] [default: "default"]
--usage-stats Enable anonymous usage statistics [boolean] [default: true]
--dry-run Update the index, but don't create the media files / website [boolean] [default: false]
Deprecated:
--original-photos Copy and allow download of full-size photos (use --photo-download=copy) [boolean]
--original-videos Copy and allow download of full-size videos (use --video-download=copy) [boolean]
--original-photos Copy and allow download of full-size photos [boolean]
--original-videos Copy and allow download of full-size videos [boolean]
--albums-date-format How albums are named in <date> mode [moment.js pattern]
--css Path to a custom provided CSS/LESS file for styling [string]
--download-photos Target of the photo download links [choices: "large", "copy", "symlink", "link"]
--download-videos Target of the video download links [choices: "large", "copy", "symlink", "link"]
--download-link-prefix Path or URL prefix for linked downloads [string]
--usage-stats Enable anonymous usage statistics [boolean]
Options:
--version Show version number [boolean]
@ -171,7 +160,8 @@ Options:
per argument, not including the leading "--". For example:
{ "sort-albums-by": "start-date" }
```
<!--ENDCLI-->
<!-- END cli -->
## Contributing
@ -194,10 +184,3 @@ thumbsup [options] --log trace
If you want to contribute some code, please check out the [contributing guidelines](.github/CONTRIBUTING.md)
for an overview of the design and a run-through of the different automated/manual tests.
## Disclaimer
While a lot of effort is put into testing Thumbsup (over 400 automated tests), the software is provided as-is under the MIT license. The authors cannot be held responsible for any unintended behaviours.
We recommend running Thumbsup with the least appropriate privilege, such as giving read-only access to the source images.
The Docker setup detailed in the documentation follows this advice.

@ -0,0 +1,35 @@
const Insight = require('insight')
const path = require('path')
const pkg = require(path.join(__dirname, '..', 'package.json'))
// Google Analytics tracking code
const TRACKING_CODE = 'UA-110087713-3'
class Analytics {
constructor ({ enabled }) {
this.enabled = enabled
this.insight = new Insight({ trackingCode: TRACKING_CODE, pkg })
this.insight.optOut = !enabled
}
// report that the gallery has started building
start (done) {
this.insight.track('start')
}
// report that the gallery has finished building + some stats
finish (stats, done) {
this.insight.track('finish')
this.insight.trackEvent({ category: 'gallery', action: 'albums', label: 'Album count', value: stats.albums })
this.insight.trackEvent({ category: 'gallery', action: 'photos', label: 'Photo count', value: stats.photos })
this.insight.trackEvent({ category: 'gallery', action: 'videos', label: 'Video count', value: stats.videos })
}
// report that an error happened
// but don't report the contents (might contain file paths etc)
error (done) {
this.insight.track('error')
}
}
module.exports = Analytics

@ -18,13 +18,6 @@ const BINARIES = [
url: 'http://www.graphicsmagick.org',
msg: ''
},
{
// optional to process HEIC files
mandatory: false,
cmd: 'magick',
url: 'https://imagemagick.org',
msg: 'You will not be able to process HEIC images.'
},
{
// optional to process videos
mandatory: false,
@ -45,13 +38,6 @@ const BINARIES = [
cmd: 'dcraw',
url: 'https://www.cybercom.net/~dcoffin/dcraw/',
msg: 'You will not be able to process RAW photos.'
},
{
// optional to create album ZIP files
mandatory: false,
cmd: 'zip',
url: 'https://linux.die.net/man/1/zip',
msg: 'You will not be able to create ZIP files.'
}
]

@ -1,7 +1,8 @@
const fs = require('node:fs')
const util = require('node:util')
const tty = require('node:tty')
const debug = require('debug')
const fs = require('fs')
const path = require('path')
const util = require('util')
const tty = require('tty')
/*
Thumbsup uses the <debug> package for logging.
@ -23,7 +24,7 @@ const debug = require('debug')
If --log is not specified, but the output doesn't support ANSI (e.g. non-TTY terminal, or file redirection)
then the mode is automatically switched to "--log info"
*/
exports.init = (logLevel, logFile) => {
exports.init = (logLevel, outputFolder) => {
// if the output doesn't support ANSI codes (e.g. pipe, redirect to file)
// then switch to full-text mode, because Listr's output won't make much sense
if (logLevel === 'default' && !tty.isatty(process.stdout.fd)) {
@ -32,7 +33,7 @@ exports.init = (logLevel, logFile) => {
// Configure the loggers
if (logLevel === 'default') {
configureDefaultMode(logFile)
configureDefaultMode(outputFolder)
} else {
configureDebugMode(logLevel)
}
@ -54,8 +55,9 @@ function overrideDebugFormat () {
If --log is not specified, we won't show any detailed log on stdout
Instead we send all errors to a file for troubleshooting
*/
function configureDefaultMode (logFile) {
const stream = fs.createWriteStream(logFile, { flags: 'a' })
function configureDefaultMode (outputFolder) {
const logfile = path.join(outputFolder, 'thumbsup.log')
const stream = fs.createWriteStream(logfile, { flags: 'a' })
overrideDebugFormat()
debug.enable('thumbsup:error,thumbsup:warn')
debug.useColors = () => false

@ -3,6 +3,7 @@ const boxen = require('boxen')
const DOCS_URL = chalk.green('https://thumbsup.github.io/docs')
const ISSUES_URL = chalk.green('https://github.com/thumbsup/thumbsup/issues')
const LOG_FILE = chalk.green('thumbsup.log')
function box (str) {
const lines = str.split('\n').map(s => ` ${s} `).join('\n')
@ -39,17 +40,24 @@ exports.PROBLEMS = (count) => chalk.yellow(`
exports.GREETING = () => box(`
Thanks for using thumbsup!
Don't forget to check out the docs at ${DOCS_URL}.
When building a gallery, thumbsup reports anonymous stats such as the OS and
gallery size. This is used to understand usage patterns & guide development
effort. You can disable usage reporting by specifying --no-usage-stats.
This welcome message will not be shown again for this gallery.
Enjoy!
`)
exports.SORRY = (logFile) => box(`
exports.SORRY = () => box(`
Something went wrong!
An unexpected error occurred and the gallery didn't build successfully.
This is most likely an edge-case that hasn't been tested before.
Please check the logs at ${chalk.green(logFile)}.
Please check ${LOG_FILE} in the output folder.
To help improve thumbsup and hopefully resolve your problem,
you can raise an issue at ${ISSUES_URL}.
`)

@ -1,9 +1,8 @@
/* eslint-disable quote-props */
const path = require('node:path')
const os = require('node:os')
const _ = require('lodash')
const yargs = require('yargs')
const messages = require('./messages')
const path = require('path')
const yargs = require('yargs')
const os = require('os')
const _ = require('lodash')
const OPTIONS = {
@ -29,12 +28,6 @@ const OPTIONS = {
// ------------------------------------
// Input options
// ------------------------------------
'scan-mode': {
group: 'Input options:',
description: 'How files are indexed',
choices: ['full', 'partial', 'incremental'],
'default': 'full'
},
'include-photos': {
group: 'Input options:',
description: 'Include photos in the gallery',
@ -74,12 +67,6 @@ const OPTIONS = {
type: 'number',
'default': 120
},
'small-size': {
group: 'Output options:',
description: 'Pixel height of the small photos',
type: 'number',
'default': 300
},
'large-size': {
group: 'Output options:',
description: 'Pixel height of the fullscreen photos',
@ -110,24 +97,6 @@ const OPTIONS = {
choices: ['mp4', 'webm'],
'default': 'mp4'
},
'video-hwaccel': {
group: 'Output options:',
description: 'Use hardware acceleration (requires bitrate)',
choices: ['none', 'vaapi'],
'default': 'none'
},
'video-stills': {
group: 'Output options:',
description: 'Where the video still frame is taken',
choices: ['seek', 'middle'],
'default': 'seek'
},
'video-stills-seek': {
group: 'Output options:',
description: 'Number of seconds where the still frame is taken',
type: 'number',
'default': 1
},
'photo-preview': {
group: 'Output options:',
description: 'How lightbox photos are generated',
@ -209,28 +178,24 @@ const OPTIONS = {
group: 'Album options:',
description: 'How to sort albums',
choices: ['title', 'start-date', 'end-date'],
coerce: commaSeparated,
'default': 'start-date'
},
'sort-albums-direction': {
group: 'Album options:',
description: 'Album sorting direction',
choices: ['asc', 'desc'],
coerce: commaSeparated,
'default': 'asc'
},
'sort-media-by': {
group: 'Album options:',
description: 'How to sort photos and videos',
choices: ['filename', 'date'],
coerce: commaSeparated,
'default': 'date'
},
'sort-media-direction': {
group: 'Album options:',
description: 'Media sorting direction',
choices: ['asc', 'desc'],
coerce: commaSeparated,
'default': 'asc'
},
'home-album-name': {
@ -239,54 +204,12 @@ const OPTIONS = {
type: 'string',
'default': 'Home'
},
'album-page-size': {
group: 'Album options:',
description: 'Max number of files displayed on a page',
type: 'number',
'default': null
},
'album-zip-files': {
group: 'Album options:',
description: 'Create a ZIP file per album',
type: 'boolean',
'default': false
},
// 'keyword-fields': {
// group: 'Album options:',
// description: 'Where to look in the metadata data for keywords (for %keywords)',
// type: 'array'
// },
'include-keywords': {
group: 'Album options:',
description: 'Keywords to include in %keywords',
type: 'array'
},
'exclude-keywords': {
group: 'Album options:',
description: 'Keywords to exclude from %keywords',
type: 'array'
},
// 'people-fields': {
// group: 'Album options:',
// description: 'Where to look in the metadata data for people names (for %people)',
// type: 'array'
// },
'include-people': {
group: 'Album options:',
description: 'Names to include in %people',
type: 'array'
},
'exclude-people': {
group: 'Album options:',
description: 'Names to exclude from %people',
type: 'array'
},
'album-previews': {
group: 'Album options:',
description: 'How previews are selected',
choices: ['first', 'spread', 'random'],
'default': 'first'
},
// ------------------------------------
// Website options
@ -357,12 +280,6 @@ const OPTIONS = {
type: 'string',
'default': 'en'
},
'seo-location': {
group: 'Website options:',
description: 'Location where the site will be hosted. If provided, sitemap.xml and robots.txt will be created.',
type: 'string',
'default': null
},
// ------------------------------------
// Misc options
@ -374,18 +291,6 @@ const OPTIONS = {
normalize: true
},
'database-file': {
group: 'Misc options:',
description: 'Path to the database file',
normalize: true
},
'log-file': {
group: 'Misc options:',
description: 'Path to the log file',
normalize: true
},
'log': {
group: 'Misc options:',
description: 'Print a detailed text log',
@ -393,6 +298,13 @@ const OPTIONS = {
'default': 'default'
},
'usage-stats': {
group: 'Misc options:',
description: 'Enable anonymous usage statistics',
type: 'boolean',
'default': true
},
'dry-run': {
group: 'Misc options:',
description: "Update the index, but don't create the media files / website",
@ -406,12 +318,12 @@ const OPTIONS = {
'original-photos': {
group: 'Deprecated:',
description: 'Copy and allow download of full-size photos (use --photo-download=copy)',
description: 'Copy and allow download of full-size photos',
type: 'boolean'
},
'original-videos': {
group: 'Deprecated:',
description: 'Copy and allow download of full-size videos (use --video-download=copy)',
description: 'Copy and allow download of full-size videos',
type: 'boolean'
},
'albums-date-format': {
@ -437,18 +349,13 @@ const OPTIONS = {
group: 'Deprecated:',
description: 'Path or URL prefix for linked downloads',
type: 'string'
},
'usage-stats': {
group: 'Deprecated:',
description: 'Enable anonymous usage statistics',
type: 'boolean'
}
}
// explicitly pass <process.argv> so we can unit test this logic
// otherwise it pre-loads all process arguments on require()
exports.get = (args, exitOnFailure = true) => {
exports.get = (args) => {
const parsedOptions = yargs(args)
.usage(messages.USAGE())
.wrap(null)
@ -456,8 +363,6 @@ exports.get = (args, exitOnFailure = true) => {
.config('config')
.options(OPTIONS)
.epilogue(messages.CONFIG_USAGE())
.exitProcess(exitOnFailure)
.check(validation)
.argv
// Warn users when they use deprecated options
@ -472,21 +377,9 @@ exports.get = (args, exitOnFailure = true) => {
// This means we can process the camelCase version only after that
const opts = _.omitBy(parsedOptions, (value, key) => key.indexOf('-') >= 0)
// Default database file
if (!opts.databaseFile) {
opts.databaseFile = path.join(opts.output, 'thumbsup.db')
}
// Default log file
if (!opts.logFile) {
opts.logFile = changeExtension(opts.databaseFile, '.log')
}
// Better to work with absolute paths
// Make input/output folder absolute paths
opts.input = path.resolve(opts.input)
opts.output = path.resolve(opts.output)
opts.databaseFile = path.resolve(opts.databaseFile)
opts.logFile = path.resolve(opts.logFile)
// By default, use relative links to the input folder
if (opts.downloadLinkPrefix) opts.linkPrefix = opts.downloadLinkPrefix
@ -512,38 +405,16 @@ exports.get = (args, exitOnFailure = true) => {
// Add a dash prefix to any --gm-args value
// We can't specify the prefix on the CLI otherwise the parser thinks it's a thumbsup arg
if (opts.gmArgs) {
opts.gmArgs = opts.gmArgs.map(val => val.startsWith('+') ? val : `-${val}`)
opts.gmArgs = opts.gmArgs.map(val => `-${val}`)
}
return opts
}
function validation (opts) {
// all custom validation rules go here
// this way, they are reported the same way as invalid arguments
if (opts.videoHwaccel !== 'none' && !opts.videoBitrate) {
throw new Error('--video-hwaccel requires a value for --bitrate')
}
// everything is OK
return true
}
function replaceInArray (list, match, replacement) {
for (let i = 0; i < list.length; ++i) {
for (var i = 0; i < list.length; ++i) {
if (list[i] === match) {
list[i] = replacement
}
}
}
function commaSeparated (value) {
if (value.indexOf(',') === -1) return value
return value.split(',')
}
function changeExtension (file, ext) {
const originalExt = path.extname(file)
const filename = path.basename(file, originalExt)
return path.join(path.dirname(file), filename + ext)
}
/* eslint-enable quote-props */

@ -1,10 +1,12 @@
#!/usr/bin/env node
const fs = require('node:fs')
const fs = require('fs-extra')
const path = require('path')
const moment = require('moment')
const dependencies = require('../src/cli/dependencies')
const messages = require('../src/cli/messages')
const options = require('../src/cli/options')
const Analytics = require('./analytics')
const dependencies = require('./dependencies')
const messages = require('./messages')
const options = require('./options')
console.log('')
@ -13,16 +15,23 @@ const args = process.argv.slice(2)
const opts = options.get(args)
// Only require the index after logging options have been set
fs.mkdirSync(opts.output, { recursive: true })
require('./log').init(opts.log, opts.logFile)
fs.mkdirpSync(opts.output)
require('./log').init(opts.log, opts.output)
const index = require('../src/index')
// If this is the first run, display a welcome message
const firstRun = fs.existsSync(opts.databaseFile) === false
const indexPath = path.join(opts.output, 'thumbsup.db')
const firstRun = fs.existsSync(indexPath) === false
if (firstRun) {
console.log(`${messages.GREETING()}\n`)
}
// Basic usage report (anonymous statistics)
const analytics = new Analytics({
enabled: opts['usageStats']
})
analytics.start()
// Catch all exceptions and exit gracefully
process.on('uncaughtException', handleError)
process.on('unhandledRejection', handleError)
@ -52,6 +61,7 @@ index.build(opts, (err, result) => {
photos: result.album.stats.photos,
videos: result.album.stats.videos
}
analytics.finish(stats)
console.log(messages.SUCCESS(stats) + '\n')
exit(0)
}
@ -60,20 +70,24 @@ index.build(opts, (err, result) => {
// Print an error report and exit
// Note: remove "err.context" (entire data model) which can make the output hard to read
function handleError (err) {
analytics.error()
delete err.context
require('debug')('thumbsup:error')(err)
console.error('\nUnexpected error', err.message)
console.error(`\n${messages.SORRY(opts.logFile)}\n`)
console.error(`\n${messages.SORRY()}\n`)
exit(1)
}
// Force a successful or failed exit
// This is required because capturing unhandled errors will make Listr run forever
// This is required
// - because capturing unhandled errors will make Listr run forever
// - to ensure pending Analytics HTTP requests don't keep the tool running
function exit (code) {
process.exit(code)
// just some time to ensure analytics has time to fire
setTimeout(() => process.exit(code), 10)
}
// Count the total number of nested albums
// Cound the total number of nested albums
function countAlbums (total, album) {
return 1 + album.albums.reduce(countAlbums, total)
}

@ -1,11 +0,0 @@
# -------------------------------------------------
# This Docker image is used to speed up the builds
# -------------------------------------------------
ARG NODE_VERSION
# Node.js + runtime dependencies
FROM ghcr.io/thumbsup/runtime:node-${NODE_VERSION}
# Standard build dependencies for npm install
RUN apk add --no-cache git make g++ python3 bash

@ -1,17 +0,0 @@
# -------------------------------------------------
# This Docker image contains all the typical
# runtime dependencies for thumbsup, including
# exiftool, imagemagick, ffmpeg, gifsicle...
# -------------------------------------------------
ARG NODE_VERSION
FROM node:${NODE_VERSION}-alpine as base
# Metadata
LABEL org.opencontainers.image.source https://github.com/thumbsup/thumbsup
# Add libraries
RUN apk add --update --no-cache libgomp zlib libpng libjpeg-turbo libwebp tiff lcms2 x265 libde265 libheif
# Add external programs
RUN apk add --update --no-cache ffmpeg imagemagick graphicsmagick exiftool gifsicle zip

14538
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
{
"name": "thumbsup",
"version": "2.18.0",
"version": "2.12.0",
"description": "Photo / video gallery generator",
"author": "Romain Prieto",
"license": "MIT",
@ -27,62 +27,72 @@
"bin"
],
"scripts": {
"pretest": "standard && depcheck",
"test": "c8 mocha"
"lint": "standard && require-lint",
"unit": "istanbul cover node_modules/mocha/bin/_mocha",
"test": "npm run lint && npm run unit"
},
"dependencies": {
"@thumbsup/theme-cards": "^1.3.0",
"@thumbsup/theme-classic": "^1.2.0",
"@thumbsup/theme-flow": "^1.3.0",
"@thumbsup/theme-mosaic": "^1.2.0",
"async": "^3.2.5",
"better-sqlite3": "^9.1.1",
"boxen": "^5.0.1",
"chalk": "^4.1.0",
"command-exists": "^1.2.9",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"fs-readdir-recursive": "^1.1.0",
"handlebars": "^4.7.8",
"ini": "^4.1.1",
"@thumbsup/theme-cards": "^1.2.0",
"@thumbsup/theme-classic": "^1.1.0",
"@thumbsup/theme-flow": "^1.2.1",
"@thumbsup/theme-mosaic": "^1.1.0",
"JSONStream": "^1.3.5",
"less": "^4.2.0",
"async": "^3.0.1",
"better-sqlite3": "^5.4.0",
"boxen": "^4.0.0",
"chalk": "^2.4.2",
"command-exists": "^1.2.8",
"debug": "^4.1.1",
"event-stream": "^4.0.1",
"fs-extra": "^8.0.1",
"fs-readdir-recursive": "^1.0.0",
"handlebars": "^4.1.2",
"ini": "^1.3.4",
"insight": "^0.10.3",
"less": "^3.9.0",
"listr": "^0.14.3",
"lodash": "^4.17.21",
"micromatch": "^4.0.5",
"moment": "^2.29.4",
"readdir-enhanced": "^6.0.4",
"lodash": "^4.17.11",
"micromatch": "^4.0.2",
"moment": "^2.24.0",
"readdir-enhanced": "^2.2.4",
"resolve-pkg": "^2.0.0",
"slugify": "^1.6.6",
"thumbsup-downsize": "^2.5.2",
"url-join": "^4.0.1",
"yargs": "^17.7.2",
"zen-observable": "^0.10.0"
"slugify": "^1.3.4",
"through2": "^3.0.1",
"thumbsup-downsize": "^2.3.1",
"url-join": "^4.0.0",
"yargs": "^13.2.4",
"zen-observable": "^0.8.14"
},
"devDependencies": {
"c8": "^8.0.1",
"depcheck": "^1.4.7",
"glob": "^10.3.10",
"mocha": "^10.2.0",
"mock-fs": "^5.2.0",
"glob": "^7.1.4",
"gm": "^1.23.1",
"injectmd": "^1.0.0",
"istanbul": "^0.4.5",
"markdown-toc": "^1.1.0",
"mocha": "^6.1.4",
"mock-fs": "^4.10.0",
"mock-spawn": "^0.2.6",
"require-all": "^3.0.0",
"require-lint": "^1.3.0",
"should": "^13.2.3",
"sinon": "^17.0.1",
"standard": "^17.1.0",
"stream-mock": "^2.0.5",
"tmp": "^0.2.1",
"yaml": "^2.3.4"
"sinon": "^7.3.2",
"standard": "^12.0.1",
"stream-mock": "^2.0.2",
"tmp": "^0.1.0"
},
"standard": {
"env": [
"mocha"
],
"ignore": [
"themes/**/public"
],
"globals": [
"itLinux",
"itWindows"
"after",
"afterEach",
"before",
"beforeEach",
"describe",
"xdescribe",
"it",
"xit"
]
}
}

@ -0,0 +1,11 @@
#!/bin/bash -e
PATH=$PATH:./node_modules/.bin
function cli {
echo "\`\`\`"
node bin/thumbsup.js --help
echo "\`\`\`"
}
echo "$(cli)" | node-injectmd -t cli -i README.md

@ -0,0 +1,20 @@
#!/bin/bash -e
if [ -z "${TRAVIS_TAG}" ]; then
echo "This script releases a Docker image corresponding to the npm package version"
echo "It should only be run on Travis CI for tagged commits"
exit 1
fi
# Build the image
DOCKER_IMAGE="thumbsupgallery/thumbsup"
PACKAGE_VERSION="${TRAVIS_TAG//v}"
docker build -f Dockerfile.release -t "${DOCKER_IMAGE}:${PACKAGE_VERSION}" --build-arg "PACKAGE_VERSION=${PACKAGE_VERSION}" .
# Pushes both <thumbsup:x.y.z> and <thumbsup:latest>
docker login -u "${DOCKER_USERNAME}" -p "${DOCKER_PASSWORD}"
docker tag "${DOCKER_IMAGE}:${PACKAGE_VERSION}" "${DOCKER_IMAGE}:latest"
docker push "${DOCKER_IMAGE}:${PACKAGE_VERSION}"
docker push "${DOCKER_IMAGE}:latest"

@ -1,11 +0,0 @@
const fs = require('node:fs')
const child = require('node:child_process')
// get latest CLI help text
const output = child.execSync('node bin/thumbsup.js --help')
const codeblock = '```' + output + '```'
// update README file
const readme = fs.readFileSync('README.md', 'utf-8')
const updated = readme.replace(/<!--STARTCLI-->[\s\S]*?<!--ENDCLI-->/, `<!--STARTCLI-->\n${codeblock}\n<!--ENDCLI-->`)
fs.writeFileSync('README.md', updated)

@ -1,8 +1,8 @@
const os = require('node:os')
const stream = require('node:stream')
const _ = require('lodash')
const debug = require('debug')('thumbsup:debug')
const es = require('event-stream')
const exiftool = require('./stream.js')
const os = require('os')
/*
Fans out the list of files to multiple exiftool processes (default = CPU count)
@ -19,23 +19,5 @@ exports.parse = (rootFolder, filePaths, concurrency) => {
return exiftool.parse(rootFolder, buckets[i])
})
// merge the object streams
return merge(streams)
}
function merge (streams) {
let ended = 0
const merged = new stream.PassThrough({ objectMode: true })
streams.forEach(s => {
s.pipe(merged, { end: false })
s.once('end', () => {
++ended
if (ended === streams.length) {
merged.emit('end')
}
})
s.once('error', (err) => {
merged.emit('error', err)
})
})
return merged
return es.merge(streams)
}

@ -1,8 +1,9 @@
const childProcess = require('node:child_process')
const trace = require('debug')('thumbsup:trace')
const childProcess = require('child_process')
const debug = require('debug')('thumbsup:debug')
const error = require('debug')('thumbsup:error')
const es = require('event-stream')
const JSONStream = require('JSONStream')
const through2 = require('through2')
/*
Spawn a single <exiftool> process and send all the files to be parsed
@ -17,8 +18,6 @@ exports.parse = (rootFolder, filePaths) => {
'%+.6f', // lat/long = float values
'-struct', // preserve XMP structure
'-json', // JSON output
'-charset', // allow UTF8 filenames
'filename=utf8', // allow UTF8 filenames
'-@', // specify more arguments separately
'-' // read arguments from standard in
]
@ -26,31 +25,15 @@ exports.parse = (rootFolder, filePaths) => {
// create a new <exiftool> child process
const child = childProcess.spawn('exiftool', args, {
cwd: rootFolder,
stdio: ['pipe', 'pipe', 'pipe']
stdio: [ 'pipe', 'pipe', 'ignore' ]
})
// stream <stdout> into a JSON parser
// parse every top-level object and emit it on the stream
const parser = JSONStream.parse([true])
child.stdout.pipe(parser)
// Error handling
child.on('error', (err) => {
error('Error: please verify that <exiftool> is installed on your system')
error(`Error: please verify that <exiftool> is installed on your system`)
error(err.toString())
})
child.on('close', (code, signal) => {
debug(`Exiftool exited with code ${code}`)
})
parser.on('error', (err) => {
error('Error: failed to parse JSON from Exiftool output')
error(err.message)
})
// Print exiftool error messages if any
child.stderr.on('data', chunk => {
trace('Exiftool output:', chunk.toString())
})
// write all files to <stdin>
// exiftool will only start processing after <stdin> is closed
@ -58,5 +41,17 @@ exports.parse = (rootFolder, filePaths) => {
child.stdin.write(allFiles + '\n')
child.stdin.end()
return parser
// stream <stdout> into a JSON parser
// parse every top-level object and emit it on the stream
return es.pipeline(
child.stdout,
through2(chunkToString),
JSONStream.parse([true])
)
}
function chunkToString (chunk, enc, callback) {
// convert to string to help JSONStream deal with odd encodings
this.push(chunk.toString())
callback()
}

@ -1,44 +1,27 @@
/* eslint-disable no-prototype-builtins */
const _ = require('lodash')
const GlobPattern = require('./pattern')
/*
Calculate the difference between files on disk and already indexed
- databaseMap = hashmap of {path, timestamp}
- diskMap = hashmap of {path, timestamp}
*/
exports.calculate = (databaseMap, diskMap, { scanMode = 'full', include, exclude }) => {
exports.calculate = (databaseMap, diskMap) => {
const delta = {
unchanged: [],
added: [],
modified: [],
deleted: [],
skipped: []
deleted: []
}
// TODO: the glob pattern should be passed in
// It should be identical to the one used by the Glob object that scans the disk
// For now, partial scans only uses the include/exclude filter
// If we pass it it, other filters would apply as well (e.g. photo/video/raw...)
const pattern = new GlobPattern({ include, exclude, extensions: [] })
_.each(databaseMap, (dbTime, dbPath) => {
const shouldProcessDBEntry = (scanMode === 'full') ? true : pattern.match(dbPath)
if (shouldProcessDBEntry) {
if (diskMap.hasOwnProperty(dbPath)) {
const modified = Math.abs(dbTime - diskMap[dbPath]) > 1000
if (modified) {
delta.modified.push(dbPath)
} else {
delta.unchanged.push(dbPath)
}
if (diskMap.hasOwnProperty(dbPath)) {
const modified = Math.abs(dbTime - diskMap[dbPath]) > 1000
if (modified) {
delta.modified.push(dbPath)
} else {
if (scanMode === 'incremental') {
delta.skipped.push(dbPath)
} else {
delta.deleted.push(dbPath)
}
delta.unchanged.push(dbPath)
}
} else {
delta.skipped.push(dbPath)
delta.deleted.push(dbPath)
}
})
_.each(diskMap, (diskTime, diskPath) => {
@ -48,4 +31,3 @@ exports.calculate = (databaseMap, diskMap, { scanMode = 'full', include, exclude
})
return delta
}
/* eslint-enable no-prototype-builtins */

@ -3,7 +3,7 @@ const warn = require('debug')('thumbsup:warn')
const GlobPattern = require('./pattern')
const PHOTO_EXT = ['bmp', 'gif', 'jpg', 'jpeg', 'png', 'tif', 'tiff', 'webp', 'heic']
const VIDEO_EXT = ['3gp', 'avi', 'flv', 'm2ts', 'm4v', 'mkv', 'mp4', 'mpg', 'mpeg', 'mov', 'mts', 'ogg', 'ogv', 'webm', 'wmv']
const VIDEO_EXT = ['3gp', 'avi', 'flv', 'm2ts', 'm4v', 'mkv', 'mp4', 'mov', 'mts', 'ogg', 'ogv', 'webm', 'wmv']
const RAW_PHOTO_EXT = [
'3fr', 'arw', 'cr2', 'crw', 'dcr', 'dng', 'erf', 'k25', 'kdc',
'mef', 'mrw', 'nef', 'orf', 'pef', 'raf', 'sr2', 'srf', 'x3f'
@ -16,14 +16,13 @@ const RAW_PHOTO_EXT = [
exports.find = function (rootFolder, options, callback) {
const entries = {}
const pattern = new GlobPattern({
include: (options.include && options.include.length > 0) ? options.include : ['**/**'],
include: (options.include && options.include.length > 0) ? options.include : '**/**',
exclude: options.exclude || [],
extensions: exports.supportedExtensions(options)
})
const stream = readdir.stream(rootFolder, {
const stream = readdir.readdirStreamStat(rootFolder, {
filter: file => pattern.match(file.path),
deep: dir => pattern.canTraverse(dir.path),
stats: true,
basePath: '',
sep: '/'
})

@ -1,19 +1,19 @@
const EventEmitter = require('node:events')
const fs = require('node:fs')
const path = require('node:path')
const _ = require('lodash')
const Database = require('better-sqlite3')
const moment = require('moment')
const delta = require('./delta')
const EventEmitter = require('events')
const exiftool = require('../exiftool/parallel')
const fs = require('fs-extra')
const globber = require('./glob')
const moment = require('moment')
const path = require('path')
const EXIF_DATE_FORMAT = 'YYYY:MM:DD HH:mm:ssZ'
class Index {
constructor (indexPath) {
// create the database if it doesn't exist
fs.mkdirSync(path.dirname(indexPath), { recursive: true })
fs.mkdirpSync(path.dirname(indexPath))
this.db = new Database(indexPath, {})
this.db.exec('CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, timestamp INTEGER, metadata BLOB)')
}
@ -34,13 +34,13 @@ class Index {
// create hashmap of all files in the database
const databaseMap = {}
for (const row of selectStatement.iterate()) {
for (var row of selectStatement.iterate()) {
databaseMap[row.path] = row.timestamp
}
function finished () {
// emit every file in the index
for (const row of selectMetadata.iterate()) {
for (var row of selectMetadata.iterate()) {
emitter.emit('file', {
path: row.path,
timestamp: new Date(row.timestamp),
@ -57,15 +57,13 @@ class Index {
if (err) return console.error('error', err)
// calculate the difference: which files have been added, modified, etc
const deltaFiles = delta.calculate(databaseMap, diskMap, options)
const deltaFiles = delta.calculate(databaseMap, diskMap)
emitter.emit('stats', {
database: Object.keys(databaseMap).length,
disk: Object.keys(diskMap).length,
unchanged: deltaFiles.unchanged.length,
added: deltaFiles.added.length,
modified: deltaFiles.modified.length,
deleted: deltaFiles.deleted.length,
skipped: deltaFiles.skipped.length
total: Object.keys(diskMap).length
})
// remove deleted files from the DB
@ -74,7 +72,7 @@ class Index {
})
// check if any files need parsing
let processed = 0
var processed = 0
const toProcess = _.union(deltaFiles.added, deltaFiles.modified)
if (toProcess.length === 0) {
return finished()
@ -87,7 +85,7 @@ class Index {
const timestamp = moment(entry.File.FileModifyDate, EXIF_DATE_FORMAT).valueOf()
insertStatement.run(entry.SourceFile, timestamp, JSON.stringify(entry))
++processed
emitter.emit('progress', { path: entry.SourceFile, processed, total: toProcess.length })
emitter.emit('progress', { path: entry.SourceFile, processed: processed, total: toProcess.length })
}).on('end', finished)
})

@ -1,13 +1,10 @@
const path = require('node:path')
const _ = require('lodash')
const micromatch = require('micromatch')
class GlobPattern {
constructor ({ include, exclude, extensions }) {
this.includeList = (include && include.length > 0) ? include : ['**/**']
this.excludeList = exclude || []
this.includeFolders = _.uniq(_.flatMap(this.includeList, this.subFolders))
this.directoryExcludeList = this.excludeList.concat(['**/@eaDir/**', '#recycle/**'])
this.includeList = include
this.excludeList = exclude
this.directoryExcludeList = exclude.concat(['**/@eaDir/**', '#recycle/**'])
this.extensions = extPattern(extensions)
}
@ -23,29 +20,13 @@ class GlobPattern {
canTraverse (folderPath) {
const opts = { dot: false, nocase: true }
const withSlash = `${folderPath}/`
return micromatch.any(withSlash, this.includeFolders, opts) &&
return micromatch.any(withSlash, this.includeList, opts) &&
micromatch.any(withSlash, this.directoryExcludeList, opts) === false
}
// returns the list of all folder names in a path
// so they can be included in traversal
subFolders (filepath) {
// keep the required path if it allows traversal (thing/ or thing/**)
const list = filepath.match(/(\/$)|(\*\*$)/) ? [filepath] : []
// then find all parent folders
let dir = path.dirname(filepath)
while (dir !== '.' && dir !== '/') {
list.push(dir + '/')
dir = path.dirname(dir)
}
return list
}
}
function extPattern (extensions) {
if (extensions.length === 0) {
return '**/*'
} else if (extensions.length === 1) {
if (extensions.length === 1) {
return '**/*.' + extensions[0]
} else {
return '**/*.{' + extensions.join(',') + '}'

@ -1,74 +0,0 @@
# listr-work-queue
> Drop-in [`Listr`](https://github.com/SamVerschueren/listr) subclass for tasks that need to be picked from a queue concurrently
------
:information_source: *About this package*
*The main difference with the standard `concurrent: <count>` option is that tasks are only rendered as they get picked up, and disappear once processed.*
*This component will not be necessary anymore if the following `Listr` feature request is implemented: [#53 Display only the task that is running](https://github.com/SamVerschueren/listr/issues/53).
When it is, we can simply rely on the `concurrent: <count>` option and let Listr look after the scheduling / execution of the tasks.*
------
## Usage
```js
const Listr = require('listr')
const ListrWorkQueue = require('./listr-work-queue/index.js')
const tasks = new Listr([{
title: 'Running jobs',
task: () => new ListrWorkQueue(/* tasks */, {
concurrent: WORKER_COUNT,
exitOnError: false
})
}])
tasks.run().then(() => console.log('Done'))
```
## Jobs
Every job is an object with 2 properties, similar to the standard `Listr` tasks.
The `task` property **must** be a Promise. Observables and streams are not supported.
```js
{
title: 'Job A',
task: () => new Promise(resolve => setTimeout(resolve, 1000))
}
```
## Options
- `concurrent`: the number of workers getting tasks from the queue.
```js
{
concurrent: os.cpus().length
}
```
- `exitOnError`: whether to stop processing new jobs when one of the jobs fails (default is `true`). Note that pending tasks will still complete before the process is stopped.
```js
{
exitOnError: false
}
```
- `update()`: a callback to report on the overall progress. This can be used for example to update the parent task title.
```js
{
title: 'Running jobs',
task: (ctx, parent) => new ListrWorkQueue(/* tasks */, {
update: (completed, total) => {
parent.title = `Running jobs (${completed}/${total})`
}
})
}
```

@ -43,8 +43,9 @@ exports.build = function (opts, done) {
{
title: 'Cleaning up',
enabled: (ctx) => opts.cleanup,
skip: () => opts.dryRun,
task: (ctx) => {
return steps.cleanup(ctx.files, opts.output, opts.dryRun)
return steps.cleanup(ctx.files, opts.output)
}
},
{
@ -57,7 +58,7 @@ exports.build = function (opts, done) {
})
}
], {
renderer,
renderer: renderer,
dateFormat: false
})

@ -5,22 +5,21 @@ based on the --albums-from array of patterns provided
--------------------------------------------------------------------------------
*/
const path = require('node:path')
const _ = require('lodash')
const path = require('path')
const albumPattern = require('./album-pattern')
class AlbumMapper {
constructor (patterns, opts) {
constructor (patterns) {
const defaulted = (patterns && patterns.length > 0) ? patterns : ['%path']
this.patterns = defaulted.map(p => load(p, opts))
this.patterns = defaulted.map(load)
}
getAlbums (file) {
return _.flatMap(this.patterns, pattern => pattern(file))
}
}
function load (pattern, opts) {
function load (pattern) {
// custom mapper file
if (typeof pattern === 'string' && pattern.startsWith('file://')) {
const filepath = pattern.slice('file://'.length)
@ -28,7 +27,7 @@ function load (pattern, opts) {
}
// string pattern
if (typeof pattern === 'string') {
return albumPattern.create(pattern, opts)
return albumPattern.create(pattern)
}
// already a function
return pattern

@ -5,8 +5,8 @@ Can be based on anything, e.g. directory name, date, metadata keywords...
e.g. `Holidays/London/IMG_00001.jpg` -> `Holidays/London`
--------------------------------------------------------------------------------
*/
const path = require('node:path')
const moment = require('moment')
const path = require('path')
const TOKEN_REGEX = /%[a-z]+/g
const DATE_REGEX = /{[^}]+}/g
@ -15,21 +15,15 @@ const TOKEN_FUNC = {
'%path': file => path.dirname(file.path)
}
exports.create = (pattern, opts) => {
exports.create = pattern => {
const cache = {
usesTokens: TOKEN_REGEX.test(pattern),
usesDates: DATE_REGEX.test(pattern),
usesKeywords: pattern.indexOf('%keywords') > -1,
usesPeople: pattern.indexOf('%people') > -1
usesKeywords: pattern.indexOf('%keywords') > -1
}
// return a standard mapper function (file => album names)
return mapperFunction(pattern, cache, opts)
}
function mapperFunction (pattern, cache, opts) {
if (opts === undefined) { opts = {} }
return file => {
let album = pattern
var album = pattern
// replace known tokens
if (cache.usesTokens) {
album = album.replace(TOKEN_REGEX, token => replaceToken(file, token))
@ -37,23 +31,15 @@ function mapperFunction (pattern, cache, opts) {
if (cache.usesDates) {
album = album.replace(DATE_REGEX, format => replaceDate(file, format))
}
// create one album per keyword if required
if (cache.usesKeywords) {
// create one album per keyword
return replaceTags(file.meta.keywords, { includes: opts.includeKeywords, excludes: opts.excludeKeywords }, album, '%keywords')
} else if (cache.usesPeople) {
// create one album per person
return replaceTags(file.meta.people, { includes: opts.includePeople, excludes: opts.excludePeople }, album, '%people')
return file.meta.keywords.map(k => album.replace('%keywords', k))
} else {
return [album]
}
}
}
function replaceTags (words, filter, album, tag) {
words = filterWords(words, filter)
return words.map(k => album.replace(tag, k))
}
function replaceToken (file, token) {
const fn = TOKEN_FUNC[token]
return fn ? fn(file) : token
@ -63,18 +49,3 @@ function replaceDate (file, format) {
const fmt = format.slice(1, -1)
return moment(file.meta.date).format(fmt)
}
function filterWords (words, filter) {
const { includes, excludes } = filter
if (includes && includes.length > 0) words = setIntersection(words, includes)
if (excludes && excludes.length > 0) words = setDifference(words, excludes)
return words
}
function setDifference (words, excludeWords) {
return words.filter(x => !excludeWords.includes(x))
}
function setIntersection (words, includeWords) {
return words.filter(x => includeWords.includes(x))
}

@ -1,35 +1,32 @@
const path = require('node:path')
const _ = require('lodash')
const path = require('path')
const Album = require('../model/album')
exports.createAlbums = function (collection, mapper, opts, picasaReader) {
exports.createAlbums = function (collection, mapper, opts) {
// returns a top-level album for the home page
// under which all files are grouped into sub-albums
// and finalised recursively (calculate stats, etc...)
const home = group(collection, mapper, opts, picasaReader)
const home = group(collection, mapper, opts.homeAlbumName)
home.finalize(opts)
return home
}
function group (collection, mapper, opts, picasaReader) {
function group (collection, mapper, homeAlbumName) {
// this hashtable will contain all albums, with the full path as key
// e.g. groups['holidays/tokyo']
const groups = {
var groups = {
// the home album is indexed as '.'
// the value of '.' is local to this function, and doesn't appear anywhere else
'.': new Album(opts.homeAlbumName)
'.': new Album(homeAlbumName)
}
// put all files in the right albums
// a file can be in multiple albums
collection.forEach(function (file) {
const albums = _.chain(mapper.getAlbums(file))
// All special names map to the same home
.map(albumPath => !albumPath || ['', '.', '/'].includes(albumPath) ? '.' : albumPath)
// no duplicate albums
.uniq()
.value()
const albums = mapper.getAlbums(file)
albums.forEach(albumPath => {
createAlbumHierarchy(groups, albumPath, opts, picasaReader)
if (!albumPath || albumPath === '/') {
albumPath = '.'
}
createAlbumHierarchy(groups, albumPath)
groups[albumPath].files.push(file)
})
})
@ -37,27 +34,17 @@ function group (collection, mapper, opts, picasaReader) {
return groups['.']
}
function createAlbumHierarchy (allGroupNames, segment, opts, picasaReader) {
if (!allGroupNames.hasOwnProperty(segment)) { // eslint-disable-line
function createAlbumHierarchy (allGroupNames, segment) {
if (!allGroupNames.hasOwnProperty(segment)) {
// create parent albums first
const parent = path.dirname(segment)
var parent = path.dirname(segment)
if (parent !== '.') {
createAlbumHierarchy(allGroupNames, parent, opts, picasaReader)
createAlbumHierarchy(allGroupNames, parent)
}
const picasaName = getPicasaName(segment, opts, picasaReader)
const lastSegment = path.basename(segment)
const title = picasaName || lastSegment
// then create album if it doesn't exist
// and attach it to its parent
allGroupNames[segment] = new Album({ title })
var lastSegment = path.basename(segment)
allGroupNames[segment] = new Album({ title: lastSegment })
allGroupNames[parent].albums.push(allGroupNames[segment])
}
}
function getPicasaName (segment, opts, picasaReader) {
const fullPath = path.join(opts.input, segment)
const picasaFile = picasaReader.album(fullPath)
return picasaFile != null ? picasaFile.name : null
}

@ -4,41 +4,43 @@ Provides Picasa metadata based on <picasa.ini> files in the input folder
--------------------------------------------------------------------------------
*/
const fs = require('node:fs')
const path = require('node:path')
const ini = require('ini')
const fs = require('fs')
const path = require('path')
class Picasa {
constructor () {
// memory cache of all Picasa files read so far
this.folders = {}
}
album (dir) {
const entry = this.folderMetadata(dir)
if (!this.folders[dir]) {
this.folders[dir] = loadPicasa(dir)
}
// album metadata is stored in a section called [Picasa]
const entry = this.folders[dir]
return entry.Picasa || null
}
file (filepath) {
const dir = path.dirname(filepath)
const entry = this.folderMetadata(dir)
if (!this.folders[dir]) {
this.folders[dir] = loadPicasa(dir)
}
// file metadata is stored in a section called [FileName.ext]
const entry = this.folders[dir]
const filename = path.basename(filepath)
const fileParts = filename.split('.')
return getIniValue(entry, fileParts)
}
}
folderMetadata (dirname) {
// try reading from cache first
if (this.folders[dirname]) {
return this.folders[dirname]
}
// otherwise try to read the file from disk
const inipath = path.join(dirname, 'picasa.ini')
const content = loadIfExists(inipath)
this.folders[dirname] = content ? ini.parse(content) : {}
return this.folders[dirname]
function loadPicasa (dirname) {
const inipath = path.join(dirname, 'picasa.ini')
const content = loadIfExists(inipath)
if (!content) {
// return an empty hash, as if the picasa.ini file existed but was empty
return {}
} else {
return ini.parse(content)
}
}

@ -6,32 +6,30 @@ A single photo/video could exist in multiple albums
--------------------------------------------------------------------------------
*/
const path = require('node:path')
const _ = require('lodash')
const path = require('path')
const url = require('url')
const slugify = require('slugify')
const url = require('./url')
let index = 0
var index = 0
// number of images to show in the album preview grid
const PREVIEW_COUNT = 10
const SLUGIFY_OPTIONS = { replacement: '-', remove: /[*+~.()'"!:@]/g }
const SORT_ALBUMS_BY = {
title: function (album) { return album.title },
'title': function (album) { return album.title },
'start-date': function (album) { return album.stats.fromDate },
'end-date': function (album) { return album.stats.toDate }
}
const SORT_MEDIA_BY = {
filename: function (file) { return file.filename },
date: function (file) { return file.meta.date }
'filename': function (file) { return file.filename },
'date': function (file) { return file.meta.date }
}
const PREVIEW_MISSING = {
urls: {
thumbnail: 'public/missing.png',
small: 'public/missing.png'
thumbnail: 'public/missing.png'
}
}
@ -50,7 +48,7 @@ function Album (opts) {
Album.prototype.finalize = function (options, parent) {
options = options || {}
const albumsOutputFolder = options.albumsOutputFolder || '.'
var albumsOutputFolder = options.albumsOutputFolder || '.'
// calculate final file paths and URLs
if (parent == null) {
this.path = options.index || 'index.html'
@ -61,7 +59,7 @@ Album.prototype.finalize = function (options, parent) {
this.basename = parent.basename + '-' + this.basename
}
this.path = path.join(albumsOutputFolder, this.basename + '.html')
this.url = url.fromPath(this.path)
this.url = url.resolve(albumsOutputFolder + '/', this.basename + '.html')
this.depth = parent.depth + 1
}
// path to the optional ZIP file
@ -69,7 +67,7 @@ Album.prototype.finalize = function (options, parent) {
this.zip = this.path.replace(/\.[^\\/.]+$/, '.zip')
}
// then finalize all nested albums (which uses the parent basename)
for (let i = 0; i < this.albums.length; ++i) {
for (var i = 0; i < this.albums.length; ++i) {
this.albums[i].finalize(options, this)
}
// perform stats & other calculations
@ -78,20 +76,20 @@ Album.prototype.finalize = function (options, parent) {
this.calculateStats()
this.calculateSummary()
this.sort(options)
this.pickPreviews(options)
this.pickPreviews()
}
Album.prototype.calculateStats = function () {
// nested albums
const nestedPhotos = _.map(this.albums, 'stats.photos')
const nestedVideos = _.map(this.albums, 'stats.videos')
const nestedFromDates = _.map(this.albums, 'stats.fromDate')
const nestedToDates = _.map(this.albums, 'stats.toDate')
var nestedPhotos = _.map(this.albums, 'stats.photos')
var nestedVideos = _.map(this.albums, 'stats.videos')
var nestedFromDates = _.map(this.albums, 'stats.fromDate')
var nestedToDates = _.map(this.albums, 'stats.toDate')
// current level
const currentPhotos = _.filter(this.files, { type: 'image' }).length
const currentVideos = _.filter(this.files, { type: 'video' }).length
const currentFromDate = _.map(this.files, 'meta.date')
const currentToDate = _.map(this.files, 'meta.date')
var currentPhotos = _.filter(this.files, { type: 'image' }).length
var currentVideos = _.filter(this.files, { type: 'video' }).length
var currentFromDate = _.map(this.files, 'meta.date')
var currentToDate = _.map(this.files, 'meta.date')
// aggregate all stats
this.stats = {
albums: this.albums.length,
@ -104,7 +102,7 @@ Album.prototype.calculateStats = function () {
}
Album.prototype.calculateSummary = function () {
const items = [
var items = [
itemCount(this.stats.albums, 'album'),
itemCount(this.stats.photos, 'photo'),
itemCount(this.stats.videos, 'video')
@ -113,58 +111,31 @@ Album.prototype.calculateSummary = function () {
}
Album.prototype.sort = function (options) {
const sortAlbumsBy = getItemOrLast(options.sortAlbumsBy, this.depth)
const sortAlbumsDirection = getItemOrLast(options.sortAlbumsDirection, this.depth)
const sortMediaBy = getItemOrLast(options.sortMediaBy, this.depth)
const sortMediaDirection = getItemOrLast(options.sortMediaDirection, this.depth)
this.files = _.orderBy(this.files, SORT_MEDIA_BY[sortMediaBy], sortMediaDirection)
this.albums = _.orderBy(this.albums, SORT_ALBUMS_BY[sortAlbumsBy], sortAlbumsDirection)
this.files = _.orderBy(this.files, SORT_MEDIA_BY[options.sortMediaBy], options.sortMediaDirection)
this.albums = _.orderBy(this.albums, SORT_ALBUMS_BY[options.sortAlbumsBy], options.sortAlbumsDirection)
}
Album.prototype.pickPreviews = function (options) {
// consider nested albums if there aren't enough photos
let potential = this.files
if (potential.length < PREVIEW_COUNT) {
const nested = _.flatMap(this.albums, 'previews').filter(file => file !== PREVIEW_MISSING)
potential = potential.concat(nested)
}
// choose the previews
if (!options.albumPreviews || options.albumPreviews === 'first') {
this.previews = _.slice(potential, 0, PREVIEW_COUNT)
} else if (options.albumPreviews === 'random') {
this.previews = _.sampleSize(potential, PREVIEW_COUNT)
} else if (options.albumPreviews === 'spread') {
if (potential.length < PREVIEW_COUNT) {
this.previews = _.slice(potential, 0, PREVIEW_COUNT)
} else {
const bucketSize = Math.floor(potential.length / PREVIEW_COUNT)
const buckets = _.chunk(potential, bucketSize)
this.previews = buckets.slice(0, PREVIEW_COUNT).map(b => b[0])
}
} else {
throw new Error(`Unsupported preview type: ${options.albumPreviews}`)
}
// and fill any gap with a placeholder
const missing = PREVIEW_COUNT - this.previews.length
for (let i = 0; i < missing; ++i) {
Album.prototype.pickPreviews = function () {
// also consider previews from nested albums
var nestedPicks = _.flatten(_.map(this.albums, 'previews')).filter(function (file) {
return file !== PREVIEW_MISSING
})
// then pick the top ones
var potentialPicks = _.concat(this.files, nestedPicks)
this.previews = potentialPicks.slice(0, PREVIEW_COUNT)
// and fill the gap with a placeholder
var missing = PREVIEW_COUNT - this.previews.length
for (var i = 0; i < missing; ++i) {
this.previews.push(PREVIEW_MISSING)
}
}
function itemCount (count, type) {
if (count === 0) return ''
const plural = (count > 1) ? 's' : ''
var plural = (count > 1) ? 's' : ''
return '' + count + ' ' + type + plural
}
function getItemOrLast (array, index) {
if (typeof (array) === 'undefined') return undefined
if (typeof (array) === 'string') return array
if (index > array.length) return array[array.length - 1]
return array[index]
}
// for testing purposes
Album.resetIds = function () {
index = 0

@ -5,16 +5,15 @@ Also includes how it maps to the different output files
--------------------------------------------------------------------------------
*/
const path = require('node:path')
const _ = require('lodash')
const path = require('path')
const moment = require('moment')
const output = require('./output')
const url = require('./url')
const MIME_REGEX = /([^/]+)\/(.*)/
const EXIF_DATE_FORMAT = 'YYYY:MM:DD HH:mm:ssZ'
let index = 0
var index = 0
class File {
constructor (dbEntry, meta, opts) {
@ -25,7 +24,7 @@ class File {
this.type = mediaType(dbEntry)
this.isVideo = (this.type === 'video')
this.output = output.paths(this.path, this.type, opts || {})
this.urls = _.mapValues(this.output, o => url.fromPath(o.path))
this.urls = _.mapValues(this.output, o => o.path.replace('\\', '/'))
this.meta = meta
}
}

@ -5,9 +5,8 @@ This is based on parsing "provider data" such as Exiftool or Picasa
--------------------------------------------------------------------------------
*/
const path = require('node:path')
const _ = require('lodash')
const moment = require('moment')
const path = require('path')
// mime type for videos
const MIME_VIDEO_REGEX = /^video\/.*$/
@ -25,9 +24,8 @@ class Metadata {
constructor (exiftool, picasa, opts) {
// standardise metadata
this.date = getDate(exiftool)
this.caption = caption(exiftool, picasa)
this.caption = caption(exiftool)
this.keywords = keywords(exiftool, picasa)
this.people = people(exiftool)
this.video = video(exiftool)
this.animated = animated(exiftool)
this.rating = rating(exiftool)
@ -58,10 +56,10 @@ function getDate (exif) {
function getMetaDate (exif) {
const date = tagValue(exif, 'EXIF', 'DateTimeOriginal') ||
tagValue(exif, 'H264', 'DateTimeOriginal') ||
tagValue(exif, 'QuickTime', 'ContentCreateDate') ||
tagValue(exif, 'QuickTime', 'CreationDate') ||
tagValue(exif, 'QuickTime', 'CreateDate')
tagValue(exif, 'H264', 'DateTimeOriginal') ||
tagValue(exif, 'QuickTime', 'ContentCreateDate') ||
tagValue(exif, 'QuickTime', 'CreationDate') ||
tagValue(exif, 'QuickTime', 'CreateDate')
if (date) {
const parsed = moment(date, EXIF_DATE_FORMAT)
if (parsed.isValid()) return parsed
@ -80,41 +78,38 @@ function getFilenameDate (exif) {
function caption (exif, picasa) {
return picasaValue(picasa, 'caption') ||
tagValue(exif, 'EXIF', 'ImageDescription') ||
tagValue(exif, 'IPTC', 'Caption-Abstract') ||
tagValue(exif, 'IPTC', 'Headline') ||
tagValue(exif, 'XMP', 'Description') ||
tagValue(exif, 'XMP', 'Title') ||
tagValue(exif, 'XMP', 'Label') ||
tagValue(exif, 'QuickTime', 'Title')
tagValue(exif, 'EXIF', 'ImageDescription') ||
tagValue(exif, 'IPTC', 'Caption-Abstract') ||
tagValue(exif, 'IPTC', 'Headline') ||
tagValue(exif, 'XMP', 'Description') ||
tagValue(exif, 'XMP', 'Title') ||
tagValue(exif, 'XMP', 'Label')
}
function keywords (exif, picasa) {
const sources = [
tagValue(exif, 'IPTC', 'Keywords'),
tagValue(exif, 'XMP', 'Subject'),
picasaValue(picasa, 'keywords')
]
return _.chain(sources).flatMap(makeArray).uniq().value()
}
function people (exif) {
return tagValue(exif, 'XMP', 'PersonInImage') || []
// try Picasa (comma-separated)
const picasaValues = picasaValue(picasa, 'keywords')
if (picasaValues) return picasaValues.split(',')
// try IPTC (string or array)
const iptcValues = tagValue(exif, 'IPTC', 'Keywords')
if (iptcValues) return makeArray(iptcValues)
// no keywords
return []
}
function video (exif) {
return MIME_VIDEO_REGEX.test(exif.File.MIMEType)
return MIME_VIDEO_REGEX.test(exif.File['MIMEType'])
}
function animated (exif) {
if (exif.File.MIMEType !== 'image/gif') return false
if (exif.File['MIMEType'] !== 'image/gif') return false
if (exif.GIF && exif.GIF.FrameCount > 0) return true
return false
}
function rating (exif) {
if (!exif.XMP) return 0
return exif.XMP.Rating || 0
return exif.XMP['Rating'] || 0
}
function favourite (picasa) {
@ -132,13 +127,12 @@ function picasaValue (picasa, name) {
}
function makeArray (value) {
if (!value) return []
return Array.isArray(value) ? value : value.split(',')
return Array.isArray(value) ? value : [value]
}
function dimensions (exif) {
// Use the Composite field to avoid having to check all possible tag groups (EXIF, QuickTime, ASF...)
if (!exif.Composite || !exif.Composite.ImageSize) {
if (!exif.Composite) {
return {
width: null,
height: null

@ -49,6 +49,6 @@ function relationship (filepath, rel, opts) {
}
return {
path: fn(filepath, rel, opts),
rel
rel: rel
}
}

@ -1,6 +1,5 @@
const path = require('node:path')
const path = require('path')
const urljoin = require('url-join')
const url = require('./url')
const BROWSER_SUPPORTED_EXT = /(jpg|jpeg|png|gif)$/i
@ -52,8 +51,8 @@ function photoExtension (filepath) {
}
function join (prefix, filepath) {
if (prefix.match(/^(http|https|file):\/\//)) {
return urljoin(prefix, url.fromPath(filepath))
if (prefix.match(/^https?:\/\//)) {
return urljoin(prefix, filepath)
} else {
return path.join(prefix, filepath)
}

@ -1,19 +0,0 @@
const path = require('node:path')
const process = require('node:process')
exports.fromPath = function (filepath) {
// already a URL (typically provided as a CLI argument, e.g. link prefix)
if (filepath.match(/^(http|https|file):\/\//)) {
return filepath
}
// absolute paths should start with file://
const prefix = path.isAbsolute(filepath) ? 'file://' : ''
// convert \ to / but only on Windows
if (process.platform === 'win32') {
filepath = filepath.replace(/\\/g, '/')
}
// encode URLs, but decode overly-encoded slashes
filepath = encodeURIComponent(filepath).replace(/%2F/g, '/')
// prepend the prefix if needed
return prefix + filepath
}

@ -1,5 +1,5 @@
const warn = require('debug')('thumbsup:warn')
const messages = require('./cli/messages')
const messages = require('../bin/messages')
/*
Keeps track of which source files we failed to process

@ -1,43 +1,37 @@
const fs = require('node:fs')
const downsize = require('thumbsup-downsize')
const fs = require('fs-extra')
exports.createMap = function (opts) {
const thumbSize = opts.thumbSize || 120
const smallSize = opts.smallSize || 300
const smallSize = 300
const largeSize = opts.largeSize || 1000
const defaultOptions = {
quality: opts.photoQuality,
args: opts.gmArgs
}
const watermarkDefault = {
const watermark = (!opts.watermark) ? null : {
file: opts.watermark,
position: opts.watermarkPosition
}
const watermark = (!opts.watermark) ? null : watermarkDefault
const seek = opts.videoStills === 'middle' ? -1 : opts.videoStillsSeek
const thumbnail = Object.assign({}, defaultOptions, {
height: thumbSize,
width: thumbSize,
seek
width: thumbSize
})
const small = Object.assign({}, defaultOptions, {
height: smallSize,
seek
height: smallSize
})
const large = Object.assign({}, defaultOptions, {
height: largeSize,
watermark,
animated: true,
seek
watermark: watermark,
animated: true
})
const videoOpts = {
format: opts.videoFormat,
quality: opts.videoQuality,
bitrate: opts.videoBitrate,
hwaccel: opts.videoHwaccel
bitrate: opts.videoBitrate
}
return {
'fs:copy': (task, done) => fs.copyFile(task.src, task.dest, done),
'fs:copy': (task, done) => fs.copy(task.src, task.dest, done),
'fs:symlink': (task, done) => fs.symlink(task.src, task.dest, done),
'photo:thumbnail': (task, done) => downsize.image(task.src, task.dest, thumbnail, done),
'photo:small': (task, done) => downsize.image(task.src, task.dest, small, done),

@ -1,7 +1,7 @@
const path = require('node:path')
const childProcess = require('node:child_process')
const async = require('async')
const childProcess = require('child_process')
const Observable = require('zen-observable')
const path = require('path')
const trace = require('debug')('thumbsup:trace')
const debug = require('debug')('thumbsup:debug')
const error = require('debug')('thumbsup:error')
@ -42,10 +42,10 @@ function createZip (targetZipPath, currentFolder, filesToInclude, done) {
trace(`Calling: zip ${args.join(' ')}`)
const child = childProcess.spawn('zip', args, {
cwd: currentFolder,
stdio: ['ignore', 'ignore', 'ignore']
stdio: [ 'ignore', 'ignore', 'ignore' ]
})
child.on('error', (err) => {
error('Error: please verify that <zip> is installed on your system')
error(`Error: please verify that <zip> is installed on your system`)
error(err.toString())
})
child.on('close', (code, signal) => {

@ -1,35 +1,27 @@
const fs = require('node:fs')
const path = require('node:path')
const _ = require('lodash')
const debug = require('debug')('thumbsup:debug')
const fs = require('fs')
const Observable = require('zen-observable')
const path = require('path')
const readdir = require('fs-readdir-recursive')
exports.run = function (fileCollection, outputRoot, dryRun) {
const obsolete = findObsolete(fileCollection, outputRoot)
exports.run = function (fileCollection, outputRoot) {
return new Observable(observer => {
obsolete.forEach(f => {
const relativePath = path.relative(outputRoot, f)
if (dryRun) {
debug(`Dry run, would delete: ${relativePath}`)
} else {
observer.next(relativePath)
fs.unlinkSync(f)
}
const mediaRoot = path.join(outputRoot, 'media')
const diskFiles = readdir(mediaRoot).map(f => path.join(mediaRoot, f))
const requiredFiles = []
fileCollection.forEach(f => {
Object.keys(f.output).forEach(out => {
var dest = path.join(outputRoot, f.output[out].path)
requiredFiles.push(dest)
})
})
const useless = _.difference(diskFiles, requiredFiles)
if (useless.length) {
useless.forEach(f => {
observer.next(path.relative(outputRoot, f))
fs.unlinkSync(f)
})
}
observer.complete()
})
}
function findObsolete (fileCollection, outputRoot) {
const mediaRoot = path.join(outputRoot, 'media')
const diskFiles = readdir(mediaRoot).map(f => path.join(mediaRoot, f))
const requiredFiles = []
fileCollection.forEach(f => {
Object.keys(f.output).forEach(out => {
const dest = path.join(outputRoot, f.output[out].path)
requiredFiles.push(dest)
})
})
return _.difference(diskFiles, requiredFiles)
}

@ -5,7 +5,6 @@ Caches the results in <thumbsup.db> for faster re-runs
--------------------------------------------------------------------------------
*/
const path = require('node:path')
const hierarchy = require('../input/hierarchy.js')
const Index = require('../components/index/index')
const info = require('debug')('thumbsup:info')
@ -13,12 +12,13 @@ const AlbumMapper = require('../input/album-mapper')
const Metadata = require('../model/metadata')
const File = require('../model/file')
const Observable = require('zen-observable')
const path = require('path')
const Picasa = require('../input/picasa')
exports.run = function (opts, callback) {
return new Observable(observer => {
const picasaReader = new Picasa()
const index = new Index(opts.databaseFile)
const index = new Index(path.join(opts.output, 'thumbsup.db'))
const emitter = index.update(opts.input, opts)
const files = []
@ -27,7 +27,7 @@ exports.run = function (opts, callback) {
})
// after a file is indexed
let lastPercent = -1
var lastPercent = -1
emitter.on('progress', stats => {
const percent = Math.floor(stats.processed * 100 / stats.total)
if (percent > lastPercent) {
@ -38,8 +38,7 @@ exports.run = function (opts, callback) {
// emitted for every file once indexing is finished
emitter.on('file', file => {
const filePath = path.join(opts.input, file.metadata.SourceFile)
const picasa = picasaReader.file(filePath)
const picasa = picasaReader.file(file.metadata.SourceFile)
const meta = new Metadata(file.metadata, picasa || {}, opts)
const model = new File(file.metadata, meta, opts)
// only include valid photos and videos (i.e. exiftool recognised the format)
@ -50,8 +49,8 @@ exports.run = function (opts, callback) {
// finished, we can create the albums
emitter.on('done', stats => {
const mapper = new AlbumMapper(opts.albumsFrom, opts)
const album = hierarchy.createAlbums(files, mapper, opts, picasaReader)
const mapper = new AlbumMapper(opts.albumsFrom)
const album = hierarchy.createAlbums(files, mapper, opts)
callback(null, files, album)
observer.complete()
})

@ -1,9 +1,9 @@
const path = require('node:path')
const fs = require('node:fs')
const debug = require('debug')('thumbsup:debug')
const error = require('debug')('thumbsup:error')
const fs = require('fs-extra')
const info = require('debug')('thumbsup:info')
const ListrWorkQueue = require('../components/listr-work-queue/index')
const path = require('path')
const actions = require('./actions')
exports.run = function (files, problems, opts, parentTask) {
@ -36,19 +36,17 @@ exports.create = function (files, opts, problems) {
const destDate = modifiedDate(dest)
const action = actionMap[f.output[out].rel]
// ignore output files that don't have an action (e.g. existing links)
if (action) {
debug(`Comparing ${f.path} (${f.date}) and ${f.output[out].path} (${destDate})`)
}
debug(`Comparing ${f.path} (${f.date}) and ${f.output[out].path} (${destDate})`)
if (action && f.date > destDate) {
sourceFiles.add(f.path)
tasks[dest] = {
file: f,
dest,
dest: dest,
rel: f.output[out].rel,
action: (done) => {
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.mkdirsSync(path.dirname(dest))
debug(`${f.output[out].rel} from ${src} to ${dest}`)
return action({ src, dest }, err => {
return action({ src: src, dest: dest }, err => {
if (err) {
error(`Error processing ${f.path} -> ${f.output[out].path}\n${err}`)
problems.addFile(f.path)
@ -75,7 +73,7 @@ function listrTaskFromJob (job, outputRoot) {
title: relative,
task: (ctx, task) => {
return new Promise((resolve, reject) => {
const progressEmitter = job.action(err => {
var progressEmitter = job.action(err => {
err ? reject(err) : resolve()
})
// render progress percentage for videos

@ -1,58 +0,0 @@
const _ = require('lodash')
exports.create = function (album, opts, themeSettings) {
const baseModel = {
gallery: Object.assign({}, opts, { home: album }),
settings: themeSettings,
home: album
}
return createPages(baseModel, album, opts.albumPageSize, [])
}
function createPages (baseModel, album, pageSize, breadcrumbs) {
// HTML pages for the current album
const slicedAlbums = createSlicedAlbums(album, pageSize)
const pages = slicedAlbums.map((album, index) => {
const pagination = createPagination(slicedAlbums, index)
const model = Object.assign({}, baseModel, {
path: pagination[index].path,
breadcrumbs,
album,
pagination: (pageSize ? pagination : [])
})
return model
})
// and all nested albums
album.albums.forEach(function (nested) {
const crumbs = breadcrumbs.concat([album])
const nestedPages = createPages(baseModel, nested, pageSize, crumbs)
Array.prototype.push.apply(pages, nestedPages)
})
return pages
}
function createSlicedAlbums (album, pageSize) {
if (!pageSize) return [album]
if (album.files.length < pageSize) return [album]
const pagedFiles = _.chunk(album.files, pageSize)
return pagedFiles.map(page => {
return Object.assign({}, album, { files: page })
})
}
function createPagination (albums, currentIndex) {
return albums.map((album, index) => {
return {
index: index + 1,
current: (index === currentIndex),
path: injectPageNumber(album.path, index),
url: injectPageNumber(album.url, index)
}
})
}
function injectPageNumber (filepath, index) {
if (index === 0) return filepath
const base = filepath.slice(0, -5)
return `${base}${index + 1}.html`
}

@ -1,47 +0,0 @@
const fs = require('node:fs')
const path = require('node:path')
class SEO {
constructor (output, seoLocation, rootAlbum) {
this.output = output
this.seoPrefix = seoLocation + (seoLocation.endsWith('/') ? '' : '/')
this.album = rootAlbum
}
robots () {
return `User-Agent: *\nDisallow:\nSitemap: ${this.seoPrefix}sitemap.xml\n`
}
sitemap () {
const now = new Date().toISOString()
const prefix = this.seoPrefix
// gather all album pages
const urls = []
addAlbumUrls(urls, this.album)
// create one <url> section per album
const xml = urls.map(url => `
<url>
<loc>${prefix}${url}</loc>
<lastmod>${now}</lastmod>
</url>`)
// return the full document
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${xml.join('')}
</urlset>
`
}
writeFiles () {
const robotsFile = path.join(this.output, 'robots.txt')
const sitemapFile = path.join(this.output, 'sitemap.xml')
fs.writeFileSync(robotsFile, this.robots())
fs.writeFileSync(sitemapFile, this.sitemap())
}
}
function addAlbumUrls (list, album) {
list.push(album.url)
album.albums.forEach(subAlbum => addAlbumUrls(list, subAlbum))
}
module.exports = SEO

@ -1,14 +1,8 @@
const path = require('node:path')
const path = require('path')
module.exports = (target, options) => {
// if already an absolute URL, do nothing
if (target.match(/^(http|https|file):\/\//)) {
return target
}
const albumPath = options.data.root.album.path
const backToGalleryRoot = path.relative(path.dirname(albumPath), '.')
const relative = path.join(backToGalleryRoot, target)
const url = relative.replace(/\\/g, '/')
const relative = path.relative(path.dirname(albumPath), target)
// Escape single/double quotes
return url.replace(/'/g, '%27').replace(/"/g, '%22')
return relative.replace(/'/g, '%27').replace(/"/g, '%22')
}

@ -1,3 +1,4 @@
/*
Render the first X items in an array
Usage:
@ -6,11 +7,11 @@
{{/slice}}
*/
module.exports = (context, block) => {
let ret = ''
let count = parseInt(block.hash.count)
var ret = ''
var count = parseInt(block.hash.count)
if (isNaN(count)) count = 1
let i = 0
const j = (count < context.length) ? count : context.length
var i = 0
var j = (count < context.length) ? count : context.length
for (i, j; i < j; i++) {
ret += block.fn(context[i])
}

@ -6,11 +6,11 @@
{{/times}}
*/
module.exports = function (n, block) {
let accum = ''
var accum = ''
const data = require('handlebars').createFrame({})
for (let i = 0; i < n; ++i) {
for (var i = 0; i < n; ++i) {
data.index = i
accum += block.fn(this, { data })
accum += block.fn(this, { data: data })
}
return accum
}

@ -1,12 +1,12 @@
{{#if gallery.googleAnalytics}}
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{gallery.googleAnalytics}}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{gallery.googleAnalytics}}');
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', '{{gallery.googleAnalytics}}', 'auto');
ga('send', 'pageview');
</script>
{{/if}}

@ -1,10 +1,9 @@
const fs = require('node:fs')
const path = require('node:path')
const async = require('async')
const debug = require('debug')('thumbsup:debug')
const fsextra = require('fs-extra')
const fs = require('fs-extra')
const handlebars = require('handlebars')
const less = require('less')
const path = require('path')
class Theme {
constructor (themeDir, destDir, opts) {
@ -41,7 +40,7 @@ class Theme {
const pkg = JSON.parse(contents)
return pkg.thumbsup || {}
} catch (ex) {
debug('Theme does not have a package.json, using default options')
debug(`Theme does not have a package.json, using default options`)
return {}
}
}
@ -63,7 +62,7 @@ class Theme {
const fullPath = path.join(this.dest, targetPath)
debug(`Theme rendering ${targetPath}`)
const contents = this.template(data)
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
fs.mkdirpSync(path.dirname(fullPath))
fs.writeFile(fullPath, contents, next)
}
@ -115,7 +114,7 @@ class Theme {
if (err) return done(err)
const filename = this.opts.stylesheetName || 'theme.css'
const dest = path.join(this.dest, 'public', filename)
fs.mkdirSync(path.join(this.dest, 'public'), { recursive: true })
fs.mkdirpSync(path.join(this.dest, 'public'))
fs.writeFile(dest, output.css, done)
})
}
@ -138,12 +137,12 @@ class Theme {
// copy all files in the <public> folder
const src = path.join(this.dir, 'public')
const dest = path.join(this.dest, 'public')
fsextra.copy(src, dest, done)
fs.copy(src, dest, done)
}
}
function compileTemplate (hbsFile) {
const src = fs.readFileSync(hbsFile)
var src = fs.readFileSync(hbsFile)
return handlebars.compile(src.toString())
}

@ -1,10 +1,8 @@
const fs = require('node:fs')
const path = require('node:path')
const fs = require('fs')
const path = require('path')
const async = require('async')
const resolvePkg = require('resolve-pkg')
const Theme = require('./theme')
const SEO = require('./seo')
const pages = require('./pages')
exports.build = function (rootAlbum, opts, callback) {
// create the base layer assets
@ -23,25 +21,43 @@ exports.build = function (rootAlbum, opts, callback) {
// data passed to the template
const themeSettings = readThemeSettings(opts.themeSettings)
const templateData = {
// deprecated "home", to be removed later
gallery: Object.assign({}, opts, { home: rootAlbum }),
settings: themeSettings,
home: rootAlbum,
// overwritten per page
breadcrumbs: null,
album: null
}
// create the rendering tasks
const viewModels = pages.create(rootAlbum, opts, themeSettings)
const tasks = viewModels.map(model => {
return next => theme.render(model.path, model, next)
})
// and finally render each page
const tasks = createRenderingTasks(theme, templateData, rootAlbum, [])
// now build everything
async.series([
next => base.prepare(next),
next => theme.prepare(next),
next => async.series(tasks, next)
next => async.parallel(tasks, next)
], callback)
}
// add robots & sitemap if needed
if (opts.seoLocation) {
const seo = new SEO(opts.output, opts.seoLocation, rootAlbum)
seo.writeFiles()
function createRenderingTasks (theme, templateData, currentAlbum, breadcrumbs) {
// a function to render this album
const thisAlbumTask = next => {
theme.render(currentAlbum.path, Object.assign({}, templateData, {
breadcrumbs: breadcrumbs,
album: currentAlbum
}), next)
}
const tasks = [thisAlbumTask]
// and all nested albums
currentAlbum.albums.forEach(function (nested) {
const crumbs = breadcrumbs.concat([currentAlbum])
const nestedTasks = createRenderingTasks(theme, templateData, nested, crumbs)
Array.prototype.push.apply(tasks, nestedTasks)
})
return tasks
}
function localThemePath (themeName) {

@ -1,21 +0,0 @@
{{!--
This theme renders useful information as a YAML file
For reliable integration tests
--}}
title: {{ album.title }}
albums:
{{#each album.albums}}
- title: {{title}}
url: {{relative url}}
{{/each}}
files:
{{#each album.files}}
- name: {{filename}}
caption: {{meta.caption}}
thumbnail: {{relative urls.thumbnail}}
preview: {{relative urls.large}}
download: {{relative urls.download}}
{{/each}}

@ -1,6 +0,0 @@
{
"name": "@thumbsup/test-theme",
"thumbsup": {
"themeRoot": "."
}
}

@ -1,5 +0,0 @@
@color: #333;
h1 {
border: @color;
}

@ -0,0 +1,26 @@
const should = require('should/as-function')
const messages = require('../../bin/messages.js')
describe('messages', function () {
['SUCCESS', 'GREETING', 'SORRY'].forEach(type => {
it(`wraps ${type} messages in a box`, () => {
const success = messages[type]({})
should(success.indexOf('┌───')).above(-1)
should(success.indexOf('───┐')).above(-1)
should(success.indexOf('└───')).above(-1)
should(success.indexOf('───┘')).above(-1)
should(success.split('\n').length).above(4)
})
})
it('lists mandatory binary dependencies', () => {
const required = messages.BINARIES_REQUIRED(['bin1', 'bin2'])
should(required.indexOf('bin1\n')).above(-1)
should(required.indexOf('bin2\n')).above(-1)
})
it('can print one or more problem', () => {
should(messages.PROBLEMS(1).indexOf('with 1 file.')).above(-1)
should(messages.PROBLEMS(2).indexOf('with 2 files.')).above(-1)
})
})

@ -1,24 +1,10 @@
const path = require('node:path')
const process = require('node:process')
const path = require('path')
const should = require('should/as-function')
const sinon = require('sinon')
const options = require('../../src/cli/options.js')
const fixtures = require('../fixtures.js')
const options = require('../../bin/options.js')
const BASE_ARGS = ['--input', 'photos', '--output', 'website']
const ospath = fixtures.ospath
describe('options', function () {
before(() => {
// all other modules use debug() which is already captured during tests
// but options are parsed before log management so they use console.error
sinon.stub(console, 'error')
})
after(() => {
console.error.restore()
})
describe('parsing', () => {
it('parses a single basic option', () => {
const opts = options.get(BASE_ARGS)
@ -30,19 +16,8 @@ describe('options', function () {
should(opts.themePath).eql('foobar')
})
it('can use --no to reverse a boolean', () => {
const opts = options.get(BASE_ARGS.concat(['--no-cleanup']))
should(opts.cleanup).eql(false)
})
it('is case-sensitive for booleans', () => {
const opts1 = options.get(BASE_ARGS.concat(['--include-videos', 'false']))
should(opts1.includeVideos).eql(false)
const opts2 = options.get(BASE_ARGS.concat(['--include-videos', 'FALSE']))
should(opts2.includeVideos).eql(true)
})
it('rejects invalid values for choices', () => {
should.throws(function () {
options.get(BASE_ARGS.concat(['--video-format', 'test']), false)
}, /mp4/)
const opts = options.get(BASE_ARGS.concat(['--no-usage-stats']))
should(opts.usageStats).eql(false)
})
})
describe('paths', () => {
@ -70,23 +45,6 @@ describe('options', function () {
should(opts.albumsFrom).eql(['%path', '%keywords'])
})
})
describe('--sort-albums-direction', () => {
it('can be specified with multiple arguments', () => {
const args = BASE_ARGS.concat([
'--sort-albums-direction', 'asc',
'--sort-albums-direction', 'desc'
])
const opts = options.get(args)
should(opts.sortAlbumsDirection).eql(['asc', 'desc'])
})
it('can be specified multiple times with a comma', () => {
const args = BASE_ARGS.concat([
'--sort-albums-direction', 'asc,desc'
])
const opts = options.get(args)
should(opts.sortAlbumsDirection).eql(['asc', 'desc'])
})
})
describe('--gm-args', () => {
it('is optional', () => {
const opts = options.get(BASE_ARGS)
@ -108,59 +66,6 @@ describe('options', function () {
should(opts.gmArgs).eql(['-equalize', '-modulate 120'])
})
})
describe('video', () => {
it('hardware acceleration requires a bitrate', () => {
should.throws(function () {
options.get(BASE_ARGS.concat(['--video-hwaccel', 'vaapi']), false)
}, /bitrate/)
})
})
describe('misc', () => {
describe('database file path', () => {
it('defaults to the output folder', () => {
const opts = options.get(BASE_ARGS)
should(opts.databaseFile).eql(path.resolve('website/thumbsup.db'))
})
it('can overridde with a relative url', () => {
const args = BASE_ARGS.concat(['--database-file', 'album.db'])
const opts = options.get(args)
should(opts.databaseFile).eql(path.join(process.cwd(), 'album.db'))
})
itLinux('can be overridden with an absolute url (Linux)', () => {
const args = BASE_ARGS.concat(['--database-file', '/media/album.db'])
const opts = options.get(args)
should(opts.databaseFile).eql('/media/album.db')
})
itWindows('can be overridden with an absolute url (Windows)', () => {
const args = BASE_ARGS.concat(['--database-file', 'C:\\media\\album.db'])
const opts = options.get(args)
should(opts.databaseFile).eql('C:\\media\\album.db')
})
})
describe('log file path', () => {
it('defaults to the output folder', () => {
const opts = options.get(BASE_ARGS)
should(opts.logFile).eql(path.resolve('website/thumbsup.log'))
})
it('is written next to the database file if specified', () => {
const args = BASE_ARGS.concat(['--database-file', 'album.db'])
const opts = options.get(args)
should(opts.logFile).eql(path.join(process.cwd(), 'album.log'))
})
it('can be specified explicitely', () => {
const args = BASE_ARGS.concat(['--log-file', 'custom.log'])
const opts = options.get(args)
should(opts.logFile).eql(path.join(process.cwd(), 'custom.log'))
})
})
describe('includes', () => {
it('always creates an array', () => {
const args = BASE_ARGS.concat(['--include', 'holidays/**'])
const opts = options.get(args)
should(opts.include).eql(['holidays/**'])
})
})
})
describe('deprecated', () => {
it('--original-photos false', () => {
const args = BASE_ARGS.concat(['--original-photos false'])
@ -233,7 +138,7 @@ describe('options', function () {
'--css', 'path/to/custom.css'
])
const opts = options.get(args)
should(opts.themeStyle).eql(ospath('path/to/custom.css'))
should(opts.themeStyle).eql('path/to/custom.css')
})
})
})

@ -1,39 +0,0 @@
const should = require('should/as-function')
const messages = require('../../src/cli/messages.js')
describe('messages', function () {
it('shows SUCCESS in a box', () => {
const stats = { albums: 1, photos: 1, videos: 1 }
const success = messages.SUCCESS(stats)
assertInABox(success)
})
it('shows GREETING in a box', () => {
const greeting = messages.GREETING()
assertInABox(greeting)
})
it('shows SORRY in a box', () => {
const sorry = messages.SORRY('thumbsup.log')
assertInABox(sorry)
})
it('lists mandatory binary dependencies', () => {
const required = messages.BINARIES_REQUIRED(['bin1', 'bin2'])
should(required.indexOf('bin1\n')).above(-1)
should(required.indexOf('bin2\n')).above(-1)
})
it('can print one or more problem', () => {
should(messages.PROBLEMS(1).indexOf('with 1 file.')).above(-1)
should(messages.PROBLEMS(2).indexOf('with 2 files.')).above(-1)
})
})
function assertInABox (result) {
should(result.indexOf('┌───')).above(-1)
should(result.indexOf('───┐')).above(-1)
should(result.indexOf('└───')).above(-1)
should(result.indexOf('───┘')).above(-1)
should(result.split('\n').length).above(4)
}

@ -1,11 +1,11 @@
const path = require('node:path')
const path = require('path')
const should = require('should/as-function')
const fixtures = require('../../fixtures')
const exiftool = require('../../../src/components/exiftool/parallel')
describe('exiftool', function () {
this.slow(10000)
this.timeout(10000)
this.slow(1000)
this.timeout(1000)
it('processes all files', (done) => {
// generate some photos in a temp folder
@ -28,42 +28,6 @@ describe('exiftool', function () {
})
})
it('can process files with UTF8 names', (done) => {
// generate some photos in a temp folder
const structure = {
'photoà.jpg': fixtures.fromDisk('photo.jpg')
}
const tmpdir = fixtures.createTempStructure(structure)
const processed = []
const stream = exiftool.parse(tmpdir, Object.keys(structure))
stream.on('data', entry => {
processed.push(entry.SourceFile)
}).on('end', () => {
should(processed).eql(['photoà.jpg'])
done()
})
})
xit('can process files starting with a # sign', (done) => {
// they are currently ignored by Exiftool for an unknown reason
const structure = {
'photo1.jpg': fixtures.fromDisk('photo.jpg'),
'#photo2.jpg': fixtures.fromDisk('photo.jpg')
}
const tmpdir = fixtures.createTempStructure(structure)
const processed = []
// force concurrency of 1 to ensure there's a single exiftool instance
// otherwise, once instance will receive '#photo2.jpg' and think it has nothing to process
// which generates an unrelated error
const stream = exiftool.parse(tmpdir, Object.keys(structure), 1)
stream.on('data', entry => {
processed.push(entry.SourceFile)
}).on('end', () => {
should(processed).eql(['photo1.jpg', '#photo2.jpg'])
done()
})
})
it('can process badly encoded fields', (done) => {
// here we test with an XMP file because it's easier to see what's wrong
// but the problem will more likely be with a badly encoded XMP section inside a JPG file

@ -63,7 +63,7 @@ function mockExifStream (root, filenames) {
const input = filenames.map(name => {
return { SourceFile: `${root}/${name}`, Directory: root }
})
return new streamMock.ObjectReadableMock(input)
return new streamMock.ReadableMock(input, { objectMode: true })
}
function reduceStream (stream, done) {

@ -1,4 +1,4 @@
const childProcess = require('node:child_process')
const childProcess = require('child_process')
const debug = require('debug')
const mockSpawn = require('mock-spawn')
const should = require('should/as-function')
@ -36,8 +36,7 @@ describe('exiftool stream', function () {
})
// check the data returned
const stream = exifStream.parse('input', ['IMG_0001.jpg', 'IMG_0002.jpg'])
reduceStream(stream, (err, emittedData) => {
should(err).eql(null)
reduceStream(stream, emittedData => {
should(emittedData).eql([{
SourceFile: 'IMG_0001.jpg',
MIMEType: 'image/jpeg'
@ -60,45 +59,20 @@ describe('exiftool stream', function () {
})
// check the data returned
const stream = exifStream.parse('input', ['IMG_0001.jpg'])
reduceStream(stream, (err, emittedData) => {
should(err).eql(null)
reduceStream(stream, emittedData => {
should(emittedData).eql([])
debug.assertContains('installed on your system')
debug.assertContains('spawn ENOENT')
done()
})
})
it('sends an errors if exiftool response cannot be parsed', (done) => {
// setup a mock that returns invalid JSON on stdout
// this can happen if the exiftool arguments are invalid
const errorSpawn = mockSpawn()
childProcess.spawn.callsFake(errorSpawn)
errorSpawn.setDefault(function (cb) {
setTimeout(() => {
this.stdout.write('ERROR: bad syntax')
}, 10)
setTimeout(() => cb(), 20)
})
// check the data returned
const stream = exifStream.parse('input', ['IMG_0001.jpg', 'IMG_0002.jpg'])
reduceStream(stream, (err) => {
should(err).not.eql(null)
should(err).property('message').match(/Invalid JSON/)
done()
})
})
})
function reduceStream (stream, done) {
const emittedData = []
stream.on('data', entry => {
emittedData.push(entry)
})
stream.on('error', (err) => {
done(err, emittedData)
})
stream.on('end', () => {
done(null, emittedData)
}).on('end', () => {
done(emittedData)
})
}

@ -2,213 +2,113 @@ const delta = require('../../../src/components/index/delta')
const should = require('should/as-function')
describe('Index: delta', () => {
describe('Scan mode: full', () => {
it('no changes', () => {
const database = {
IMG_0001: 1410000000000,
IMG_0002: 1420000000000
}
const disk = {
IMG_0001: 1410000000000,
IMG_0002: 1420000000000
}
const res = delta.calculate(database, disk, {})
should(res).eql({
unchanged: ['IMG_0001', 'IMG_0002'],
added: [],
modified: [],
deleted: [],
skipped: []
})
})
it('no changes within a second', () => {
const database = {
IMG_0001: 1410000001000,
IMG_0002: 1420000001000
}
const disk = {
IMG_0001: 1410000001500, // 500ms later
IMG_0002: 1420000000500 // 500ms earlier
}
const res = delta.calculate(database, disk, {})
should(res).eql({
unchanged: ['IMG_0001', 'IMG_0002'],
added: [],
modified: [],
deleted: [],
skipped: []
})
})
it('new files', () => {
const database = {
IMG_0001: 1410000000000,
IMG_0002: 1420000000000
}
const disk = {
IMG_0001: 1410000000000,
IMG_0002: 1420000000000,
IMG_0003: 1430000000000
}
const res = delta.calculate(database, disk, {})
should(res).eql({
unchanged: ['IMG_0001', 'IMG_0002'],
added: ['IMG_0003'],
modified: [],
deleted: [],
skipped: []
})
})
it('deleted files', () => {
const database = {
IMG_0001: 1410000000000,
IMG_0002: 1420000000000
}
const disk = {
IMG_0001: 1410000000000
}
const res = delta.calculate(database, disk, {})
should(res).eql({
unchanged: ['IMG_0001'],
added: [],
modified: [],
deleted: ['IMG_0002'],
skipped: []
})
})
it('modified files', () => {
const database = {
IMG_0001: 1410000000000,
IMG_0002: 1420000000000
}
const disk = {
IMG_0001: 1410000000000,
IMG_0002: 1420000002000
}
const res = delta.calculate(database, disk, {})
should(res).eql({
unchanged: ['IMG_0001'],
added: [],
modified: ['IMG_0002'],
deleted: [],
skipped: []
})
it('no changes', () => {
const database = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000000000
}
const disk = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000000000
}
const res = delta.calculate(database, disk)
should(res).eql({
unchanged: ['IMG_0001', 'IMG_0002'],
added: [],
modified: [],
deleted: []
})
})
it('all cases', () => {
const database = {
IMG_0001: 1410000000000,
IMG_0002: 1420000000000,
IMG_0003: 1430000000000
}
const disk = {
IMG_0001: 1410000000000,
IMG_0002: 1420000002000,
IMG_0004: 1445000000000
}
const res = delta.calculate(database, disk, {})
should(res).eql({
unchanged: ['IMG_0001'],
added: ['IMG_0004'],
modified: ['IMG_0002'],
deleted: ['IMG_0003'],
skipped: []
})
it('no changes within a second', () => {
const database = {
'IMG_0001': 1410000001000,
'IMG_0002': 1420000001000
}
const disk = {
'IMG_0001': 1410000001500, // 500ms later
'IMG_0002': 1420000000500 // 500ms earlier
}
const res = delta.calculate(database, disk)
should(res).eql({
unchanged: ['IMG_0001', 'IMG_0002'],
added: [],
modified: [],
deleted: []
})
})
describe('Scan mode: partial', () => {
it('considers deleted files outside the inclusion pattern as skipped', () => {
const database = {
'London/IMG_0001': 1410000000000,
'Tokyo/IMG_0002': 1420000000000
}
const disk = {
'London/IMG_0001': 1410000000000
}
const res = delta.calculate(database, disk, {
scanMode: 'incremental',
include: ['London/**'],
exclude: []
})
should(res).eql({
unchanged: ['London/IMG_0001'],
added: [],
modified: [],
deleted: [],
skipped: ['Tokyo/IMG_0002']
})
it('new files', () => {
const database = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000000000
}
const disk = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000000000,
'IMG_0003': 1430000000000
}
const res = delta.calculate(database, disk)
should(res).eql({
unchanged: ['IMG_0001', 'IMG_0002'],
added: ['IMG_0003'],
modified: [],
deleted: []
})
})
it('considers deleted files matching an exclusion pattern as skipped', () => {
const database = {
'London/IMG_0001': 1410000000000,
'Tokyo/IMG_0002': 1420000000000
}
const disk = {
'London/IMG_0001': 1410000000000
}
const res = delta.calculate(database, disk, {
scanMode: 'incremental',
include: [],
exclude: ['Tokyo/**']
})
should(res).eql({
unchanged: ['London/IMG_0001'],
added: [],
modified: [],
deleted: [],
skipped: ['Tokyo/IMG_0002']
})
it('deleted files', () => {
const database = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000000000
}
const disk = {
'IMG_0001': 1410000000000
}
const res = delta.calculate(database, disk)
should(res).eql({
unchanged: ['IMG_0001'],
added: [],
modified: [],
deleted: ['IMG_0002']
})
})
it('considers files inside the inclusion pattern as deleted', () => {
const database = {
'London/IMG_0001': 1410000000000,
'Tokyo/IMG_0002': 1420000000000
}
const disk = {
'London/IMG_0001': 1410000000000
}
const res = delta.calculate(database, disk, {
scanMode: 'partial',
include: ['**/**'],
exclude: []
})
should(res).eql({
unchanged: ['London/IMG_0001'],
added: [],
modified: [],
deleted: ['Tokyo/IMG_0002'],
skipped: []
})
it('modified files', () => {
const database = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000000000
}
const disk = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000002000
}
const res = delta.calculate(database, disk)
should(res).eql({
unchanged: ['IMG_0001'],
added: [],
modified: ['IMG_0002'],
deleted: []
})
})
describe('Scan mode: incremental', () => {
it('considers files inside the inclusion pattern as skipped', () => {
const database = {
'London/IMG_0001': 1410000000000,
'Tokyo/IMG_0002': 1420000000000
}
const disk = {
'London/IMG_0001': 1410000000000
}
const res = delta.calculate(database, disk, {
scanMode: 'incremental',
include: [],
exclude: []
})
should(res).eql({
unchanged: ['London/IMG_0001'],
added: [],
modified: [],
deleted: [],
skipped: ['Tokyo/IMG_0002']
})
it('all cases', () => {
const database = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000000000,
'IMG_0003': 1430000000000
}
const disk = {
'IMG_0001': 1410000000000,
'IMG_0002': 1420000002000,
'IMG_0004': 1445000000000
}
const res = delta.calculate(database, disk)
should(res).eql({
unchanged: ['IMG_0001'],
added: ['IMG_0004'],
modified: ['IMG_0002'],
deleted: ['IMG_0003']
})
})
})

@ -1,16 +1,16 @@
const fs = require('node:fs')
const { sep } = require('node:path')
const os = require('node:os')
const fs = require('fs')
const glob = require('../../../src/components/index/glob')
const { sep } = require('path')
const os = require('os')
const should = require('should/as-function')
const tmp = require('tmp')
const glob = require('../../../src/components/index/glob')
describe('Index: glob', function () {
this.slow(500)
this.timeout(500)
// we require "mock-fs" inside the tests, otherwise it also affects other tests
let mock = null
var mock = null
before(() => {
mock = require('mock-fs')
})
@ -169,75 +169,29 @@ describe('Index: glob', function () {
'media/holidays/IMG_0002.jpg': '...'
})
const options = {
include: ['holidays/**']
include: [ 'holidays/**' ]
}
assertGlobReturns('media', options, [
'holidays/IMG_0002.jpg'
], done)
})
it('can include deep subfolders', (done) => {
mock({
'media/work/IMG_0001.jpg': '...',
'media/holidays/venice/IMG_0002.jpg': '...'
})
const options = {
include: [
'holidays/**'
]
}
assertGlobReturns('media', options, [
'holidays/venice/IMG_0002.jpg'
], done)
})
it('can include nested subfolders', (done) => {
mock({
'media/work/IMG_0001.jpg': '...',
'media/holidays/venice/IMG_0002.jpg': '...'
})
const options = {
include: [
'holidays/venice/**'
]
}
assertGlobReturns('media', options, [
'holidays/venice/IMG_0002.jpg'
], done)
})
it('can include a specific file by path', (done) => {
mock({
'media/work/IMG_0001.jpg': '...',
'media/holidays/venice/IMG_0002.jpg': '...'
})
const options = {
include: [
'holidays/venice/IMG_0002.jpg'
]
}
assertGlobReturns('media', options, [
'holidays/venice/IMG_0002.jpg'
], done)
})
it('can specify an exclude pattern', (done) => {
mock({
'media/work/IMG_0001.jpg': '...',
'media/holidays/IMG_0002.jpg': '...'
})
const options = {
exclude: ['work/**']
exclude: [ 'work/**' ]
}
assertGlobReturns('media', options, [
'holidays/IMG_0002.jpg'
], done)
})
it('ignores invalid file names on Linux', function (done) {
if (os.platform() !== 'linux') {
it('ignores invalid file names', function (done) {
if (os.platform() === 'darwin') {
// the invalid filename generates a system error on macOS
// and is actually valid on Windows
return this.skip()
}
const tmpdir = tmp.dirSync({ unsafeCleanup: true })
@ -250,7 +204,7 @@ describe('Index: glob', function () {
]),
Buffer.from('file3c.jpg')
]
for (const filename of filenames) {
for (let filename of filenames) {
// we can't use path.join because it will check whether the components
// are valid, which they are not
fs.writeFileSync(Buffer.concat([

@ -1,4 +1,5 @@
const path = require('node:path')
const fs = require('fs')
const path = require('path')
const should = require('should/as-function')
const Index = require('../../../src/components/index/index')
const fixtures = require('../../fixtures')
@ -7,125 +8,82 @@ describe('Index', function () {
this.slow(1000)
this.timeout(1000)
let tmpdir = null
const image = fixtures.fromDisk('photo.jpg')
var tmpdir = null
beforeEach(() => {
before(() => {
const image = fixtures.fromDisk('photo.jpg')
tmpdir = fixtures.createTempStructure({
'input/london/IMG_0001.jpg': image,
'input/newyork/IMG_0002.jpg': image
})
})
function runIndex (options, done) {
it('indexes a folder', (done) => {
const index = new Index(path.join(tmpdir, 'thumbsup.db'))
const emitter = index.update(path.join(tmpdir, 'input'), options)
const emitter = index.update(path.join(tmpdir, 'input'))
const emitted = []
let processed = 0
let stats = null
var processed = 0
var stats = null
emitter.on('progress', () => ++processed)
emitter.on('file', meta => emitted.push(meta))
emitter.on('stats', s => { stats = s })
emitter.on('done', result => {
done({ result, stats, emitted, processed })
})
}
it('indexes a folder', (done) => {
runIndex({}, data => {
should(data.result.count).eql(2)
should(data.stats).eql({
database: 0,
disk: 2,
unchanged: 0,
added: 2,
modified: 0,
deleted: 0,
skipped: 0
})
// check stats
should(result.count).eql(2)
should(stats).eql({ unchanged: 0, added: 2, modified: 0, deleted: 0, total: 2 })
// check all files were indexed
const paths = data.emitted.map(e => e.path).sort()
const paths = emitted.map(e => e.path).sort()
should(paths).eql([
'london/IMG_0001.jpg',
'newyork/IMG_0002.jpg'
])
// check all files were sent to exiftool
should(data.processed).eql(2)
should(processed).eql(2)
// check the path matches the SourceFile property
const sourceFiles = data.emitted.map(e => e.metadata.SourceFile).sort()
const sourceFiles = emitted.map(e => e.metadata.SourceFile).sort()
should(paths).eql(sourceFiles)
done()
})
})
it('can re-index with no changes', (done) => {
runIndex({}, () => {
// then do a second run
runIndex({}, data => {
should(data.result.count).eql(2)
should(data.stats).eql({
database: 2,
disk: 2,
unchanged: 2,
added: 0,
modified: 0,
deleted: 0,
skipped: 0
})
// all files are emitted, but they were not processed again
should(data.emitted).has.length(2)
should(data.processed).eql(0)
done()
})
const index = new Index(path.join(tmpdir, 'thumbsup.db'))
const emitter = index.update(path.join(tmpdir, 'input'))
var emitted = 0
var processed = 0
var stats = null
emitter.on('progress', () => ++processed)
emitter.on('file', () => ++emitted)
emitter.on('stats', s => { stats = s })
emitter.on('done', result => {
// check stats
should(result.count).eql(2)
should(stats).eql({ unchanged: 2, added: 0, modified: 0, deleted: 0, total: 2 })
// all files are emitted, but they were not processed again
should(emitted).eql(2)
should(processed).eql(0)
done()
})
})
it('can un-index a deleted file', (done) => {
runIndex({}, () => {
// then do a second run
fixtures.deleteTempFile(tmpdir, 'input/newyork/IMG_0002.jpg')
runIndex({}, data => {
should(data.result.count).eql(1)
should(data.stats).eql({
database: 2,
disk: 1,
unchanged: 1,
added: 0,
modified: 0,
deleted: 1,
skipped: 0
})
// the remaining file was emitted
should(data.emitted).has.length(1)
should(data.processed).eql(0)
done()
})
})
})
describe('scan modes', () => {
it('partial ignores changes outside the include pattern', (done) => {
runIndex({}, () => {
// then do a second run
fixtures.deleteTempFile(tmpdir, 'input/newyork/IMG_0002.jpg')
const options = { scanMode: 'partial', include: ['london/**'] }
runIndex(options, data => {
should(data.result.count).eql(2)
should(data.stats).eql({
database: 2,
disk: 1,
unchanged: 1,
added: 0,
modified: 0,
deleted: 0,
skipped: 1
})
// but it still emitted 2 files
should(data.emitted).has.length(2)
should(data.processed).eql(0)
done()
})
})
fs.unlinkSync(path.join(tmpdir, 'input/newyork/IMG_0002.jpg'))
const index = new Index(path.join(tmpdir, 'thumbsup.db'))
const emitter = index.update(path.join(tmpdir, 'input'))
var emitted = 0
var processed = 0
var stats = null
emitter.on('progress', () => ++processed)
emitter.on('file', () => ++emitted)
emitter.on('stats', s => { stats = s })
emitter.on('done', result => {
// check stats
should(result.count).eql(1)
should(stats).eql({ unchanged: 1, added: 0, modified: 0, deleted: 1, total: 1 })
// the remaining file was emitted
should(emitted).eql(1)
should(processed).eql(0)
done()
})
})

@ -37,7 +37,6 @@ describe('Index: pattern', function () {
extensions: ['jpg']
})
should(pattern.match('holidays/IMG_0001.jpg')).eql(true)
should(pattern.match('holidays/venice/IMG_0001.jpg')).eql(true)
})
it('matches files that meet one of the include patterns', () => {
@ -65,7 +64,6 @@ describe('Index: pattern', function () {
extensions: ['jpg']
})
should(pattern.match('holidays/IMG_0001.jpg')).eql(true)
should(pattern.match('holidays/venice/IMG_0001.jpg')).eql(true)
})
it('rejects files that dont meet any of the include patterns', () => {
@ -105,67 +103,6 @@ describe('Index: pattern', function () {
})
})
describe('calculating sub-folders for traversal', () => {
it('includes all sub-folders', () => {
const pattern = new GlobPattern({
include: ['holidays/venice/IMG001.jpg'],
exclude: [],
extensions: []
})
should(pattern.includeFolders).eql(['holidays/venice/', 'holidays/'])
})
it('keeps the required include if it ends with a wildcard', () => {
// to ensure sub-sub folders can be traversed as expected
const pattern = new GlobPattern({
include: ['holidays/venice/**'],
exclude: [],
extensions: []
})
should(pattern.includeFolders).eql(['holidays/venice/**', 'holidays/venice/', 'holidays/'])
})
it('keeps the required include if it ends with a /', () => {
const pattern = new GlobPattern({
include: ['holidays/venice/'],
exclude: [],
extensions: []
})
should(pattern.includeFolders).eql(['holidays/venice/', 'holidays/'])
})
it('combines all include paths (no repetitions)', () => {
const pattern = new GlobPattern({
include: ['holidays/venice/IMG_001.jpg', 'holidays/milan/IMG_002.jpg'],
exclude: [],
extensions: []
})
should(pattern.includeFolders).eql([
'holidays/venice/',
'holidays/',
'holidays/milan/'
])
})
it('works with a root wildcard', () => {
const pattern = new GlobPattern({
include: ['**'],
exclude: [],
extensions: []
})
should(pattern.includeFolders).eql(['**'])
})
it('works with a root double wildcard', () => {
const pattern = new GlobPattern({
include: ['**/**'],
exclude: [],
extensions: []
})
should(pattern.includeFolders).eql(['**/**', '**/'])
})
})
describe('traversing folders', () => {
it('traverses folders that meet an include pattern', () => {
const pattern = new GlobPattern({
@ -176,58 +113,22 @@ describe('Index: pattern', function () {
should(pattern.canTraverse('holidays')).eql(true)
})
it('traverses nested folders that meet a deep wildcard (**)', () => {
it('traverses nested folders that meet an include pattern', () => {
const pattern = new GlobPattern({
include: ['holidays/**', 'home/**'],
exclude: [],
extensions: []
})
should(pattern.canTraverse('holidays/2016')).eql(true)
should(pattern.canTraverse('holidays/2016/venice')).eql(true)
})
it('traverses folders that meet a nested deep wildcard', () => {
const pattern = new GlobPattern({
include: ['holidays/2016/**', 'home/**'],
exclude: [],
extensions: []
})
should(pattern.canTraverse('holidays')).eql(true)
should(pattern.canTraverse('holidays/2016')).eql(true)
should(pattern.canTraverse('holidays/2016/venice')).eql(true)
})
it('traverses a single folder (no children)', () => {
it('traverses folders that meet an include directory', () => {
const pattern = new GlobPattern({
include: ['holidays/'],
exclude: [],
extensions: []
})
should(pattern.canTraverse('holidays')).eql(true)
// only traverses a single level since '/**' wasn't specified
should(pattern.canTraverse('holidays/2016')).eql(false)
})
it('traverses a nested folder (no children)', () => {
const pattern = new GlobPattern({
include: ['holidays/2016/'],
exclude: [],
extensions: []
})
should(pattern.canTraverse('holidays')).eql(true)
should(pattern.canTraverse('holidays/2016')).eql(true)
// not beyond since '/**' wasn't specified
should(pattern.canTraverse('holidays/2016/venice')).eql(false)
})
it('traverses folders that meet an full-path include pattern', () => {
const pattern = new GlobPattern({
include: ['holidays/venice/IMG_001.jpg'],
exclude: [],
extensions: []
})
should(pattern.canTraverse('holidays')).eql(true)
should(pattern.canTraverse('holidays/venice')).eql(true)
})
it('ignores folders that meet an exclude pattern', () => {

@ -1,3 +1,4 @@
/*
Special Listr renderer that
- on every change, renders the whole task list in memory
@ -8,18 +9,15 @@ module.exports = class ListrTestRenderer {
static get nonTTY () {
return true
}
constructor (tasks) {
this._tasks = tasks
this.output = []
}
render () {
for (const task of this._tasks) {
for (let task of this._tasks) {
this.subscribe(task)
}
}
subscribe (task) {
task.subscribe(
event => {
@ -34,14 +32,12 @@ module.exports = class ListrTestRenderer {
}
)
}
allTitles (tasks, indent) {
return tasks.map(task => {
const subTitles = this.allTitles(task.subtasks, indent + 1)
return ' '.repeat(indent) + task.title + '\n' + subTitles
}).join('')
}
end () {
}
}

@ -26,12 +26,12 @@ describe('Listr work queue', function () {
listr.run().then(() => {
const output = listr._renderer.output
// At some point a thread should have been waiting
hasItemMatching(output, 'Waiting')
hasItemMatching(output, `Waiting`)
// And a thread should have finished
hasItemMatching(output, 'Finished')
hasItemMatching(output, `Finished`)
// And every single render should conform to a particular format
const regex = /^Running jobs\n((\s\s(Waiting|Finished|Job \d+)\n){3})?$/
for (const line of output) {
for (let line of output) {
if (!regex.test(line)) {
should.fail(`Listr output does not match expected format: ${line}`)
}

@ -1,7 +1,7 @@
const fs = require('node:fs')
const path = require('node:path')
const _ = require('lodash')
const fs = require('fs-extra')
const moment = require('moment')
const path = require('path')
const tmp = require('tmp')
const File = require('../src/model/file')
const Metadata = require('../src/model/metadata')
@ -18,10 +18,7 @@ exports.exiftool = function (opts) {
IPTC: {
Keywords: opts.keywords
},
XMP: {
PersonInImage: opts.people,
Subject: opts.subjects
},
XMP: {},
H264: {},
QuickTime: {}
}
@ -31,9 +28,9 @@ exports.metadata = function (opts) {
return new Metadata(exports.exiftool(opts))
}
exports.file = function (fileOpts, opts) {
const exiftool = exports.exiftool(fileOpts)
const meta = new Metadata(exiftool, undefined, opts)
exports.file = function (opts) {
const exiftool = exports.exiftool(opts)
const meta = new Metadata(exiftool)
return new File(exiftool, meta)
}
@ -42,14 +39,10 @@ exports.date = function (str) {
// return new Date(Date.parse(str))
}
exports.photo = function (photoOpts, opts) {
if (typeof photoOpts === 'string') {
photoOpts = { path: photoOpts }
} else {
photoOpts = photoOpts || {}
}
photoOpts.mimeType = 'image/jpg'
return exports.file(photoOpts, opts)
exports.photo = function (opts) {
opts = opts || {}
opts.mimeType = 'image/jpg'
return exports.file(opts)
}
exports.video = function (opts) {
@ -66,18 +59,8 @@ exports.fromDisk = function (filename) {
exports.createTempStructure = function (files) {
const tmpdir = tmp.dirSync({ unsafeCleanup: true }).name
_.each(files, (content, filepath) => {
const folder = path.dirname(`${tmpdir}/${filepath}`)
fs.mkdirSync(folder, { recursive: true })
fs.ensureFileSync(`${tmpdir}/${filepath}`)
fs.writeFileSync(`${tmpdir}/${filepath}`, content)
})
return tmpdir
}
exports.deleteTempFile = function (tmpdir, filepath) {
fs.unlinkSync(path.join(tmpdir, filepath))
}
// convert to OS-dependent style paths for testing
exports.ospath = function (filepath) {
return filepath.replace(/\//g, path.sep)
}

@ -1,19 +1,12 @@
const tmp = require('tmp')
const path = require('path')
const requireAll = require('require-all')
// require all source code
// so that the coverage report is accurate
requireAll(path.join(__dirname, '..', 'src'))
// capture all unhandled rejected promises
// and ensure the current test fails
process.on('unhandledRejection', err => {
throw err
})
// Automatically delete temporary files/folders
// Created during the tests
tmp.setGracefulCleanup()
// Helpers for Linux and Windows-only tests
function test (title, fn) {
it(title, fn)
}
global.itLinux = (process.platform !== 'win32') ? test : () => {}
global.itWindows = (process.platform === 'win32') ? test : () => {}

@ -1,5 +1,6 @@
const fs = require('node:fs')
const path = require('node:path')
const fs = require('fs')
const path = require('path')
const os = require('os')
const should = require('should/as-function')
const tmp = require('tmp')
const AlbumMapper = require('../../src/input/album-mapper.js')
@ -42,6 +43,7 @@ describe('Album mapper', function () {
describe('custom function using file://', () => {
it('with an absolute path', () => {
const absolutePath = createTmpFile({
dir: os.tmpdir(),
contents: "module.exports = file => ['my-album']"
})
const mapper = new AlbumMapper([`file://${absolutePath}`])
@ -50,7 +52,7 @@ describe('Album mapper', function () {
it('with a path relative to the current directory', () => {
const absolutePath = createTmpFile({
tmpdir: process.cwd(),
dir: process.cwd(),
contents: "module.exports = file => ['my-album']"
})
const relative = path.basename(absolutePath)
@ -66,7 +68,7 @@ describe('Album mapper', function () {
*/
function createTmpFile (opts) {
const file = tmp.fileSync({
tmpdir: opts.tmpdir || undefined,
dir: opts.dir,
discardDescriptor: true
})
fs.writeFileSync(file.name, opts.contents)

@ -58,102 +58,40 @@ describe('AlbumPattern', function () {
})
describe('keywords', () => {
it('can return a single keyword', () => {
const func = pattern.create('%keywords', {})
const func = pattern.create('%keywords')
const file = fixtures.photo({
keywords: ['beach']
}, {})
})
should(func(file)).eql(['beach'])
})
it('can return multiple keyword', () => {
const func = pattern.create('%keywords', {})
const func = pattern.create('%keywords')
const file = fixtures.photo({
keywords: ['beach', 'sunset']
}, {})
})
should(func(file)).eql(['beach', 'sunset'])
})
it('can use plain text around the keywords', () => {
const func = pattern.create('Tags/%keywords', {})
const func = pattern.create('Tags/%keywords')
const file = fixtures.photo({
keywords: ['beach', 'sunset']
}, {})
})
should(func(file)).eql(['Tags/beach', 'Tags/sunset'])
})
it('can find keywords in a specified tag', () => {
const func = pattern.create('%keywords')
const file = fixtures.photo({
subjects: ['sunny beach']
}, {})
should(func(file)).eql(['sunny beach'])
})
it('can deal with keyword includes and excludes', () => {
const opts = {
includeKeywords: ['sunny beach', 'sandy shore', 'waves'],
excludeKeywords: ['sandy shore']
}
const func = pattern.create('%keywords', opts)
const file = fixtures.photo({
subjects: ['beach', 'sunny beach', 'sandy shore', 'waves']
}, opts)
should(func(file)).eql(['sunny beach', 'waves'])
})
it('does not return any albums if the photo does not have keywords', () => {
const func = pattern.create('{YYYY}/tags/%keywords')
const file = fixtures.photo()
should(func(file)).eql([])
})
})
describe('people', () => {
it('can return a single person', () => {
const func = pattern.create('%people')
const file = fixtures.photo({
people: ['john doe']
})
should(func(file)).eql(['john doe'])
})
it('can return multiple people', () => {
const func = pattern.create('%people')
const file = fixtures.photo({
people: ['john doe', 'jane doe']
}, {
peopleFields: ['XMP.PersonInImage']
})
should(func(file)).eql(['john doe', 'jane doe'])
})
it('can use plain text around the people', () => {
const func = pattern.create('Tags/%people')
const file = fixtures.photo({
people: ['john doe', 'jane doe']
}, {
peopleFields: ['XMP.PersonInImage']
})
should(func(file)).eql(['Tags/john doe', 'Tags/jane doe'])
})
it('can deal with people includes and excludes', () => {
const opts = {
peopleFields: ['XMP.PersonInImage'],
includePeople: ['jane doe', 'john lennon', 'paul mccartney'],
excludePeople: ['john lennon']
}
const func = pattern.create('%people', opts)
const file = fixtures.photo({
people: ['john doe', 'jane doe', 'john lennon', 'paul mccartney']
}, opts)
should(func(file)).eql(['jane doe', 'paul mccartney'])
})
it('does not return any albums if the photo does not have people', () => {
const func = pattern.create('{YYYY}/tags/%people')
const file = fixtures.photo()
should(func(file)).eql([])
})
})
describe('Complex patterns', () => {
it('can mix several tokens inside a complex pattern', () => {
const func = pattern.create('{YYYY}/%path/%keywords', {})
const func = pattern.create('{YYYY}/%path/%keywords')
const file = fixtures.photo({
path: 'Holidays/IMG_0001.jpg',
date: '2016:07:14 12:07:41',
keywords: ['beach', 'sunset']
}, {})
})
should(func(file)).eql(['2016/Holidays/beach', '2016/Holidays/sunset'])
})
})

@ -1,13 +1,10 @@
const path = require('node:path')
const path = require('path')
const should = require('should/as-function')
const sinon = require('sinon')
const hierarchy = require('../../src/input/hierarchy.js')
const Album = require('../../src/model/album.js')
const fixtures = require('../fixtures')
const Picasa = require('../../src/input/picasa')
const DEFAULT_OPTS = { homeAlbumName: 'Home', input: '' }
const picasaReader = new Picasa()
const DEFAULT_OPTS = { homeAlbumName: 'Home' }
describe('hierarchy', function () {
beforeEach(function () {
@ -17,7 +14,7 @@ describe('hierarchy', function () {
describe('root album', function () {
it('creates a root album (homepage) to put all sub-albums', function () {
const mapper = mockMapper(file => ['all'])
const home = hierarchy.createAlbums([], mapper, DEFAULT_OPTS, picasaReader)
const home = hierarchy.createAlbums([], mapper, DEFAULT_OPTS)
should(home.title).eql('Home')
})
@ -29,7 +26,7 @@ describe('hierarchy', function () {
it('defaults the homepage to index.html', function () {
const mapper = mockMapper(file => ['all'])
const home = hierarchy.createAlbums([], mapper, DEFAULT_OPTS, picasaReader)
const home = hierarchy.createAlbums([], mapper, DEFAULT_OPTS)
should(home.path).eql('index.html')
should(home.url).eql('index.html')
})
@ -51,7 +48,7 @@ describe('hierarchy', function () {
fixtures.photo({ path: 'IMG_000002.jpg' })
]
const mapper = mockMapper(file => [value])
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS, picasaReader)
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS)
should(home.albums.length).eql(0)
should(home.files.length).eql(2)
should(home.files[0].filename).eql('IMG_000001.jpg')
@ -61,33 +58,13 @@ describe('hierarchy', function () {
})
describe('nested albums', function () {
it('uses album title from Picasa file if available', function () {
const files = [
fixtures.photo({ path: 'IMG_000001.jpg' })
]
const mapper = mockMapper(file => ['all'])
const opts = { ...DEFAULT_OPTS, input: '/root' }
const expectedPath = path.join(opts.input, 'all')
const picasa = new Picasa()
sinon.stub(picasa, 'album').withArgs(expectedPath)
.returns({ name: 'picasa-name' })
const home = hierarchy.createAlbums(files, mapper, opts, picasa)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('picasa-name')
should(home.albums[0].files).eql([files[0]])
})
it('can group media into a single folder', function () {
const files = [
fixtures.photo({ path: 'IMG_000001.jpg' }),
fixtures.photo({ path: 'IMG_000002.jpg' })
]
const mapper = mockMapper(file => ['all'])
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS, picasaReader)
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('all')
should(home.albums[0].files).eql([files[0], files[1]])
@ -99,7 +76,7 @@ describe('hierarchy', function () {
fixtures.photo({ path: 'two/IMG_000002.jpg' })
]
const mapper = mockMapper(file => [path.dirname(file.path)])
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS, picasaReader)
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS)
should(home.albums.length).eql(2)
should(home.albums[0].title).eql('one')
should(home.albums[0].files).eql([files[0]])
@ -113,7 +90,7 @@ describe('hierarchy', function () {
fixtures.photo({ path: 'IMG_000002.jpg' })
]
const mapper = mockMapper(file => ['one/two'])
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS, picasaReader)
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
should(home.albums[0].albums.length).eql(1)
@ -127,7 +104,7 @@ describe('hierarchy', function () {
fixtures.photo({ path: 'one/two/IMG_000002.jpg' })
]
const mapper = mockMapper(file => [path.dirname(file.path)])
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS, picasaReader)
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
should(home.albums[0].files).eql([files[0]])
@ -135,32 +112,6 @@ describe('hierarchy', function () {
should(home.albums[0].albums[0].title).eql('two')
should(home.albums[0].albums[0].files).eql([files[1]])
})
it('does not duplicate home album', function () {
const files = [
fixtures.photo({ path: 'one/IMG_000001.jpg' })
]
const mapper = mockMapper(file => ['.', '/', path.dirname(file.path)])
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS, picasaReader)
should(home.files.length).eql(1)
should(home.files[0].filename).eql(files[0].filename)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
should(home.albums[0].files).eql(files)
should(home.albums[0].albums.length).eql(0)
})
it('does not duplicate sub albums', function () {
const files = [
fixtures.photo({ path: 'one/IMG_000001.jpg' })
]
const mapper = mockMapper(file => ['one', path.dirname(file.path)])
const home = hierarchy.createAlbums(files, mapper, DEFAULT_OPTS, picasaReader)
should(home.albums.length).eql(1)
should(home.albums[0].title).eql('one')
should(home.albums[0].files).eql(files)
should(home.albums[0].albums.length).eql(0)
})
})
})

@ -1,5 +1,3 @@
const fs = require('node:fs')
const sinon = require('sinon')
const should = require('should/as-function')
const Picasa = require('../../src/input/picasa.js')
@ -15,7 +13,7 @@ keywords=beach,sunset
describe('Picasa', function () {
// we require "mock-fs" inside the tests, otherwise it also affects other tests
let mock = null
var mock = null
beforeEach(function () {
mock = require('mock-fs')
@ -31,7 +29,7 @@ describe('Picasa', function () {
})
const picasa = new Picasa()
const meta = picasa.album('holidays')
should(meta).have.properties({
should(meta).eql({
name: 'My holidays'
})
})
@ -40,21 +38,13 @@ describe('Picasa', function () {
const meta = picasa.album('holidays')
should(meta).eql(null)
})
it('returns <null> if the Picasa file is invalid', function () {
mock({
'holidays/picasa.ini': '[[invalid'
})
const picasa = new Picasa()
const meta = picasa.album('holidays')
should(meta).eql(null)
})
it('returns raw file metadata as read from the INI file', function () {
mock({
'holidays/picasa.ini': PICASA_INI
})
const picasa = new Picasa()
const meta = picasa.file('holidays/IMG_0001.jpg')
should(meta).have.properties({
should(meta).eql({
star: 'yes',
caption: 'Nice sunset',
keywords: 'beach,sunset'
@ -66,7 +56,7 @@ describe('Picasa', function () {
})
const picasa = new Picasa()
const meta = picasa.file('holidays/IMG.0001.small.jpg')
should(meta).have.properties({
should(meta).eql({
caption: 'dots'
})
})
@ -75,19 +65,7 @@ describe('Picasa', function () {
'holidays/picasa.ini': PICASA_INI
})
const picasa = new Picasa()
const meta = picasa.file('holidays/IMG_0002.jpg')
const meta = picasa.album('holidays/IMG_0002.jpg')
should(meta).eql(null)
})
it('only reads the file from disk once', function () {
mock({
'holidays/picasa.ini': PICASA_INI
})
sinon.spy(fs, 'readFileSync')
const picasa = new Picasa()
picasa.album('holidays')
picasa.album('holidays')
picasa.file('holidays/IMG_0001.jpg')
picasa.file('holidays/IMG_0002.jpg')
should(fs.readFileSync.callCount).eql(1)
})
})

@ -1,25 +0,0 @@
const should = require('should/as-function')
const IntegrationTest = require('./integration-test')
const fixtures = require('../fixtures')
describe('Integration: picasa', function () {
this.slow(5000)
this.timeout(5000)
beforeEach(IntegrationTest.before)
afterEach(IntegrationTest.after)
it('reads a picasa.ini file', function (done) {
const integration = new IntegrationTest({
'input/folder/IMG_0001.jpg': fixtures.fromDisk('photo.jpg'),
'input/folder/picasa.ini': '[IMG_0001.jpg]\ncaption=Beach'
})
const customOpts = []
integration.run(customOpts, () => {
integration.assertExist(['index.html', 'folder.html'])
const res = integration.parseYaml('folder.html')
should(res.files[0].caption).eql('Beach')
done()
})
})
})

@ -1,108 +0,0 @@
const should = require('should/as-function')
const IntegrationTest = require('./integration-test')
const fixtures = require('../fixtures')
describe('Integration: scan modes', function () {
this.slow(5000)
this.timeout(5000)
const image = fixtures.fromDisk('photo.jpg')
beforeEach(IntegrationTest.before)
afterEach(IntegrationTest.after)
function newIntegrationTest () {
return new IntegrationTest({
'input/london/IMG_0001.jpg': image,
'input/london/IMG_0002.jpg': image,
'input/newyork/day 1/IMG_0003.jpg': image,
'input/newyork/day 2/IMG_0004.jpg': image
})
}
describe('Full', () => {
it('removes files that no longer exist in the source', function (done) {
const integration = newIntegrationTest()
integration.run(['--scan-mode', 'full'], () => {
const london1 = integration.parseYaml('london.html')
should(london1.files).have.length(2)
// delete a file and run again
integration.deleteInputFile('input/london/IMG_0002.jpg')
integration.run(['--scan-mode', 'full', '--cleanup', 'true'], () => {
const london2 = integration.parseYaml('london.html')
// the deleted file was removed
should(london2.files).have.length(1)
integration.assertNotExist([
'media/thumbs/london/IMG_0002.jpg',
'media/small/london/IMG_0002.jpg',
'media/large/london/IMG_0002.jpg'
])
done()
})
})
})
it("removes files that don't match the include filter", function (done) {
const integration = newIntegrationTest()
integration.run(['--scan-mode', 'full'], () => {
// first run, there's 2 albums (London + New York)
const index1 = integration.parseYaml('index.html')
should(index1.albums).have.length(2)
// run again, only including New York
integration.run(['--scan-mode', 'full', '--include', 'newyork/**', '--cleanup', 'true'], () => {
const index2 = integration.parseYaml('index.html')
// the London album is no longer there
should(index2.albums).have.length(1)
should(index2.albums[0].title).eql('newyork')
integration.assertNotExist([
'media/thumbs/london/IMG_0001.jpg',
'media/small/london/IMG_0001.jpg',
'media/large/london/IMG_0001.jpg'
])
done()
})
})
})
})
describe('Partial', () => {
it('ignores changes outside the include pattern', function (done) {
const integration = newIntegrationTest()
integration.run(['--scan-mode', 'full'], () => {
const london1 = integration.parseYaml('london.html')
should(london1.files).have.length(2)
integration.deleteInputFile('input/london/IMG_0002.jpg')
// run again, with only processing New York
integration.run(['--scan-mode', 'partial', '--include', 'newyork/**', '--cleanup', 'true'], () => {
// the London album still exists
const index2 = integration.parseYaml('index.html')
should(index2.albums).have.length(2)
// and it still has 2 files
const london2 = integration.parseYaml('london.html')
should(london2.files).have.length(2)
// and the excluded thumbnails are not deleted
integration.assertExist(['media/thumbs/london/IMG_0002.jpg'])
done()
})
})
})
})
describe('Incremental', () => {
it('does not remove deleted files', function (done) {
const integration = newIntegrationTest()
integration.run(['--scan-mode', 'full'], () => {
const london1 = integration.parseYaml('london.html')
should(london1.files).have.length(2)
// run again after deleting a file
integration.deleteInputFile('input/london/IMG_0002.jpg')
integration.run(['--scan-mode', 'incremental'], () => {
const london2 = integration.parseYaml('london.html')
should(london2.files).have.length(2)
integration.assertExist(['media/thumbs/london/IMG_0002.jpg'])
done()
})
})
})
})
})

@ -1,57 +0,0 @@
const IntegrationTest = require('./integration-test')
const fixtures = require('../fixtures')
describe('Integration: media files', function () {
this.slow(5000)
this.timeout(5000)
beforeEach(IntegrationTest.before)
afterEach(IntegrationTest.after)
const image = fixtures.fromDisk('photo.jpg')
const integration = new IntegrationTest({
'input/london/IMG_0001.jpg': image,
'input/london/IMG_0002.jpg': image,
'input/newyork/day 1/IMG_0003.jpg': image,
'input/newyork/day 2/IMG_0004.jpg': image
})
it('builds the gallery from scratch', function (done) {
const customOpts = []
integration.run(customOpts, () => {
// Database
integration.assertExist([
'thumbsup.db'
])
// Albums
integration.assertExist([
'index.html',
'london.html',
'newyork-day-1.html',
'newyork-day-2.html'
])
// Thumbnails
integration.assertExist([
'media/thumbs/london/IMG_0001.jpg',
'media/thumbs/london/IMG_0002.jpg',
'media/thumbs/newyork/day 1/IMG_0003.jpg',
'media/thumbs/newyork/day 2/IMG_0004.jpg'
])
// Large versions
integration.assertExist([
'media/large/london/IMG_0001.jpg',
'media/large/london/IMG_0002.jpg',
'media/large/newyork/day 1/IMG_0003.jpg',
'media/large/newyork/day 2/IMG_0004.jpg'
])
done()
})
})
it('builds the gallery a second time', function (done) {
const customOpts = []
integration.run(customOpts, () => {
done()
})
})
})

@ -1,84 +0,0 @@
const fs = require('node:fs')
const path = require('node:path')
const debug = require('debug')
const glob = require('glob')
const YAML = require('yaml')
const should = require('should/as-function')
const fixtures = require('../fixtures')
const options = require('../../src/cli/options')
const index = require('../../src/index')
class IntegrationTest {
constructor (structure) {
this.tmpdir = fixtures.createTempStructure(structure)
this.input = path.join(this.tmpdir, 'input')
this.output = path.join(this.tmpdir, 'output')
this.actualFiles = []
}
run (customOptions, done) {
const defaultOptions = [
'--input', this.input,
'--output', this.output,
'--theme-path', 'test-fixtures/theme',
'--log', 'info'
]
const allOptions = defaultOptions.concat(customOptions)
const opts = options.get(allOptions)
index.build(opts, err => {
// Reset the logger ASAP to print the test status
console.log = console.logOld
should(err).eql(null)
debug.assertNotContains('thumbsup:error')
this.actualFiles = glob.sync('**/*', {
cwd: this.output,
nodir: true,
nonull: false
})
setImmediate(done)
})
}
deleteInputFile (filepath) {
fixtures.deleteTempFile(this.tmpdir, filepath)
}
assertExist (expected) {
const missing = expected.filter(f => this.actualFiles.indexOf(f) === -1)
should(missing).eql([])
}
assertNotExist (expected) {
const present = expected.filter(f => this.actualFiles.indexOf(f) !== -1)
should(present).eql([])
}
parse (filepath) {
const fullpath = path.join(this.output, filepath)
return fs.readFileSync(fullpath, { encoding: 'utf8' })
}
parseYaml (filepath) {
const contents = this.parse(filepath)
return YAML.parse(contents)
}
getPath (structurePath) {
return path.join(this.tmpdir, structurePath)
}
}
IntegrationTest.before = function () {
// Listr uses control.log() to print progress
// But so does Mocha to print test results
// So we override it for the duration of the integration test
console.logOld = console.log
console.log = debug('thumbsup:info')
debug.reset()
}
IntegrationTest.after = function () {
console.log = console.logOld
}
module.exports = IntegrationTest

@ -1,38 +0,0 @@
const should = require('should/as-function')
const IntegrationTest = require('./integration-test')
const fixtures = require('../fixtures')
describe('Integration: themes', function () {
this.slow(5000)
this.timeout(5000)
beforeEach(IntegrationTest.before)
afterEach(IntegrationTest.after)
const integration = new IntegrationTest({
'input/IMG_0001.jpg': fixtures.fromDisk('photo.jpg'),
'custom.less': '@color: #444;'
})
it('processes LESS variables', function (done) {
const customOpts = []
integration.run(customOpts, () => {
integration.assertExist(['public/theme.css'])
const res = integration.parse('public/theme.css')
should(res.includes('border: #333')).eql(true)
done()
})
})
it('can customise LESS variables', function (done) {
const customOpts = [
'--theme-style', integration.getPath('custom.less')
]
integration.run(customOpts, () => {
integration.assertExist(['public/theme.css'])
const res = integration.parse('public/theme.css')
should(res.includes('border: #444')).eql(true)
done()
})
})
})

@ -1,38 +0,0 @@
const should = require('should/as-function')
const IntegrationTest = require('./integration-test')
const fixtures = require('../fixtures')
describe('Integration: urls', function () {
this.slow(5000)
this.timeout(5000)
beforeEach(IntegrationTest.before)
afterEach(IntegrationTest.after)
const integration = new IntegrationTest({
'input/IMG_0001.jpg': fixtures.fromDisk('photo.jpg')
})
it('uses relative URLs by default', function (done) {
const customOpts = []
integration.run(customOpts, () => {
integration.assertExist(['index.html'])
const res = integration.parseYaml('index.html')
should(res.files[0].thumbnail).eql('media/thumbs/IMG_0001.jpg')
done()
})
})
it('can use an external link prefix', function (done) {
const customOpts = [
'--photo-preview', 'link',
'--link-prefix', 'http://example.com'
]
integration.run(customOpts, () => {
integration.assertExist(['index.html'])
const res = integration.parseYaml('index.html')
should(res.files[0].preview).eql('http://example.com/IMG_0001.jpg')
done()
})
})
})

@ -0,0 +1,108 @@
const debug = require('debug')
const glob = require('glob')
const path = require('path')
const should = require('should/as-function')
const fixtures = require('../fixtures')
const options = require('../../bin/options')
const index = require('../../src/index')
describe('Full integration', function () {
this.slow(5000)
this.timeout(5000)
var tmpdir = null
var opts = null
before(() => {
const image = fixtures.fromDisk('photo.jpg')
tmpdir = fixtures.createTempStructure({
'input/london/IMG_0001.jpg': image,
'input/london/IMG_0002.jpg': image,
'input/newyork/day 1/IMG_0003.jpg': image,
'input/newyork/day 2/IMG_0004.jpg': image
})
opts = options.get([
'--input', path.join(tmpdir, 'input'),
'--output', path.join(tmpdir, 'output'),
'--title', 'Photo album',
'--homeAlbumName', 'Home',
'--theme', 'classic',
'--log', 'info'
])
})
// Listr uses control.log() to print progress
// But so does Mocha to print test results
// So we override it for the duration of the integration test
beforeEach(() => {
console.logOld = console.log
console.log = debug('thumbsup:info')
debug.reset()
})
afterEach(() => {
console.log = console.logOld
})
it('builds the gallery from scratch', function (testDone) {
index.build(opts, err => {
// Reset the logger ASAP to print the test status
console.log = console.logOld
// Check for any errors
console.log(err)
should(err).eql(null)
debug.assertNotContains('thumbsup:error')
// Check the contents of the output folder
const actualFiles = actualStructure(opts.output)
// Database
assertExist(actualFiles, [
'thumbsup.db'
])
// Albums
assertExist(actualFiles, [
'index.html',
'london.html',
'newyork-day-1.html',
'newyork-day-2.html'
])
// Thumbnails
assertExist(actualFiles, [
'media/thumbs/london/IMG_0001.jpg',
'media/thumbs/london/IMG_0002.jpg',
'media/thumbs/newyork/day 1/IMG_0003.jpg',
'media/thumbs/newyork/day 2/IMG_0004.jpg'
])
// Large versions
assertExist(actualFiles, [
'media/large/london/IMG_0001.jpg',
'media/large/london/IMG_0002.jpg',
'media/large/newyork/day 1/IMG_0003.jpg',
'media/large/newyork/day 2/IMG_0004.jpg'
])
testDone()
})
})
it('builds the gallery a second time (nothing to do)', function (testDone) {
index.build(opts, err => {
// Reset the logger ASAP to print the test status
console.log = console.logOld
should(err).eql(null)
testDone()
})
})
})
function actualStructure (dir) {
return glob.sync('**/*', {
cwd: dir,
ignore: 'public',
nodir: true,
nonull: false
})
}
function assertExist (actual, expected) {
const missing = expected.filter(f => actual.indexOf(f) === -1)
should([]).eql(missing)
}

@ -1,5 +1,5 @@
const util = require('node:util')
const debug = require('debug')
const util = require('util')
debug.recorded = []

@ -0,0 +1,3 @@
--recursive
--require test/helpers.js
--require test/log.js

@ -1,113 +0,0 @@
const _ = require('lodash')
const should = require('should/as-function')
const Album = require('../../src/model/album')
const fixtures = require('../fixtures')
function arrayOfFiles (count) {
const base = new Array(count)
return Array.from(base, (_, index) => fixtures.photo(`${index}`))
}
function outputName (file) {
const thumb = file.urls.thumbnail
return thumb.substring(thumb.lastIndexOf('/') + 1, thumb.lastIndexOf('.'))
}
describe('Album', function () {
this.slow(200)
describe('previews', function () {
it('picks the first 10 photos by default', function () {
const album = new Album({ files: arrayOfFiles(100) })
album.finalize()
should(album.previews).have.length(10)
const thumbs = album.previews.map(outputName)
should(thumbs).eql(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
})
it('adds <missing> thumbnails to fill', function () {
const album = new Album({ files: arrayOfFiles(5) })
album.finalize()
const thumbs = album.previews.map(outputName)
should(thumbs.slice(0, 5)).eql(['0', '1', '2', '3', '4'])
for (let i = 5; i < 10; ++i) {
should(album.previews[i].urls.thumbnail).eql('public/missing.png')
}
})
it('uses files from nested albums too', function () {
const album = new Album({
title: 'a',
files: [fixtures.photo('a1'), fixtures.photo('a2')],
albums: [
new Album({
title: 'b',
files: [fixtures.photo('b1'), fixtures.photo('b2')]
}),
new Album({
title: 'c',
files: [fixtures.photo('c1'), fixtures.photo('c2')]
})
]
})
album.finalize()
should(album.previews).have.length(10)
const thumbs = album.previews.map(outputName)
should(thumbs.slice(0, 6)).eql(['a1', 'a2', 'b1', 'b2', 'c1', 'c2'])
for (let i = 6; i < 10; ++i) {
should(album.previews[i].urls.thumbnail).eql('public/missing.png')
}
})
describe('preview modes', () => {
it('can pick the first 10 photos', function () {
const album = new Album({ files: arrayOfFiles(100) })
album.finalize({ albumPreviews: 'first' })
should(album.previews).have.length(10)
const thumbs = album.previews.map(outputName)
should(thumbs).eql(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
})
it('can randomize the previews', function () {
const album = new Album({ files: arrayOfFiles(100) })
album.finalize({ albumPreviews: 'random' })
should(album.previews).have.length(10)
should(_.uniq(album.previews)).have.length(10)
})
it('can spread the previews', function () {
const album = new Album({ files: arrayOfFiles(50) })
album.finalize({ albumPreviews: 'spread' })
should(album.previews).have.length(10)
const thumbs = album.previews.map(outputName)
should(thumbs).eql(['0', '5', '10', '15', '20', '25', '30', '35', '40', '45'])
})
it('ignores the extra photos when spreading on un-even counts', function () {
const album = new Album({ files: arrayOfFiles(58) })
album.finalize({ albumPreviews: 'spread' })
should(album.previews).have.length(10)
const thumbs = album.previews.map(outputName)
should(thumbs).eql(['0', '5', '10', '15', '20', '25', '30', '35', '40', '45'])
})
it('picks the first 10 when trying to spread under 10 photos', function () {
const album = new Album({ files: arrayOfFiles(5) })
album.finalize({ albumPreviews: 'spread' })
should(album.previews).have.length(10)
const thumbs = album.previews.map(outputName)
should(thumbs.slice(0, 5)).eql(['0', '1', '2', '3', '4'])
for (let i = 5; i < 10; ++i) {
should(album.previews[i].urls.thumbnail).eql('public/missing.png')
}
})
it('throws an error if the preview type is not supported', function () {
const album = new Album({ files: arrayOfFiles(5) })
should.throws(function () {
album.finalize({ albumPreviews: 'test' })
})
})
})
})
})

@ -1,6 +1,6 @@
const should = require('should/as-function')
const Album = require('../../src/model/album')
const fixtures = require('../fixtures')
var should = require('should/as-function')
var Album = require('../../src/model/album')
var fixtures = require('../fixtures')
describe('Album', function () {
describe('stats', function () {

@ -4,13 +4,6 @@ const should = require('should/as-function')
const Album = require('../../src/model/album')
const fixtures = require('../fixtures')
// Common fixtures
const fileA = fixtures.photo({ path: 'a' })
const fileB = fixtures.photo({ path: 'b' })
const fileC = fixtures.photo({ path: 'c' })
const file2010 = fixtures.photo({ date: '2010-01-01' })
const file2011 = fixtures.photo({ path: '2011-01-01' })
describe('Album', function () {
describe('options', function () {
it('can pass the title as a single argument', function () {
@ -32,12 +25,12 @@ describe('Album', function () {
})
it('sanitises more special characters than the slugify() default', function () {
const a = new Album('hello*+~.()\'"!:@world')
const a = new Album(`hello*+~.()'"!:@world`)
should(a.basename).eql('helloworld')
})
it('doesn\'t use a dash if the words have no space', function () {
// not ideal but that's how slugify() works
// not ideal but that's hoz slugify() works
const a = new Album("aujourd'hui")
should(a.basename).eql('aujourdhui')
})
@ -107,7 +100,49 @@ describe('Album', function () {
})
root.finalize({ index: 'index.html', albumsOutputFolder: 'albums' })
should(root.path).eql('index.html')
should(root.albums[0].url).eql('albums/2010.html')
should(root.albums[0].path).eql('albums/2010.html')
})
})
describe('previews', function () {
it('picks the first 10 files as previews', function () {
const a = new Album({ files: [
fixtures.photo(), fixtures.photo(), fixtures.photo(), fixtures.photo(),
fixtures.photo(), fixtures.photo(), fixtures.photo(), fixtures.photo(),
fixtures.photo(), fixtures.photo(), fixtures.photo(), fixtures.photo()
] })
a.finalize()
should(a.previews).have.length(10)
})
it('adds <missing> thumbnails to fill', function () {
const a = new Album({ files: [
fixtures.photo(), fixtures.photo()
] })
a.finalize()
should(a.previews[2].urls.thumbnail).eql('public/missing.png')
should(a.previews[9].urls.thumbnail).eql('public/missing.png')
})
it('uses files from nested albums too', function () {
const a = new Album({
title: 'a',
albums: [
new Album({
title: 'b',
files: [fixtures.photo(), fixtures.photo()]
}),
new Album({
title: 'c',
files: [fixtures.photo(), fixtures.photo()]
})
]
})
a.finalize()
should(a.previews).have.length(10)
for (var i = 0; i < 4; ++i) {
should(a.previews[i].urls.thumbnail).not.eql('public/missing.png')
}
})
})
@ -140,15 +175,21 @@ describe('Album', function () {
})
it('can sort media by filename', function () {
const album = new Album({ files: [fileC, fileA, fileB] })
const a = fixtures.photo({ path: 'a' })
const b = fixtures.photo({ path: 'b' })
const c = fixtures.photo({ path: 'c' })
const album = new Album({ files: [c, a, b] })
album.finalize({ sortMediaBy: 'filename' })
should(album.files).eql([fileA, fileB, fileC])
should(album.files).eql([a, b, c])
})
it('can sort media by reverse filename', function () {
const album = new Album({ files: [fileC, fileA, fileB] })
const a = fixtures.photo({ path: 'a' })
const b = fixtures.photo({ path: 'b' })
const c = fixtures.photo({ path: 'c' })
const album = new Album({ files: [c, a, b] })
album.finalize({ sortMediaBy: 'filename', sortMediaDirection: 'desc' })
should(album.files).eql([fileC, fileB, fileA])
should(album.files).eql([c, b, a])
})
it('can sort media by date', function () {
@ -161,13 +202,15 @@ describe('Album', function () {
})
it('sorts nested albums too', function () {
const nested = new Album({
title: 'nested',
files: [fileB, fileA]
})
const nested = new Album({ title: 'nested',
files: [
fixtures.photo({ path: 'b' }),
fixtures.photo({ path: 'a' })
] })
const root = new Album({ title: 'home', albums: [nested] })
root.finalize({ sortMediaBy: 'filename' })
should(nested.files).eql([fileA, fileB])
should(nested.files[0].path).eql('a')
should(nested.files[1].path).eql('b')
})
})
@ -201,72 +244,15 @@ describe('Album', function () {
})
it('passes finalising options to all nested albums (e.g. sorting)', function () {
const nested = new Album({
title: 'nested',
files: [fileB, fileA]
})
const nested = new Album({ title: 'nested',
files: [
fixtures.photo({ path: 'b' }),
fixtures.photo({ path: 'a' })
] })
const root = new Album({ title: 'home', albums: [nested] })
root.finalize({ sortMediaBy: 'filename' })
should(nested.files).eql([fileA, fileB])
})
})
describe('nested sorting', function () {
it('can specify nested album sorting method', function () {
const a1 = new Album({ files: [file2011] })
const a2 = new Album({ files: [file2010] })
const a = new Album({ title: 'A', albums: [a1, a2] })
const b = new Album('B')
const root = new Album({ albums: [b, a] })
root.finalize({
sortAlbumsBy: ['title', 'start-date'],
sortAlbumsDirection: 'asc'
})
should(root.albums).eql([a, b])
should(a.albums).eql([a2, a1])
})
it('can specify nested album sorting direction', function () {
const a1 = new Album('A1')
const a2 = new Album('A2')
const a = new Album({ albums: [a1, a2] })
const b = new Album('B')
const root = new Album({ albums: [a, b] })
root.finalize({
sortAlbumsBy: 'title',
sortAlbumsDirection: ['asc', 'desc']
})
should(root.albums).eql([a, b])
should(a.albums).eql([a2, a1])
})
it('can specify nested media sorting method', function () {
const nested = new Album({
files: [fileB, fileA]
})
const root = new Album({
albums: [nested],
files: [file2011, file2010]
})
root.finalize({
sortMediaBy: ['date', 'filename'],
sortMediaDirection: 'asc'
})
should(root.files).eql([file2010, file2011])
should(nested.files).eql([fileA, fileB])
})
it('can specify nested media sorting direction', function () {
const nested = new Album({
files: [fileA, fileB]
})
const root = new Album({
albums: [nested],
files: [fileB, fileA]
})
root.finalize({
sortMediaBy: 'filename',
sortMediaDirection: ['asc', 'desc']
})
should(root.files).eql([fileA, fileB])
should(nested.files).eql([fileB, fileA])
should(nested.files[0].path).eql('a')
should(nested.files[1].path).eql('b')
})
})
@ -316,5 +302,5 @@ function albumWithFileDates (dates) {
const files = dates.map(function (d) {
return fixtures.photo({ date: d })
})
return new Album({ files })
return new Album({ files: files })
}

@ -51,22 +51,6 @@ describe('File', function () {
const video = new File(dbFile({ File: { MIMEType: 'video/quicktime' } }))
should(video.isVideo).eql(true)
})
it('exposes the URL for each output file', function () {
const file = new File(dbFile({ File: { MIMEType: 'image/jpeg' } }))
should(file.urls.thumbnail).eql('media/thumbs/photo.jpg')
should(file.urls.small).eql('media/small/photo.jpg')
should(file.urls.large).eql('media/large/photo.jpg')
should(file.urls.download).eql('media/large/photo.jpg')
})
it('encodes the URLs to cater for special characters', function () {
const file = new File(dbFile({
SourceFile: 'test%22folder/photo.jpg',
File: { MIMEType: 'image/jpeg' }
}))
should(file.urls.small).eql('media/small/test%2522folder/photo.jpg')
})
})
function dbFile (data) {

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save