diff --git a/.changeset/fluffy-foxes-wish.md b/.changeset/fluffy-foxes-wish.md new file mode 100644 index 0000000..617214a --- /dev/null +++ b/.changeset/fluffy-foxes-wish.md @@ -0,0 +1,5 @@ +--- +'intrepid-ibex': minor +--- + +added a blob viewer made to feel like eye of gnome diff --git a/src/lib/atproto/blobs.svelte.ts b/src/lib/atproto/blobs.svelte.ts new file mode 100644 index 0000000..7138336 --- /dev/null +++ b/src/lib/atproto/blobs.svelte.ts @@ -0,0 +1,176 @@ +import { Client, ok, simpleFetchHandler } from '@atcute/client'; +import type { Did } from '@atcute/lexicons/syntax'; +import type {} from '@atcute/atproto'; +import { errorMessage } from '$lib/utils/errors'; +import type { AccountIdentity, BlobReference, RepoBlobSummary } from './types'; + +class RepoBlobState { + blobs = $state([]); + selectedCid = $state(null); + isLoading = $state(false); + isLoadingMore = $state(false); + canLoadMore = $state(false); + error = $state(null); + loadedDid = $state(null); + private cursor = $state(null); + + // TODO: should be $derived + get selectedBlob() { + return this.blobs.find((blob) => blob.cid === this.selectedCid) ?? null; + } + + // TODO: should be $derived + get selectedIndex() { + return this.blobs.findIndex((blob) => blob.cid === this.selectedCid); + } + + async load(identity: AccountIdentity, selectedCid?: string | null) { + if (this.loadedDid === identity.did && this.blobs.length > 0) { + if (selectedCid) this.select(identity, selectedCid); + return; + } + + this.reset(); + this.loadedDid = identity.did; + this.isLoading = true; + this.error = null; + + try { + const page = await listBlobPage(identity); + this.blobs = page.cids.map((cid) => blobSummary(identity, cid, null)); + this.cursor = page.cursor ?? null; + this.canLoadMore = Boolean(page.cursor); + + if (selectedCid) { + this.select(identity, selectedCid); + } else { + this.selectedCid = this.blobs[0]?.cid ?? null; + } + } catch (unknownError) { + if (selectedCid) { + this.blobs = [blobSummary(identity, selectedCid, null)]; + this.selectedCid = selectedCid; + } + this.error = errorMessage(unknownError, 'Could not load repository blobs.'); + } finally { + this.isLoading = false; + } + } + + async loadMore(identity: AccountIdentity) { + if (!this.cursor || this.isLoadingMore) return; + + this.isLoadingMore = true; + this.error = null; + + try { + const page = await listBlobPage(identity, this.cursor); + const knownCids = new Set(this.blobs.map((blob) => blob.cid)); + const nextBlobs = page.cids.filter((cid) => !knownCids.has(cid)).map((cid) => blobSummary(identity, cid, null)); + + this.blobs = [...this.blobs, ...nextBlobs]; + this.cursor = page.cursor ?? null; + this.canLoadMore = Boolean(page.cursor); + } catch (unknownError) { + this.canLoadMore = false; + this.error = errorMessage(unknownError, 'Could not load more blobs.'); + } finally { + this.isLoadingMore = false; + } + } + + openMedia(identity: AccountIdentity, media: BlobReference) { + this.loadedDid = identity.did; + const summary = blobSummary(identity, media.cid, media.sourceUri); + + if (!this.blobs.some((blob) => blob.cid === media.cid)) { + this.blobs = [summary, ...this.blobs]; + } + + this.selectedCid = media.cid; + this.error = null; + } + + select(identity: AccountIdentity, cid: string) { + if (!this.blobs.some((blob) => blob.cid === cid)) { + this.blobs = [blobSummary(identity, cid, null), ...this.blobs]; + } + + this.selectedCid = cid; + } + + selectPrevious() { + if (this.selectedIndex <= 0) return; + this.selectedCid = this.blobs[this.selectedIndex - 1]?.cid ?? this.selectedCid; + } + + selectNext() { + if (this.selectedIndex < 0 || this.selectedIndex >= this.blobs.length - 1) return; + this.selectedCid = this.blobs[this.selectedIndex + 1]?.cid ?? this.selectedCid; + } + + reset() { + this.blobs = []; + this.selectedCid = null; + this.isLoading = false; + this.isLoadingMore = false; + this.canLoadMore = false; + this.error = null; + this.loadedDid = null; + this.cursor = null; + } +} + +function isBlobObject(value: object): value is { ref: { $link: string } } { + if (!('ref' in value)) return false; + const { ref } = value; + return ( + typeof ref === 'object' && ref !== null && '$link' in ref && typeof (ref as { $link?: unknown }).$link === 'string' + ); +} + +async function listBlobPage(identity: AccountIdentity, cursor?: string | null) { + const rpc = new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); + return ok( + rpc.get('com.atproto.sync.listBlobs', { + params: { did: identity.did as Did, limit: 50, cursor: cursor ?? undefined } + }) + ); +} + +function blobSummary(identity: AccountIdentity, cid: string, sourceUri: string | null): RepoBlobSummary { + return { cid, sourceUri, rawUrl: rawBlobUrl(identity, cid) }; +} + +function rawBlobUrl(identity: AccountIdentity, cid: string) { + const service = identity.pds ?? 'https://public.api.bsky.app'; + const url = new URL('/xrpc/com.atproto.sync.getBlob', service); + url.searchParams.set('did', identity.did); + url.searchParams.set('cid', cid); + return url.toString(); +} + +export function firstBlobReference(value: unknown, sourceUri: string | null): BlobReference | null { + if (typeof value !== 'object' || value === null) return null; + + if (isBlobObject(value)) { + return { cid: value.ref.$link, sourceUri }; + } + + if (Array.isArray(value)) { + for (const item of value) { + const found = firstBlobReference(item, sourceUri); + if (found) return found; + } + return null; + } + + for (const child of Object.values(value)) { + const found = firstBlobReference(child, sourceUri); + if (found) return found; + } + + return null; +} + +export const repoBlobs = new RepoBlobState(); diff --git a/src/lib/atproto/blobs.test.ts b/src/lib/atproto/blobs.test.ts new file mode 100644 index 0000000..edd60c6 --- /dev/null +++ b/src/lib/atproto/blobs.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { firstBlobReference } from './blobs.svelte'; + +describe('ATProto blob helpers', () => { + it('finds the first embedded blob CID in a post image record', () => { + const blob = firstBlobReference( + { + $type: 'app.bsky.feed.post', + embed: { + $type: 'app.bsky.embed.images', + images: [ + { alt: 'screenshot', image: { ref: { $link: 'bafkreigoodimage' }, mimeType: 'image/png', size: 1234 } } + ] + } + }, + 'at://did:plc:abc123/app.bsky.feed.post/post1' + ); + + expect(blob).toEqual({ cid: 'bafkreigoodimage', sourceUri: 'at://did:plc:abc123/app.bsky.feed.post/post1' }); + }); + + it('returns null when a record has no blob ref', () => { + expect(firstBlobReference({ text: 'plain record' }, 'at://did:plc:abc123/app.bsky.feed.post/post2')).toBeNull(); + }); +}); diff --git a/src/lib/atproto/repo.svelte.ts b/src/lib/atproto/repo.svelte.ts index e992087..baaa9e8 100644 --- a/src/lib/atproto/repo.svelte.ts +++ b/src/lib/atproto/repo.svelte.ts @@ -8,8 +8,6 @@ import { listRecordPages, type RecordPage } from './pagination'; import { isRecordValue } from './types'; import type { AccountIdentity, CollectionSummary, RepoRecordSummary, UnknownRecord } from './types'; -export type { CollectionSummary, RepoRecordSummary } from './types'; - class RepoBrowserState { collections = $state([]); selectedCollection = $state(null); @@ -244,8 +242,6 @@ class RepoBrowserState { } } -export const repoBrowser = new RepoBrowserState(); - function createRepoClient(identity: AccountIdentity) { return new Client({ handler: simpleFetchHandler({ service: identity.pds ?? 'https://public.api.bsky.app' }) }); } @@ -265,16 +261,6 @@ function preferredCollection(collections: string[]) { return collections.includes('app.bsky.feed.post') ? 'app.bsky.feed.post' : (collections[0] ?? null); } -export function iconForCollection(name: string) { - const appIcon = collectionIconMatch(name)?.icon; - if (appIcon) return appIcon; - if (name.includes('profile') || name.includes('actor')) return '/icons/humanity/places/user-home.svg'; - if (name.includes('chat') || name.includes('convo')) return '/icons/humanity/apps/evolution-mail.svg'; - if (name.includes('feed') || name.includes('post')) return '/icons/humanity/apps/internet-feed-reader.svg'; - if (name.includes('graph') || name.includes('follow')) return '/icons/humanity/places/folder.svg'; - return '/icons/humanity/mimes/text-x-generic.svg'; -} - function collectionSummaryForName(name: string, loadedCount: number | null): CollectionSummary { return { name, icon: iconForCollection(name), appLabel: appLabelForCollection(name), loadedCount }; } @@ -297,6 +283,7 @@ function summarizeRecord(record: UnknownRecord, handle: string): RepoRecordSumma collection: collectionFromUri(record.uri), rkey: recordKeyFromUri(record.uri), json: JSON.stringify(record.value, null, 2), + value: record.value, icon: iconForCollection(collectionFromUri(record.uri)), appLabel: appLabelForCollection(collectionFromUri(record.uri)) }; @@ -307,8 +294,8 @@ function summarizeCachedRecord(record: CachedRecord, handle: string): RepoRecord } async function cacheLiveRecords( - identity: AccountIdentity, - collectionName: string, + id: AccountIdentity, + name: string, records: readonly UnknownRecord[], cursor: string | null ) { @@ -317,37 +304,33 @@ async function cacheLiveRecords( const db = await getDatabase(); await cacheFetchedRecords( db, - records.map((record) => toCachedRecordInput(identity, collectionName, record)) + records.map((record) => toCachedRecordInput(id, name, record)) ); await updateCollectionSyncState(db, { - accountDid: identity.did, - repoDid: identity.did, - collection: collectionName, + accountDid: id.did, + repoDid: id.did, + collection: name, cursor, lastSyncedAt: new Date().toISOString(), lastError: null }); - } catch (cacheError) { - console.warn('Could not write records to local cache.', cacheError); + } catch (err) { + console.warn('Could not write records to local cache.', err); } } -function toCachedRecordInput( - identity: AccountIdentity, - collectionName: string, - record: UnknownRecord -): CachedRecordInput { +function toCachedRecordInput(id: AccountIdentity, name: string, record: UnknownRecord): CachedRecordInput { const value = isRecordValue(record.value) ? record.value : {}; const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName) ?? ''; - const type = stringifyField(value.$type) ?? collectionName; + const type = stringifyField(value.$type) ?? name; const createdAt = stringifyField(value.createdAt); const indexedAt = stringifyField(value.indexedAt); const updatedAt = stringifyField(value.updatedAt); return { - accountDid: identity.did, - repoDid: identity.did, - collection: collectionName, + accountDid: id.did, + repoDid: id.did, + collection: name, rkey: recordKeyFromUri(record.uri), uri: record.uri, cid: record.cid, @@ -388,3 +371,15 @@ function formatRecordTime(value: string | null) { minute: '2-digit' }).format(date); } + +export function iconForCollection(name: string) { + const appIcon = collectionIconMatch(name)?.icon; + if (appIcon) return appIcon; + if (name.includes('profile') || name.includes('actor')) return '/icons/humanity/places/user-home.svg'; + if (name.includes('chat') || name.includes('convo')) return '/icons/humanity/apps/evolution-mail.svg'; + if (name.includes('feed') || name.includes('post')) return '/icons/humanity/apps/internet-feed-reader.svg'; + if (name.includes('graph') || name.includes('follow')) return '/icons/humanity/places/folder.svg'; + return '/icons/humanity/mimes/text-x-generic.svg'; +} + +export const repoBrowser = new RepoBrowserState(); diff --git a/src/lib/atproto/routes.test.ts b/src/lib/atproto/routes.test.ts index 77f00d5..096512b 100644 --- a/src/lib/atproto/routes.test.ts +++ b/src/lib/atproto/routes.test.ts @@ -1,6 +1,6 @@ /** Move to test/routes.test.ts */ import { describe, expect, it } from 'vitest'; -import { collectionPath, identityPath, recordPath, repoPath } from './routes'; +import { blobPath, blobsPath, collectionPath, identityPath, recordPath, repoPath } from './routes'; describe('ATProto route helpers', () => { it('builds canonical repo routes', () => { @@ -17,6 +17,14 @@ describe('ATProto route helpers', () => { expect(identityPath('did:plc:abc123')).toBe('/repos/did:plc:abc123/identity'); }); + it('builds canonical blob browser routes', () => { + expect(blobsPath('did:plc:abc123')).toBe('/repos/did:plc:abc123/blobs'); + }); + + it('encodes blob CIDs in canonical blob routes', () => { + expect(blobPath('did:plc:abc123', 'bafy/test')).toBe('/repos/did:plc:abc123/blobs/bafy%2Ftest'); + }); + it('encodes record keys in canonical record routes', () => { expect(recordPath({ did: 'did:plc:abc123', collection: 'app.bsky.feed.post', rkey: 'post/key' })).toBe( '/repos/did:plc:abc123/collections/app.bsky.feed.post/post%2Fkey' diff --git a/src/lib/atproto/routes.ts b/src/lib/atproto/routes.ts index 97fcce0..e1deabb 100644 --- a/src/lib/atproto/routes.ts +++ b/src/lib/atproto/routes.ts @@ -1,13 +1,13 @@ -export type RecordRouteParams = { did: string; collection: string; rkey: string }; +import type { CollectionRouteParams, RecordRouteParams } from './types'; -export type CollectionRouteParams = Pick; +export const repoPath = (did: string) => `/repos/${did}`; -export function repoPath(did: string) { - return `/repos/${did}`; -} +export const identityPath = (did: string) => `${repoPath(did)}/identity`; + +export const blobsPath = (did: string) => `${repoPath(did)}/blobs`; -export function identityPath(did: string) { - return `${repoPath(did)}/identity`; +export function blobPath(did: string, cid: string) { + return `${blobsPath(did)}/${encodeURIComponent(cid)}`; } export function collectionPath({ did, collection }: CollectionRouteParams) { diff --git a/src/lib/atproto/types.ts b/src/lib/atproto/types.ts index d99eeed..4f894f4 100644 --- a/src/lib/atproto/types.ts +++ b/src/lib/atproto/types.ts @@ -17,18 +17,9 @@ export type DidDocument = { service?: DidService[]; }; -export type DidService = { - id?: string; - type?: string; - serviceEndpoint?: string | string[] | Record; -}; +export type DidService = { id?: string; type?: string; serviceEndpoint?: string | string[] | Record }; -export type DidVerificationMethod = { - id?: string; - type?: string; - controller?: string; - publicKeyMultibase?: string; -}; +export type DidVerificationMethod = { id?: string; type?: string; controller?: string; publicKeyMultibase?: string }; export type CollectionSummary = { name: string; icon: string; appLabel: string | null; loadedCount: number | null }; @@ -42,10 +33,19 @@ export type RepoRecordSummary = { collection: string; rkey: string; json: string; + value: unknown; icon: string; appLabel: string | null; }; +export type BlobReference = { cid: string; sourceUri: string | null }; + +export type RepoBlobSummary = { cid: string; rawUrl: string; sourceUri: string | null }; + +export type RecordRouteParams = { did: string; collection: string; rkey: string }; + +export type CollectionRouteParams = Pick; + export type UnknownRecord = { uri: string; cid: string; value: unknown }; export function isRecordValue(value: unknown): value is Record { diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte index 4b38d43..c8dc0f6 100644 --- a/src/lib/components/CollectionBrowser.svelte +++ b/src/lib/components/CollectionBrowser.svelte @@ -1,15 +1,15 @@ + +
+ + +
+
+ + + + +
+ +
+ {#if repoBlobs.error} +

{repoBlobs.error}

+ {/if} + + {#if selectedBlob} + {#if previewMode === 'image'} + {`Blob (previewMode = 'video')} /> + {:else if previewMode === 'video'} + + {:else} +
+ +

Preview unavailable

+

This blob could not be rendered as an image or video by the browser.

+ +
+ {/if} + {:else if !repoBlobs.isLoading} +
+ +

No blob selected

+

Select a CID from the list to preview it.

+
+ {/if} +
+ +
+ {#if selectedBlob} + {selectedBlob.cid} + {#if selectedBlob.sourceUri} + + {/if} + {:else} + {repoBlobs.blobs.length} blobs + {/if} +
+
+
+ + diff --git a/src/lib/window-manager.svelte.ts b/src/lib/window-manager.svelte.ts index 0ba6c11..32c75e6 100644 --- a/src/lib/window-manager.svelte.ts +++ b/src/lib/window-manager.svelte.ts @@ -1,6 +1,6 @@ /* Split up this file into windows/types.ts & windows/manager.svelte.ts */ -export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer' | 'identity-inspector'; +export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer' | 'identity-inspector' | 'eog'; export type ManagedWindow = { id: WindowId; @@ -58,10 +58,19 @@ class WindowManager { isMinimized: false, isMaximized: false, zIndex: 5 + }, + { + id: 'eog', + title: 'Eye of GNOME', + icon: '/icons/humanity/apps/eog.svg', + isOpen: false, + isMinimized: false, + isMaximized: false, + zIndex: 6 } ]); - private nextZIndex = 5; + private nextZIndex = 6; get openWindows() { return this.windows.filter((window) => window.isOpen); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3c4a7e9..e8d282d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { page } from '$app/state'; + import { repoBlobs } from '$lib/atproto/blobs.svelte'; import { repoBrowser } from '$lib/atproto/repo.svelte'; import { accountSetup } from '$lib/atproto/setup.svelte'; import favicon from '$lib/assets/favicon.svg'; @@ -17,6 +18,7 @@ import BootSplash from '$lib/components/BootSplash.svelte'; import DesktopIcon from '$lib/components/DesktopIcon.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; + import EyeOfGnome from '$lib/components/EyeOfGnome.svelte'; import Gedit from '$lib/components/Gedit.svelte'; import GnomePanel from '$lib/components/GnomePanel.svelte'; import IdentityInspector from '$lib/components/IdentityInspector.svelte'; @@ -58,6 +60,7 @@ const geditWindow = $derived(windowManager.getWindow('gedit')); const documentViewerWindow = $derived(windowManager.getWindow('document-viewer')); const identityInspectorWindow = $derived(windowManager.getWindow('identity-inspector')); + const eogWindow = $derived(windowManager.getWindow('eog')); const shortcuts = $derived([ { label: 'ibex Home', @@ -91,6 +94,20 @@ void goto(resolve('/browse')); } }, + { + label: 'Image Viewer', + icon: '/icons/humanity/apps/eog.svg', + selected: eogWindow?.isOpen && !eogWindow.isMinimized, + onactivate: () => { + if (accountSetup.identity) { + void goto(resolve(`/repos/${accountSetup.identity.did}/blobs`), { keepFocus: true, noScroll: true }); + return; + } + + windowManager.restore('main'); + void goto(resolve('/browse')); + } + }, { label: 'Computer', icon: '/icons/humanity/devices/computer.svg', @@ -128,13 +145,17 @@ repoBrowser.selectedRecord.icon ); } + + if (repoBlobs.selectedCid) { + windowManager.setTitle('eog', `${repoBlobs.selectedCid} - Eye of GNOME`, '/icons/humanity/apps/eog.svg'); + } }); $effect(() => { const route = repoRouteFromParams(); if (bootStatus !== 'ready' || !route) return; - const routeKey = [route.did, route.app, route.collection, route.rkey].filter(Boolean).join('/'); + const routeKey = [route.did, route.app, route.collection, route.rkey, route.cid].filter(Boolean).join('/'); if (handledRepoRoute === routeKey) return; handledRepoRoute = routeKey; @@ -196,7 +217,13 @@ accountSetup.load(); } - async function openRepoRoute(route: { did: string; app?: string; collection?: string; rkey?: string }) { + async function openRepoRoute(route: { + did: string; + app?: string; + collection?: string; + rkey?: string; + cid?: string; + }) { try { const { hydratePublicIdentity } = await import('$lib/atproto/identity'); const identity = await hydratePublicIdentity(route.did); @@ -211,6 +238,17 @@ return; } + if (route.app === 'blobs') { + await repoBrowser.load(identity); + await repoBlobs.load(identity, route.cid); + windowManager.setTitle( + 'eog', + route.cid ? `${route.cid} - Eye of GNOME` : `${identity.handle} - Eye of GNOME` + ); + windowManager.open('eog'); + return; + } + if (route.collection && route.rkey) { await repoBrowser.openRecordRoute(identity, route.collection, route.rkey); if (repoBrowser.selectedRecord) { @@ -235,7 +273,18 @@ const { did, collection, rkey } = page.params; if (!did) return null; - return { did, app: page.route.id === '/repos/[did]/identity' ? 'identity' : undefined, collection, rkey }; + return { + did, + app: + page.route.id === '/repos/[did]/identity' + ? 'identity' + : page.route.id?.startsWith('/repos/[did]/blobs') + ? 'blobs' + : undefined, + collection, + rkey, + cid: page.params.cid + }; } async function waitForMinimumBootTime(startedAt: number) { @@ -378,6 +427,25 @@ {/if} + {#if eogWindow?.isOpen && !eogWindow.isMinimized} +
+ windowManager.focus('eog')} + onminimize={() => windowManager.minimize('eog')} + onmaximize={() => windowManager.toggleMaximize('eog')} + onclose={() => windowManager.close('eog')}> + + +
+ {/if} + {#if showStickyNote} (showStickyNote = false)} /> {/if} @@ -435,7 +503,8 @@ .about-window.maximized, .document-viewer-window.maximized, .gedit-window.maximized, - .identity-inspector-window.maximized { + .identity-inspector-window.maximized, + .eog-window.maximized { position: fixed; top: 1.75rem; right: 0; @@ -449,7 +518,8 @@ .about-window, .document-viewer-window, .gedit-window, - .identity-inspector-window { + .identity-inspector-window, + .eog-window { position: absolute; z-index: 3; } @@ -482,10 +552,18 @@ height: min(39rem, calc(100vh - 5rem)); } + .eog-window { + top: min(5.5rem, 10vh); + left: min(18rem, 22vw); + width: min(54rem, calc(100vw - 2rem)); + height: min(38rem, calc(100vh - 5rem)); + } + .about-window :global(.app-window), .document-viewer-window :global(.app-window), .gedit-window :global(.app-window), - .identity-inspector-window :global(.app-window) { + .identity-inspector-window :global(.app-window), + .eog-window :global(.app-window) { height: 100%; } @@ -497,7 +575,8 @@ .about-window, .gedit-window, - .identity-inspector-window { + .identity-inspector-window, + .eog-window { left: auto; right: var(--space-3); } @@ -519,7 +598,8 @@ .about-window, .gedit-window, - .identity-inspector-window { + .identity-inspector-window, + .eog-window { top: var(--space-3); right: var(--space-3); left: var(--space-3); diff --git a/src/routes/repos/[did]/blobs/+page.svelte b/src/routes/repos/[did]/blobs/+page.svelte new file mode 100644 index 0000000..5bf03f4 --- /dev/null +++ b/src/routes/repos/[did]/blobs/+page.svelte @@ -0,0 +1 @@ + diff --git a/src/routes/repos/[did]/blobs/[cid]/+page.svelte b/src/routes/repos/[did]/blobs/[cid]/+page.svelte new file mode 100644 index 0000000..5bf03f4 --- /dev/null +++ b/src/routes/repos/[did]/blobs/[cid]/+page.svelte @@ -0,0 +1 @@ + diff --git a/static/icons/humanity/apps/eog.svg b/static/icons/humanity/apps/eog.svg new file mode 100644 index 0000000..2f96e58 --- /dev/null +++ b/static/icons/humanity/apps/eog.svg @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +