23 KiB
Edufeed: AMB-NIP
Abstract
This NIP defines how to handle the metadata profile "Allgemeines Metadatenprofil für Bildungsressourcen" (AMB) in nostr:
- How to convert AMB metadata to an AMB nostr event
- How to convert an AMB nostr-event to AMB metadata
- How to query for AMB nostr-events in supporting relays
Event Kind
This NIP defines kind:30142 as an AMB Metadata Event.
This means this is a replaceable event, that can be addressed using kind:pubkey:d-tag.
How to convert AMB metadata to an AMB nostr event
The transformation uses JSON-flattening with : as the delimiter to convert nested AMB metadata structures into flat Nostr tags. Additionally, Nostr-native tag conventions are used where applicable for better interoperability and query efficiency.
Nostr-Native Conventions
This NIP follows Nostr conventions where they align with AMB requirements:
ttags: Used for keywords/topics (instead of flattenedkeywordstags)ptags: Used for creator/contributor references when the creator has a Nostr identity (pubkey), with fallback to flattened structure for non-Nostr identifiersatags: Used for references to other addressable events on Nostr (including other AMB events), with fallback to flattened URIs for external resources
Flattening Rules
-
Simple properties: Map directly to tags
- AMB:
{"name": "Resource Title"} - Nostr:
["name", "Resource Title"]
- AMB:
-
Nested objects: Flatten using
:delimiter- AMB:
{"creator": {"name": "John", "id": "123"}} - Nostr:
["creator:name", "John"],["creator:id", "123"]
- AMB:
-
Arrays: Repeat the same flattened tag key (order is preserved by tag array position)
- AMB:
{"keywords": ["Math", "Physics"]} - Nostr:
["t", "Math"],["t", "Physics"](Nostr-nativettag)
- AMB:
-
Arrays of objects: Repeat flattened keys for each object
- AMB:
{"creator": [{"name": "John"}, {"name": "Jane"}]} - Nostr:
["creator:name", "John"],["creator:name", "Jane"]
- AMB:
-
Deep nesting: Continue flattening with additional
:delimiters- AMB:
{"creator": {"affiliation": {"name": "MIT"}}} - Nostr:
["creator:affiliation:name", "MIT"]
- AMB:
Property Mappings
This is how we convert each property of the AMB:
General:
id→["d", <id>](special case: use Nostr'sdtag as identifier)type→["type", <value>](repeat for multiple types)name→["name", <value>]description→["description", <value>]about(array of concept objects) → Repeat for each:["about:id", <uri>]["about:prefLabel", <label>]["about:type", "Concept"]["about:inLanguage", <language>](if applicable)
keywords→["t", <keyword>](repeat for each keyword, using Nostrttag)inLanguage→["inLanguage", <languageCode>](repeat for each language)image→["image", <uri>]trailer(MediaObject) →["trailer:contentUrl", <url>]["trailer:type", <"VideoObject"|"AudioObject">]["trailer:encodingFormat", <format>](optional)["trailer:contentSize", <bytes>](optional)["trailer:sha256", <hash>](optional)["trailer:embedUrl", <url>](optional)["trailer:bitrate", <kbps>](optional)
Provenance:
creator(array of Person/Organization objects) → Repeat for each:- Nostr-native (if creator has Nostr pubkey):
["p", <npub-hex>, <relay>, "creator"] - Fallback (for non-Nostr identifiers):
["creator:id", <uri>](optional, e.g., ORCID, GND)["creator:name", <name>]["creator:type", <"Person"|"Organization">]["creator:honorificPrefix", <title>](optional, for persons)["creator:affiliation:id", <uri>](optional)["creator:affiliation:name", <name>](optional)["creator:affiliation:type", "Organization"](optional)
- Nostr-native (if creator has Nostr pubkey):
contributor(array of Person/Organization objects) → Same structure ascreatordateCreated→["dateCreated", <ISO8601Date>]datePublished→["datePublished", <ISO8601Date>]dateModified→["dateModified", <ISO8601Date>]publisher(array of Organization/Person objects) → Repeat for each:["publisher:id", <uri>](optional)["publisher:name", <name>]["publisher:type", <"Organization"|"Person">]
funder(array of Person/Organization/FundingScheme objects) → Repeat for each:["funder:id", <uri>](optional)["funder:name", <name>]["funder:type", <"Person"|"Organization"|"FundingScheme">]
Costs and Rights:
isAccessibleForFree→["isAccessibleForFree", <"true"|"false">]license(object) →["license:id", <license_uri>]
conditionsOfAccess(Concept object) →["conditionsOfAccess:id", <uri>]["conditionsOfAccess:prefLabel", <label>](optional)["conditionsOfAccess:type", "Concept"](optional)["conditionsOfAccess:inLanguage", <language>](optional)
Educational:
learningResourceType(array of Concept objects) → Repeat for each:["learningResourceType:id", <uri>]["learningResourceType:prefLabel", <label>](optional)["learningResourceType:type", "Concept"](optional)["learningResourceType:inLanguage", <language>](optional)
audience(array of Concept objects) → Repeat for each:["audience:id", <uri>]["audience:prefLabel", <label>](optional)["audience:type", "Concept"](optional)["audience:inLanguage", <language>](optional)
teaches(array of Concept objects) → Repeat for each:["teaches:id", <uri>]["teaches:prefLabel", <label>](optional)
assesses(array of Concept objects) → Repeat for each:["assesses:id", <uri>]["assesses:prefLabel", <label>](optional)
competencyRequired(array of Concept objects) → Repeat for each:["competencyRequired:id", <uri>]["competencyRequired:prefLabel", <label>](optional)
educationalLevel(array of Concept objects) → Repeat for each:["educationalLevel:id", <uri>]["educationalLevel:prefLabel", <label>](optional)["educationalLevel:type", "Concept"](optional)["educationalLevel:inLanguage", <language>](optional)
interactivityType(Concept object) →["interactivityType:id", <uri>]["interactivityType:prefLabel", <label>](optional)["interactivityType:type", "Concept"](optional)["interactivityType:inLanguage", <language>](optional)
Relations:
isBasedOn(array of objects) → Repeat for each:- Nostr-native (if referenced resource is addressable AMB event):
["a", "30142:<pubkey>:<d-value>", <relay>, "isBasedOn"] - Fallback (for external URIs):
["isBasedOn:id", <uri>]["isBasedOn:name", <name>](optional)
- Nostr-native (if referenced resource is addressable AMB event):
isPartOf(array of objects) → Repeat for each:- Nostr-native (if referenced resource is addressable AMB event):
["a", "30142:<pubkey>:<d-value>", <relay>, "isPartOf"] - Fallback (for external URIs):
["isPartOf:id", <uri>]["isPartOf:name", <name>](optional)["isPartOf:type", <type>](optional)
- Nostr-native (if referenced resource is addressable AMB event):
hasPart(array of objects) → Repeat for each:- Nostr-native (if referenced resource is addressable AMB event):
["a", "30142:<pubkey>:<d-value>", <relay>, "hasPart"] - Fallback (for external URIs):
["hasPart:id", <uri>]["hasPart:name", <name>](optional)["hasPart:type", <type>](optional)
- Nostr-native (if referenced resource is addressable AMB event):
Meta-Metadata:
mainEntityOfPage(array of WebPage objects) → Repeat for each:["mainEntityOfPage:id", <uri>]["mainEntityOfPage:type", "WebContent"]["mainEntityOfPage:provider:id", <uri>](optional)["mainEntityOfPage:provider:name", <name>](optional)["mainEntityOfPage:provider:type", <type>](optional)["mainEntityOfPage:dateCreated", <ISO8601Date>](optional)["mainEntityOfPage:dateModified", <ISO8601Date>](optional)
Technical:
duration→["duration", <ISO8601Duration>](format: PnYnMnDTnHnMnS)encoding(array of MediaObject objects) → Repeat for each:["encoding:type", "MediaObject"]["encoding:contentUrl", <url>](or useembedUrl)["encoding:embedUrl", <url>](or usecontentUrl)["encoding:encodingFormat", <format>](optional, IANA media type)["encoding:contentSize", <bytes>](optional)["encoding:sha256", <hash>](optional)["encoding:bitrate", <kbps>](optional)
caption(array of MediaObject objects) → Repeat for each:["caption:id", <uri>]["caption:type", "MediaObject"]["caption:encodingFormat", <format>](optional, IANA media type)["caption:inLanguage", <languageCode>](optional)
Conversion Guidelines
AMB → Nostr Event (JavaScript)
function flattenAMBToNostrTags(ambData) {
const tags = [];
function addTag(key, value) {
if (value !== null && value !== undefined && value !== '') {
tags.push([key, String(value)]);
}
}
function flattenObject(obj, prefix = '') {
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}:${key}` : key;
// Special case: id maps to 'd' tag
if (key === 'id' && !prefix) {
addTag('d', value);
continue;
}
// Special case: keywords map to 't' tags (Nostr-native)
if (key === 'keywords' && !prefix && Array.isArray(value)) {
value.forEach(keyword => {
addTag('t', keyword);
});
continue;
}
if (Array.isArray(value)) {
// Arrays: repeat the same key for each element
value.forEach(item => {
if (typeof item === 'object' && item !== null) {
flattenObject(item, fullKey);
} else {
addTag(fullKey, item);
}
});
} else if (typeof value === 'object' && value !== null) {
// Nested objects: flatten recursively
flattenObject(value, fullKey);
} else {
// Simple values
addTag(fullKey, value);
}
}
}
flattenObject(ambData);
return tags;
}
// Example usage:
const ambMetadata = {
id: "https://example.org/resource/123",
name: "Introduction to Physics",
creator: [
{
name: "Dr. Jane Smith",
type: "Person",
affiliation: {
name: "MIT",
type: "Organization"
}
}
],
keywords: ["Physics", "Education"]
};
const tags = flattenAMBToNostrTags(ambMetadata);
// Result (using Nostr-native conventions):
// [
// ["d", "https://example.org/resource/123"],
// ["name", "Introduction to Physics"],
// ["creator:name", "Dr. Jane Smith"],
// ["creator:type", "Person"],
// ["creator:affiliation:name", "MIT"],
// ["creator:affiliation:type", "Organization"],
// ["t", "Physics"],
// ["t", "Education"]
// ]
//
// Note: If creator had Nostr identity, would also include:
// ["p", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "wss://relay.example.com", "creator"]
Nostr Event → AMB Metadata (JavaScript)
function unflattenNostrTagsToAMB(tags) {
const result = {};
// Group tags by their key
const tagGroups = {};
const pTags = []; // Collect p tags separately
tags.forEach(([key, ...values]) => {
// Special case: 'd' tag maps to 'id'
if (key === 'd') {
result.id = values[0];
return;
}
// Special case: 'p' tags with 'creator' marker map to creator.id
if (key === 'p') {
const [pubkey, relay, marker] = values;
if (marker === 'creator' || marker === 'contributor') {
pTags.push({ pubkey, relay, marker });
}
return;
}
// Special case: 't' tags map to 'keywords'
if (key === 't') {
if (!result.keywords) {
result.keywords = [];
}
result.keywords.push(values[0]);
return;
}
if (!tagGroups[key]) {
tagGroups[key] = [];
}
tagGroups[key].push(values);
});
// Process p tags (creator/contributor)
if (pTags.length > 0) {
result.creator = pTags.map(({ pubkey }) => ({
id: pubkey
}));
}
// Process each tag group
for (const [key, valuesList] of Object.entries(tagGroups)) {
const parts = key.split(':');
// Multiple occurrences of same key = array
if (valuesList.length > 1) {
// Check if this is an object array (has nested properties)
const hasNestedProps = Object.keys(tagGroups).some(k =>
k.startsWith(key + ':')
);
if (hasNestedProps) {
// Array of objects - reconstruct each object
const arrayResult = [];
valuesList.forEach(values => {
const obj = {};
setNestedValue(obj, parts, values[0]);
arrayResult.push(obj);
});
setNestedValue(result, parts, arrayResult);
} else {
// Simple array
setNestedValue(result, parts, valuesList.map(v => v[0]));
}
} else {
// Single occurrence
setNestedValue(result, parts, valuesList[0][0]);
}
}
return result;
}
function setNestedValue(obj, parts, value) {
const lastPart = parts[parts.length - 1];
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[lastPart] = value;
}
// Example usage:
const nostrTags = [
["d", "https://example.org/resource/123"],
["name", "Introduction to Physics"],
["p", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "wss://relay.example.com", "creator"],
["creator:affiliation:name", "MIT"],
["creator:affiliation:type", "Organization"],
["t", "Physics"],
["t", "Education"]
];
const ambMetadata = unflattenNostrTagsToAMB(nostrTags);
// Result:
// {
// id: "https://example.org/resource/123",
// name: "Introduction to Physics",
// creator: {
// // Note: p tag is decoded to get the pubkey
// id: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
// affiliation: {
// name: "MIT",
// type: "Organization"
// }
// },
// keywords: ["Physics", "Education"]
// }
How to convert an AMB nostr-event to AMB metadata
To convert a Nostr event back to AMB metadata:
- Extract tags: Get the
tagsarray from the Nostr event - Group by prefix: Collect all tags that share the same prefix (before the first
:) - Reconstruct nesting: Use the
:delimiter to rebuild nested object structure - Handle arrays: Multiple tags with identical keys become array elements
- Preserve order: Array order is determined by tag order in the event
- Special mappings:
dtag →idproperty- Convert string booleans to actual booleans
- Parse ISO8601 dates if needed for validation
Reconstruction Algorithm
function reconstructAMB(nostrEvent) {
const amb = {
"@context": [
"https://w3id.org/kim/amb/context.jsonld",
{ "@language": "de" }
],
type: ["LearningResource"]
};
// Group tags by their base key (before first ':')
const tagsByBase = {};
nostrEvent.tags.forEach(tag => {
const [key, ...values] = tag;
const baseKey = key.split(':')[0];
if (!tagsByBase[baseKey]) {
tagsByBase[baseKey] = [];
}
tagsByBase[baseKey].push({ fullKey: key, values });
});
// Reconstruct each property
for (const [baseKey, tags] of Object.entries(tagsByBase)) {
if (baseKey === 'd') {
amb.id = tags[0].values[0];
continue;
}
// Check if this is a simple property or nested
const hasNested = tags.some(t => t.fullKey.includes(':'));
if (!hasNested) {
// Simple property
if (tags.length === 1) {
amb[baseKey] = tags[0].values[0];
} else {
// Multiple values = array
amb[baseKey] = tags.map(t => t.values[0]);
}
} else {
// Nested property - group by instance
amb[baseKey] = reconstructNestedProperty(tags);
}
}
return amb;
}
function reconstructNestedProperty(tags) {
// Group tags that belong to the same array instance
const instances = [];
let currentInstance = {};
let lastDepth = 0;
tags.forEach(tag => {
const parts = tag.fullKey.split(':');
const depth = parts.length;
// If depth decreased, we're starting a new instance
if (depth <= lastDepth && Object.keys(currentInstance).length > 0) {
instances.push(currentInstance);
currentInstance = {};
}
// Build nested structure
let current = currentInstance;
for (let i = 1; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = tag.values[0];
lastDepth = depth;
});
if (Object.keys(currentInstance).length > 0) {
instances.push(currentInstance);
}
return instances.length === 1 ? instances[0] : instances;
}
How to query for AMB nostr-events in supporting relays
TODO: This section will be completed once relay support is implemented.
Query capabilities should include:
- Filter by
kind:30142for all AMB events - Filter by author (
pubkey) - Filter by specific subjects using
#about:idtag - Filter by learning resource type using
#learningResourceType:idtag - Filter by educational level using
#educationalLevel:idtag - Full-text search in
nameanddescriptiontags
Examples
Example 1: Simple Educational Resource
{
"kind": 30142,
"id": "6ba638a3786cfce89af1702a36c59e0bd9206863afa5cb6b1299aaf0d9f48c84",
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"created_at": 1743419457,
"tags": [
["d", "oersi.org/resources/aHR0cHM6Ly9hdi50aWIuZXUvbWVkaWEvNjY5ODM=11"],
["type", "LearningResource"],
["name", "Pythagorean Theorem Video"],
["description", "An introductory video explaining the Pythagorean theorem"],
["about:id", "http://w3id.org/kim/schulfaecher/s1017"],
["about:prefLabel", "Mathematik"],
["about:type", "Concept"],
["about:inLanguage", "de"],
["about:id", "http://w3id.org/kim/schulfaecher/s1005"],
["about:prefLabel", "Deutsch"],
["about:type", "Concept"],
["about:inLanguage", "de"],
["learningResourceType:id", "http://w3id.org/openeduhub/vocabs/new_lrt/7a6e9608-2554-4981-95dc-47ab9ba924de"],
["learningResourceType:prefLabel", "Video"],
["learningResourceType:type", "Concept"],
["learningResourceType:inLanguage", "de"],
["t", "Pythagoras"],
["t", "Geometrie"],
["t", "Mathematik"],
["inLanguage", "de"],
["license:id", "https://creativecommons.org/licenses/by/4.0/"],
["isAccessibleForFree", "true"]
],
"content": "",
"sig": "6b0b78d56dea322864d35ea3b6d7e892d0e62bed96cd11ecb27d6c1d0b6d0cd68cd9ec82419946a5fb3c8d4a21eca88c9a5dad47a3b3e466ba18787224a613ef"
}
Example 2: Resource with Creator and Affiliation
{
"kind": 30142,
"id": "7ca749b4897efdc98fe2803dc60f68c9e1cd29764e8a55d1e9ef47a46ba4fe75",
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"created_at": 1743419500,
"tags": [
["d", "https://example.org/courses/physics-101"],
["type", "LearningResource"],
["type", "Course"],
["name", "Introduction to Physics"],
["description", "A comprehensive introduction to classical mechanics"],
["p", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "wss://relay.example.com", "creator"],
["creator:id", "https://orcid.org/0000-0001-2345-6789"],
["creator:name", "Dr. Jane Smith"],
["creator:type", "Person"],
["creator:honorificPrefix", "Dr."],
["creator:affiliation:id", "https://ror.org/example123"],
["creator:affiliation:name", "Massachusetts Institute of Technology"],
["creator:affiliation:type", "Organization"],
["p", "2c4b52e2a4f7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c", "wss://relay.example.com", "creator"],
["creator:id", "https://orcid.org/0000-0009-8765-4321"],
["creator:name", "Prof. John Doe"],
["creator:type", "Person"],
["creator:honorificPrefix", "Prof."],
["creator:affiliation:name", "Stanford University"],
["creator:affiliation:type", "Organization"],
["dateCreated", "2024-01-15"],
["datePublished", "2024-02-01"],
["about:id", "https://w3id.org/kim/hochschulfaechersystematik/n079"],
["about:prefLabel", "Informatik"],
["about:type", "Concept"],
["learningResourceType:id", "https://w3id.org/kim/hcrt/course"],
["learningResourceType:prefLabel", "Course"],
["audience:id", "http://purl.org/dcx/lrmi-vocabs/educationalAudienceRole/student"],
["audience:prefLabel", "student"],
["audience:type", "Concept"],
["educationalLevel:id", "https://w3id.org/kim/educationalLevel/level_06"],
["educationalLevel:prefLabel", "Bachelor or equivalent"],
["inLanguage", "en"],
["license:id", "https://creativecommons.org/licenses/by-sa/4.0/"],
["isAccessibleForFree", "true"]
],
"content": "",
"sig": "8d1c89f5da33ec9a2b456def78a90b1cd23e456f78a90b12cd34e567f89a012b34c56d78e9f0a12bc3d45e6f78901a23b45c67d89e0f1a2b3c4d5e6f7890123a"
}
Tools
Decoding Nostr Identifiers with njump
njump is a helpful tool for decoding and navigating Nostr identifiers, particularly useful when working with p tags (pubkeys) and a tags (addressable events):
-
For
ptag pubkeys: Paste the hex pubkey or npub identifier athttps://njump.me/<pubkey-or-npub>to view the profile and see all resources published by that user- Example:
https://njump.me/79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 - Or:
https://njump.me/npub1...
- Example:
-
For
atag addressable events: Usehttps://njump.me/naddr1...to view the specific event- Example:
https://njump.me/naddr1qqqgcjsyup9qqqg6cjvto navigate to an addressable resource
- Example:
Using nak to create AMB events
You can use nak to create AMB events with the flattened tag structure:
# Example 1: Simple resource with Nostr-native t tags
nak event \
--kind 30142 \
--tag d="oersi.org/resources/example123" \
--tag type="LearningResource" \
--tag name="Pythagorean Theorem Video" \
--tag description="An introductory video" \
--tag about:id="http://w3id.org/kim/schulfaecher/s1017" \
--tag about:prefLabel="Mathematik" \
--tag about:type="Concept" \
--tag about:inLanguage="de" \
--tag t="Pythagoras" \
--tag t="Geometrie" \
--tag inLanguage="de" \
--tag license:id="https://creativecommons.org/licenses/by/4.0/"
# Example 2: Resource with creator (with Nostr identity p tag)
nak event \
--kind 30142 \
--tag d="https://example.org/resource/456" \
--tag name="Physics Course" \
--tag p="79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798;wss://relay.example.com;creator" \
--tag creator:affiliation:name="MIT" \
--tag creator:affiliation:type="Organization"
# Example 3: Resource with relation to another AMB event (using a tag)
nak event \
--kind 30142 \
--tag d="https://example.org/resource/789" \
--tag name="Physics Module Part 1" \
--tag a="30142:79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798:physics-course;wss://relay.example.com;isPartOf"