redmine/redmine

View on GitHub
lib/redmine/asset_path.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen_string_literal: true

# Redmine - project management software
# Copyright (C) 2006-  Jean-Philippe Lang
#
# This program 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 2
# of the License, or (at your option) any later version.
#
# This program 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 this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

module Redmine
  class AssetPath
    attr_reader :paths, :prefix, :version

    def initialize(base_dir, paths, prefix=nil)
      @base_dir = base_dir
      @paths  = paths
      @prefix = prefix
      @transition = Transition.new(src: Set.new, dest: Set.new)
      @version = Rails.application.config.assets.version
    end

    def update(transition_map:, assets:)
      each_file do |file, intermediate_path, logical_path|
        @transition.add_src  intermediate_path, logical_path
        @transition.add_dest intermediate_path, logical_path
        asset = if file.extname == '.css'
                  Redmine::Asset.new(file,   logical_path: logical_path, version: version, transition_map: transition_map)
                else
                  Propshaft::Asset.new(file, logical_path: logical_path, version: version)
                end
        assets[asset.logical_path.to_s] ||= asset
      end
      @transition.update(transition_map)
      nil
    end

    def each_file
      paths.each do |path|
        without_dotfiles(all_files_from_tree(path)).each do |file|
          relative_path = file.relative_path_from(path).to_s
          logical_path  = prefix ? File.join(prefix, relative_path) : relative_path
          intermediate_path = Pathname.new("/#{prefix}").join(file.relative_path_from(@base_dir))
          yield file, intermediate_path, logical_path
        end
      end
    end

    private

    Transition = Struct.new(:src, :dest, keyword_init: true) do
      def add_src(file, logical_path)
        src.add  path_pair(file, logical_path) if file.extname == '.css'
      end

      def add_dest(file, logical_path)
        return if file.extname == '.js' || file.extname == '.map'

        # No parent-child directories are needed in dest.
        dirname = file.dirname
        if child = dest.find{|d| child_path? dirname, d[0]}
          dest.delete child
          dest.add path_pair(file, logical_path)
        elsif !dest.any?{|d| parent_path? dirname, d[0]}
          dest.add path_pair(file, logical_path)
        end
      end

      def path_pair(file, logical_path)
        [file.dirname, Pathname.new("/#{logical_path}").dirname]
      end

      def parent_path?(path, other)
        return false if other == path

        path.ascend.any?(other)
      end

      def child_path?(path, other)
        return false if path == other

        other.ascend.any?(path)
      end

      def update(transition_map)
        product = src.to_a.product(dest.to_a).select{|t| t[0] != t[1]}
        maps = product.map do |t|
          AssetPathMap.new(src: t[0][0], dest: t[1][0], logical_src: t[0][1], logical_dest: t[1][1])
        end
        maps.each do |m|
          if m.before != m.after
            transition_map[m.dirname] ||= {}
            transition_map[m.dirname][m.before] = m.after
          end
        end
      end
    end

    AssetPathMap = Struct.new(:src, :dest, :logical_src, :logical_dest, keyword_init: true) do
      def dirname
        key = logical_src.to_s.sub('/', '')
        key == '' ? '.' : key
      end

      def before
        dest.relative_path_from(src).to_s
      end

      def after
        logical_dest.relative_path_from(logical_src).to_s
      end
    end

    def without_dotfiles(files)
      files.reject { |file| file.basename.to_s.starts_with?(".") }
    end

    def all_files_from_tree(path)
      path.children.flat_map { |child| child.directory? ? all_files_from_tree(child) : child }
    end
  end

  class AssetLoadPath < Propshaft::LoadPath
    attr_reader :extension_paths, :default_asset_path, :transition_map

    def initialize(config)
      @extension_paths    = config.redmine_extension_paths
      @default_asset_path = config.redmine_default_asset_path
      super(config.paths, version: config.version)
    end

    def asset_files
      Enumerator.new do |y|
        Rails.logger.info all_paths
        all_paths.each do |path|
          next unless path.exist?

          without_dotfiles(all_files_from_tree(path)).each do |file|
            y << file
          end
        end
      end
    end

    def assets_by_path
      merge_required = @cached_assets_by_path.nil?
      super
      if merge_required
        @transition_map = {}
        default_asset_path.update(assets: @cached_assets_by_path, transition_map: transition_map)
        extension_paths.each do |asset_path|
          # Support link from extension assets to assets in the application
          default_asset_path.each_file do |file, intermediate_path, logical_path|
            asset_path.instance_eval { @transition.add_dest intermediate_path, logical_path }
          end
          asset_path.update(assets: @cached_assets_by_path, transition_map: transition_map)
        end
      end
      @cached_assets_by_path
    end

    def cache_sweeper
      @cache_sweeper ||= begin
        exts_to_watch  = Mime::EXTENSION_LOOKUP.map(&:first)
        files_to_watch = Array(all_paths).to_h { |dir| [dir.to_s, exts_to_watch] }
        Rails.application.config.file_watcher.new([], files_to_watch) do
          clear_cache
        end
      end
    end

    def all_paths
      [paths, default_asset_path.paths, extension_paths.map{|path| path.paths}].flatten.compact
    end

    def clear_cache
      @transition_map = nil
      super
    end
  end

  class Asset < Propshaft::Asset
    def initialize(file, logical_path:, version:, transition_map:)
      @transition_map = transition_map
      super(file, logical_path: logical_path, version: version)
    end

    def content
      if conversion = @transition_map[logical_path.dirname.to_s]
        convert_path super, conversion
      else
        super
      end
    end

    ASSET_URL_PATTERN = /(url\(\s*["']?([^"'\s)]+)\s*["']?\s*\))/ unless defined? ASSET_URL_PATTERN

    def convert_path(input, conversion)
      input.gsub(ASSET_URL_PATTERN) do |matched|
        conversion.each do |key, val|
          matched.sub!(key, val)
        end
        matched
      end
    end
  end
end