diff --git a/backend/aethel_db/views/detail.py b/backend/aethel_db/views/detail.py index ea20558..3744ed8 100644 --- a/backend/aethel_db/views/detail.py +++ b/backend/aethel_db/views/detail.py @@ -1,10 +1,10 @@ from enum import Enum -from dataclasses import dataclass, asdict +from dataclasses import dataclass from django.http import HttpRequest, JsonResponse from rest_framework import status from rest_framework.views import APIView -from spindle.utils import serialize_phrases_with_infix_notation +from spindle.utils import serialize_phrases from aethel.frontend import Sample from aethel_db.models import dataset @@ -16,7 +16,23 @@ class AethelDetailResult: name: str term: str subset: str - phrases: list[dict] + phrases: list[dict[str, str]] + + def serialize(self): + return { + "sentence": self.sentence, + "name": self.name, + "term": self.term, + "subset": self.subset, + "phrases": [ + { + "type": phrase["type"], + "displayType": phrase["display_type"], + "items": phrase["items"], + } + for phrase in self.phrases + ], + } class AethelDetailError(Enum): @@ -43,18 +59,17 @@ def parse_sample(self, sample: Sample) -> None: name=sample.name, term=str(sample.proof.term), subset=sample.subset, - phrases=serialize_phrases_with_infix_notation(sample.lexical_phrases), + phrases=serialize_phrases(sample.lexical_phrases), ) def json_response(self) -> JsonResponse: - result = asdict(self.result) if self.result else None status_code = ( AETHEL_DETAIL_STATUS_CODES[self.error] if self.error else status.HTTP_200_OK ) return JsonResponse( { - "result": result, + "result": self.result.serialize() if self.result else None, "error": self.error, }, status=status_code, diff --git a/backend/aethel_db/views/list.py b/backend/aethel_db/views/list.py index 8c4f538..0d477e8 100644 --- a/backend/aethel_db/views/list.py +++ b/backend/aethel_db/views/list.py @@ -1,4 +1,5 @@ from dataclasses import asdict, dataclass, field +from enum import Enum from django.http import HttpRequest, JsonResponse from rest_framework import status @@ -8,7 +9,7 @@ from aethel_db.models import dataset from aethel.frontend import LexicalPhrase -from aethel.mill.types import type_prefix, Type, type_repr +from aethel.mill.types import type_prefix, Type, type_repr, parse_prefix @dataclass @@ -22,6 +23,12 @@ class AethelListPhrase: items: list[AethelListLexicalItem] +class AethelListError(Enum): + INVALID_LIMIT_OR_SKIP = "INVALID_LIMIT_OR_SKIP" + WORD_TOO_SHORT = "WORD_TOO_SHORT" + CANNOT_PARSE_TYPE = "CANNOT_PARSE_TYPE" + + @dataclass class AethelListResult: phrase: AethelListPhrase @@ -48,7 +55,9 @@ class AethelListResponse: """ results: dict[tuple[str, str, str], AethelListResult] = field(default_factory=dict) - error: str | None = None + error: AethelListError | None = None + limit: int = 10 + skip: int = 0 def get_or_create_result( self, phrase: LexicalPhrase, type: Type @@ -82,11 +91,26 @@ def get_or_create_result( return self.results.setdefault(key, new_result) def json_response(self) -> JsonResponse: - results = [result.serialize() for result in self.results.values()] + if self.error: + return JsonResponse( + { + "results": [], + "totalCount": 0, + "error": self.error.value, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + total_count = len(self.results) + limit = min(self.limit, total_count) + skip = max(0, self.skip) + paginated = list(self.results.values())[skip : skip + limit] + serialized = [result.serialize() for result in paginated] return JsonResponse( { - "results": results, + "results": serialized, + "totalCount": total_count, "error": self.error, }, status=status.HTTP_200_OK, @@ -97,22 +121,40 @@ class AethelListView(APIView): def get(self, request: HttpRequest) -> JsonResponse: word_input = self.request.query_params.get("word", None) type_input = self.request.query_params.get("type", None) + limit = self.request.query_params.get("limit", 10) + skip = self.request.query_params.get("skip", 0) + + try: + limit = int(limit) + skip = int(skip) + except ValueError: + return AethelListResponse( + error=AethelListError.INVALID_LIMIT_OR_SKIP + ).json_response() # We only search for strings of 3 or more characters. if word_input is not None and len(word_input) < 3: - return AethelListResponse().json_response() + return AethelListResponse( + error=AethelListError.WORD_TOO_SHORT + ).json_response() + + response_object = AethelListResponse(skip=skip, limit=limit) + + try: + parsed_type = parse_prefix(type_input) if type_input else None + except Exception: + response_object.error = AethelListError.CANNOT_PARSE_TYPE + return response_object.json_response() - response_object = AethelListResponse() for sample in dataset.samples: for phrase in sample.lexical_phrases: word_match = word_input and match_word_with_phrase(phrase, word_input) - type_match = type_input and match_type_with_phrase(phrase, type_input) + type_match = parsed_type and match_type_with_phrase(phrase, parsed_type) if not (word_match or type_match): continue result = response_object.get_or_create_result( - # type_prefix returns a string representation of the type, with spaces between the elements. phrase=phrase, type=phrase.type, ) diff --git a/backend/aethel_db/views/sample_data.py b/backend/aethel_db/views/sample_data.py index b72ba9b..40619f5 100644 --- a/backend/aethel_db/views/sample_data.py +++ b/backend/aethel_db/views/sample_data.py @@ -1,4 +1,5 @@ from dataclasses import asdict, dataclass, field +from enum import Enum import json from django.http import HttpRequest, JsonResponse from rest_framework.views import APIView @@ -20,6 +21,11 @@ class AethelSampleDataPhrase: highlight: bool +class AethelSampleError(Enum): + INVALID_WORD = "INVALID_WORD" + NO_INPUT = "NO_INPUT" + + @dataclass class AethelSampleDataResult: name: str @@ -35,7 +41,7 @@ def serialize(self): @dataclass class AethelSampleDataResponse: results: list[AethelSampleDataResult] = field(default_factory=list) - error: str | None = None + error: AethelSampleError | None = None def get_or_create_result(self, sample: Sample) -> AethelSampleDataResult: """ @@ -55,9 +61,10 @@ def get_or_create_result(self, sample: Sample) -> AethelSampleDataResult: return new_result def json_response(self) -> JsonResponse: + serialized = [result.serialize() for result in self.results] return JsonResponse( { - "results": [r.serialize() for r in self.results], + "results": serialized, "error": self.error, }, status=status.HTTP_200_OK, @@ -71,27 +78,31 @@ def get(self, request: HttpRequest) -> JsonResponse: response_object = AethelSampleDataResponse() - # Not expected. - if not type_input or not word_input: + error = self.validate_input(type_input, word_input) + + if error: + response_object.error = error return response_object.json_response() word_input = json.loads(word_input) - assert dataset is not None + if dataset is None: + raise Exception("Dataset is not loaded.") + # parse_prefix expects a type string with spaces. - type_input = Type.parse_prefix(type_input, debug=True) + type_input = Type.parse_prefix(type_input) by_type = dataset.by_type(str(type_input)) # re-serialize type to match index by_word = dataset.by_words(word_input) by_name = {sample.name: sample for sample in by_type + by_word} # we have to do the intersection by name because Samples are not hashable - intersection = set(s.name for s in by_type).intersection(set(s.name for s in by_word)) + intersection = set(s.name for s in by_type).intersection( + set(s.name for s in by_word) + ) samples = [by_name[name] for name in intersection] for sample in samples: for phrase_index, phrase in enumerate(sample.lexical_phrases): - word_match = match_word_with_phrase_exact( - phrase, word_input - ) + word_match = match_word_with_phrase_exact(phrase, word_input) type_match = match_type_with_phrase(phrase, type_input) if not (word_match and type_match): @@ -102,3 +113,17 @@ def get(self, request: HttpRequest) -> JsonResponse: aethel_sample.highlight_phrase_at_index(index=phrase_index) return response_object.json_response() + + def validate_input( + self, + type_input: str | None, + word_input: str | None, + ) -> AethelSampleError | None: + try: + word_input = json.loads(word_input) + except json.JSONDecodeError: + return AethelSampleError.INVALID_WORD + + if not type_input or not word_input: + # Not expected. + return AethelSampleError.NO_INPUT diff --git a/backend/spindle/utils.py b/backend/spindle/utils.py index cfd5abe..159fe68 100644 --- a/backend/spindle/utils.py +++ b/backend/spindle/utils.py @@ -1,12 +1,20 @@ from aethel.frontend import LexicalPhrase +from aethel.mill.types import type_prefix, type_repr -def serialize_phrases_with_infix_notation( +def serialize_phrases( lexical_phrases: list[LexicalPhrase], ) -> list[dict[str, str]]: """ - Serializes a list of LexicalPhrases in a human-readable infix notation that is already available in Æthel in Type.__repr__. + Serializes a list of LexicalPhrases in a human-readable infix notation that is already available in Æthel in Type.__repr__ or type_repr(). This is used to display the types in the frontend. - The standard JSON serialization of phrases uses a prefix notation for types, which is good for data-exchange purposes (easier parsing) but less ideal for human consumption. + The standard JSON serialization of phrases uses a prefix notation for types, which is good for data-exchange purposes (easier parsing) but less ideal for human consumption. This notation is used to query. """ - return [dict(phrase.json(), type=repr(phrase.type)) for phrase in lexical_phrases] + return [ + dict( + phrase.json(), + display_type=type_repr(phrase.type), + type=type_prefix(phrase.type), + ) + for phrase in lexical_phrases + ] diff --git a/backend/spindle/views.py b/backend/spindle/views.py index 5581dfe..1922f88 100644 --- a/backend/spindle/views.py +++ b/backend/spindle/views.py @@ -22,7 +22,7 @@ serial_proof_to_json, ) -from spindle.utils import serialize_phrases_with_infix_notation +from spindle.utils import serialize_phrases http = urllib3.PoolManager() @@ -31,7 +31,6 @@ Mode = Literal["latex", "pdf", "overleaf", "term-table", "proof"] - class SpindleErrorSource(Enum): INPUT = "input" SPINDLE = "spindle" @@ -52,6 +51,11 @@ class SpindleResponse: def json_response(self) -> JsonResponse: # TODO: set HTTP error code when error is not None + + # Convert display_type to displayType for frontend. + for phrase in self.lexical_phrases: + phrase["displayType"] = phrase.pop("display_type") + return JsonResponse( { "latex": self.latex, @@ -193,7 +197,7 @@ def overleaf_redirect(self, latex: str) -> JsonResponse: def term_table_response(self, parsed: ParserResponse) -> JsonResponse: """Return the term and the lexical phrases as a JSON response.""" - phrases = serialize_phrases_with_infix_notation(parsed.lexical_phrases) + phrases = serialize_phrases(parsed.lexical_phrases) return SpindleResponse( term=str(parsed.proof.term), lexical_phrases=phrases, @@ -210,10 +214,10 @@ def spindle_status(): try: r = http.request( method="GET", - url=settings.SPINDLE_URL + '/status/', + url=settings.SPINDLE_URL + "/status/", headers={"Content-Type": "application/json"}, timeout=1, - retries=False + retries=False, ) return r.status < 400 except Exception: diff --git a/compose.yaml b/compose.yaml index 3c8961c..87bc15a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ version: "3" services: nginx: container_name: nginx - restart: always + restart: unless-stopped image: nginx:latest volumes: - ./nginx.conf:/etc/nginx/nginx.conf @@ -26,7 +26,7 @@ services: ng-prod: image: pp-ng-prod container_name: pp-ng - restart: always + restart: unless-stopped profiles: ["prod"] build: context: ./frontend @@ -37,11 +37,16 @@ services: container_name: pp-dj build: backend profiles: ["prod"] - restart: always + restart: unless-stopped environment: - DJANGO_DEBUG=0 - DJANGO_SECRET_KEY=$DJANGO_SECRET_KEY - command: gunicorn -b 0.0.0.0:8000 wsgi:application --pythonpath=parseport --capture-output --access-logfile /logs/access_log --error-logfile /logs/error_log + command: > + gunicorn -b 0.0.0.0:8000 wsgi:application + --pythonpath=parseport + --capture-output + --access-logfile /logs/access_log + --error-logfile /logs/error_log expose: - "8000" volumes: @@ -65,8 +70,13 @@ services: build: context: ../spindle-server profiles: ["prod"] - restart: always - command: gunicorn -w 1 -b 0.0.0.0:32768 'app:create_app()' --timeout 600 --capture-output --access-logfile /logs/access_log --error-logfile /logs/error_log + restart: unless-stopped + command: > + gunicorn -w 1 -b 0.0.0.0:32768 'app:create_app()' + --timeout 600 + --capture-output + --access-logfile /logs/access_log + --error-logfile /logs/error_log expose: - "32768" volumes: @@ -77,6 +87,8 @@ services: image: pp-spindle-dev profiles: ["dev"] command: flask run --host 0.0.0.0 --port 32768 + ports: + - "32768:32768" latex-prod: &latex-prod image: pp-latex-prod @@ -84,8 +96,12 @@ services: build: context: ../latex-service profiles: ["prod"] - restart: always - command: gunicorn -w 1 -b 0.0.0.0:32769 'app:app' --capture-output --access-logfile /logs/access_log --error-logfile /logs/error_log + restart: unless-stopped + command: > + gunicorn -w 1 -b 0.0.0.0:32769 'app:app' + --capture-output + --access-logfile /logs/access_log + --error-logfile /logs/error_log expose: - "32769" volumes: @@ -96,6 +112,8 @@ services: image: pp-latex-dev profiles: ["dev"] command: flask run --host 0.0.0.0 --port 32769 + ports: + - "32769:32769" networks: parseport: diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..e69de29 diff --git a/frontend/package.json b/frontend/package.json index 9bf43cc..d69e1ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "parseport", - "version": "0.0.0", + "version": "0.3.0", "scripts": { "start": "yarn serve", "build": "ng build", diff --git a/frontend/src/app/aethel/aethel.component.html b/frontend/src/app/aethel/aethel.component.html index 2773116..45f7903 100644 --- a/frontend/src/app/aethel/aethel.component.html +++ b/frontend/src/app/aethel/aethel.component.html @@ -1,112 +1,149 @@ -

Æthel

- -

- Welcome to Æthel. Search for a word or lemma in the input field below to - start. -

+
+
+
+

Æthel

+

+ Æthel is a semantic compositionality dataset for Dutch. It + consists of a lexicon of supertags for about 900k words in + context, together with validated derivations for some 70k sample + sentences, associating them with programs (lambda terms) for + their meaning composition. Æthel’s types and derivations are + obtained by means of an extraction algorithm applied to the + syntactic analyses of LASSY Small, the gold standard corpus of + written Dutch. +

+

+ More info can be found under + About and + References. The + notations are explained under + Notation. +

+

+ You can use the interface below to search for a word or lemma. + Once you have retrieved a sample, you can inspect it to isolate + and look into a word, a type, or a word-type pair. You can then + look for other words that occur with the same type, or other + occurrences of the same word-type pair. +

-@if (status$ | async) { -
-
- -
- - -
- @if (form.touched && form.invalid) { -

- Please enter at least three characters. + @if (status$ | async) { + +

+ +
+ + +
+ @if (form.touched && form.invalid) { +

+ Please enter at least three characters. +

+ } +
+ + } @else { +

+ The Æthel dataset is temporarily unavailable.

+ } @if (submitted | async) { + + + + + + Word + + + + Lemma + + + + Type + + + + Samples + + + + + + + + + + {{ combineWord(row) }} + {{ combineLemma(row) }} + + {{ row.sampleCount }} + + + + + + No results found. + + + + + + + }
- -} -@else { -

- The Æthel dataset is temporarily unavailable. -

-} - -@if (submitted | async) { -
- - - - - - Word - - - - Lemma - - - - Type - - - - Samples - - - - - - - - - - {{ combineWord(row) }} - {{ combineLemma(row) }} - - {{ row.sampleCount }} - - - - - No results found. - - - - - - +
-} diff --git a/frontend/src/app/aethel/aethel.component.spec.ts b/frontend/src/app/aethel/aethel.component.spec.ts index 6ebfb7c..89f401c 100644 --- a/frontend/src/app/aethel/aethel.component.spec.ts +++ b/frontend/src/app/aethel/aethel.component.spec.ts @@ -1,6 +1,9 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { AethelComponent } from "./aethel.component"; -import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { + HttpClientTestingModule, + HttpTestingController, +} from "@angular/common/http/testing"; import { CommonModule } from "@angular/common"; import { ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; @@ -23,7 +26,7 @@ describe("AethelComponent", () => { HttpClientTestingModule, ReactiveFormsModule, CommonModule, - RouterModule.forRoot(routes) + RouterModule.forRoot(routes), ], }).compileComponents(); httpController = TestBed.inject(HttpTestingController); @@ -42,28 +45,30 @@ describe("AethelComponent", () => { it("should not request data if there is no query parameter", () => { route.queryParams = of({}); component.ngOnInit(); - expect(component.form.controls.aethelInput.value).toBe(""); + expect(component.form.controls.word.value).toBe(""); httpController.expectNone("/api/aethel"); }); - it("should insert data into the form when there is a 'word' query parameter", () => { - route.queryParams = of({ word: "test" }); + it("should insert data into the form when there are query parameters", () => { + route.queryParams = of({ word: "test", skip: 420, limit: 666 }); component.ngOnInit(); - expect(component.form.controls.aethelInput.value).toBe("test"); + expect(component.form.controls.word.value).toBe("test"); + expect(component.form.controls.skip.value).toBe(420); + expect(component.form.controls.limit.value).toBe(666); }); it("should pass query param data to the API service", () => { - apiService.input$.subscribe(input => { + apiService.input$.subscribe((input) => { expect(input.word).toBe("test3"); - }); + }); route.queryParams = of({ word: "test3" }); component.ngOnInit(); }); it("should react to form submissions", () => { const navigatorSpy = spyOn(router, "navigateByUrl"); - component.form.controls.aethelInput.setValue("test-two"); - component.submit(); - expect(navigatorSpy).toHaveBeenCalledWith("/?word=test-two"); + component.form.controls.word.setValue("test-two"); + component.submitWord(); + expect(navigatorSpy).toHaveBeenCalledWith("/?word=test-two&limit=10&skip=0"); }); }); diff --git a/frontend/src/app/aethel/aethel.component.ts b/frontend/src/app/aethel/aethel.component.ts index ea1e7dd..06f3c58 100644 --- a/frontend/src/app/aethel/aethel.component.ts +++ b/frontend/src/app/aethel/aethel.component.ts @@ -1,7 +1,7 @@ import { Component, DestroyRef, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { AethelListResult } from "../shared/types"; +import { AethelInput, AethelListResult } from "../shared/types"; import { AethelApiService } from "../shared/services/aethel-api.service"; import { Subject, distinctUntilChanged, map } from "rxjs"; import { @@ -11,6 +11,7 @@ import { import { ActivatedRoute, Router } from "@angular/router"; import { isNonNull } from "../shared/operators/IsNonNull"; import { StatusService } from "../shared/services/status.service"; +import { TablePageEvent } from "primeng/table"; @Component({ selector: "pp-aethel", @@ -19,11 +20,22 @@ import { StatusService } from "../shared/services/status.service"; }) export class AethelComponent implements OnInit { public form = new FormGroup({ - aethelInput: new FormControl("", { + word: new FormControl("", { + nonNullable: true, validators: [Validators.minLength(3)], }), + type: new FormControl("", { + nonNullable: true, + }), + limit: new FormControl(10, { + nonNullable: true, + }), + skip: new FormControl(0, { + nonNullable: true, + }), }); public rows: AethelListResult[] = []; + public totalRowCount = 0; public loading$ = this.apiService.loading$; public submitted = this.apiService.output$.pipe(map(() => true)); @@ -32,7 +44,7 @@ export class AethelComponent implements OnInit { chevronDown: faChevronDown, }; - status$ = new Subject(); + public status$ = new Subject(); constructor( private apiService: AethelApiService, @@ -59,6 +71,7 @@ export class AethelComponent implements OnInit { // TODO: handle error! } this.rows = this.addUniqueKeys(response.results); + this.totalRowCount = response.totalCount; }); // Whenever the query parameter changes, we run a new query. @@ -69,15 +82,32 @@ export class AethelComponent implements OnInit { takeUntilDestroyed(this.destroyRef), ) .subscribe((query) => { - const word = query["word"]; + const word = query["word"] ?? ""; const type = query["type"]; - if (word) { - this.form.controls.aethelInput.setValue(word); - } - this.apiService.input$.next({ word, type }); + const skip = query["skip"] ? parseInt(query["skip"], 10) : 0; + const limit = query["limit"] + ? parseInt(query["limit"], 10) + : 10; + + this.form.patchValue({ word, type, skip, limit }); + this.apiService.input$.next({ word, type, skip, limit }); }); } + public changePage(page: TablePageEvent): void { + if ( + page.first === this.form.controls.skip.value && + page.rows === this.form.controls.limit.value + ) { + return; + } + this.form.patchValue({ + skip: page.first, + limit: page.rows, + }); + this.prepareQuery(); + } + public combineWord(row: AethelListResult): string { return row.phrase.items.map((item) => item.word).join(" "); } @@ -86,28 +116,50 @@ export class AethelComponent implements OnInit { return row.phrase.items.map((item) => item.lemma).join(" "); } - public submit(): void { + public submitWord(): void { + // When the user submits a new word, go back to the first page. + this.form.controls.skip.setValue(0); + this.form.controls.type.setValue(""); + this.prepareQuery(); + } + + private prepareQuery(): void { this.form.markAllAsTouched(); - this.form.controls.aethelInput.updateValueAndValidity(); - const query = this.form.controls.aethelInput.value; - if (!query) { - return; - } - this.updateUrl(query); + this.form.controls.word.updateValueAndValidity(); + const queryInput: AethelInput = this.form.getRawValue(); + this.updateUrl(queryInput); } - private updateUrl(query: string): void { - // This does not actually refresh the page because it just adds parameters to the current route. - // It just updates the URL in the browser, triggering a new query. + private updateUrl(queryInput: AethelInput): void { + // This does not actually refresh the page because it just adds + // parameters to the current route. This triggers a new query. const url = this.router .createUrlTree([], { relativeTo: this.route, - queryParams: { word: query }, + queryParams: this.formatQueryParams(queryInput), }) .toString(); this.router.navigateByUrl(url); } + private formatQueryParams(queryInput: AethelInput): AethelInput { + const queryParams: AethelInput = {}; + + // Only include word and type in the URL if they are not null, + // undefined or an empty string. + if (queryInput.word && queryInput.word !== "") { + queryParams.word = queryInput.word; + } + if (queryInput.type && queryInput.type !== "") { + queryParams.type = queryInput.type; + } + + queryParams.limit = queryInput.limit; + queryParams.skip = queryInput.skip; + + return queryParams; + } + /** * Adds unique keys to the items in the array. This is needed for the table to keep track of the data and automatically collapse rows when the data changes. */ diff --git a/frontend/src/app/aethel/sample-details/sample-data.component.html b/frontend/src/app/aethel/sample-details/sample-data.component.html index 523f980..b278042 100644 --- a/frontend/src/app/aethel/sample-details/sample-data.component.html +++ b/frontend/src/app/aethel/sample-details/sample-data.component.html @@ -1,11 +1,9 @@ - @if (loading) { -

Loading samples...

- } @else { -
    - @for (sample of samples; track $index) { +
      + @for (sample of visibleSamples(); track $index) {
    • -

      +

      {{ $index + 1 }}

      +

      @for (phrase of sample.phrases; track phrase.index) { {{ phrase.display }} @@ -14,7 +12,7 @@

      Go to sample @@ -22,5 +20,16 @@
    • }
    + @if (loading) { +

    Loading samples...

    + } + @else if (!allSamplesLoaded()) { + } diff --git a/frontend/src/app/aethel/sample-details/sample-data.component.scss b/frontend/src/app/aethel/sample-details/sample-data.component.scss index d98290d..5a257c3 100644 --- a/frontend/src/app/aethel/sample-details/sample-data.component.scss +++ b/frontend/src/app/aethel/sample-details/sample-data.component.scss @@ -1,9 +1,12 @@ .sample-item { display: flex; - justify-content: space-between; align-items: center; width: 100%; + + a { + margin-left: auto; + } } .sample-item:not(:last-child) { diff --git a/frontend/src/app/aethel/sample-details/sample-data.component.spec.ts b/frontend/src/app/aethel/sample-details/sample-data.component.spec.ts index 1449392..9902daf 100644 --- a/frontend/src/app/aethel/sample-details/sample-data.component.spec.ts +++ b/frontend/src/app/aethel/sample-details/sample-data.component.spec.ts @@ -62,7 +62,7 @@ describe("SampleDataComponent", () => { expect(req.request.method).toBe("GET"); req.flush(mockResponse); - expect(component.samples).toEqual(mockResponse.results); + expect(component['samples']()).toEqual(mockResponse.results); expect(component.loading).toBeFalse(); }); diff --git a/frontend/src/app/aethel/sample-details/sample-data.component.ts b/frontend/src/app/aethel/sample-details/sample-data.component.ts index 428d7c2..1a16469 100644 --- a/frontend/src/app/aethel/sample-details/sample-data.component.ts +++ b/frontend/src/app/aethel/sample-details/sample-data.component.ts @@ -1,5 +1,5 @@ import { HttpClient } from "@angular/common/http"; -import { Component, DestroyRef, Input, OnInit } from "@angular/core"; +import { Component, computed, DestroyRef, Input, OnInit, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Params } from "@angular/router"; import { @@ -19,9 +19,23 @@ import { environment } from "src/environments/environment"; export class SampleDataComponent implements OnInit { @Input({ required: true }) aethelResult: AethelListResult | null = null; - public samples: AethelSampleDataResult[] = []; + public limit = signal(10); public loading = false; + // Hides the "Load More" button when all samples have been loaded. + public allSamplesLoaded = computed(() => { + if (!this.aethelResult) { + return false; + } + return this.limit() >= this.samples().length; + }); + + public visibleSamples = computed(() => { + return this.samples().slice(0, this.limit()); + }) + + private samples = signal([]); + constructor( private destroyRef: DestroyRef, private http: HttpClient, @@ -43,11 +57,15 @@ export class SampleDataComponent implements OnInit { ) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data) => { - this.samples = data.results; + this.samples.update(existing => [...existing, ...data.results]); this.loading = false; }); } + public loadMoreSamples(): void { + this.limit.update(limit => limit + 10); + } + public getSampleURL(sampleName: string): string[] { return ["sample", sampleName.replace(".xml", "")]; } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index e7b2bf0..185884d 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -11,35 +11,27 @@ import { MenuComponent } from "./menu/menu.component"; import { HomeComponent } from "./home/home.component"; import { SpindleComponent } from "./spindle/spindle.component"; import { ReactiveFormsModule } from "@angular/forms"; -import { AlertComponent } from "./shared/components/alert/alert.component"; -import { AlertContainerDirective } from "./shared/directives/alert-container.directive"; -import { AlertService } from "./shared/services/alert.service"; -import { ExportButtonComponent } from "./spindle/export-button/export-button.component"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { TableModule } from "primeng/table"; import { AethelComponent } from "./aethel/aethel.component"; import { SpindleAboutComponent } from "./spindle/spindle-about/spindle-about.component"; import { SpindleNotationComponent } from "./spindle/spindle-notation/spindle-notation.component"; import { ReferencesComponent } from "./references/references.component"; -import { ProofPipe } from "./shared/pipes/proof.pipe"; import { SampleComponent } from "./sample/sample.component"; import { SampleDataComponent } from "./aethel/sample-details/sample-data.component"; +import { SharedModule } from "./shared/shared.module"; @NgModule({ declarations: [ - AlertComponent, - AlertContainerDirective, AppComponent, FooterComponent, HomeComponent, MenuComponent, SpindleComponent, - ExportButtonComponent, SpindleAboutComponent, SpindleNotationComponent, ReferencesComponent, AethelComponent, - ProofPipe, SampleComponent, SampleDataComponent, ], @@ -51,8 +43,8 @@ import { SampleDataComponent } from "./aethel/sample-details/sample-data.compone ReactiveFormsModule, FontAwesomeModule, TableModule, + SharedModule, ], - providers: [AlertService], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/frontend/src/app/home/home.component.html b/frontend/src/app/home/home.component.html index 5d48da2..f500acd 100644 --- a/frontend/src/app/home/home.component.html +++ b/frontend/src/app/home/home.component.html @@ -1,2 +1,12 @@ -

    Welcome to ParsePort

    -

    Here are some introductory words.

    \ No newline at end of file +
    +
    +
    +

    ParsePort

    + +

    + Welcome to ParsePort, a growing collection of NLP-related + parsers and parsed corpora developed at Utrecht University. +

    +
    +
    +
    diff --git a/frontend/src/app/menu/menu.component.html b/frontend/src/app/menu/menu.component.html index 744420c..cbe8c76 100644 --- a/frontend/src/app/menu/menu.component.html +++ b/frontend/src/app/menu/menu.component.html @@ -37,6 +37,7 @@ class="navbar-item" [routerLink]="['/spindle']" routerLinkActive="is-active" + [routerLinkActiveOptions]="{ exact: true }" i18n > Spindle @@ -53,7 +54,7 @@
    @@ -62,7 +63,16 @@ + Notation + + + diff --git a/frontend/src/app/references/references.component.html b/frontend/src/app/references/references.component.html index ee5c318..f61319b 100644 --- a/frontend/src/app/references/references.component.html +++ b/frontend/src/app/references/references.component.html @@ -1,27 +1,117 @@
    -

    References

    +
    +
    +

    References

    +
      +
    • + Kogkalidis, K. (2023). + + Dependency as Modality, Parsing as Permutation . Netherlands Graduate School of Linguistics + (E. W. Beth Dissertation Prize, FoLLI). +
    • -
      -

      Spindle

      -

      - Kogkalidis, K. (2023). Dependency as Modality, Parsing as Permutation. Netherlands Graduate School of Linguistics. -

      +
    • + Kogkalidis, K., Moortgat, M., & Moot, R. (2023). + SPINDLE: Spinning Raw Text into Lambda Terms with Graph + Attention. + In D. Croce & L. Soldaini (Eds.), + Proceedings of the 17th Conference of the European + Chapter of the Association for Computational + Linguistics: System Demonstrations + (pp. 128–135). Association for Computational Linguistics. +
    • -

      - Kogkalidis, K., Moortgat, M., & Moot, R. (2023). SPINDLE: Spinning Raw Text into Lambda Terms with Graph Attention. In D. Croce & L. Soldaini (Eds.), Proceedings of the 17th Conference of the European Chapter of the Association for Computational Linguistics: System Demonstrations (pp. 128–135). Association for Computational Linguistics. -

      +
    • + Kogkalidis, K., Moortgat, M., & Moot, R. (2020). + Neural Proof Nets. + In R. Fernández & T. Linzen (Eds.), + Proceedings of the 24th Conference on Computational + Natural Language Learning + (pp. 26–40). Association for Computational Linguistics. +
    • -

      - Kogkalidis, K., Moortgat, M., & Moot, R. (2020). Neural Proof Nets. In R. Fernández & T. Linzen (Eds.), Proceedings of the 24th Conference on Computational Natural Language Learning (pp. 26–40). Association for Computational Linguistics. -

      +
    • + Kogkalidis, K., Moortgat, M., & Moot, R. (2020). + ÆTHEL: Automatically Extracted Typelogical Derivations + for Dutch. + In N. Calzolari, F. Béchet, P. Blache, K. Choukri, C. Cieri, + T. Declerck, S. Goggi, H. Isahara, B. Maegaard, J. Mariani, + H. Mazo, A. Moreno, J. Odijk, & S. Piperidis (Eds.), + Proceedings of the Twelfth Language Resources and + Evaluation Conference + (pp. 5257–5266). European Language Resources Association. +
    • -

      - Kogkalidis, K., Moortgat, M., & Moot, R. (2020). ÆTHEL: Automatically Extracted Typelogical Derivations for Dutch. In N. Calzolari, F. Béchet, P. Blache, K. Choukri, C. Cieri, T. Declerck, S. Goggi, H. Isahara, B. Maegaard, J. Mariani, H. Mazo, A. Moreno, J. Odijk, & S. Piperidis (Eds.), Proceedings of the Twelfth Language Resources and Evaluation Conference (pp. 5257–5266). European Language Resources Association. -

      - -

      - Kogkalidis, K., & Moortgat, M. (2023). Geometry-Aware Supertagging with Heterogeneous Dynamic Convolutions. In E. Breitholtz, S. Lappin, S. Loaiciga, N. Ilinykh, & S. Dobnik (Eds.), Proceedings of the 2023 CLASP Conference on Learning with Small Data (LSD) (pp. 107–119). Association for Computational Linguistics. -

      -
      +
    • + Kogkalidis, K., & Moortgat, M. (2023). + Geometry-Aware Supertagging with Heterogeneous Dynamic + Convolutions. + In E. Breitholtz, S. Lappin, S. Loaiciga, N. Ilinykh, & + S. Dobnik (Eds.), + Proceedings of the 2023 CLASP Conference on Learning + with Small Data (LSD) + (pp. 107–119). Association for Computational Linguistics. +
    • +
    • + Van Noord, G. et al. (2013). + Large Scale Syntactic Annotation of Written Dutch: + Lassy. In P. Spyns & J. Odijk (Eds.), + Essential Speech and Language Technology for Dutch, + Results by the STEVIN-programme, Theory and Applications + of Natural Language Processing + (pp. 147–164). Springer. +
    • +
    +

    Source code

    +
      +
    • + Æthel (GitHub): backend code for the definition and representation of + proof-derivations, and their extraction from Lassy- and + Alpino-style parses. +
    • +
    • + Spindle (GitHub): backend code for the neural proof search engine. +
    • +
    • + ParsePort (GitHub): frontend and backend code for this web application. +
    • +
    +
    +
    diff --git a/frontend/src/app/routes.ts b/frontend/src/app/routes.ts index 10ff8b5..cca54d4 100644 --- a/frontend/src/app/routes.ts +++ b/frontend/src/app/routes.ts @@ -53,7 +53,7 @@ const routes: Routes = [ component: HomeComponent, }, { - path: 'refs', + path: 'references', component: ReferencesComponent, }, { diff --git a/frontend/src/app/sample/sample.component.html b/frontend/src/app/sample/sample.component.html index 10af818..c5d5aea 100644 --- a/frontend/src/app/sample/sample.component.html +++ b/frontend/src/app/sample/sample.component.html @@ -1,57 +1,92 @@ - +
    + +
    +
    + @if (sampleResult$ | async; as sample;) { +

    Sample sentence

    +

    {{ sample.sentence }}

    -@if (sampleResult$ | async; as sample;) { -
    -
    -

    Sample sentence

    -

    {{ sample.sentence }}

    -
    -
    +
    +
    +

    Term analysis

    +
    +
    +
    -
    -
    -

    Term analysis

    -
    -
    -
    + + + + + + + + + + + + + + + + + +
    #PhraseTypeSearch in Æthel
    + c{{ i }} + + {{ item.word }} + + + + + @if (showButtons(phrase.items)) { +
    + + + +
    + } +
    - - - - - - - - - - - - - - - - - -
    #PhraseTypeSearch in Æthel
    - c{{ i }} - - {{ item.word }} - - - - @if (showButtons(phrase.items)) { - - - - } -
    -} + + } +
    +
    +
    diff --git a/frontend/src/app/sample/sample.component.scss b/frontend/src/app/sample/sample.component.scss index 57e2824..81997bd 100644 --- a/frontend/src/app/sample/sample.component.scss +++ b/frontend/src/app/sample/sample.component.scss @@ -1,3 +1,15 @@ -.search-button:not(:last-child) { - margin-right: .5rem; +td { + vertical-align: middle; + + .button-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + + button { + flex: 1; + } + } } diff --git a/frontend/src/app/sample/sample.component.spec.ts b/frontend/src/app/sample/sample.component.spec.ts index e21d797..1f14f91 100644 --- a/frontend/src/app/sample/sample.component.spec.ts +++ b/frontend/src/app/sample/sample.component.spec.ts @@ -10,14 +10,16 @@ import { import { AethelDetail, AethelDetailError, - LexicalPhrase, + AethelDetailPhrase, } from "../shared/types"; import { By } from "@angular/platform-browser"; -import { ProofPipe } from "../shared/pipes/proof.pipe"; +import { SharedModule } from "../shared/shared.module"; +import { CommonModule } from "@angular/common"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -const fakePhrase: LexicalPhrase = { +const fakePhrase: AethelDetailPhrase = { type: "cheese->tosti", + displayType: "cheese -> tosti", items: [ { word: "cheeses", @@ -38,10 +40,12 @@ describe("SampleComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [SampleComponent, ProofPipe], + declarations: [SampleComponent], imports: [ HttpClientTestingModule, + SharedModule, FontAwesomeModule, + CommonModule, RouterModule.forRoot(routes), ], providers: [ @@ -71,7 +75,7 @@ describe("SampleComponent", () => { it("should construct a valid route for word search", () => { const spy = spyOn(router, "navigate"); - component.searchAethel(fakePhrase, 'word'); + component.searchAethel(fakePhrase, "word"); expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { queryParams: { word: "cheeses" }, }); @@ -79,7 +83,7 @@ describe("SampleComponent", () => { it("should construct a valid route for type search", () => { const spy = spyOn(router, "navigate"); - component.searchAethel(fakePhrase, 'type'); + component.searchAethel(fakePhrase, "type"); expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { queryParams: { type: "cheese->tosti" }, }); @@ -87,7 +91,7 @@ describe("SampleComponent", () => { it("should construct a valid route for word and type search", () => { const spy = spyOn(router, "navigate"); - component.searchAethel(fakePhrase, 'word-and-type'); + component.searchAethel(fakePhrase, "word-and-type"); expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { queryParams: { word: "cheeses", type: "cheese->tosti" }, }); diff --git a/frontend/src/app/sample/sample.component.ts b/frontend/src/app/sample/sample.component.ts index 7330533..1454056 100644 --- a/frontend/src/app/sample/sample.component.ts +++ b/frontend/src/app/sample/sample.component.ts @@ -1,20 +1,24 @@ -import { Component } from "@angular/core"; +import { Component, DestroyRef, OnInit } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; import { AethelApiService } from "../shared/services/aethel-api.service"; import { map } from "rxjs"; -import { AethelMode, LexicalPhrase } from "../shared/types"; +import { AethelDetailPhrase, AethelMode, ExportMode } from "../shared/types"; import { isNonNull } from "../shared/operators/IsNonNull"; import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; import { Location } from "@angular/common"; +import { SpindleApiService } from "../shared/services/spindle-api.service"; +import { TextOutput } from "../shared/components/spindle-export/export-text/export-text.component"; +import { ErrorHandlerService } from "../shared/services/error-handler.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @Component({ selector: "pp-sample", templateUrl: "./sample.component.html", styleUrl: "./sample.component.scss", }) -export class SampleComponent { +export class SampleComponent implements OnInit { private sampleName = this.route.snapshot.params["sampleName"]; - private sample$ = this.apiService.sampleResult$(this.sampleName); + private sample$ = this.aethelService.sampleResult$(this.sampleName); public sampleResult$ = this.sample$.pipe( map((response) => response?.result), isNonNull(), @@ -24,19 +28,69 @@ export class SampleComponent { arrowLeft: faArrowLeft, }; + public loading$ = this.spindleService.loading$; + + public textOutput: TextOutput | null = null; + constructor( + private destroyRef: DestroyRef, private route: ActivatedRoute, - private apiService: AethelApiService, + private spindleService: SpindleApiService, + private aethelService: AethelApiService, + private errorHandler: ErrorHandlerService, private router: Router, private location: Location, ) {} - public searchAethel(phrase: LexicalPhrase, mode: AethelMode): void { + ngOnInit(): void { + this.spindleService.output$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((response) => { + // HTTP error + if (!response) { + return; + } + if (response.error) { + this.errorHandler.handleSpindleError(response.error); + return; + } + if (response.latex) { + this.textOutput = { + extension: "tex", + text: response.latex, + }; + } + if (response.redirect) { + // Opens a new tab. + window.open(response.redirect, "_blank"); + } + if (response.pdf) { + const base64 = response.pdf; + this.spindleService.downloadAsFile(base64, "pdf"); + } + if (response.proof) { + this.textOutput = { + extension: "json", + // The additional arguments are for pretty-printing. + text: JSON.stringify(response.proof, null, 2), + }; + } + }); + } + + public searchAethel(phrase: AethelDetailPhrase, mode: AethelMode): void { const queryParams = this.formatQueryParams(phrase, mode); this.router.navigate(["/aethel"], { queryParams }); } - public showButtons(items: LexicalPhrase["items"]): boolean { + public exportResult(mode: ExportMode, sentence: string): void { + this.spindleService.input$.next({ + mode, + sentence, + }); + } + + public showButtons(items: AethelDetailPhrase["items"]): boolean { // Buttons are hidden if the phrase contains too few characters. const combined = items.map((item) => item.word).join(" "); return combined.length > 2; @@ -46,7 +100,10 @@ export class SampleComponent { this.location.back(); } - private formatQueryParams(phrase: LexicalPhrase, mode: AethelMode): Params { + private formatQueryParams( + phrase: AethelDetailPhrase, + mode: AethelMode, + ): Params { const queryParams: Params = {}; if (mode === "word" || mode === "word-and-type") { queryParams["word"] = phrase.items diff --git a/frontend/src/app/spindle/export-button/export-button.component.html b/frontend/src/app/shared/components/spindle-export/export-button/export-button.component.html similarity index 91% rename from frontend/src/app/spindle/export-button/export-button.component.html rename to frontend/src/app/shared/components/spindle-export/export-button/export-button.component.html index f758eaf..5a476f6 100644 --- a/frontend/src/app/spindle/export-button/export-button.component.html +++ b/frontend/src/app/shared/components/spindle-export/export-button/export-button.component.html @@ -2,7 +2,7 @@ [class]="getButtonClass(mode)" [class.is-loading]="isLoading" [class.with-text-below]="textBelow !== null" - (click)="export.emit(mode ?? 'term-table')" + (click)="exportResult.emit(mode ?? 'term-table')" type="button" > diff --git a/frontend/src/app/spindle/export-button/export-button.component.scss b/frontend/src/app/shared/components/spindle-export/export-button/export-button.component.scss similarity index 100% rename from frontend/src/app/spindle/export-button/export-button.component.scss rename to frontend/src/app/shared/components/spindle-export/export-button/export-button.component.scss diff --git a/frontend/src/app/spindle/export-button/export-button.component.spec.ts b/frontend/src/app/shared/components/spindle-export/export-button/export-button.component.spec.ts similarity index 100% rename from frontend/src/app/spindle/export-button/export-button.component.spec.ts rename to frontend/src/app/shared/components/spindle-export/export-button/export-button.component.spec.ts diff --git a/frontend/src/app/spindle/export-button/export-button.component.ts b/frontend/src/app/shared/components/spindle-export/export-button/export-button.component.ts similarity index 67% rename from frontend/src/app/spindle/export-button/export-button.component.ts rename to frontend/src/app/shared/components/spindle-export/export-button/export-button.component.ts index 2096a76..39b2e57 100644 --- a/frontend/src/app/spindle/export-button/export-button.component.ts +++ b/frontend/src/app/shared/components/spindle-export/export-button/export-button.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { SpindleMode } from "src/app/shared/types"; +import { ExportMode } from "src/app/shared/types"; @Component({ selector: "pp-export-button", @@ -7,13 +7,13 @@ import { SpindleMode } from "src/app/shared/types"; styleUrls: ["./export-button.component.scss"], }) export class ExportButtonComponent { - @Output() export = new EventEmitter(); - @Input() mode: SpindleMode | null = null; + @Output() exportResult = new EventEmitter(); + @Input() mode: ExportMode | null = null; @Input() isLoading = false; @Input() buttonText = $localize`Export`; @Input() textBelow: string | null = null; - public getButtonClass(mode: SpindleMode | null): string { + public getButtonClass(mode: ExportMode | null): string { return mode ? `button ${mode}-button` : "button"; } } diff --git a/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.html b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.html new file mode 100644 index 0000000..b6192ce --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.html @@ -0,0 +1,32 @@ +
    + + + + +
    diff --git a/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.scss b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.scss new file mode 100644 index 0000000..f90f874 --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.scss @@ -0,0 +1,3 @@ +.button-container { + margin-top: 15px; +} diff --git a/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.spec.ts b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.spec.ts new file mode 100644 index 0000000..3ad9c85 --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ExportButtonsComponent } from "./export-buttons.component"; +import { SharedModule } from "../../../shared.module"; + +describe("ExportButtonsComponent", () => { + let component: ExportButtonsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SharedModule], + }).compileComponents(); + + fixture = TestBed.createComponent(ExportButtonsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.ts b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.ts new file mode 100644 index 0000000..6ede4ab --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-buttons/export-buttons.component.ts @@ -0,0 +1,16 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { ExportMode } from "../../../types"; +import { Observable } from "rxjs"; + +@Component({ + selector: "pp-export-buttons", + templateUrl: "./export-buttons.component.html", + styleUrl: "./export-buttons.component.scss", +}) +export class ExportButtonsComponent { + @Input({ required: true }) + public loading$: Observable | null = null; + + @Output() + public exportResult = new EventEmitter(); +} diff --git a/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.html b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.html new file mode 100644 index 0000000..c3c29e6 --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.html @@ -0,0 +1,30 @@ +
    +
    + + +
    +
    
    +
    diff --git a/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.scss b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.scss new file mode 100644 index 0000000..9b1c5a1 --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.scss @@ -0,0 +1,17 @@ +.output-container { + position: relative; + + .output-button-container { + position: absolute; + top: 5px; + right: 5px; + display: flex; + flex-direction: column; + gap: 5px; + } + + pre { + max-width: 100%; + overflow: auto; + } +} diff --git a/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.spec.ts b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.spec.ts new file mode 100644 index 0000000..11e46d0 --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ExportTextComponent } from "./export-text.component"; +import { SharedModule } from "../../../shared.module"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; + +describe("ExportTextComponent", () => { + let component: ExportTextComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, SharedModule], + }).compileComponents(); + + fixture = TestBed.createComponent(ExportTextComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.ts b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.ts new file mode 100644 index 0000000..6f69981 --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/export-text/export-text.component.ts @@ -0,0 +1,51 @@ +import { Component, Input } from "@angular/core"; +import { AlertService } from "../../../services/alert.service"; +import { AlertType } from "../../alert/alert.component"; +import { SpindleApiService } from "../../../services/spindle-api.service"; +import { faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; + +export interface TextOutput { + extension: "json" | "tex"; + text: string; +} + +@Component({ + selector: "pp-export-text", + templateUrl: "./export-text.component.html", + styleUrl: "./export-text.component.scss", +}) +export class ExportTextComponent { + @Input({ required: true }) textOutput: TextOutput | null = null; + + public faDownload = faDownload; + public faCopy = faCopy; + + constructor( + private alertService: AlertService, + private apiService: SpindleApiService, + ) {} + + public downloadAsFile( + textData: string, + extension: "tex" | "json" | "pdf", + ): void { + this.apiService.downloadAsFile(textData, extension); + } + + public copyToClipboard(text: string): void { + navigator.clipboard + .writeText(text) + .then(() => { + this.alertService.alert$.next({ + message: $localize`Copied to clipboard.`, + type: AlertType.SUCCESS, + }); + }) + .catch(() => { + this.alertService.alert$.next({ + message: $localize`Failed to copy to clipboard.`, + type: AlertType.DANGER, + }); + }); + } +} diff --git a/frontend/src/app/shared/components/spindle-export/spindle-export.component.html b/frontend/src/app/shared/components/spindle-export/spindle-export.component.html new file mode 100644 index 0000000..1960f1a --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/spindle-export.component.html @@ -0,0 +1,5 @@ +
    +

    Export results

    + + +
    diff --git a/frontend/src/app/shared/components/spindle-export/spindle-export.component.scss b/frontend/src/app/shared/components/spindle-export/spindle-export.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/shared/components/spindle-export/spindle-export.component.spec.ts b/frontend/src/app/shared/components/spindle-export/spindle-export.component.spec.ts new file mode 100644 index 0000000..b17ad52 --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/spindle-export.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { SpindleExportComponent } from "./spindle-export.component"; +import { SharedModule } from "../../shared.module"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; + +describe("SpindleExportComponent", () => { + let component: SpindleExportComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SharedModule, HttpClientTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(SpindleExportComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/components/spindle-export/spindle-export.component.ts b/frontend/src/app/shared/components/spindle-export/spindle-export.component.ts new file mode 100644 index 0000000..887a45c --- /dev/null +++ b/frontend/src/app/shared/components/spindle-export/spindle-export.component.ts @@ -0,0 +1,15 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { TextOutput } from "./export-text/export-text.component"; +import { ExportMode } from "../../types"; +import { Observable } from "rxjs"; + +@Component({ + selector: "pp-spindle-export", + templateUrl: "./spindle-export.component.html", + styleUrl: "./spindle-export.component.scss", +}) +export class SpindleExportComponent { + @Input({ required: true }) textOutput: TextOutput | null = null; + @Input({ required: true }) loading$: Observable | null = null; + @Output() exportResult = new EventEmitter(); +} diff --git a/frontend/src/app/shared/services/aethel-api.service.ts b/frontend/src/app/shared/services/aethel-api.service.ts index 0e2a81d..a939cfa 100644 --- a/frontend/src/app/shared/services/aethel-api.service.ts +++ b/frontend/src/app/shared/services/aethel-api.service.ts @@ -44,9 +44,15 @@ export class AethelApiService if (input.type) { params = params.set("type", input.type); } + if (input.limit) { + params = params.set("limit", input.limit.toString()); + } + if (input.skip) { + params = params.set("skip", input.skip.toString()); + } return this.http - .get(`${environment.apiUrl}aethel/`, { + .get(`${environment.apiUrl}aethel`, { headers, params, }) diff --git a/frontend/src/app/shared/services/spindle-api.service.ts b/frontend/src/app/shared/services/spindle-api.service.ts index 764ffb3..b1b9141 100644 --- a/frontend/src/app/shared/services/spindle-api.service.ts +++ b/frontend/src/app/shared/services/spindle-api.service.ts @@ -14,7 +14,7 @@ import { } from "rxjs"; import { environment } from "src/environments/environment"; import { ErrorHandlerService } from "./error-handler.service"; -import { SpindleInput, SpindleMode, SpindleReturn } from "../types"; +import { SpindleInput, ExportMode, SpindleReturn } from "../types"; import { ParsePortDataService } from "./ParsePortDataService"; @Injectable({ @@ -22,7 +22,7 @@ import { ParsePortDataService } from "./ParsePortDataService"; }) export class SpindleApiService implements - ParsePortDataService + ParsePortDataService { input$ = new Subject(); @@ -58,7 +58,7 @@ export class SpindleApiService share(), ); - loading$: Observable = merge( + loading$: Observable = merge( this.throttledInput$.pipe(map((input) => input.mode)), this.output$.pipe(map(() => null)), ); @@ -66,5 +66,41 @@ export class SpindleApiService constructor( private http: HttpClient, private errorHandler: ErrorHandlerService, - ) {} + ) { } + + public downloadAsFile( + textData: string, + extension: "tex" | "json" | "pdf", + ): void { + const fileName = "spindleParseResult." + extension; + let url = ""; + // PDF data (base64) does not need to be converted to a blob. + if (extension === "pdf") { + url = `data:application/pdf;base64,${textData}`; + } else { + const blob = new Blob([textData], { + type: `application/${extension}`, + }); + url = window.URL.createObjectURL(blob); + } + + this.downloadFile(fileName, url); + + // Revoke the object URL after downloading. + if (extension !== "pdf") { + this.revokeObjectURL(url); + } + } + + private downloadFile(fileName: string, url: string): void { + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + link.remove(); + } + + private revokeObjectURL(url: string): void { + window.URL.revokeObjectURL(url); + } } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts new file mode 100644 index 0000000..3e8dffc --- /dev/null +++ b/frontend/src/app/shared/shared.module.ts @@ -0,0 +1,45 @@ +import { NgModule } from "@angular/core"; +import { AlertComponent } from "./components/alert/alert.component"; +import { AlertContainerDirective } from "./directives/alert-container.directive"; +import { ExportButtonComponent } from "./components/spindle-export/export-button/export-button.component"; +import { ExportButtonsComponent } from "./components/spindle-export/export-buttons/export-buttons.component"; +import { ProofPipe } from "./pipes/proof.pipe"; +import { AethelApiService } from "./services/aethel-api.service"; +import { AlertService } from "./services/alert.service"; +import { ConfigService } from "./services/config.service"; +import { ErrorHandlerService } from "./services/error-handler.service"; +import { SpindleApiService } from "./services/spindle-api.service"; +import { StatusService } from "./services/status.service"; +import { CommonModule } from "@angular/common"; +import { ExportTextComponent } from "./components/spindle-export/export-text/export-text.component"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { SpindleExportComponent } from "./components/spindle-export/spindle-export.component"; + +@NgModule({ + declarations: [ + AlertComponent, + AlertContainerDirective, + ExportButtonComponent, + ExportButtonsComponent, + ProofPipe, + ExportTextComponent, + SpindleExportComponent, + ], + imports: [CommonModule, FontAwesomeModule], + providers: [ + AethelApiService, + AlertService, + ConfigService, + ErrorHandlerService, + SpindleApiService, + StatusService, + ], + exports: [ + AlertComponent, + AlertContainerDirective, + ProofPipe, + SpindleExportComponent, + ExportButtonComponent, + ], +}) +export class SharedModule {} diff --git a/frontend/src/app/shared/types.ts b/frontend/src/app/shared/types.ts index c547915..52f5104 100644 --- a/frontend/src/app/shared/types.ts +++ b/frontend/src/app/shared/types.ts @@ -1,8 +1,8 @@ -export type SpindleMode = "latex" | "pdf" | "overleaf" | "term-table" | "proof"; +export type ExportMode = "latex" | "pdf" | "overleaf" | "term-table" | "proof"; export interface SpindleInput { sentence: string; - mode: SpindleMode; + mode: ExportMode; } // This should be the same as the one in the backend. @@ -20,9 +20,10 @@ type LexicalItem = { lemma: string; }; -export type LexicalPhrase = { +export type AethelDetailPhrase = { items: LexicalItem[]; type: string; + displayType: string; }; // Should correspond with SpindleResponse dataclass in backend. @@ -32,7 +33,7 @@ export interface SpindleReturn { pdf: string | null; redirect: string | null; term: string | null; - lexical_phrases: LexicalPhrase[]; + lexical_phrases: AethelDetailPhrase[]; proof: Record | null; } @@ -41,6 +42,8 @@ export type AethelMode = "word" | "type" | "word-and-type"; export interface AethelInput { word?: string; type?: string; + limit?: number; + skip?: number; } export interface AethelListLexicalItem { @@ -49,11 +52,11 @@ export interface AethelListLexicalItem { } export interface AethelListPhrase { - items: AethelListLexicalItem[] + items: AethelListLexicalItem[]; } export interface AethelListResult { - phrase: AethelListPhrase + phrase: AethelListPhrase; type: string; displayType: string; sampleCount: number; @@ -61,6 +64,7 @@ export interface AethelListResult { export interface AethelList { results: AethelListResult[]; + totalCount: number; error: string | null; } @@ -76,7 +80,7 @@ export interface AethelDetailResult { name: string; term: string; subset: string; - phrases: LexicalPhrase[]; + phrases: AethelDetailPhrase[]; } export interface AethelDetail { diff --git a/frontend/src/app/spindle/spindle-about/spindle-about.component.html b/frontend/src/app/spindle/spindle-about/spindle-about.component.html index 2d2d9d9..a8719dd 100644 --- a/frontend/src/app/spindle/spindle-about/spindle-about.component.html +++ b/frontend/src/app/spindle/spindle-about/spindle-about.component.html @@ -1,73 +1,125 @@
    -

    About

    - Spindle (“Spindle Parses Into Dependency Enhanced Lambda Expressions”) is a neurosymbolic parser for Dutch. +
    +
    +

    About Spindle and Æthel

    +

    + Spindle (“Spindle Parses Into Dependency Enhanced Lambda + Expressions”) is a neurosymbolic parser for Dutch. Spindle is + trained on Æthel (“Automatically Extracted Theorems from Lassy”), a + large dataset of machine-verified derivations extracted from LASSY + Small, the gold standard treebank of written Dutch. +

    -

    What am I seeing?

    -

    - Spindle outputs are abstract syntactic derivations in the implication-only fragment of intuitionistic linear logic (LP), extended with a family of residuated pairs of modal operators (LP◇,□). -

    +

    What am I seeing?

    +

    + Spindle outputs and Æthel samples are abstract syntactic derivations + in the implication-only fragment of intuitionistic linear logic + (LP), extended with a family of residuated pairs of modal operators + (LP◇,□). +

    -

    - This might sound scary, but it's really not that bad. - In practice, you give Spindle a Dutch sentence, and it tells you how it has been composed, explicating: -

    -
      -
    1. The (possibly higher-order) function-argument relations between constituent words and phrases
    2. -
    3. The dependencies between a "head" an the elemens dependent on it, which can be either complements or adjuncts.
    4. -
    +

    + This might sound scary, but it's really not that bad. In practice, + you give Spindle a Dutch sentence, and it tells you how it has been + composed, explicating: +

    +
      +
    1. + The (possibly higher-order) function-argument relations between + constituent words and phrases +
    2. +
    3. + The dependencies between a "head" an the elemens dependent on + it, which can be either complements or adjuncts. +
    4. +
    -

    - Which phrases can combine and how is fully determined by the formulas (aka types) assigned to the words that make them up. Logical implications in these formulas denote functions, the modalities (indexed with dependency labels) encode grammatical roles. Words combine to form larger phrases by virtue of a fixed set of inference rules for the type-forming operations. -

    -

    - A parse of a phrase is then nothing more and nothing less than a proof: a sequence of valid derivation steps leading from axioms—the formulas/types assigned to the elementary building blocks, i.e. words—to the well-formed complex expression that constitutes the eventual phrase. - You might have encountered this idea in the literature as the parsing as deduction paradigm. -

    +

    + Which phrases can combine and how is fully determined by the + formulas (aka types) assigned to the words that make them up. + Logical implications in these formulas denote functions, the + modalities (indexed with dependency labels) encode grammatical + roles. Words combine to form larger phrases by virtue of a fixed set + of inference rules for the type-forming operations. +

    +

    + A parse of a phrase is then nothing more and nothing less + than a proof: a sequence of valid derivation steps leading + from axioms—the formulas/types assigned to the elementary building + blocks, i.e. words—to the well-formed complex expression that + constitutes the eventual phrase. You might have encountered this + idea in the literature as the + parsing as deduction paradigm. +

    -

    - The interesting bit is that intuitionistic proofs also are compositional meaning instructions (or functional programs), courtesy of the Curry-Howard-de Bruijn correspondence. - What this means is that the proofs produced by Spindle are also meaning recipes (more formally λ-terms); these you can actually execute provided you have the basic ingredients—meanings for the constants/words—and a concrete interpretation for the recipe instructions, i.e. the inference steps. -

    +

    + The interesting bit is that intuitionistic proofs also are + compositional meaning instructions (or functional programs), + courtesy of the Curry-Howard-de Bruijn correspondence. What this + means is that the proofs produced by Spindle are also + meaning recipes (more formally λ-terms); these you can + actually execute provided you have the basic ingredients—meanings + for the constants/words—and a concrete interpretation for the recipe + instructions, i.e. the inference steps. +

    -
    Does this mean that Spindle is never mistaken?
    +
    Does this mean that Spindle is never mistaken?
    +

    + No. Spindle can be (and frequently is) wrong. Sometimes it can't + come up with a proof, in which case it knows it's wrong and openly + admits it. Other times it does come up with a proof, but the proof + does not capture the sentence's actual structure (either because + some word got the wrong formula, or because a logically correct but + linguistically implausible inference path has been followed). + Fortunately, every so often, Spindle is also correct: it returns a + proof that should appease both linguists and logicians. +

    -

    - No. Spindle can be (and frequently is) wrong. Sometimes it can't come up with a proof, in which case it knows it's wrong and openly admits it. Other times it does come up with a proof, but the proof does not capture the sentence's actual structure (either because some word got the wrong formula, or because a logically correct but linguistically implausible inference path has been followed). Fortunately, every so often, Spindle is also correct: it returns a proof that should appease both linguists and logicians. -

    +

    How does this even work?

    +

    + This works thanks to a sophisticated interplay between a logical + core (a miniature type checker) and two machine learning components. +

    +

    + Machine learning component (1) is a supertagger: it takes a Dutch + text (a sequence of words) as input and converts it into a sequence + of formulas of LP◇,□; +

    +

    + Machine learning component (2) is a greedy theorem prover that uses + the extracted formulas and their representations in order to resolve + the entire proof in a single step (without ever backtracking). The + output of the machine learning components is an (unverified) + candidate proof; it is passed to the type checker which + either accepts it (in which case you get to see it), or rejects it + (in which case you do not). +

    -

    How does this even work?

    -

    - This works thanks to a sophisticated interplay between a logical core (a miniature type checker) and two machine learning components. -

    -

    - Machine learning component (1) is a supertagger: it takes a Dutch text (a sequence of words) as input and converts it into a sequence of formulas of LP◇,□; -

    -

    - Machine learning component (2) is a greedy theorem prover that uses the extracted formulas and their representations in order to resolve the entire proof in a single step (without ever backtracking). - The output of the machine learning components is an (unverified) candidate proof; it is passed to the type checker which either accepts it (in which case you get to see it), or rejects it (in which case you do not). -

    +
    Ok, how does this really work?
    +

    + Please check out the literature at the relevant links section. +

    -
    Ok, how does this really work?
    - -

    - Please check out the literature at the relevant links section. -

    - - -

    Credits

    -

    - Spindle has been designed and developed as a part of the PhD thesis of Konstantinos Kogkalidis. -

    -

    - The online interface is due to the Research Software Lab, Centre of Digital Humanities at Utrecht University. -

    -

    - Funding was provided by the NWO project “A composition calculus for vector-based semantic modelling with a localization for Dutch” (grant nr. 360-89-070). -

    - -

    Contact

    -

    - For comments/complaints you can contact konstantinos(funny-a-symbol)riseup(sentence-end-marker)net -

    +

    Credits

    +

    + Spindle has been designed and developed as a part of the PhD thesis + of Konstantinos Kogkalidis. +

    +

    + The online interface is due to the Research Software Lab, Centre of + Digital Humanities at Utrecht University. +

    +

    + Funding was provided by the NWO project “A composition calculus for + vector-based semantic modelling with a localization for Dutch” + (grant nr. 360-89-070). +

    +

    Contact

    +

    + For comments/complaints you can contact + konstantinos(funny-a-symbol)riseup(sentence-end-marker)net +

    +
    +
    diff --git a/frontend/src/app/spindle/spindle-notation/spindle-notation.component.html b/frontend/src/app/spindle/spindle-notation/spindle-notation.component.html index 8909e91..50aba1e 100644 --- a/frontend/src/app/spindle/spindle-notation/spindle-notation.component.html +++ b/frontend/src/app/spindle/spindle-notation/spindle-notation.component.html @@ -1,46 +1,74 @@
    -

    Notation

    -
    -
    - Types -
    -
    atomic: T; complex: given types A, B we have - - - - - - - - - - -
    ABlinear implication
    d Adiamond with dependency label d
    d Abox with dependency label d
    -
    -
    -
    -
    Terms
    -
    atomic: c constants (i.e. words), x variables; complex: given terms M, N we have - - - - - - - - - - - - - - - - - - - -
    M N⊸ elimination, function application
    λx.M⊸ introduction, abstraction
    d Mdiamond elimination
    d Mdiamond introduction
    d Mbox elimination
    d Mbox introduction
    -
    -
    +
    +
    +

    Notation

    +
    +
    Types
    +
    + atomic: T; complex: given types + A, B we have + + + + + + + + + + + + + +
    ABlinear implication
    + ♢d A + diamond with dependency label d
    + □d A + box with dependency label d
    +
    +
    +
    +
    Terms
    +
    + atomic: c constants (i.e. words), + x variables; complex: given terms M, + N we have + + + + + + + + + + + + + + + + + + + + + + + + + +
    M N⊸ elimination, function application
    λx.M⊸ introduction, abstraction
    + ▽d M + diamond elimination
    + △d M + diamond introduction
    + ▼d M + box elimination
    + ▲d M + box introduction
    +
    +
    +
    +
    diff --git a/frontend/src/app/spindle/spindle.component.html b/frontend/src/app/spindle/spindle.component.html index 39ffc61..d1d5cbf 100644 --- a/frontend/src/app/spindle/spindle.component.html +++ b/frontend/src/app/spindle/spindle.component.html @@ -1,21 +1,25 @@ -

    Spindle

    +
    +
    +
    +

    Spindle

    -
    -
    -

    Spindle is a neurosymbolic typelogical parser for Dutch.

    -

    - Upon entering a Dutch phrase, Spindle returns its analysis in the - form of a lambda term that records the steps of its derivation. - Derivations are driven by the type formulas assigned to words. These - formulas determine how words combine into larger phrases. -

    -

    - More info and can be found under - About and - References. The notations used are - explained under Notation. -

    - @if (spindleReady$ | async) { +

    Spindle is a neurosymbolic typelogical parser for Dutch.

    + +

    + Upon entering a Dutch phrase, Spindle returns its analysis in the + form of a lambda term that records the steps of its derivation. + Derivations are driven by the type formulas assigned to words. These + formulas determine how words combine into larger phrases. +

    + +

    + More info and can be found under + About and + References. The notations used are + explained under Notation. +

    + + @if (spindleReady$ | async) {
    @@ -24,7 +28,7 @@

    Spindle

    class="input" [class.is-danger]=" spindleInput.touched && spindleInput.invalid - " + " type="text" [formControl]="spindleInput" (keydown.enter)="$event.preventDefault(); parse()" @@ -40,107 +44,49 @@

    Spindle

    - } -
    -
    - -@if (spindleReady$ | async) { -
    - -
    -} -@else { -

    - Spindle is temporarily unavailable. -

    -} + } -
    -

    Term:

    -
    - - - - - - -
    - - c{{ i }} - - - {{ item.word }} -
    -
    + @if (spindleReady$ | async) { +
    + +
    + } @else { +

    Spindle is temporarily unavailable.

    + } -
    -

    Export results

    -
    - - - - -
    +
    +

    Term:

    +
    + + + + + + +
    + + c{{ i }} + + + {{ item.word }} +
    +
    -
    -
    - - +
    -
    
         
    + + diff --git a/frontend/src/app/spindle/spindle.component.scss b/frontend/src/app/spindle/spindle.component.scss index a171bcb..e69de29 100644 --- a/frontend/src/app/spindle/spindle.component.scss +++ b/frontend/src/app/spindle/spindle.component.scss @@ -1,25 +0,0 @@ -.intro p { - margin: revert; -} - -.button-container { - margin-top: 15px; -} - -.output-container { - position: relative; - - .output-button-container { - position: absolute; - top: 5px; - right: 5px; - display: flex; - flex-direction: column; - gap: 5px; - } - - pre { - max-width: 100%; - overflow: scroll; - } -} diff --git a/frontend/src/app/spindle/spindle.component.spec.ts b/frontend/src/app/spindle/spindle.component.spec.ts index 7be7c48..6f89277 100644 --- a/frontend/src/app/spindle/spindle.component.spec.ts +++ b/frontend/src/app/spindle/spindle.component.spec.ts @@ -6,10 +6,11 @@ import { HttpTestingController, } from "@angular/common/http/testing"; import { ReactiveFormsModule } from "@angular/forms"; -import { ExportButtonComponent } from "./export-button/export-button.component"; +import { ExportButtonComponent } from "../shared/components/spindle-export/export-button/export-button.component"; import { RouterModule } from "@angular/router"; import { SpindleApiService } from "../shared/services/spindle-api.service"; import { SpindleReturn } from "../shared/types"; +import { SharedModule } from "../shared/shared.module"; describe("SpindleComponent", () => { let component: SpindleComponent; @@ -22,6 +23,7 @@ describe("SpindleComponent", () => { imports: [ HttpClientTestingModule, ReactiveFormsModule, + SharedModule, RouterModule.forRoot([]), ], declarations: [SpindleComponent, ExportButtonComponent], @@ -62,7 +64,7 @@ describe("SpindleComponent", () => { expect(input.mode).toEqual("pdf"); expect(input.sentence).toEqual("test sentence 2"); }); - component.export("pdf"); + component.exportResult("pdf"); const request = httpTestingController.expectOne("/api/spindle/pdf"); request.flush({}); }); @@ -70,7 +72,7 @@ describe("SpindleComponent", () => { it("should start a download when export to PDF is clicked", () => { // Private method, so any is required. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const downloadSpy = spyOn(component, "downloadFile"); + const downloadSpy = spyOn(apiService, "downloadFile"); const fakeReturn: SpindleReturn = { error: null, @@ -83,7 +85,7 @@ describe("SpindleComponent", () => { }; component.spindleInput.setValue("test sentence 3"); - component.export("pdf"); + component.exportResult("pdf"); const request = httpTestingController.expectOne("/api/spindle/pdf"); expect(request.request.method).toEqual("POST"); @@ -107,7 +109,7 @@ describe("SpindleComponent", () => { }; component.spindleInput.setValue("test sentence 4"); - component.export("proof"); + component.exportResult("proof"); const request = httpTestingController.expectOne("/api/spindle/proof"); expect(request.request.method).toEqual("POST"); diff --git a/frontend/src/app/spindle/spindle.component.ts b/frontend/src/app/spindle/spindle.component.ts index d9366a4..00dc4ab 100644 --- a/frontend/src/app/spindle/spindle.component.ts +++ b/frontend/src/app/spindle/spindle.component.ts @@ -2,18 +2,11 @@ import { Component, DestroyRef, OnInit } from "@angular/core"; import { FormControl, Validators } from "@angular/forms"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ErrorHandlerService } from "../shared/services/error-handler.service"; -import { AlertService } from "../shared/services/alert.service"; -import { AlertType } from "../shared/components/alert/alert.component"; -import { faDownload, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { LexicalPhrase, SpindleMode } from "../shared/types"; +import { AethelDetailPhrase, ExportMode } from "../shared/types"; import { SpindleApiService } from "../shared/services/spindle-api.service"; import { Subject, filter, map, share, switchMap, takeUntil, timer } from "rxjs"; import { StatusService } from "../shared/services/status.service"; - -interface TextOutput { - extension: "json" | "tex"; - text: string; -} +import { TextOutput } from "../shared/components/spindle-export/export-text/export-text.component"; @Component({ selector: "pp-spindle", @@ -26,34 +19,36 @@ export class SpindleComponent implements OnInit { }); term: string | null = null; textOutput: TextOutput | null = null; - lexicalPhrases: LexicalPhrase[] = []; + lexicalPhrases: AethelDetailPhrase[] = []; loading$ = this.apiService.loading$; - faCopy = faCopy; - faDownload = faDownload; - stopStatus$ = new Subject(); spindleReady$ = timer(0, 5000).pipe( takeUntil(this.stopStatus$), switchMap(() => this.statusService.get()), - map(status => status.spindle), - share() + map((status) => status.spindle), + share(), ); + get parsed(): boolean { + return this.term !== null && this.lexicalPhrases.length > 0; + } + constructor( private apiService: SpindleApiService, - private alertService: AlertService, private errorHandler: ErrorHandlerService, private destroyRef: DestroyRef, - private statusService: StatusService + private statusService: StatusService, ) {} ngOnInit(): void { - this.spindleReady$.pipe( - filter(ready => ready === true), - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => this.stopStatus$.next()); + this.spindleReady$ + .pipe( + filter((ready) => ready === true), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => this.stopStatus$.next()); this.apiService.output$ .pipe(takeUntilDestroyed(this.destroyRef)) @@ -78,7 +73,7 @@ export class SpindleComponent implements OnInit { } if (response.pdf) { const base64 = response.pdf; - this.downloadAsFile(base64, "pdf"); + this.apiService.downloadAsFile(base64, "pdf"); } if (response.term && response.lexical_phrases) { this.term = response.term; @@ -91,23 +86,15 @@ export class SpindleComponent implements OnInit { text: JSON.stringify(response.proof, null, 2), }; } - if (response.term && response.lexical_phrases) { - this.term = response.term; - this.lexicalPhrases = response.lexical_phrases; - } }); } - parse(): void { + public parse(): void { this.clearResults(); - this.export("term-table"); - } - - get parsed(): boolean { - return this.term !== null && this.lexicalPhrases.length > 0; + this.exportResult("term-table"); } - export(mode: SpindleMode): void { + public exportResult(mode: ExportMode): void { this.spindleInput.markAsTouched(); this.spindleInput.updateValueAndValidity(); const userInput = this.spindleInput.value; @@ -120,59 +107,9 @@ export class SpindleComponent implements OnInit { }); } - copyToClipboard(text: string): void { - navigator.clipboard - .writeText(text) - .then(() => { - this.alertService.alert$.next({ - message: $localize`Copied to clipboard.`, - type: AlertType.SUCCESS, - }); - }) - .catch(() => { - this.alertService.alert$.next({ - message: $localize`Failed to copy to clipboard.`, - type: AlertType.DANGER, - }); - }); - } - - downloadAsFile(textData: string, extension: "tex" | "json" | "pdf"): void { - const fileName = "spindleParseResult." + extension; - let url = ""; - // PDF data (base64) does not need to be converted to a blob. - if (extension === "pdf") { - url = `data:application/pdf;base64,${textData}`; - } else { - const blob = new Blob([textData], { - type: `application/${extension}`, - }); - url = window.URL.createObjectURL(blob); - } - - this.downloadFile(fileName, url); - - // Revoke the object URL after downloading. - if (extension !== "pdf") { - this.revokeObjectURL(url); - } - } - private clearResults(): void { this.term = null; this.textOutput = null; this.lexicalPhrases = []; } - - private downloadFile(fileName: string, url: string): void { - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - link.click(); - link.remove(); - } - - private revokeObjectURL(url: string): void { - window.URL.revokeObjectURL(url); - } } diff --git a/package.json b/package.json index 10771f1..955efd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parseport", - "version": "0.2.0", + "version": "0.3.0", "description": "Dutch sentence parser for Spindle + Æthel (and maybe others in the future).", "author": "UU Centre for Digital Humanities - Research Software Lab", "license": "BSD-3-Clause",