From 2436f94761c05edf6a2babc76d0486338e9d863f Mon Sep 17 00:00:00 2001
From: Takashi Kato <tohosaku@users.osdn.me>
Date: Wed, 24 Jul 2024 13:31:07 +0000
Subject: [PATCH] Add svg icon fetch task

---
 config/icon_config.yml |  14 ++
 config/icon_source.yml | 417 +++++++++++++++++++++++++++++++++++++++++
 lib/redmine/icon.rb    | 210 +++++++++++++++++++++
 lib/tasks/icon.rake    |  42 +++++
 4 files changed, 683 insertions(+)
 create mode 100644 config/icon_config.yml
 create mode 100644 config/icon_source.yml
 create mode 100644 lib/redmine/icon.rb
 create mode 100644 lib/tasks/icon.rake

diff --git a/config/icon_config.yml b/config/icon_config.yml
new file mode 100644
index 000000000..1172a84bc
--- /dev/null
+++ b/config/icon_config.yml
@@ -0,0 +1,14 @@
+application:
+  prefix: "icon-"
+  dir: icons
+file:
+  prefix: "icon-file."
+  dir: icons/file
+jstoolbar:
+  prefix: "jstb_"
+  dir: icons/jstoolbar
+background:
+  template: '--icon-%s: %s;'
+  dir: icons
+other:
+  dir: icons
diff --git a/config/icon_source.yml b/config/icon_source.yml
new file mode 100644
index 000000000..394197358
--- /dev/null
+++ b/config/icon_source.yml
@@ -0,0 +1,417 @@
+application:
+  - name: add
+    iconset: fa6-solid
+    svg: circle-plus
+    style:
+      - add
+      - add-bullet
+  - name: edit
+    iconset: fa6-solid
+    svg: pencil
+  - name: copy
+    iconset: fa6-solid
+    svg: clone
+  - name: duplicate
+    iconset: fa6-solid
+    svg: file-circle-plus
+  - name: del
+    iconset: fa6-regular
+    svg: trash-can
+  - name: move
+    iconset: fa6-solid
+    svg: share
+  - name: save
+    iconset: fa6-regular
+    svg: floppy-disk
+  - name: download
+    iconset: fa6-solid
+  - name: cancel
+    iconset: fa6-solid
+    svg: reply
+  - name: table-multiple
+    iconset: fa-solid
+    svg: sync-alt
+    style:
+      - multiple
+  - name: folder
+    iconset: fa6-regular
+  - name: folder-open
+    iconset: fa6-solid
+    style:
+      - .open .icon-folder
+  - name: package
+    iconset: fa6-solid
+    svg: cube
+  - name: user
+    iconset: fa6-solid
+  - name: projects
+    iconset: fa6-solid
+    svg: cubes
+    style:
+      - projects
+      - project
+  - name: help
+    iconset: fa6-solid
+    svg: circle-info
+  - name: attachment
+    iconset: fa6-solid
+    svg: paperclip
+  - name: history
+    iconset: fa6-solid
+    svg: clock-rotate-left
+  - name: time
+    iconset: fa6-regular
+    svg: clock
+    style:
+      - time
+      - time-entry
+      - time-add
+  - name: stats
+    iconset: fa6-regular
+    svg: chart-bar
+  - name: warning
+    iconset: fa6-solid
+    svg: triangle-exclamation
+  - name: error
+    iconset: fa6-solid
+    svg: circle-exclamation
+  - name: fav
+    iconset: fa6-solid
+    svg: star
+  - name: fav-off
+    iconset: fa6-regular
+    svg: star
+  - name: reload
+    iconset: fa-solid
+    svg: sync
+  - name: lock
+    iconset: fa6-solid
+    style:
+      - lock
+      - locked
+  - name: unlock
+    iconset: fa6-solid
+  - name: checked
+    iconset: fa6-solid
+    svg: check
+  - name: report
+    iconset: fluent
+    svg: document-lightning-32-regular
+  - name: comment
+    iconset: fa6-regular
+    style:
+      - comment
+      - comments
+      - message
+      - issue-note
+  - name: summary
+    iconset: fa6-solid
+    svg: bolt-lightning
+  - name: server-authentication
+    iconset: fa6-solid
+    svg: key
+  - name: issue
+    iconset: fa6-regular
+    svg: note-sticky
+  - name: zoom-in
+    iconset: fa-solid
+    svg: search-plus
+  - name: zoom-out
+    iconset: fa-solid
+    svg: search-minus
+  - name: textfield-key
+    iconset: fa6-solid
+    svg: key
+    style:
+      - passwd
+  - name: arrow-right
+    iconset: fa6-solid
+    style:
+      - allow-right
+      - test
+      - sticky
+  - name: email
+    iconset: fa6-regular
+    svg: envelope
+    style:
+      - email
+      - email-add
+  - name: email-disabled
+    iconset: fa6-solid
+    svg: envelope
+  - name: "true"
+    iconset: fa6-solid
+    svg: check
+    style:
+      - ok
+  - name: "false"
+    iconset: fa6-solid
+    svg: xmark
+    style:
+      - not-ok
+  - name: link-break
+    iconset: fa6-solid
+    svg: link-slash
+  - name: text-list-bullets
+    iconset: fa6-solid
+    svg: list-ul
+    style:
+      - list
+  - name: close
+    iconset: fa6-solid
+    svg: square-xmark
+  - name: settings
+    iconset: fa6-solid
+    svg: gear
+  - name: group
+    iconset: fa6-solid
+    svg: user-group
+    style:
+      - group
+      - groupnonmember
+      - groupanonymous
+  - name: roles
+    iconset: fa6-solid
+    svg: eye
+  - name: issue-edit
+    iconset: fa6-solid
+    svg: pen-to-square
+  - name: workflows
+    iconset: fa6-solid
+    svg: gears
+  - name: textfield
+    iconset: fa6-regular
+    svg: file-lines
+    style:
+      - custom-fields
+  - name: plugin
+    iconset: fa6-solid
+    svg: puzzle-piece
+    style:
+      - plugins
+  - name: news
+    iconset: fa6-regular
+    svg: newspaper
+  - name: issue-closed
+    iconset: fa6-regular
+    svg: square-check
+  - name: changeset
+    iconset: fa6-solid
+    svg: code
+  - name: comments
+    iconset: fa6-regular
+    style:
+      - reply
+  - name: wiki-edit
+    iconset: fa6-solid
+    svg: book
+    style:
+      - wiki-page
+  - name: document
+    iconset: fa6-regular
+    svg: file
+  - name: link
+    iconset: fa6-solid
+    style:
+      - shared
+  - name: 3-bullets
+    iconset: fa6-solid
+    svg: ellipsis
+    style:
+      - actions
+  - name: reorder
+    iconset: fa6-solid
+    svg: arrows-up-down
+    style:
+      - sort-handle
+  - name: angle-down
+    iconset: fa6-solid
+    style:
+      - expanded
+  - name: angle-right
+    iconset: fa6-solid
+    style:
+      - collapsed
+  - name: tag-blue-delete
+    iconset: fa6-solid
+    style:
+      - bookmark
+      - bookmarked-project
+    svg: bookmark
+  - name: tag-blue-add
+    iconset: fa6-regular
+    style:
+      - bookmark-off
+    svg: bookmark
+  - name: angle-down
+    iconset: fa6-solid
+    style:
+      - sorted-asc
+  - name: angle-up
+    iconset: fa6-solid
+    style:
+      - sorted-desc
+  - name: toggle-plus
+    iconset: fa6-regular
+    svg: square-plus
+  - name: toggle-minus
+    iconset: fa6-regular
+    svg: square-minus
+  - name: clear-query
+    iconset: fa6-solid
+    svg: circle-xmark
+  - name: import
+    iconset: fa6-solid
+    svg: file-import
+  - name: copy-link
+    iconset: fa6-regular
+    svg: clipboard
+  - name: file
+    iconset: fa6-regular
+    svg: file
+background:
+  - name: bg-angle-up
+    iconset: fa6-solid
+    svg: angle-up
+    color: "#999"
+  - name: bg-angle-down
+    iconset: fa6-solid
+    svg: angle-down
+    color: "#999"
+  - name: bg-angle-left
+    iconset: fa6-solid
+    svg: angle-left
+    color: "#999"
+  - name: bg-angle-right
+    iconset: fa6-solid
+    svg: angle-right
+    color: "#999"
+  - name: bg-magnifier
+    iconset: fa6-solid
+    svg: magnifying-glass
+    color: "#999"
+other:
+  - name: external
+    iconset: fa6-solid
+    svg: arrow-up-right-from-square
+  - name: hourglass
+    iconset: fa6-solid
+    svg: hourglass
+  - name: bullet-go
+    iconset: mdi
+    svg: arrow-right-bold
+  - name: bullet-end
+    iconset: mdi
+    svg: arrow-left-bold
+  - name: bullet-go-end
+    iconset: mdi
+    svg: arrow-left-right-bold
+  - name: atom
+    iconset: fa6-solid
+    svg: square-rss
+  - name: scm-change
+    iconset: mdi
+    svg: pencil-circle
+  - name: scm-move
+    iconset: fa6-solid
+    svg: circle-arrow-right
+plugin:
+  - name: plugin-tmp
+    iconset: fa6-solid
+    svg: cube
+file:
+  - name: text-plain
+    iconset: mdi
+    svg: file-document-outline
+  - name: text-x-c
+    iconset: mdi
+    svg: language-c
+  - name: text-x-csharp
+    iconset: mdi
+    svg: language-csharp
+  - name: text-x-java
+    iconset: mdi
+    svg: language-java
+  - name: text-x-javascript
+    iconset: mdi
+    svg: language-javascript
+  - name: text-x-php
+    iconset: mdi
+    svg: language-php
+  - name: text-x-ruby
+    iconset: mdi
+    svg: language-ruby
+  - name: text-xml
+    iconset: mdi
+    svg: xml
+  - name: text-css
+    iconset: mdi
+    svg: language-css3
+  - name: text-html
+    iconset: mdi
+    svg: xml
+  - name: image-gif
+    iconset: mdi
+    svg: file-gif-box
+  - name: image-jpeg
+    iconset: mdi
+    svg: file-jpg-box
+  - name: image-png
+    iconset: mdi
+    svg: file-png-box
+  - name: image-tiff
+    iconset: mdi
+    svg: file-image-outline
+  - name: application-pdf
+    iconset: mdi
+    svg: file-pdf-box
+  - name: application-zip
+    iconset: mdi
+    svg: zip-box-outline
+  - name: application-x-gzip
+    iconset: mdi
+    svg: zip-box-outline
+jstoolbar:
+  - name: strong
+    iconset: fa6-solid
+    svg: bold
+  - name: em
+    iconset: fa6-solid
+    svg: italic
+  - name: ins
+    iconset: fa6-solid
+    svg: underline
+  - name: del
+    iconset: fa6-solid
+    svg: strikethrough
+  - name: ul
+    iconset: fa6-solid
+    svg: list-ul
+  - name: ol
+    iconset: fa6-solid
+    svg: list-ol
+  - name: tl
+    iconset: fa6-solid
+    svg: list-check
+  - name: bq
+    iconset: fa6-solid
+    svg: indent
+  - name: unbq
+    iconset: fa6-solid
+    svg: outdent
+  - name: table
+    iconset: fa6-solid
+    svg: table
+  - name: precode
+    iconset: fa6-solid
+    svg: code
+  - name: link
+    iconset: fa6-solid
+    svg: link
+  - name: img
+    iconset: fa6-solid
+    svg: image
+  - name: help
+    iconset: fa6-regular
+    svg: circle-question
+# C, H1, H2, H3, PRE is string. not svg icon
diff --git a/lib/redmine/icon.rb b/lib/redmine/icon.rb
new file mode 100644
index 000000000..9f871810b
--- /dev/null
+++ b/lib/redmine/icon.rb
@@ -0,0 +1,210 @@
+# 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.
+
+require 'uri'
+require 'net/http'
+require 'fileutils'
+
+module Redmine
+  class Icon
+    def initialize(url: , source: , config: , images_dir:, styles_dir:)
+      @uri = URI.parse url
+      @source = YAML.safe_load File.open(source)
+      hash = YAML.safe_load(File.open(config))
+      @categories = hash.map{|key, val| [key, IconCategory.new(key, val)] }.to_h
+      @images_dir = images_dir
+      @styles_dir = styles_dir
+      @prepared = false
+    end
+
+    def prepare
+      return if @prepared
+
+      @iconsets = {}
+      @icons = []
+      @source.each do |category, array|
+
+        array.each do |data|
+          iconset_name = data['iconset']
+          @iconsets[ iconset_name ] ||= IconSet.new(iconset_name)
+          icon = SVGIcon.new(data, @iconsets[ iconset_name ], @categories[category])
+
+          @icons << icon
+          @iconsets[ iconset_name ].icons << icon
+        end
+      end
+      fetch
+      @prepared = true
+    end
+
+    def dump_icons
+      @icons.each {|i| i.dump(@images_dir) }
+    end
+
+    def dump_css
+      @categories.values.each {|c| c.to_css(@styles_dir)}
+    end
+
+    private
+
+    def fetch
+      http = Net::HTTP.new(@uri.host, @uri.port)
+      http.use_ssl = true
+      http.verify_mode = OpenSSL::SSL::VERIFY_NONE
+
+      http.start do |client|
+
+        source = "/collections?prefixes=#{@iconsets.keys.join(',')}"
+        metadata = fetch_from(client, source)
+
+        @iconsets.each do |prefix, iconset|
+          iconset.metadata ||= metadata[prefix]
+
+          source = "/#{prefix}.json?icons=#{iconset.icons.map{|i| i.svg_name}.join(',')}"
+          iconset.data = fetch_from(client, source)
+        end
+      end
+    end
+
+    def fetch_from(client, source)
+      res = client.get(source)
+
+      case res
+      when Net::HTTPSuccess
+        JSON.parse res.body
+      else
+        raise StandardError.new('collection api failed!!')
+      end
+    end
+
+    class IconCategory
+      attr_reader :name, :prefix, :dir, :icons, :template
+
+      def initialize(name, obj)
+        @name   = name
+        @prefix = obj['prefix']
+        @dir    = obj['dir']
+        @template = obj['template']
+        @icons  = []
+      end
+
+      def to_css(dir)
+        FileUtils.mkdir_p dir
+        css = icons.map{|i| i.css }.select{|c| c != nil}.join("\n")
+
+        File.write dir.join("#{name}.css"), css
+      end
+    end
+
+    class IconSet
+      SVG =<<~EOL
+      <!-- iconset: %<iconset>s icon: %<svg>s.svg%<version>s license: %<license>s url: %<url>s -->
+      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %<width>d %<height>d">
+        %<content>s
+      </svg>
+      EOL
+
+      attr_accessor :version, :data, :metadata
+      attr_reader :name, :icons
+
+      def initialize(name)
+        @name = name
+        @icons = []
+      end
+
+      def xml(svg_name, color)
+        content = data['icons'][svg_name]
+        raise StandardError.new("Icon not found #{svg_name} in #{name}") unless content
+
+        width   = content['width']  || data['width']
+        height  = content['height'] || data['height']
+        body    = content['body']
+        body    = body.sub('currentColor', color) if color
+        version = metadata['version'] ? " version: #{metadata['version']}" : nil
+        format(SVG, iconset: metadata['name'],
+               version: version,
+               license: metadata['license']['title'],
+               svg: svg_name,
+               url: metadata['author']['url'],
+               width: width,
+               height: height,
+               content: body)
+      end
+    end
+
+    class SVGIcon
+      attr_reader :name, :svg_name, :iconset, :category, :color
+
+      def initialize(obj, iconset, category)
+        @category = category
+        @category.icons << self if category
+
+        @name = obj['name']
+        @svg_name = obj['svg'] || obj['name']
+        @iconset  = iconset
+        @selector = obj['style'] || [ obj['name'] ]
+        @color = obj['color']
+      end
+
+      def selector_line
+        @selector.map do |k|
+          if (k =~ /\./)
+            k
+          else
+            prefix = category.prefix || ''
+            ".#{prefix}#{k}::before"
+          end
+        end
+      end
+
+      def css
+        if category
+          url = "url(/#{category.dir}/#{name}.svg)"
+          if category.prefix
+            "#{selector_line.join(', ')} { --icon-image: #{url}}"
+          elsif category.template
+            format(category.template, name, url)
+          end
+        else
+          nil
+        end
+      end
+
+      def dump(dest)
+        return nil if category == nil
+
+        dir = dest.join(category.dir)
+
+        FileUtils.mkdir_p dir
+        File.write dir.join("#{name}.svg"), to_xml
+      end
+
+      private
+
+      def path
+        category.dir + '/' + name + '.svg'
+      end
+
+      def to_xml
+        iconset.xml svg_name, color
+      end
+    end
+  end
+end
+
diff --git a/lib/tasks/icon.rake b/lib/tasks/icon.rake
new file mode 100644
index 000000000..f9050ce33
--- /dev/null
+++ b/lib/tasks/icon.rake
@@ -0,0 +1,42 @@
+# 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.
+
+require 'redmine/icon'
+
+namespace :icon do
+
+  @icon = nil
+
+  task :setup do
+    @icon ||= Redmine::Icon.new url: 'https://api.iconify.design/',
+      source: Rails.root.join('config', 'icon_source.yml'),
+      config: Rails.root.join('config', 'icon_config.yml'),
+      images_dir: Rails.root.join('app/assets/images'),
+      styles_dir: Rails.root.join('app/assets/stylesheets/icons')
+    @icon.prepare
+  end
+
+  desc 'Fetch svg icons from iconify.design'
+  task :fetch => :setup do
+    @icon.dump_icons
+  end
+
+  desc 'Output stylesheets to show svg icon'
+  task :css => :setup do
+    @icon.dump_css
+  end
+end
-- 
2.39.2

