diff --git a/app/components/photo-gallery.gjs b/app/components/photo-gallery.gjs index dc9ecbe..43a6fe9 100644 --- a/app/components/photo-gallery.gjs +++ b/app/components/photo-gallery.gjs @@ -21,7 +21,10 @@ const GalleryContent = {{@bindKeyboard @handleKeydown}} > {{! template-lint-disable no-invalid-interactive }} - + { try { if (photoData.hash) { @@ -139,6 +159,10 @@ export default class PlacePhotoUpload extends Component { const tags = [['i', `osm:${osmType}:${osmId}`]]; + for (const tag of this.selectedTags) { + tags.push(['t', tag]); + } + if (lat && lon) { tags.push(['g', Geohash.encode(lat, lon, 4)]); tags.push(['g', Geohash.encode(lat, lon, 6)]); @@ -227,6 +251,26 @@ export default class PlacePhotoUpload extends Component { /> + {{#if this.suggestedTags.length}} + + + Choose a tag/category (optional): + + + {{#each this.suggestedTags as |tag|}} + + {{capitalize tag}} + + {{/each}} + + + {{/if}} + c.toUpperCase()) ); } + +export function capitalize(text) { + if (typeof text !== 'string' || !text) return ''; + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/app/utils/nostr.js b/app/utils/nostr.js index aad664e..bf09957 100644 --- a/app/utils/nostr.js +++ b/app/utils/nostr.js @@ -30,6 +30,11 @@ export function parsePlacePhotos(events) { const allPhotos = []; for (const event of sortedEvents) { + const eventTagValues = event.tags + .filter((t) => t[0] === 't') + .map((t) => t[1]) + .filter(Boolean); + // Find all imeta tags const imetas = event.tags.filter((t) => t[0] === 'imeta'); for (const imeta of imetas) { @@ -70,6 +75,7 @@ export function parsePlacePhotos(events) { isLandscape, aspectRatio, placeIdentifier, + tags: eventTagValues, }); } } diff --git a/app/utils/photo-tag-suggestions.js b/app/utils/photo-tag-suggestions.js new file mode 100644 index 0000000..eb6407b --- /dev/null +++ b/app/utils/photo-tag-suggestions.js @@ -0,0 +1,32 @@ +import { POI_CATEGORIES } from './poi-categories'; +import { getMatchingPoiCategoryIds } from './poi-category-matcher'; + +export const CATEGORY_TAGS = { + restaurants: ['food', 'menu', 'vibe', 'front'], + coffee: ['food', 'menu', 'vibe', 'front'], + groceries: ['front', 'food'], + 'things-to-do': ['architecture', 'amenities', 'vibe', 'front'], + accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'], +}; + +export function getSuggestedPhotoTags(place) { + const osmTags = place?.osmTags || place?.tags || {}; + const categoryIds = getMatchingPoiCategoryIds(osmTags, POI_CATEGORIES); + + const suggested = []; + for (const categoryId of categoryIds) { + const tags = CATEGORY_TAGS[categoryId]; + if (!Array.isArray(tags)) continue; + for (const tag of tags) { + if (!suggested.includes(tag)) { + suggested.push(tag); + } + } + } + + if (suggested.length === 0) { + return []; + } + + return suggested; +} diff --git a/app/utils/poi-category-matcher.js b/app/utils/poi-category-matcher.js new file mode 100644 index 0000000..b3632b9 --- /dev/null +++ b/app/utils/poi-category-matcher.js @@ -0,0 +1,95 @@ +export function getMatchingPoiCategories(osmTags, categories) { + if (!Array.isArray(categories) || !osmTags) return []; + + return categories.filter((category) => { + if (!Array.isArray(category.filter)) return false; + return category.filter.some((filterStr) => + matchesFilter(osmTags, filterStr) + ); + }); +} + +export function getMatchingPoiCategoryIds(osmTags, categories) { + return getMatchingPoiCategories(osmTags, categories).map((c) => c.id); +} + +function matchesFilter(osmTags, filterStr) { + const clauses = parseOverpassClauses(filterStr); + if (clauses.length === 0) return false; + return clauses.every((clause) => matchesClause(osmTags, clause)); +} + +function parseOverpassClauses(filterStr) { + if (!filterStr) return []; + const matches = filterStr.match(/\[[^\]]+\]/g); + if (!matches) return []; + + return matches + .map((raw) => parseClause(raw.slice(1, -1).trim())) + .filter(Boolean); +} + +function parseClause(content) { + const presenceMatch = content.match(/^"([^"]+)"$/); + if (presenceMatch) { + return { type: 'presence', key: presenceMatch[1] }; + } + + const equalsMatch = content.match(/^"([^"]+)"\s*=\s*"([^"]*)"$/); + if (equalsMatch) { + return { type: 'equals', key: equalsMatch[1], value: equalsMatch[2] }; + } + + const regexMatch = content.match(/^"([^"]+)"\s*~\s*"([^"]*)"$/); + if (regexMatch) { + return { + type: 'regex', + key: regexMatch[1], + pattern: regexMatch[2], + regex: new RegExp(regexMatch[2]), + }; + } + + const notRegexMatch = content.match(/^"([^"]+)"\s*!~\s*"([^"]*)"$/); + if (notRegexMatch) { + return { + type: 'not-regex', + key: notRegexMatch[1], + pattern: notRegexMatch[2], + regex: new RegExp(notRegexMatch[2]), + }; + } + + return null; +} + +function matchesClause(osmTags, clause) { + const tagValues = getTagValues(osmTags, clause.key); + + switch (clause.type) { + case 'presence': + return tagValues.length > 0; + case 'equals': + return tagValues.some((value) => value === clause.value); + case 'regex': + return tagValues.some((value) => clause.regex.test(value)); + case 'not-regex': + return ( + tagValues.length === 0 || + !tagValues.some((value) => clause.regex.test(value)) + ); + default: + return false; + } +} + +function getTagValues(osmTags, key) { + if (!osmTags || !key) return []; + const rawValue = osmTags[key]; + if (rawValue === undefined || rawValue === null) return []; + + return String(rawValue) + .split(';') + .map((value) => value.trim()) + .filter(Boolean); +} diff --git a/tests/index.html b/tests/index.html index 775b080..a1fbf86 100644 --- a/tests/index.html +++ b/tests/index.html @@ -16,7 +16,12 @@ - + diff --git a/tests/integration/components/photo-gallery-test.gjs b/tests/integration/components/photo-gallery-test.gjs index 1d540e9..95abd26 100644 --- a/tests/integration/components/photo-gallery-test.gjs +++ b/tests/integration/components/photo-gallery-test.gjs @@ -287,35 +287,30 @@ module('Integration | Component | photo-gallery', function (hooks) { ); - // Let carousel settle - await new Promise((resolve) => setTimeout(resolve, 150)); + assert + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event1'); // Right Arrow await triggerKeyEvent(document, 'keydown', 'ArrowRight'); - await new Promise((resolve) => setTimeout(resolve, 150)); - // Let's just assert that currentPhoto was updated internally, which trickles down. - // The actual DOM update for the main image might be tricky if the carousel relies on scroll events. - // We can at least check if the thumbnail selection changed, as that is directly driven by currentPhoto assert - .dom('.thumbnail-strip-container .carousel-slide.active img') - .hasAttribute('data-src', 'photo2.jpg'); + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event2'); // Right Arrow again await triggerKeyEvent(document, 'keydown', 'ArrowRight'); - await new Promise((resolve) => setTimeout(resolve, 150)); assert - .dom('.thumbnail-strip-container .carousel-slide.active img') - .hasAttribute('data-src', 'photo3.jpg'); + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event3'); // Left Arrow await triggerKeyEvent(document, 'keydown', 'ArrowLeft'); - await new Promise((resolve) => setTimeout(resolve, 150)); assert - .dom('.thumbnail-strip-container .carousel-slide.active img') - .hasAttribute('data-src', 'photo2.jpg'); + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event2'); }); test('escape key closes gallery', async function (assert) { diff --git a/tests/integration/components/place-photo-upload-test.gjs b/tests/integration/components/place-photo-upload-test.gjs new file mode 100644 index 0000000..bd3f5e6 --- /dev/null +++ b/tests/integration/components/place-photo-upload-test.gjs @@ -0,0 +1,100 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'marco/tests/helpers'; +import { render, click, triggerEvent } from '@ember/test-helpers'; +import Service from '@ember/service'; +import PlacePhotoUpload from 'marco/components/place-photo-upload'; + +module('Integration | Component | place-photo-upload', function (hooks) { + setupRenderingTest(hooks); + + class MockNostrAuthService extends Service { + get isConnected() { + return true; + } + + get signer() { + return null; + } + } + + hooks.beforeEach(function () { + this.owner.register('service:nostrAuth', MockNostrAuthService); + }); + + async function selectFile(element, file) { + const input = element.querySelector('#photo-upload-input'); + Object.defineProperty(input, 'files', { + value: [file], + configurable: true, + }); + await triggerEvent(input, 'change'); + } + + test('it shows tag suggestions when they exist after upload selection', async function (assert) { + this.place = { + title: 'Cafe Alpha', + osmId: '123', + osmType: 'node', + osmTags: { amenity: 'cafe' }, + }; + + await render( + + ); + + assert.dom('.photo-tag-suggestions').doesNotExist(); + + const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + await selectFile(this.element, file); + + assert.dom('.photo-tag-suggestions').exists(); + assert.dom('.photo-tag-chip').exists(); + assert.dom('.photo-tag-chip').includesText('Food'); + }); + + test('it only allows one selected tag at a time', async function (assert) { + this.place = { + title: 'Cafe Alpha', + osmId: '123', + osmType: 'node', + osmTags: { amenity: 'cafe' }, + }; + + await render( + + ); + + const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + await selectFile(this.element, file); + + const chips = this.element.querySelectorAll('.photo-tag-chip'); + assert.ok(chips.length > 1, 'multiple tag chips are rendered'); + + await click(chips[0]); + assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 }); + assert.dom(chips[0]).hasClass('is-selected'); + + await click(chips[1]); + assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 }); + assert.dom(chips[1]).hasClass('is-selected'); + assert.dom(chips[0]).doesNotHaveClass('is-selected'); + }); + + test('it hides tag suggestions when no tags are suggested', async function (assert) { + this.place = { + title: 'Office Beta', + osmId: '456', + osmType: 'node', + osmTags: { office: 'lawyer' }, + }; + + await render( + + ); + + const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + await selectFile(this.element, file); + + assert.dom('.photo-tag-suggestions').doesNotExist(); + }); +}); diff --git a/tests/integration/components/search-box-test.gjs b/tests/integration/components/search-box-test.gjs index d6936e5..0a9e4c3 100644 --- a/tests/integration/components/search-box-test.gjs +++ b/tests/integration/components/search-box-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'marco/tests/helpers'; -import { render, fillIn, click, waitFor } from '@ember/test-helpers'; +import { render, fillIn, click, waitFor, focus } from '@ember/test-helpers'; import SearchBox from 'marco/components/search-box'; import Service from '@ember/service'; @@ -208,18 +208,22 @@ module('Integration | Component | search-box', function (hooks) { ); // Type "Resta" to trigger "Restaurants" category match + await focus('.search-input'); await fillIn('.search-input', 'Resta'); - // Wait for debounce (300ms) + execution - const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - await delay(400); + await waitFor('.search-result-item'); - // The first result should be the category match - assert.dom('.search-result-item').exists({ count: 1 }); - assert.dom('.result-title').hasText('Restaurants'); + const resultItems = Array.from( + this.element.querySelectorAll('.search-result-item') + ); + const categoryResult = resultItems.find((item) => + item.textContent.includes('Restaurants') + ); + + assert.ok(categoryResult, 'Restaurants category result is shown'); // Click the result - await click('.search-result-item'); + await click(categoryResult); // Assert transition with lat/lon from map center assert.verifySteps([ diff --git a/tests/unit/utils/nostr-test.js b/tests/unit/utils/nostr-test.js new file mode 100644 index 0000000..19e51b7 --- /dev/null +++ b/tests/unit/utils/nostr-test.js @@ -0,0 +1,144 @@ +import { module, test } from 'qunit'; +import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr'; + +module('Unit | Utility | nostr', function () { + test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) { + assert.strictEqual(normalizeRelayUrl(null), ''); + assert.strictEqual(normalizeRelayUrl(''), ''); + assert.strictEqual(normalizeRelayUrl(' '), ''); + + assert.strictEqual( + normalizeRelayUrl('Relay.example.com'), + 'wss://relay.example.com' + ); + assert.strictEqual( + normalizeRelayUrl('ws://Relay.example.com/'), + 'ws://relay.example.com' + ); + assert.strictEqual( + normalizeRelayUrl('wss://relay.example.com///'), + 'wss://relay.example.com' + ); + }); + + test('parsePlacePhotos includes event t tags on photo objects', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 123, + tags: [ + ['i', 'osm:node:123'], + ['t', 'food'], + ['t', 'vibe'], + ['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 1); + assert.deepEqual(photos[0].tags, ['food', 'vibe']); + }); + + test('parsePlacePhotos sorts by created_at', function (assert) { + const events = [ + { + id: 'event-2', + pubkey: 'pubkey-2', + created_at: 200, + tags: [ + ['i', 'osm:node:456'], + ['imeta', 'url https://example.com/late.jpg', 'dim 600x900'], + ], + }, + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [ + ['i', 'osm:node:123'], + ['imeta', 'url https://example.com/early.jpg', 'dim 600x900'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 2); + assert.strictEqual(photos[0].url, 'https://example.com/early.jpg'); + assert.strictEqual(photos[1].url, 'https://example.com/late.jpg'); + }); + + test('parsePlacePhotos promotes first landscape photo to index 0', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [ + ['imeta', 'url https://example.com/portrait.jpg', 'dim 600x900'], + ], + }, + { + id: 'event-2', + pubkey: 'pubkey-2', + created_at: 200, + tags: [ + ['imeta', 'url https://example.com/landscape.jpg', 'dim 1200x600'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 2); + assert.strictEqual(photos[0].url, 'https://example.com/landscape.jpg'); + assert.strictEqual(photos[1].url, 'https://example.com/portrait.jpg'); + }); + + test('parsePlacePhotos skips imeta entries without urls', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [['imeta', 'dim 800x600']], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.deepEqual(photos, []); + }); + + test('parsePlacePhotos returns one photo per event imeta tag', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [ + ['i', 'osm:node:123'], + ['imeta', 'url https://example.com/photo-1.jpg', 'dim 800x600'], + ], + }, + { + id: 'event-2', + pubkey: 'pubkey-2', + created_at: 200, + tags: [ + ['i', 'osm:node:456'], + ['imeta', 'url https://example.com/photo-2.jpg', 'dim 600x800'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 2); + assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123'); + assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456'); + }); +}); diff --git a/tests/unit/utils/photo-tag-suggestions-test.js b/tests/unit/utils/photo-tag-suggestions-test.js new file mode 100644 index 0000000..f86b5f4 --- /dev/null +++ b/tests/unit/utils/photo-tag-suggestions-test.js @@ -0,0 +1,30 @@ +import { module, test } from 'qunit'; +import { POI_CATEGORIES } from 'marco/utils/poi-categories'; +import { getMatchingPoiCategoryIds } from 'marco/utils/poi-category-matcher'; +import { + getSuggestedPhotoTags, + CATEGORY_TAGS, +} from 'marco/utils/photo-tag-suggestions'; + +module('Unit | Utility | photo-tag-suggestions', function () { + test('returns tags for all matching categories with de-duplication', function (assert) { + const place = { osmTags: { amenity: 'cafe' } }; + const categoryIds = getMatchingPoiCategoryIds( + place.osmTags, + POI_CATEGORIES + ); + + assert.ok(categoryIds.includes('restaurants')); + assert.ok(categoryIds.includes('coffee')); + + const result = getSuggestedPhotoTags(place); + assert.deepEqual(result, CATEGORY_TAGS.restaurants); + }); + + test('returns no tags when no category matches', function (assert) { + const place = { osmTags: { office: 'lawyer' } }; + const result = getSuggestedPhotoTags(place); + + assert.deepEqual(result, []); + }); +}); diff --git a/tests/unit/utils/poi-category-matcher-test.js b/tests/unit/utils/poi-category-matcher-test.js new file mode 100644 index 0000000..4f74706 --- /dev/null +++ b/tests/unit/utils/poi-category-matcher-test.js @@ -0,0 +1,38 @@ +import { module, test } from 'qunit'; +import { POI_CATEGORIES } from 'marco/utils/poi-categories'; +import { + getMatchingPoiCategories, + getMatchingPoiCategoryIds, +} from 'marco/utils/poi-category-matcher'; + +module('Unit | Utility | poi-category-matcher', function () { + test('matches multiple categories from OSM tags', function (assert) { + const tags = { amenity: 'cafe' }; + const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES); + + assert.ok(categoryIds.includes('restaurants')); + assert.ok(categoryIds.includes('coffee')); + }); + + test('supports semicolon-separated values', function (assert) { + const tags = { amenity: 'cafe;bar' }; + const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES); + + assert.ok(categoryIds.includes('coffee')); + }); + + test('negative regex clause fails if any value matches', function (assert) { + const tags = { amenity: 'cafe', cuisine: 'coffee;irish' }; + const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES); + + assert.notOk(categoryIds.includes('restaurants')); + }); + + test('presence clause matches when tag exists', function (assert) { + const tags = { historic: 'castle' }; + const categories = getMatchingPoiCategories(tags, POI_CATEGORIES); + const categoryIds = categories.map((category) => category.id); + + assert.ok(categoryIds.includes('things-to-do')); + }); +});
+ Choose a tag/category (optional): +