Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d17f4e1a9
|
||
|
|
cf253098fe
|
||
|
|
b127a2b0d6
|
||
|
|
9200e6f5c8
|
||
|
|
bf09101a24
|
||
|
|
df720d1b21
|
||
|
|
e74c01b1d1
|
||
|
|
0f6272006c
|
||
|
|
85de25032e
|
||
|
|
b9103b28a9
|
||
|
|
b8a9c71657
|
||
|
|
a7b0b52a58
|
||
|
|
12728f449b
|
||
|
|
fe5e85b029
|
||
|
|
c6a0b0da5e
|
||
|
|
553faa5edd
|
||
|
|
dda0a9846d
|
||
|
|
4f423c8abc
|
||
|
|
231a08c2f9
|
||
|
|
7fccc56c92
|
||
|
|
878387b9a8
|
||
|
|
0f6bfb7e74
|
||
|
|
faea3d26cd
|
No files matched your search
@@ -8,8 +8,11 @@
|
||||
<br>
|
||||
|
||||
Marco (as in [Marco Polo][1]) is an unhosted maps application that respects
|
||||
your privacy and choices. It allows users to connect their own remote storage
|
||||
to sync place bookmarks across apps and devices.
|
||||
your privacy and choices.
|
||||
|
||||
It allows users to connect their own remote storage to sync place bookmarks
|
||||
across apps and devices. It also supports the decentralized sharing of place
|
||||
photos (and soon reviews) via [Nostr][2].
|
||||
|
||||
This README outlines the details of collaborating on this Ember application, or
|
||||
just building and deploying it for yourself.
|
||||
@@ -79,3 +82,4 @@ To run the script, you need `imagemagick` and `librsvg` installed:
|
||||
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
|
||||
|
||||
[1]: https://en.wikipedia.org/wiki/Marco_Polo
|
||||
[2]: https://nostr.org
|
||||
@@ -7,6 +7,7 @@ import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import { encodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
@@ -15,6 +16,8 @@ import ZapPhotoModal from './zap-photo-modal';
|
||||
import Icon from './icon';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
const MAX_NEVENT_RELAY_HINTS = 3;
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
@@ -236,8 +239,23 @@ export default class PhotoGallery extends Component {
|
||||
@action
|
||||
async copyEventId(closeMenu) {
|
||||
if (this.currentPhoto?.eventId) {
|
||||
let value = this.currentPhoto.eventId;
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||
const nevent = encodePointer({
|
||||
id: this.currentPhoto.eventId,
|
||||
relays: this.nostrData
|
||||
.getEventRelays(this.currentPhoto.eventId)
|
||||
.slice(0, MAX_NEVENT_RELAY_HINTS),
|
||||
author: this.currentPhoto.pubkey,
|
||||
kind: 360,
|
||||
});
|
||||
if (nevent) value = nevent;
|
||||
} catch (err) {
|
||||
console.warn('Failed to encode nevent, copying raw event ID:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.toast.show('Event ID copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy event ID:', err);
|
||||
@@ -290,7 +308,14 @@ export default class PhotoGallery extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
const responses = await this.nostrRelay.publish(
|
||||
this.nostrData.activeWriteRelays,
|
||||
event
|
||||
);
|
||||
this.nostrData.recordPublishResult(event.id, responses);
|
||||
if (!responses?.some((res) => res.ok)) {
|
||||
throw new Error('Failed to publish deletion event.');
|
||||
}
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
@@ -316,6 +316,70 @@ export default class PlaceDetails extends Component {
|
||||
return this.formatMultiLine(val);
|
||||
}
|
||||
|
||||
get operatorNames() {
|
||||
const raw = this.tags.operator;
|
||||
if (!raw) return null;
|
||||
const values = raw
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.filter(
|
||||
(value) =>
|
||||
value && value.toLowerCase() !== 'yes' && value.toLowerCase() !== 'no'
|
||||
);
|
||||
if (values.length === 0) return null;
|
||||
return this.formatMultiLine(values.join(';'));
|
||||
}
|
||||
|
||||
get operatorWebsite() {
|
||||
const val = this.tags['operator:website'];
|
||||
return val ? val.trim() : null;
|
||||
}
|
||||
|
||||
get operatorWebsiteLabel() {
|
||||
return this.operatorWebsite ? this.getDomain(this.operatorWebsite) : null;
|
||||
}
|
||||
|
||||
get operatorWikidata() {
|
||||
const val = this.tags['operator:wikidata'];
|
||||
if (!val) return null;
|
||||
return (
|
||||
val
|
||||
.split(';')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)[0] || null
|
||||
);
|
||||
}
|
||||
|
||||
get operatorWikipedia() {
|
||||
const val = this.tags['operator:wikipedia'];
|
||||
if (!val) return null;
|
||||
return (
|
||||
val
|
||||
.split(';')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)[0] || null
|
||||
);
|
||||
}
|
||||
|
||||
get operatorWikipediaUrl() {
|
||||
const val = this.operatorWikipedia;
|
||||
if (!val) return null;
|
||||
const match = val.match(/^([a-z-]+):(.+)$/i);
|
||||
if (match) {
|
||||
return `https://${match[1]}.wikipedia.org/wiki/${match[2]}`;
|
||||
}
|
||||
return `https://wikipedia.org/wiki/${val}`;
|
||||
}
|
||||
|
||||
get showOperator() {
|
||||
return !!(
|
||||
this.operatorNames ||
|
||||
this.operatorWebsite ||
|
||||
this.operatorWikidata ||
|
||||
this.operatorWikipedia
|
||||
);
|
||||
}
|
||||
|
||||
get cuisine() {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
@@ -571,6 +635,45 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showOperator}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="briefcase" @title="Operator" />
|
||||
<span>
|
||||
{{this.operatorNames}}
|
||||
{{#if this.operatorWebsite}}
|
||||
<br />
|
||||
<a
|
||||
href={{this.operatorWebsite}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{this.operatorWebsiteLabel}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if this.operatorWikidata}}
|
||||
<br />
|
||||
<a
|
||||
href="https://www.wikidata.org/wiki/{{this.operatorWikidata}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikidata
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if this.operatorWikipedia}}
|
||||
<br />
|
||||
<a
|
||||
href={{this.operatorWikipediaUrl}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikipedia
|
||||
</a>
|
||||
{{/if}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
<div class="meta-info">
|
||||
|
||||
|
||||
@@ -211,7 +211,14 @@ export default class PlacePhotoUpload extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
const responses = await this.nostrRelay.publish(
|
||||
this.nostrData.activeWriteRelays,
|
||||
event
|
||||
);
|
||||
this.nostrData.recordPublishResult(event.id, responses);
|
||||
if (!responses?.some((res) => res.ok)) {
|
||||
throw new Error('Failed to publish event.');
|
||||
}
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
this.toast.show('Photo published successfully');
|
||||
|
||||
@@ -23,18 +23,34 @@ export default class ListsListController extends Controller {
|
||||
|
||||
@tracked model;
|
||||
@tracked loadedPlaces = [];
|
||||
@tracked loadedListId = null;
|
||||
|
||||
get listId() {
|
||||
return this.model?.list_id;
|
||||
}
|
||||
|
||||
get isLoading() {
|
||||
// Only show the spinner when there is nothing to display yet. On re-entry
|
||||
// the previously loaded places (plus live storage state) render instantly,
|
||||
// so we avoid a loading flash and preserve restored scroll position.
|
||||
return this.loadPlacesTask.isRunning && this.places.length === 0;
|
||||
}
|
||||
|
||||
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||
// Already loaded this list: keep the existing places so returning from
|
||||
// place details is instant (mirrors the activity service's load guard).
|
||||
if (this.loadedListId === listId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedListId = listId;
|
||||
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||
try {
|
||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
this.loadedPlaces = [];
|
||||
this.loadedListId = null; // Allow a retry on next entry
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -339,6 +339,33 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
// Public getter for the normalized relays an event is known to have been
|
||||
// seen on. Used to build shareable `nevent` pointers with relay hints.
|
||||
getEventRelays(eventId) {
|
||||
const relays = this._eventRelays.get(eventId);
|
||||
return relays ? uniqNormalizedRelays([...relays]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the result of publishing an event to relays.
|
||||
*
|
||||
* Successful relays are merged into the provenance map so the event gets
|
||||
* correct relay hints (e.g. in `nevent` pointers) and trust checks. The full
|
||||
* per-relay responses are only debug-logged for now; this is the intended
|
||||
* hook for a future publishing-status store/UI.
|
||||
*
|
||||
* @param {string} eventId The published event id
|
||||
* @param {Array<{ok: boolean, message?: string, from: string}>} responses
|
||||
*/
|
||||
recordPublishResult(eventId, responses = []) {
|
||||
console.debug('[nostr-data] Publish result', eventId, responses);
|
||||
for (const res of responses || []) {
|
||||
if (res?.ok && res.from) {
|
||||
this._recordProvenance(eventId, res.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request content events from relays while capturing full provenance.
|
||||
*
|
||||
|
||||
@@ -8,18 +8,11 @@ export default class NostrRelayService extends Service {
|
||||
if (!relays || relays.length === 0) {
|
||||
throw new Error('No relays provided to publish the event.');
|
||||
}
|
||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||
// and automatically handles reconnecting and retrying.
|
||||
const responses = await this.pool.publish(relays, event);
|
||||
|
||||
// Check if at least one relay accepted the event
|
||||
const success = responses.some((res) => res.ok);
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||
);
|
||||
}
|
||||
|
||||
return responses;
|
||||
// The publish method is a wrapper around the event method that returns a
|
||||
// Promise<PublishResponse[]> and automatically handles reconnecting and
|
||||
// retrying. It resolves with the per-relay responses even when no relay
|
||||
// accepted the event; callers are responsible for checking `ok` so that
|
||||
// failed attempts can still be recorded.
|
||||
return await this.pool.publish(relays, event);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,10 @@ export default class SettingsService extends Service {
|
||||
name: 'private.coffee (AT)',
|
||||
url: 'https://overpass.private.coffee/api/interpreter',
|
||||
},
|
||||
{
|
||||
name: 'overpass.btcmap.org (SG)',
|
||||
url: 'https://overpass.btcmap.org/api/interpreter',
|
||||
},
|
||||
// {
|
||||
// name: 'overpass.openstreetmap.us (US)',
|
||||
// url: 'https://overpass.openstreetmap.us/api/interpreter'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import restoreScroll from '../../modifiers/restore-scroll';
|
||||
|
||||
export default class ListsIndexTemplate extends Component {
|
||||
@service storage;
|
||||
@@ -26,8 +27,16 @@ export default class ListsIndexTemplate extends Component {
|
||||
return htmlSafe(`background-color: ${finalColor}`);
|
||||
}
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition('lists-index');
|
||||
}
|
||||
|
||||
@action
|
||||
selectList(listId) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('lists-index', sidebarContent.scrollTop);
|
||||
}
|
||||
this.router.transitionTo('lists.list', listId);
|
||||
}
|
||||
|
||||
@@ -59,7 +68,7 @@ export default class ListsIndexTemplate extends Component {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content" {{restoreScroll this.scrollTop}}>
|
||||
<ul class="places-list">
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,7 @@ import PlacesSidebar from '#components/places-sidebar';
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@isLoading={{@controller.isLoading}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import briefcase from 'feather-icons/dist/icons/briefcase.svg?raw';
|
||||
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||
@@ -53,6 +54,8 @@ import anchor from '@waysidemapping/pinhead/dist/icons/anchor.svg?raw';
|
||||
import airportTerminal from '@waysidemapping/pinhead/dist/icons/airport_terminal.svg?raw';
|
||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
|
||||
import bicycle from '@waysidemapping/pinhead/dist/icons/bicycle.svg?raw';
|
||||
import bulldozer from '@waysidemapping/pinhead/dist/icons/bulldozer.svg?raw';
|
||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
|
||||
import banknoteInSlot from '@waysidemapping/pinhead/dist/icons/banknote_in_slot.svg?raw';
|
||||
@@ -95,7 +98,9 @@ import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
|
||||
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
|
||||
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
|
||||
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
|
||||
import fortress from '@waysidemapping/pinhead/dist/icons/fortress.svg?raw';
|
||||
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||
import gableRoofedBuilding from '@waysidemapping/pinhead/dist/icons/gable_roofed_building.svg?raw';
|
||||
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
@@ -139,6 +144,8 @@ import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?r
|
||||
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
|
||||
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
|
||||
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
||||
import townBuildings from '@waysidemapping/pinhead/dist/icons/town_buildings.svg?raw';
|
||||
import transitVehicleOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/transit_vehicle_on_railway_track.svg?raw';
|
||||
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||
@@ -164,6 +171,8 @@ const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
barbell,
|
||||
'barrel-with-bunghole': barrelWithBunghole,
|
||||
bicycle,
|
||||
bulldozer,
|
||||
banknote,
|
||||
'banknote-in-slot': banknoteInSlot,
|
||||
'badge-shield-with-fire': badgeShieldWithFire,
|
||||
@@ -174,6 +183,7 @@ const ICONS = {
|
||||
'boxing-glove-up': boxingGloveUp,
|
||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||
bridge,
|
||||
briefcase,
|
||||
bus,
|
||||
camera,
|
||||
'feather-camera': featherCamera,
|
||||
@@ -216,6 +226,8 @@ const ICONS = {
|
||||
'flower-bouquet': flowerBouquet,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
fort,
|
||||
fortress,
|
||||
'gable-roofed-building': gableRoofedBuilding,
|
||||
'gasoline-pump': gasolinePump,
|
||||
gift,
|
||||
globe,
|
||||
@@ -284,6 +296,8 @@ const ICONS = {
|
||||
target,
|
||||
'trash-2': trash2,
|
||||
'upload-cloud': uploadCloud,
|
||||
'town-buildings': townBuildings,
|
||||
'transit-vehicle-on-railway-track': transitVehicleOnRailwayTrack,
|
||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||
user,
|
||||
'user-check': userCheck,
|
||||
|
||||
+27
-13
@@ -99,18 +99,9 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||
{ tags: { leisure: 'marina' }, icon: 'anchor' },
|
||||
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
|
||||
|
||||
// Transport
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
|
||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||
{ tags: { bus: true }, icon: 'bus' },
|
||||
{
|
||||
tags: { railway: 'tram_stop' },
|
||||
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||
},
|
||||
{ tags: { landuse: 'cemetery' }, icon: 'memorial-stone-with-inscription' },
|
||||
{ tags: { landuse: 'construction' }, icon: 'bulldozer' },
|
||||
{ tags: { landuse: 'residential' }, icon: 'town-buildings' },
|
||||
|
||||
// Tourism
|
||||
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||
@@ -131,9 +122,11 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'aqueduct' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||
{ tags: { historic: 'monastery' }, icon: 'fortress' },
|
||||
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||
@@ -153,7 +146,23 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||
{ tags: { historic: 'ruin' }, icon: 'camera' },
|
||||
{ tags: { historic: 'yes' }, icon: 'camera' },
|
||||
|
||||
// Transport
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
|
||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||
{ tags: { highway: 'cycleway' }, icon: 'bicycle' },
|
||||
{ tags: { bus: true }, icon: 'bus' },
|
||||
{
|
||||
tags: { railway: 'tram_stop' },
|
||||
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||
},
|
||||
{ tags: { light_rail: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
{ tags: { train: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
{ tags: { tram: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
{ tags: { subway: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
|
||||
// Accommodation
|
||||
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||
@@ -190,6 +199,7 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
||||
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
||||
{ tags: { landuse: 'recreation_ground' }, icon: 'person-running' },
|
||||
{ tags: { sport: true }, icon: 'person-running' },
|
||||
|
||||
// Healthcare
|
||||
@@ -204,6 +214,10 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||
{ tags: { building: 'office' }, icon: 'lowrise-building' },
|
||||
|
||||
// Fallback
|
||||
{ tags: { historic: true }, icon: 'camera' },
|
||||
{ tags: { building: true }, icon: 'gable-roofed-building' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Place Icons
|
||||
|
||||
How place/POI icons on the map are imported, selected from OSM tags, and rendered.
|
||||
|
||||
> Scope: this covers the **POI icon system** used for map markers and UI.
|
||||
> It does **not** cover the PWA/launcher icons in `public/icons/` and `release/icons/`
|
||||
> (those are app icons referenced from `index.html` / `web-app-manifest.json`).
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `app/utils/icons.js` | Registry. Imports every SVG as a raw string (`?raw`), exposes `getIcon(name)` and `isIconFilled(name)`. |
|
||||
| `app/utils/osm-icons.js` | OSM tag → icon mapping. `POI_ICON_RULES`, `getIconNameForTags(tags)`, `getIconSvgForTags(tags)`. |
|
||||
| `app/components/icon.gjs` | Generic `<Icon @name="..." />` renderer used across the UI. |
|
||||
| `app/components/map.gjs` | Map marker call site; embeds the icon SVG into an OpenLayers `Icon` data-URI. |
|
||||
| `app/icons/` | Custom/local SVG files. |
|
||||
| `node_modules/@waysidemapping/pinhead/dist/icons/*.svg` | Pinhead source icons (2,320 files). |
|
||||
| `node_modules/feather-icons/dist/icons/*.svg` | Feather source icons. |
|
||||
| `tests/unit/utils/osm-icons-test.js` | Tests for tag matching + integrity check that every rule icon exists. |
|
||||
| `tests/integration/components/icon-test.gjs` | Tests for the `<Icon>` component. |
|
||||
|
||||
## How icons are imported (`app/utils/icons.js`)
|
||||
|
||||
Every icon is a **static named import** with Vite's `?raw` suffix, so the file contents
|
||||
arrive as a string. There is **no glob/context import**; each icon is listed explicitly.
|
||||
|
||||
Imports are grouped and sorted alphabetically (per the header comment at
|
||||
`app/utils/icons.js:1`):
|
||||
|
||||
1. **Feather icons** — `feather-icons/dist/icons/*.svg`
|
||||
2. **Pinhead icons** — `@waysidemapping/pinhead/dist/icons/*.svg`
|
||||
3. **Custom/local icons** — `../icons/*.svg`
|
||||
|
||||
```js
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||
import bitcoin from '../icons/bitcoin.svg?raw';
|
||||
```
|
||||
|
||||
The imported strings are collected in the `ICONS` object, keyed by the kebab-case name
|
||||
used everywhere else in the app. Quoted keys are needed when the name contains dashes:
|
||||
|
||||
```js
|
||||
const ICONS = {
|
||||
activity, // shorthand works for single-word names
|
||||
'map-pin': mapPin, // quote dashed keys
|
||||
donut,
|
||||
'loading-ring': loadingRing,
|
||||
};
|
||||
```
|
||||
|
||||
Some registry keys are intentionally **not** normalized to kebab-case
|
||||
(e.g. `climbing_wall`, `parking_p`, `winding_way_wide`) — match whatever string the
|
||||
rule uses.
|
||||
|
||||
`FILLED_ICONS` lists names that render with `fill` instead of `stroke`
|
||||
(`app/utils/icons.js:309`). `getIcon(name)` returns the SVG string (or `undefined`);
|
||||
`isIconFilled(name)` drives the `.icon-filled` CSS class.
|
||||
|
||||
## How icons are selected from OSM tags (`app/utils/osm-icons.js`)
|
||||
|
||||
`POI_ICON_RULES` is an **ordered** array of `{ tags, icon }`. Each rule requires **all**
|
||||
of its listed tags to match. The **first matching rule wins**, so more specific rules
|
||||
must come before catch-alls.
|
||||
|
||||
```js
|
||||
export const POI_ICON_RULES = [
|
||||
{ tags: { cuisine: 'donut' }, icon: 'donut' },
|
||||
...{ tags: { shop: true }, icon: 'shopping-bag' }, // catch-all, must be late
|
||||
];
|
||||
```
|
||||
|
||||
`getIconNameForTags(tags)` (`app/utils/osm-icons.js:214`) matching semantics:
|
||||
|
||||
- Returns `null` if `tags` is falsy or nothing matches.
|
||||
- A rule tag only matches if its value is truthy.
|
||||
- A tag value is split on `;` and trimmed, so `cuisine=donut;coffee_shop` matches both
|
||||
`donut` and `coffee_shop` rules.
|
||||
- `expectedValue: true` matches any non-empty value.
|
||||
|
||||
`getIconSvgForTags(tags)` wraps the name lookup and returns the raw SVG. It is exported
|
||||
but currently unused.
|
||||
|
||||
## Rendering
|
||||
|
||||
- **UI:** `<Icon @name="donut" @size={{16}} />` — `app/components/icon.gjs`. Renders
|
||||
only if `getIcon(@name)` returns an SVG. `@filled` overrides `isIconFilled`.
|
||||
- **Map markers:** `searchResultStyle` in `app/components/map.gjs:123` calls
|
||||
`getIconNameForTags(tags)` (line 151), strips the `<svg>` wrapper, and embeds the
|
||||
paths in white inside the red pin at a `0.8` scale (lines 194-199). Results are cached
|
||||
in `cachedIconUrls` keyed by icon name / `'default'`.
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
1. **Find an SVG.**
|
||||
- Pinhead: browse `node_modules/@waysidemapping/pinhead/dist/icons/` or search the
|
||||
gallery at https://pinhead.ink. Filenames use snake_case (`coffee_bean.svg`).
|
||||
- Feather: `node_modules/feather-icons/dist/icons/`.
|
||||
- Otherwise drop a custom file in `app/icons/`.
|
||||
2. **Import it** in `app/utils/icons.js` in the correct group, keeping the group
|
||||
alphabetical, using `?raw`.
|
||||
3. **Register it** in the `ICONS` map with a kebab-case key (quote it if it has dashes).
|
||||
4. **Mark it filled** in `FILLED_ICONS` if it is a fill-style (not stroke-style) icon.
|
||||
5. **Map OSM tags** in `POI_ICON_RULES` (`app/utils/osm-icons.js`), inserting the rule
|
||||
before any catch-all that would otherwise shadow it.
|
||||
|
||||
No test is needed just to register a new icon or tag rule. The generic integrity test
|
||||
(`all icons used in POI_ICON_RULES exist in the icons utility`) already checks every
|
||||
rule's icon. Only add a focused test in `tests/unit/utils/osm-icons-test.js` for
|
||||
non-trivial matching behavior (e.g. ordering/shadowing, semicolon-separated values, or
|
||||
a new catch-all).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Order matters.** First match wins; put specific rules before catch-alls
|
||||
(`shop: true`, `office: true`, `craft: true`, `sport: true`, `historic: yes`).
|
||||
- **The integrity test is your safety net.** `osm-icons-test.js` asserts every icon named
|
||||
in `POI_ICON_RULES` exists in `getIcon`. Forgetting the import/registry entry fails CI.
|
||||
This covers _all_ icons automatically, so you normally don't need to add a test when
|
||||
adding one.
|
||||
- **Naming mismatch.** Pinhead filenames are snake_case; registry keys are usually
|
||||
kebab-case, with a few unnormalized exceptions. A typo yields `null` (no marker icon)
|
||||
rather than an error.
|
||||
- **Two icon systems.** Don't touch `public/icons/` (PWA icons) when working on POI icons;
|
||||
`pnpm build:icons` regenerates those PNGs and is release-only.
|
||||
- **Don't confuse with categories.** Tag→category matching for the sidebar/chips lives in
|
||||
`app/utils/poi-category-matcher.js` and `app/utils/poi-categories.js`, separate from
|
||||
tag→icon.
|
||||
|
||||
## Running the tests
|
||||
|
||||
```sh
|
||||
pnpm test # builds and runs the full QUnit suite via Testem
|
||||
pnpm lint # ESLint + Stylelint + Prettier + ember-template-lint
|
||||
pnpm lint:fix # auto-fix
|
||||
```
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.34.1",
|
||||
"version": "1.34.6",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||
@@ -1 +1 @@
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-B0dlspz_.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-Djj89Vv-.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
@@ -1 +1 @@
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-B0dlspz_.js";function l(){for(var t=[],n=0;n<arguments.length;n++)t[n]=arguments[n];return 1===(t=o(t)).length?e(t[0]):new r(function(t){return function(r){for(var n=[],s=function(s){n.push(e(t[s]).subscribe(i(r,function(t){if(n){for(var e=0;e<n.length;e++)e!==s&&n[e].unsubscribe();n=null}r.next(t)})))},a=0;n&&!r.closed&&a<t.length;a++)s(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-Djj89Vv-.js";function l(){for(var t=[],n=0;n<arguments.length;n++)t[n]=arguments[n];return 1===(t=o(t)).length?e(t[0]):new r(function(t){return function(r){for(var n=[],s=function(s){n.push(e(t[s]).subscribe(i(r,function(t){if(n){for(var e=0;e<n.length;e++)e!==s&&n[e].unsubscribe();n=null}r.next(t)})))},a=0;n&&!r.closed&&a<t.length;a++)s(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-B0dlspz_.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-Djj89Vv-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -271,4 +271,85 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
'Saved Places are ordered by createdAt in descending order'
|
||||
);
|
||||
});
|
||||
|
||||
test('returning from place details preserves scroll position without a loading flash', async function (assert) {
|
||||
const places = Array.from({ length: 40 }, (_, i) => ({
|
||||
id: `place-${i}`,
|
||||
title: `Place ${i}`,
|
||||
geohash: 'u33dc0',
|
||||
lat: 48.1,
|
||||
lon: 11.5,
|
||||
createdAt: new Date(2023, 0, 1, 12, 0, i).toISOString(),
|
||||
osmTags: { name: `Place ${i}` },
|
||||
}));
|
||||
|
||||
class ManyPlacesStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = places;
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: places.map((p) => ({ id: p.id, geohash: p.geohash })),
|
||||
},
|
||||
];
|
||||
|
||||
findPlaceById(id) {
|
||||
return this.savedPlaces.find((p) => p.id === id) || null;
|
||||
}
|
||||
|
||||
isPlaceSaved() {
|
||||
return true;
|
||||
}
|
||||
|
||||
loadPlacesInBounds() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlacesInList(listId) {
|
||||
if (listId === 'to-go') {
|
||||
return Promise.resolve(this.savedPlaces);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.unregister('service:storage');
|
||||
this.owner.register('service:storage', ManyPlacesStorageService);
|
||||
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
sidebarContent.scrollTop = 300;
|
||||
assert.strictEqual(sidebarContent.scrollTop, 300, 'List is scrolled');
|
||||
|
||||
// Open place details from an item that is currently visible so the browser
|
||||
// does not scroll it back into view (which would reset our saved position),
|
||||
// then return via the back button.
|
||||
const visibleItem = document.querySelectorAll('.place-item')[5];
|
||||
await click(visibleItem);
|
||||
await waitFor('.sidebar-content');
|
||||
await click('.back-btn');
|
||||
await waitFor('.places-list');
|
||||
|
||||
// Allow the restore-scroll requestAnimationFrame to run
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
|
||||
const restored = document.querySelector('.sidebar-content');
|
||||
assert.strictEqual(
|
||||
restored.scrollTop,
|
||||
300,
|
||||
'Scroll position is restored on return'
|
||||
);
|
||||
assert
|
||||
.dom('.sidebar-loading')
|
||||
.doesNotExist('No loading spinner flashes when returning');
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,12 @@ export class MockNostrDataService extends Service {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
getEventRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
recordPublishResult() {}
|
||||
|
||||
refreshProfiles() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -109,14 +115,14 @@ export class MockNostrDataService extends Service {
|
||||
|
||||
export class MockNostrRelayService extends Service {
|
||||
pool = {
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
publish: () => Promise.resolve([{ ok: true, from: 'wss://relay.test' }]),
|
||||
subscribe: () => {},
|
||||
unsubscribe: () => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
async publish() {
|
||||
return [{ ok: true }];
|
||||
return [{ ok: true, from: 'wss://relay.test' }];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import { decodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import PhotoGallery from 'marco/components/photo-gallery';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
@@ -163,8 +164,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
||||
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
const publishStub = sinon
|
||||
.stub(this.nostrRelay, 'publish')
|
||||
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||
const recordPublishSpy = sinon.spy(this.nostrData, 'recordPublishResult');
|
||||
const toastSpy = sinon.spy(this.toast, 'show');
|
||||
|
||||
await render(
|
||||
@@ -224,6 +228,17 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
'added kind 5 event to local store'
|
||||
);
|
||||
|
||||
// Check publish result recording
|
||||
assert.ok(
|
||||
recordPublishSpy.calledOnce,
|
||||
'nostrData.recordPublishResult was called'
|
||||
);
|
||||
assert.strictEqual(
|
||||
recordPublishSpy.firstCall.args[0],
|
||||
publishedEvent.id,
|
||||
'publish result recorded for the deletion event'
|
||||
);
|
||||
|
||||
// Check UX
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Photo deleted successfully'),
|
||||
@@ -261,7 +276,9 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
sinon.stub(window, 'confirm').returns(true);
|
||||
sinon.stub(this.blossom, 'delete').resolves();
|
||||
sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
sinon
|
||||
.stub(this.nostrRelay, 'publish')
|
||||
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||
sinon.stub(this.nostrData.store, 'add');
|
||||
|
||||
await render(
|
||||
@@ -284,9 +301,27 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it copies event id to clipboard', async function (assert) {
|
||||
const eventId = '1'.repeat(64);
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.photos = [
|
||||
{
|
||||
eventId,
|
||||
pubkey: USER_A,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'photo.jpg',
|
||||
},
|
||||
];
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
sinon
|
||||
.stub(this.nostrData, 'getEventRelays')
|
||||
.returns([
|
||||
'wss://a.test',
|
||||
'wss://b.test',
|
||||
'wss://c.test',
|
||||
'wss://d.test',
|
||||
'wss://e.test',
|
||||
]);
|
||||
const clipboardStub = sinon
|
||||
.stub(navigator.clipboard, 'writeText')
|
||||
.resolves();
|
||||
@@ -317,7 +352,23 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
await click(copyBtn);
|
||||
|
||||
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
|
||||
const copied = clipboardStub.firstCall.args[0];
|
||||
assert.ok(
|
||||
copied.startsWith('nevent1'),
|
||||
'copied value is an nevent identifier'
|
||||
);
|
||||
const decoded = decodePointer(copied);
|
||||
assert.strictEqual(decoded.type, 'nevent', 'decoded value is an nevent');
|
||||
assert.strictEqual(
|
||||
decoded.data.id,
|
||||
eventId,
|
||||
'decoded nevent references the correct event id'
|
||||
);
|
||||
assert.deepEqual(
|
||||
decoded.data.relays,
|
||||
['wss://a.test', 'wss://b.test', 'wss://c.test'],
|
||||
'decoded nevent includes at most 3 relay hints'
|
||||
);
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||
'success toast was shown'
|
||||
|
||||
@@ -457,4 +457,112 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
|
||||
assert.dom('.place-payment-methods').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders the operator name with a briefcase icon', async function (assert) {
|
||||
const place = {
|
||||
title: 'Operated Cafe',
|
||||
osmTags: {
|
||||
operator: 'Le Méridien',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const operatorBlock = this.element.querySelector(
|
||||
'.meta-info .content-with-icon span.icon[title="Operator"]'
|
||||
);
|
||||
assert.ok(operatorBlock, 'Operator block is rendered');
|
||||
|
||||
const row = operatorBlock.closest('.content-with-icon');
|
||||
assert.dom(row).includesText('Le Méridien');
|
||||
});
|
||||
|
||||
test('it splits semicolon-separated operators onto separate lines', async function (assert) {
|
||||
const place = {
|
||||
title: 'Multi Operator Stop',
|
||||
osmTags: {
|
||||
operator: 'De Lijn;TEC',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const row = this.element
|
||||
.querySelector('span.icon[title="Operator"]')
|
||||
.closest('.content-with-icon');
|
||||
|
||||
assert.dom(row).includesText('De Lijn');
|
||||
assert.dom(row).includesText('TEC');
|
||||
assert.true(
|
||||
row.querySelector('span:not(.icon)').innerHTML.includes('<br>'),
|
||||
'Operators are separated by a line break'
|
||||
);
|
||||
});
|
||||
|
||||
test('it suppresses invalid operator values of yes and no', async function (assert) {
|
||||
const place = {
|
||||
title: 'Invalid Operator',
|
||||
osmTags: {
|
||||
operator: 'yes',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert
|
||||
.dom('span.icon[title="Operator"]')
|
||||
.doesNotExist('Operator block is hidden for operator=yes');
|
||||
});
|
||||
|
||||
test('it renders operator wikidata and website links', async function (assert) {
|
||||
const place = {
|
||||
title: 'Linked Operator',
|
||||
osmTags: {
|
||||
operator: 'Flixbus',
|
||||
'operator:wikidata': 'Q15712278',
|
||||
'operator:website': 'https://www.flixbus.com/',
|
||||
'operator:wikipedia': 'en:FlixBus',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const row = this.element
|
||||
.querySelector('span.icon[title="Operator"]')
|
||||
.closest('.content-with-icon');
|
||||
|
||||
const wikidataLink = row.querySelector(
|
||||
'a[href="https://www.wikidata.org/wiki/Q15712278"]'
|
||||
);
|
||||
assert.ok(wikidataLink, 'Wikidata link is rendered');
|
||||
assert.dom(wikidataLink).hasText('Wikidata');
|
||||
|
||||
const websiteLink = row.querySelector('a[href="https://www.flixbus.com/"]');
|
||||
assert.ok(websiteLink, 'Operator website link is rendered');
|
||||
assert.dom(websiteLink).hasText('www.flixbus.com');
|
||||
|
||||
const wikipediaLink = row.querySelector(
|
||||
'a[href="https://en.wikipedia.org/wiki/FlixBus"]'
|
||||
);
|
||||
assert.ok(wikipediaLink, 'Operator Wikipedia link is rendered');
|
||||
assert.dom(wikipediaLink).hasText('Wikipedia');
|
||||
});
|
||||
|
||||
test('it renders the operator block when only operator subkeys are present', async function (assert) {
|
||||
const place = {
|
||||
title: 'Subkey Only',
|
||||
osmTags: {
|
||||
'operator:wikidata': 'Q42',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert
|
||||
.dom('span.icon[title="Operator"]')
|
||||
.exists('Operator block is rendered from subkeys alone');
|
||||
assert
|
||||
.dom('a[href="https://www.wikidata.org/wiki/Q42"]')
|
||||
.exists('Wikidata link is rendered');
|
||||
});
|
||||
});
|
||||
@@ -498,6 +498,56 @@ module('Unit | Service | nostr-data | provenance', function (hooks) {
|
||||
);
|
||||
assert.true(service.store.hasEvent(eventId), 'event added to store');
|
||||
});
|
||||
|
||||
test('getEventRelays returns normalized relays for an event', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(904);
|
||||
|
||||
service._recordProvenance(eventId, 'wss://one.example/');
|
||||
service._recordProvenance(eventId, 'WSS://Two.Example');
|
||||
|
||||
assert.deepEqual(
|
||||
service.getEventRelays(eventId).sort(),
|
||||
['wss://one.example', 'wss://two.example'],
|
||||
'returns normalized, unique relays'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.getEventRelays('unknown-event'),
|
||||
[],
|
||||
'returns an empty array for unknown events'
|
||||
);
|
||||
});
|
||||
|
||||
test('recordPublishResult records only successful relays into provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(905);
|
||||
|
||||
service.recordPublishResult(eventId, [
|
||||
{ ok: true, from: 'wss://accepted.example' },
|
||||
{ ok: false, from: 'wss://rejected.example', message: 'blocked' },
|
||||
]);
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance recorded');
|
||||
assert.true(
|
||||
relays.has('wss://accepted.example'),
|
||||
'accepted relay is recorded'
|
||||
);
|
||||
assert.false(
|
||||
relays.has('wss://rejected.example'),
|
||||
'rejected relay is not recorded'
|
||||
);
|
||||
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
eventId
|
||||
);
|
||||
assert.deepEqual(
|
||||
persisted,
|
||||
['wss://accepted.example'],
|
||||
'accepted relay is persisted'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
|
||||
|
||||
Reference in new issue
Block a user