落雨楓 2 years ago
commit 50fc3ce7a6

@ -28,7 +28,7 @@
"ResourceModules": { "ResourceModules": {
"ext.isekai.widgets.global": { "ext.isekai.widgets.global": {
"styles": [ "styles": [
"ext.isekai.widgets.global.less" "ext.isekai.alert.less"
] ]
}, },
"ext.isekai.createPage": { "ext.isekai.createPage": {
@ -77,25 +77,6 @@
"isekai-discover-error-cannotload" "isekai-discover-error-cannotload"
] ]
}, },
"ext.isekai.feedList": {
"es6": true,
"scripts": [
"feedList/ext.isekai.feedList.js"
],
"styles": [
"feedList/ext.isekai.feedList.less"
],
"dependencies": [
"vue"
],
"targets": [
"desktop",
"mobile"
],
"messages": [
]
},
"ext.isekai.previewCard": { "ext.isekai.previewCard": {
"scripts": [ "scripts": [
"previewCard/ext.isekai.previewCard.js" "previewCard/ext.isekai.previewCard.js"

@ -1,17 +1,17 @@
{ {
"isekai-widgets-desc": "異世界ウィキで使用するカスタムウィジェットたち", "isekai-widgets-desc": "異世界ウィキで使用するカスタムウィジェットたち",
"isekai-createpage-page-title": "新しいページの名", "isekai-createpage-page-title": "新しいページの名",
"isekai-createpage-create-page": "ページを新規作成", "isekai-createpage-create-page": "ページを新規作成",
"isekai-createpage-create-page-button": "作成", "isekai-createpage-create-page-button": "作成",
"isekai-createpage-page-exists": "同じタイトルのページが既に存在します。変更してください。", "isekai-createpage-page-exists": "同じタイトルのページが既に存在します。変更してください。",
"isekai-createpage-title-empty": "おタイトルを入力してください", "isekai-createpage-title-empty": "おタイトルを入力してください",
"isekai-createpage-redirecting": "ジャンプしてお待ちください...", "isekai-createpage-redirecting": "ジャンプしてお待ちください...",
"isekai-discover-langcode": "ja", "isekai-discover-langcode": "ja",
"isekai-discover-randompage": "おまかせ表示", "isekai-discover-randompage": "おまかせ表示",
"isekai-discover-loading": "読み込み中...", "isekai-discover-loading": "読み込み中...",
"isekai-discover-change-btn": "変える", "isekai-discover-change-btn": "変える",
"isekai-discover-readmore-btn": "開く", "isekai-discover-readmore-btn": "開く",
"isekai-discover-error-cannotload": "サーバからのページを読み取りに失敗しました。" "isekai-discover-error-cannotload": "サーバからのページを読み取りに失敗しました。"
} }

@ -1,39 +1,39 @@
<?php <?php
namespace Isekai\Widgets; namespace Isekai\Widgets;
use Html; use Html;
class ExtraFontWidget { class ExtraFontWidget {
public static function create($text, $params, $parser, $frame){ public static function create($text, $params, $parser, $frame){
$existsFonts = $parser->extIsekaiWidgetsCache->get('extraFonts', INF, []); $existsFonts = $parser->extIsekaiWidgetsCache->get('extraFonts', INF, []);
$content = $text = $parser->recursiveTagParse($text, $frame); $content = $text = $parser->recursiveTagParse($text, $frame);
if (!isset($params['name']) || empty($params['name'])) { if (!isset($params['name']) || empty($params['name'])) {
return '<span class="error">' . wfMessage('isekai-font-error-invalid-params')->parse() . '</span>' . $content; return '<span class="error">' . wfMessage('isekai-font-error-invalid-params')->parse() . '</span>' . $content;
} }
$fontName = 'extra-' . $params['name']; $fontName = 'extra-' . $params['name'];
if (preg_match('/[`~!@#$%^&*()+=<>?:"{}|,.\/;\'\\\\\[\]]\r\n/', $fontName)) { if (preg_match('/[`~!@#$%^&*()+=<>?:"{}|,.\/;\'\\\\\[\]]\r\n/', $fontName)) {
return '<span class="error">' . return '<span class="error">' .
wfMessage('isekai-font-error-font-name-invalid')->parse() . wfMessage('isekai-font-error-font-name-invalid')->parse() .
'</span>' . '</span>' .
$content; $content;
} }
$existsFonts = $parser->extIsekaiWidgetsCache->get('extraFonts', INF, []); $existsFonts = $parser->extIsekaiWidgetsCache->get('extraFonts', INF, []);
if (!isset($existsFonts[$fontName])) { if (!isset($existsFonts[$fontName])) {
return '<span class="error">' . return '<span class="error">' .
wfMessage('isekai-font-error-font-not-imported', $params['name'])->parse() . wfMessage('isekai-font-error-font-not-imported', $params['name'])->parse() .
'</span>' . '</span>' .
$content; $content;
} }
$fontId = $existsFonts[$fontName]; $fontId = $existsFonts[$fontName];
return [ return [
Html::rawElement('span', [ Html::rawElement('span', [
'class' => 'isekai-extra-font font-' . $fontId, 'class' => 'isekai-extra-font font-' . $fontId,
], $content), ], $content),
"markerType" => 'nowiki' "markerType" => 'nowiki'
]; ];
} }
} }

@ -1,53 +1,53 @@
<?php <?php
namespace Isekai\Widgets; namespace Isekai\Widgets;
use Title; use Title;
use MediaWiki\MediaWikiServices; use MediaWiki\MediaWikiServices;
class FontFaceWidget { class FontFaceWidget {
/** /**
* @param string $text * @param string $text
* @param array $params * @param array $params
* @param \Parser $parser * @param \Parser $parser
* @param \PPFrame $frame * @param \PPFrame $frame
*/ */
public static function create($text, $params, $parser, $frame) { public static function create($text, $params, $parser, $frame) {
if (!isset($params['src']) || !isset($params['name']) || if (!isset($params['src']) || !isset($params['name']) ||
empty($params['src']) || empty($params['name'])) { empty($params['src']) || empty($params['name'])) {
return '<span class="error">' . wfMessage('isekai-fontface-error-invalid-params')->parse() . '</span>'; return '<span class="error">' . wfMessage('isekai-fontface-error-invalid-params')->parse() . '</span>';
} }
$service = MediaWikiServices::getInstance(); $service = MediaWikiServices::getInstance();
$fontName = 'extra-' . $params['name']; $fontName = 'extra-' . $params['name'];
$existsFonts = $parser->extIsekaiWidgetsCache->get('extraFonts', INF, []); $existsFonts = $parser->extIsekaiWidgetsCache->get('extraFonts', INF, []);
if (isset($existsFonts[$fontName])) { if (isset($existsFonts[$fontName])) {
return '<span class="error">' . return '<span class="error">' .
wfMessage('isekai-fontface-error-font-already-defined', $params['name'])->parse() . wfMessage('isekai-fontface-error-font-already-defined', $params['name'])->parse() .
'</span>'; '</span>';
} }
if (preg_match('/[`~!@#$%^&*()+=<>?:"{}|,.\/;\'\\\\\[\]]\r\n/', $fontName)) { if (preg_match('/[`~!@#$%^&*()+=<>?:"{}|,.\/;\'\\\\\[\]]\r\n/', $fontName)) {
return '<span class="error">' . return '<span class="error">' .
wfMessage('isekai-fontface-error-font-name-invalid')->parse() . wfMessage('isekai-fontface-error-font-name-invalid')->parse() .
'</span>'; '</span>';
} }
$title = Title::newFromText($params['src'], NS_FILE); $title = Title::newFromText($params['src'], NS_FILE);
$file = $service->getRepoGroup()->findFile($title); $file = $service->getRepoGroup()->findFile($title);
if (!$file) { if (!$file) {
return '<span class="error">' . return '<span class="error">' .
wfMessage('isekai-fontface-error-font-not-exists', $params['src'])->parse() . wfMessage('isekai-fontface-error-font-not-exists', $params['src'])->parse() .
'</span>'; '</span>';
} }
$fontUrl = $file->getUrl(); $fontUrl = $file->getUrl();
$fontId = substr(Utils::safeBase64Encode(md5($fontName, true)), 0, 8); $fontId = substr(Utils::safeBase64Encode(md5($fontName, true)), 0, 8);
$css = "<span><style>@font-face{src: url('{$fontUrl}');font-family:'{$fontName}'}" . $css = "<span><style>@font-face{src: url('{$fontUrl}');font-family:'{$fontName}'}" .
".isekai-extra-font.font-{$fontId}{font-family:'{$fontName}'}</style></span>"; ".isekai-extra-font.font-{$fontId}{font-family:'{$fontName}'}</style></span>";
$existsFonts[$fontName] = $fontId; $existsFonts[$fontName] = $fontId;
$existsFonts = $parser->extIsekaiWidgetsCache->set('extraFonts', $existsFonts); $existsFonts = $parser->extIsekaiWidgetsCache->set('extraFonts', $existsFonts);
return [$css, "markerType" => 'nowiki']; return [$css, "markerType" => 'nowiki'];
} }
} }

@ -1,35 +1,35 @@
<?php <?php
namespace Isekai\Widgets; namespace Isekai\Widgets;
use Html; use Html;
class Html5Widget { class Html5Widget {
public static function createDetails(string $text, array $args, \Parser $parser, \PPFrame $frame) { public static function createDetails(string $text, array $args, \Parser $parser, \PPFrame $frame) {
$parser->getOutput()->addModules('ext.isekai.collapse'); $parser->getOutput()->addModules('ext.isekai.collapse');
$allowedAttr = ['class']; $allowedAttr = ['class'];
$htmlArgs = array_filter($args, function($k) use($allowedAttr) { $htmlArgs = array_filter($args, function($k) use($allowedAttr) {
return in_array($k, $allowedAttr); return in_array($k, $allowedAttr);
}, ARRAY_FILTER_USE_KEY); }, ARRAY_FILTER_USE_KEY);
$content = ''; $content = '';
if ($text) { if ($text) {
$content = Utils::makeParagraph($parser->recursiveTagParse($text, $frame), true); $content = Utils::makeParagraph($parser->recursiveTagParse($text, $frame), true);
} }
return [Html::rawElement('details', $htmlArgs, $content), "markerType" => 'nowiki']; return [Html::rawElement('details', $htmlArgs, $content), "markerType" => 'nowiki'];
} }
public static function createSummary(string $text, array $args, \Parser $parser, \PPFrame $frame) { public static function createSummary(string $text, array $args, \Parser $parser, \PPFrame $frame) {
$allowedAttr = ['class']; $allowedAttr = ['class'];
$htmlArgs = array_filter($args, function($k) use($allowedAttr) { $htmlArgs = array_filter($args, function($k) use($allowedAttr) {
return in_array($k, $allowedAttr); return in_array($k, $allowedAttr);
}, ARRAY_FILTER_USE_KEY); }, ARRAY_FILTER_USE_KEY);
$content = ''; $content = '';
if ($text) { if ($text) {
$content = $parser->recursiveTagParse($text, $frame); $content = $parser->recursiveTagParse($text, $frame);
} }
return [Html::rawElement('summary', $htmlArgs, $content), "markerType" => 'nowiki']; return [Html::rawElement('summary', $htmlArgs, $content), "markerType" => 'nowiki'];
} }
} }

@ -1,217 +1,217 @@
<?php <?php
namespace Isekai\Widgets; namespace Isekai\Widgets;
use Html; use Html;
use MediaWiki\MediaWikiServices; use MediaWiki\MediaWikiServices;
use Title; use Title;
class TileWidget { class TileWidget {
private $size = 'medium'; private $size = 'medium';
private $icon = false; private $icon = false;
private $title = ''; private $title = '';
private $href = ''; private $href = '';
private $badge = false; private $badge = false;
private $color = false; private $color = false;
private $cover = false; private $cover = false;
private $images = []; private $images = [];
private $grid = false; private $grid = false;
private $attributes = []; private $attributes = [];
public function __construct($args, $content){ public function __construct($args, $content){
$this->content = $content; $this->content = $content;
$this->parseArgs($args); $this->parseArgs($args);
} }
public static function create(string $text, array $args, \Parser $parser, \PPFrame $frame){ public static function create(string $text, array $args, \Parser $parser, \PPFrame $frame){
$parser->getOutput()->addModules('ext.isekai.tile'); $parser->getOutput()->addModules('ext.isekai.tile');
$content = ''; $content = '';
if ($text) { if ($text) {
$content = $frame->expand($text); $content = $frame->expand($text);
$title = preg_replace('/\[\[.*?\]\]/', '', $content); $title = preg_replace('/\[\[.*?\]\]/', '', $content);
$title = preg_replace('/<img .*?src="(?<src>.*?)".*?srcset="(?<srcset>.*?)"[^\>]+>/', '', $title); $title = preg_replace('/<img .*?src="(?<src>.*?)".*?srcset="(?<srcset>.*?)"[^\>]+>/', '', $title);
$title = strip_tags(trim($title)); $title = strip_tags(trim($title));
$args['title'] = $title; $args['title'] = $title;
} }
$tile = new TileWidget($args, $content); $tile = new TileWidget($args, $content);
return [$tile->toHtml(), 'markerType' => 'nowiki']; return [$tile->toHtml(), 'markerType' => 'nowiki'];
} }
private function parseArgs($args){ private function parseArgs($args){
$allowedArgs = ['size', 'icon', 'title', 'cover', 'badge', 'color', 'href', 'grid']; $allowedArgs = ['size', 'icon', 'title', 'cover', 'badge', 'color', 'href', 'grid'];
foreach($args as $name => $arg){ foreach($args as $name => $arg){
if(in_array($name, $allowedArgs)){ if(in_array($name, $allowedArgs)){
$this->$name = $arg; $this->$name = $arg;
} elseif(substr($name, 0, 2) !== 'on'){ } elseif(substr($name, 0, 2) !== 'on'){
$this->attributes[$name] = $arg; $this->attributes[$name] = $arg;
} }
} }
} }
private function getSizeArgs(array &$element, array &$content){ private function getSizeArgs(array &$element, array &$content){
$element['data-size'] = $this->size; $element['data-size'] = $this->size;
$element['class'][] = 'tile-' . $this->size; $element['class'][] = 'tile-' . $this->size;
} }
private function getColorArgs(array &$element, array &$content){ private function getColorArgs(array &$element, array &$content){
if($this->color){ if($this->color){
if(substr($this->color, 0, 1) == '#' || substr($this->color, 0, 3) == 'rgb'){ if(substr($this->color, 0, 1) == '#' || substr($this->color, 0, 3) == 'rgb'){
$element['style'][] = 'background-color: ' . $this->color; $element['style'][] = 'background-color: ' . $this->color;
} else { } else {
$color = str_replace($this->color, 'bg-', ''); $color = str_replace($this->color, 'bg-', '');
$element['class'][] = 'bg-' . $color; $element['class'][] = 'bg-' . $color;
} }
} }
} }
private function getTitleArgs(array &$element, array &$content){ private function getTitleArgs(array &$element, array &$content){
if(!empty($this->title)){ if(!empty($this->title)){
$content[] = Html::element('span', [ $content[] = Html::element('span', [
'class' => ['branding-bar'], 'class' => ['branding-bar'],
], $this->title); ], $this->title);
$element['data-title'] = $this->title; $element['data-title'] = $this->title;
} }
} }
private function getCoverArgs(array &$element, array &$content){ private function getCoverArgs(array &$element, array &$content){
$element['data-cover'] = $this->cover; $element['data-cover'] = $this->cover;
} }
private function getHrefArgs(array &$element, array &$content){ private function getHrefArgs(array &$element, array &$content){
if(substr($this->href, 0, 2) == '[[' && substr($this->href, -2, 2) == ']]'){ //内部链接 if(substr($this->href, 0, 2) == '[[' && substr($this->href, -2, 2) == ']]'){ //内部链接
$titleText = substr($this->href, 2, strlen($this->href) - 4); $titleText = substr($this->href, 2, strlen($this->href) - 4);
$title = Title::newFromText($titleText); $title = Title::newFromText($titleText);
$href = $title->getLocalURL(); $href = $title->getLocalURL();
} else { } else {
$href = $this->href; $href = $this->href;
} }
$element['href'] = $href; $element['href'] = $href;
} }
private function getIconArgs(array &$element, array &$content){ private function getIconArgs(array &$element, array &$content){
if($this->icon){ if($this->icon){
if(is_string($this->icon)){ if(is_string($this->icon)){
if(preg_match('/\.[a-zA-Z0-9]{3,4}$/', $this->icon)){ if(preg_match('/\.[a-zA-Z0-9]{3,4}$/', $this->icon)){
//图片图标 //图片图标
$iconSrc = $this->icon; $iconSrc = $this->icon;
$type = 'image'; $type = 'image';
} else { } else {
$iconSrc = explode(' ', $this->icon); $iconSrc = explode(' ', $this->icon);
$type = 'class'; $type = 'class';
} }
} else { } else {
$type = 'class'; $type = 'class';
$iconSrc = $this->icon; $iconSrc = $this->icon;
} }
if($type == 'class'){ if($type == 'class'){
$content[] = Html::element('span', [ $content[] = Html::element('span', [
'class' => array_merge($iconSrc, ['icon']), 'class' => array_merge($iconSrc, ['icon']),
]); ]);
} elseif($type == 'image'){ } elseif($type == 'image'){
$content[] = Html::element('img', [ $content[] = Html::element('img', [
'src' => $iconSrc, 'src' => $iconSrc,
'class' => ['icon'], 'class' => ['icon'],
]); ]);
} }
} }
} }
private function getBadgeArgs(array &$element, array &$content){ private function getBadgeArgs(array &$element, array &$content){
if($this->badge){ if($this->badge){
$content[] = Html::element('span', [ $content[] = Html::element('span', [
'class' => ['badge-bottom'], 'class' => ['badge-bottom'],
], strval($this->badge)); ], strval($this->badge));
} }
} }
private function getImagesArgs(array &$element, array &$content){ private function getImagesArgs(array &$element, array &$content){
$service = MediaWikiServices::getInstance(); $service = MediaWikiServices::getInstance();
$this->images = []; $this->images = [];
// 提取wikitext图片 // 提取wikitext图片
preg_match_all('/\[\[(?<title>.+?:.+?)(\|.*?)?\]\]/', $this->content, $matches); preg_match_all('/\[\[(?<title>.+?:.+?)(\|.*?)?\]\]/', $this->content, $matches);
if (isset($matches['title']) && !empty($matches['title'])) { if (isset($matches['title']) && !empty($matches['title'])) {
foreach ($matches['title'] as $titleText) { foreach ($matches['title'] as $titleText) {
$title = Title::newFromText($titleText); $title = Title::newFromText($titleText);
if ($title->inNamespace(NS_FILE)) { if ($title->inNamespace(NS_FILE)) {
$file = $service->getRepoGroup()->findFile($title); $file = $service->getRepoGroup()->findFile($title);
$thumb = $file->getUrl(); $thumb = $file->getUrl();
$this->images[] = $thumb; $this->images[] = $thumb;
} }
} }
} }
// 提取html图片 // 提取html图片
preg_match_all('/<img .*?src="(?<src>.*?)".*?srcset="(?<srcset>.*?)"[^\>]+>/', $this->content, $matches); preg_match_all('/<img .*?src="(?<src>.*?)".*?srcset="(?<srcset>.*?)"[^\>]+>/', $this->content, $matches);
if (isset($matches['src']) && !empty($matches['src'])) { if (isset($matches['src']) && !empty($matches['src'])) {
$this->images = array_merge($this->images, $matches['src']); $this->images = array_merge($this->images, $matches['src']);
} }
if(!empty($this->images)){ if(!empty($this->images)){
$element['data-effect'] = 'image-set'; $element['data-effect'] = 'image-set';
foreach($this->images as $image){ foreach($this->images as $image){
$content[] = Html::element('img', [ $content[] = Html::element('img', [
'src' => $image, 'src' => $image,
'style' => 'display: none' 'style' => 'display: none'
]); ]);
} }
} }
} }
private function getGridArgs(array &$element, array &$content){ private function getGridArgs(array &$element, array &$content){
if($this->grid){ if($this->grid){
$grid = explode(' ', $this->grid); $grid = explode(' ', $this->grid);
$element['class'][] = 'col-' . $grid[0]; $element['class'][] = 'col-' . $grid[0];
if(count($grid) > 1){ if(count($grid) > 1){
$element['class'][] = 'row-' . $grid[1]; $element['class'][] = 'row-' . $grid[1];
} }
} }
} }
public function toHtml(){ public function toHtml(){
$element = array_merge($this->attributes, [ $element = array_merge($this->attributes, [
'data-role' => 'tile', 'data-role' => 'tile',
]); ]);
$content = []; $content = [];
if(isset($element['class'])){ if(isset($element['class'])){
$element['class'] = explode(' ', $element['class']); $element['class'] = explode(' ', $element['class']);
} else { } else {
$element['class'] = []; $element['class'] = [];
} }
if(isset($element['style'])){ if(isset($element['style'])){
$element['style'] = explode(' ', $element['style']); $element['style'] = explode(' ', $element['style']);
} else { } else {
$element['style'] = []; $element['style'] = [];
} }
$this->getSizeArgs($element, $content); $this->getSizeArgs($element, $content);
$this->getColorArgs($element, $content); $this->getColorArgs($element, $content);
$this->getIconArgs($element, $content); $this->getIconArgs($element, $content);
$this->getTitleArgs($element, $content); $this->getTitleArgs($element, $content);
$this->getCoverArgs($element, $content); $this->getCoverArgs($element, $content);
$this->getHrefArgs($element, $content); $this->getHrefArgs($element, $content);
$this->getBadgeArgs($element, $content); $this->getBadgeArgs($element, $content);
$this->getImagesArgs($element, $content); $this->getImagesArgs($element, $content);
$this->getGridArgs($element, $content); $this->getGridArgs($element, $content);
$content = implode('', $content); $content = implode('', $content);
if(!empty($element['class'])){ if(!empty($element['class'])){
$element['class'] = implode(' ', $element['class']); $element['class'] = implode(' ', $element['class']);
} else { } else {
unset($element['class']); unset($element['class']);
} }
if(!empty($element['style'])){ if(!empty($element['style'])){
$element['style'] = implode('; ', $element['style']) . ';'; $element['style'] = implode('; ', $element['style']) . ';';
} else { } else {
unset($element['style']); unset($element['style']);
} }
return Html::rawElement('a', $element, $content); return Html::rawElement('a', $element, $content);
} }
} }

@ -1,35 +1,35 @@
<?php <?php
namespace Isekai\Widgets; namespace Isekai\Widgets;
use MapCacheLRU; use MapCacheLRU;
use Parser; use Parser;
class Widgets { class Widgets {
/** /**
* @param \Parser $parser * @param \Parser $parser
*/ */
public static function onParserSetup(&$parser){ public static function onParserSetup(&$parser){
$parser->extIsekaiWidgetsCache = new MapCacheLRU( 100 ); // 100 is arbitrary $parser->extIsekaiWidgetsCache = new MapCacheLRU( 100 ); // 100 is arbitrary
$parser->setHook('createpage', [CreatePageWidget::class, 'create']); $parser->setHook('createpage', [CreatePageWidget::class, 'create']);
$parser->setHook('discoverbox', [DiscoverWidget::class, 'create']); $parser->setHook('discoverbox', [DiscoverWidget::class, 'create']);
$parser->setHook('feedlist', [FeedListWidget::class, 'create']); $parser->setHook('feedlist', [FeedListWidget::class, 'create']);
$parser->setHook('previewcard', [PreviewCardWidget::class, 'create']); $parser->setHook('previewcard', [PreviewCardWidget::class, 'create']);
$parser->setHook('tile', [TileWidget::class, 'create']); $parser->setHook('tile', [TileWidget::class, 'create']);
$parser->setHook('tilegroup', [TileGroupWidget::class, 'create']); $parser->setHook('tilegroup', [TileGroupWidget::class, 'create']);
$parser->setHook('fontface', [FontFaceWidget::class, 'create']); $parser->setHook('fontface', [FontFaceWidget::class, 'create']);
$parser->setHook('exfont', [ExtraFontWidget::class, 'create']); $parser->setHook('exfont', [ExtraFontWidget::class, 'create']);
$parser->setHook('details', [Html5Widget::class, 'createDetails']); $parser->setHook('details', [Html5Widget::class, 'createDetails']);
$parser->setHook('summary', [Html5Widget::class, 'createSummary']); $parser->setHook('summary', [Html5Widget::class, 'createSummary']);
return true; return true;
} }
public static function onLoad(\OutputPage $outputPage) { public static function onLoad(\OutputPage $outputPage) {
$outputPage->addModuleStyles("ext.isekai.widgets.global"); $outputPage->addModuleStyles("ext.isekai.widgets.global");
$outputPage->addModuleStyles("ext.isekai.collapse"); $outputPage->addModuleStyles("ext.isekai.collapse");
} }
} }

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save