summaryrefslogtreecommitdiffstats
path: root/lib/redmine/asset_path.rb
blob: 4284e1971560f5023c6b4e715696f0acbdc8517a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# frozen_string_literal: true

# Redmine - project management software
# Copyright (C) 2006-2023  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