mirror of
https://github.com/edufeed-org/nips.git
synced 2025-12-07 23:34:32 +00:00
Compare commits
2 commits
0d3e53d901
...
30aea8c65f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30aea8c65f | ||
|
|
19dddd09ce |
1 changed files with 605 additions and 107 deletions
712
edufeed.md
712
edufeed.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Abstract
|
||||
|
||||
This NIP defines how to handle the metadata profile ["Allgemeines Metadatenprofil für Bildugnsressourcen" (AMB)](https://dini-ag-kim.github.io/amb/latest/) in nostr:
|
||||
This NIP defines how to handle the metadata profile ["Allgemeines Metadatenprofil für Bildungsressourcen" (AMB)](https://dini-ag-kim.github.io/amb/latest/) in nostr:
|
||||
|
||||
- How to convert AMB metadata to an AMB nostr event
|
||||
- How to convert an AMB nostr-event to AMB metadata
|
||||
|
|
@ -11,87 +11,514 @@ This NIP defines how to handle the metadata profile ["Allgemeines Metadatenprofi
|
|||
## Event Kind
|
||||
|
||||
This NIP defines `kind:30142` as an AMB Metadata Event.
|
||||
This means this is a replacable event, that can be adressed using `kind:pubkey:d-tag`.
|
||||
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 is quite straightforward.
|
||||
For the attributes of the AMB we use tags.
|
||||
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.
|
||||
|
||||
For attributes expecting values of controlled vocabularies, we use this scheme:
|
||||
### Nostr-Native Conventions
|
||||
|
||||
`[<attribute_name>, <id> <prefLabel>, <languageCode>, <type>]`
|
||||
This NIP follows Nostr conventions where they align with AMB requirements:
|
||||
|
||||
For properties that expect arrays, each value is represented as a separate tag with the same key.
|
||||
- **`t` tags**: Used for keywords/topics (instead of flattened `keywords` tags)
|
||||
- **`p` tags**: Used for creator/contributor references when the creator has a Nostr identity (pubkey), with fallback to flattened structure for non-Nostr identifiers
|
||||
- **`a` tags**: Used for references to other addressable events on Nostr (including other AMB events), with fallback to flattened URIs for external resources
|
||||
|
||||
### Flattening Rules
|
||||
|
||||
1. **Simple properties**: Map directly to tags
|
||||
- AMB: `{"name": "Resource Title"}`
|
||||
- Nostr: `["name", "Resource Title"]`
|
||||
|
||||
2. **Nested objects**: Flatten using `:` delimiter
|
||||
- AMB: `{"creator": {"name": "John", "id": "123"}}`
|
||||
- Nostr: `["creator:name", "John"]`, `["creator:id", "123"]`
|
||||
|
||||
3. **Arrays**: Repeat the same flattened tag key (order is preserved by tag array position)
|
||||
- AMB: `{"keywords": ["Math", "Physics"]}`
|
||||
- Nostr: `["t", "Math"]`, `["t", "Physics"]` (Nostr-native `t` tag)
|
||||
|
||||
4. **Arrays of objects**: Repeat flattened keys for each object
|
||||
- AMB: `{"creator": [{"name": "John"}, {"name": "Jane"}]}`
|
||||
- Nostr: `["creator:name", "John"]`, `["creator:name", "Jane"]`
|
||||
|
||||
5. **Deep nesting**: Continue flattening with additional `:` delimiters
|
||||
- AMB: `{"creator": {"affiliation": {"name": "MIT"}}}`
|
||||
- Nostr: `["creator:affiliation:name", "MIT"]`
|
||||
|
||||
### Property Mappings
|
||||
|
||||
This is how we convert each property of the AMB:
|
||||
|
||||
General:
|
||||
**General:**
|
||||
|
||||
- `id`: `["d", <id>]` (we use nostr's `d`-tag here as identifier)
|
||||
- `type`: `["type", <type1>, <type2>, ...]`
|
||||
- `name`: `["name", <name>]`
|
||||
- `description`: `["description", <description>, <languageCode>]` (language optional, not specified in AMB)
|
||||
- `about`: `["about", <id>, <prefLabel>, <language>,]`
|
||||
- `keywords`: `["keywords", <keyword1>, <keyword2>, ...]`
|
||||
- `inLanguage`: `["inLanguage", <languageCode1>, <languageCode2>, ...]`
|
||||
- `image`: `["image", <image>]`
|
||||
- `trailer`: `["image", <contentUrl>, <type>, <encodingFormat>, <contentSize>, <sha256>, <embedUrl>, <bitRate>]`
|
||||
- `id` → `["d", <id>]` (special case: use Nostr's `d` tag 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 Nostr `t` tag)
|
||||
- `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)
|
||||
|
||||
Provenience:
|
||||
**Provenance:**
|
||||
|
||||
- `creator`: `["creator", <id>, <name>, <type>, <affiliationName>, <affiliationType>, <affiliationId>]`
|
||||
- `contributor`: `["contributor", <id>, <name>, <type>, <affiliationName>, <affiliationType>, <affiliationId>]`
|
||||
- `dateCreated`: `["dateCreated", <ISO8601Date>]`
|
||||
- `datePublished`: `["datePublished", <ISO8601Date>]`
|
||||
- `dateModified`: `["dateModified", <ISO8601Date>]`
|
||||
- `publisher`: `["publisher", <id>, <name>, <type>]`
|
||||
- `funder`: `["funder", <id>, <name>, <type>]`
|
||||
- `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)
|
||||
- `contributor` (array of Person/Organization objects) → Same structure as `creator`
|
||||
- `dateCreated` → `["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:
|
||||
**Costs and Rights:**
|
||||
|
||||
- `isAccessibleForFree`: `["isAccessibleForFree", <isAccessibleForFree>]`
|
||||
- `license`: `["license", <license_uri>, <license_shortName>]` (`license_shortName` optional, not specified in AMB)
|
||||
- `conditionsOfAccess`: `["conditionsOfAccess", <id>, <prefLabel>,, <language> ]`
|
||||
- `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:
|
||||
**Educational:**
|
||||
|
||||
- `learningResourceType`: `["learningResourceType", <id>, <label>, <language>]`
|
||||
- `audience`: `["audience", <id>, <prefLabel>, <language>]`
|
||||
- `teaches`: `["teaches", <id>, <prefLabel>, <language>]`
|
||||
- `assesses`: `["assesses", <id>, <prefLabel>, <language>]`
|
||||
- `competencyRequired`: `["competencyRequired", <id>, <prefLabel>, <language>]`
|
||||
- `educationalLevel`: `["educationalLevel", <id>, <prefLabel>, <language>]`
|
||||
- `interactivityType`: `["interactivityType", <id>, <prefLabel>, <language>]`
|
||||
- `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:
|
||||
**Relations:**
|
||||
|
||||
- `isBasedOn` `["isBasedOn", <id>, <name>]` (additional attributes unsupported right now)
|
||||
- `isPartOf`: `["isPartOf", <id>, <name>, <type>]`
|
||||
- `hasPart`: `["hasPart", <id>, <name>, <type>]`
|
||||
- `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)
|
||||
- `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)
|
||||
- `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)
|
||||
|
||||
Meta-Metadata:
|
||||
**Meta-Metadata:**
|
||||
|
||||
- unsupported right now
|
||||
- `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:
|
||||
**Technical:**
|
||||
|
||||
- `duration`: `["duration", <duration>]`
|
||||
- `encoding` (unsupported right now)
|
||||
- `caption`: `["caption", <id>, <encodingFormat>, <language>, <type>]`
|
||||
- `duration` → `["duration", <ISO8601Duration>]` (format: PnYnMnDTnHnMnS)
|
||||
- `encoding` (array of MediaObject objects) → Repeat for each:
|
||||
- `["encoding:type", "MediaObject"]`
|
||||
- `["encoding:contentUrl", <url>]` (or use `embedUrl`)
|
||||
- `["encoding:embedUrl", <url>]` (or use `contentUrl`)
|
||||
- `["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
|
||||
|
||||
## How to convert an AMB nostr-event to AMB metadata.
|
||||
### AMB → Nostr Event (JavaScript)
|
||||
|
||||
TODO
|
||||
```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;
|
||||
}
|
||||
|
||||
## How to query for AMB nostr-events in supporting relays.
|
||||
// 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"]
|
||||
};
|
||||
|
||||
TODO
|
||||
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)
|
||||
|
||||
```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:
|
||||
|
||||
1. **Extract tags**: Get the `tags` array from the Nostr event
|
||||
2. **Group by prefix**: Collect all tags that share the same prefix (before the first `:`)
|
||||
3. **Reconstruct nesting**: Use the `:` delimiter to rebuild nested object structure
|
||||
4. **Handle arrays**: Multiple tags with identical keys become array elements
|
||||
5. **Preserve order**: Array order is determined by tag order in the event
|
||||
6. **Special mappings**:
|
||||
- `d` tag → `id` property
|
||||
- Convert string booleans to actual booleans
|
||||
- Parse ISO8601 dates if needed for validation
|
||||
|
||||
### Reconstruction Algorithm
|
||||
|
||||
```javascript
|
||||
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:30142` for all AMB events
|
||||
- Filter by author (`pubkey`)
|
||||
- Filter by specific subjects using `#about:id` tag
|
||||
- Filter by learning resource type using `#learningResourceType:id` tag
|
||||
- Filter by educational level using `#educationalLevel:id` tag
|
||||
- Full-text search in `name` and `description` tags
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1
|
||||
### Example 1: Simple Educational Resource
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -100,67 +527,138 @@ TODO
|
|||
"pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"created_at": 1743419457,
|
||||
"tags": [
|
||||
[
|
||||
"name",
|
||||
"hello world"
|
||||
],
|
||||
[
|
||||
"description",
|
||||
"noch eine beschreibung",
|
||||
"de"
|
||||
],
|
||||
[
|
||||
"d",
|
||||
"oersi.org/resources/aHR0cHM6Ly9hdi50aWIuZXUvbWVkaWEvNjY5ODM\\=11"
|
||||
],
|
||||
[
|
||||
"about",
|
||||
"http://w3id.org/kim/schulfaecher/s1017",
|
||||
"Mathematik",
|
||||
"de"
|
||||
],
|
||||
[
|
||||
"about",
|
||||
"http://w3id.org/kim/schulfaecher/s1005",
|
||||
"Deutsch",
|
||||
"de"
|
||||
],
|
||||
[
|
||||
"learningResourceType",
|
||||
"http://w3id.org/openeduhub/vocabs/new_lrt/7a6e9608-2554-4981-95dc-47ab9ba924de",
|
||||
"Video",
|
||||
"de"
|
||||
],
|
||||
[
|
||||
"keywords",
|
||||
"Pythagoras",
|
||||
"Geometrie",
|
||||
"neu"
|
||||
],
|
||||
[
|
||||
"inLanguage",
|
||||
"de"
|
||||
]
|
||||
["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"
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
### Example 2: Resource with Creator and Affiliation
|
||||
|
||||
To create "Example 1" you could for example use [`nak`](https://github.com/fiatjaf/nak)
|
||||
|
||||
```bash
|
||||
nak event \
|
||||
--k 30142 \
|
||||
--t name="hello world" \
|
||||
--t description="noch eine beschreibung;de" \
|
||||
--t d="oersi.org/resources/aHR0cHM6Ly9hdi50aWIuZXUvbWVkaWEvNjY5ODM\=11" \
|
||||
--t about="http://w3id.org/kim/schulfaecher/s1017;Mathematik;de" \
|
||||
--t about="http://w3id.org/kim/schulfaecher/s1005;Deutsch;de" \
|
||||
--t learningResourceType="http://w3id.org/openeduhub/vocabs/new_lrt/7a6e9608-2554-4981-95dc-47ab9ba924de;Video;de" \
|
||||
--t keywords="Pythagoras;Geometrie;neu" \
|
||||
--tag inLanguage="de"
|
||||
```json
|
||||
{
|
||||
"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`](https://njump.me/) is a helpful tool for decoding and navigating Nostr identifiers, particularly useful when working with `p` tags (pubkeys) and `a` tags (addressable events):
|
||||
|
||||
- **For `p` tag pubkeys**: Paste the hex pubkey or npub identifier at `https://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...`
|
||||
|
||||
- **For `a` tag addressable events**: Use `https://njump.me/naddr1...` to view the specific event
|
||||
- Example: `https://njump.me/naddr1qqqgcjsyup9qqqg6cjv` to navigate to an addressable resource
|
||||
|
||||
### Using `nak` to create AMB events
|
||||
|
||||
You can use [`nak`](https://github.com/fiatjaf/nak) to create AMB events with the flattened tag structure:
|
||||
|
||||
```bash
|
||||
# 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"
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [AMB Specification](https://dini-ag-kim.github.io/amb/latest/)
|
||||
- [Nostr Protocol (NIP-01)](https://github.com/nostr-protocol/nips/blob/master/01.md)
|
||||
- [Addressable Events (NIP-33)](https://github.com/nostr-protocol/nips/blob/master/33.md)
|
||||
- [JSON-Flattening Concept](https://localizely.com/json-flattener/)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue