Skip to content

Commit 4357a32

Browse files
committed
Preserve converted notes when renaming
Persist a conversion-source marker in generated Markdown so a renamed note remains associated with its original document. This prevents conversion scans from recreating the old output name and keeps DOCX attachments available after a rename. PDF notes retain their existing embed relationship.
1 parent ec5b60e commit 4357a32

6 files changed

Lines changed: 227 additions & 16 deletions

File tree

‎src/NoteManager.Core/Services/EmbeddedMediaVaultIndex.cs‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@ public sealed class EmbeddedMediaVaultIndex
77
private readonly string _rootFolder;
88
// The media snapshot must retain the same file identity semantics as the active vault.
99
private readonly StringComparer _pathComparer;
10-
// Keep the indexed media snapshot so viewer resolution shares its candidate set with publication.
11-
private readonly IReadOnlyList<string> _mediaPaths;
10+
// Keep every vault file so conversion-source metadata can resolve DOCX and other non-previewable documents.
11+
private readonly IReadOnlyList<string> _vaultFilePaths;
1212
private readonly IReadOnlyDictionary<string, string[]> _filesByFolder;
1313

1414
private EmbeddedMediaVaultIndex(
1515
string rootFolder,
1616
StringComparer pathComparer,
17-
IReadOnlyList<string> mediaPaths,
17+
IReadOnlyList<string> vaultFilePaths,
1818
IReadOnlyDictionary<string, string[]> filesByFolder)
1919
{
2020
_rootFolder = rootFolder;
2121
_pathComparer = pathComparer;
22-
_mediaPaths = mediaPaths;
22+
_vaultFilePaths = vaultFilePaths;
2323
_filesByFolder = filesByFolder;
2424
}
2525

@@ -35,10 +35,6 @@ public static EmbeddedMediaVaultIndex Create(
3535
.EnumerateFiles(rootFolder, "*", options)
3636
.OrderBy(path => path, pathComparer)
3737
.ToArray();
38-
var mediaPaths = allPaths
39-
.Where(path => EmbeddedMediaReference.TryGetKind(path, out _))
40-
.ToArray();
41-
4238
// Grouping the snapshot by folder avoids enumerating the same directory
4339
// once per Markdown note when a conversion output contains many notes.
4440
var filesByFolder = allPaths
@@ -53,7 +49,7 @@ public static EmbeddedMediaVaultIndex Create(
5349
return new EmbeddedMediaVaultIndex(
5450
rootFolder,
5551
pathComparer,
56-
mediaPaths,
52+
allPaths,
5753
filesByFolder);
5854
}
5955

@@ -69,12 +65,21 @@ public EmbeddedMediaReference[] ResolveAll(
6965
GetKind(target)))
7066
.ToArray();
7167

68+
// Conversion metadata survives a Markdown rename, unlike implicit basename matching, and exposes non-media sources as attachments.
69+
var conversionSourceReferences = MarkdownMetadataParser
70+
.ParseDocumentConversionSourceReferences(markdown)
71+
.Select(target => new EmbeddedMediaReference(
72+
target,
73+
Resolve(target, markdownFilePath),
74+
GetKindOrDocument(target)));
75+
7276
// Related documents follow explicit Markdown embeds so authored content
7377
// keeps its original order, while automatic attachments remain below it.
7478
var resolvedPaths = new HashSet<string>(
7579
embeddedReferences.Select(reference => reference.ResolvedPath),
7680
_pathComparer);
77-
var relatedDocuments = ResolveRelatedDocuments(markdownFilePath)
81+
var relatedDocuments = conversionSourceReferences
82+
.Concat(ResolveRelatedDocuments(markdownFilePath))
7883
.Where(reference => resolvedPaths.Add(reference.ResolvedPath));
7984

8085
return [.. embeddedReferences, .. relatedDocuments];
@@ -159,6 +164,11 @@ private static EmbeddedMediaKind GetKind(string target)
159164
nameof(target));
160165
}
161166

167+
private static EmbeddedMediaKind GetKindOrDocument(string target)
168+
=> EmbeddedMediaReference.TryGetKind(target, out var kind)
169+
? kind
170+
: EmbeddedMediaKind.Document;
171+
162172
public string Resolve(string rawTarget, string markdownFilePath)
163173
{
164174
try
@@ -182,7 +192,7 @@ private string ResolveCore(string rawTarget, string markdownFilePath)
182192
target,
183193
markdownFilePath,
184194
_rootFolder,
185-
_mediaPaths,
195+
_vaultFilePaths,
186196
_pathComparer);
187197
// Preserve unresolved text for the viewer while the shared policy prevents external or ambiguous files loading.
188198
return resolution.ResolvedPath ?? target;

‎src/NoteManager.Core/Services/MarkdownMetadataParser.cs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,27 @@ public static string[] ParseInlineEmbeddedMediaEmbeds(string markdown)
6767
return embeds.ToArray();
6868
}
6969

70+
/// <summary>
71+
/// Reads the durable source marker written by Document Conversion so a note
72+
/// can retain its original document after the Markdown filename changes.
73+
/// </summary>
74+
public static string[] ParseDocumentConversionSourceReferences(string markdown)
75+
{
76+
var sources = new List<string>();
77+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
78+
79+
foreach (Match match in DocumentConversionSourceRegex().Matches(markdown))
80+
{
81+
var target = match.Groups["target"].Value.Trim();
82+
if (target.Length > 0 && seen.Add(target))
83+
{
84+
sources.Add(target);
85+
}
86+
}
87+
88+
return sources.ToArray();
89+
}
90+
7091
private static string NormalizeTag(string value)
7192
{
7293
var tag = value.Trim();
@@ -95,4 +116,8 @@ private static string NormalizeTag(string value)
95116

96117
[GeneratedRegex(@"!\[\[\s*(?<target>[^\]\r\n|#]+?\.(?:pdf|png|jpe?g|bmp))(?:[|#][^\]\r\n]*)?\s*\]\]", RegexOptions.IgnoreCase)]
97118
private static partial Regex InlineEmbeddedMediaEmbedRegex();
119+
120+
// The converter URI-escapes the target, keeping this single-line metadata marker safe for filenames with Markdown syntax.
121+
[GeneratedRegex(@"<!--\s*notemanager-conversion-source:\s*(?<target>.*?)\s*-->", RegexOptions.IgnoreCase)]
122+
private static partial Regex DocumentConversionSourceRegex();
98123
}

‎src/NoteManager.Plugin.DocumentConversion/DocumentConversionService.cs‎

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text;
22
using System.Text.Json;
3+
using NoteManager.App.Services;
34
using NoteManager.Plugins;
45

56
namespace NoteManager.Plugin.DocumentConversion;
@@ -172,6 +173,7 @@ await LogAndReportAsync(
172173
try
173174
{
174175
// Complete post-processing while the output is private so failures cannot affect a concurrent note.
176+
AppendConversionSourceReference(document, stagingOutput.OutputPath);
175177
AppendOriginalPdfEmbed(document, stagingOutput.OutputPath);
176178
if (!TryPublishStagedDocument(stagingOutput.OutputPath, document.OutputPath))
177179
{
@@ -247,6 +249,8 @@ internal static IReadOnlyList<PendingDocument> FindPendingDocuments(
247249
AttributesToSkip = FileAttributes.ReparsePoint
248250
};
249251

252+
var convertedInputPaths = FindConvertedInputPaths(vaultPath, enumerationOptions);
253+
250254
return Directory.EnumerateFiles(vaultPath, "*", enumerationOptions)
251255
.Where(IsSupportedDocument)
252256
.Select(path => new FileInfo(path))
@@ -262,12 +266,64 @@ internal static IReadOnlyList<PendingDocument> FindPendingDocuments(
262266
file.FullName,
263267
Path.ChangeExtension(file.FullName, ".md"),
264268
file.LastWriteTimeUtc))
265-
.Where(document => !File.Exists(document.OutputPath))
269+
// A conversion source marker stays with its Markdown note through renames, preventing regeneration at the old basename.
270+
.Where(document => !File.Exists(document.OutputPath)
271+
&& !convertedInputPaths.Contains(document.InputPath))
266272
.OrderByDescending(document => document.LastWriteTimeUtc)
267273
.ThenBy(document => document.InputPath, StringComparer.OrdinalIgnoreCase)
268274
.ToArray();
269275
}
270276

277+
private static HashSet<string> FindConvertedInputPaths(
278+
string vaultPath,
279+
EnumerationOptions enumerationOptions)
280+
{
281+
var normalizedVaultPath = Path.GetFullPath(vaultPath);
282+
var convertedInputPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
283+
284+
foreach (var markdownPath in Directory.EnumerateFiles(
285+
normalizedVaultPath,
286+
"*.md",
287+
enumerationOptions))
288+
{
289+
try
290+
{
291+
var markdownFolder = Path.GetDirectoryName(markdownPath)!;
292+
foreach (var sourceReference in MarkdownMetadataParser
293+
.ParseDocumentConversionSourceReferences(File.ReadAllText(markdownPath)))
294+
{
295+
var sourcePath = Path.GetFullPath(Path.Combine(
296+
markdownFolder,
297+
ObsidianEmbedTarget.GetLiteralPath(sourceReference)
298+
.Replace('/', Path.DirectorySeparatorChar)));
299+
if (IsPathInsideVault(sourcePath, normalizedVaultPath))
300+
{
301+
convertedInputPaths.Add(sourcePath);
302+
}
303+
}
304+
}
305+
catch (Exception exception) when (
306+
exception is IOException
307+
or UnauthorizedAccessException
308+
or ArgumentException
309+
or NotSupportedException
310+
or UriFormatException)
311+
{
312+
// An unreadable or malformed Markdown note must not stop unrelated conversion work.
313+
}
314+
}
315+
316+
return convertedInputPaths;
317+
}
318+
319+
private static bool IsPathInsideVault(string path, string vaultPath)
320+
{
321+
var relativePath = Path.GetRelativePath(vaultPath, path);
322+
return !Path.IsPathRooted(relativePath)
323+
&& !relativePath.Equals("..", StringComparison.Ordinal)
324+
&& !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal);
325+
}
326+
271327
private static bool IsSupportedDocument(string path)
272328
{
273329
var extension = Path.GetExtension(path);
@@ -436,6 +492,22 @@ private static void AppendOriginalPdfEmbed(
436492
$"{Environment.NewLine}{Environment.NewLine}![[{escapedSourcePath}]]");
437493
}
438494

495+
private static void AppendConversionSourceReference(
496+
PendingDocument document,
497+
string stagedOutputPath)
498+
{
499+
var outputDirectory = Path.GetDirectoryName(document.OutputPath)!;
500+
var sourcePath = Path.GetRelativePath(outputDirectory, document.InputPath)
501+
.Replace(Path.DirectorySeparatorChar, '/');
502+
// URI escaping makes the metadata a stable literal path even when a filename contains comment or Markdown delimiters.
503+
var escapedSourcePath = Uri.EscapeDataString(sourcePath);
504+
505+
// Persist this relationship inside the output because File.Move carries it through a note rename without touching shared sources.
506+
File.AppendAllText(
507+
stagedOutputPath,
508+
$"{Environment.NewLine}{Environment.NewLine}<!-- notemanager-conversion-source: {escapedSourcePath} -->");
509+
}
510+
439511
private static bool TryReadItemResult(
440512
string standardOutput,
441513
out CliItemResult result)

‎tests/NoteManager.App.Tests/EmbeddedMediaReferenceTests.cs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,37 @@ public void LoadFolder_EmbeddedCorrespondingPdf_DoesNotDuplicateAttachment()
136136
}
137137
}
138138

139+
[Theory]
140+
[InlineData("invoice.docx", EmbeddedMediaKind.Document)]
141+
[InlineData("invoice.pdf", EmbeddedMediaKind.Pdf)]
142+
public void LoadFolder_ConversionSourceMetadata_ResolvesRenamedNoteAttachment(
143+
string sourceFileName,
144+
EmbeddedMediaKind expectedKind)
145+
{
146+
var root = Path.Combine(
147+
Path.GetTempPath(),
148+
$"NoteManager.App.Tests.{Guid.NewGuid():N}");
149+
Directory.CreateDirectory(root);
150+
try
151+
{
152+
File.WriteAllText(Path.Combine(root, sourceFileName), "source");
153+
// The durable marker represents a converted note after it has been renamed from invoice.md to paid.md.
154+
File.WriteAllText(
155+
Path.Combine(root, "paid.md"),
156+
$"<!-- notemanager-conversion-source: {Uri.EscapeDataString(sourceFileName)} -->");
157+
158+
var note = Assert.Single(MarkdownFolderService.LoadFolder(root).Notes);
159+
160+
var attachment = Assert.Single(note.EmbeddedMediaReferences);
161+
Assert.Equal(sourceFileName, attachment.FileName);
162+
Assert.Equal(expectedKind, attachment.Kind);
163+
}
164+
finally
165+
{
166+
Directory.Delete(root, recursive: true);
167+
}
168+
}
169+
139170
/// <summary>
140171
/// Verifies that only PDFs gain an automatic inline preview; a related
141172
/// image without an explicit Markdown embed remains a document attachment.

‎tests/NoteManager.App.Tests/NoteRenamingTests.cs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,32 @@ public async Task RenameNote_WhenNamesExist_UsesFirstAvailableNumberedSuffix()
6161
Assert.Equal("filename (2).md", viewModel.SelectedNote!.FileName);
6262
}
6363

64+
[Theory]
65+
[InlineData(".docx")]
66+
[InlineData(".pdf")]
67+
public async Task RenameNote_WithConversionSourceMetadata_PreservesOriginalAttachment(
68+
string extension)
69+
{
70+
using var folder = new TemporaryNoteFolder();
71+
var sourcePath = Path.Combine(folder.Path, $"invoice{extension}");
72+
var originalPath = folder.WriteNote(
73+
"invoice.md",
74+
$"<!-- notemanager-conversion-source: {Uri.EscapeDataString(Path.GetFileName(sourcePath))} -->");
75+
File.WriteAllText(sourcePath, "source");
76+
77+
using var viewModel = new MainViewModel();
78+
await viewModel.LoadMarkdownFolderAsync(folder.Path, originalPath);
79+
80+
var renamed = viewModel.TryRenameNote(viewModel.SelectedNote!, "paid");
81+
82+
// The marker moves with the Markdown file, keeping the untouched source available after the user renames the note.
83+
Assert.True(renamed);
84+
Assert.False(File.Exists(originalPath));
85+
Assert.Equal(
86+
Path.GetFullPath(sourcePath),
87+
Assert.Single(viewModel.SelectedNote!.EmbeddedMediaReferences).ResolvedPath);
88+
}
89+
6490
[Theory]
6591
[InlineData("")]
6692
[InlineData("../outside.md")]

‎tests/NoteManager.Plugin.DocumentConversion.Tests/DocumentConversionServiceTests.cs‎

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ public async Task ConvertPendingAsync_PreservesSuccessfulOutputAndLeavesSharedTe
5151
Assert.Equal(1, result.Converted);
5252
Assert.Equal(1, result.Failures);
5353
Assert.Equal("newer.txt", Path.GetFileName(attemptedInputs[0]));
54-
Assert.Equal("converted", File.ReadAllText(Path.ChangeExtension(newerInput, ".md")));
54+
// Generated content keeps the converter payload before the durable source marker appended during publication.
55+
Assert.StartsWith("converted", File.ReadAllText(Path.ChangeExtension(newerInput, ".md")));
5556
Assert.False(File.Exists(Path.ChangeExtension(olderInput, ".md")));
5657
Assert.True(File.Exists(existingTemporaryOutput));
5758
// Shared temporary names do not establish ownership, so both existing and newly appearing files survive.
@@ -83,9 +84,11 @@ public async Task ConvertPendingAsync_AppendsOriginalPdfEmbedAfterExtractedText(
8384
new DocumentConversionLog(context.ConfigurationDirectory),
8485
new DocumentConversionOptions()).ConvertPendingAsync(context);
8586

86-
// The embed persists the PDF-to-Markdown relationship for NoteManager's media viewer.
87+
// The marker suppresses duplicate rescans after rename while the embed preserves the established PDF viewer relationship.
8788
Assert.Equal(
88-
$"Extracted PDF text{Environment.NewLine}{Environment.NewLine}![[report.pdf]]",
89+
$"Extracted PDF text{Environment.NewLine}{Environment.NewLine}"
90+
+ "<!-- notemanager-conversion-source: report.pdf -->"
91+
+ $"{Environment.NewLine}{Environment.NewLine}![[report.pdf]]",
8992
File.ReadAllText(Path.ChangeExtension(sourcePath, ".md")));
9093
Assert.True(File.Exists(sourcePath));
9194
Assert.Equal(1, result.Converted);
@@ -217,6 +220,49 @@ public void FindPendingDocuments_ExcludesTildePrefixedWordTemporaryFiles()
217220
Assert.Equal(document, pending[0].InputPath);
218221
}
219222

223+
[Theory]
224+
[InlineData(".docx")]
225+
[InlineData(".pdf")]
226+
public async Task ConvertPendingAsync_ConvertedNoteRenamed_DoesNotRegenerateOriginalOutput(
227+
string extension)
228+
{
229+
using var folder = new TemporaryFolder();
230+
var sourcePath = Path.Combine(folder.Path, $"invoice{extension}");
231+
var originalOutputPath = Path.ChangeExtension(sourcePath, ".md");
232+
var renamedOutputPath = Path.Combine(folder.Path, "paid.md");
233+
File.WriteAllText(sourcePath, "source");
234+
var conversionCount = 0;
235+
var runner = new StubRunner((_, output) =>
236+
{
237+
conversionCount++;
238+
File.WriteAllText(output, "converted");
239+
return SuccessResult();
240+
});
241+
var context = CreateContext(folder.Path);
242+
var service = new DocumentConversionService(
243+
runner,
244+
new DocumentConversionLog(context.ConfigurationDirectory),
245+
new DocumentConversionOptions());
246+
247+
await service.ConvertPendingAsync(context);
248+
File.Move(originalOutputPath, renamedOutputPath);
249+
var rescan = await service.ConvertPendingAsync(context);
250+
251+
// The marker moves with paid.md, so the surviving source no longer looks pending at invoice.md.
252+
Assert.Equal(1, conversionCount);
253+
Assert.Equal(0, rescan.Total);
254+
Assert.False(File.Exists(originalOutputPath));
255+
Assert.True(File.Exists(sourcePath));
256+
Assert.Contains(
257+
$"notemanager-conversion-source: {Uri.EscapeDataString(Path.GetFileName(sourcePath))}",
258+
File.ReadAllText(renamedOutputPath));
259+
if (extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase))
260+
{
261+
// PDF output keeps its authored embed while the marker provides the scan-stable identity.
262+
Assert.Contains("![[invoice.pdf]]", File.ReadAllText(renamedOutputPath));
263+
}
264+
}
265+
220266
[Fact]
221267
public async Task ConvertPendingAsync_StagesUtf8TextWithByteOrderMark()
222268
{
@@ -392,7 +438,8 @@ public async Task ConvertPendingAsync_InvalidJsonResult_FailsOnlyItsDocumentAndC
392438
Assert.Equal(1, result.Converted);
393439
Assert.Equal(1, result.Failures);
394440
Assert.False(File.Exists(Path.ChangeExtension(invalidInput, ".md")));
395-
Assert.Equal("converted", File.ReadAllText(Path.ChangeExtension(validInput, ".md")));
441+
// A later valid output retains its conversion result before the source marker is added.
442+
Assert.StartsWith("converted", File.ReadAllText(Path.ChangeExtension(validInput, ".md")));
396443
Assert.Empty(Directory.EnumerateDirectories(folder.Path, ".notemanager-doc2md-*"));
397444
Assert.Equal(1, refreshCount);
398445
Assert.Contains(statuses, status => status.Contains(

0 commit comments

Comments
 (0)