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:
commit
d6dbe22358
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue