mirror of
https://github.com/linuxserver/Heimdall.git
synced 2026-08-07 15:41:28 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5247349d33 | |||
| 5907a1f231 | |||
| df0eba046b | |||
| 6a776e30f8 | |||
| cbf099be2c | |||
| 56c53ab9c5 | |||
| 98b6d96cd1 | |||
| 5be7a65677 | |||
| 44be3cb319 | |||
| 243ad00810 | |||
| 7861ae1512 | |||
| 66dfe95c9f | |||
| 130661bd34 | |||
| 900fc83e79 | |||
| 4f30332854 | |||
| 852c231724 | |||
| 045bdf0deb | |||
| 755c3e59e1 |
@@ -31,17 +31,17 @@ jobs:
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
|
||||
- name: Cache yarn dependencies
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: yarn-${{ hashFiles('yarn.lock') }}
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run yarn
|
||||
run: yarn && yarn dev
|
||||
- name: Install node modules and build assets
|
||||
run: npm ci && npm run dev
|
||||
|
||||
- name: Run ESLint
|
||||
run: yarn lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: php artisan test
|
||||
|
||||
@@ -271,7 +271,7 @@ class ItemController extends Controller
|
||||
$httpsProxy = getenv('HTTPS_PROXY');
|
||||
$httpsProxyLower = getenv('https_proxy');
|
||||
if ($httpsProxy !== false || $httpsProxyLower !== false) {
|
||||
$options['proxy']['http'] = $httpsProxy ?: $httpsProxyLower;
|
||||
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
|
||||
}
|
||||
|
||||
$file = $request->input('icon');
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Search;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
@@ -41,4 +43,97 @@ class SearchController extends Controller
|
||||
|
||||
abort(404, 'Provider type not supported');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete suggestions for a search query
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function autocomplete(Request $request)
|
||||
{
|
||||
$requestprovider = $request->input('provider');
|
||||
$query = $request->input('q');
|
||||
|
||||
if (!$query || trim($query) === '') {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
$provider = Search::providerDetails($requestprovider);
|
||||
|
||||
if (!$provider || !isset($provider->autocomplete)) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
// Replace {query} placeholder with actual query
|
||||
$autocompleteUrl = str_replace('{query}', urlencode($query), $provider->autocomplete);
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)->get($autocompleteUrl);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->body();
|
||||
|
||||
// Parse the response based on provider
|
||||
$suggestions = $this->parseAutocompleteResponse($data, $provider->id);
|
||||
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Return empty array on error
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse autocomplete response based on provider format
|
||||
*
|
||||
* @param string $data
|
||||
* @param string $providerId
|
||||
* @return array
|
||||
*/
|
||||
private function parseAutocompleteResponse($data, $providerId)
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
switch ($providerId) {
|
||||
case 'google':
|
||||
// Google returns XML format
|
||||
if (strpos($data, '<?xml') === 0) {
|
||||
$xml = simplexml_load_string($data);
|
||||
if ($xml && isset($xml->CompleteSuggestion)) {
|
||||
foreach ($xml->CompleteSuggestion as $suggestion) {
|
||||
if (isset($suggestion->suggestion['data'])) {
|
||||
$suggestions[] = (string) $suggestion->suggestion['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bing':
|
||||
case 'ddg':
|
||||
// Bing and DuckDuckGo return JSON array format
|
||||
$json = json_decode($data, true);
|
||||
if (is_array($json) && isset($json[1]) && is_array($json[1])) {
|
||||
$suggestions = $json[1];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Try to parse as JSON array
|
||||
$json = json_decode($data, true);
|
||||
if (is_array($json)) {
|
||||
if (isset($json[1]) && is_array($json[1])) {
|
||||
$suggestions = $json[1];
|
||||
} else {
|
||||
$suggestions = $json;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ class TagController extends Controller
|
||||
$data['tag'] = $item->id;
|
||||
$data['all_apps'] = $item->children;
|
||||
|
||||
$data['taglist'] = Item::ofType('tag')->where('id', '>', 0)->orderBy('title', 'asc')->get();
|
||||
|
||||
return view('welcome', $data);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,24 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
class ProcessApps implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Most failures here are GitHub rate-limit responses; retries inside the
|
||||
* same window do not help, so a single attempt is enough.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
|
||||
* does not permanently block future ProcessApps dispatches.
|
||||
*/
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
@@ -57,4 +70,13 @@ class ProcessApps implements ShouldQueue, ShouldBeUnique
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Log::error(static::class . ' permanently failed', [
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile() . ':' . $exception->getLine(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -12,11 +12,26 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class UpdateApps implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Most failures here are GitHub rate-limit responses; retries inside the
|
||||
* same window do not help, so a single attempt is enough. The throttle
|
||||
* loop in handle() means the job is intentionally long-running, so we
|
||||
* leave $timeout unset and let the operator's worker config govern.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Expire the ShouldBeUnique lock after 10 minutes so a crashed worker
|
||||
* does not permanently block future UpdateApps dispatches.
|
||||
*/
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
@@ -49,8 +64,14 @@ class UpdateApps implements ShouldQueue, ShouldBeUnique
|
||||
Cache::lock('updateApps')->forceRelease();
|
||||
}
|
||||
|
||||
public function failed($exception): void
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Cache::lock('updateApps')->forceRelease();
|
||||
|
||||
Log::error(static::class . ' permanently failed', [
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => $exception->getMessage(),
|
||||
'file' => $exception->getFile() . ':' . $exception->getLine(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-5
@@ -111,16 +111,31 @@ abstract class Search
|
||||
if ((bool) $user_search_provider) {
|
||||
$name = 'app.options.'.$user_search_provider;
|
||||
$provider = self::providerDetails($user_search_provider);
|
||||
$providers = self::providers();
|
||||
$providerCount = count($providers);
|
||||
|
||||
// If there's only one provider, use its key instead of the user's setting
|
||||
if ($providerCount === 1) {
|
||||
$user_search_provider = $providers->keys()->first();
|
||||
}
|
||||
|
||||
$output .= '<div class="searchform">';
|
||||
$output .= '<form action="'.url('search').'"'.getLinkTargetAttribute().' method="get">';
|
||||
$output .= '<div id="search-container" class="input-container">';
|
||||
$output .= '<select name="provider">';
|
||||
foreach (self::providers() as $key => $searchprovider) {
|
||||
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
|
||||
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
|
||||
|
||||
// Only show dropdown if there's more than one provider
|
||||
if ($providerCount > 1) {
|
||||
$output .= '<select name="provider">';
|
||||
foreach ($providers as $key => $searchprovider) {
|
||||
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
|
||||
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
|
||||
}
|
||||
$output .= '</select>';
|
||||
} else {
|
||||
// Hidden input for single provider
|
||||
$output .= '<input type="hidden" name="provider" value="'.$user_search_provider.'" />';
|
||||
}
|
||||
$output .= '</select>';
|
||||
|
||||
$output .= '<input type="text" name="q" value="'.e(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
|
||||
$output .= '<button type="submit">'.ucwords(__('app.settings.search')).'</button>';
|
||||
$output .= '</div>';
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Facade;
|
||||
|
||||
return [
|
||||
|
||||
'version' => '2.7.6',
|
||||
'version' => '2.7.7',
|
||||
|
||||
'appsource' => env('APP_SOURCE', 'https://appslist.heimdall.site/'),
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"select2": "^4.0.13",
|
||||
"select2": "~4.0.13",
|
||||
"sortablejs": "^1.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"webpack-cli": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"select2": "^4.0.13",
|
||||
"select2": "~4.0.13",
|
||||
"sortablejs": "^1.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2183
-2
File diff suppressed because one or more lines are too long
Vendored
+4636
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Generated
+2
-3
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"/js/dummy.js": "/js/dummy.js?id=daec5f3b283a510837bec36ca3868a54",
|
||||
"/css/app.css": "/css/app.css?id=8e5c9ae35dd160a37c9d33d663f996b9",
|
||||
"/js/app.js": "/js/app.js?id=19052619246fec368cad13937c62d850"
|
||||
"/css/app.css": "/css/app.css?id=271cb5f5a1f91d0a6dfbc65e374ffc14",
|
||||
"/js/app.js": "/js/app.js?id=2ebeb753597d1cbbf88d8bc652e4af5b"
|
||||
}
|
||||
|
||||
+101
-1
@@ -108,11 +108,90 @@ $.when($.ready).then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Autocomplete functionality
|
||||
let autocompleteTimeout = null;
|
||||
let currentAutocompleteRequest = null;
|
||||
|
||||
function hideAutocomplete() {
|
||||
$("#search-autocomplete").remove();
|
||||
}
|
||||
|
||||
function showAutocomplete(suggestions, inputElement) {
|
||||
hideAutocomplete();
|
||||
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $input = $(inputElement);
|
||||
const position = $input.position();
|
||||
const width = $input.outerWidth();
|
||||
|
||||
const $autocomplete = $('<div id="search-autocomplete"></div>');
|
||||
|
||||
suggestions.forEach((suggestion) => {
|
||||
const $item = $('<div class="autocomplete-item"></div>')
|
||||
.text(suggestion)
|
||||
.on("click", () => {
|
||||
$input.val(suggestion);
|
||||
hideAutocomplete();
|
||||
$input.closest("form").submit();
|
||||
});
|
||||
$autocomplete.append($item);
|
||||
});
|
||||
|
||||
$autocomplete.css({
|
||||
position: "absolute",
|
||||
top: `${position.top + $input.outerHeight()}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
|
||||
$input.closest("#search-container").append($autocomplete);
|
||||
}
|
||||
|
||||
function fetchAutocomplete(query, provider) {
|
||||
// Cancel previous request if any
|
||||
if (currentAutocompleteRequest) {
|
||||
currentAutocompleteRequest.abort();
|
||||
}
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
currentAutocompleteRequest = $.ajax({
|
||||
url: `${base}search/autocomplete`,
|
||||
method: "GET",
|
||||
data: {
|
||||
q: query,
|
||||
provider,
|
||||
},
|
||||
success(data) {
|
||||
const inputElement = $("#search-container input[name=q]")[0];
|
||||
showAutocomplete(data, inputElement);
|
||||
},
|
||||
error() {
|
||||
hideAutocomplete();
|
||||
},
|
||||
complete() {
|
||||
currentAutocompleteRequest = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
$("#search-container")
|
||||
.on("input", "input[name=q]", function () {
|
||||
const search = this.value;
|
||||
const items = $("#sortable").find(".item-container");
|
||||
if ($("#search-container select[name=provider]").val() === "tiles") {
|
||||
// Get provider from either select or hidden input
|
||||
const provider =
|
||||
$("#search-container select[name=provider]").val() ||
|
||||
$("#search-container input[name=provider]").val();
|
||||
|
||||
if (provider === "tiles") {
|
||||
hideAutocomplete();
|
||||
if (search.length > 0) {
|
||||
items.hide();
|
||||
items
|
||||
@@ -126,6 +205,12 @@ $.when($.ready).then(() => {
|
||||
}
|
||||
} else {
|
||||
items.show();
|
||||
|
||||
// Debounce autocomplete requests
|
||||
clearTimeout(autocompleteTimeout);
|
||||
autocompleteTimeout = setTimeout(() => {
|
||||
fetchAutocomplete(search, provider);
|
||||
}, 300);
|
||||
}
|
||||
})
|
||||
.on("change", "select[name=provider]", function () {
|
||||
@@ -147,9 +232,24 @@ $.when($.ready).then(() => {
|
||||
} else {
|
||||
$("#search-container button").show();
|
||||
items.show();
|
||||
hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Hide autocomplete when clicking outside
|
||||
$(document).on("click", (e) => {
|
||||
if (!$(e.target).closest("#search-container").length) {
|
||||
hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Hide autocomplete on Escape key
|
||||
$(document).on("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
$("#search-container select[name=provider]").trigger("change");
|
||||
|
||||
$("#app")
|
||||
|
||||
@@ -926,6 +926,12 @@ div.create {
|
||||
max-width: 620px;
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
|
||||
// Reduce width when there's no select dropdown (only has hidden input)
|
||||
&:has(input[name="provider"][type="hidden"]) {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -933,7 +939,6 @@ div.create {
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 0px 5px 0 rgba(0,0,0,0.4);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -945,6 +950,11 @@ div.create {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
// When there's no select dropdown, round the input's left corners
|
||||
input[name="q"]:first-child {
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
button {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
@@ -965,7 +975,42 @@ div.create {
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
border-right: 1px solid #ddd;
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
// When select exists, remove input's left border radius
|
||||
select ~ input[name="q"] {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#search-autocomplete {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-top: none;
|
||||
border-radius: 0 0 5px 5px;
|
||||
box-shadow: 0px 4px 8px 0 rgba(0,0,0,0.2);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
.autocomplete-item {
|
||||
padding: 12px 15px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui-autocomplete {
|
||||
|
||||
@@ -75,6 +75,7 @@ Route::post('test_config', [ItemController::class,'testConfig'])->name('test_con
|
||||
Route::get('get_stats/{id}', [ItemController::class,'getStats'])->name('get_stats');
|
||||
|
||||
Route::get('/search', [SearchController::class,'index'])->name('search');
|
||||
Route::get('/search/autocomplete', [SearchController::class,'autocomplete'])->name('search.autocomplete');
|
||||
|
||||
Route::get('view/{name_view}', function ($name_view) {
|
||||
return view('SupportedApps::'.$name_view)->render();
|
||||
|
||||
@@ -18,6 +18,7 @@ bing:
|
||||
method: get
|
||||
target: _blank
|
||||
query: q
|
||||
autocomplete: https://api.bing.com/osjson.aspx?query={query}
|
||||
|
||||
ddg:
|
||||
id: ddg
|
||||
@@ -26,6 +27,7 @@ ddg:
|
||||
method: get
|
||||
target: _blank
|
||||
query: q
|
||||
autocomplete: https://duckduckgo.com/ac/?q={query}&type=list
|
||||
|
||||
google:
|
||||
id: google
|
||||
@@ -34,6 +36,7 @@ google:
|
||||
method: get
|
||||
target: _blank
|
||||
query: q
|
||||
autocomplete: https://suggestqueries.google.com/complete/search?output=toolbar&hl=en&q={query}
|
||||
|
||||
startpage:
|
||||
id: startpage
|
||||
|
||||
Vendored
-1
@@ -12,7 +12,6 @@ const mix = require("laravel-mix");
|
||||
*/
|
||||
|
||||
mix
|
||||
.js("resources/assets/js/app.js", "public/js/dummy.js")
|
||||
.babel(
|
||||
[
|
||||
"node_modules/sortablejs/Sortable.min.js",
|
||||
|
||||
Reference in New Issue
Block a user