commit d6dbe2235848438d735b0ab60cd199c0c7b53971 Author: Jeremy Anderson Date: Mon Aug 10 08:33:00 2026 -0400 AutoIngest is a Windows tray-resident tool that silently moves photos off an SD card or USB drive into a dated folder on your PC diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76590c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# AutoIngest .gitignore +# Build outputs and tooling caches are ignored. The two publish/ folders below +# are intentionally NOT ignored — they hold the distributable .exe builds that +# get committed for end users. + +## .NET build output +**/bin/ +**/obj/ + +## Visual Studio / IDE state +.vs/ +*.user +*.suo +*.userprefs +*.vspx + +## Visual Studio Code +.vscode/ + +## ReSharper / Rider +.idea/ +*.DotSettings.user + +## Publish working dirs (these are created locally by the publish scripts when +## testing intermediate output; the canonical builds live in publish/ and +## publish-standalone/ at the repo root, which are tracked). +publish-temp/ +publish-test/ + +## OS-generated files +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +.DS_Store + +## NuGet (project-level; the single PackageReference is restored from nuget.org) +*.nupkg +*.snupkg +.nuget/ + +## Logs and local config scraps +*.log +*.tmp diff --git a/AutoIngest.sln b/AutoIngest.sln new file mode 100644 index 0000000..406d0ac --- /dev/null +++ b/AutoIngest.sln @@ -0,0 +1,19 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoIngest", "AutoIngest\AutoIngest.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/AutoIngest/App/MainForm.Designer.cs b/AutoIngest/App/MainForm.Designer.cs new file mode 100644 index 0000000..c3500fc --- /dev/null +++ b/AutoIngest/App/MainForm.Designer.cs @@ -0,0 +1,122 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace AutoIngest.App +{ + /// + /// Tiny borderless toast popup. Lives in the tray by default; only shown briefly + /// while an SD/USB import is running (and a few seconds after it finishes). + /// Canonical serialized InitializeComponent so the WinForms Designer can open it. + /// + partial class MainForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _trayBitmap?.Dispose(); + _hideTimer?.Dispose(); + _monitor?.Dispose(); + components?.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + this.pnlToast = new System.Windows.Forms.Panel(); + this.txtLog = new System.Windows.Forms.RichTextBox(); + this.lblStatus = new System.Windows.Forms.Label(); + this.lblTitle = new System.Windows.Forms.Label(); + this.pnlToast.SuspendLayout(); + this.SuspendLayout(); + // + // pnlToast + // + this.pnlToast.BackColor = System.Drawing.Color.FromArgb(30, 30, 30); + this.pnlToast.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.pnlToast.Controls.Add(this.txtLog); + this.pnlToast.Controls.Add(this.lblStatus); + this.pnlToast.Controls.Add(this.lblTitle); + this.pnlToast.Dock = System.Windows.Forms.DockStyle.Fill; + this.pnlToast.Location = new System.Drawing.Point(0, 0); + this.pnlToast.Name = "pnlToast"; + this.pnlToast.Size = new System.Drawing.Size(340, 170); + this.pnlToast.TabIndex = 0; + // + // txtLog + // + this.txtLog.BackColor = System.Drawing.Color.FromArgb(20, 20, 20); + this.txtLog.BorderStyle = System.Windows.Forms.BorderStyle.None; + this.txtLog.Dock = System.Windows.Forms.DockStyle.Fill; + this.txtLog.Font = new System.Drawing.Font("Consolas", 8F); + this.txtLog.ForeColor = System.Drawing.Color.FromArgb(224, 224, 224); + this.txtLog.Location = new System.Drawing.Point(0, 48); + this.txtLog.Name = "txtLog"; + this.txtLog.ReadOnly = true; + this.txtLog.ScrollBars = System.Windows.Forms.RichTextBoxScrollBars.Vertical; + this.txtLog.Size = new System.Drawing.Size(338, 121); + this.txtLog.TabIndex = 2; + this.txtLog.Text = ""; + // + // lblStatus + // + this.lblStatus.BackColor = System.Drawing.Color.FromArgb(30, 30, 30); + this.lblStatus.Dock = System.Windows.Forms.DockStyle.Top; + this.lblStatus.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold); + this.lblStatus.ForeColor = System.Drawing.Color.FromArgb(88, 166, 255); + this.lblStatus.Location = new System.Drawing.Point(0, 24); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Padding = new System.Windows.Forms.Padding(10, 2, 10, 2); + this.lblStatus.Size = new System.Drawing.Size(338, 24); + this.lblStatus.TabIndex = 1; + this.lblStatus.Text = "\u25CF Idle \u2014 insert an SD card or USB drive"; + this.lblStatus.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // lblTitle + // + this.lblTitle.BackColor = System.Drawing.Color.FromArgb(42, 42, 42); + this.lblTitle.Dock = System.Windows.Forms.DockStyle.Top; + this.lblTitle.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Bold); + this.lblTitle.ForeColor = System.Drawing.Color.FromArgb(224, 224, 224); + this.lblTitle.Location = new System.Drawing.Point(0, 0); + this.lblTitle.Name = "lblTitle"; + this.lblTitle.Padding = new System.Windows.Forms.Padding(10, 4, 10, 4); + this.lblTitle.Size = new System.Drawing.Size(338, 24); + this.lblTitle.TabIndex = 0; + this.lblTitle.Text = "AutoIngest"; + this.lblTitle.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // MainForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.BackColor = System.Drawing.Color.FromArgb(30, 30, 30); + this.ClientSize = new System.Drawing.Size(340, 170); + this.Controls.Add(this.pnlToast); + this.ForeColor = System.Drawing.Color.FromArgb(224, 224, 224); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.None; + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "MainForm"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.Manual; + this.Text = "AutoIngest"; + this.TopMost = true; + this.pnlToast.ResumeLayout(false); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.Panel pnlToast; + private System.Windows.Forms.Label lblTitle; + private System.Windows.Forms.Label lblStatus; + private System.Windows.Forms.RichTextBox txtLog; + } +} diff --git a/AutoIngest/App/MainForm.cs b/AutoIngest/App/MainForm.cs new file mode 100644 index 0000000..d571c36 --- /dev/null +++ b/AutoIngest/App/MainForm.cs @@ -0,0 +1,403 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Globalization; +using System.IO; +using System.Windows.Forms; +using AutoIngest.Core; +using AutoIngest.Engine; + +namespace AutoIngest.App +{ + /// + /// Tray-resident photo importer. The window is a small borderless toast that stays + /// hidden by default and only pops into the bottom-right corner of the screen while + /// an SD/USB import is running (and for a few seconds after it finishes). + /// + /// Static UI lives in MainForm.Designer.cs; this file holds the toast show/hide + /// logic, tray icon, monitor wiring, and config. + /// + public partial class MainForm : Form + { + #region theme + static readonly Color CLR_BG = Color.FromArgb(30, 30, 30); + static readonly Color CLR_BG2 = Color.FromArgb(42, 42, 42); + static readonly Color CLR_TEXT = Color.FromArgb(224, 224, 224); + static readonly Color CLR_TEXT_DIM = Color.FromArgb(153, 153, 153); + static readonly Color CLR_ACCENT = Color.FromArgb(88, 166, 255); + static readonly Color CLR_GREEN = Color.FromArgb(63, 185, 80); + static readonly Color CLR_RED = Color.FromArgb(248, 81, 73); + #endregion + + #region fields + SDCardMonitor? _monitor; + readonly AutoIngest.Engine.ImageConverter _converter = null!; + AppConfig _config = null!; + + // Runtime-only UI (not Designer-serialized). + NotifyIcon trayIcon = null!; + ContextMenuStrip trayMenu = null!; + Bitmap? _trayBitmap; + + /// Auto-hides the toast a few seconds after the last activity. + System.Windows.Forms.Timer? _hideTimer; + + /// True while an import is actively running — keeps the toast pinned open. + bool _importing; + + const int HIDE_DELAY_MS = 4000; + const string APP_NAME = "AutoIngest"; + #endregion + + public MainForm() + { + InitializeComponent(); + + // Let the WinForms Designer instantiate the form without running + // runtime-only initialization (config load, monitor, file I/O, etc.). + if (LicenseManager.UsageMode == LicenseUsageMode.Designtime) + return; + + _converter = new AutoIngest.Engine.ImageConverter(); + _config = ConfigManager.LoadConfig(); + + _hideTimer = new System.Windows.Forms.Timer { Interval = HIDE_DELAY_MS }; + _hideTimer.Tick += (_, _) => + { + _hideTimer.Stop(); + if (!_importing) Hide(); + }; + + BuildTrayIcon(); + WireMonitorEvents(); + + Shown += (_, _) => + { + // The very first Shown fires after Application.Run creates the form. + // We want to start hidden, so immediately hide (the form never had to + // be visible in the first place — but WinForms shows it once on Run). + Hide(); + StartMonitor(); + }; + + FormClosing += MainForm_FormClosing; + + // Start hidden — tray only. + Visible = false; + WindowState = FormWindowState.Minimized; + } + + #region startup / monitor + void StartMonitor() + { + _monitor = new SDCardMonitor(_converter, _config.JpgQuality); + WireRuntimeMonitorEvents(); + if (_config.AutoImport) + _monitor.Start(); + + Log("AutoIngest ready.", CLR_ACCENT); + Log("Insert an SD card or USB drive to begin.", CLR_TEXT_DIM); + } + #endregion + + #region toast show / hide + const int TOAST_MARGIN = 12; + + /// + /// Positions the toast at the bottom-right of the working area (just above the + /// taskbar, clear of the screen edges on every monitor / DPI scale) and shows it. + /// Uses SetBounds so x/y and the rendered size are applied atomically, then clamps + /// inside the working area so the form is always fully on-screen. + /// + void ShowToast() + { + // Make sure the form has a real size before we measure it. On the first show + // the layout may not have run yet, so force it. + if (Width == 0 || Height == 0) + { + PerformLayout(); + var pref = PreferredSize; + if (Width == 0) Width = pref.Width; + if (Height == 0) Height = pref.Height; + } + + // GetWorkingArea(this) resolves the actual screen the form is on and accounts + // for the taskbar; fall back to the primary screen's working area. + var area = Screen.GetWorkingArea(this); + if (area.IsEmpty) area = Screen.PrimaryScreen?.WorkingArea ?? SystemInformation.WorkingArea; + + int w = Width; + int h = Height; + + // Target the bottom-right corner with a margin, then clamp into the working + // area so rounding / DPI scaling can never push part of it off-screen. + int x = area.Right - w - TOAST_MARGIN; + int y = area.Bottom - h - TOAST_MARGIN; + if (x < area.Left + TOAST_MARGIN) x = area.Left + TOAST_MARGIN; + if (y < area.Top + TOAST_MARGIN) y = area.Top + TOAST_MARGIN; + if (x + w > area.Right - TOAST_MARGIN) x = area.Right - w - TOAST_MARGIN; + if (y + h > area.Bottom - TOAST_MARGIN) y = area.Bottom - h - TOAST_MARGIN; + + _hideTimer?.Stop(); + if (!Visible) + { + // SetBounds applies position + size atomically before the window is shown, + // so it appears exactly where we want it with no flicker or repositioning. + SetBounds(x, y, w, h); + Show(); + WindowState = FormWindowState.Normal; + } + else + { + SetBounds(x, y, w, h); + } + } + + /// + /// Arm the auto-hide timer. Called after any activity; if no further activity + /// arrives within HIDE_DELAY_MS the toast hides itself (unless an import is + /// actively running). + /// + void ArmAutoHide() + { + _hideTimer?.Stop(); + _hideTimer?.Start(); + } + #endregion + + #region monitor events + // Wired at design time (cheap — just lambda closures). + void WireMonitorEvents() { } + + // Wired once the monitor exists (after StartMonitor creates it). + void WireRuntimeMonitorEvents() + { + var monitor = _monitor!; + + monitor.DriveFound += (driveId, label) => + { + _importing = true; + this.Invoke(new Action(() => + { + lblStatus.Text = $"\u25CF Reading {label}"; + lblStatus.ForeColor = CLR_ACCENT; + ShowToast(); + })); + Log($"Drive found: {label}", CLR_ACCENT); + }; + + monitor.ImportProgress += (current, total, filename) => + { + this.Invoke(new Action(() => + { + lblStatus.Text = $"\u25CF {current}/{total}: {filename}"; + ShowToast(); + })); + }; + + monitor.ImportDone += (moved, converted, sizeStr, destFolder) => + { + _importing = false; + this.Invoke(new Action(() => + { + lblStatus.Text = $"\u2713 Done \u2014 {moved} moved, {converted} converted ({sizeStr})"; + lblStatus.ForeColor = CLR_GREEN; + ShowToast(); + ArmAutoHide(); + })); + Log($"Import complete: {moved} moved, {converted} converted, {sizeStr} total -> {destFolder}", CLR_GREEN); + }; + + monitor.ImportError += msg => + { + this.Invoke(new Action(() => + { + ShowToast(); + ArmAutoHide(); + })); + Log($"Error: {msg}", CLR_RED); + }; + + monitor.LogMessage += (msg, color) => + { + var c = ParseHexColor(color) ?? CLR_TEXT; + Log(msg, c); + }; + } + #endregion + + #region log + void Log(string msg, Color color) + { + string line = $"[{DateTime.Now:HH:mm:ss}] {msg}\n"; + if (txtLog.InvokeRequired) + txtLog.Invoke(new Action(() => AppendLog(line, color))); + else + AppendLog(line, color); + } + + void AppendLog(string text, Color color) + { + txtLog.SelectionStart = txtLog.TextLength; + txtLog.SelectionColor = color; + txtLog.AppendText(text); + txtLog.SelectionColor = CLR_TEXT; + txtLog.ScrollToCaret(); + } + + static Color? ParseHexColor(string hex) + { + if (hex.Length < 7 || hex[0] != '#') return null; + try + { + int r = int.Parse(hex[1..3], NumberStyles.HexNumber); + int g = int.Parse(hex[3..5], NumberStyles.HexNumber); + int b = int.Parse(hex[5..7], NumberStyles.HexNumber); + return Color.FromArgb(r, g, b); + } + catch (FormatException) { return null; } + catch (OverflowException) { return null; } + } + #endregion + + #region tray icon + void BuildTrayIcon() + { + trayMenu = new ContextMenuStrip + { + BackColor = CLR_BG2, + ForeColor = CLR_TEXT, + Renderer = new DarkMenuRenderer() + }; + + var showItem = new ToolStripMenuItem("Show status"); + showItem.Click += (_, _) => { ShowToast(); }; + var folderItem = new ToolStripMenuItem("Open import folder"); + folderItem.Click += (_, _) => OpenImportFolder(); + var resetItem = new ToolStripMenuItem("Reset drive memory"); + resetItem.Click += (_, _) => + { + _monitor?.ResetMemory(); + Log("Drive memory cleared. All drives will be re-scanned.", CLR_ACCENT); + }; + var aboutItem = new ToolStripMenuItem("About"); + aboutItem.Click += (_, _) => + MessageBox.Show( + APP_NAME + "\n" + + "Camera photo importer for ecommerce workflows\n\n" + + "Author: Jeremy Anderson\n" + + "Email: info@dcos.net\n" + + "Web: https://dcos.net\n\n" + + "C# / .NET 8 / Magick.NET\n\n" + + "Licensed under the Microsoft Public License (Ms-PL).\n" + + "See https://opensource.org/license/ms-pl-html/", + "About " + APP_NAME, MessageBoxButtons.OK, MessageBoxIcon.Information); + var exitItem = new ToolStripMenuItem("Exit"); + exitItem.Click += (_, _) => ExitApplication(); + + trayMenu.Items.AddRange(new ToolStripItem[] + { + showItem, folderItem, resetItem, + new ToolStripSeparator(), aboutItem, + new ToolStripSeparator(), exitItem + }); + + var bmp = new Bitmap(16, 16); + _trayBitmap = bmp; + using (var g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + g.FillEllipse(Brushes.LimeGreen, 1, 1, 14, 14); + g.DrawEllipse(Pens.DarkGreen, 1, 1, 14, 14); + } + + trayIcon = new NotifyIcon + { + Icon = Icon.FromHandle(bmp.GetHicon()), + Text = APP_NAME, + Visible = true, + ContextMenuStrip = trayMenu + }; + // Left-click toggles the toast so the user can peek at status on demand. + trayIcon.MouseClick += (_, e) => + { + if (e.Button == MouseButtons.Left) + { + if (Visible) Hide(); + else { ShowToast(); ArmAutoHide(); } + } + }; + trayIcon.DoubleClick += (_, _) => ShowToast(); + } + #endregion + + #region helpers + static void OpenImportFolder() + { + string today = DateTime.Now.ToString("yyyy-MM-dd"); + string picturesFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); + string todayFolder = Path.Combine(picturesFolder, today); + + string target = Directory.Exists(todayFolder) ? todayFolder + : Directory.Exists(picturesFolder) ? picturesFolder + : ""; + + if (target != "") + Process.Start("explorer.exe", target); + else + MessageBox.Show("No Pictures folder found.", APP_NAME, + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + void ExitApplication() + { + trayIcon.Visible = false; + _monitor?.Stop(); + Application.Exit(); + } + #endregion + + #region form events + // Closing the toast (alt-f4 / clicking it off) should never quit the app — + // it just hides. The app only exits via the tray's Exit item. + void MainForm_FormClosing(object? sender, FormClosingEventArgs e) + { + if (e.CloseReason == CloseReason.UserClosing || + e.CloseReason == CloseReason.FormOwnerClosing) + { + e.Cancel = true; + Hide(); + } + // Any other close reason (taskmgr, shutdown, Application.Exit) is allowed. + } + #endregion + } + + #region dark menu renderer + public class DarkMenuRenderer : ToolStripProfessionalRenderer + { + public DarkMenuRenderer() : base(new DarkColorTable()) { } + } + + public class DarkColorTable : ProfessionalColorTable + { + public override Color MenuStripGradientBegin => Color.FromArgb(42, 42, 42); + public override Color MenuStripGradientEnd => Color.FromArgb(42, 42, 42); + public override Color MenuItemSelected => Color.FromArgb(88, 166, 255); + public override Color MenuItemBorder => Color.FromArgb(68, 68, 68); + public override Color MenuBorder => Color.FromArgb(68, 68, 68); + public override Color SeparatorDark => Color.FromArgb(68, 68, 68); + public override Color SeparatorLight => Color.FromArgb(68, 68, 68); + public override Color ImageMarginGradientBegin => Color.FromArgb(42, 42, 42); + public override Color ImageMarginGradientMiddle => Color.FromArgb(42, 42, 42); + public override Color ImageMarginGradientEnd => Color.FromArgb(42, 42, 42); + public override Color ToolStripDropDownBackground => Color.FromArgb(35, 35, 35); + public override Color ButtonSelectedHighlight => Color.FromArgb(88, 166, 255); + public override Color ButtonSelectedBorder => Color.FromArgb(88, 166, 255); + public override Color CheckBackground => Color.FromArgb(42, 42, 42); + public override Color CheckSelectedBackground => Color.FromArgb(88, 166, 255); + } + #endregion +} diff --git a/AutoIngest/App/MainForm.resx b/AutoIngest/App/MainForm.resx new file mode 100644 index 0000000..84c81a5 --- /dev/null +++ b/AutoIngest/App/MainForm.resx @@ -0,0 +1,26 @@ + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 104, 17 + + diff --git a/AutoIngest/App/Program.cs b/AutoIngest/App/Program.cs new file mode 100644 index 0000000..57622de --- /dev/null +++ b/AutoIngest/App/Program.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Windows.Forms; + +namespace AutoIngest.App +{ + static class Program + { + static Mutex _mutex = null!; + + [STAThread] + static void Main() + { + bool owned; + _mutex = new Mutex(true, "AutoIngest_SingleInstance", out owned); + if (!owned) + { + MessageBox.Show( + "AutoIngest is already running.", + "Already Running", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + return; + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.SetHighDpiMode(HighDpiMode.SystemAware); + + // MainForm starts hidden — it lives in the tray and only pops up as a toast + // when an SD card / USB import is in progress. + Application.Run(new MainForm()); + } + } +} diff --git a/AutoIngest/App/app.manifest b/AutoIngest/App/app.manifest new file mode 100644 index 0000000..d55eec5 --- /dev/null +++ b/AutoIngest/App/app.manifest @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/AutoIngest/AutoIngest.csproj b/AutoIngest/AutoIngest.csproj new file mode 100644 index 0000000..3eb94d4 --- /dev/null +++ b/AutoIngest/AutoIngest.csproj @@ -0,0 +1,26 @@ + + + + WinExe + net8.0-windows + true + enable + AutoIngest + AutoIngest + 3.0.0 + AutoIngest + AutoIngest + Camera photo importer for ecommerce workflows + dcos.net + Jeremy Anderson + Copyright © Jeremy Anderson + MS-PL + https://dcos.net + App\app.manifest + + + + + + + diff --git a/AutoIngest/Core/ConfigManager.cs b/AutoIngest/Core/ConfigManager.cs new file mode 100644 index 0000000..3db99bb --- /dev/null +++ b/AutoIngest/Core/ConfigManager.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace AutoIngest.Core +{ + public class AppConfig + { + public int JpgQuality { get; set; } = 92; + public bool AutoImport { get; set; } = true; + } + + public static class ConfigManager + { + static readonly string CONFIG_PATH = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".autoingest_config.json"); + + static readonly JsonSerializerOptions JSON_OPTS = new() { WriteIndented = true }; + + public static AppConfig LoadConfig() + { + try + { + if (File.Exists(CONFIG_PATH)) + { + string json = File.ReadAllText(CONFIG_PATH); + return JsonSerializer.Deserialize(json) ?? new AppConfig(); + } + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + catch (JsonException) { } + return new AppConfig(); + } + + public static void SaveConfig(AppConfig config) + { + try + { + File.WriteAllText(CONFIG_PATH, JsonSerializer.Serialize(config, JSON_OPTS)); + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + } + } +} diff --git a/AutoIngest/Engine/ImageConverter.cs b/AutoIngest/Engine/ImageConverter.cs new file mode 100644 index 0000000..aac22bb --- /dev/null +++ b/AutoIngest/Engine/ImageConverter.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using ImageMagick; + +namespace AutoIngest.Engine +{ + /// + /// Converts any image format to ecommerce-optimized progressive JPG. + /// Delegates all format detection to Magick.NET (RAW, HEIC, SVG, standard raster). + /// + public class ImageConverter + { + static ImageConverter() + { + MagickNET.SetTempDirectory(Path.GetTempPath()); + } + + public void ConvertToJpg(string inputPath, string outputPath, + int quality = 92) + { + using var image = new MagickImage(inputPath); + + // No dimension limit: images are converted at their native resolution. + // Older cameras (≤20MP) produce reasonably sized output and we don't want + // to throw away detail by downscaling. + + image.ColorSpace = ColorSpace.sRGB; + image.Alpha(AlphaOption.Off); + + image.Format = MagickFormat.Jpeg; + image.Quality = (uint)quality; + image.Settings.SetDefine("jpeg:optimize-coding", "true"); + image.Settings.SetDefine("jpeg:progressive", "true"); + image.Settings.Interlace = Interlace.Line; + + image.Write(outputPath); + } + + public bool CanRead(string path) + { + try + { + _ = new MagickImageInfo(path); + return true; + } + catch (MagickCorruptImageErrorException) { return false; } + catch (MagickDelegateErrorException) { return false; } + catch { return false; } + } + } +} diff --git a/AutoIngest/Engine/SDCardMonitor.cs b/AutoIngest/Engine/SDCardMonitor.cs new file mode 100644 index 0000000..d33c362 --- /dev/null +++ b/AutoIngest/Engine/SDCardMonitor.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Threading; + +namespace AutoIngest.Engine +{ + public enum DriveType : uint + { + DRIVE_UNKNOWN = 0, + DRIVE_NO_ROOT_DIR = 1, + DRIVE_REMOVABLE = 2, + DRIVE_FIXED = 3, + DRIVE_REMOTE = 4, + DRIVE_CDROM = 5, + DRIVE_RAMDISK = 6 + } + + public static class Win32 + { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern DriveType GetDriveType(string lpRootPathName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool GetVolumeInformationW( + string rootPathName, + System.Text.StringBuilder volumeNameBuffer, + int volumeNameSize, + out uint volumeSerialNumber, + out uint maximumComponentLength, + out uint fileSystemFlags, + System.Text.StringBuilder fileSystemNameBuffer, + int fileSystemNameSize); + } + + public class SDCardMonitor : IDisposable + { + const int CHECK_INTERVAL_MS = 4000; + const int MAX_COLLISION_SUFFIX = 9999; + + static readonly HashSet IGNORE_DRIVES = new() { 'C' }; + + static readonly string[] SD_KEYWORDS = { + "SD", "CARD", "CAMERA", "NO NAME", "UNTITLED", + "CANON", "NIKON", "SONY", "FUJI", "GOPRO", + "EOS", "LUMIX", "CYBERSHOT", "ALPHA", "PENTAX", + "OLYMPUS", "LEICA", "HUAWEI", "SAMSUNG", "PIXEL", + "BLACKMAGIC", "DJI", "INSTA360", "MEMORY", "FLASH" + }; + + static readonly string[] DCIM_PROBES = { "DCIM", "Pictures", "photos", "PICTURES" }; + + static readonly HashSet SKIP_FOLDERS = new(StringComparer.OrdinalIgnoreCase) { + "System Volume Information", "$RECYCLE.BIN", + "Windows", "Program Files", "Program Files (x86)", + "ProgramData", "AppData", "Recovery" + }; + + static readonly HashSet SKIP_PREFIXES = new() { ".", "$", "~" }; + + public static readonly HashSet IMAGE_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", + ".raw", ".cr2", ".cr3", ".nef", ".arw", ".dng", ".orf", ".rw2", + ".gpr", ".pef", ".srw", ".srf", ".sr2", ".raf", + ".heic", ".heif", + ".ico", ".ppm", ".pgm", ".pbm", ".svg" + }; + + public event Action? DriveFound; + public event Action? ImportProgress; + public event Action? ImportDone; + public event Action? ImportError; + public event Action? LogMessage; + + private Thread? _thread; + private volatile bool _running; + private volatile bool _importing; + private readonly HashSet _processedDrives = new(); + private readonly object _lock = new(); + private readonly ImageConverter _converter; + private readonly int _jpgQuality; + + public SDCardMonitor(ImageConverter converter, int jpgQuality = 92) + { + _converter = converter; + _jpgQuality = jpgQuality; + } + + public void Start() + { + _running = true; + _thread = new Thread(RunLoop) { IsBackground = true }; + _thread.Start(); + } + + public void Stop() + { + _running = false; + _thread?.Join(5000); + } + + public void ResetMemory() + { + lock (_lock) _processedDrives.Clear(); + } + + void RunLoop() + { + // Import into the user's Pictures library (~/Pictures), under a dated subfolder + // for today's imports: Pictures//. + var photosBase = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); + + Log("Auto-import monitor active. Insert SD card or USB drive.", "#58a6ff"); + Log($"Destination: {photosBase}\\\\", "#58a6ff"); + + while (_running) + { + try + { + lock (_lock) + { + if (!_importing) + { + foreach (var ch in GetDriveLetters()) + { + string did = $"DRV:{ch}"; + if (_processedDrives.Contains(did)) continue; + + _importing = true; + _processedDrives.Add(did); + + var result = ImportFromDrive(ch, photosBase); + if (result.total > 0) + ImportDone?.Invoke(result.moved, result.converted, result.sizeStr, result.destFolder); + break; + } + + var currentIds = new HashSet( + GetDriveLetters().Select(c => $"DRV:{c}")); + _processedDrives.ExceptWith(currentIds); + + _importing = false; + } + } + } + catch (Exception ex) + { + Log(ex.Message, "#f85149"); + ImportError?.Invoke(ex.Message); + _importing = false; + } + + Thread.Sleep(CHECK_INTERVAL_MS); + } + } + + static IEnumerable GetDriveLetters() + { + return Enumerable.Range(0, 26) + .Select(i => (char)('A' + i)) + .Where(ch => !IGNORE_DRIVES.Contains(ch)) + .Where(ch => + { + string root = $"{ch}:\\"; + if (!Directory.Exists(root)) return false; + try + { + var dt = Win32.GetDriveType(root); + if (dt == DriveType.DRIVE_REMOVABLE) return true; + if (dt == DriveType.DRIVE_FIXED) return LooksLikeSDCard(ch); + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + return false; + }); + } + + static bool LooksLikeSDCard(char ch) + { + string root = $"{ch}:\\"; + + if (DCIM_PROBES.Any(p => Directory.Exists(Path.Combine(root, p)))) + return true; + + try + { + var volName = new System.Text.StringBuilder(1024); + var fsName = new System.Text.StringBuilder(1024); + if (Win32.GetVolumeInformationW( + root, volName, 1024, out _, out _, out _, fsName, 1024)) + { + string label = volName.ToString().ToUpperInvariant(); + return SD_KEYWORDS.Any(kw => label.Contains(kw)); + } + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + + return false; + } + + List FindPhotos(char ch) + { + string root = $"{ch}:\\"; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + var dcimResults = DCIM_PROBES + .Select(p => Path.Combine(root, p)) + .Where(Directory.Exists) + .SelectMany(p => ScanFolder(p, 10)) + .Where(fp => seen.Add(fp)) + .ToList(); + if (dcimResults.Count > 0) return dcimResults; + + List rootResults = new(); + try + { + rootResults = Directory.EnumerateFiles(root) + .Where(f => seen.Add(f) && IsImageFile(f)) + .ToList(); + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + if (rootResults.Count > 0) return rootResults; + + return ScanFolder(root, 3).Where(fp => seen.Add(fp)).ToList(); + } + + static List ScanFolder(string folder, int maxDepth) + { + var results = new List(); + try + { + ScanRecursive(folder, maxDepth, 0, results); + } + catch (UnauthorizedAccessException) { } + catch (IOException) { } + return results; + } + + static void ScanRecursive(string dir, int maxDepth, int depth, List results) + { + if (depth > maxDepth) return; + + string[] dirs; + string[] files; + try + { + dirs = Directory.GetDirectories(dir); + files = Directory.GetFiles(dir); + } + catch (UnauthorizedAccessException) { return; } + catch (IOException) { return; } + + dirs = dirs.Where(d => + { + string name = Path.GetFileName(d); + return !SKIP_FOLDERS.Contains(name) && + !SKIP_PREFIXES.Any(p => name.StartsWith(p)); + }).ToArray(); + + results.AddRange(files.Where(f => + { + string name = Path.GetFileName(f); + return !SKIP_PREFIXES.Any(p => name.StartsWith(p)) && IsImageFile(f); + })); + + foreach (var d in dirs) + ScanRecursive(d, maxDepth, depth + 1, results); + } + + (int moved, int converted, int total, string sizeStr, string destFolder) ImportFromDrive(char ch, string photosBase) + { + var photos = FindPhotos(ch); + if (photos.Count == 0) + return (0, 0, 0, "", ""); + + string today = DateTime.Now.ToString("yyyy-MM-dd"); + string destFolder = Path.Combine(photosBase, today); + Directory.CreateDirectory(destFolder); + + DriveFound?.Invoke(ch.ToString(), $"{photos.Count} photos found on {ch}:\\"); + + int moved = 0, converted = 0, skipped = 0; + long totalBytes = 0; + + foreach (var (src, idx) in photos.Select((f, i) => (f, i))) + { + string filename = Path.GetFileName(src); + ImportProgress?.Invoke(idx + 1, photos.Count, filename); + + try + { + string ext = Path.GetExtension(src).ToLowerInvariant(); + + if (ext is ".jpg" or ".jpeg") + { + // Resolve the destination, comparing against a same-named file in + // TODAY'S folder only: same size + same hash => genuine dupe (skip); + // otherwise rename with a suffix and move. + string destPath = ResolveDestPath(destFolder, filename, src); + if (destPath == "") + { + skipped++; + Log($"Skipped (duplicate of file already imported today): {filename}", "#8b949e"); + continue; + } + + long srcLen = new FileInfo(src).Length; + File.Move(src, destPath); + + // Verify the move landed correctly before counting it. + if (!File.Exists(destPath) || new FileInfo(destPath).Length != srcLen) + throw new IOException("move verification failed (size mismatch)"); + + moved++; + totalBytes += srcLen; + Log(string.Equals(destPath, Path.Combine(destFolder, filename), StringComparison.OrdinalIgnoreCase) + ? $"Moved: {filename}" + : $"Moved: {filename} -> {Path.GetFileName(destPath)} (name in use today)", + "#3fb950"); + } + else + { + // Convert to JPG, then delete the original from the device. The output + // name is resolved against today's folder (two RAWs may share a stem; + // only an identical converted output is a real dupe). + string destName = Path.GetFileNameWithoutExtension(src) + ".jpg"; + string destPath = ResolveDestPath(destFolder, destName, src); + if (destPath == "") + { + skipped++; + Log($"Skipped (duplicate output already imported today): {filename}", "#8b949e"); + continue; + } + + long srcLen = new FileInfo(src).Length; + _converter.ConvertToJpg(src, destPath, _jpgQuality); + + // Verify destination exists with non-zero size before removing the source. + long size = new FileInfo(destPath).Length; + if (size <= 0) + throw new IOException("conversion produced an empty file"); + + File.Delete(src); + converted++; + totalBytes += size; + Log($"Converted: {filename} -> JPG ({size / 1024.0:F1} KB) [src {srcLen / 1024.0:F0} KB removed]", "#58a6ff"); + } + } + catch (Exception ex) + { + Log($"{filename}: {ex.Message}", "#f85149"); + ImportError?.Invoke($"{filename}: {ex.Message}"); + } + } + + if (skipped > 0) + Log($"Skipped {skipped} duplicate file(s) already imported today.", "#8b949e"); + + string sizeStr = FormatSize(totalBytes); + return (moved, converted, moved + converted, sizeStr, destFolder); + } + + /// + /// Resolves a destination path inside for a source + /// file that wants to be named . + /// + /// Dupe rules (scoped to TODAY'S folder only — we are not a cross-day dedupe tool): + /// - No existing file with the desired name -> use the desired name. + /// - Existing file, different size -> not a dupe; rename with a suffix. + /// - Existing file, same size, same hash -> genuine duplicate; return "" (skip). + /// - Existing file, same size, different hash-> not a dupe; rename with a suffix. + /// + /// Returns the resolved destination path, or "" if the source is a byte-for-byte + /// duplicate of what's already there and should be skipped. + /// + static string ResolveDestPath(string destFolder, string desiredName, string sourceFile) + { + string desired = Path.Combine(destFolder, desiredName); + if (!File.Exists(desired)) + return desired; + + // Name collision in today's folder. Compare sizes first (cheap) before hashing. + long srcLen = new FileInfo(sourceFile).Length; + long dstLen = new FileInfo(desired).Length; + + if (srcLen != dstLen) + return MakeUniquePath(destFolder, desiredName); + + // Same size: hash both to decide. Same hash => genuine dupe (skip). + if (FilesEqual(sourceFile, desired)) + return ""; + + // Same size, different content: rename with a suffix. + return MakeUniquePath(destFolder, desiredName); + } + + /// True if the two files are byte-for-byte identical (SHA256 match). + static bool FilesEqual(string a, string b) + { + try + { + using var ha = SHA256.Create(); + using var hb = SHA256.Create(); + using var fa = File.OpenRead(a); + using var fb = File.OpenRead(b); + return ha.ComputeHash(fa).SequenceEqual(hb.ComputeHash(fb)); + } + catch (UnauthorizedAccessException) { return false; } + catch (IOException) { return false; } + } + + /// + /// Returns a non-colliding path in by appending + /// _1, _2, ... to the stem until a free name is found. + /// + static string MakeUniquePath(string folder, string desiredName) + { + string path = Path.Combine(folder, desiredName); + if (!File.Exists(path)) return path; + + string stem = Path.GetFileNameWithoutExtension(desiredName); + string ext = Path.GetExtension(desiredName); + + foreach (int n in Enumerable.Range(1, MAX_COLLISION_SUFFIX)) + { + path = Path.Combine(folder, $"{stem}_{n}{ext}"); + if (!File.Exists(path)) return path; + } + + return Path.Combine(folder, $"{stem}_{Guid.NewGuid():N}{ext}"); + } + + static bool IsImageFile(string path) => IMAGE_EXTENSIONS.Contains(Path.GetExtension(path)); + + static string FormatSize(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1048576) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / 1048576.0:F1} MB"; + } + + void Log(string msg, string color) => LogMessage?.Invoke(msg, color); + + public void Dispose() + { + Stop(); + GC.SuppressFinalize(this); + } + } +} diff --git a/BLOG.md b/BLOG.md new file mode 100644 index 0000000..14d8be5 --- /dev/null +++ b/BLOG.md @@ -0,0 +1,88 @@ +# AutoIngest: camera photo importer tool + +**Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net** + +--- + +## The Problem + +A friend sells vintage reel-to-reel film on eBay. His workflow is straightforward: photograph film reels with a camera, move the photos to his PC, and attach them to eBay listings in the browser. Every photo needs to be a JPG. + +His cameras vary, one shoots in .JPG, one shoots in RAW, another . Sometimes he gets HEIC files from an iPhone. The photos land on an SD card, and they need to end up as JPGs in a date-stamped folder on his PC. He is not a technical person. He needs to insert the card and have it work. + +The original implementation was Python with PySide6, Pillow, rawpy, pillow-heif, cairosvg, and pywin32. That's six packages with native C extensions that need to compile or match pre-built wheels for the target Windows version. On a non-technical person's PC, this is a liability. + +## The Decision: C# / .NET 8 + +The choice came down to one question: what can we hand the seller as a single file that just works? + +| Requirement | Python | C# / .NET 8 | +|-------------|--------|-------------| +| Deploy as single exe | No (requires interpreter + pip packages) | Yes (`PublishSingleFile`) | +| Native image format support | 4+ libraries with fragile native builds | One NuGet package (Magick.NET) | +| Drive detection on Windows | pywin32 (another native dep) | P/Invoke to kernel32 (zero deps) | +| GUI framework | PySide6 (large native dep) | WinForms (ships with .NET) | +| Runtime on target PC | Python 3.x + pip + all packages | .NET 8 Desktop Runtime (pre-installed on Win11) | +| Self-contained option | PyInstaller (fragile, 200+ MB) | `dotnet publish --self-contained` (60-80 MB, reliable) | + +C# won on every axis that matters for deployment to a non-technical end user. + +## Architecture + +The app is organized into three layers (`App`, `Core`, `Engine`) with one NuGet dependency: + +``` +App/ + Program.cs → Single-instance mutex, entry point + MainForm.cs → WinForms GUI, dark theme, system tray, log panel + MainForm.Designer.cs → Toast popup layout (WinForms Designer) +Core/ + ConfigManager.cs → JSON persistence for app settings +Engine/ + SDCardMonitor.cs → Background thread polls drives via kernel32 + ImageConverter.cs → Magick.NET wrapper (30+ formats → progressive JPG) +``` + +### Drive Detection + +No WMI queries. No COM initialization. Direct P/Invoke to `kernel32.GetDriveType()` and `kernel32.GetVolumeInformationW()`. A 4-second polling interval checks all drive letters, filters out fixed drives (unless they have DCIM folders or camera-related volume labels), and triggers import on new devices. + +### Import Pipeline + +1. Discover photos on the drive (DCIM → Pictures → root → 3-level deep scan) +2. Create `~/Pictures/YYYY-MM-DD/` if it doesn't exist +3. JPG/JPEG files are **moved** directly via `File.Move` (the card is the source of truth; we don't leave dupes behind) +4. Everything else goes through Magick.NET: convert color space to sRGB, strip alpha, write as progressive optimized JPG (native resolution preserved — no downscaling), then the original is deleted from the card +5. Same-day collisions: if a same-named file already exists in today's folder, sizes (then SHA-256) decide — byte-identical files are skipped; different content gets a `_1`, `_2` suffix (capped at 9999, then GUID fallback) +6. Log every file to the UI log panel + +### Why System Tray + +The app is designed to be invisible. The seller inserts an SD card, the app moves and converts silently, and he opens the output folder in the ecommerce listing. + +## Code Quality + +The codebase follows a few deliberate conventions: + +- **Targeted exception handling:** Most `catch` blocks name a specific exception type (`UnauthorizedAccessException`, `IOException`, `MagickCorruptImageErrorException`). A couple of `catch { return false; }` guards remain where any failure should simply mean "not readable." +- **Named constants over magic numbers:** Intervals (`CHECK_INTERVAL_MS = 4000`), suffix caps (`MAX_COLLISION_SUFFIX = 9999`), colors, and skip-lists are all named fields, not inline literals. +- **Bounded loops:** The polling thread is a `_running`-flag-controlled `while (_running)` with a fixed sleep, not `while (true)`. Collision suffixes cap at 9999 before falling back to a GUID. +- **Step-down logic:** Guard clauses and early returns throughout (`ResolveDestPath`, `FindPhotos`, `ShowToast`). The common case executes first. +- **One responsibility per class:** `SDCardMonitor` monitors drives. `ImageConverter` converts images. `ConfigManager` persists settings. No god objects. +- **Disposable cleanup:** `MainForm.Dispose` releases the tray bitmap, hide-timer, monitor, and components. `MagickImage` is `using`-scoped. The tray icon bitmap is held for the `NotifyIcon` handle's lifetime. +- **Comments describe why, not what:** Comments explain design decisions (e.g. why dedupe is scoped to today's folder, why `SetBounds` is used before `Show`). + +## What's Next + +Potential improvements for future versions: + +- MTP/phone detection via Shell.Application COM interop (the Python version had this) + +## Final Word + +The Python version was 2900 lines. The C# version is under 1000 lines of source, ships as one file, and has zero runtime dependencies on the target PC (in the self-contained build). The seller gets an exe on his desktop and it works. That's the goal. + +--- + +**Author:** Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net +**License:** MS-PL (Microsoft Public License) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ddea4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,68 @@ +Microsoft Public License (Ms-PL) + +This license governs use of the accompanying software. If you use the +software, you accept this license. If you do not accept the license, do +not use the software. + +1. Definitions + + The terms "reproduce," "reproduction," "derivative works," and + "distribution" have the same meaning here as under U.S. copyright law. + + A "contribution" is the original software, or any additions or changes + to the software. + + A "contributor" is any person that distributes its contribution under + this license. + + "Licensed patents" are a contributor's patent claims that read + directly on its contribution. + +2. Grant of Rights + + (A) Copyright Grant- Subject to the terms of this license, including + the license conditions and limitations in section 3, each contributor + grants you a non-exclusive, worldwide, royalty-free copyright license + to reproduce its contribution, prepare derivative works of its + contribution, and distribute its contribution or any derivative works + that you create. + + (B) Patent Grant- Subject to the terms of this license, including the + license conditions and limitations in section 3, each contributor + grants you a non-exclusive, worldwide, royalty-free license under its + licensed patents to make, use, sell, offer for sale, import, and/or + otherwise dispose of its contribution in the software or derivative + works of the contribution. + +3. Conditions and Limitations + + (A) No Trademark License- This license does not grant you rights to + use any contributors' name, logo, or trademarks. + + (B) If you bring a patent claim against any contributor over patents + that you claim are infringed by the software, your patent license from + such contributor to the software ends automatically. + + (C) If you distribute any portion of the software, you must retain all + copyright, patent, trademark, and attribution notices that are present + in the software. + + (D) If you distribute any portion of the software in source code form, + you may do so only under this license by including a complete copy of + this license with your distribution. If you distribute any portion of + the software in compiled or object code form, you may only do so under + a license that complies with this license. + + (E) The software is licensed "as-is." You bear the risk of using it. + The contributors give no express warranties, guarantees or conditions. + You may have additional consumer rights under your local laws which + this license cannot change. To the extent permitted under your local + laws, the contributors exclude the implied warranties of + merchantability, fitness for a particular purpose and + non-infringement. + +--- + +Camera Importer — camera photo importer for ecommerce workflows. +Copyright (C) 2025 Jeremy Anderson +Website: https://dcos.net diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..f3c657d --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,88 @@ +# Quick Start Guide + +**AutoIngest — Get running in under 5 minutes.** + +--- + +## 1. Install Prerequisites + +### On the build machine (your PC or VM): + +1. Install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/vs/community/) (free). +2. In the Visual Studio Installer, ensure the **.NET desktop development** workload is checked. +3. If you already have VS2022, open it and go to Tools → Get Tools and Features to verify. + +### On the target machine: + +No prerequisites if you build the **self-contained** version. Zero install. + +--- + +## 2. Build + +### Option A: Visual Studio (recommended) + +1. Open `AutoIngest.sln` +2. Build → Build Solution +3. Run it locally to verify + +### Option B: Command line + +```bat +cd AutoIngest +dotnet restore +dotnet build -c Release +``` + +--- + +## 3. Publish + +### For Windows 11 (framework-dependent, ~15 MB): + +```bat +publish.bat +``` + +Output: `publish/AutoIngest.exe` + +### For any Windows 10/11 PC (self-contained, ~60-80 MB): + +```bat +publish-standalone.bat +``` + +Output: `publish-standalone/AutoIngest.exe` + +--- + +## 4. Deploy + +Copy `AutoIngest.exe` to the target PC. Done. + +--- + +## 5. Use + +1. Double-click `AutoIngest.exe` to start. It runs in the system tray (green circle icon) — no window opens. +2. Insert an SD card or USB card reader with photos. +3. A small toast pops into the bottom-right corner while photos are **moved** to `Pictures\YYYY-MM-DD\` (JPGs moved as-is; other formats converted to JPG and the originals removed). +4. The toast auto-hides a few seconds after the import finishes. +5. Left-click the tray icon any time to peek at status. Right-click for the menu (open import folder, reset drive memory, exit). +6. Open `Pictures\YYYY-MM-DD\` in Explorer and attach photos to your eBay listing. + +--- + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `dotnet` not found | Install .NET 8 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com/download/dotnet/8.0) | +| Magick.NET restore fails | Check internet connection. NuGet.org must be accessible. | +| Target PC says "missing runtime" | Use the self-contained build (`publish-standalone.bat`) instead. | +| Drive not detected | Verify the SD card mounts as a drive letter. Right-click the tray icon → Show status to view the log. | +| Photos not in expected folder | The destination is `%USERPROFILE%\Pictures\YYYY-MM-DD\`. Check that folder. | + +--- + +**Author:** Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5cce15 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# AutoIngest + +**Author:** Jeremy Anderson +**Contact:** info@dcos.net +**Website:** [dcos.net](https://dcos.net) + +--- + +## What It Does + +AutoIngest is a Windows tray-resident tool that silently moves photos off an SD card or USB drive into a dated folder on your PC. Plug in the card, the `.jpg`s are **moved** (not copied) to `~/Pictures/YYYY-MM-DD/`, and any RAW/HEIC/PNG files are converted to JPG and the originals removed from the card. A small toast pops into the corner while it's working, then disappears. Duplicate-aware within a single day — if you re-insert the same card on the same day, byte-identical files are skipped. + +Built for eBay sellers who just want the photos off the camera and into a folder, with nothing to click. + +## Features + +### Silent move-import (the core) +- Polls for removable drives every 4 seconds via kernel32 `GetDriveType` and `GetVolumeInformationW` +- Probes DCIM, Pictures, and root folders for image files +- **Moves** JPG/JPEG files directly to `~/Pictures//` — no dupes left on the device +- Converts all other formats (RAW, HEIC, PNG, etc.) to progressive JPG, then deletes the original from the card +- Same-day duplicate aware: within today's import folder, byte-identical files (matched by size then SHA-256) are skipped rather than re-imported +- Verify-before-delete: a file is only removed from the card after the destination is confirmed present and correctly sized +- Single-instance enforcement via named mutex + +### Toast UI +- Lives in the system tray by default — no window at startup +- A small borderless toast pops into the bottom-right corner when an import starts +- Shows live status (`Moving 3/12: IMG_004.jpg`) and a scrolling log +- Auto-hides ~4 seconds after the import completes +- Left-click the tray icon to peek at status on demand; right-click for the menu (show status, open import folder, reset drive memory, about, exit) + +### Image Conversion +- Powered by [Magick.NET](https://github.com/dlemstra/Magick.NET) (ImageMagick 7 bindings) +- Handles 30+ input formats: JPG, PNG, BMP, TIFF, WebP, CR2, CR3, NEF, ARW, DNG, HEIC, HEIF, SVG, RAW, and more +- Converts at native resolution (no downscaling) to preserve full image detail +- Outputs progressive, optimized JPG + +## Architecture + +``` +AutoIngest/ +├── AutoIngest.csproj Project file, package reference, assembly metadata +├── App/ WinForms UI layer (namespace AutoIngest.App) +│ ├── Program.cs Entry point, single-instance mutex +│ ├── MainForm.cs Tray icon, toast show/hide, monitor wiring +│ ├── MainForm.Designer.cs Toast popup layout (WinForms Designer) +│ ├── MainForm.resx Designer resource header +│ └── app.manifest Windows 10/11 compatibility manifest +├── Core/ Config/models layer (namespace AutoIngest.Core) +│ └── ConfigManager.cs JSON config persistence +└── Engine/ Import/conversion layer (namespace AutoIngest.Engine) + ├── SDCardMonitor.cs Background drive polling, photo discovery, move/convert pipeline + └── ImageConverter.cs Magick.NET wrapper for universal format-to-JPG conversion +``` + +**Single NuGet dependency:** `Magick.NET-Q16-AnyCPU` + +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Move, not copy | The card is the source of truth; leaving dupes after import causes confusion. Move it once, done. | +| Same-day dedupe | Re-inserting a card on the same day shouldn't double-import. Within today's folder, byte-identical files are skipped; name collisions with different content get a `_1`, `_2` suffix. (We are not a cross-day dedupe tool — different days go in different folders.) | +| Tray + toast, no main window | This is a silent utility. You shouldn't have to manage a window — it just tells you when it's working. | +| C# / .NET 8 over Python | Single-exe deployment. No runtime, pip, or native extension installation on the end user's PC. | +| WinForms over WPF | Simple, direct control layout, no XAML complexity. | +| Magick.NET over System.Drawing | One library handles all 30+ formats including RAW, HEIC, SVG. | +| kernel32 P/Invoke over WMI | Deterministic, fast drive detection without COM initialization overhead. | +| Polling (4s interval) over WMI events | WMI event subscriptions require COM apartment threading and have inconsistent delivery across Windows versions. | +| Flat output directory | Matches the workflow: pull to date folder, open in browser, attach to listing. | + +## Building + +### Prerequisites +- Windows 10/11 +- [Visual Studio 2022 Community](https://visualstudio.microsoft.com/vs/community/) (free) with ".NET desktop development" workload +- .NET 8 SDK (installed by VS2022 with the desktop workload) + +### Build from Visual Studio +1. Open `AutoIngest.sln` +2. Build → Build Solution (or Ctrl+Shift+B) +3. The output exe is in `AutoIngest/bin/Debug/net8.0-windows/` + +### Build from command line +```bat +cd AutoIngest +dotnet restore +dotnet build -c Release +``` + +### Publish as single exe +```bat +REM Framework-dependent (~15 MB, requires .NET 8 desktop runtime on target PC) +publish.bat + +REM Self-contained (~60-80 MB, runs on any Win10/11 PC) +publish-standalone.bat +``` + +## Deployment + +Copy the resulting `AutoIngest.exe` to the target PC. No installer required. + +- **Framework-dependent build:** Requires .NET 8 Desktop Runtime. Windows 11 includes this by default. Windows 10 may need it from [dotnet.microsoft.com](https://dotnet.microsoft.com/download/dotnet/8.0). +- **Self-contained build:** No prerequisites. Runs on any Windows 10/11 PC. + +## Configuration + +Config file: `~/.autoingest_config.json` — created automatically on first run. Holds JPG quality and auto-import toggle. + +## Tech Stack + +| Component | Technology | +|-----------|------------| +| Language | C# 12 / .NET 8 | +| UI Framework | Windows Forms | +| Image Processing | Magick.NET-Q16-AnyCPU (ImageMagick 7) | +| Drive Detection | kernel32 `GetDriveType` + `GetVolumeInformationW` via P/Invoke | +| Serialization | System.Text.Json | +| Target | Windows 10 1809+ / Windows 11 | + +## License + +MS-PL (Microsoft Public License). See [LICENSE](LICENSE). Author: Jeremy Anderson (info@dcos.net, https://dcos.net). diff --git a/publish-standalone.bat b/publish-standalone.bat new file mode 100755 index 0000000..fbe2fdd --- /dev/null +++ b/publish-standalone.bat @@ -0,0 +1,45 @@ +@echo off +echo ============================================ +echo AutoIngest - Standalone Build +echo (no .NET runtime required on target PC) +echo ============================================ +echo. + +REM Check for .NET 8 SDK +where dotnet >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: .NET 8 SDK not found. + echo Install it from: https://dotnet.microsoft.com/download/dotnet/8.0 + pause + exit /b 1 +) + +echo Restoring NuGet packages... +cd /d "%~dp0AutoIngest" +dotnet restore +if %errorlevel% neq 0 ( + echo ERROR: NuGet restore failed. + pause + exit /b 1 +) + +echo. +echo Building Standalone Release... +echo (this will take a moment...) +dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o ..\publish-standalone +if %errorlevel% neq 0 ( + echo ERROR: Build failed. + pause + exit /b 1 +) + +echo. +echo ============================================ +echo BUILD SUCCESS +echo Output: publish-standalone\AutoIngest.exe +echo File size will be ~60-80 MB (includes everything) +echo ============================================ +echo. +echo This exe runs on ANY Windows 10/11 PC - no runtime needed. +echo. +pause diff --git a/publish-standalone/AutoIngest.exe b/publish-standalone/AutoIngest.exe new file mode 100755 index 0000000..3590b16 Binary files /dev/null and b/publish-standalone/AutoIngest.exe differ diff --git a/publish-standalone/AutoIngest.pdb b/publish-standalone/AutoIngest.pdb new file mode 100644 index 0000000..ab1e0ba Binary files /dev/null and b/publish-standalone/AutoIngest.pdb differ diff --git a/publish.bat b/publish.bat new file mode 100755 index 0000000..83df652 --- /dev/null +++ b/publish.bat @@ -0,0 +1,44 @@ +@echo off +echo ============================================ +echo AutoIngest - Build and Publish +echo ============================================ +echo. + +REM Check for .NET 8 SDK +where dotnet >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: .NET 8 SDK not found. + echo Install it from: https://dotnet.microsoft.com/download/dotnet/8.0 + pause + exit /b 1 +) + +echo Restoring NuGet packages... +cd /d "%~dp0AutoIngest" +dotnet restore +if %errorlevel% neq 0 ( + echo ERROR: NuGet restore failed. + pause + exit /b 1 +) + +echo. +echo Building Release... +dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o ..\publish +if %errorlevel% neq 0 ( + echo ERROR: Build failed. + pause + exit /b 1 +) + +echo. +echo ============================================ +echo BUILD SUCCESS +echo Output: publish\AutoIngest.exe +echo ============================================ +echo. +echo NOTE: This requires .NET 8 Desktop Runtime on the target PC. +echo Windows 11 already has it. For Windows 10, get it from: +echo https://dotnet.microsoft.com/download/dotnet/8.0 +echo. +pause diff --git a/publish/AutoIngest.exe b/publish/AutoIngest.exe new file mode 100755 index 0000000..08105e2 Binary files /dev/null and b/publish/AutoIngest.exe differ diff --git a/publish/AutoIngest.pdb b/publish/AutoIngest.pdb new file mode 100644 index 0000000..ab1e0ba Binary files /dev/null and b/publish/AutoIngest.pdb differ