Show markdown description text on item detail page

* Update build process to *not* copy over prerendered pages during a
  regular build (only during a `dist` build)
* Add dependency on npm "markdown" package and include it in the
  scripts loaded in the default layout
* Update the Item model to be able to load from a *.cg file
* Add an ItemParser to process the `item.cg` file
* Update the general parser to be able to work with HereDoc-style
  text blocks
* Add a markdown-specific stylesheet
* Add a sample of the `item.cg` file format (Advanced Crafting Table)
This commit is contained in:
Andrew Miner
2015-03-08 22:24:15 -07:00
parent f92e98674a
commit ff17d5f9ba
21 changed files with 183 additions and 69 deletions
+19 -3
View File
@@ -22,6 +22,7 @@ module.exports = (grunt)->
extensions: ['.coffee'] extensions: ['.coffee']
files: files:
'./dist/js/main.js': ['./src/coffee/main.coffee'] './dist/js/main.js': ['./src/coffee/main.coffee']
test: test:
options: options:
transform: ['coffeeify'] transform: ['coffeeify']
@@ -30,6 +31,15 @@ module.exports = (grunt)->
extensions: ['.coffee'] extensions: ['.coffee']
files: files:
'./dist/js/test.js': ['./src/coffee/test/test.coffee'] './dist/js/test.js': ['./src/coffee/test/test.coffee']
markdown:
options:
browserifyOptions:
debug: false
standalone: 'markdown'
files:
'./dist/js/markdown.js': ['./node_modules/markdown/lib/index.js']
when: when:
options: options:
browserifyOptions: browserifyOptions:
@@ -102,6 +112,12 @@ module.exports = (grunt)->
src: './static/' src: './static/'
dest: './dist/' dest: './dist/'
recursive: true recursive: true
non_html:
options:
src: './static/'
dest: './dist/'
exclude: '*.html'
recursive: true
sass: sass:
build: build:
@@ -134,7 +150,7 @@ module.exports = (grunt)->
tasks: ['browserify:main'] tasks: ['browserify:main']
jade: jade:
files: ['./src/**/*.jade'] files: ['./src/**/*.jade']
tasks: ['jade:pages', 'jade:templates'] tasks: ['jade:pages', 'jade:templates', 'browserify:main']
sass: sass:
files: ['./src/**/*.scss'] files: ['./src/**/*.scss']
tasks: ['sass', 'copy:styles'] tasks: ['sass', 'copy:styles']
@@ -144,8 +160,8 @@ module.exports = (grunt)->
grunt.registerTask 'default', 'build' grunt.registerTask 'default', 'build'
grunt.registerTask 'build', [ 'rsync', 'copy', 'sass:build', 'jade', 'browserify', 'exorcise' ] grunt.registerTask 'build', [ 'rsync:non_html', 'copy', 'sass:build', 'jade', 'browserify', 'exorcise' ]
grunt.registerTask 'dist', ['clean', 'build', 'copy:index_prerender', 'sass:dist', 'uglify'] grunt.registerTask 'dist', ['clean', 'build', 'rsync:static', 'copy:index_prerender', 'sass:dist', 'uglify']
grunt.registerTask 'clean-watch', ['clean', 'build', 'watch'] grunt.registerTask 'clean-watch', ['clean', 'build', 'watch']
+1
View File
@@ -36,6 +36,7 @@
"chai": "^1.10.0", "chai": "^1.10.0",
"jade": "^1.8.2", "jade": "^1.8.2",
"jquery": "^2.1.3", "jquery": "^2.1.3",
"markdown": "^0.5.0",
"mocha": "^2.0.1", "mocha": "^2.0.1",
"sinon": "^1.12.2", "sinon": "^1.12.2",
"sinon-chai": "^2.6.0", "sinon-chai": "^2.6.0",
+3 -2
View File
@@ -58,12 +58,13 @@ Text.title = 'Crafting Guide for Minecraft | The Ultimate Step-by-Step Tutorial
exports.Url = Url = {} exports.Url = Url = {}
Url.crafting = _.template "/craft/<%= inventoryText %>" Url.crafting = _.template "/craft/<%= inventoryText %>"
Url.itemIcon = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/icon.png"
Url.item = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/" Url.item = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/"
Url.itemData = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/item.cg"
Url.itemIcon = _.template "/browse/<%= modSlug %>/<%= itemSlug %>/icon.png"
Url.mod = _.template "/browse/<%= modSlug %>/" Url.mod = _.template "/browse/<%= modSlug %>/"
Url.modData = _.template "/data/<%= modSlug %>/mod.cg" Url.modData = _.template "/data/<%= modSlug %>/mod.cg"
Url.modIcon = _.template "/browse/<%= modSlug %>/icon.png" Url.modIcon = _.template "/browse/<%= modSlug %>/icon.png"
Url.modVersion = _.template "/data/<%= modSlug %>/<%= modVersion %>/mod-version.cg" Url.modVersionData = _.template "/data/<%= modSlug %>/<%= modVersion %>/mod-version.cg"
exports.UrlParam = UrlParam = {} exports.UrlParam = UrlParam = {}
UrlParam.quantity = 'count' UrlParam.quantity = 'count'
@@ -18,8 +18,8 @@ module.exports = class ItemController extends BaseController
options.templateName = 'item' options.templateName = 'item'
super options super options
@_imageLoader = options.imageLoader @imageLoader = options.imageLoader
@_modPack = options.modPack @modPack = options.modPack
# BaseController Overrides ##################################################################### # BaseController Overrides #####################################################################
@@ -30,9 +30,9 @@ module.exports = class ItemController extends BaseController
super super
refresh: -> refresh: ->
display = @_modPack.findItemDisplay @model.slug display = @modPack.findItemDisplay @model.slug
@_imageLoader.load display.iconUrl, @$icon @imageLoader.load display.iconUrl, @$icon
@$name.html display.itemName @$name.html display.itemName
@$nameLink.attr 'href', display.itemUrl @$nameLink.attr 'href', display.itemUrl
@@ -76,7 +76,6 @@ module.exports = class ItemGroupController extends BaseController
delay += @_delayStep delay += @_delayStep
else else
controller.model = item controller.model = item
controller.refresh()
controllerIndex += 1 controllerIndex += 1
while @_itemControllers.length > controllerIndex while @_itemControllers.length > controllerIndex
@@ -57,15 +57,17 @@ module.exports = class ItemPageController extends PageController
imageLoader: @imageLoader imageLoader: @imageLoader
modPack: @modPack modPack: @modPack
@$usedAsToolToMakeSection = @$('.usedAsToolToMake')
@$byline = @$('.byline') @$byline = @$('.byline')
@$bylineLink = @$('.byline a') @$bylineLink = @$('.byline a')
@$craftingPlanLink = @$('a.craftingPlan') @$craftingPlanLink = @$('a.craftingPlan')
@$descriptionPanel = @$('.description .panel')
@$descriptionSection = @$('.description')
@$name = @$('h1.name') @$name = @$('h1.name')
@$recipeContainer = @$('.recipes .panel') @$recipeContainer = @$('.recipes .panel')
@$recipesSection = @$('.recipes') @$recipesSection = @$('.recipes')
@$similarSection = @$('.similar') @$similarSection = @$('.similar')
@$titleImage = @$('.titleImage img') @$titleImage = @$('.titleImage img')
@$usedAsToolToMakeSection = @$('.usedAsToolToMake')
@$usedToMakeSection = @$('.usedToMake') @$usedToMakeSection = @$('.usedToMake')
super super
@@ -84,6 +86,7 @@ module.exports = class ItemPageController extends PageController
@$el.slideUp duration:Duration.normal @$el.slideUp duration:Duration.normal
@_refreshByline() @_refreshByline()
@_refreshDescription()
@_refreshRecipes() @_refreshRecipes()
@_refreshSimilarItems() @_refreshSimilarItems()
@_refreshUsedAsToolToMake() @_refreshUsedAsToolToMake()
@@ -100,15 +103,6 @@ module.exports = class ItemPageController extends PageController
# Private Methods ############################################################################## # Private Methods ##############################################################################
_refreshUsedAsToolToMake: ->
@_usedAsToolToMakeController.title = 'Used as Tool to Make'
@_usedAsToolToMakeController.model = @model.findToolForRecipes()
if @_usedAsToolToMakeController.model?
@$usedAsToolToMakeSection.slideDown duration:Duration.normal
else
@$usedAsToolToMakeSection.slideUp duration:Duration.normal
_refreshByline: -> _refreshByline: ->
mod = @model.item?.modVersion?.mod mod = @model.item?.modVersion?.mod
if mod?.name?.length > 0 if mod?.name?.length > 0
@@ -118,14 +112,15 @@ module.exports = class ItemPageController extends PageController
else else
@$byline.fadeOut duration:Duration.fast @$byline.fadeOut duration:Duration.fast
_refreshUsedToMake: -> _refreshDescription: ->
@_usedToMakeController.title = 'Used to Make' logger.debug "refreshing description"
logger.debug "item.description: #{@model?.item?.description}"
@_usedToMakeController.model = @model.findComponentInItems() description = @model.compileDescription()
if @_usedToMakeController.model? if description?
@$usedToMakeSection.slideDown duration:Duration.normal @$descriptionPanel.html description
@$descriptionSection.slideDown duration:Duration.normal
else else
@$usedToMakeSection.slideUp duration:Duration.normal @$descriptionSection.slideUp duration:Duration.normal
_refreshRecipes: -> _refreshRecipes: ->
@_recipeControllers ?= [] @_recipeControllers ?= []
@@ -165,10 +160,35 @@ module.exports = class ItemPageController extends PageController
else else
@$similarSection.slideUp duration:Duration.normal @$similarSection.slideUp duration:Duration.normal
_refreshUsedAsToolToMake: ->
@_usedAsToolToMakeController.title = 'Used as Tool to Make'
@_usedAsToolToMakeController.model = @model.findToolForRecipes()
if @_usedAsToolToMakeController.model?
@$usedAsToolToMakeSection.slideDown duration:Duration.normal
else
@$usedAsToolToMakeSection.slideUp duration:Duration.normal
_refreshUsedToMake: ->
@_usedToMakeController.title = 'Used to Make'
@_usedToMakeController.model = @model.findComponentInItems()
if @_usedToMakeController.model?
@$usedToMakeSection.slideDown duration:Duration.normal
else
@$usedToMakeSection.slideUp duration:Duration.normal
_resolveItemSlug: -> _resolveItemSlug: ->
return if @model.item?
item = @modPack.findItem @_itemSlug, includeDisabled:false item = @modPack.findItem @_itemSlug, includeDisabled:false
if item? and not ItemSlug.equal item.slug, @_itemSlug if item?
if not ItemSlug.equal item.slug, @_itemSlug
router.navigate Url.item(modSlug:item.slug.mod, itemSlug:item.slug.item), trigger:true router.navigate Url.item(modSlug:item.slug.mod, itemSlug:item.slug.item), trigger:true
return return
@model.item = item @model.item = item
item.fetch()
item.on Event.sync, =>
logger.debug "item loaded"
@refresh()
+13
View File
@@ -10,6 +10,7 @@ BaseModel = require './base_model'
ItemSlug = require './item_slug' ItemSlug = require './item_slug'
Recipe = require './recipe' Recipe = require './recipe'
StringBuilder = require './string_builder' StringBuilder = require './string_builder'
{Url} = require '../constants'
######################################################################################################################## ########################################################################################################################
@@ -57,6 +58,18 @@ module.exports = class Item extends BaseModel
Object.defineProperties @prototype, Object.defineProperties @prototype,
isCraftable: {get:@prototype.getIsCraftable} isCraftable: {get:@prototype.getIsCraftable}
# Backbone.Model Overrides #####################################################################
parse: (text)->
ItemParser = require './item_parser' # to avoid require cycles
@_parser ?= new ItemParser model:this
@_parser.parse text
return null # prevent calling `set`
url: ->
return Url.itemData modSlug:@slug.mod, itemSlug:@slug.item
# Object Overrides ############################################################################# # Object Overrides #############################################################################
toString: -> toString: ->
+4
View File
@@ -20,6 +20,10 @@ module.exports = class ItemPage extends BaseModel
# Property Methods ############################################################################# # Property Methods #############################################################################
compileDescription: ->
return null unless @item?.description?
return markdown.parse @item.description
findComponentInItems: -> findComponentInItems: ->
return @_findRecipesMatching (recipe)=> recipe.requires @item.slug return @_findRecipesMatching (recipe)=> recipe.requires @item.slug
+19
View File
@@ -0,0 +1,19 @@
###
Crafting Guide - item_parser.coffee
Copyright (c) 2014-2015 by Redwood Labs
All rights reserved.
###
VersionedParserBase = require './versioned_parser_base'
ItemParserV1 = require './parser_versions/item_parser_v1'
########################################################################################################################
module.exports = class ItemParser extends VersionedParserBase
# VersionedParserBase Overrides ################################################################
_createParsers: (options)->
return result =
'1': new ItemParserV1 options
+1 -1
View File
@@ -171,7 +171,7 @@ module.exports = class ModVersion extends BaseModel
return null # prevent calling `set` return null # prevent calling `set`
url: -> url: ->
return Url.modVersion modSlug:@modSlug, modVersion:@version return Url.modVersionData modSlug:@modSlug, modVersion:@version
# Object Overrides ############################################################################# # Object Overrides #############################################################################
@@ -78,7 +78,7 @@ module.exports = class CommandParserVersionBase
line = line.trim() line = line.trim()
return [] if line.length is 0 return [] if line.length is 0
hereDoc = @_parseHereDoc line [line, hereDoc] = @_parseHereDoc line
lineParts = (part.trim() for part in line.split(';')) lineParts = (part.trim() for part in line.split(';'))
commands = [] commands = []
@@ -89,8 +89,10 @@ module.exports = class CommandParserVersionBase
if not match? then throw new Error "Expected <command>: <args>, but found: \"#{linePart}\"" if not match? then throw new Error "Expected <command>: <args>, but found: \"#{linePart}\""
args = [] args = []
args = (s.trim() for s in match[2].split(',')) if match[2]? args = (s for s in match[2].split(',') when s.length > 0) if match[2]?
args.push hereDoc if hereDoc args = (s.trim() for s in args)
args = (s for s in args when s.length > 0)
args.push hereDoc if hereDoc?
commands.push name:match[1], args:args commands.push name:match[1], args:args
return commands return commands
@@ -107,9 +109,11 @@ module.exports = class CommandParserVersionBase
_parseHereDoc: (line)-> _parseHereDoc: (line)->
hereDocIndex = line.indexOf '<<-' hereDocIndex = line.indexOf '<<-'
return null unless hereDocIndex isnt -1 return [line, null] unless hereDocIndex isnt -1
hereDocStopText = line[hereDocIndex+3...line.length] hereDocStopText = line[hereDocIndex+3...line.length]
line = line[0...hereDocIndex]
hereDocLines = [] hereDocLines = []
while true while true
@_lineNumber += 1 @_lineNumber += 1
@@ -120,11 +124,11 @@ module.exports = class CommandParserVersionBase
hereDocLines.push nextLine hereDocLines.push nextLine
shortestIndent = Number.MAX_VALUE shortestIndent = Number.MAX_VALUE
for line in hereDocLines for hereDocLine in hereDocLines
shortestIndent = Math.min line.match(/( *).*/)[1].length, shortestIndent shortestIndent = Math.min hereDocLine.match(/( *).*/)[1].length, shortestIndent
for i in [0...hereDocLines.length] for i in [0...hereDocLines.length]
hereDocLines[i] = hereDocLines[i][shortestIndent..] hereDocLines[i] = hereDocLines[i][shortestIndent..]
return null unless hereDocLines.length > 0 return [line, null] unless hereDocLines.length > 0
return hereDocLines.join '\n' return [line, hereDocLines.join('\n')]
@@ -22,16 +22,17 @@ module.exports = class ItemParserV1 extends CommandParserVersionBase
# Command Methods ############################################################################## # Command Methods ##############################################################################
_command_description: (textParts...)-> _command_description: (textParts...)->
@_rawData.text ?= '' @_rawData.description ?= ''
@_rawData.text += textParts.join ', ' @_rawData.description += textParts.join ', '
_command_officialUrl: (officialUrl)-> _command_officialUrl: (officialUrl)->
if @_rawData.officialUrl? then throw new Error 'duplicate declaration of "officialUrl"' if @_rawData.officialUrl? then throw new Error 'duplicate declaration of "officialUrl"'
if officialUrl.length is 0 then throw new Error 'officialUrl cannot be empty' if officialUrl.length is 0 then throw new Error 'officialUrl cannot be empty'
@_rawData.officialUrl = officialUrl @_rawData.officialUrl = officialUrl
_command_video: (youTubeId, name)-> _command_video: (youTubeId, nameParts...)->
if not youTubeId?.length then throw new Error 'video declaration requires a YouTubeID' if not youTubeId?.length then throw new Error 'video declaration requires a YouTubeID'
name = nameParts.join ', '
if not name?.length then throw new Error 'video declaration requires a name' if not name?.length then throw new Error 'video declaration requires a name'
@_rawData.videos ?= [] @_rawData.videos ?= []
@@ -40,6 +41,6 @@ module.exports = class ItemParserV1 extends CommandParserVersionBase
# Object Building Methods ###################################################################### # Object Building Methods ######################################################################
_buildItem: (rawData, model)-> _buildItem: (rawData, model)->
item.description = rawData.description if rawData.description model.description = rawData.description if rawData.description
item.officialUrl = rawData.officialUrl if rawData.officialUrl model.officialUrl = rawData.officialUrl if rawData.officialUrl
item.videos = rawData.videos if rawData.videos model.videos = rawData.videos if rawData.videos
@@ -21,25 +21,28 @@ describe 'command_parser_version_base.coffee', ->
it 'returns null for non-heredoc lines', -> it 'returns null for non-heredoc lines', ->
result = parser._parseHereDoc 'foobar: baz' result = parser._parseHereDoc 'foobar: baz'
expect(result).to.be.null expect(result[1]).to.be.null
it 'identifies the right text for a real heredoc', -> it 'identifies the right text for a real heredoc', ->
parser._lines = ['command: <<-END', 'alpha', 'bravo', 'charlie', 'END', 'command1: arg2'] parser._lines = ['command: <<-END', 'alpha', 'bravo', 'charlie', 'END', 'command1: arg2']
parser._lineNumber = 1 parser._lineNumber = 1
result = parser._parseHereDoc parser._lines[0] result = parser._parseHereDoc parser._lines[0]
result.should.equal 'alpha\nbravo\ncharlie' result[0].should.equal 'command: '
result[1].should.equal 'alpha\nbravo\ncharlie'
it 'identifies an empty heredoc', -> it 'identifies an empty heredoc', ->
parser._lines = ['command: <<-END', 'END'] parser._lines = ['command: <<-END', 'END']
parser._lineNumber = 1 parser._lineNumber = 1
result = parser._parseHereDoc parser._lines[0] result = parser._parseHereDoc parser._lines[0]
expect(result).to.be.null result[0].should.equal 'command: '
expect(result[1]).to.be.null
it 'trims smallest leading whitespace', -> it 'trims smallest leading whitespace', ->
parser._lines = ['command: <<-END', ' alpha', ' bravo', ' charlie', 'END', 'command1: arg2'] parser._lines = ['command: <<-END', ' alpha', ' bravo', ' charlie', 'END', 'command1: arg2']
parser._lineNumber = 1 parser._lineNumber = 1
result = parser._parseHereDoc parser._lines[0] result = parser._parseHereDoc parser._lines[0]
result.should.equal 'alpha\n bravo\ncharlie' result[0].should.equal 'command: '
result[1].should.equal 'alpha\n bravo\ncharlie'
+1 -1
View File
@@ -103,7 +103,7 @@ var buf = [];
var jade_mixins = {}; var jade_mixins = {};
var jade_interp; var jade_interp;
buf.push("<div class=\"view__item_page\"><div class=\"sidebar\"><div class=\"titleImage\"><a><img/></a></div><a class=\"craftingPlan externalLink\"><p>See Crafting Plan</p></a><script async src=\"//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js\"></script>\n<!-- Sidebar Skyscraper -->\n<ins class=\"adsbygoogle\"\n style=\"display:inline-block;width:160px;height:600px\"\n data-ad-client=\"ca-pub-6593013914878730\"\n data-ad-slot=\"7613920409\"></ins>\n<script>\n(adsbygoogle = window.adsbygoogle || []).push({});\n</script>\n</div><div class=\"mainBody\"><h1 class=\"name\"></h1><div class=\"byline\"><p>from <a></a></p></div><div class=\"description\"><p></p></div><div class=\"recipes section\"><h2>Recipes</h2><div class=\"panel\"></div></div><div class=\"usedToMake\"><div class=\"view__item_group\"></div></div><div class=\"usedAsToolToMake\"><div class=\"view__item_group\"></div></div><div class=\"similar\"><div class=\"view__item_group\"></div></div><div class=\"plan\"></div></div></div>");;return buf.join(""); buf.push("<div class=\"view__item_page\"><div class=\"sidebar\"><div class=\"titleImage\"><a><img/></a></div><a class=\"craftingPlan externalLink\"><p>See Crafting Plan</p></a><script async src=\"//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js\"></script>\n<!-- Sidebar Skyscraper -->\n<ins class=\"adsbygoogle\"\n style=\"display:inline-block;width:160px;height:600px\"\n data-ad-client=\"ca-pub-6593013914878730\"\n data-ad-slot=\"7613920409\"></ins>\n<script>\n(adsbygoogle = window.adsbygoogle || []).push({});\n</script>\n</div><div class=\"mainBody\"><h1 class=\"name\"></h1><div class=\"byline\"><p>from <a></a></p></div><div class=\"description section\"><h2>Description</h2><div class=\"panel markdown\"></div></div><div class=\"recipes section\"><h2>Recipes</h2><div class=\"panel\"></div></div><div class=\"usedToMake\"><div class=\"view__item_group\"></div></div><div class=\"usedAsToolToMake\"><div class=\"view__item_group\"></div></div><div class=\"similar\"><div class=\"view__item_group\"></div></div><div class=\"plan\"></div></div></div>");;return buf.join("");
}; };
this["JST"]["minimal_recipe"] = function template(locals) { this["JST"]["minimal_recipe"] = function template(locals) {
+1
View File
@@ -34,4 +34,5 @@ html
script(src="/js/jquery.js") script(src="/js/jquery.js")
script(src="/js/backbone.js") script(src="/js/backbone.js")
script(src="/js/jade.js") script(src="/js/jade.js")
script(src="/js/markdown.js")
script(src="/js/when.js") script(src="/js/when.js")
+4 -1
View File
@@ -15,7 +15,10 @@
.mainBody .mainBody
h1.name h1.name
.byline: p from <a></a> .byline: p from <a></a>
.description: p
.description.section
h2 Description
.panel.markdown
.recipes.section .recipes.section
h2 Recipes h2 Recipes
+1
View File
@@ -86,6 +86,7 @@ $layer-dialog: 1000;
// Child Stylesheets /////////////////////////////////////////////////////////////////////////////////////////////////// // Child Stylesheets ///////////////////////////////////////////////////////////////////////////////////////////////////
@import 'classes'; @import 'classes';
@import 'markdown';
@import 'tags'; @import 'tags';
@import 'views'; @import 'views';
+22
View File
@@ -0,0 +1,22 @@
/*
Crafting Guide - markdown.scss
Copyright (C) 2015 by Redwood Labs
All rights reserved.
*/
.markdown {
p {
font-family: $font-family-normal;
font-size: $font-size-normal;
line-height: $font-size-normal + 0.25em;
}
img {
position: relative; left: 50%;
@include transform(translateX(-50%));
margin: 2em 0;
text-align: center;
}
}
-9
View File
@@ -31,15 +31,6 @@ All rights reserved.
} }
} }
.description {
margin-bottom: 4em;
p {
font-family: $font-family-normal;
font-size: $font-size-large;
}
}
.recipes { .recipes {
display: none; display: none;
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,15 @@
schema: 1
officialUrl: http://www.mod-buildcraft.com/wiki/doku.php?id=advanced_crafting_table
video: Z-Kl3IRjxE, Tutorial
description:<<-END
The advanced crafting table is an improved version of the basic automatic crafting table. It contains an inventory
for storing the input and an inventory for storing the outputs of the crafting process, making it potentially more
easy and seamless to create large automatic crafting systems. The advanced crafting table requires 500 MJ to craft
any recipe.
![GUI](./gui.png)
END