aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
8 files changed, 520 insertions, 0 deletions
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);
+ }
+ }
+}