source/nl.nlsw.Items.cs
// __ _ ____ _ _ _ _ ____ ____ ____ ____ ____ ___ _ _ ____ ____ ____
// | \| |=== |/\| |___ | |--- |=== ==== [__] |--- | |/\| |--| |--< |=== // /// @file nl.nlsw.Items.cs /// @copyright Ernst van der Pols, Licensed under the EUPL-1.2-or-later using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using System.Xml; /// /// Base classes for collections of items with attributes properties. /// /// @author Ernst van der Pols /// @date 2022-10-22 /// @requires .NET Framework 4.5 /// namespace nl.nlsw.Items { /// Attributes (or parameters), i.e. key=value pairs /// The base class allows for multiple entries with the same key, /// using case insensitive CultureInvariant key comparison. public class Attributes : System.Collections.Specialized.NameValueCollection { /// Check if the attribute with the specified name contains the specified value. /// Uses a case-sensitive comparison. public bool HasValue(string name, string value) { string[] values = GetValues(name); if (values != null) { foreach (string v in values) { if (String.Compare(v,value,StringComparison.Ordinal) == 0) { return true; } } } return false; } /// Check if the attribute with the specified name contains exactly the specified values. /// Uses a comparison as specified. public bool HasEqualValues(string name, string[] values, StringComparison comparison = StringComparison.Ordinal) { string[] vs = GetValues(name); if ((vs == null) ^ (values == null)) { return false; } if (vs != null) { if (vs.Length != values.Length) { return false; } foreach (string v1 in vs) { bool found = false; foreach (string v2 in values) { if (String.Compare(v1,v2,comparison) == 0) { found = true; break; } } if (!found) { return false; } } } // both null or all strings match return true; } /// Check if the attribute with the specified name has exactly the same /// values in the other Attributes. /// Uses a case-sensitive comparison. public bool HasEqualValues(string name, Attributes other) { return HasEqualValues(name,other.GetValues(name),StringComparison.Ordinal); } /// Check if the attribute with the specified name has exactly the same /// values in the other Attributes. /// Uses a case-insensitive comparison. public bool HasEqualValuesIgnoreCase(string name, Attributes other) { return HasEqualValues(name,other.GetValues(name),StringComparison.OrdinalIgnoreCase); } /// Check if the attribute with the specified name contains the specified value. /// Uses a case-insensitive comparison. public bool HasValueIgnoreCase(string name, string value) { string[] values = GetValues(name); if (values != null) { foreach (string v in values) { if (String.Compare(v,value,StringComparison.OrdinalIgnoreCase) == 0) { return true; } } } return false; } /// Add the value once to the attribute with the specified name. /// If the attribute already contains this value, it is not added again. /// Uses a case-sensitive comparison. public void AddOnce(string name, string value) { if (HasValue(name,value)) { return; } Add(name,value); } /// Add the value once to the attribute with the specified name. /// If the attribute already contains this value, it is not added again. /// Uses a case-insensitive comparison. public void AddOnceIgnoreCase(string name, string value) { if (HasValueIgnoreCase(name,value)) { return; } Add(name,value); } /// Remove the specified value from the specified attribute. /// Uses a case-insensitive value comparison. public void RemoveValue(string name, string value) { string[] values = GetValues(name); if ((values != null) && (value != null)) { Remove(name); for (int i = 0; i < values.Length; i++) { if (String.Compare(values[i],value,StringComparison.Ordinal) != 0) { Add(name,values[i]); } } } } /// Remove the specified value from the specified attribute. /// Uses a case-insensitive value comparison. public void RemoveValueIgnoreCase(string name, string value) { string[] values = GetValues(name); if ((values != null) && (value != null)) { Remove(name); for (int i = 0; i < values.Length; i++) { if (String.Compare(values[i],value,StringComparison.OrdinalIgnoreCase) != 0) { Add(name,values[i]); } } } } /// Counterpart of GetValues(), missing in the base class. public void SetValues(string name, string[] values) { if (values == null || values.Length == 0) { Remove(name); } else { Set(name,values[0]); for (int i = 1; i < values.Length; i++) { Add(name,values[i]); } } } } /// /// A CompoundValue is a data value container that can represent a single "line" /// of a Comma-Separated-Values data file, as specified in RFC 4180. /// /// The class extends this specification to support nested CompoundValues as well, /// i.e. any value can be a comma-separated-value that (recursively) holds another /// list of comma-separated-values, surrounded by parentheses. /// This extension provides for a simple format for serializing an ordered, directed /// rooted tree of data nodes. /// /// The class is based on a generic object list. For serialization to string the ToString() /// operation of the objects are used. De-serialization builds a CompoundValue tree of strings. /// A nested CompoundValue has a link to is Parent CompoundValue. /// /// @see https://tools.ietf.org/html/rfc4180 /// @see https://en.wikipedia.org/wiki/Comma-separated_values /// @see https://www.loc.gov/preservation/digital/formats/fdd/fdd000323.shtml /// @see https://en.wikipedia.org/wiki/Tree_(graph_theory) /// public class CompoundValue : global::System.Collections.Generic.List<object> { const char ValueSeparator = ','; const char DoubleQuote = '"'; const char CompoundOpen = '('; const char CompoundClose = ')'; /// Characters that require escaping public static readonly char[] Delimiters = {',','"','\r','\n', CompoundOpen,CompoundClose}; /// The depth of this node in the tree. public int Depth { get { int result = 0; for (CompoundValue p = Parent; p != null; result++, p = p.Parent) { } return result; } } /// The parent node. public CompoundValue Parent { get; set; } /// Default Constructor (defines the parent) public CompoundValue(CompoundValue parent = null) { Parent = parent; } /// Initializing constructor public CompoundValue(string value) { FromString(value); } /// Get a value at the specified index, null if not present or index out-of-range. public object GetValue(int index) { if (index < Count) { return this[index]; } return null; } /// Set the CompoundValue to the values in the list, array, or other enumerable /// @note List<T> has a constructor for IEnumerable, but no assignment public void FromEnumerable(IEnumerable list) { Clear(); if (list != null) { foreach (object item in list) { Add(item); } } } /// Set the CompoundValue to the (comma-separated) values specified in the string. /// @exception FormatException if the value has an invalid format public void FromString(string value) { Clear(); if (string.IsNullOrEmpty(value)) { return; } CompoundValue current = this; int start = 0, position; for (; (position = value.IndexOfAny(Delimiters,start)) != -1; start = position + 1) { switch (value[position]) { case ValueSeparator: current.Add(value.Substring(start,position - start)); break; case DoubleQuote: if (position != start) { // this delimiter must here be at start of field throw new FormatException("unescaped DOUBLE-QUOTE character in CompoundValue"); } start++; { // escaped value, scan for next DoubleQuote position = value.IndexOf(DoubleQuote,start); bool doubles = false; while ((position >= 0) && ((position + 1) < value.Length) && (value[position + 1] == DoubleQuote)) { // escaped DoubleQuote: needs to be replaced with single DoubleQuote doubles = true; position = value.IndexOf(DoubleQuote,position + 2); } if (position < 0) { throw new FormatException("unclosed escaped CompoundValue (missing DOUBLE-QUOTE)"); } string s = value.Substring(start); if (doubles) { s = s.Replace("\"\"","\""); } current.Add(s); } break; case CompoundOpen: if (position != start) { // this delimiter must here be at start of field throw new FormatException(String.Format("unescaped '{0}' character in CompoundValue",CompoundOpen)); } // increase nesting level CompoundValue cv = new CompoundValue(current); // set at this location current.Add(cv); // switch reading to nested level current = cv; break; case CompoundClose: current.Add(value.Substring(start,position - start)); // decrease nesting level if (current.Parent == null) { throw new FormatException("unbalanced CompoundValue: closing delimiter without earlier opening delimiter"); } current = current.Parent; // skip the CompoundClose position++; // this delimiter must be followed by 1) nothing (end-of-line) or 2) comma if ((position < value.Length) && (value[position] != ValueSeparator)) { throw new FormatException(String.Format("missing value separator '{0}' after enquoted value",ValueSeparator)); } break; case '\r': throw new FormatException(String.Format("unescaped CARRIAGE RETURN character in CompoundValue",CompoundOpen)); case '\n': throw new FormatException(String.Format("unescaped LINEFEED character in CompoundValue",CompoundOpen)); default: throw new FormatException("unescaped CompoundValue delimiter encountered: "+value[position]); } } if (start < value.Length) { // no delimiters found, a single simple string has remained current.Add(value.Substring(start)); } if (current != this) { throw new FormatException("unbalanced CompoundValue: opening delimiter without closing delimiter"); } } /// Set the value at the specified index. /// If the index is outside the current list, the list is expanded to public void SetValue(int index, object value) { while (index >= Count) { Add(null); } this[index] = value; } /// /// Match the (text) value or the fields of the CompoundValue with the specified regular expression. /// @return the first successful match encountered, or null otherwise public static Match TextValueMatch(object value, System.Text.RegularExpressions.Regex regex) { Match result = null; if (value is CompoundValue) { for (int i = 0; i < ((CompoundValue)value).Count; i++) { result = TextValueMatch(((CompoundValue)value)[i],regex); if ((result != null) && result.Success) { return result; } } } else if (value != null) { result = regex.Match(value.ToString()); if (result != null && result.Success) { return result; } } // null encountered or no success return null; } /// /// Test whether the (compound) text value matches the regular expression. /// public static bool TextValueMatches(object value, System.Text.RegularExpressions.Regex regex) { Match m = TextValueMatch(value,regex); return (m != null) && m.Success; } /// Get the fields as a string, separated with the specified separator. /// Optionally, empty fields are included in the result, and nested fields can be indicated. public string ToFormattedString(int[] indices = null, string separator = " ", string open = null, string close = null, bool includeEmpty = false) { StringBuilder sb = new StringBuilder(); ToStringBuilderFormatted(sb,indices,separator,open,close,includeEmpty); return sb.ToString(); } public override string ToString() { StringBuilder sb = new StringBuilder(); ToStringBuilder(sb); return sb.ToString(); } /// Appends the CompoundValue to the string builder. /// Uses recursion to write nested CompoundValues. public void ToStringBuilder(StringBuilder sb) { if (Parent != null) { sb.Append(CompoundOpen); } for (int i = 0; i < Count; i++) { object v = this[i]; if (i > 0) { sb.Append(ValueSeparator); } if (v is CompoundValue) { ((CompoundValue)v).ToStringBuilder(sb); } else if (v != null) { string s = v.ToString(); if (s.IndexOfAny(Delimiters) >= 0) { // enclose this value with DoubleQuotes sb.Append(DoubleQuote); int start = sb.Length; sb.Append(s); // replace DoubleQuote with 2DoubleQuote sb.Replace("\"","\"\"",start,s.Length); sb.Append(DoubleQuote); } else { sb.Append(s); } } } if (Parent != null) { sb.Append(CompoundClose); } } /// Appends the CompoundValue to the string builder, using the specified fields and format delimiters. /// Uses recursion to write nested CompoundValues. /// @param sb the output string builder /// @param indices the field indices to include; these apply only to this level of values; null means all fields. /// @param separator the separator to use /// @param open the nested value(s) opening delimiter /// @param close the nested value(s) closing delimiter (ignored if 'open' is null) /// @param includeEmpty By default empty fields are not included, but if you want the delimiters can be written for empty fields as well. public void ToStringBuilderFormatted(StringBuilder sb, int[] indices = null, string separator = " ", string open = null, string close = null, bool includeEmpty = false) { int openPos = sb.Length; if (Parent != null) { sb.Append(open); } int startPos = sb.Length; for (int i = 0; (indices == null) ? i < Count : i < indices.Length; i++) { object v = this[(indices == null) ? i : indices[i]]; int separatorPos = -1; if (sb.Length > startPos) { separatorPos = sb.Length; sb.Append(separator); } if (v is CompoundValue) { ((CompoundValue)v).ToStringBuilderFormatted(sb,null,separator,open,close,includeEmpty); } else if (v != null) { string s = v.ToString(); sb.Append(s); } if (!includeEmpty && (separatorPos >= 0) && (separator != null)) { // determine if we should remove the separator if (sb.Length == (separatorPos + separator.Length)) { sb.Remove(separatorPos,separator.Length); } } } if (Parent != null && (open != null)) { if (!includeEmpty) { // determine if we should remove the opening delimiter if (sb.Length == (openPos + open.Length)) { sb.Remove(openPos,open.Length); } } else { sb.Append(close); } } } } /// Class of properties that can have multiple values. /// /// The values are typically contained in a CompoundValue container. /// Three subclasses can be distinghuished: /// - list properties: a list of multiple similar values /// - structured properties: a set of sub-properties, each with its own name /// - a combination of both, e.g. an ordered list of values /// public class CompoundProperty : Property { /// Get the value as CompoundValue /// @note may return null public CompoundValue CompoundValue { get { return Value as CompoundValue; } set { Value = value; } } /// Get the names of the compound fields (if any) public virtual string[] FieldNames { get; set; } /// Get the number of values (first order) public int ValueCount { get { if (Value == null) return 0; if (CompoundValue == null) return 1; return CompoundValue.Count; } } /// Default constructor public CompoundProperty() { } /// Initializing Constructor public CompoundProperty(string name, object value = null) : base(name,value) { } /// Add the value, optionally at the specified index. public void AddValue(object value, int? index = null) { // check if we have a compound value to hold the new value CompoundValue cv = base.Value as CompoundValue; if (cv == null) { cv = new CompoundValue(); if (base.Value != null) { // preserve the degenerate first value cv.Add(base.Value); } base.Value = cv; } if (index == null) { // simply add the value to the Value list cv.Add(value); } else { // add the new value to the list at the specified index object atIndex = cv.GetValue((int)index); // do we have a CompoundValue there? CompoundValue svc = atIndex as CompoundValue; if (svc == null) { // no, create one svc = new CompoundValue(cv); if (atIndex != null) { // preserve the (degenerate) first value of the list svc.Add(atIndex); } // set the CompoundValue at the index cv.SetValue((int)index,svc); } // add the value to the list svc.Add(value); } } /// Get the name of the compound field at the specified index. public string GetFieldName(int index) { if (FieldNames == null) { throw new Exception("FieldNames not initialized"); } return FieldNames[(index < FieldNames.Length ? index : FieldNames.Length - 1)]; } /// Get the value, optionally at the specified indices. public object GetValue(int? index = null, int? subIndex = null) { object value = base.Value; if (index == null) { return value; } if (value is CompoundValue) { value = ((CompoundValue)value).GetValue((int)index); if (subIndex == null) { return value; } if (value is CompoundValue) { return ((CompoundValue)value).GetValue((int)subIndex); } else if (subIndex == 0) { return value; } return null; } else if ((index == 0) && (subIndex == null || subIndex == 0)) { return value; } return null; } /// Get the values as string, optionally at the specified field index. public string[] GetValuesAsString(int? index = null) { object value = base.Value; if ((index != null) && (value is CompoundValue)) { // get the string values of the specified field value = ((CompoundValue)value).GetValue((int)index); } string[] result = null; if (value is CompoundValue) { CompoundValue cv = (CompoundValue)value; result = new string[cv.Count]; for (int i = 0; i < cv.Count; i++) { if (cv[i] != null) { result[i] = cv[i].ToString(); } } } else if (value != null) { result = new string[1]; result[0] = value.ToString(); } return result; } /// Set the value, optionally at the specified indices. public void SetValue(object value, int index = 0, int subIndex = 0) { // check if we have a compound value to hold the new value CompoundValue cv = base.Value as CompoundValue; if (cv == null) { if ((index == 0) && (subIndex == 0)) { // degenerate case: store the value directly base.Value = value; return; } // we need a CompoundValue, so create one to hold the value cv = new CompoundValue(); if (base.Value != null) { // preserve the degenerate first value cv.Add(base.Value); } base.Value = cv; } // check if we have a CompoundValue to hold the value object atIndex = cv.GetValue(index); CompoundValue scv = atIndex as CompoundValue; if (scv == null) { if (subIndex == 0) { // degenerate case: store the value directly cv.SetValue(index,value); return; } scv = new CompoundValue(cv); if (atIndex != null) { // preserve the degenerate first value scv.Add(atIndex); } cv.SetValue(index,scv); } scv.SetValue(subIndex, value); } /// Set the values, optionally at the specified index. /// @note other values (at the index) are removed public void SetValuesAsString(string[] values, int? index = null) { if (values == null) { throw new ArgumentNullException("values"); } CompoundValue cv = base.Value as CompoundValue; if (cv == null) { // we need a CompoundValue, so create one to hold the values cv = new CompoundValue(); base.Value = cv; } if (index == null) { // replace existing fields with the values cv.Clear(); cv.AddRange(values); } else { // replace values of field 'index' // check if we have a CompoundValue to hold the value CompoundValue scv = cv.GetValue((int)index) as CompoundValue; if (scv == null) { scv = new CompoundValue(cv); cv.SetValue((int)index,scv); } // replace existing subfields with the values scv.Clear(); scv.AddRange(values); } } } /// Extension methods on standard .NET classes public static class Extensions { /// Return the substring after the specified search string. /// /// - If the value of `value` or `search` is the empty sequence, it is interpreted as the zero-length string. /// - If the value of `search` is the zero-length string, then the function returns the value of `value`. /// - If the value of `value` does not contain a string that is equal to the value of `search`, then the function returns the zero-length string. /// - The function returns the substring of the value of `value` that follows in the value of `value` the first occurrence of `search`. /// @param value the string to get a substring of /// @param search the string to search as prefix of the part to return /// @see https://www.w3.org/TR/xpath-functions-31/#func-substring-after public static string SubstringAfter(this string value, string search) { if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(search)) { int position = value.IndexOf(search); return (position < 0) ? string.Empty : value.Substring(position + search.Length); } return value; } } /// /// A keyed collection of ItemObjects. /// /// The ItemList has an internal dictionary for a fast lookup of items, based on their Identifier. /// Since the Identifier of an ItemObject is mutable, the update of the ItemObject.Identifier results in an update of the /// associated ItemList as well; the ItemObject holds a reference to the Dictionary for this. /// An ItemObject can only be in one ItemList. /// @see https://docs.microsoft.com/en-us/dotnet/api/system.collections.objectmodel.keyedcollection-2?view=netframework-4.5.2 /// public class ItemList : System.Collections.ObjectModel.KeyedCollection<string,ItemObject> { /// The default constructor. /// The specified dictionary threshold 0 means that the internal Dictionary /// is created the first time an object is added. public ItemList() : base(null, 0) { } /// Create a new ItemObject and add it to the Dictionary. /// @param name the (display) name of the ItemObject /// @param id a unique identifier URI; by default a new UUID URI is generated public ItemObject NewItem(string name = null, nl.nlsw.Identifiers.Uri id = null) { ItemObject result = new ItemObject(name,id); Add(result); return result; } /// Get the key of the ItemObject object. protected override string GetKeyForItem(ItemObject item) { // The Identifier is the key. return item.Identifier.ToString(); } protected override void InsertItem(int index, ItemObject newItem) { if (newItem.ItemList != null) { throw new ArgumentException("The item is already registered in a list.",newItem.Name); } base.InsertItem(index, newItem); newItem.ItemList = this; } /// Move the items from the other ItemList into this one. /// @post other will be empty /// @exception one of the imported items has a key that already exists public void MoveFrom(ItemList other) { for (int i = other.Count - 1; i >= 0; i--) { ItemObject p = other[i]; other.RemoveItem(i); Add(p); } } protected override void SetItem(int index, ItemObject newItem) { ItemObject replaced = Items[index]; if (newItem.ItemList != null) { throw new ArgumentException("The item is already registered in a list.",newItem.Name); } base.SetItem(index, newItem); newItem.ItemList = this; replaced.ItemList = null; } protected override void RemoveItem(int index) { ItemObject removedItem = Items[index]; base.RemoveItem(index); removedItem.ItemList = null; } protected override void ClearItems() { foreach (ItemObject item in Items) { item.ItemList = null; } base.ClearItems(); } /// To be called from ItemObject.Identifier.set internal void ChangeKey(ItemObject item, string newKey) { base.ChangeItemKey(item, newKey); } } /// /// The ItemObject class represents a named item with a unique identifier, /// that is collected in an ItemList. /// /// An ItemObject may have properties. Each ItemObject is registered in a single ItemList. /// /// @note The name Item cannot be used if you want the class to have an indexer property. /// The .NET runtime uses the name "Item" for these properties, and this results /// in the compiler error "Item" member names cannot be the same as their /// enclosing type. /// @note The use of the name 'Thing' for this class is rejected, because we don't want /// the condition "Person is Thing" to be true. /// @see https://schema.org/Thing /// public class ItemObject { /// The ItemList (keyed collection of items) that this ItemObject belongs to. private ItemList _ItemList = null; /// Unique identifier of the item, e.g. a urn:uuid private nl.nlsw.Identifiers.Uri _Identifier = null; /// Properties of the item private Properties _Properties = null; /// Get a property or the properties by case insensitive name [System.Xml.Serialization.XmlIgnore()] public object this[string name] { get { if (_Properties != null) { return _Properties[name]; } return null; } } /// The ItemList that this ItemObject belongs to. [System.Xml.Serialization.XmlIgnore()] public ItemList ItemList { get { return this._ItemList; } internal set { this._ItemList = value; } } public bool HasProperties { get { return (_Properties != null) && (_Properties.Count > 0); } } /// /// The unique identifier of the ItemObject. /// The value should be a normalized Uri, such that a string /// comparison can be used for equality check. /// The Uri.Equals is not suited since e.q. a comparison of a mailto-uri (i.e. an /// e-mail address) will fail since UserInfo is not included in the Uri.Equals. /// A UUID is preferred as identifier. /// /// The (string value) Identifier is used as key in the associated ItemList. /// If the Identifier is changed, the ItemList is automatically updated. /// In that case, an ArgumentException is thrown if the new value is null or an existing key. /// [System.Xml.Serialization.XmlIgnore()] public nl.nlsw.Identifiers.Uri Identifier { get { return this._Identifier; } set { if (ItemList != null) { // @todo change Identifier comparison from string to the Identifier object itself ItemList.ChangeKey(this, value == null ? null : value.ToString()); } this._Identifier = value; } } /// Get an icon character representing the (type of) ItemObject. /// @note to represent any Unicode char, you need a string in C#, /// since a char is only 16-bit [System.Xml.Serialization.XmlIgnore()] public virtual string IconChar { get { return "\u2022"; } // BULLET } [System.Xml.Serialization.XmlIgnore()] public string Name { get; set; } [System.Xml.Serialization.XmlIgnore()] public Properties Properties { get { if (_Properties == null) { _Properties = new Properties(); } return _Properties; } } /// Default and initializing constructor /// @param name the (display) name of the ItemObject /// @param id a unique identifier URI; by default a new UUID URI is generated public ItemObject(string name = null, nl.nlsw.Identifiers.Uri id = null) { if (id == null) { // create a new UUID URI id = nl.nlsw.Identifiers.UrnUri.NewUuidUrnUri(); } this.Identifier = id; this.Name = name; } /// Get the replacement value of the specified macro for this object. /// @param macro the macro to get the value of /// @return the macro value, or null if not available public virtual string GetMacroValue(string macro) { if (string.Equals("name",macro,StringComparison.OrdinalIgnoreCase)) { return this.Name; } else if (string.Equals("id",macro,StringComparison.OrdinalIgnoreCase)) { return (this.Identifier == null) ? null : this.Identifier.ToString(); } else if (string.Equals("uuid",macro,StringComparison.OrdinalIgnoreCase)) { return nl.nlsw.Identifiers.UrnUri.GetUUIDString(this.Identifier); } return null; } /// Get properties by name, and (optionally) attribute name and value. public Properties GetProperties(string name, string attrName = null, string attrValue = null) { if (_Properties != null) { return _Properties.Get(name,attrName,attrValue); } return null; } /// Get the first property by name, and (optionally) attribute name and value. public Property GetProperty(string name, string attrName = null, string attrValue = null) { if (_Properties != null) { return _Properties.GetProperty(name,attrName,attrValue); } return null; } public override string ToString() { return (string.IsNullOrEmpty(Name) ? (Identifier == null ? null : Identifier.ToString()) : Name); } } /// A stack of ItemObjects, typically used when processing nested sets of ItemObjects. public class ItemStack : System.Collections.Generic.Stack<ItemObject> { } /// A Property of an ItemObject. /// /// Class of attributed Name=Value objects, holding a property of an ItemObject. /// The value of the property can be formatted in different ways based on a IFormatProvider. /// public class Property : IFormattable { private Attributes _attrs = null; private object _Value; /// the name of the attribute that holds the group name of the property public static readonly string GroupNameAttribute = ".group"; /// The name of the group that the property belongs to. public string GroupName { get { return GetAttribute(GroupNameAttribute); } set { SetAttribute(GroupNameAttribute, value); } } /// The attributes of the property public Attributes Attributes { get { if (_attrs == null) { _attrs = new Attributes(); } return _attrs; } } /// Get or set an attribute of the property. public string this[string name] { get { return GetAttribute(name); } set { SetAttribute(name, value); } } /// Get an icon character representing the (type of) Property. /// @note to represent any Unicode char, you need a string in C#, /// since a char is only 16-bit public virtual string IconChar { // U+214A PROPERTY LINE // (think of the symbol as indicating the start of another property) get { return "\u214A"; } } /// The name of the property public string Name { get; set; } /// The value of the property public virtual object Value { get { return _Value; } set { _Value = value; } } /// The type of the value public virtual string ValueType { get { return "text"; } } public bool HasAttributes { get { return (_attrs != null) && (_attrs.Count > 0); } } /// Class constructor static Property() { } /// Default constructor public Property() { } /// Initializing Constructor public Property(string name, object value = null) { this.Name = name; this.Value = value; } /// Get the value of the specified attribute. If not present, the default value is returned. /// If the attribute has multiple values, they are returned as a comma-separated list, without proper enquoting. public string GetAttribute(string name, string defaultValue = null) { if (_attrs != null) { return _attrs.Get(name) ?? defaultValue; } return defaultValue; } /// Test whether the property has an attribute with the specified name and (optionally) value combination. /// Name comparison is case-insensitive, value comparison is case-sensitive. public bool HasAttribute(string name, string value = null) { if (_attrs != null) { string[] values = _attrs.GetValues(name); if (values != null) { return (value == null) || (System.Array.IndexOf(values,value) >= 0); } } return false; } /// Check if this property has the specified name. /// Performs a case-insensitive compare. public bool HasName(string name) { return (String.Compare(Name,name,StringComparison.OrdinalIgnoreCase) == 0); } /// Test whether the attribute name represents an internal, hidden attribute. /// A name represents a hidden attribute if: /// - the name is null or empty, indicating no attribute, which is hidden by its nature /// - it starts with a FULL STOP, indicating an internal attribute public virtual bool IsHiddenAttribute(string name) { return string.IsNullOrEmpty(name) || name.StartsWith(".",StringComparison.Ordinal); } /// Set the value of the specified attribute. public void SetAttribute(string name, string value) { Attributes.Set(name,value); } /// Object.ToString() /// To be used for general display of the property /// By default, return Value.ToString() public override string ToString() { return Value == null ? null : Value.ToString(); } /// IFormattable.ToString() /// To be used for (formatted) output of the property value. /// By default, returns Value.ToString() public virtual string ToString(string format, IFormatProvider formatProvider) { return Value == null ? null : Value.ToString(); } } /// A list of properties /// The list may contain multiple properties with the same name. public class Properties : List<Property> { /// Get a property or the properties by case insensitive name public object this[string name] { get { Property first = null; Properties list = null; foreach (Property prop in this) { if (prop.HasName(name)) { if (first == null) { first = prop; } else { if (list == null) { list = new Properties(); list.Add(first); } list.Add(prop); } } } return (list == null ? (object)first : (object)list); } } /// Add a property /// @deprecated use a factory method API for type specific properties public Property AddProperty(string name, object value) { Property prop = new Property(name,value); Add(prop); return prop; } /// Get the properties by (optionally) case insensitive name, /// and (optionally) case insensitive attribute name, /// and (optionally) case sensitive attribute value. /// E.g. get all properties with a specific attribute (value) by setting name to null. public Properties Get(string name, string attrName = null, string attrValue = null) { Properties result = null; foreach (Property prop in this) { if (((name == null) || prop.HasName(name)) && ((attrName == null) || prop.HasAttribute(attrName,attrValue))) { if (result == null) { result = new Properties(); } result.Add(prop); } } return result; } /// Get the first property by (optionally) case insensitive name, /// and (optionally) case insensitive attribute name, /// and (optionally) case sensitive attribute value. public Property GetProperty(string name, string attrName = null, string attrValue = null) { foreach (Property prop in this) { if (((name == null) || prop.HasName(name)) && ((attrName == null) || prop.HasAttribute(attrName,attrValue))) { return prop; } } return null; } /// Remove all properties with the specified name public void RemoveProperty(string name) { for (int i = Count-1; i >= 0; i--) { if (this[i].HasName(name)) { Remove(this[i]); } } } } /// Base class for reading ItemObjects from a stream public class Reader : System.IDisposable { /// declare the stack used during parsing (nested) items private ItemStack _ItemStack = new nl.nlsw.Items.ItemStack(); /// the default encoding of the source text private System.Text.Encoding _DefaultEncoding; /// the buffer for unfolding content lines private System.Text.StringBuilder _ContentLine = new System.Text.StringBuilder(); /// the buffer for lines to process when no format is known yet private List<string> _LineCache; /// Track whether Dispose has been called. private bool _Disposed = false; /// Buffer for unfolding the current content line public System.Text.StringBuilder ContentLine { get { return _ContentLine; } } /// The current source encoding public System.Text.Encoding CurrentEncoding { get { System.IO.StreamReader sr = this.TextReader as System.IO.StreamReader; if (sr != null) { // the StreamReader may have detected another encoding than the default return sr.CurrentEncoding; } return _DefaultEncoding; } } /// The current ItemObject public ItemObject CurrentItem { get { return (_ItemStack.Count == 0) ? null : _ItemStack.Peek(); } } /// The target ItemList for read ItemObjects public ItemList CurrentItemList { get; set; } /// The default Encoding to use public System.Text.Encoding DefaultEncoding { get { return _DefaultEncoding; } } /// The number of files read public int FileCount { get; set; } /// The current file being read public System.IO.FileInfo FileInfo { get; set; } /// The name of the file or other source being read public string FileName { get { if (FileInfo != null) { return FileInfo.FullName; } if (TextReader != null) { System.IO.StreamReader sr = TextReader as System.IO.StreamReader; if (sr != null) { if (sr.BaseStream is System.IO.FileStream) { return ((System.IO.FileStream)sr.BaseStream).Name; } if (sr.BaseStream is System.Net.Sockets.NetworkStream) { // no more info retrievable return "<networkstream>"; } } return "<stream>"; } return "<pipe>"; } } /// Check if the reader has cached lines public bool HasCachedLines { get { return _LineCache != null && _LineCache.Count > 0; } } /// Check if the reader has a content line read public bool HasContentLine { get { return _ContentLine.Length > 0; } } /// The buffer for lines to process when no format is known yet public List<string> LineCache { get { if (_LineCache == null) { _LineCache = new List<string>(); } return _LineCache; } } /// The number of ItemObjects read public int ItemCount { get; set; } /// Next line must be joined with the previous one, /// as part of QuotedPrintable encoded data public bool QuotedPrintableFolding { get; set; } /// To keep track of the ItemObject nesting public ItemStack Stack { get { return _ItemStack; } } /// The TextReader to use for reading public System.IO.TextReader TextReader { get; set; } /// Default constructor public Reader() { _DefaultEncoding = System.Text.Encoding.UTF8; } /// Initializing constructor public Reader(System.Text.Encoding defaultEncoding = null) { _DefaultEncoding = defaultEncoding ?? System.Text.Encoding.UTF8; } /// Implementation of IDisposable public void Dispose() { Dispose(true); // This object will be cleaned up by the Dispose method. // Therefore, you should call GC.SuppressFinalize to // take this object off the finalization queue // and prevent finalization code for this object // from executing a second time. GC.SuppressFinalize(this); } /// Dispose(bool disposing) executes in two distinct scenarios. /// If disposing equals true, the method has been called directly /// or indirectly by a user's code. Managed and unmanaged resources /// can be disposed. /// If disposing equals false, the method has been called by the /// runtime from inside the finalizer and you should not reference /// other objects. Only unmanaged resources can be disposed. protected virtual void Dispose(bool disposing) { if (!this._Disposed) { if (disposing) { // clean up managed resources if (TextReader != null) { TextReader.Dispose(); TextReader = null; } FileInfo = null; } this._Disposed = true; } } /// /// Decode a string containing a compound value, i.e. delimited fields, optionally with nested sub-fields. /// In addition, or alternatively, escaped characters can be unescaped. /// Escaped delimiters are unescaped in the returned result automatically. /// /// Example use cases: /// - the data string does not contain any of the delimiters: the input string is returned /// /// - the data string only contains escaped delimiters or other escape sequences: the unescaped string is returned /// @example "simple text string\, that may contain a delimiter like \\" => "simple text string, that may contain a delimiter like \" /// /// - a string containing delimited list members (a List<String> is returned with the members; actually a CompundValue, since that is also a List) /// @example "member 1,member\, with path \\,member 3" => ( "member 1", "member, with path \", "member 3" ) /// /// - a string containing delimited fields with simple string content or a list of members (a List<Object> is returned with the fields, either a String or a List<String>) /// @example "field 1;field2\, member 1, field2\, member 2;field 3" => ( "field 1", ( "field 2, member1", "field 2, member 2"), "field 3" ) /// /// Example application is decoding the vCard property value. /// /// @param data the input data string /// @param delimiters the first character in the array must be the escape character; subsequent characters are the ordered delimiters of the nested fields /// @param replacements the additional escape sequences that need to be decoded; the delimiters are replaced automatically; a non-specified escape sequence /// is left as-is. /// @return a System.String or a nl.nlsw.Items.CompoundValue /// public static object DecodeCompoundValue(string data, char[] delimiters, Dictionary<char,string> replacements = null) { int position = (delimiters != null) ? data.IndexOfAny(delimiters) : -1; if (position == -1) { // no delimiters specified or present, return the input string return data; } System.Text.StringBuilder sb = new System.Text.StringBuilder(); CompoundValue cv = null; string replacement; int start = 0; for (; (position = data.IndexOfAny(delimiters, position)) != -1; start = position) { sb.Append(data,start, position - start); start = position; char delim = data[position++]; int delindex = Array.IndexOf<char>(delimiters,delim); if (delindex < 0) { throw new InvalidOperationException("found compound value delimiter must be in the array of delimiters"); } else if (delindex == 0) { // the EscapeCharacter found if (position < data.Length) { if (Array.IndexOf<char>(delimiters,data[position]) != -1) { // copy the escaped delimiter sb.Append(data[position]); } else if ((replacements != null) && replacements.TryGetValue(data[position],out replacement)) { // it is one of the replacements sb.Append(replacement); } else { // other character: copy both (keep original text) and continue sb.Append(data,start,2); } position++; } else { // EscapeChar at end of data: copy and finish sb.Append(delim); } } else { // delimiter is one of the compound field delimiters if (cv == null) { // start a compound value for this delimiter cv = new CompoundValue(); } int depth = delindex - 1; if (depth == cv.Depth) { // sibling field parsed cv.Add(sb.ToString()); sb.Clear(); } else if (depth > cv.Depth) { while (depth > cv.Depth) { // increase level CompoundValue child = new CompoundValue(cv); cv.Add(child); cv = child; } cv.Add(sb.ToString()); sb.Clear(); } else { cv.Add(sb.ToString()); sb.Clear(); while (depth < cv.Depth) { if (cv.Parent == null) { throw new FormatException("stack underflow during compound value parsing"); } cv = cv.Parent; } } } } if (start < data.Length) { sb.Append(data,start, data.Length - start); } if (cv != null) { if (sb.Length > 0) { cv.Add(sb.ToString()); sb.Clear(); } while (cv.Parent != null) { cv = cv.Parent; } return cv; } // simply return the unescaped string, no compound value present return sb.ToString(); } /// Decode Base64 encoded text /// @see https://en.wikipedia.org/wiki/Base64 public static string DecodeBase64Text(string data, System.Text.Encoding encoding) { // convert base64 text to array byte[] bytes = System.Convert.FromBase64String(data); // convert bytes to text return encoding.GetString(bytes); } /// /// Decode Base64 encoded text recursively in a list /// public static System.Collections.IList DecodeBase64Text(System.Collections.IList data, System.Text.Encoding encoding) { for (int i = 0; i < data.Count; i++) { if (data[i] is string) { data[i] = DecodeBase64Text((string)(data[i]), encoding); } else if (data[i] is System.Collections.IList) { data[i] = DecodeBase64Text((System.Collections.IList)(data[i]), encoding); } } return data; } /// Decode Quoted-Printable text /// /// @see https://en.wikipedia.org/wiki/Quoted-printable /// @see https://stackoverflow.com/questions/2226554/c-class-for-decoding-quoted-printable-encoding public static string DecodeQuotedPrintable(string data, System.Text.Encoding encoding) { int position = data.IndexOf('='); if (position == -1) { // no fields, members, or escaped chars present, return the input string return data; } System.Text.StringBuilder result = new System.Text.StringBuilder(data.Length); System.Collections.Generic.List<byte> bytes = new System.Collections.Generic.List<byte>(); int start = 0; for (start = 0; ((position = data.IndexOf('=', position)) != -1) && (position + 2 < data.Length); start = position) { result.Append(data, start, position - start); do { position++; if ((data[position] == '\r') && (data[position + 1] == '\n')) { // unfold soft line breaks: remove "=\r\n" from data position += 2; } else { try { // hex-convert a byte bytes.Add(System.Convert.ToByte(data.Substring(position, 2), 16)); } catch (Exception ex) { throw new Exception(data.Substring(position, 2),ex); } position += 2; } } while ((position < data.Length) && (data[position] == '=')); if (bytes.Count > 0) { // convert bytes to text string equivalent = encoding.GetString(bytes.ToArray()); result.Append(equivalent); bytes.Clear(); } } if (start < data.Length) { result.Append(data, start, data.Length - start); } return result.ToString(); } /// Decode Quoted-Printable text recursively in a list /// public static System.Collections.IList DecodeQuotedPrintable(System.Collections.IList data, System.Text.Encoding encoding) { for (int i = 0; i < data.Count; i++) { if (data[i] is string) { data[i] = DecodeQuotedPrintable((string)(data[i]), encoding); } else if (data[i] is System.Collections.IList) { data[i] = DecodeQuotedPrintable((System.Collections.IList)(data[i]), encoding); } } return data; } } /// /// Base class for writing ItemObjects. /// Writing to string is supported via the StringWriter base class. /// Creating an XmlDocument is also supported. /// @todo specialize into TextWriter and XmlWriter ? public class Writer : System.IO.StringWriter, System.IFormatProvider, System.ICustomFormatter { /// The ItemObject being written private ItemObject _CurrentItem; /// The current node to add content to private System.Xml.XmlNode _CurrentNode; /// The current XmlDocument being written private System.Xml.XmlDocument _Document; /// The XML namespace manager associated with the current document private System.Xml.XmlNamespaceManager _NamespaceManager; /// Hashtable of XML namespaces private System.Collections.Hashtable _Namespaces; /// Write the XmlDocument with non-significant line breaks and hierarchical indenting /// for easy human reading. private bool _Indent = false; /// The culture to use during writing. /// @todo distinguish from the iFormatProvider of the StringWriter public System.Globalization.CultureInfo CultureInfo { get; set; } /// The current XmlNode to write to. /// By default of a specific node set the DocumentElement of the Document is returned. public System.Xml.XmlNode CurrentNode { get { if (_CurrentNode == null && Document != null) { return Document.DocumentElement; } return _CurrentNode; } set { _CurrentNode = value; } } /// The ItemObject being written public ItemObject CurrentItem { get { return _CurrentItem; } set { if (value != _CurrentItem) { if (_CurrentItem != null) { // auto-increment ItemCount ItemCount++; } } _CurrentItem = value; } } /// The XmlDocument to write to. public System.Xml.XmlDocument Document { get { return _Document; } set { if (_Document != value) { _Document = value; // refresh the associated namespacemanager on next get _NamespaceManager = null; } } } public override System.Text.Encoding Encoding { get { return System.Text.Encoding.UTF8; } } /// The current property (value) requires Base64 encoding, /// e.g. because of non-ASCII characters present public bool EncodeBase64 { get; set; } /// Write the XmlDocument with non-significant line breaks /// and hierarchical indenting for easy human reading. public bool Indent { get { return _Indent; } set { _Indent = value; } } /// The XmlNamespaceManager needed for SelectNodes() and SelectSingleNode(). public System.Xml.XmlNamespaceManager NamespaceManager { get { if ((_NamespaceManager == null) && (_Document != null)) { // create the manager _NamespaceManager = new XmlNamespaceManager(_Document.NameTable); if (_Namespaces != null) { // fill with the registered namespaces foreach (DictionaryEntry entry in _Namespaces) { _NamespaceManager.AddNamespace(entry.Key.ToString(), entry.Value.ToString()); } } } return _NamespaceManager; } } /// The XML namespaces needed for SelectNodes() and SelectSingleNode(). public System.Collections.Hashtable Namespaces { get { return _Namespaces; } set { if (_Namespaces != value) { _Namespaces = value; // clear the NamespaceManager to force an update on next get _NamespaceManager = null; } } } /// Keep track of the number of ItemObjects written public int ItemCount { get; set; } /// Get the Position in the underlying StringBuilder public int Position { get { return GetStringBuilder().Length; } } /// Default constructor /// Uses the invariant culture, for persistent storage. public Writer() : base(System.Globalization.CultureInfo.InvariantCulture) { } /// Initializing constructor /// Uses the invariant culture, for persistent storage. public Writer(IFormatProvider provider) : base(provider) { } /// Encode text as Base64 encoded text /// @see https://en.wikipedia.org/wiki/Base64 public static string EncodeBase64Text(string data, System.Text.Encoding encoding) { // get the byte representation of the text data, for the given encoding byte[] bytes = encoding.GetBytes(data); // convert binary data to base64 text string return System.Convert.ToBase64String(bytes); } /// /// Flushes the contents of the internal string builder to the specified file, /// and clears the string builder. /// The file is written in UTF-8 wihout BOM. /// public void FlushToFile(string filename) { System.Text.StringBuilder sb = GetStringBuilder(); // write UTF8 text without BOM System.IO.File.WriteAllText(filename, sb.ToString()); // clear the buffer sb.Clear(); } /// /// Flushes the contents of the internal string builder to a string, /// and clears the string builder. /// @return the content string /// public string FlushToString() { System.Text.StringBuilder sb = GetStringBuilder(); string result = sb.ToString(); // clear the buffer sb.Clear(); return result; } /// Format a Property (value) /// ICustomFormatter.Format() public virtual string Format(string format, object arg, IFormatProvider formatProvider) { return null; } /// IFormatProvider.GetFormat() public virtual object GetFormat(Type formatType) { return this; } /// /// Perform line folding on the (possibly long) line in the string buffer (starting at startIndex), /// by inserting a LineBreak after each section of the line of which the byte representation of the /// encoding counts OctetsPerLine bytes or less. /// The string buffer is supposed to contain a single line, i.e. any newline characters already present /// are handled as normal characters. /// @param LineBreakLineChars the number of characters of the LineBreak to include in the next line /// public void OctetBasedLineFolding(int startIndex = 0, int OctetsPerLine = 75, string LineBreak = "\r\n ", int LineBreakLineChars = 1) { System.Text.StringBuilder sb = GetStringBuilder(); int maxBytesPerChar = Encoding.GetMaxByteCount(1); if ((startIndex < 0) || (startIndex >= sb.Length)) { throw new ArgumentOutOfRangeException("startIndex","startIndex must be in the string buffer range"); } if (OctetsPerLine < maxBytesPerChar) { throw new ArgumentOutOfRangeException("OctetsPerLine","OctetsPerLine seems rather small"); } // check if line folding may be necessary if ((maxBytesPerChar * (sb.Length - startIndex)) > OctetsPerLine) { // determine the chunk size: we take as muchs chars as possible, assume 1 byte per char int lineChunkOctets = OctetsPerLine; // copy the string buffer contents in a consecutive array char[] chars = new char [sb.Length - startIndex]; sb.CopyTo(startIndex,chars,0,sb.Length - startIndex); for (int start = 0, linestart = startIndex; start < chars.Length; ) { // determine the actual chunk size (limit to remaining number of chars) int count = Math.Min(lineChunkOctets,chars.Length - start); // count the bytes of the chunk of the line int numBytes = Encoding.GetByteCount(chars, start, count); // reduce the chunk while still too many bytes needed while ((count > 0) && (numBytes > lineChunkOctets)) { // reduce number of characters until number of bytes fit numBytes = Encoding.GetByteCount(chars, start, --count); } // do we need to insert a LineBreak? if ((chars.Length > (start + count)) && (count > 0)) { // yes sb.Insert(linestart + count,LineBreak); // account the LineBreak in the line position linestart += LineBreak.Length; // the next line is already started with some chars from the line break (those chars have 1 byte per char). lineChunkOctets = OctetsPerLine - LineBreakLineChars; } else { // we are done break; } start += count; linestart += count; } } } /// Write the specified field values of the specified entry as CSV line to the stringbuffer of the writer. /// @param fields the column (field) identifiers /// @param entry the hashtable with the entry values; if null the field identifiers are written (header line) public void WriteCSVLine(string[] fields, System.Collections.IDictionary entry = null) { // use our own encoder in CompoundValue nl.nlsw.Items.CompoundValue cv = new nl.nlsw.Items.CompoundValue(); if (entry != null) { foreach (string field in fields) { cv.Add(entry[field]); } } else { foreach (string field in fields) { cv.Add(field); } } // write the CSV line entry to the string buffer WriteLine(cv.ToString()); } } } |