mirror of https://github.com/licensee/licensee.git
use Thor for CLI
This commit is contained in:
parent
1553efdd95
commit
f93bdaabe3
|
@ -3,6 +3,7 @@ Gemfile.lock
|
|||
/tmp
|
||||
/spec/examples.txt
|
||||
/coverage
|
||||
.env
|
||||
|
||||
vendor/html5shiv
|
||||
vendor/jquery
|
||||
|
|
13
.rubocop.yml
13
.rubocop.yml
|
@ -38,3 +38,16 @@ Layout/IndentHeredoc:
|
|||
|
||||
Style/StructInheritance:
|
||||
Enabled: false
|
||||
|
||||
Metrics/LineLength:
|
||||
Exclude:
|
||||
- bin/*
|
||||
- lib/licensee/commands/*
|
||||
|
||||
Metrics/PerceivedComplexity:
|
||||
Exclude:
|
||||
- lib/licensee/commands/*
|
||||
|
||||
Metrics/CyclomaticComplexity:
|
||||
Exclude:
|
||||
- lib/licensee/commands/*
|
||||
|
|
83
bin/licensee
83
bin/licensee
|
@ -1,64 +1,37 @@
|
|||
#!/usr/bin/env ruby
|
||||
|
||||
require 'dotenv/load'
|
||||
require 'thor'
|
||||
require 'json'
|
||||
|
||||
require_relative '../lib/licensee'
|
||||
|
||||
path = ARGV[0] || Dir.pwd
|
||||
class LicenseeCLI < Thor
|
||||
package_name 'Licensee'
|
||||
class_option :remote, type: :boolean, desc: 'Assume PATH is a GitHub owner/repo path'
|
||||
default_task :detect
|
||||
|
||||
# Given a string or object, prepares it for output and human consumption
|
||||
def humanize(value, type = nil)
|
||||
case type
|
||||
when :license
|
||||
value.name
|
||||
when :matcher
|
||||
value.class
|
||||
when :confidence
|
||||
Licensee::ContentHelper.format_percent(value)
|
||||
when :method
|
||||
value.to_s.tr('_', ' ').capitalize
|
||||
else
|
||||
value
|
||||
private
|
||||
|
||||
def path
|
||||
@path ||= if !options[:remote] || args.first =~ %r{^https://}
|
||||
args.first || Dir.pwd
|
||||
else
|
||||
"https://github.com/#{args.first}"
|
||||
end
|
||||
end
|
||||
|
||||
def project
|
||||
@project ||= Licensee.project(path,
|
||||
detect_packages: options[:packages], detect_readme: options[:readme])
|
||||
end
|
||||
|
||||
def remote?
|
||||
path =~ %r{^https://}
|
||||
end
|
||||
end
|
||||
|
||||
# Methods to call when displaying information about ProjectFiles
|
||||
MATCHED_FILE_METHODS = %i[
|
||||
content_hash attribution confidence matcher license
|
||||
].freeze
|
||||
commands_dir = File.expand_path 'lib/licensee/commands/'
|
||||
Dir["#{commands_dir}/*.rb"].each { |c| require(c) }
|
||||
|
||||
project = Licensee.project(path, detect_packages: true, detect_readme: true)
|
||||
|
||||
if project.license
|
||||
puts "License: #{project.license.name} (#{project.license.spdx_id})"
|
||||
elsif project.licenses
|
||||
puts "Licenses: #{project.licenses.map(&:name)}"
|
||||
else
|
||||
puts 'License: Not detected'
|
||||
end
|
||||
|
||||
puts "Matched files: #{project.matched_files.map(&:filename)}"
|
||||
|
||||
project.matched_files.each do |matched_file|
|
||||
puts "#{matched_file.filename}:"
|
||||
|
||||
MATCHED_FILE_METHODS.each do |method|
|
||||
next unless matched_file.respond_to? method
|
||||
value = matched_file.public_send method
|
||||
next if value.nil?
|
||||
puts " #{humanize(method, :method)}: #{humanize(value, method)}"
|
||||
end
|
||||
|
||||
next unless matched_file.is_a? Licensee::ProjectFiles::LicenseFile
|
||||
next unless matched_file.confidence != 100
|
||||
|
||||
matcher = Licensee::Matchers::Dice.new(matched_file)
|
||||
licenses = matcher.licenses_by_similiarity
|
||||
next if licenses.empty?
|
||||
puts ' Closest licenses:'
|
||||
licenses[0...3].each do |license, similarity|
|
||||
spdx_id = license.meta['spdx-id']
|
||||
percent = Licensee::ContentHelper.format_percent(similarity)
|
||||
puts " * #{spdx_id} similarity: #{percent}"
|
||||
end
|
||||
end
|
||||
|
||||
exit !project.licenses.empty?
|
||||
LicenseeCLI.start(ARGV)
|
||||
|
|
|
@ -6,6 +6,7 @@ require 'yaml'
|
|||
|
||||
module Licensee
|
||||
autoload :ContentHelper, 'licensee/content_helper'
|
||||
autoload :HashHelper, 'licensee/hash_helper'
|
||||
autoload :License, 'licensee/license'
|
||||
autoload :LicenseField, 'licensee/license_field'
|
||||
autoload :LicenseMeta, 'licensee/license_meta'
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
class LicenseeCLI < Thor
|
||||
# Methods to call when displaying information about ProjectFiles
|
||||
MATCHED_FILE_METHODS = %i[
|
||||
content_hash attribution confidence matcher license
|
||||
].freeze
|
||||
|
||||
desc 'detect [PATH]', 'Detect the license of the given project'
|
||||
option :json, type: :boolean, desc: 'Return output as JSON'
|
||||
option :packages, type: :boolean, default: true, desc: 'Detect licenses in package manager files'
|
||||
option :readme, type: :boolean, default: true, desc: 'Detect licenses in README files'
|
||||
option :confidence, type: :numeric, default: Licensee.confidence_threshold, desc: 'Confidence threshold'
|
||||
option :license, type: :string, desc: 'The SPDX ID or key of the license to compare (implies --diff)'
|
||||
option :diff, type: :boolean, desc: 'Compare the license to the closest match'
|
||||
def detect(_path = nil)
|
||||
Licensee.confidence_threshold = options[:confidence]
|
||||
|
||||
if options[:json]
|
||||
say project.to_h.to_json
|
||||
exit !project.licenses.empty?
|
||||
end
|
||||
|
||||
rows = []
|
||||
rows << if project.license
|
||||
['License:', project.license.name]
|
||||
elsif project.licenses
|
||||
['Licenses:', project.licenses.map(&:name)]
|
||||
else
|
||||
['License:', set_color('None', :red)]
|
||||
end
|
||||
|
||||
rows << ['Matched files:', project.matched_files.map(&:filename).join(', ')]
|
||||
print_table rows
|
||||
|
||||
project.matched_files.each do |matched_file|
|
||||
rows = []
|
||||
say "#{matched_file.filename}:"
|
||||
|
||||
MATCHED_FILE_METHODS.each do |method|
|
||||
next unless matched_file.respond_to? method
|
||||
value = matched_file.public_send method
|
||||
next if value.nil?
|
||||
rows << [humanize(method, :method), humanize(value, method)]
|
||||
end
|
||||
print_table rows, indent: 2
|
||||
|
||||
next unless matched_file.is_a? Licensee::ProjectFiles::LicenseFile
|
||||
next if matched_file.confidence == 100
|
||||
|
||||
licenses = licenses_by_similiarity(matched_file)
|
||||
next if licenses.empty?
|
||||
say ' Closest licenses:'
|
||||
rows = licenses[0...3].map do |license, similarity|
|
||||
spdx_id = license.meta['spdx-id']
|
||||
percent = Licensee::ContentHelper.format_percent(similarity)
|
||||
["#{spdx_id} similarity:", percent]
|
||||
end
|
||||
print_table rows, indent: 4
|
||||
end
|
||||
|
||||
if project.license_file && (options[:license] || options[:diff])
|
||||
license = options[:license] || closest_license_key(project.license_file)
|
||||
invoke(:diff, nil,
|
||||
license: license, license_to_diff: project.license_file
|
||||
) if license
|
||||
end
|
||||
|
||||
exit !project.licenses.empty?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Given a string or object, prepares it for output and human consumption
|
||||
def humanize(value, type = nil)
|
||||
case type
|
||||
when :license
|
||||
value.name
|
||||
when :matcher
|
||||
value.class
|
||||
when :confidence
|
||||
Licensee::ContentHelper.format_percent(value)
|
||||
when :method
|
||||
value.to_s.tr('_', ' ').capitalize + ':'
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def licenses_by_similiarity(matched_file)
|
||||
matcher = Licensee::Matchers::Dice.new(matched_file)
|
||||
potential_licenses = Licensee.licenses(hidden: true).select(&:wordset)
|
||||
matcher.instance_variable_set('@potential_licenses', potential_licenses)
|
||||
matcher.licenses_by_similiarity
|
||||
end
|
||||
|
||||
def closest_license_key(matched_file)
|
||||
licenses = licenses_by_similiarity(matched_file)
|
||||
licenses.first.first.key unless licenses.empty?
|
||||
end
|
||||
end
|
|
@ -0,0 +1,62 @@
|
|||
require 'tmpdir'
|
||||
|
||||
class LicenseeCLI < Thor
|
||||
desc 'diff [PATH]', 'Compare the given license text to a known license'
|
||||
option :license, type: :string, desc: 'The SPDX ID or key of the license to compare'
|
||||
def diff(_path = nil)
|
||||
say "Comparing to #{expected_license.name}:"
|
||||
rows = []
|
||||
|
||||
left = expected_license.content_normalized(wrap: 80)
|
||||
right = license_to_diff.content_normalized(wrap: 80)
|
||||
similarity = expected_license.similarity(license_to_diff)
|
||||
similarity = Licensee::ContentHelper.format_percent(similarity)
|
||||
|
||||
rows << ['Input Length:', license_to_diff.length]
|
||||
rows << ['License length:', expected_license.length]
|
||||
rows << ['Similarity:', similarity]
|
||||
print_table rows
|
||||
|
||||
if left == right
|
||||
say 'Exact match!', :green
|
||||
exit
|
||||
end
|
||||
|
||||
Dir.mktmpdir do |dir|
|
||||
path = File.expand_path 'LICENSE', dir
|
||||
Dir.chdir(dir) do
|
||||
`git init`
|
||||
File.write(path, left)
|
||||
`git add LICENSE`
|
||||
`git commit -m 'left'`
|
||||
File.write(path, right)
|
||||
say `git diff --word-diff`
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def license_to_diff
|
||||
return options[:license_to_diff] if options[:license_to_diff]
|
||||
return project.license_file if remote?
|
||||
|
||||
@license_via_stdin ||= begin
|
||||
if STDIN.tty?
|
||||
error 'You must pipe license contents to the command via STDIN'
|
||||
exit 1
|
||||
end
|
||||
|
||||
Licensee::ProjectFiles::LicenseFile.new(STDIN.read, 'LICENSE')
|
||||
end
|
||||
end
|
||||
|
||||
def expected_license
|
||||
@expected_license ||= Licensee::License.find options[:license]
|
||||
return @expected_license if @expected_license
|
||||
|
||||
error "#{options[:license]} is not a valid license"
|
||||
error "Valid licenses: #{Licensee::License.all(hidden: true).map(&:key).join(', ')}"
|
||||
exit 1
|
||||
end
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
class LicenseeCLI < Thor
|
||||
desc 'license-path [PATH]', "Returns the path to the given project's license file"
|
||||
def license_path(_path)
|
||||
if project.license_file
|
||||
if remote?
|
||||
say project.license_file.path
|
||||
else
|
||||
say File.expand_path project.license_file.path
|
||||
end
|
||||
else
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,6 @@
|
|||
class LicenseeCLI < Thor
|
||||
desc 'version', 'Return the Licensee version'
|
||||
def version
|
||||
say Licensee::VERSION
|
||||
end
|
||||
end
|
|
@ -0,0 +1,20 @@
|
|||
module Licensee
|
||||
module HashHelper
|
||||
def to_h
|
||||
hash = {}
|
||||
self.class::HASH_METHODS.each do |method|
|
||||
key = method.to_s.delete('?').to_sym
|
||||
value = public_send(method)
|
||||
hash[key] = if value.is_a?(Array)
|
||||
value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
|
||||
elsif value.respond_to?(:to_h) && !value.nil?
|
||||
value.to_h
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
hash
|
||||
end
|
||||
end
|
||||
end
|
|
@ -94,7 +94,12 @@ module Licensee
|
|||
SOURCE_PREFIX = %r{https?://(?:www\.)?}i
|
||||
SOURCE_SUFFIX = %r{(?:\.html?|\.txt|\/)}i
|
||||
|
||||
HASH_METHODS = %i[
|
||||
key spdx_id meta url rules fields other? gpl? lgpl? cc?
|
||||
].freeze
|
||||
|
||||
include Licensee::ContentHelper
|
||||
include Licensee::HashHelper
|
||||
extend Forwardable
|
||||
def_delegators :meta, *LicenseMeta.helper_methods
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@ module Licensee
|
|||
|
||||
PREDICATE_FIELDS = %i[featured hidden].freeze
|
||||
|
||||
include Licensee::HashHelper
|
||||
HASH_METHODS = members - %i[conditions permissions limitations spdx_id]
|
||||
|
||||
class << self
|
||||
# Create a new LicenseMeta from YAML
|
||||
#
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
module Licensee
|
||||
# Exposes #conditions, #permissions, and #limitation arrays of LicenseRules
|
||||
class LicenseRules < Struct.new(:conditions, :permissions, :limitations)
|
||||
include Licensee::HashHelper
|
||||
HASH_METHODS = Rule.groups
|
||||
|
||||
class << self
|
||||
def from_license(license)
|
||||
from_meta(license.meta)
|
||||
|
@ -9,7 +12,7 @@ module Licensee
|
|||
def from_meta(meta)
|
||||
rules = {}
|
||||
Rule.groups.each do |group|
|
||||
rules[group] = meta[group].map do |tag|
|
||||
rules[group] = (meta[group] || []).map do |tag|
|
||||
Rule.find_by_tag_and_group(tag, group)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
module Licensee
|
||||
module Matchers
|
||||
class Exact
|
||||
attr_reader :file
|
||||
|
||||
def initialize(file)
|
||||
@file = file
|
||||
end
|
||||
|
||||
class Exact < Licensee::Matchers::Matcher
|
||||
def match
|
||||
return @match if defined? @match
|
||||
@match = Licensee.licenses(hidden: true).find do |license|
|
||||
|
|
|
@ -3,10 +3,17 @@ module Licensee
|
|||
class Matcher
|
||||
attr_reader :file
|
||||
|
||||
include Licensee::HashHelper
|
||||
HASH_METHODS = %i[name confidence].freeze
|
||||
|
||||
def initialize(file)
|
||||
@file = file
|
||||
end
|
||||
|
||||
def name
|
||||
@name ||= self.class.to_s.split('::').last.to_sym
|
||||
end
|
||||
|
||||
def match
|
||||
raise 'Not implemented'
|
||||
end
|
||||
|
|
|
@ -35,6 +35,14 @@ module Licensee
|
|||
FILENAMES_SCORES[filename] || 0.0
|
||||
end
|
||||
|
||||
def content_hash
|
||||
nil
|
||||
end
|
||||
|
||||
def content_normalized
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extension
|
||||
|
|
|
@ -10,6 +10,11 @@ module Licensee
|
|||
|
||||
attr_reader :content
|
||||
|
||||
include Licensee::HashHelper
|
||||
HASH_METHODS = %i[
|
||||
filename content content_hash content_normalized matcher matched_license
|
||||
].freeze
|
||||
|
||||
ENCODING = Encoding::UTF_8
|
||||
ENCODING_OPTIONS = {
|
||||
invalid: :replace,
|
||||
|
@ -61,6 +66,10 @@ module Licensee
|
|||
alias match license
|
||||
alias path filename
|
||||
|
||||
def matched_license
|
||||
license.key if license
|
||||
end
|
||||
|
||||
# Is this file a COPYRIGHT file with only a copyright statement?
|
||||
# If so, it can be excluded from determining if a project has >1 license
|
||||
def copyright?
|
||||
|
|
|
@ -10,6 +10,9 @@ module Licensee
|
|||
alias detect_readme? detect_readme
|
||||
alias detect_packages? detect_packages
|
||||
|
||||
include Licensee::HashHelper
|
||||
HASH_METHODS = %i[licenses matched_files].freeze
|
||||
|
||||
def initialize(detect_packages: false, detect_readme: false)
|
||||
@detect_packages = detect_packages
|
||||
@detect_readme = detect_readme
|
||||
|
|
|
@ -2,6 +2,9 @@ module Licensee
|
|||
class Rule
|
||||
attr_reader :tag, :label, :description, :group
|
||||
|
||||
include Licensee::HashHelper
|
||||
HASH_METHODS = %i[tag label description].freeze
|
||||
|
||||
def initialize(tag: nil, label: nil, description: nil, group: nil)
|
||||
@tag = tag
|
||||
@label = label
|
||||
|
|
|
@ -20,6 +20,8 @@ Gem::Specification.new do |gem|
|
|||
|
||||
gem.add_dependency('octokit', '~> 4.8.0')
|
||||
gem.add_dependency('rugged', '~> 0.24')
|
||||
gem.add_dependency('thor', '~> 0.19')
|
||||
gem.add_dependency('dotenv', '~> 2.0')
|
||||
|
||||
gem.add_development_dependency('coveralls', '~> 0.8')
|
||||
gem.add_development_dependency('mustache', '>= 0.9', '< 2.0')
|
||||
|
|
47
script/diff
47
script/diff
|
@ -1,50 +1,3 @@
|
|||
#!/usr/bin/env ruby
|
||||
# Diffs STDIN against a known license
|
||||
# Example usage: cat LICENSE.md | be script/diff mit
|
||||
|
||||
require 'licensee'
|
||||
require 'tmpdir'
|
||||
|
||||
if STDIN.tty? || ARGV[0].to_s.empty?
|
||||
puts 'USAGE: [LICENSE_CONTENT] | script/diff [LICENSE_ID]'
|
||||
exit 1
|
||||
end
|
||||
|
||||
text = STDIN.read
|
||||
license_file = Licensee::ProjectFiles::LicenseFile.new(text, 'LICENSE')
|
||||
license = Licensee::License.find(ARGV[0])
|
||||
|
||||
if license.nil?
|
||||
puts "#{ARGV[0]} is not a valid license"
|
||||
licenses = Licensee::License.all(hidden: true).map(&:key).join(', ')
|
||||
puts "Valid licenses: #{licenses}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "Comparing to #{license.name}:"
|
||||
|
||||
left = license.content_normalized(wrap: 80)
|
||||
right = license_file.content_normalized(wrap: 80)
|
||||
|
||||
puts " Input length: #{license_file.length}"
|
||||
puts " License length: #{license.length}"
|
||||
similarity = license.similarity(license_file)
|
||||
similarity = Licensee::ContentHelper.format_percent(similarity)
|
||||
puts " Similarity: #{similarity}"
|
||||
|
||||
if left == right
|
||||
puts ' Exact match!'
|
||||
exit
|
||||
end
|
||||
|
||||
dir = Dir.mktmpdir
|
||||
path = File.expand_path 'LICENSE', dir
|
||||
|
||||
Dir.chdir(dir) do
|
||||
`git init`
|
||||
File.write(path, left)
|
||||
`git add LICENSE`
|
||||
`git commit -m 'left'`
|
||||
File.write(path, right)
|
||||
puts `git diff --word-diff`
|
||||
end
|
||||
|
|
|
@ -1,64 +1,51 @@
|
|||
RSpec.describe 'command line invocation' do
|
||||
let(:command) { ['ruby', 'bin/licensee'] }
|
||||
let(:command) { ['bundle', 'exec', 'bin/licensee'] }
|
||||
let(:output) do
|
||||
Dir.chdir project_root do
|
||||
puts [command, arguments].flatten.inspect
|
||||
Open3.capture3(*[command, arguments].flatten)
|
||||
end
|
||||
end
|
||||
let(:parsed_output) { YAML.safe_load(stdout) }
|
||||
let(:stdout) { output[0] }
|
||||
let(:stderr) { output[1] }
|
||||
let(:status) { output[2] }
|
||||
let(:hash) { '46cdc03462b9af57968df67b450cc4372ac41f53' }
|
||||
let(:expected) {
|
||||
{
|
||||
"License" => "MIT License",
|
||||
"Matched files" => "LICENSE.md, licensee.gemspec",
|
||||
"LICENSE.md" => {
|
||||
"Content hash" => hash,
|
||||
"Attribution" => "Copyright (c) 2014-2017 Ben Balter",
|
||||
"Confidence" => "100.00%",
|
||||
"Matcher" => "Licensee::Matchers::Exact",
|
||||
"License" => "MIT License"
|
||||
},
|
||||
"licensee.gemspec" => {
|
||||
"Confidence" => "90.00%",
|
||||
"Matcher" => "Licensee::Matchers::Gemspec",
|
||||
"License" => "MIT License"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context 'without any arguments' do
|
||||
let(:arguments) { [] }
|
||||
{
|
||||
"No arguments" => [],
|
||||
"Project root" => [project_root],
|
||||
"License path" => [File.expand_path('LICENSE.md', project_root)]
|
||||
}.each do |name, args|
|
||||
context "When given #{name}" do
|
||||
let(:arguments) { args }
|
||||
|
||||
it 'Returns a zero exit code' do
|
||||
expect(status.exitstatus).to eql(0)
|
||||
end
|
||||
it 'Returns a zero exit code' do
|
||||
expect(status.exitstatus).to eql(0)
|
||||
end
|
||||
|
||||
it "detects the folder's license" do
|
||||
expect(stdout).to match('License: MIT License')
|
||||
end
|
||||
|
||||
it 'outputs the hash' do
|
||||
expect(stdout).to match(hash)
|
||||
end
|
||||
|
||||
it 'outputs the attribution' do
|
||||
expect(stdout).to match('2014-2017 Ben Balter')
|
||||
end
|
||||
|
||||
it 'outputs the confidence' do
|
||||
expect(stdout).to match('Confidence: 100.00%')
|
||||
expect(stdout).to match('Confidence: 90.00%')
|
||||
end
|
||||
|
||||
it 'outputs the method' do
|
||||
expect(stdout).to match('Matcher: Licensee::Matchers::Exact')
|
||||
expect(stdout).to match('Matcher: Licensee::Matchers::Gemspec')
|
||||
end
|
||||
|
||||
it 'outputs the matched files' do
|
||||
matched_files = 'Matched files: ["LICENSE.md", "licensee.gemspec"]'
|
||||
expect(stdout).to include(matched_files)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given a folder path' do
|
||||
let(:arguments) { [project_root] }
|
||||
|
||||
it "detects the folder's license" do
|
||||
expect(stdout).to match('License: MIT License')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when given a license path' do
|
||||
let(:license_path) { File.expand_path 'LICENSE.md', project_root }
|
||||
let(:arguments) { [license_path] }
|
||||
|
||||
it "detects the file's license" do
|
||||
expect(stdout).to match('License: MIT License')
|
||||
it "returns the exected values" do
|
||||
puts stdout
|
||||
expect(parsed_output).to eql(expected), stdout + stderr
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,7 @@ require 'licensee'
|
|||
require 'open3'
|
||||
require 'tmpdir'
|
||||
require 'mustache'
|
||||
require 'yaml'
|
||||
|
||||
require 'webmock/rspec'
|
||||
WebMock.disable_net_connect!
|
||||
|
|
Loading…
Reference in New Issue