Commit:
bd972f2Parent:
7c9ab9aPre-populate new notes with the previous note's frontmatter
Mirrors the companion CLI's New-Note cmdlet: Markdig's UseYamlFrontMatter locates the --- delimited YAML block and YamlDotNet (de)serializes it as a loose Dictionary<string, object>, so arbitrary frontmatter schemas round-trip without a fixed model. Tapping the new-note FAB now fetches the most recent note's content (usually already warm from the prefetch cache), extracts its frontmatter, and opens the editor with a fresh block pre-filled - carried-over fields plus today's date stamped in, cursor placed past it so the body can be typed immediately. NotePreviewBuilder's ad hoc line-splitting frontmatter stripper is replaced with the same Markdig -based detection for consistency. YamlDotNet's dictionary-based (de)serialization leans on unannotated reflection, which TrimMode=full isn't safe with - exempted just that assembly from trimming (TrimmerRootAssembly RootMode="all") rather than risk a runtime failure only a device could catch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Directory.Packages.props
+2
-0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 074317f..7f36e2d 100644
@@ -10,6 +10,7 @@
</GlobalPackageReference>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="Microsoft.Identity.Client" Version="4.87.0" />
<PackageVersion Include="Microsoft.Graph" Version="6.2.0" />
<PackageVersion Include="Microsoft.Graph.Core" Version="4.0.1" />
@@ -20,5 +21,6 @@
<PackageVersion Include="Xamarin.AndroidX.RecyclerView" Version="1.4.0.6" />
<PackageVersion Include="Xamarin.Google.Android.Material" Version="1.14.0.6" />
<PackageVersion Include="Xamarin.AndroidX.AppCompat" Version="1.7.1.4" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>
</Project>
\ No newline at end of file
src/Activities/NoteListActivity.cs
+30
-2
diff --git a/src/Activities/NoteListActivity.cs b/src/Activities/NoteListActivity.cs
index f1362e0..e4eb5ee 100644
@@ -44,7 +44,7 @@ public class NoteListActivity : AppCompatActivity
_progressBar = FindViewById<LinearProgressIndicator>(Resource.Id.note_refresh_progress)!;
FindViewById<FloatingActionButton>(Resource.Id.new_note_fab)!.Click += (_, _) =>
OnNewNoteClicked();
_ = OnNewNoteClickedAsync();
new ItemTouchHelper(new SwipeToDeleteCallback(OnNoteSwiped, this)).AttachToRecyclerView(
_recyclerView
@@ -173,14 +173,42 @@ public class NoteListActivity : AppCompatActivity
_recyclerView.ScrollToPosition(index);
}
private void OnNewNoteClicked()
private async Task OnNewNoteClickedAsync()
{
var fileName = DateTime.Now.ToString("yyyy-MM-ddTHHmmss") + ".md";
var initialContent = await BuildInitialContentAsync();
var intent = new Intent(this, typeof(NoteViewActivity));
intent.PutExtra(NoteViewActivity.ExtraNoteName, fileName);
intent.PutExtra(NoteViewActivity.ExtraInitialContent, initialContent);
StartActivityForResult(intent, EditNoteRequestCode);
}
// Mirrors the companion CLI's New-Note: carries over the previous note's frontmatter
// fields (if any) into a fresh block stamped with today's date, so recurring fields don't
// need retyping. Falls back to a bare dated block if there's no previous note, or its
// content can't be fetched - that fetch failing shouldn't block creating a new note.
private async Task<string> BuildInitialContentAsync()
{
var mostRecent = _adapter.ItemCount > 0 ? _adapter.GetItem(_adapter.ItemCount - 1) : null;
var fields = new Dictionary<string, object>();
if (mostRecent != null)
{
try
{
var content = await NoteContentCache.GetOrStart(
mostRecent.Id,
() => _graphService.GetContentAsync(mostRecent.Id)
);
fields = NoteFrontmatter.Extract(content) ?? fields;
}
catch { }
}
return NoteFrontmatter.BuildBlock(fields);
}
private void OnNoteTapped(NoteCacheEntry entry)
{
var intent = new Intent(this, typeof(NoteViewActivity));
src/Activities/NoteViewActivity.cs
+22
-9
diff --git a/src/Activities/NoteViewActivity.cs b/src/Activities/NoteViewActivity.cs
index ebc9658..5ed98c1 100644
@@ -21,6 +21,7 @@ public class NoteViewActivity : AppCompatActivity
public const string ExtraNoteETag = "note_etag";
public const string ExtraNoteLastModifiedUtc = "note_last_modified_utc";
public const string ExtraNoteSize = "note_size";
public const string ExtraInitialContent = "initial_content";
private EditText _contentView = null!;
private LinearProgressIndicator _progressBar = null!;
@@ -53,18 +54,30 @@ public class NoteViewActivity : AppCompatActivity
_noteName = Intent?.GetStringExtra(ExtraNoteName) ?? "";
toolbar.Title = _noteName;
// For an existing note, the tapped-from-list entry's fields are the starting point;
// they're only overwritten once we actually load or save fresher data below.
var incoming = NoteResultIntent.TryGet(Intent);
_currentPreview = incoming?.Preview ?? "";
_noteETag = incoming?.ETag ?? "";
_lastModifiedUtc = incoming?.LastModifiedUtc;
_size = incoming?.Size;
_contentView = FindViewById<EditText>(Resource.Id.note_view_content)!;
_progressBar = FindViewById<LinearProgressIndicator>(Resource.Id.note_view_progress)!;
SetContentTextSuppressed(_currentPreview);
if (_noteId != null)
{
// For an existing note, the tapped-from-list entry's fields are the starting point;
// they're only overwritten once we actually load or save fresher data below.
var incoming = NoteResultIntent.TryGet(Intent);
_currentPreview = incoming?.Preview ?? "";
_noteETag = incoming?.ETag ?? "";
_lastModifiedUtc = incoming?.LastModifiedUtc;
_size = incoming?.Size;
SetContentTextSuppressed(_currentPreview);
}
else
{
// A brand new note starts pre-populated with a frontmatter block carried over from
// the previous note (see NoteListActivity.BuildInitialContentAsync) - the cursor
// lands past it so the user can start typing the body immediately.
var initialContent = Intent?.GetStringExtra(ExtraInitialContent) ?? "";
SetContentTextSuppressed(initialContent);
_contentView.SetSelection(initialContent.Length);
}
_contentView.TextChanged += (_, _) =>
{
if (!_suppressDirtyTracking)
src/NotesApp.csproj
+13
-0
diff --git a/src/NotesApp.csproj b/src/NotesApp.csproj
index c8df5c6..410279b 100644
@@ -9,8 +9,13 @@
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<TrimMode>full</TrimMode>
<!-- IL2104 fires because YamlDotNet's own IL has unannotated reflection patterns, but
RootMode="all" below already keeps every member of that assembly - nothing it
reflects over gets removed, so the warning has no runtime consequence here. -->
<NoWarn>$(NoWarn);IL2104</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Markdig" />
<PackageReference Include="Microsoft.Identity.Client" />
<PackageReference Include="Microsoft.Graph" />
<PackageReference Include="Microsoft.Graph.Core" />
@@ -19,5 +24,13 @@
<PackageReference Include="Xamarin.AndroidX.RecyclerView" />
<PackageReference Include="Xamarin.Google.Android.Material" />
<PackageReference Include="Xamarin.AndroidX.AppCompat" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<!-- YamlDotNet's Dictionary<string, object> (de)serialization relies on runtime reflection
over arbitrary types (property/method discovery, scalar resolution) with no
trim-safety annotations, so TrimMode=full can strip members it needs at runtime.
Exempt just this assembly rather than risk a hard-to-reproduce runtime failure. -->
<TrimmerRootAssembly Include="YamlDotNet" RootMode="all" />
</ItemGroup>
</Project>
src/Storage/NoteFrontmatter.cs
+58
-0
diff --git a/src/Storage/NoteFrontmatter.cs b/src/Storage/NoteFrontmatter.cs
new file mode 100644
index 0000000..5fe2120
@@ -0,0 +1,58 @@
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.Converters;
namespace NotesApp.Storage;
// Mirrors the frontmatter handling in the companion CLI's New-Note cmdlet: Markdig's
// UseYamlFrontMatter extension locates the --- delimited YAML block, and YamlDotNet
// (de)serializes it as a loose Dictionary<string, object> so arbitrary frontmatter schemas
// round-trip without a fixed model.
public static class NoteFrontmatter
{
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
private static readonly IDeserializer Deserializer = new DeserializerBuilder().Build();
private static readonly ISerializer Serializer = new SerializerBuilder()
.WithTypeConverter(new DateOnlyConverter(formats: "yyyy-MM-dd"))
.Build();
// The note's body with any leading YAML frontmatter block (delimiters included) removed,
// or the content unchanged if it has none.
public static string StripBlock(string content)
{
var block = FindBlock(content);
if (block is null)
return content;
var start = Math.Min(block.Span.End + 1, content.Length);
return content[start..].TrimStart('\r', '\n');
}
public static Dictionary<string, object>? Extract(string content)
{
var block = FindBlock(content);
if (block is null)
return null;
return Deserializer.Deserialize<Dictionary<string, object>>(block.Lines.ToString());
}
// Builds a fresh frontmatter block (fences included) from a field dictionary, stamping
// today's date the same way the CLI's New-Note does - even an otherwise-empty dictionary
// still gets a dated block.
public static string BuildBlock(Dictionary<string, object> fields)
{
fields["date"] = DateOnly.FromDateTime(DateTime.Now);
var yaml = Serializer.Serialize(fields).TrimEnd();
return $"---\n{yaml}\n---\n\n\n";
}
private static YamlFrontMatterBlock? FindBlock(string content) =>
Markdown.Parse(content, Pipeline).Descendants<YamlFrontMatterBlock>().FirstOrDefault();
}
src/Storage/NotePreviewBuilder.cs
+1
-24
diff --git a/src/Storage/NotePreviewBuilder.cs b/src/Storage/NotePreviewBuilder.cs
index 8c085a2..c1c6d61 100644
@@ -10,7 +10,7 @@ public static class NotePreviewBuilder
public static string Build(string content)
{
var withoutFrontmatter = StripFrontmatter(content);
var withoutFrontmatter = NoteFrontmatter.StripBlock(content);
var trimmed =
withoutFrontmatter.Length > MaxLength
@@ -19,27 +19,4 @@ public static class NotePreviewBuilder
var lastNewline = trimmed.LastIndexOf('\n');
return lastNewline > 0 ? trimmed[..lastNewline] : trimmed;
}
private static string StripFrontmatter(string content)
{
var lines = content.Split(
["\n", "\r\n"],
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries
);
if (lines is [])
return content;
var firstLine = lines[0];
if (firstLine.Any(c => c != '-'))
return content;
for (var i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.Length == firstLine.Length && line.All(c => c == '-'))
return string.Join('\n', lines[(i + 1)..]);
}
return content;
}
}
src/packages.lock.json
+12
-0
diff --git a/src/packages.lock.json b/src/packages.lock.json
index fb64160..754dfe5 100644
@@ -8,6 +8,12 @@
"resolved": "1.3.0",
"contentHash": "jzzLpmz7F6LyBh5FTZy8/4dEYVcAG9VjBPKryZ82LIoUuupcBkCSXzaeS/4oYcp/rBC8yVLcDstJ8CIv9n2IFw=="
},
"Markdig": {
"type": "Direct",
"requested": "[1.3.2, )",
"resolved": "1.3.2",
"contentHash": "fZgOC/3CswUrndjDTac70aQpYdtxbW5+5bRumR7vzvI2HJbkmgKisB1c9oT+GA6v0jB/JDR9BLa9FiPzQmaK6A=="
},
"Microsoft.Graph": {
"type": "Direct",
"requested": "[6.2.0, )",
@@ -156,6 +162,12 @@
"Xamarin.Google.ErrorProne.Annotations": "2.50.0.1"
}
},
"YamlDotNet": {
"type": "Direct",
"requested": "[18.1.0, )",
"resolved": "18.1.0",
"contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw=="
},
"Azure.Core": {
"type": "Transitive",
"resolved": "1.50.0",