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 };