Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ NOSTR_RELAY_URLS=ws://localhost:10547
# External Source Adapters Configuration
# Comma-separated list of adapter IDs to enable
# Available adapters: arasaac
# Example: ENABLED_ADAPTERS=arasaac
# Example: ENABLED_ADAPTERS=arasaac,openverse
# ENABLED_ADAPTERS=

# Timeout for adapter requests in milliseconds (default: 3000)
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ USER node
COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml $APP_PATH/
COPY --chown=node:node packages/oer-adapter-core $APP_PATH/packages/oer-adapter-core/
COPY --chown=node:node packages/oer-adapter-arasaac $APP_PATH/packages/oer-adapter-arasaac/
COPY --chown=node:node packages/oer-adapter-openverse $APP_PATH/packages/oer-adapter-openverse/
RUN pnpm install

COPY --chown=node:node src tsconfig.build.json tsconfig.json $APP_PATH/
Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Nostr OER Finder - Aggregator and Plugin

An Open Educational Resources (OER) discovery system built on Nostr, providing:

1. **Aggregator Service**: Listens to configurable Nostr relays for OER image resources, collects them, and exposes them via a public API. Can also proxy to external OER sources through an **extendable adapter system** - add your own adapters to integrate any external API.
2. **Source Adapters**: Pluggable adapters for external OER sources (e.g., ARASAAC) that integrate seamlessly with search results. The adapter plugin system makes it easy to add new sources.
3. **JavaScript Packages**: Type-safe API client and web components for integrating OER resources into applications

**Motivation**: Instead of configuring for each new educational app new OER sources, this project aims to offer a meta search with reusable web components. The idea is to make it as easy as possible to install a OER search component in any Javascript application with multiple sources preconfigured. The main idea started to listen for OER Nostr events. But as this network must be estabilished first, additional sources were introduced.

## Demo of the configurable Web Components
<img src="./docs/images/oer-finder-plugin-example.png" width=750/>
The screenshot shows an example of using Openverse as a OER source for the keyword "car".

## Overview

```
┌───────────────────────────────────────────────────────────────────┐
│ Your Application │
Expand Down Expand Up @@ -29,20 +43,10 @@
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ARASAAC │ │ Future │ │ Future │
│ARASAAC │ │ Openverse│ │ Future │
└────────┘ └──────────┘ └──────────┘
```

An Open Educational Resources (OER) discovery system built on Nostr, providing:

1. **Aggregator Service**: Listens to configurable Nostr relays for OER image resources, collects them, and exposes them via a public API. Can also proxy to external OER sources through an **extendable adapter system** - add your own adapters to integrate any external API.
2. **Source Adapters**: Pluggable adapters for external OER sources (e.g., ARASAAC) that integrate seamlessly with search results. The adapter plugin system makes it easy to add new sources.
3. **JavaScript Packages**: Type-safe API client and web components for integrating OER resources into applications

## Demo of the configurable Web Components
<img src="./docs/images/oer-finder-plugin-example-2.png" width=750/>
<img src="./docs/images/oer-finder-plugin-example.png" width=750/>

## Quick Start

### Development Server Setup
Expand Down Expand Up @@ -143,6 +147,7 @@ pnpm add @edufeed-org/oer-finder-plugin
- **[Server Setup](./docs/server-setup.md)** - Installation, configuration, development, and testing
- **[Client Packages](./docs/client-packages.md)** - API client and web components usage
- **[Client Packages Examples for Angular](./docs/client-packages-angular.md)** - Web components usage in Angular
- **[Client Packages Examples for Svelte](./docs/client-packages-svelte.md)** - Web components usage in Svelte
- **[Client Packages Examples for React](./docs/client-packages-react.md)** - React component wrappers usage

### Architecture & Design
Expand Down
4 changes: 2 additions & 2 deletions docs/client-packages-svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ For installation, see [Client Packages (Web Components Plugin)](./client-package

The recommended pattern is to slot `<oer-list>` and `<oer-pagination>` inside `<oer-search>` for automatic pagination handling.

```svelte
```javascript
<script lang="ts">
import type {
OerSearchResultEvent,
Expand Down Expand Up @@ -90,7 +90,7 @@ The recommended pattern is to slot `<oer-list>` and `<oer-pagination>` inside `<

For array/object properties like `available-sources`, convert to JSON string:

```svelte
```javascript
<script lang="ts">
const availableSources = [
{ value: 'all', label: 'All Sources' },
Expand Down
Binary file removed docs/images/oer-finder-plugin-example-2.png
Binary file not shown.
Binary file modified docs/images/oer-finder-plugin-example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"dependencies": {
"@edufeed-org/oer-adapter-arasaac": "workspace:*",
"@edufeed-org/oer-adapter-core": "workspace:*",
"@edufeed-org/oer-adapter-openverse": "workspace:*",
"@nestjs/common": "^11.1.8",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.8",
Expand Down
8 changes: 6 additions & 2 deletions packages/oer-adapter-arasaac/src/arasaac.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,13 @@ export function mapArasaacPictogramToOerItem(
const attribution =
language === 'de' ? ARASAAC_ATTRIBUTION_DE : ARASAAC_ATTRIBUTION_EN;

const landingUrl = `${ARASAAC_WEB_BASE_URL}/${language}/${pictogram._id}`;
const imageUrls = buildImageUrls(pictogram._id, imageBaseUrl);

return {
id: `arasaac-${pictogram._id}`,
url: `${ARASAAC_WEB_BASE_URL}/${language}/${pictogram._id}`,
url: imageUrls.medium,
foreign_landing_url: landingUrl,
name: primaryKeyword,
description: null,
attribution,
Expand All @@ -105,7 +109,7 @@ export function mapArasaacPictogramToOerItem(
file_size: null,
file_dim: '500x500',
file_alt: primaryKeyword,
images: buildImageUrls(pictogram._id, imageBaseUrl),
images: imageUrls,
creators: buildCreators(),
};
}
2 changes: 2 additions & 0 deletions packages/oer-adapter-core/src/adapter.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface ExternalOerItem {
id: string;
/** URL to the resource */
url: string;
/** URL to the resource's landing page on the original source website */
foreign_landing_url: string | null;
/** Name/title of the resource */
name: string | null;
/** Description of the resource */
Expand Down
38 changes: 38 additions & 0 deletions packages/oer-adapter-openverse/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@edufeed-org/oer-adapter-openverse",
"version": "0.0.1",
"description": "Openverse adapter for OER source integration - openly licensed images and audio",
"author": "B310 Digital GmbH",
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/edufeed-org/oer-finder-plugin.git",
"directory": "packages/oer-adapter-openverse"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build && tsc --emitDeclarationOnly",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@edufeed-org/oer-adapter-core": "workspace:*",
"valibot": "^1.1.0"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^6.4.1"
}
}
21 changes: 21 additions & 0 deletions packages/oer-adapter-openverse/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export {
OpenverseAdapter,
createOpenverseAdapter,
} from './openverse.adapter.js';
export type {
OpenverseImage,
OpenverseTag,
OpenverseSearchResponse,
} from './openverse.types.js';
export {
OpenverseTagSchema,
OpenverseImageSchema,
OpenverseSearchResponseSchema,
parseOpenverseSearchResponse,
} from './openverse.types.js';
export {
mapOpenverseImageToOerItem,
buildImageUrls,
extractKeywords,
buildCreators,
} from './openverse.mapper.js';
132 changes: 132 additions & 0 deletions packages/oer-adapter-openverse/src/openverse.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type {
SourceAdapter,
AdapterSearchQuery,
AdapterSearchOptions,
AdapterSearchResult,
} from '@edufeed-org/oer-adapter-core';
import { parseOpenverseSearchResponse } from './openverse.types.js';
import { mapOpenverseImageToOerItem } from './openverse.mapper.js';

/** API base URL */
const API_BASE_URL = 'https://api.openverse.org/v1';

/**
* Openverse adapter for searching openly licensed images.
*
* Openverse is a search engine for openly licensed media, including images and audio.
* It aggregates content from multiple sources like Flickr, Wikimedia Commons,
* Metropolitan Museum of Art, and many others.
*
* API Documentation: https://api.openverse.org/v1/
* All content is openly licensed (CC licenses, public domain, etc.)
*/
export class OpenverseAdapter implements SourceAdapter {
readonly sourceId = 'openverse';
readonly sourceName = 'Openverse';

/**
* Search for images matching the query.
*
* Openverse API endpoint: GET /v1/images/?q={searchText}
*
* Mapped filters:
* - license: Maps CC license URIs to Openverse codes (by, by-sa, cc0, pdm, etc.)
*
* Default filters applied:
* - mature=false: Excludes mature content (safe for educational use)
* - filter_dead=true: Excludes broken/dead links
*
* Note: The `type` parameter from OerQueryDto is not mapped since this endpoint
* only returns images. Filtering by media type (image/video/audio) is not
* applicable here.
*/
async search(
query: AdapterSearchQuery,
options?: AdapterSearchOptions,
): Promise<AdapterSearchResult> {
const keywords = query.keywords?.trim();
if (!keywords) {
return { items: [], total: 0 };
}

const params = new URLSearchParams();
params.set('q', keywords);
params.set('page', query.page.toString());
params.set('page_size', query.pageSize.toString());

// Default filters for educational/safe content
params.set('mature', 'false');
params.set('filter_dead', 'true');

// Filter by license if specified
if (query.license) {
const licenseParam = this.mapLicenseToOpenverse(query.license);
if (licenseParam) {
params.set('license', licenseParam);
}
}

const url = `${API_BASE_URL}/images/?${params.toString()}`;

const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
signal: options?.signal,
});

if (!response.ok) {
if (response.status === 404) {
return { items: [], total: 0 };
}
throw new Error(
`Openverse API error: ${response.status} ${response.statusText}`,
);
}

const rawData: unknown = await response.json();
const searchResponse = parseOpenverseSearchResponse(rawData);

const items = searchResponse.results.map((image) =>
mapOpenverseImageToOerItem(image),
);

return {
items,
total: searchResponse.result_count,
};
}

/**
* Map a license URI to Openverse license parameter.
* Openverse accepts license codes like "by", "by-sa", "cc0", etc.
*/
private mapLicenseToOpenverse(licenseUri: string): string | null {
const licenseMap: Record<string, string> = {
'creativecommons.org/licenses/by/': 'by',
'creativecommons.org/licenses/by-sa/': 'by-sa',
'creativecommons.org/licenses/by-nc/': 'by-nc',
'creativecommons.org/licenses/by-nd/': 'by-nd',
'creativecommons.org/licenses/by-nc-sa/': 'by-nc-sa',
'creativecommons.org/licenses/by-nc-nd/': 'by-nc-nd',
'creativecommons.org/publicdomain/zero/': 'cc0',
'creativecommons.org/publicdomain/mark/': 'pdm',
};

for (const [pattern, code] of Object.entries(licenseMap)) {
if (licenseUri.includes(pattern)) {
return code;
}
}

return null;
}

}

/**
* Factory function to create an OpenverseAdapter.
*/
export function createOpenverseAdapter(): OpenverseAdapter {
return new OpenverseAdapter();
}
Loading