├── .rspec ├── Gemfile ├── .gitignore ├── .rubocop.yml ├── _includes ├── search-autocomplete-default-result-template.html ├── search-full-default-result-template.html ├── package-list-item.html └── browse-paginator-navigation.html ├── _layouts ├── category-packages.html ├── sidebar-latest-packages.html └── sidebar-lastupdated-packages.html ├── lib ├── fdroid │ ├── Permission.rb │ ├── Repo.rb │ ├── Version.rb │ ├── IndexV1.rb │ └── Package.rb ├── jekyll-fdroid.rb └── jekyll │ ├── FDroidBrowsingPage.rb │ ├── FDroidLatestPackagesTag.rb │ ├── FDroidLastUpdatedPackagesTag.rb │ ├── FDroidRepoInfoTag.rb │ ├── FDroidPackageDetailPage.rb │ ├── ReadYamlPage.rb │ ├── FDroidCategoryDetailPage.rb │ └── FDroidPackageDetailGenerator.rb ├── spec ├── lib │ └── fdroid │ │ ├── FDroidRepoInfoTag_spec.rb │ │ └── FDroidIndex_spec.rb ├── spec_helper.rb └── assets │ └── localized.json ├── _pages └── categories-overview.html ├── jekyll-fdroid.gemspec ├── README.md ├── .gitlab-ci.yml ├── _sass └── jekyll-fdroid.scss └── LICENSE /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | *.gem 4 | .vscode 5 | vendor 6 | .bundle 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisabledByDefault: true 3 | TargetRubyVersion: 2.5 4 | 5 | Layout: 6 | Enabled: true 7 | 8 | Layout/LineLength: 9 | Max: 600 -------------------------------------------------------------------------------- /_includes/search-autocomplete-default-result-template.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |

6 | {{ name }} 7 |

8 | 9 |
10 | {{ summary }} 11 |
12 |
13 |
-------------------------------------------------------------------------------- /_includes/search-full-default-result-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |

6 | {{ name }} 7 |

8 | 9 |
10 | {{ summary }} 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /_layouts/category-packages.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: " " 4 | permalink: filled_by_script 5 | pagination: 6 | enabled: true 7 | collection: filled_by_script 8 | per_page: 30 9 | trail: 10 | before: 4 11 | after: 4 12 | permalink: '/:num/' 13 | title: ':title' 14 | sort_field: 'last_updated' 15 | sort_reverse: true 16 | --- 17 | 18 |

{{ site.data.strings.app_categories[page.app_category] }}

19 |
20 | {% for package in paginator.posts %} 21 | {% include package-list-item.html package=package %} 22 | {% endfor %} 23 | 24 | {% include_cached browse-paginator-navigation.html paginator=paginator permalink=page.dir %} 25 |
26 | -------------------------------------------------------------------------------- /_includes/package-list-item.html: -------------------------------------------------------------------------------- 1 | 2 | {% if include.package.icon != "" and include.package.icon != nil %} 3 | 4 | {% else %} 5 | 6 | {% endif %} 7 | 8 |
9 |

10 | {{ include.package.title }} 11 |

12 | 13 |
14 | {{ include.package.summary }} 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /_layouts/sidebar-latest-packages.html: -------------------------------------------------------------------------------- 1 | {% assign packages = site.packages | sort: "last_updated" | reverse | sort: "added" | reverse %} 2 | {% assign remaining = 3 %} 3 | {% for package in packages %} 4 | {% if package.is_localized and package.icon and package.summary and package.whats_new and package.anti_features == null %} 5 | {% include package-list-item.html package=package %} 6 | {% assign remaining = remaining | minus: 1 %} 7 | {% endif %} 8 | {% if remaining <= 0 %}{% break %}{% endif %} 9 | {% endfor %} 10 | 11 | {% for package in packages %} 12 | {% if remaining <= 0 %}{% break %}{% endif %} 13 | {% if package.is_localized and package.icon and package.summary and package.anti_features == null %} 14 | {% include package-list-item.html package=package %} 15 | {% assign remaining = remaining | minus: 1 %} 16 | {% endif %} 17 | {% endfor %} 18 | 19 | 20 | {% for package in packages limit:remaining %} 21 | {% include package-list-item.html package=package %} 22 | {% endfor %} 23 | -------------------------------------------------------------------------------- /_layouts/sidebar-lastupdated-packages.html: -------------------------------------------------------------------------------- 1 | {% assign packages = site.packages | sort: "added" | reverse | sort: "last_updated" | reverse %} 2 | {% assign remaining = 3 %} 3 | {% for package in packages %} 4 | {% if package.is_localized and package.icon and package.summary and package.whats_new and package.anti_features == null %} 5 | {% include package-list-item.html package=package %} 6 | {% assign remaining = remaining | minus: 1 %} 7 | {% endif %} 8 | {% if remaining <= 0 %}{% break %}{% endif %} 9 | {% endfor %} 10 | 11 | {% for package in packages %} 12 | {% if remaining <= 0 %}{% break %}{% endif %} 13 | {% if package.is_localized and package.icon and package.summary and package.anti_features == null %} 14 | {% include package-list-item.html package=package %} 15 | {% assign remaining = remaining | minus: 1 %} 16 | {% endif %} 17 | {% endfor %} 18 | 19 | 20 | {% for package in packages limit:remaining %} 21 | {% include package-list-item.html package=package %} 22 | {% endfor %} 23 | -------------------------------------------------------------------------------- /lib/fdroid/Permission.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | module FDroid 19 | class Permission 20 | def initialize(permission) 21 | @permission = permission[0] 22 | @min_sdk = permission[1] 23 | end 24 | 25 | def to_data 26 | { 27 | 'permission' => @permission, 28 | 'min_sdk' => @min_sdk, 29 | } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/jekyll-fdroid.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | require "fdroid/IndexV1" 19 | require "jekyll/ReadYamlPage" 20 | require "jekyll/FDroidBrowsingPage" 21 | require "jekyll/FDroidLastUpdatedPackagesTag" 22 | require "jekyll/FDroidLatestPackagesTag" 23 | require "jekyll/FDroidPackageDetailGenerator" 24 | require "jekyll/FDroidPackageDetailPage" 25 | require "jekyll/FDroidCategoryDetailPage" 26 | require "jekyll/FDroidRepoInfoTag" 27 | -------------------------------------------------------------------------------- /spec/lib/fdroid/FDroidRepoInfoTag_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'liquid' 3 | require 'jekyll' 4 | require_relative '../../../lib/jekyll/FDroidRepoInfoTag' 5 | 6 | module Jekyll 7 | RSpec.describe FDroidRepoInfoTag do 8 | def make_context 9 | Liquid::Context.new( 10 | {}, 11 | {}, 12 | { 13 | :site => Jekyll::Site.new( 14 | { 15 | 'source' => '/tmp', 16 | 'destination' => '/tmp/build', 17 | 'permalink' => '', 18 | 'liquid' => { 19 | 'error_mode' => '' 20 | }, 21 | 'limit_posts' => 0, 22 | 'plugins' => [], 23 | 'kramdown' => {}, 24 | 'fdroid-repo' => 'https://guardianproject.info/fdroid/repo' 25 | } 26 | ) 27 | } 28 | ) 29 | end 30 | 31 | it 'renders fdroid_repo_info correctly', { :network => true, :tag => true } do 32 | template = Liquid::Template.parse('Repo: {% fdroid_repo_info %}').render make_context 33 | expect(template).to match(/Repo: Guardian Project Official Releases \d\d\d\d-\d\d-\d\d/) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/jekyll/FDroidBrowsingPage.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | module Jekyll 19 | class FDroidBrowsingPage < ReadYamlPage 20 | def initialize(site, base) 21 | @site = site 22 | @base = base 23 | @dir = "packages" 24 | @name = "index.html" 25 | 26 | self.process(@name) 27 | self.read_yaml((File.expand_path "../../_pages", File.dirname(__FILE__)), 'categories-overview.html') 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/jekyll/FDroidLatestPackagesTag.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | module Jekyll 19 | class FDroidLatestPackagesTag < Liquid::Tag 20 | def initialize(tag_name, text, tokens) 21 | super 22 | end 23 | 24 | def render(context) 25 | template = Liquid::Template.parse(IO.read((File.expand_path "../../_layouts/sidebar-latest-packages.html", File.dirname(__FILE__)))) 26 | template.render(context) 27 | end 28 | end 29 | end 30 | 31 | Liquid::Template.register_tag('fdroid_show_latest_packages', Jekyll::FDroidLatestPackagesTag) 32 | -------------------------------------------------------------------------------- /lib/jekyll/FDroidLastUpdatedPackagesTag.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | module Jekyll 19 | class FDroidLastUpdatedPackagesTag < Liquid::Tag 20 | def initialize(tag_name, text, tokens) 21 | super 22 | end 23 | 24 | def render(context) 25 | template = Liquid::Template.parse(IO.read((File.expand_path "../../_layouts/sidebar-lastupdated-packages.html", File.dirname(__FILE__)))) 26 | template.render(context) 27 | end 28 | end 29 | end 30 | 31 | Liquid::Template.register_tag('fdroid_show_last_updated_packages', Jekyll::FDroidLastUpdatedPackagesTag) 32 | -------------------------------------------------------------------------------- /lib/jekyll/FDroidRepoInfoTag.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Peter Serwylo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | require_relative '../fdroid/IndexV1' 19 | 20 | module Jekyll 21 | # Used to output the repo name/timestamp used to generate this F-Droid site. 22 | class FDroidRepoInfoTag < Liquid::Tag 23 | @@repotag = '' 24 | 25 | def initialize(tag_name, text, tokens) 26 | super 27 | end 28 | 29 | def render(context) 30 | if @@repotag == '' 31 | site = context.registers[:site] 32 | url = site.config['fdroid-repo'] 33 | index = FDroid::IndexV1.download(url, 'en') 34 | @@repotag = "#{index.repo.name} #{index.repo.date}" 35 | end 36 | return @@repotag 37 | end 38 | end 39 | end 40 | 41 | Liquid::Template.register_tag('fdroid_repo_info', Jekyll::FDroidRepoInfoTag) 42 | -------------------------------------------------------------------------------- /_pages/categories-overview.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | title: " " 4 | permalink: /packages/ 5 | --- 6 | {% for app_category in site.app_categories %} 7 | {% assign app_category_escaped = app_category | escape %} 8 | {% assign filtered_packages = site.packages | where_exp: "item", "item.categories contains app_category_escaped" | sort: "last_updated" | reverse %} 9 | {% if filtered_packages.size > 0 %} 10 |

{{ site.data.strings.app_categories[app_category] }}

11 |
12 | {% assign remaining = 3 %} 13 | {% for package in filtered_packages %} 14 | {% if package.is_localized and package.icon and package.summary and package.whats_new %} 15 | {% include package-list-item.html package=package %} 16 | {% assign remaining = remaining | minus: 1 %} 17 | {% endif %} 18 | {% if remaining <= 0 %}{% break %}{% endif %} 19 | {% endfor %} 20 | {% for package in filtered_packages %} 21 | {% if package.is_localized and package.icon and package.summary %} 22 | {% include package-list-item.html package=package %} 23 | {% assign remaining = remaining | minus: 1 %} 24 | {% endif %} 25 | {% if remaining <= 0 %}{% break %}{% endif %} 26 | {% endfor %} 27 | {% for package in filtered_packages limit: remaining %} 28 | {% include package-list-item.html package=package %} 29 | {% endfor %} 30 |
31 |

{{ site.data.strings.app_categories.show_all_packages | replace: '%d', filtered_packages.size }}

32 | {% endif %} 33 | {% endfor %} 34 | -------------------------------------------------------------------------------- /lib/jekyll/FDroidPackageDetailPage.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | module Jekyll 19 | class FDroidPackageDetailPage < ReadYamlPage 20 | # @param [Jekyll::Site] site 21 | # @param [string] base 22 | # @param [FDroid::Package] package 23 | def initialize(site, base, package) 24 | @site = site 25 | @base = base 26 | @dir = 'packages' 27 | @name = "#{package.package_name}/index.html" 28 | 29 | self.process(@name) 30 | self.read_yaml(get_layout_dir, 'package.html') 31 | self.data.update(package.to_data) 32 | end 33 | 34 | def get_layout_dir() 35 | layout_dir_override = File.join(site.source, '_layouts') 36 | if File.exists? File.join(layout_dir_override, 'package.html') 37 | layout_dir_override 38 | else 39 | File.expand_path '../../_layouts', File.dirname(__FILE__) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /jekyll-fdroid.gemspec: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | Gem::Specification.new do |s| 19 | s.name = 'jekyll-fdroid' 20 | s.version = '1.2.1' 21 | s.add_runtime_dependency 'jekyll', '< 5.0' 22 | s.add_runtime_dependency "jekyll-include-cache" 23 | s.add_runtime_dependency "jekyll-paginate-v2" 24 | s.add_runtime_dependency 'rubyzip' 25 | s.add_runtime_dependency 'json', '>= 1.8.5' 26 | s.add_runtime_dependency 'loofah' 27 | s.add_development_dependency 'rspec' 28 | s.add_development_dependency 'rubocop' 29 | s.date = '2020-03-13' 30 | s.summary = "F-Droid - Free and Open Source Android App Repository" 31 | s.description = "Browse packages of a F-Droid repository." 32 | s.authors = ["F-Droid"] 33 | s.email = 'team@f-droid.org' 34 | s.files = Dir['lib/**/*.rb'] 35 | s.homepage = 36 | 'https://gitlab.com/fdroid/jekyll-fdroid' 37 | s.license = 'AGPL-3.0' 38 | end 39 | -------------------------------------------------------------------------------- /lib/jekyll/ReadYamlPage.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | # Found at https://github.com/ggreer/jekyll-gallery-generator/blob/master/lib/jekyll-gallery-generator.rb#L69 19 | 20 | module Jekyll 21 | class ReadYamlPage < Page 22 | # We need do define it ourself because the templates are in the plugin's directory 23 | def read_yaml(base, name, opts = {}) 24 | begin 25 | self.content = File.read(File.join(base.to_s, name.to_s), **(site ? site.file_read_opts : {}).merge(opts)) 26 | if content =~ /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m 27 | self.content = $POSTMATCH 28 | self.data = SafeYAML.load($1) 29 | end 30 | rescue SyntaxError => e 31 | Jekyll.logger.warn "YAML Exception reading #{File.join(base.to_s, name.to_s)}: #{e.message}" 32 | rescue Exception => e 33 | Jekyll.logger.warn "Error reading file #{File.join(base.to_s, name.to_s)}: #{e.message}" 34 | end 35 | 36 | self.data ||= {} 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/fdroid/Repo.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Peter Serwylo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | require 'uri' 19 | 20 | module FDroid 21 | class Repo 22 | def initialize(repo) 23 | @repo = repo 24 | end 25 | 26 | def name 27 | escape_html @repo['name'] 28 | end 29 | 30 | def address 31 | url = @repo['address'] 32 | url =~ /\A#{URI::regexp}\z/ ? escape_html(url) : nil 33 | end 34 | 35 | def icon_url 36 | url = "#{self.address}/icons/#{@repo['icon']}" 37 | url =~ /\A#{URI::regexp}\z/ ? escape_html(url) : nil 38 | end 39 | 40 | def description 41 | escape_html @repo['description'] 42 | end 43 | 44 | def timestamp 45 | Integer(@repo['timestamp']) rescue nil 46 | end 47 | 48 | def date 49 | Date.strptime("#{@repo['timestamp'] / 1000}", '%s') 50 | end 51 | 52 | private 53 | 54 | def escape_html(value) 55 | value.gsub(/[<>"'&]/, ESCAPES) 56 | end 57 | 58 | ESCAPES = { 59 | '<' => '<', '>' => '>', '"' => '"', "'" => ''', '&' => '&' 60 | } 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/jekyll/FDroidCategoryDetailPage.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | module Jekyll 19 | class FDroidCategoryDetailPage < ReadYamlPage 20 | # @param [Jekyll::Site] site 21 | # @param [string] base 22 | # @param [string] app_category 23 | # @param [string] app_category_id 24 | def initialize(site, base, app_category, app_category_id) 25 | @site = site 26 | @base = base 27 | @dir = 'categories' 28 | 29 | # Avoid special characters in URL, otherwise language support doesn't work 30 | @name = "#{app_category_id}/index.html" 31 | 32 | self.process(@name) 33 | self.read_yaml(get_layout_dir, 'category-packages.html') 34 | self.data['app_category'] = app_category 35 | self.data['permalink'] = "/categories/#{app_category_id}/" 36 | self.data['pagination']['collection'] = app_category_id 37 | end 38 | 39 | def get_layout_dir() 40 | layout_dir_override = File.join(site.source, '_layouts') 41 | if File.exists? File.join(layout_dir_override, 'category-packages.html') 42 | layout_dir_override 43 | else 44 | File.expand_path '../../_layouts', File.dirname(__FILE__) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /_includes/browse-paginator-navigation.html: -------------------------------------------------------------------------------- 1 | {% if include.paginator.total_pages > 1 %} 2 | 3 |
    4 | 5 | {% if include.paginator.previous_page %} 6 | 9 | {% else %} 10 | 11 | {% endif %} 12 | 13 | {% if include.paginator.page_trail[0].num > 1 %} 14 | 17 | 18 | {% endif %} 19 | 20 | {% for trail in include.paginator.page_trail %} 21 | {% if include.paginator.page == trail.num %} 22 | 23 | {% else %} 24 | 27 | {% endif %} 28 | {% endfor %} 29 | 30 | {% if include.paginator.page_trail[-1].num < include.paginator.total_pages %} 31 | 32 | 41 | {% endif %} 42 | 43 | {% if include.paginator.next_page %} 44 | 47 | {% else %} 48 | 49 | {% endif %} 50 | 51 |
52 | 53 | {% endif %} 54 | -------------------------------------------------------------------------------- /lib/fdroid/Version.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | require_relative './Permission' 19 | 20 | module FDroid 21 | class Version 22 | def initialize(version) 23 | @version = version 24 | end 25 | 26 | def <=>(other) 27 | self.version_code <=> other.version_code 28 | end 29 | 30 | def version_code 31 | @version['versionCode'] 32 | end 33 | 34 | def version_name 35 | @version['versionName'] 36 | end 37 | 38 | def to_data 39 | added = nil 40 | if @version['added'] != nil then 41 | added = Date.strptime("#{@version['added'] / 1000}", '%s') 42 | end 43 | 44 | { 45 | 'added' => added, 46 | 'anti_features' => @version['antiFeatures'], 47 | 'apk_name' => @version['apkName'], 48 | 'file_extension' => File.extname(@version['apkName'].to_s).strip.upcase[1..-1], 49 | 'hash' => @version['hash'], 50 | 'hash_type' => @version['hashType'], 51 | 'max_sdk_version' => @version['maxSdkVersion'], 52 | 'min_sdk_version' => @version['minSdkVersion'], 53 | 'nativecode' => @version['nativecode'], 54 | 'srcname' => @version['srcname'], 55 | 'sig' => @version['sig'], 56 | 'signer' => @version['signer'], 57 | 'size' => @version['size'], 58 | 'target_sdk_version' => @version['targetSdkVersion'], 59 | 'uses_permission' => permission, 60 | 'version_name' => version_name, 61 | 'version_code' => version_code, 62 | } 63 | end 64 | 65 | def permission 66 | if @version['uses-permission'] == nil then 67 | [] 68 | else 69 | @version['uses-permission'].map { |perm| Permission.new(perm).to_data } 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | 3 | [![Gem Version](https://badge.fury.io/rb/jekyll-fdroid.svg)](https://rubygems.org/gems/jekyll-fdroid) 4 | 5 | With this gem you can browse packages of a F-Droid repository in a Jekyll site. 6 | Add the following configurations to your `_config.yml`: 7 | ``` 8 | gems: 9 | - jekyll-fdroid 10 | - jekyll-include-cache 11 | - jekyll-paginate-v2 12 | fdroid-repo: https://guardianproject.info/fdroid/repo 13 | ``` 14 | 15 | `jekyll-include-cache` and `jekyll-paginate-v2` are needed to be added to the configuration manually 16 | because we [weren't able to add the configuration programmatically](https://gitlab.com/fdroid/jekyll-fdroid/issues/29). 17 | 18 | For default styling of the browsing and packages' pages 19 | you need to import the plugin's stylesheet in your SASS file like this: 20 | ``` 21 | @import "jekyll-fdroid"; 22 | ``` 23 | 24 | To show a list of latest or last updated packages, 25 | use the following tags in your page: 26 | ``` 27 | {% fdroid_show_latest_packages %} 28 | {% fdroid_show_last_updated_packages %} 29 | ``` 30 | 31 | ## Running Tests 32 | 33 | To run the test suite, you must first have installed the releveant dependencies: 34 | 35 | ``` 36 | bundle install --path vendor 37 | ``` 38 | 39 | The tests are then run via RSpec: 40 | 41 | ``` 42 | bundle exec rspec 43 | ``` 44 | 45 | If you want to exclude tests which hit the network to download F-Droid metadata, run: 46 | 47 | ``` 48 | bundle exec rspec --tag "~network" 49 | ``` 50 | 51 | ## Can I use this plugin with the old index? 52 | 53 | Starting at version 0.2.0 this plugin only supports the new JSON index 54 | of F-Droid. 55 | If you want to use this plugin with the old XML index, 56 | you can use the [release 0.1.1](https://rubygems.org/gems/jekyll-fdroid/versions/0.1.1) 57 | which is the last one supporting the old index. 58 | 59 | ## Publishing a new version 60 | 61 | Jekyll-FDroid is distributed via [RubyGems.org](https://rubygems.org/gems/jekyll-fdroid). 62 | To quickly sum up [their extensive guides](https://guides.rubygems.org/): 63 | 64 | ```bash 65 | # Build gem package 66 | gem build jekyll-fdroid.gemspec 67 | # Push to RubyGems 68 | gem push jekyll-fdroid-1.0.0.gem 69 | ``` 70 | 71 | ## License 72 | 73 | This program is Free Software: 74 | You can use, study share and improve it at your will. 75 | Specifically you can redistribute and/or modify it under the terms of the 76 | [GNU Affero General Public License](https://www.gnu.org/licenses/agpl.html) 77 | as published by the Free Software Foundation, 78 | either version 3 of the License, 79 | or (at your option) any later version. 80 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | 2 | variables: 3 | LC_ALL: C.UTF-8 4 | DEBIAN_FRONTEND: noninteractive 5 | 6 | # this job aims to setup a common Ruby dev environment 7 | ruby_latest: 8 | image: ruby 9 | script: 10 | - ruby -v 11 | - for f in `find * -name \*.rb`; do printf "$f\t"; ruby -c $f; done 12 | - apt-get update 13 | - apt-get install -y bundler 14 | - bundle config set path 'vendor' 15 | - bundle install 16 | - bundle exec rubocop lib spec 17 | - bundle exec rspec 18 | 19 | # this job aims to reproduce the production setup 20 | fdroid-website: 21 | image: debian:buster 22 | variables: 23 | DEBIAN_FRONTEND: noninteractive 24 | LANG: C.UTF-8 25 | artifacts: 26 | paths: 27 | - public 28 | script: 29 | - apt-get -q update 30 | - apt-get -qy install bundler git ruby --no-install-recommends 31 | - git -C .. clone --depth=1 https://gitlab.com/fdroid/fdroid-website.git 32 | - cd ../fdroid-website 33 | # use the 'gitlab ci' subset of languages 34 | - sed -i 35 | -e 's,^languages:,ignored_languages:,' 36 | -e 's,^gitlab_ci_languages:,languages:,' 37 | _config.yml 38 | # force the use of the local jekyll-fdroid 39 | - perl -i -pe "BEGIN{undef $/;} s@\n *gem 'jekyll-fdroid'.*?\n\s*:ref *=> *'[^']+'\n@\n@smg" Gemfile 40 | - sed -i "s@^end@ gem 'jekyll-fdroid', path\x3a '$CI_PROJECT_DIR'\nend@" Gemfile 41 | # run fdroid-website setup 42 | - ruby -e "require 'yaml'; d=YAML.load_file('.gitlab-ci.yml'); 43 | puts d['.apt-template'].gsub(/\\\\\\n/, ' '); 44 | puts d['.setup_for_jekyll'].gsub(/\\\\\\n/, ' ')" 45 | | bash -e 46 | 47 | - cd $CI_PROJECT_DIR 48 | - rubocop lib spec 49 | - rspec 50 | 51 | - cd ../fdroid-website 52 | # This is where GitLab pages will deploy to by default (e.g. "https://fdroid.gitlab.io/jekyll-fdroid") 53 | # so we need to make sure that the Jekyll configuration understands this. 54 | - sed -Ei 55 | -e "s,^(url\x3a).*,\1 'https://$CI_PROJECT_NAMESPACE.gitlab.io'," 56 | -e "s,^(baseurl\x3a).*,\1 '/$CI_PROJECT_NAME'," 57 | _config.yml 58 | - echo "Jekyll config used for CI:" && cat _config.yml 59 | - jekyll build -d $CI_PROJECT_DIR/public --trace 60 | - ./tools/prepare-multi-lang.sh $CI_PROJECT_DIR/public --no-type-maps 61 | 62 | # TODO kludge to get css until someone figures out why sass is not compiling the css 63 | - apt-get install curl 64 | - mkdir -p $CI_PROJECT_DIR/public/css 65 | - curl https://fdroid.gitlab.io/fdroid-website/css/main.css > $CI_PROJECT_DIR/public/css/main.css 66 | 67 | 68 | pages: 69 | stage: deploy 70 | artifacts: 71 | paths: 72 | - public 73 | expire_in: 1w 74 | when: always 75 | script: 76 | - ls -lR public 77 | 78 | -------------------------------------------------------------------------------- /lib/fdroid/IndexV1.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | require 'tmpdir' 19 | require 'open-uri' 20 | require 'net/http' 21 | require 'json' 22 | require 'zip' 23 | require_relative './Package' 24 | require_relative './Repo' 25 | 26 | module FDroid 27 | class IndexV1 28 | attr_reader :packages, :repo 29 | 30 | @@downloaded_repos = {} 31 | 32 | # Download and parse an index, returning a new instance of IndexV1. 33 | # @param [string] repo 34 | # @param [string] locale 35 | # @return [FDroid::IndexV1] 36 | def self.download(repo, locale) 37 | repo = URI.parse "#{repo}/index-v1.jar" 38 | index = download_index repo 39 | IndexV1.new(JSON.parse(index), locale) 40 | end 41 | 42 | # Make a network request, download the index-v1.jar file from the repo, unzip and get the contents 43 | # of the index-v1.json file. 44 | # @param [string] repo 45 | # @return [Hash] 46 | def self.download_index(repo) 47 | if @@downloaded_repos.has_key? repo 48 | return @@downloaded_repos[repo] 49 | end 50 | 51 | Dir.mktmpdir do |dir| 52 | jar = File.join dir, 'index-v1.jar' 53 | open(jar, 'wb') do |file| 54 | begin 55 | file.write(Net::HTTP.get(repo)) 56 | rescue Net::OpenTimeout, Net::ReadTimeout => e 57 | puts "Timeout (#{e}), retrying in 1 second..." 58 | sleep(1) 59 | retry 60 | end 61 | end 62 | 63 | Zip::File.open(jar) do |zip_file| 64 | entry = zip_file.glob('index-v1.json').first 65 | @@downloaded_repos[repo] = entry.get_input_stream.read 66 | next @@downloaded_repos[repo] 67 | end 68 | end 69 | end 70 | 71 | # What IndexV2 calls "packages", IndexV1 calls "apps" 72 | # What IndexV2 calls "versions", IndexV1 calls "packages" 73 | def initialize(index, locale) 74 | @packages = index['apps'].map do |app_json| 75 | packages_json = index['packages'][app_json['packageName']] 76 | Package.new(app_json, packages_json, locale) 77 | end 78 | 79 | @repo = Repo.new(index['repo']) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/jekyll/FDroidPackageDetailGenerator.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as 7 | # published by the Free Software Foundation, either version 3 of the 8 | # License, or (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | 18 | module Jekyll 19 | class FDroidPackagesGenerator < Generator 20 | attr_accessor :alreadyBuilt 21 | 22 | safe true 23 | priority :highest 24 | 25 | def generate(site) 26 | # generator will only run on first build, not because of auto-regeneration 27 | if @alreadyBuilt != true 28 | @alreadyBuilt = true 29 | 30 | # Add plugin's SASS directory so site's list of SASS directories 31 | if site.config["sass"].nil? || site.config["sass"].empty? 32 | site.config["sass"] = Hash.new 33 | end 34 | if site.config["sass"]["load_paths"].nil? || site.config["sass"]["load_paths"].empty? 35 | site.config["sass"]["load_paths"] = ["_sass", (File.expand_path "../../_sass", File.dirname(__FILE__))] 36 | else 37 | site.config["sass"]["load_paths"] << (File.expand_path "../../_sass", File.dirname(__FILE__)) 38 | end 39 | 40 | # Enable pagination 41 | if site.config["pagination"].nil? || site.config["pagination"].empty? 42 | site.config["pagination"] = Hash.new 43 | end 44 | site.config["pagination"]["enabled"] = true 45 | 46 | index = FDroid::IndexV1.download(site.config["fdroid-repo"], site.active_lang || 'en_US') 47 | 48 | # Generate collection and detail page for every category 49 | site.config["app_categories"].each do |app_category| 50 | app_category_id = Utils.slugify(app_category) 51 | site.collections[app_category_id] = Collection.new(site, app_category_id) 52 | site.pages << FDroidCategoryDetailPage.new(site, site.source, app_category, app_category_id) 53 | end 54 | 55 | # Generate detail page for every package 56 | site.collections["packages"] = Collection.new(site, "packages") 57 | index.packages.each do |package| 58 | # This page needs to be created twice, once for site.pages, and once for site.collections. 59 | # If not, then the i18n code in jekyll-polyglot will end up processing the page twice, as 60 | # it iterates over all pages and all packages. The end result is a double prefix for "/en/en" 61 | # for any links in the page. 62 | # https://gitlab.com/fdroid/jekyll-fdroid/issues/38 63 | site.pages << FDroidPackageDetailPage.new(site, site.source, package) 64 | site.collections["packages"].docs << FDroidPackageDetailPage.new(site, site.source, package) 65 | 66 | package.categories.each do |app_category| 67 | app_category_id = Utils.slugify(app_category) 68 | if site.collections[app_category_id].nil? 69 | puts("Warning: Package '#{package.package_name}' has unknown category '#{app_category}', will be ignored") 70 | else 71 | site.collections[app_category_id].docs << FDroidPackageDetailPage.new(site, site.source, package) 72 | end 73 | end 74 | end 75 | 76 | # Generate browsing pages 77 | site.includes_load_paths << (File.expand_path "../../_includes", File.dirname(__FILE__)) 78 | site.pages << FDroidBrowsingPage.new(site, site.source) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /_sass/jekyll-fdroid.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * F-Droid's Jekyll Plugin 3 | * 4 | * Copyright (C) 2017 Nico Alt 5 | * Includes tweak by Dario Centrella in 2020 6 | * 7 | * This program is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU Affero General Public License as 9 | * published by the Free Software Foundation, either version 3 of the 10 | * License, or (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU Affero General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU Affero General Public License 18 | * along with this program. If not, see . 19 | */ 20 | 21 | @if global-variable-exists(icon-size) != true { 22 | $icon-size: 48px !global; 23 | } 24 | @if global-variable-exists(base-font-size) != true { 25 | $base-font-size: 16px !global; 26 | } 27 | @if global-variable-exists(spacing-unit) != true { 28 | $spacing-unit: 32px !global; 29 | } 30 | @if global-variable-exists(text-color) != true { 31 | $text-color: lighten(#000, 13%) !global; 32 | } 33 | @if global-variable-exists(text-color-light) != true { 34 | $text-color-light: lighten(#000, 46%) !global; 35 | } 36 | 37 | /** 38 | * Browsing 39 | */ 40 | 41 | .package-header, .package-header:visited { 42 | display: flex; 43 | align-items: top; 44 | color: $text-color; 45 | margin-bottom: $spacing-unit / 3; 46 | 47 | &:hover { 48 | text-decoration: none; 49 | } 50 | 51 | .package-icon { 52 | width: $icon-size; 53 | height: $icon-size; 54 | margin-top: $spacing-unit / 4; 55 | margin-inline-end: $spacing-unit / 2; 56 | flex-shrink: 0; 57 | } 58 | 59 | .package-name { 60 | margin: 0; 61 | } 62 | 63 | .package-license { 64 | color: $text-color-light; 65 | 66 | &:before { content: " (" } 67 | &:after { content: ")" } 68 | } 69 | } 70 | 71 | /** 72 | * List of packages for sidebar. Show a more condensed view than the full list of packages. 73 | */ 74 | .sidebar-widget .package-header { 75 | .package-icon { 76 | width: $icon-size * 5 / 6; 77 | height: $icon-size * 5 / 6; 78 | } 79 | 80 | .package-name { 81 | font-size: $base-font-size * 1.1; 82 | } 83 | 84 | .package-summary, .package-license { 85 | color: $text-color-light; 86 | font-size: $base-font-size * 0.9; 87 | } 88 | } 89 | 90 | .browse-navigation { 91 | @include reset-ul; 92 | 93 | display: inline-block; 94 | margin: 1em; 95 | 96 | .nav { 97 | $border-radius: 0.2em; 98 | display: inline; 99 | float: left; 100 | color: $text-color; 101 | 102 | .label { 103 | border: solid 1px #aaa; 104 | margin: 0 0 0 -1px; 105 | text-align: center; 106 | padding: 0.25em 0.5em; 107 | } 108 | 109 | &.disabled { 110 | color: #bbb; 111 | } 112 | 113 | &.active { 114 | .label { 115 | background-color: lighten($primary-color, 50%); 116 | } 117 | } 118 | 119 | &:first-child .label { 120 | border-top-left-radius: $border-radius; 121 | border-bottom-left-radius: $border-radius; 122 | } 123 | 124 | &:last-child .label { 125 | border-top-right-radius: $border-radius; 126 | border-bottom-right-radius: $border-radius; 127 | } 128 | 129 | a { 130 | color: $text-color; 131 | } 132 | 133 | a:hover { 134 | text-decoration: none; 135 | background: lighten($primary-color, 15%); 136 | } 137 | 138 | } 139 | 140 | // Only display previous, current, and next on smaller screens. 141 | @include media-query($tablet) { 142 | .nav .label { 143 | display: none; 144 | } 145 | 146 | .nav.page.active .label, 147 | .nav.previous .label, 148 | .nav.next .label { 149 | display: inline; 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * Packages view 156 | */ 157 | .package-versions-list { 158 | list-style:none; 159 | padding-inline-start: 0; 160 | } 161 | 162 | .package-version { 163 | margin-bottom: 5px; 164 | } 165 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | RSpec.configure do |config| 20 | # rspec-expectations config goes here. You can use an alternate 21 | # assertion/expectation library such as wrong or the stdlib/minitest 22 | # assertions if you prefer. 23 | config.expect_with :rspec do |expectations| 24 | # This option will default to `true` in RSpec 4. It makes the `description` 25 | # and `failure_message` of custom matchers include text for helper methods 26 | # defined using `chain`, e.g.: 27 | # be_bigger_than(2).and_smaller_than(4).description 28 | # # => "be bigger than 2 and smaller than 4" 29 | # ...rather than: 30 | # # => "be bigger than 2" 31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 32 | end 33 | 34 | # rspec-mocks config goes here. You can use an alternate test double 35 | # library (such as bogus or mocha) by changing the `mock_with` option here. 36 | config.mock_with :rspec do |mocks| 37 | # Prevents you from mocking or stubbing a method that does not exist on 38 | # a real object. This is generally recommended, and will default to 39 | # `true` in RSpec 4. 40 | mocks.verify_partial_doubles = true 41 | end 42 | 43 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 44 | # have no way to turn it off -- the option exists only for backwards 45 | # compatibility in RSpec 3). It causes shared context metadata to be 46 | # inherited by the metadata hash of host groups and examples, rather than 47 | # triggering implicit auto-inclusion in groups with matching metadata. 48 | config.shared_context_metadata_behavior = :apply_to_host_groups 49 | 50 | # The settings below are suggested to provide a good initial experience 51 | # with RSpec, but feel free to customize to your heart's content. 52 | =begin 53 | # This allows you to limit a spec run to individual examples or groups 54 | # you care about by tagging them with `:focus` metadata. When nothing 55 | # is tagged with `:focus`, all examples get run. RSpec also provides 56 | # aliases for `it`, `describe`, and `context` that include `:focus` 57 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 58 | config.filter_run_when_matching :focus 59 | 60 | # Allows RSpec to persist some state between runs in order to support 61 | # the `--only-failures` and `--next-failure` CLI options. We recommend 62 | # you configure your source control system to ignore this file. 63 | config.example_status_persistence_file_path = "spec/examples.txt" 64 | 65 | # Limits the available syntax to the non-monkey patched syntax that is 66 | # recommended. For more details, see: 67 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 68 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 69 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 70 | config.disable_monkey_patching! 71 | 72 | # This setting enables warnings. It's recommended, but in some cases may 73 | # be too noisy due to issues in dependencies. 74 | config.warnings = true 75 | 76 | # Many RSpec users commonly either run the entire suite or an individual 77 | # file, and it's useful to allow more verbose output when running an 78 | # individual spec file. 79 | if config.files_to_run.one? 80 | # Use the documentation formatter for detailed output, 81 | # unless a formatter has already been configured 82 | # (e.g. via a command-line flag). 83 | config.default_formatter = 'doc' 84 | end 85 | 86 | # Print the 10 slowest examples and example groups at the 87 | # end of the spec run, to help surface which specs are running 88 | # particularly slow. 89 | config.profile_examples = 10 90 | 91 | # Run specs in random order to surface order dependencies. If you find an 92 | # order dependency and want to debug it, you can fix the order by providing 93 | # the seed, which is printed after each run. 94 | # --seed 1234 95 | config.order = :random 96 | 97 | # Seed global randomization in this process using the `--seed` CLI option. 98 | # Setting this allows you to use `--seed` to deterministically reproduce 99 | # test failures related to randomization by passing the same `--seed` value 100 | # as the one that triggered the failure. 101 | Kernel.srand config.seed 102 | =end 103 | end 104 | -------------------------------------------------------------------------------- /spec/assets/localized.json: -------------------------------------------------------------------------------- 1 | { 2 | "fr-CA": { 3 | "phoneScreenshots": [ 4 | "Phone 1 [fr-CA].jpg", 5 | "Phone 2 [fr-CA].jpg" 6 | ], 7 | "wearScreenshots": [ 8 | "Wear 1 [fr-CA].jpg", 9 | "Wear 2 [fr-CA].jpg" 10 | ], 11 | "sevenInchScreenshots": [ 12 | "7\" 1 [fr-CA].jpg", 13 | "7\" 2 [fr-CA].jpg" 14 | ], 15 | "tenInchScreenshots": [ 16 | "10\" 1 [fr-CA].jpg", 17 | "10\" 2 [fr-CA].jpg" 18 | ], 19 | "tvScreenshots": [ 20 | "TV 1 [fr-CA].jpg", 21 | "TV 2 [fr-CA].jpg" 22 | ], 23 | "whatsNew": "Whats New [fr-CA].png", 24 | "video": "Video [fr-CA].png", 25 | "icon": "icon [fr-CA].png", 26 | "name": "App [fr-CA]", 27 | "summary": "Summary [fr-CA]", 28 | "featureGraphic": "Feature Graphic [fr-CA].png", 29 | "promoGraphic": "Promo Graphic [fr-CA].png", 30 | "tvBanner": "TV Banner [fr-CA].png", 31 | "description": "Description [fr-CA]" 32 | }, 33 | "de": { 34 | "phoneScreenshots": [ 35 | "Phone 1 [de].jpg", 36 | "Phone 2 [de].jpg" 37 | ], 38 | "wearScreenshots": [ 39 | "Wear 1 [de].jpg", 40 | "Wear 2 [de].jpg" 41 | ], 42 | "sevenInchScreenshots": [ 43 | "7\" 1 [de].jpg", 44 | "7\" 2 [de].jpg" 45 | ], 46 | "tenInchScreenshots": [ 47 | "10\" 1 [de].jpg", 48 | "10\" 2 [de].jpg" 49 | ], 50 | "tvScreenshots": [ 51 | "TV 1 [de].jpg", 52 | "TV 2 [de].jpg" 53 | ], 54 | "whatsNew": "Whats New [de].png", 55 | "video": "Video [de].png", 56 | "icon": "icon [de].png", 57 | "name": "App [de]", 58 | "summary": "Summary [de]", 59 | "featureGraphic": "Feature Graphic [de].png", 60 | "promoGraphic": "Promo Graphic [de].png", 61 | "tvBanner": "TV Banner [de].png", 62 | "description": "Description [de]" 63 | }, 64 | "de-DE": { 65 | "phoneScreenshots": [ 66 | "Phone 1 [de-DE].jpg", 67 | "Phone 2 [de-DE].jpg" 68 | ], 69 | "wearScreenshots": [ 70 | "Wear 1 [de-DE].jpg", 71 | "Wear 2 [de-DE].jpg" 72 | ], 73 | "sevenInchScreenshots": [ 74 | "7\" 1 [de-DE].jpg", 75 | "7\" 2 [de-DE].jpg" 76 | ], 77 | "tenInchScreenshots": [ 78 | "10\" 1 [de-DE].jpg", 79 | "10\" 2 [de-DE].jpg" 80 | ], 81 | "tvScreenshots": [ 82 | "TV 1 [de-DE].jpg", 83 | "TV 2 [de-DE].jpg" 84 | ], 85 | "whatsNew": "Whats New [de-DE].png", 86 | "video": "Video [de-DE].png", 87 | "icon": "icon [de-DE].png", 88 | "name": "App [de-DE]", 89 | "summary": "Summary [de-DE]", 90 | "featureGraphic": "Feature Graphic [de-DE].png", 91 | "promoGraphic": "Promo Graphic [de-DE].png", 92 | "tvBanner": "TV Banner [de-DE].png", 93 | "description": "Description [de-DE]" 94 | }, 95 | "de-AT": { 96 | "phoneScreenshots": [ 97 | "Phone 1 [de-AT].jpg", 98 | "Phone 2 [de-AT].jpg" 99 | ], 100 | "wearScreenshots": [ 101 | "Wear 1 [de-AT].jpg", 102 | "Wear 2 [de-AT].jpg" 103 | ], 104 | "sevenInchScreenshots": [ 105 | "7\" 1 [de-AT].jpg", 106 | "7\" 2 [de-AT].jpg" 107 | ], 108 | "tenInchScreenshots": [ 109 | "10\" 1 [de-AT].jpg", 110 | "10\" 2 [de-AT].jpg" 111 | ], 112 | "tvScreenshots": [ 113 | "TV 1 [de-AT].jpg", 114 | "TV 2 [de-AT].jpg" 115 | ], 116 | "whatsNew": "Whats New [de-AT].png", 117 | "video": "Video [de-AT].png", 118 | "icon": "icon [de-AT].png", 119 | "name": "App [de-AT]", 120 | "summary": "Summary [de-AT]", 121 | "featureGraphic": "Feature Graphic [de-AT].png", 122 | "promoGraphic": "Promo Graphic [de-AT].png", 123 | "tvBanner": "TV Banner [de-AT].png", 124 | "description": "Description [de-AT]" 125 | }, 126 | "en-US": { 127 | "phoneScreenshots": [ 128 | "Phone 1 [en-US].jpg", 129 | "Phone 2 [en-US].jpg" 130 | ], 131 | "wearScreenshots": [ 132 | "Wear 1 [en-US].jpg", 133 | "Wear 2 [en-US].jpg" 134 | ], 135 | "sevenInchScreenshots": [ 136 | "7\" 1 [en-US].jpg", 137 | "7\" 2 [en-US].jpg" 138 | ], 139 | "tenInchScreenshots": [ 140 | "10\" 1 [en-US].jpg", 141 | "10\" 2 [en-US].jpg" 142 | ], 143 | "tvScreenshots": [ 144 | "TV 1 [en-US].jpg", 145 | "TV 2 [en-US].jpg" 146 | ], 147 | "whatsNew": "Whats New [en-US].png", 148 | "video": "Video [en-US].png", 149 | "icon": "icon [en-US].png", 150 | "name": "App [en-US]", 151 | "summary": "Summary [en-US]", 152 | "featureGraphic": "Feature Graphic [en-US].png", 153 | "promoGraphic": "Promo Graphic [en-US].png", 154 | "tvBanner": "TV Banner [en-US].png", 155 | "description": "Description [en-US]" 156 | }, 157 | "en-AU": { 158 | "phoneScreenshots": [ 159 | "Phone 1 [en-AU].jpg", 160 | "Phone 2 [en-AU].jpg" 161 | ], 162 | "wearScreenshots": [ 163 | "Wear 1 [en-AU].jpg", 164 | "Wear 2 [en-AU].jpg" 165 | ], 166 | "sevenInchScreenshots": [ 167 | "7\" 1 [en-AU].jpg", 168 | "7\" 2 [en-AU].jpg" 169 | ], 170 | "tenInchScreenshots": [ 171 | "10\" 1 [en-AU].jpg", 172 | "10\" 2 [en-AU].jpg" 173 | ], 174 | "tvScreenshots": [ 175 | "TV 1 [en-AU].jpg", 176 | "TV 2 [en-AU].jpg" 177 | ], 178 | "whatsNew": "Whats New [en-AU].png", 179 | "video": "Video [en-AU].png", 180 | "icon": "icon [en-AU].png", 181 | "name": "App [en-AU]", 182 | "summary": "Summary [en-AU]", 183 | "featureGraphic": "Feature Graphic [en-AU].png", 184 | "promoGraphic": "Promo Graphic [en-AU].png", 185 | "tvBanner": "TV Banner [en-AU].png", 186 | "description": "Description [en-AU]" 187 | }, 188 | "en": { 189 | "phoneScreenshots": [ 190 | "Phone 1 [en].jpg", 191 | "Phone 2 [en].jpg" 192 | ], 193 | "wearScreenshots": [ 194 | "Wear 1 [en].jpg", 195 | "Wear 2 [en].jpg" 196 | ], 197 | "sevenInchScreenshots": [ 198 | "7\" 1 [en].jpg", 199 | "7\" 2 [en].jpg" 200 | ], 201 | "tenInchScreenshots": [ 202 | "10\" 1 [en].jpg", 203 | "10\" 2 [en].jpg" 204 | ], 205 | "tvScreenshots": [ 206 | "TV 1 [en].jpg", 207 | "TV 2 [en].jpg" 208 | ], 209 | "whatsNew": "Whats New [en].png", 210 | "video": "Video [en].png", 211 | "icon": "icon [en].png", 212 | "name": "App [en]", 213 | "summary": "Summary [en]", 214 | "featureGraphic": "Feature Graphic [en].png", 215 | "promoGraphic": "Promo Graphic [en].png", 216 | "tvBanner": "TV Banner [en].png", 217 | "description": "Description [en]" 218 | } 219 | } -------------------------------------------------------------------------------- /lib/fdroid/Package.rb: -------------------------------------------------------------------------------- 1 | # F-Droid's Jekyll Plugin 2 | # 3 | # Copyright (C) 2017 Nico Alt 4 | # Copyright (C) 2022 FC Stegerman 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with this program. If not, see . 18 | 19 | require 'loofah' 20 | require 'uri' 21 | require_relative './Version' 22 | 23 | # override the HTML elements loofah allows; be more restrictive 24 | module Loofah::HTML5::Scrub 25 | OVERRIDDEN_SAFE_ELEMENTS = Set.new( 26 | ["a", "b", "big", "blockquote", "br", "cite", "em", "i", "small", 27 | "strike", "strong", "sub", "sup", "tt", "u"] + ["li", "ol", "ul"] 28 | ) 29 | 30 | def self.allowed_element?(element_name) 31 | OVERRIDDEN_SAFE_ELEMENTS.include?(element_name) 32 | end 33 | end 34 | 35 | module Loofah::Scrubbers 36 | class FDroid < Loofah::Scrubber 37 | def initialize 38 | @direction = :top_down 39 | end 40 | 41 | def scrub(node) 42 | return CONTINUE unless (node.type == Nokogiri::XML::Node::ELEMENT_NODE) && (node.name == 'a') 43 | 44 | node.keys.each do |attribute| 45 | if attribute != 'href' 46 | node.delete attribute 47 | end 48 | end 49 | 50 | begin 51 | url = URI.parse(node.attributes['href'].to_s) 52 | return STOP if url.host == nil || url.host.empty? || url.host == 'f-droid.org' 53 | rescue URI::Error 54 | # treat this URL as external 55 | end 56 | 57 | append_attribute(node, 'rel', 'external') 58 | append_attribute(node, 'rel', 'nofollow') 59 | append_attribute(node, 'rel', 'noopener') 60 | append_attribute(node, 'target', '_blank') 61 | return STOP 62 | end 63 | end 64 | end 65 | 66 | Loofah::Scrubbers::MAP[:fdroid] = Loofah::Scrubbers::FDroid 67 | 68 | module FDroid 69 | class Package 70 | def initialize(package, versions, locale) 71 | # Sort versions in reverse-chronological order 72 | @versions = versions.map { |p| Version.new(p) } 73 | @package = package 74 | @locale = locale 75 | @available_locales = package.key?('localized') ? Package.available_locales(locale, package['localized']) : nil 76 | @is_localized = Package.is_localized(locale, @available_locales) 77 | end 78 | 79 | # NB: safe (has strict checks on it and was the subject of a previous audit) 80 | def package_name 81 | @package['packageName'] 82 | end 83 | 84 | def to_s 85 | package_name 86 | end 87 | 88 | # NB: safe (can contain '&' but must be in site.config["app_categories"]) 89 | def categories 90 | @package['categories'] 91 | end 92 | 93 | # Generates a hash of dumb strings to be used in templates. 94 | # If a specific value is not present, then it will have a nil value. 95 | # If a value can be localized, then it will choose the most appropriate 96 | # translation based on @available_locales and @locale. 97 | # The 'versions' key is an array of Version.to_data hashes. 98 | # @return [Hash] 99 | def to_data 100 | liberapay = @package['liberapay'] 101 | if liberapay == nil 102 | liberapayID = @package['liberapayID'] 103 | if liberapayID != nil 104 | liberapay = "~#{liberapayID}" 105 | end 106 | end 107 | data = { 108 | # These fields are taken as is from the metadata. If not present, they are 109 | 'package_name' => package_name, 110 | 'author_email' => @package['authorEmail'], 111 | 'author_name' => @package['authorName'], 112 | 'author_website' => @package['authorWebSite'], 113 | 'translation' => @package['translation'], 114 | 'bitcoin' => @package['bitcoin'], 115 | 'litecoin' => @package['litecoin'], 116 | 'donate' => @package['donate'], 117 | 'flattrID' => @package['flattrID'], 118 | 'liberapay' => liberapay, 119 | 'liberapayID' => @package['liberapayID'], 120 | 'openCollective' => @package['openCollective'], 121 | 'categories' => @package['categories'], 122 | 'anti_features' => @package['antiFeatures'], 123 | 'suggested_version_code' => suggested_version_code, 124 | 'suggested_version_name' => @versions.detect { |p| p.version_code == suggested_version_code }&.version_name, 125 | 'issue_tracker' => @package['issueTracker'], 126 | 'changelog' => @package['changelog'], 127 | 'license' => @package['license'], 128 | 'source_code' => @package['sourceCode'], 129 | 'website' => @package['webSite'], 130 | 'added' => @package['added'], 131 | 'last_updated' => @package['lastUpdated'], 132 | 'is_localized' => @is_localized, 133 | 'whats_new' => Package.process_package_description(Package.localized(@available_locales, @package['localized'], 'whatsNew')), 134 | 'icon' => icon, 135 | 'title' => name, 136 | 'summary' => summary, 137 | 'description' => Package.process_package_description(description), 138 | 'feature_graphic' => Package.localized_graphic_path(@available_locales, @package['localized'], 'featureGraphic'), 139 | 'phone_screenshots' => Package.localized_graphic_list_paths(@available_locales, @package['localized'], 'phoneScreenshots'), 140 | 'seven_inch_screenshots' => Package.localized_graphic_list_paths(@available_locales, @package['localized'], 'sevenInchScreenshots'), 141 | 'ten_inch_screenshots' => Package.localized_graphic_list_paths(@available_locales, @package['localized'], 'tenInchScreenshots'), 142 | 'tv_screenshots' => Package.localized_graphic_list_paths(@available_locales, @package['localized'], 'tvScreenshots'), 143 | 'wear_screenshots' => Package.localized_graphic_list_paths(@available_locales, @package['localized'], 'wearScreenshots'), 144 | 'versions' => @versions.sort.reverse.map { |p| p.to_data }, 145 | 'beautiful_url' => "/packages/#{package_name}" 146 | } 147 | 148 | # recursively sanitise data before returning, except for description and 149 | # whats_new, which have already passed through process_package_description 150 | # (and were thus scrubbed by loofah via format_description_to_html) 151 | return Package.sanitise(data, skip = ['description', 'whats_new']) 152 | end 153 | 154 | # Any transformations which are required to turn the "description" into something which is 155 | # displayable via HTML is done here (e.g. replacing "fdroid.app:" schemes, formatting new lines, 156 | # etc. 157 | def self.process_package_description(string) 158 | return nil if string == nil 159 | 160 | format_description_to_html(replace_fdroid_app_links(string)) 161 | end 162 | 163 | # Finds all https://f-droid.org links that end with an Application ID, and 164 | # replaces them with an HTML link. 165 | # @param [string] string 166 | # @return [string] 167 | def self.replace_fdroid_app_links(string) 168 | string.gsub(/fdroid\.app:([a-zA-Z0-9._]+)/, 169 | '\1') 170 | .gsub(/([^"])(https:\/\/f-droid\.org\/[^\s?#]+\/)((?:[a-zA-Z_]+(?:\d*[a-zA-Z_]*)*)(?:\.[a-zA-Z_]+(?:\d*[a-zA-Z_]*)*)*)\/?/, 171 | '\1\3') 172 | end 173 | 174 | # Ensure newlines in descriptions are preserved (converted to "
" tags) 175 | # Handles UNIX, Windows and MacOS newlines, with a one-to-one replacement 176 | def self.format_description_to_html(string) 177 | Loofah.fragment(string) 178 | .scrub!(:strip) 179 | .scrub!(:fdroid) 180 | .to_html(:save_with => 0) 181 | .gsub(/(?:\n\r?|\r\n?)/, '
') 182 | end 183 | 184 | # @param [string] available_locales 185 | # @param [string] localized 186 | # @param [string] field 187 | # @return [string] 188 | def self.localized(available_locales, localized, field) 189 | return nil unless available_locales != nil 190 | 191 | available_locales.each do |l| 192 | if localized[l].key?(field) 193 | return localized[l][field] 194 | end 195 | end 196 | 197 | return nil 198 | end 199 | 200 | # Prefixes the result with "chosen_locale/" before returning. 201 | # @see localized 202 | def self.localized_graphic_path(available_locales, localized, field) 203 | return nil unless available_locales != nil 204 | 205 | available_locales.each do |l| 206 | if localized[l].key?(field) 207 | return "#{l}/#{localized[l][field]}" 208 | end 209 | end 210 | return nil 211 | end 212 | 213 | # Similar to localized_graphic_path, but prefixes each item in the resulting array 214 | # with "chosen_locale/field/". 215 | # @see localized 216 | # @see localized_graphic_path 217 | def self.localized_graphic_list_paths(available_locales, localized, field) 218 | return nil unless available_locales != nil 219 | 220 | available_locales.each do |l| 221 | if localized[l].key?(field) 222 | return localized[l][field].map { |val| "#{l}/#{field}/#{val}" } 223 | end 224 | end 225 | return nil 226 | end 227 | 228 | # simple test for whether this app contains localized metadata for this app 229 | def self.is_localized(locale, available_locales) 230 | return nil unless locale != nil && available_locales != nil 231 | return locale if locale == '_' 232 | 233 | available_locales.each do |l| 234 | if l == locale 235 | return l 236 | end 237 | end 238 | lang = locale.split(/[_-]/)[0] 239 | available_locales.each do |l| 240 | if l == lang 241 | return l 242 | end 243 | end 244 | available_locales.each do |l| 245 | if l.start_with?(lang) 246 | return l 247 | end 248 | end 249 | return nil 250 | end 251 | 252 | # Given the desired_locale, searches through the list of localized_data entries 253 | # and finds those with keys which match either: 254 | # * The desired locale exactly 255 | # * The same language as the desired locale (but different region) 256 | # * Any English language (so if the desired language is not there it will suffice) 257 | # 258 | # These will be sorted in order of preference: 259 | # * Exact matches (language and region) 260 | # * Language portion matches and region matches an "alias" (e.g. zh_Hant and zh-TW). 261 | # * Language portion matches but region is absent/doesn't match. 262 | # * en-US 263 | # * en 264 | # * en-* 265 | # 266 | # It is intentionally liberal in searching for either "_" or "-" to separate language 267 | # and region, because they both mean (in different context) to split langugae on the 268 | # left, and region on the right, and it is cheap to do so. 269 | # 270 | # @param [string] desired_locale 271 | # @param [Hash] localized_data 272 | # @return [Array] 273 | def self.available_locales(desired_locale, localized_data) 274 | # website uses zh_Hant/zh_Hans, but zh-TW/zh-CN are common in localized data 275 | aliases = { 'zh' => { 'Hant' => ['TW'], 'Hans' => ['CN'] } } 276 | 277 | parts = desired_locale.split(/[_-]/) 278 | desired_lang = parts[0] 279 | desired_region = parts.length > 1 ? parts[1] : nil 280 | 281 | locales = localized_data.keys.select do |available_locale| 282 | parts = available_locale.split(/[_-]/) 283 | available_lang = parts[0] 284 | available_lang == desired_lang || available_lang == 'en' 285 | end 286 | 287 | measure_locale_goodness = lambda do |locale| 288 | parts = locale.split(/[_-]/) 289 | lang = parts[0] 290 | region = parts.length > 1 ? parts[1] : nil 291 | if locale == desired_locale 292 | return 1 293 | elsif lang == desired_lang 294 | if aliases.fetch(lang, {}).fetch(desired_region, []).include?(region) 295 | return 2 296 | else 297 | return 3 298 | end 299 | elsif locale == 'en-US' 300 | return 4 301 | elsif lang == 'en' && region.nil? 302 | return 5 303 | elsif lang == 'en' 304 | return 6 305 | end 306 | end 307 | 308 | locales.sort do |a, b| 309 | measure_locale_goodness.call(a) <=> measure_locale_goodness.call(b) 310 | end 311 | end 312 | 313 | # used to recursively sanitise the hash returned by to_data, except for any 314 | # data already passed through process_package_description (and thus scrubbed 315 | # by loofah) 316 | def self.sanitise(value, skip = []) 317 | case value 318 | when String 319 | value.gsub(/[<>"'&]/, ESCAPES) 320 | when Hash 321 | value.map { |k, v| skip.include?(k) ? [k, v] : [k, sanitise(v)] }.to_h 322 | when Array 323 | value.map { |x| sanitise(x) } 324 | when Date, Float, Integer, nil 325 | value 326 | else 327 | raise "cannot sanitise #{value.inspect}" 328 | end 329 | end 330 | 331 | ESCAPES = { 332 | '<' => '<', '>' => '>', '"' => '"', "'" => ''', '&' => '&' 333 | } 334 | 335 | private 336 | 337 | def icon 338 | localized = Package.localized_graphic_path(@available_locales, @package['localized'], 'icon') 339 | if localized 340 | "#{package_name}/#{localized}" 341 | elsif @package['icon'] 342 | "icons-640/#{@package['icon']}" 343 | end 344 | end 345 | 346 | # this must exist since all entries are sorted by name, 347 | # it uses tildes since they sort last 348 | def name 349 | @package['name'] || Package.localized(@available_locales, @package['localized'], 'name') || '~missing name~' 350 | end 351 | 352 | def summary 353 | @package['summary'] || Package.localized(@available_locales, @package['localized'], 'summary') 354 | end 355 | 356 | def description 357 | @package['description'] || Package.localized(@available_locales, @package['localized'], 'description') 358 | end 359 | 360 | def suggested_version_code 361 | Integer(@package['suggestedVersionCode']) rescue nil 362 | end 363 | end 364 | end 365 | -------------------------------------------------------------------------------- /spec/lib/fdroid/FDroidIndex_spec.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | require 'rspec' 4 | require 'pp' 5 | require 'json' 6 | require_relative '../../../lib/fdroid/IndexV1' 7 | require_relative '../../../lib/fdroid/Package' 8 | 9 | module FDroid 10 | RSpec.describe Package do 11 | localized_path = File.expand_path '../../assets/localized.json', File.dirname(__FILE__) 12 | localized = JSON.parse(File.read(localized_path)) 13 | 14 | gp_path = File.expand_path '../../assets/index-v1.gp.json', File.dirname(__FILE__) 15 | gp_json = JSON.parse(File.read(gp_path)) 16 | 17 | it 'Decides which locales to use' do 18 | de_locales = Package.available_locales('de-DE', localized) 19 | expect(de_locales).to eq(['de-DE', 'de', 'de-AT', 'en-US', 'en', 'en-AU']) 20 | expect(Package.is_localized('de-CH', de_locales)).to eq('de') 21 | expect(Package.is_localized('de', de_locales)).to eq('de') 22 | expect(Package.is_localized('de-DE', de_locales)).to eq('de-DE') 23 | expect(Package.is_localized('de-AT', de_locales)).to eq('de-AT') 24 | expect(Package.is_localized('en-AU', de_locales)).to eq('en-AU') 25 | expect(Package.is_localized('en-US', de_locales)).to eq('en-US') 26 | expect(Package.is_localized('en-GB', de_locales)).to eq('en') 27 | expect(Package.is_localized('zh-CN', de_locales)).to eq(nil) 28 | 29 | fr_locales = Package.available_locales('fr-FR', localized) 30 | expect(fr_locales).to eq(['fr-CA', 'en-US', 'en', 'en-AU']) 31 | expect(Package.is_localized('fr-CA', fr_locales)).to eq('fr-CA') 32 | expect(Package.is_localized('fr-FR', fr_locales)).to eq('fr-CA') 33 | expect(Package.is_localized('zh-CN', fr_locales)).to eq(nil) 34 | 35 | en_locales = Package.available_locales('en', localized) 36 | expect(en_locales).to eq(['en', 'en-US', 'en-AU']) 37 | expect(Package.is_localized('en-AU', en_locales)).to eq('en-AU') 38 | expect(Package.is_localized('en-ZA', en_locales)).to eq('en') 39 | expect(Package.is_localized('fr-FR', en_locales)).to eq(nil) 40 | 41 | zh_locales = Package.available_locales('zh', localized) 42 | expect(zh_locales).to eq(['en-US', 'en', 'en-AU']) 43 | expect(Package.is_localized('fr-FR', zh_locales)).to eq(nil) 44 | expect(Package.is_localized('zh-CN', zh_locales)).to eq(nil) 45 | end 46 | 47 | it 'Handles locale aliases' do 48 | localized_zh = { 'en-US' => {}, 'zh-CN' => {}, 'zh-TW' => {} } 49 | zh_Hant = Package.available_locales('zh_Hant', localized_zh) 50 | expect(zh_Hant).to eq(['zh-TW', 'zh-CN', 'en-US']) 51 | zh_Hans = Package.available_locales('zh_Hans', localized_zh) 52 | expect(zh_Hans).to eq(['zh-CN', 'zh-TW', 'en-US']) 53 | end 54 | 55 | it 'Calculates localized metadata correctly' do 56 | de_locales = Package.available_locales('de-DE', localized) 57 | 58 | name = Package.localized(de_locales, localized, 'name') 59 | expect(name).to eql('App [de-DE]') 60 | 61 | feature_graphic = Package.localized_graphic_path(de_locales, localized, 'featureGraphic') 62 | expect(feature_graphic).to eql('de-DE/Feature Graphic [de-DE].png') 63 | 64 | phone_screenshots = Package.localized_graphic_list_paths(de_locales, localized, 'phoneScreenshots') 65 | expect(phone_screenshots).to eql( 66 | [ 67 | 'de-DE/phoneScreenshots/Phone 1 [de-DE].jpg', 68 | 'de-DE/phoneScreenshots/Phone 2 [de-DE].jpg', 69 | ] 70 | ) 71 | end 72 | 73 | it 'Formats package descriptions correctly' do 74 | text = "This 75 | is 76 | a 77 | 78 | multi-line 79 | 80 | string 81 | here" 82 | multi_line = Package.format_description_to_html(text) 83 | expect(multi_line).to eql("This
is
a

multi-line

string
here") 84 | end 85 | 86 | it 'Formats f-droid.org links in descriptions' do 87 | text = "fdroid.app:com.linuxcounter.lico_update_003:" 88 | multi_line = Package.process_package_description(text) 89 | expect(multi_line).to eql('com.linuxcounter.lico_update_003:') 90 | 91 | text = "pointing to https://f-droid.org/packages/com.banasiak.coinflip/: 92 | This" 93 | multi_line = Package.process_package_description(text) 94 | expect(multi_line).to eql('pointing to com.banasiak.coinflip:
This') 95 | 96 | text = "Starting with https://f-droid.org/packages/SpeedoMeterPackage.main. 97 | (this" 98 | multi_line = Package.process_package_description(text) 99 | expect(multi_line).to eql('Starting with SpeedoMeterPackage.main.
(this') 100 | 101 | text = "works https://f-droid.org/packages/org.fitchfamily.android.wifi_backend_v2) 102 | Do" 103 | multi_line = Package.process_package_description(text) 104 | expect(multi_line).to eql('works org.fitchfamily.android.wifi_backend_v2)
Do') 105 | 106 | text = "forget https://f-droid.org/packages/org.microg.nlp" 107 | multi_line = Package.process_package_description(text) 108 | expect(multi_line).to eql('forget org.microg.nlp') 109 | 110 | text = ' * North America, Canada' 111 | multi_line = Package.process_package_description(text) 112 | expect(multi_line).to eql(text) 113 | 114 | text = ' Kwik DMAP is a stand-alone digital' 115 | multi_line = Package.process_package_description(text) 116 | expect(multi_line).to eql(text) 117 | end 118 | 119 | it 'Scrubs ,