Compare commits

..

6 Commits

Author SHA1 Message Date
sss3978 00a7ac0341 Merge d30132197e into 71aa8f8051 2026-04-22 14:00:59 +00:00
sss3978 d30132197e Update client/src/i18n/translations/ja.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 23:00:56 +09:00
sss3978 9bf4220054 Update ja.ts 2026-04-22 21:59:16 +09:00
sss3978 0d0ab5080c Update supportedLanguages.ts 2026-04-22 21:56:52 +09:00
sss3978 1084d40685 Update TranslationContext.tsx 2026-04-22 21:55:47 +09:00
sss3978 75ef928264 Create ja.ts 2026-04-22 21:54:10 +09:00
35 changed files with 2518 additions and 360 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
strategy: init
+8 -14
View File
@@ -127,23 +127,19 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
- **Collab** — chat, notes, polls, day-by-day attendance
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
- **Dashboard widgets** — currency converter and timezone clocks
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -156,7 +152,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -176,7 +172,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
Open `http://localhost:3000`. The first user to register becomes admin.
<div align="center">
@@ -342,8 +338,7 @@ server {
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
client_max_body_size 50m;
location / {
proxy_pass http://localhost:3000;
@@ -360,7 +355,6 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.2
version: 2.9.14
description: Minimal Helm chart for TREK app
appVersion: "3.0.2"
appVersion: "2.9.14"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "3.0.2",
"version": "2.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "3.0.2",
"version": "2.9.14",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.2",
"version": "2.9.14",
"private": true,
"type": "module",
"scripts": {
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
@@ -7,8 +7,6 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types'
@@ -77,8 +75,6 @@ const defaultForm = {
arrival_time: '',
confirmation_number: '',
notes: '',
price: '',
budget_category: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
@@ -98,13 +94,6 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -137,8 +126,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -152,7 +139,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({})
setToPick({})
}
}, [isOpen, reservation, selectedDayId, budgetItems])
}, [isOpen, reservation, selectedDayId])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -186,10 +173,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
@@ -217,11 +200,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints,
needs_review: false,
}
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
await onSave(payload)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -444,40 +422,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form>
</Modal>
)
+3 -2
View File
@@ -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<string, string | { name: string; category: stri
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
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 {
+2 -1
View File
@@ -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']
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -317,7 +317,7 @@ export default function JourneyPublicPage() {
)}
{/* Content */}
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
<div className="px-5 pt-4 pb-5">
{/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
@@ -448,7 +448,7 @@ export default function JourneyPublicPage() {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
{journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "3.0.2",
"version": "2.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "3.0.2",
"version": "2.9.14",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.2",
"version": "2.9.14",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
+2 -4
View File
@@ -372,10 +372,8 @@ export function createApp(): express.Application {
} else {
console.error('Unhandled error:', err);
}
const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
const status = err.statusCode || 500;
res.status(status).json({ error: 'Internal server error' });
});
return app;
-64
View File
@@ -2043,70 +2043,6 @@ 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)
`);
},
];
if (currentVersion < migrations.length) {
-18
View File
@@ -9,7 +9,6 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
const router = express.Router();
@@ -26,26 +25,9 @@ const storage = multer.diskStorage({
},
});
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: imageFilter,
});
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
+12 -10
View File
@@ -781,20 +781,22 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null;
// caption lives on the gallery row; sort_order lives on the junction table
// (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
// would not be reflected in the returned row).
if (data.caption !== undefined) {
db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
}
if (data.sort_order !== undefined) {
db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
const fields: string[] = [];
const values: unknown[] = [];
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
if (!fields.length) {
// no-op: return some photo row for this gallery item (first entry link)
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
}
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
}
// deletePhoto: hard-delete (backwards compat name used by old route).
export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
export function deletePhoto(photoId: number, userId: number): { photo_id: number; file_path?: string | null; journey_id: number } | null {
const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null;
@@ -804,7 +806,7 @@ export function deletePhoto(photoId: number, userId: number): { id: number; phot
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(row.photo_id);
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
}
// ── Contributors ─────────────────────────────────────────────────────────
@@ -27,17 +27,14 @@ export async function ensureLocalThumbnail(
const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
}
} catch { /* regenerate */ }
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 }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
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 }
}
+1 -1
View File
@@ -143,7 +143,7 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
// 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.replace(/\/+$/, '') !== issuer) {
if (doc.issuer && doc.issuer !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
}
doc._issuer = url;
+7 -67
View File
@@ -43,24 +43,6 @@ 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(`
@@ -178,26 +160,13 @@ 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,
resolvedDayId,
resolvedEndDayId,
day_id || null,
end_day_id ?? null,
place_id || null,
assignment_id || null,
title,
@@ -207,7 +176,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
confirmation_number || null,
notes || null,
status || 'pending',
resolvedType,
type || 'other',
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0
@@ -321,35 +290,6 @@ 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),
@@ -370,13 +310,13 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ?
`).run(
title || null,
nextReservationTime,
nextReservationEndTime,
(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),
location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes,
nextDayId,
nextEndDayId,
day_id !== undefined ? (day_id || null) : current.day_id,
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null,
+1 -1
View File
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe('journey_photo_id required');
expect(res.body.error).toBe('photo_id required');
});
});
@@ -84,9 +84,8 @@ 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;
// 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);
// 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);
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
@@ -123,7 +122,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, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
const res = await request(app)
.get('/api/system-notices/active')
@@ -1325,10 +1325,9 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
// Junction row must be gone (ON DELETE CASCADE from journey_entries).
// Gallery row (journey_photos) is preserved — photo may belong to other entries.
const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
expect(junctionRow).toBeUndefined();
// Photo should be deleted with the entry
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
expect(deletedPhoto).toBeUndefined();
});
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1396,12 +1395,17 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id);
// Trip photos now go straight into the journey gallery (no wrapper entry).
// Should have a [Trip Photos] entry with the imported photo
const photoEntry = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.journey_id = ?
`).all(journey.id);
WHERE jp.entry_id = ?
`).all(photoEntry.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
});
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,24 +70,10 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).run(journeyId, trekId, now);
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
testDb.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
`).run(entryId, trekId, Date.now());
// Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId;
+1 -1
View File
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
Some addons require credentials or environment variables before they are functional:
- **Journey**works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
- **Journey**requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages
+7 -7
View File
@@ -48,7 +48,7 @@ Verified in `server/src/config.ts` (line 107):
## HTTPS / Proxy
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation.
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
| Variable | Description | Default |
|---|---|---|
@@ -62,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
## OIDC / SSO
For setup instructions, see [OIDC-SSO](OIDC-SSO).
For setup instructions, see [OIDC-SSO].
| Variable | Description | Default |
|---|---|---|
@@ -110,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
## MCP
For setup instructions, see [MCP-Overview](MCP-Overview).
For setup instructions, see [MCP-Overview].
| Variable | Description | Default |
|---|---|---|
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview](MCP-Overview).
## Related Pages
- [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
- [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
+7 -13
View File
@@ -30,23 +30,17 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- **Public Share Links** — share a read-only view of any trip
### Addons _(admin-toggleable)_
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
- **Documents** — file manager for trips, places, and reservations
- **Collab** — group chat, shared notes, polls, day-by-day attendance
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
- **Journey** magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods
- **Naver List Import** — import places from shared Naver Maps lists
- **MCP**expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1)
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
- **Journey** — travel journal linking entries to trips, with contributor roles
- **Memories** — photo-focused trip memories
- **Collab**group chat, shared notes, polls, and activity sign-ups
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
### AI / MCP Integration
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
- **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more
- **27 OAuth Scopes** — granular permissions across 13 permission groups
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **24 OAuth Scopes** — granular permissions across 13 permission groups
- **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders
### Admin
@@ -54,7 +48,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- Addon management, API key storage, scheduled auto-backups
- System notices for onboarding and announcements
> **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log.
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically.
## Get Started
+5 -5
View File
@@ -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](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].
## 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](Reverse-Proxy) for complete proxy configuration examples.
See [Reverse-Proxy] for complete proxy configuration examples.
## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
- [Updating](Updating) — how to pull a new image
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — HTTPS configuration
- [Updating] — how to pull a new image
+6 -6
View File
@@ -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](Environment-Variables) for the full list.
See [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](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], 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](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
- [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
+2 -2
View File
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
+2 -2
View File
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
## Next Steps
- [Environment-Variables](Environment-Variables) — complete variable reference
- [Updating](Updating) — how to pull a new image on Unraid
- [Environment-Variables] — complete variable reference
- [Updating] — how to pull a new image on Unraid
+1 -1
View File
@@ -2,7 +2,7 @@
TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser.
> **Admin:** Enable at least one photo provider (Immich or Synology Photos) in **Admin → Addons** photo provider toggles appear as sub-items under the **Journey** addon. Once a provider is on, a Photo Providers section appears in each user's **Settings → Integrations**. If your provider runs on a local or private network, the server must be configured to allow internal network access. See [Admin-Addons](Admin-Addons) and [Internal-Network-Access](Internal-Network-Access).
> **Admin:** Two things must be enabled for photo providers to appear in Settings: the **Memories addon** and the **individual photo provider** (Immich or Synology Photos). Both are toggled separately in **Admin → Addons**. See [Admin-Addons](Admin-Addons). If your provider is on a local or private network, the server must be configured to allow internal network access. See [Internal-Network-Access](Internal-Network-Access).
---
+4 -4
View File
@@ -60,7 +60,7 @@ You will be prompted to change the password on first login.
## Next Steps
- [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
- [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
+3 -3
View File
@@ -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://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`.
See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables.
See [Environment-Variables] for full documentation of these and all other variables.
## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference including OIDC
- [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars
- [Environment-Variables] — full variable reference including OIDC
- [Install-Docker-Compose] — production compose file with proxy-ready env vars
+1 -20
View File
@@ -1,9 +1,7 @@
# Tags and Categories
TREK has two independent labelling systems for places:
TREK has a labeling system: **Global Place Categories** (admin-managed, shared across all users).
- **Global Place Categories** — admin-managed, shared across every user on the instance (e.g. `Restaurant`, `Museum`).
- **Personal Tags** — user-scoped, private labels (e.g. `hidden gem`, `kid-friendly`).
<!-- TODO: screenshot: tag list on place detail -->
@@ -26,23 +24,6 @@ Categories appear in:
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them.
## Personal Tags
Tags are private labels owned by each user. They attach to individual places via a many-to-many relationship (`place_tags` table), so the same tag can be applied to as many places as you like, and a single place can carry multiple tags.
**Fields per tag:**
- **Name** — free-form text.
- **Color** — hex value displayed alongside the tag name. Default: `#10b981` (emerald).
Tags are scoped to their creator — other trip members do not see your tags, and different users can create tags with identical names without conflict. Deleting a tag automatically removes it from every place it was attached to.
### Where to manage them
At the moment tags are exposed primarily through the MCP API — AI assistants connected to your instance can list, create, update, and delete tags (`list_tags`, `create_tag`, `update_tag`, `delete_tag`) and attach them to places through the place endpoints. A dedicated web UI for tag management is not yet available; the filter `tag` parameter on the places API / MCP resource does support filtering places by a tag ID once one exists.
> **AI / MCP:** See [MCP-Tools-and-Resources](MCP-Tools-and-Resources) for the full tag tool list.
## When to use which
| Use case | Use |
+5 -5
View File
@@ -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](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] 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](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] 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](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
- [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