diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fc367a69ea5..4365e14d3a6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -34,8 +34,9 @@ jobs: tags_flavor: suffix=-dev # As this is a "dev" image, its tags are all suffixed with "-dev". Otherwise, it uses the same # tagging logic as the primary 'dspace/dspace-angular' image above. - # run_python_version_script: true - # python_version_script_dest: src/static-files/VERSION_D.html + # Generate deployed-version info served at /static/VERSION_D (issue #813) + run_python_version_script: true + python_version_script_dest: src/static-files/VERSION_D.html secrets: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} @@ -52,8 +53,9 @@ jobs: build_id: dspace-angular image_name: dataquest/dspace-angular dockerfile_path: ./Dockerfile.dist - # run_python_version_script: true - # python_version_script_dest: src/static-files/VERSION_D.html + # Generate deployed-version info served at /static/VERSION_D (issue #813) + run_python_version_script: true + python_version_script_dest: src/static-files/VERSION_D.html secrets: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} diff --git a/angular.json b/angular.json index cf348ef8d54..cc728f35864 100644 --- a/angular.json +++ b/angular.json @@ -40,6 +40,11 @@ "aot": true, "assets": [ "src/assets", + { + "glob": "VERSION_D.html", + "input": "src/static-files", + "output": "static-files" + }, "src/robots.txt" ], "styles": [ diff --git a/scripts/sourceversion.py b/scripts/sourceversion.py new file mode 100644 index 00000000000..c6d50504613 --- /dev/null +++ b/scripts/sourceversion.py @@ -0,0 +1,38 @@ +import subprocess +import sys +from datetime import datetime, timezone + +# when next editing this script, please introduce argparse. +# do not forget, it is called in BE by .github\workflows\reusable-docker-build.yml +# argparse must be introduced there. +# that action also calls BE version of this script, which is different (BE: scripts/sourceversion.py). +# It must also cooperate with argparse + +# the idea is, that this will be different on each branch, but could be possibly passed by argv/argparse +RELEASE_TAG_BASE='none' + +def get_time_in_timezone(zone: str = "Europe/Bratislava"): + try: + from zoneinfo import ZoneInfo + my_tz = ZoneInfo(zone) + except Exception as e: + my_tz = timezone.utc + return datetime.now(my_tz) + + +if __name__ == '__main__': + ts = get_time_in_timezone() + # we have html tags, since this script ends up creating VERSION_D.html + print(f"

This info was generated on:
{ts.strftime('%Y-%m-%d %H:%M:%S %Z%z')}

") + + cmd = 'git log -1 --pretty=format:"

Git hash:
%H
Date of commit:
%ai

"' + subprocess.check_call(cmd, shell=True) + + # when adding argparse, this should be a bit more obvious + link = sys.argv[1] + sys.argv[2] + print('

Build run:

' + link + ' ') + + link = "https://github.com/dataquest-dev/dspace-angular/releases/tag/" \ + + RELEASE_TAG_BASE + "-" + datetime.now().strftime('%Y.%m.') + sys.argv[2] + + print('

Release link:

' + link + ' (if it does not work, then this is not an official release instance) ') diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 17f8bb5443d..6e84aebb65c 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -41,6 +41,7 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; import { PROCESS_MODULE_PATH } from './process-page/process-page-routing.paths'; +import { STATIC_PAGE_PATH } from './static-page/static-page-routing-paths'; import { viewTrackerResolver } from './statistics/angulartics/dspace/view-tracker.resolver'; import { provideSubmissionState } from './submission/provide-submission-state'; import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-routing-paths'; @@ -289,6 +290,11 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [notAuthenticatedGuard], }, + { + path: STATIC_PAGE_PATH, + loadChildren: () => import('./static-page/static-page-routes') + .then((m) => m.ROUTES), + }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent, data: { title: '404.page-not-found' } }, ], }, diff --git a/src/app/shared/html-content.service.ts b/src/app/shared/html-content.service.ts new file mode 100644 index 00000000000..90c4c797b4c --- /dev/null +++ b/src/app/shared/html-content.service.ts @@ -0,0 +1,85 @@ +import { isPlatformBrowser } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { + Inject, + Injectable, + PLATFORM_ID, +} from '@angular/core'; +import { + firstValueFrom, + of, +} from 'rxjs'; +import { + catchError, + map, +} from 'rxjs/operators'; + +import { LocaleService } from '../core/locale/locale.service'; +import { + HTML_SUFFIX, + STATIC_FILES_PROJECT_PATH, +} from '../static-page/static-page-routing-paths'; + +interface HtmlContentResult { + found: boolean; + body: string; +} + +/** + * Service for loading static `.html` files stored in the `src/static-files` folder + * (registered as a build asset in `angular.json`). Used e.g. to render the + * deployed-version info at `/static/VERSION_D` (issue #813). + */ +@Injectable({ + providedIn: 'root', +}) +export class HtmlContentService { + constructor( + private http: HttpClient, + private localeService: LocaleService, + @Inject(PLATFORM_ID) private platformId: object, + ) {} + + private withSuffix(name: string): string { + return name.endsWith(HTML_SUFFIX) ? name : name + HTML_SUFFIX; + } + + private fetch(url: string) { + return this.http.get(url, { responseType: 'text' }).pipe( + map((body): HtmlContentResult => ({ found: true, body })), + catchError(() => of({ found: false, body: '' })), + ); + } + + /** + * Load the html content for a file name, trying the current locale package first + * (`static-files//.html`) and falling back to the default package + * (`static-files/.html`). Returns `undefined` when nothing was found. + * + * The files are fetched client-side only; during SSR this resolves to `undefined` + * and the content is loaded after hydration. + */ + async getHtmlContentByPathAndLocale(fileName: string): Promise { + if (!isPlatformBrowser(this.platformId)) { + return undefined; + } + + let language = await firstValueFrom(this.localeService.getCurrentLanguageCode()); + // Default language `en` lives in the non-translated (root) package. + language = language === 'en' ? '' : language; + + if (language) { + const localized = await firstValueFrom( + this.fetch(this.withSuffix(`${STATIC_FILES_PROJECT_PATH}/${language}/${fileName}`)), + ); + if (localized.found) { + return localized.body; + } + } + + const fallback = await firstValueFrom( + this.fetch(this.withSuffix(`${STATIC_FILES_PROJECT_PATH}/${fileName}`)), + ); + return fallback.found ? fallback.body : undefined; + } +} diff --git a/src/app/shared/utils/clarin-safehtml.pipe.ts b/src/app/shared/utils/clarin-safehtml.pipe.ts new file mode 100644 index 00000000000..d11327aaeb1 --- /dev/null +++ b/src/app/shared/utils/clarin-safehtml.pipe.ts @@ -0,0 +1,23 @@ +import { + Pipe, + PipeTransform, +} from '@angular/core'; +import { + DomSanitizer, + SafeHtml, +} from '@angular/platform-browser'; + +/** + * Pipe to keep html tags (e.g. `id`) when rendering a string via `[innerHTML]`. + */ +@Pipe({ + name: 'dsSafeHtml', + standalone: true, +}) +export class ClarinSafeHtmlPipe implements PipeTransform { + constructor(private sanitized: DomSanitizer) {} + + transform(htmlString: string): SafeHtml { + return this.sanitized.bypassSecurityTrustHtml(htmlString ?? ''); + } +} diff --git a/src/app/static-page/static-page-routes.ts b/src/app/static-page/static-page-routes.ts new file mode 100644 index 00000000000..3ebf3f3e673 --- /dev/null +++ b/src/app/static-page/static-page-routes.ts @@ -0,0 +1,10 @@ +import { Route } from '@angular/router'; + +import { StaticPageComponent } from './static-page.component'; + +export const ROUTES: Route[] = [ + { + path: ':id', + component: StaticPageComponent, + }, +]; diff --git a/src/app/static-page/static-page-routing-paths.ts b/src/app/static-page/static-page-routing-paths.ts new file mode 100644 index 00000000000..ae4978b440d --- /dev/null +++ b/src/app/static-page/static-page-routing-paths.ts @@ -0,0 +1,12 @@ +/** + * Constants for the `/static` route. + * + * The static page component loads `.html` files bundled from `src/static-files` + * (registered as a build asset in `angular.json`). This is how the deployed-version + * info is served at `/static/VERSION_D` (issue #813). + */ +export const STATIC_PAGE_PATH = 'static'; + +export const STATIC_FILES_PROJECT_PATH = 'static-files'; + +export const HTML_SUFFIX = '.html'; diff --git a/src/app/static-page/static-page.component.html b/src/app/static-page/static-page.component.html new file mode 100644 index 00000000000..c70c67b73a9 --- /dev/null +++ b/src/app/static-page/static-page.component.html @@ -0,0 +1,28 @@ +@if (contentState === 'loading') { +
+
+ {{ 'loading.default' | translate }} +
+
+} + + +@if (contentState === 'found') { +
+
+
+} + + +@if (contentState === 'not-found') { +
+

404

+

{{ 'static-page.404.page-not-found' | translate }}

+
+

{{ 'static-page.404.help' | translate }}

+
+

+ {{ 'static-page.404.link.home-page' | translate }} +

+
+} diff --git a/src/app/static-page/static-page.component.scss b/src/app/static-page/static-page.component.scss new file mode 100644 index 00000000000..ea542ba0349 --- /dev/null +++ b/src/app/static-page/static-page.component.scss @@ -0,0 +1,5 @@ +.page-not-found { + text-align: center; + margin-top: 3rem; + margin-bottom: 3rem; +} diff --git a/src/app/static-page/static-page.component.ts b/src/app/static-page/static-page.component.ts new file mode 100644 index 00000000000..9bf627a3074 --- /dev/null +++ b/src/app/static-page/static-page.component.ts @@ -0,0 +1,84 @@ +import { + ChangeDetectorRef, + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + RouterLink, +} from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; + +import { ServerResponseService } from '../core/services/server-response.service'; +import { HtmlContentService } from '../shared/html-content.service'; +import { ClarinSafeHtmlPipe } from '../shared/utils/clarin-safehtml.pipe'; + +/** + * Component which loads and shows static files from the `src/static-files` folder. + * E.g. `/static/VERSION_D` renders `static-files/VERSION_D.html` + * (the deployed-version info, issue #813). + */ +@Component({ + selector: 'ds-static-page', + templateUrl: './static-page.component.html', + styleUrls: ['./static-page.component.scss'], + imports: [ + ClarinSafeHtmlPipe, + RouterLink, + TranslateModule, + ], +}) +export class StaticPageComponent implements OnInit { + htmlContent = ''; + contentState: 'loading' | 'found' | 'not-found' = 'loading'; + + constructor( + private htmlContentService: HtmlContentService, + private route: ActivatedRoute, + private responseService: ServerResponseService, + private changeDetector: ChangeDetectorRef, + ) {} + + async ngOnInit(): Promise { + this.contentState = 'loading'; + this.htmlContent = ''; + + const fileName = this.getHtmlFileName(); + if (!fileName) { + this.markNotFound(); + return; + } + + try { + const content = await this.htmlContentService.getHtmlContentByPathAndLocale(fileName); + if (content !== undefined) { + this.htmlContent = content; + this.contentState = 'found'; + this.changeDetector.detectChanges(); + return; + } + } catch { + // fall through to not-found handling below + } + + this.markNotFound(); + } + + private markNotFound(): void { + this.responseService.setNotFound(); + this.contentState = 'not-found'; + this.changeDetector.detectChanges(); + } + + /** + * Read the file name from the URL - `static/FILE_NAME`. + */ + private getHtmlFileName(): string | null { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + return null; + } + // Drop any trailing fragment, e.g. `VERSION_D#section`. + return id.split('#')[0]; + } +} diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 495a38b37c9..ba2342d47da 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -7532,6 +7532,15 @@ // "sorting.person.birthDate.DESC": "Birth Date Descending", "sorting.person.birthDate.DESC": "Datum narození sestupně", + // "static-page.404.help": "The static page you requested does not exist. It may have been moved or deleted. You can use the button below to get back to the home page.", + "static-page.404.help": "Požadovaná statická stránka neexistuje. Mohla být přesunuta nebo smazána. Pomocí níže uvedeného tlačítka se můžete vrátit na domovskou stránku.", + + // "static-page.404.link.home-page": "Take me to the home page", + "static-page.404.link.home-page": "Návrat na domovskou stránku", + + // "static-page.404.page-not-found": "page not found", + "static-page.404.page-not-found": "stránka nebyla nalezena", + // "statistics.title": "Statistics", "statistics.title": "Statistiky", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 2fcac568949..28797ae6a02 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -5012,6 +5012,13 @@ "sorting.person.birthDate.DESC": "Birth Date Descending", + "static-page.404.help": "The static page you requested does not exist. It may have been moved or deleted. You can use the button below to get back to the home page.", + + "static-page.404.link.home-page": "Take me to the home page", + + "static-page.404.page-not-found": "page not found", + + "statistics.title": "Statistics", "statistics.header": "Statistics for {{ scope }}", diff --git a/src/static-files/VERSION_D.html b/src/static-files/VERSION_D.html new file mode 100644 index 00000000000..dd4019e2622 --- /dev/null +++ b/src/static-files/VERSION_D.html @@ -0,0 +1,7 @@ +