murb/workbook

View on GitHub
lib/workbook/book.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen_string_literal: true
# frozen_string_literal: true

require "forwardable"
require "open-uri"
require "workbook/writers/xls_writer"
require "workbook/writers/xlsx_writer"
require "workbook/writers/html_writer"
require "workbook/readers/xls_reader"
require "workbook/readers/xls_shared"
require "workbook/readers/xlsx_reader"
require "workbook/readers/ods_reader"
require "workbook/readers/csv_reader"
require "workbook/readers/txt_reader"
require "workbook/modules/diff_sort"

module Workbook
  # The Book class is the container of sheets. It can be inialized by either the standard initalizer or the open method. The
  # Book class can also keep a reference to a template class, storing shared formatting options.
  #
  SUPPORTED_MIME_TYPES = [
    "application/zip",
    "text/plain",
    "application/x-ariadne-download",
    "application/vnd.ms-excel",
    "application/excel",
    "application/vnd.ms-office",
    "text/csv",
    "text/tab-separated-values",
    "application/x-ms-excel",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "application/vnd.oasis.opendocument.spreadsheet",
    "application/x-vnd.oasis.opendocument.spreadsheet",
    "CDF V2 Document, No summary info"
  ]

  class Book
    include Enumerable
    extend Forwardable

    include Workbook::Readers::XlsShared
    include Workbook::Writers::XlsWriter
    include Workbook::Writers::XlsxWriter
    include Workbook::Writers::HtmlWriter
    include Workbook::Readers::XlsReader
    include Workbook::Readers::OdsReader
    include Workbook::Readers::XlsxReader
    include Workbook::Readers::CsvReader
    include Workbook::Readers::TxtReader
    include Workbook::Modules::BookDiffSort

    delegate [:last, :pop, :delete_at, :each, :[]] => :@sheets

    # @param [Workbook::Sheet, Array] sheet    create a new workbook based on an existing sheet, or initialize a sheet based on the array
    # @return [Workbook::Book]
    def initialize sheet = nil
      @sheets = []
      push sheet if sheet
    end

    # @return [Workbook::Template] returns the template describing how the document should be/is formatted
    def template
      @template ||= Workbook::Template.new
    end

    # @param [Workbook::Format] template    a template describing how the document should be/is formatted
    def template= template
      raise ArgumentError, "format should be a Workboot::Format" unless template.is_a? Workbook::Template
      @template = template
    end

    # The title of the workbook
    #
    # @return [String] the title of the workbook
    def title
      defined?(@title) && !@title.nil? ? @title : "untitled document"
    end

    attr_writer :title

    # Push (like in array) a sheet to the workbook (parameter is optional, default is a new sheet)
    #
    # @param [Workbook::Sheet, Array[Array]] sheet
    def push sheet = Workbook::Sheet.new
      sheet = Workbook::Sheet.new(sheet) unless sheet.is_a? Workbook::Sheet
      sheet.book = self

      @sheets.push(sheet)
      sheet
    end

    # Inserts a new Table at the specified index
    #
    # @param [Integer] index of the table
    # @param [Workbook::Table, Array<Array>] table   The new first table of this sheet
    #
    # @return [Workbook::Table]
    def []= index, value
      table_to_insert = value.is_a?(Workbook::Sheet) ? value : Workbook::Sheet.new
      table_to_insert.book = self
      @sheets[index] = table_to_insert
    end

    # returns the index of an item
    def index item
      @sheets.index item
    end

    # << (like in array) a sheet to the workbook (parameter is optional, default is a new sheet)
    #
    # @param [Workbook::Sheet, Array[Array]] sheet
    def << sheet = Workbook::Sheet.new
      push sheet
    end

    # Sheet returns the first sheet of a workbook, or an empty one.
    #
    # @return [Workbook::Sheet] The first sheet, and creates an empty one if one doesn't exists
    def sheet
      first || push
    end

    # If the first sheet has any contents
    #
    # @return [Boolean] returns true if the first sheet has contents
    def has_contents?
      sheet.has_contents?
    end

    # Loads an external file into an existing worbook
    #
    # @param [String] filename   a string with a reference to the file to be opened
    # @param [String] extension  an optional string enforcing a certain parser (based on the file extension, e.g. 'txt', 'csv' or 'xls')
    # @return [Workbook::Book] A new instance, based on the filename
    def import filename, extension = nil, options = {}
      extension ||= file_extension(filename)
      if ["txt", "csv", "xml"].include?(extension)
        open_text filename, extension, options
      else
        open_binary filename, extension, options
      end
    end

    # Open the file in binary, read-only mode, do not read it, but pas it throug to the extension determined loaded
    #
    # @param [String] filename a string with a reference to the file to be opened
    # @param [String] extension an optional string enforcing a certain parser (based on the file extension, e.g. 'txt', 'csv' or 'xls')
    # @return [Workbook::Book] A new instance, based on the filename
    def open_binary filename, extension = nil, options = {}
      extension ||= file_extension(filename)
      f = File.open(filename)
      send("load_#{extension}".to_sym, f, options)
    end

    # Open the file in non-binary, read-only mode, read it and parse it to UTF-8
    #
    # @param [String] filename   a string with a reference to the file to be opened
    # @param [String] extension  an optional string enforcing a certain parser (based on the file extension, e.g. 'txt', 'csv' or 'xls')
    def open_text filename, extension = nil, options = {}
      extension ||= file_extension(filename)
      t = text_to_utf8(File.open(filename).read)
      send("load_#{extension}".to_sym, t, options)
    end

    # Writes the book to a file. Filetype is based on the extension, but can be overridden
    #
    # @param [String] filename   a string with a reference to the file to be written to
    # @param [Hash] options  depends on the writer chosen by the file's filetype
    def write filename, options = {}
      extension = file_extension(filename)
      send("write_to_#{extension}".to_sym, filename, options)
    end

    # Helper method to convert text in a file to UTF-8
    #
    # @param [String] text a string to convert
    def text_to_utf8 text
      unless text.valid_encoding? && (text.encoding == "UTF-8")
        # TODO: had some ruby 1.9 problems with rchardet ... but ideally it or a similar functionality will be reintroduced
        source_encoding = text.valid_encoding? ? text.encoding : "US-ASCII"
        text = text.encode("UTF-8", source_encoding, invalid: :replace, undef: :replace, replace: "")
        text = text.delete("\u0000") # TODO: this cleanup of nil values isn't supposed to be needed...
      end
      text
    end

    # @param [String, File] filename   The full filename, or path
    #
    # @return [String] The file extension
    def file_extension(filename)
      ext = File.extname(filename).delete(".").downcase if filename
      # for remote files which has asset id after extension
      ext.split("?")[0]
    end

    # Load the CSV data contained in the given StringIO or String object
    #
    # @param [StringIO] stringio_or_string StringIO stream or String object, with data in CSV format
    # @param [Symbol] filetype (currently only :csv or :txt), indicating the format of the first parameter
    def read(stringio_or_string, filetype, options = {})
      raise ArgumentError.new("The filetype parameter should be either :csv or :txt") unless [:csv, :txt].include?(filetype)
      t = stringio_or_string.respond_to?(:read) ? stringio_or_string.read : stringio_or_string.to_s
      t = text_to_utf8(t)
      send(:"parse_#{filetype}", t, options)
    end

    # Create or open the existing sheet at an index value
    #
    # @param [Integer] index    the index of the sheet
    def create_or_open_sheet_at index
      s = self[index]
      s = self[index] = Workbook::Sheet.new if s.nil?
      s.book = self
      s
    end

    class << self
      # Create an instance from a file, using open.
      #
      # @param [String] filename of the document
      # @param [String] extension of the document (not required). The parser used is based on the extension of the file, this option allows you to override the default.
      # @return [Workbook::Book] A new instance, based on the filename
      def open filename, extension = nil
        wb = new
        wb.import filename, extension
        wb
      end

      # Create an instance from the given stream or string, which should be in CSV or TXT format
      #
      # @param [StringIO] stringio_or_string StringIO stream or String object, with data in CSV or TXT format
      # @param [Symbol] filetype (currently only :csv or :txt), indicating the format of the first parameter
      # @return [Workbook::Book] A new instance
      def read stringio_or_string, filetype, options = {}
        wb = new
        wb.read(stringio_or_string, filetype, options)
        wb
      end
    end
  end
end