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

This commit is contained in:
Jeremy Anderson 2026-08-10 08:33:00 -04:00
commit d6dbe22358
21 changed files with 1695 additions and 0 deletions

44
.gitignore vendored Normal file
View File

@ -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

19
AutoIngest.sln Normal file
View File

@ -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

122
AutoIngest/App/MainForm.Designer.cs generated Normal file
View File

@ -0,0 +1,122 @@
using System.Drawing;
using System.Windows.Forms;
namespace AutoIngest.App
{
/// <summary>
/// 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.
/// </summary>
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;
}
}

403
AutoIngest/App/MainForm.cs Normal file
View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Auto-hides the toast a few seconds after the last activity.</summary>
System.Windows.Forms.Timer? _hideTimer;
/// <summary>True while an import is actively running — keeps the toast pinned open.</summary>
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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// 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).
/// </summary>
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
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
Standard header required by the WinForms Designer to open MainForm visually.
-->
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="trayMenu.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="trayIcon.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>104, 17</value>
</metadata>
</root>

35
AutoIngest/App/Program.cs Normal file
View File

@ -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());
}
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="AutoIngest"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<AssemblyName>AutoIngest</AssemblyName>
<RootNamespace>AutoIngest</RootNamespace>
<Version>3.0.0</Version>
<AssemblyTitle>AutoIngest</AssemblyTitle>
<Product>AutoIngest</Product>
<Description>Camera photo importer for ecommerce workflows</Description>
<Company>dcos.net</Company>
<Authors>Jeremy Anderson</Authors>
<Copyright>Copyright © Jeremy Anderson</Copyright>
<PackageLicenseExpression>MS-PL</PackageLicenseExpression>
<PackageProjectUrl>https://dcos.net</PackageProjectUrl>
<ApplicationManifest>App\app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.16.0" />
</ItemGroup>
</Project>

View File

@ -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<AppConfig>(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) { }
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using System.IO;
using ImageMagick;
namespace AutoIngest.Engine
{
/// <summary>
/// Converts any image format to ecommerce-optimized progressive JPG.
/// Delegates all format detection to Magick.NET (RAW, HEIC, SVG, standard raster).
/// </summary>
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; }
}
}
}

View File

@ -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<char> 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<string> SKIP_FOLDERS = new(StringComparer.OrdinalIgnoreCase) {
"System Volume Information", "$RECYCLE.BIN",
"Windows", "Program Files", "Program Files (x86)",
"ProgramData", "AppData", "Recovery"
};
static readonly HashSet<string> SKIP_PREFIXES = new() { ".", "$", "~" };
public static readonly HashSet<string> 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<string, string>? DriveFound;
public event Action<int, int, string>? ImportProgress;
public event Action<int, int, string, string>? ImportDone;
public event Action<string>? ImportError;
public event Action<string, string>? LogMessage;
private Thread? _thread;
private volatile bool _running;
private volatile bool _importing;
private readonly HashSet<string> _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/<yyyy-MM-dd>/.
var photosBase = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
Log("Auto-import monitor active. Insert SD card or USB drive.", "#58a6ff");
Log($"Destination: {photosBase}\\<today>\\", "#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<string>(
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<char> 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<string> FindPhotos(char ch)
{
string root = $"{ch}:\\";
var seen = new HashSet<string>(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<string> 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<string> ScanFolder(string folder, int maxDepth)
{
var results = new List<string>();
try
{
ScanRecursive(folder, maxDepth, 0, results);
}
catch (UnauthorizedAccessException) { }
catch (IOException) { }
return results;
}
static void ScanRecursive(string dir, int maxDepth, int depth, List<string> 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);
}
/// <summary>
/// Resolves a destination path inside <paramref name="destFolder"/> for a source
/// file that wants to be named <paramref name="desiredName"/>.
///
/// 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.
/// </summary>
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);
}
/// <summary>True if the two files are byte-for-byte identical (SHA256 match).</summary>
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; }
}
/// <summary>
/// Returns a non-colliding path in <paramref name="folder"/> by appending
/// _1, _2, ... to the stem until a free name is found.
/// </summary>
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);
}
}
}

88
BLOG.md Normal file
View File

@ -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)

68
LICENSE Normal file
View File

@ -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 <info@dcos.net>
Website: https://dcos.net

88
QUICKSTART.md Normal file
View File

@ -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

125
README.md Normal file
View File

@ -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/<today's date>/` — 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).

45
publish-standalone.bat Executable file
View File

@ -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

BIN
publish-standalone/AutoIngest.exe Executable file

Binary file not shown.

Binary file not shown.

44
publish.bat Executable file
View File

@ -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

BIN
publish/AutoIngest.exe Executable file

Binary file not shown.

BIN
publish/AutoIngest.pdb Normal file

Binary file not shown.