Skip to content

Commit 45a76fd

Browse files
committed
Import dropped PDFs as converted documents
Allow users to add PDFs without selecting a Markdown note, so external documents become standalone searchable notes rather than being forced into an existing editor. Preserve selected-note embedding for the existing workflow. Link each generated Markdown file to its original PDF, then refresh the note list after conversion so progress and completed documents are visible.
1 parent 4657948 commit 45a76fd

10 files changed

Lines changed: 343 additions & 29 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,9 @@ the newest first, and invokes DOC2MD once per document. A source is converted
403403
only when the sibling path produced by replacing its extension with `.md` does
404404
not already exist. The plugin never passes DOC2MD's `--overwrite` option, so an
405405
existing Markdown counterpart is not changed. A failure affects only that
406-
document; successful outputs from the same scan are preserved.
406+
document; successful outputs from the same scan are preserved. Generated
407+
Markdown from a PDF ends with an Obsidian embed of that original PDF, keeping
408+
the extracted text and source document connected.
407409

408410
The generated `settings.json` uses recursive conversion, local PDF processing,
409411
and `eng+pol` OCR by default. Deactivate the plugin before editing that file,

‎src/NoteManager.Core/Plugins/INoteManagerPlugin.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ Task StartAsync(
1111
Task StopAsync(CancellationToken cancellationToken = default);
1212
}
1313

14+
/// <summary>
15+
/// Lets the desktop request an immediate conversion after it imports a document.
16+
/// </summary>
17+
public interface IDocumentConversionTrigger
18+
{
19+
Task ConvertPendingDocumentsAsync(
20+
CancellationToken cancellationToken = default);
21+
}
22+
1423
public sealed record PluginMetadata(
1524
string Id,
1625
string Name,

‎src/NoteManager.Core/ViewModels/MainViewModel.cs‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,69 @@ public bool CanImportPdfIntoNote(NoteItem? note)
613613
&& IsFolderMode
614614
&& IsMarkdownPathInCurrentFolder(note.SourceFilePath);
615615

616+
public bool CanImportPdfDocuments
617+
=> IsFolderMode && !string.IsNullOrWhiteSpace(CurrentFolderPath);
618+
619+
public async Task<ImportedPdf[]> ImportPdfDocumentsAsync(
620+
IReadOnlyList<string> sourcePaths)
621+
{
622+
ArgumentNullException.ThrowIfNull(sourcePaths);
623+
624+
var pdfPaths = sourcePaths
625+
.Where(path => File.Exists(path)
626+
&& Path.GetExtension(path).Equals(
627+
".pdf",
628+
StringComparison.OrdinalIgnoreCase))
629+
.Distinct(StringComparer.OrdinalIgnoreCase)
630+
.ToArray();
631+
if (pdfPaths.Length == 0)
632+
{
633+
SetStatus("Select one or more PDF files to import as documents");
634+
return [];
635+
}
636+
637+
if (!CanImportPdfDocuments)
638+
{
639+
SetStatus("Open a Markdown folder before importing PDF documents");
640+
return [];
641+
}
642+
643+
var folderPath = CurrentFolderPath;
644+
var folderGeneration = _folderGeneration;
645+
SetStatus(
646+
pdfPaths.Length == 1
647+
? $"Importing {Path.GetFileName(pdfPaths[0])} for conversion…"
648+
: $"Importing {pdfPaths.Length:N0} PDF documents for conversion…");
649+
650+
try
651+
{
652+
// Do not create a Markdown embed here: the converter owns the new document's Markdown counterpart.
653+
var importedPdfs = await Task.Run(
654+
() => ImportPdfFiles(pdfPaths, folderPath, markdownFilePath: null));
655+
if (folderGeneration != _folderGeneration)
656+
{
657+
DeleteCopiedImports(importedPdfs);
658+
return [];
659+
}
660+
661+
var copiedCount = importedPdfs.Count(pdf => pdf.WasCopied);
662+
SetStatus(
663+
copiedCount == 0
664+
? $"Imported {importedPdfs.Length:N0} PDF document(s) for conversion"
665+
: $"Imported {importedPdfs.Length:N0} PDF document(s); {copiedCount:N0} copied to the folder root");
666+
return importedPdfs;
667+
}
668+
catch (Exception exception) when (
669+
exception is IOException
670+
or UnauthorizedAccessException
671+
or ArgumentException
672+
or NotSupportedException)
673+
{
674+
SetStatus($"Could not import the PDF document: {exception.Message}");
675+
return [];
676+
}
677+
}
678+
616679
public async Task ImportPdfFilesAsync(
617680
NoteItem targetNote,
618681
IReadOnlyList<string> sourcePaths,
@@ -705,7 +768,7 @@ or ArgumentException
705768
private static ImportedPdf[] ImportPdfFiles(
706769
IEnumerable<string> sourcePaths,
707770
string folderPath,
708-
string markdownFilePath)
771+
string? markdownFilePath)
709772
{
710773
var importedPdfs = new List<ImportedPdf>();
711774
try

‎src/NoteManager.Desktop/MainWindow.axaml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
Command="{Binding NewNoteCommand}" />
2323
<MenuItem Header="_Open folder…"
2424
Click="OpenFolder_OnClick" />
25-
<MenuItem Header="_Import PDF…"
25+
<MenuItem Header="_Import PDF into note…"
2626
Click="ImportPdf_OnClick" />
27+
<MenuItem Header="Import PDF _documents…"
28+
Click="ImportPdfDocuments_OnClick" />
2729
<Separator />
2830
<MenuItem Header="E_xit"
2931
Click="Exit_OnClick" />

‎src/NoteManager.Desktop/MainWindow.axaml.cs‎

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -278,30 +278,58 @@ private async Task RequireFolderSelectionAsync()
278278

279279
private async void ImportPdf_OnClick(object? sender, RoutedEventArgs e)
280280
{
281-
if (_storagePickerOpen)
281+
var note = ViewModel.SelectedNote;
282+
if (note is null || !ViewModel.CanImportPdfIntoNote(note))
282283
{
284+
ViewModel.StatusText = "Select a Markdown note before importing a PDF";
283285
return;
284286
}
285287

286-
var note = ViewModel.SelectedNote;
287-
if (note is null || !ViewModel.CanImportPdfIntoNote(note))
288+
var paths = await PickPdfPathsAsync("Import PDF into note");
289+
290+
if (paths.Length > 0)
288291
{
289-
ViewModel.StatusText = "Select a Markdown note before importing a PDF";
292+
await ViewModel.ImportPdfFilesAsync(
293+
note,
294+
paths,
295+
MarkdownEditor.CaretIndex);
296+
}
297+
}
298+
299+
private async void ImportPdfDocuments_OnClick(object? sender, RoutedEventArgs e)
300+
{
301+
if (!ViewModel.CanImportPdfDocuments)
302+
{
303+
ViewModel.StatusText = "Open a Markdown folder before importing PDF documents";
290304
return;
291305
}
292306

293-
string[] paths;
307+
var paths = await PickPdfPathsAsync("Import PDF documents");
308+
if (paths.Length > 0)
309+
{
310+
// This command deliberately creates converted notes instead of modifying the selected editor.
311+
await ImportPdfDocumentsAsync(paths);
312+
}
313+
}
314+
315+
private async Task<string[]> PickPdfPathsAsync(string title)
316+
{
317+
if (_storagePickerOpen)
318+
{
319+
return [];
320+
}
321+
294322
_storagePickerOpen = true;
295323
try
296324
{
297325
var results = await StorageProvider.OpenFilePickerAsync(
298326
new FilePickerOpenOptions
299327
{
300-
Title = "Import PDF",
328+
Title = title,
301329
AllowMultiple = true,
302330
FileTypeFilter = [PdfFileType]
303331
});
304-
paths = results
332+
return results
305333
.Select(file => file.TryGetLocalPath())
306334
.Where(path => !string.IsNullOrWhiteSpace(path))
307335
.Cast<string>()
@@ -311,14 +339,30 @@ private async void ImportPdf_OnClick(object? sender, RoutedEventArgs e)
311339
{
312340
_storagePickerOpen = false;
313341
}
342+
}
314343

315-
if (paths.Length > 0)
344+
private async Task ImportPdfDocumentsAsync(IReadOnlyList<string> paths)
345+
{
346+
var importedPdfs = await ViewModel.ImportPdfDocumentsAsync(paths);
347+
if (importedPdfs.Length == 0)
316348
{
317-
await ViewModel.ImportPdfFilesAsync(
318-
note,
319-
paths,
320-
MarkdownEditor.CaretIndex);
349+
return;
321350
}
351+
352+
if (!await _pluginManager.TriggerDocumentConversionAsync())
353+
{
354+
ViewModel.StatusText =
355+
"PDF documents were imported. Activate Document Conversion to create their Markdown notes.";
356+
return;
357+
}
358+
359+
var convertedPath = importedPdfs
360+
.Select(pdf => Path.ChangeExtension(pdf.DestinationPath, ".md"))
361+
.FirstOrDefault(File.Exists);
362+
// Reload only after the plugin returns so the note list and search index include completed outputs.
363+
await ViewModel.LoadMarkdownFolderAsync(
364+
ViewModel.CurrentFolderPath,
365+
convertedPath);
322366
}
323367

324368
private void MainWindow_OnDragOver(object? sender, DragEventArgs e)
@@ -330,7 +374,8 @@ private void MainWindow_OnDragOver(object? sender, DragEventArgs e)
330374
".pdf",
331375
StringComparison.OrdinalIgnoreCase)) == true;
332376
e.DragEffects = hasPdf
333-
&& ViewModel.CanImportPdfIntoNote(ViewModel.SelectedNote)
377+
&& (ViewModel.CanImportPdfIntoNote(ViewModel.SelectedNote)
378+
|| ViewModel.CanImportPdfDocuments)
334379
? DragDropEffects.Copy
335380
: DragDropEffects.None;
336381
}
@@ -347,20 +392,24 @@ private async void MainWindow_OnDrop(object? sender, DragEventArgs e)
347392
.Cast<string>()
348393
.Distinct(StringComparer.OrdinalIgnoreCase)
349394
.ToArray() ?? [];
350-
if (note is null
351-
|| paths.Length == 0
352-
|| !ViewModel.CanImportPdfIntoNote(note))
395+
if (paths.Length == 0)
353396
{
354-
ViewModel.StatusText =
355-
"Drop PDF files while a Markdown note is selected";
397+
ViewModel.StatusText = "Drop one or more PDF files into NoteManager";
356398
return;
357399
}
358400

359401
e.Handled = true;
360-
await ViewModel.ImportPdfFilesAsync(
361-
note,
362-
paths,
363-
MarkdownEditor.CaretIndex);
402+
if (ViewModel.CanImportPdfIntoNote(note))
403+
{
404+
await ViewModel.ImportPdfFilesAsync(
405+
note!,
406+
paths,
407+
MarkdownEditor.CaretIndex);
408+
return;
409+
}
410+
411+
// With no selected Markdown editor, a window drop imports documents for conversion.
412+
await ImportPdfDocumentsAsync(paths);
364413
}
365414

366415
private async void Tags_OnClick(object? sender, RoutedEventArgs e)

‎src/NoteManager.Desktop/Services/PluginManager.cs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,32 @@ public async Task StopAllAsync(CancellationToken cancellationToken = default)
212212
}
213213
}
214214

215+
public async Task<bool> TriggerDocumentConversionAsync(
216+
CancellationToken cancellationToken = default)
217+
{
218+
await _lifecycleLock.WaitAsync(cancellationToken);
219+
try
220+
{
221+
var converter = Plugins
222+
.Where(plugin => plugin.IsRunning)
223+
.Select(plugin => plugin.DiscoveredPlugin.Instance)
224+
.OfType<IDocumentConversionTrigger>()
225+
.FirstOrDefault();
226+
if (converter is null)
227+
{
228+
return false;
229+
}
230+
231+
// Reuse the active plugin so imports retain the configured converter and its status reporting.
232+
await converter.ConvertPendingDocumentsAsync(cancellationToken);
233+
return true;
234+
}
235+
finally
236+
{
237+
_lifecycleLock.Release();
238+
}
239+
}
240+
215241
private async Task StartEntryAsync(
216242
PluginListItemViewModel entry,
217243
CancellationToken cancellationToken)

0 commit comments

Comments
 (0)