Click here to Skip to main content
15,867,756 members
Articles / Desktop Programming / WPF

Library Commander

Rate me:
Please Sign up or sign in to vote.
5.00/5 (3 votes)
19 Jun 2017CPOL6 min read 13K   458   10   3
LibraryCommander is a personal desktop application to manage my texts (e-book) collection, classify and search them by categories and tags.

Introduction

LibraryCommander is a personal desktop application to manage my texts (e-book) collection, classify and search them by categories and tags.

LibraryCommander uses SQLite database to keep documents metadata (title, authors, language, etc) but stores actual files on disk in predefined folder (creates additional folders inside to categorize documents based on their metadata). Files are easily accessible from the application, Windows Explorer or any other file manager.

Data Model

Let's review application data model before discussing storage structure.

DataModel

The central class is of course Book. A book belongs to a certain Category and can have multiple Tags (at least one). A book is written in one Language by one or many Authors. A set of books can form a series (Cycle, e.g. "Diskworld" by Terry Pratchett), a book has Volume attribute to store its number in the series. The same book can be in different Formats (file extensions).

For each Category there is a separate folder in the storage directory. Category folders have subfolders for every Language. Books are stored in language folders unless user want to create additional folder for author or cycle when adding a book to the library.

Consider "Discworld" series by Terry Pratchett, for example. One could put it in "Fantasy" category and prefer to have authors folder ("Terry Pratchett") and series folder ("Discworld") to store other Pratchett works separately. So the final location of a book would be

"Library\Fantasy\En\Terry Pratchett\Discworld\Terry Pratchett. 16 - Soul Music.epub"

Document are added to Library via BookCard dialog:

Book Card

Each document should have title, category, author(s), tag(s), language, format(s) and file location to copy. If file is selected before entering title and format, then title and format are taken from file name. Cycle, volume and (publishing) year are optional attributes. Tags and cycles are associated with a concrete category and cannot be selected before category.

Books from the same cycle usually have the same attributes (except Title and file location). To simplify the task of adding multiple books LibraryCommander has template functionality: enter attributes of the first book, copy template (Ctrl+C) and when adding the next book paste attributes from template  (Ctrl+V).

LibraryCommander navigation

LibraryCommander was inspired by orthodox file managers. Take a look at the main screen:

Main screen

It presents a two-panel directory view (Files and Library panel) with a command list below. Each panels show current folder path and list of files/subfolders. Only one of the panels is active at a given time. The active panel contains the "cursor". Files in the active panel serve as the parameters of operations.

Data for panels are provided by so called FsNavigator classes (FileSystem Navigator). Given initial path FsNavigator returns list of files and folders by that path, wrapped in FsItem objects (with Properties Name, Size, Extension (for files) and IsDirectory flag to differentiate files/folders).

Navigator for Files panel is trivial and uses DirectoryInfo.EnumerateDirectories() and DirectoryInfo.EnumerateFiles() method to get all elements in current folder.

Navigator for Library panel (VirtualFsNavigator class) works based on documents metadata. It can check if a book was added to library but its file is missing in the storage. It also ignores files which exist in storage folders but not in a library.

VirtualFsNavigator selects files and folders based on current level in the storage.

- Storage Top level

  • no files
  • folders for each Category

- Storage Category level

  • no files
  • folders for each Language

- Storage Language level

  • files for books in current Category and Language which don't have Author or Cycle subfolder
  • folders for Author and Cycle, selected from books in current Category and Language which have subfolder

- Storage Author level

  • files for books in current Category and Language which have Author subfolder
  • folders for Cycle subfolder, selected from books of current Author in current Category and Language

- Storage Cycle level

  • files for books of current Author in current Category and Language which have Cycle subfolder
  • no folders

Hotkeys

Functional buttons in LibraryCommander have associated hotkeys. Hotkeys in WPF can be easily created using InputBindings. However with a large number of functions it becomes tedious to add all of them to a window InputBindings. To speed up the process and clearly associate hotkeys with certain buttons I created string attached DependencyProperty for Button class called "Hotkey". When "Hotkey" is assigned (e.g. "Control+F" or "F1") string is parsed in property changed callback in Cmd class and if key and modifiers are correct, InputBinding is added to Button window. Here is Cmd code:

C#
public static class Cmd
{
    public static readonly DependencyProperty HotkeyProperty =
        DependencyProperty.RegisterAttached("Hotkey", typeof(string), typeof(Cmd), new PropertyMetadata(null, HotkeyChangedCallback));

    public static string GetHotkey(DependencyObject obj)
    {
        return (string)obj.GetValue(HotkeyProperty);
    }

    public static void SetHotkey(DependencyObject obj, string value)
    {
        obj.SetValue(HotkeyProperty, value);
    }

    private static readonly char _cmdJoinChar = '+';
    private static readonly char _cmdNameChar = '_';

    private static string NormalizeName(string name)
    {
        // + symbol in names is prohibited by NameScope (throws exception)
        return name.Replace(_cmdJoinChar, _cmdNameChar);
    }

    private static void HotkeyChangedCallback(DependencyObject obj, DependencyPropertyChangedEventArgs e)
    {
        var btn = obj as Button;
        if (btn == null)
            return;

        Window parentWindow = Window.GetWindow(btn);
        if (parentWindow == null)
            return;

        KeyBinding kb = null;

        string hkOld = (string) e.OldValue;

        // find and remove key binding with old hotkey
        if (false == String.IsNullOrWhiteSpace(hkOld))
        {
            hkOld = NormalizeName(hkOld);
            kb = parentWindow.InputBindings
                .OfType<KeyBinding>()
                .FirstOrDefault(k => hkOld == (string) k.GetValue(FrameworkElement.NameProperty));

            if (kb != null)
                parentWindow.InputBindings.Remove(kb);
        }

        string hkNew = (string) e.NewValue;

        if (String.IsNullOrWhiteSpace(hkNew))
            return;

        // create key binding with new hotkey

        var keys = hkNew.Split(new [] { _cmdJoinChar }, StringSplitOptions.RemoveEmptyEntries);

        ModifierKeys modifier = ModifierKeys.None;
        ModifierKeys m;

        // parse hotkey string and extract modifiers and main key
        string strKey = null;
        foreach (string k in keys)
        {
            if (Enum.TryParse(k, out m))
                modifier = modifier | m;
            else
            {
                // more than one Key is not supported
                if (strKey != null)
                    return;

                strKey = k;
            }
        }

        Key key;
        if (false == Enum.TryParse(strKey, out key))
            return;

        // Key + Modifier
        kb = new KeyBinding {Key = key, Modifiers = modifier};

        // x:Name
        kb.SetValue(FrameworkElement.NameProperty, NormalizeName(hkNew));

        // Command
        var cmdBinding = new Binding("Command") {Source = btn};
        BindingOperations.SetBinding(kb, InputBinding.CommandProperty, cmdBinding);

        // Command Parameter
        var paramBinding = new Binding("CommandParameter") {Source = btn};
        BindingOperations.SetBinding(kb, InputBinding.CommandParameterProperty, paramBinding);

        // Adding hotkey to Window
        parentWindow.InputBindings.Add(kb);
    }
}

List of LibraryCommander hotkeys:

Main screen

  • Control+{Number}: select existing partition (Number >= 1)
  • Tab: switch active panel
  • Arrows Up and Down: move to previous/next file/folder in list
  • Enter: open selected file/folder
  • Escape: go to Parent folder from nested folder
  • F2: open selected file/folder
  • F3: edit selected book (active only on Library panel)
  • F4: create new book (active only on Library panel)
  • F5: copy selected file to Library (active only on Files panel)
  • F6: move selected file to Library (active only on Files panel)
  • F8: delete selected book (active only on Library panel)
  • Control+F: open Library Search dialog (active only on Library panel)

BookCard window

  • Control+C: copy template
  • Control+V: paste template
  • Control+O: select book file
  • Control+A: select Author
  • Control+K: select Category
  • Control+T: select Tags
  • Control+L: select Language
  • Control+E: select file format (Extension)
  • Escape: closes BookCard window, selection window (for authors, categories, etc) and InputBoxes.

Localization

LibraryCommander supports two languages. English is default and it switches to Russian when appropriate machine locale detected. Languages can also be switched on main screen.

Localization approach is described in the "Localization for Dummies" CodeProject article. There is a set of string resources for each language (En, Ru). The article suggests {x:Static} extension to access resources from xaml, but it doesn't help to switch language at runtime. I created LocalizationProvider class which stores default culture, current culture, can get resource values by key and update values when culture was switched (implements INotifyPropertyChanged):

C#
private Dictionary<string, string> _cache = new Dictionary<string, string>();

protected string GetResource([CallerMemberName]string resourceKey = null)
{
    string resource;
    // trying to get string from Cache
    if (_cache.TryGetValue(resourceKey, out resource))
        return resource;

    // trying to get string from resources for Current culture
    resource = Resources.ResourceManager.GetString(resourceKey, CurrentCulture);

    if (resource == null && CurrentCulture.Name != DefaultCulture.Name)
        // trying to get string from resources for Default culture
        resource = Resources.ResourceManager.GetString(resourceKey, DefaultCulture);

    // if localized string was not found in Resources, use resourceKey
    // it helps to add less strings to En Resources (property names are in English)
    // for other culture it allows to notice mistake without throwing exception
    if (resource == null)
        resource = resourceKey;

    // add resolved string to cache
    _cache.Add(resourceKey, resource);

    return resource;
}

Getting resource values isn't a one-step process so LocalizationProvider uses string cache. LocalizationProvider is a base class and different localizations are supposed to be implemented as derived classes.  An example of such implementation is Commands class with the names of main screen functional buttons:

C#
public class Commands: LocalizationProvider
{
    private static Commands _instance = new Commands();

    private Commands()
    {
    }

    /// <summary>
    /// Static item accessible from view (via {x:Static} extension)
    /// </summary>
    public static Commands Instance { get { return _instance; } }

    public string Cmd { get { return GetResource(); } }

    public string Pick { get { return GetResource(); } }

    public string Add { get { return GetResource(); } }

    public string Edit { get { return GetResource(); } }

    public string Copy { get { return GetResource(); } }

    public string Move { get { return GetResource(); } }

    public string Del { get { return GetResource(); } }

    public string Quit { get { return GetResource(); } }

    public string Search { get { return GetResource(); } }

    public string Save { get { return GetResource(); } }

    public string Close { get { return GetResource(); } }
}

CallerMemberName attribute on method parameter shortens implementation to one method call (provided that property name and resource key match).

Buttons content in the view is set via binding expression, e.g.:

C#
{Binding Path=Quit, Source={x:Static localization:Commands.Instance}}

Points of Interest

LibraryCommander is translated in two languages (En, Ru). Localized values are stored in project Resources. Application culture can be switched in runtume.

LibraryCommander has multiple keyboard shortcuts. Shortcuts in dialogs are implemented in the form of Window.KeyBindings. Custom attached property Cmd.Hotkey helps to reduce length of markup and clearly associate hotkeys with certain buttons.

LibraryCommander uses custom WPF styles to imitate old-school applications. Collection of styles (codename RetroUI) is my creation and includes Button, CheckBox, RadioButton, TabControl, ListBox, ComboBox, DataGrid, TreeView controls and can be found in my GitHub repository: https://github.com/AlexanderSharykin/RetroUI

How to use

To use LibraryCommander

Download LibraryCommander.zip (GitHub release)

Change "library" folder in LibraryCommander.exe.config file

Run LibraryCommander.exe

Application performs config verification at startup. LibraryCommander requires storage folder for documents and database for metadata.

Storage folder path should be provided in .config file (section <appSettings>, key "library"). If folder is not found, verification shows error message with description of a problem and exit application.

LibraryCommander release comes with Books.db file and SQLite connection by default. To launch project from IDE modify file path in SQLite connection string. Books.db file with empty tables can be found in sources in "Db" folder. There is also a script ("SqlServer Db Schema.sql") to create LibraryCommander database in SQL Server. An example of SQL Server connection settings is provided in .config file.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Russian Federation Russian Federation

Comments and Discussions

 
QuestionLooks interesting Pin
Member 244330615-Jun-17 22:03
Member 244330615-Jun-17 22:03 
I like the UI Smile | :)

However the compile version did not work, no errors at all.

If I take the source I get a crash at:
var settings = new EntityRepository<AppSettings>().Query().FirstOrDefault();


An unhandled exception of type 'NHibernate.Exceptions.GenericADOException' occurred in NHibernate.dll

Additional information: could not execute query

[ select appsetting0_.Id as Id1_10_, appsetting0_.Culture as Cultu2_10_, appsetting0_.FsLocation as FsLoc3_10_, appsetting0_.LibraryLocation as Libra4_10_, appsetting0_.IdCategory as IdCat5_10_, appsetting0_.IdLanguage as IdLan6_10_, appsetting0_.IdAuthor as IdAut7_10_, appsetting0_.IdCycle as IdCyc8_10_ from AppSettings appsetting0_ limit 1 ]

[SQL: select appsetting0_.Id as Id1_10_, appsetting0_.Culture as Cultu2_10_, appsetting0_.FsLocation as FsLoc3_10_, appsetting0_.LibraryLocation as Libra4_10_, appsetting0_.IdCategory as IdCat5_10_, appsetting0_.IdLanguage as IdLan6_10_, appsetting0_.IdAuthor as IdAut7_10_, appsetting0_.IdCycle as IdCyc8_10_ from AppSettings appsetting0_ limit 1]

AnswerRe: Fixes Pin
Alexander Sharykin16-Jun-17 7:53
Alexander Sharykin16-Jun-17 7:53 
PraiseRe: Fixes Pin
Member 244330617-Jun-17 10:44
Member 244330617-Jun-17 10:44 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.