From d51e62622aef0ea473dd93cf61eb54b037d42715 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:23:48 +0000 Subject: [PATCH] Keep CSV-exported paths that contain "; " intact [patch] Export joined an entry's known paths with "; " and Import split on it, so a folder such as "Smith; Jones wedding" came back as two bogus paths, or the row was dropped. Export now ends every path with a line break, which the quoted field already round-trips and no path contains. Import still reads older exports that have no line break in the field as "; "-joined. Fixes ktsu-dev/ImageDescriber#158 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kj95GdQsZpSCcND7LqacT1 --- ImageDescriber.Test/ImportTests.cs | 32 ++++++++++++++++++++++++++++++ ImageDescriber/Verbs/Export.cs | 6 +++++- ImageDescriber/Verbs/Import.cs | 15 +++++++++++--- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/ImageDescriber.Test/ImportTests.cs b/ImageDescriber.Test/ImportTests.cs index b9b175e..aa93966 100644 --- a/ImageDescriber.Test/ImportTests.cs +++ b/ImageDescriber.Test/ImportTests.cs @@ -244,6 +244,38 @@ public void CsvExportThenImportRoundTripsEveryField() } } + [TestMethod] + public void CsvExportThenImportKeepsPathsThatContainTheOldSeparator() + { + string folder = Path.Combine(Path.GetTempPath(), "Photos", "Smith; Jones wedding"); + ImageDescription original = new() + { + Hash = new string('d', 64), + SuggestedFileName = "wedding.jpg".As(), + KnownPaths = [Path.Combine(folder, "IMG_001.jpg").As(), Path.Combine(folder, "IMG_002; copy.jpg").As()], + Model = "llava".As(), + DescribedAt = new DateTime(2026, 9, 27, 1, 8, 10, DateTimeKind.Utc), + FileSizeBytes = 3, + Description = "A wedding.", + }; + + List imported = Import.ParseCsv(Export.BuildCsv([original])); + + Assert.HasCount(1, imported); + Assert.AreSequenceEqual(original.KnownPaths, imported[0].KnownPaths); + } + + [TestMethod] + public void SplitKnownPathsReadsBothTheCurrentAndTheLegacyFormat() + { + string[] expected = ["/a/b.jpg", "/c/d.jpg"]; + + Assert.AreSequenceEqual(expected, Import.SplitKnownPaths("/a/b.jpg\n/c/d.jpg\n")); + Assert.AreSequenceEqual(expected, Import.SplitKnownPaths("/a/b.jpg\r\n/c/d.jpg\r\n")); + Assert.AreSequenceEqual(expected, Import.SplitKnownPaths("/a/b.jpg; /c/d.jpg")); + Assert.IsEmpty(Import.SplitKnownPaths(string.Empty)); + } + [TestMethod] public void ParseCsvRecordsKeepsLineBreaksInsideQuotedFields() { diff --git a/ImageDescriber/Verbs/Export.cs b/ImageDescriber/Verbs/Export.cs index f20a7e2..645c00c 100644 --- a/ImageDescriber/Verbs/Export.cs +++ b/ImageDescriber/Verbs/Export.cs @@ -74,7 +74,9 @@ internal static string BuildCsv(IEnumerable descriptions) foreach (ImageDescription desc in descriptions) { - string joinedPaths = string.Join("; ", desc.KnownPaths.Select(p => p.WeakString)); + // End every path with a line break, which the quoted field keeps and no path contains. + // "; " would do as a separator only until a folder name contains it. + string joinedPaths = string.Concat(desc.KnownPaths.Select(p => p.WeakString + KnownPathTerminator)); string[] fields = [ desc.Hash, @@ -94,5 +96,7 @@ internal static string BuildCsv(IEnumerable descriptions) return sb.ToString(); } + internal const char KnownPathTerminator = '\n'; + private static string QuoteCsvField(string value) => $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; } diff --git a/ImageDescriber/Verbs/Import.cs b/ImageDescriber/Verbs/Import.cs index 9040143..91e3f62 100644 --- a/ImageDescriber/Verbs/Import.cs +++ b/ImageDescriber/Verbs/Import.cs @@ -178,9 +178,7 @@ internal static List ParseCsv(string text) // Fields: Hash, SuggestedFileName, KnownPaths, Model, DescribedAt, FileSizeBytes, Description string hash = fields[0]; FileName suggestedFileName = fields[1].As(); - List knownPaths = [.. fields[2] - .Split("; ", StringSplitOptions.RemoveEmptyEntries) - .Select(p => p.As())]; + List knownPaths = [.. SplitKnownPaths(fields[2]).Select(p => p.As())]; OllamaModelName model = fields[3].As(); DateTime describedAt = DateTime.Parse(fields[4], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); long fileSizeBytes = long.Parse(fields[5], CultureInfo.InvariantCulture); @@ -214,6 +212,17 @@ internal static List ParseCsv(string text) return entries; } + /// + /// Splits a KnownPaths field. Current exports end every path with a line break; exports + /// written before that joined the paths with "; ", so a field without one is read that way. + /// + internal static string[] SplitKnownPaths(string field) => + field.Contains(Export.KnownPathTerminator, StringComparison.Ordinal) + ? [.. field.Split(Export.KnownPathTerminator, StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.TrimEnd('\r')) + .Where(p => p.Length > 0)] + : field.Split("; ", StringSplitOptions.RemoveEmptyEntries); + internal static List ParseCsvLine(string line) => ParseCsvRecords(line).Select(r => r.Fields).FirstOrDefault() ?? [];