From 2ecbe281b0351e630c7730b44c1f4a9c3b3062a5 Mon Sep 17 00:00:00 2001 From: Daniel Smith Date: Sun, 26 Aug 2018 21:22:32 -0400 Subject: Initial commit --- src/Commands/ExtractCommand.cs | 158 +++++++++++++++++++++++++++++++++++++++++ src/Commands/ICommand.cs | 7 ++ src/Commands/PreviewCommand.cs | 149 ++++++++++++++++++++++++++++++++++++++ src/Commands/RootCommand.cs | 34 +++++++++ src/Photo.cs | 52 ++++++++++++++ src/PhotoPath.cs | 38 ++++++++++ src/PhotoStore.cs | 63 ++++++++++++++++ src/Program.cs | 19 +++++ 8 files changed, 520 insertions(+) create mode 100644 src/Commands/ExtractCommand.cs create mode 100644 src/Commands/ICommand.cs create mode 100644 src/Commands/PreviewCommand.cs create mode 100644 src/Commands/RootCommand.cs create mode 100644 src/Photo.cs create mode 100644 src/PhotoPath.cs create mode 100644 src/PhotoStore.cs create mode 100644 src/Program.cs (limited to 'src') 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 photos = photoStore.GetAllPhotos(); + + Dictionary> 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 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 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 photos = photoStore.GetAllPhotos(); + + Dictionary> 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 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 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 paths = new List(); + + 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 _relativePaths; + + public int Id { get; } + public string Caption { get; set; } + public string AlbumName { get; set; } + public IEnumerable RelativePaths => _relativePaths; + + public Photo(int id, string caption = null, string albumName = null) + { + Id = id; + Caption = caption; + AlbumName = albumName; + _relativePaths = new List(); + } + + 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 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 GetAllPhotos() + { + var photos = new Dictionary(); + + 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); + } + } +} -- cgit v1.2.3