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 @@
+