mass update

This commit is contained in:
2025-12-04 15:23:47 +01:00
parent 054c99f720
commit e04e57d2d2
6 changed files with 310 additions and 47 deletions
+2
View File
@@ -295,6 +295,7 @@ class BackendApi {
String? storyline,
String? coverUrl,
int? releaseYear,
bool? locked,
}) async {
final map = await _post({
'action': 'set_game_status',
@@ -309,6 +310,7 @@ class BackendApi {
if (storyline != null) 'storyline': storyline,
if (coverUrl != null) 'cover_url': coverUrl,
if (releaseYear != null) 'release_year': releaseYear,
if (locked != null) 'locked': locked,
});
return (map['game'] as Map).cast<String, dynamic>();
}
+1
View File
@@ -0,0 +1 @@
test
+3
View File
@@ -11,6 +11,7 @@ class Game {
final String? summary;
final String? coverUrl;
final int? releaseYear;
final bool locked;
Game({
required this.id,
@@ -23,6 +24,7 @@ class Game {
this.summary,
this.coverUrl,
this.releaseYear,
this.locked = false,
});
factory Game.fromJson(Map<String, dynamic> j) {
@@ -37,6 +39,7 @@ class Game {
summary: j['loc_summary'] as String?,
coverUrl: j['cover_url'] as String?,
releaseYear: (j['release_year'] as num?)?.toInt(),
locked: ((j['loc_locked'] ?? j['locked'] ?? 0) as num?)?.toInt() == 1,
);
}
@@ -19,6 +19,14 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
late Game _game;
late ItemStatus _status;
late ItemStatus _origStatus;
late TextEditingController _titleCtrl;
late TextEditingController _summaryCtrl;
String _title = '';
String _summary = '';
late String _origTitle;
late String _origSummary;
bool _lockTexts = false;
late bool _origLocked;
@override
void initState() {
@@ -26,13 +34,52 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
_game = widget.game;
_status = widget.game.status;
_origStatus = widget.game.status;
_title = (widget.game.localizedTitle?.isNotEmpty ?? false) ? widget.game.localizedTitle! : widget.game.name;
_summary = widget.game.summary ?? '';
_lockTexts = widget.game.locked;
_origTitle = _title;
_origSummary = _summary;
_origLocked = _lockTexts;
_titleCtrl = TextEditingController(text: _title);
_summaryCtrl = TextEditingController(text: _summary);
_titleCtrl.addListener(() {
final v = _titleCtrl.text;
if (v != _title) {
setState(() {
_title = v;
});
}
});
_summaryCtrl.addListener(() {
final v = _summaryCtrl.text;
if (v != _summary) {
setState(() {
_summary = v;
});
}
});
}
bool get _dirty => _status != _origStatus;
@override
void dispose() {
_titleCtrl.dispose();
_summaryCtrl.dispose();
super.dispose();
}
bool get _dirty => _status != _origStatus || _title != _origTitle || _summary != _origSummary;
bool get _lockedDirty => _lockTexts != _origLocked;
bool get _isDirty => _dirty || _lockedDirty;
bool _hasCustomTexts(String title, String summary) {
return title.trim().isNotEmpty || summary.trim().isNotEmpty;
}
Future<void> _save() async {
final messenger = ScaffoldMessenger.of(context);
try {
final title = _title.trim();
final summary = _summary.trim();
final backend = ref.read(backendApiProvider);
await backend.setGameStatus(
igdbId: _game.igdbId,
@@ -40,13 +87,36 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
originalName: _game.originalName,
status: _status.index,
lang: 'de',
title: _game.localizedTitle ?? _game.name,
summary: _game.summary,
title: title.isNotEmpty ? title : _game.name,
summary: summary,
coverUrl: _game.coverUrl,
releaseYear: _game.releaseYear,
locked: _lockTexts,
);
if (!mounted) return;
setState(() => _origStatus = _status);
setState(() {
_game = Game(
id: _game.id,
igdbId: _game.igdbId,
name: _game.name,
localizedTitle: title.isNotEmpty ? title : null,
originalName: _game.originalName,
status: _status,
note: _game.note,
summary: summary.isNotEmpty ? summary : null,
coverUrl: _game.coverUrl,
releaseYear: _game.releaseYear,
locked: _lockTexts,
);
_title = title;
_summary = summary;
_titleCtrl.text = title;
_summaryCtrl.text = summary;
_origStatus = _status;
_origTitle = title;
_origSummary = summary;
_origLocked = _lockTexts;
});
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
// ignore: unused_result
@@ -75,7 +145,7 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
),
],
),
floatingActionButton: _dirty
floatingActionButton: _isDirty
? FloatingActionButton.extended(
onPressed: _save,
icon: const Icon(Icons.save),
@@ -123,7 +193,24 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
children: [
_header(context, g),
const SizedBox(height: 16),
if ((g.summary ?? '').isNotEmpty) _summaryCard(context, g.summary!),
_summaryCard(context),
const SizedBox(height: 8),
Row(
children: [
Checkbox(
value: _lockTexts,
onChanged: (v) => setState(() => _lockTexts = v ?? false),
checkColor: Colors.white,
side: const BorderSide(color: Colors.white70),
),
const Expanded(
child: Text(
'Eigene Texte vor Updates schützen',
style: TextStyle(color: Colors.white),
),
),
],
),
],
),
),
@@ -160,10 +247,26 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
g.releaseYear != null ? '${g.displayName} (${g.releaseYear})' : g.displayName,
TextField(
controller: _titleCtrl,
maxLines: 2,
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white),
decoration: InputDecoration(
labelText: 'Titel (DE)',
labelStyle: const TextStyle(color: Colors.white70),
hintText: g.displayName,
hintStyle: const TextStyle(color: Colors.white54),
enabledBorder: const UnderlineInputBorder(borderSide: BorderSide(color: Colors.white70)),
focusedBorder: const UnderlineInputBorder(borderSide: BorderSide(color: Colors.white)),
),
),
if (g.releaseYear != null) ...[
const SizedBox(height: 6),
Text(
'Erscheinungsjahr: ${g.releaseYear}',
style: const TextStyle(color: Colors.white70),
),
],
if (g.originalName != null && g.originalName!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
@@ -182,7 +285,7 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
);
}
Widget _summaryCard(BuildContext context, String summary) {
Widget _summaryCard(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
@@ -195,9 +298,29 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Beschreibung', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
Text('Beschreibung (DE)', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
const SizedBox(height: 6),
Text(summary),
TextField(
controller: _summaryCtrl,
maxLines: null,
minLines: 4,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Beschreibung bearbeiten',
hintStyle: const TextStyle(color: Colors.white54),
filled: true,
fillColor: Colors.black12,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white30),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
contentPadding: const EdgeInsets.all(12),
),
),
],
),
),
@@ -228,8 +351,11 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
final cover = (data['cover'] is Map && data['cover']['image_id'] != null)
? 'https://images.igdb.com/igdb/image/upload/t_cover_big/${data['cover']['image_id']}.jpg'
: _game.coverUrl;
final summary = data['summary'] as String? ?? _game.summary;
final title = data['name'] as String? ?? _game.name;
final igdbSummary = data['summary'] as String? ?? '';
final igdbTitle = data['name'] as String? ?? '';
// If not locked, always replace with fresh IGDB texts
final summary = _lockTexts ? _summary : igdbSummary;
final title = _lockTexts ? _title : (igdbTitle.isNotEmpty ? igdbTitle : _title);
final firstRelease = data['first_release_date'];
int? year;
if (firstRelease is num) {
@@ -237,16 +363,18 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
}
await ref.read(backendApiProvider).setGameStatus(
igdbId: _game.igdbId,
name: title,
originalName: _game.originalName,
status: _status.index,
lang: 'de',
title: title,
summary: summary,
coverUrl: cover,
releaseYear: year,
);
igdbId: _game.igdbId,
name: title,
originalName: _game.originalName,
status: _status.index,
lang: 'de',
// Wenn nicht gesperrt, die aktuellen Texte (aus IGDB oder lokal) zurückschreiben
title: title,
summary: summary,
coverUrl: cover,
releaseYear: year,
locked: _lockTexts,
);
if (!mounted) return;
setState(() {
@@ -261,7 +389,16 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
summary: summary,
coverUrl: cover,
releaseYear: year ?? _game.releaseYear,
locked: _lockTexts,
);
_title = title;
_summary = summary;
_origTitle = _title;
_origSummary = _summary;
_titleCtrl.text = _title;
_summaryCtrl.text = _summary;
_origStatus = _status;
_origLocked = _lockTexts;
});
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
+118 -13
View File
@@ -38,17 +38,27 @@ class GamesScreen extends ConsumerWidget {
onChanged: (f) => ref.read(gameFilterProvider.notifier).state = f,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Neue Spiele hinzufügen'),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const GamesAddScreen()),
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.update),
label: const Text('Alle Spiele updaten'),
onPressed: gamesAsync is AsyncData<List<Game>> && (gamesAsync.value?.isNotEmpty ?? false)
? () => _updateAllGames(context, ref, gamesAsync.value ?? const [])
: null,
),
const SizedBox(width: 8),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Neue Spiele hinzufügen'),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const GamesAddScreen()),
);
},
),
],
),
const SizedBox(height: 12),
Expanded(
@@ -130,8 +140,10 @@ class GamesScreen extends ConsumerWidget {
final cover = (data['cover'] is Map && data['cover']['image_id'] != null)
? 'https://images.igdb.com/igdb/image/upload/t_cover_big/${data['cover']['image_id']}.jpg'
: g.coverUrl;
final summary = data['summary'] as String? ?? g.summary;
final title = data['name'] as String? ?? g.name;
final summary = g.locked ? (g.summary ?? '') : (data['summary'] as String? ?? g.summary ?? '');
final title = g.locked
? (g.localizedTitle?.isNotEmpty == true ? g.localizedTitle! : g.name)
: (data['name'] as String? ?? g.name);
final fr = data['first_release_date'];
int? year;
if (fr is num) {
@@ -147,6 +159,7 @@ class GamesScreen extends ConsumerWidget {
summary: summary,
coverUrl: cover,
releaseYear: year,
locked: g.locked,
);
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
@@ -158,6 +171,98 @@ class GamesScreen extends ConsumerWidget {
}
}
Future<void> _updateAllGames(BuildContext context, WidgetRef ref, List<Game> games) async {
if (games.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
int idx = 0;
int ok = 0;
int fail = 0;
String current = '';
final total = games.length;
await showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
return StatefulBuilder(builder: (ctx, setState) {
Future<void> run() async {
try {
for (final g in games) {
idx++;
current = g.displayName;
setState(() {});
try {
final igdb = ref.read(igdbApiProvider);
final data = await igdb.getGameDetails(g.igdbId, lang: 'de');
final cover = (data['cover'] is Map && data['cover']['image_id'] != null)
? 'https://images.igdb.com/igdb/image/upload/t_cover_big/${data['cover']['image_id']}.jpg'
: g.coverUrl;
final summary = g.locked ? (g.summary ?? '') : (data['summary'] as String? ?? g.summary ?? '');
final title = g.locked
? (g.localizedTitle?.isNotEmpty == true ? g.localizedTitle! : g.name)
: (data['name'] as String? ?? g.name);
final fr = data['first_release_date'];
int? year;
if (fr is num) {
year = DateTime.fromMillisecondsSinceEpoch(fr.toInt() * 1000).year;
}
await ref.read(backendApiProvider).setGameStatus(
igdbId: g.igdbId,
name: title,
originalName: g.originalName,
status: g.status.index,
lang: 'de',
title: title,
summary: summary,
coverUrl: cover,
releaseYear: year,
locked: g.locked,
);
ok++;
} catch (_) {
fail++;
}
}
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
// ignore: unused_result
ref.invalidate(gamesProvider);
messenger.showSnackBar(SnackBar(content: Text('Alle Spiele aktualisiert: $ok/$total (Fehler: $fail)')));
} finally {
if (ctx.mounted) Navigator.of(ctx).pop();
}
}
if (idx == 0 && ok == 0 && fail == 0) {
// ignore: discarded_futures
run();
}
final progress = total > 0 ? ((idx / total).clamp(0, 1)).toDouble() : null;
return AlertDialog(
title: const Text('Alle Spiele updaten'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Aktualisiere: $idx / $total'),
if (current.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
current,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
LinearProgressIndicator(value: progress),
],
),
);
});
},
);
}
Future<void> _deleteGame(BuildContext context, WidgetRef ref, Game g) async {
final ok = await showDialog<bool>(
context: context,
+26 -11
View File
@@ -161,26 +161,33 @@ function getLocalization(int $igdbId, string $lang = 'de'): ?array {
return $row ?: null;
}
function saveLocalization(int $igdbId, string $lang, ?string $title, ?string $summary, ?string $storyline, ?int $userId): array {
function saveLocalization(int $igdbId, string $lang, ?string $title, ?string $summary, ?string $storyline, ?int $userId, ?int $locked = null): array {
$pdo = getDb();
$existing = getLocalization($igdbId, $lang);
$lockedVal = $locked === null ? (int)($existing['locked'] ?? 0) : (int)!!$locked;
$titleToSave = $title !== null ? $title : ($existing['title'] ?? null);
$summaryToSave = $summary !== null ? $summary : ($existing['summary'] ?? null);
$storyToSave = $storyline !== null ? $storyline : ($existing['storyline'] ?? null);
$sql = '
INSERT INTO igdb_localizations (igdb_id, lang, title, summary, storyline, user_id)
VALUES (:id, :lang, :title, :summary, :storyline, :user_id)
INSERT INTO igdb_localizations (igdb_id, lang, title, summary, storyline, user_id, locked)
VALUES (:id, :lang, :title, :summary, :storyline, :user_id, :locked)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
summary = VALUES(summary),
storyline = VALUES(storyline),
user_id = VALUES(user_id),
locked = VALUES(locked),
updated_at = CURRENT_TIMESTAMP
';
$stmt = $pdo->prepare($sql);
$stmt->execute([
':id' => $igdbId,
':lang' => $lang,
':title' => $title,
':summary' => $summary,
':storyline' => $storyline,
':title' => $titleToSave,
':summary' => $summaryToSave,
':storyline' => $storyToSave,
':user_id' => $userId,
':locked' => $lockedVal,
]);
return getLocalization($igdbId, $lang);
}
@@ -498,10 +505,11 @@ if (isset($_GET['action'])) {
$game['my_note'] = null;
}
$hasLoc = false;
$locLocked = 0;
if ($lang !== 'en') {
$loc = getLocalization($id, $lang);
if ($loc !== null) {
$hasLoc = true;
$locLocked = (int)($loc['locked'] ?? 0);
if (!empty($loc['title'])) $game['name'] = $loc['title'];
if (!empty($loc['summary'])) $game['summary'] = $loc['summary'];
if (!empty($loc['storyline'])) $game['storyline'] = $loc['storyline'];
@@ -509,15 +517,20 @@ if (isset($_GET['action'])) {
'lang' => $lang,
'source' => 'custom',
'id' => $loc['id'],
'locked' => $locLocked,
];
if ($locLocked === 1) {
$hasLoc = true; // locked -> treat as authoritative, skip external fallback
}
}
}
$game['has_localization'] = $hasLoc;
$game['loc_locked'] = $locLocked;
$game['has_external_de'] = false;
$game['external_de_source']= null;
$game['external_de_summary']= null;
$game['external_de_url'] = null;
if ($checkExternal && $lang === 'de' && !$hasLoc) {
if ($checkExternal && $lang === 'de' && $locLocked === 0) {
$wiki = wikiFetchGermanSummaryForTitle($game['name']);
if ($wiki !== null && !empty($wiki['extract'])) {
$game['has_external_de'] = true;
@@ -1067,7 +1080,8 @@ try {
CASE g.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status,
loc.title AS loc_title,
loc.summary AS loc_summary,
loc.storyline AS loc_storyline
loc.storyline AS loc_storyline,
loc.locked AS loc_locked
FROM game g
LEFT JOIN igdb_localizations loc
ON loc.igdb_id = g.igdb_id AND loc.lang = ?
@@ -1333,6 +1347,7 @@ try {
$locTitle = isset($in['title']) ? trim($in['title']) : '';
$locSummary = isset($in['summary']) ? trim($in['summary']) : '';
$locStory = isset($in['storyline']) ? trim($in['storyline']) : '';
$lockFlag = array_key_exists('locked', $in) ? (int)!!$in['locked'] : null;
$coverUrl = isset($in['cover_url']) ? trim($in['cover_url']) : null;
$releaseYear = isset($in['release_year']) ? (int)$in['release_year'] : null;
$GLOBALS['__release_year'] = $releaseYear;
@@ -1340,8 +1355,8 @@ try {
if ($name === '') fail('Missing "name"');
if ($status < 0 || $status > 2) fail('Invalid "status" (0,1,2)');
$game = saveGameStatus($igdbId, $name, $orig !== '' ? $orig : null, $status, $note !== '' ? $note : null, $coverUrl ?: null);
if ($locTitle !== '' || $locSummary !== '' || $locStory !== '') {
saveLocalization($igdbId, $lang, $locTitle ?: null, $locSummary ?: null, $locStory ?: null, null);
if ($locTitle !== '' || $locSummary !== '' || $locStory !== '' || $lockFlag !== null) {
saveLocalization($igdbId, $lang, $locTitle ?: null, $locSummary ?: null, $locStory ?: null, null, $lockFlag);
}
resp(['ok' => true, 'game' => $game]);
break;