• R/O
  • HTTP
  • SSH
  • HTTPS

Tags
Keine Tags

Frequently used words (click to add to your profile)

javac++androidlinuxc#windowsobjective-ccocoa誰得qtpythonphprubygameguibathyscaphec計画中(planning stage)翻訳omegatframeworktwitterdomtestvb.netdirectxゲームエンジンbtronarduinopreviewer

CLI interface to medialist (fossil mirror)


File Info

Rev. ec203fcc9ae92b8a6b939408c411defbbebfce76
Größe 45,214 Bytes
Zeit 2023-04-06 19:53:04
Autor mio
Log Message

0.4 release commit

FossilOrigin-Name: fe8fd62036270b304582473103fa3d72ab27006058649a998ef7373fa7bacc2b

Content

/*
 * Copyright (C) 2022, 2023 dawning.
 *
 * This file is part of medialist-cli.
 *
 * medialist-cli is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * medialist-cli is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with medialist-cli.  If not, see <https://www.gnu.org/licenses/>.
 */
module medialist;

import std.algorithm.searching;
import std.algorithm.sorting;
import std.conv;
import std.datetime.date;
import std.datetime.systime;
import std.exception : ErrnoException;
import std.file;
import std.path;
import std.stdio;
import std.string;

/// 
/// Enumeration string containing the verison of MediaList.
/// 
/// Since: 0.4.0
/// 
enum string MediaListVersion = "0.4.0";

///
/// Enumeration array of int containing the version of MediaList.
/// 
/// Version represents the Major.Minor.Patch.
/// 
/// Major version changes represent the modification or removal of an existing
/// API.  Patch versions represent compile-time fixes.  Minor versions represent
/// all other changes.
///
/// Since: 0.4.0
////
enum int[3] MediaListVersionA = [0, 4, 0];

///
/// The main structure for interacting with a list in `medialist`.
///
/// Examples:
/// -------------
/// MediaList* list = ml_open_list("/path/to/my/list.mtsv");
/// // ...
/// ml_free_list(list);
/// -------------
///
/// See_Also:
///   ml_open_list, ml_free_list
/// 
struct MediaList
{
    /// The absolute file path for this list.
    immutable string filePath;
    /// The list's name
    immutable string listName;
    /// Whether the list is open somewhere else in the
    /// program.
    ///
    /// Note: The future of this property is undecided.
    /// It requires the people programming to remember
    /// that it exists, and whether they've set it or
    /// the library has set it.  It's probable that
    /// this will be removed.
    bool isOpen = false;
}

///
/// An individual item from a `MediaList`.
///
/// The properties here are mutable to allow for editing, however, do
/// note that editing them in a returned value does $(B not) edit them
/// in the underlying MediaList file.  You still need to call
/// `ml_send_command` to update the file.
///
struct MediaListItem
{
    /// The 'title' field from the MediaList file.
    string title;
    /// The 'progress' field from the MediaList file.
    string progress;
    /// The 'status' field from the MediaList file.
    string status;
    /// The 'start_date' field from the MediaList file.
    ///
    /// This value is expected to be in the format of `YYYY-MM-DD`.
    string startDate;
    /// The 'end_date' field from the MediaList file.
    ///
    /// This value is expected to be in the format of `YYYY-MM-DD`.
    string endDate;
    /// The date this item was last updated.
    ///
    /// This value is expected to be in the format of `YYYY-MM-DD`.
    string lastUpdated;

    /// Whether this item should be considered "valid", meaning
    /// it is still present on the underlying list.
    ///
    /// Note: The future of this property is undecided.
    /// It requires the people programming to remember
    /// that it exists, and whether they've set it or
    /// the library has set it.  It's probable that
    /// this will be removed.
    bool valid = false;
    
    ///
    /// Compare two MediaListItem structures to determine which
    /// is newer.
    ///
    /// The comparison is based on the `lastUpdated` field, where
    /// if the *other* has a older `lastUpdated` field, then this
    /// instance will be greater than the other.
    ///
    int opCmp(ref const MediaListItem other) const {
        Date thisDate = Date.fromISOExtString(lastUpdated);
        Date otherDate = Date.fromISOExtString(other.lastUpdated);

        return thisDate.opCmp(otherDate);
    }

    ///
    /// Convert a MediaListItem structure in to a TSV representation.
    ///
    /// This does $(B not) add a trailing newline character. The order
    /// follows the MediaList default order:
    ///
    /// title, progress, status, start_date, end_date, last_updated.
    ///
    /// Returns: A string containing the TSV representation of *item*.
    ///
    string tsvRepresentation() const {
        import std.format : format;

        return "%s\t%s\t%s\t%s\t%s\t%s".format(this.title,
                                               this.progress,
                                               this.status,
                                               this.startDate,
                                               this.endDate,
                                               this.lastUpdated);
    }
}

///
/// Convert a MediaListItem structure in to a TSV representation.
///
/// This does $(B not) add a trailing newline character. The order
/// follows the MediaList default order:
///
/// title, progress, status, start_date, end_date, last_updated.
///
/// Params:
///   item = The MediaListItem to convert to a string
///
/// Returns: A string containing the TSV representation of *item*.
///
deprecated("Use MediaListItem.tsvRepresentation instead. Remove at 0.4")
string ml_medialistitem_to_string(in ref MediaListItem item) {
    import std.format : format;

    return item.tsvRepresentation();
}

///
/// An individual header field from the underlying mTSV file.
///
/// Note: Editing these fields does not result in editing them
///       in the underlying mTSV file.
///
struct MediaListHeader
{
    /// The name of the header as it appears in the mTSV file.
    string tsvName = null;
    /// The "human friendly name".
    ///
    /// If this value is set, then it should be used when displaying
    /// the header to people.
    string humanFriendlyName = null;
    /// The column that this header appears in the mTSV file.
    ///
    /// The value is `0` indexed.
    size_t column;
}

///
/// The command to send when updating a `MediaList`.
///
enum MLCommand
{
    ///
    /// Add a new item to a list.
    ///
    /// The `args` parameter should be in the following format:
    /// `["Item Name", "(Optional) Progress", "(Optional) Status"]`
    ///
    /// You will need to include the "Progress" value if you want to
    /// set the "Status".
    /// 
    add,
    ///
    /// Delete specific items from the list, or the entire list.
    ///
    /// The `args` parameter should be in the following format:
    /// `["(Optional) Item ID", "(Optional) Item ID", ...]`
    ///
    /// If no there are no "Item ID" values in `args`, then the entire
    /// list is irreversibly deleted. **NOTE**: This functionality has
    /// been deprecated from version 0.4, it'll be removed in a later
    /// version.
    ///
    delete_,
    ///
    /// Update an item on a list.
    ///
    /// The `args` parameter should be in the following format:
    /// `["Item ID", "field::value", "field::value", ...]`
    ///
    /// For example: ml_send_command(list, MLCommand.update, ["1", "status:READING"]);
    /// The "field" is automatically converted to lowercase, while the value is kept
    /// as-is.
    ///
    /// To update the start and end date of an item, use "start_date" and
    /// "end_date" respectively.
    ///
    update,
}

///
/// Error values used by functions in place of Exceptions.
///
enum MLError
{
    /// The function executed without any errors.
    success = 0,
    /// Invalid arguments sent to `ml_send_command`.
    ///
    /// You can see the format for each command by looking at the
    /// `MLCommand` enumeration.
    ///    
    invalidArgs,
    /// The list is already open for reading or writing.
    fileAlreadyOpen,
    /// Failed to read from the list.
    fileReadError,
    /// The specified item could not be found in the list.
    itemNotFound,
    /// We don't have permission to edit the list file.
    permissionError,
    /// Unknown value sent to ml_send_command.
    ///
    /// Check all values in the `MLCommand` enumeration.
    unknownCommand,
    /// Unknown error has occurred.
    unspecifiedError,
}

private static string[] mlErrorStrings = [
    "No error occurred",
    "Invalid arguments provided.",
    "The file for this list is already open for editing.",
    "Failed to read from the list.",
    "Could no find the requested item in the list.",
    "Unable to access the file for this list as we don't have permission.",
    "An unknown error has occurred.",
    "An unknown value was used for ml_send_command."
];

///
/// A custom MediaList Exception class which is used for the throwing
/// versions of functions in places where MLError would be used.
///
class MLException : Exception
{
    /// Underlying `MLError` value.
    final @property MLError error() nothrow pure @nogc @safe
    {
        return mError;
    }

    private MLError mError;

    ///
    /// Constructor which takes an error message.
    ///
    /// The `MLError.unspecifiedError` value is used as the error code.
    ///
    this(string msg)
    {
        this(msg, MLError.unspecifiedError);
    }

    ///
    /// Constructor which takes an error code.
    ///
    /// The error message is based on the error code.
    ///
    /// See_Also:
    ///   MLError
    ///
    this(MLError error)
    {
        this(mlErrorStrings[error], error);
    }

    /// Constructor which takes an error message and error code.
    this(string msg, MLError error)
    {
        mError = error;
        super(msg);
    }
}

///
/// Prepare a list for editing.
///
/// This does not open the file, except for writing the required
/// headers to the file if the list doesn't exist.
///
/// The resources used by the returned value should be released with
/// `ml_free_list`.
///
/// Params:
///  filePath = The (relative or absolute) path to the mTSV file.
///
/// Returns: A GC-allocated MediaList structure.
///
/// Throws:
///  - ErrnoException if the file could not be opened, there was an
///    error writing to file, or if there was an error closing the
///    file.
///
MediaList* ml_open_list(string filePath)
{
    string listName = stripExtension(baseName(filePath));
    MediaList* ml = new MediaList(filePath, listName);

    if (false == exists(filePath)) {
        File f = File(filePath, "w+");

        ml.isOpen = true;
        scope(exit) ml.isOpen = false;

        f.writeln("# This file is in the mTSV format.");
        f.writeln("# For more information about this format,");
        f.writeln("# please view the website:");
        f.writeln("# http://yume-neru.neocities.org/p/mtsv.html");
        f.writeln("title\tprogress\tstatus\tstart_date\tend_date\tlast_updated");
    }

    return ml;
}

///
/// Prepare a list for editing.
///
/// This does not open the file, except for writing the required
/// headers to the file if the list doesn't exist.
///
/// The resources used by the returned value should be released with
/// `ml_free_list`.
///
/// Params:
///  filePath = The (relative or absolute) path to the mTSV file.
///  error = The out-parameter for error reporting in the nothrow
///          overload.
///
/// Returns: A GC-allocated MediaList structure.
///
MediaList* ml_open_list(string filePath, out MLError error) nothrow
{
    import std.exception : ErrnoException;
    import core.stdc.errno;

    MediaList* list;

    try {
        list = ml_open_list(filePath);
    } catch (ErrnoException ee) {
        switch (errno)
        {
        case EACCES:
            error = MLError.permissionError;
            break;
        default:
            error = MLError.unspecifiedError;
            break;
        }
    } catch (Exception e) {
        error = MLError.unspecifiedError;
    }

    return list;
}

///
/// Finalize and free the resources held by the MediaList structure.
///
/// Params:
///   list = The MediaList structure.
///
/// See_Also: ml_open_list
///
void ml_free_list(MediaList* list) nothrow
{
    import core.memory : GC;

    destroy(list);
    GC.free(list);
}

///
/// Fetch the headers from the mTSV file *list* points to.
///
/// Params:
///   list = The MediaList to fetch the headers from.
///
/// Returns: An array of all the headers in the mTSV file, along with
///          their "human friendly name" if applicable.
///
/// Examples:
/// ---------
/// import std.stdio;
///
/// import medialist;
///
/// void main()
/// {
///     MediaList* list = ml_open_list("list.mtsv");
///
///     MediaListHeader[] headers = ml_fetch_headers(list);
///
///     foreach(const ref header; headers) {
///         writeln("Header %d: %s%s", header.column, header.tsvName,
///                 header.humanFriendlyName is null ? "" : "(" ~ header.humanFriendlyName ~ ")");
///     }
///
///     ml_free_list(list);
/// }
/// ---------
///
/// Possible ouput:
/// ---------------
/// Header 0: title (Title)
/// Header 1: status (Status)
/// Header 2: progress
/// Header 3: start_date (Start Date)
/// Header 4: end_date (End Date)
/// Header 5: last_updated
/// ---------------
///
/// See_Also:
///   ml_fetch_item, ml_fetch_items, ml_fetch_all
///
MediaListHeader[] ml_fetch_headers(MediaList* list)
{
    if (true == list.isOpen)
    {
        throw new MLException(mlErrorStrings[MLError.fileAlreadyOpen], MLError.fileAlreadyOpen);
    }
    File listFile = File(list.filePath);

    list.isOpen = true;
    scope(exit) list.isOpen = false;

    MediaListHeader[] headers;
    string[string] configurations;

    string line;

    while ((line = listFile.readln()) !is null) {
        if (line.length == 0)
            continue;

        /* Header line, which we will parse now */
        if ('#' != line[0])
            break;

        /*
         * The minimum mTSV configuration line is:
         * #k=v
         */
        if (line.length < 4)
            continue;

        /* mTSV configuration requires there to be NO space */
        if (' ' != line[1])
            continue;

        string[] sections = line[1..$].strip().split("=");

        /* XXX: Should we error here? */
        if (sections.length < 2)
            continue;

        configurations[sections[0]] = sections[1];
    }

    if (line is null) {
        throw new MLException(mlErrorStrings[MLError.fileReadError], MLError.fileReadError);
    }

    string[] sections = line.strip().split("\t");
    foreach(size_t idx, ref string section; sections) {
        if (section in configurations)
            headers ~= MediaListHeader(section, configurations[section], idx);
        else
            headers ~= MediaListHeader(section, null, idx);
    }

    return headers;
}

///
/// Fetch the headers from the mTSV file *list* points to.
///
/// Params:
///   list = The MediaList to fetch the headers from.
///   error = The out-paramter for error reporting in the no-throw overload.
///
/// Returns: An array of all the headers in the mTSV file, along with
///          their "human friendly name" if applicable. This list may
///          be empty if `error` is not `MLError.success`.
///
/// Examples:
/// ---------
/// import std.stdio;
///
/// import medialist;
///
/// void main()
/// {
///     MediaList* list = ml_open_list("list.mtsv");
///
///     MLError error;
///     MediaListHeader[] headers = ml_fetch_headers(list, error);
///     if (MLError.success != error) {
///         stderr.writeln("Encountered error: ", error);
///     }
///
///     foreach(const ref header; headers) {
///         writeln("Header %d: %s%s", header.column, header.tsvName,
///                 header.humanFriendlyName is null ? "" : "(" ~ header.humanFriendlyName ~ ")");
///     }
///
///     ml_free_list(list);
/// }
/// ---------
///
/// Possible ouput:
/// ---------------
/// Header 0: title (Title)
/// Header 1: status (Status)
/// Header 2: progress
/// Header 3: start_date (Start Date)
/// Header 4: end_date (End Date)
/// Header 5: last_updated
/// ---------------
///
/// See_Also:
///   ml_fetch_item, ml_fetch_items, ml_fetch_all
///
MediaListHeader[] ml_fetch_headers(MediaList* list, out MLError error) nothrow
{
    import core.stdc.errno;
    import core.exception : UnicodeException;

    import std.exception : ErrnoException;
    import std.stdio : StdioException;

    MediaListHeader[] headers;

    if (list.isOpen)
    {
        error = MLError.fileAlreadyOpen;
        return headers;
    }

    try
    {
        File listFile = File(list.filePath);
        list.isOpen = true;
        scope (exit)
            list.isOpen = false;
        string[string] configurations;
        string line;

        while ((line = listFile.readln()) !is null)
        {
            if (line.length == 0)
                continue;

            /* Header line, which we will parse now */
            if ('#' != line[0])
                break;

            /*
             * The minimum mTSV configuration line is:
             * #k=v
             */
            if (line.length < 4)
                continue;

            /* mTSV configuration requires there to be NO space */
            if (' ' != line[1])
                continue;

            string[] sections = line[1 .. $].strip().split("=");

            /* This is a normal comment line. */
            if (sections.length < 2)
                continue;

            configurations[sections[0]] = sections[1];
        }

        string[] sections = line.strip().split("\t");
        foreach (size_t idx, ref string section; sections)
        {
            if (section in configurations)
                headers ~= MediaListHeader(section, configurations[section], idx);
            else
                headers ~= MediaListHeader(section, null, idx);
        }
    }
    catch (ErrnoException ee)
    {
        static if (__VERSION__ < 2088L)
        {
            int ec = errno;
        }
        else
        {
            uint ec = ee.errno();
        }

        switch (ec)
        {
        case EACCES:
            error = MLError.permissionError;
            return headers;
        default:
            error = MLError.unspecifiedError;
            return headers;
        }
    }
    catch (StdioException se)
    {
        switch (se.errno)
        {
        case EACCES:
            error = MLError.permissionError;
            return headers;
        default:
            error = MLError.unspecifiedError;
            return headers;
        }
    }
    catch (Exception)
    {
        error = MLError.unspecifiedError;
        return headers;
    }

    return headers;
}

///
/// Fetch an individual item from a list.
///
/// Params:
///   list = The MediaList to fetch the item from.
///   id = The "item ID" for the item to fetch.
///
/// Returns: The item corresponding to the *id* from *list*. If the
///          item could not be found, then an MLException is thrown
///          and an empty MediaListItem structure is returned.
///
/// Examples:
/// ---------
/// import std.stdio;
/// 
/// import medialist;
/// 
/// void main()
/// {
///     MediaList* list = ml_open_list("list.mtsv");
/// 
///     MediaListItem item = ml_fetch_item(list, 1);
///     writeln("%s of the way through %s.", item.progress, item.title);
/// 
///     ml_free_list(list);
/// }
/// ---------
///
/// Possible output:
/// ----------------
/// 6/12 of the way through The Haruhi Suzumiya Series.
/// ----------------
///
/// See_Also:
///   ml_fetch_items, ml_fetch_all
///
MediaListItem ml_fetch_item(MediaList* list, size_t id)
{
    MediaListItem newItem;

    if (true == list.isOpen) {
        throw new MLException(MLError.fileAlreadyOpen);
    }

    list.isOpen = true;
    scope(exit) list.isOpen = false;

    File listFile = File(list.filePath);

    string line;
    size_t currentLine = 0;
    bool found = false;
    bool pastHeader = false;

    while ((line = listFile.readln()) !is null) {
        if (0 >= line.length)
            continue;

        if ('#' == line[0])
            continue;

        /* First non-comment line in medialist mTSV file is header */
        if (false == pastHeader) {
            pastHeader = true;
            currentLine += 1;
            continue;
        }

        if (id == currentLine) {
            found = true;
            break;
        }
        currentLine += 1;
    }

    if (false == found)
        throw new MLException(MLError.itemNotFound);

    size_t[2][6] headerPositions = _ml_get_header_positions(list);

    string[] sections = line.strip().split("\t");
    size_t titleIndex = 0;
    size_t progressIndex = 0;
    size_t statusIndex = 0;
    size_t startIndex = 0;
    size_t endIndex = 0;
    size_t lastIndex = 0;

    foreach(const ref header; headerPositions) {
        switch(header[0]) {
        case MLHeaders.title:
            titleIndex = header[1];
            break;
        case MLHeaders.progress:
            progressIndex = header[1];
            break;
        case MLHeaders.status:
            statusIndex = header[1];
            break;
        case MLHeaders.startDate:
            startIndex = header[1];
            break;
        case MLHeaders.endDate:
            endIndex = header[1];
            break;
        case MLHeaders.lastUpdated:
            lastIndex = header[1];
            break;
        default:
            break;
        }
    }

    newItem.title = sections[titleIndex];
    newItem.progress = sections[progressIndex];
    newItem.status = sections[statusIndex];
    newItem.startDate = sections[startIndex];
    newItem.endDate = sections[endIndex];
    newItem.lastUpdated = sections[lastIndex];
    newItem.valid = true;

    return newItem;
}

///
/// Fetch an individual item from a list.
///
/// Params:
///   list = The MediaList to fetch the item from.
///   id = The "item ID" for the item to fetch.
///   error = The out-parameter for error reporting.
///
/// Returns: The item corresponding to the *id* from *list*. If the
///          item could not be found, then *err* is set to
///          `MLError.itemNotFound` and an empty MediaListItem
///          structure is returned.
///
/// Examples:
/// ---------
/// import std.stdio;
/// 
/// import medialist;
/// 
/// void main()
/// {
///     MediaList* list = ml_open_list("list.mtsv");
/// 
///     MediaListItem item = ml_fetch_item(list, 1);
///     writeln("%s of the way through %s.", item.progress, item.title);
/// 
///     ml_free_list(list);
/// }
/// ---------
///
/// Possible output:
/// ----------------
/// 6/12 of the way through The Haruhi Suzumiya Series.
/// ----------------
///
/// See_Also:
///   ml_fetch_items, ml_fetch_all
///
MediaListItem ml_fetch_item(MediaList* list, size_t id, out MLError error) nothrow
{
    import core.stdc.errno : errno, EACCES;

    import std.exception : ErrnoException;
    import std.stdio : StdioException;

    MediaListItem fetchedItem;

    try
    {
        fetchedItem = ml_fetch_item(list, id);
    }
    catch (MLException me)
    {
        error = me.error;
        return fetchedItem;
    }
    catch (ErrnoException e)
    {
        error = (errno == EACCES) ? MLError.permissionError : MLError.unspecifiedError;
        return fetchedItem;
    }
    catch (StdioException se)
    {
        error = MLError.fileReadError;
        return fetchedItem;
    }
    catch (Exception e) {
        error = MLError.unspecifiedError;
        return fetchedItem;
    }

    return fetchedItem;
}

///
/// Fetch multiple items from *list*.
///
/// Params:
///   list = The MediaList to fetch the items from.
///   ids = The "item ID" of each item you want to fetch.
///
/// Returns: An array containing all the items that were successfully
///          fetched from *list*. Any items that could not be found
///          are skipped.
///
/// See_Also:
///  ml_fetch_item, ml_fetch_all
///
MediaListItem[] ml_fetch_items(MediaList* list, size_t[] ids ...)
{
    if (list.isOpen)
        throw new MLException(MLError.fileAlreadyOpen);

    File listFile = File(list.filePath);
    list.isOpen = true;
    scope(exit) list.isOpen = false;

    string line;
    bool pastHeader = false;
    MediaListItem[] items;

    size_t[2][6] headerPositions = _ml_get_header_positions(list);
    size_t titleIndex = 0;
    size_t progressIndex = 0;
    size_t statusIndex = 0;
    size_t startIndex = 0;
    size_t endIndex = 0;
    size_t lastIndex = 0;

    foreach(const ref header; headerPositions) {
        switch(header[0]) {
        case MLHeaders.title:
            titleIndex = header[1];
            break;
        case MLHeaders.progress:
            progressIndex = header[1];
            break;
        case MLHeaders.status:
            statusIndex = header[1];
            break;
        case MLHeaders.startDate:
            startIndex = header[1];
            break;
        case MLHeaders.endDate:
            endIndex = header[1];
            break;
        case MLHeaders.lastUpdated:
            lastIndex = header[1];
            break;
        default:
            break;
        }
    }

    size_t currentID = 1;
    while ((line = listFile.readln()) !is null) {
        if (line.length == 0)
            continue;
        if (line[0] == '#')
            continue;

        if (false == pastHeader) {
            pastHeader = true;
            continue;
        }

        if (true == canFind(ids, currentID)) {
            string[] sections = line.strip().split("\t");
            items ~= MediaListItem(sections[titleIndex],
                sections[progressIndex], sections[statusIndex], sections[startIndex],
                sections[endIndex], sections[lastIndex], true);
        }

        currentID += 1;
    }

    return items;
}

///
/// Fetch multiple items from *list*.
///
/// Params:
///   list = The MediaList to fetch the items from.
///   err = The out-parameter for error reporting.
///   ids = The "item ID" of each item you want to fetch.
///
/// Returns: An array containing all the items that were successfully
///          fetched from *list*. Any items that could not be found
///          are skipped.
///
/// See_Also:
///  ml_fetch_item, ml_fetch_all
///
MediaListItem[] ml_fetch_items(MediaList* list, out MLError err,
                               size_t[] ids ...) nothrow
{
    import core.stdc.errno : errno, EACCES;

    MediaListItem[] items;

    try {
        items = ml_fetch_items(list, ids);
    } catch (MLException e) {
        err = e.error;
    } catch (StdioException e) {
        if (errno == EACCES) {
            err = MLError.permissionError;
        } else {
            err = MLError.unspecifiedError;
        }
    } catch (Exception e) {
        err = MLError.unspecifiedError;
    }

    return items;
}

///
/// Fetch all them items found in *list*.
///
/// This will fetch every item that is present in the *list*, meaning
/// that the returned value could be quite large (both in length and
/// resources).
///
/// Params:
///   list = The MediaList to fetch all the items from.
///
/// Returns: An array containing all the items from *list*.
///
/// See_Also:
///   ml_fetch_item, ml_fetch_items
///
MediaListItem[] ml_fetch_all(MediaList* list)
{
    if (list.isOpen)
        throw new MLException(MLError.fileAlreadyOpen);

    File listFile = File(list.filePath);
    list.isOpen = true;
    scope (exit)
        list.isOpen = false;

    string line;
    bool pastHeader = false;
    MediaListItem[] items;

    size_t[2][6] headerPositions = _ml_get_header_positions(list);
    size_t titleIndex = 0;
    size_t progressIndex = 0;
    size_t statusIndex = 0;
    size_t startIndex = 0;
    size_t endIndex = 0;
    size_t lastIndex = 0;

    foreach (const ref header; headerPositions)
    {
        switch (header[0])
        {
        case MLHeaders.title:
            titleIndex = header[1];
            break;
        case MLHeaders.progress:
            progressIndex = header[1];
            break;
        case MLHeaders.status:
            statusIndex = header[1];
            break;
        case MLHeaders.startDate:
            startIndex = header[1];
            break;
        case MLHeaders.endDate:
            endIndex = header[1];
            break;
        case MLHeaders.lastUpdated:
            lastIndex = header[1];
            break;
        default:
            break;
        }
    }

    while ((line = listFile.readln()) !is null)
    {
        if (line.length == 0)
            continue;

        if (line[0] == '#')
            continue;

        if (false == pastHeader)
        {
            pastHeader = true;
            continue;
        }

        // Separate the split and strip so we don't remove any
        // trailing \t which may be used as a placeholder.
        string[] sections = line.split("\t");
        sections[$ - 1] = sections[$ - 1].strip;

        items ~= MediaListItem(sections[titleIndex],
            sections[progressIndex],
            sections[statusIndex],
            sections[startIndex],
            sections[endIndex],
            sections[lastIndex], true);
    }

    return items;
}

///
/// Fetch all them items found in *list*.
///
/// This will fetch every item that is present in the *list*, meaning
/// that the returned value could be quite large (both in length and
/// resources).
///
/// Params:
///   list = The MediaList to fetch all the items from.
///   error = The out-parameter for error reporting.
///
/// Returns: An array containing all the items from *list*.
///
/// See_Also:
///   ml_fetch_item, ml_fetch_items
///
MediaListItem[] ml_fetch_all(MediaList* list, out MLError error) nothrow
{
    import core.stdc.errno : errno, EACCES;

    MediaListItem[] items;

    try
    {
        items = ml_fetch_all(list);
    }
    catch (MLException e)
    {
        error = e.error;
        return items;
    }
    catch (ErrnoException e)
    {
        error = (errno == EACCES) ? MLError.permissionError : MLError.unspecifiedError;
        return items;
    }
    catch (StdioException)
    {
        error = MLError.fileReadError;
        return items;
    }
    catch (Exception e)
    {
        error = MLError.unspecifiedError;
        return items;
    }

    return items;
}

///
/// Perform a *command* on *list*.
///
/// This is the main way to interact with a MediaList. The value of
/// *args* will depend on the value of *command*.  See `MLCommand`
/// for more information.
///
/// Params:
///   list = The MediaList to perform the action on.
///   command = The type of action to perform.
///   args = The arguments that *command* will use.
///
/// Returns: If no error occurred, then `MLError.success` will be
///          returned.  Otherwise, a value of `MLError`.
///
/// Throws: `MLException` if there was an error in the way the *args* were
///         formatted.
///
/// See_Also:
///  MLCommand
///
void ml_send_command(MediaList* list, MLCommand command, string[] args)
{
    switch (command)
    {
    case MLCommand.add:
        _ml_add(list, args);
        break;
    case MLCommand.delete_:
        _ml_delete(list, args);
        break;
    case MLCommand.update:
        _ml_update(list, args);
        break;
    default:
        throw new MLException(MLError.unknownCommand);
    }
}

///
/// Perform a *command* on *list*.
///
/// This is the main way to interact with a MediaList. The value of
/// *args* will depend on the value of *command*.  See `MLCommand`
/// for more information.  If there are any errors, *error* will be
/// set to a value other than `MLError.success`.
///
/// Params:
///   list = The MediaList to perform the action on.
///   command = The type of action to perform.
///   args = The arguments that *command* will use.
///   error = The out-parameter for determining any errrors.
///
/// See_Also:
///  MLCommand
///
void ml_send_command(MediaList* list, MLCommand command, string[] args,
             out MLError error) nothrow
{
    switch (command)
    {
    case MLCommand.add:
        _ml_add(list, args, error);
        break;
    case MLCommand.delete_:
        _ml_delete(list, args, error);
        break;
    case MLCommand.update:
        _ml_update(list, args, error);
        break;
    default:
        error = MLError.unknownCommand;
        break;
    }
}

private void _ml_add(MediaList* list, string[] args)
{
    string title;
    string progress = "-/-";
    string status = "UNKNOWN";

    DateTime currentDate = cast(DateTime)Clock.currTime;

    if (args.length < 1)
        throw new MLException(MLError.invalidArgs);

    title = args[0];

    if (args.length >= 2)
        progress = (args[1] is null) ? "-/-" : args[1];

    if (args.length >= 3)
        status = (args[2] is null) ? "UNKNOWN" : args[2];

    if (true == list.isOpen)
        throw new MLException(MLError.fileAlreadyOpen);

    size_t[2][6] headerPositions = _ml_get_header_positions(list);
    int currentIndent = 0;

    list.isOpen = true;
    scope(exit) list.isOpen = false;

    File listFile = File(list.filePath, "a");

    foreach(const ref headerPosition; headerPositions) {
        while (currentIndent < headerPosition[1]) {
            listFile.write("\t");
            currentIndent += 1;
        }

        switch(headerPosition[0])
        {
        case MLHeaders.title:
            listFile.write(title);
            break;
        case MLHeaders.progress:
            listFile.write(progress);
            break;
        case MLHeaders.status:
            listFile.write(status);
            break;
        case MLHeaders.lastUpdated:
            listFile.writef("%d-%02d-%02d", currentDate.year,
                            currentDate.month, currentDate.day);
            break;
        default:
            break;
        }
    }

    listFile.write("\n");
}

private void _ml_add(MediaList* list, string[] args, out MLError error) nothrow
{
    import core.stdc.errno : errno, EACCES;
    
    error = MLError.success;
    
    try {
        _ml_add(list, args);
    } catch (MLException e) {
        error = e.error;
    } catch (StdioException e) {
        error = (errno == EACCES) ?
            MLError.permissionError :
            MLError.unspecifiedError;
    } catch (Exception e) {
        error = MLError.unspecifiedError;
    }
}

/**
 * Convert and sort an array of strings to "size_t".
 *
 * This assumes all elements in the array can be converted. If any fail,
 * then "null" is returned from the function.
 */
private size_t[] _ml_conv_sort_num_list(const string[] args)
{
    size_t[] ids = new size_t[args.length];

    foreach(size_t idx, const ref string arg; args) {
        try {
            ids[idx] = to!size_t(arg);
        } catch (Exception e) {
            return null;
        }
    }

    sort(ids);

    return ids;
}

private void _ml_delete(MediaList* list, string[] args)
{
    if (true == list.isOpen)
        throw new MLException(MLError.fileAlreadyOpen);

    if (0 == args.length) {
        // NOTE: This will be removed in a version after 0.4.0.
        remove(list.filePath);
        return;
    }

    size_t[] ids = _ml_conv_sort_num_list(args);

    if (null is ids)
        throw new MLException(MLError.invalidArgs);

    File listFile = File(list.filePath);
    list.isOpen = true;
    scope(exit) list.isOpen = false;

    /*
     * To avoid storing all lines in memory, we create a temporary file with
     * which we write all lines that we are keeping.  Once we're done writing,
     * we then overwrite the actual list file with the contents of the temporary
     * file.
     *
     * If someone knows of a better way to go about this, I wouldn't mind
     * knowing.
     */
    string tempFilePath = buildPath(tempDir(),
        "temp" ~ baseName(list.filePath));
    File tempFile = File(tempFilePath, "w+");

    size_t currentID = 1;
    size_t idsIndex = 0;
    string line;
    bool pastHeader = false;

    while ((line = listFile.readln()) !is null) {
        if (line[0] == '#') {
            tempFile.write(line);
            continue;
        }

        if (false == pastHeader) {
            tempFile.write(line);
            pastHeader = true;
            continue;
        }

        if (ids[idsIndex] != currentID)
            tempFile.write(line);
        else
            idsIndex += 1;

        currentID += 1;
    }

    listFile.close();
    tempFile.close();

    listFile = File(list.filePath, "w+");
    tempFile = File(tempFilePath);

    while ((line = tempFile.readln) !is null) {
        listFile.write(line);
    }

    listFile.close();
    tempFile.close();

    remove(tempFilePath);
}

private void _ml_delete(MediaList* list, string[] args, out MLError error) nothrow
{
    import core.stdc.errno : errno, EACCES;

    error = MLError.success;

    try {
        _ml_delete(list, args);
    } catch (MLException me) {
        error = me.error;
    } catch (StdioException se) {
        error = (errno == EACCES) ?
            MLError.permissionError :
            MLError.unspecifiedError;
    } catch (Exception e) {
        error = MLError.unspecifiedError;
    }
}

private void _ml_update(MediaList* list, string[] args)
{
    if (list.isOpen)
        throw new MLException(MLError.fileAlreadyOpen);

    if (2 > args.length)
        throw new MLException(MLError.invalidArgs);

    string title = null;
    string progress = null;
    string status = null;
    string startDate = "";
    string endDate = "";

    size_t id;

    try {
        id = to!size_t(args[0]);
    } catch (Exception e) {
        throw new MLException(MLError.invalidArgs);
    }

    foreach(string arg; args) {
        string[] kv = arg.split("::");

        if (2 > kv.length)
            continue;

        const k = kv[0].toLower();
        const v = kv[1];

        switch (k) {
        case "title":
            title = v;
            break;
        case "status":
            status = v;
            break;
        case "progress":
            progress = v;
            break;
        case "start_date":
            startDate = v;
            break;
        case "end_date":
            endDate = v;
            break;
        default:
            break;
        }
    }

    string tempFilePath = buildPath(tempDir, "ml_temp.tsv");
    File tempFile = File(tempFilePath, "w+");
    scope(exit) {
        // Windows requires the file to be closed before removing.
        tempFile.close();
        remove(tempFilePath);
    }

    size_t[2][6] headerPositions = _ml_get_header_positions(list);
    size_t titleIndex = 0;
    size_t progressIndex = 0;
    size_t statusIndex = 0;
    size_t startDateIndex = 0;
    size_t endDateIndex = 0;
    size_t lastUpdatedIndex = 0;

    foreach (const ref header; headerPositions) {
        switch (header[0]) {
            case MLHeaders.title:
                titleIndex = header[1];
                break;
            case MLHeaders.progress:
                progressIndex = header[1];
                break;
            case MLHeaders.status:
                statusIndex = header[1];
                break;
            case MLHeaders.startDate:
                startDateIndex = header[1];
                break;
            case MLHeaders.endDate:
                endDateIndex = header[1];
                break;
            case MLHeaders.lastUpdated:
                lastUpdatedIndex = header[1];
                break;
            default:
                break;
        }
    }

    File listFile = File(list.filePath, "r");
    list.isOpen = true;
    scope(exit) list.isOpen = false;

    string line;
    bool pastHeader;
    size_t currentIndex = 1;

    while ((line = listFile.readln()) !is null) {
        if (line.length == 0)
            continue;

        if (line[0] == '#') {
            tempFile.write(line);
            continue;
        }

        if (false == pastHeader) {
            pastHeader = true;
            tempFile.write(line);
            continue;
        }

        if (currentIndex == id) {
            string[] sections = line.strip().split("\t");

            /* Add missing columns if required (e.g. updating pre-0.2 file). */
            if (sections.length < headerPositions.length) {
                while (sections.length < headerPositions.length)
                    sections ~= "";
            }

            if (title !is null)
                sections[titleIndex] = title;
            if (progress !is null)
                sections[progressIndex] = progress;

            if (status !is null) {
                const oldStatus = sections[statusIndex];
                
                /* temporary for comparison. */
                const lowerOldStatus = oldStatus.toLower;
                const lowerNewStatus = status.toLower;
                
                if ((lowerOldStatus == "plan-to-read" && lowerNewStatus == "reading") ||
                    (lowerOldStatus == "plan-to-watch" && lowerNewStatus == "watching")) {

                    if (startDate == "") {
                        Date date = cast(Date)Clock.currTime;
                        startDate = format!"%d-%02d-%02d"(date.year, date.month, date.day);
                        sections[startDateIndex] = startDate;
                    }
                }

                if (lowerNewStatus == "complete" &&
                    (lowerOldStatus == "reading" || lowerOldStatus == "watching")) {

                    if (endDate == "") {
                        Date date = cast(Date)Clock.currTime;
                        endDate = format!"%d-%02d-%02d"(date.year, date.month, date.day);
                        sections[endDateIndex] = endDate;
                    }
                }

                sections[statusIndex] = status;
            }

            if (startDate != "")
                sections[startDateIndex] = startDate;
            if (endDate != "")
                sections[endDateIndex] = endDate;

            Date date = cast(Date)Clock.currTime();
            sections[lastUpdatedIndex] = format!"%d-%02d-%02d"(date.year, date.month, date.day);
            tempFile.writeln(join(sections, "\t"));
        } else {
            tempFile.write(line);
        }

        currentIndex += 1;
    }

    listFile.close();
    tempFile.flush();
    tempFile.close();

    tempFile = File(tempFilePath, "r");
    listFile = File(list.filePath, "w+");

    while ((line = tempFile.readln()) !is null) {
        listFile.write(line);
    }
    listFile.flush();
}

private void _ml_update(MediaList* list, string[] args, out MLError error) nothrow
{
    import core.stdc.errno : errno, EACCES;

    error = MLError.success;

    try {
        _ml_update(list, args);
    } catch (MLException me) {
        error = me.error;
    } catch (StdioException se) {
        error = (errno == EACCES) ?
            MLError.permissionError :
            MLError.unspecifiedError;
    } catch (Exception e) {
        error = MLError.unspecifiedError;
    }
}

private enum MLHeaders
{
    title = 0,
    progress = 1,
    status = 2,
    startDate = 3,
    endDate = 4,
    lastUpdated = 5
}

private size_t[2][6] _ml_get_header_positions(MediaList* list)
{
    list.isOpen = true;
    scope(exit) list.isOpen = false;

    File f = File(list.filePath);
    string line;

    /*
     * [
     *   [ MLHeaders, tabIndent ],
     *   [ MLHeaders, tabIndent ],
     *   [ MLHeaders, tabIndent ],
     *   [...]
     * ]
     *
     * We keep the tab indent so we don't mess up any other programs or custom
     * configurations.
     */
    size_t[2][6] headerPositions = -1;
    int arrayPosition = 0;

    while ((line = f.readln) !is null) {
        /* skip configuration and comments */
        if (line[0] == '#')
            continue;

        /* first non-comment/non-configuration line is the header */
        string[] sections = line.strip().split("\t");

        foreach(idx, ref string section; sections) {
            switch(section.toLower)
            {
            case "title":
                headerPositions[arrayPosition] = [MLHeaders.title, idx];
                arrayPosition += 1;
                break;
            case "progress":
                headerPositions[arrayPosition] = [MLHeaders.progress, idx];
                arrayPosition += 1;
                break;
            case "status":
                headerPositions[arrayPosition] = [MLHeaders.status, idx];
                arrayPosition += 1;
                break;
            case "start_date":
                headerPositions[arrayPosition] = [MLHeaders.startDate, idx];
                arrayPosition += 1;
                break;
            case "end_date":
                headerPositions[arrayPosition] = [MLHeaders.endDate, idx];
                arrayPosition += 1;
                break;
            case "last_updated":
                headerPositions[arrayPosition] = [MLHeaders.lastUpdated, idx];
                arrayPosition += 1;
                break;
            default:
                break;
            }

            if (arrayPosition > MLHeaders.max)
                break;
        }
    }

    return headerPositions;
}