refactoring van de cmap structuren bijna compleet
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user