aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Daniel Smith <rdnlsmith@gmail.com> 2018-08-26 21:22:32 -0400
committerGravatar Daniel Smith <rdnlsmith@gmail.com> 2018-08-26 21:22:32 -0400
commit2ecbe281b0351e630c7730b44c1f4a9c3b3062a5 (patch)
tree4c9ab179dd654230b4f9b29da8437bee9b0fffac
Initial commit
-rw-r--r--.gitignore34
-rw-r--r--LICENSE.md9
-rw-r--r--README.md78
-rw-r--r--iPhotoExtractor.csproj14
-rw-r--r--src/Commands/ExtractCommand.cs158
-rw-r--r--src/Commands/ICommand.cs7
-rw-r--r--src/Commands/PreviewCommand.cs149
-rw-r--r--src/Commands/RootCommand.cs34
-rw-r--r--src/Photo.cs52
-rw-r--r--src/PhotoPath.cs38
-rw-r--r--src/PhotoStore.cs63
-rw-r--r--src/Program.cs19
12 files changed, 655 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bcf84ec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,34 @@
+# Borrowed from https://gist.github.com/vmandic/ac2ecc9c24f6899ee0ec46e4ce444a0e
+
+*.swp
+*.*~
+project.lock.json
+.DS_Store
+*.pyc
+
+# Visual Studio Code
+.vscode
+
+# User-specific files
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+build/
+bld/
+[Bb]in/
+[Oo]bj/
+msbuild.log
+msbuild.err
+msbuild.wrn
+
+# Visual Studio 2015
+.vs/ \ No newline at end of file
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..3dd9515
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 R. Daniel Smith
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..43c3627
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+# iPhotoExtractor
+
+Copies pictures from an iPhoto library into a directory structure based on iPhoto events. For
+example:
+
+```
+.
+├── Christmas
+│ ├── IMG_0001.JPG
+│ ├── IMG_0002.JPG
+│ ├── IMG_0003.JPG
+│ └── Originals
+│   └── IMG_0002.JPG
+└── New Year's
+ ├── IMG_0004.JPG
+ └── IMG_0005.JPG
+```
+
+If a photo was modified by iPhoto, the modified version will appear in the main event directory and
+the original version will be copied to an `Originals` subdirectory.
+
+This was inspired by
+[gaetronik/iPhotoShotwell](https://github.com/gaetronik/iPhotoShotwell)
+and
+[fasaxc/shotwell-iphoto-import](https://github.com/fasaxc/shotwell-iphoto-import),
+but differs in two respects:
+
+1. Both scripts target the Linux-only
+ [Shotwell](https://gitlab.gnome.org/GNOME/shotwell/)
+ photo manager, whereas this produces a platform-independent directory tree; and
+2. Both scripts obtain album data from the `AlbumData.xml` file, whereas this reads the `SqEvents`
+ table from `iPhotoMain.db`. This was important in my case, because the events had been used as
+ albums and the actual albums were uselessly broad things like "Photos," "Last Import," etc.
+
+## Requirements
+
+Install the
+[.NET Core SDK](https://www.microsoft.com/net/download)
+(2.0 or newer) for your platform.
+
+## Usage
+
+Clone the repo and run like so:
+
+```bash
+git clone https://github.com/rdnlsmith/iPhotoExtractor.git
+cd iPhotoExtractor
+dotnet run extract '/path/to/iPhoto Library' '/path/to/output'
+```
+
+The output folder does not need to exist beforehand. In fact, it will display a warning if you do
+specify an existing directory.
+
+If you want to see what the final directory structure will look like without actually copying any
+files, run
+
+```bash
+dotnet run preview '/path/to/iPhoto Library' '/path/to/output'
+```
+
+## Limitations
+
+This program has only been tested with one iPhoto library, and I don't know what version of iPhoto
+produced it.
+
+This program ought to work on any platform supporting .NET Core, but it has only been tested on
+Ubuntu 18.04.
+
+The output of the `preview` command is not guaranteed to be entirely correct, as the `extract`
+command will append _e. g._ `(2)` to a file name if a file by that name already exists. This may
+occur since some of the modified photos in my library had multiple file paths.
+
+The `extract` command copies every photo, which means you need enough free disk space to duplicate
+your library. I might consider adding a `--destructive` flag to move photos instead of copying them.
+
+This program has already served its purpose, and I don't have any more iPhoto libraries to test it
+on, so I'm not likely to make further improvements for myself. If you use this program and run into
+problems, please open an issue and I will try to help. \ No newline at end of file
diff --git a/iPhotoExtractor.csproj b/iPhotoExtractor.csproj
new file mode 100644
index 0000000..36393db
--- /dev/null
+++ b/iPhotoExtractor.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>netcoreapp2.0</TargetFramework>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Data.Sqlite.Core" Version="2.1.0" />
+ <PackageReference Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
+ <PackageReference Include="SQLitePCLRaw.bundle_green" Version="1.1.11" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Commands/ExtractCommand.cs b/src/Commands/ExtractCommand.cs
new file mode 100644
index 0000000..5872fd6
--- /dev/null
+++ b/src/Commands/ExtractCommand.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace iPhotoExtractor.Commands
+{
+ public class ExtractCommand : ICommand
+ {
+ public static void Configure(CommandLineApplication command)
+ {
+ command.HelpOption("-h|--help");
+ command.Description = "Copy photos from their original locations to a directory " +
+ "structure mirroring the event albums within iPhoto.";
+
+ var libPathArgument = command.Argument(
+ "library_dir",
+ "Path to the iPhoto library directory.");
+
+ var outputDirArgument = command.Argument(
+ "[output_dir]",
+ "Path to the directory where the extracted photos will be copied.");
+
+ command.OnExecute(() =>
+ {
+ (new ExtractCommand(libPathArgument.Value, outputDirArgument.Value)).Run();
+ return 0;
+ });
+ }
+
+ private readonly string _libraryDir;
+ private readonly string _outputDir;
+
+ public ExtractCommand(string libraryDir, string outputDir)
+ {
+ _libraryDir = libraryDir;
+ _outputDir = outputDir;
+ }
+
+ public void Run()
+ {
+ var libraryDir = String.IsNullOrWhiteSpace(_libraryDir) ?
+ Directory.GetCurrentDirectory() :
+ _libraryDir;
+
+ var outputDir = String.IsNullOrWhiteSpace(_outputDir) ?
+ Path.Combine(Directory.GetCurrentDirectory(), "Extracted Photos") :
+ _outputDir;
+
+ var dbPath = Path.Combine(libraryDir, "iPhotoMain.db");
+
+ if (!File.Exists(dbPath))
+ {
+ Console.WriteLine($"File '{dbPath}' not found.");
+ return;
+ }
+
+ if (Directory.Exists(outputDir))
+ {
+ Console.Write($"Warning: directory '{outputDir}' already exists. Continue (y/n)? ");
+ var response = Console.ReadLine().Trim().ToLower();
+
+ if (response != "y" && response != "yes")
+ return;
+ }
+
+ Directory.CreateDirectory(outputDir);
+
+ var photoStore = new PhotoStore(dbPath);
+ List<Photo> photos = photoStore.GetAllPhotos();
+
+ Dictionary<string, List<Photo>> albums = photos
+ .GroupBy(p => p.AlbumName)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ Console.WriteLine($"Found {albums.Keys.Count} albums.");
+ var counter = 1;
+
+ foreach (string album in albums.Keys)
+ {
+ var albumPhotos = albums[album];
+ var s = albumPhotos.Count == 1 ? "" : "s";
+
+ Console.WriteLine($"[{counter}/{albums.Keys.Count}] Extracting album '{album}' " +
+ $"({albumPhotos.Count} photo{s})...");
+
+ ExtractAlbum(libraryDir, outputDir, album, albumPhotos);
+ counter++;
+ }
+
+ Console.WriteLine("Done.");
+ }
+
+ private void ExtractAlbum(
+ string libraryDir,
+ string outputDir,
+ string albumName,
+ List<Photo> photos)
+ {
+ string albumDir = String.IsNullOrWhiteSpace(albumName) ?
+ Path.Combine(outputDir, "Untitled Album") :
+ Path.Combine(outputDir, albumName);
+
+ if (!Directory.Exists(albumDir))
+ Directory.CreateDirectory(albumDir);
+
+ var hasModified = photos.Any(p => p.HasModifiedVersion());
+ string originalsDir = Path.Combine(albumDir, "Originals");
+
+ if (hasModified && !Directory.Exists(originalsDir))
+ Directory.CreateDirectory(originalsDir);
+
+ foreach (var photo in photos)
+ {
+ List<string> paths = photo.GetUniquePaths(PhotoPathType.Modified);
+ bool isModified = false;
+
+ if (paths.Any())
+ {
+ isModified = true;
+
+ foreach (string path in paths)
+ {
+ CopyPhoto(libraryDir, albumDir, path);
+ }
+ }
+
+ paths = photo.GetUniquePaths(PhotoPathType.Original);
+ string destDir = isModified ? originalsDir : albumDir;
+
+ foreach (string path in paths)
+ {
+ CopyPhoto(libraryDir, destDir, path);
+ }
+ }
+ }
+
+ private void CopyPhoto(string libraryDir, string destDir, string relativePhotoPath)
+ {
+ string fileName = Path.GetFileNameWithoutExtension(relativePhotoPath);
+ string extension = Path.GetExtension(relativePhotoPath);
+ string destFileName = $"{fileName}{extension}";
+ var suffix = 2;
+
+ while (File.Exists(Path.Combine(destDir, destFileName)))
+ {
+ destFileName = $"{fileName} ({suffix}){extension}";
+ suffix++;
+ }
+
+ string sourcePath = Path.Combine(libraryDir, relativePhotoPath);
+ string destPath = Path.Combine(destDir, destFileName);
+
+ File.Copy(sourcePath, destPath);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Commands/ICommand.cs b/src/Commands/ICommand.cs
new file mode 100644
index 0000000..8515d48
--- /dev/null
+++ b/src/Commands/ICommand.cs
@@ -0,0 +1,7 @@
+namespace iPhotoExtractor.Commands
+{
+ public interface ICommand
+ {
+ void Run();
+ }
+} \ No newline at end of file
diff --git a/src/Commands/PreviewCommand.cs b/src/Commands/PreviewCommand.cs
new file mode 100644
index 0000000..aea4afc
--- /dev/null
+++ b/src/Commands/PreviewCommand.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace iPhotoExtractor.Commands
+{
+ public class PreviewCommand : ICommand
+ {
+ private const char TEE = '\u251c';
+ private const char ELBOW = '\u2514';
+ private const char V_BAR = '\u2502';
+ private const char H_BAR = '\u2500';
+
+ public static void Configure(CommandLineApplication command)
+ {
+ command.HelpOption("-h|--help");
+ command.Description = "Display the directory structure that would result from " +
+ "running 'iPhotoExtractor extract'.";
+
+ var libPathArgument = command.Argument(
+ "library_dir",
+ "Path to the iPhoto library directory.");
+
+ var outputDirArgument = command.Argument(
+ "[output_dir]",
+ "Path to the directory where extracted photos will be copied.");
+
+ command.OnExecute(() =>
+ {
+ (new PreviewCommand(libPathArgument.Value, outputDirArgument.Value)).Run();
+ return 0;
+ });
+ }
+
+ private readonly string _libraryDir;
+ private readonly string _outputDir;
+
+ public PreviewCommand(string libraryDir, string outputDir)
+ {
+ _libraryDir = libraryDir;
+ _outputDir = outputDir;
+ }
+
+ public void Run()
+ {
+ var libraryDir = String.IsNullOrWhiteSpace(_libraryDir) ?
+ Directory.GetCurrentDirectory() :
+ _libraryDir;
+
+ var outputDir = String.IsNullOrWhiteSpace(_outputDir) ?
+ Directory.GetCurrentDirectory() :
+ _outputDir;
+
+ var dbPath = Path.Combine(libraryDir, "iPhotoMain.db");
+
+ if (!File.Exists(dbPath))
+ {
+ Console.WriteLine($"File '{dbPath}' not found.");
+ return;
+ }
+
+ var photoStore = new PhotoStore(dbPath);
+ List<Photo> photos = photoStore.GetAllPhotos();
+
+ Dictionary<string, List<Photo>> albums = photos
+ .GroupBy(p => p.AlbumName)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ Console.WriteLine(_outputDir);
+ string album;
+
+ for (int i = 0; i < albums.Keys.Count - 1; i++)
+ {
+ album = albums.Keys.ElementAt(i);
+ DrawAlbumTree(album, albums[album], false);
+ }
+
+ album = albums.Keys.Last();
+ DrawAlbumTree(album, albums[album], true);
+ }
+
+ private void DrawAlbumTree(string albumName, List<Photo> photos, bool isLastChild)
+ {
+ var modified = photos
+ .Where(p => p.HasModifiedVersion() && p.HasOriginalVersion())
+ .ToList();
+
+ var hasModified = modified.Any();
+ string prefix = isLastChild ? " " : $"{V_BAR} ";
+
+ Console.WriteLine($"{GetCurrLevelTreeString(isLastChild)}{albumName}");
+ DrawPhotos(photos, prefix, hasModified);
+
+ if (!hasModified)
+ return;
+
+ Console.WriteLine($"{prefix}{GetCurrLevelTreeString(true)}Originals");
+ DrawPhotos(modified, prefix + " ", false);
+ }
+
+ private void DrawPhotos(List<Photo> photos, string prefix, bool hasModified)
+ {
+ for (int i = 0; i < photos.Count; i++)
+ {
+ var photo = photos.ElementAt(i);
+ var isLastChild = i == photos.Count - 1 && !hasModified;
+
+ List<string> paths = new List<string>();
+
+ if (hasModified)
+ paths.AddRange(photo.GetUniquePaths(PhotoPathType.Modified));
+
+ if (!paths.Any())
+ paths.AddRange(photo.GetUniquePaths(PhotoPathType.Original));
+
+ if (!paths.Any())
+ paths.Add("");
+
+ for (int j = 0; j < paths.Count; j++)
+ {
+ var path = paths.ElementAt(j);
+
+ if (j < paths.Count - 1)
+ DrawPhoto(path, prefix, false);
+ else
+ DrawPhoto(path, prefix, isLastChild);
+ }
+ }
+ }
+
+ private void DrawPhoto(string relativePath, string prefix, bool isLastChild)
+ {
+ string fileName = Path.GetFileName(relativePath);
+ string currLevel = GetCurrLevelTreeString(isLastChild);
+
+ Console.WriteLine($"{prefix}{currLevel}{fileName} ({relativePath})");
+ }
+
+ private string GetCurrLevelTreeString(bool isLastChild)
+ {
+ if (isLastChild)
+ return $"{ELBOW}{H_BAR}{H_BAR} ";
+ else
+ return $"{TEE}{H_BAR}{H_BAR} ";
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Commands/RootCommand.cs b/src/Commands/RootCommand.cs
new file mode 100644
index 0000000..1abab5a
--- /dev/null
+++ b/src/Commands/RootCommand.cs
@@ -0,0 +1,34 @@
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace iPhotoExtractor.Commands
+{
+ public class RootCommand : ICommand
+ {
+ public static void Configure(CommandLineApplication app)
+ {
+ app.Name = "iPhotoExtractor";
+ app.HelpOption("-h|--help");
+
+ app.Command("preview", PreviewCommand.Configure);
+ app.Command("extract", ExtractCommand.Configure);
+
+ app.OnExecute(() =>
+ {
+ (new RootCommand(app)).Run();
+ return 0;
+ });
+ }
+
+ private readonly CommandLineApplication _app;
+
+ public RootCommand(CommandLineApplication app)
+ {
+ _app = app;
+ }
+
+ public void Run()
+ {
+ _app.ShowHelp();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Photo.cs b/src/Photo.cs
new file mode 100644
index 0000000..513c0b8
--- /dev/null
+++ b/src/Photo.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace iPhotoExtractor
+{
+ class Photo
+ {
+ private readonly List<PhotoPath> _relativePaths;
+
+ public int Id { get; }
+ public string Caption { get; set; }
+ public string AlbumName { get; set; }
+ public IEnumerable<PhotoPath> RelativePaths => _relativePaths;
+
+ public Photo(int id, string caption = null, string albumName = null)
+ {
+ Id = id;
+ Caption = caption;
+ AlbumName = albumName;
+ _relativePaths = new List<PhotoPath>();
+ }
+
+ public void AddPath(string relativePath)
+ {
+ _relativePaths.Add(new PhotoPath(relativePath));
+ }
+
+ public void AddPath(PhotoPath path)
+ {
+ _relativePaths.Add(path);
+ }
+
+ public bool HasOriginalVersion()
+ {
+ return RelativePaths?.Any(p => p.PathType == PhotoPathType.Original) ?? false;
+ }
+
+ public bool HasModifiedVersion()
+ {
+ return RelativePaths?.Any(p => p.PathType == PhotoPathType.Modified) ?? false;
+ }
+
+ public List<string> GetUniquePaths(PhotoPathType pathType)
+ {
+ return RelativePaths?
+ .Where(p => p.PathType == pathType)
+ .Select(p => p.RelativePath)
+ .Distinct()
+ .ToList();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/PhotoPath.cs b/src/PhotoPath.cs
new file mode 100644
index 0000000..71c3bdf
--- /dev/null
+++ b/src/PhotoPath.cs
@@ -0,0 +1,38 @@
+using System.IO;
+
+namespace iPhotoExtractor
+{
+ enum PhotoPathType
+ {
+ Original,
+ Modified,
+ Other
+ }
+
+ class PhotoPath
+ {
+ public PhotoPathType PathType { get; }
+ public string RelativePath { get; }
+
+ public PhotoPath(string relativePath)
+ {
+ RelativePath = relativePath;
+ string[] parts = relativePath.Split(Path.DirectorySeparatorChar);
+
+ switch(parts[0].ToLower())
+ {
+ case "originals":
+ PathType = PhotoPathType.Original;
+ break;
+
+ case "modified":
+ PathType = PhotoPathType.Modified;
+ break;
+
+ default:
+ PathType = PhotoPathType.Other;
+ break;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/PhotoStore.cs b/src/PhotoStore.cs
new file mode 100644
index 0000000..e0b292b
--- /dev/null
+++ b/src/PhotoStore.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+
+namespace iPhotoExtractor
+{
+ class PhotoStore
+ {
+ private readonly string _sqliteConnectionString;
+
+ public PhotoStore(string pathToDbFile)
+ {
+ _sqliteConnectionString = $"Data Source={pathToDbFile};";
+ }
+
+ public List<Photo> GetAllPhotos()
+ {
+ var photos = new Dictionary<int, Photo>();
+
+ var query =
+@"select
+ spi.primaryKey,
+ spi.caption,
+ se.name,
+ sfi.relativePath
+from SqPhotoInfo spi
+left outer join SqEvent se on se.primaryKey = spi.event
+left outer join SqFileImage sfim on sfim.photoKey = spi.primaryKey
+left outer join SqFileInfo sfi on sfi.primaryKey = sfim.sqFileInfo
+where lower(sfi.relativePath) like 'modified%' or lower(sfi.relativePath) like 'originals%'
+";
+
+ using (var connection = new SqliteConnection(_sqliteConnectionString))
+ {
+ connection.Open();
+
+ using (var command = new SqliteCommand(query, connection))
+ using (SqliteDataReader reader = command.ExecuteReader())
+ {
+ while (reader.Read())
+ {
+ var id = Convert.ToInt32(reader["primaryKey"]);
+ var photoPath = new PhotoPath(reader["relativePath"].ToString());
+
+ if (!photos.ContainsKey(id))
+ {
+ var caption = reader["caption"].ToString();
+ var albumName = reader["name"].ToString();
+ var photo = new Photo(id, caption, albumName);
+
+ photos.Add(id, photo);
+ }
+
+ photos[id].AddPath(photoPath);
+ }
+ }
+ }
+
+ return photos.Values.ToList();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Program.cs b/src/Program.cs
new file mode 100644
index 0000000..c0d1a20
--- /dev/null
+++ b/src/Program.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using iPhotoExtractor.Commands;
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace iPhotoExtractor
+{
+ class Program
+ {
+ static void Main(string[] args)
+ {
+ var app = new CommandLineApplication();
+ RootCommand.Configure(app);
+ app.Execute(args);
+ }
+ }
+}