Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions ImageDescriber.Test/ImportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileName>(),
KnownPaths = [Path.Combine(folder, "IMG_001.jpg").As<AbsoluteFilePath>(), Path.Combine(folder, "IMG_002; copy.jpg").As<AbsoluteFilePath>()],
Model = "llava".As<OllamaModelName>(),
DescribedAt = new DateTime(2026, 9, 27, 1, 8, 10, DateTimeKind.Utc),
FileSizeBytes = 3,
Description = "A wedding.",
};

List<ImageDescription> 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()
{
Expand Down
6 changes: 5 additions & 1 deletion ImageDescriber/Verbs/Export.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ internal static string BuildCsv(IEnumerable<ImageDescription> 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,
Expand All @@ -94,5 +96,7 @@ internal static string BuildCsv(IEnumerable<ImageDescription> descriptions)
return sb.ToString();
}

internal const char KnownPathTerminator = '\n';

private static string QuoteCsvField(string value) => $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
}
15 changes: 12 additions & 3 deletions ImageDescriber/Verbs/Import.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,7 @@ internal static List<ImageDescription> ParseCsv(string text)
// Fields: Hash, SuggestedFileName, KnownPaths, Model, DescribedAt, FileSizeBytes, Description
string hash = fields[0];
FileName suggestedFileName = fields[1].As<FileName>();
List<AbsoluteFilePath> knownPaths = [.. fields[2]
.Split("; ", StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.As<AbsoluteFilePath>())];
List<AbsoluteFilePath> knownPaths = [.. SplitKnownPaths(fields[2]).Select(p => p.As<AbsoluteFilePath>())];
OllamaModelName model = fields[3].As<OllamaModelName>();
DateTime describedAt = DateTime.Parse(fields[4], CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
long fileSizeBytes = long.Parse(fields[5], CultureInfo.InvariantCulture);
Expand Down Expand Up @@ -214,6 +212,17 @@ internal static List<ImageDescription> ParseCsv(string text)
return entries;
}

/// <summary>
/// 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.
/// </summary>
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<string> ParseCsvLine(string line) =>
ParseCsvRecords(line).Select(r => r.Fields).FirstOrDefault() ?? [];

Expand Down
Loading