cmiles - info

Life, Tech and Unimportant Minutiae

Created by Charles on 6/20/2024. Updated on 6/21/2024.

In the late 1990s I spent a few days in Paris - these days its unimaginable that I wouldn't take a few photos, post something, message friends/family and maybe even send an email from such an interesting destination - but back then, without a cell phone, mobile data or even a laptop I managed to spend a week in Europe with no evidence of it other than some very (very!) hazy memories...


2024 June Pointless Waymarks Cloud Backup - Main Window
2024 June Pointless Waymarks Cloud Backup - Main Window.

In mid-2023 I had renewed interest in creating good off-site backups for some of my personal data. After years of using a few different backup products at home and at work I had a specific set of requirements:

  • Storage in an S3 Compatible Service: I want to be able to get to my backups from a public computer, to have many options for how I can access my backups and not have any issues if the program that made the backup is not available.
  • 'Flat' Backup: To make finding and accessing files in the backup as simple as possible I don't want versioned files/backups - just a simple mirror of my data. (For me only doing versioned/'Time Machine' style backups locally is a good compromise.)
  • Data Files Only: No machine/system backup needed, my desktop setup is easy enough to recreate and not something that needs to be backed-up - I'm only interested in backing up data.
  • No Integrated Synchronization or Restore Features: Many years ago I lost some files by putting in the wrong synchronization settings for a backup. That mistake has stuck with me and I would rather just eliminate the possibility of that error by not even having those options!
  • Hash Based File Change Detection: Skip date/size comparisons and always compare file hashes to determine if a file has changed.
  • Easy to Schedule/Time Limited Runs: Our Starlink setup has been fantastic but it has limited upload capacity - I want to be able to schedule backups to start late at night and set them to only run for a few hours.

When I looked for backup software with those requirements in mind I didn't find exactly what I wanted and didn't find anything that I was excited about. In many cases - and especially at work - I would have picked the best all-things-considered compromise and moved on, but I have some data that is valuable enough to me that I don't want to compromise on how it is taken care of. It is fair to say that to some extent everything in my Pointless Waymarks Project is about taking care of the data I care deeply about...

2024 June Pointless Waymarks Cloud Backup - Job Editor Window
2024 June Pointless Waymarks Cloud Backup - Job Editor Window.

So while there is always some absurdity in choosing to re-invent the wheel I decided to write my own backup program! Why? Because with a very specific set of requirements and no need to 'productize' the program the scope is reasonable for the time I have to invest and maybe more importantly I continue to find it incredibly fun, educational and satisfying to write and use my own software. After using, living with and working on this project for about a year here are some of the things that I did in addition to the requirements above:

  • Console App + WPF GUI: Console App 'Runner' for easy scheduling and a GUI to make setting up and tracking the backups fast and easy.
  • SQLite: Both to save the Backup Jobs and to cache information about S3 and local files. Initially I had hoped to largely avoid caching information about files - but listing S3 objects can be both slow and costly (depending on S3 provider) so over time more caching made sense.
  • Multiple S3 Provider Options: I started with only AWS S3 - I have buckets on S3 that are over a decade old and it is a great service. But AWS doesn't hide the fact that they charge you for every action - over time my bill has grown and more alternatives have come online - currently I've added support for Cloudflare R2 and Wasabi.
2024 June Pointless Waymarks Cloud Backup - Batch List Window
2024 June Pointless Waymarks Cloud Backup - Batch List Window.

Surprises and interesting details:

  • Listing 100k objects in an S3 Bucket and Retrieving Metadata takes longer than I expected... Between dealing with smaller buckets, writing programs that weren't performance sensitive and a general expectations of reasonable speed these days it was a surprise how long it can take to list and retrieve metadata for hundreds of thousands of objects. With more infrastructure there are probably 'better ways' - but straight thru the API I never found a way to make listing 100k+ objects impressively fast and eventually ended up caching S3 file information in Sqlite to help with this.
  • API Compatibility Might Not Be As Expected... I think it's fair to say that Cloudflare and Wasabi's marketing makes you believe that you are going to seamlessly use Amazon's S3 API. I actually thought this would be the case since my use of the API (imho) is fairly simple, but for me it didn't work out. I didn't run into major problems but things like Why doesn't CopyObject for CloudFlare R2 not work with the AWS SDK for .NET? and Wasabi erroring with paginators both cost me time...
  • TinyIpc: .NET inter process broadcast message bus - Working with an inter-process communications library for any desktop apps now seems like a very smart default choice. Having components broadcast and respond to messages from any source on your local computer is a great feature -> same process, different instance of the application, messages from related console programs, other application all together, ... - extending your messaging to all those sources/scenarios with a library like TinyIpc can be a great lift to user experience without much, if any, additional cost in time, complexity or infrastructure.

Overall the Pointless Waymarks Cloud Backup is a fairly simple project and this write up reflects that - my goal wasn't to architect a novel new backup paradigm, just to reliably backup files I care about to S3. The code is MIT Licensed and available under the Pointless Waymarks Project. I don't plan on doing any pre-built installers or public releases - it is a constant work in process and probably only appropriate to use if you don't mind working on code and occasionally debugging issues - but it is open and available to share with friends and fellow devs in case it is useful and there are scripts to build auto-updating installers if you do want to run the software.

2024 June Pointless Waymarks Cloud Backup - Command Line Runner Progress
2024 June Pointless Waymarks Cloud Backup - Command Line Runner Progress.
2024 June Pointless Waymarks Cloud Backup - Progress Window
2024 June Pointless Waymarks Cloud Backup - Progress Window.
2024 June Pointless Waymarks Cloud Backup - Completion Notification
2024 June Pointless Waymarks Cloud Backup - Completion Notification.

Some days I appreciate that my first few decades of life have huge chunks that are only hazy memories - no photographs, no social media, not even text or email messages - but other days I wish I had some photographs from Paris, so it was probably partly nostalgia that drove me to pick Paris for my offsite photograph backups. If I can't have photographs from Paris I might as well have photographs in Paris!!!


Created by Charles on 10/29/2023. Updated on 10/31/2023.

2023 October 28 22 11 40 Slice Septic North
Moonlit Night - North. 10/28/2023.

For a number of years my wife and I used a now archived project and a Raspberry Pi 4 Model B to take photographs of and sensor readings from an area inside our house. The project worked nicely and was a good learning experience.

Once the project was defunct I found myself enjoying commercial sensor products more than using sensors on a Pi (SensorPush and Tempest Weather System for example), but the intersection of photography and programming the Pi offered remained very intriguing...

2023 October Largely Recycled Pi Camera Enclosure
Largely Recycled Pi Camera Enclosure. 10/16/2023.

This year we built a 12V solar system at our home that has enough spare power to easily run a few Pis - and it happens to be in a spot with a decent view - so I got to work purchasing Pis with cameras, building weatherproof enclosures and writing software for a new photography project!

For Pis I went with:

2023 October Camera Mounted to the Front of the Pi Enclosure
Camera Mounted to the Front of the Pi Enclosure. 10/12/2023.

The Pis need to be outside for this project and so a weatherproof enclosure was a necessity. For the enclosures I used wood, glue, screws, nails, paint and screen that we already had around the house. My carpentry and painting was very basic - not really worth comment - but the process of getting the camera lens sealed was interesting and worth sharing:

  • The first enclosure I built used plexiglass for the entire front panel of the enclosure - but at least with the plexiglass I had the images were never sharp. I was using plexiglass left over from another (not camera oriented) project and I didn't want to dive into figuring out 'best optical quality plexiglass' (and didn't want glass for durability reasons) so I abandoned this approach.
  • To move to another strategy I mounted the camera on a solid front panel with a hole for the lens - on the first try the silver part of the camera module was tight to the front panel - but with the tight mounting I was back to out-of-focus photographs... In the end I added a spacer to mount the camera just off the front panel.
  • I tried a plexiglass dome off of Amazon to cover the exit hole for the camera - this was great for part of the photograph but distorted the edges. Clearly a dome over a camera can work but at least with this dome/setup it was going to require mounting the camera farther into the dome, not a complication I was interested in.
  • The solution that finally worked for me was using a UV lens filter and hot gluing it to the outside of the enclosure. I used $8 Tiffen 55mm UV Protector Filters - it is easy to find smaller diameter filters but after some experiments I found this size easy to position so that it isn't visible in the corners/edges of the photo.

The total cost of a Pi and enclosure was around $100 - you can easily find security cameras or game cameras at this price point which likely have some features that would be time consuming to replicate with a Pi, but for some projects the flexibility of having your camera connected to an open general purpose computer is hard to beat!

2023 October Bottom of the Pi Enclosure
Bottom of the Pi Enclosure. 10/12/2023.
2023 October Three Pi Enclosures Facing Roughly East, North and West
Three Pi Enclosures Facing Roughly East, North and West. 10/12/2023.

Taking photographs with a Pi and the official camera modules is easy to script and there are some interesting pieces of software available like allsky - but for this project I decided to write my own software in C# to allow the scheduling that I wanted.

The software is available on GitHub - cmiles/PiSlicedDayPhotos - features include:

  • The program gets Sunrise/Sunset times from a file you place next to the program. This allows you to provide whatever sunrise/sunset times you want - in our case we generated the file with gvellut's tppss which allowed us to get sunrise/sunset times taking into account the local topography (we have a mountain peak to the east and care about when the sun comes over the ridge - not about the sunrise time on the imagined true horizon we can't see...).
  • Photos are taken at Sunrise, Sunset and at times defined in the configuration file including:
    • A number of photographs evenly distributed between Sunrise and Sunset (during the day - 0 is valid)
    • A number of photographs evenly distributed between Sunset and Sunrise (during the night - 0 is valid)
    • Times relative to sunrise and sunset - for example Sunrise+10 for a photograph 10 minutes after Sunrise
    • Clock Times
  • Error messages are delivered as image files into the same directory as the photographs - I rarely ssh into or monitor the Pis and only look at the photographs after a periodic task transfers them into a central directory - so this provides 'good enough' alerting without needing the Pis to have email/text/alert api access.
2023 October 20 14 12 40 Slice Septic West
Daylight Document - West. 10/20/2023.
2023 October 20 14 12 39 Slice Septic North
Daylight Document - North. 10/20/2023.
2023 October 20 14 12 40 Slice Septic East
Daylight Document - West. 10/20/2023.

The wide angle camera and three overlapping photographs give an interesting-to-me picture of the landscape surrounding us - sometimes during the day the photographs are pure documentation, hard to love full-sun-clear-sky-views, sometimes they capture interesting weather or nice light and even over the short time we have had them running you can see the sun move and the moon change.

But the individual photographs are really not the point of this project - if I want a great picture I have frequent access to this location, own higher quality cameras and gear and have enough skill to take and process better photographs than I can create with the Pi Camera Module.

The point of this project is to try to run these cameras for years of time - hopefully capturing interesting details like the landscape in wet years/dry years/hot years/cool years, watching the lights and the landscape change as areas are developed/preserved/destroyed/re-created and eventually allowing us to compare details from 1, 2, 3, 5 or 10+ years ago. Of course many long term projects never make it to old age - but fun to try regardless!!!

2023 October 24 10 48 40 Slice Septic West
Cloudy Day - West. 10/24/2023.
2023 October 24 10 48 40 Slice Septic North
Cloudy Day - North. 10/24/2023.
2023 October 24 10 48 40 Slice Septic East
Cloudy Day - East. 10/24/2023.
2023 October 16 17 54 00 Slice Septic West
Just after Sunset - West. 10/16/2023.
2023 October 16 17 54 00 Slice Septic North
Just after Sunset - North. 10/16/2023.
2023 October 16 17 54 00 Slice Septic East
Just after Sunset - East. 10/16/2023.
2023 October 16 22 14 20 Slice Septic North
Dark Night - North. 10/16/2023.

Created by Charles on 6/6/2023. Updated on 6/18/2023.

2023 May Caught
Caught. Charles Miles. 5/22/2023.

INotifyPropertyChanged is central to the WPF binding system and as far as I know the interface hasn't changed since WPF's beginnings in .NET Framework 3.0 (2006 or so). It is rather beautiful - spare, simple, easy to implement and it creates quite a bit of value by facilitating WPF bindings.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.ComponentModel
{
    public interface INotifyPropertyChanged
    {
        event PropertyChangedEventHandler? PropertyChanged;
    }
}

For many years I've written code like this:

public class MainViewModel
{
    public string? UserInput { get; set; }
}

And then used Resharper to generate the INPC implementation and expand the properties to use it.

public class MainViewModel : INotifyPropertyChanged
{
    private string? _userInput;

    public string? UserInput
    {
        get => _userInput;
        set
        {
            if (value == _userInput) return;
            _userInput = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

}

The boiler plate code for INPC looks out of place against 'modern' C#/.NET with its minimal APIs and top level statements... Over a decade of real world experience suggests the cost of this verbose code is fairly low, but hundreds/thousands/+ lines of code, even stable and simple code, obviously has some cost. For many years I didn't find an alternative to the classic INPC boiler plate/generation where the various trade offs seemed worthwhile - but recently I've been using the source generator approach from The Windows Community Toolkit successfully. The example above reduces down to the code below:

public partial class MainViewModel : ObservableObject
{
    [ObservableProperty] private string? _userInput;
}

ObservableObject (which can either be inherited or added via attribute/source generation) implements INPC and _userInput is transformed into a public property suitable for use with WPF bindings! The Windows Community Toolkit seems well supported and the project continues to evolve - I suspect using The Windows Community toolkit is a good choice for use in long lived applications.

As far as I can tell The Windows Community Toolkit's focus is creating great tools/approaches like [ObservableProperty] - not on helping you build functionality like [ObservableProperty] yourself. This distinction recently became relevant to me because The Windows Community Toolkit offers another great feature for WPF/MVVM/ICommand based approaches with the RelayCommand attribute. Similar to the [ObservableProperty] [RelayCommand] can help reduce boiler plate by generating the code to create a RelayCommand property automatically!

But the [RelayCommand] attribute doesn't work for me in the Pointless Waymarks Project - I have a custom setup for Commands that is central to the project. For several months I have occasionally tried to think of a way to modify my approach to work with [RelayCommand] - the reduction of boiler plate using the [ObservableProperty] creates is addictive... But I've failed to come up with any elegant ideas.

Enter Metalama: A Framework for Clean & Concise Code in C#. For me Metalama is interesting because it provides a toolkit to implement functionality like [ObservableProperty] or [RelayCommand]. Thru examples, documentation and a bit of hacking I was able to build INPC and Command aspects in about a day so that I can write code like this:

[NotifyPropertyChanged]
[GenerateStatusCommands]
public class MainViewModel
{
    public string? UserInput { get; set; }

    public MainViewModel()
    {
        BuildCommands();
    }

    [BlockingCommand]
    public Task ModifyUserInput()
    {
        UserInput += "Modified";
        return Task.CompletedTask;
    }
}

The code below is NOT, ABSOLUTELY NOT, meant as 'good Metalama examples' (or even as 'good code examples' in general!) - but I found it quite encouraging that I was able to create useful code so quickly using Metalama. Some of the code below is specific to the Pointless Waymarks Project but the look and feel of the code might be interesting if you are considering trying Metalama, this was created using Visual Studio and the experience was like editing any other code:

public class NotifyPropertyChangedAttribute : TypeAspect
{
    public override void BuildAspect(IAspectBuilder<INamedType> builder)
    {
        builder.Advice.ImplementInterface(builder.Target, typeof(INotifyPropertyChanged), OverrideStrategy.Ignore);

        foreach (var property in builder.Target.Properties.Where(p =>
                     p is { IsAbstract: false, Writeability: Writeability.All } && !p.Attributes.Any(typeof(DoNotGenerateInpc))))
            builder.Advice.OverrideAccessors(property, null, nameof(OverridePropertySetter));
    }

    [Introduce(WhenExists = OverrideStrategy.Ignore)]
    protected void OnPropertyChanged(string name)
    {
        PropertyChanged?.Invoke(meta.This, new PropertyChangedEventArgs(name));
    }

    [Template]
    private dynamic OverridePropertySetter(dynamic value)
    {
        SetField(ref meta.Target.FieldOrProperty.Value, value);

        return value;
    }

    [InterfaceMember] public event PropertyChangedEventHandler? PropertyChanged;

    [Introduce(WhenExists = OverrideStrategy.Ignore)]
    protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
    {
        if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentNullException(nameof(propertyName));

        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}

public class DoNotGenerateInpc : Attribute
{
}

public class GenerateStatusCommandsAttribute : TypeAspect
{
    public override void BuildAspect(IAspectBuilder<INamedType> builder)
    {
        foreach (var method in builder.Target.Methods.Where(p =>
                     (p.Attributes.Any(typeof(BlockingCommandAttribute)) ||
                      p.Attributes.Any(typeof(NonBlockingCommandAttribute))) && p.Parameters.Count == 0))
            builder.Advice.IntroduceAutomaticProperty(method.DeclaringType, $"{method.Name}Command",
                TypeFactory.GetType(typeof(RelayCommand)).ToNullableType(), IntroductionScope.Default,
                OverrideStrategy.Ignore,
                propertyBuilder => propertyBuilder.Accessibility = Accessibility.Public);

        foreach (var method in builder.Target.Methods.Where(p =>
                     (p.Attributes.Any(typeof(BlockingCommandAttribute)) ||
                      p.Attributes.Any(typeof(NonBlockingCommandAttribute))) && p.Parameters.Count == 1 &&
                     p.Parameters[0].Type.ToType() != typeof(CancellationToken)))
        {
            var firstParameterType = method.Parameters[0].Type;

            builder.Advice.IntroduceAutomaticProperty(method.DeclaringType, $"{method.Name}Command",
                ((INamedType)TypeFactory.GetType(typeof(RelayCommand<>)))
                   .WithTypeArguments(firstParameterType).ToNullableType(),
                IntroductionScope.Default,
                OverrideStrategy.Ignore,
                propertyBuilder =>
                {
                    propertyBuilder.Accessibility = Accessibility.Public;
                    propertyBuilder.InitializerExpression = null;
                });
        }

        foreach (var method in builder.Target.Methods.Where(p =>
                     (p.Attributes.Any(typeof(BlockingCommandAttribute)) ||
                      p.Attributes.Any(typeof(NonBlockingCommandAttribute))) && p.Parameters.Count == 1 &&
                     p.Parameters[0].Type.ToType() == typeof(CancellationToken)))
            builder.Advice.IntroduceAutomaticProperty(method.DeclaringType, $"{method.Name}Command",
                TypeFactory.GetType(typeof(RelayCommand)).ToNullableType(), IntroductionScope.Default, OverrideStrategy.Ignore,
                propertyBuilder => propertyBuilder.Accessibility = Accessibility.Public);

        builder.Advice.IntroduceMethod(builder.Target, "BuildCommands");
    }

    [Template]
    public void BuildCommands()
    {
        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(BlockingCommandAttribute)) && p.Parameters.Count == 0))
            meta.InsertStatement(
                $"{loopMethods.Name}Command = StatusContext.RunBlockingTaskCommand({loopMethods.Name});");

        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(NonBlockingCommandAttribute)) && p.Parameters.Count == 0))
            meta.InsertStatement(
                $"{loopMethods.Name}Command = StatusContext.RunNonBlockingTaskCommand({loopMethods.Name});");

        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(BlockingCommandAttribute)) && p.Parameters.Count == 1))
            if (loopMethods.Parameters[0].Type != TypeFactory.GetType(typeof(CancellationToken)))
                meta.InsertStatement(
                    $"{loopMethods.Name}Command = StatusContext.RunBlockingTaskCommand<{loopMethods.Parameters[0].Type}>({loopMethods.Name});");
            else
                meta.InsertStatement(
                    $"{loopMethods.Name}Command = StatusContext.RunBlockingTaskWithCancellationCommand({loopMethods.Name}, \"Cancel {SplitCamelCase(loopMethods.Name)}\");");

        foreach (var loopMethods in meta.Target.Type.Methods.Where(p =>
                     p.Attributes.Any(typeof(NonBlockingCommandAttribute)) && p.Parameters.Count == 1 &&
                     p.Parameters[0].Type != TypeFactory.GetType(typeof(CancellationToken))))
            if (loopMethods.Parameters[0].Type != TypeFactory.GetType(typeof(CancellationToken)))
                meta.InsertStatement(
                    $"{loopMethods.Name}Command = StatusContext.RunNonBlockingTaskCommand<{loopMethods.Parameters[0].Type}>({loopMethods.Name});");
    }

    private string SplitCamelCase(string str)
    {
        //https://stackoverflow.com/questions/5796383/insert-spaces-between-words-on-a-camel-cased-token
        return Regex.Replace(Regex.Replace(str, @"(\P{Ll})(\P{Ll}\p{Ll})", "$1 $2"), @"(\p{Ll})(\P{Ll})", "$1 $2");
    }
}

public class BlockingCommandAttribute : Attribute
{
}

public class NonBlockingCommandAttribute : Attribute
{
}

Moving to Metalama is a tradeoff - The Windows Community Toolkit source generators offer more functionality than I will ever implement myself in Metalama and there is a cost if you want all of the Metalama features - but so far Metalama has offered good functionality without a major investment and I'm intrigued by the idea that code generation/aspects could be an integrated, easy to create, everyday part of coding a C# project.

2023 June Bird on a Wire
Purple Martin. Charles Miles. 6/4/2023.

PS - I had enough code to convert that I quickly got tired of editing everything 'manually' - but not enough (and enough variety) that a full scripted conversion didn't seem worthwhile. My in-between solution was helper LINQPad scripts that when run pull text off the clipboard, do some simple-but-helpful text conversion work and then put it back on the clipboard. This did speed me up - maybe it could help you with something so left here because ... why not!

//LinqPad snippet: Takes the text on the clipboard, does text replacement to convert
//[ObservableProperty] declarations to Public Property declarations and puts the 
//converted text on the clipboard:
//  [ObservableProperty] private int _userInitialValue = 1;
//becomes:
//  public int UserInitialValue { get; set; } = 1;
//
//Written to help convert blocks of MVVM Toolkit [ObservableProperty] declarations into
//public property declarations.

var source = Clipboard.GetText();

var lines = source.Split(";");

var results = new List<string>();

foreach (var loopLine in lines)
{
 if (string.IsNullOrWhiteSpace(loopLine) || loopLine.Contains("RelayCommand")) continue;
 
 var modifiedLine = string.Empty;
 modifiedLine = loopLine.Replace("[ObservableProperty] private ", "public ");
 if (modifiedLine.Contains("="))
 {
  modifiedLine = $"{modifiedLine.Replace("=", "{get; set;} =")};";
 }
 else
 {
  modifiedLine = $"{modifiedLine} {{ get; set;}}";
 }
 
 var underscorePosition = modifiedLine.IndexOf(" _");

 if (underscorePosition >= 0)
 {
  var underscoreAndLowercase = modifiedLine.Substring(underscorePosition + 1, 2);
  var underscoreAndUppercase = underscoreAndLowercase.Substring(1, 1).ToUpper();
  modifiedLine = modifiedLine.Replace(underscoreAndLowercase, underscoreAndUppercase);
 }
 
 results.Add(modifiedLine);
}

//Show the conversion in the output window and put it on the clipboard
String.Join(string.Empty, results).Dump();
Clipboard.SetText(String.Join(string.Empty, results));
//LinqPad Snippet: Takes the text on the clipboard, thru text replacement changes private
//field initialization to public property initializations and puts the coverted clip onto
//the clipboard for pasting - no code introspection, just text processing:
//  _userInitialValue = referenceObject.Something;
//to:
//  UserInitialValue = referenceObject.Something;
//
//Written as a little help for moving away from the MVVM Toolkits [ObservableProperty] 
//where you may end up with quite a bit of private field initialization - esp. in constructors 
//and in Nullable enabled scenarios - that you now want to be public property initialization.

var source = Clipboard.GetText();

var currentUnderscorePosition = source.IndexOf(" _");

while(currentUnderscorePosition >= 0){
 var underscoreAndLowercase = source.Substring(currentUnderscorePosition + 1, 2);
 var underscoreAndUppercase = underscoreAndLowercase.Substring(1, 1).ToUpper();
 source = source.Replace(underscoreAndLowercase, underscoreAndUppercase);
 currentUnderscorePosition = source.IndexOf(" _");
}

//Show the conversion in the output window and put it on the clipboard
source.Dump();
Clipboard.SetText(source);

Created and Updated by Charles on 3/22/2023.

2019 May Sunset over Green Mountain from Guthrie Mountain
Sunset over Green Mountain from Guthrie Mountain. Charles Miles. 5/9/2019.

Back in time, deep in the 2000s internet, you would have found my Flickr photographs consistently licensed with a Creative Commons license. I'm not sure what I would have said at the time about the license - but looking back I think it was mainly a signal, a way to be part of a community creating content online and looking with suspicion on older models of intellectual property.

Every now and then someone would use - or contact me about using a photo. But with only an occasional request, and no intention to make photography my job, licensing choices faded into the background - my licensing brain cells moved on to software licenses I guess...

2016 May Green Mountain and Guthrie Mountain from Barnum Rock
Green Mountain and Guthrie Mountain from Barnum Rock. Charles Miles. 5/18/2016.

Recently I have been thinking about photo licensing again:

Openverse Search Results for Guthrie Mountains - March 2023
Openverse Search Results for Guthrie Mountains - March 2023.

In the screenshot above you can see some of my photographs on Openverse - "a tool that allows openly licensed and public domain works to be discovered and used by everyone." I applaud the idea behind Openverse - educational material, historic places, important events... photographs of all sorts that can be searched and used liberally (especially in free projects) - hugely important. (Perhaps see and consider Wikimedia Commons for a variation of this idea with a hard to ignore impact).

Openverse searches across more than 300 million images from open APIs and the Common Crawl dataset. It goes beyond simple search to aggregate results across multiple public repositories into a single catalog, and facilitates reuse through features like machine-generated tags and one-click attribution.

It seems that a very important part of CC licenses has become signaling to machines that they can use content without any meaningful query to, conversation with or participation by its creator.

2012 December Guthrie Mountain
Guthrie Mountain. Charles Miles. 12/22/2012.

Openverse presents quite a lot of information of about a photograph - but no nuance. No creator profiles, no effective search for a specific creator, no stories, no context on what it would be appropriate for, nothing about why, wishes, hopes or dreams - no context... I don't doubt that this targets the needs of the average Openverse customer who wants to quickly get an image of ________ - but dehumanized mega-search isn't where my small records of place and time belong. My concerns about the usage of my photographs go well beyond what I can express with a CC license. The Creative Commons alludes to this general idea in their 2021-2025 Strategy Document:

Today, changed technological, social, cultural, political, legal and economic environments raise new challenges for the open movement. In order to protect what we have achieved so far and to create the world we want to see, we must expand our focus beyond copyright licensing, because content sharing cannot be decoupled from economic or ethical concerns. Indeed, the benefits of open sharing can be undermined by exploitative practices that threaten the financial sustainability of open endeavors, leading to economic hardship. Further, open sharing practices can also be marred by ethical concerns, such as the problematic use of open content to train potentially harmful artificial intelligence (Al) technologies or the use of open content in violation of non-copyright norms.

These days if you visit my photographs on Flickr you will see them listed as 'all rights reserved'. A relic from the old world I guess, but I don't think there is a license, metadata field or legal framework that would adequately define how I feel about these photographs or what I believe it would be appropriate to use them for. 'All rights reserved' seems to be the best stand in for 'let's talk like humans about it'... If you are interested in having a conversation about using my photographs contact me, regardless of my answer I bet you have an interesting story that I'd love to hear...

2012 December Snow on the Guthrie Mountain Trail
Snow on the Guthrie Mountain Trail. Charles Miles. 12/22/2012.

Created by Charles on 1/29/2023. Updated on 2/19/2023.

A little slice of life from 2022/2023 - my personal browsing setup frozen in time - maybe something of interest now but probably mostly something to look back on with amusement in the post-internet future when the AIs deliver all this straight into your brain...

2023 February - Kagi Search
A screen shot of Librewolf and Kagi Search.

$10 a month to use a search engine - when google search is free - and yet another subscription - seems like too much? But two months in and I'm hooked! I've tried to switch away from google search several times in the past few years and always switched back because the search results never seemed as good. So far with Kagi the results seem as good, or maybe even slightly better than google - and with NO ADS! It is a surprising relief not having the top third (?half) of every search result filled with paid spam... The Website Ranking Adjustment has also proved to be quite useful -> each search result has a small icon next to it where you can quickly apply a ranking adjustment for the site - this has allowed me to quickly eliminate a number of no-value-to-me sites and make it more likely that other sites appear at the top. For now I'm very happy giving Kagi my $10 a month.

LibreWolf

Years ago my browser choices were driven by compatibility and tooling - eventually Chrome was all I used. In recent years most of the browsers I have tried work without many issues and it has been fun trying a few different browsers for daily use. So far my favorite is LibreWolf:

This project is a custom and independent version of Firefox, with the primary goals of privacy, security and user freedom.

LibreWolf is designed to increase protection against tracking and fingerprinting techniques, while also including a few security improvements. This is achieved through our privacy and security oriented settings and patches. LibreWolf also aims to remove all the telemetry, data collection and annoyances, as well as disabling anti-freedom features like DRM.

2023 February - Kagi Search
A screen shot of Librewolf and Kagi Search.

So far the problems I've had have been covered by the LibreWolf FAQ, if you try LibreWolf I recommend having it open as you get started...

Some settings changes/exceptions that I made are listed below - these aren't 'recommended', just some perspective on what I found that balanced security, privacy and everyday use:

  • Enable WebGL - Disabled by default with the note "WebGL is a strong fingerprinting vector. If you need to enable it, consider using an extension like Canvas Blocker." I found a number of sites I use need WebGL so I enabled it and installed CanvasBlocker.
  • Add an Exception to HTTPS Only Mode for our BirdNET-Pi site since it is setup as local and http only.
  • Enable letterboxing - This anti-fingerprinting measure works by only allowing your screen to adjust to certain sizes, rather than a unique size that might help identify you. I thought the extra space/border around everything would be distracting, but I turned this on, it didn't bother me and now I always have it on.
  • Make exceptions as needed for Canvas Access - To the left of a site's URL in the address bar an icon will appear if canvas access has been silently blocked. On many sites LibreWolf's default behavior of silently blocking canvas requests works great - but on other sites it results in items like icons appearing as strange striped blocks...

The list of extensions below is largely similar to a list I would make for any browser - but just-in-case it is interesting, there are a few LibreWolf specific details:

KeePass Password Safe

There are quite a few password managers available and I've only tried a handful - but so far the one that is the best fit for what I want is KeePass. KeePass runs on your local machine and stores passwords in an encrypted database file. Services like Dropbox can sync your database file across devices, Android apps are available and desktop browser support is provided by Kee. No centralized service to trust, no subscription fees and an offline first experience while still getting good-enough-for-me browser and mobile device support!

(Worth mentioning that KeePassXC is also an excellent choice - it works essentially the same way and can use the same encrypted database files. I currently use KeePass in part because the KPSimpleBackup Plugin is a notable benefit to my personal workflow, but largely the programs are interchangeable - great to have multiple programs that support the same file format!)

I run KeePass with two plugins:

  • KPSimpleBackup - Backup Plugin for KeePass2 - with KeePass it is completely up to you to keep your password database backed up. You should have a backup strategy that covers all of your important personal digital information, but given the extraordinary value of passwords I use KPSimpleBackup to also provide additional backups on multiple machines.
  • KeePassRPC - provides communication between KeePass and a browser plugin like Kee - worth the extra time to setup.
2023 February The Sun Near Home
The Sun Near Home. Charles Miles. 2/6/2023.

It was fun to write this down - it is an amusing game these days trying to guess what tech will survive 1, 5, 10, 20+ years into the future - and maybe an even tougher game trying to guess what tech will remain personally relevant. Even during the short time I was writing this there was an explosion of interest in a Microsoft announcement of AI-powered Bing Search - I suspect it is mostly hype for my personal use, but I've already had some fun at work trying ChatGPT for product descriptions (interesting) and trail descriptions (disappointing) so who knows...


Created by Charles on 1/1/2023. Updated on 6/17/2023.

2023 January Saguaros and Cloud Break Light
Saguaros and Cloud Break Light. Charles Miles. 1/1/2023.

Andrew Whitechapel's 2005 "Getting the Application Object in a Shimmed Automation Add-in" - now only available via the Wayback Machine - was an early code source and inspiration when I start creating Excel files in .NET over 15 years ago.

For many years the COM Interop approach facilitated by the code above was the main technique I used for generating Excel files - but by the mid-2010s it was clear to me that using libraries like ClosedXML, which don't require the Excel application to be installed, are a better approach.

At work the task of moving many years of Excel COM Interop based reporting to ClosedXML is an ongoing, long-term, low-priority task.

At the end of 2022 I tackled converting a heavily used report where the final step of the report creation is splitting and positioning the user's initial view. (Note here that 'splitting' is not the same as 'freezing'...)

I couldn't find a way to do this in ClosedXML... So I decided that after writing the file to disk with ClosedXML I would use OpenXML to setup the split. A simple enough approach (that eventually worked), but working directly thru OpenXML is not my usual code path and the first few failures made it clear that I was going to need some reference material on what properties to set with what values.

I used the one trick I remembered for these files to help me look at the underlying XML - I renamed the file with a .zip extension and then navigated into it. This works and it is a good trick because it doesn't require any additional programs/tooling - if I had managed to immediately intuit the correct code changes I wouldn't have given this hack a second thought... But I didn't get the code right in the first few tries and as I tested code changes I very quickly wanted a better way to take a direct look at the files' XML.

After a little searching I found the Borislav Ivanov's Open XML Package Editor Power Tool for Visual Studio. Once installed in Visual Studio you can drop an Open XML/Excel file into Visual Studio and start exploring. This was fantastic and let me find the last detail I needed...

The code below has so far stood up to some light testing - presented as research and reading material more than code you should copy and paste...

/// <summary>
///     Splits the first sheet in an OpenXML Excel File into 2 vertical panes.
///     If the file, workbook or worksheet don't exist the method will exit
///     without throwing an exception.
/// </summary>
/// <param name="filename">Full Path and Filename of the OpenXML Excel Files</param>
/// <param name="verticalSplitHeight">Value in 1/20th of a point</param>
/// <param name="topLeftCellAddressForUpperPane">
///     In A1 style - upper left corner of the top pane, left column for both
///     panes
/// </param>
/// <param name="topRowForLowerPane">Top row for the lower pane</param>
/// <returns>The Filename</returns>
public static string ExcelFileFirstSheetTwoSplitVerticalPanes(this string filename, double verticalSplitHeight,
    string topLeftCellAddressForUpperPane, int topRowForLowerPane)
{
    //1/1/2023 - As far as I can tell ClosedXML doesn't offer options to create a Vertical
    //Split (not Freeze) with the TopLeft Cell set for both panes.
    //
    //Let me know if this is incorrect and this functionality is available - it would be
    //better not to re-open and re-save the file...But all things considered getting the
    //correct view setup for a user can be an important enough
    //feature to merit this approach.

    if (!File.Exists(filename)) return filename;

    using var xl = SpreadsheetDocument.Open(filename, true);

    var workbook = xl.WorkbookPart;
    var worksheet = workbook?.WorksheetParts.FirstOrDefault();
    if (worksheet?.Worksheet.SheetViews?.FirstOrDefault() is not SheetView view) return filename;

    //Clear the view for the new Pane and Selections created below
    view.RemoveAllChildren();

    //This will set the upper pane's top left cell
    view.TopLeftCell = topLeftCellAddressForUpperPane;

    //Setup the split - note that VerticalSplit is the number of rows when Freezing
    //but is "Vertical position of the split, in 1/20th of a point" when splitting.
    //   https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.pane?view=openxml-2.8.1
    var newPane = new Pane
    {
        VerticalSplit = verticalSplitHeight,
        TopLeftCell = $"A{topRowForLowerPane}",
        ActivePane = PaneValues.BottomLeft,
        State = PaneStateValues.Split
    };

    //Set a reasonable Selection
    var lowerPaneTopLeftCellAddress =
        $"{string.Concat(topLeftCellAddressForUpperPane.Where(x => !char.IsDigit(x)))}{topRowForLowerPane}";

    var selectionSor = new ListValue<StringValue>();
    selectionSor.Items.Add(lowerPaneTopLeftCellAddress);

    var selection = new Selection
    {
        SequenceOfReferences = selectionSor,
        ActiveCell = lowerPaneTopLeftCellAddress,
        Pane = PaneValues.BottomLeft
    };

    //Add the Pane and Selection - save the file.
    view.Append(newPane);
    view.Append(selection);
    worksheet.Worksheet.Save();
    workbook.Workbook.Save();
    xl.Close();

    return filename;
}
2023 January A Rainbow for the New Year
A Rainbow for the New Year. Charles Miles. 1/1/2023.

PS - COM Interop is still a key desktop technology especially if you want to read data from, or write data to, a file the user has open in Excel. For a modern update to Andrew Whitechapel's approach see Jamie Faix's Automate multiple Excel instances on Codeproject. I have lightly modified Faix's code for the ExcelInteropExtensions in my Pointless Waymarks Project.


Posts Before:
2022 November Fall Color in Ash Creek 01
2020 December Lines to the Past
2022 July Starlink Dish mounted on the former DirecTV Mount
2022 June Sun behind the Tucson Mountains
2022 April Dusty Sunset
2022 April Sun over Cat Mountain