]> git.lizzy.rs Git - dragonfireclient.git/blob - builtin/mainmenu/dlg_contentstore.lua
790da03bafc3fdcc01c9a4ae6378b6030b585147
[dragonfireclient.git] / builtin / mainmenu / dlg_contentstore.lua
1 --Minetest
2 --Copyright (C) 2018-20 rubenwardy
3 --
4 --This program is free software; you can redistribute it and/or modify
5 --it under the terms of the GNU Lesser General Public License as published by
6 --the Free Software Foundation; either version 2.1 of the License, or
7 --(at your option) any later version.
8 --
9 --This program is distributed in the hope that it will be useful,
10 --but WITHOUT ANY WARRANTY; without even the implied warranty of
11 --MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 --GNU Lesser General Public License for more details.
13 --
14 --You should have received a copy of the GNU Lesser General Public License along
15 --with this program; if not, write to the Free Software Foundation, Inc.,
16 --51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18 if not core.get_http_api then
19         function create_store_dlg()
20                 return messagebox("store",
21                                 fgettext("ContentDB is not available when Minetest was compiled without cURL"))
22         end
23         return
24 end
25
26 -- Unordered preserves the original order of the ContentDB API,
27 -- before the package list is ordered based on installed state.
28 local store = { packages = {}, packages_full = {}, packages_full_unordered = {} }
29
30 local http = core.get_http_api()
31
32 -- Screenshot
33 local screenshot_dir = core.get_cache_path() .. DIR_DELIM .. "cdb"
34 assert(core.create_dir(screenshot_dir))
35 local screenshot_downloading = {}
36 local screenshot_downloaded = {}
37
38 -- Filter
39 local search_string = ""
40 local cur_page = 1
41 local num_per_page = 5
42 local filter_type = 1
43 local filter_types_titles = {
44         fgettext("All packages"),
45 --      fgettext("Games"),
46         fgettext("Clientmods"),
47         fgettext("Texture packs"),
48 }
49
50 local number_downloading = 0
51 local download_queue = {}
52
53 local filter_types_type = {
54         nil,
55 --      "game",
56         "mod",
57         "txp",
58 }
59
60 local REASON_NEW = "new"
61 local REASON_UPDATE = "update"
62 local REASON_DEPENDENCY = "dependency"
63
64
65 local function get_download_url(package, reason)
66         local base_url = core.settings:get("contentdb_url")
67         local ret = base_url .. ("/packages/%s/%s/releases/%d/download/"):format(package.author, package.name, package.release)
68         if reason then
69                 ret = ret .. "?reason=" .. reason
70         end
71         return ret
72 end
73
74
75 local function download_package(param)
76         if core.download_file(param.url, param.filename) then
77                 return {
78                         filename = param.filename,
79                         successful = true,
80                 }
81         else
82                 core.log("error", "downloading " .. dump(param.url) .. " failed")
83                 return {
84                         successful = false,
85                 }
86         end
87 end
88
89 local function start_install(package, reason)
90         local params = {
91                 package = package,
92                 url = get_download_url(package, reason),
93                 filename = os.tempfolder() .. "_MODNAME_" .. package.name .. ".zip",
94         }
95
96         number_downloading = number_downloading + 1
97
98         local function callback(result)
99                 if result.successful then
100                         local path, msg = pkgmgr.install(package.type,
101                                         result.filename, package.name,
102                                         package.path)
103                         if not path then
104                                 gamedata.errormessage = msg
105                         else
106                                 core.log("action", "Installed package to " .. path)
107
108                                 local conf_path
109                                 local name_is_title = false
110                                 if package.type == "mod" then
111                                         local actual_type = pkgmgr.get_folder_type(path)
112                                         if actual_type.type == "modpack" then
113                                                 conf_path = path .. DIR_DELIM .. "modpack.conf"
114                                         else
115                                                 conf_path = path .. DIR_DELIM .. "mod.conf"
116                                         end
117                                 elseif package.type == "game" then
118                                         conf_path = path .. DIR_DELIM .. "game.conf"
119                                         name_is_title = true
120                                 elseif package.type == "txp" then
121                                         conf_path = path .. DIR_DELIM .. "texture_pack.conf"
122                                 end
123
124                                 if conf_path then
125                                         local conf = Settings(conf_path)
126                                         if name_is_title then
127                                                 conf:set("name",   package.title)
128                                         else
129                                                 conf:set("title",  package.title)
130                                                 conf:set("name",   package.name)
131                                         end
132                                         if not conf:get("description") then
133                                                 conf:set("description", package.short_description)
134                                         end
135                                         conf:set("author",     package.author)
136                                         conf:set("release",    package.release)
137                                         conf:write()
138                                 end
139                         end
140                         os.remove(result.filename)
141                 else
142                         gamedata.errormessage = fgettext("Failed to download $1", package.name)
143                 end
144
145                 package.downloading = false
146
147                 number_downloading = number_downloading - 1
148
149                 local next = download_queue[1]
150                 if next then
151                         table.remove(download_queue, 1)
152
153                         start_install(next.package, next.reason)
154                 end
155
156                 ui.update()
157         end
158
159         package.queued = false
160         package.downloading = true
161
162         if not core.handle_async(download_package, params, callback) then
163                 core.log("error", "ERROR: async event failed")
164                 gamedata.errormessage = fgettext("Failed to download $1", package.name)
165                 return
166         end
167 end
168
169 local function queue_download(package, reason)
170         local max_concurrent_downloads = tonumber(core.settings:get("contentdb_max_concurrent_downloads"))
171         if number_downloading < max_concurrent_downloads then
172                 start_install(package, reason)
173         else
174                 table.insert(download_queue, { package = package, reason = reason })
175                 package.queued = true
176         end
177 end
178
179 local function get_raw_dependencies(package)
180         if package.raw_deps then
181                 return package.raw_deps
182         end
183
184         local url_fmt = "/api/packages/%s/dependencies/?only_hard=1&protocol_version=%s&engine_version=%s"
185         local version = core.get_version()
186         local base_url = core.settings:get("contentdb_url")
187         local url = base_url .. url_fmt:format(package.id, core.get_max_supp_proto(), version.string)
188
189         local response = http.fetch_sync({ url = url })
190         if not response.succeeded then
191                 return
192         end
193
194         local data = core.parse_json(response.data) or {}
195
196         local content_lookup = {}
197         for _, pkg in pairs(store.packages_full) do
198                 content_lookup[pkg.id] = pkg
199         end
200
201         for id, raw_deps in pairs(data) do
202                 local package2 = content_lookup[id:lower()]
203                 if package2 and not package2.raw_deps then
204                         package2.raw_deps = raw_deps
205
206                         for _, dep in pairs(raw_deps) do
207                                 local packages = {}
208                                 for i=1, #dep.packages do
209                                         packages[#packages + 1] = content_lookup[dep.packages[i]:lower()]
210                                 end
211                                 dep.packages = packages
212                         end
213                 end
214         end
215
216         return package.raw_deps
217 end
218
219 local function has_hard_deps(raw_deps)
220         for i=1, #raw_deps do
221                 if not raw_deps[i].is_optional then
222                         return true
223                 end
224         end
225
226         return false
227 end
228
229 -- Recursively resolve dependencies, given the installed mods
230 local function resolve_dependencies_2(raw_deps, installed_mods, out)
231         local function resolve_dep(dep)
232                 -- Check whether it's already installed
233                 if installed_mods[dep.name] then
234                         return {
235                                 is_optional = dep.is_optional,
236                                 name = dep.name,
237                                 installed = true,
238                         }
239                 end
240
241                 -- Find exact name matches
242                 local fallback
243                 for _, package in pairs(dep.packages) do
244                         if package.type ~= "game" then
245                                 if package.name == dep.name then
246                                         return {
247                                                 is_optional = dep.is_optional,
248                                                 name = dep.name,
249                                                 installed = false,
250                                                 package = package,
251                                         }
252                                 elseif not fallback then
253                                         fallback = package
254                                 end
255                         end
256                 end
257
258                 -- Otherwise, find the first mod that fulfils it
259                 if fallback then
260                         return {
261                                 is_optional = dep.is_optional,
262                                 name = dep.name,
263                                 installed = false,
264                                 package = fallback,
265                         }
266                 end
267
268                 return {
269                         is_optional = dep.is_optional,
270                         name = dep.name,
271                         installed = false,
272                 }
273         end
274
275         for _, dep in pairs(raw_deps) do
276                 if not dep.is_optional and not out[dep.name] then
277                         local result  = resolve_dep(dep)
278                         out[dep.name] = result
279                         if result and result.package and not result.installed then
280                                 local raw_deps2 = get_raw_dependencies(result.package)
281                                 if raw_deps2 then
282                                         resolve_dependencies_2(raw_deps2, installed_mods, out)
283                                 end
284                         end
285                 end
286         end
287
288         return true
289 end
290
291 -- Resolve dependencies for a package, calls the recursive version.
292 local function resolve_dependencies(raw_deps, game)
293         assert(game)
294
295         local installed_mods = {}
296
297         local mods = {}
298         pkgmgr.get_game_mods(game, mods)
299         for _, mod in pairs(mods) do
300                 installed_mods[mod.name] = true
301         end
302
303         for _, mod in pairs(pkgmgr.global_mods:get_list()) do
304                 installed_mods[mod.name] = true
305         end
306
307         local out = {}
308         if not resolve_dependencies_2(raw_deps, installed_mods, out) then
309                 return nil
310         end
311
312         local retval = {}
313         for _, dep in pairs(out) do
314                 retval[#retval + 1] = dep
315         end
316
317         table.sort(retval, function(a, b)
318                 return a.name < b.name
319         end)
320
321         return retval
322 end
323
324 local install_dialog = {}
325 function install_dialog.get_formspec()
326         local package = install_dialog.package
327         local raw_deps = install_dialog.raw_deps
328         local will_install_deps = install_dialog.will_install_deps
329
330         local selected_game_idx = 1
331         local selected_gameid = core.settings:get("menu_last_game")
332         local games = table.copy(pkgmgr.games)
333         for i=1, #games do
334                 if selected_gameid and games[i].id == selected_gameid then
335                         selected_game_idx = i
336                 end
337
338                 games[i] = core.formspec_escape(games[i].name)
339         end
340
341         local selected_game = pkgmgr.games[selected_game_idx]
342         local deps_to_install = 0
343         local deps_not_found = 0
344
345         install_dialog.dependencies = resolve_dependencies(raw_deps, selected_game)
346         local formatted_deps = {}
347         for _, dep in pairs(install_dialog.dependencies) do
348                 formatted_deps[#formatted_deps + 1] = "#fff"
349                 formatted_deps[#formatted_deps + 1] = core.formspec_escape(dep.name)
350                 if dep.installed then
351                         formatted_deps[#formatted_deps + 1] = "#ccf"
352                         formatted_deps[#formatted_deps + 1] = fgettext("Already installed")
353                 elseif dep.package then
354                         formatted_deps[#formatted_deps + 1] = "#cfc"
355                         formatted_deps[#formatted_deps + 1] = fgettext("$1 by $2", dep.package.title, dep.package.author)
356                         deps_to_install = deps_to_install + 1
357                 else
358                         formatted_deps[#formatted_deps + 1] = "#f00"
359                         formatted_deps[#formatted_deps + 1] = fgettext("Not found")
360                         deps_not_found = deps_not_found + 1
361                 end
362         end
363
364         local message_bg = "#3333"
365         local message
366         if will_install_deps then
367                 message = fgettext("$1 and $2 dependencies will be installed.", package.title, deps_to_install)
368         else
369                 message = fgettext("$1 will be installed, and $2 dependencies will be skipped.", package.title, deps_to_install)
370         end
371         if deps_not_found > 0 then
372                 message = fgettext("$1 required dependencies could not be found.", deps_not_found) ..
373                                 " " .. fgettext("Please check that the base game is correct.", deps_not_found) ..
374                                 "\n" .. message
375                 message_bg = mt_color_orange
376         end
377
378         local formspec = {
379                 "formspec_version[3]",
380                 "size[7,7.85]",
381                 "style[title;border=false]",
382                 "box[0,0;7,0.5;#3333]",
383                 "button[0,0;7,0.5;title;", fgettext("Install $1", package.title) , "]",
384
385                 "container[0.375,0.70]",
386
387                 "label[0,0.25;", fgettext("Base Game:"), "]",
388                 "dropdown[2,0;4.25,0.5;gameid;", table.concat(games, ","), ";", selected_game_idx, "]",
389
390                 "label[0,0.8;", fgettext("Dependencies:"), "]",
391
392                 "tablecolumns[color;text;color;text]",
393                 "table[0,1.1;6.25,3;packages;", table.concat(formatted_deps, ","), "]",
394
395                 "container_end[]",
396
397                 "checkbox[0.375,5.1;will_install_deps;",
398                         fgettext("Install missing dependencies"), ";",
399                         will_install_deps and "true" or "false", "]",
400
401                 "box[0,5.4;7,1.2;", message_bg, "]",
402                 "textarea[0.375,5.5;6.25,1;;;", message, "]",
403
404                 "container[1.375,6.85]",
405                 "button[0,0;2,0.8;install_all;", fgettext("Install"), "]",
406                 "button[2.25,0;2,0.8;cancel;", fgettext("Cancel"), "]",
407                 "container_end[]",
408         }
409
410         return table.concat(formspec, "")
411 end
412
413 function install_dialog.handle_submit(this, fields)
414         if fields.cancel then
415                 this:delete()
416                 return true
417         end
418
419         if fields.will_install_deps ~= nil then
420                 install_dialog.will_install_deps = core.is_yes(fields.will_install_deps)
421                 return true
422         end
423
424         if fields.install_all then
425                 queue_download(install_dialog.package, REASON_NEW)
426
427                 if install_dialog.will_install_deps then
428                         for _, dep in pairs(install_dialog.dependencies) do
429                                 if not dep.is_optional and not dep.installed and dep.package then
430                                         queue_download(dep.package, REASON_DEPENDENCY)
431                                 end
432                         end
433                 end
434
435                 this:delete()
436                 return true
437         end
438
439         if fields.gameid then
440                 for _, game in pairs(pkgmgr.games) do
441                         if game.name == fields.gameid then
442                                 core.settings:set("menu_last_game", game.id)
443                                 break
444                         end
445                 end
446                 return true
447         end
448
449         return false
450 end
451
452 function install_dialog.create(package, raw_deps)
453         install_dialog.dependencies = nil
454         install_dialog.package = package
455         install_dialog.raw_deps = raw_deps
456         install_dialog.will_install_deps = true
457         return dialog_create("install_dialog",
458                         install_dialog.get_formspec,
459                         install_dialog.handle_submit,
460                         nil)
461 end
462
463
464 local confirm_overwrite = {}
465 function confirm_overwrite.get_formspec()
466         local package = confirm_overwrite.package
467
468         return "size[11.5,4.5,true]" ..
469                         "label[2,2;" ..
470                         fgettext("\"$1\" already exists. Would you like to overwrite it?", package.name) .. "]"..
471                         "style[install;bgcolor=red]" ..
472                         "button[3.25,3.5;2.5,0.5;install;" .. fgettext("Overwrite") .. "]" ..
473                         "button[5.75,3.5;2.5,0.5;cancel;" .. fgettext("Cancel") .. "]"
474 end
475
476 function confirm_overwrite.handle_submit(this, fields)
477         if fields.cancel then
478                 this:delete()
479                 return true
480         end
481
482         if fields.install then
483                 this:delete()
484                 confirm_overwrite.callback()
485                 return true
486         end
487
488         return false
489 end
490
491 function confirm_overwrite.create(package, callback)
492         assert(type(package) == "table")
493         assert(type(callback) == "function")
494
495         confirm_overwrite.package = package
496         confirm_overwrite.callback = callback
497         return dialog_create("confirm_overwrite",
498                 confirm_overwrite.get_formspec,
499                 confirm_overwrite.handle_submit,
500                 nil)
501 end
502
503
504 local function get_file_extension(path)
505         local parts = path:split(".")
506         return parts[#parts]
507 end
508
509 local function get_screenshot(package)
510         if not package.thumbnail then
511                 return defaulttexturedir .. "no_screenshot.png"
512         elseif screenshot_downloading[package.thumbnail] then
513                 return defaulttexturedir .. "loading_screenshot.png"
514         end
515
516         -- Get tmp screenshot path
517         local ext = get_file_extension(package.thumbnail)
518         local filepath = screenshot_dir .. DIR_DELIM ..
519                 ("%s-%s-%s.%s"):format(package.type, package.author, package.name, ext)
520
521         -- Return if already downloaded
522         local file = io.open(filepath, "r")
523         if file then
524                 file:close()
525                 return filepath
526         end
527
528         -- Show error if we've failed to download before
529         if screenshot_downloaded[package.thumbnail] then
530                 return defaulttexturedir .. "error_screenshot.png"
531         end
532
533         -- Download
534
535         local function download_screenshot(params)
536                 return core.download_file(params.url, params.dest)
537         end
538         local function callback(success)
539                 screenshot_downloading[package.thumbnail] = nil
540                 screenshot_downloaded[package.thumbnail] = true
541                 if not success then
542                         core.log("warning", "Screenshot download failed for some reason")
543                 end
544                 ui.update()
545         end
546         if core.handle_async(download_screenshot,
547                         { dest = filepath, url = package.thumbnail }, callback) then
548                 screenshot_downloading[package.thumbnail] = true
549         else
550                 core.log("error", "ERROR: async event failed")
551                 return defaulttexturedir .. "error_screenshot.png"
552         end
553
554         return defaulttexturedir .. "loading_screenshot.png"
555 end
556
557 function store.load()
558         local version = core.get_version()
559         local base_url = core.settings:get("contentdb_url")
560         local url = base_url ..
561                 "/api/packages/?type=mod&type=game&type=txp&protocol_version=" ..
562                 core.get_max_supp_proto() .. "&engine_version=" .. version.string
563
564         for _, item in pairs(core.settings:get("contentdb_flag_blacklist"):split(",")) do
565                 item = item:trim()
566                 if item ~= "" then
567                         url = url .. "&hide=" .. item
568                 end
569         end
570
571         local timeout = tonumber(core.settings:get("curl_file_download_timeout"))
572         local response = http.fetch_sync({ url = url, timeout = timeout })
573         if not response.succeeded then
574                 return
575         end
576
577         store.packages_full = core.parse_json(response.data) or {}
578         store.aliases = {}
579
580         for _, package in pairs(store.packages_full) do
581                 local name_len = #package.name
582                 if package.type == "game" and name_len > 5 and package.name:sub(name_len - 4) == "_game" then
583                         package.id = package.author:lower() .. "/" .. package.name:sub(1, name_len - 5)
584                 else
585                         package.id = package.author:lower() .. "/" .. package.name
586                 end
587
588                 if package.aliases then
589                         for _, alias in ipairs(package.aliases) do
590                                 -- We currently don't support name changing
591                                 local suffix = "/" .. package.name
592                                 if alias:sub(-#suffix) == suffix then
593                                         store.aliases[alias:lower()] = package.id
594                                 end
595                         end
596                 end
597         end
598
599         store.packages_full_unordered = store.packages_full
600         store.packages = store.packages_full
601         store.loaded = true
602 end
603
604 function store.update_paths()
605         local mod_hash = {}
606         pkgmgr.refresh_globals()
607         for _, mod in pairs(pkgmgr.clientmods:get_list()) do
608                 if mod.author and mod.release > 0 then
609                         local id = mod.author:lower() .. "/" .. mod.name
610                         mod_hash[store.aliases[id] or id] = mod
611                 end
612         end
613
614         local game_hash = {}
615         pkgmgr.update_gamelist()
616         for _, game in pairs(pkgmgr.games) do
617                 if game.author ~= "" and game.release > 0 then
618                         local id = game.author:lower() .. "/" .. game.id
619                         game_hash[store.aliases[id] or id] = game
620                 end
621         end
622
623         local txp_hash = {}
624         for _, txp in pairs(pkgmgr.get_texture_packs()) do
625                 if txp.author and txp.release > 0 then
626                         local id = txp.author:lower() .. "/" .. txp.name
627                         txp_hash[store.aliases[id] or id] = txp
628                 end
629         end
630
631         for _, package in pairs(store.packages_full) do
632                 local content
633                 if package.type == "mod" then
634                         content = mod_hash[package.id]
635                 elseif package.type == "game" then
636                         content = game_hash[package.id]
637                 elseif package.type == "txp" then
638                         content = txp_hash[package.id]
639                 end
640
641                 if content then
642                         package.path = content.path
643                         package.installed_release = content.release or 0
644                 else
645                         package.path = nil
646                 end
647         end
648 end
649
650 function store.sort_packages()
651         local ret = {}
652
653         -- Add installed content
654         for i=1, #store.packages_full_unordered do
655                 local package = store.packages_full_unordered[i]
656                 if package.path then
657                         ret[#ret + 1] = package
658                 end
659         end
660
661         -- Sort installed content by title
662         table.sort(ret, function(a, b)
663                 return a.title < b.title
664         end)
665
666         -- Add uninstalled content
667         for i=1, #store.packages_full_unordered do
668                 local package = store.packages_full_unordered[i]
669                 if not package.path then
670                         ret[#ret + 1] = package
671                 end
672         end
673
674         store.packages_full = ret
675 end
676
677 function store.filter_packages(query)
678         if query == "" and filter_type == 1 then
679                 store.packages = store.packages_full
680                 return
681         end
682
683         local keywords = {}
684         for word in query:lower():gmatch("%S+") do
685                 table.insert(keywords, word)
686         end
687
688         local function matches_keywords(package)
689                 for k = 1, #keywords do
690                         local keyword = keywords[k]
691
692                         if string.find(package.name:lower(), keyword, 1, true) or
693                                         string.find(package.title:lower(), keyword, 1, true) or
694                                         string.find(package.author:lower(), keyword, 1, true) or
695                                         string.find(package.short_description:lower(), keyword, 1, true) then
696                                 return true
697                         end
698                 end
699
700                 return false
701         end
702
703         store.packages = {}
704         for _, package in pairs(store.packages_full) do
705                 if (query == "" or matches_keywords(package)) and
706                                 (filter_type == 1 or package.type == filter_types_type[filter_type]) then
707                         store.packages[#store.packages + 1] = package
708                 end
709         end
710 end
711
712 function store.get_formspec(dlgdata)
713         store.update_paths()
714
715         dlgdata.pagemax = math.max(math.ceil(#store.packages / num_per_page), 1)
716         if cur_page > dlgdata.pagemax then
717                 cur_page = 1
718         end
719
720         local W = 15.75
721         local H = 9.5
722         local formspec
723         if #store.packages_full > 0 then
724                 formspec = {
725                         "formspec_version[3]",
726                         "size[15.75,9.5]",
727                         "position[0.5,0.55]",
728
729                         "style[status,downloading,queued;border=false]",
730
731                         "container[0.375,0.375]",
732                         "field[0,0;7.225,0.8;search_string;;", core.formspec_escape(search_string), "]",
733                         "field_close_on_enter[search_string;false]",
734                         "image_button[7.3,0;0.8,0.8;", core.formspec_escape(defaulttexturedir .. "search.png"), ";search;]",
735                         "image_button[8.125,0;0.8,0.8;", core.formspec_escape(defaulttexturedir .. "clear.png"), ";clear;]",
736                         "dropdown[9.6,0;2.4,0.8;type;", table.concat(filter_types_titles, ","), ";", filter_type, "]",
737                         "container_end[]",
738
739                         -- Page nav buttons
740                         "container[0,", H - 0.8 - 0.375, "]",
741                         "button[0.375,0;4,0.8;back;", fgettext("Back to Main Menu"), "]",
742
743                         "container[", W - 0.375 - 0.8*4 - 2,  ",0]",
744                         "image_button[0,0;0.8,0.8;", core.formspec_escape(defaulttexturedir), "start_icon.png;pstart;]",
745                         "image_button[0.8,0;0.8,0.8;", core.formspec_escape(defaulttexturedir), "prev_icon.png;pback;]",
746                         "style[pagenum;border=false]",
747                         "button[1.6,0;2,0.8;pagenum;", tonumber(cur_page), " / ", tonumber(dlgdata.pagemax), "]",
748                         "image_button[3.6,0;0.8,0.8;", core.formspec_escape(defaulttexturedir), "next_icon.png;pnext;]",
749                         "image_button[4.4,0;0.8,0.8;", core.formspec_escape(defaulttexturedir), "end_icon.png;pend;]",
750                         "container_end[]",
751
752                         "container_end[]",
753                 }
754
755                 if number_downloading > 0 then
756                         formspec[#formspec + 1] = "button[12.75,0.375;2.625,0.8;downloading;"
757                         if #download_queue > 0 then
758                                 formspec[#formspec + 1] = fgettext("$1 downloading,\n$2 queued", number_downloading, #download_queue)
759                         else
760                                 formspec[#formspec + 1] = fgettext("$1 downloading...", number_downloading)
761                         end
762                         formspec[#formspec + 1] = "]"
763                 else
764                         local num_avail_updates = 0
765                         for i=1, #store.packages_full do
766                                 local package = store.packages_full[i]
767                                 if package.path and package.installed_release < package.release and
768                                                 not (package.downloading or package.queued) then
769                                         num_avail_updates = num_avail_updates + 1
770                                 end
771                         end
772
773                         if num_avail_updates == 0 then
774                                 formspec[#formspec + 1] = "button[12.75,0.375;2.625,0.8;status;"
775                                 formspec[#formspec + 1] = fgettext("No updates")
776                                 formspec[#formspec + 1] = "]"
777                         else
778                                 formspec[#formspec + 1] = "button[12.75,0.375;2.625,0.8;update_all;"
779                                 formspec[#formspec + 1] = fgettext("Update All [$1]", num_avail_updates)
780                                 formspec[#formspec + 1] = "]"
781                         end
782                 end
783
784                 if #store.packages == 0 then
785                         formspec[#formspec + 1] = "label[4,3;"
786                         formspec[#formspec + 1] = fgettext("No results")
787                         formspec[#formspec + 1] = "]"
788                 end
789         else
790                 formspec = {
791                         "size[12,7]",
792                         "position[0.5,0.55]",
793                         "label[4,3;", fgettext("No packages could be retrieved"), "]",
794                         "container[0,", H - 0.8 - 0.375, "]",
795                         "button[0,0;4,0.8;back;", fgettext("Back to Main Menu"), "]",
796                         "container_end[]",
797                 }
798         end
799
800         -- download/queued tooltips always have the same message
801         local tooltip_colors = ";#dff6f5;#302c2e]"
802         formspec[#formspec + 1] = "tooltip[downloading;" .. fgettext("Downloading...") .. tooltip_colors
803         formspec[#formspec + 1] = "tooltip[queued;" .. fgettext("Queued") .. tooltip_colors
804
805         local start_idx = (cur_page - 1) * num_per_page + 1
806         for i=start_idx, math.min(#store.packages, start_idx+num_per_page-1) do
807                 local package = store.packages[i]
808                 local container_y = (i - start_idx) * 1.375 + (2*0.375 + 0.8)
809                 formspec[#formspec + 1] = "container[0.375,"
810                 formspec[#formspec + 1] = container_y
811                 formspec[#formspec + 1] = "]"
812
813                 -- image
814                 formspec[#formspec + 1] = "image[0,0;1.5,1;"
815                 formspec[#formspec + 1] = core.formspec_escape(get_screenshot(package))
816                 formspec[#formspec + 1] = "]"
817
818                 -- title
819                 formspec[#formspec + 1] = "label[1.875,0.1;"
820                 formspec[#formspec + 1] = core.formspec_escape(
821                                 core.colorize(mt_color_green, package.title) ..
822                                 core.colorize("#BFBFBF", " by " .. package.author))
823                 formspec[#formspec + 1] = "]"
824
825                 -- buttons
826                 local left_base = "image_button[-1.55,0;0.7,0.7;" .. core.formspec_escape(defaulttexturedir)
827                 formspec[#formspec + 1] = "container["
828                 formspec[#formspec + 1] = W - 0.375*2
829                 formspec[#formspec + 1] = ",0.1]"
830
831                 if package.downloading then
832                         formspec[#formspec + 1] = "animated_image[-1.7,-0.15;1,1;downloading;"
833                         formspec[#formspec + 1] = core.formspec_escape(defaulttexturedir)
834                         formspec[#formspec + 1] = "cdb_downloading.png;3;400;]"
835                 elseif package.queued then
836                         formspec[#formspec + 1] = left_base
837                         formspec[#formspec + 1] = core.formspec_escape(defaulttexturedir)
838                         formspec[#formspec + 1] = "cdb_queued.png;queued]"
839                 elseif not package.path then
840                         local elem_name = "install_" .. i .. ";"
841                         formspec[#formspec + 1] = "style[" .. elem_name .. "bgcolor=#71aa34]"
842                         formspec[#formspec + 1] = left_base .. "cdb_add.png;" .. elem_name .. "]"
843                         formspec[#formspec + 1] = "tooltip[" .. elem_name .. fgettext("Install") .. tooltip_colors
844                 else
845                         if package.installed_release < package.release then
846
847                                 -- The install_ action also handles updating
848                                 local elem_name = "install_" .. i .. ";"
849                                 formspec[#formspec + 1] = "style[" .. elem_name .. "bgcolor=#28ccdf]"
850                                 formspec[#formspec + 1] = left_base .. "cdb_update.png;" .. elem_name .. "]"
851                                 formspec[#formspec + 1] = "tooltip[" .. elem_name .. fgettext("Update") .. tooltip_colors
852                         else
853
854                                 local elem_name = "uninstall_" .. i .. ";"
855                                 formspec[#formspec + 1] = "style[" .. elem_name .. "bgcolor=#a93b3b]"
856                                 formspec[#formspec + 1] = left_base .. "cdb_clear.png;" .. elem_name .. "]"
857                                 formspec[#formspec + 1] = "tooltip[" .. elem_name .. fgettext("Uninstall") .. tooltip_colors
858                         end
859                 end
860
861                 local web_elem_name = "view_" .. i .. ";"
862                 formspec[#formspec + 1] = "image_button[-0.7,0;0.7,0.7;" ..
863                         core.formspec_escape(defaulttexturedir) .. "cdb_viewonline.png;" .. web_elem_name .. "]"
864                 formspec[#formspec + 1] = "tooltip[" .. web_elem_name ..
865                         fgettext("View more information in a web browser") .. tooltip_colors
866                 formspec[#formspec + 1] = "container_end[]"
867
868                 -- description
869                 local description_width = W - 0.375*5 - 0.85 - 2*0.7
870                 formspec[#formspec + 1] = "textarea[1.855,0.3;"
871                 formspec[#formspec + 1] = tostring(description_width)
872                 formspec[#formspec + 1] = ",0.8;;;"
873                 formspec[#formspec + 1] = core.formspec_escape(package.short_description)
874                 formspec[#formspec + 1] = "]"
875
876                 formspec[#formspec + 1] = "container_end[]"
877         end
878
879         return table.concat(formspec, "")
880 end
881
882 function store.handle_submit(this, fields)
883         if fields.search or fields.key_enter_field == "search_string" then
884                 search_string = fields.search_string:trim()
885                 cur_page = 1
886                 store.filter_packages(search_string)
887                 return true
888         end
889
890         if fields.clear then
891                 search_string = ""
892                 cur_page = 1
893                 store.filter_packages("")
894                 return true
895         end
896
897         if fields.back then
898                 this:delete()
899                 return true
900         end
901
902         if fields.pstart then
903                 cur_page = 1
904                 return true
905         end
906
907         if fields.pend then
908                 cur_page = this.data.pagemax
909                 return true
910         end
911
912         if fields.pnext then
913                 cur_page = cur_page + 1
914                 if cur_page > this.data.pagemax then
915                         cur_page = 1
916                 end
917                 return true
918         end
919
920         if fields.pback then
921                 if cur_page == 1 then
922                         cur_page = this.data.pagemax
923                 else
924                         cur_page = cur_page - 1
925                 end
926                 return true
927         end
928
929         if fields.type then
930                 local new_type = table.indexof(filter_types_titles, fields.type)
931                 if new_type ~= filter_type then
932                         filter_type = new_type
933                         store.filter_packages(search_string)
934                         return true
935                 end
936         end
937
938         if fields.update_all then
939                 for i=1, #store.packages_full do
940                         local package = store.packages_full[i]
941                         if package.path and package.installed_release < package.release and
942                                         not (package.downloading or package.queued) then
943                                 queue_download(package, REASON_UPDATE)
944                         end
945                 end
946                 return true
947         end
948
949         local start_idx = (cur_page - 1) * num_per_page + 1
950         assert(start_idx ~= nil)
951         for i=start_idx, math.min(#store.packages, start_idx+num_per_page-1) do
952                 local package = store.packages[i]
953                 assert(package)
954
955                 if fields["install_" .. i] then
956                         local install_parent
957                         if package.type == "mod" then
958                                 install_parent = core.get_modpath()
959                         elseif package.type == "game" then
960                                 install_parent = core.get_gamepath()
961                         elseif package.type == "txp" then
962                                 install_parent = core.get_texturepath()
963                         else
964                                 error("Unknown package type: " .. package.type)
965                         end
966
967
968                         local function on_confirm()
969                                 local deps = get_raw_dependencies(package)
970                                 if deps and has_hard_deps(deps) then
971                                         local dlg = install_dialog.create(package, deps)
972                                         dlg:set_parent(this)
973                                         this:hide()
974                                         dlg:show()
975                                 else
976                                         queue_download(package, package.path and REASON_UPDATE or REASON_NEW)
977                                 end
978                         end
979
980                         if not package.path and core.is_dir(install_parent .. DIR_DELIM .. package.name) then
981                                 local dlg = confirm_overwrite.create(package, on_confirm)
982                                 dlg:set_parent(this)
983                                 this:hide()
984                                 dlg:show()
985                         else
986                                 on_confirm()
987                         end
988
989                         return true
990                 end
991
992                 if fields["uninstall_" .. i] then
993                         local dlg = create_delete_content_dlg(package)
994                         dlg:set_parent(this)
995                         this:hide()
996                         dlg:show()
997                         return true
998                 end
999
1000                 if fields["view_" .. i] then
1001                         local url = ("%s/packages/%s/%s?protocol_version=%d"):format(
1002                                         core.settings:get("contentdb_url"),
1003                                         package.author, package.name, core.get_max_supp_proto())
1004                         core.open_url(url)
1005                         return true
1006                 end
1007         end
1008
1009         return false
1010 end
1011
1012 function create_store_dlg(type)
1013         if not store.loaded or #store.packages_full == 0 then
1014                 store.load()
1015         end
1016
1017         store.update_paths()
1018         store.sort_packages()
1019
1020         search_string = ""
1021         cur_page = 1
1022
1023         if type then
1024                 -- table.indexof does not work on tables that contain `nil`
1025                 for i, v in pairs(filter_types_type) do
1026                         if v == type then
1027                                 filter_type = i
1028                                 break
1029                         end
1030                 end
1031         end
1032
1033         store.filter_packages(search_string)
1034
1035         return dialog_create("store",
1036                         store.get_formspec,
1037                         store.handle_submit,
1038                         nil)
1039 end