218 lines
6.9 KiB
JavaScript
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 };
|