feat: places (reverse geocoding) + folder browser
Places: - worker/geocode.js: Nominatim reverse geocoder, 1 req/sec rate limit, in-memory cache keyed to 3-decimal-place coordinates - Ingest calls reverseGeocode() for any photo with GPS data; stores place_city and place_country on the photo row - DB migrations add place_city / place_country columns + index on place_city - Stats API returns cities grouped by count - buildFilter() supports ?city= parameter - FilterSidebar shows a Places section with city list + country sublabel Folder browser: - GET /api/fs/browse?path=… lists subdirectories (path-normalized to prevent traversal) - Settings panel: 📁 button opens inline directory browser below the add input — navigate with up arrow / directory clicks, Select fills the input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
64f7906b22
commit
3429ec1d80
8 files changed
+264
-25
No files matched your search
+2
-2
@@ -10,7 +10,7 @@ import SettingsPanel from './components/SettingsPanel.jsx';
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
const MapView = lazy(() => import('./components/MapView.jsx'));
|
const MapView = lazy(() => import('./components/MapView.jsx'));
|
||||||
|
|
||||||
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '' };
|
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
||||||
const SIDEBAR_W = 210;
|
const SIDEBAR_W = 210;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -101,7 +101,7 @@ export default function App() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred;
|
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred || filters.city;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const json = (body) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Library
|
// Library
|
||||||
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred } = {}) {
|
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred, city } = {}) {
|
||||||
const p = new URLSearchParams({ limit });
|
const p = new URLSearchParams({ limit });
|
||||||
if (cursor) p.set('cursor', cursor);
|
if (cursor) p.set('cursor', cursor);
|
||||||
if (q) p.set('q', q);
|
if (q) p.set('q', q);
|
||||||
@@ -19,9 +19,14 @@ export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectio
|
|||||||
if (tagId) p.set('tagId', tagId);
|
if (tagId) p.set('tagId', tagId);
|
||||||
if (collectionId) p.set('collectionId', collectionId);
|
if (collectionId) p.set('collectionId', collectionId);
|
||||||
if (starred) p.set('starred', starred);
|
if (starred) p.set('starred', starred);
|
||||||
|
if (city) p.set('city', city);
|
||||||
return api(`/api/photos?${p}`);
|
return api(`/api/photos?${p}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchDirBrowser(dirPath) {
|
||||||
|
return api(`/api/fs/browse?path=${encodeURIComponent(dirPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchPhotoDetail(id) {
|
export function fetchPhotoDetail(id) {
|
||||||
return api(`/api/photos/${id}`);
|
return api(`/api/photos/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,27 +9,27 @@ export default function FilterSidebar({ filters, onChange, stats, total }) {
|
|||||||
setExpanded(e => ({ ...e, [section]: !e[section] }));
|
setExpanded(e => ({ ...e, [section]: !e[section] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive active sidebar selection
|
const selectedYear = (filters.from && filters.to) ? filters.from.slice(0, 4) : null;
|
||||||
const selectedYear = (filters.from && filters.to)
|
const isStarred = filters.starred === '1';
|
||||||
? filters.from.slice(0, 4)
|
const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId && !filters.city;
|
||||||
: null;
|
|
||||||
const isStarred = filters.starred === '1';
|
|
||||||
const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId;
|
|
||||||
|
|
||||||
function selectAll() {
|
function selectAll() {
|
||||||
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '' });
|
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' });
|
||||||
}
|
}
|
||||||
function selectStarred() {
|
function selectStarred() {
|
||||||
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1' });
|
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1', city: '' });
|
||||||
}
|
}
|
||||||
function selectYear(year) {
|
function selectYear(year) {
|
||||||
onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '' });
|
onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '', city: '' });
|
||||||
}
|
}
|
||||||
function selectTag(id) {
|
function selectTag(id) {
|
||||||
onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '' });
|
onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '', city: '' });
|
||||||
}
|
}
|
||||||
function selectCollection(id) {
|
function selectCollection(id) {
|
||||||
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '' });
|
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '', city: '' });
|
||||||
|
}
|
||||||
|
function selectCity(city) {
|
||||||
|
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '', city });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +71,26 @@ export default function FilterSidebar({ filters, onChange, stats, total }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Places */}
|
||||||
|
{stats?.cities?.length > 0 && (
|
||||||
|
<Section title="Places">
|
||||||
|
{(expanded.cities ? stats.cities : stats.cities.slice(0, MAX_DEFAULT)).map(({ city, country, count }) => (
|
||||||
|
<Row
|
||||||
|
key={city}
|
||||||
|
active={filters.city === city}
|
||||||
|
onClick={() => selectCity(city)}
|
||||||
|
label={city}
|
||||||
|
sublabel={country}
|
||||||
|
count={count}
|
||||||
|
indent
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{stats.cities.length > MAX_DEFAULT && (
|
||||||
|
<SeeMore expanded={expanded.cities} onClick={() => toggle('cities')} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Date Taken */}
|
{/* Date Taken */}
|
||||||
{stats?.years?.length > 0 && (
|
{stats?.years?.length > 0 && (
|
||||||
<Section title="Date Taken">
|
<Section title="Date Taken">
|
||||||
@@ -146,7 +166,7 @@ function Section({ title, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ active, onClick, icon, iconColor, label, count, indent }) {
|
function Row({ active, onClick, icon, iconColor, label, sublabel, count, indent }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -167,6 +187,11 @@ function Row({ active, onClick, icon, iconColor, label, count, indent }) {
|
|||||||
)}
|
)}
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{label}
|
{label}
|
||||||
|
{sublabel && (
|
||||||
|
<span style={{ marginLeft: 5, fontSize: 10, color: active ? '#4a6a8a' : '#2a2a2a' }}>
|
||||||
|
{sublabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{count != null && (
|
{count != null && (
|
||||||
<span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}>
|
<span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
fetchSettings, patchSettings,
|
fetchSettings, patchSettings,
|
||||||
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
|
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
|
||||||
clearDismissals,
|
clearDismissals, fetchDirBrowser,
|
||||||
} from '../api/photos.js';
|
} from '../api/photos.js';
|
||||||
|
|
||||||
export default function SettingsPanel({ onClose }) {
|
export default function SettingsPanel({ onClose }) {
|
||||||
@@ -12,6 +12,11 @@ export default function SettingsPanel({ onClose }) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [cleared, setCleared] = useState(false);
|
const [cleared, setCleared] = useState(false);
|
||||||
|
|
||||||
|
// Folder browser
|
||||||
|
const [browseOpen, setBrowseOpen] = useState(false);
|
||||||
|
const [browseData, setBrowseData] = useState(null); // { path, parent, dirs }
|
||||||
|
const [browseLoading, setBrowseLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings().then(setSettings);
|
fetchSettings().then(setSettings);
|
||||||
fetchWatchFolders().then(setFolders);
|
fetchWatchFolders().then(setFolders);
|
||||||
@@ -38,6 +43,32 @@ export default function SettingsPanel({ onClose }) {
|
|||||||
setNewPath('');
|
setNewPath('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openBrowser() {
|
||||||
|
setBrowseOpen(true);
|
||||||
|
setBrowseLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchDirBrowser('/photos');
|
||||||
|
setBrowseData(data);
|
||||||
|
} catch {
|
||||||
|
setBrowseData({ path: '/', parent: null, dirs: [] });
|
||||||
|
}
|
||||||
|
setBrowseLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseInto(dirPath) {
|
||||||
|
setBrowseLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchDirBrowser(dirPath);
|
||||||
|
setBrowseData(data);
|
||||||
|
} catch {}
|
||||||
|
setBrowseLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBrowsed() {
|
||||||
|
if (browseData?.path) setNewPath(browseData.path);
|
||||||
|
setBrowseOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRemoveFolder(id) {
|
async function handleRemoveFolder(id) {
|
||||||
await removeWatchFolder(id);
|
await removeWatchFolder(id);
|
||||||
setFolders(f => f.filter(x => x.id !== id));
|
setFolders(f => f.filter(x => x.id !== id));
|
||||||
@@ -144,6 +175,18 @@ export default function SettingsPanel({ onClose }) {
|
|||||||
padding: '5px 8px', outline: 'none', fontFamily: 'monospace',
|
padding: '5px 8px', outline: 'none', fontFamily: 'monospace',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
onClick={openBrowser}
|
||||||
|
title="Browse filesystem"
|
||||||
|
style={{
|
||||||
|
background: browseOpen ? '#1e2f45' : '#181818',
|
||||||
|
border: '1px solid ' + (browseOpen ? '#2a4a6a' : '#222'),
|
||||||
|
borderRadius: 5, color: browseOpen ? '#7ab8f5' : '#555',
|
||||||
|
cursor: 'pointer', padding: '5px 9px', fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📁
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddFolder}
|
onClick={handleAddFolder}
|
||||||
disabled={!newPath.trim()}
|
disabled={!newPath.trim()}
|
||||||
@@ -158,6 +201,88 @@ export default function SettingsPanel({ onClose }) {
|
|||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline folder browser */}
|
||||||
|
{browseOpen && (
|
||||||
|
<div style={{
|
||||||
|
margin: '4px 18px 8px',
|
||||||
|
background: '#0d0d0d', border: '1px solid #222',
|
||||||
|
borderRadius: 6, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Breadcrumb + up */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 10px', borderBottom: '1px solid #1a1a1a',
|
||||||
|
background: '#131313',
|
||||||
|
}}>
|
||||||
|
{browseData?.parent !== null && browseData?.parent !== undefined && (
|
||||||
|
<button
|
||||||
|
onClick={() => browseInto(browseData.parent)}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', color: '#4d9eff',
|
||||||
|
cursor: 'pointer', fontSize: 14, lineHeight: 1, padding: 0, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
title="Up"
|
||||||
|
>↑</button>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
flex: 1, fontSize: 11, color: '#666', fontFamily: 'monospace',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
direction: 'rtl', textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
{browseLoading ? '…' : (browseData?.path ?? '')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={selectBrowsed}
|
||||||
|
title="Use this path"
|
||||||
|
style={{
|
||||||
|
background: '#1e2f45', border: '1px solid #2a4a6a',
|
||||||
|
borderRadius: 4, color: '#7ab8f5',
|
||||||
|
cursor: 'pointer', fontSize: 11, padding: '2px 8px', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrowseOpen(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', color: '#444',
|
||||||
|
cursor: 'pointer', fontSize: 14, lineHeight: 1, padding: 0, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Directory list */}
|
||||||
|
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
|
||||||
|
{!browseLoading && browseData?.dirs?.length === 0 && (
|
||||||
|
<p style={{ padding: '10px 12px', color: '#333', fontSize: 12, margin: 0 }}>
|
||||||
|
No subdirectories
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{browseData?.dirs?.map(name => {
|
||||||
|
const full = (browseData.path === '/' ? '' : browseData.path) + '/' + name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => browseInto(full)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 7,
|
||||||
|
width: '100%', padding: '5px 10px',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: '#bbb', fontSize: 12, textAlign: 'left',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = '#1a1a1a'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#4d9eff', flexShrink: 0 }}>📁</span>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── Ingestion ── */}
|
{/* ── Ingestion ── */}
|
||||||
|
|||||||
+35
-4
@@ -1,6 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { openDb } from '../worker/db.js';
|
import { openDb } from '../worker/db.js';
|
||||||
import { scan, getGroups } from './dedupe.js';
|
import { scan, getGroups } from './dedupe.js';
|
||||||
@@ -37,6 +38,8 @@ function mapPhoto(p) {
|
|||||||
height: p.height,
|
height: p.height,
|
||||||
filesize: p.filesize,
|
filesize: p.filesize,
|
||||||
starred: p.starred === 1,
|
starred: p.starred === 1,
|
||||||
|
place_city: p.place_city ?? null,
|
||||||
|
place_country: p.place_country ?? null,
|
||||||
thumb_url: posterUrl(p.thumb_path),
|
thumb_url: posterUrl(p.thumb_path),
|
||||||
preview_url: posterUrl(p.preview_path),
|
preview_url: posterUrl(p.preview_path),
|
||||||
};
|
};
|
||||||
@@ -45,7 +48,7 @@ function mapPhoto(p) {
|
|||||||
function badRequest(res, msg) { res.status(400).json({ error: msg }); }
|
function badRequest(res, msg) { res.status(400).json({ error: msg }); }
|
||||||
function notFound(res) { res.status(404).json({ error: 'not found' }); }
|
function notFound(res) { res.status(404).json({ error: 'not found' }); }
|
||||||
|
|
||||||
function buildFilter(q, from, to, tagId, collectionId, starred) {
|
function buildFilter(q, from, to, tagId, collectionId, starred, city) {
|
||||||
const joinParts = [];
|
const joinParts = [];
|
||||||
const joinParams = [];
|
const joinParams = [];
|
||||||
const condParts = [];
|
const condParts = [];
|
||||||
@@ -66,6 +69,7 @@ function buildFilter(q, from, to, tagId, collectionId, starred) {
|
|||||||
if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); }
|
if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); }
|
||||||
if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
|
if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
|
||||||
if (starred === '1') condParts.push('p.starred = 1');
|
if (starred === '1') condParts.push('p.starred = 1');
|
||||||
|
if (city) { condParts.push('p.place_city = ?'); condParams.push(city); }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
joinSql: joinParts.join(' '),
|
joinSql: joinParts.join(' '),
|
||||||
@@ -106,10 +110,14 @@ app.get('/api/library/stats', (_req, res) => {
|
|||||||
const years = db.raw.prepare(
|
const years = db.raw.prepare(
|
||||||
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE date_taken IS NOT NULL GROUP BY year ORDER BY year DESC"
|
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE date_taken IS NOT NULL GROUP BY year ORDER BY year DESC"
|
||||||
).all();
|
).all();
|
||||||
|
const cities = db.raw.prepare(
|
||||||
|
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE place_city IS NOT NULL GROUP BY place_city ORDER BY count DESC LIMIT 100"
|
||||||
|
).all();
|
||||||
res.json({
|
res.json({
|
||||||
total: db.count(),
|
total: db.count(),
|
||||||
starred,
|
starred,
|
||||||
years,
|
years,
|
||||||
|
cities,
|
||||||
tags: db.listAllTags(),
|
tags: db.listAllTags(),
|
||||||
collections: db.listCollections(),
|
collections: db.listCollections(),
|
||||||
});
|
});
|
||||||
@@ -121,15 +129,16 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() })
|
|||||||
|
|
||||||
const SELECT_COLS = `
|
const SELECT_COLS = `
|
||||||
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
|
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
|
||||||
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize, p.starred
|
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize,
|
||||||
|
p.starred, p.place_city, p.place_country
|
||||||
`;
|
`;
|
||||||
|
|
||||||
app.get('/api/photos', (req, res) => {
|
app.get('/api/photos', (req, res) => {
|
||||||
const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
|
const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
|
||||||
const cursor = req.query.cursor ?? null;
|
const cursor = req.query.cursor ?? null;
|
||||||
const { q, from, to, tagId, collectionId, starred } = req.query;
|
const { q, from, to, tagId, collectionId, starred, city } = req.query;
|
||||||
|
|
||||||
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred);
|
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred, city);
|
||||||
|
|
||||||
let cursorSql = '';
|
let cursorSql = '';
|
||||||
const cursorParams = [];
|
const cursorParams = [];
|
||||||
@@ -318,6 +327,28 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
|
|||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Filesystem browser ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/fs/browse', (req, res) => {
|
||||||
|
const raw = (req.query.path ?? '/photos').replace(/\0/g, '');
|
||||||
|
const dir = path.resolve('/', raw); // normalize — prevents traversal via ../../
|
||||||
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||||
|
return res.status(404).json({ error: 'not a directory' });
|
||||||
|
}
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: 'permission denied' });
|
||||||
|
}
|
||||||
|
const dirs = entries
|
||||||
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
||||||
|
.map(e => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
const parent = dir === '/' ? null : path.dirname(dir);
|
||||||
|
res.json({ path: dir, parent, dirs });
|
||||||
|
});
|
||||||
|
|
||||||
// ── SPA fallback ──────────────────────────────────────────────────────────────
|
// ── SPA fallback ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const distPath = path.resolve(__dirname, '../dist');
|
const distPath = path.resolve(__dirname, '../dist');
|
||||||
|
|||||||
+11
-4
@@ -32,7 +32,9 @@ export function openDb(dbPath) {
|
|||||||
width INTEGER,
|
width INTEGER,
|
||||||
height INTEGER,
|
height INTEGER,
|
||||||
filesize INTEGER,
|
filesize INTEGER,
|
||||||
starred INTEGER NOT NULL DEFAULT 0
|
starred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
place_city TEXT,
|
||||||
|
place_country TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken);
|
CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken);
|
||||||
@@ -96,7 +98,10 @@ export function openDb(dbPath) {
|
|||||||
|
|
||||||
// Column migrations — run before any indexes that depend on the new column
|
// Column migrations — run before any indexes that depend on the new column
|
||||||
try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {}
|
try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
|
try { db.exec('ALTER TABLE photos ADD COLUMN place_city TEXT'); } catch {}
|
||||||
|
try { db.exec('ALTER TABLE photos ADD COLUMN place_country TEXT'); } catch {}
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
|
||||||
|
|
||||||
// Seed default settings
|
// Seed default settings
|
||||||
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||||
@@ -106,10 +111,12 @@ export function openDb(dbPath) {
|
|||||||
insert: db.prepare(`
|
insert: db.prepare(`
|
||||||
INSERT OR IGNORE INTO photos
|
INSERT OR IGNORE INTO photos
|
||||||
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
(filepath, thumb_path, preview_path, date_taken, date_imported,
|
||||||
camera_model, gps_lat, gps_lon, phash, width, height, filesize)
|
camera_model, gps_lat, gps_lon, phash, width, height, filesize,
|
||||||
|
place_city, place_country)
|
||||||
VALUES
|
VALUES
|
||||||
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
|
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
|
||||||
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize)
|
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize,
|
||||||
|
@place_city, @place_country)
|
||||||
`),
|
`),
|
||||||
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
|
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
|
||||||
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const cache = new Map();
|
||||||
|
let lastCallMs = 0;
|
||||||
|
|
||||||
|
export async function reverseGeocode(lat, lon) {
|
||||||
|
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`;
|
||||||
|
if (cache.has(key)) return cache.get(key);
|
||||||
|
|
||||||
|
// Nominatim ToS: max 1 request/second
|
||||||
|
const wait = 1100 - (Date.now() - lastCallMs);
|
||||||
|
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
||||||
|
lastCallMs = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'Luma/1.0 self-hosted-photo-manager' },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
const place = {
|
||||||
|
city: data.address?.city
|
||||||
|
?? data.address?.town
|
||||||
|
?? data.address?.village
|
||||||
|
?? data.address?.county
|
||||||
|
?? null,
|
||||||
|
country: data.address?.country ?? null,
|
||||||
|
};
|
||||||
|
cache.set(key, place);
|
||||||
|
return place;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[geocode] ${lat.toFixed(4)},${lon.toFixed(4)}: ${err.message}`);
|
||||||
|
cache.set(key, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -4,6 +4,7 @@ import crypto from 'crypto';
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import { computeDHash } from './hash.js';
|
import { computeDHash } from './hash.js';
|
||||||
|
import { reverseGeocode } from './geocode.js';
|
||||||
|
|
||||||
const SUPPORTED = new Set([
|
const SUPPORTED = new Set([
|
||||||
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
||||||
@@ -37,6 +38,13 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
|
|||||||
const gpsLat = exif.latitude ?? null;
|
const gpsLat = exif.latitude ?? null;
|
||||||
const gpsLon = exif.longitude ?? null;
|
const gpsLon = exif.longitude ?? null;
|
||||||
|
|
||||||
|
let placeCity = null, placeCountry = null;
|
||||||
|
if (gpsLat !== null && gpsLon !== null) {
|
||||||
|
const place = await reverseGeocode(gpsLat, gpsLon);
|
||||||
|
placeCity = place?.city ?? null;
|
||||||
|
placeCountry = place?.country ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
|
const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
@@ -78,7 +86,9 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
|
|||||||
phash,
|
phash,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
filesize: stat.size,
|
filesize: stat.size,
|
||||||
|
place_city: placeCity,
|
||||||
|
place_country: placeCountry,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[ingest] ${path.basename(filepath)}`);
|
console.log(`[ingest] ${path.basename(filepath)}`);
|
||||||
|
|||||||
Reference in new issue
Block a user