Files
racket-wiki/static/cmap/model/concept-repository.js
T
2026-09-02 08:38:03 +02:00

218 lines
6.9 KiB
JavaScript

const CONCEPT_FIELDS = [
"label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function normalizeTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
const type = String(tag.type || "label").trim() || "label";
const value = String(tag.value || tag.name || "").trim();
return value ? { type, value } : null;
}).filter(Boolean);
}
/**
* Store the shared content and external references of one concept.
* A Concept never contains coordinates, styling or browser objects.
*/
export class Concept {
constructor(id, values = {}) {
if (!id) throw new TypeError("A concept id is required");
this.id = String(id);
this.label = String(values.label || "Concept");
this.synopsis = String(values.synopsis || "");
this.aspects = Array.isArray(values.aspects) ? values.aspects.map(String) : [];
this.tags = normalizeTags(values.tags);
this.descriptionPageSlug = values.descriptionPageSlug || null;
this.pageSlug = values.pageSlug || null;
this.cmapSlug = values.cmapSlug || null;
this.externalUrl = values.externalUrl || null;
this.imageSource = values.imageSource || "";
}
/** Change shared content without accepting placement or presentation fields. */
update(changes = {}) {
for (const field of CONCEPT_FIELDS) {
if (changes[field] === undefined) continue;
if (field === "aspects") {
this.aspects = Array.isArray(changes.aspects) ? changes.aspects.map(String) : [];
} else if (field === "tags") {
this.tags = normalizeTags(changes.tags);
} else {
this[field] = changes[field];
}
}
return this;
}
/** Return the stable JSON representation used at the wiki API boundary. */
toDocument() {
return {
id: this.id,
label: this.label,
synopsis: this.synopsis,
aspects: [...this.aspects],
tags: copy(this.tags),
descriptionPageSlug: this.descriptionPageSlug,
pageSlug: this.pageSlug,
cmapSlug: this.cmapSlug,
externalUrl: this.externalUrl,
imageSource: this.imageSource
};
}
}
/** Describe a semantic relation that exists independently of a diagram. */
export class ConceptRelation {
constructor(id, sourceConceptId, targetConceptId, values = {}) {
if (!id || !sourceConceptId || !targetConceptId) {
throw new TypeError("A concept relation requires an id and two concepts");
}
this.id = String(id);
this.sourceConceptId = String(sourceConceptId);
this.targetConceptId = String(targetConceptId);
this.label = String(values.label || "");
this.tags = normalizeTags(values.tags);
}
toDocument() {
return {
id: this.id,
sourceConceptId: this.sourceConceptId,
targetConceptId: this.targetConceptId,
label: this.label,
tags: copy(this.tags)
};
}
}
/** Describe semantic parent/child ownership between repository concepts. */
export class ConceptOwnership {
constructor(parentConceptId, childConceptId) {
if (!parentConceptId || !childConceptId || parentConceptId === childConceptId) {
throw new TypeError("Concept ownership requires two different concepts");
}
this.parentConceptId = String(parentConceptId);
this.childConceptId = String(childConceptId);
}
toDocument() {
return {
parentConceptId: this.parentConceptId,
childConceptId: this.childConceptId
};
}
}
/**
* Own all shared concepts and their semantic relationships.
* Placements refer to this repository by concept id.
*/
export class ConceptRepository {
constructor(concepts = [], relations = [], ownerships = []) {
this.conceptsById = new Map();
this.relationsById = new Map();
this.ownershipsByChildId = new Map();
for (const value of concepts) this.add(value);
for (const value of relations) this.addRelation(value);
for (const value of ownerships) this.addOwnership(value);
}
add(value) {
const concept = value instanceof Concept ? value : new Concept(value.id, value);
this.conceptsById.set(concept.id, concept);
return concept;
}
ensure(id, values = {}) {
const key = String(id);
const existing = this.conceptsById.get(key);
if (existing) {
existing.update(values);
return existing;
}
return this.add(new Concept(key, values));
}
concept(id) {
return this.conceptsById.get(String(id)) || null;
}
concepts() {
return [...this.conceptsById.values()];
}
remove(id) {
const key = String(id);
this.conceptsById.delete(key);
for (const [relationId, relation] of this.relationsById) {
if (relation.sourceConceptId === key || relation.targetConceptId === key) {
this.relationsById.delete(relationId);
}
}
this.ownershipsByChildId.delete(key);
for (const [childId, ownership] of this.ownershipsByChildId) {
if (ownership.parentConceptId === key) this.ownershipsByChildId.delete(childId);
}
}
addRelation(value) {
const relation = value instanceof ConceptRelation ? value : new ConceptRelation(
value.id, value.sourceConceptId, value.targetConceptId, value);
this.requireConcept(relation.sourceConceptId);
this.requireConcept(relation.targetConceptId);
this.relationsById.set(relation.id, relation);
return relation;
}
addOwnership(value) {
const ownership = value instanceof ConceptOwnership ? value : new ConceptOwnership(
value.parentConceptId, value.childConceptId);
this.requireConcept(ownership.parentConceptId);
this.requireConcept(ownership.childConceptId);
if (this.isOwnedBy(ownership.parentConceptId, ownership.childConceptId)) {
throw new Error("Concept ownership would create a cycle");
}
this.ownershipsByChildId.set(ownership.childConceptId, ownership);
return ownership;
}
isOwnedBy(conceptId, possibleAncestorId) {
let current = this.ownershipsByChildId.get(String(conceptId));
const seen = new Set();
while (current && !seen.has(current.childConceptId)) {
if (current.parentConceptId === String(possibleAncestorId)) return true;
seen.add(current.childConceptId);
current = this.ownershipsByChildId.get(current.parentConceptId);
}
return false;
}
requireConcept(id) {
const concept = this.concept(id);
if (!concept) throw new Error(`Unknown concept: ${id}`);
return concept;
}
toDocument() {
return this.concepts().map((concept) => concept.toDocument());
}
relationDocuments() {
return [...this.relationsById.values()].map((relation) => relation.toDocument());
}
ownershipDocuments() {
return [...this.ownershipsByChildId.values()].map((ownership) => ownership.toDocument());
}
}
export { CONCEPT_FIELDS, normalizeTags };