Save workspace changes
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
Import legacy News into Skinbase
|
||||
|
||||
1. Add these environment variables to your `.env` (or set in your environment):
|
||||
|
||||
- `LEGACY_DB_HOST`
|
||||
- `LEGACY_DB_PORT` (optional, default 3306)
|
||||
- `LEGACY_DB_DATABASE`
|
||||
- `LEGACY_DB_USERNAME`
|
||||
- `LEGACY_DB_PASSWORD`
|
||||
|
||||
2. Run a dry-run to preview what will be imported:
|
||||
|
||||
```
|
||||
php artisan news:import-legacy --dry-run --limit=100
|
||||
```
|
||||
|
||||
3. Run the actual import in batches:
|
||||
|
||||
```
|
||||
php artisan news:import-legacy --start=0 --limit=500
|
||||
php artisan news:import-legacy --start=500 --limit=500
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The importer maps legacy `news` fields conservatively. You should review and adapt the mapping in `app/Console/Commands/ImportLegacyNewsCommand.php` before running a full import.
|
||||
- Author and category mapping is best-effort (falls back to user id 1 and null category). Adjust mapping logic if you want to preserve authors/categories.
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
$rows = \Illuminate\Support\Facades\DB::select("SHOW FULL COLUMNS FROM artwork_comments");
|
||||
foreach ($rows as $r) {
|
||||
if (in_array($r->Field, ['content', 'raw_content', 'rendered_content'])) {
|
||||
echo $r->Field . ' => type=' . $r->Type . ' collation=' . ($r->Collation ?? 'NULL') . PHP_EOL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__ . '/../bootstrap/app.php';
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
$file = $argv[1] ?? 'fp';
|
||||
|
||||
echo "Checking translations for file: $file\n";
|
||||
|
||||
$translationsCount = DB::table('translations')->where('file', $file)->count();
|
||||
$translationsSample = DB::table('translations')->where('file', $file)->limit(5)->get();
|
||||
|
||||
$activeLanguages = DB::table('active_languages')->where('type', $file)->get();
|
||||
$activeLanguagesCount = $activeLanguages->count();
|
||||
|
||||
echo "translations.count = $translationsCount\n";
|
||||
echo "active_languages.count = $activeLanguagesCount\n";
|
||||
echo "active_languages rows:\n";
|
||||
foreach ($activeLanguages as $al) {
|
||||
echo " - iso={$al->iso} active={$al->active} type={$al->type}\n";
|
||||
}
|
||||
|
||||
echo "sample translations:\n";
|
||||
foreach ($translationsSample as $t) {
|
||||
echo " - key={$t->keycode} file={$t->file} value=" . substr($t->value,0,80) . "...\n";
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
try {
|
||||
$manager = extension_loaded('gd') ? \Intervention\Image\ImageManager::gd() : \Intervention\Image\ImageManager::imagick();
|
||||
echo 'configured: ' . (extension_loaded('gd') ? 'gd' : 'imagick') . PHP_EOL;
|
||||
} catch (\Throwable $e) {
|
||||
echo 'error: ' . $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
20
.deploy/artwork-evolution-release/scripts/check_redis.php
Normal file
20
.deploy/artwork-evolution-release/scripts/check_redis.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
try {
|
||||
$redis = Illuminate\Support\Facades\Redis::connection();
|
||||
$result = $redis->ping();
|
||||
$payload = is_object($result) && method_exists($result, 'getPayload') ? $result->getPayload() : $result;
|
||||
$ok = ($payload === 'PONG' || $result === true || $result === 1);
|
||||
echo 'Redis: ' . ($ok ? 'OK (PONG)' : 'UNEXPECTED: ' . var_export($result, true)) . PHP_EOL;
|
||||
echo 'Host: ' . config('database.redis.default.host') . ':' . config('database.redis.default.port') . PHP_EOL;
|
||||
echo 'Client: ' . config('database.redis.client') . PHP_EOL;
|
||||
|
||||
// Also check if the stats delta key has anything queued
|
||||
$depth = $redis->llen('artwork_stats:deltas');
|
||||
echo 'Delta queue depth (artwork_stats:deltas): ' . $depth . PHP_EOL;
|
||||
} catch (Exception $e) {
|
||||
echo 'FAILED: ' . $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
$rows = App\Models\ArtworkComment::where('artwork_id', 10)
|
||||
->whereNotNull('parent_id')
|
||||
->select('id', 'parent_id', 'content')
|
||||
->orderByDesc('id')
|
||||
->limit(15)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $r) {
|
||||
echo "id={$r->id} parent_id={$r->parent_id} content=" . mb_substr($r->content, 0, 40) . PHP_EOL;
|
||||
}
|
||||
|
||||
echo "\n--- Tree test (recursive eager-load) ---\n";
|
||||
$top = App\Models\ArtworkComment::with(['approvedReplies'])
|
||||
->where('id', 175742)
|
||||
->get();
|
||||
|
||||
function printTree($comments, $indent = 0) {
|
||||
foreach ($comments as $c) {
|
||||
$prefix = str_repeat(' ', $indent);
|
||||
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
|
||||
echo "{$prefix}[{$c->id}] " . mb_substr($c->content, 0, 40) . " ({$replies->count()} replies)\n";
|
||||
if ($replies->count()) {
|
||||
printTree($replies, $indent + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printTree($top);
|
||||
23
.deploy/artwork-evolution-release/scripts/check_stats.php
Normal file
23
.deploy/artwork-evolution-release/scripts/check_stats.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
$id = 69478;
|
||||
|
||||
$artwork = DB::table('artworks')->where('id', $id)->first();
|
||||
echo "Artwork: " . ($artwork ? $artwork->title : 'NOT FOUND') . PHP_EOL;
|
||||
|
||||
$stats = DB::table('artwork_stats')->where('artwork_id', $id)->first();
|
||||
echo "artwork_stats row: " . json_encode($stats) . PHP_EOL;
|
||||
|
||||
$viewEvents = DB::table('artwork_view_events')->where('artwork_id', $id)->count();
|
||||
echo "artwork_view_events count: " . $viewEvents . PHP_EOL;
|
||||
|
||||
// Check a few artworks that DO have stats
|
||||
$sample = DB::table('artwork_stats')->whereColumn('views', '>', 'id')->limit(3)->pluck('artwork_id');
|
||||
echo "Sample artworks with views > 0: " . json_encode($sample) . PHP_EOL;
|
||||
|
||||
// Count how many artworks_stats rows exist at all
|
||||
$total = DB::table('artwork_stats')->count();
|
||||
echo "Total artwork_stats rows: " . $total . PHP_EOL;
|
||||
35
.deploy/artwork-evolution-release/scripts/check_stats2.php
Normal file
35
.deploy/artwork-evolution-release/scripts/check_stats2.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require_once __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
$artworkId = 69478;
|
||||
|
||||
$stats = DB::table('artwork_stats')->where('artwork_id', $artworkId)->first();
|
||||
echo "artwork_stats row: " . json_encode($stats) . PHP_EOL;
|
||||
|
||||
$events = DB::table('artwork_view_events')->where('artwork_id', $artworkId)->count();
|
||||
echo "artwork_view_events for {$artworkId}: {$events}" . PHP_EOL;
|
||||
|
||||
$latest = DB::table('artwork_view_events')->latest('viewed_at')->take(5)->get(['artwork_id', 'viewed_at', 'session_hash']);
|
||||
echo "Latest view events (any artwork): " . json_encode($latest) . PHP_EOL;
|
||||
|
||||
// Check Redis queue depth
|
||||
try {
|
||||
$queueLen = Redis::llen('artwork_stats:deltas');
|
||||
echo "Redis artwork_stats:deltas queue length: {$queueLen}" . PHP_EOL;
|
||||
|
||||
if ($queueLen > 0) {
|
||||
$peek = Redis::lrange('artwork_stats:deltas', 0, 2);
|
||||
echo "First entries: " . json_encode($peek) . PHP_EOL;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
echo "Redis error: " . $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
|
||||
// Check artwork exists
|
||||
$artwork = DB::table('artworks')->where('id', $artworkId)->first(['id', 'title', 'status', 'user_id']);
|
||||
echo "Artwork: " . json_encode($artwork) . PHP_EOL;
|
||||
429
.deploy/artwork-evolution-release/scripts/deploy-production.sh
Normal file
429
.deploy/artwork-evolution-release/scripts/deploy-production.sh
Normal file
@@ -0,0 +1,429 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root_dir="$(cd -- "$script_dir/.." && pwd)"
|
||||
|
||||
local_folder="${LOCAL_FOLDER:-$root_dir}"
|
||||
remote_folder="${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}"
|
||||
remote_server="${REMOTE_SERVER:-klevze@server3.klevze.si}"
|
||||
php_bin="${PHP_BIN:-php}"
|
||||
composer_bin="${COMPOSER_BIN:-composer}"
|
||||
ssh_bin="${SSH_BIN:-ssh}"
|
||||
rsync_bin="${RSYNC_BIN:-rsync}"
|
||||
local_build_command="${LOCAL_BUILD_COMMAND:-}"
|
||||
|
||||
run_local_build=1
|
||||
run_remote_migrations=1
|
||||
run_db_sync=0
|
||||
run_meilisearch_setup=0
|
||||
auto_detect_meilisearch=1
|
||||
db_sync_source=""
|
||||
legacy_db_sync_mode=0
|
||||
force_db_sync=0
|
||||
skip_maintenance=0
|
||||
db_sync_confirm_target="${DB_SYNC_CONFIRM_TARGET:-}"
|
||||
db_sync_confirm_phrase="${DB_SYNC_CONFIRM_PHRASE:-}"
|
||||
meilisearch_models_csv=""
|
||||
readonly all_meilisearch_models_csv='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message'
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash sync.sh [options]
|
||||
|
||||
Options:
|
||||
--skip-build Skip local npm build before rsync.
|
||||
--skip-migrate Skip php artisan migrate on the server.
|
||||
--with-db-from=local Replace the production database with a dump from the local database.
|
||||
--confirm-db-sync-target HOST
|
||||
Must match the remote server name when running non-interactively.
|
||||
--confirm-db-sync-phrase TEXT
|
||||
Must equal 'replace production db from local' when running non-interactively.
|
||||
--with-db Legacy alias for --with-db-from=local.
|
||||
--force-db-sync Legacy extra confirmation flag for --with-db.
|
||||
--with-meilisearch Force Meilisearch settings sync and reimport all searchable models.
|
||||
--skip-meilisearch Skip Meilisearch refresh, including auto-detected refreshes.
|
||||
--no-maintenance Skip php artisan down/up during deploy.
|
||||
--help Show this help.
|
||||
|
||||
Environment overrides:
|
||||
LOCAL_FOLDER, REMOTE_FOLDER, REMOTE_SERVER, PHP_BIN, COMPOSER_BIN, SSH_BIN, RSYNC_BIN,
|
||||
LOCAL_BUILD_COMMAND, DB_SYNC_CONFIRM_TARGET, DB_SYNC_CONFIRM_PHRASE
|
||||
EOF
|
||||
}
|
||||
|
||||
confirm_database_replacement() {
|
||||
local expected_phrase="replace production db from local"
|
||||
local typed_target=""
|
||||
local typed_phrase=""
|
||||
|
||||
if [[ "$run_db_sync" -ne 1 || "$db_sync_source" != "local" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "WARNING: this will overwrite the production database on $remote_server using your local database dump."
|
||||
|
||||
if [[ -n "$db_sync_confirm_target" || -n "$db_sync_confirm_phrase" ]]; then
|
||||
if [[ "$db_sync_confirm_target" != "$remote_server" ]]; then
|
||||
echo "Refusing DB sync: --confirm-db-sync-target must exactly match $remote_server." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$db_sync_confirm_phrase" != "$expected_phrase" ]]; then
|
||||
echo "Refusing DB sync: --confirm-db-sync-phrase must exactly equal '$expected_phrase'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
echo "Refusing DB sync in non-interactive mode without --confirm-db-sync-target \"$remote_server\" and --confirm-db-sync-phrase \"$expected_phrase\"." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -p "Type the remote server to confirm DB replacement [$remote_server]: " typed_target
|
||||
if [[ "$typed_target" != "$remote_server" ]]; then
|
||||
echo "Refusing DB sync: remote server confirmation did not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r -p "Type '$expected_phrase' to continue: " typed_phrase
|
||||
if [[ "$typed_phrase" != "$expected_phrase" ]]; then
|
||||
echo "Refusing DB sync: confirmation phrase did not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
is_wsl() {
|
||||
[[ -n "${WSL_DISTRO_NAME:-}" || -n "${WSL_INTEROP:-}" ]]
|
||||
}
|
||||
|
||||
run_frontend_build() {
|
||||
if [[ -n "$local_build_command" ]]; then
|
||||
(
|
||||
cd "$local_folder"
|
||||
eval "$local_build_command"
|
||||
)
|
||||
return
|
||||
fi
|
||||
|
||||
if is_wsl && command -v wslpath >/dev/null 2>&1 && command -v powershell.exe >/dev/null 2>&1; then
|
||||
local windows_local_folder
|
||||
windows_local_folder="$(wslpath -w "$local_folder")"
|
||||
|
||||
echo "Detected WSL checkout; running frontend build with Windows npm.cmd to match local node_modules..."
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \
|
||||
"Set-Location -LiteralPath '$windows_local_folder'; npm.cmd run build"
|
||||
return
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$local_folder"
|
||||
npm run build
|
||||
)
|
||||
}
|
||||
|
||||
build_rsync_args() {
|
||||
rsync_args=(
|
||||
-rlvz
|
||||
--no-perms
|
||||
--no-times
|
||||
--omit-dir-times
|
||||
--delete
|
||||
--delete-delay
|
||||
--exclude ".phpintel/"
|
||||
--exclude "bootstrap/cache/"
|
||||
--exclude ".env"
|
||||
--exclude "public/hot"
|
||||
--exclude "node_modules"
|
||||
--exclude "public/files/"
|
||||
--exclude "resources/lang/"
|
||||
--exclude "storage/"
|
||||
--exclude ".git/"
|
||||
--exclude ".cursor/"
|
||||
--exclude ".venv/"
|
||||
--exclude "/var/php-tmp"
|
||||
--exclude "/oldSite"
|
||||
--exclude "/vendor"
|
||||
-e "$ssh_bin"
|
||||
)
|
||||
}
|
||||
|
||||
collect_sync_changed_files() {
|
||||
local itemized
|
||||
|
||||
if ! itemized="$($rsync_bin "${rsync_args[@]}" --dry-run --itemize-changes "$local_folder/" "$remote_server:$remote_folder/" 2>/dev/null)"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$itemized" | awk '
|
||||
/^deleting / {
|
||||
sub(/^deleting /, "", $0)
|
||||
if ($0 !~ /\/$/) print
|
||||
next
|
||||
}
|
||||
/^[<>ch.*][^ ]* / {
|
||||
path = $0
|
||||
sub(/^[^ ]+ /, "", path)
|
||||
if (path !~ /\/$/) print path
|
||||
}
|
||||
' | sed '/^$/d' | sort -u
|
||||
}
|
||||
|
||||
detect_meilisearch_models_from_sync() {
|
||||
local changed_files
|
||||
local file
|
||||
local force_full=0
|
||||
local -a models=()
|
||||
|
||||
if ! changed_files="$(collect_sync_changed_files)"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
[[ -n "$changed_files" ]] || return 1
|
||||
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
config/scout.php|app/Console/Commands/ConfigureMeilisearchIndex.php)
|
||||
force_full=1
|
||||
;;
|
||||
app/Models/Artwork.php)
|
||||
models+=("App\Models\Artwork")
|
||||
;;
|
||||
app/Models/User.php)
|
||||
models+=("App\Models\User")
|
||||
;;
|
||||
app/Models/Group.php)
|
||||
models+=("App\Models\Group")
|
||||
;;
|
||||
app/Models/Post.php)
|
||||
models+=("App\Models\Post")
|
||||
;;
|
||||
app/Models/Message.php)
|
||||
models+=("App\Models\Message")
|
||||
;;
|
||||
esac
|
||||
done <<< "$changed_files"
|
||||
|
||||
if [[ "$force_full" -eq 1 ]]; then
|
||||
printf '%s\n' "$all_meilisearch_models_csv"
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ ${#models[@]} -gt 0 ]] || return 1
|
||||
|
||||
printf '%s\n' "$(printf '%s\n' "${models[@]}" | awk '!seen[$0]++' | paste -sd, -)"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-build)
|
||||
run_local_build=0
|
||||
;;
|
||||
--skip-migrate)
|
||||
run_remote_migrations=0
|
||||
;;
|
||||
--with-db-from=local)
|
||||
run_db_sync=1
|
||||
db_sync_source="local"
|
||||
;;
|
||||
--with-db-from=*)
|
||||
echo "Unsupported DB sync source in option: $1" >&2
|
||||
echo "Only --with-db-from=local is supported." >&2
|
||||
exit 1
|
||||
;;
|
||||
--with-db)
|
||||
run_db_sync=1
|
||||
db_sync_source="local"
|
||||
legacy_db_sync_mode=1
|
||||
;;
|
||||
--force-db-sync)
|
||||
force_db_sync=1
|
||||
;;
|
||||
--confirm-db-sync-target)
|
||||
shift
|
||||
db_sync_confirm_target="${1:?Missing value for --confirm-db-sync-target}"
|
||||
;;
|
||||
--confirm-db-sync-target=*)
|
||||
db_sync_confirm_target="${1#*=}"
|
||||
;;
|
||||
--confirm-db-sync-phrase)
|
||||
shift
|
||||
db_sync_confirm_phrase="${1:?Missing value for --confirm-db-sync-phrase}"
|
||||
;;
|
||||
--confirm-db-sync-phrase=*)
|
||||
db_sync_confirm_phrase="${1#*=}"
|
||||
;;
|
||||
--with-meilisearch)
|
||||
run_meilisearch_setup=1
|
||||
auto_detect_meilisearch=0
|
||||
meilisearch_models_csv="$all_meilisearch_models_csv"
|
||||
;;
|
||||
--skip-meilisearch)
|
||||
run_meilisearch_setup=0
|
||||
auto_detect_meilisearch=0
|
||||
meilisearch_models_csv=""
|
||||
;;
|
||||
--no-maintenance)
|
||||
skip_maintenance=1
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ "$run_db_sync" -eq 1 && "$db_sync_source" != "local" ]]; then
|
||||
echo "Refusing DB sync without an explicit source. Use --with-db-from=local." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$run_db_sync" -eq 1 ]] && { [[ -z "$db_sync_confirm_target" && -n "$db_sync_confirm_phrase" ]] || [[ -n "$db_sync_confirm_target" && -z "$db_sync_confirm_phrase" ]]; }; then
|
||||
echo "Refusing DB sync: both --confirm-db-sync-target and --confirm-db-sync-phrase are required together when provided." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$legacy_db_sync_mode" -eq 1 && "$force_db_sync" -ne 1 ]]; then
|
||||
echo "Refusing legacy --with-db without --force-db-sync. Prefer --with-db-from=local instead." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$run_db_sync" -eq 1 ]]; then
|
||||
confirm_database_replacement
|
||||
fi
|
||||
|
||||
if [[ "$run_local_build" -eq 1 ]]; then
|
||||
echo "Building frontend assets locally..."
|
||||
run_frontend_build
|
||||
fi
|
||||
|
||||
build_rsync_args
|
||||
|
||||
if [[ "$run_meilisearch_setup" -eq 0 && "$auto_detect_meilisearch" -eq 1 ]]; then
|
||||
if meilisearch_models_csv="$(detect_meilisearch_models_from_sync)"; then
|
||||
run_meilisearch_setup=1
|
||||
echo "Detected Meilisearch-relevant changes in this deployment; will refresh indexes for: $meilisearch_models_csv"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Syncing application files to $remote_server..."
|
||||
"$rsync_bin" "${rsync_args[@]}" "$local_folder/" "$remote_server:$remote_folder/"
|
||||
|
||||
if [[ "$run_db_sync" -eq 1 ]]; then
|
||||
echo "Replacing the production database from the local dump..."
|
||||
"$script_dir/push-db-to-prod.sh" \
|
||||
--force \
|
||||
--remote-server "$remote_server" \
|
||||
--remote-folder "$remote_folder" \
|
||||
$( [[ "$run_remote_migrations" -eq 0 ]] && printf '%s' '--skip-migrate' )
|
||||
fi
|
||||
|
||||
echo "Running remote Composer and Artisan steps..."
|
||||
"$ssh_bin" "$remote_server" \
|
||||
REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \
|
||||
PHP_BIN="$(printf '%q' "$php_bin")" \
|
||||
COMPOSER_BIN="$(printf '%q' "$composer_bin")" \
|
||||
RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \
|
||||
SKIP_MAINTENANCE="$skip_maintenance" \
|
||||
RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \
|
||||
MEILISEARCH_MODELS_CSV="$(printf '%q' "$meilisearch_models_csv")" \
|
||||
'bash -s' <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
cd "$REMOTE_FOLDER"
|
||||
|
||||
ensure_php_runtime_dir() {
|
||||
local target_dir="$1"
|
||||
local -a privileged_cmd=()
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
privileged_cmd=(sudo -n)
|
||||
elif [[ "$(id -u)" -eq 0 ]]; then
|
||||
privileged_cmd=()
|
||||
fi
|
||||
|
||||
if [[ ! -d "$target_dir" ]]; then
|
||||
if [[ ${#privileged_cmd[@]} -gt 0 ]]; then
|
||||
"${privileged_cmd[@]}" mkdir -p "$target_dir"
|
||||
else
|
||||
mkdir -p "$target_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${#privileged_cmd[@]} -gt 0 || "$(id -u)" -eq 0 ]]; then
|
||||
"${privileged_cmd[@]}" chown -R skinbase:skinbase "$target_dir"
|
||||
"${privileged_cmd[@]}" chmod 770 "$target_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
chmod 770 "$target_dir" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
ensure_php_runtime_dir "$REMOTE_FOLDER/var/php-tmp"
|
||||
ensure_php_runtime_dir "$REMOTE_FOLDER/var/php-sessions"
|
||||
|
||||
bring_app_up() {
|
||||
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
|
||||
"$PHP_BIN" artisan up >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap bring_app_up EXIT
|
||||
|
||||
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
|
||||
"$PHP_BIN" artisan down --retry=60 || true
|
||||
fi
|
||||
|
||||
"$COMPOSER_BIN" install --no-dev --prefer-dist --optimize-autoloader --no-interaction
|
||||
|
||||
if [[ "$RUN_REMOTE_MIGRATIONS" -eq 1 ]]; then
|
||||
"$PHP_BIN" artisan migrate --force
|
||||
fi
|
||||
|
||||
"$PHP_BIN" artisan optimize:clear
|
||||
"$PHP_BIN" artisan optimize
|
||||
|
||||
if ! "$PHP_BIN" artisan homepage:warm-guest-cache; then
|
||||
echo "Warning: homepage guest cache warm failed during deploy." >&2
|
||||
fi
|
||||
|
||||
if ! "$PHP_BIN" artisan posts:warm-trending; then
|
||||
echo "Warning: post trending cache warm failed during deploy." >&2
|
||||
fi
|
||||
|
||||
"$PHP_BIN" artisan queue:restart || true
|
||||
"$PHP_BIN" artisan horizon:terminate || true
|
||||
|
||||
if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then
|
||||
if [[ -z "${MEILISEARCH_MODELS_CSV:-}" ]]; then
|
||||
MEILISEARCH_MODELS_CSV='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message'
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a meilisearch_models <<< "$MEILISEARCH_MODELS_CSV"
|
||||
|
||||
echo "Importing searchable models into Meilisearch (auto-creates indexes)..."
|
||||
for model in "${meilisearch_models[@]}"; do
|
||||
[[ -n "$model" ]] || continue
|
||||
echo " -> $model"
|
||||
"$PHP_BIN" artisan scout:import "$model"
|
||||
done
|
||||
echo "Syncing Meilisearch index settings..."
|
||||
"$PHP_BIN" artisan scout:sync-index-settings
|
||||
echo "Meilisearch setup complete."
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_MAINTENANCE" -eq 0 ]]; then
|
||||
"$PHP_BIN" artisan up
|
||||
trap - EXIT
|
||||
fi
|
||||
EOF
|
||||
|
||||
echo "Deployment complete."
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
// Fill CSV `Value` column from resources/lang files when available.
|
||||
$input = __DIR__ . '/../storage/app/translations_missing_admin_converted.csv';
|
||||
$output = __DIR__ . '/../storage/app/translations_missing_admin_converted_filled.csv';
|
||||
|
||||
if (!file_exists($input)) {
|
||||
fwrite(STDERR, "Input CSV not found: $input\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$in = fopen($input, 'r');
|
||||
$out = fopen($output, 'w');
|
||||
if (!$in || !$out) {
|
||||
fwrite(STDERR, "Failed to open input/output files.\n");
|
||||
exit(3);
|
||||
}
|
||||
|
||||
// Read header
|
||||
$headerLine = fgets($in);
|
||||
if ($headerLine === false) {
|
||||
fwrite(STDERR, "Empty CSV file.\n");
|
||||
exit(4);
|
||||
}
|
||||
// Normalize header and write it back
|
||||
fwrite($out, rtrim($headerLine, "\r\n") . PHP_EOL);
|
||||
|
||||
$langCache = [];
|
||||
$rowCount = 0;
|
||||
// Try to bootstrap Laravel to access DB translations if available
|
||||
$appBooted = false;
|
||||
try {
|
||||
if (file_exists(__DIR__ . '/../vendor/autoload.php') && file_exists(__DIR__ . '/../bootstrap/app.php')) {
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require_once __DIR__ . '/../bootstrap/app.php';
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
$appBooted = true;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Ignore bootstrap failures and continue with file-based filling
|
||||
$appBooted = false;
|
||||
}
|
||||
while (($line = fgets($in)) !== false) {
|
||||
// Parse semicolon-delimited CSV with double-quote enclosure
|
||||
$cols = str_getcsv(trim($line, "\n\r"), ';', '"');
|
||||
// Support both quoted and unquoted CSVs
|
||||
if (count($cols) < 4) {
|
||||
// pad if necessary
|
||||
$cols = array_pad($cols, 4, '');
|
||||
}
|
||||
$filename = trim($cols[0], ' "');
|
||||
$language = trim($cols[1], ' "');
|
||||
$keycode = trim($cols[2], ' "');
|
||||
$value = isset($cols[3]) ? $cols[3] : '';
|
||||
|
||||
// Only fill if empty
|
||||
if ($value === null || $value === '') {
|
||||
$langKey = $language . '::' . $filename;
|
||||
if (!isset($langCache[$langKey])) {
|
||||
$path = __DIR__ . "/../resources/lang/{$language}/{$filename}.php";
|
||||
if (file_exists($path)) {
|
||||
$data = include $path;
|
||||
if (!is_array($data)) $data = [];
|
||||
$langCache[$langKey] = $data;
|
||||
} else {
|
||||
$langCache[$langKey] = null; // mark missing
|
||||
}
|
||||
}
|
||||
$translations = $langCache[$langKey];
|
||||
if (is_array($translations) && array_key_exists($keycode, $translations)) {
|
||||
$value = $translations[$keycode];
|
||||
} elseif ($appBooted) {
|
||||
// Try DB translations using the Translation model if available
|
||||
try {
|
||||
$modelClass = '\\Klevze\\ControlPanel\\Models\\Content\\Translation';
|
||||
if (class_exists($modelClass)) {
|
||||
$rec = $modelClass::where('file', $filename)->where('keycode', $keycode)->first();
|
||||
if ($rec && isset($rec->value) && is_array($rec->value) && array_key_exists($language, $rec->value)) {
|
||||
$value = $rec->value[$language];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore DB issues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure value is string
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// Write row using semicolon delimiter and double quotes
|
||||
// fputcsv will escape quotes correctly
|
||||
fputcsv($out, [$filename, $language, $keycode, $value], ';', '"');
|
||||
$rowCount++;
|
||||
}
|
||||
|
||||
fclose($in);
|
||||
fclose($out);
|
||||
|
||||
fwrite(STDOUT, "Filled {$rowCount} rows. Output: {$output}\n");
|
||||
exit(0);
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Sets artworks.updated_at = artworks.created_at for every row where they differ.
|
||||
*
|
||||
* Usage (local):
|
||||
* php scripts/fix_artworks_updated_at.php [--dry-run]
|
||||
*
|
||||
* Usage (production via SSH):
|
||||
* ssh klevze@server3.klevze.si "cd /opt/www/virtual/SkinbaseNova && php scripts/fix_artworks_updated_at.php"
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
$options = getopt('', ['dry-run']);
|
||||
$isDryRun = array_key_exists('dry-run', $options);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// Count rows that need updating
|
||||
$total = DB::table('artworks')
|
||||
->whereColumn('updated_at', '!=', 'created_at')
|
||||
->orWhereNull('updated_at')
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
fwrite(STDOUT, "Nothing to do: all artworks already have updated_at = created_at.\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDOUT, sprintf("Rows to fix: %d%s\n", $total, $isDryRun ? ' (dry-run, no changes written)' : ''));
|
||||
|
||||
if ($isDryRun) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Single bulk UPDATE — fast even on large tables, uses the existing index on created_at
|
||||
$affected = DB::statement('UPDATE artworks SET updated_at = created_at WHERE updated_at != created_at OR updated_at IS NULL');
|
||||
|
||||
fwrite(STDOUT, sprintf("Done. updated_at synced to created_at for %d rows.\n", $total));
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function printSection($title) {
|
||||
echo "\n===== $title =====\n";
|
||||
}
|
||||
|
||||
printSection('Recent queued jobs (jobs table)');
|
||||
$jobs = DB::table('jobs')->where('payload', 'like', '%AutoTagArtworkJob%')->orderByDesc('id')->limit(10)->get();
|
||||
if (count($jobs) === 0) {
|
||||
echo "No queued AutoTagArtworkJob entries found.\n";
|
||||
} else {
|
||||
foreach ($jobs as $j) {
|
||||
echo "--- job id: {$j->id} | queued_at: " . ($j->available_at ?? $j->created_at ?? '') . "\n";
|
||||
$payload = isset($j->payload) ? $j->payload : '';
|
||||
echo substr($payload, 0, 2000) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
printSection('Recent failed jobs (failed_jobs table)');
|
||||
$failed = DB::table('failed_jobs')->where('payload', 'like', '%AutoTagArtworkJob%')->orderByDesc('id')->limit(10)->get();
|
||||
if (count($failed) === 0) {
|
||||
echo "No failed AutoTagArtworkJob entries found.\n";
|
||||
} else {
|
||||
foreach ($failed as $f) {
|
||||
echo "--- failed id: {$f->id} | connection: {$f->connection} | failed_at: {$f->failed_at}\n";
|
||||
$payload = isset($f->payload) ? $f->payload : '';
|
||||
echo substr($payload, 0, 2000) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
printSection('Recent Laravel logs (last 200 lines)');
|
||||
$logs = glob(__DIR__ . '/../storage/logs/*.log');
|
||||
if ($logs === false || count($logs) === 0) {
|
||||
echo "No log files found.\n";
|
||||
} else {
|
||||
usort($logs, function($a, $b){ return filemtime($b) <=> filemtime($a); });
|
||||
$latest = $logs[0];
|
||||
echo "Latest log: " . basename($latest) . "\n\n";
|
||||
$lines = file($latest, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
echo "Unable to read log file.\n";
|
||||
} else {
|
||||
$tail = array_slice($lines, -200);
|
||||
foreach ($tail as $line) {
|
||||
echo $line . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nDone.\n";
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
$cols = \Illuminate\Support\Facades\DB::connection('legacy')->select('DESCRIBE users');
|
||||
foreach ($cols as $c) {
|
||||
echo $c->Field . ' | ' . $c->Type . ' | null=' . $c->Null . ' | default=' . ($c->Default ?? 'NULL') . PHP_EOL;
|
||||
}
|
||||
|
||||
echo PHP_EOL . '-- Sample row --' . PHP_EOL;
|
||||
$row = \Illuminate\Support\Facades\DB::connection('legacy')->table('users')->where('user_id', 53000)->first();
|
||||
if ($row) {
|
||||
foreach ((array)$row as $k => $v) {
|
||||
echo $k . ': ' . $v . PHP_EOL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
// Usage: php scripts/populate_sl_translations.php [in-file] [out-file]
|
||||
// Default input: storage/app/translations_missing_admin.csv
|
||||
// This script will call LibreTranslate (https://libretranslate.com) to translate
|
||||
// an English guess of each key into Slovenian and update the CSV's suggested_sl column.
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$in = $argv[1] ?? $root . '/storage/app/translations_missing_admin.csv';
|
||||
$out = $argv[2] ?? $in; // overwrite by default
|
||||
|
||||
if (!file_exists($in)) {
|
||||
echo "Input CSV not found: $in\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$backup = $in . '.bak.' . date('YmdHis');
|
||||
copy($in, $backup);
|
||||
echo "Backup written to: $backup\n";
|
||||
|
||||
$rows = [];
|
||||
$fh = fopen($in, 'r');
|
||||
$header = fgetcsv($fh);
|
||||
if ($header === false) {
|
||||
echo "Empty CSV\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
while (($data = fgetcsv($fh)) !== false) {
|
||||
$rows[] = $data;
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
$libreUrl = 'https://libretranslate.com/translate';
|
||||
|
||||
function translateText($text, $url)
|
||||
{
|
||||
$payload = json_encode([
|
||||
'q' => $text,
|
||||
'source' => 'en',
|
||||
'target' => 'sl',
|
||||
'format' => 'text'
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$resp = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err) {
|
||||
throw new Exception('Curl error: ' . $err);
|
||||
}
|
||||
if ($code < 200 || $code >= 300) {
|
||||
throw new Exception('HTTP ' . $code . ' response: ' . $resp);
|
||||
}
|
||||
|
||||
$json = json_decode($resp, true);
|
||||
return $json['translatedText'] ?? null;
|
||||
}
|
||||
|
||||
echo "Processing " . count($rows) . " rows...\n";
|
||||
|
||||
$i = 0;
|
||||
foreach ($rows as &$r) {
|
||||
$i++;
|
||||
// CSV columns: file,keycode,suggested_sl,suggested_en,placeholder
|
||||
$file = $r[0] ?? '';
|
||||
$keycode = $r[1] ?? '';
|
||||
$s_sl = $r[2] ?? '';
|
||||
$s_en = $r[3] ?? '';
|
||||
|
||||
if (trim($s_sl) !== '') {
|
||||
// already populated
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build an English humanized guess from keycode if suggested_en is empty
|
||||
if (trim($s_en) !== '') {
|
||||
$source = $s_en;
|
||||
} else {
|
||||
$source = strtolower(str_replace('_', ' ', $keycode));
|
||||
// Make it sentence-like: capitalize first letter and replace multiple spaces
|
||||
$source = preg_replace('/\s+/', ' ', $source);
|
||||
$source = ucfirst($source);
|
||||
}
|
||||
|
||||
try {
|
||||
$translated = translateText($source, $libreUrl);
|
||||
if ($translated === null) {
|
||||
$translated = '';
|
||||
}
|
||||
$r[2] = $translated;
|
||||
echo "[{$i}] OK: {$keycode} -> {$translated}\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "[{$i}] WARN: {$keycode} -> " . $e->getMessage() . "\n";
|
||||
// leave blank on error
|
||||
}
|
||||
|
||||
// Rate limit: sleep 0.5s
|
||||
usleep(500000);
|
||||
}
|
||||
unset($r);
|
||||
|
||||
// Write out CSV
|
||||
$ofh = fopen($out, 'w');
|
||||
fputcsv($ofh, $header);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($ofh, $row);
|
||||
}
|
||||
fclose($ofh);
|
||||
|
||||
echo "Wrote updated CSV to: $out\n";
|
||||
|
||||
exit(0);
|
||||
284
.deploy/artwork-evolution-release/scripts/push-db-to-prod.sh
Normal file
284
.deploy/artwork-evolution-release/scripts/push-db-to-prod.sh
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root_dir="$(cd -- "$script_dir/.." && pwd)"
|
||||
|
||||
local_folder="${LOCAL_FOLDER:-$root_dir}"
|
||||
local_env_file="${LOCAL_ENV_FILE:-$local_folder/.env}"
|
||||
remote_folder="${REMOTE_FOLDER:-/opt/www/virtual/SkinbaseNova}"
|
||||
remote_server="${REMOTE_SERVER:-klevze@server3.klevze.si}"
|
||||
remote_env_file="${REMOTE_ENV_FILE:-$remote_folder/.env}"
|
||||
php_bin="${PHP_BIN:-php}"
|
||||
ssh_bin="${SSH_BIN:-ssh}"
|
||||
scp_bin="${SCP_BIN:-scp}"
|
||||
local_mysqldump_command="${LOCAL_MYSQLDUMP_COMMAND:-}"
|
||||
|
||||
force_replace=0
|
||||
skip_remote_backup=0
|
||||
run_remote_migrate=1
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash scripts/push-db-to-prod.sh --force [options]
|
||||
|
||||
Options:
|
||||
--force Required. Confirms that production DB replacement is intentional.
|
||||
--skip-remote-backup Skip taking a production backup before import.
|
||||
--skip-migrate Skip php artisan migrate after import.
|
||||
--remote-server HOST Override the SSH destination.
|
||||
--remote-folder PATH Override the remote app path.
|
||||
--local-env-file PATH Override the local Laravel .env path.
|
||||
--remote-env-file PATH Override the remote Laravel .env path.
|
||||
--help Show this help.
|
||||
|
||||
This script replaces the remote application database with a dump created from the local database.
|
||||
EOF
|
||||
}
|
||||
|
||||
is_wsl() {
|
||||
[[ -n "${WSL_DISTRO_NAME:-}" || -n "${WSL_INTEROP:-}" ]]
|
||||
}
|
||||
|
||||
use_windows_local_mysql_client() {
|
||||
if [[ -n "$local_mysqldump_command" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! is_wsl; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$local_db_host" != "127.0.0.1" && "$local_db_host" != "localhost" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_local_dump() {
|
||||
local uncompressed_dump_file="${local_dump_file%.gz}"
|
||||
|
||||
if [[ -n "$local_mysqldump_command" ]]; then
|
||||
echo "Creating local database dump with LOCAL_MYSQLDUMP_COMMAND..."
|
||||
(
|
||||
cd "$local_folder"
|
||||
eval "$local_mysqldump_command"
|
||||
) | gzip > "$local_dump_file"
|
||||
return
|
||||
fi
|
||||
|
||||
if use_windows_local_mysql_client; then
|
||||
local windows_dump_file
|
||||
local escaped_password
|
||||
local escaped_host
|
||||
local escaped_port
|
||||
local escaped_user
|
||||
local escaped_name
|
||||
|
||||
windows_dump_file="$(wslpath -w "$uncompressed_dump_file")"
|
||||
escaped_password="${local_db_password//\'/\'\'}"
|
||||
escaped_host="${local_db_host//\'/\'\'}"
|
||||
escaped_port="${local_db_port//\'/\'\'}"
|
||||
escaped_user="${local_db_user//\'/\'\'}"
|
||||
escaped_name="${local_db_name//\'/\'\'}"
|
||||
|
||||
echo "Detected WSL with a Windows-local MySQL host; running local dump with Windows mysqldump.exe..."
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \
|
||||
"\$ErrorActionPreference = 'Stop'; \
|
||||
\$mysqldump = (Get-Command mysqldump.exe).Source; \
|
||||
if (-not \$mysqldump) { throw 'mysqldump.exe not found on Windows PATH.' } \
|
||||
\$env:MYSQL_PWD = '$escaped_password'; \
|
||||
\$arguments = @('--host=$escaped_host', '--port=$escaped_port', '--user=$escaped_user', '--single-transaction', '--quick', '--routines', '--triggers', '--hex-blob', '--no-tablespaces', '--default-character-set=utf8mb4', '$escaped_name'); \
|
||||
\$process = Start-Process -FilePath \$mysqldump -ArgumentList \$arguments -NoNewWindow -RedirectStandardOutput '$windows_dump_file' -Wait -PassThru; \
|
||||
if (\$process.ExitCode -ne 0) { exit \$process.ExitCode }"
|
||||
|
||||
gzip -f "$uncompressed_dump_file"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Creating local database dump from $local_db_name..."
|
||||
MYSQL_PWD="$local_db_password" mysqldump \
|
||||
--host="$local_db_host" \
|
||||
--port="$local_db_port" \
|
||||
--user="$local_db_user" \
|
||||
--single-transaction \
|
||||
--quick \
|
||||
--routines \
|
||||
--triggers \
|
||||
--hex-blob \
|
||||
--no-tablespaces \
|
||||
--default-character-set=utf8mb4 \
|
||||
"$local_db_name" | gzip > "$local_dump_file"
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local file="$2"
|
||||
local line
|
||||
|
||||
line="$(grep -E "^${key}=" "$file" | tail -n 1 || true)"
|
||||
line="${line#*=}"
|
||||
line="${line%$'\r'}"
|
||||
line="${line#\"}"
|
||||
line="${line%\"}"
|
||||
line="${line#\'}"
|
||||
line="${line%\'}"
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--force)
|
||||
force_replace=1
|
||||
;;
|
||||
--skip-remote-backup)
|
||||
skip_remote_backup=1
|
||||
;;
|
||||
--skip-migrate)
|
||||
run_remote_migrate=0
|
||||
;;
|
||||
--remote-server)
|
||||
shift
|
||||
remote_server="${1:?Missing value for --remote-server}"
|
||||
;;
|
||||
--remote-folder)
|
||||
shift
|
||||
remote_folder="${1:?Missing value for --remote-folder}"
|
||||
remote_env_file="$remote_folder/.env"
|
||||
;;
|
||||
--local-env-file)
|
||||
shift
|
||||
local_env_file="${1:?Missing value for --local-env-file}"
|
||||
;;
|
||||
--remote-env-file)
|
||||
shift
|
||||
remote_env_file="${1:?Missing value for --remote-env-file}"
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ "$force_replace" -ne 1 ]]; then
|
||||
echo "Refusing to replace the production database without --force." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$local_env_file" ]]; then
|
||||
echo "Local env file not found: $local_env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local_db_host="$(read_env_value DB_HOST "$local_env_file")"
|
||||
local_db_port="$(read_env_value DB_PORT "$local_env_file")"
|
||||
local_db_name="$(read_env_value DB_DATABASE "$local_env_file")"
|
||||
local_db_user="$(read_env_value DB_USERNAME "$local_env_file")"
|
||||
local_db_password="$(read_env_value DB_PASSWORD "$local_env_file")"
|
||||
|
||||
if [[ -z "$local_db_host" || -z "$local_db_port" || -z "$local_db_name" || -z "$local_db_user" ]]; then
|
||||
echo "Local database settings are incomplete in $local_env_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local_dump_file="$(mktemp "${TMPDIR:-/tmp}/skinbase-prod-sync-XXXXXX.sql.gz")"
|
||||
remote_dump_file="/tmp/$(basename "$local_dump_file")"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$local_dump_file"
|
||||
rm -f "${local_dump_file%.gz}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
run_local_dump
|
||||
|
||||
echo "Uploading dump to $remote_server..."
|
||||
"$scp_bin" "$local_dump_file" "$remote_server:$remote_dump_file"
|
||||
|
||||
echo "Importing dump on the production server..."
|
||||
"$ssh_bin" "$remote_server" \
|
||||
REMOTE_FOLDER="$(printf '%q' "$remote_folder")" \
|
||||
REMOTE_ENV_FILE="$(printf '%q' "$remote_env_file")" \
|
||||
REMOTE_DUMP_FILE="$(printf '%q' "$remote_dump_file")" \
|
||||
PHP_BIN="$(printf '%q' "$php_bin")" \
|
||||
SKIP_REMOTE_BACKUP="$skip_remote_backup" \
|
||||
RUN_REMOTE_MIGRATE="$run_remote_migrate" \
|
||||
'bash -s' <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local file="$2"
|
||||
local line
|
||||
|
||||
line="$(grep -E "^${key}=" "$file" | tail -n 1 || true)"
|
||||
line="${line#*=}"
|
||||
line="${line%$'\r'}"
|
||||
line="${line#\"}"
|
||||
line="${line%\"}"
|
||||
line="${line#\'}"
|
||||
line="${line%\'}"
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
if [[ ! -f "$REMOTE_ENV_FILE" ]]; then
|
||||
echo "Remote env file not found: $REMOTE_ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
db_host="$(read_env_value DB_HOST "$REMOTE_ENV_FILE")"
|
||||
db_port="$(read_env_value DB_PORT "$REMOTE_ENV_FILE")"
|
||||
db_name="$(read_env_value DB_DATABASE "$REMOTE_ENV_FILE")"
|
||||
db_user="$(read_env_value DB_USERNAME "$REMOTE_ENV_FILE")"
|
||||
db_password="$(read_env_value DB_PASSWORD "$REMOTE_ENV_FILE")"
|
||||
|
||||
if [[ -z "$db_host" || -z "$db_port" || -z "$db_name" || -z "$db_user" ]]; then
|
||||
echo "Remote database settings are incomplete in $REMOTE_ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_REMOTE_BACKUP" -eq 0 ]]; then
|
||||
backup_dir="$REMOTE_FOLDER/storage/app/deploy-backups"
|
||||
backup_file="$backup_dir/prod-before-sync-$(date +%Y%m%d-%H%M%S).sql.gz"
|
||||
mkdir -p "$backup_dir"
|
||||
|
||||
echo "Creating production backup at $backup_file..."
|
||||
MYSQL_PWD="$db_password" mysqldump \
|
||||
--host="$db_host" \
|
||||
--port="$db_port" \
|
||||
--user="$db_user" \
|
||||
--single-transaction \
|
||||
--quick \
|
||||
--routines \
|
||||
--triggers \
|
||||
--hex-blob \
|
||||
--no-tablespaces \
|
||||
--default-character-set=utf8mb4 \
|
||||
"$db_name" | gzip > "$backup_file"
|
||||
fi
|
||||
|
||||
echo "Replacing production database $db_name..."
|
||||
gunzip -c "$REMOTE_DUMP_FILE" | MYSQL_PWD="$db_password" mysql \
|
||||
--host="$db_host" \
|
||||
--port="$db_port" \
|
||||
--user="$db_user" \
|
||||
"$db_name"
|
||||
|
||||
rm -f "$REMOTE_DUMP_FILE"
|
||||
|
||||
if [[ "$RUN_REMOTE_MIGRATE" -eq 1 ]]; then
|
||||
cd "$REMOTE_FOLDER"
|
||||
"$PHP_BIN" artisan migrate --force
|
||||
fi
|
||||
EOF
|
||||
|
||||
echo "Production database sync complete."
|
||||
@@ -0,0 +1,96 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* render-nova-card.cjs
|
||||
*
|
||||
* Headless Playwright screenshot renderer for Nova Cards.
|
||||
* Visits the signed render-frame URL, waits for React + fonts to finish,
|
||||
* then screenshots the [data-card-canvas] element and writes a PNG to disk.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/render-nova-card.cjs \
|
||||
* --url=<signed-render-frame-url> \
|
||||
* --out=<absolute-path-to-output.png> \
|
||||
* [--width=1080] [--height=1080]
|
||||
*
|
||||
* Exit codes: 0 = success, 1 = error (message on stderr).
|
||||
* On success writes JSON to stdout: { "success": true, "out": "...", "width": N, "height": N }
|
||||
*/
|
||||
|
||||
const { chromium } = require('@playwright/test')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// ── Parse CLI args ────────────────────────────────────────────────────────────
|
||||
const argv = Object.fromEntries(
|
||||
process.argv.slice(2)
|
||||
.filter((a) => a.startsWith('--'))
|
||||
.map((a) => {
|
||||
const eq = a.indexOf('=')
|
||||
return eq === -1 ? [a.slice(2), true] : [a.slice(2, eq), a.slice(eq + 1)]
|
||||
}),
|
||||
)
|
||||
|
||||
const { url, out } = argv
|
||||
const width = parseInt(argv.width || '1080', 10)
|
||||
const height = parseInt(argv.height || '1080', 10)
|
||||
|
||||
if (!url || !out) {
|
||||
process.stderr.write(
|
||||
'Usage: render-nova-card.cjs --url=<url> --out=<png-path> [--width=1080] [--height=1080]\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height },
|
||||
deviceScaleFactor: 1,
|
||||
})
|
||||
|
||||
// Navigate and wait for network to settle (background images, fonts).
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 })
|
||||
|
||||
// Wait for React to finish painting the canvas element.
|
||||
const canvas = page.locator('[data-card-canvas]').first()
|
||||
await canvas.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
|
||||
// Wait for all web fonts to finish loading before we capture layout.
|
||||
await page.evaluate(async () => {
|
||||
const requiredFonts = [
|
||||
'Anton',
|
||||
'Caveat',
|
||||
'Cormorant Garamond',
|
||||
'Inter',
|
||||
'Libre Franklin',
|
||||
'Playfair Display',
|
||||
]
|
||||
|
||||
await Promise.all(requiredFonts.map((family) => document.fonts?.load?.(`16px "${family}"`) || Promise.resolve()))
|
||||
|
||||
if (document.fonts?.ready) {
|
||||
await document.fonts.ready
|
||||
}
|
||||
})
|
||||
|
||||
// One extra frame so CSS transforms / container queries resolve fully.
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Screenshot only the canvas element; Playwright clips to the element bounding box.
|
||||
const png = await canvas.screenshot({ type: 'png' })
|
||||
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true })
|
||||
fs.writeFileSync(out, png)
|
||||
|
||||
process.stdout.write(JSON.stringify({ success: true, out, width, height }) + '\n')
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
})().catch((err) => {
|
||||
process.stderr.write((err?.message ?? String(err)) + '\n')
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
$options = getopt('', ['id::', 'limit::', 'publish']);
|
||||
$id = isset($options['id']) ? (int) $options['id'] : null;
|
||||
$limit = isset($options['limit']) ? max(1, (int) $options['limit']) : 25;
|
||||
$publish = array_key_exists('publish', $options);
|
||||
|
||||
$brokenQuery = Artwork::query()->where(function ($query): void {
|
||||
$query->where('file_path', 'pending')
|
||||
->orWhereNull('hash')
|
||||
->orWhereNull('file_ext')
|
||||
->orWhereNull('thumb_ext')
|
||||
->orWhere('file_name', 'pending')
|
||||
->orWhere('file_size', 0)
|
||||
->orWhere('width', '<=', 1)
|
||||
->orWhere('height', '<=', 1);
|
||||
});
|
||||
|
||||
if ($id && $id > 0) {
|
||||
$brokenQuery->whereKey($id);
|
||||
}
|
||||
|
||||
$rows = $brokenQuery->orderBy('id')->limit($limit)->get();
|
||||
if ($rows->isEmpty()) {
|
||||
fwrite(STDOUT, "No matching broken artworks found.\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$storageRoot = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
|
||||
$fixed = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$makeUniqueSlug = static function (Artwork $artwork, string $title): string {
|
||||
$base = Str::slug($title);
|
||||
if ($base === '') {
|
||||
$base = 'artwork-' . $artwork->id;
|
||||
}
|
||||
|
||||
$slug = $base;
|
||||
$suffix = 2;
|
||||
while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) {
|
||||
$slug = $base . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
};
|
||||
|
||||
foreach ($rows as $artwork) {
|
||||
$files = DB::table('artwork_files')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->get(['variant', 'path', 'mime', 'size'])
|
||||
->keyBy('variant');
|
||||
|
||||
$orig = $files->get('orig');
|
||||
if (! $orig || empty($orig->path)) {
|
||||
fwrite(STDOUT, "[SKIP] {$artwork->id}: no orig variant in artwork_files\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$origPath = (string) $orig->path;
|
||||
$absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\\\'], DIRECTORY_SEPARATOR, $origPath);
|
||||
|
||||
$hash = null;
|
||||
$fileSize = (int) ($orig->size ?? 0);
|
||||
$width = (int) $artwork->width;
|
||||
$height = (int) $artwork->height;
|
||||
|
||||
if (is_file($absolute)) {
|
||||
$hash = hash_file('sha256', $absolute) ?: null;
|
||||
$actualSize = @filesize($absolute);
|
||||
if (is_int($actualSize) && $actualSize > 0) {
|
||||
$fileSize = $actualSize;
|
||||
}
|
||||
|
||||
$dimensions = @getimagesize($absolute);
|
||||
if (is_array($dimensions) && isset($dimensions[0], $dimensions[1])) {
|
||||
$width = max(1, (int) $dimensions[0]);
|
||||
$height = max(1, (int) $dimensions[1]);
|
||||
}
|
||||
}
|
||||
|
||||
$ext = strtolower((string) pathinfo($origPath, PATHINFO_EXTENSION));
|
||||
if ($ext === '') {
|
||||
$ext = 'webp';
|
||||
}
|
||||
|
||||
$title = trim((string) ($artwork->title ?? ''));
|
||||
if ($title === '') {
|
||||
$title = 'Artwork ' . $artwork->id;
|
||||
}
|
||||
|
||||
$slug = $makeUniqueSlug($artwork, $title);
|
||||
|
||||
$updates = [
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
'file_name' => basename($origPath),
|
||||
'file_path' => $origPath,
|
||||
'file_size' => max(1, $fileSize),
|
||||
'mime_type' => (string) ($orig->mime ?: 'image/webp'),
|
||||
'hash' => $hash ?: (string) ($artwork->hash ?? ''),
|
||||
'file_ext' => $ext,
|
||||
'thumb_ext' => $ext,
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
];
|
||||
|
||||
if ($publish) {
|
||||
$updates['is_public'] = true;
|
||||
$updates['is_approved'] = true;
|
||||
$updates['published_at'] = $artwork->published_at ?: now();
|
||||
}
|
||||
|
||||
if (empty($updates['hash'])) {
|
||||
fwrite(STDOUT, "[SKIP] {$artwork->id}: hash could not be recovered from file\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Artwork::query()->whereKey($artwork->id)->update($updates);
|
||||
fwrite(STDOUT, "[FIXED] {$artwork->id}: {$updates['file_path']} | hash=" . substr((string) $updates['hash'], 0, 12) . "...\n");
|
||||
$fixed++;
|
||||
}
|
||||
|
||||
fwrite(STDOUT, "Done. fixed={$fixed} skipped={$skipped}\n");
|
||||
exit(0);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
|
||||
// Test on a single comment first
|
||||
$c = ArtworkComment::find(175742);
|
||||
if (!$c) { echo "Comment not found\n"; exit(1); }
|
||||
|
||||
echo "raw_content: " . $c->raw_content . PHP_EOL;
|
||||
echo "old rendered: " . $c->rendered_content . PHP_EOL;
|
||||
|
||||
$new = ContentSanitizer::render($c->raw_content);
|
||||
echo "new rendered: " . $new . PHP_EOL;
|
||||
|
||||
// Now re-render all
|
||||
$count = 0;
|
||||
$errors = 0;
|
||||
ArtworkComment::whereNotNull('raw_content')
|
||||
->where('raw_content', '!=', '')
|
||||
->chunk(100, function ($comments) use (&$count, &$errors) {
|
||||
foreach ($comments as $c) {
|
||||
try {
|
||||
$c->rendered_content = ContentSanitizer::render($c->raw_content);
|
||||
$c->timestamps = false;
|
||||
$c->saveQuietly();
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
echo "Error on comment #{$c->id}: " . $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
echo "Re-rendered {$count} comments ({$errors} errors)." . PHP_EOL;
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
$app = require __DIR__ . '/../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
$options = getopt('', ['id::', 'limit::']);
|
||||
$id = isset($options['id']) ? (int) $options['id'] : null;
|
||||
$limit = isset($options['limit']) ? max(1, (int) $options['limit']) : 5;
|
||||
|
||||
$query = Artwork::query()->orderByDesc('id');
|
||||
if ($id && $id > 0) {
|
||||
$query->whereKey($id);
|
||||
}
|
||||
|
||||
$rows = $query->limit($limit)->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
fwrite(STDOUT, "No artwork rows found.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$requiredFields = [
|
||||
'title',
|
||||
'slug',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'hash',
|
||||
'file_ext',
|
||||
'thumb_ext',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
$hasFailure = false;
|
||||
|
||||
foreach ($rows as $artwork) {
|
||||
$missing = [];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
$value = $artwork->{$field};
|
||||
if ($value === null || $value === '' || $value === 'pending') {
|
||||
$missing[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if (! (bool) $artwork->is_public) {
|
||||
$missing[] = 'is_public';
|
||||
}
|
||||
|
||||
if (! (bool) $artwork->is_approved) {
|
||||
$missing[] = 'is_approved';
|
||||
}
|
||||
|
||||
if ((int) $artwork->width <= 1) {
|
||||
$missing[] = 'width';
|
||||
}
|
||||
|
||||
if ((int) $artwork->height <= 1) {
|
||||
$missing[] = 'height';
|
||||
}
|
||||
|
||||
$publishedAt = $artwork->published_at instanceof Carbon
|
||||
? $artwork->published_at->toDateTimeString()
|
||||
: (string) $artwork->published_at;
|
||||
|
||||
fwrite(STDOUT, str_repeat('-', 72) . "\n");
|
||||
fwrite(STDOUT, "artwork_id: {$artwork->id}\n");
|
||||
fwrite(STDOUT, "title : " . (string) $artwork->title . "\n");
|
||||
fwrite(STDOUT, "slug : " . (string) $artwork->slug . "\n");
|
||||
fwrite(STDOUT, "file_path : " . (string) $artwork->file_path . "\n");
|
||||
fwrite(STDOUT, "hash : " . (string) $artwork->hash . "\n");
|
||||
fwrite(STDOUT, "file_ext : " . (string) $artwork->file_ext . " | thumb_ext: " . (string) $artwork->thumb_ext . "\n");
|
||||
fwrite(STDOUT, "visible : is_public=" . ((int) (bool) $artwork->is_public) . " is_approved=" . ((int) (bool) $artwork->is_approved) . "\n");
|
||||
fwrite(STDOUT, "published : " . ($publishedAt !== '' ? $publishedAt : 'NULL') . "\n");
|
||||
|
||||
if ($missing !== []) {
|
||||
$hasFailure = true;
|
||||
fwrite(STDOUT, "status : FAIL (missing/invalid: " . implode(', ', array_unique($missing)) . ")\n");
|
||||
} else {
|
||||
fwrite(STDOUT, "status : OK\n");
|
||||
}
|
||||
}
|
||||
|
||||
fwrite(STDOUT, str_repeat('-', 72) . "\n");
|
||||
if ($hasFailure) {
|
||||
fwrite(STDOUT, "Result: FAIL\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
fwrite(STDOUT, "Result: OK\n");
|
||||
exit(0);
|
||||
237
.deploy/artwork-evolution-release/scripts/vision-smoke.ps1
Normal file
237
.deploy/artwork-evolution-release/scripts/vision-smoke.ps1
Normal file
@@ -0,0 +1,237 @@
|
||||
param(
|
||||
[string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot ".." )).Path,
|
||||
[string]$EnvFile = ".env",
|
||||
[string]$SampleImageUrl = "https://files.skinbase.org/img/aa/bb/cc/md.webp",
|
||||
[switch]$SkipAnalyze
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Info([string]$Message) {
|
||||
Write-Host "[INFO] $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Message) {
|
||||
Write-Host "[OK] $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Fail([string]$Message) {
|
||||
Write-Host "[FAIL] $Message" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Get-EnvMap([string]$Root, [string]$RelativeEnvFile) {
|
||||
$map = @{}
|
||||
|
||||
$envPath = Join-Path $Root $RelativeEnvFile
|
||||
if (-not (Test-Path $envPath)) {
|
||||
$fallback = Join-Path $Root ".env.example"
|
||||
if (Test-Path $fallback) {
|
||||
Write-Info "Env file '$RelativeEnvFile' not found. Falling back to .env.example."
|
||||
$envPath = $fallback
|
||||
} else {
|
||||
throw "Neither '$RelativeEnvFile' nor '.env.example' was found in $Root"
|
||||
}
|
||||
}
|
||||
|
||||
Get-Content -Path $envPath | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line -eq "" -or $line.StartsWith("#")) { return }
|
||||
$idx = $line.IndexOf("=")
|
||||
if ($idx -lt 1) { return }
|
||||
|
||||
$key = $line.Substring(0, $idx).Trim()
|
||||
$val = $line.Substring($idx + 1).Trim()
|
||||
|
||||
if ($val.StartsWith('"') -and $val.EndsWith('"') -and $val.Length -ge 2) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
if (-not $map.ContainsKey($key)) {
|
||||
$map[$key] = $val
|
||||
}
|
||||
}
|
||||
|
||||
return $map
|
||||
}
|
||||
|
||||
function Get-Setting([hashtable]$Map, [string]$Key, [string]$Default = "") {
|
||||
$fromProcess = [Environment]::GetEnvironmentVariable($Key)
|
||||
if (-not [string]::IsNullOrWhiteSpace($fromProcess)) {
|
||||
return $fromProcess.Trim()
|
||||
}
|
||||
|
||||
if ($Map.ContainsKey($Key)) {
|
||||
return [string]$Map[$Key]
|
||||
}
|
||||
|
||||
return $Default
|
||||
}
|
||||
|
||||
function Test-Truthy([string]$Value, [bool]$Default = $false) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return $Default }
|
||||
|
||||
switch ($Value.Trim().ToLowerInvariant()) {
|
||||
"1" { return $true }
|
||||
"true" { return $true }
|
||||
"yes" { return $true }
|
||||
"on" { return $true }
|
||||
"0" { return $false }
|
||||
"false" { return $false }
|
||||
"no" { return $false }
|
||||
"off" { return $false }
|
||||
default { return $Default }
|
||||
}
|
||||
}
|
||||
|
||||
function Join-Url([string]$Base, [string]$Path) {
|
||||
$left = $Base.TrimEnd('/')
|
||||
$right = $Path.TrimStart('/')
|
||||
return "$left/$right"
|
||||
}
|
||||
|
||||
function Invoke-Health([string]$Name, [string]$BaseUrl) {
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
throw "$Name base URL is empty"
|
||||
}
|
||||
|
||||
$url = Join-Url $BaseUrl "/health"
|
||||
Write-Info "Checking $Name health: $url"
|
||||
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $url -Method GET -TimeoutSec 10 -UseBasicParsing
|
||||
} catch {
|
||||
throw "$Name health request failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) {
|
||||
throw "$Name health returned status $($response.StatusCode)"
|
||||
}
|
||||
|
||||
Write-Ok "$Name health check passed"
|
||||
}
|
||||
|
||||
function Invoke-Analyze([string]$ClipBaseUrl, [string]$AnalyzeEndpoint, [string]$ImageUrl) {
|
||||
if ([string]::IsNullOrWhiteSpace($ClipBaseUrl)) {
|
||||
throw "CLIP base URL is empty"
|
||||
}
|
||||
|
||||
$url = Join-Url $ClipBaseUrl $AnalyzeEndpoint
|
||||
Write-Info "Running sample CLIP analyze call: $url"
|
||||
|
||||
$payload = @{
|
||||
image_url = $ImageUrl
|
||||
} | ConvertTo-Json -Depth 4
|
||||
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $url -Method POST -ContentType "application/json" -Body $payload -TimeoutSec 15 -UseBasicParsing
|
||||
} catch {
|
||||
throw "CLIP analyze request failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) {
|
||||
throw "CLIP analyze returned status $($response.StatusCode)"
|
||||
}
|
||||
|
||||
$json = $null
|
||||
try {
|
||||
$json = $response.Content | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "CLIP analyze response is not valid JSON"
|
||||
}
|
||||
|
||||
$hasTags = $false
|
||||
if ($json -is [System.Collections.IEnumerable] -and -not ($json -is [string])) {
|
||||
$hasTags = $true
|
||||
}
|
||||
if ($null -ne $json.tags) { $hasTags = $true }
|
||||
if ($null -ne $json.data) { $hasTags = $true }
|
||||
|
||||
if (-not $hasTags) {
|
||||
throw "CLIP analyze response does not contain expected tags/data payload"
|
||||
}
|
||||
|
||||
Write-Ok "Sample CLIP analyze call passed"
|
||||
}
|
||||
|
||||
function Test-NonBlockingPublish([string]$Root) {
|
||||
Write-Info "Validating non-blocking publish path (code assertions)"
|
||||
|
||||
$uploadController = Join-Path $Root "app/Http/Controllers/Api/UploadController.php"
|
||||
$derivativesJob = Join-Path $Root "app/Jobs/GenerateDerivativesJob.php"
|
||||
|
||||
if (-not (Test-Path $uploadController)) { throw "Missing file: $uploadController" }
|
||||
if (-not (Test-Path $derivativesJob)) { throw "Missing file: $derivativesJob" }
|
||||
|
||||
$uploadText = Get-Content -Raw -Path $uploadController
|
||||
$jobText = Get-Content -Raw -Path $derivativesJob
|
||||
|
||||
if ($uploadText -notmatch "AutoTagArtworkJob::dispatch\(") {
|
||||
throw "UploadController does not dispatch AutoTagArtworkJob"
|
||||
}
|
||||
|
||||
if ($jobText -notmatch "AutoTagArtworkJob::dispatch\(") {
|
||||
throw "GenerateDerivativesJob does not dispatch AutoTagArtworkJob"
|
||||
}
|
||||
|
||||
if ($uploadText -match "dispatchSync\(" -or $jobText -match "dispatchSync\(") {
|
||||
throw "Found dispatchSync in publish path; auto-tagging must remain async"
|
||||
}
|
||||
|
||||
if ($uploadText -match "Illuminate\\Support\\Facades\\Http" -or $uploadText -match "Http::") {
|
||||
throw "UploadController appears to call external vision HTTP directly"
|
||||
}
|
||||
|
||||
Write-Ok "Non-blocking publish path validation passed"
|
||||
}
|
||||
|
||||
$failed = $false
|
||||
|
||||
try {
|
||||
Write-Info "Vision smoke test starting"
|
||||
Write-Info "Project root: $ProjectRoot"
|
||||
|
||||
$envMap = Get-EnvMap -Root $ProjectRoot -RelativeEnvFile $EnvFile
|
||||
|
||||
$visionEnabled = Test-Truthy (Get-Setting -Map $envMap -Key "VISION_ENABLED" -Default "true") $true
|
||||
if (-not $visionEnabled) {
|
||||
throw "VISION_ENABLED=false. Vision integration is disabled; smoke check cannot continue."
|
||||
}
|
||||
|
||||
$clipBaseUrl = Get-Setting -Map $envMap -Key "CLIP_BASE_URL"
|
||||
$clipAnalyzeEndpoint = Get-Setting -Map $envMap -Key "CLIP_ANALYZE_ENDPOINT" -Default "/analyze"
|
||||
|
||||
$yoloEnabled = Test-Truthy (Get-Setting -Map $envMap -Key "YOLO_ENABLED" -Default "true") $true
|
||||
$yoloBaseUrl = Get-Setting -Map $envMap -Key "YOLO_BASE_URL"
|
||||
|
||||
Invoke-Health -Name "CLIP" -BaseUrl $clipBaseUrl
|
||||
|
||||
if ($yoloEnabled) {
|
||||
if ([string]::IsNullOrWhiteSpace($yoloBaseUrl)) {
|
||||
Write-Info "YOLO is enabled but YOLO_BASE_URL is empty; skipping YOLO /health check."
|
||||
} else {
|
||||
Invoke-Health -Name "YOLO" -BaseUrl $yoloBaseUrl
|
||||
}
|
||||
} else {
|
||||
Write-Info "YOLO is disabled; skipping YOLO /health check."
|
||||
}
|
||||
|
||||
if ($SkipAnalyze) {
|
||||
Write-Info "Skipping sample analyze call (SkipAnalyze set)."
|
||||
} else {
|
||||
Invoke-Analyze -ClipBaseUrl $clipBaseUrl -AnalyzeEndpoint $clipAnalyzeEndpoint -ImageUrl $SampleImageUrl
|
||||
}
|
||||
|
||||
Test-NonBlockingPublish -Root $ProjectRoot
|
||||
|
||||
Write-Ok "Vision smoke test completed successfully"
|
||||
} catch {
|
||||
$failed = $true
|
||||
Write-Fail $_.Exception.Message
|
||||
}
|
||||
|
||||
if ($failed) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user