Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhancement#461 reuse occurrences #464

Merged
merged 4 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/action/AsyncAnnotatorActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import { GetStoreState, ThunkDispatch } from "../util/Types";
import {
asyncActionFailure,
asyncActionRequest,
asyncActionSuccess,
asyncActionSuccessWithPayload,
} from "./SyncActions";
import Constants from "../util/Constants";
import Ajax, { params } from "../util/Ajax";
import Ajax, { content, params } from "../util/Ajax";
import JsonLdUtils from "../util/JsonLdUtils";
import Term, { CONTEXT as TERM_CONTEXT, TermData } from "../model/Term";
import { ErrorData } from "../model/ErrorInfo";
import {
isActionRequestPending,
loadTermByIri as loadTermByIriBase,
} from "./AsyncActions";
import TermOccurrence from "../model/TermOccurrence";

export function loadAllTerms(
vocabularyIri: IRI,
Expand Down Expand Up @@ -80,3 +82,16 @@ export function loadTermByIri(termIri: string) {
});
};
}

export function saveOccurrence(occurrence: TermOccurrence) {
const action = { type: ActionType.CREATE_TERM_OCCURRENCE };
return (dispatch: ThunkDispatch) => {
dispatch(asyncActionRequest(action, true));
return Ajax.put(
`${Constants.API_PREFIX}/occurrence`,
content(occurrence.toJsonLd())
)
.then(() => dispatch(asyncActionSuccess(action)))
.catch((error: ErrorData) => dispatch(asyncActionFailure(action, error)));
};
}
4 changes: 2 additions & 2 deletions src/action/AsyncTermActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export function removeOccurrence(
type: ActionType.REMOVE_TERM_OCCURRENCE,
};
return (dispatch: ThunkDispatch) => {
dispatch(asyncActionRequest(action));
dispatch(asyncActionRequest(action, true));
const OccurrenceIri = VocabularyUtils.create(occurrence.iri!);
return Ajax.delete(
Constants.API_PREFIX + "/occurrence/" + OccurrenceIri.fragment,
Expand Down Expand Up @@ -267,7 +267,7 @@ export function approveOccurrence(
type: ActionType.APPROVE_TERM_OCCURRENCE,
};
return (dispatch: ThunkDispatch) => {
dispatch(asyncActionRequest(action));
dispatch(asyncActionRequest(action, true));
const OccurrenceIri = VocabularyUtils.create(occurrence.iri!);
return Ajax.put(
Constants.API_PREFIX + "/occurrence/" + OccurrenceIri.fragment,
Expand Down
31 changes: 26 additions & 5 deletions src/component/annotator/AnnotationDomHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const AnnotationType = {
DEFINITION: VocabularyUtils.DEFINITION,
};

export const SELECTOR_CONTEXT_LENGTH = 32;

function toHtmlString(nodeList: NodeList): string {
let result = "";
for (let i = 0; i < nodeList.length; i++) {
Expand Down Expand Up @@ -71,11 +73,14 @@ const AnnotationDomHelper = {
removeAnnotation(annotation: DomHandlerNode, dom: DomHandlerNode[]): void {
// assuming annotation.type === "tag"
const elem = annotation as DomHandlerElement;
if (
Utils.sanitizeArray(elem.children).length === 1 &&
elem.children![0].type === "text"
) {
const newNode = this.createTextualNode(elem);
if (Utils.sanitizeArray(elem.children).length === 1) {
let newNode;
const child = elem.children![0];
if (child.type === "text") {
newNode = this.createTextualNode(elem);
} else {
newNode = child;
}
DomUtils.replaceElement(elem, newNode);
const elemInd = dom.indexOf(elem);
if (elemInd !== -1) {
Expand Down Expand Up @@ -156,8 +161,24 @@ const AnnotationDomHelper = {
},

generateSelector(node: DomHandlerNode): TextQuoteSelector {
let prefix = undefined;
let suffix = undefined;
if (node.previousSibling) {
prefix = HtmlDomUtils.getTextContent(node.previousSibling);
if (prefix.length > SELECTOR_CONTEXT_LENGTH) {
prefix = prefix.substring(prefix.length - SELECTOR_CONTEXT_LENGTH);
}
}
if (node.nextSibling) {
suffix = HtmlDomUtils.getTextContent(node.nextSibling);
if (suffix.length > SELECTOR_CONTEXT_LENGTH) {
suffix = suffix.substring(0, SELECTOR_CONTEXT_LENGTH);
}
}
return {
exactMatch: HtmlDomUtils.getTextContent(node),
prefix,
suffix,
types: [VocabularyUtils.TEXT_QUOTE_SELECTOR],
};
},
Expand Down
45 changes: 29 additions & 16 deletions src/component/annotator/Annotator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ import Vocabulary from "../../model/Vocabulary";
import IfVocabularyActionAuthorized from "../vocabulary/authorization/IfVocabularyActionAuthorized";
import AccessLevel, { hasAccess } from "../../model/acl/AccessLevel";
import { AssetData } from "../../model/Asset";
import { annotationIdToTermOccurrenceIri } from "./AnnotatorUtil";
import {
annotationIdToTermOccurrenceIri,
createTermOccurrence,
} from "./AnnotatorUtil";
import { saveOccurrence } from "../../action/AsyncAnnotatorActions";

interface AnnotatorProps extends HasI18n {
fileIri: IRI;
Expand All @@ -61,6 +65,7 @@ interface AnnotatorProps extends HasI18n {
updateTerm: (term: Term) => Promise<any>;
approveTermOccurrence: (occurrence: AssetData) => Promise<any>;
removeTermOccurrence: (occurrence: AssetData) => Promise<any>;
saveTermOccurrence: (occurrence: TermOccurrence) => Promise<any>;
}

interface AnnotatorState {
Expand Down Expand Up @@ -238,12 +243,12 @@ export class Annotator extends React.Component<AnnotatorProps, AnnotatorState> {
} else {
delete ann.attribs.resource;
}
delete ann.attribs.score;
let shouldUpdate = true;
if (term !== null) {
shouldUpdate = this.createOccurrence(annotationSpan, term);
shouldUpdate = this.createOccurrence(annotationSpan, ann, term);
this.approveOccurrence(annotationSpan);
}
delete ann.attribs.score;
if (shouldUpdate) {
this.updateInternalHtml(dom);
}
Expand All @@ -266,13 +271,15 @@ export class Annotator extends React.Component<AnnotatorProps, AnnotatorState> {

/**
* Creates occurrence based on the specified annotation and term.
* @param annotationNode Annotation
* @param annotationNode Representation of the annotation
* @param annotationElem The DOM element representing the annotation
* @param term Term whose occurrence this should be
* @private
* @return Whether the HTML content of the annotator should be updated
*/
private createOccurrence(
annotationNode: AnnotationSpanProps,
annotationElem: Element,
term: Term
): boolean {
if (annotationNode.typeof === AnnotationType.DEFINITION) {
Expand All @@ -286,9 +293,19 @@ export class Annotator extends React.Component<AnnotatorProps, AnnotatorState> {
) as Element,
});
return false;
} else {
if (!annotationNode.score) {
// Create occurrence only if we are not just approving an existing one
const to = createTermOccurrence(
term,
annotationElem,
this.props.fileIri
);
to.types = [VocabularyUtils.TERM_FILE_OCCURRENCE];
this.props.saveTermOccurrence(to);
}
return true;
}
return true;
// TODO Creating occurrences is not implemented, yet
}

public onSaveTermDefinition = (term: Term) => {
Expand All @@ -307,17 +324,11 @@ export class Annotator extends React.Component<AnnotatorProps, AnnotatorState> {

private setTermDefinitionSource(term: Term, annotationElement: Element) {
const dom = [...this.state.internalHtml];
const defSource = new TermOccurrence({
const defSource = createTermOccurrence(
term,
target: {
source: {
iri: this.props.fileIri.namespace + this.props.fileIri.fragment,
},
selectors: [AnnotationDomHelper.generateSelector(annotationElement)],
types: [VocabularyUtils.FILE_OCCURRENCE_TARGET],
},
types: [],
});
annotationElement,
this.props.fileIri
);
defSource.types = [VocabularyUtils.TERM_DEFINITION_SOURCE];
return this.props
.setTermDefinitionSource(defSource, term)
Expand Down Expand Up @@ -698,6 +709,8 @@ export default connect(
updateTerm: (term: Term) => dispatch(updateTerm(term)),
approveTermOccurrence: (occurrence: AssetData) =>
dispatch(approveOccurrence(occurrence, true)),
saveTermOccurrence: (occurrence: TermOccurrence) =>
dispatch(saveOccurrence(occurrence)),
removeTermOccurrence: (occurrence: AssetData) =>
dispatch(removeOccurrence(occurrence, true)),
};
Expand Down
28 changes: 27 additions & 1 deletion src/component/annotator/AnnotatorUtil.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { IRI, IRIImpl } from "../../util/VocabularyUtils";
import { Element } from "domhandler";
import VocabularyUtils, { IRI, IRIImpl } from "../../util/VocabularyUtils";
import JsonLdUtils from "../../util/JsonLdUtils";
import Term from "../../model/Term";
import TermOccurrence from "../../model/TermOccurrence";
import AnnotationDomHelper from "./AnnotationDomHelper";

const OCCURRENCE_SEPARATOR = "/occurrences";

Expand All @@ -12,3 +16,25 @@ export function annotationIdToTermOccurrenceIri(
}
return `${IRIImpl.toString(fileIri)}${OCCURRENCE_SEPARATOR}/${annotationId}`;
}

export function createTermOccurrence(
term: Term,
annotationElement: Element,
fileIri: IRI
) {
return new TermOccurrence({
iri: annotationIdToTermOccurrenceIri(
annotationElement.attribs.about,
fileIri
),
term,
target: {
source: {
iri: fileIri.namespace + fileIri.fragment,
},
selectors: [AnnotationDomHelper.generateSelector(annotationElement)],
types: [VocabularyUtils.FILE_OCCURRENCE_TARGET],
},
types: [],
});
}
14 changes: 13 additions & 1 deletion src/component/annotator/HtmlDomUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const BLOCK_ELEMENTS = [
"ul",
];

const PUNCTUATION_CHARS = [".", ",", "!", "?", ":", ";"];

function calculatePathLength(node: Node, ancestor: Node): number {
let parent = node.parentNode;
let length = 0;
Expand Down Expand Up @@ -137,14 +139,24 @@ const HtmlDomUtils = {
sel.modify("move", "backward", "word");
const text = endNode.textContent || "";
let index = endOffset;
while (text.charAt(index).trim().length !== 0 && index < text.length) {
while (
!this.isWhitespaceOrPunctuation(text.charAt(index)) &&
index < text.length
) {
index++;
}
sel.extend(endNode, index);
}
}
},

isWhitespaceOrPunctuation(character: string) {
return (
character.trim().length === 0 ||
PUNCTUATION_CHARS.indexOf(character) !== -1
);
},

/**
* Returns true if the range starts in one element and ends in another (except text nodes).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,5 +299,12 @@ describe("AnnotationDomHelper", () => {
(annotationSpan.children![0] as DataNode).data
);
});

it("uses previous and next siblings to provide selector prefix and suffix", () => {
const selector = sut.generateSelector(annotationSpan);
expect(selector).toBeDefined();
expect(selector.prefix).toEqual("First paragraph.\n ");
expect(selector.suffix).toEqual("\n ");
});
});
});
20 changes: 20 additions & 0 deletions src/component/annotator/__tests__/Annotator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe("Annotator", () => {
updateTerm(term: Term): Promise<any>;
approveTermOccurrence: (occurrence: AssetData) => Promise<any>;
removeTermOccurrence: (occurrence: AssetData) => Promise<any>;
saveTermOccurrence: (occurrence: TermOccurrence) => Promise<any>;
};
let user: User;
let file: File;
Expand All @@ -69,6 +70,7 @@ describe("Annotator", () => {
updateTerm: jest.fn().mockResolvedValue({}),
approveTermOccurrence: jest.fn().mockResolvedValue({}),
removeTermOccurrence: jest.fn().mockResolvedValue({}),
saveTermOccurrence: jest.fn().mockResolvedValue({}),
};
user = Generator.generateUser();
file = new File({
Expand Down Expand Up @@ -1085,5 +1087,23 @@ describe("Annotator", () => {
wrapper.instance().onAnnotationTermSelected(annotation, term);
expect(mockedCallbackProps.onUpdate).not.toHaveBeenCalled();
});

it("does not create term occurrence when user approves existing annotation", () => {
const wrapper = shallow<Annotator>(
<Annotator
fileIri={fileIri}
vocabularyIri={vocabularyIri}
{...mockedCallbackProps}
{...stateProps}
initialHtml={generalHtmlContent}
{...intlFunctions()}
/>
);
const term = Generator.generateTerm();
annotation.resource = term.iri;
annotation.score = "1.0";
wrapper.instance().onAnnotationTermSelected(annotation, term);
expect(mockedCallbackProps.saveTermOccurrence).not.toHaveBeenCalled();
});
});
});
24 changes: 23 additions & 1 deletion src/component/annotator/__tests__/AnnotatorUtil.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Element } from "domhandler";
import VocabularyUtils from "../../../util/VocabularyUtils";
import Generator from "../../../__tests__/environment/Generator";
import { annotationIdToTermOccurrenceIri } from "../AnnotatorUtil";
import {
annotationIdToTermOccurrenceIri,
createTermOccurrence,
} from "../AnnotatorUtil";

describe("annotationToTermOccurrenceIri", () => {
it("extracts term occurrence identifier from annotated file identifier and annotation about value", () => {
Expand All @@ -12,3 +16,21 @@ describe("annotationToTermOccurrenceIri", () => {
);
});
});

describe("createTermOccurrence", () => {
it("generates term occurrence with identifier based on file IRI and annotation element about attribute", () => {
const term = Generator.generateTerm();
const fileIri = VocabularyUtils.create(Generator.generateUri());
const localId = Generator.randomInt(100, 10000);
const annotatedElem = new Element(
"span",
{ about: `_:${localId}`, id: `id${localId}` },
[]
);

const result = createTermOccurrence(term, annotatedElem, fileIri);
expect(result.iri).toEqual(`${fileIri.toString()}/occurrences/${localId}`);
expect(result.term).toEqual(term);
expect(result.target.source).toEqual({ iri: fileIri.toString() });
});
});
Loading
Loading