From 75ef928264008ca1e516057ad2fe5b597e8c791f Mon Sep 17 00:00:00 2001 From: sss3978 <106522699+soma3978@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:54:10 +0900 Subject: [PATCH 1/9] Create ja.ts --- client/src/i18n/translations/ja.ts | 2399 ++++++++++++++++++++++++++++ 1 file changed, 2399 insertions(+) create mode 100644 client/src/i18n/translations/ja.ts diff --git a/client/src/i18n/translations/ja.ts b/client/src/i18n/translations/ja.ts new file mode 100644 index 00000000..5df1ce30 --- /dev/null +++ b/client/src/i18n/translations/ja.ts @@ -0,0 +1,2399 @@ +const ja: Record = { + // Common + 'common.save': '保存', + 'common.showMore': 'もっと見る', + 'common.showLess': '閉じる', + 'common.cancel': 'キャンセル', + 'common.clear': 'クリア', + 'common.delete': '削除', + 'common.edit': '編集', + 'common.add': '追加', + 'common.loading': '読み込み中…', + 'common.import': 'インポート', + 'common.select': '選択', + 'common.selectAll': 'すべて選択', + 'common.deselectAll': 'すべて解除', + 'common.error': 'エラー', + 'common.unknownError': '不明なエラー', + 'common.tooManyAttempts': '試行回数が多すぎます。時間をおいて再度お試しください。', + 'common.back': '戻る', + 'common.all': 'すべて', + 'common.close': '閉じる', + 'common.open': '開く', + 'common.upload': 'アップロード', + 'common.search': '検索', + 'common.confirm': '確認', + 'common.ok': 'OK', + 'common.yes': 'はい', + 'common.no': 'いいえ', + 'common.or': 'または', + 'common.none': 'なし', + 'common.date': '日付', + 'common.rename': '名前を変更', + 'common.name': '名前', + 'common.email': 'メールアドレス', + 'common.password': 'パスワード', + 'common.saving': '保存中…', + 'common.justNow': 'たった今', + 'common.hoursAgo': '{count}時間前', + 'common.daysAgo': '{count}日前', + 'common.saved': '保存しました', + 'trips.memberRemoved': '{username} を削除しました', + 'trips.memberRemoveError': '削除に失敗しました', + 'trips.memberAdded': '{username} を追加しました', + 'trips.memberAddError': '追加に失敗しました', + 'trips.reminder': 'リマインダー', + 'trips.reminderNone': 'なし', + 'trips.reminderDay': '日', + 'trips.reminderDays': '日', + 'trips.reminderCustom': 'カスタム', + 'trips.reminderDaysBefore': '出発前', + 'trips.reminderDisabledHint': '旅行のリマインダーは無効です。管理 > 設定 > 通知から有効にしてください。', + 'common.update': '更新', + 'common.change': '変更', + 'common.uploading': 'アップロード中…', + 'common.backToPlanning': 'プランに戻る', + 'common.reset': 'リセット', + 'common.expand': '展開', + 'common.collapse': '折りたたむ', + + // Navbar + 'nav.trip': '旅行', + 'nav.share': '共有', + 'nav.settings': '設定', + 'nav.admin': '管理', + 'nav.logout': 'ログアウト', + 'nav.lightMode': 'ライトモード', + 'nav.darkMode': 'ダークモード', + 'nav.autoMode': '自動', + 'nav.administrator': '管理者', + +// Dashboard + 'dashboard.title': 'マイ旅行', + 'dashboard.subtitle.loading': '旅行を読み込み中...', + 'dashboard.subtitle.trips': '{count}件の旅行({archived}件アーカイブ)', + 'dashboard.subtitle.empty': '最初の旅行を始めましょう', + 'dashboard.subtitle.activeOne': '進行中の旅行 {count}件', + 'dashboard.subtitle.activeMany': '進行中の旅行 {count}件', + 'dashboard.subtitle.archivedSuffix': ' · アーカイブ {count}件', + 'dashboard.newTrip': '新しい旅行', + 'dashboard.gridView': 'グリッド表示', + 'dashboard.listView': 'リスト表示', + 'dashboard.currency': '通貨', + 'dashboard.timezone': 'タイムゾーン', + 'dashboard.localTime': '現地', + 'dashboard.timezoneCustomTitle': 'カスタムタイムゾーン', + 'dashboard.timezoneCustomLabelPlaceholder': 'ラベル(任意)', + 'dashboard.timezoneCustomTzPlaceholder': '例:America/New_York', + 'dashboard.timezoneCustomAdd': '追加', + 'dashboard.timezoneCustomErrorEmpty': 'タイムゾーンIDを入力してください', + 'dashboard.timezoneCustomErrorInvalid': '無効なタイムゾーンです(例:Europe/Berlin)', + 'dashboard.timezoneCustomErrorDuplicate': 'すでに追加されています', + 'dashboard.emptyTitle': '旅行はまだありません', + 'dashboard.emptyText': '最初の旅行を作成して計画を始めましょう!', + 'dashboard.emptyButton': '最初の旅行を作成', + 'dashboard.nextTrip': '次の旅行', + 'dashboard.shared': '共有済み', + 'dashboard.sharedBy': '{name}さんが共有', + 'dashboard.days': '日数', + 'dashboard.places': '場所', + 'dashboard.members': '同行者', + 'dashboard.archive': 'アーカイブ', + 'dashboard.copyTrip': 'コピー', + 'dashboard.copySuffix': 'コピー', + 'dashboard.restore': '復元', + 'dashboard.archived': 'アーカイブ済み', + 'dashboard.status.ongoing': '進行中', + 'dashboard.status.today': '今日', + 'dashboard.status.tomorrow': '明日', + 'dashboard.status.past': '過去', + 'dashboard.status.daysLeft': '残り{count}日', + 'dashboard.toast.loadError': '旅行の読み込みに失敗しました', + 'dashboard.toast.created': '旅行を作成しました!', + 'dashboard.toast.createError': '旅行の作成に失敗しました', + 'dashboard.toast.updated': '旅行を更新しました!', + 'dashboard.toast.updateError': '旅行の更新に失敗しました', + 'dashboard.toast.deleted': '旅行を削除しました', + 'dashboard.toast.deleteError': '旅行の削除に失敗しました', + 'dashboard.toast.archived': '旅行をアーカイブしました', + 'dashboard.toast.archiveError': '旅行のアーカイブに失敗しました', + 'dashboard.toast.restored': '旅行を復元しました', + 'dashboard.toast.restoreError': '旅行の復元に失敗しました', + 'dashboard.toast.copied': '旅行をコピーしました!', + 'dashboard.toast.copyError': '旅行のコピーに失敗しました', + 'dashboard.confirm.delete': '旅行「{title}」を削除しますか?すべての場所と計画は完全に削除されます。', + 'dashboard.editTrip': '旅行を編集', + 'dashboard.createTrip': '新しい旅行を作成', + 'dashboard.tripTitle': 'タイトル', + 'dashboard.tripTitlePlaceholder': '例:日本の夏', + 'dashboard.tripDescription': '説明', + 'dashboard.tripDescriptionPlaceholder': 'この旅行について', + 'dashboard.startDate': '開始日', + 'dashboard.endDate': '終了日', + 'dashboard.dayCount': '日数', + 'dashboard.dayCountHint': '日付が未設定の場合に作成する日数です。', + 'dashboard.noDateHint': '日付未設定 — 既定で7日分が作成されます。後で変更できます。', + 'dashboard.coverImage': 'カバー画像', + 'dashboard.addCoverImage': 'カバー画像を追加(またはドラッグ&ドロップ)', + 'dashboard.addMembers': '同行者', + 'dashboard.addMember': 'メンバーを追加', + 'dashboard.coverSaved': 'カバー画像を保存しました', + 'dashboard.coverUploadError': 'アップロードに失敗しました', + 'dashboard.coverRemoveError': '削除に失敗しました', + 'dashboard.titleRequired': 'タイトルは必須です', + 'dashboard.endDateError': '終了日は開始日より後にしてください', + + // Settings + 'settings.title': '設定', + 'settings.subtitle': '個人設定を管理', + 'settings.tabs.display': '表示', + 'settings.tabs.map': '地図', + 'settings.tabs.notifications': '通知', + 'settings.tabs.integrations': '連携', + 'settings.tabs.account': 'アカウント', + 'settings.tabs.offline': 'オフライン', + 'settings.tabs.about': '情報', + 'settings.map': '地図', + 'settings.mapTemplate': '地図テンプレート', + 'settings.mapTemplatePlaceholder.select': 'テンプレートを選択…', + 'settings.mapDefaultHint': '空欄の場合は OpenStreetMap(既定)を使用', + 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'settings.mapHint': '地図タイルのURLテンプレート', + 'settings.mapProvider': '地図プロバイダー', + 'settings.mapProviderHint': '旅程プランナーとジャーニー地図に影響します。Atlas は常に Leaflet を使用します。', + 'settings.mapLeafletSubtitle': 'クラシックな2D、任意のラスタータイル', + 'settings.mapMapboxSubtitle': 'ベクタータイル、3D建物・地形', + 'settings.mapExperimental': '実験的', + 'settings.mapMapboxToken': 'Mapbox アクセストークン', + 'settings.mapMapboxTokenHint': 'mapbox.com の公開トークン(pk.*)', + 'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens', + 'settings.mapStyle': '地図スタイル', + 'settings.mapStylePlaceholder': 'Mapboxスタイルを選択', + 'settings.mapStyleHint': 'プリセットまたは mapbox://styles/USER/ID のURL', + 'settings.map3dBuildings': '3D建物・地形', + 'settings.map3dHint': 'ピッチ+実際の3D押し出し表示。衛星含む全スタイルで動作。', + 'settings.mapHighQuality': '高品質モード', + 'settings.mapHighQualityHint': 'アンチエイリアス+地球投影で、より鮮明でリアルに表示。', + 'settings.mapHighQualityWarning': '低性能デバイスではパフォーマンスに影響する場合があります。', + 'settings.mapTipLabel': 'ヒント:', + 'settings.mapTip': '右クリック+ドラッグで回転/傾き。中クリックで場所を追加(右クリックは回転用)。', + 'settings.latitude': '緯度', + 'settings.longitude': '経度', + 'settings.saveMap': '地図を保存', + 'settings.apiKeys': 'APIキー', + 'settings.mapsKey': 'Google Maps APIキー', + 'settings.mapsKeyHint': '場所検索用。Places API(新)が必要。console.cloud.google.com で取得', + 'settings.weatherKey': 'OpenWeatherMap APIキー', + 'settings.weatherKeyHint': '天気情報用。openweathermap.org/api で無料取得', + 'settings.keyPlaceholder': 'キーを入力…', + 'settings.configured': '設定済み', + 'settings.saveKeys': 'キーを保存', + 'settings.display': '表示', + 'settings.colorMode': 'カラーモード', + 'settings.light': 'ライト', + 'settings.dark': 'ダーク', + 'settings.auto': '自動', + 'settings.language': '言語', + 'settings.temperature': '温度単位', + 'settings.timeFormat': '時刻形式', + 'settings.routeCalculation': '経路計算', + 'settings.bookingLabels': '予約ルートのラベル', + 'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。', + 'settings.blurBookingCodes': '予約コードをぼかす', + 'settings.notifications': '通知', + 'settings.notifyTripInvite': '旅行の招待', + 'settings.notifyBookingChange': '予約の変更', + 'settings.notifyTripReminder': '旅行リマインダー', + 'settings.notifyTodoDue': 'ToDoの期限', + 'settings.notifyVacayInvite': 'Vacay fusion の招待', + 'settings.notifyPhotosShared': '共有写真(Immich)', + 'settings.notifyCollabMessage': 'チャットメッセージ(Collab)', + 'settings.notifyPackingTagged': '持ち物リスト:割り当て', + 'settings.notifyWebhook': 'Webhook通知', + 'settings.notifyVersionAvailable': '新しいバージョン', + 'settings.notificationPreferences.email': 'メール', + 'settings.notificationPreferences.webhook': 'Webhook', + 'settings.notificationPreferences.inapp': 'アプリ内', + 'settings.notificationPreferences.ntfy': 'Ntfy', + 'settings.notificationPreferences.noChannels': '通知チャネルが未設定です。管理者に設定を依頼してください。', + 'settings.webhookUrl.label': 'Webhook URL', + 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', + 'settings.webhookUrl.hint': 'Discord、Slack、または独自のWebhook URLを入力してください。', + 'settings.webhookUrl.saved': 'Webhook URLを保存しました', + 'settings.webhookUrl.test': 'テスト', + 'settings.webhookUrl.testSuccess': 'テストWebhookを送信しました', + 'settings.webhookUrl.testFailed': 'テストWebhookに失敗しました', + 'settings.ntfyUrl.topicLabel': 'Ntfy トピック', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy サーバーURL(任意)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'ntfyトピックを入力してください。サーバー未入力時は管理者設定の既定値を使用します。', + 'settings.ntfyUrl.tokenLabel': 'アクセストークン(任意)', + 'settings.ntfyUrl.tokenHint': 'パスワード保護トピックに必要です。', + 'settings.ntfyUrl.saved': 'Ntfy設定を保存しました', + 'settings.ntfyUrl.test': 'テスト', + 'settings.ntfyUrl.testSuccess': 'テスト通知を送信しました', + 'settings.ntfyUrl.testFailed': 'テスト通知に失敗しました', + 'settings.ntfyUrl.tokenCleared': 'アクセストークンを削除しました', + 'admin.notifications.title': '通知', + 'admin.notifications.hint': '通知チャネルを1つ選択してください。同時に有効にできるのは1つだけです。', + 'admin.notifications.none': '無効', + 'admin.notifications.email': 'メール(SMTP)', + 'admin.notifications.webhook': 'Webhook', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.ntfy.hint': 'ユーザーが独自のntfyトピックを設定できるようにします。下で既定サーバーを設定してください。', + 'admin.notifications.save': '通知設定を保存', + 'admin.notifications.saved': '通知設定を保存しました', + 'admin.notifications.testWebhook': 'Webhookテスト送信', + 'admin.notifications.testWebhookSuccess': 'テストWebhookを送信しました', + 'admin.notifications.testWebhookFailed': 'テストWebhookに失敗しました', + 'admin.notifications.testNtfy': 'ntfyテスト送信', + 'admin.notifications.testNtfySuccess': 'テストntfyを送信しました', + 'admin.notifications.testNtfyFailed': 'テストntfyに失敗しました', + 'admin.notifications.emailPanel.title': 'メール(SMTP)', + 'admin.notifications.webhookPanel.title': 'Webhook', + 'admin.notifications.inappPanel.title': 'アプリ内', + 'admin.notifications.inappPanel.hint': 'アプリ内通知は常に有効で、全体では無効にできません。', + 'admin.notifications.adminWebhookPanel.title': '管理者Webhook', + 'admin.notifications.adminWebhookPanel.hint': '管理者通知専用のWebhookです(例:バージョン通知)。常に送信されます。', + 'admin.notifications.adminWebhookPanel.saved': '管理者Webhook URLを保存しました', + 'admin.notifications.adminWebhookPanel.testSuccess': 'テストWebhookを送信しました', + 'admin.notifications.adminWebhookPanel.testFailed': 'テストWebhookに失敗しました', + 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'URLが設定されていると常に送信されます', + 'admin.notifications.adminNtfyPanel.title': '管理者Ntfy', + 'admin.notifications.adminNtfyPanel.hint': '管理者通知専用のntfyトピックです。', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy サーバーURL', + 'admin.notifications.adminNtfyPanel.serverHint': 'ユーザー通知の既定サーバーとしても使用されます。', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': '管理者トピック', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'アクセストークン(任意)', + 'admin.notifications.adminNtfyPanel.tokenCleared': '管理者アクセストークンを削除しました', + 'admin.notifications.adminNtfyPanel.saved': '管理者ntfy設定を保存しました', + 'admin.notifications.adminNtfyPanel.test': 'ntfyテスト送信', + 'admin.notifications.adminNtfyPanel.testSuccess': 'テストntfyを送信しました', + 'admin.notifications.adminNtfyPanel.testFailed': 'テストntfyに失敗しました', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'トピック設定時は常に送信されます', + 'admin.notifications.adminNotificationsHint': '管理者専用通知の配信先を設定します。', + 'admin.notifications.tripReminders.title': '旅行リマインダー', + 'admin.notifications.tripReminders.hint': '旅行開始前に通知を送信します(旅行側の設定が必要)。', + 'admin.notifications.tripReminders.enabled': '旅行リマインダー有効', + 'admin.notifications.tripReminders.disabled': '旅行リマインダー無効', + 'admin.smtp.title': 'メール・通知', + 'admin.smtp.hint': 'メール通知送信用のSMTP設定。', + 'admin.smtp.testButton': 'テストメール送信', + 'admin.webhook.hint': 'ユーザーが独自のWebhook URLを設定できるようにします。', + 'admin.smtp.testSuccess': 'テストメールを送信しました', + 'admin.smtp.testFailed': 'テストメールに失敗しました', + 'settings.notificationsDisabled': '通知が未設定です。管理者に有効化を依頼してください。', + 'settings.notificationsActive': '有効なチャネル', + 'settings.notificationsManagedByAdmin': '通知イベントは管理者が設定します。', + 'dayplan.icsTooltip': 'カレンダーを書き出し(ICS)', + 'share.linkTitle': '公開リンク', + 'share.linkHint': 'ログイン不要で閲覧できるリンクを作成します(閲覧のみ)。', + 'share.createLink': 'リンク作成', + 'share.deleteLink': 'リンク削除', + 'share.createError': 'リンクを作成できませんでした', + 'common.copy': 'コピー', + 'common.copied': 'コピーしました', + 'share.permMap': '地図・プラン', + 'share.permBookings': '予約', + 'share.permPacking': '持ち物', + 'shared.expired': 'リンクが無効', + 'shared.expiredHint': 'この共有リンクは無効です。', + 'shared.readOnly': '読み取り専用', + 'shared.tabPlan': 'プラン', + 'shared.tabBookings': '予約', + 'shared.tabPacking': '持ち物', + 'shared.tabBudget': '予算', + 'shared.tabChat': 'チャット', + 'shared.days': '日', + 'shared.places': '場所', + 'shared.other': 'その他', + 'shared.totalBudget': '合計予算', + 'shared.messages': 'メッセージ', + 'shared.sharedVia': '共有元', + 'shared.confirmed': '確定', + 'shared.pending': '保留', + 'share.permBudget': '予算', + 'share.permCollab': 'チャット', + 'settings.on': 'オン', + 'settings.off': 'オフ', + 'settings.mcp.title': 'MCP設定', + 'settings.mcp.endpoint': 'MCPエンドポイント', + 'settings.mcp.clientConfig': 'クライアント設定', + 'settings.mcp.clientConfigHint': ' を下のAPIトークンに置き換えてください。', + 'settings.mcp.clientConfigHintOAuth': ' をOAuth 2.1の認証情報に置き換えてください。', + 'settings.mcp.copy': 'コピー', + 'settings.mcp.copied': 'コピーしました!', + 'settings.mcp.apiTokens': 'APIトークン', + 'settings.mcp.createToken': '新しいトークン', + 'settings.mcp.noTokens': 'トークンがありません。作成してください。', + 'settings.mcp.tokenCreatedAt': '作成日', + 'settings.mcp.tokenUsedAt': '最終使用', + 'settings.mcp.deleteTokenTitle': 'トークン削除', + 'settings.mcp.deleteTokenMessage': 'このトークンは即時無効になります。', + 'settings.mcp.modal.createTitle': 'APIトークン作成', + 'settings.mcp.modal.tokenName': 'トークン名', + 'settings.mcp.modal.tokenNamePlaceholder': '例:Claude Desktop', + 'settings.mcp.modal.creating': '作成中…', + 'settings.mcp.modal.create': '作成', + 'settings.mcp.modal.createdTitle': 'トークン作成完了', + 'settings.mcp.modal.createdWarning': '表示は一度きりです。今すぐ保存してください。', + 'settings.mcp.modal.done': '完了', + 'settings.mcp.toast.created': 'トークンを作成しました', + 'settings.mcp.toast.createError': 'トークン作成に失敗しました', + 'settings.mcp.toast.deleted': 'トークンを削除しました', + 'settings.mcp.toast.deleteError': 'トークン削除に失敗しました', + 'settings.mcp.apiTokensDeprecated': 'APIトークンは非推奨です。OAuth 2.1 クライアントを使用してください。', + 'settings.oauth.clients': 'OAuth 2.1 クライアント', + 'settings.oauth.clientsHint': '第三者アプリが接続できるよう登録します。', + 'settings.oauth.createClient': '新規クライアント', + 'settings.oauth.noClients': '登録されたクライアントはありません。', + 'settings.oauth.clientId': 'クライアントID', + 'settings.oauth.clientSecret': 'クライアントシークレット', + 'settings.oauth.deleteClient': 'クライアント削除', + 'settings.oauth.deleteClientMessage': 'このクライアントは完全に削除されます。', + 'settings.oauth.rotateSecret': 'シークレット更新', + 'settings.oauth.rotateSecretMessage': '新しいシークレットを生成します。', + 'settings.oauth.rotateSecretConfirm': '更新', + 'settings.oauth.rotateSecretConfirming': '更新中…', + 'settings.oauth.rotateSecretDoneTitle': '新しいシークレット', + 'settings.oauth.rotateSecretDoneWarning': '表示は一度きりです。今すぐ保存してください。', + 'settings.oauth.activeSessions': '有効なセッション', + 'settings.oauth.sessionScopes': 'スコープ', + 'settings.oauth.sessionExpires': '有効期限', + 'settings.oauth.revoke': '取り消し', + 'settings.oauth.revokeSession': 'セッション取り消し', + 'settings.oauth.revokeSessionMessage': 'このセッションのアクセスを即時無効にします。', + 'settings.oauth.modal.createTitle': 'OAuthクライアント登録', + 'settings.oauth.modal.presets': '簡単設定', + 'settings.oauth.modal.clientName': 'アプリ名', + 'settings.oauth.modal.clientNamePlaceholder': '例:Claude Web', + 'settings.oauth.modal.redirectUris': 'リダイレクトURI', + 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback', + 'settings.oauth.modal.redirectUrisHint': '1行につき1つ。HTTPS必須。', + 'settings.oauth.modal.scopes': '許可スコープ', + 'settings.oauth.modal.scopesHint': 'list_trips と get_trip_summary は常に利用可能です。', + 'settings.oauth.modal.selectAll': 'すべて選択', + 'settings.oauth.modal.deselectAll': 'すべて解除', + 'settings.oauth.modal.creating': '登録中…', + 'settings.oauth.modal.create': '登録', + 'settings.oauth.modal.createdTitle': '登録完了', + 'settings.oauth.modal.createdWarning': 'シークレットは一度しか表示されません。', + 'settings.oauth.toast.createError': '登録に失敗しました', + 'settings.oauth.toast.deleted': 'クライアントを削除しました', + 'settings.oauth.toast.deleteError': '削除に失敗しました', + 'settings.oauth.toast.revoked': 'セッションを取り消しました', + 'settings.oauth.toast.revokeError': '取り消しに失敗しました', + 'settings.oauth.toast.rotateError': '更新に失敗しました', + 'settings.account': 'アカウント', + 'settings.about': '情報', + 'settings.about.reportBug': '不具合報告', + 'settings.about.reportBugHint': '問題を見つけたらお知らせください', + 'settings.about.featureRequest': '機能リクエスト', + 'settings.about.featureRequestHint': '新機能を提案', + 'settings.about.wikiHint': 'ドキュメント・ガイド', + 'settings.about.supporters.badge': '月額サポーター', + 'settings.about.supporters.title': 'TREKの旅仲間', + 'settings.about.supporters.subtitle': '皆さんの支援がTREKの未来を支えています。', + 'settings.about.supporters.since': '{date}からサポート', + 'settings.about.supporters.tierEmpty': '最初の一人に', + 'settings.about.supporter.tier.noReturnTicket': '片道切符', + 'settings.about.supporter.tier.lostLuggageVip': 'ロストラゲージVIP', + 'settings.about.supporter.tier.businessClassDreamer': 'ビジネスクラスの夢', + 'settings.about.supporter.tier.budgetTraveller': '節約トラベラー', + 'settings.about.supporter.tier.hostelBunkmate': 'ホステル仲間', + 'settings.about.description': 'TREKはセルフホスト型の旅行プランナーです。', + 'settings.about.madeWith': 'Made with', + 'settings.about.madeBy': 'by Maurice とオープンソースコミュニティ。', + 'settings.username': 'ユーザー名', + 'settings.email': 'メール', + 'settings.role': '役割', + 'settings.roleAdmin': '管理者', + 'settings.oidcLinked': '連携先', + 'settings.changePassword': 'パスワード変更', + 'settings.currentPassword': '現在のパスワード', + 'settings.currentPasswordRequired': '現在のパスワードが必要です', + 'settings.newPassword': '新しいパスワード', + 'settings.confirmPassword': '新しいパスワード(確認)', + 'settings.updatePassword': 'パスワード更新', + 'settings.passwordRequired': '現在と新しいパスワードを入力してください', + 'settings.passwordTooShort': '8文字以上必要です', + 'settings.passwordMismatch': 'パスワードが一致しません', + 'settings.passwordWeak': '大文字・小文字・数字・記号を含めてください', + 'settings.passwordChanged': 'パスワードを変更しました', + 'settings.mustChangePassword': '続行するにはパスワード変更が必要です。', + 'settings.deleteAccount': 'アカウント削除', + 'settings.deleteAccountTitle': 'アカウントを削除しますか?', + 'settings.deleteAccountWarning': 'すべてのデータが完全に削除されます。', + 'settings.deleteAccountConfirm': '完全に削除', + 'settings.deleteBlockedTitle': '削除できません', + 'settings.deleteBlockedMessage': '唯一の管理者です。別のユーザーを管理者にしてください。', + 'settings.roleUser': 'ユーザー', + 'settings.saveProfile': 'プロフィールを保存', + 'settings.toast.mapSaved': '地図設定を保存しました', + 'settings.toast.keysSaved': 'APIキーを保存しました', + 'settings.toast.displaySaved': '表示設定を保存しました', + 'settings.toast.profileSaved': 'プロフィールを保存しました', + 'settings.uploadAvatar': 'プロフィール画像をアップロード', + 'settings.removeAvatar': 'プロフィール画像を削除', + 'settings.avatarUploaded': 'プロフィール画像を更新しました', + 'settings.avatarRemoved': 'プロフィール画像を削除しました', + 'settings.avatarError': 'アップロードに失敗しました', + 'settings.mfa.title': '二要素認証(2FA)', + 'settings.mfa.description': 'サインイン時に追加の認証を行います。', + 'settings.mfa.requiredByPolicy': '管理者により2FAが必須です。', + 'settings.mfa.backupTitle': 'バックアップコード', + 'settings.mfa.backupDescription': '認証アプリが使えない場合に使用します。', + 'settings.mfa.backupWarning': '今すぐ保存してください。各コードは1回限りです。', + 'settings.mfa.backupCopy': 'コードをコピー', + 'settings.mfa.backupDownload': 'TXTでダウンロード', + 'settings.mfa.backupPrint': '印刷 / PDF', + 'settings.mfa.backupCopied': 'バックアップコードをコピーしました', + 'settings.mfa.enabled': '2FAは有効です。', + 'settings.mfa.disabled': '2FAは無効です。', + 'settings.mfa.setup': '認証アプリを設定', + 'settings.mfa.scanQr': 'QRコードをスキャンするか、手動で入力してください。', + 'settings.mfa.secretLabel': 'シークレットキー(手動入力)', + 'settings.mfa.codePlaceholder': '6桁コード', + 'settings.mfa.enable': '2FAを有効化', + 'settings.mfa.cancelSetup': 'キャンセル', + 'settings.mfa.disableTitle': '2FAを無効化', + 'settings.mfa.disableHint': 'パスワードと現在のコードを入力してください。', + 'settings.mfa.disable': '2FAを無効化', + 'settings.mfa.toastEnabled': '2FAを有効にしました', + 'settings.mfa.toastDisabled': '2FAを無効にしました', + 'settings.mfa.demoBlocked': 'デモモードでは利用できません', + + // Login + 'login.error': 'ログインに失敗しました。認証情報を確認してください。', + 'login.tagline': 'あなたの旅行。\nあなたの計画。', + 'login.description': 'インタラクティブなマップ、予算、リアルタイム同期で、みんなで旅行を計画。', + 'login.features.maps': 'インタラクティブマップ', + 'login.features.mapsDesc': 'Google Places、経路、クラスタリング', + 'login.features.realtime': 'リアルタイム同期', + 'login.features.realtimeDesc': 'WebSocketで共同計画', + 'login.features.budget': '予算管理', + 'login.features.budgetDesc': 'カテゴリ、グラフ、人数別費用', + 'login.features.collab': 'コラボレーション', + 'login.features.collabDesc': '複数ユーザーで旅行を共有', + 'login.features.packing': '持ち物リスト', + 'login.features.packingDesc': 'カテゴリ、進捗、提案', + 'login.features.bookings': '予約', + 'login.features.bookingsDesc': '航空券、ホテル、レストランなど', + 'login.features.files': 'ドキュメント', + 'login.features.filesDesc': '書類のアップロードと管理', + 'login.features.routes': 'スマート経路', + 'login.features.routesDesc': '自動最適化&Google Maps書き出し', + 'login.selfHosted': 'セルフホスト · オープンソース · データはあなたのもの', + 'login.title': 'サインイン', + 'login.subtitle': 'おかえりなさい', + 'login.signingIn': 'サインイン中…', + 'login.signIn': 'サインイン', + 'login.createAdmin': '管理者アカウントを作成', + 'login.createAdminHint': 'TREKの最初の管理者アカウントを設定します。', + 'login.setNewPassword': '新しいパスワードを設定', + 'login.setNewPasswordHint': '続行する前にパスワードを変更してください。', + 'login.createAccount': 'アカウントを作成', + 'login.createAccountHint': '新しいアカウントを登録。', + 'login.creating': '作成中…', + 'login.noAccount': 'アカウントをお持ちでないですか?', + 'login.hasAccount': 'すでにアカウントをお持ちですか?', + 'login.register': '登録', + 'login.emailPlaceholder': 'your@email.com', + 'login.username': 'ユーザー名', + 'login.oidc.registrationDisabled': '登録は無効です。管理者に連絡してください。', + 'login.oidc.noEmail': 'プロバイダーからメールが取得できませんでした。', + 'login.oidc.tokenFailed': '認証に失敗しました。', + 'login.oidc.invalidState': 'セッションが無効です。もう一度お試しください。', + 'login.demoFailed': 'デモログインに失敗しました', + 'login.oidcSignIn': '{name}でサインイン', + 'login.oidcOnly': 'パスワード認証は無効です。SSOプロバイダーでサインインしてください。', + 'login.oidcLoggedOut': 'ログアウトしました。SSOプロバイダーで再度サインインしてください。', + 'login.demoHint': 'デモを試す — 登録不要', + 'login.mfaTitle': '二要素認証', + 'login.mfaSubtitle': '認証アプリの6桁コードを入力してください。', + 'login.mfaCodeLabel': '確認コード', + 'login.mfaCodeRequired': '認証アプリのコードを入力してください。', + 'login.mfaHint': 'Google Authenticator、Authy などのTOTPアプリを開いてください。', + 'login.mfaBack': '← サインインに戻る', + 'login.mfaVerify': '確認', + 'login.invalidInviteLink': '無効または期限切れの招待リンクです', + 'login.oidcFailed': 'OIDCログインに失敗しました', + 'login.usernameRequired': 'ユーザー名を入力してください', + 'login.passwordMinLength': 'パスワードは8文字以上である必要があります', + 'login.forgotPassword': 'パスワードを忘れた場合', + 'login.forgotPasswordTitle': 'パスワードをリセット', + 'login.forgotPasswordBody': '登録時のメールアドレスを入力してください。アカウントが存在する場合、リセット用リンクを送信します。', + 'login.forgotPasswordSubmit': 'リセットリンクを送信', + 'login.forgotPasswordSentTitle': 'メールを確認してください', + 'login.forgotPasswordSentBody': '該当するアカウントがある場合、リセットリンクを送信しました。リンクの有効期限は60分です。', + 'login.forgotPasswordSmtpHintOff': '注意:管理者がSMTPを設定していないため、リセットリンクはメールではなくサーバーコンソールに出力されます。', + 'login.backToLogin': 'ログインに戻る', + 'login.newPassword': '新しいパスワード', + 'login.confirmPassword': '新しいパスワード(確認)', + 'login.passwordsDontMatch': 'パスワードが一致しません', + 'login.mfaCode': '2FAコード', + 'login.resetPasswordTitle': '新しいパスワードを設定', + 'login.resetPasswordBody': '以前使用していない強力なパスワードを設定してください(8文字以上)。', + 'login.resetPasswordMfaBody': '2FAコードまたはバックアップコードを入力してリセットを完了してください。', + 'login.resetPasswordSubmit': 'パスワードをリセット', + 'login.resetPasswordVerify': '確認してリセット', + 'login.resetPasswordSuccessTitle': 'パスワードを更新しました', + 'login.resetPasswordSuccessBody': '新しいパスワードでログインできます。', + 'login.resetPasswordInvalidLink': '無効なリセットリンク', + 'login.resetPasswordInvalidLinkBody': 'リンクが無効または破損しています。新しいリンクをリクエストしてください。', + 'login.resetPasswordFailed': 'リセットに失敗しました。リンクの有効期限が切れている可能性があります。', + + // Register + 'register.passwordMismatch': 'パスワードが一致しません', + 'register.passwordTooShort': 'パスワードは8文字以上必要です', + 'register.failed': '登録に失敗しました', + 'register.getStarted': '始める', + 'register.subtitle': 'アカウントを作成して、理想の旅行計画を始めましょう。', + 'register.feature1': '無制限の旅行プラン', + 'register.feature2': 'インタラクティブなマップ表示', + 'register.feature3': '場所とカテゴリを管理', + 'register.feature4': '予約を管理', + 'register.feature5': '持ち物リストを作成', + 'register.feature6': '写真・ファイルを保存', + 'register.createAccount': 'アカウントを作成', + 'register.startPlanning': '旅行計画を始める', + 'register.minChars': '最小6文字', + 'register.confirmPassword': 'パスワード確認', + 'register.repeatPassword': 'パスワードを再入力', + 'register.registering': '登録中...', + 'register.register': '登録', + 'register.hasAccount': 'すでにアカウントをお持ちですか?', + 'register.signIn': 'サインイン', + + // Admin + 'admin.title': '管理', + 'admin.subtitle': 'ユーザー管理とシステム設定', + 'admin.tabs.users': 'ユーザー', + 'admin.tabs.categories': 'カテゴリ', + 'admin.tabs.backup': 'バックアップ', + 'admin.tabs.notifications': '通知', + 'admin.tabs.audit': '監査', + 'admin.stats.users': 'ユーザー', + 'admin.stats.trips': '旅行', + 'admin.stats.places': '場所', + 'admin.stats.photos': '写真', + 'admin.stats.files': 'ファイル', + 'admin.table.user': 'ユーザー', + 'admin.table.email': 'メール', + 'admin.table.role': '権限', + 'admin.table.created': '作成日', + 'admin.table.lastLogin': '最終ログイン', + 'admin.table.actions': '操作', + 'admin.you': '(あなた)', + 'admin.editUser': 'ユーザーを編集', + 'admin.newPassword': '新しいパスワード', + 'admin.newPasswordHint': '空欄の場合は現在のパスワードを維持', + 'admin.deleteUser': 'ユーザー「{name}」を削除しますか?すべての旅行が完全に削除されます。', + 'admin.deleteUserTitle': 'ユーザー削除', + 'admin.newPasswordPlaceholder': '新しいパスワードを入力…', + 'admin.toast.loadError': '管理データの読み込みに失敗しました', + 'admin.toast.userUpdated': 'ユーザーを更新しました', + 'admin.toast.updateError': '更新に失敗しました', + 'admin.toast.userDeleted': 'ユーザーを削除しました', + 'admin.toast.deleteError': '削除に失敗しました', + 'admin.toast.cannotDeleteSelf': '自分のアカウントは削除できません', + 'admin.toast.userCreated': 'ユーザーを作成しました', + 'admin.toast.createError': 'ユーザー作成に失敗しました', + 'admin.toast.fieldsRequired': 'ユーザー名、メール、パスワードは必須です', + 'admin.createUser': 'ユーザーを作成', + 'admin.invite.title': '招待リンク', + 'admin.invite.subtitle': '使い切り登録リンクを作成', + 'admin.invite.create': 'リンク作成', + 'admin.invite.createAndCopy': '作成してコピー', + 'admin.invite.empty': '招待リンクがまだありません', + 'admin.invite.maxUses': '最大使用回数', + 'admin.invite.expiry': '有効期限', + 'admin.invite.uses': '使用済み', + 'admin.invite.expiresAt': '期限', + 'admin.invite.createdBy': '作成者', + 'admin.invite.active': '有効', + 'admin.invite.expired': '期限切れ', + 'admin.invite.usedUp': '使用済み', + 'admin.invite.copied': '招待リンクをコピーしました', + 'admin.invite.copyLink': 'リンクをコピー', + 'admin.invite.deleted': '招待リンクを削除しました', + 'admin.invite.createError': '招待リンクの作成に失敗しました', + 'admin.invite.deleteError': '招待リンクの削除に失敗しました', + 'admin.tabs.settings': '設定', + 'admin.allowRegistration': '登録を許可', + 'admin.allowRegistrationHint': '新規ユーザーの自己登録を許可します', + 'admin.authMethods': '認証方法', + 'admin.passwordLogin': 'パスワードログイン', + 'admin.passwordLoginHint': 'メールとパスワードでのログインを許可', + 'admin.passwordRegistration': 'パスワード登録', + 'admin.passwordRegistrationHint': 'メールとパスワードでの新規登録を許可', + 'admin.oidcLogin': 'SSOログイン', + 'admin.oidcLoginHint': 'SSOでのログインを許可', + 'admin.oidcRegistration': 'SSO自動登録', + 'admin.oidcRegistrationHint': '新しいSSOユーザーを自動作成', + 'admin.envOverrideHint': 'パスワードログイン設定は OIDC_ONLY 環境変数で制御されています。', + 'admin.lockoutWarning': '少なくとも1つのログイン方法を有効にしてください', + 'admin.requireMfa': '二要素認証(2FA)を必須にする', + 'admin.requireMfaHint': '2FA未設定のユーザーは、利用前に設定が必要です。', + 'admin.apiKeys': 'APIキー', + 'admin.apiKeysHint': '任意。写真や天気などの拡張データを有効化します。', + 'admin.mapsKey': 'Google Maps APIキー', + 'admin.mapsKeyHint': '場所検索に必要。console.cloud.google.com で取得', + 'admin.mapsKeyHintLong': 'APIキーなしではOpenStreetMapを使用します。Google APIキーがあれば写真、評価、営業時間も表示できます。', + 'admin.recommended': '推奨', + 'admin.weatherKey': 'OpenWeatherMap APIキー', + 'admin.weatherKeyHint': '天気データ用。openweathermap.org で無料', + 'admin.validateKey': 'テスト', + 'admin.keyValid': '接続済み', + 'admin.keyInvalid': '無効', + 'admin.keySaved': 'APIキーを保存しました', + 'admin.oidcTitle': 'シングルサインオン(OIDC)', + 'admin.oidcSubtitle': 'Google、Apple、Authentik、Keycloak などでログインを許可します。', + 'admin.oidcDisplayName': '表示名', + 'admin.oidcIssuer': 'Issuer URL', + 'admin.oidcIssuerHint': 'OpenID ConnectのIssuer URL(例:https://accounts.google.com)', + 'admin.oidcSaved': 'OIDC設定を保存しました', + 'admin.oidcOnlyMode': 'パスワード認証を無効化', + 'admin.oidcOnlyModeHint': '有効にするとSSOのみ使用可能になり、パスワードログインと登録は無効になります。', + + // File Types + 'admin.fileTypes': '許可するファイル形式', + 'admin.fileTypesHint': 'ユーザーがアップロードできるファイル形式を設定します。', + 'admin.fileTypesFormat': '拡張子をカンマ区切り(例:jpg,png,pdf,doc)。すべて許可する場合は *。', + 'admin.fileTypesSaved': 'ファイル形式の設定を保存しました', + + 'admin.placesPhotos.title': '場所の写真', + 'admin.placesPhotos.subtitle': 'Google Places APIから写真を取得します。APIクォータ節約のため無効にできます。Wikimediaの写真には影響しません。', + 'admin.placesAutocomplete.title': '場所のオートコンプリート', + 'admin.placesAutocomplete.subtitle': '検索候補にGoogle Places APIを使用します。APIクォータ節約のため無効にできます。', + 'admin.placesDetails.title': '場所の詳細', + 'admin.placesDetails.subtitle': 'Google Places APIから営業時間、評価、ウェブサイトなどの詳細情報を取得します。APIクォータ節約のため無効にできます。', +// Packing Templates & Bag Tracking + 'admin.bagTracking.title': 'バッグ管理', + 'admin.bagTracking.subtitle': '持ち物の重量とバッグ割り当てを有効化', + 'admin.collab.chat.title': 'チャット', + 'admin.collab.chat.subtitle': '旅行の共同作業用リアルタイムメッセージ', + 'admin.collab.notes.title': 'ノート', + 'admin.collab.notes.subtitle': '共有ノートとドキュメント', + 'admin.collab.polls.title': '投票', + 'admin.collab.polls.subtitle': 'グループ投票・アンケート', + 'admin.collab.whatsnext.title': '次にすること', + 'admin.collab.whatsnext.subtitle': 'アクティビティ提案と次のステップ', + 'admin.tabs.config': 'カスタマイズ', + 'admin.tabs.defaults': 'ユーザーのデフォルト', + 'admin.defaultSettings.title': '既定のユーザー設定', + 'admin.defaultSettings.description': 'インスタンス全体の既定値を設定します。設定を変更していないユーザーにはこれらの値が適用されます。各ユーザーの設定変更は常に優先されます。', + 'admin.defaultSettings.saved': '既定値を保存しました', + 'admin.defaultSettings.reset': '組み込みの既定値に戻す', + 'admin.defaultSettings.resetToBuiltIn': 'リセット', + 'admin.tabs.templates': '持ち物テンプレート', + 'admin.packingTemplates.title': '持ち物テンプレート', + 'admin.packingTemplates.subtitle': '旅行で使い回せる持ち物リストを作成', + 'admin.packingTemplates.create': '新規テンプレート', + 'admin.packingTemplates.namePlaceholder': 'テンプレート名(例:ビーチ旅行)', + 'admin.packingTemplates.empty': 'テンプレートはまだありません', + 'admin.packingTemplates.items': 'アイテム', + 'admin.packingTemplates.categories': 'カテゴリ', + 'admin.packingTemplates.itemName': 'アイテム名', + 'admin.packingTemplates.itemCategory': 'カテゴリ', + 'admin.packingTemplates.categoryName': 'カテゴリ名(例:衣類)', + 'admin.packingTemplates.addCategory': 'カテゴリを追加', + 'admin.packingTemplates.created': 'テンプレートを作成しました', + 'admin.packingTemplates.deleted': 'テンプレートを削除しました', + 'admin.packingTemplates.loadError': 'テンプレートの読み込みに失敗しました', + 'admin.packingTemplates.createError': 'テンプレートの作成に失敗しました', + 'admin.packingTemplates.deleteError': 'テンプレートの削除に失敗しました', + 'admin.packingTemplates.saveError': '保存に失敗しました', + +// Addons + 'admin.tabs.addons': 'アドオン', + 'admin.addons.title': 'アドオン', + 'admin.addons.subtitle': '機能を有効/無効にしてTREKをカスタマイズします。', + 'admin.addons.catalog.packing.name': 'リスト', + 'admin.addons.catalog.packing.description': '旅行用の持ち物リストとToDo', + 'admin.addons.catalog.budget.name': '予算', + 'admin.addons.catalog.budget.description': '支出の管理と予算計画', + 'admin.addons.catalog.documents.name': 'ドキュメント', + 'admin.addons.catalog.documents.description': '旅行書類の保存・管理', + 'admin.addons.catalog.vacay.name': 'Vacay', + 'admin.addons.catalog.vacay.description': 'カレンダー表示の個人休暇プランナー', + 'admin.addons.catalog.atlas.name': 'Atlas', + 'admin.addons.catalog.atlas.description': '訪問国と旅行統計の世界地図', + 'admin.addons.catalog.collab.name': 'Collab', + 'admin.addons.catalog.collab.description': 'リアルタイムのメモ、投票、チャット', + 'admin.addons.catalog.memories.name': '写真(Immich)', + 'admin.addons.catalog.memories.description': 'Immichで旅行写真を共有', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'AI連携のためのModel Context Protocol', + 'admin.addons.subtitleBefore': '機能を有効/無効にして ', + 'admin.addons.subtitleAfter': ' をカスタマイズします。', + 'admin.addons.enabled': '有効', + 'admin.addons.disabled': '無効', + 'admin.addons.type.trip': '旅行', + 'admin.addons.type.global': '全体', + 'admin.addons.type.integration': '連携', + 'admin.addons.tripHint': '各旅行内のタブとして利用可能', + 'admin.addons.globalHint': 'メインナビの独立セクションとして利用可能', + 'admin.addons.integrationHint': '専用ページのないバックエンド/API連携', + 'admin.addons.toast.updated': 'アドオンを更新しました', + 'admin.addons.toast.error': 'アドオンの更新に失敗しました', + 'admin.addons.noAddons': '利用可能なアドオンはありません', +// Weather info + 'admin.weather.title': '天気データ', + 'admin.weather.badge': '2026年3月24日以降', + 'admin.weather.description': 'TREKは天気データにOpen‑Meteoを使用しています。無料でオープンソース、APIキーは不要です。', + 'admin.weather.forecast': '16日間予報', + 'admin.weather.forecastDesc': '以前は5日(OpenWeatherMap)', + 'admin.weather.climate': '過去の気候データ', + 'admin.weather.climateDesc': '16日以降は過去85年の平均値', + 'admin.weather.requests': '1日10,000リクエスト', + 'admin.weather.requestsDesc': '無料、APIキー不要', + 'admin.weather.locationHint': '各日の座標付き最初の場所を基準にします。場所が未割り当ての場合は、場所一覧の任意の場所を参照します。', + + // GitHub + 'admin.tabs.mcpTokens': 'MCPトークン', + 'admin.mcpTokens.title': 'MCPトークン', + 'admin.mcpTokens.subtitle': 'すべてのユーザーのAPIトークンを管理', + 'admin.mcpTokens.sectionTitle': 'API トークン', + 'admin.mcpTokens.owner': '所有者', + 'admin.mcpTokens.tokenName': 'トークン名', + 'admin.mcpTokens.created': '作成日', + 'admin.mcpTokens.lastUsed': '最終使用', + 'admin.mcpTokens.never': '未使用', + 'admin.mcpTokens.empty': 'MCPトークンはまだ作成されていません', + 'admin.mcpTokens.deleteTitle': 'トークンを削除', + 'admin.mcpTokens.deleteMessage': 'このトークンは即座に失効します。ユーザーはこのトークン経由のMCPアクセスを失います。', + 'admin.mcpTokens.deleteSuccess': 'トークンを削除しました', + 'admin.mcpTokens.deleteError': 'トークンの削除に失敗しました', + 'admin.mcpTokens.loadError': 'トークンの読み込みに失敗しました', + 'admin.oauthSessions.sectionTitle': 'OAuthセッション', + 'admin.oauthSessions.clientName': 'クライアント', + 'admin.oauthSessions.owner': '所有者', + 'admin.oauthSessions.scopes': 'スコープ', + 'admin.oauthSessions.created': '作成日時', + 'admin.oauthSessions.empty': '有効なOAuthセッションはありません', + 'admin.oauthSessions.revokeTitle': 'セッションを無効化', + 'admin.oauthSessions.revokeMessage': 'このOAuthセッションは即時無効化され、クライアントはMCPへのアクセスを失います。', + 'admin.oauthSessions.revokeSuccess': 'セッションを無効化しました', + 'admin.oauthSessions.revokeError': 'セッションの無効化に失敗しました', + 'admin.oauthSessions.loadError': 'OAuthセッションの読み込みに失敗しました', + 'admin.tabs.github': 'GitHub', + + 'admin.audit.subtitle': 'セキュリティおよび管理イベント(バックアップ、ユーザー、MFA、設定)。', + 'admin.audit.empty': '監査ログはまだありません。', + 'admin.audit.refresh': '更新', + 'admin.audit.loadMore': 'さらに読み込む', + 'admin.audit.showing': '{count}件表示 · 全{total}件', + 'admin.audit.col.time': '時刻', + 'admin.audit.col.user': 'ユーザー', + 'admin.audit.col.action': '操作', + 'admin.audit.col.resource': '対象', + 'admin.audit.col.ip': 'IP', + 'admin.audit.col.details': '詳細', + 'admin.github.title': 'リリース履歴', + 'admin.github.subtitle': '{repo} の最新アップデート', + 'admin.github.latest': '最新', + 'admin.github.prerelease': 'プレリリース', + 'admin.github.showDetails': '詳細を表示', + 'admin.github.hideDetails': '詳細を非表示', + 'admin.github.loadMore': 'さらに読み込む', + 'admin.github.loading': '読み込み中...', + 'admin.github.error': 'リリースの読み込みに失敗しました', + 'admin.github.by': '作成者', + 'admin.github.support': 'TREKの開発を支援', + + 'admin.update.available': '更新があります', + 'admin.update.text': 'TREK {version} が利用可能です。現在は {current} を使用しています。', + 'admin.update.button': 'GitHubで見る', + 'admin.update.install': '更新をインストール', + 'admin.update.confirmTitle': '更新をインストールしますか?', + 'admin.update.confirmText': 'TREKを {current} から {version} に更新します。更新後、サーバーは自動的に再起動します。', + 'admin.update.dataInfo': 'すべてのデータ(旅行、ユーザー、APIキー、アップロード、Vacay、Atlas、予算)は保持されます。', + 'admin.update.warning': '再起動中、アプリは短時間利用できません。', + 'admin.update.confirm': '今すぐ更新', + 'admin.update.installing': '更新中…', + 'admin.update.success': '更新が完了しました!サーバーを再起動しています…', + 'admin.update.failed': '更新に失敗しました', + 'admin.update.backupHint': '更新前にバックアップを作成することをおすすめします。', + 'admin.update.backupLink': 'バックアップへ', + 'admin.update.howTo': '更新方法', + 'admin.update.dockerText': 'TREKはDockerで実行されています。{version} に更新するには、サーバーで次のコマンドを実行してください:', + 'admin.update.reloadHint': '数秒後にページを再読み込みしてください。', + +// Vacay addon + 'vacay.subtitle': '休暇日数の計画と管理', + 'vacay.settings': '設定', + 'vacay.year': '年', + 'vacay.addYear': '翌年を追加', + 'vacay.addPrevYear': '前年を追加', + 'vacay.removeYear': '年を削除', + 'vacay.removeYearConfirm': '{year}年を削除しますか?', + 'vacay.removeYearHint': 'この年の休暇データと会社休日はすべて完全に削除されます。', + 'vacay.remove': '削除', + 'vacay.persons': '人物', + 'vacay.noPersons': '人物が追加されていません', + 'vacay.addPerson': '人物を追加', + 'vacay.editPerson': '人物を編集', + 'vacay.removePerson': '人物を削除', + 'vacay.removePersonConfirm': '{name}を削除しますか?', + 'vacay.removePersonHint': 'この人物のすべての休暇データが完全に削除されます。', + 'vacay.personName': '名前', + 'vacay.personNamePlaceholder': '名前を入力', + 'vacay.color': '色', + 'vacay.add': '追加', + 'vacay.legend': '凡例', + 'vacay.publicHoliday': '祝日', + 'vacay.companyHoliday': '会社休日', + 'vacay.weekend': '週末', + 'vacay.modeVacation': '休暇', + 'vacay.modeCompany': '会社休日', + 'vacay.entitlement': '付与日数', + 'vacay.entitlementDays': '日', + 'vacay.used': '使用済み', + 'vacay.remaining': '残り', + 'vacay.carriedOver': '{year}年から繰越', + 'vacay.blockWeekends': '週末を除外', + 'vacay.blockWeekendsHint': '週末に休暇を登録できないようにします', + 'vacay.weekendDays': '週末', + 'vacay.mon': '月', + 'vacay.tue': '火', + 'vacay.wed': '水', + 'vacay.thu': '木', + 'vacay.fri': '金', + 'vacay.sat': '土', + 'vacay.sun': '日', + 'vacay.publicHolidays': '祝日', + 'vacay.publicHolidaysHint': 'カレンダーに祝日を表示', + 'vacay.selectCountry': '国を選択', + 'vacay.selectRegion': '地域を選択(任意)', + 'vacay.addCalendar': 'カレンダーを追加', + 'vacay.calendarLabel': 'ラベル(任意)', + 'vacay.calendarColor': '色', + 'vacay.noCalendars': '祝日カレンダーはまだありません', + 'vacay.companyHolidays': '会社休日', + 'vacay.companyHolidaysHint': '会社全体の休日を設定できます', + 'vacay.companyHolidaysNoDeduct': '会社休日は休暇日数に含まれません。', + 'vacay.weekStart': '週の開始', + 'vacay.weekStartHint': 'カレンダーの週を月曜始まりにするか日曜始まりにするかを選択します', + 'vacay.carryOver': '繰越', + 'vacay.carryOverHint': '残りの休暇日数を翌年に自動で繰り越します', + 'vacay.sharing': '共有', + 'vacay.sharingHint': '他のTREKユーザーと休暇計画を共有', + 'vacay.owner': '所有者', + 'vacay.shareEmailPlaceholder': 'TREKユーザーのメール', + 'vacay.shareSuccess': '計画を共有しました', + 'vacay.shareError': '共有できませんでした', + 'vacay.dissolve': '統合を解除', + 'vacay.dissolveHint': 'カレンダーを分離します。データは保持されます。', + 'vacay.dissolveAction': '解除', + 'vacay.dissolved': 'カレンダーを分離しました', + 'vacay.fusedWith': '統合相手', + 'vacay.you': 'あなた', + 'vacay.noData': 'データなし', + 'vacay.changeColor': '色を変更', + 'vacay.inviteUser': 'ユーザーを招待', + 'vacay.inviteHint': '別のTREKユーザーを招待して、休暇カレンダーを共有します。', + 'vacay.selectUser': 'ユーザーを選択', + 'vacay.sendInvite': '招待を送信', + 'vacay.inviteSent': '招待を送信しました', + 'vacay.inviteError': '招待を送信できませんでした', + 'vacay.pending': '保留中', + 'vacay.noUsersAvailable': '利用可能なユーザーがいません', + 'vacay.accept': '承認', + 'vacay.decline': '拒否', + 'vacay.acceptFusion': '承認して統合', + 'vacay.inviteTitle': '統合の招待', + 'vacay.inviteWantsToFuse': 'が休暇カレンダーの共有を希望しています。', + 'vacay.fuseInfo1': '双方が1つの共有カレンダーですべての休暇を確認できます。', + 'vacay.fuseInfo2': '双方が互いの予定を作成・編集できます。', + 'vacay.fuseInfo3': '双方が予定の削除や付与日数の変更を行えます。', + 'vacay.fuseInfo4': '祝日や会社休日などの設定は共有されます。', + 'vacay.fuseInfo5': '統合はいつでも解除できます。データは保持されます。', + 'nav.myTrips': 'マイ旅行', + + // Atlas addon + 'atlas.subtitle': '世界に広がるあなたの旅の足跡', + 'atlas.countries': '国', + 'atlas.trips': '旅行', + 'atlas.places': '場所', + 'atlas.unmark': '削除', + 'atlas.confirmMark': 'この国を訪問済みにしますか?', + 'atlas.confirmUnmark': 'この国を訪問済みリストから削除しますか?', + 'atlas.confirmUnmarkRegion': 'この地域を訪問済みリストから削除しますか?', + 'atlas.markVisited': '訪問済みにする', + 'atlas.markVisitedHint': 'この国を訪問済みリストに追加', + 'atlas.markRegionVisitedHint': 'この地域を訪問済みリストに追加', + 'atlas.addToBucket': '行きたいリストに追加', + 'atlas.addPoi': '場所を追加', + 'atlas.searchCountry': '国を検索...', + 'atlas.bucketNamePlaceholder': '名前(国・都市・場所など)', + 'atlas.month': '月', + 'atlas.year': '年', + 'atlas.addToBucketHint': '行きたい場所として保存', + 'atlas.bucketWhen': '訪問予定はいつですか?', + 'atlas.statsTab': '統計', + 'atlas.bucketTab': '行きたいリスト', + 'atlas.addBucket': '行きたいリストに追加', + 'atlas.bucketNotesPlaceholder': 'メモ(任意)', + 'atlas.bucketEmpty': '行きたいリストは空です', + 'atlas.bucketEmptyHint': '行ってみたい場所を追加しましょう', + 'atlas.days': '日', + 'atlas.visitedCountries': '訪問国', + 'atlas.cities': '都市', + 'atlas.noData': '旅行データがありません', + 'atlas.noDataHint': '旅行を作成して場所を追加すると、マップに表示されます', + 'atlas.lastTrip': '前回の旅行', + 'atlas.nextTrip': '次の旅行', + 'atlas.daysLeft': '残り日数', + 'atlas.streak': '連続', + 'atlas.years': '年', + 'atlas.yearInRow': '年連続', + 'atlas.yearsInRow': '年連続', + 'atlas.tripIn': '旅行', + 'atlas.tripsIn': '旅行', + 'atlas.since': '開始', + 'atlas.europe': 'ヨーロッパ', + 'atlas.asia': 'アジア', + 'atlas.northAmerica': '北アメリカ', + 'atlas.southAmerica': '南アメリカ', + 'atlas.africa': 'アフリカ', + 'atlas.oceania': 'オセアニア', + 'atlas.other': 'その他', + 'atlas.firstVisit': '最初の旅行', + 'atlas.lastVisitLabel': '最後の旅行', + 'atlas.tripSingular': '旅行', + 'atlas.tripPlural': '旅行', + 'atlas.placeVisited': '訪問した場所', + 'atlas.placesVisited': '訪問した場所', + +// Trip Planner + 'trip.tabs.plan': '計画', + 'trip.tabs.transports': '移動', + 'trip.tabs.reservations': '予約', + 'trip.tabs.reservationsShort': '予約', + 'trip.tabs.packing': '持ち物リスト', + 'trip.tabs.packingShort': '持ち物', + 'trip.tabs.lists': 'リスト', + 'trip.tabs.listsShort': 'リスト', + 'trip.tabs.budget': '予算', + 'trip.tabs.files': 'ファイル', + 'trip.loading': '旅行を読み込み中...', + 'trip.loadingPhotos': '場所の写真を読み込み中...', + 'trip.mobilePlan': '計画', + 'trip.mobilePlaces': '場所', + 'trip.toast.placeUpdated': '場所を更新しました', + 'trip.toast.placeAdded': '場所を追加しました', + 'trip.toast.placeDeleted': '場所を削除しました', + 'trip.toast.selectDay': 'まず日を選択してください', + 'trip.toast.assignedToDay': '場所を日に割り当てました', + 'trip.toast.reorderError': '並び替えに失敗しました', + 'trip.toast.reservationUpdated': '予約を更新しました', + 'trip.toast.reservationAdded': '予約を追加しました', + 'trip.toast.deleted': '削除しました', + 'trip.confirm.deletePlace': 'この場所を削除してもよろしいですか?', + 'trip.confirm.deletePlaces': '{count}件の場所を削除してもよろしいですか?', + 'trip.toast.placesDeleted': '{count}件の場所を削除しました', + +// Day Plan Sidebar + 'dayplan.emptyDay': 'この日の予定はありません', + 'dayplan.cannotReorderTransport': '時刻が固定された予約は並び替えできません', + 'dayplan.confirmRemoveTimeTitle': '時刻を削除しますか?', + 'dayplan.confirmRemoveTimeBody': 'この場所には固定時刻({time})があります。移動すると時刻が削除され、自由に並び替えできます。', + 'dayplan.confirmRemoveTimeAction': '時刻を削除して移動', + 'dayplan.cannotDropOnTimed': '時刻指定の項目の間には配置できません', + 'dayplan.cannotBreakChronology': '時刻指定の項目や予約の時系列が崩れます', + 'dayplan.addNote': 'メモを追加', + 'dayplan.expandAll': 'すべての日を展開', + 'dayplan.collapseAll': 'すべての日を折りたたむ', + 'dayplan.editNote': 'メモを編集', + 'dayplan.noteAdd': 'メモを追加', + 'dayplan.noteEdit': 'メモを編集', + 'dayplan.noteTitle': 'メモ', + 'dayplan.noteSubtitle': '日別メモ', + 'dayplan.totalCost': '合計費用', + 'dayplan.days': '日', + 'dayplan.dayN': '{n}日目', + 'dayplan.calculating': '計算中...', + 'dayplan.route': 'ルート', + 'dayplan.optimize': '最適化', + 'dayplan.optimized': 'ルートを最適化しました', + 'dayplan.routeError': 'ルートの計算に失敗しました', + 'dayplan.toast.needTwoPlaces': 'ルート最適化には2つ以上の場所が必要です', + 'dayplan.toast.routeOptimized': 'ルートを最適化しました', + 'dayplan.toast.noGeoPlaces': '座標付きの場所がありません', + 'dayplan.confirmed': '確定', + 'dayplan.pendingRes': '保留', + 'dayplan.pdf': 'PDF', + 'dayplan.pdfTooltip': '日別計画をPDFで書き出し', + 'dayplan.pdfError': 'PDFの書き出しに失敗しました', + + // Places Sidebar + 'places.addPlace': '場所/アクティビティを追加', + 'places.importFile': 'ファイルをインポート', + 'places.sidebarDrop': 'ドロップしてインポート', + 'places.importFileHint': 'Google My Maps、Google Earth、GPSトラッカーなどの .gpx、.kml、.kmz ファイルをインポートできます。', + 'places.importFileDropHere': 'クリックしてファイルを選択、またはここにドラッグ&ドロップ', + 'places.importFileDropActive': 'ドロップして選択', + 'places.importFileUnsupported': '対応していないファイル形式です。.gpx、.kml、.kmz を使用してください。', + 'places.importFileTooLarge': 'ファイルが大きすぎます。最大 {maxMb} MB までです。', + 'places.importFileError': 'インポートに失敗しました', + 'places.importAllSkipped': 'すべての場所は既に旅行に含まれています。', + 'places.gpxImported': 'GPXから {count} 件の場所をインポートしました', + 'places.gpxImportTypes': '何をインポートしますか?', + 'places.gpxImportWaypoints': 'ウェイポイント', + 'places.gpxImportRoutes': 'ルート', + 'places.gpxImportTracks': 'トラック(経路付き)', + 'places.gpxImportNoneSelected': '少なくとも1つ選択してください。', + 'places.kmlImportTypes': '何をインポートしますか?', + 'places.kmlImportPoints': 'ポイント(プレースマーク)', + 'places.kmlImportPaths': 'パス(ライン)', + 'places.kmlImportNoneSelected': '少なくとも1つ選択してください。', + 'places.selectionCount': '{count} 件選択中', + 'places.deleteSelected': '選択を削除', + 'places.kmlKmzImported': 'KMZ/KMLから {count} 件の場所をインポートしました', + 'places.urlResolved': 'URLから場所をインポートしました', + 'places.importList': 'リストをインポート', + 'places.kmlKmzSummaryValues': 'プレースマーク: {total} • 追加: {created} • スキップ: {skipped}', + 'places.importGoogleList': 'Google リスト', + 'places.importNaverList': 'Naver リスト', + 'places.googleListHint': '共有されたGoogleマップのリストリンクを貼り付けてください。', + 'places.googleListImported': '「{list}」から {count} 件の場所をインポートしました', + 'places.googleListError': 'Googleマップのリストをインポートできませんでした', + 'places.naverListHint': '共有されたNaverマップのリストリンクを貼り付けてください。', + 'places.naverListImported': '「{list}」から {count} 件の場所をインポートしました', + 'places.naverListError': 'Naverマップのリストをインポートできませんでした', + 'places.viewDetails': '詳細を見る', + 'places.assignToDay': 'どの日に追加しますか?', + 'places.all': 'すべて', + 'places.unplanned': '未計画', + 'places.filterTracks': 'トラック', + 'places.search': '場所を検索…', + 'places.allCategories': 'すべてのカテゴリ', + 'places.categoriesSelected': 'カテゴリ', + 'places.clearFilter': 'フィルター解除', + 'places.count': '{count} 件の場所', + 'places.countSingular': '1 件の場所', + 'places.allPlanned': 'すべての場所が計画済みです', + 'places.noneFound': '場所が見つかりません', + 'places.editPlace': '場所を編集', + 'places.formName': '名前', + 'places.formNamePlaceholder': '例:エッフェル塔', + 'places.formDescription': '説明', + 'places.formDescriptionPlaceholder': '短い説明…', + 'places.formAddress': '住所', + 'places.formAddressPlaceholder': '通り、都市、国', + 'places.formLat': '緯度(例:48.8566)', + 'places.formLng': '経度(例:2.3522)', + 'places.formCategory': 'カテゴリ', + 'places.noCategory': 'カテゴリなし', + 'places.categoryNamePlaceholder': 'カテゴリ名', + 'places.formTime': '時間', + 'places.startTime': '開始', + 'places.endTime': '終了', + 'places.endTimeBeforeStart': '終了時間が開始時間より前です', + 'places.timeCollision': '時間が重複しています:', + 'places.formWebsite': 'ウェブサイト', + 'places.formNotes': 'メモ', + 'places.formNotesPlaceholder': '個人的なメモ…', + 'places.formReservation': '予約', + 'places.reservationNotesPlaceholder': '予約メモ、確認番号など…', + 'places.mapsSearchPlaceholder': '場所を検索…', + 'places.mapsSearchError': '場所の検索に失敗しました。', + 'places.loadingDetails': '詳細を読み込み中…', + 'places.osmHint': 'OpenStreetMapで検索しています(写真・営業時間・評価なし)。設定でGoogle APIキーを追加すると詳細が表示されます。', + 'places.osmActive': 'OpenStreetMapで検索中(写真・評価・営業時間なし)。設定でGoogle APIキーを追加してください。', + 'places.categoryCreateError': 'カテゴリの作成に失敗しました', + 'places.nameRequired': '名前を入力してください', + 'places.saveError': '保存に失敗しました', +// Place Inspector + 'inspector.opened': '営業中', + 'inspector.closed': '営業時間外', + 'inspector.openingHours': '営業時間', + 'inspector.showHours': '営業時間を表示', + 'inspector.files': 'ファイル', + 'inspector.remove': '削除', + 'inspector.filesCount': '{count}件のファイル', + 'inspector.removeFromDay': 'この日から削除', + 'inspector.addToDay': '日に追加', + 'inspector.confirmedRes': '確定済み予約', + 'inspector.pendingRes': '保留中の予約', + 'inspector.google': 'Googleマップで開く', + 'inspector.website': 'Webサイトを開く', + 'inspector.addRes': '予約', + 'inspector.editRes': '予約を編集', + 'inspector.participants': '参加者', + 'inspector.trackStats': '統計を記録', + +// Reservations + 'reservations.title': '予約', + 'reservations.empty': '予約はまだありません', + 'reservations.emptyHint': '航空券、ホテルなどの予約を追加しましょう', + 'reservations.add': '予約を追加', + 'reservations.addManual': '手動予約', + 'reservations.placeHint': 'ヒント:予約は場所から直接作成すると、日別計画に紐づけやすくなります。', + 'reservations.confirmed': '確定', + 'reservations.pending': '保留', + 'reservations.summary': '確定 {confirmed}件、保留 {pending}件', + 'reservations.fromPlan': '計画から', + 'reservations.showFiles': 'ファイルを表示', + 'reservations.editTitle': '予約を編集', + 'reservations.status': 'ステータス', + 'reservations.datetime': '日時', + 'reservations.startTime': '開始時刻', + 'reservations.endTime': '終了時刻', + 'reservations.date': '日付', + 'reservations.time': '時間', + 'reservations.timeAlt': '時間(代替、例:19:30)', + 'reservations.notes': 'メモ', + 'reservations.notesPlaceholder': '追加のメモ...', + 'reservations.meta.airline': '航空会社', + 'reservations.meta.flightNumber': '便名', + 'reservations.meta.from': '出発地', + 'reservations.meta.to': '到着地', + 'reservations.needsReview': '要確認', + 'reservations.needsReviewHint': '空港を自動で特定できませんでした。場所を確認してください。', + 'reservations.searchLocation': '駅・港・住所を検索…', + 'airport.searchPlaceholder': '空港コードまたは都市名(例:FRA)', + 'map.connections': '接続', + 'map.showConnections': '予約ルートを表示', + 'map.hideConnections': '予約ルートを非表示', + 'reservations.meta.trainNumber': '列車番号', + 'reservations.meta.platform': 'ホーム', + 'reservations.meta.seat': '座席', + 'reservations.meta.checkIn': 'チェックイン', + 'reservations.meta.checkOut': 'チェックアウト', + 'reservations.meta.linkAccommodation': '宿泊先', + 'reservations.meta.checkInUntil': 'チェックイン期限', + 'reservations.meta.pickAccommodation': '宿泊先にリンク', + 'reservations.meta.noAccommodation': 'なし', + 'reservations.meta.hotelPlace': '宿泊先', + 'reservations.meta.pickHotel': '宿泊先を選択', + 'reservations.meta.fromDay': '開始', + 'reservations.meta.toDay': '終了', + 'reservations.meta.selectDay': '日を選択', + 'reservations.type.flight': '航空便', + 'reservations.type.hotel': '宿泊', + 'reservations.type.restaurant': 'レストラン', + 'reservations.type.train': '列車', + 'reservations.type.car': 'レンタカー', + 'reservations.type.cruise': 'クルーズ', + 'reservations.type.event': 'イベント', + 'reservations.type.tour': 'ツアー', + 'reservations.type.other': 'その他', + 'reservations.confirm.delete': '予約「{name}」を削除しますか?', + 'reservations.confirm.deleteTitle': '予約を削除しますか?', + 'reservations.confirm.deleteBody': '「{name}」は完全に削除されます。', + 'reservations.toast.updated': '予約を更新しました', + 'reservations.toast.removed': '予約を削除しました', + 'reservations.toast.fileUploaded': 'ファイルをアップロードしました', + 'reservations.toast.uploadError': 'アップロードに失敗しました', + 'reservations.newTitle': '新しい予約', + 'reservations.bookingType': '予約タイプ', + 'reservations.titleLabel': 'タイトル', + 'reservations.titlePlaceholder': '例:Lufthansa LH123、Hotel Adlon', + 'reservations.locationAddress': '場所/住所', + 'reservations.locationPlaceholder': '住所、空港、ホテル...', + 'reservations.confirmationCode': '予約コード', + 'reservations.confirmationPlaceholder': '例:ABC12345', + 'reservations.day': '日', + 'reservations.noDay': '日なし', + 'reservations.place': '場所', + 'reservations.noPlace': '場所なし', + 'reservations.pendingSave': '保存されます…', + 'reservations.uploading': 'アップロード中...', + 'reservations.attachFile': 'ファイルを添付', + 'reservations.linkExisting': '既存ファイルをリンク', + 'reservations.toast.saveError': '保存に失敗しました', + 'reservations.toast.updateError': '更新に失敗しました', + 'reservations.toast.deleteError': '削除に失敗しました', + 'reservations.confirm.remove': '「{name}」の予約を削除しますか?', + 'reservations.linkAssignment': '日への割り当てにリンク', + 'reservations.pickAssignment': '計画から割り当てを選択...', + 'reservations.noAssignment': 'リンクなし(単独)', + 'reservations.price': '価格', + 'reservations.budgetCategory': '予算カテゴリ', + 'reservations.budgetCategoryPlaceholder': '例:交通、宿泊', + 'reservations.budgetCategoryAuto': '自動(予約タイプから)', + 'reservations.budgetHint': '保存時に予算エントリが自動で作成されます。', + 'reservations.departureDate': '出発', + 'reservations.arrivalDate': '到着', + 'reservations.departureTime': '出発時刻', + 'reservations.arrivalTime': '到着時刻', + 'reservations.pickupDate': '受取', + 'reservations.returnDate': '返却', + 'reservations.pickupTime': '受取時刻', + 'reservations.returnTime': '返却時刻', + 'reservations.endDate': '終了日', + 'reservations.meta.departureTimezone': '出発TZ', + 'reservations.meta.arrivalTimezone': '到着TZ', + 'reservations.span.departure': '出発', + 'reservations.span.arrival': '到着', + 'reservations.span.inTransit': '移動中', + 'reservations.span.pickup': '受取', + 'reservations.span.return': '返却', + 'reservations.span.active': '有効', + 'reservations.span.start': '開始', + 'reservations.span.end': '終了', + 'reservations.span.ongoing': '進行中', + 'reservations.validation.endBeforeStart': '終了日時は開始日時より後である必要があります', + 'reservations.addBooking': '予約を追加', + + // Budget + 'budget.title': '予算', + 'budget.exportCsv': 'CSVを書き出し', + 'budget.emptyTitle': '予算はまだありません', + 'budget.emptyText': 'カテゴリと項目を作成して旅行予算を計画しましょう', + 'budget.emptyPlaceholder': 'カテゴリ名を入力...', + 'budget.createCategory': 'カテゴリを作成', + 'budget.category': 'カテゴリ', + 'budget.categoryName': 'カテゴリ名', + 'budget.table.name': '名前', + 'budget.table.total': '合計', + 'budget.table.persons': '人数', + 'budget.table.days': '日数', + 'budget.table.perPerson': '1人あたり', + 'budget.table.perDay': '1日あたり', + 'budget.table.perPersonDay': '人/日', + 'budget.table.note': 'メモ', + 'budget.table.date': '日付', + 'budget.newEntry': '新しい項目', + 'budget.defaultEntry': '新しい項目', + 'budget.defaultCategory': '新しいカテゴリ', + 'budget.total': '合計', + 'budget.totalBudget': '総予算', + 'budget.byCategory': 'カテゴリ別', + 'budget.editTooltip': 'クリックして編集', + 'budget.linkedToReservation': '予約に連携中 — 名前はそちらで編集してください', + 'budget.confirm.deleteCategory': '{count}件の項目があるカテゴリ「{name}」を削除しますか?', + 'budget.deleteCategory': 'カテゴリを削除', + 'budget.perPerson': '1人あたり', + 'budget.paid': '支払済み', + 'budget.open': '未精算', + 'budget.noMembers': 'メンバー未割り当て', + 'budget.settlement': '精算', + 'budget.settlementInfo': '予算項目のメンバーアイコンをクリックして緑にすると、支払い済みを示します。精算では、誰が誰にいくら支払うべきかを表示します。', + 'budget.netBalances': '差引残高', + +// Files + 'files.title': 'ファイル', + 'files.pageTitle': 'ファイル・ドキュメント', + 'files.subtitle': '{trip} のファイル {count} 件', + 'files.download': 'ダウンロード', + 'files.openError': 'ファイルを開けませんでした', + 'files.downloadPdf': 'PDFをダウンロード', + 'files.count': '{count}件のファイル', + 'files.countSingular': '1件のファイル', + 'files.uploaded': '{count}件アップロード', + 'files.uploadError': 'アップロードに失敗しました', + 'files.dropzone': 'ここにファイルをドロップ', + 'files.dropzoneHint': 'またはクリックして参照', + 'files.allowedTypes': '画像、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大50MB', + 'files.uploading': 'アップロード中...', + 'files.filterAll': 'すべて', + 'files.filterPdf': 'PDF', + 'files.filterImages': '画像', + 'files.filterDocs': 'ドキュメント', + 'files.filterCollab': 'Collabメモ', + 'files.sourceCollab': 'Collabメモより', + 'files.empty': 'ファイルはまだありません', + 'files.emptyHint': 'ファイルをアップロードして旅行に添付しましょう', + 'files.openTab': '新しいタブで開く', + 'files.confirm.delete': 'このファイルを削除しますか?', + 'files.toast.deleted': 'ファイルを削除しました', + 'files.toast.deleteError': 'ファイルの削除に失敗しました', + 'files.sourcePlan': '日別計画', + 'files.sourceBooking': '予約', + 'files.attach': '添付', + 'files.pasteHint': 'クリップボードから画像を貼り付けることもできます(Ctrl+V)', + 'files.trash': 'ゴミ箱', + 'files.trashEmpty': 'ゴミ箱は空です', + 'files.emptyTrash': 'ゴミ箱を空にする', + 'files.restore': '復元', + 'files.star': 'スター', + 'files.unstar': 'スター解除', + 'files.assign': '割り当て', + 'files.assignTitle': 'ファイルを割り当て', + 'files.assignPlace': '場所', + 'files.assignBooking': '予約', + 'files.unassigned': '未割り当て', + 'files.unlink': 'リンクを解除', + 'files.toast.trashed': 'ゴミ箱に移動しました', + 'files.toast.restored': 'ファイルを復元しました', + 'files.toast.trashEmptied': 'ゴミ箱を空にしました', + 'files.toast.assigned': 'ファイルを割り当てました', + 'files.toast.assignError': '割り当てに失敗しました', + 'files.toast.restoreError': '復元に失敗しました', + 'files.confirm.permanentDelete': 'このファイルを完全に削除しますか?元に戻せません。', + 'files.confirm.emptyTrash': 'ゴミ箱内のファイルをすべて完全に削除しますか?元に戻せません。', + 'files.noteLabel': 'メモ', + 'files.notePlaceholder': 'メモを追加...', + +// Packing + 'packing.title': '持ち物リスト', + 'packing.empty': '持ち物リストは空です', + 'packing.import': 'インポート', + 'packing.importTitle': '持ち物リストをインポート', + 'packing.importHint': '1行につき1項目。形式:カテゴリ, 名前, 重量(g・任意), バッグ(任意), checked/unchecked(任意)', + 'packing.importPlaceholder': '衛生用品, 歯ブラシ\n衣類, Tシャツ, 200\n書類, パスポート, , 機内持ち込み\n電子機器, 充電器, 50, スーツケース, checked', + 'packing.importCsv': 'CSV/TXTを読み込む', + 'packing.importAction': '{count}件をインポート', + 'packing.importSuccess': '{count}件インポートしました', + 'packing.importError': 'インポートに失敗しました', + 'packing.importEmpty': 'インポートする項目がありません', + 'packing.progress': '{packed}/{total} 梱包済み({percent}%)', + 'packing.clearChecked': 'チェック済み{count}件を削除', + 'packing.clearCheckedShort': '{count}件を削除', + 'packing.suggestions': 'おすすめ', + 'packing.suggestionsTitle': 'おすすめを追加', + 'packing.allSuggested': 'おすすめはすべて追加済み', + 'packing.allPacked': 'すべて梱包済み!', + 'packing.addPlaceholder': '新しい項目を追加...', + 'packing.categoryPlaceholder': 'カテゴリ...', + 'packing.filterAll': 'すべて', + 'packing.filterOpen': '未完了', + 'packing.filterDone': '完了', + 'packing.emptyTitle': '持ち物リストは空です', + 'packing.emptyHint': '項目を追加するか、おすすめを使いましょう', + 'packing.emptyFiltered': 'このフィルターに一致する項目はありません', + 'packing.menuRename': '名前を変更', + 'packing.menuCheckAll': 'すべてチェック', + 'packing.menuUncheckAll': 'すべて解除', + 'packing.menuDeleteCat': 'カテゴリを削除', + 'packing.noMembers': '旅行メンバーがいません', + 'packing.addItem': '項目を追加', + 'packing.addItemPlaceholder': '項目名...', + 'packing.addCategory': 'カテゴリを追加', + 'packing.newCategoryPlaceholder': 'カテゴリ名(例:衣類)', + 'packing.applyTemplate': 'テンプレートを適用', + 'packing.template': 'テンプレート', + 'packing.templateApplied': 'テンプレートから{count}件追加しました', + 'packing.templateError': 'テンプレートの適用に失敗しました', + 'packing.saveAsTemplate': 'テンプレートとして保存', + 'packing.templateName': 'テンプレート名', + 'packing.templateSaved': '持ち物リストをテンプレートとして保存しました', + 'packing.bags': 'バッグ', + 'packing.noBag': '未割り当て', + 'packing.totalWeight': '総重量', + 'packing.bagName': 'バッグ名...', + 'packing.addBag': 'バッグを追加', + 'packing.changeCategory': 'カテゴリを変更', + 'packing.confirm.clearChecked': 'チェック済み{count}件を削除しますか?', + 'packing.confirm.deleteCat': '{count}件の項目があるカテゴリ「{name}」を削除しますか?', + 'packing.defaultCategory': 'その他', + 'packing.toast.saveError': '保存に失敗しました', + 'packing.toast.deleteError': '削除に失敗しました', + 'packing.toast.renameError': '名前の変更に失敗しました', + 'packing.toast.addError': '追加に失敗しました', + +// Packing suggestions + 'packing.suggestions.items': [ + { name: 'パスポート', category: '書類' }, + { name: '身分証明書', category: '書類' }, + { name: '海外旅行保険', category: '書類' }, + { name: '航空券', category: '書類' }, + { name: 'クレジットカード', category: '金融' }, + { name: '現金', category: '金融' }, + { name: 'ビザ', category: '書類' }, + { name: 'Tシャツ', category: '衣類' }, + { name: 'ズボン', category: '衣類' }, + { name: '下着', category: '衣類' }, + { name: '靴下', category: '衣類' }, + { name: '上着', category: '衣類' }, + { name: '寝間着', category: '衣類' }, + { name: '水着', category: '衣類' }, + { name: 'レインジャケット', category: '衣類' }, + { name: '歩きやすい靴', category: '衣類' }, + { name: '歯ブラシ', category: '洗面用具' }, + { name: '歯磨き粉', category: '洗面用具' }, + { name: 'シャンプー', category: '洗面用具' }, + { name: 'デオドラント', category: '洗面用具' }, + { name: '日焼け止め', category: '洗面用具' }, + { name: 'カミソリ', category: '洗面用具' }, + { name: '充電器', category: '電子機器' }, + { name: 'モバイルバッテリー', category: '電子機器' }, + { name: 'ヘッドホン', category: '電子機器' }, + { name: '変換プラグ', category: '電子機器' }, + { name: 'カメラ', category: '電子機器' }, + { name: '鎮痛薬', category: '健康' }, + { name: '絆創膏', category: '健康' }, + { name: '消毒液', category: '健康' }, + ], + + // Members / Sharing + 'members.shareTrip': '旅行を共有', + 'members.inviteUser': 'ユーザーを招待', + 'members.selectUser': 'ユーザーを選択…', + 'members.invite': '招待', + 'members.allHaveAccess': 'すでに全員がアクセスできます。', + 'members.access': 'アクセス', + 'members.person': '人', + 'members.persons': '人', + 'members.you': 'あなた', + 'members.owner': 'オーナー', + 'members.leaveTrip': '旅行を退出', + 'members.removeAccess': 'アクセスを削除', + 'members.confirmLeave': '旅行を退出しますか?アクセスできなくなります。', + 'members.confirmRemove': 'このユーザーのアクセスを削除しますか?', + 'members.loadError': 'メンバーの読み込みに失敗しました', + 'members.added': '追加しました', + 'members.addError': '追加に失敗しました', + 'members.removed': 'メンバーを削除しました', + 'members.removeError': '削除に失敗しました', + +// Categories (Admin) + 'categories.title': 'カテゴリ', + 'categories.subtitle': '場所のカテゴリを管理', + 'categories.new': '新しいカテゴリ', + 'categories.empty': 'カテゴリはまだありません', + 'categories.namePlaceholder': 'カテゴリ名', + 'categories.icon': 'アイコン', + 'categories.color': '色', + 'categories.customColor': 'カスタムカラーを選択', + 'categories.preview': 'プレビュー', + 'categories.defaultName': 'カテゴリ', + 'categories.update': '更新', + 'categories.create': '作成', + 'categories.confirm.delete': 'カテゴリを削除しますか?このカテゴリの場所は削除されません。', + 'categories.toast.loadError': 'カテゴリの読み込みに失敗しました', + 'categories.toast.nameRequired': '名前を入力してください', + 'categories.toast.updated': 'カテゴリを更新しました', + 'categories.toast.created': 'カテゴリを作成しました', + 'categories.toast.saveError': '保存に失敗しました', + 'categories.toast.deleted': 'カテゴリを削除しました', + 'categories.toast.deleteError': '削除に失敗しました', + +// Backup (Admin) + 'backup.title': 'データバックアップ', + 'backup.subtitle': 'データベースとアップロードされたすべてのファイル', + 'backup.refresh': '更新', + 'backup.upload': 'バックアップをアップロード', + 'backup.uploading': 'アップロード中…', + 'backup.create': 'バックアップを作成', + 'backup.creating': '作成中…', + 'backup.empty': 'バックアップはまだありません', + 'backup.createFirst': '最初のバックアップを作成', + 'backup.download': 'ダウンロード', + 'backup.restore': '復元', + 'backup.confirm.restore': 'バックアップ「{name}」を復元しますか?\n\n現在のすべてのデータはバックアップで置き換えられます。', + 'backup.confirm.uploadRestore': 'バックアップファイル「{name}」をアップロードして復元しますか?\n\n現在のすべてのデータは上書きされます。', + 'backup.confirm.delete': 'バックアップ「{name}」を削除しますか?', + 'backup.toast.loadError': 'バックアップの読み込みに失敗しました', + 'backup.toast.created': 'バックアップを作成しました', + 'backup.toast.createError': 'バックアップの作成に失敗しました', + 'backup.toast.restored': 'バックアップを復元しました。ページを再読み込みします…', + 'backup.toast.restoreError': '復元に失敗しました', + 'backup.toast.uploadError': 'アップロードに失敗しました', + 'backup.toast.deleted': 'バックアップを削除しました', + 'backup.toast.deleteError': '削除に失敗しました', + 'backup.toast.downloadError': 'ダウンロードに失敗しました', + 'backup.toast.settingsSaved': '自動バックアップ設定を保存しました', + 'backup.toast.settingsError': '設定の保存に失敗しました', + 'backup.auto.title': '自動バックアップ', + 'backup.auto.subtitle': 'スケジュールに基づいて自動実行', + 'backup.auto.enable': '自動バックアップを有効化', + 'backup.auto.enableHint': '選択したスケジュールで自動的に作成されます', + 'backup.auto.interval': '間隔', + 'backup.auto.hour': '実行時刻', + 'backup.auto.hourHint': 'サーバーのローカル時刻({format}形式)', + 'backup.auto.dayOfWeek': '曜日', + 'backup.auto.dayOfMonth': '月の日', + 'backup.auto.dayOfMonthHint': 'すべての月に対応するため1~28に制限されています', + 'backup.auto.scheduleSummary': 'スケジュール', + 'backup.auto.summaryDaily': '毎日 {hour}:00', + 'backup.auto.summaryWeekly': '毎週{day} {hour}:00', + 'backup.auto.summaryMonthly': '毎月{day}日 {hour}:00', + 'backup.auto.envLocked': 'Docker', + 'backup.auto.envLockedHint': '自動バックアップはDockerの環境変数で設定されています。変更するにはdocker-compose.ymlを更新し、コンテナを再起動してください。', + 'backup.auto.copyEnv': 'Docker環境変数をコピー', + 'backup.auto.envCopied': 'Docker環境変数をコピーしました', + 'backup.auto.keepLabel': '古いバックアップを削除', + 'backup.dow.sunday': '日', + 'backup.dow.monday': '月', + 'backup.dow.tuesday': '火', + 'backup.dow.wednesday': '水', + 'backup.dow.thursday': '木', + 'backup.dow.friday': '金', + 'backup.dow.saturday': '土', + 'backup.interval.hourly': '毎時間', + 'backup.interval.daily': '毎日', + 'backup.interval.weekly': '毎週', + 'backup.interval.monthly': '毎月', + 'backup.keep.1day': '1日', + 'backup.keep.3days': '3日', + 'backup.keep.7days': '7日', + 'backup.keep.14days': '14日', + 'backup.keep.30days': '30日', + 'backup.keep.forever': '無期限', + +// Photos + 'photos.title': '写真', + 'photos.subtitle': '{trip} の写真 {count} 枚', + 'photos.dropHere': 'ここに写真をドロップ…', + 'photos.dropHereActive': 'ここに写真をドロップ', + 'photos.captionForAll': 'キャプション(全体)', + 'photos.captionPlaceholder': '任意のキャプション…', + 'photos.addCaption': 'キャプションを追加…', + 'photos.allDays': 'すべての日', + 'photos.noPhotos': 'まだ写真はありません', + 'photos.uploadHint': '旅行の写真をアップロード', + 'photos.clickToSelect': 'またはクリックして選択', + 'photos.linkPlace': '場所を紐づけ', + 'photos.noPlace': '場所なし', + 'photos.uploadN': '{n} 枚の写真をアップロード', + 'photos.linkDay': '日を紐づけ', + 'photos.noDay': '日付なし', + 'photos.dayLabel': '{number}日目', + 'photos.photoSelected': '写真を選択しました', + 'photos.photosSelected': '写真を選択しました', + 'photos.fileTypeHint': 'JPG、PNG、WebP · 最大 10 MB · 最大 30 枚', + +// Backup restore modal + 'backup.restoreConfirmTitle': 'バックアップを復元しますか?', + 'backup.restoreWarning': '現在のすべてのデータ(旅行、場所、ユーザー、アップロード)はバックアップで完全に置き換えられます。この操作は元に戻せません。', + 'backup.restoreTip': 'ヒント:復元前に現在の状態をバックアップすることをおすすめします。', + 'backup.restoreConfirm': 'はい、復元します', + +// PDF + 'pdf.travelPlan': '旅行計画', + 'pdf.planned': '予定', + 'pdf.costLabel': '費用(EUR)', + 'pdf.preview': 'PDFプレビュー', + 'pdf.saveAsPdf': 'PDFとして保存', + + // Planner + 'planner.places': '場所', + 'planner.bookings': '予約', + 'planner.packingList': '持ち物リスト', + 'planner.documents': 'ドキュメント', + 'planner.dayPlan': '日別計画', + 'planner.reservations': '予約', + 'planner.minTwoPlaces': '座標付きの場所が少なくとも2つ必要です', + 'planner.noGeoPlaces': '座標付きの場所がありません', + 'planner.routeCalculated': 'ルートを計算しました', + 'planner.routeCalcFailed': 'ルートを計算できませんでした', + 'planner.routeError': 'ルート計算中にエラーが発生しました', + 'planner.icsExportFailed': 'ICSの書き出しに失敗しました', + 'planner.routeOptimized': 'ルートを最適化しました', + 'planner.reservationUpdated': '予約を更新しました', + 'planner.reservationAdded': '予約を追加しました', + 'planner.confirmDeleteReservation': '予約を削除しますか?', + 'planner.reservationDeleted': '予約を削除しました', + 'planner.days': '日', + 'planner.allPlaces': 'すべての場所', + 'planner.totalPlaces': '合計{n}件の場所', + 'planner.noDaysPlanned': '計画された日がありません', + 'planner.editTrip': '旅行を編集 →', + 'planner.placeOne': '1件の場所', + 'planner.placeN': '{n}件の場所', + 'planner.addNote': 'メモを追加', + 'planner.noEntries': 'この日の予定はありません', + 'planner.addPlace': '場所/アクティビティを追加', + 'planner.addPlaceShort': '+ 場所/アクティビティ', + 'planner.resPending': '予約保留 · ', + 'planner.resConfirmed': '予約確定 · ', + 'planner.notePlaceholder': 'メモ…', + 'planner.noteTimePlaceholder': '時刻(任意)', + 'planner.noteExamplePlaceholder': '例:中央駅から14:30発のS3、7番桟橋からフェリー、昼食休憩…', + 'planner.totalCost': '合計費用', + 'planner.searchPlaces': '場所を検索…', + 'planner.allCategories': 'すべてのカテゴリ', + 'planner.noPlacesFound': '場所が見つかりません', + 'planner.addFirstPlace': '最初の場所を追加', + 'planner.noReservations': '予約はありません', + 'planner.addFirstReservation': '最初の予約を追加', + 'planner.new': '新規', + 'planner.addToDay': '+ 日', + 'planner.calculating': '計算中…', + 'planner.route': 'ルート', + 'planner.optimize': '最適化', + 'planner.openGoogleMaps': 'Googleマップで開く', + 'planner.selectDayHint': '左の一覧から日を選択すると、日別計画が表示されます', + 'planner.noPlacesForDay': 'この日の場所はまだありません', + 'planner.addPlacesLink': '場所を追加 →', + 'planner.minTotal': '最短合計', + 'planner.noReservation': '予約なし', + 'planner.removeFromDay': 'この日から削除', + 'planner.addToThisDay': 'この日に追加', + 'planner.overview': '概要', + 'planner.noDays': '日がありません', + 'planner.editTripToAddDays': '旅行を編集して日を追加', + 'planner.dayCount': '{n}日間', + 'planner.clickToUnlock': 'クリックして解除', + 'planner.keepPosition': '最適化中も位置を保持', + 'planner.dayDetails': '日詳細', + 'planner.dayN': '{n}日目', + +// Dashboard Stats + 'stats.countries': '国', + 'stats.cities': '都市', + 'stats.trips': '旅行', + 'stats.places': '場所', + 'stats.worldProgress': '世界進捗', + 'stats.visited': '訪問済み', + 'stats.remaining': '未訪問', + 'stats.visitedCountries': '訪問国', + +// Day Detail Panel + 'day.precipProb': '降水確率', + 'day.precipitation': '降水量', + 'day.wind': '風', + 'day.sunrise': '日の出', + 'day.sunset': '日の入り', + 'day.hourlyForecast': '時間別予報', + 'day.climateHint': '過去の平均値 — 実際の予報はこの日付の16日前から表示されます。', + 'day.noWeather': '天気データがありません。座標付きの場所を追加してください。', + 'day.overview': '1日の概要', + 'day.accommodation': '宿泊先', + 'day.addAccommodation': '宿泊先を追加', + 'day.hotelDayRange': '適用日', + 'day.noPlacesForHotel': '先に旅行に場所を追加してください', + 'day.allDays': 'すべて', + 'day.checkIn': 'チェックイン', + 'day.checkInUntil': 'チェックイン期限', + 'day.checkOut': 'チェックアウト', + 'day.confirmation': '確認', + 'day.editAccommodation': '宿泊先を編集', + 'day.reservations': '予約', + +// Photos / Immich + 'memories.title': '写真', + 'memories.notConnected': '{provider_name} が接続されていません', + 'memories.notConnectedHint': '設定で {provider_name} インスタンスを接続すると、この旅行に写真を追加できます。', + 'memories.notConnectedMultipleHint': '設定で次の写真プロバイダーのいずれかを接続してください:{provider_names}', + 'memories.noDates': '写真を読み込むには旅行の日付を追加してください。', + 'memories.noPhotos': '写真が見つかりません', + 'memories.noPhotosHint': '{provider_name} にこの旅行期間の写真がありません。', + 'memories.photosFound': '枚の写真', + 'memories.fromOthers': '他のユーザーから', + 'memories.sharePhotos': '写真を共有', + 'memories.sharing': '共有', + 'memories.reviewTitle': '写真を確認', + 'memories.reviewHint': 'クリックして共有から除外できます。', + 'memories.shareCount': '{count}枚の写真を共有', + //------------------------- + //todo section + 'memories.providerUrl': 'サーバーURL', + 'memories.providerApiKey': 'APIキー', + 'memories.providerUsername': 'ユーザー名', + 'memories.providerPassword': 'パスワード', + 'memories.providerOTP': 'MFAコード(有効な場合)', + 'memories.skipSSLVerification': 'SSL証明書の検証をスキップ', + 'memories.immichAutoUpload': 'アップロード時に旅程の写真をImmichにミラー', + 'memories.providerUrlHintSynology': 'URLにPhotosアプリのパスを含めてください(例:https://nas:5001/photo)', + 'memories.testConnection': '接続をテスト', + 'memories.testFirst': '先に接続をテストしてください', + 'memories.connected': '接続済み', + 'memories.disconnected': '未接続', + 'memories.connectionSuccess': '{provider_name} に接続しました', + 'memories.connectionError': '{provider_name} に接続できませんでした', + 'memories.saved': '{provider_name} の設定を保存しました', + 'memories.providerDisconnectedBanner': '{provider_name} との接続が切れています。写真を見るには設定で再接続してください。', + 'memories.saveError': '{provider_name} の設定を保存できませんでした', + //------------------------ + 'memories.addPhotos': '写真を追加', + 'memories.linkAlbum': 'アルバムをリンク', + 'memories.selectAlbum': '{provider_name} のアルバムを選択', + 'memories.selectAlbumMultiple': 'アルバムを選択', + 'memories.noAlbums': 'アルバムが見つかりません', + 'memories.syncAlbum': 'アルバムを同期', + 'memories.unlinkAlbum': 'アルバムのリンクを解除', + 'memories.photos': '写真', + 'memories.selectPhotos': '{provider_name} から写真を選択', + 'memories.selectPhotosMultiple': '写真を選択', + 'memories.selectHint': '写真をタップして選択してください。', + 'memories.selected': '選択済み', + 'memories.addSelected': '{count} 枚の写真を追加', + 'memories.alreadyAdded': '追加済み', + 'memories.private': '非公開', + 'memories.stopSharing': '共有を停止', + 'memories.oldest': '古い順', + 'memories.newest': '新しい順', + 'memories.allLocations': 'すべての場所', + 'memories.tripDates': '旅行期間', + 'memories.allPhotos': 'すべての写真', + 'memories.confirmShareTitle': '旅行メンバーと共有しますか?', + 'memories.confirmShareHint': '{count} 枚の写真がこの旅行の全メンバーに表示されます。後から個別に非公開にできます。', + 'memories.confirmShareButton': '写真を共有', + 'memories.error.loadAlbums': 'アルバムの読み込みに失敗しました', + 'memories.error.linkAlbum': 'アルバムのリンクに失敗しました', + 'memories.error.unlinkAlbum': 'アルバムのリンク解除に失敗しました', + 'memories.error.syncAlbum': 'アルバムの同期に失敗しました', + 'memories.error.loadPhotos': '写真の読み込みに失敗しました', + 'memories.error.addPhotos': '写真の追加に失敗しました', + 'memories.error.removePhoto': '写真の削除に失敗しました', + 'memories.error.toggleSharing': '共有設定の更新に失敗しました', + 'memories.saveRouteNotConfigured': 'このプロバイダーでは保存先が設定されていません', + 'memories.testRouteNotConfigured': 'このプロバイダーではテスト用の保存先が設定されていません', + 'memories.fillRequiredFields': '必須項目をすべて入力してください', + + // Collab Addon + 'collab.tabs.chat': 'チャット', + 'collab.tabs.notes': 'ノート', + 'collab.tabs.polls': '投票', +'collab.whatsNext.title': '次にすること', + 'collab.whatsNext.today': '今日', + 'collab.whatsNext.tomorrow': '明日', + 'collab.whatsNext.empty': '予定されたアクティビティはありません', + 'collab.whatsNext.until': '〜', + 'collab.whatsNext.emptyHint': '時間が設定されたアクティビティがここに表示されます', + 'collab.chat.send': '送信', + 'collab.chat.placeholder': 'メッセージを入力…', + 'collab.chat.empty': '会話を始めましょう', + 'collab.chat.emptyHint': 'メッセージは旅行メンバー全員と共有されます', + 'collab.chat.emptyDesc': 'アイデアや計画、最新情報を共有しましょう', + 'collab.chat.today': '今日', + 'collab.chat.yesterday': '昨日', + 'collab.chat.deletedMessage': 'メッセージを削除しました', + 'collab.chat.reply': '返信', + 'collab.chat.loadMore': '以前のメッセージを読み込む', + 'collab.chat.justNow': 'たった今', + 'collab.chat.minutesAgo': '{n}分前', + 'collab.chat.hoursAgo': '{n}時間前', + 'collab.notes.title': 'ノート', + 'collab.notes.new': '新規ノート', + 'collab.notes.empty': 'まだノートがありません', + 'collab.notes.emptyHint': 'アイデアや計画を書き留めましょう', + 'collab.notes.all': 'すべて', + 'collab.notes.titlePlaceholder': 'ノートのタイトル', + 'collab.notes.contentPlaceholder': '内容を入力…', + 'collab.notes.categoryPlaceholder': 'カテゴリ', + 'collab.notes.newCategory': '新しいカテゴリ…', + 'collab.notes.category': 'カテゴリ', + 'collab.notes.noCategory': 'カテゴリなし', + 'collab.notes.color': '色', + 'collab.notes.save': '保存', + 'collab.notes.cancel': 'キャンセル', + 'collab.notes.edit': '編集', + 'collab.notes.delete': '削除', + 'collab.notes.pin': '固定', + 'collab.notes.unpin': '固定を解除', + 'collab.notes.daysAgo': '{n}日前', + 'collab.notes.categorySettings': 'カテゴリ管理', + 'collab.notes.create': '作成', + 'collab.notes.website': 'ウェブサイト', + 'collab.notes.websitePlaceholder': 'https://...', + 'collab.notes.attachFiles': 'ファイルを添付', + 'collab.notes.noCategoriesYet': 'カテゴリがまだありません', + 'collab.notes.emptyDesc': 'ノートを作成して始めましょう', + 'collab.polls.title': '投票', + 'collab.polls.new': '新しい投票', + 'collab.polls.empty': 'まだ投票がありません', + 'collab.polls.emptyHint': '質問してみんなで投票しましょう', + 'collab.polls.question': '質問', + 'collab.polls.questionPlaceholder': '何をしますか?', + 'collab.polls.addOption': '+ 選択肢を追加', + 'collab.polls.optionPlaceholder': '選択肢 {n}', + 'collab.polls.create': '投票を作成', + 'collab.polls.close': '閉じる', + 'collab.polls.closed': '終了', + 'collab.polls.votes': '{n}票', + 'collab.polls.vote': '{n}票', + 'collab.polls.multipleChoice': '複数選択', + 'collab.polls.multiChoice': '複数選択', + 'collab.polls.deadline': '締切', + 'collab.polls.option': '選択肢', + 'collab.polls.options': '選択肢', + 'collab.polls.delete': '削除', + 'collab.polls.closedSection': '終了', + + // Permissions + 'admin.tabs.permissions': '権限', + 'perm.title': '権限設定', + 'perm.subtitle': 'アプリ全体の操作権限を管理', + 'perm.saved': '権限設定を保存しました', + 'perm.resetDefaults': '既定に戻す', + 'perm.customized': 'カスタマイズ済み', + 'perm.level.admin': '管理者のみ', + 'perm.level.tripOwner': '旅行オーナー', + 'perm.level.tripMember': '旅行メンバー', + 'perm.level.everybody': '全員', + 'perm.cat.trip': '旅行管理', + 'perm.cat.members': 'メンバー管理', + 'perm.cat.files': 'ファイル', + 'perm.cat.content': 'コンテンツと予定', + 'perm.cat.extras': '予算・持ち物・コラボ', + 'perm.action.trip_create': '旅行を作成', + 'perm.action.trip_edit': '旅行詳細を編集', + 'perm.action.trip_delete': '旅行を削除', + 'perm.action.trip_archive': '旅行をアーカイブ/復元', + 'perm.action.trip_cover_upload': 'カバー画像をアップロード', + 'perm.action.member_manage': 'メンバーを追加/削除', + 'perm.action.file_upload': 'ファイルをアップロード', + 'perm.action.file_edit': 'ファイル情報を編集', + 'perm.action.file_delete': 'ファイルを削除', + 'perm.action.place_edit': '場所を追加/編集/削除', + 'perm.action.day_edit': '日・メモ・割り当てを編集', + 'perm.action.reservation_edit': '予約を管理', + 'perm.action.budget_edit': '予算を管理', + 'perm.action.packing_edit': '持ち物を管理', + 'perm.action.collab_edit': 'コラボ(メモ・投票・チャット)', + 'perm.action.share_manage': '共有リンクを管理', + 'perm.actionHint.trip_create': '新しい旅行を作成できる人', + 'perm.actionHint.trip_edit': '旅行名や日付などを変更できる人', + 'perm.actionHint.trip_delete': '旅行を完全に削除できる人', + 'perm.actionHint.trip_archive': '旅行をアーカイブできる人', + 'perm.actionHint.trip_cover_upload': 'カバー画像を変更できる人', + 'perm.actionHint.member_manage': 'メンバーを招待/削除できる人', + 'perm.actionHint.file_upload': 'ファイルをアップロードできる人', + 'perm.actionHint.file_edit': 'ファイル説明やリンクを編集できる人', + 'perm.actionHint.file_delete': 'ファイルをゴミ箱へ移動/完全削除できる人', + 'perm.actionHint.place_edit': '場所を追加・編集・削除できる人', + 'perm.actionHint.day_edit': '日やメモ、割り当てを編集できる人', + 'perm.actionHint.reservation_edit': '予約を作成・編集・削除できる人', + 'perm.actionHint.budget_edit': '予算項目を管理できる人', + 'perm.actionHint.packing_edit': '持ち物やバッグを管理できる人', + 'perm.actionHint.collab_edit': 'メモや投票、メッセージを作成できる人', + 'perm.actionHint.share_manage': '公開共有リンクを管理できる人', + + // Undo + 'undo.button': '元に戻す', + 'undo.tooltip': '元に戻す: {action}', + 'undo.assignPlace': '場所を日に割り当て', + 'undo.removeAssignment': '日の割り当てを解除', + 'undo.reorder': '場所を並び替え', + 'undo.optimize': 'ルートを最適化', + 'undo.deletePlace': '場所を削除', + 'undo.deletePlaces': '場所を削除', + 'undo.moveDay': '場所を別の日に移動', + 'undo.lock': '場所のロックを切り替え', + 'undo.importGpx': 'GPXをインポート', + 'undo.importKeyholeMarkup': 'KMZ/KMLをインポート', + 'undo.importGoogleList': 'Googleマップをインポート', + 'undo.importNaverList': 'Naverマップをインポート', + 'undo.addPlace': '場所を追加', + 'undo.done': '元に戻しました: {action}', + + // Notifications + 'notifications.title': '通知', + 'notifications.markAllRead': 'すべて既読', + 'notifications.deleteAll': 'すべて削除', + 'notifications.showAll': 'すべて表示', + 'notifications.empty': '通知はありません', + 'notifications.emptyDescription': 'すべて確認済みです!', + 'notifications.all': 'すべて', + 'notifications.unreadOnly': '未読', + 'notifications.markRead': '既読にする', + 'notifications.markUnread': '未読にする', + 'notifications.delete': '削除', + 'notifications.system': 'システム', + 'notifications.synologySessionCleared.title': 'Synology Photosが切断されました', + 'notifications.synologySessionCleared.text': 'サーバーまたはアカウントが変更されました。設定で接続を再テストしてください。', + + // Notification test keys (dev only) + 'notifications.versionAvailable.title': '更新があります', + 'notifications.versionAvailable.text': 'TREK {version} が利用可能です。', + 'notifications.versionAvailable.button': '詳細を見る', + 'notifications.test.title': '{actor} からのテスト通知', + 'notifications.test.text': 'これはテスト通知です。', + 'notifications.test.booleanTitle': '{actor} が承認を求めています', + 'notifications.test.booleanText': 'テスト用の承認通知です。', + 'notifications.test.accept': '承認', + 'notifications.test.decline': '却下', + 'notifications.test.navigateTitle': '確認してください', + 'notifications.test.navigateText': 'テスト用の遷移通知です。', + 'notifications.test.goThere': '移動', + 'notifications.test.adminTitle': '管理者通知', + 'notifications.test.adminText': '{actor} が管理者全員に通知を送りました。', + 'notifications.test.tripTitle': '{actor} が旅行に投稿しました', + 'notifications.test.tripText': '旅行「{trip}」のテスト通知です。', + + // Todo + 'todo.subtab.packing': '持ち物リスト', + 'todo.subtab.todo': 'ToDo', + 'todo.completed': '完了', + 'todo.filter.all': 'すべて', + 'todo.filter.open': '未完了', + 'todo.filter.done': '完了', + 'todo.uncategorized': '未分類', + 'todo.namePlaceholder': 'タスク名', + 'todo.descriptionPlaceholder': '説明(任意)', + 'todo.unassigned': '未割り当て', + 'todo.noCategory': 'カテゴリなし', + 'todo.hasDescription': '説明あり', + 'todo.addItem': '新しいタスクを追加...', + 'todo.sidebar.sortBy': '並び替え', + 'todo.priority': '優先度', + 'todo.newCategoryLabel': '新規', + 'budget.categoriesLabel': 'カテゴリ', + 'todo.newCategory': 'カテゴリ名', + 'todo.addCategory': 'カテゴリを追加', + 'todo.newItem': '新しいタスク', + 'todo.empty': 'タスクはまだありません。追加して始めましょう!', + 'todo.filter.my': '自分のタスク', + 'todo.filter.overdue': '期限切れ', + 'todo.sidebar.tasks': 'タスク', + 'todo.sidebar.categories': 'カテゴリ', + 'todo.detail.title': 'タスク', + 'todo.detail.description': '説明', + 'todo.detail.category': 'カテゴリ', + 'todo.detail.dueDate': '期限', + 'todo.detail.assignedTo': '担当者', + 'todo.detail.delete': '削除', + 'todo.detail.save': '変更を保存', + 'todo.sortByPrio': '優先度', + 'todo.detail.priority': '優先度', + 'todo.detail.noPriority': 'なし', + 'todo.detail.create': 'タスクを作成', + + // Notifications — dev test events + 'notif.test.title': '[Test] Notification', + 'notif.test.simple.text': 'This is a simple test notification.', + 'notif.test.boolean.text': 'Do you accept this test notification?', + 'notif.test.navigate.text': 'Click below to navigate to the dashboard.', + + // Notifications + 'notif.trip_invite.title': '旅行への招待', + 'notif.trip_invite.text': '{actor}が「{trip}」に招待しました', + 'notif.booking_change.title': '予約が更新されました', + 'notif.booking_change.text': '{actor}が「{trip}」の予約を更新しました', + 'notif.trip_reminder.title': '旅行リマインド', + 'notif.trip_reminder.text': '旅行「{trip}」がまもなく始まります!', + 'notif.todo_due.title': 'ToDoの期限', + 'notif.todo_due.text': '「{trip}」の{todo}は{due}が期限です', + 'notif.vacay_invite.title': 'Vacay Fusionへの招待', + 'notif.vacay_invite.text': '{actor}から旅行プランの統合に招待されました', + 'notif.photos_shared.title': '写真が共有されました', + 'notif.photos_shared.text': '{actor}が「{trip}」で{count}枚の写真を共有しました', + 'notif.collab_message.title': '新しいメッセージ', + 'notif.collab_message.text': '{actor}が「{trip}」でメッセージを送りました', + 'notif.packing_tagged.title': '持ち物の割り当て', + 'notif.packing_tagged.text': '{actor}が「{trip}」の{category}をあなたに割り当てました', + 'notif.version_available.title': '新しいバージョンがあります', + 'notif.version_available.text': 'TREK {version}が利用可能です', + 'notif.action.view_trip': '旅行を見る', + 'notif.action.view_collab': 'メッセージを見る', + 'notif.action.view_packing': '持ち物を見る', + 'notif.action.view_photos': '写真を見る', + 'notif.action.view_vacay': 'Vacayを見る', + 'notif.action.view_admin': '管理画面へ', + 'notif.action.view': '表示', + 'notif.action.accept': '承認', + 'notif.action.decline': '拒否', + 'notif.generic.title': '通知', + 'notif.generic.text': '新しい通知があります', + 'notif.dev.unknown_event.title': '[DEV] 不明なイベント', + 'notif.dev.unknown_event.text': 'イベントタイプ「{event}」はEVENT_NOTIFICATION_CONFIGに登録されていません', + +// Journey addon + 'journey.search.placeholder': 'ジャーニーを検索…', + 'journey.search.noResults': '「{query}」に一致するジャーニーはありません', + 'journey.title': 'ジャーニー', + 'journey.subtitle': '旅の記録をリアルタイムで残そう', + 'journey.new': '新しいジャーニー', + 'journey.create': '作成', + 'journey.titlePlaceholder': 'どこへ行きますか?', + 'journey.empty': 'ジャーニーはまだありません', + 'journey.emptyHint': '次の旅を記録してみましょう', + 'journey.deleted': 'ジャーニーを削除しました', + 'journey.createError': 'ジャーニーを作成できませんでした', + 'journey.deleteError': 'ジャーニーを削除できませんでした', + 'journey.deleteConfirmTitle': '削除', + 'journey.deleteConfirmMessage': '「{title}」を削除しますか?元に戻せません。', + 'journey.deleteConfirmGeneric': '本当に削除しますか?', + 'journey.notFound': 'ジャーニーが見つかりません', + 'journey.photos': '写真', + 'journey.timelineEmpty': 'まだ立ち寄りがありません', + 'journey.timelineEmptyHint': 'チェックインするか、日記を書いて始めましょう', + 'journey.status.draft': '下書き', + 'journey.status.active': '進行中', + 'journey.status.completed': '完了', + 'journey.status.upcoming': '予定', + 'journey.status.archived': 'アーカイブ', + 'journey.checkin.add': 'チェックイン', + 'journey.checkin.namePlaceholder': '場所名', + 'journey.checkin.notesPlaceholder': 'メモ(任意)', + 'journey.checkin.save': '保存', + 'journey.checkin.error': 'チェックインを保存できませんでした', + 'journey.entry.add': '日記', + 'journey.entry.edit': '編集', + 'journey.entry.titlePlaceholder': 'タイトル(任意)', + 'journey.entry.bodyPlaceholder': '今日は何がありましたか?', + 'journey.entry.save': '保存', + 'journey.entry.error': '日記を保存できませんでした', + 'journey.photo.add': '写真', + 'journey.photo.uploadError': 'アップロードに失敗しました', + 'journey.share.share': '共有', + 'journey.share.public': '公開', + 'journey.share.linkCopied': '公開リンクをコピーしました', + 'journey.share.disabled': '公開共有は無効です', + 'journey.editor.titlePlaceholder': 'この瞬間に名前をつけて…', + 'journey.editor.bodyPlaceholder': 'この日のストーリーを書いてみよう…', + 'journey.editor.placePlaceholder': '場所(任意)', + 'journey.editor.tagsPlaceholder': 'タグ:穴場、最高の食事、また行きたい…', + 'journey.visibility.private': '非公開', + 'journey.visibility.shared': '共有', + 'journey.visibility.public': '公開', + 'journey.emptyState.title': 'ここから物語が始まります', + 'journey.emptyState.subtitle': '場所にチェックインするか、最初の日記を書いてみましょう', + +// Journey Frontpage + 'journey.frontpage.subtitle': '旅を、忘れられない物語に', + 'journey.frontpage.createJourney': 'ジャーニーを作成', + 'journey.frontpage.activeJourney': '進行中のジャーニー', + 'journey.frontpage.allJourneys': 'すべてのジャーニー', + 'journey.frontpage.journeys': 'ジャーニー', + 'journey.frontpage.createNew': '新しいジャーニーを作成', + 'journey.frontpage.createNewSub': '旅を選んで、物語を書き、共有しよう', + 'journey.frontpage.live': 'ライブ', + 'journey.frontpage.synced': '同期済み', + 'journey.frontpage.continueWriting': '続けて書く', + 'journey.frontpage.updated': '{time}に更新', + 'journey.frontpage.suggestionLabel': '旅行が終了しました', + 'journey.frontpage.suggestionText': '{title}をジャーニーにしよう', + 'journey.frontpage.dismiss': '閉じる', + 'journey.frontpage.journeyName': 'ジャーニー名', + 'journey.frontpage.namePlaceholder': '例:東南アジア 2026', + 'journey.frontpage.selectTrips': '旅行を選択', + 'journey.frontpage.tripsSelected': '件選択', + 'journey.frontpage.trips': '旅行', + 'journey.frontpage.placesImported': '場所がインポートされます', + 'journey.frontpage.places': '場所', + + // Journey Detail + 'journey.detail.backToJourney': 'ジャーニーに戻る', + 'journey.detail.syncedWithTrips': '旅行と同期済み', + 'journey.detail.addEntry': 'エントリーを追加', + 'journey.detail.newEntry': '新しいエントリー', + 'journey.detail.editEntry': 'エントリーを編集', + 'journey.detail.noEntries': 'エントリーはまだありません', + 'journey.detail.noEntriesHint': '旅行を追加して下書きエントリーを作成しましょう', + 'journey.detail.noPhotos': '写真はまだありません', + 'journey.detail.noPhotosHint': 'エントリーに写真を追加するか、Immich/Synologyライブラリを表示', + 'journey.detail.journeyTab': 'ジャーニー', + 'journey.detail.journeyStats': '統計', + 'journey.detail.syncedTrips': '同期中の旅行', + 'journey.detail.noTripsLinked': 'リンクされた旅行はありません', + 'journey.detail.contributors': '参加者', + 'journey.detail.readMore': 'もっと見る', + 'journey.detail.prosCons': '良かった点・気になった点', + 'journey.detail.photos': '写真', + 'journey.detail.day': '{number}日目', + 'journey.detail.places': '場所', + +// Journey Detail — Stats + 'journey.stats.days': '日数', + 'journey.stats.cities': '都市', + 'journey.stats.entries': 'エントリー', + 'journey.stats.photos': '写真', + 'journey.stats.places': '場所', + 'journey.skeletons.show': '提案を表示', + 'journey.skeletons.hide': '提案を非表示', + +// Journey Detail — Verdict + 'journey.verdict.lovedIt': '最高だった', + 'journey.verdict.couldBeBetter': '改善の余地あり', + +// Journey Detail — Synced badge + 'journey.synced.places': '場所', + 'journey.synced.synced': '同期済み', + +// Journey Entry Editor + 'journey.editor.discardChangesConfirm': '未保存の変更があります。破棄しますか?', + 'journey.editor.uploadPhotos': '写真をアップロード', + 'journey.editor.uploading': 'アップロード中…', + 'journey.editor.fromGallery': 'ギャラリーから', + 'journey.editor.allPhotosAdded': 'すべての写真は追加済みです', + 'journey.editor.writeStory': 'ストーリーを書く…', + 'journey.editor.prosCons': '良かった点・気になった点', + 'journey.editor.pros': '良かった点', + 'journey.editor.cons': '気になった点', + 'journey.editor.proPlaceholder': '良かったこと…', + 'journey.editor.conPlaceholder': 'いまいちだったこと…', + 'journey.editor.addAnother': '追加', + 'journey.editor.date': '日付', + 'journey.editor.location': '場所', + 'journey.editor.searchLocation': '場所を検索…', + 'journey.editor.mood': '気分', + 'journey.editor.weather': '天気', + 'journey.editor.photoFirst': '1番目', + 'journey.editor.makeFirst': '1番目にする', + 'journey.editor.searching': '検索中…', + +// Journey Entry — Moods + 'journey.mood.amazing': '最高', + 'journey.mood.good': '良い', + 'journey.mood.neutral': '普通', + 'journey.mood.rough': '大変', + +// Journey Entry — Weather + 'journey.weather.sunny': '晴れ', + 'journey.weather.partly': '晴れ時々くもり', + 'journey.weather.cloudy': 'くもり', + 'journey.weather.rainy': '雨', + 'journey.weather.stormy': '嵐', + 'journey.weather.cold': '雪', + +// Journey — Trip Linking + 'journey.trips.linkTrip': '旅行をリンク', + 'journey.trips.searchTrip': '旅行を検索', + 'journey.trips.searchPlaceholder': '旅行名または目的地…', + 'journey.trips.noTripsAvailable': '利用できる旅行がありません', + 'journey.trips.link': 'リンク', + 'journey.trips.tripLinked': '旅行をリンクしました', + 'journey.trips.linkFailed': 'リンクに失敗しました', + 'journey.trips.addTrip': '旅行を追加', + 'journey.trips.unlinkTrip': 'リンク解除', + 'journey.trips.unlinkMessage': '「{title}」のリンクを解除しますか?この旅行から同期されたエントリーと写真はすべて完全に削除されます。元に戻せません。', + 'journey.trips.unlink': '解除', + 'journey.trips.tripUnlinked': 'リンクを解除しました', + 'journey.trips.unlinkFailed': '解除に失敗しました', + 'journey.trips.noTripsLinkedSettings': 'リンクされた旅行はありません', + +// Journey — Contributors + 'journey.contributors.invite': '参加者を招待', + 'journey.contributors.searchUser': 'ユーザーを検索', + 'journey.contributors.searchPlaceholder': 'ユーザー名またはメール…', + 'journey.contributors.noUsers': 'ユーザーが見つかりません', + 'journey.contributors.role': '役割', + 'journey.contributors.added': '参加者を追加しました', + 'journey.contributors.addFailed': '追加に失敗しました', + 'journey.contributors.remove': '参加者を削除', + 'journey.contributors.removeConfirm': '{username}をこのジャーニーから削除しますか?', + 'journey.contributors.removed': '参加者を削除しました', + 'journey.contributors.removeFailed': '削除に失敗しました', + +// Journey — Share + 'journey.share.publicShare': '公開共有', + 'journey.share.createLink': '共有リンクを作成', + 'journey.share.linkCreated': '共有リンクを作成しました', + 'journey.share.createFailed': 'リンク作成に失敗しました', + 'journey.share.copy': 'コピー', + 'journey.share.copied': 'コピーしました!', + 'journey.share.timeline': 'タイムライン', + 'journey.share.gallery': 'ギャラリー', + 'journey.share.map': 'マップ', + 'journey.share.removeLink': '共有リンクを削除', + 'journey.share.linkDeleted': '共有リンクを削除しました', + 'journey.share.deleteFailed': '削除に失敗しました', + 'journey.share.updateFailed': '更新に失敗しました', + +// Journey — Invite + 'journey.invite.role': '役割', + 'journey.invite.viewer': '閲覧者', + 'journey.invite.editor': '編集者', + 'journey.invite.invite': '招待', + 'journey.invite.inviting': '招待中…', + +// Journey — Settings Dialog + 'journey.settings.title': 'ジャーニー設定', + 'journey.settings.coverImage': 'カバー画像', + 'journey.settings.changeCover': 'カバーを変更', + 'journey.settings.addCover': 'カバー画像を追加', + 'journey.settings.name': '名前', + 'journey.settings.subtitle': 'サブタイトル', + 'journey.settings.subtitlePlaceholder': '例:タイ・ベトナム・カンボジア', + 'journey.settings.endJourney': 'ジャーニーをアーカイブ', + 'journey.settings.reopenJourney': 'ジャーニーを復元', + 'journey.settings.archived': 'ジャーニーをアーカイブしました', + 'journey.settings.reopened': 'ジャーニーを復元しました', + 'journey.settings.endDescription': 'Liveバッジを非表示にします。いつでも再開できます。', + 'journey.settings.delete': '削除', + 'journey.settings.deleteJourney': 'ジャーニーを削除', + 'journey.settings.deleteMessage': '「{title}」を削除しますか?すべてのエントリーと写真が失われます。', + 'journey.settings.saved': '設定を保存しました', + 'journey.settings.saveFailed': '保存に失敗しました', + 'journey.settings.coverUpdated': 'カバーを更新しました', + 'journey.settings.coverFailed': 'アップロードに失敗しました', + 'journey.settings.failedToDelete': '削除に失敗しました', + 'journey.entries.deleteTitle': 'エントリーを削除', + 'journey.photosUploaded': '{count}枚の写真をアップロード', + 'journey.photosAdded': '{count}枚の写真を追加', + +// Journey — Public Page + 'journey.public.notFound': '見つかりません', + 'journey.public.notFoundMessage': 'このジャーニーは存在しないか、リンクの有効期限が切れています。', + 'journey.public.readOnly': '閲覧のみ · 公開ジャーニー', + 'journey.public.tagline': '旅の記録&探索キット', + 'journey.public.sharedVia': '共有元', + 'journey.public.madeWith': '作成:', + +// Journey — PDF Export + 'journey.pdf.journeyBook': 'ジャーニーブック', + 'journey.pdf.madeWith': 'Made with TREK', + 'journey.pdf.day': '日目', + 'journey.pdf.theEnd': 'おわり', + 'journey.pdf.saveAsPdf': 'PDFとして保存', + 'journey.pdf.pages': 'ページ', + 'journey.picker.tripPeriod': '旅行期間', + 'journey.picker.dateRange': '日付範囲', + 'journey.picker.allPhotos': 'すべての写真', + 'journey.picker.albums': 'アルバム', + 'journey.picker.selected': '選択中', + 'journey.picker.addTo': '追加先', + 'journey.picker.newGallery': '新しいギャラリー', + 'journey.picker.selectAll': 'すべて選択', + 'journey.picker.deselectAll': '選択解除', + 'journey.picker.noAlbums': 'アルバムがありません', + 'journey.picker.selectDate': '日付を選択', + 'journey.picker.search': '検索', + +// Dashboard Mobile + 'dashboard.greeting.morning': 'おはようございます、', + 'dashboard.greeting.afternoon': 'こんにちは、', + 'dashboard.greeting.evening': 'こんばんは、', + 'dashboard.mobile.liveNow': 'ライブ中', + 'dashboard.mobile.tripProgress': '旅行の進行状況', + 'dashboard.mobile.daysLeft': '残り{count}日', + 'dashboard.mobile.places': '場所', + 'dashboard.mobile.buddies': '仲間', + 'dashboard.mobile.newTrip': '新しい旅行', + 'dashboard.mobile.currency': '通貨', + 'dashboard.mobile.timezone': 'タイムゾーン', + 'dashboard.mobile.upcomingTrips': '今後の旅行', + 'dashboard.mobile.yourTrips': 'あなたの旅行', + 'dashboard.mobile.trips': '旅行', + 'dashboard.mobile.starts': '開始', + 'dashboard.mobile.duration': '期間', + 'dashboard.mobile.day': '日', + 'dashboard.mobile.days': '日', + 'dashboard.mobile.ongoing': '進行中', + 'dashboard.mobile.startsToday': '今日開始', + 'dashboard.mobile.tomorrow': '明日', + 'dashboard.mobile.inDays': '{count}日後', + 'dashboard.mobile.inMonths': '{count}か月後', + 'dashboard.mobile.completed': '完了', + 'dashboard.mobile.currencyConverter': '通貨換算', + + // BottomNav & Profile + 'nav.profile': 'プロフィール', + 'nav.bottomSettings': '設定', + 'nav.bottomAdmin': '管理者設定', + 'nav.bottomLogout': 'ログアウト', + 'nav.bottomAdminBadge': '管理者', + +// DayPlan Mobile + 'dayplan.mobile.addPlace': '場所を追加', + 'dayplan.mobile.searchPlaces': '場所を検索…', + 'dayplan.mobile.allAssigned': 'すべて割り当て済み', + 'dayplan.mobile.noMatch': '一致なし', + 'dayplan.mobile.createNew': '新しい場所を作成', + +'admin.addons.catalog.journey.name': 'ジャーニー', + 'admin.addons.catalog.journey.description': 'チェックイン、写真、日ごとのストーリーで旅を記録', + +// OAuth scope groups + 'oauth.scope.group.trips': '旅行', + 'oauth.scope.group.places': '場所', + 'oauth.scope.group.atlas': 'アトラス', + 'oauth.scope.group.packing': '持ち物', + 'oauth.scope.group.todos': 'ToDo', + 'oauth.scope.group.budget': '予算', + 'oauth.scope.group.reservations': '予約', + 'oauth.scope.group.collab': 'コラボ', + 'oauth.scope.group.notifications': '通知', + 'oauth.scope.group.vacay': '休暇', + 'oauth.scope.group.geo': '地図', + 'oauth.scope.group.weather': '天気', + 'oauth.scope.group.journey': 'ジャーニー', + +// OAuth scope labels & descriptions + 'oauth.scope.trips:read.label': '旅行・旅程を表示', + 'oauth.scope.trips:read.description': '旅行、日程、メモ、メンバーを閲覧', + 'oauth.scope.trips:write.label': '旅行・旅程を編集', + 'oauth.scope.trips:write.description': '旅行や日程、メモの作成・更新、メンバー管理', + 'oauth.scope.trips:delete.label': '旅行を削除', + 'oauth.scope.trips:delete.description': '旅行全体を完全に削除(元に戻せません)', + 'oauth.scope.trips:share.label': '共有リンクを管理', + 'oauth.scope.trips:share.description': '旅行の公開共有リンクを作成・更新・無効化', + 'oauth.scope.places:read.label': '場所・地図データを表示', + 'oauth.scope.places:read.description': '場所、日への割り当て、タグ、カテゴリを閲覧', + 'oauth.scope.places:write.label': '場所を管理', + 'oauth.scope.places:write.description': '場所、割り当て、タグの作成・更新・削除', + 'oauth.scope.atlas:read.label': 'アトラスを表示', + 'oauth.scope.atlas:read.description': '訪問した国・地域、バケットリストを閲覧', + 'oauth.scope.atlas:write.label': 'アトラスを管理', + 'oauth.scope.atlas:write.description': '訪問済みの国・地域を管理、バケットリスト編集', + 'oauth.scope.packing:read.label': '持ち物リストを表示', + 'oauth.scope.packing:read.description': '持ち物、バッグ、担当者を閲覧', + 'oauth.scope.packing:write.label': '持ち物リストを管理', + 'oauth.scope.packing:write.description': '持ち物やバッグの追加・編集・削除・並び替え', + 'oauth.scope.todos:read.label': 'ToDoリストを表示', + 'oauth.scope.todos:read.description': '旅行のToDoと担当者を閲覧', + 'oauth.scope.todos:write.label': 'ToDoリストを管理', + 'oauth.scope.todos:write.description': 'ToDoの作成・編集・完了・削除・並び替え', + 'oauth.scope.budget:read.label': '予算を表示', + 'oauth.scope.budget:read.description': '予算項目や内訳を閲覧', + 'oauth.scope.budget:write.label': '予算を管理', + 'oauth.scope.budget:write.description': '予算項目の作成・編集・削除', + 'oauth.scope.reservations:read.label': '予約を表示', + 'oauth.scope.reservations:read.description': '予約や宿泊情報を閲覧', + 'oauth.scope.reservations:write.label': '予約を管理', + 'oauth.scope.reservations:write.description': '予約の作成・編集・削除・並び替え', + 'oauth.scope.collab:read.label': 'コラボを表示', + 'oauth.scope.collab:read.description': '共同メモ、投票、メッセージを閲覧', + 'oauth.scope.collab:write.label': 'コラボを管理', + 'oauth.scope.collab:write.description': '共同メモ、投票、メッセージを管理', + 'oauth.scope.notifications:read.label': '通知を表示', + 'oauth.scope.notifications:read.description': 'アプリ内通知と未読数を閲覧', + 'oauth.scope.notifications:write.label': '通知を管理', + 'oauth.scope.notifications:write.description': '通知を既読にする・対応する', + 'oauth.scope.vacay:read.label': '休暇プランを表示', + 'oauth.scope.vacay:read.description': '休暇プランのデータや統計を閲覧', + 'oauth.scope.vacay:write.label': '休暇プランを管理', + 'oauth.scope.vacay:write.description': '休暇エントリーや予定を管理', + 'oauth.scope.geo:read.label': '地図・ジオコーディング', + 'oauth.scope.geo:read.description': '場所検索、地図URL解析、逆ジオコーディング', + 'oauth.scope.weather:read.label': '天気予報', + 'oauth.scope.weather:read.description': '旅行先・日程の天気予報を取得', + 'oauth.scope.journey:read.label': 'ジャーニーを表示', + 'oauth.scope.journey:read.description': 'ジャーニー、エントリー、参加者を閲覧', + 'oauth.scope.journey:write.label': 'ジャーニーを管理', + 'oauth.scope.journey:write.description': 'ジャーニーやエントリーの作成・編集・削除', + 'oauth.scope.journey:share.label': 'ジャーニー共有を管理', + 'oauth.scope.journey:share.description': '公開共有リンクの作成・更新・無効化', + +// System notices — 3.0.0 upgrade + 'system_notice.v3_photos.title': '写真の場所が3.0で変更されました', + 'system_notice.v3_photos.body': '旅行プランナー内の写真は削除されましたが、写真データは安全です。TREKがImmichやSynologyのライブラリを変更することはありません。\n\n写真は現在ジャーニーアドオンにあります。ジャーニーは任意機能です。未有効の場合は、管理画面 → アドオンで有効にしてください。', + 'system_notice.v3_journey.title': 'ジャーニー登場 — 旅の日記', + 'system_notice.v3_journey.body': 'タイムライン、写真ギャラリー、インタラクティブな地図で旅を物語に。', + 'system_notice.v3_journey.cta_label': 'ジャーニーを開く', + 'system_notice.v3_journey.highlight_timeline': '日ごとのタイムラインとギャラリー', + 'system_notice.v3_journey.highlight_photos': 'ImmichやSynologyからインポート', + 'system_notice.v3_journey.highlight_share': 'ログイン不要で公開共有', + 'system_notice.v3_journey.highlight_export': 'PDFフォトブックとして書き出し', + 'system_notice.v3_features.title': '3.0のその他の注目点', + 'system_notice.v3_features.body': '今回のリリースで知っておきたいポイント。', + 'system_notice.v3_features.highlight_dashboard': 'モバイル重視のダッシュボード刷新', + 'system_notice.v3_features.highlight_offline': 'PWAとして完全オフライン対応', + 'system_notice.v3_features.highlight_search': 'リアルタイム場所検索', + 'system_notice.v3_features.highlight_import': 'KMZ/KMLから場所をインポート', + +// System notices — MCP OAuth 2.1 upgrade + 'system_notice.v3_mcp.title': 'MCP:OAuth 2.1に更新', + 'system_notice.v3_mcp.body': 'MCP連携が全面的に刷新されました。OAuth 2.1が推奨認証方式です。従来の静的トークン(trek_…)は非推奨となり、将来削除されます。', + 'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1推奨(mcp-remote)', + 'system_notice.v3_mcp.highlight_scopes': '24の詳細な権限スコープ', + 'system_notice.v3_mcp.highlight_deprecated': '静的trek_トークンは非推奨', + 'system_notice.v3_mcp.highlight_tools': 'ツールとプロンプトを拡張', + +// System notices — personal thank you + 'system_notice.v3_thankyou.title': '開発者より一言', + 'system_notice.v3_thankyou.body': '少しだけお時間をください。\n\nTREKは、自分の旅のために作った小さな個人プロジェクトでした。それが今では4,000人以上に使ってもらえるとは思ってもいませんでした。スターも、Issueも、機能要望も、すべて目を通しています。\n\nTREKはこれからもオープンソース、自分でホストでき、あなたのものです。トラッキングなし、サブスクなし。旅が好きな人が作ったツールです。\n\nhttps://github.com/jubnlにも感謝を。3.0の多くはあなたのおかげです。\n\nバグ報告、翻訳、共有、利用してくれたすべての方へ—本当にありがとうございます。\n\nこれからも一緒に旅を。\n\n— Maurice', + +// System notices — onboarding + 'system_notice.welcome_v1.title': 'TREKへようこそ', + 'system_notice.welcome_v1.body': 'オールインワンの旅行プランナー。旅程作成、共有、整理をオンライン・オフラインで。', + 'system_notice.welcome_v1.cta_label': '旅行を計画', + 'system_notice.welcome_v1.hero_alt': 'TREKのUIが重なった風景写真', + 'system_notice.welcome_v1.highlight_plan': '日ごとの旅程作成', + 'system_notice.welcome_v1.highlight_share': '仲間と共同編集', + 'system_notice.welcome_v1.highlight_offline': 'モバイルでオフライン対応', + 'system_notice.dev_test_modal.title': '[Dev] テスト通知', + 'system_notice.dev_test_modal.body': 'これは開発用テスト通知です。', + 'system_notice.pager.prev': '前へ', + 'system_notice.pager.next': '次へ', + 'system_notice.pager.counter': '{current} / {total}', + 'system_notice.pager.goto': '通知{n}へ', + 'system_notice.pager.position': '{total}件中{current}件目', + 'transport.addTransport': '移動手段を追加', + 'transport.modalTitle.create': '移動手段を追加', + 'transport.modalTitle.edit': '移動手段を編集', + 'transport.title': '移動手段', + 'transport.addManual': '手動で追加', +} + +export default ja From 1084d40685a59b719b9d7a613b9a850777e3124c Mon Sep 17 00:00:00 2001 From: sss3978 <106522699+soma3978@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:55:47 +0900 Subject: [PATCH 2/9] Update TranslationContext.tsx --- client/src/i18n/TranslationContext.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx index cb2a5175..81045346 100644 --- a/client/src/i18n/TranslationContext.tsx +++ b/client/src/i18n/TranslationContext.tsx @@ -15,6 +15,7 @@ import ar from './translations/ar' import br from './translations/br' import cs from './translations/cs' import pl from './translations/pl' +import ja from './translations/ja' import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages' export { SUPPORTED_LANGUAGES } @@ -23,7 +24,7 @@ type TranslationStrings = Record = { - de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, + de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja, } // Derived from SUPPORTED_LANGUAGES — add new languages there, not here. @@ -38,7 +39,7 @@ export function getLocaleForLanguage(language: string): string { export function getIntlLanguage(language: string): string { if (language === 'br') return 'pt-BR' - return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en' + return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja' ].includes(language) ? language : 'en' } export function isRtlLanguage(language: string): boolean { From 0d0ab5080c0d588ea2959fd7816b8be27b8b224e Mon Sep 17 00:00:00 2001 From: sss3978 <106522699+soma3978@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:56:52 +0900 Subject: [PATCH 3/9] Update supportedLanguages.ts --- client/src/i18n/supportedLanguages.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/i18n/supportedLanguages.ts b/client/src/i18n/supportedLanguages.ts index 458a9fff..ace1a6c6 100644 --- a/client/src/i18n/supportedLanguages.ts +++ b/client/src/i18n/supportedLanguages.ts @@ -12,8 +12,9 @@ export const SUPPORTED_LANGUAGES = [ { value: 'zh', label: '简体中文', locale: 'zh-CN' }, { value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' }, { value: 'it', label: 'Italiano', locale: 'it-IT' }, - { value: 'ar', label: 'العربية', locale: 'ar-SA' }, + { value: 'ar', label: 'العربية', locale: 'ar-SA' }, { value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' }, + { value: 'ja', label: '日本語', locale: 'ja-JP' }, ] as const export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value'] From 9bf422005475e789743efaeefc2006deda0a3173 Mon Sep 17 00:00:00 2001 From: sss3978 <106522699+soma3978@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:59:16 +0900 Subject: [PATCH 4/9] Update ja.ts --- client/src/i18n/translations/ja.ts | 84 +++++++++++++++--------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/client/src/i18n/translations/ja.ts b/client/src/i18n/translations/ja.ts index 5df1ce30..bcc5a4b5 100644 --- a/client/src/i18n/translations/ja.ts +++ b/client/src/i18n/translations/ja.ts @@ -160,7 +160,7 @@ const ja: Record = { 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 'settings.mapHint': '地図タイルのURLテンプレート', 'settings.mapProvider': '地図プロバイダー', - 'settings.mapProviderHint': '旅程プランナーとジャーニー地図に影響します。Atlas は常に Leaflet を使用します。', + 'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。', 'settings.mapLeafletSubtitle': 'クラシックな2D、任意のラスタータイル', 'settings.mapMapboxSubtitle': 'ベクタータイル、3D建物・地形', 'settings.mapExperimental': '実験的', @@ -1978,22 +1978,22 @@ const ja: Record = { 'notif.dev.unknown_event.text': 'イベントタイプ「{event}」はEVENT_NOTIFICATION_CONFIGに登録されていません', // Journey addon - 'journey.search.placeholder': 'ジャーニーを検索…', - 'journey.search.noResults': '「{query}」に一致するジャーニーはありません', - 'journey.title': 'ジャーニー', + 'journey.search.placeholder': '日記を検索…', + 'journey.search.noResults': '「{query}」に一致する日記はありません', + 'journey.title': '日記', 'journey.subtitle': '旅の記録をリアルタイムで残そう', - 'journey.new': '新しいジャーニー', + 'journey.new': '新しい日記', 'journey.create': '作成', 'journey.titlePlaceholder': 'どこへ行きますか?', - 'journey.empty': 'ジャーニーはまだありません', + 'journey.empty': '日記はまだありません', 'journey.emptyHint': '次の旅を記録してみましょう', - 'journey.deleted': 'ジャーニーを削除しました', - 'journey.createError': 'ジャーニーを作成できませんでした', - 'journey.deleteError': 'ジャーニーを削除できませんでした', + 'journey.deleted': '日記を削除しました', + 'journey.createError': '日記を作成できませんでした', + 'journey.deleteError': '日記を削除できませんでした', 'journey.deleteConfirmTitle': '削除', 'journey.deleteConfirmMessage': '「{title}」を削除しますか?元に戻せません。', 'journey.deleteConfirmGeneric': '本当に削除しますか?', - 'journey.notFound': 'ジャーニーが見つかりません', + 'journey.notFound': '日記が見つかりません', 'journey.photos': '写真', 'journey.timelineEmpty': 'まだ立ち寄りがありません', 'journey.timelineEmptyHint': 'チェックインするか、日記を書いて始めましょう', @@ -2031,20 +2031,20 @@ const ja: Record = { // Journey Frontpage 'journey.frontpage.subtitle': '旅を、忘れられない物語に', - 'journey.frontpage.createJourney': 'ジャーニーを作成', - 'journey.frontpage.activeJourney': '進行中のジャーニー', - 'journey.frontpage.allJourneys': 'すべてのジャーニー', - 'journey.frontpage.journeys': 'ジャーニー', - 'journey.frontpage.createNew': '新しいジャーニーを作成', + 'journey.frontpage.createJourney': '日記を作成', + 'journey.frontpage.activeJourney': '進行中の日記', + 'journey.frontpage.allJourneys': 'すべての日記', + 'journey.frontpage.journeys': '日記', + 'journey.frontpage.createNew': '新しい日記を作成', 'journey.frontpage.createNewSub': '旅を選んで、物語を書き、共有しよう', 'journey.frontpage.live': 'ライブ', 'journey.frontpage.synced': '同期済み', 'journey.frontpage.continueWriting': '続けて書く', 'journey.frontpage.updated': '{time}に更新', 'journey.frontpage.suggestionLabel': '旅行が終了しました', - 'journey.frontpage.suggestionText': '{title}をジャーニーにしよう', + 'journey.frontpage.suggestionText': '{title}を日記にしよう', 'journey.frontpage.dismiss': '閉じる', - 'journey.frontpage.journeyName': 'ジャーニー名', + 'journey.frontpage.journeyName': '日記名', 'journey.frontpage.namePlaceholder': '例:東南アジア 2026', 'journey.frontpage.selectTrips': '旅行を選択', 'journey.frontpage.tripsSelected': '件選択', @@ -2053,7 +2053,7 @@ const ja: Record = { 'journey.frontpage.places': '場所', // Journey Detail - 'journey.detail.backToJourney': 'ジャーニーに戻る', + 'journey.detail.backToJourney': '日記に戻る', 'journey.detail.syncedWithTrips': '旅行と同期済み', 'journey.detail.addEntry': 'エントリーを追加', 'journey.detail.newEntry': '新しいエントリー', @@ -2062,7 +2062,7 @@ const ja: Record = { 'journey.detail.noEntriesHint': '旅行を追加して下書きエントリーを作成しましょう', 'journey.detail.noPhotos': '写真はまだありません', 'journey.detail.noPhotosHint': 'エントリーに写真を追加するか、Immich/Synologyライブラリを表示', - 'journey.detail.journeyTab': 'ジャーニー', + 'journey.detail.journeyTab': '日記', 'journey.detail.journeyStats': '統計', 'journey.detail.syncedTrips': '同期中の旅行', 'journey.detail.noTripsLinked': 'リンクされた旅行はありません', @@ -2151,7 +2151,7 @@ const ja: Record = { 'journey.contributors.added': '参加者を追加しました', 'journey.contributors.addFailed': '追加に失敗しました', 'journey.contributors.remove': '参加者を削除', - 'journey.contributors.removeConfirm': '{username}をこのジャーニーから削除しますか?', + 'journey.contributors.removeConfirm': '{username}をこの日記から削除しますか?', 'journey.contributors.removed': '参加者を削除しました', 'journey.contributors.removeFailed': '削除に失敗しました', @@ -2178,20 +2178,20 @@ const ja: Record = { 'journey.invite.inviting': '招待中…', // Journey — Settings Dialog - 'journey.settings.title': 'ジャーニー設定', + 'journey.settings.title': '日記設定', 'journey.settings.coverImage': 'カバー画像', 'journey.settings.changeCover': 'カバーを変更', 'journey.settings.addCover': 'カバー画像を追加', 'journey.settings.name': '名前', 'journey.settings.subtitle': 'サブタイトル', 'journey.settings.subtitlePlaceholder': '例:タイ・ベトナム・カンボジア', - 'journey.settings.endJourney': 'ジャーニーをアーカイブ', - 'journey.settings.reopenJourney': 'ジャーニーを復元', - 'journey.settings.archived': 'ジャーニーをアーカイブしました', - 'journey.settings.reopened': 'ジャーニーを復元しました', + 'journey.settings.endJourney': '日記をアーカイブ', + 'journey.settings.reopenJourney': '日記を復元', + 'journey.settings.archived': '日記をアーカイブしました', + 'journey.settings.reopened': '日記を復元しました', 'journey.settings.endDescription': 'Liveバッジを非表示にします。いつでも再開できます。', 'journey.settings.delete': '削除', - 'journey.settings.deleteJourney': 'ジャーニーを削除', + 'journey.settings.deleteJourney': '日記を削除', 'journey.settings.deleteMessage': '「{title}」を削除しますか?すべてのエントリーと写真が失われます。', 'journey.settings.saved': '設定を保存しました', 'journey.settings.saveFailed': '保存に失敗しました', @@ -2204,14 +2204,14 @@ const ja: Record = { // Journey — Public Page 'journey.public.notFound': '見つかりません', - 'journey.public.notFoundMessage': 'このジャーニーは存在しないか、リンクの有効期限が切れています。', - 'journey.public.readOnly': '閲覧のみ · 公開ジャーニー', + 'journey.public.notFoundMessage': 'この日記は存在しないか、リンクの有効期限が切れています。', + 'journey.public.readOnly': '閲覧のみ · 公開日記', 'journey.public.tagline': '旅の記録&探索キット', 'journey.public.sharedVia': '共有元', 'journey.public.madeWith': '作成:', // Journey — PDF Export - 'journey.pdf.journeyBook': 'ジャーニーブック', + 'journey.pdf.journeyBook': '日記ブック', 'journey.pdf.madeWith': 'Made with TREK', 'journey.pdf.day': '日目', 'journey.pdf.theEnd': 'おわり', @@ -2271,13 +2271,13 @@ const ja: Record = { 'dayplan.mobile.noMatch': '一致なし', 'dayplan.mobile.createNew': '新しい場所を作成', -'admin.addons.catalog.journey.name': 'ジャーニー', +'admin.addons.catalog.journey.name': '日記', 'admin.addons.catalog.journey.description': 'チェックイン、写真、日ごとのストーリーで旅を記録', // OAuth scope groups 'oauth.scope.group.trips': '旅行', 'oauth.scope.group.places': '場所', - 'oauth.scope.group.atlas': 'アトラス', + 'oauth.scope.group.atlas': '地図', 'oauth.scope.group.packing': '持ち物', 'oauth.scope.group.todos': 'ToDo', 'oauth.scope.group.budget': '予算', @@ -2287,7 +2287,7 @@ const ja: Record = { 'oauth.scope.group.vacay': '休暇', 'oauth.scope.group.geo': '地図', 'oauth.scope.group.weather': '天気', - 'oauth.scope.group.journey': 'ジャーニー', + 'oauth.scope.group.journey': '日記', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': '旅行・旅程を表示', @@ -2302,9 +2302,9 @@ const ja: Record = { 'oauth.scope.places:read.description': '場所、日への割り当て、タグ、カテゴリを閲覧', 'oauth.scope.places:write.label': '場所を管理', 'oauth.scope.places:write.description': '場所、割り当て、タグの作成・更新・削除', - 'oauth.scope.atlas:read.label': 'アトラスを表示', + 'oauth.scope.atlas:read.label': '地図を表示', 'oauth.scope.atlas:read.description': '訪問した国・地域、バケットリストを閲覧', - 'oauth.scope.atlas:write.label': 'アトラスを管理', + 'oauth.scope.atlas:write.label': '地図を管理', 'oauth.scope.atlas:write.description': '訪問済みの国・地域を管理、バケットリスト編集', 'oauth.scope.packing:read.label': '持ち物リストを表示', 'oauth.scope.packing:read.description': '持ち物、バッグ、担当者を閲覧', @@ -2338,19 +2338,19 @@ const ja: Record = { 'oauth.scope.geo:read.description': '場所検索、地図URL解析、逆ジオコーディング', 'oauth.scope.weather:read.label': '天気予報', 'oauth.scope.weather:read.description': '旅行先・日程の天気予報を取得', - 'oauth.scope.journey:read.label': 'ジャーニーを表示', - 'oauth.scope.journey:read.description': 'ジャーニー、エントリー、参加者を閲覧', - 'oauth.scope.journey:write.label': 'ジャーニーを管理', - 'oauth.scope.journey:write.description': 'ジャーニーやエントリーの作成・編集・削除', - 'oauth.scope.journey:share.label': 'ジャーニー共有を管理', + 'oauth.scope.journey:read.label': '日記を表示', + 'oauth.scope.journey:read.description': '日記、エントリー、参加者を閲覧', + 'oauth.scope.journey:write.label': '日記を管理', + 'oauth.scope.journey:write.description': '日記やエントリーの作成・編集・削除', + 'oauth.scope.journey:share.label': '日記共有を管理', 'oauth.scope.journey:share.description': '公開共有リンクの作成・更新・無効化', // System notices — 3.0.0 upgrade 'system_notice.v3_photos.title': '写真の場所が3.0で変更されました', - 'system_notice.v3_photos.body': '旅行プランナー内の写真は削除されましたが、写真データは安全です。TREKがImmichやSynologyのライブラリを変更することはありません。\n\n写真は現在ジャーニーアドオンにあります。ジャーニーは任意機能です。未有効の場合は、管理画面 → アドオンで有効にしてください。', - 'system_notice.v3_journey.title': 'ジャーニー登場 — 旅の日記', + 'system_notice.v3_photos.body': '旅行プランナー内の写真は削除されましたが、写真データは安全です。TREKがImmichやSynologyのライブラリを変更することはありません。\n\n写真は現在日記アドオンにあります。日記は任意機能です。未有効の場合は、管理画面 → アドオンで有効にしてください。', + 'system_notice.v3_journey.title': '日記登場 — 旅の日記', 'system_notice.v3_journey.body': 'タイムライン、写真ギャラリー、インタラクティブな地図で旅を物語に。', - 'system_notice.v3_journey.cta_label': 'ジャーニーを開く', + 'system_notice.v3_journey.cta_label': '日記を開く', 'system_notice.v3_journey.highlight_timeline': '日ごとのタイムラインとギャラリー', 'system_notice.v3_journey.highlight_photos': 'ImmichやSynologyからインポート', 'system_notice.v3_journey.highlight_share': 'ログイン不要で公開共有', From d30132197eb7b731baee5b870061ffd87734a3b7 Mon Sep 17 00:00:00 2001 From: sss3978 <106522699+soma3978@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:00:56 +0900 Subject: [PATCH 5/9] Update client/src/i18n/translations/ja.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/i18n/translations/ja.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/i18n/translations/ja.ts b/client/src/i18n/translations/ja.ts index bcc5a4b5..289c1cd4 100644 --- a/client/src/i18n/translations/ja.ts +++ b/client/src/i18n/translations/ja.ts @@ -1939,10 +1939,10 @@ const ja: Record = { 'todo.detail.create': 'タスクを作成', // Notifications — dev test events - 'notif.test.title': '[Test] Notification', - 'notif.test.simple.text': 'This is a simple test notification.', - 'notif.test.boolean.text': 'Do you accept this test notification?', - 'notif.test.navigate.text': 'Click below to navigate to the dashboard.', + 'notif.test.title': '[テスト] 通知', + 'notif.test.simple.text': 'これはシンプルなテスト通知です。', + 'notif.test.boolean.text': 'このテスト通知を承認しますか?', + 'notif.test.navigate.text': '下をクリックしてダッシュボードに移動してください。', // Notifications 'notif.trip_invite.title': '旅行への招待', From 73fb48e3bba4181402e1d1d8225abb534cd39d0c Mon Sep 17 00:00:00 2001 From: sss3978 <106522699+soma3978@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:19:20 +0900 Subject: [PATCH 6/9] Update index.test.ts --- client/tests/unit/i18n/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/tests/unit/i18n/index.test.ts b/client/tests/unit/i18n/index.test.ts index c042debd..17e306fe 100644 --- a/client/tests/unit/i18n/index.test.ts +++ b/client/tests/unit/i18n/index.test.ts @@ -91,7 +91,7 @@ describe('isRtlLanguage', () => { describe('SUPPORTED_LANGUAGES', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) - expect(SUPPORTED_LANGUAGES).toHaveLength(15) + expect(SUPPORTED_LANGUAGES).toHaveLength(16) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' })) }) From 002ea91be89f5d1630a0774cd403f900009da2af Mon Sep 17 00:00:00 2001 From: "Julien G." <66769052+jubnl@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:57:50 +0200 Subject: [PATCH 7/9] Align dev (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump version to 3.0.0 [skip ci] * fix: resolve dead wiki links across install and config pages * fix(reservations): restore correct day assignment for non-transport bookings v3.0.0 switched the planner from rendering reservations by reservation_time to rendering them by day_id (commit 3f61e1c), but migration 110 only backfilled day_id for transport types. Tours, restaurants, events and 'other' bookings kept whatever day_id was stored in the DB — often the trip's first day, from older code paths that defaulted it there — so after the upgrade those rows all show up on day 1 regardless of their actual reservation_time. - Migration 122: for every non-hotel reservation, null out any day_id / end_day_id that does not match the reservation's time, then backfill it from reservation_time / reservation_end_time. Idempotent; leaves already-correct rows alone. - reservationService.createReservation / updateReservation now derive day_id / end_day_id from reservation_time / reservation_end_time when the client didn't send one explicitly, so the mismatch cannot reappear on new or edited bookings. Hotels are skipped because they store their date range on the linked day_accommodation. * chore: bump version to 3.0.1 [skip ci] * fix(oidc): normalize discovery doc issuer before comparison Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against the already-normalized configured issuer, breaking OIDC login entirely. Closes #834 * test(systemNotices): exclude v3 upgrade notices from login_count-only tests Tests that expect an empty notice list were using first_seen_version='0.0.0' (DB default), which matches the existingUserBeforeVersion('3.0.0') condition now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the firstLogin condition controls visibility in these tests. * chore: bump version to 3.0.2 [skip ci] * fix(oidc): normalize id_token iss claim before issuer comparison (#837) jwt.verify does an exact string match on the issuer. Providers like Authentik include a trailing slash in the id_token iss claim while the configured issuer is already normalized (no trailing slash), causing every login attempt to fail with jwt issuer invalid. Move the issuer check out of jwt.verify options and apply the same trailing-slash normalization used in the discovery doc validation. Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing slash, wrong issuer, and wrong audience cases. Closes #834 * chore: bump version to 3.0.3 [skip ci] * fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845) OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery doc's issuer for id_token comparison instead of rejecting a path mismatch as an error. Authentik (and similar realm-path providers) return a canonical issuer like /application/o// that differs from the operator's base OIDC_ISSUER. Strict equality blocked login in 3.x despite working in v2. Default discovery (no custom URL) keeps the strict check. Adds OIDC-SVC-037/038/039. UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h paddingBottom offset that other overlays already use. On mobile portrait the action buttons were hidden behind the sticky bottom nav bar. Closes #843 Closes #844 * chore: bump version to 3.0.4 [skip ci] * fix(files): open attachments only in new tab (#840) window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead. * chore: bump version to 3.0.5 [skip ci] * fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848) * fix(journey): make sort_order authoritative for within-day entry ordering Reorder buttons appeared broken because the server ORDER BY put entry_time before sort_order, so entries synced from trip places with differing times would always sort by time regardless of sort_order writes. The client store mirrored the same comparator, making even the optimistic update invisible. - Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries - Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0 - Update client store comparator to match - Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order - Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019 Closes #846 * fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847) Reservations were matched to days by pickup date only, so the end-day card (e.g. car Return, flight Arrival) was silently dropped from the PDF. Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id span, show reservation_end_time on end days, prefix title with phase label (Return/Arrival/etc.), and use per-day position for sort order. * test(pdf): add missing day_id to transport reservation fixture * chore: bump version to 3.0.6 [skip ci] * [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849) * fix: server/package.json & server/package-lock.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-UUID-16133035 * fix: bump fast-xml-parser version --------- Co-authored-by: snyk-bot Co-authored-by: jubnl * chore: bump version to 3.0.7 [skip ci] * fix: hot fixes 23-04-2026 (#856) * fix(packing): resolve avatar URL path in bag and category assignees (#854) packingService was returning raw avatar filenames from the DB instead of the full /uploads/avatars/ path, causing broken profile images for users with uploaded avatars. * fix(budget): use Map.get() to fix category rename no-op (#855) * fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863) - Change Helmet default from no-referrer to strict-origin-when-cross-origin so browsers send the origin on cross-origin requests, allowing Google Maps API key restrictions by HTTP referrer to work correctly - Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts: .env.example, docker-compose.yml, README.md, unraid-template.xml, charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md * fix(planner): prefetch budget items on trip page mount (#864) Loads budgetItems alongside reservations when TripPlannerPage mounts so the Budget category dropdown in ReservationModal and TransportModal shows pre-existing categories on first open, regardless of whether the Budget tab has been visited. Closes #861 * fix(reservations): prevent Invalid Date when end time is set without end date (#866) When reservation_end_time held a bare time string ("HH:MM"), fmtDate() produced Invalid Date on the reservation card. - Modal: when end date is blank but end time is filled, construct a same-day ISO datetime using the start date (prevents time-only strings from ever being persisted) - Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD") still show the multi-day range, while bare time strings are skipped and handled correctly by the existing time column logic Closes #860 * fix(planner): format reservation end time instead of rendering raw ISO string (#867) Closes #859 * fix(planner): wire Route toggle into mobile day sidebar (#850) (#868) The per-booking Route icon was missing on mobile because the mobile DayPlanSidebar invocation in TripPlannerPage didn't pass visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't activate reservation map overlays without forcing desktop mode. Also corrects the Map-Features wiki: fixes the setting name ("Booking route labels" not "Show connection labels"), documents the route_calculation requirement for travel-time pills, and explains that overlays are off by default and must be toggled per reservation. * chore: bump version to 3.0.8 [skip ci] --------- Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Maurice Co-authored-by: Xre0uS <36565320+Xre0uS@users.noreply.github.com> Co-authored-by: snyk-bot --- .github/workflows/wiki.yml | 2 +- README.md | 1 + charts/trek/Chart.yaml | 4 +- charts/trek/templates/configmap.yaml | 3 + charts/trek/values.yaml | 2 + client/package-lock.json | 4 +- client/package.json | 2 +- client/src/components/Budget/BudgetPanel.tsx | 2 +- client/src/components/PDF/TripPDF.test.ts | 1 + client/src/components/PDF/TripPDF.tsx | 55 ++++++-- .../src/components/Planner/DayDetailPanel.tsx | 6 +- .../src/components/Planner/DayPlanSidebar.tsx | 5 +- .../components/Planner/ReservationModal.tsx | 2 + .../components/Planner/ReservationsPanel.tsx | 11 +- .../src/components/shared/ConfirmDialog.tsx | 2 +- .../src/components/shared/CopyTripDialog.tsx | 2 +- client/src/pages/TripPlannerPage.tsx | 7 +- client/src/store/journeyStore.test.ts | 31 +++++ client/src/store/journeyStore.ts | 6 +- client/src/utils/fileDownload.ts | 33 ++++- client/tests/unit/utils/fileDownload.test.ts | 69 +++++++--- docker-compose.yml | 1 + server/.env.example | 1 + server/package-lock.json | 89 ++++--------- server/package.json | 4 +- server/src/app.ts | 1 + server/src/db/migrations.ts | 87 +++++++++++++ server/src/routes/oidc.ts | 2 +- server/src/services/journeyService.ts | 23 +++- server/src/services/oidcService.ts | 28 ++++- server/src/services/packingService.ts | 18 ++- server/src/services/reservationService.ts | 74 +++++++++-- .../tests/integration/systemNotices.test.ts | 7 +- .../unit/services/journeyService.test.ts | 106 ++++++++++++++++ .../tests/unit/services/oidcService.test.ts | 119 ++++++++++++++++++ unraid-template.xml | 1 + wiki/Environment-Variables.md | 15 +-- wiki/Install-Docker-Compose.md | 10 +- wiki/Install-Docker.md | 12 +- wiki/Install-Helm.md | 4 +- wiki/Install-Unraid.md | 4 +- wiki/Map-Features.md | 8 +- wiki/Quick-Start.md | 8 +- wiki/Reverse-Proxy.md | 6 +- wiki/Updating.md | 10 +- 45 files changed, 711 insertions(+), 177 deletions(-) diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index 440b6b7a..3526e38e 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -23,4 +23,4 @@ jobs: - name: Publish to GitHub wiki uses: Andrew-Chen-Wang/github-wiki-action@v5 with: - strategy: init + strategy: clone diff --git a/README.md b/README.md index 3e90a0e8..f3dfae0f 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically. | `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | +| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto | | `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` | diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 71765000..7ce3bafe 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 2.9.14 +version: 3.0.8 description: Minimal Helm chart for TREK app -appVersion: "2.9.14" +appVersion: "3.0.8" diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index af3a7182..33efce0c 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -22,6 +22,9 @@ data: {{- if .Values.env.FORCE_HTTPS }} FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }} {{- end }} + {{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }} + HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }} + {{- end }} {{- if .Values.env.COOKIE_SECURE }} COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 42c86b1f..0f19d230 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -30,6 +30,8 @@ env: # Also used as the base URL for links in email notifications and other external links. # FORCE_HTTPS: "false" # Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY. + # HSTS_INCLUDE_SUBDOMAINS: "false" + # When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP. # COOKIE_SECURE: "true" # Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production. # TRUST_PROXY: "1" diff --git a/client/package-lock.json b/client/package-lock.json index 1763c702..6dd3d7b8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.8", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/package.json b/client/package.json index 9efbb68c..3e508bb2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "2.9.14", + "version": "3.0.8", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index e442a0ff..a91a977e 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro } const handleRenameCategory = async (oldName, newName) => { if (!newName.trim() || newName.trim() === oldName) return - const items = grouped[oldName] || [] + const items = grouped.get(oldName) || [] for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } const handleAddCategory = () => { diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 46549188..77e33eac 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -78,6 +78,7 @@ const transportReservation = { id: 400, title: 'Flight to Rome', type: 'flight', + day_id: 10, reservation_time: '2025-06-01T14:30:00', confirmation_number: 'ABC123', metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 040bb711..1a5a3316 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const totalCost = Object.values(assignments || {}) .flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) + // Span helpers for multi-day transport (mirrors DayPlanSidebar logic) + const pdfGetDayOrder = (d: Day) => d.day_number + const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => { + const startId = r.day_id + const endId = r.end_day_id ?? startId + if (!startId || startId === endId) return 'single' + if (dayId === startId) return 'start' + if (dayId === endId) return 'end' + return 'middle' + } + const pdfGetDisplayTime = (r: any, dayId: number): string | null => { + const phase = pdfGetSpanPhase(r, dayId) + if (phase === 'end') return r.reservation_end_time || null + if (phase === 'middle') return null + return r.reservation_time || null + } + const pdfGetSpanLabel = (r: any, phase: string): string | null => { + if (phase === 'single') return null + if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`) + if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`) + return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) + } + const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => { + if (r.type === 'hotel') return false + const startId = r.day_id + const endId = r.end_day_id ?? startId + if (startId == null) return false + if (endId !== startId) { + const startDay = sorted.find(d => d.id === startId) + const endDay = sorted.find(d => d.id === endId) + const thisDay = sorted.find(d => d.id === dayId) + if (!startDay || !endDay || !thisDay) return false + return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay) + } + return startId === dayId + }) + // Build day HTML const daysHtml = sorted.map((day, di) => { const assigned = assignments[String(day.id)] || [] const notes = (dayNotes || []).filter(n => n.day_id === day.id) const cost = dayCost(assignments, day.id, loc) - // Reservations for this day (hotel rendered via accommodations block) - const dayReservations = (reservations || []).filter(r => { - if (!r.reservation_time || r.type === 'hotel') return false - return day.date && r.reservation_time.split('T')[0] === day.date - }) + // Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only) + const dayReservations = pdfGetTransportForDay(day.id) + .filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')) const merged = [] assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) dayReservations.forEach(r => { - const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) + const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) merged.push({ type: 'reservation', k: pos, data: r }) }) merged.sort((a, b) => a.k - b.k) @@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') const locationLine = r.location || meta.location || '' - const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' + const phase = pdfGetSpanPhase(r, day.id) + const spanLabel = pdfGetSpanLabel(r, phase) + const displayTime = pdfGetDisplayTime(r, day.id) + const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : '' + const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}` return `
${icon}
-
${escHtml(r.title)}${time ? ` ${time}` : ''}
+
${titleHtml}${time ? ` ${time}` : ''}
${subtitle ? `
${escHtml(subtitle)}
` : ''} ${locationLine ? `
${escHtml(locationLine)}
` : ''} ${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''} diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 8ef2282b..9487f402 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) - const fmtTime = (v) => formatTime12(v, is12h) + const fmtTime = (v) => { + if (!v) return v + if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h }) + return formatTime12(v, is12h) + } const unit = isFahrenheit ? '°F' : '°C' const collapsed = collapsedProp const toggleCollapse = () => onToggleCollapse?.() diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 64049e89..b1eda2d3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {res.reservation_time?.includes('T') && ( {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time && ` – ${res.reservation_end_time}`} + {res.reservation_end_time && ` – ${(() => { + const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time) + return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) + })()}`} )} {(() => { diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 7fa8a7e8..f5ec6f13 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p let combinedEndTime = form.reservation_end_time if (form.end_date) { combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date + } else if (form.reservation_end_time && form.reservation_time) { + combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}` } if (isBudgetEnabled) { if (form.price) metadata.price = form.price diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 00c105c2..770d36a5 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.date')}
{fmtDate(r.reservation_time)} - {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && ( + {(() => { + const endDatePart = r.reservation_end_time + ? r.reservation_end_time.includes('T') + ? r.reservation_end_time.split('T')[0] + : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time) + ? r.reservation_end_time + : null + : null + return endDatePart && endDatePart !== r.reservation_time.split('T')[0] + })() && ( <> – {fmtDate(r.reservation_end_time)} )}
diff --git a/client/src/components/shared/ConfirmDialog.tsx b/client/src/components/shared/ConfirmDialog.tsx index 31cd9295..d75ad190 100644 --- a/client/src/components/shared/ConfirmDialog.tsx +++ b/client/src/components/shared/ConfirmDialog.tsx @@ -41,7 +41,7 @@ export default function ConfirmDialog({ return (
{ - if (tripId) tripActions.loadReservations(tripId) + if (tripId) { + tripActions.loadReservations(tripId) + tripActions.loadBudgetItems?.(tripId) + } }, [tripId]) useTripWebSocket(tripId) @@ -1106,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} /> }
diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts index 564969ec..d5c7a2a3 100644 --- a/client/src/store/journeyStore.test.ts +++ b/client/src/store/journeyStore.test.ts @@ -355,6 +355,37 @@ describe('journeyStore', () => { expect(useJourneyStore.getState().loading).toBe(false); }); + // ── reorderEntries ─────────────────────────────────────────────────────── + + it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => { + const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 }); + const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 }); + const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 }); + const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] }); + useJourneyStore.setState({ current: detail }); + + server.use( + http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true })) + ); + await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]); + const ids = useJourneyStore.getState().current?.entries.map(e => e.id); + expect(ids).toEqual([202, 201, 203]); + }); + + it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => { + const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 }); + const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 }); + const detail = buildJourneyDetail({ id: 56, entries: [a, b] }); + useJourneyStore.setState({ current: detail }); + + server.use( + http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 })) + ); + await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy(); + const ids = useJourneyStore.getState().current?.entries.map(e => e.id); + expect(ids).toEqual([211, 212]); + }); + // ── clear ──────────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-015: clear resets state', () => { diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 47b75971..c2edfa69 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -223,10 +223,8 @@ export const useJourneyStore = create((set, get) => ({ ) entries.sort((a, b) => { if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date) - const atime = a.entry_time || '' - const btime = b.entry_time || '' - if (atime !== btime) return atime.localeCompare(btime) - return (a.sort_order || 0) - (b.sort_order || 0) + if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0) + return a.id - b.id }) return { current: { ...s.current, entries } } }) diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index b9904472..10e05fd0 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void { setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100) } +// navigator.standalone is true only on iOS when running as an +// add-to-home-screen PWA. In that context, target="_blank" hands off to +// Safari, which cannot access blob URLs sandboxed to the WebView. +function isIosStandalone(): boolean { + return (navigator as any).standalone === true +} + /** * Fetches a protected file using cookie auth (credentials: include) and * triggers a browser download. Works inside PWA standalone mode because the @@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise click rather + * than window.open(). window.open() called with the "noreferrer"/"noopener" + * window feature returns null per spec, which previously made the popup-block + * fallback trigger a download in the *current* tab on top of the new-tab open + * — i.e. the file opened twice. The anchor approach avoids that ambiguity: + * the new tab is opened by the browser's normal link-handling path, and no + * spurious in-page download is triggered. */ export async function openFile(url: string, filename?: string): Promise { assertRelativeUrl(url) @@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise { return } - const win = window.open(blobUrl, '_blank', 'noreferrer') - if (win) { - setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) - } else { - // Popup blocked — fall back to download + // iOS PWA: target="_blank" would open Safari, which can't access the blob + if (isIosStandalone()) { triggerAnchorDownload(blobUrl, filename) + return } + + const a = document.createElement('a') + a.href = blobUrl + a.target = '_blank' + a.rel = 'noopener noreferrer' + document.body.appendChild(a) + a.click() + // Keep the blob URL alive long enough for the new tab to load it, then + // clean up the DOM node and revoke the URL. + setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000) } diff --git a/client/tests/unit/utils/fileDownload.test.ts b/client/tests/unit/utils/fileDownload.test.ts index 89632017..b5a8833c 100644 --- a/client/tests/unit/utils/fileDownload.test.ts +++ b/client/tests/unit/utils/fileDownload.test.ts @@ -74,32 +74,42 @@ describe('downloadFile', () => { }) describe('openFile', () => { - it('fetches with credentials:include and opens blob URL in new tab', async () => { + it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => { vi.stubGlobal('fetch', makeFetchMock(200)) - const mockWin = { closed: false } - const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window) + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) await openFile('/uploads/files/doc.pdf') expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' }) expect(URL.createObjectURL).toHaveBeenCalled() - expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer') + // Must NOT call window.open — that path returns null when noreferrer is + // set, which previously caused the file to also open in the current tab. + expect(openSpy).not.toHaveBeenCalled() + expect(clickSpy).toHaveBeenCalledTimes(1) + + // The anchor used to open the new tab must be target=_blank, must NOT + // carry a `download` attribute (otherwise it would download in-page + // instead of opening), and must use rel=noopener noreferrer. + const appendCalls = (document.body.appendChild as ReturnType).mock.calls + const anchor = appendCalls[0]?.[0] as HTMLAnchorElement + expect(anchor.target).toBe('_blank') + expect(anchor.rel).toBe('noopener noreferrer') + expect(anchor.hasAttribute('download')).toBe(false) // Revoke happens after 30s timeout vi.runAllTimers() expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') }) - it('falls back to anchor download when popup is blocked', async () => { + it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => { vi.stubGlobal('fetch', makeFetchMock(200)) - vi.spyOn(window, 'open').mockReturnValue(null) const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - await openFile('/uploads/files/doc.pdf') + await openFile('/uploads/files/doc.pdf', 'doc.pdf') - expect(clickSpy).toHaveBeenCalled() - vi.runAllTimers() - expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') + // Exactly ONE anchor click — opening the new tab. No fallback download. + expect(clickSpy).toHaveBeenCalledTimes(1) }) it('throws on 401 response', async () => { @@ -108,28 +118,55 @@ describe('openFile', () => { expect(URL.createObjectURL).not.toHaveBeenCalled() }) - it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => { + it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => { const htmlBlob = new Blob([''], { type: 'text/html' }) vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob)) const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window) const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - await openFile('/uploads/files/malicious.html') + await openFile('/uploads/files/malicious.html', 'malicious.html') // Must NOT open inline — download anchor clicked instead expect(openSpy).not.toHaveBeenCalled() - expect(clickSpy).toHaveBeenCalled() + expect(clickSpy).toHaveBeenCalledTimes(1) + + const appendCalls = (document.body.appendChild as ReturnType).mock.calls + const anchor = appendCalls[0]?.[0] as HTMLAnchorElement + expect(anchor.download).toBe('malicious.html') }) it('forces download for SVG MIME type', async () => { const svgBlob = new Blob([''], { type: 'image/svg+xml' }) vi.stubGlobal('fetch', makeFetchMock(200, svgBlob)) - vi.spyOn(window, 'open').mockReturnValue({} as Window) + const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window) const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) await openFile('/uploads/files/malicious.svg') - expect(window.open).not.toHaveBeenCalled() - expect(clickSpy).toHaveBeenCalled() + expect(openSpy).not.toHaveBeenCalled() + expect(clickSpy).toHaveBeenCalledTimes(1) + }) + + it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => { + vi.stubGlobal('fetch', makeFetchMock(200)) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + // Simulate iOS PWA (Add-to-Home-Screen) context + Object.defineProperty(navigator, 'standalone', { configurable: true, value: true }) + + try { + await openFile('/uploads/files/doc.pdf', 'doc.pdf') + + // Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank), + // because target="_blank" in iOS PWA would hand off to Safari which cannot + // read the in-WebView blob URL. + expect(clickSpy).toHaveBeenCalledTimes(1) + const appendCalls = (document.body.appendChild as ReturnType).mock.calls + const anchor = appendCalls[0]?.[0] as HTMLAnchorElement + expect(anchor.target).toBe('') + expect(anchor.download).toBe('doc.pdf') + } finally { + // Clean up the non-standard iOS-only property we forced above. + delete (navigator as any).standalone + } }) }) diff --git a/docker-compose.yml b/docker-compose.yml index e0d84418..a72cbecd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: # - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy +# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP. # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended. # - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work. # - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless. diff --git a/server/.env.example b/server/.env.example index ba9da901..7fb96267 100644 --- a/server/.env.example +++ b/server/.env.example @@ -13,6 +13,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy. +# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP. COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP. TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS. ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked. diff --git a/server/package-lock.json b/server/package-lock.json index 7eb10679..6510f142 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-server", - "version": "2.9.14", + "version": "3.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-server", - "version": "2.9.14", + "version": "3.0.8", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "archiver": "^6.0.1", @@ -30,7 +30,7 @@ "typescript": "^6.0.2", "undici": "^7.0.0", "unzipper": "^0.12.3", - "uuid": "^9.0.0", + "uuid": "^14.0.0", "ws": "^8.19.0", "zod": "^4.3.6" }, @@ -663,9 +663,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -682,9 +679,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -701,9 +695,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -720,9 +711,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -739,9 +727,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -758,9 +743,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -777,9 +759,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -796,9 +775,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -815,9 +791,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -840,9 +813,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -865,9 +835,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -890,9 +857,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -915,9 +879,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -940,9 +901,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -965,9 +923,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -990,9 +945,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1551,6 +1503,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@otplib/core": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", @@ -3767,9 +3731,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -3782,9 +3746,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.12", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz", - "integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", + "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", "funding": [ { "type": "github", @@ -3793,7 +3757,8 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, @@ -6481,16 +6446,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vary": { diff --git a/server/package.json b/server/package.json index 7ae1cec8..b061a193 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "trek-server", - "version": "2.9.14", + "version": "3.0.8", "main": "src/index.ts", "scripts": { "start": "node --import tsx src/index.ts", @@ -35,7 +35,7 @@ "typescript": "^6.0.2", "undici": "^7.0.0", "unzipper": "^0.12.3", - "uuid": "^9.0.0", + "uuid": "^14.0.0", "ws": "^8.19.0", "zod": "^4.3.6" }, diff --git a/server/src/app.ts b/server/src/app.ts index 9bca8fcb..d21c0d3c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -124,6 +124,7 @@ export function createApp(): express.Application { }, crossOriginEmbedderPolicy: false, hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, })); if (shouldForceHttps) { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 410e67f8..29640339 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2043,6 +2043,93 @@ function runMigrations(db: Database.Database): void { db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)'); }, + // Migration 122: Correct stale day_id / end_day_id on non-transport + // reservations. Migration 110 only backfilled transport types; tours, + // restaurants, events and "other" bookings kept a stale day_id from + // older code paths that often defaulted to the first day of the trip. + // Starting with v3.0.0 the planner renders reservations by day_id + // instead of reservation_time, so those stale rows show up on the + // wrong day. This migration nulls out day_id / end_day_id values that + // don't match the reservation's time and then backfills them from + // reservation_time / reservation_end_time. + () => { + db.exec(` + UPDATE reservations + SET day_id = NULL + WHERE reservation_time IS NOT NULL + AND day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.day_id + AND d.date = substr(reservations.reservation_time, 1, 10) + ) + `); + + db.exec(` + UPDATE reservations + SET end_day_id = NULL + WHERE reservation_end_time IS NOT NULL + AND end_day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.end_day_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + ) + `); + + db.exec(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + db.exec(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) + != substr(reservations.reservation_time, 1, 10) + `); + }, + // #846: make sort_order authoritative within a day. Previous ORDER BY put + // entry_time before sort_order, silently ignoring reorder clicks when two + // same-date entries had different times. Backfill renumbers using the old + // effective key (entry_time ASC, id ASC) so existing journeys retain their + // current visual order. + () => { + db.exec(` + WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY journey_id, entry_date + ORDER BY entry_time ASC, id ASC + ) - 1 AS rn + FROM journey_entries + ) + UPDATE journey_entries + SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id) + `); + db.exec( + 'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' + + 'ON journey_entries(journey_id, entry_date, sort_order)' + ); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index dcd21a78..4a86a340 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => { tokenData.id_token, doc, config.clientId, - config.issuer, + (doc.issuer ?? '').replace(/\/+$/, '') || config.issuer, ); if (idVerify.ok !== true) { const reason = 'error' in idVerify ? idVerify.error : 'unknown'; diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index a03d1fea..afa6c7b3 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) { if (!journey) return null; const entries = db.prepare( - 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC' + 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC' ).all(journeyId) as JourneyEntry[]; const photos = db.prepare( @@ -306,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb ).all(journeyId, tripId) as { source_place_id: number }[]; const existingPlaceIds = new Set(existing.map(e => e.source_place_id)); + // Track next sort_order per date so synced skeletons get unique, sequential positions. + const dateMaxOrder = new Map(); + const maxRows = db.prepare( + 'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date' + ).all(journeyId) as { entry_date: string; m: number }[]; + for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m); + for (const place of places) { if (existingPlaceIds.has(place.id)) continue; existingPlaceIds.add(place.id); const entryDate = place.day_date || new Date().toISOString().split('T')[0]; const entryTime = place.assignment_time || place.place_time || null; + const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; + dateMaxOrder.set(entryDate, nextOrder); db.prepare(` INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at) @@ -320,7 +329,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb journeyId, tripId, place.id, authorId, place.name, entryDate, entryTime, place.address || place.name, place.lat || null, place.lng || null, - place.day_number || 0, now, now + nextOrder, now, now ); } } @@ -367,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) { const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number }; const entryDate = place.day_date; + const maxOrder = db.prepare( + 'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?' + ).get(link.journey_id, entryDate) as { m: number | null }; + const nextOrder = (maxOrder?.m ?? -1) + 1; db.prepare(` INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?) + VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( link.journey_id, tripId, placeId, journey.user_id, place.name, entryDate, place.assignment_time || place.place_time || null, place.address || place.name, place.lat || null, place.lng || null, - now, now + nextOrder, now, now ); } } @@ -451,7 +464,7 @@ export function listEntries(journeyId: number, userId: number) { if (!canAccessJourney(journeyId, userId)) return null; const entries = db.prepare( - 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC' + 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC' ).all(journeyId) as JourneyEntry[]; const photos = db.prepare( diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index db0ef8ad..42c0c5cd 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr const res = await fetch(url); if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); const doc = (await res.json()) as OidcDiscoveryDoc; - // Validate that the discovery doc's issuer matches the operator-configured - // one. A MITM or compromised doc could otherwise supply a crafted issuer - // that passes jwt.verify() because we used doc.issuer as the expected value. - if (doc.issuer && doc.issuer !== issuer) { - throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`); + // Validate that the discovery doc's issuer matches the operator-configured one. + // When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration + // and we reject. When the operator explicitly overrides the discovery URL (e.g. + // Authentik realm paths), the discovery doc's issuer is the canonical value — + // trust it and warn rather than blocking login. + const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? ''; + if (docIssuer && docIssuer !== issuer) { + if (discoveryUrl) { + console.warn( + `[OIDC] Discovery doc issuer "${doc.issuer}" differs from configured OIDC_ISSUER "${issuer}". ` + + `Using discovery doc issuer for id_token verification (custom OIDC_DISCOVERY_URL is set).`, + ); + } else { + throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`); + } } doc._issuer = url; discoveryCache = doc; @@ -313,7 +323,6 @@ export async function verifyIdToken( try { const verified = jwt.verify(idToken, publicKey, { algorithms: [alg as jwt.Algorithm], - issuer: expectedIssuer, audience: clientId, }); claims = typeof verified === 'string' ? {} : (verified as Record); @@ -322,6 +331,13 @@ export async function verifyIdToken( return { ok: false, error: `signature_or_claim_mismatch: ${msg}` }; } + // Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik) + // include a trailing slash in the id_token iss claim. + const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : ''; + if (tokenIssuer !== expectedIssuer) { + return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` }; + } + return { ok: true, claims }; } diff --git a/server/src/services/packingService.ts b/server/src/services/packingService.ts index a9eddb25..19fa54d9 100644 --- a/server/src/services/packingService.ts +++ b/server/src/services/packingService.ts @@ -1,4 +1,5 @@ import { db, canAccessTrip } from '../db/database'; +import { avatarUrl } from './authService'; const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']; @@ -131,7 +132,10 @@ export function listBags(tripId: string | number) { if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []); membersByBag.get(m.bag_id)!.push(m); } - return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] })); + return bags.map(b => ({ + ...b, + members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })), + })); } export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) { @@ -140,11 +144,12 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId); const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); for (const uid of userIds) ins.run(bagId, uid); - return db.prepare(` + const rows = db.prepare(` SELECT bm.user_id, u.username, u.avatar FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id WHERE bm.bag_id = ? - `).all(bagId); + `).all(bagId) as { user_id: number; username: string; avatar: string | null }[]; + return rows.map(m => ({ ...m, avatar: avatarUrl(m) })); } export function createBag(tripId: string | number, data: { name: string; color?: string }) { @@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) { const assignees: Record = {}; for (const row of rows as any[]) { if (!assignees[row.category_name]) assignees[row.category_name] = []; - assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar }); + assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) }); } return assignees; @@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s for (const uid of userIds) insert.run(tripId, categoryName, uid); } - return db.prepare(` + const updated = db.prepare(` SELECT pca.user_id, u.username, u.avatar FROM packing_category_assignees pca JOIN users u ON pca.user_id = u.id WHERE pca.trip_id = ? AND pca.category_name = ? - `).all(tripId, categoryName); + `).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[]; + return updated.map(m => ({ ...m, avatar: avatarUrl(m) })); } // ── Reorder ──────────────────────────────────────────────────────────────── diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index 4410c36d..d95aadd5 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -43,6 +43,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] { ).all(reservationId) as ReservationEndpoint[]; } +// Resolve the day row whose date matches the date portion of an ISO-ish +// timestamp. Used to keep `day_id` / `end_day_id` in sync with +// `reservation_time` / `reservation_end_time` so non-transport bookings +// (tours, restaurants, events, ...) end up on the right day in the UI, +// which now filters by day_id instead of reservation_time. +function resolveDayIdFromTime( + tripId: string | number, + time: string | null | undefined, +): number | null { + if (!time) return null; + const datePart = time.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null; + const row = db + .prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1') + .get(tripId, datePart) as { id: number } | undefined; + return row?.id ?? null; +} + const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => { db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId); const insert = db.prepare(` @@ -160,13 +178,26 @@ export function createReservation(tripId: string | number, data: CreateReservati } } + // Derive day_id / end_day_id from reservation_time when the client + // didn't explicitly set them (non-hotel bookings only — hotels store + // their date range on the linked day_accommodation). + const resolvedType = type || 'other'; + let resolvedDayId: number | null = day_id ?? null; + if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) { + resolvedDayId = resolveDayIdFromTime(tripId, reservation_time); + } + let resolvedEndDayId: number | null = end_day_id ?? null; + if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) { + resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time); + } + const result = db.prepare(` INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( tripId, - day_id || null, - end_day_id ?? null, + resolvedDayId, + resolvedEndDayId, place_id || null, assignment_id || null, title, @@ -176,7 +207,7 @@ export function createReservation(tripId: string | number, data: CreateReservati confirmation_number || null, notes || null, status || 'pending', - type || 'other', + resolvedType, resolvedAccommodationId, metadata ? JSON.stringify(metadata) : null, needs_review ? 1 : 0 @@ -290,6 +321,35 @@ export function updateReservation(id: string | number, tripId: string | number, } } + const resolvedType = (type ?? current.type) || 'other'; + const nextReservationTime = resolvedType === 'hotel' + ? null + : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time); + const nextReservationEndTime = resolvedType === 'hotel' + ? null + : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time); + + // day_id / end_day_id: honour an explicit value from the client, + // otherwise derive from the (possibly updated) reservation_time so the + // planner renders the booking on the correct day. + let nextDayId: number | null; + if (day_id !== undefined) { + nextDayId = day_id || null; + } else if (reservation_time !== undefined && resolvedType !== 'hotel') { + nextDayId = resolveDayIdFromTime(tripId, nextReservationTime); + } else { + nextDayId = current.day_id ?? null; + } + + let nextEndDayId: number | null; + if (end_day_id !== undefined) { + nextEndDayId = end_day_id ?? null; + } else if (reservation_end_time !== undefined && resolvedType !== 'hotel') { + nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime); + } else { + nextEndDayId = (current as any).end_day_id ?? null; + } + db.prepare(` UPDATE reservations SET title = COALESCE(?, title), @@ -310,13 +370,13 @@ export function updateReservation(id: string | number, tripId: string | number, WHERE id = ? `).run( title || null, - (type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time), - (type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time), + nextReservationTime, + nextReservationEndTime, location !== undefined ? (location || null) : current.location, confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number, notes !== undefined ? (notes || null) : current.notes, - day_id !== undefined ? (day_id || null) : current.day_id, - end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null, + nextDayId, + nextEndDayId, place_id !== undefined ? (place_id || null) : current.place_id, assignment_id !== undefined ? (assignment_id || null) : current.assignment_id, status || null, diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 0fcd6a33..40179329 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -84,8 +84,9 @@ describe('GET /api/system-notices/active', () => { it('returns empty array for non-first-login user with no applicable notices', async () => { const { user } = createUser(testDb); - // login_count > 1 means firstLogin condition does not match for any notice - testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); + // login_count > 1 means firstLogin condition does not match for any notice; + // first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); const res = await request(app) .get('/api/system-notices/active') .set('Cookie', authCookie(user.id)); @@ -122,7 +123,7 @@ describe('GET /api/system-notices/active', () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id); + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); const res = await request(app) .get('/api/system-notices/active') diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts index 950eff04..82b14d55 100644 --- a/server/tests/unit/services/journeyService.test.ts +++ b/server/tests/unit/services/journeyService.test.ts @@ -68,6 +68,7 @@ import { removeContributor, getSuggestions, syncTripPlaces, + reorderEntries, onPlaceCreated, onPlaceUpdated, onPlaceDeleted, @@ -1465,3 +1466,108 @@ describe('addProviderPhoto — passphrase', () => { expect(row?.passphrase).not.toBe('secret-pp'); }); }); + +// -- reorderEntries (#846) ---------------------------------------------------- + +function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } { + const now = Date.now(); + const res = testDb.prepare(` + INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at) + VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?) + `).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now); + return { id: Number(res.lastInsertRowid) }; +} + +describe('reorderEntries', () => { + it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 }); + const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 }); + + const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]); + expect(ok).toBe(true); + + const entries = listEntries(journey.id, user.id)!; + const dayEntries = entries.filter(e => e.entry_date === '2026-08-01'); + expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]); + }); + + it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => { + const { user } = createUser(testDb); + const j1 = createJourney(testDb, user.id); + const j2 = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' }); + + const ok = reorderEntries(j1.id, user.id, [entry.id]); + expect(ok).toBe(false); + }); + + it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 }); + const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 }); + const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 }); + + reorderEntries(journey.id, user.id, [day1b.id, day1a.id]); + + const entries = listEntries(journey.id, user.id)!; + const day2Entry = entries.find(e => e.id === day2.id)!; + expect(day2Entry.sort_order).toBe(0); + }); +}); + +describe('syncTripPlaces sort_order', () => { + it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id, { + title: 'Order Trip', + start_date: '2026-09-01', + end_date: '2026-09-02', + }); + const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; + const p1 = createPlace(testDb, trip.id, { name: 'Place A' }); + const p2 = createPlace(testDb, trip.id, { name: 'Place B' }); + const p3 = createPlace(testDb, trip.id, { name: 'Place C' }); + createDayAssignment(testDb, day.id, p1.id); + createDayAssignment(testDb, day.id, p2.id); + createDayAssignment(testDb, day.id, p3.id); + + syncTripPlaces(journey.id, trip.id, user.id); + + const rows = testDb.prepare( + 'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC' + ).all(journey.id) as { sort_order: number }[]; + const orders = rows.map(r => r.sort_order); + expect(new Set(orders).size).toBe(orders.length); + expect(orders).toEqual([0, 1, 2]); + }); +}); + +describe('onPlaceCreated sort_order', () => { + it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id, { + title: 'Append Trip', + start_date: '2026-10-01', + end_date: '2026-10-02', + }); + addTripToJourney(journey.id, trip.id, user.id); + + const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string }; + insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 }); + + const place = createPlace(testDb, trip.id, { name: 'Late Addition' }); + createDayAssignment(testDb, day.id, place.id); + onPlaceCreated(trip.id, place.id); + + const newEntry = testDb.prepare( + 'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?' + ).get(journey.id, place.id) as { sort_order: number } | undefined; + expect(newEntry).toBeDefined(); + expect(newEntry!.sort_order).toBe(6); + }); +}); diff --git a/server/tests/unit/services/oidcService.test.ts b/server/tests/unit/services/oidcService.test.ts index eca92065..5de82a4b 100644 --- a/server/tests/unit/services/oidcService.test.ts +++ b/server/tests/unit/services/oidcService.test.ts @@ -4,6 +4,8 @@ * discover caching, and the ReDoS-sensitive issuer trailing-slash regex. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import { generateKeyPairSync } from 'crypto'; +import jwtLib from 'jsonwebtoken'; // ── DB setup ────────────────────────────────────────────────────────────────── @@ -50,6 +52,7 @@ import { frontendUrl, findOrCreateUser, discover, + verifyIdToken, } from '../../../src/services/oidcService'; const MOCK_CONFIG = { @@ -216,6 +219,59 @@ describe('discover', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); await expect(discover('https://bad-issuer.example.com')).rejects.toThrow(); }); + + it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => { + const doc = { + issuer: 'https://auth.example.com/application/o/myapp/', + authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/', + token_endpoint: 'https://auth.example.com/application/o/token/', + userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/', + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc })); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await discover( + 'https://auth.example.com', + 'https://auth.example.com/application/o/myapp/.well-known/openid-configuration', + ); + + expect(result.issuer).toBe(doc.issuer); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER')); + warnSpy.mockRestore(); + }); + + it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => { + const doc = { + issuer: 'https://evil.example.com', + authorization_endpoint: 'https://unique-2.example.com/auth', + token_endpoint: 'https://unique-2.example.com/token', + userinfo_endpoint: 'https://unique-2.example.com/userinfo', + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc })); + + await expect(discover('https://unique-2.example.com')).rejects.toThrow( + 'OIDC discovery issuer mismatch', + ); + }); + + it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => { + const doc = { + issuer: 'https://auth.example.com/', + authorization_endpoint: 'https://auth.example.com/auth', + token_endpoint: 'https://auth.example.com/token', + userinfo_endpoint: 'https://auth.example.com/userinfo', + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc })); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await discover( + 'https://auth.example.com', + 'https://auth.example.com/.well-known/openid-configuration', + ); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); // ── issuer trailing-slash regex (ReDoS guard) ───────────────────────────────── @@ -460,3 +516,66 @@ describe('getUserInfo', () => { expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123'); }); }); + +// ── verifyIdToken ───────────────────────────────────────────────────────────── + +describe('verifyIdToken', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = publicKey.export({ format: 'jwk' }) as Record; + const ISSUER = 'https://auth.example.com/application/o/trek'; + const CLIENT_ID = 'trek-client'; + const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json'; + + function mockJwks() { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ keys: [jwk] }), + })); + } + + function makeToken(iss: string, overrides: object = {}) { + return jwtLib.sign( + { sub: 'user-sub', email: 'user@example.com', ...overrides }, + privateKey, + { algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' } + ); + } + + const doc = { jwks_uri: JWKS_URI } as any; + + afterEach(() => { vi.unstubAllGlobals(); }); + + it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => { + mockJwks(); + const token = makeToken(ISSUER); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(true); + }); + + it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => { + mockJwks(); + const token = makeToken(ISSUER + '/'); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(true); + }); + + it('OIDC-SVC-035: rejects token with wrong issuer', async () => { + mockJwks(); + const token = makeToken('https://evil.example.com'); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(false); + expect((result as any).error).toMatch('jwt issuer invalid'); + }); + + it('OIDC-SVC-036: rejects token with wrong audience', async () => { + mockJwks(); + const token = makeToken(ISSUER, {}); + const wrongAudToken = jwtLib.sign( + { sub: 'user-sub', iss: ISSUER }, + privateKey, + { algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' } + ); + const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(false); + }); +}); diff --git a/unraid-template.xml b/unraid-template.xml index 69ca38f6..b9c48442 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -37,6 +37,7 @@ false + false true 1 false diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 9f1e658f..7ea29c5a 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -48,11 +48,12 @@ Verified in `server/src/config.ts` (line 107): ## HTTPS / Proxy -These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation. +These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation. | Variable | Description | Default | |---|---|---| | `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` | +| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | | `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto | @@ -62,7 +63,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See ## OIDC / SSO -For setup instructions, see [OIDC-SSO]. +For setup instructions, see [OIDC-SSO](OIDC-SSO). | Variable | Description | Default | |---|---|---| @@ -110,7 +111,7 @@ Both variables must be set together. If either is omitted, the account is create ## MCP -For setup instructions, see [MCP-Overview]. +For setup instructions, see [MCP-Overview](MCP-Overview). | Variable | Description | Default | |---|---|---| @@ -129,7 +130,7 @@ For setup instructions, see [MCP-Overview]. ## Related Pages -- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio -- [OIDC-SSO] — complete OIDC configuration guide -- [MCP-Overview] — MCP server setup and rate limiting -- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data +- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio +- [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide +- [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting +- [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data diff --git a/wiki/Install-Docker-Compose.md b/wiki/Install-Docker-Compose.md index f9344617..16b72821 100644 --- a/wiki/Install-Docker-Compose.md +++ b/wiki/Install-Docker-Compose.md @@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com APP_URL=https://trek.example.com ``` -Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables]. +Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables). ## Start TREK @@ -111,10 +111,10 @@ docker compose logs -f This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`. -See [Reverse-Proxy] for complete proxy configuration examples. +See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples. ## Next Steps -- [Environment-Variables] — full variable reference -- [Reverse-Proxy] — HTTPS configuration -- [Updating] — how to pull a new image +- [Environment-Variables](Environment-Variables) — full variable reference +- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration +- [Updating](Updating) — how to pull a new image diff --git a/wiki/Install-Docker.md b/wiki/Install-Docker.md index 17dd3983..62ddbf8d 100644 --- a/wiki/Install-Docker.md +++ b/wiki/Install-Docker.md @@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support: -e ALLOWED_ORIGINS=https://trek.example.com \ ``` -See [Environment-Variables] for the full list. +See [Environment-Variables](Environment-Variables) for the full list. ## Volume Reference @@ -66,11 +66,11 @@ docker logs trek ## Limitations of `docker run` -A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file. +A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file. ## Next Steps -- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag -- [Install-Docker-Compose] — recommended for production -- [Environment-Variables] — full list of configurable variables -- [Updating] — how to pull a new image without losing data +- [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag +- [Install-Docker-Compose](Install-Docker-Compose) — recommended for production +- [Environment-Variables](Environment-Variables) — full list of configurable variables +- [Updating](Updating) — how to pull a new image without losing data diff --git a/wiki/Install-Helm.md b/wiki/Install-Helm.md index d0fca6db..1a320a09 100644 --- a/wiki/Install-Helm.md +++ b/wiki/Install-Helm.md @@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts ## Next Steps -- [Environment-Variables] — full variable reference -- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments +- [Environment-Variables](Environment-Variables) — full variable reference +- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments diff --git a/wiki/Install-Unraid.md b/wiki/Install-Unraid.md index 83e1706f..0edf49d6 100644 --- a/wiki/Install-Unraid.md +++ b/wiki/Install-Unraid.md @@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are ## Next Steps -- [Environment-Variables] — complete variable reference -- [Updating] — how to pull a new image on Unraid +- [Environment-Variables](Environment-Variables) — complete variable reference +- [Updating](Updating) — how to pull a new image on Unraid diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md index 878976eb..3d8e170e 100644 --- a/wiki/Map-Features.md +++ b/wiki/Map-Features.md @@ -36,18 +36,20 @@ When you have a day selected, a dark dashed line connects consecutive places in At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean. +> **Requires:** Settings → Display → **Route calculation** must be ON. When this setting is OFF, TREK never queries the routing service, so no pills are calculated or drawn at any zoom level. + ## Reservation and transport overlay -Flights, trains, cars, and cruises are drawn as overlays between their endpoint places: +Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** — activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it. - **Flights and cruises** — geodesic great-circle arcs - **Trains and cars** — straight lines - **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map - **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name -- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights. +- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings → Display → **Route calculation** to be ON. - **Confirmed reservations** — solid line; **Pending** — dashed line -> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`). +> **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings → Display (`map_booking_labels`). ## Location button diff --git a/wiki/Quick-Start.md b/wiki/Quick-Start.md index 786af51d..17e7b970 100644 --- a/wiki/Quick-Start.md +++ b/wiki/Quick-Start.md @@ -60,7 +60,7 @@ You will be prompted to change the password on first login. ## Next Steps -- [Install-Docker-Compose] — production setup with security hardening -- [Reverse-Proxy] — put TREK behind HTTPS (required for PWA install and secure cookies) -- [Environment-Variables] — full configuration reference -- [Admin-Panel-Overview] — explore what the admin panel can do +- [Install-Docker-Compose](Install-Docker-Compose) — production setup with security hardening +- [Reverse-Proxy](Reverse-Proxy) — put TREK behind HTTPS (required for PWA install and secure cookies) +- [Environment-Variables](Environment-Variables) — full configuration reference +- [Admin-Panel-Overview](Admin-Panel-Overview) — explore what the admin panel can do diff --git a/wiki/Reverse-Proxy.md b/wiki/Reverse-Proxy.md index a9993960..526472ee 100644 --- a/wiki/Reverse-Proxy.md +++ b/wiki/Reverse-Proxy.md @@ -98,9 +98,9 @@ Four variables control how TREK behaves behind a proxy. They work as a group: If you access TREK directly on `http://:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`. -See [Environment-Variables] for full documentation of these and all other variables. +See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables. ## Next Steps -- [Environment-Variables] — full variable reference including OIDC -- [Install-Docker-Compose] — production compose file with proxy-ready env vars +- [Environment-Variables](Environment-Variables) — full variable reference including OIDC +- [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars diff --git a/wiki/Updating.md b/wiki/Updating.md index 46e45ba9..2e63c91d 100644 --- a/wiki/Updating.md +++ b/wiki/Updating.md @@ -4,7 +4,7 @@ How to update TREK to a newer version without losing data. ## Before You Update -Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups] for details. +Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details. ## Docker Compose (Recommended) @@ -42,7 +42,7 @@ TREK runs any pending database migrations automatically at startup. No manual mi If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade. -If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation] for the full procedure. +If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure. ## Unraid @@ -50,6 +50,6 @@ In the Unraid Docker tab, click the TREK container and select **Update**. Unraid ## Next Steps -- [Backups] — schedule automatic backups so you always have a restore point before updates -- [Encryption-Key-Rotation] — if you need to rotate or migrate the encryption key -- [Install-Docker-Compose] — switch to Compose for easier future updates +- [Backups](Backups) — schedule automatic backups so you always have a restore point before updates +- [Encryption-Key-Rotation](Encryption-Key-Rotation) — if you need to rotate or migrate the encryption key +- [Install-Docker-Compose](Install-Docker-Compose) — switch to Compose for easier future updates From 499097fa3cdcc89cf786607fdd0e33e4d374190f Mon Sep 17 00:00:00 2001 From: "Julien G." <66769052+jubnl@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:18:22 +0200 Subject: [PATCH 8/9] align dev (#899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump version to 3.0.0 [skip ci] * fix: resolve dead wiki links across install and config pages * fix(reservations): restore correct day assignment for non-transport bookings v3.0.0 switched the planner from rendering reservations by reservation_time to rendering them by day_id (commit 3f61e1c), but migration 110 only backfilled day_id for transport types. Tours, restaurants, events and 'other' bookings kept whatever day_id was stored in the DB — often the trip's first day, from older code paths that defaulted it there — so after the upgrade those rows all show up on day 1 regardless of their actual reservation_time. - Migration 122: for every non-hotel reservation, null out any day_id / end_day_id that does not match the reservation's time, then backfill it from reservation_time / reservation_end_time. Idempotent; leaves already-correct rows alone. - reservationService.createReservation / updateReservation now derive day_id / end_day_id from reservation_time / reservation_end_time when the client didn't send one explicitly, so the mismatch cannot reappear on new or edited bookings. Hotels are skipped because they store their date range on the linked day_accommodation. * chore: bump version to 3.0.1 [skip ci] * fix(oidc): normalize discovery doc issuer before comparison Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against the already-normalized configured issuer, breaking OIDC login entirely. Closes #834 * test(systemNotices): exclude v3 upgrade notices from login_count-only tests Tests that expect an empty notice list were using first_seen_version='0.0.0' (DB default), which matches the existingUserBeforeVersion('3.0.0') condition now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the firstLogin condition controls visibility in these tests. * chore: bump version to 3.0.2 [skip ci] * fix(oidc): normalize id_token iss claim before issuer comparison (#837) jwt.verify does an exact string match on the issuer. Providers like Authentik include a trailing slash in the id_token iss claim while the configured issuer is already normalized (no trailing slash), causing every login attempt to fail with jwt issuer invalid. Move the issuer check out of jwt.verify options and apply the same trailing-slash normalization used in the discovery doc validation. Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing slash, wrong issuer, and wrong audience cases. Closes #834 * chore: bump version to 3.0.3 [skip ci] * fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845) OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery doc's issuer for id_token comparison instead of rejecting a path mismatch as an error. Authentik (and similar realm-path providers) return a canonical issuer like /application/o// that differs from the operator's base OIDC_ISSUER. Strict equality blocked login in 3.x despite working in v2. Default discovery (no custom URL) keeps the strict check. Adds OIDC-SVC-037/038/039. UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h paddingBottom offset that other overlays already use. On mobile portrait the action buttons were hidden behind the sticky bottom nav bar. Closes #843 Closes #844 * chore: bump version to 3.0.4 [skip ci] * fix(files): open attachments only in new tab (#840) window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead. * chore: bump version to 3.0.5 [skip ci] * fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848) * fix(journey): make sort_order authoritative for within-day entry ordering Reorder buttons appeared broken because the server ORDER BY put entry_time before sort_order, so entries synced from trip places with differing times would always sort by time regardless of sort_order writes. The client store mirrored the same comparator, making even the optimistic update invisible. - Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries - Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0 - Update client store comparator to match - Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order - Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019 Closes #846 * fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847) Reservations were matched to days by pickup date only, so the end-day card (e.g. car Return, flight Arrival) was silently dropped from the PDF. Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id span, show reservation_end_time on end days, prefix title with phase label (Return/Arrival/etc.), and use per-day position for sort order. * test(pdf): add missing day_id to transport reservation fixture * chore: bump version to 3.0.6 [skip ci] * [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849) * fix: server/package.json & server/package-lock.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-UUID-16133035 * fix: bump fast-xml-parser version --------- Co-authored-by: snyk-bot Co-authored-by: jubnl * chore: bump version to 3.0.7 [skip ci] * fix: hot fixes 23-04-2026 (#856) * fix(packing): resolve avatar URL path in bag and category assignees (#854) packingService was returning raw avatar filenames from the DB instead of the full /uploads/avatars/ path, causing broken profile images for users with uploaded avatars. * fix(budget): use Map.get() to fix category rename no-op (#855) * fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863) - Change Helmet default from no-referrer to strict-origin-when-cross-origin so browsers send the origin on cross-origin requests, allowing Google Maps API key restrictions by HTTP referrer to work correctly - Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts: .env.example, docker-compose.yml, README.md, unraid-template.xml, charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md * fix(planner): prefetch budget items on trip page mount (#864) Loads budgetItems alongside reservations when TripPlannerPage mounts so the Budget category dropdown in ReservationModal and TransportModal shows pre-existing categories on first open, regardless of whether the Budget tab has been visited. Closes #861 * fix(reservations): prevent Invalid Date when end time is set without end date (#866) When reservation_end_time held a bare time string ("HH:MM"), fmtDate() produced Invalid Date on the reservation card. - Modal: when end date is blank but end time is filled, construct a same-day ISO datetime using the start date (prevents time-only strings from ever being persisted) - Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD") still show the multi-day range, while bare time strings are skipped and handled correctly by the existing time column logic Closes #860 * fix(planner): format reservation end time instead of rendering raw ISO string (#867) Closes #859 * fix(planner): wire Route toggle into mobile day sidebar (#850) (#868) The per-booking Route icon was missing on mobile because the mobile DayPlanSidebar invocation in TripPlannerPage didn't pass visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't activate reservation map overlays without forcing desktop mode. Also corrects the Map-Features wiki: fixes the setting name ("Booking route labels" not "Show connection labels"), documents the route_calculation requirement for travel-time pills, and explains that overlays are off by default and must be toggled per reservation. * chore: bump version to 3.0.8 [skip ci] * docs(wiki): add MCP OAuth troubleshooting entry for missing APP_URL * Fix demo banner overlapping bottom tab bar on mobile The demo welcome modal extended below the mobile bottom tab bar, hiding the dismiss button so visitors couldn't close it. - Use dvh so mobile URL bar is accounted for correctly - Reserve ~80px of bottom padding for the tab bar - Make the footer sticky so the dismiss button stays visible while scrolling through the modal content - Bump z-index to ensure the overlay sits above the tab bar * Fix 500 on reservation edit after DB reinit (issue #883) saveEndpoints was bound at module load via db.transaction(...). When the demo-mode hourly reset (or a self-hoster's backup restore) closes the DB connection and reinitialises it, the bound transaction still references the now-closed connection — every subsequent reservation save with an endpoints field throws "The database connection is not open", which the client surfaces as "Internal server error". Bind the transaction lazily on each call so it always runs against the current connection. * Fix exit code 132 on old CPUs by replacing sharp with jimp (issue #888) (#895) sharp's prebuilt Linux x64 binary requires SSE4.2 (x86-64-v2), causing a SIGILL crash on older hardware (e.g. AMD A6-3420M). Replace with jimp, a pure-JS image library with no native binaries. Also skip thumbnail generation entirely when the Journey addon is disabled (the default), preventing the issue for most installs regardless of the image library used. * chore: Add Trademark policy * chore: Add Trademark policy * chore: bump version to 3.0.9 [skip ci] --------- Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Maurice Co-authored-by: Xre0uS <36565320+Xre0uS@users.noreply.github.com> Co-authored-by: snyk-bot --- TRADEMARKS.md | 121 ++ charts/trek/Chart.yaml | 4 +- client/package-lock.json | 10 +- client/package.json | 2 +- client/src/components/Layout/DemoBanner.tsx | 17 +- server/package-lock.json | 1417 +++++++++++------ server/package.json | 4 +- .../src/services/memories/thumbnailService.ts | 29 +- server/src/services/reservationService.ts | 26 +- wiki/Troubleshooting.md | 17 + 10 files changed, 1083 insertions(+), 564 deletions(-) create mode 100644 TRADEMARKS.md diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 00000000..3483065e --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,121 @@ +# Trademark Policy + +## Introduction + +This is the TREK project's policy for the use of our trademarks. While TREK is +available under the GNU Affero General Public License v3.0 (AGPL-3.0), that +license does not include a license to use our trademarks. + +This policy describes how you may use our trademarks. Our goal is to strike a +balance between: 1) our need to ensure that our trademarks remain reliable +indicators of the software we release; and 2) our community members' desire to +be full participants in the TREK project. + +## Our trademarks + +This policy covers the name "TREK" as well as any associated logos, trade dress, +goodwill, or designs (our "Marks"). + +## In general + +Whenever you use our Marks, you must always do so in a way that does not mislead +anyone about exactly who is the source of the software. For example, you cannot +say you are distributing TREK when you're distributing a modified version of it, +because people would think they would be getting the same software that they +can get directly from us when they aren't. You also cannot use our Marks on +your website in a way that suggests that your website is an official TREK +website or that we endorse your website. But, if true, you can say you like +TREK, that you participate in the TREK community, that you are providing an +unmodified version of TREK, or that you wrote a guide describing how to use +TREK. + +This fundamental requirement, that it is always clear to people what they are +getting and from whom, is reflected throughout this policy. It should also +serve as your guide if you are not sure about how you are using the Marks. + +In addition: + +* You may not use or register, in whole or in part, the Marks as part of your + own trademark, service mark, domain name, company name, trade name, product + name or service name. +* Trademark law does not allow your use of names or trademarks that are too + similar to ours. You therefore may not use an obvious variation of any of our + Marks or any phonetic equivalent, foreign language equivalent, takeoff, or + abbreviation for a similar or compatible product or service. +* You agree that you will not acquire any rights in the Marks and that any + goodwill generated by your use of the Marks and participation in our + community inures solely to our benefit. + +## Distribution of unmodified source code or unmodified executable code we have compiled + +When you redistribute an unmodified copy of TREK, you are not changing the +quality or nature of it. Therefore, you may retain the Marks we have placed on +the software to identify your redistribution. This kind of use only applies if +you are redistributing an official TREK distribution that has not been changed +in any way. + +## Distribution of executable code that you have compiled, or modified code + +You may use the word mark "TREK", but not any TREK logos, to truthfully +describe the origin of the software that you are providing, that is, that the +code you are distributing is a modification of TREK. You may say, for example, +that "this software is derived from the source code for TREK." + +Of course, you can place your own trademarks or logos on versions of the +software to which you have made substantive modifications, because by modifying +the software, you have become the origin of that exact version. In that case, +you should not use our Marks. + +However, you may use our Marks for the distribution of code (source or +executable) on the condition that any executable is built from an official TREK +source code release and that any modifications are limited to switching on or +off features already included in the software, translations into other +languages, and incorporating minor bug-fix patches. Use of our Marks on any +further modification is not permitted. + +## Mobile wrappers, hosted instances, and forks + +The following clarifications apply specifically to common ways TREK is +redistributed: + +* **Self-hosted instances of unmodified TREK.** You may refer to your instance + as "a TREK instance" or "running TREK." You may not name the service itself + in a way that suggests it is the official TREK ("TREK Cloud," "TREK + Official," etc.). +* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at + TREK.** You may describe your app as "a mobile client for TREK" or "for use + with TREK." You may not publish it on app stores under the name "TREK" or a + confusingly similar name, and you may not use the TREK logo as the app icon + unless your wrapper distributes only an unmodified, official TREK instance + and you have obtained permission. +* **Forks of the TREK source code.** Forks that diverge from upstream must use + a different name. You may state that your fork is "based on TREK" or "a fork + of TREK," but the project name itself must be your own. + +## Statements about your software's relation to TREK + +You may use the word mark, but not TREK logos, to truthfully describe the +relationship between your software and ours. The word mark "TREK" should be +used after a verb or preposition that describes the relationship between your +software and ours. So you may say, for example, "Bob's app for TREK" but may +not say "Bob's TREK app." Some other examples that may work for you are: + +* [Your software] uses TREK +* [Your software] is powered by TREK +* [Your software] runs on TREK +* [Your software] for use with TREK +* [Your software] for TREK + +## Questions and permission requests + +If you are not sure whether your intended use of the Marks is permitted under +this policy, or if you would like to request explicit permission for a use that +is not covered, please open an issue on the TREK GitHub repository or contact +the maintainers directly. + +--- + +These guidelines are based on the +[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used +under a +[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US). \ No newline at end of file diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 7ce3bafe..a06ed5a9 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 3.0.8 +version: 3.0.9 description: Minimal Helm chart for TREK app -appVersion: "3.0.8" +appVersion: "3.0.9" diff --git a/client/package-lock.json b/client/package-lock.json index 6dd3d7b8..646cd40a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "3.0.8", + "version": "3.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "3.0.8", + "version": "3.0.9", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", @@ -8907,9 +8907,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { diff --git a/client/package.json b/client/package.json index 3e508bb2..04e502f9 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "3.0.8", + "version": "3.0.9", "private": true, "type": "module", "scripts": { diff --git a/client/src/components/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx index ba77cd40..c4068017 100644 --- a/client/src/components/Layout/DemoBanner.tsx +++ b/client/src/components/Layout/DemoBanner.tsx @@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null { return (
setDismissed(true)}>
) => e.stopPropagation()}> {/* Header */} @@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null { {/* Footer */}
diff --git a/server/package-lock.json b/server/package-lock.json index 6510f142..0c011b40 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-server", - "version": "3.0.8", + "version": "3.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-server", - "version": "3.0.8", + "version": "3.0.9", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "archiver": "^6.0.1", @@ -18,6 +18,7 @@ "express": "^4.18.3", "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", + "jimp": "^1.6.1", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "node-cron": "^4.2.1", @@ -25,7 +26,6 @@ "otplib": "^12.0.1", "qrcode": "^1.5.4", "semver": "^7.7.4", - "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^6.0.2", "undici": "^7.0.0", @@ -133,14 +133,14 @@ "node": ">=18" } }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" } }, "node_modules/@esbuild/aix-ppc64": { @@ -571,471 +571,6 @@ "hono": "^4" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1149,6 +684,583 @@ "node": ">=8" } }, + "node_modules/@jimp/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", + "license": "MIT", + "dependencies": { + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^21.3.3", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/core/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", + "license": "MIT", + "dependencies": { + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.1", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1936,6 +2048,29 @@ "win32" ] }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/archiver": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", @@ -2482,6 +2617,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2585,6 +2726,15 @@ "dev": true, "license": "MIT" }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -2775,6 +2925,12 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -3597,6 +3753,11 @@ "node": ">=18.0.0" } }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3766,6 +3927,24 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4008,6 +4187,16 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -4199,6 +4388,21 @@ "dev": true, "license": "ISC" }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4383,6 +4587,44 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jimp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -4392,6 +4634,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -4954,6 +5202,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5029,6 +5283,34 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5131,6 +5413,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -5150,9 +5453,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -5494,6 +5797,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -5572,50 +5884,6 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5787,6 +6055,15 @@ "node": ">=10" } }, + "node_modules/simple-xml-to-json": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", + "license": "MIT", + "engines": { + "node": ">=20.12.2" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5945,6 +6222,22 @@ ], "license": "MIT" }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", @@ -6182,6 +6475,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -6289,6 +6588,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -6299,13 +6616,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -6376,6 +6686,18 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6430,6 +6752,15 @@ "node-int64": "^0.4.0" } }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6780,6 +7111,34 @@ } } }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/server/package.json b/server/package.json index b061a193..6b8f49ce 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "trek-server", - "version": "3.0.8", + "version": "3.0.9", "main": "src/index.ts", "scripts": { "start": "node --import tsx src/index.ts", @@ -23,6 +23,7 @@ "express": "^4.18.3", "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", + "jimp": "^1.6.1", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "node-cron": "^4.2.1", @@ -30,7 +31,6 @@ "otplib": "^12.0.1", "qrcode": "^1.5.4", "semver": "^7.7.4", - "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^6.0.2", "undici": "^7.0.0", diff --git a/server/src/services/memories/thumbnailService.ts b/server/src/services/memories/thumbnailService.ts index 765f5f95..d328b579 100644 --- a/server/src/services/memories/thumbnailService.ts +++ b/server/src/services/memories/thumbnailService.ts @@ -1,7 +1,9 @@ -import sharp from 'sharp' +import { Jimp } from 'jimp' import path from 'path' import fs from 'fs/promises' import crypto from 'crypto' +import { isAddonEnabled } from '../adminService' +import { ADDON_IDS } from '../../addons' const THUMB_MAX = 800 const THUMB_QUALITY = 80 @@ -10,12 +12,14 @@ export async function ensureLocalThumbnail( uploadsRoot: string, originalRelPath: string, ): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> { + if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return null + const originalAbs = path.join(uploadsRoot, originalRelPath) try { await fs.access(originalAbs) } catch { return null } // Deterministic name so concurrent requests don't race on the same photo. const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16) - const thumbRel = `journey/thumbs/${hash}.webp` + const thumbRel = `journey/thumbs/${hash}.jpg` const thumbAbs = path.join(uploadsRoot, thumbRel) try { @@ -24,18 +28,21 @@ export async function ensureLocalThumbnail( fs.stat(thumbAbs).catch(() => null), ]) if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) { - const meta = await sharp(thumbAbs).metadata() - return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } + const img = await Jimp.read(thumbAbs) + return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height } } await fs.mkdir(path.dirname(thumbAbs), { recursive: true }) - await sharp(originalAbs) - .rotate() - .resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true }) - .webp({ quality: THUMB_QUALITY }) - .toFile(thumbAbs) - const meta = await sharp(thumbAbs).metadata() - return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } + + // Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior. + const img = await Jimp.read(originalAbs) + const { width: w, height: h } = img.bitmap + if (w > THUMB_MAX || h > THUMB_MAX) { + img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX }) + } + await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY }) + + return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height } } catch { // Unsupported format, corrupt file, etc. — fall back to original in caller. return null diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index d95aadd5..354a14f7 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -61,16 +61,24 @@ function resolveDayIdFromTime( return row?.id ?? null; } -const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => { - db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId); - const insert = db.prepare(` - INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - endpoints.forEach((e, i) => { - insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null); +function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void { + // Bind the transaction lazily on each call. Binding at module load time + // captures the DB connection that was open then, which becomes invalid + // after demo-reset / restore-from-backup closes and reinitialises the + // connection — every later endpoint save would throw + // "The database connection is not open". + const tx = db.transaction((rid: number, eps: EndpointInput[]) => { + db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(rid); + const insert = db.prepare(` + INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + eps.forEach((e, i) => { + insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null); + }); }); -}); + tx(reservationId, endpoints); +} export function listReservations(tripId: string | number) { const reservations = db.prepare(` diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index eb32feb2..aae97181 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -223,6 +223,23 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default). --- +## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts + +**Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes. + +**Fix:** Set `APP_URL` to the public URL of your instance: + +```yaml +environment: + - APP_URL=https://trek.example.com +``` + +Restart the container after adding the variable. Once set, clicking **Connect** in the MCP client should redirect to your TREK instance and complete the OAuth flow normally. + +> **Note:** `APP_URL` is required for any MCP OAuth integration. Without it, the authorization endpoint resolves to `http://localhost:`, which is unreachable from external MCP clients. + +--- + ## MCP integration: "Too many requests" or "Session limit reached" **Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response. From b8019dfc8ca9b021c274da8f992b2fad0144f620 Mon Sep 17 00:00:00 2001 From: sss3978 <106522699+soma3978@users.noreply.github.com> Date: Thu, 14 May 2026 17:22:21 +0900 Subject: [PATCH 9/9] Update ja.ts --- client/src/i18n/translations/ja.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/client/src/i18n/translations/ja.ts b/client/src/i18n/translations/ja.ts index 289c1cd4..75014962 100644 --- a/client/src/i18n/translations/ja.ts +++ b/client/src/i18n/translations/ja.ts @@ -30,6 +30,8 @@ const ja: Record = { 'common.none': 'なし', 'common.date': '日付', 'common.rename': '名前を変更', + 'common.discardChanges': '変更をキャンセル', + 'common.discard': 'キャンセル', 'common.name': '名前', 'common.email': 'メールアドレス', 'common.password': 'パスワード', @@ -122,6 +124,20 @@ const ja: Record = { 'dashboard.toast.copied': '旅行をコピーしました!', 'dashboard.toast.copyError': '旅行のコピーに失敗しました', 'dashboard.confirm.delete': '旅行「{title}」を削除しますか?すべての場所と計画は完全に削除されます。', + 'dashboard.confirm.copy.title': 'この旅行をコピーしますか?', + 'dashboard.confirm.copy.willCopy': 'コピーされる内容', + 'dashboard.confirm.copy.will1': '日数、訪問先 & 日ごとの割り当て', + 'dashboard.confirm.copy.will2': '宿泊施設 & 予約', + 'dashboard.confirm.copy.will3': '予算項目 & カテゴリの順序', + 'dashboard.confirm.copy.will4': '持ち物リスト(未チェックのみ)', + 'dashboard.confirm.copy.will5': 'TODO(未割り当て & 未チェック)', + 'dashboard.confirm.copy.will6': '日ごとのメモ', + 'dashboard.confirm.copy.wontCopy': 'コピーされない内容', + 'dashboard.confirm.copy.wont1': '共同編集者 & メンバー割り当て', + 'dashboard.confirm.copy.wont2': '共同ノート、投票 & メッセージ', + 'dashboard.confirm.copy.wont3': 'ファイル & 写真', + 'dashboard.confirm.copy.wont4': '共有トークン', + 'dashboard.confirm.copy.confirm': '旅行をコピー', 'dashboard.editTrip': '旅行を編集', 'dashboard.createTrip': '新しい旅行を作成', 'dashboard.tripTitle': 'タイトル', @@ -1306,6 +1322,7 @@ const ja: Record = { 'files.toast.deleteError': 'ファイルの削除に失敗しました', 'files.sourcePlan': '日別計画', 'files.sourceBooking': '予約', + 'files.sourceTransport': '移動', 'files.attach': '添付', 'files.pasteHint': 'クリップボードから画像を貼り付けることもできます(Ctrl+V)', 'files.trash': 'ゴミ箱', @@ -1318,6 +1335,7 @@ const ja: Record = { 'files.assignTitle': 'ファイルを割り当て', 'files.assignPlace': '場所', 'files.assignBooking': '予約', + 'files.assignTransport': '移動', 'files.unassigned': '未割り当て', 'files.unlink': 'リンクを解除', 'files.toast.trashed': 'ゴミ箱に移動しました', @@ -1685,6 +1703,7 @@ const ja: Record = { 'memories.providerUrlHintSynology': 'URLにPhotosアプリのパスを含めてください(例:https://nas:5001/photo)', 'memories.testConnection': '接続をテスト', 'memories.testFirst': '先に接続をテストしてください', + 'memories.testShort': 'テスト', 'memories.connected': '接続済み', 'memories.disconnected': '未接続', 'memories.connectionSuccess': '{provider_name} に接続しました', @@ -2374,7 +2393,11 @@ const ja: Record = { 'system_notice.v3_thankyou.title': '開発者より一言', 'system_notice.v3_thankyou.body': '少しだけお時間をください。\n\nTREKは、自分の旅のために作った小さな個人プロジェクトでした。それが今では4,000人以上に使ってもらえるとは思ってもいませんでした。スターも、Issueも、機能要望も、すべて目を通しています。\n\nTREKはこれからもオープンソース、自分でホストでき、あなたのものです。トラッキングなし、サブスクなし。旅が好きな人が作ったツールです。\n\nhttps://github.com/jubnlにも感謝を。3.0の多くはあなたのおかげです。\n\nバグ報告、翻訳、共有、利用してくれたすべての方へ—本当にありがとうございます。\n\nこれからも一緒に旅を。\n\n— Maurice', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': '対応が必要:ユーザーアカウントの競合', + 'system_notice.v3014_whitespace_collision.body': '3.0.14 へのアップグレードにより、保存されているアカウントの先頭または末尾の空白が原因で、ユーザー名またはメールアドレスの競合が1件以上検出されました。影響を受けたアカウントは自動的にリネームされています。対象となるアカウントを特定するには、サーバーログで **[migration] WHITESPACE COLLISION** で始まる行を確認してください。', // System notices — onboarding + 'system_notice.welcome_v1.title': 'TREKへようこそ', 'system_notice.welcome_v1.body': 'オールインワンの旅行プランナー。旅程作成、共有、整理をオンライン・オフラインで。', 'system_notice.welcome_v1.cta_label': '旅行を計画',