Compare commits

...

8 Commits

Author SHA1 Message Date
9c30211ba2 feat: splash transition
refactor: require app reload for setting changes

feat: require reload on setting changes

feat: new logo

feat: update to capacitor 5

feat: new logo

feat: update to capacitor 5

refactor: simplify settings provider
2024-01-03 12:27:42 +00:00
63a38e0077 feat: enable checkJs by default 2024-01-03 12:15:15 +00:00
c8b260201c feat: add direnv for nix
feat: update nix flake to not rely on buildFHSUserEnv
2024-01-03 12:15:15 +00:00
123c50d1af fix: backend tests break every year
refactor: update some backend unit tests
2024-01-03 12:57:24 +01:00
Rainer Killinger
d65e6351e9 fix: iOS build resources 2023-12-21 12:51:39 +01:00
Rainer Killinger
2c5d7403db refactor: add asdf tool versioning file 2023-12-21 12:25:38 +01:00
6ca03f463d fix: changeset crashes because it uses internal prettier version 2023-12-21 11:26:01 +01:00
Rainer Killinger
1f74a9bc82 refactor: overhaul minimal-deployment compose file 2023-12-19 16:52:58 +01:00
120 changed files with 976 additions and 1737 deletions

View File

@@ -0,0 +1,5 @@
---
"@openstapps/prettier-config": patch
---
Update Prettier to 3.1.1

View File

@@ -0,0 +1,5 @@
---
"@openstapps/backend": patch
---
Backend unit tests break every year

View File

@@ -0,0 +1,9 @@
---
'@openstapps/app': minor
---
Require full reload for setting & language changes
Setting changes are relatively rare, so it makes little sense
going through the effort of ensuring everything is reactive to
language changes as well as creating all the pipe bindings etc.

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

1
.gitignore vendored
View File

@@ -24,6 +24,7 @@ report-junit.xml
# NixOS flake # NixOS flake
result result
hsperfdata_root hsperfdata_root
.direnv/
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov lib-cov

3
.tool-versions Normal file
View File

@@ -0,0 +1,3 @@
nodejs 18.16.1
pnpm 8.8.0
python 3.11.5

View File

@@ -1,4 +1,4 @@
# Open StApps Monorepo # <img src="logo-bg.svg" height="24"> Open StApps Monorepo
Refer to the [contribution guide](./CONTRIBUTING.md) Refer to the [contribution guide](./CONTRIBUTING.md)

View File

@@ -1,5 +1,3 @@
// @ts-check
/** /**
* This is the database configuration for the technical university of berlin * This is the database configuration for the technical university of berlin
* *

View File

@@ -1,4 +1,3 @@
// @ts-check
import {SCSettingInputType, SCThingOriginType, SCThingType} from '@openstapps/core'; import {SCSettingInputType, SCThingOriginType, SCThingType} from '@openstapps/core';
/** @type {import('@openstapps/core').SCLanguageSetting} */ /** @type {import('@openstapps/core').SCLanguageSetting} */

View File

@@ -1,4 +1,3 @@
// @ts-check
/** @type {import('@openstapps/core').SCAppConfigurationMenuCategory[]} */ /** @type {import('@openstapps/core').SCAppConfigurationMenuCategory[]} */
const menus = [ const menus = [
{ {

View File

@@ -1,4 +1,3 @@
// @ts-check
import {SCSettingInputType, SCThingOriginType, SCThingType} from '@openstapps/core'; import {SCSettingInputType, SCThingOriginType, SCThingType} from '@openstapps/core';
/** @type {import('@openstapps/core').SCUserGroupSetting} */ /** @type {import('@openstapps/core').SCUserGroupSetting} */

View File

@@ -1,4 +1,3 @@
// @ts-check
import {SCThingType} from '@openstapps/core'; import {SCThingType} from '@openstapps/core';
/** @type {import('@openstapps/core').SCBackendAggregationConfiguration[]} */ /** @type {import('@openstapps/core').SCBackendAggregationConfiguration[]} */

View File

@@ -1,4 +1,3 @@
// @ts-check
import { import {
month, month,
sommerRange, sommerRange,

View File

@@ -1,4 +1,3 @@
// @ts-check
import {SCThingType} from '@openstapps/core'; import {SCThingType} from '@openstapps/core';
import aggregations from './aggregations.js'; import aggregations from './aggregations.js';
import boostings from './boostings.js'; import boostings from './boostings.js';
@@ -17,7 +16,7 @@ export const backend = {
hiddenTypes: [SCThingType.DateSeries, SCThingType.Diff, SCThingType.Floor], hiddenTypes: [SCThingType.DateSeries, SCThingType.Diff, SCThingType.Floor],
mappingIgnoredTags: ['minlength', 'pattern', 'see', 'tjs-format'], mappingIgnoredTags: ['minlength', 'pattern', 'see', 'tjs-format'],
maxMultiSearchRouteQueries: 5, maxMultiSearchRouteQueries: 5,
maxRequestBodySize: 2 * 10 ** 6, maxRequestBodySize: 2e6,
name: 'Goethe-Universität Frankfurt am Main', name: 'Goethe-Universität Frankfurt am Main',
namespace: '909a8cbc-8520-456c-b474-ef1525f14209', namespace: '909a8cbc-8520-456c-b474-ef1525f14209',
sortableFields: [ sortableFields: [

View File

@@ -1,4 +1,3 @@
// @ts-check
import app from './app/index.js'; import app from './app/index.js';
import {backend, internal} from './backend/index.js'; import {backend, internal} from './backend/index.js';

View File

@@ -1,5 +1,3 @@
// @ts-check
/** /**
* This is the default configuration for elasticsearch (a database) * This is the default configuration for elasticsearch (a database)
* *

View File

@@ -1,4 +1,3 @@
// @ts-check
import {readFile} from 'fs/promises'; import {readFile} from 'fs/promises';
import {SCAboutPageContentType} from '@openstapps/core'; import {SCAboutPageContentType} from '@openstapps/core';

View File

@@ -1,4 +1,3 @@
// @ts-check
/** /**
* Generates a range of numbers that represent consecutive calendar months * Generates a range of numbers that represent consecutive calendar months
* *

View File

@@ -1,4 +1,3 @@
// @ts-check
import {readFile, readdir} from 'fs/promises'; import {readFile, readdir} from 'fs/promises';
import url from 'url'; import url from 'url';
import path from 'path'; import path from 'path';

View File

@@ -1,4 +1,3 @@
// @ts-check
import {SCAboutPageContentType} from '@openstapps/core'; import {SCAboutPageContentType} from '@openstapps/core';
import {markdown} from '../../default/tools/markdown.js'; import {markdown} from '../../default/tools/markdown.js';

View File

@@ -1,4 +1,3 @@
// @ts-check
import {SCAboutPageContentType} from '@openstapps/core'; import {SCAboutPageContentType} from '@openstapps/core';
/** @type {import('@openstapps/core').SCAboutPage} */ /** @type {import('@openstapps/core').SCAboutPage} */

View File

@@ -1,4 +1,3 @@
// @ts-check
import about from './about.js'; import about from './about.js';
import imprint from './imprint.js'; import imprint from './imprint.js';
import privacy from './privacy.js'; import privacy from './privacy.js';

View File

@@ -1,4 +1,3 @@
// @ts-check
import {markdown} from '../../default/tools/markdown.js'; import {markdown} from '../../default/tools/markdown.js';
/** @type {import('@openstapps/core').SCAboutPage} */ /** @type {import('@openstapps/core').SCAboutPage} */

View File

@@ -1,4 +1,3 @@
// @ts-check
import aboutPages from './about-pages/index.js'; import aboutPages from './about-pages/index.js';
import defaultApp from '../default/app/index.js'; import defaultApp from '../default/app/index.js';
import {backend as defaultBackend, internal as defaultInternal} from '../default/backend/index.js'; import {backend as defaultBackend, internal as defaultInternal} from '../default/backend/index.js';

View File

@@ -1,4 +1,3 @@
// @ts-check
import {versions} from '../../default/tools/version.js'; import {versions} from '../../default/tools/version.js';
/** @type {import('@openstapps/core').SCAppVersionInfo[]} */ /** @type {import('@openstapps/core').SCAppVersionInfo[]} */

View File

@@ -14,6 +14,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { import {
SCBook,
SCBulkAddRoute, SCBulkAddRoute,
SCBulkDoneRoute, SCBulkDoneRoute,
SCBulkRequest, SCBulkRequest,
@@ -23,29 +24,30 @@ import {
import {expect} from 'chai'; import {expect} from 'chai';
import {bulk, DEFAULT_TEST_TIMEOUT} from '../common.js'; import {bulk, DEFAULT_TEST_TIMEOUT} from '../common.js';
import {testApp} from '../tests-setup.js'; import {testApp} from '../tests-setup.js';
import {readFile} from 'fs/promises';
import {v4} from 'uuid'; import {v4} from 'uuid';
import bookFile from '@openstapps/core/test/resources/indexable/Book.2.json' assert {type: 'json'};
const book = JSON.parse( const book = bookFile.instance as SCBook;
await readFile('node_modules/@openstapps/core/test/resources/indexable/Book.2.json', 'utf8'),
).instance;
describe('Bulk routes', async function () { describe('Bulk routes', async function () {
// increase timeout for the suite // increase timeout for the suite
this.timeout(DEFAULT_TEST_TIMEOUT); this.timeout(DEFAULT_TEST_TIMEOUT);
const request: SCBulkRequest = { let request: SCBulkRequest;
expiration: bulk.expiration, let bulkRoute: SCBulkRoute;
source: bulk.source, let bulkAddRoute: SCBulkAddRoute;
type: bulk.type, let bulkDoneRoute: SCBulkDoneRoute;
};
const bulkRoute = new SCBulkRoute();
const bulkAddRoute = new SCBulkAddRoute();
const bulkDoneRoute = new SCBulkDoneRoute();
// afterEach(async function() { before(function () {
// TODO: Delete saved bulks request = {
// }); expiration: bulk.expiration,
source: bulk.source,
type: bulk.type,
};
bulkRoute = new SCBulkRoute();
bulkAddRoute = new SCBulkAddRoute();
bulkDoneRoute = new SCBulkDoneRoute();
});
it('should create bulk', async function () { it('should create bulk', async function () {
const {status, body, error} = await testApp const {status, body, error} = await testApp

View File

@@ -21,7 +21,12 @@ import {expect} from 'chai';
describe('Index route', async function () { describe('Index route', async function () {
// increase timeout for the suite // increase timeout for the suite
this.timeout(DEFAULT_TEST_TIMEOUT); this.timeout(DEFAULT_TEST_TIMEOUT);
const indexRoute = new SCIndexRoute();
let indexRoute: SCIndexRoute;
before(function () {
indexRoute = new SCIndexRoute();
});
it('should respond with both app and backend configuration', async function () { it('should respond with both app and backend configuration', async function () {
const request: SCIndexRequest = {}; const request: SCIndexRequest = {};

View File

@@ -30,15 +30,11 @@ import chaiAsPromised from 'chai-as-promised';
import {DEFAULT_TEST_TIMEOUT} from '../common.js'; import {DEFAULT_TEST_TIMEOUT} from '../common.js';
import {testApp} from '../tests-setup.js'; import {testApp} from '../tests-setup.js';
import {backendConfig} from '../../src/config.js'; import {backendConfig} from '../../src/config.js';
import {readFile} from 'fs/promises'; import registerRequest from '@openstapps/core/test/resources/PluginRegisterRequest.1.json' assert {type: 'json'};
// for using promises in expectations (to.eventually.be...) // for using promises in expectations (to.eventually.be...)
use(chaiAsPromised); use(chaiAsPromised);
const registerRequest = JSON.parse(
await readFile('node_modules/@openstapps/core/test/resources/PluginRegisterRequest.1.json', 'utf8'),
);
// cast it because of "TS2322: Type 'string' is not assignable to type '"add"'" // cast it because of "TS2322: Type 'string' is not assignable to type '"add"'"
export const registerAddRequest: SCPluginAdd = registerRequest.instance as SCPluginAdd; export const registerAddRequest: SCPluginAdd = registerRequest.instance as SCPluginAdd;

View File

@@ -47,8 +47,8 @@ describe('Create route', async function () {
const statusCodeSuccess = 222; const statusCodeSuccess = 222;
const bodySuccess = {foo: true}; const bodySuccess = {foo: true};
const sandbox = sinon.createSandbox(); const sandbox = sinon.createSandbox();
const validationError = new SCValidationErrorResponse([]); let validationError: SCValidationErrorResponse;
const internalServerError = new SCInternalServerErrorResponse(); let internalServerError: SCInternalServerErrorResponse;
beforeEach(function () { beforeEach(function () {
app = express(); app = express();
@@ -64,6 +64,9 @@ describe('Create route', async function () {
handler = (_request, _app) => { handler = (_request, _app) => {
return Promise.resolve(bodySuccess); return Promise.resolve(bodySuccess);
}; };
validationError = new SCValidationErrorResponse([]);
internalServerError = new SCInternalServerErrorResponse();
}); });
afterEach(function () { afterEach(function () {

View File

@@ -29,11 +29,19 @@ import {backendConfig} from '../../src/config.js';
describe('Search route', async function () { describe('Search route', async function () {
// increase timeout for the suite // increase timeout for the suite
this.timeout(DEFAULT_TEST_TIMEOUT); this.timeout(DEFAULT_TEST_TIMEOUT);
const searchRoute = new SCSearchRoute(); let searchRoute: SCSearchRoute;
const multiSearchRoute = new SCMultiSearchRoute(); let multiSearchRoute: SCMultiSearchRoute;
const syntaxError = new SCSyntaxErrorResponse('Foo Message'); let syntaxError: SCSyntaxErrorResponse;
const methodNotAllowedError = new SCMethodNotAllowedErrorResponse(); let methodNotAllowedError: SCMethodNotAllowedErrorResponse;
const tooManyRequestsError = new SCTooManyRequestsErrorResponse(); let tooManyRequestsError: SCTooManyRequestsErrorResponse;
before(function () {
searchRoute = new SCSearchRoute();
multiSearchRoute = new SCMultiSearchRoute();
syntaxError = new SCSyntaxErrorResponse('Foo Message');
methodNotAllowedError = new SCMethodNotAllowedErrorResponse();
tooManyRequestsError = new SCTooManyRequestsErrorResponse();
});
it('should reject GET, PUT with a valid search query', async function () { it('should reject GET, PUT with a valid search query', async function () {
// const expectedParams = JSON.parse(JSON.stringify(defaultParams)); // const expectedParams = JSON.parse(JSON.stringify(defaultParams));

View File

@@ -13,23 +13,25 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {SCThingUpdateRoute} from '@openstapps/core'; import {SCBook, SCThingUpdateRoute} from '@openstapps/core';
import chaiAsPromised from 'chai-as-promised'; import chaiAsPromised from 'chai-as-promised';
import {bulkStorageMock, DEFAULT_TEST_TIMEOUT} from '../common.js'; import {bulkStorageMock, DEFAULT_TEST_TIMEOUT} from '../common.js';
import {expect, use} from 'chai'; import {expect, use} from 'chai';
import {testApp} from '../tests-setup.js'; import {testApp} from '../tests-setup.js';
import {readFile} from 'fs/promises'; import bookFile from '@openstapps/core/test/resources/indexable/Book.1.json' assert {type: 'json'};
use(chaiAsPromised); use(chaiAsPromised);
const book = JSON.parse( const book = bookFile.instance as SCBook;
await readFile('node_modules/@openstapps/core/test/resources/indexable/Book.1.json', 'utf8'),
).instance;
describe('Thing update route', async function () { describe('Thing update route', async function () {
// increase timeout for the suite // increase timeout for the suite
this.timeout(DEFAULT_TEST_TIMEOUT); this.timeout(DEFAULT_TEST_TIMEOUT);
const thingUpdateRoute = new SCThingUpdateRoute(); let thingUpdateRoute: SCThingUpdateRoute;
before(function () {
thingUpdateRoute = new SCThingUpdateRoute();
});
it('should update a thing', async function () { it('should update a thing', async function () {
const thingUpdateRouteurlPath = thingUpdateRoute.urlPath const thingUpdateRouteurlPath = thingUpdateRoute.urlPath

View File

@@ -39,7 +39,6 @@ import {Elasticsearch} from '../../../src/storage/elasticsearch/elasticsearch.js
import {bulk, DEFAULT_TEST_TIMEOUT, getTransport, getIndex} from '../../common.js'; import {bulk, DEFAULT_TEST_TIMEOUT, getTransport, getIndex} from '../../common.js';
import fs from 'fs'; import fs from 'fs';
import {backendConfig} from '../../../src/config.js'; import {backendConfig} from '../../../src/config.js';
import {readFile} from 'fs/promises';
import { import {
ACTIVE_INDICES_ALIAS, ACTIVE_INDICES_ALIAS,
getIndexUID, getIndexUID,
@@ -50,6 +49,11 @@ import {
} from '../../../src/storage/elasticsearch/util/index.js'; } from '../../../src/storage/elasticsearch/util/index.js';
import cron from 'node-cron'; import cron from 'node-cron';
import {query} from './query.js'; import {query} from './query.js';
import messageFile from '@openstapps/core/test/resources/indexable/Message.1.json' assert {type: 'json'};
import bookFile from '@openstapps/core/test/resources/indexable/Book.1.json' assert {type: 'json'};
const message = messageFile.instance as SCMessage;
const book = bookFile.instance as SCBook;
use(chaiAsPromised); use(chaiAsPromised);
@@ -60,13 +64,6 @@ function searchResponse<T>(...hits: SearchHit<T>[]): SearchResponse<T> {
return {hits: {hits}, took: 0, timed_out: false, _shards: {total: 1, failed: 0, successful: 1}}; return {hits: {hits}, took: 0, timed_out: false, _shards: {total: 1, failed: 0, successful: 1}};
} }
const message = JSON.parse(
await readFile('node_modules/@openstapps/core/test/resources/indexable/Message.1.json', 'utf8'),
);
const book = JSON.parse(
await readFile('node_modules/@openstapps/core/test/resources/indexable/Book.1.json', 'utf8'),
);
describe('Elasticsearch', function () { describe('Elasticsearch', function () {
// increase timeout for the suite // increase timeout for the suite
this.timeout(DEFAULT_TEST_TIMEOUT); this.timeout(DEFAULT_TEST_TIMEOUT);
@@ -74,8 +71,15 @@ describe('Elasticsearch', function () {
before(function () { before(function () {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('before');
sandbox.stub(fs, 'readFileSync').returns('{}'); sandbox.stub(fs, 'readFileSync').returns('{}');
sandbox.stub(backendConfig.internal.boostings.default[0], 'fields').value({
'academicTerms.acronym': {
'SS 2023': 1.05,
'WS 2023/24': 1.1,
'SoSe 2023': 1.05,
'WiSe 2023/24': 1.1,
},
});
}); });
after(function () { after(function () {
sandbox.restore(); sandbox.restore();
@@ -445,7 +449,7 @@ describe('Elasticsearch', function () {
_id: '', _id: '',
_index: '', _index: '',
_score: 0, _score: 0,
_source: message as SCMessage, _source: message,
}; };
sandbox.stub(es.client, 'search').resolves(searchResponse(foundObject)); sandbox.stub(es.client, 'search').resolves(searchResponse(foundObject));
@@ -475,7 +479,7 @@ describe('Elasticsearch', function () {
const object: SearchHit<SCMessage> = { const object: SearchHit<SCMessage> = {
_id: '', _id: '',
_index: oldIndex, _index: oldIndex,
_source: message as SCMessage, _source: message,
}; };
sandbox.stub(es.client, 'search').resolves(searchResponse<SCMessage>(object)); sandbox.stub(es.client, 'search').resolves(searchResponse<SCMessage>(object));
sandbox.stub(es, 'prepareBulkWrite').resolves(index); sandbox.stub(es, 'prepareBulkWrite').resolves(index);
@@ -489,7 +493,7 @@ describe('Elasticsearch', function () {
sandbox.stub(es.client, 'create').resolves({result: 'not_found'} as CreateResponse); sandbox.stub(es.client, 'create').resolves({result: 'not_found'} as CreateResponse);
await es.init(); await es.init();
return expect(es.post(message as SCMessage, bulk)).to.rejectedWith('creation'); return expect(es.post(message, bulk)).to.rejectedWith('creation');
}); });
it('should create a new object', async function () { it('should create a new object', async function () {
@@ -502,7 +506,7 @@ describe('Elasticsearch', function () {
}); });
await es.init(); await es.init();
await es.post(message as SCMessage, bulk); await es.post(message, bulk);
expect(createStub.called).to.be.true; expect(createStub.called).to.be.true;
expect(caughtParameter.document).to.be.eql({ expect(caughtParameter.document).to.be.eql({
@@ -527,7 +531,7 @@ describe('Elasticsearch', function () {
_id: '', _id: '',
_index: getIndex(), _index: getIndex(),
_score: 0, _score: 0,
_source: message as SCMessage, _source: message,
}; };
sandbox.stub(es.client, 'search').resolves(searchResponse()); sandbox.stub(es.client, 'search').resolves(searchResponse());
@@ -541,7 +545,7 @@ describe('Elasticsearch', function () {
_id: '', _id: '',
_index: getIndex(), _index: getIndex(),
_score: 0, _score: 0,
_source: message as SCMessage, _source: message,
}; };
sandbox.stub(es.client, 'search').resolves(searchResponse(object)); sandbox.stub(es.client, 'search').resolves(searchResponse(object));
// @ts-expect-error unused // @ts-expect-error unused
@@ -564,13 +568,13 @@ describe('Elasticsearch', function () {
_id: '123', _id: '123',
_index: getIndex(), _index: getIndex(),
_score: 0, _score: 0,
_source: message as SCMessage, _source: message,
}; };
const objectBook: SearchHit<SCBook> = { const objectBook: SearchHit<SCBook> = {
_id: '321', _id: '321',
_index: getIndex(), _index: getIndex(),
_score: 0, _score: 0,
_source: book as SCBook, _source: book,
}; };
const fakeEsAggregations = { const fakeEsAggregations = {
'@all': { '@all': {

View File

@@ -1,4 +1,3 @@
// @ts-check
const fs = require("fs"); const fs = require("fs");
const path = require("node:path"); const path = require("node:path");
const child_process = require("child_process"); const child_process = require("child_process");

View File

@@ -1,4 +1,3 @@
// @ts-check
"use strict" "use strict"
const rule = require('./copyright-header-rule') const rule = require('./copyright-header-rule')

View File

@@ -1,5 +1,3 @@
// @ts-check
/** @type {import('eslint').Linter.Config} */ /** @type {import('eslint').Linter.Config} */
const config = { const config = {
root: true, root: true,

View File

@@ -19,9 +19,9 @@
"test": "prettier --config index.js --check \"test/*.js\"" "test": "prettier --config index.js --check \"test/*.js\""
}, },
"devDependencies": { "devDependencies": {
"prettier": "3.1.0" "prettier": "3.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"prettier": "3.1.0" "prettier": "3.1.1"
} }
} }

View File

@@ -14,6 +14,7 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"isolatedModules": true, "isolatedModules": true,
"allowJs": true, "allowJs": true,
"checkJs": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"noImplicitAny": true, "noImplicitAny": true,

View File

@@ -1,44 +1,62 @@
version: '3.7' version: '3.7'
x-development-variables: &development-variables
NODE_ENV: "development"
ALLOW_NO_TRANSPORT: "true"
services: services:
database: database:
image: registry.gitlab.com/openstapps/openstapps/database:2.0.0 image: registry.gitlab.com/openstapps/openstapps/database:3.0.0
volumes: # If you need persistence for debugging purposes uncomment the following lines
- ./database:/usr/share/elasticsearch/data #volumes:
# - ./database:/usr/share/elasticsearch/data
expose: expose:
- "9200" - 9200
ports:
- 127.0.0.1:9200:9200
environment:
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms2g -Xmx2g"
- discovery.type=single-node
ulimits:
memlock:
soft: -1
hard: -1
restart: unless-stopped restart: unless-stopped
backend: backend:
image: registry.gitlab.com/openstapps/openstapps/backend:3.0.0-next.0 image: registry.gitlab.com/openstapps/openstapps/backend:3.1.0
environment: environment:
<<: *development-variables
ES_ADDR: "http://database:9200" ES_ADDR: "http://database:9200"
NODE_CONFIG_ENV: "elasticsearch" NODE_CONFIG_ENV: "elasticsearch"
ALLOW_NO_TRANSPORT: "true" NODE_APP_INSTANCE: "f-u"
PROMETHEUS_MIDDLEWARE: "false"
expose: expose:
- 3000 - 3000
ports: ports:
- 3000:3000 - 127.0.0.1:3000:3000
links:
- "database"
labels: labels:
- stapps.version=1.0.0 - stapps.version=4.1.0
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- database - database
api:
image: registry.gitlab.com/openstapps/openstapps/api:3.0.0-next.0
links: links:
- "backend" - database
minimal-connector: # api:
image: registry.gitlab.com/openstapps/minimal-connector:core-0.23 # image: registry.gitlab.com/openstapps/openstapps/api:3.0.0
container_name: minimal-connector-0.23 # links:
command: ["http://backend:3000", "minimal-connector", "f-u"] # - backend
app: # minimal-connector:
image: registry.gitlab.com/openstapps/app/executable:core-0.23 # image: registry.gitlab.com/openstapps/minimal-connector:core-0.23
expose: # container_name: minimal-connector-0.23
- 8100 # command: ["http://backend:3000", "minimal-connector", "f-u"]
ports:
- 8100:8100 # app:
# image: registry.gitlab.com/openstapps/app/executable:core-0.23
# expose:
# - 8100
# ports:
# - 8100:8100

34
flake.lock generated
View File

@@ -1,5 +1,23 @@
{ {
"nodes": { "nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1701626906, "lastModified": 1701626906,
@@ -18,8 +36,24 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

142
flake.nix
View File

@@ -1,77 +1,75 @@
{ {
description = "A Nix-flake-based development environment for OpenStApps"; description = "A Nix-flake-based development environment for OpenStApps";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; inputs = {
outputs = { self, nixpkgs }: nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
let flake-utils.url = "github:numtide/flake-utils";
buildToolsVersion = "30.0.3"; };
overlays = [ outputs = {
(final: prev: rec { self,
nodejs = prev.nodejs-18_x; nixpkgs,
pnpm = prev.nodePackages.pnpm; flake-utils,
chrome = prev.google-chrome; }: let
firefox = prev.firefox; aapt2buildToolsVersion = "33.0.2";
webkit = prev.epiphany; # Safari-ish browser in
android = prev.androidenv.composeAndroidPackages { flake-utils.lib.eachDefaultSystem (system: let
buildToolsVersions = [ "${buildToolsVersion}" ]; pkgs = import nixpkgs {
platformVersions = [ "33" ]; inherit system;
}; overlays = [
cypress = prev.cypress.overrideAttrs(cyPrev: rec { (final: prev: rec {
version = "13.2.0"; fontMin = prev.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
src = prev.fetchzip { android = prev.androidenv.composeAndroidPackages {
url = "https://cdn.cypress.io/desktop/${version}/linux-x64/cypress.zip"; buildToolsVersions = ["30.0.3" aapt2buildToolsVersion];
hash = "sha256-9o0nprGcJhudS1LNm+T7Vf0Dwd1RBauYKI+w1FBQ3ZM="; platformVersions = ["33"];
}; };
}); cypress = prev.cypress.overrideAttrs (cyPrev: rec {
}) version = "13.2.0";
]; src = prev.fetchzip {
# TODO: aarch64-linux, x68_64-darwin, aarch64-darwin url = "https://cdn.cypress.io/desktop/${version}/linux-x64/cypress.zip";
supportedSystems = [ "x86_64-linux" ]; hash = "sha256-9o0nprGcJhudS1LNm+T7Vf0Dwd1RBauYKI+w1FBQ3ZM=";
forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { };
pkgs = import nixpkgs { });
inherit overlays system; })
config = { ];
allowUnfree = true; config = {
android_sdk.accept_license = true; allowUnfree = true;
}; android_sdk.accept_license = true;
}; };
}); };
in androidFhs = pkgs.buildFHSUserEnv {
{ name = "android-env";
devShells = forEachSupportedSystem ({ pkgs }: targetPkgs = pkgs: with pkgs; [];
let runScript = "bash";
python = (pkgs.python311.withPackages(ps: with ps; [ brotli fonttools ] ++ (with fonttools.optional-dependencies; [ ufo lxml unicode woff ]))); profile = ''
in export ALLOW_NINJA_ENV=true
{ export USE_CCACHE=1
default = (pkgs.buildFHSUserEnv { export LD_LIBRARY_PATH=/usr/lib:/usr/lib32
name = "StApps Dev"; '';
targetPkgs = pkgs: with pkgs; [ };
nodejs in {
pnpm devShell = pkgs.mkShell rec {
python nativeBuildInputs = [androidFhs];
docker buildInputs = with pkgs; [
# tools nodejs-18_x
curl nodePackages.pnpm
jq # tools
# browsers curl
firefox jq
chrome fontMin
webkit # browsers
cypress firefox
# android google-chrome
jdk17 epiphany # Safari-ish browser
android.androidsdk cypress
musl # android
]; jdk17
runScript = "bash"; android.androidsdk
profile = '' musl
export CYPRESS_INSTALL_BINARY=0 ];
export CYPRESS_RUN_BINARY=${pkgs.cypress}/bin/Cypress ANDROID_JAVA_HOME = "${pkgs.jdk.home}";
export ANDROID_SDK_ROOT=${pkgs.android.androidsdk}/libexec/android-sdk ANDROID_SDK_ROOT = "${pkgs.android.androidsdk}/libexec/android-sdk";
export ANDROID_JAVA_HOME=${pkgs.jdk.home} GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_SDK_ROOT}/build-tools/${aapt2buildToolsVersion}/aapt2";
export DOCKER_HOST=unix:///run/user/1000/docker.sock CYPRESS_INSTALL_BINARY = "0";
{ dockerd-rootless & } 2>/dev/null CYPRESS_RUN_BINARY = "${pkgs.cypress}/bin/Cypress";
''; };
}).env; });
});
};
} }

View File

@@ -25,7 +25,7 @@ def capacitor_pods
pod 'CapacitorPreferences', :path => '../../../../node_modules/.pnpm/@capacitor+preferences@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/preferences' pod 'CapacitorPreferences', :path => '../../../../node_modules/.pnpm/@capacitor+preferences@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/preferences'
pod 'CapacitorShare', :path => '../../../../node_modules/.pnpm/@capacitor+share@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/share' pod 'CapacitorShare', :path => '../../../../node_modules/.pnpm/@capacitor+share@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/share'
pod 'CapacitorSplashScreen', :path => '../../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/splash-screen' pod 'CapacitorSplashScreen', :path => '../../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/splash-screen'
pod 'TransistorsoftCapacitorBackgroundFetch', :path => '../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@1.0.2_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch' pod 'TransistorsoftCapacitorBackgroundFetch', :path => '../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@5.1.1_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch'
pod 'CapacitorSecureStoragePlugin', :path => '../../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.9.0_@capacitor+core@5.5.0/node_modules/capacitor-secure-storage-plugin' pod 'CapacitorSecureStoragePlugin', :path => '../../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.9.0_@capacitor+core@5.5.0/node_modules/capacitor-secure-storage-plugin'
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
end end

View File

@@ -36,7 +36,7 @@ PODS:
- CordovaPlugins (5.5.0): - CordovaPlugins (5.5.0):
- CapacitorCordova - CapacitorCordova
- SwiftKeychainWrapper (4.0.1) - SwiftKeychainWrapper (4.0.1)
- TransistorsoftCapacitorBackgroundFetch (1.0.2): - TransistorsoftCapacitorBackgroundFetch (5.1.1):
- Capacitor - Capacitor
DEPENDENCIES: DEPENDENCIES:
@@ -58,7 +58,7 @@ DEPENDENCIES:
- "CapacitorShare (from `../../../../node_modules/.pnpm/@capacitor+share@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/share`)" - "CapacitorShare (from `../../../../node_modules/.pnpm/@capacitor+share@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/share`)"
- "CapacitorSplashScreen (from `../../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/splash-screen`)" - "CapacitorSplashScreen (from `../../../../node_modules/.pnpm/@capacitor+splash-screen@5.0.6_@capacitor+core@5.5.0/node_modules/@capacitor/splash-screen`)"
- CordovaPlugins (from `../capacitor-cordova-ios-plugins`) - CordovaPlugins (from `../capacitor-cordova-ios-plugins`)
- "TransistorsoftCapacitorBackgroundFetch (from `../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@1.0.2_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch`)" - "TransistorsoftCapacitorBackgroundFetch (from `../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@5.1.1_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch`)"
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -102,7 +102,7 @@ EXTERNAL SOURCES:
CordovaPlugins: CordovaPlugins:
:path: "../capacitor-cordova-ios-plugins" :path: "../capacitor-cordova-ios-plugins"
TransistorsoftCapacitorBackgroundFetch: TransistorsoftCapacitorBackgroundFetch:
:path: "../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@1.0.2_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch" :path: "../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@5.1.1_@capacitor+core@5.5.0/node_modules/@transistorsoft/capacitor-background-fetch"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Capacitor: 57890b363df14d5d2d5d8461aa23e886cb34da2a Capacitor: 57890b363df14d5d2d5d8461aa23e886cb34da2a
@@ -124,8 +124,8 @@ SPEC CHECKSUMS:
CapacitorSplashScreen: 5fa2ab5e46cf5cc530cf16a51c80c7a986579ccd CapacitorSplashScreen: 5fa2ab5e46cf5cc530cf16a51c80c7a986579ccd
CordovaPlugins: de5669381702d76ed5b1d442177a6a5fc3252a9d CordovaPlugins: de5669381702d76ed5b1d442177a6a5fc3252a9d
SwiftKeychainWrapper: 807ba1d63c33a7d0613288512399cd1eda1e470c SwiftKeychainWrapper: 807ba1d63c33a7d0613288512399cd1eda1e470c
TransistorsoftCapacitorBackgroundFetch: 74ca62dae7ec78639eaf3d0d1e24c595ada213dd TransistorsoftCapacitorBackgroundFetch: ce4b3e01b898cef516e68485d2160a078016ee97
PODFILE CHECKSUM: 073b899f90bacc5049101cb9c562a168757d554e PODFILE CHECKSUM: 229278f2c257e8ab555325c7115b2e187e8e628d
COCOAPODS: 1.13.0 COCOAPODS: 1.13.0

View File

@@ -1,4 +1,3 @@
// @ts-check
/* /*
* Copyright (C) 2022 StApps * Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it * This program is free software: you can redistribute it and/or modify it

View File

@@ -93,6 +93,7 @@
"cordova-plugin-calendar": "5.1.6", "cordova-plugin-calendar": "5.1.6",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deepmerge": "4.3.1", "deepmerge": "4.3.1",
"fast-deep-equal": "3.1.3",
"form-data": "4.0.0", "form-data": "4.0.0",
"geojson": "0.5.0", "geojson": "0.5.0",
"ionic-appauth": "0.9.0", "ionic-appauth": "0.9.0",

View File

@@ -0,0 +1,80 @@
import {Animation, AnimationController} from '@ionic/angular';
import {iosDuration, iosEasing, mdDuration, mdEasing} from './easings';
/**
* Splash screen animation
*/
export function splashAnimation(animationCtl: AnimationController): Animation {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
return animationCtl
.create()
.fromTo('opacity', 0, 1)
.duration(150)
.beforeClearStyles(['visibility'])
.addElement(document.querySelector('ion-app')!);
}
const isMd = document.querySelector('ion-app.md') !== null;
const navElement = document.querySelector('stapps-navigation-tabs')!;
const navBounds = navElement.getBoundingClientRect();
let horizontal = navBounds.width < navBounds.height;
if (window.getComputedStyle(navElement).display === 'none') {
horizontal = true;
}
const translate = (amount: number, unit = 'px') =>
`translate${horizontal ? 'X' : 'Y'}(${horizontal ? amount * -1 : amount}${unit})`;
const duration = 2 * (isMd ? mdDuration : iosDuration);
const animation = animationCtl
.create()
.duration(duration)
.easing(isMd ? mdEasing : iosEasing)
.addAnimation(
animationCtl.create().beforeClearStyles(['visibility']).addElement(document.querySelector('ion-app')!),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(horizontal ? 64 : 192), translate(0))
.fromTo('opacity', 0, 1)
.addElement(document.querySelector('stapps-navigation > ion-split-pane')!),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(64), translate(0))
.addElement(document.querySelectorAll('ion-split-pane > ion-menu > ion-content')),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(horizontal ? 32 : -72), translate(0))
.addElement(document.querySelectorAll('ion-router-outlet > .ion-page > ion-content')!),
)
.addAnimation(
animationCtl
.create()
.fromTo('transform', translate(100, '%'), translate(0, '%'))
.addElement(document.querySelector('stapps-navigation-tabs')!),
);
if (!horizontal) {
animation.addAnimation(
animationCtl
.create()
.fromTo('background', 'none', 'none')
.addElement(document.querySelector('ion-router-outlet')!),
);
const parallax = document
.querySelector('ion-router-outlet > .ion-page > ion-content')
?.shadowRoot?.querySelector('[part=parallax]');
if (parallax) {
animation.addAnimation(
animationCtl.create().fromTo('translate', '0 256px', '0 0px').addElement(parallax),
);
}
}
return animation;
}

View File

@@ -22,28 +22,16 @@ import {environment} from '../environments/environment';
import {Capacitor} from '@capacitor/core'; import {Capacitor} from '@capacitor/core';
import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service'; import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service';
import {Keyboard, KeyboardResize} from '@capacitor/keyboard'; import {Keyboard, KeyboardResize} from '@capacitor/keyboard';
import {AppVersionService} from './modules/about/app-version.service';
import {SplashScreen} from '@capacitor/splash-screen'; import {SplashScreen} from '@capacitor/splash-screen';
import {AppVersionService} from './modules/about/app-version.service';
/**
* TODO
*/
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: 'app.component.html', templateUrl: 'app.component.html',
}) })
export class AppComponent implements AfterContentInit { export class AppComponent implements AfterContentInit {
/**
* TODO
*/
pages: Array<{ pages: Array<{
/**
* TODO
*/
component: unknown; component: unknown;
/**
* TODO
*/
title: string; title: string;
}>; }>;
@@ -65,7 +53,7 @@ export class AppComponent implements AfterContentInit {
void this.initializeApp(); void this.initializeApp();
} }
async ngAfterContentInit() { ngAfterContentInit() {
this.scheduleSyncService.init(); this.scheduleSyncService.init();
void this.scheduleSyncService.enable(); void this.scheduleSyncService.enable();
this.versionService.getPendingReleaseNotes().then(notes => { this.versionService.getPendingReleaseNotes().then(notes => {
@@ -74,24 +62,11 @@ export class AppComponent implements AfterContentInit {
} }
}); });
if (document.readyState === 'complete') {
this.hideSplash();
} else {
document.addEventListener('readystatechange', () => {
if (document.readyState === 'complete') this.hideSplash();
});
}
}
async hideSplash() {
if (Capacitor.isNativePlatform()) { if (Capacitor.isNativePlatform()) {
void SplashScreen.hide(); void SplashScreen.hide();
} }
} }
/**
* TODO
*/
async initializeApp() { async initializeApp() {
App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => { App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => {
this.zone.run(() => { this.zone.run(() => {

View File

@@ -25,12 +25,10 @@ import moment from 'moment';
import 'moment/min/locales'; import 'moment/min/locales';
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger'; import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
import SwiperCore, {FreeMode, Navigation} from 'swiper'; import SwiperCore, {FreeMode, Navigation} from 'swiper';
import {environment} from '../environments/environment'; import {environment} from '../environments/environment';
import {AppRoutingModule} from './app-routing.module'; import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component'; import {AppComponent} from './app.component';
import {CatalogModule} from './modules/catalog/catalog.module'; import {CatalogModule} from './modules/catalog/catalog.module';
import {ConfigModule} from './modules/config/config.module';
import {ConfigProvider} from './modules/config/config.provider'; import {ConfigProvider} from './modules/config/config.provider';
import {DashboardModule} from './modules/dashboard/dashboard.module'; import {DashboardModule} from './modules/dashboard/dashboard.module';
import {DataModule} from './modules/data/data.module'; import {DataModule} from './modules/data/data.module';
@@ -44,7 +42,6 @@ import {SettingsProvider} from './modules/settings/settings.provider';
import {StorageModule} from './modules/storage/storage.module'; import {StorageModule} from './modules/storage/storage.module';
import {ThingTranslateModule} from './translation/thing-translate.module'; import {ThingTranslateModule} from './translation/thing-translate.module';
import {UtilModule} from './util/util.module'; import {UtilModule} from './util/util.module';
import {initLogger} from './_helpers/ts-logger';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {AboutModule} from './modules/about/about.module'; import {AboutModule} from './modules/about/about.module';
import {JobModule} from './modules/jobs/jobs.module'; import {JobModule} from './modules/jobs/jobs.module';
@@ -91,28 +88,25 @@ export function initializerFactory(
) { ) {
return async () => { return async () => {
try { try {
initLogger(logger);
await storageProvider.init(); await storageProvider.init();
await configProvider.init(); await configProvider.init();
await settingsProvider.init(); if (configProvider.firstSession) {
// set language from browser
await settingsProvider.setSettingValue(
'profile',
'language',
translateService.getBrowserLang() as SCSettingValue,
);
}
const languageCode = await settingsProvider.getSetting<string>('profile', 'language');
// this language will be used as a fallback when a translation isn't found in the current language
translateService.setDefaultLang('en');
translateService.use(languageCode);
moment.locale(languageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode);
setDefaultOptions({locale: dateFnsLocale});
dateFnsConfigurationService.setLocale(dateFnsLocale);
try { try {
if (configProvider.firstSession) {
// set language from browser
await settingsProvider.setSettingValue(
'profile',
'language',
translateService.getBrowserLang() as SCSettingValue,
);
}
const languageCode = (await settingsProvider.getValue('profile', 'language')) as string;
// this language will be used as a fallback when a translation isn't found in the current language
translateService.setDefaultLang('en');
translateService.use(languageCode);
moment.locale(languageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode);
setDefaultOptions({locale: dateFnsLocale});
dateFnsConfigurationService.setLocale(dateFnsLocale);
await defaultAuthService.init(); await defaultAuthService.init();
await paiaAuthService.init(); await paiaAuthService.init();
} catch (error) { } catch (error) {
@@ -151,11 +145,12 @@ export function createTranslateLoader(http: HttpClient) {
BrowserAnimationsModule, BrowserAnimationsModule,
CatalogModule, CatalogModule,
CommonModule, CommonModule,
ConfigModule,
DashboardModule, DashboardModule,
DataModule, DataModule,
HebisModule, HebisModule,
IonicModule.forRoot(), IonicModule.forRoot({
animated: 'Cypress' in window ? false : undefined,
}),
IonIconModule, IonIconModule,
JobModule, JobModule,
FavoritesModule, FavoritesModule,

View File

@@ -14,7 +14,7 @@
*/ */
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router'; import {ActivatedRoute} from '@angular/router';
import {SCAboutPage, SCAppConfiguration} from '@openstapps/core'; import {SCAboutPage} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider'; import {ConfigProvider} from '../../config/config.provider';
import packageJson from '../../../../../package.json'; import packageJson from '../../../../../package.json';
import config from 'capacitor.config'; import config from 'capacitor.config';
@@ -42,8 +42,7 @@ export class AboutPageComponent implements OnInit {
async ngOnInit() { async ngOnInit() {
const route = this.route.snapshot.url.map(it => it.path).join('/'); const route = this.route.snapshot.url.map(it => it.path).join('/');
this.content = this.content = this.configProvider.config.app.aboutPages[route] ?? {};
(this.configProvider.getValue('aboutPages') as SCAppConfiguration['aboutPages'])[route] ?? {};
this.version = Capacitor.getPlatform() === 'web' ? 'Web' : await App.getInfo().then(info => info.version); this.version = Capacitor.getPlatform() === 'web' ? 'Web' : await App.getInfo().then(info => info.version);
} }
} }

View File

@@ -19,7 +19,6 @@ import {FormsModule} from '@angular/forms';
import {IonicModule} from '@ionic/angular'; import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {AboutPageComponent} from './about-page/about-page.component'; import {AboutPageComponent} from './about-page/about-page.component';
import {MarkdownModule} from 'ngx-markdown'; import {MarkdownModule} from 'ngx-markdown';
import {AboutPageContentComponent} from './about-page/about-page-content.component'; import {AboutPageContentComponent} from './about-page/about-page-content.component';
@@ -64,6 +63,5 @@ const settingsRoutes: Routes = [
ScrollingModule, ScrollingModule,
UtilModule, UtilModule,
], ],
providers: [ConfigProvider],
}) })
export class AboutModule {} export class AboutModule {}

View File

@@ -18,12 +18,7 @@ import {IPAIAAuthAction} from './paia/paia-auth-action';
import {AuthActions, IAuthAction} from 'ionic-appauth'; import {AuthActions, IAuthAction} from 'ionic-appauth';
import {TranslateService} from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
import {JSONPath} from 'jsonpath-plus'; import {JSONPath} from 'jsonpath-plus';
import { import {SCAuthorizationProviderType, SCUserConfiguration} from '@openstapps/core';
SCAuthorizationProvider,
SCAuthorizationProviderType,
SCUserConfiguration,
SCUserConfigurationMap,
} from '@openstapps/core';
import {ConfigProvider} from '../config/config.provider'; import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider'; import {StorageProvider} from '../storage/storage.provider';
import {DefaultAuthService} from './default-auth.service'; import {DefaultAuthService} from './default-auth.service';
@@ -37,8 +32,6 @@ const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path';
providedIn: 'root', providedIn: 'root',
}) })
export class AuthHelperService { export class AuthHelperService {
userConfigurationMap: SCUserConfigurationMap;
constructor( constructor(
private translateService: TranslateService, private translateService: TranslateService,
private configProvider: ConfigProvider, private configProvider: ConfigProvider,
@@ -47,14 +40,7 @@ export class AuthHelperService {
private paiaAuth: PAIAAuthService, private paiaAuth: PAIAAuthService,
private browser: SimpleBrowser, private browser: SimpleBrowser,
private alertController: AlertController, private alertController: AlertController,
) { ) {}
this.userConfigurationMap =
(
this.configProvider.getAnyValue('auth') as {
default: SCAuthorizationProvider;
}
).default?.endpoints.mapping ?? {};
}
public getAuthMessage(provider: SCAuthorizationProviderType, action: IAuthAction | IPAIAAuthAction) { public getAuthMessage(provider: SCAuthorizationProviderType, action: IAuthAction | IPAIAAuthAction) {
let message: string | undefined; let message: string | undefined;
@@ -77,9 +63,10 @@ export class AuthHelperService {
name: '', name: '',
role: 'student', role: 'student',
}; };
for (const key in this.userConfigurationMap) { const mapping = this.configProvider.config.auth.default!.endpoints.mapping;
for (const key in mapping) {
user[key as keyof SCUserConfiguration] = JSONPath({ user[key as keyof SCUserConfiguration] = JSONPath({
path: this.userConfigurationMap[key as keyof SCUserConfiguration] as string, path: mapping[key as keyof SCUserConfiguration] as string,
json: userInfo, json: userInfo,
preventEval: true, preventEval: true,
})[0]; })[0];

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { import {
AuthorizationRequestHandler, AuthorizationRequestHandler,
AuthorizationServiceConfiguration, AuthorizationServiceConfiguration,
@@ -24,7 +23,6 @@ import {
} from '@openid/appauth'; } from '@openid/appauth';
import {AuthActionBuilder, Browser, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth'; import {AuthActionBuilder, Browser, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth';
import {ConfigProvider} from '../config/config.provider'; import {ConfigProvider} from '../config/config.provider';
import {SCAuthorizationProvider} from '@openstapps/core';
import {getClientConfig, getEndpointsConfig} from './auth.provider.methods'; import {getClientConfig, getEndpointsConfig} from './auth.provider.methods';
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {AuthService} from './auth.service'; import {AuthService} from './auth.service';
@@ -67,12 +65,9 @@ export class DefaultAuthService extends AuthService {
} }
setupConfiguration() { setupConfiguration() {
const authConfig = this.configProvider.getAnyValue('auth') as { this.authConfig = getClientConfig('default', this.configProvider.config.auth);
default: SCAuthorizationProvider;
};
this.authConfig = getClientConfig('default', authConfig);
this.localConfiguration = new AuthorizationServiceConfiguration( this.localConfiguration = new AuthorizationServiceConfiguration(
getEndpointsConfig('default', authConfig), getEndpointsConfig('default', this.configProvider.config.auth),
); );
} }

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { import {
AuthorizationError, AuthorizationError,
AuthorizationRequest, AuthorizationRequest,
@@ -47,7 +46,6 @@ import {PAIAAuthorizationResponse} from './paia-authorization-response';
import {PAIAAuthorizationNotifier} from './paia-authorization-notifier'; import {PAIAAuthorizationNotifier} from './paia-authorization-notifier';
import {PAIATokenResponse} from './paia-token-response'; import {PAIATokenResponse} from './paia-token-response';
import {IPAIAAuthAction, PAIAAuthActionBuilder} from './paia-auth-action'; import {IPAIAAuthAction, PAIAAuthActionBuilder} from './paia-auth-action';
import {SCAuthorizationProvider} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider'; import {ConfigProvider} from '../../config/config.provider';
import {getClientConfig, getEndpointsConfig} from '../auth.provider.methods'; import {getClientConfig, getEndpointsConfig} from '../auth.provider.methods';
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
@@ -154,11 +152,10 @@ export class PAIAAuthService {
} }
setupConfiguration() { setupConfiguration() {
const authConfig = this.configProvider.getAnyValue('auth') as { this.authConfig = getClientConfig('paia', this.configProvider.config.auth);
paia: SCAuthorizationProvider; this.localConfiguration = new AuthorizationServiceConfiguration(
}; getEndpointsConfig('paia', this.configProvider.config.auth),
this.authConfig = getClientConfig('paia', authConfig); );
this.localConfiguration = new AuthorizationServiceConfiguration(getEndpointsConfig('paia', authConfig));
} }
protected notifyActionListers(action: IPAIAAuthAction) { protected notifyActionListers(action: IPAIAAuthAction) {

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Calendar} from '@awesome-cordova-plugins/calendar/ngx'; import {Calendar} from '@awesome-cordova-plugins/calendar/ngx';
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {ICalEvent} from './ical/ical'; import {ICalEvent} from './ical/ical';
@@ -35,14 +34,14 @@ export class CalendarService {
goToDateClicked = this.goToDate.asObservable(); goToDateClicked = this.goToDate.asObservable();
calendarName = 'StApps'; calendarName: string;
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
constructor( constructor(
readonly calendar: Calendar, readonly calendar: Calendar,
private readonly configProvider: ConfigProvider, private readonly configProvider: ConfigProvider,
) { ) {
this.calendarName = (this.configProvider.getValue('name') as string) ?? 'StApps'; this.calendarName = this.configProvider.config.app.name ?? 'StApps';
} }
async createCalendar(): Promise<CalendarInfo | undefined> { async createCalendar(): Promise<CalendarInfo | undefined> {

View File

@@ -20,7 +20,6 @@ import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {MomentModule} from 'ngx-moment'; import {MomentModule} from 'ngx-moment';
import {DataModule} from '../data/data.module'; import {DataModule} from '../data/data.module';
import {SettingsProvider} from '../settings/settings.provider';
import {CatalogComponent} from './catalog.component'; import {CatalogComponent} from './catalog.component';
import {UtilModule} from '../../util/util.module'; import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module'; import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
@@ -46,6 +45,5 @@ const catalogRoutes: Routes = [
DataModule, DataModule,
UtilModule, UtilModule,
], ],
providers: [SettingsProvider],
}) })
export class CatalogModule {} export class CatalogModule {}

View File

@@ -16,12 +16,6 @@ import {TestBed} from '@angular/core/testing';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider'; import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {StorageProvider} from '../storage/storage.provider'; import {StorageProvider} from '../storage/storage.provider';
import {ConfigProvider, STORAGE_KEY_CONFIG} from './config.provider'; import {ConfigProvider, STORAGE_KEY_CONFIG} from './config.provider';
import {
ConfigFetchError,
ConfigInitError,
SavedConfigNotAvailable,
WrongConfigVersionInStorage,
} from './errors';
import {NGXLogger} from 'ngx-logger'; import {NGXLogger} from 'ngx-logger';
import {sampleIndexResponse} from '../../_helpers/data/sample-configuration'; import {sampleIndexResponse} from '../../_helpers/data/sample-configuration';

View File

@@ -14,19 +14,14 @@
*/ */
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {Client} from '@openstapps/api'; import {Client} from '@openstapps/api';
import {SCAppConfiguration, SCIndexResponse} from '@openstapps/core'; import {SCIndexResponse} from '@openstapps/core';
import packageInfo from '@openstapps/core/package.json'; import packageInfo from '@openstapps/core/package.json';
import {NGXLogger} from 'ngx-logger'; import {NGXLogger} from 'ngx-logger';
import {environment} from '../../../environments/environment'; import {environment} from '../../../environments/environment';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider'; import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {StorageProvider} from '../storage/storage.provider'; import {StorageProvider} from '../storage/storage.provider';
import { import equals from 'fast-deep-equal/es6';
ConfigFetchError, import {BehaviorSubject} from 'rxjs';
ConfigInitError,
ConfigValueNotAvailable,
SavedConfigNotAvailable,
WrongConfigVersionInStorage,
} from './errors';
/** /**
* Key to store config in storage module * Key to store config in storage module
@@ -35,6 +30,17 @@ import {
*/ */
export const STORAGE_KEY_CONFIG = 'stapps.config'; export const STORAGE_KEY_CONFIG = 'stapps.config';
/**
* Makes an object deeply immutable
*/
function deepFreeze<T extends object>(object: T) {
for (const key of Object.keys(object)) {
const value = (object as Record<string, unknown>)[key];
if (typeof value === 'object' && !Object.isFrozen(value)) deepFreeze(value!);
}
return Object.freeze(object);
}
/** /**
* Provides configuration * Provides configuration
*/ */
@@ -50,7 +56,7 @@ export class ConfigProvider {
/** /**
* App configuration as IndexResponse * App configuration as IndexResponse
*/ */
config: SCIndexResponse; config: Readonly<SCIndexResponse>;
/** /**
* Version of the @openstapps/core package that app is using * Version of the @openstapps/core package that app is using
@@ -62,6 +68,11 @@ export class ConfigProvider {
*/ */
firstSession = true; firstSession = true;
/**
* If the config requires an update
*/
needsUpdate$ = new BehaviorSubject(false);
/** /**
* Constructor, initialise api client * Constructor, initialise api client
* @param storageProvider StorageProvider to load persistent configuration * @param storageProvider StorageProvider to load persistent configuration
@@ -76,104 +87,35 @@ export class ConfigProvider {
this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version); this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version);
} }
/**
* Fetches configuration from backend
*/
async fetch(): Promise<SCIndexResponse> {
try {
return await this.client.handshake(this.scVersion);
} catch {
throw new ConfigFetchError();
}
}
/**
* Returns the value of an app configuration
* @param attribute requested attribute from app configuration
*/
public getValue(attribute: keyof SCAppConfiguration) {
if (this.config.app[attribute] !== undefined) {
return this.config.app[attribute];
}
throw new ConfigValueNotAvailable(attribute);
}
/**
* Returns a value of the configuration (not only app configuration)
* @param attribute requested attribute from the configuration
*/
public getAnyValue(attribute: keyof SCIndexResponse) {
if (this.config[attribute] !== undefined) {
return this.config[attribute];
}
throw new ConfigValueNotAvailable(attribute);
}
/** /**
* Initialises the ConfigProvider * Initialises the ConfigProvider
* @throws ConfigInitError if no configuration could be loaded.
* @throws WrongConfigVersionInStorage if fetch failed and saved config has wrong SCVersion
*/ */
async init(): Promise<void> { async init(): Promise<void> {
let loadError; this.config = (await this.storageProvider.has(STORAGE_KEY_CONFIG))
let fetchError; ? await this.storageProvider.get<SCIndexResponse>(STORAGE_KEY_CONFIG)
// load saved configuration : undefined!;
try { this.firstSession = !this.config;
this.config = await this.loadLocal();
this.firstSession = false; const updatedConfig = this.client.handshake(this.scVersion).then(async fetchedConfig => {
this.logger.log(`initialised configuration from storage`); if (!equals(fetchedConfig, this.config)) {
if (this.config.backend.SCVersion !== this.scVersion) { await this.storageProvider.put(STORAGE_KEY_CONFIG, fetchedConfig);
loadError = new WrongConfigVersionInStorage(this.scVersion, this.config.backend.SCVersion); this.logger.log(`Config updated`);
this.needsUpdate$.next(true);
this.needsUpdate$.complete();
} }
} catch (error) { return fetchedConfig;
loadError = error; });
}
// fetch remote configuration from backend
try {
const fetchedConfig: SCIndexResponse = await this.fetch();
await this.set(fetchedConfig);
this.logger.log(`initialised configuration from remote`);
} catch (error) {
fetchError = error;
}
// check for occurred errors and throw them
if (loadError !== undefined && fetchError !== undefined) {
throw new ConfigInitError();
}
if (loadError !== undefined) {
this.logger.warn(loadError);
}
if (fetchError !== undefined) {
this.logger.warn(fetchError);
}
}
/** this.config ??= await updatedConfig;
* Returns saved configuration from StorageModule this.config = deepFreeze(this.config);
* @throws SavedConfigNotAvailable if no configuration could be loaded
*/ if (this.config.backend.SCVersion !== this.scVersion) {
async loadLocal(): Promise<SCIndexResponse> { this.logger.warn(
// get local configuration 'Incompatible config, expected',
if (await this.storageProvider.has(STORAGE_KEY_CONFIG)) { this.scVersion,
return this.storageProvider.get<SCIndexResponse>(STORAGE_KEY_CONFIG); 'but got',
this.config.backend.SCVersion,
);
} }
throw new SavedConfigNotAvailable();
}
/**
* Saves the configuration from the provider
* @param config configuration to save
*/
async save(config: SCIndexResponse): Promise<void> {
await this.storageProvider.put(STORAGE_KEY_CONFIG, config);
}
/**
* Sets the configuration in the module and writes it into app storage
* @param config SCIndexResponse to set
*/
async set(config: SCIndexResponse): Promise<void> {
this.config = config;
await this.save(this.config);
} }
} }

View File

@@ -1,65 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AppError} from '../../_helpers/errors';
/**
* Error that is thrown when fetching from backend fails
*/
export class ConfigFetchError extends AppError {
constructor() {
super('ConfigFetchError', 'App configuration could not be fetched!');
}
}
/**
* Error that is thrown when the ConfigProvider could be initialised
*/
export class ConfigInitError extends AppError {
constructor() {
super('ConfigInitError', 'App configuration could not be initialised!');
}
}
/**
* Error that is thrown when the requested config value is not available
*/
export class ConfigValueNotAvailable extends AppError {
constructor(valueKey: string) {
super('ConfigValueNotAvailable', `No attribute "${valueKey}" in config available!`);
}
}
/**
* Error that is thrown when no saved config is available
*/
export class SavedConfigNotAvailable extends AppError {
constructor() {
super('SavedConfigNotAvailable', 'No saved app configuration available.');
}
}
/**
* Error that is thrown when the SCVersion of the saved config is not compatible with the app
*/
export class WrongConfigVersionInStorage extends AppError {
constructor(correctVersion: string, savedVersion: string) {
super(
'WrongConfigVersionInStorage',
`The saved configs backend version ${savedVersion} ` +
`does not equal the configured backend version ${correctVersion} of the app.`,
);
}
}

View File

@@ -21,7 +21,6 @@ import {SwiperModule} from 'swiper/angular';
import {TranslateModule, TranslatePipe} from '@ngx-translate/core'; import {TranslateModule, TranslatePipe} from '@ngx-translate/core';
import {MomentModule} from 'ngx-moment'; import {MomentModule} from 'ngx-moment';
import {DataModule} from '../data/data.module'; import {DataModule} from '../data/data.module';
import {SettingsProvider} from '../settings/settings.provider';
import {DashboardComponent} from './dashboard.component'; import {DashboardComponent} from './dashboard.component';
import {SearchSectionComponent} from './sections/search-section/search-section.component'; import {SearchSectionComponent} from './sections/search-section/search-section.component';
import {NewsSectionComponent} from './sections/news-section/news-section.component'; import {NewsSectionComponent} from './sections/news-section/news-section.component';
@@ -70,6 +69,6 @@ const catalogRoutes: Routes = [
NewsModule, NewsModule,
JobModule, JobModule,
], ],
providers: [SettingsProvider, TranslatePipe], providers: [TranslatePipe],
}) })
export class DashboardModule {} export class DashboardModule {}

View File

@@ -32,7 +32,6 @@ import {ScheduleProvider} from '../calendar/schedule.provider';
import {GeoNavigationDirective} from '../map/geo-navigation.directive'; import {GeoNavigationDirective} from '../map/geo-navigation.directive';
import {MapWidgetComponent} from '../map/widget/map-widget.component'; import {MapWidgetComponent} from '../map/widget/map-widget.component';
import {MenuModule} from '../menu/menu.module'; import {MenuModule} from '../menu/menu.module';
import {SettingsProvider} from '../settings/settings.provider';
import {StorageModule} from '../storage/storage.module'; import {StorageModule} from '../storage/storage.module';
import {ActionChipListComponent} from './chips/action-chip-list.component'; import {ActionChipListComponent} from './chips/action-chip-list.component';
import {AddEventActionChipComponent} from './chips/data/add-event-action-chip.component'; import {AddEventActionChipComponent} from './chips/data/add-event-action-chip.component';
@@ -214,7 +213,6 @@ import {ShareButtonComponent} from './elements/share-button.component';
StAppsWebHttpClient, StAppsWebHttpClient,
CalendarService, CalendarService,
RoutingStackService, RoutingStackService,
SettingsProvider,
{ {
provide: SimpleBrowser, provide: SimpleBrowser,
useFactory: browserFactory, useFactory: browserFactory,

View File

@@ -15,13 +15,13 @@
import {Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef} from '@angular/core'; import {Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import {ModalController} from '@ionic/angular'; import {ModalController} from '@ionic/angular';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import {SCSaveableThing, SCThings, SCUuid} from '@openstapps/core';
import {SCLanguageCode, SCSaveableThing, SCThings, SCUuid} from '@openstapps/core';
import {DataProvider, DataScope} from '../data.provider'; import {DataProvider, DataScope} from '../data.provider';
import {FavoritesService} from '../../favorites/favorites.service'; import {FavoritesService} from '../../favorites/favorites.service';
import {take} from 'rxjs/operators'; import {take} from 'rxjs/operators';
import {Network} from '@capacitor/network'; import {Network} from '@capacitor/network';
import {DataListContext} from '../list/data-list.component'; import {DataListContext} from '../list/data-list.component';
import {lastValueFrom} from 'rxjs';
export interface ExternalDataLoadEvent { export interface ExternalDataLoadEvent {
uid: SCUuid; uid: SCUuid;
@@ -29,6 +29,13 @@ export interface ExternalDataLoadEvent {
resolve: (item: SCThings | null | undefined) => void; resolve: (item: SCThings | null | undefined) => void;
} }
/**
* Type guard for SCSavableThing
*/
function isSCSavableThing(thing: SCThings | SCSaveableThing): thing is SCSaveableThing {
return (thing as SCSaveableThing).data !== undefined;
}
/** /**
* A Component to display an SCThing detailed * A Component to display an SCThing detailed
*/ */
@@ -53,11 +60,6 @@ export class DataDetailComponent implements OnInit {
@Input() autoRouteDataPath = true; @Input() autoRouteDataPath = true;
/**
* The language of the item
*/
language: SCLanguageCode;
/** /**
* Indicating wether internet connectivity is given or not * Indicating wether internet connectivity is given or not
*/ */
@@ -79,20 +81,12 @@ export class DataDetailComponent implements OnInit {
@Output() loadItem: EventEmitter<ExternalDataLoadEvent> = new EventEmitter<ExternalDataLoadEvent>(); @Output() loadItem: EventEmitter<ExternalDataLoadEvent> = new EventEmitter<ExternalDataLoadEvent>();
/**
* Type guard for SCSavableThing
*/
static isSCSavableThing(thing: SCThings | SCSaveableThing): thing is SCSaveableThing {
return (thing as SCSaveableThing).data !== undefined;
}
constructor( constructor(
protected readonly route: ActivatedRoute, protected readonly route: ActivatedRoute,
router: Router, router: Router,
private readonly dataProvider: DataProvider, private readonly dataProvider: DataProvider,
private readonly favoritesService: FavoritesService, private readonly favoritesService: FavoritesService,
readonly modalController: ModalController, readonly modalController: ModalController,
translateService: TranslateService,
) { ) {
this.inputItem = router.getCurrentNavigation()?.extras.state?.item; this.inputItem = router.getCurrentNavigation()?.extras.state?.item;
if (!this.inputItem?.origin) { if (!this.inputItem?.origin) {
@@ -100,10 +94,6 @@ export class DataDetailComponent implements OnInit {
// This can happen, for example, when detail views use `inPlace` list items // This can happen, for example, when detail views use `inPlace` list items
delete this.inputItem; delete this.inputItem;
} }
this.language = translateService.currentLang as SCLanguageCode;
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
this.language = event.lang as SCLanguageCode;
});
this.isDisconnected = new Promise(async resolve => { this.isDisconnected = new Promise(async resolve => {
const isConnected = (await Network.getStatus()).connected; const isConnected = (await Network.getStatus()).connected;
@@ -126,13 +116,8 @@ export class DataDetailComponent implements OnInit {
) )
: this.dataProvider.get(uid, DataScope.Remote))); : this.dataProvider.get(uid, DataScope.Remote)));
this.item = item // eslint-disable-next-line unicorn/no-null
? // eslint-disable-next-line unicorn/no-null this.item = item ? (isSCSavableThing(item) ? item.data : item) : null;
DataDetailComponent.isSCSavableThing(item)
? item.data
: item
: // eslint-disable-next-line unicorn/no-null
null;
} catch { } catch {
// eslint-disable-next-line unicorn/no-null // eslint-disable-next-line unicorn/no-null
this.item = null; this.item = null;
@@ -144,14 +129,10 @@ export class DataDetailComponent implements OnInit {
await this.getItem(uid ?? '', false); await this.getItem(uid ?? '', false);
// fallback to the saved item (from favorites) // fallback to the saved item (from favorites)
if (this.item === null) { if (this.item === null) {
this.favoritesService const item = await lastValueFrom(this.favoritesService.get(uid).pipe(take(1)));
.get(uid) if (item) {
.pipe(take(1)) this.item = item.data;
.subscribe(item => { }
if (item !== undefined) {
this.item = item.data;
}
});
} }
} }
} }

View File

@@ -30,8 +30,8 @@ export class OffersInListComponent {
@Input() set offers(it: Array<SCThingThatCanBeOfferedOffer<SCAcademicPriceGroup>>) { @Input() set offers(it: Array<SCThingThatCanBeOfferedOffer<SCAcademicPriceGroup>>) {
this._offers = it; this._offers = it;
this.price = it[0].prices?.default; this.price = it[0].prices?.default;
this.settingsProvider.getSetting('profile', 'group').then(group => { this.settingsProvider.getSetting<string>('profile', 'group').then(group => {
this.price = it[0].prices?.[(group.value as string).replace(/s$/, '') as never]; this.price = it[0].prices?.[group.replace(/s$/, '') as never];
}); });
const availabilities = new Set(it.map(offer => offer.availability)); const availabilities = new Set(it.map(offer => offer.availability));

View File

@@ -17,14 +17,7 @@ import {ActivatedRoute, Router} from '@angular/router';
import {Keyboard} from '@capacitor/keyboard'; import {Keyboard} from '@capacitor/keyboard';
import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular'; import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular';
import {Capacitor} from '@capacitor/core'; import {Capacitor} from '@capacitor/core';
import { import {SCFacet, SCSearchFilter, SCSearchQuery, SCSearchSort, SCThings} from '@openstapps/core';
SCFacet,
SCFeatureConfiguration,
SCSearchFilter,
SCSearchQuery,
SCSearchSort,
SCThings,
} from '@openstapps/core';
import {NGXLogger} from 'ngx-logger'; import {NGXLogger} from 'ngx-logger';
import {combineLatest, Subject} from 'rxjs'; import {combineLatest, Subject} from 'rxjs';
import {debounceTime, distinctUntilChanged, startWith} from 'rxjs/operators'; import {debounceTime, distinctUntilChanged, startWith} from 'rxjs/operators';
@@ -170,9 +163,8 @@ export class SearchPageComponent implements OnInit {
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
protected positionService: PositionService, protected positionService: PositionService,
private readonly configProvider: ConfigProvider, private readonly configProvider: ConfigProvider,
animationController: AnimationController,
) { ) {
this.routeAnimation = searchPageSwitchAnimation(animationController); this.routeAnimation = searchPageSwitchAnimation(inject(AnimationController));
} }
/** /**
@@ -323,16 +315,6 @@ export class SearchPageComponent implements OnInit {
this.queryChanged.next(); this.queryChanged.next();
} }
}); });
this.settingsProvider.settingsActionChanged$
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
});
this.dataRoutingService this.dataRoutingService
.itemSelectListener() .itemSelectListener()
.pipe(takeUntilDestroyed(this.destroy$)) .pipe(takeUntilDestroyed(this.destroy$))
@@ -342,12 +324,8 @@ export class SearchPageComponent implements OnInit {
} }
}); });
} }
try { this.isHebisAvailable =
const features = this.configProvider.getValue('features') as SCFeatureConfiguration; this.configProvider.config.app.features.plugins?.['hebis-plugin']?.urlPath !== undefined;
this.isHebisAvailable = !!features.plugins?.['hebis-plugin']?.urlPath;
} catch (error) {
this.logger.error(error);
}
} }
/** /**

View File

@@ -21,7 +21,6 @@ import {
SCRatingResponse, SCRatingResponse,
SCRatingRoute, SCRatingRoute,
SCUserGroup, SCUserGroup,
SCUserGroupSetting,
SCUuid, SCUuid,
} from '@openstapps/core'; } from '@openstapps/core';
import {StAppsWebHttpClient} from './stapps-web-http-client.provider'; import {StAppsWebHttpClient} from './stapps-web-http-client.provider';
@@ -63,9 +62,7 @@ export class RatingProvider {
} }
private get userGroup(): Promise<SCUserGroup> { private get userGroup(): Promise<SCUserGroup> {
return this.settingsProvider return this.settingsProvider.getSetting<SCUserGroup>('profile', 'group');
.getSetting('profile', 'group')
.then(it => (it as SCUserGroupSetting).value as SCUserGroup);
} }
private async getStoredRatings(): Promise<RatingStorage> { private async getStoredRatings(): Promise<RatingStorage> {

View File

@@ -75,7 +75,7 @@ export class PlaceMensaService {
sort: [ sort: [
{ {
arguments: { arguments: {
field: `offers.prices.${(priceGroup.value as string).replace(/s$/, '')}`, field: `offers.prices.${(priceGroup as string).replace(/s$/, '')}`,
}, },
order: 'desc', order: 'desc',
type: 'generic', type: 'generic',

View File

@@ -12,21 +12,13 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, OnInit} from '@angular/core'; import {Component, inject, OnInit} from '@angular/core';
import {AlertController, AnimationController} from '@ionic/angular';
import {ActivatedRoute, Router} from '@angular/router';
import {NGXLogger} from 'ngx-logger';
import {debounceTime, distinctUntilChanged, startWith, take} from 'rxjs/operators'; import {debounceTime, distinctUntilChanged, startWith, take} from 'rxjs/operators';
import {combineLatest} from 'rxjs'; import {combineLatest} from 'rxjs';
import {SCThingType} from '@openstapps/core'; import {SCThingType} from '@openstapps/core';
import {FavoritesService} from './favorites.service'; import {FavoritesService} from './favorites.service';
import {DataRoutingService} from '../data/data-routing.service';
import {ContextMenuService} from '../menu/context/context-menu.service'; import {ContextMenuService} from '../menu/context/context-menu.service';
import {SearchPageComponent} from '../data/list/search-page.component'; import {SearchPageComponent} from '../data/list/search-page.component';
import {DataProvider} from '../data/data.provider';
import {SettingsProvider} from '../settings/settings.provider';
import {PositionService} from '../map/position.service';
import {ConfigProvider} from '../config/config.provider';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
/** /**
@@ -42,34 +34,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni
showNavigation = false; showNavigation = false;
constructor( private favoritesService = inject(FavoritesService);
alertController: AlertController,
dataProvider: DataProvider,
contextMenuService: ContextMenuService,
settingsProvider: SettingsProvider,
logger: NGXLogger,
dataRoutingService: DataRoutingService,
router: Router,
route: ActivatedRoute,
positionService: PositionService,
private favoritesService: FavoritesService,
configProvider: ConfigProvider,
animationController: AnimationController,
) {
super(
alertController,
dataProvider,
contextMenuService,
settingsProvider,
logger,
dataRoutingService,
router,
route,
positionService,
configProvider,
animationController,
);
}
ngOnInit() { ngOnInit() {
super.ngOnInit(false); super.ngOnInit(false);
@@ -96,16 +61,6 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni
this.queryChanged.next(); this.queryChanged.next();
} }
}); });
this.settingsProvider.settingsActionChanged$
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
});
this.dataRoutingService this.dataRoutingService
.itemSelectListener() .itemSelectListener()
.pipe(takeUntilDestroyed(this.destroy$)) .pipe(takeUntilDestroyed(this.destroy$))

View File

@@ -28,7 +28,6 @@ import {
} from '@openstapps/core'; } from '@openstapps/core';
import {StorageProvider} from '../storage/storage.provider'; import {StorageProvider} from '../storage/storage.provider';
import {DataProvider} from '../data/data.provider'; import {DataProvider} from '../data/data.provider';
import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
import {TranslateService} from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
import {ThingTranslateService} from '../../translation/thing-translate.service'; import {ThingTranslateService} from '../../translation/thing-translate.service';
import {BehaviorSubject, Observable} from 'rxjs'; import {BehaviorSubject, Observable} from 'rxjs';
@@ -41,11 +40,6 @@ import {debounceTime, map} from 'rxjs/operators';
providedIn: 'root', providedIn: 'root',
}) })
export class FavoritesService { export class FavoritesService {
/**
* Translation pipe
*/
thingTranslatePipe: ThingTranslatePipe;
favorites = new BehaviorSubject<Map<string, SCFavorite>>(new Map<string, SCFavorite>()); favorites = new BehaviorSubject<Map<string, SCFavorite>>(new Map<string, SCFavorite>());
// using debounce time 0 allows change detection to run through async suspension // using debounce time 0 allows change detection to run through async suspension
@@ -93,8 +87,8 @@ export class FavoritesService {
return items.sort((a, b) => { return items.sort((a, b) => {
return ( return (
new Intl.Collator(this.translate.currentLang).compare( new Intl.Collator(this.translate.currentLang).compare(
this.thingTranslatePipe.transform(field, a), this.thingTranslate.get(a, field) as string,
this.thingTranslatePipe.transform(field, b), this.thingTranslate.get(b, field) as string,
) * reverse ) * reverse
); );
}); });
@@ -124,7 +118,6 @@ export class FavoritesService {
private readonly translate: TranslateService, private readonly translate: TranslateService,
private readonly thingTranslate: ThingTranslateService, private readonly thingTranslate: ThingTranslateService,
) { ) {
this.thingTranslatePipe = new ThingTranslatePipe(this.translate, this.thingTranslate);
void this.emitAll(); void this.emitAll();
} }
@@ -185,7 +178,9 @@ export class FavoritesService {
const textFilteredItems: SCIndexableThings[] = []; const textFilteredItems: SCIndexableThings[] = [];
for (const item of items) { for (const item of items) {
if ( if (
this.thingTranslatePipe.transform('name', item).toLowerCase().includes(queryText.toLowerCase()) (this.thingTranslate.get(item, 'name') as string)
.toLowerCase()
.includes(queryText.toLowerCase())
) { ) {
textFilteredItems.push(item); textFilteredItems.push(item);
} }

View File

@@ -17,7 +17,6 @@ import {DaiaAvailabilityResponse, DaiaHolding, DaiaService} from './protocol/res
import {StorageProvider} from '../storage/storage.provider'; import {StorageProvider} from '../storage/storage.provider';
import {HttpClient, HttpHeaders} from '@angular/common/http'; import {HttpClient, HttpHeaders} from '@angular/common/http';
import {ConfigProvider} from '../config/config.provider'; import {ConfigProvider} from '../config/config.provider';
import {SCFeatureConfiguration} from '@openstapps/core';
import {NGXLogger} from 'ngx-logger'; import {NGXLogger} from 'ngx-logger';
import {TranslateService} from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
@@ -67,7 +66,7 @@ export class DaiaDataProvider {
async getAvailability(id: string): Promise<DaiaHolding[] | undefined> { async getAvailability(id: string): Promise<DaiaHolding[] | undefined> {
if (this.daiaServiceUrl === undefined) { if (this.daiaServiceUrl === undefined) {
try { try {
const features = this.configProvider.getValue('features') as SCFeatureConfiguration; const features = this.configProvider.config.app.features;
if (features.extern?.daia?.url) { if (features.extern?.daia?.url) {
this.daiaServiceUrl = features.extern?.daia?.url; this.daiaServiceUrl = features.extern?.daia?.url;
} else { } else {

View File

@@ -114,16 +114,6 @@ export class HebisSearchPageComponent extends SearchPageComponent implements OnI
this.queryChanged.next(); this.queryChanged.next();
} }
}); });
this.settingsProvider.settingsActionChanged$
.pipe(takeUntilDestroyed(this.destroy$))
.subscribe(({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
});
this.dataRoutingService this.dataRoutingService
.itemSelectListener() .itemSelectListener()
.pipe(takeUntilDestroyed(this.destroy$)) .pipe(takeUntilDestroyed(this.destroy$))

View File

@@ -12,14 +12,9 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {JQueryRequestor, Requestor} from '@openid/appauth'; import {JQueryRequestor, Requestor} from '@openid/appauth';
import { import {SCAuthorizationProviderType, SCFeatureConfigurationExtern} from '@openstapps/core';
SCAuthorizationProviderType,
SCFeatureConfiguration,
SCFeatureConfigurationExtern,
} from '@openstapps/core';
import {DocumentAction, PAIADocument, PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types'; import {DocumentAction, PAIADocument, PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types';
import {HebisDataProvider} from '../../hebis/hebis-data.provider'; import {HebisDataProvider} from '../../hebis/hebis-data.provider';
import {PAIATokenResponse} from '../../auth/paia/paia-token-response'; import {PAIATokenResponse} from '../../auth/paia/paia-token-response';
@@ -53,9 +48,7 @@ export class LibraryAccountService {
private readonly toastController: ToastController, private readonly toastController: ToastController,
) { ) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const config: SCFeatureConfigurationExtern = ( const config: SCFeatureConfigurationExtern = configProvider.config.app.features.extern!.paia;
configProvider.getValue('features') as SCFeatureConfiguration
).extern!.paia;
this.baseUrl = config.url; this.baseUrl = config.url;
this.authType = config.authProvider as SCAuthorizationProviderType; this.authType = config.authProvider as SCAuthorizationProviderType;
} }

View File

@@ -19,7 +19,6 @@ import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster'; import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster';
import {IonicModule} from '@ionic/angular'; import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {Polygon} from 'geojson';
import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider'; import {ConfigProvider} from '../config/config.provider';
import {DataFacetsProvider} from '../data/data-facets.provider'; import {DataFacetsProvider} from '../data/data-facets.provider';
@@ -42,7 +41,7 @@ import {GeoNavigationDirective} from './geo-navigation.directive';
*/ */
export function initMapConfigFactory(configProvider: ConfigProvider, mapProvider: MapProvider) { export function initMapConfigFactory(configProvider: ConfigProvider, mapProvider: MapProvider) {
return async () => { return async () => {
mapProvider.defaultPolygon = (await configProvider.getValue('campusPolygon')) as Polygon; mapProvider.defaultPolygon = configProvider.config.app.campusPolygon;
}; };
} }

View File

@@ -116,7 +116,7 @@ export class MapProvider {
private positionService: PositionService, private positionService: PositionService,
private configProvider: ConfigProvider, private configProvider: ConfigProvider,
) { ) {
this.defaultPolygon = this.configProvider.getValue('campusPolygon') as Polygon; this.defaultPolygon = this.configProvider.config.app.campusPolygon;
} }
/** /**

View File

@@ -13,11 +13,11 @@
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, Input} from '@angular/core'; import {Component, Input} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import {SCThingType} from '@openstapps/core';
import {SCLanguage, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core';
import {ContextMenuService} from './context-menu.service'; import {ContextMenuService} from './context-menu.service';
import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type.js'; import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type.js';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {ThingTranslateService} from '../../../translation/thing-translate.service';
/** /**
* The context menu * The context menu
@@ -59,11 +59,6 @@ export class ContextMenuComponent {
return this.filterOption.options.filter(it => it.buckets.length > 0); return this.filterOption.options.filter(it => it.buckets.length > 0);
} }
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
/** /**
* Mapping of SCThingType * Mapping of SCThingType
*/ */
@@ -74,22 +69,10 @@ export class ContextMenuComponent {
*/ */
sortOption: SortContext; sortOption: SortContext;
/**
* Core translator
*/
translator: SCThingTranslator;
constructor( constructor(
private translateService: TranslateService,
private readonly contextMenuService: ContextMenuService, private readonly contextMenuService: ContextMenuService,
private readonly thingTranslateService: ThingTranslateService,
) { ) {
this.language = this.translateService.currentLang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
this.contextMenuService.filterContextChanged$.pipe(takeUntilDestroyed()).subscribe(filterContext => { this.contextMenuService.filterContextChanged$.pipe(takeUntilDestroyed()).subscribe(filterContext => {
this.filterOption = filterContext; this.filterOption = filterContext;
}); });
@@ -109,7 +92,7 @@ export class ContextMenuComponent {
* Returns translated property value * Returns translated property value
*/ */
getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined { getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined {
return this.translator.translatedPropertyValue(onlyForType, field, key); return this.thingTranslateService.translator.translatedPropertyValue(onlyForType, field, key);
} }
/** /**

View File

@@ -13,74 +13,23 @@
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import {SCAppConfigurationMenuCategory} from '@openstapps/core';
import { import {ConfigProvider} from '../../config/config.provider';
SCAppConfigurationMenuCategory,
SCLanguage,
SCThingTranslator,
SCTranslations,
} from '@openstapps/core';
import {NavigationService} from './navigation.service';
import config from 'capacitor.config';
import {SettingsProvider} from '../../settings/settings.provider';
import {BreakpointObserver} from '@angular/cdk/layout';
/**
* Generated class for the MenuPage page.
*
* See https://ionicframework.com/docs/components/#navigation for more info on
* Ionic pages and navigation.
*/
@Component({ @Component({
selector: 'stapps-navigation', selector: 'stapps-navigation',
styleUrls: ['navigation.scss'], styleUrls: ['navigation.scss'],
templateUrl: 'navigation.html', templateUrl: 'navigation.html',
}) })
export class NavigationComponent implements OnInit { export class NavigationComponent implements OnInit {
showTabbar = true;
/**
* Name of the app
*/
appName = config.appName;
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
/** /**
* Menu entries from config module * Menu entries from config module
*/ */
menu: SCAppConfigurationMenuCategory[]; menu: SCAppConfigurationMenuCategory[];
/** constructor(private config: ConfigProvider) {}
* Core translator
*/
translator: SCThingTranslator;
constructor(
public translateService: TranslateService,
private navigationService: NavigationService,
private settingsProvider: SettingsProvider,
private responsive: BreakpointObserver,
) {
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
this.responsive.observe(['(min-width: 768px)']).subscribe(result => {
this.showTabbar = !result.matches;
});
}
async ngOnInit() { async ngOnInit() {
this.language = (await this.settingsProvider.getValue( this.menu = this.config.config.app.menus;
'profile',
'language',
)) as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
this.menu = await this.navigationService.getMenu();
} }
} }

View File

@@ -34,11 +34,11 @@
class="menu-category" class="menu-category"
> >
<ion-icon slot="end" [name]="category.icon"></ion-icon> <ion-icon slot="end" [name]="category.icon"></ion-icon>
<ion-label> {{ category.translations[language]?.title | titlecase }} </ion-label> <ion-label> {{ 'title' | translateSimple: category | titlecase }} </ion-label>
</ion-item> </ion-item>
<ion-item *ngFor="let item of category.items" [rootLink]="item.route" [redirectedFrom]="item.route"> <ion-item *ngFor="let item of category.items" [rootLink]="item.route" [redirectedFrom]="item.route">
<ion-icon slot="end" [name]="item.icon"></ion-icon> <ion-icon slot="end" [name]="item.icon"></ion-icon>
<ion-label> {{ item.translations[language]?.title | titlecase }} </ion-label> <ion-label> {{ 'title' | translateSimple: item | titlecase }} </ion-label>
</ion-item> </ion-item>
</ion-list> </ion-list>
</ion-content> </ion-content>

View File

@@ -22,10 +22,11 @@ import {IonIconModule} from '../../../util/ion-icon/ion-icon.module';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {RouterModule} from '@angular/router'; import {RouterModule} from '@angular/router';
import {OfflineNoticeComponent} from './offline-notice.component'; import {OfflineNoticeComponent} from './offline-notice.component';
import {ThingTranslateModule} from '../../../translation/thing-translate.module';
@NgModule({ @NgModule({
declarations: [RootLinkDirective, NavigationComponent, TabsComponent, OfflineNoticeComponent], declarations: [RootLinkDirective, NavigationComponent, TabsComponent, OfflineNoticeComponent],
imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule], imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule, ThingTranslateModule],
exports: [TabsComponent, RootLinkDirective, NavigationComponent], exports: [TabsComponent, RootLinkDirective, NavigationComponent],
}) })
export class NavigationModule {} export class NavigationModule {}

View File

@@ -60,6 +60,7 @@ stapps-navigation-tabs {
} }
} }
stapps-offline-notice.needs-reload ~ ion-split-pane,
stapps-offline-notice.has-error ~ ion-split-pane, stapps-offline-notice.has-error ~ ion-split-pane,
stapps-offline-notice.is-offline ~ ion-split-pane { stapps-offline-notice.is-offline ~ ion-split-pane {
margin-top: calc(var(--font-size-md) + 2 * var(--spacing-sm)); margin-top: calc(var(--font-size-md) + 2 * var(--spacing-sm));

View File

@@ -1,40 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {SCAppConfigurationMenuCategory} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {NGXLogger} from 'ngx-logger';
@Injectable({
providedIn: 'root',
})
export class NavigationService {
constructor(
private configProvider: ConfigProvider,
private logger: NGXLogger,
) {}
async getMenu() {
let menu: SCAppConfigurationMenuCategory[] = [];
try {
menu = this.configProvider.getValue('menus') as SCAppConfigurationMenuCategory[];
} catch (error) {
this.logger.error(`error from loading menu entries: ${error}`);
}
return menu;
}
}

View File

@@ -17,6 +17,10 @@ import {InternetConnectionService} from '../../../util/internet-connection.servi
import {Router} from '@angular/router'; import {Router} from '@angular/router';
import {NGXLogger} from 'ngx-logger'; import {NGXLogger} from 'ngx-logger';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {SettingsProvider} from '../../settings/settings.provider';
import {AnimationController} from '@ionic/angular';
import {filter, race} from 'rxjs';
import {ConfigProvider} from '../../config/config.provider';
@Component({ @Component({
selector: 'stapps-offline-notice', selector: 'stapps-offline-notice',
@@ -28,12 +32,17 @@ export class OfflineNoticeComponent {
@HostBinding('class.has-error') hasError = false; @HostBinding('class.has-error') hasError = false;
@HostBinding('class.needs-reload') needsReload = false;
@ViewChild('spinIcon', {read: ElementRef}) spinIcon: ElementRef; @ViewChild('spinIcon', {read: ElementRef}) spinIcon: ElementRef;
constructor( constructor(
readonly offlineProvider: InternetConnectionService, readonly offlineProvider: InternetConnectionService,
readonly router: Router, readonly router: Router,
readonly logger: NGXLogger, readonly logger: NGXLogger,
readonly animationCtl: AnimationController,
settingsProvider: SettingsProvider,
configProvider: ConfigProvider,
) { ) {
this.offlineProvider.offline$.pipe(takeUntilDestroyed()).subscribe(isOffline => { this.offlineProvider.offline$.pipe(takeUntilDestroyed()).subscribe(isOffline => {
this.isOffline = isOffline; this.isOffline = isOffline;
@@ -41,6 +50,15 @@ export class OfflineNoticeComponent {
this.offlineProvider.error$.pipe(takeUntilDestroyed()).subscribe(hasError => { this.offlineProvider.error$.pipe(takeUntilDestroyed()).subscribe(hasError => {
this.hasError = hasError; this.hasError = hasError;
}); });
race(
settingsProvider.needsReload$.pipe(filter(it => it)),
configProvider.needsUpdate$.pipe(filter(it => it)),
)
.pipe(takeUntilDestroyed())
.subscribe(() => {
console.log('aha!');
this.needsReload = true;
});
} }
retry() { retry() {
@@ -49,4 +67,15 @@ export class OfflineNoticeComponent {
this.spinIcon.nativeElement.classList.add('spin'); this.spinIcon.nativeElement.classList.add('spin');
this.offlineProvider.retry(); this.offlineProvider.retry();
} }
async reloadPage() {
await this.animationCtl
.create()
.duration(100)
.fromTo('opacity', 1, 0)
.addElement(document.querySelector('ion-app')!)
.play();
window.location.reload();
}
} }

View File

@@ -20,6 +20,10 @@
<ion-icon #spinIcon slot="start" [size]="16" [weight]="800" name="refresh"></ion-icon> <ion-icon #spinIcon slot="start" [size]="16" [weight]="800" name="refresh"></ion-icon>
<ion-label>{{ 'app.errors.CONNECTION_ERROR' | translate }}</ion-label> <ion-label>{{ 'app.errors.CONNECTION_ERROR' | translate }}</ion-label>
</ion-button> </ion-button>
<ion-button class="reload" color="warning" (click)="reloadPage()">
<ion-icon slot="start" [size]="16" [weight]="800" name="refresh"></ion-icon>
<ion-label>{{ 'settings.reloadPage' | translate }}</ion-label>
</ion-button>
<ion-button class="close" fill="clear" color="light" (click)="offlineProvider.dismissError()" <ion-button class="close" fill="clear" color="light" (click)="offlineProvider.dismissError()"
><ion-icon [size]="16" [weight]="800" name="close" slot="icon-only"></ion-icon ><ion-icon [size]="16" [weight]="800" name="close" slot="icon-only"></ion-icon
></ion-button> ></ion-button>

View File

@@ -28,6 +28,7 @@
transition: all 150ms ease; transition: all 150ms ease;
&.needs-reload,
&.is-offline, &.is-offline,
&.has-error { &.has-error {
transform: translateY(0); transform: translateY(0);
@@ -64,6 +65,7 @@
} }
} }
&.needs-reload > .reload,
&.is-offline > .offline-button, &.is-offline > .offline-button,
&.has-error > .close, &.has-error > .close,
&.has-error > .error-button { &.has-error > .error-button {

View File

@@ -16,6 +16,7 @@
import type {AnimationBuilder} from '@ionic/angular'; import type {AnimationBuilder} from '@ionic/angular';
import {AnimationController} from '@ionic/angular'; import {AnimationController} from '@ionic/angular';
import type {AnimationOptions} from '@ionic/angular/providers/nav-controller'; import type {AnimationOptions} from '@ionic/angular/providers/nav-controller';
import {iosDuration, iosEasing, mdDuration, mdEasing} from '../../../animation/easings';
/** /**
* *
@@ -23,10 +24,11 @@ import type {AnimationOptions} from '@ionic/angular/providers/nav-controller';
export function tabsTransition(animationController: AnimationController): AnimationBuilder { export function tabsTransition(animationController: AnimationController): AnimationBuilder {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return (_baseElement: HTMLElement, options: AnimationOptions | any) => { return (_baseElement: HTMLElement, options: AnimationOptions | any) => {
const duration = options.duration || 350; const isMd = document.querySelector('ion-app.md') !== null;
const contentExitDuration = options.contentExitDuration || 100; const duration = isMd ? mdDuration : iosDuration;
const easing = isMd ? mdEasing : iosEasing;
const rootTransition = animationController.create().duration(duration); const rootTransition = animationController.create().duration(duration).easing(easing);
const enterTransition = animationController const enterTransition = animationController
.create() .create()
@@ -39,23 +41,15 @@ export function tabsTransition(animationController: AnimationController): Animat
.addElement(options.leavingEl); .addElement(options.leavingEl);
const exitTransition = animationController const exitTransition = animationController
.create() .create()
.duration(contentExitDuration * 2)
.easing('cubic-bezier(0.87, 0, 0.13, 1)')
.fromTo('opacity', '1', '0') .fromTo('opacity', '1', '0')
.addElement(options.leavingEl.querySelector('ion-header')); .addElement(options.leavingEl.querySelector('ion-header'));
const contentExit = animationController const contentExit = animationController
.create() .create()
.easing('linear')
.duration(contentExitDuration)
.fromTo('opacity', '1', '0') .fromTo('opacity', '1', '0')
.addElement(options.leavingEl.querySelectorAll(':scope > *:not(ion-header)')); .addElement(options.leavingEl.querySelectorAll(':scope > *:not(ion-header)'));
const contentEnter = animationController const contentEnter = animationController
.create() .create()
.delay(contentExitDuration) .fromTo('transform', 'scale(1.05)', 'scale(1)')
.duration(duration - contentExitDuration)
.easing('cubic-bezier(0.16, 1, 0.3, 1)')
.fromTo('transform', 'scale(1.025)', 'scale(1)')
.fromTo('opacity', '0', '1')
.addElement(options.enteringEl.querySelectorAll(':scope > *:not(ion-header)')); .addElement(options.enteringEl.querySelectorAll(':scope > *:not(ion-header)'));
rootTransition.addAnimation([enterTransition, contentExit, contentEnter, exitTransition, exitZIndex]); rootTransition.addAnimation([enterTransition, contentExit, contentEnter, exitTransition, exitZIndex]);

View File

@@ -12,17 +12,11 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {NavigationEnd, Router} from '@angular/router'; import {NavigationEnd, Router} from '@angular/router';
import { import {SCAppConfigurationMenuCategory} from '@openstapps/core';
SCAppConfigurationMenuCategory,
SCLanguage,
SCThingTranslator,
SCTranslations,
} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider'; import {ConfigProvider} from '../../config/config.provider';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
import {NGXLogger} from 'ngx-logger'; import {NGXLogger} from 'ngx-logger';
@Component({ @Component({
@@ -31,21 +25,11 @@ import {NGXLogger} from 'ngx-logger';
styleUrls: ['./tabs.component.scss'], styleUrls: ['./tabs.component.scss'],
}) })
export class TabsComponent { export class TabsComponent {
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
/** /**
* Menu entries from config module * Menu entries from config module
*/ */
menu: SCAppConfigurationMenuCategory[]; menu: SCAppConfigurationMenuCategory[];
/**
* Core translator
*/
translator: SCThingTranslator;
/** /**
* Name of selected tab * Name of selected tab
*/ */
@@ -57,8 +41,6 @@ export class TabsComponent {
private readonly logger: NGXLogger, private readonly logger: NGXLogger,
private readonly router: Router, private readonly router: Router,
) { ) {
this.language = this.translateService.currentLang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
void this.loadMenuEntries(); void this.loadMenuEntries();
this.router.events.subscribe((event: unknown) => { this.router.events.subscribe((event: unknown) => {
if (event instanceof NavigationEnd) { if (event instanceof NavigationEnd) {
@@ -66,11 +48,6 @@ export class TabsComponent {
} }
}); });
this.selectTab(router.url); this.selectTab(router.url);
translateService.onLangChange?.subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
} }
/** /**
@@ -78,7 +55,7 @@ export class TabsComponent {
*/ */
async loadMenuEntries() { async loadMenuEntries() {
try { try {
const menus = (await this.configProvider.getValue('menus')) as SCAppConfigurationMenuCategory[]; const menus = this.configProvider.config.app.menus;
const menu = menus.slice(0, 5); const menu = menus.slice(0, 5);
if (menu) { if (menu) {

View File

@@ -46,6 +46,6 @@
[tab]="category.title" [tab]="category.title"
> >
<ion-icon [name]="category.icon"></ion-icon> <ion-icon [name]="category.icon"></ion-icon>
<ion-label>{{ category.translations[language]?.title | titlecase }}</ion-label> <ion-label>{{ 'title' | translateSimple: category | titlecase }}</ion-label>
</ion-tab-button> </ion-tab-button>
</ion-tab-bar> </ion-tab-bar>

View File

@@ -1,9 +0,0 @@
<ng-container *ngFor="let setting of settings">
<stapps-chip-filter
[displayValue]="setting | settingValueTranslate | titlecase"
[value]="setting"
[active]="!!filtersMap.get($any(setting.name))"
(toggle)="stateChanged($any($event))"
>
</stapps-chip-filter>
</ng-container>

View File

@@ -1,74 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {newsFilterSettingsFieldsMapping, NewsFilterSettingsNames} from '../../news-filter-settings';
import {SCSearchValueFilter, SCSetting} from '@openstapps/core';
import {DataProvider} from '../../../data/data.provider';
@Component({
selector: 'stapps-news-settings-filter',
templateUrl: './news-settings-filter.component.html',
styleUrls: ['./news-settings-filter.component.scss'],
})
export class NewsSettingsFilterComponent implements OnInit {
/**
* A map of the filters where the keys are settings names
*/
filtersMap = new Map<NewsFilterSettingsNames, SCSearchValueFilter>();
/**
* Emits the current filters
*/
@Output() filtersChanged = new EventEmitter<SCSearchValueFilter[]>();
/**
* Provided settings to show the filters for
*/
@Input() settings: SCSetting[];
ngOnInit() {
for (const setting of this.settings) {
this.filtersMap.set(
setting.name as NewsFilterSettingsNames,
DataProvider.createValueFilter(
newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames],
setting.value as string,
),
);
}
this.filtersChanged.emit([...this.filtersMap.values()]);
}
/**
* To be executed when a chip filter has been enabled/disabled
* @param setting The value of the filter
*/
stateChanged(setting: SCSetting) {
if (this.filtersMap.get(setting.name as NewsFilterSettingsNames) === undefined) {
this.filtersMap.set(
setting.name as NewsFilterSettingsNames,
DataProvider.createValueFilter(
newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames],
setting.value as string,
),
);
} else {
this.filtersMap.delete(setting.name as NewsFilterSettingsNames);
}
this.filtersChanged.emit([...this.filtersMap.values()]);
}
}

View File

@@ -20,13 +20,11 @@ import {TranslateModule} from '@ngx-translate/core';
import {MomentModule} from 'ngx-moment'; import {MomentModule} from 'ngx-moment';
import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {DataModule} from '../data/data.module'; import {DataModule} from '../data/data.module';
import {SettingsProvider} from '../settings/settings.provider';
import {NewsItemComponent} from './item/news-item.component'; import {NewsItemComponent} from './item/news-item.component';
import {NewsPageComponent} from './page/news-page.component'; import {NewsPageComponent} from './page/news-page.component';
import {SkeletonNewsItemComponent} from './item/skeleton-news-item.component'; import {SkeletonNewsItemComponent} from './item/skeleton-news-item.component';
import {ChipFilterComponent} from '../data/chips/filter/chip-filter.component'; import {ChipFilterComponent} from '../data/chips/filter/chip-filter.component';
import {SettingsModule} from '../settings/settings.module'; import {SettingsModule} from '../settings/settings.module';
import {NewsSettingsFilterComponent} from './elements/news-filter-settings/news-settings-filter.component';
import {UtilModule} from '../../util/util.module'; import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module'; import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
@@ -36,13 +34,7 @@ const newsRoutes: Routes = [{path: 'news', component: NewsPageComponent}];
* News Module * News Module
*/ */
@NgModule({ @NgModule({
declarations: [ declarations: [NewsPageComponent, SkeletonNewsItemComponent, NewsItemComponent, ChipFilterComponent],
NewsPageComponent,
SkeletonNewsItemComponent,
NewsItemComponent,
ChipFilterComponent,
NewsSettingsFilterComponent,
],
imports: [ imports: [
IonicModule.forRoot(), IonicModule.forRoot(),
ThingTranslateModule.forChild(), ThingTranslateModule.forChild(),
@@ -56,7 +48,6 @@ const newsRoutes: Routes = [{path: 'news', component: NewsPageComponent}];
SettingsModule, SettingsModule,
UtilModule, UtilModule,
], ],
providers: [SettingsProvider],
exports: [NewsItemComponent], exports: [NewsItemComponent],
}) })
export class NewsModule {} export class NewsModule {}

View File

@@ -19,17 +19,18 @@ import {
SCSearchBooleanFilter, SCSearchBooleanFilter,
SCSearchFilter, SCSearchFilter,
SCSearchQuery, SCSearchQuery,
SCSearchValueFilter,
SCSetting,
} from '@openstapps/core'; } from '@openstapps/core';
import {DataProvider} from '../data/data.provider'; import {DataProvider} from '../data/data.provider';
import {
newsFilterSettingsCategory,
newsFilterSettingsFieldsMapping,
NewsFilterSettingsNames,
} from './news-filter-settings';
import {SettingsProvider} from '../settings/settings.provider'; import {SettingsProvider} from '../settings/settings.provider';
/**
* The mapping between settings and corresponding data fields for building a value filter
*/
const newsFilterSettingsFieldsMapping = [
['language', 'inLanguage'],
['group', 'audiences'],
] as const;
/** /**
* Service for providing news messages * Service for providing news messages
*/ */
@@ -42,28 +43,22 @@ export class NewsProvider {
private settingsProvider: SettingsProvider, private settingsProvider: SettingsProvider,
) {} ) {}
async getCurrentSettings(): Promise<SCSetting[]> { /**
const settings: SCSetting[] = []; * Gets the news filter based on user group and language settings
for (const settingName of Object.keys(newsFilterSettingsFieldsMapping) as NewsFilterSettingsNames[]) { */
settings.push(await this.settingsProvider.getSetting(newsFilterSettingsCategory, settingName));
}
return settings;
}
async getCurrentFilters(): Promise<SCSearchFilter[]> { async getCurrentFilters(): Promise<SCSearchFilter[]> {
const settings = await this.getCurrentSettings(); return Promise.all(
const filtersMap = new Map<NewsFilterSettingsNames, SCSearchValueFilter>(); newsFilterSettingsFieldsMapping.map(
for (const setting of settings) { async ([setting, field]) =>
filtersMap.set( ({
setting.name as NewsFilterSettingsNames, type: 'value',
DataProvider.createValueFilter( arguments: {
newsFilterSettingsFieldsMapping[setting.name as NewsFilterSettingsNames], field,
setting.value as string, value: await this.settingsProvider.getSetting('profile', setting),
), },
); }) satisfies SCSearchFilter,
} ),
);
return [...filtersMap.values()];
} }
/** /**

View File

@@ -12,9 +12,9 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, OnInit} from '@angular/core'; import {Component} from '@angular/core';
import {IonRefresher} from '@ionic/angular'; import {IonRefresher} from '@ionic/angular';
import {SCMessage, SCSearchFilter, SCSearchValueFilter, SCSetting} from '@openstapps/core'; import {SCMessage} from '@openstapps/core';
import {NewsProvider} from '../news.provider'; import {NewsProvider} from '../news.provider';
/** /**
@@ -25,7 +25,7 @@ import {NewsProvider} from '../news.provider';
templateUrl: 'news-page.html', templateUrl: 'news-page.html',
styleUrls: ['news-page.scss'], styleUrls: ['news-page.scss'],
}) })
export class NewsPageComponent implements OnInit { export class NewsPageComponent {
/** /**
* Thing counter to start query the next page from * Thing counter to start query the next page from
*/ */
@@ -51,24 +51,20 @@ export class NewsPageComponent implements OnInit {
*/ */
elementSize = [300, 300]; elementSize = [300, 300];
/** constructor(private newsProvider: NewsProvider) {
* Relevant settings this.fetchNews();
*/ }
settings: SCSetting[];
/**
* Active filters
*/
filters: SCSearchFilter[];
constructor(private newsProvider: NewsProvider) {}
/** /**
* Fetch news from the backend * Fetch news from the backend
*/ */
async fetchNews() { async fetchNews() {
this.from = this.pageSize; this.from = this.pageSize;
this.news = await this.newsProvider.getList(this.pageSize, 0, [...this.filters]); this.news = await this.newsProvider.getList(
this.pageSize,
0,
await this.newsProvider.getCurrentFilters(),
);
} }
/** /**
@@ -77,7 +73,11 @@ export class NewsPageComponent implements OnInit {
async loadMore(infiniteScrollElement?: HTMLIonInfiniteScrollElement, more = this.pageSize): Promise<void> { async loadMore(infiniteScrollElement?: HTMLIonInfiniteScrollElement, more = this.pageSize): Promise<void> {
const from = this.from; const from = this.from;
this.from += more; this.from += more;
const fetchedNews = await this.newsProvider.getList(more, from, [...this.filters]); const fetchedNews = await this.newsProvider.getList(
more,
from,
await this.newsProvider.getCurrentFilters(),
);
this.news = [...this.news, ...fetchedNews]; this.news = [...this.news, ...fetchedNews];
await infiniteScrollElement?.complete(); await infiniteScrollElement?.complete();
@@ -96,13 +96,6 @@ export class NewsPageComponent implements OnInit {
} }
} }
/**
* Initialize the local variables on component initialization
*/
async ngOnInit() {
this.settings = await this.newsProvider.getCurrentSettings();
}
/** /**
* Updates the shown list * Updates the shown list
* @param refresher Refresher component that triggers the update * @param refresher Refresher component that triggers the update
@@ -116,13 +109,4 @@ export class NewsPageComponent implements OnInit {
await refresher.complete(); await refresher.complete();
} }
} }
/**
* Executed when filters have been changed
* @param filters Current filters to be used
*/
toggleFilter(filters: SCSearchValueFilter[]) {
this.filters = filters;
void this.fetchNews();
}
} }

View File

@@ -32,17 +32,6 @@
> >
</ion-refresher-content> </ion-refresher-content>
</ion-refresher> </ion-refresher>
<ion-grid>
<ion-row>
<ion-col size="12">
<stapps-news-settings-filter
*ngIf="settings"
[settings]="settings"
(filtersChanged)="toggleFilter($event)"
></stapps-news-settings-filter>
</ion-col>
</ion-row>
</ion-grid>
<div class="news-grid"> <div class="news-grid">
<ng-container *ngIf="!news"> <ng-container *ngIf="!news">
<stapps-skeleton-news-item *ngFor="let skeleton of [1, 2, 3, 4, 5]"></stapps-skeleton-news-item> <stapps-skeleton-news-item *ngFor="let skeleton of [1, 2, 3, 4, 5]"></stapps-skeleton-news-item>

View File

@@ -14,8 +14,7 @@
*/ */
import {Component, Input} from '@angular/core'; import {Component, Input} from '@angular/core';
import {AlertController} from '@ionic/angular'; import {AlertController} from '@ionic/angular';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import {SCLanguageCode, SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import {SettingsProvider} from '../settings.provider'; import {SettingsProvider} from '../settings.provider';
/** /**
@@ -42,23 +41,10 @@ export class SettingsItemComponent {
*/ */
@Input() setting: SCSetting; @Input() setting: SCSetting;
/**
*
* @param alertCtrl AlertController
* @param translateService TranslateService
* @param settingsProvider SettingProvider
*/
constructor( constructor(
private readonly alertCtrl: AlertController, private readonly alertCtrl: AlertController,
private readonly translateService: TranslateService,
private readonly settingsProvider: SettingsProvider, private readonly settingsProvider: SettingsProvider,
) { ) {}
translateService.onLangChange.subscribe((_event: LangChangeEvent) => {
this.isVisible = false;
// TODO: Issue #53 check workaround for selected 'select option' not updating translation
setTimeout(() => (this.isVisible = true));
});
}
/** /**
* Shows alert with given title and message and a 'ok' button * Shows alert with given title and message and a 'ok' button
@@ -82,14 +68,6 @@ export class SettingsItemComponent {
this.setting.value !== undefined && this.setting.value !== undefined &&
SettingsProvider.validateValue(this.setting, this.setting.value) SettingsProvider.validateValue(this.setting, this.setting.value)
) { ) {
// handle general settings, with special actions
switch (this.setting.name) {
case 'language': {
this.translateService.use(this.setting.value as SCLanguageCode);
break;
}
default:
}
await this.settingsProvider.setSettingValue( await this.settingsProvider.setSettingValue(
this.setting.categories[0], this.setting.categories[0],
this.setting.name, this.setting.name,
@@ -97,7 +75,7 @@ export class SettingsItemComponent {
); );
} else { } else {
// reset setting // reset setting
this.setting.value = (await this.settingsProvider.getValue( this.setting.value = (await this.settingsProvider.getSetting(
this.setting.categories[0], this.setting.categories[0],
this.setting.name, this.setting.name,
)) as SCSettingValue | SCSettingValues; )) as SCSettingValue | SCSettingValues;

View File

@@ -47,14 +47,6 @@ export class SettingsPageComponent implements OnInit {
*/ */
settingsCache: SettingsCache; settingsCache: SettingsCache;
/**
*
* @param alertController AlertController
* @param settingsProvider SettingsProvider
* @param toastController ToastController
* @param translateService TranslateService
* @param changeDetectorRef ChangeDetectorRef
*/
constructor( constructor(
private readonly alertController: AlertController, private readonly alertController: AlertController,
private readonly settingsProvider: SettingsProvider, private readonly settingsProvider: SettingsProvider,

View File

@@ -26,20 +26,6 @@
<div class="settings-content"> <div class="settings-content">
<ng-container *ngFor="let categoryKey of categoriesOrder"> <ng-container *ngFor="let categoryKey of categoriesOrder">
<ion-list *ngIf="objectKeys(settingsCache).includes(categoryKey)"> <ion-list *ngIf="objectKeys(settingsCache).includes(categoryKey)">
<!-- <ion-item-divider>
<h2>
{{
'categories[0]'
| thingTranslate
: $any(
settingsCache[categoryKey]?.settings[
objectKeys(settingsCache[categoryKey]?.settings)[0]
]
)
| titlecase
}}
</h2>
</ion-item-divider> -->
<stapps-settings-item <stapps-settings-item
*ngFor="let settingKeys of objectKeys(settingsCache[categoryKey].settings)" *ngFor="let settingKeys of objectKeys(settingsCache[categoryKey].settings)"
[setting]="settingsCache[categoryKey].settings[settingKeys]" [setting]="settingsCache[categoryKey].settings[settingKeys]"
@@ -49,7 +35,7 @@
<calendar-sync-settings></calendar-sync-settings> <calendar-sync-settings></calendar-sync-settings>
<ion-button expand="block" (click)="presentResetAlert()" fill="outline"> <ion-button expand="block" (click)="presentResetAlert()" fill="outline" color="danger">
{{ 'settings.resetSettings' | translate }} {{ 'settings.resetSettings' | translate }}
<ion-icon slot="start" name="device_reset"></ion-icon> <ion-icon slot="start" name="device_reset"></ion-icon>
</ion-button> </ion-button>

View File

@@ -12,11 +12,8 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Pipe, PipeTransform} from '@angular/core'; import {Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {SCSetting} from '@openstapps/core'; import {SCSetting} from '@openstapps/core';
import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
import {ThingTranslateService} from '../../translation/thing-translate.service'; import {ThingTranslateService} from '../../translation/thing-translate.service';
/** /**
@@ -27,14 +24,10 @@ import {ThingTranslateService} from '../../translation/thing-translate.service';
pure: true, pure: true,
}) })
export class SettingTranslatePipe implements PipeTransform { export class SettingTranslatePipe implements PipeTransform {
constructor( constructor(private readonly thingTranslate: ThingTranslateService) {}
private readonly translate: TranslateService,
private readonly thingTranslate: ThingTranslateService,
) {}
transform(setting: SCSetting): string | undefined { transform(setting: SCSetting) {
const thingTranslatePipe = new ThingTranslatePipe(this.translate, this.thingTranslate); const translatedSettingValues = this.thingTranslate.get(setting, 'values') as string;
const translatedSettingValues = thingTranslatePipe.transform('values', setting);
return translatedSettingValues return translatedSettingValues
? String(translatedSettingValues[setting.values?.indexOf(setting.value as string) as number]) ? String(translatedSettingValues[setting.values?.indexOf(setting.value as string) as number])

View File

@@ -18,13 +18,10 @@ import {FormsModule} from '@angular/forms';
import {RouterModule, Routes} from '@angular/router'; import {RouterModule, Routes} from '@angular/router';
import {IonicModule} from '@ionic/angular'; import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {SettingsItemComponent} from './item/settings-item.component'; import {SettingsItemComponent} from './item/settings-item.component';
import {SettingsPageComponent} from './page/settings-page.component'; import {SettingsPageComponent} from './page/settings-page.component';
import {SettingTranslatePipe} from './setting-translate.pipe'; import {SettingTranslatePipe} from './setting-translate.pipe';
import {SettingsProvider} from './settings.provider';
import {CalendarSyncSettingsComponent} from './page/calendar-sync-settings.component'; import {CalendarSyncSettingsComponent} from './page/calendar-sync-settings.component';
import {ScheduleProvider} from '../calendar/schedule.provider'; import {ScheduleProvider} from '../calendar/schedule.provider';
import {ThingTranslatePipe} from '../../translation/thing-translate.pipe'; import {ThingTranslatePipe} from '../../translation/thing-translate.pipe';
@@ -60,13 +57,6 @@ const settingsRoutes: Routes = [{path: 'settings', component: SettingsPageCompon
RouterModule.forChild(settingsRoutes), RouterModule.forChild(settingsRoutes),
UtilModule, UtilModule,
], ],
providers: [ providers: [ScheduleSyncService, CalendarService, ScheduleProvider, ThingTranslatePipe],
ScheduleSyncService,
ConfigProvider,
SettingsProvider,
CalendarService,
ScheduleProvider,
ThingTranslatePipe,
],
}) })
export class SettingsModule {} export class SettingsModule {}

View File

@@ -15,7 +15,7 @@
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core'; import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import deepMerge from 'deepmerge'; import deepMerge from 'deepmerge';
import {Subject} from 'rxjs'; import {BehaviorSubject, Subject} from 'rxjs';
import {ConfigProvider} from '../config/config.provider'; import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider'; import {StorageProvider} from '../storage/storage.provider';
@@ -89,7 +89,9 @@ export interface SettingsAction {
/** /**
* Provider for app settings * Provider for app settings
*/ */
@Injectable() @Injectable({
providedIn: 'root',
})
export class SettingsProvider { export class SettingsProvider {
/** /**
* Source of settings actions * Source of settings actions
@@ -103,16 +105,16 @@ export class SettingsProvider {
*/ */
categoriesOrder: string[]; categoriesOrder: string[];
/**
* Settings actions observable
*/
settingsActionChanged$ = this.settingsActionSource.asObservable();
/** /**
* Cache for the imported settings * Cache for the imported settings
*/ */
settingsCache: SettingsCache; settingsCache: SettingsCache;
/**
* Whether the page needs a reload
*/
needsReload$ = new BehaviorSubject(false);
/** /**
* Return true if all given values are valid to possible values in given settingInput * Return true if all given values are valid to possible values in given settingInput
* @param possibleValues Possible values * @param possibleValues Possible values
@@ -148,9 +150,7 @@ export class SettingsProvider {
return false; return false;
} }
return ( return Array.isArray(possibleValues) && possibleValues.includes(enteredValue);
possibleValues !== undefined && Array.isArray(possibleValues) && possibleValues.includes(enteredValue)
);
} }
/** /**
@@ -206,7 +206,7 @@ export class SettingsProvider {
} }
/** /**
* Add an Setting to the Cache if not exist and set undefined value to defaultValue * Add a Setting to the Cache if not exist and set undefined value to defaultValue
* @param setting Setting with categories, defaultValue, name, input type and valid values * @param setting Setting with categories, defaultValue, name, input type and valid values
*/ */
private addSetting(setting: SCSetting): void { private addSetting(setting: SCSetting): void {
@@ -281,34 +281,20 @@ export class SettingsProvider {
return this.categoriesOrder; return this.categoriesOrder;
} }
/**
* Returns copy of a setting if exist
* @param category the category of requested setting
* @param name the name of requested setting
* @throws Exception if setting is not provided
*/
public async getSetting(category: string, name: string): Promise<SCSetting> {
await this.init();
if (this.settingExists(category, name)) {
// return a copy of the settings
return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name]));
}
throw new Error(`Setting "${name}" not provided`);
}
/** /**
* Returns copy of a settings value if exist * Returns copy of a settings value if exist
* @param category the category of requested setting * @param category the category of requested setting
* @param name the name of requested setting * @param name the name of requested setting
* @throws Exception if setting is not provided * @throws Exception if setting is not provided
*/ */
public async getValue(category: string, name: string): Promise<SCSettingValue | SCSettingValues> { public async getSetting<T extends SCSettingValue | SCSettingValues>(
await this.init(); category: 'profile' | string,
if (this.settingExists(category, name)) { name: string,
// return a copy of the settings value ): Promise<T> {
return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name].value)); const settings = await this.storage.get<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES);
} const value = settings[category]?.[name];
throw new Error(`Setting "${name}" not provided`); if (!value) throw new Error(`Setting "${name}" not provided`);
return value as T;
} }
/** /**
@@ -319,8 +305,8 @@ export class SettingsProvider {
this.needsInit = false; this.needsInit = false;
try { try {
const settings: SCSetting[] = this.configProvider.getValue('settings') as SCSetting[]; const settings: SCSetting[] = this.configProvider.config.app.settings;
for (const setting of settings) this.addSetting(setting); for (const setting of settings) this.addSetting(JSON.parse(JSON.stringify(setting)));
for (const category of Object.keys(this.settingsCache)) { for (const category of Object.keys(this.settingsCache)) {
if (!this.categoriesOrder.includes(category)) { if (!this.categoriesOrder.includes(category)) {
@@ -347,7 +333,6 @@ export class SettingsProvider {
: this.settingsCache[categoryKey].settings[settingKey].defaultValue; : this.settingsCache[categoryKey].settings[settingKey].defaultValue;
} }
} }
await this.saveSettingValues();
} }
} }
@@ -397,6 +382,9 @@ export class SettingsProvider {
this.getSettingValuesFromCache(), this.getSettingValuesFromCache(),
); );
} }
this.needsReload$.next(true);
this.needsReload$.complete();
} }
/** /**

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2019 StApps * Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it * This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free * under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3. * Software Foundation, version 3.
@@ -12,16 +12,15 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {NgModule} from '@angular/core'; import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {DataModule} from '../data/data.module';
import {StorageModule} from '../storage/storage.module';
import {ConfigProvider} from './config.provider';
/** @Injectable()
* TODO @Pipe({
*/ name: 'entries',
@NgModule({ pure: true,
imports: [StorageModule, DataModule],
providers: [ConfigProvider],
}) })
export class ConfigModule {} export class EntriesPipe implements PipeTransform {
transform<T>(value: Record<string | number | symbol, T>): T[] {
return Object.values(value);
}
}

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2021 StApps * Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it * This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free * under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3. * Software Foundation, version 3.
@@ -12,21 +12,23 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {SCSettingCategories, SCThingsField} from '@openstapps/core'; import {Injectable, Pipe, PipeTransform} from '@angular/core';
/**
* Category of settings to use for news filter @Injectable()
*/ @Pipe({
export const newsFilterSettingsCategory: SCSettingCategories = 'profile'; name: 'join',
/** pure: true,
* Settings to use for news filter })
*/ export class ArrayJoinPipe implements PipeTransform {
export type NewsFilterSettingsNames = 'language' | 'group'; transform(anArray: unknown[] | unknown, separator: string | unknown): string {
/** if (typeof separator !== 'string' || separator.length <= 0) {
* The mapping between settings and corresponding data fields for building a value filter return '';
*/ }
export const newsFilterSettingsFieldsMapping: {
[key in NewsFilterSettingsNames]: SCThingsField; if (!Array.isArray(anArray)) {
} = { throw new SyntaxError(`Wrong parameter in ArrayJoinPipe. Expected a valid Array, received: ${anArray}`);
language: 'inLanguage', }
group: 'audiences',
}; return anArray.join(separator);
}
}

View File

@@ -1,416 +0,0 @@
/*
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import moment from 'moment';
import {Subscription} from 'rxjs';
import {logger} from '../_helpers/ts-logger';
@Injectable()
@Pipe({
name: 'join',
pure: true,
})
export class ArrayJoinPipe implements PipeTransform {
value = '';
transform(anArray: unknown[] | unknown, separator: string | unknown): string {
if (typeof separator !== 'string' || separator.length <= 0) {
return this.value;
}
if (!Array.isArray(anArray)) {
throw new SyntaxError(`Wrong parameter in ArrayJoinPipe. Expected a valid Array, received: ${anArray}`);
}
this.value = anArray.join(separator);
return this.value;
}
}
@Injectable()
@Pipe({
name: 'entries',
pure: true,
})
export class EntriesPipe implements PipeTransform {
transform<T>(value: Record<string | number | symbol, T>): T[] {
return Object.values(value);
}
}
@Injectable()
@Pipe({
name: 'toUnix',
pure: true,
})
export class ToUnixPipe implements PipeTransform {
transform(value: string | number | Date | null | undefined): number {
return (value instanceof Date ? value : new Date(value ?? 0)).valueOf();
}
}
@Injectable()
@Pipe({
name: 'sentencecase',
pure: true,
})
export class SentenceCasePipe implements PipeTransform {
value = '';
transform(aString: string | unknown): string {
if (typeof aString !== 'string') {
throw new SyntaxError(
`Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`,
);
}
this.value = aString.slice(0, 1).toUpperCase() + aString.slice(1);
return this.value;
}
}
@Injectable()
@Pipe({
name: 'split',
pure: true,
})
export class StringSplitPipe implements PipeTransform {
value = new Array<unknown>();
transform(aString: string | unknown, splitter: string | unknown): unknown[] {
if (typeof splitter !== 'string' || splitter.length <= 0) {
return this.value as never;
}
if (typeof aString !== 'string') {
throw new SyntaxError(
`Wrong parameter in StringSplitPipe. Expected a valid String, received: ${aString}`,
);
}
this.value = aString.split(splitter);
return this.value as never;
}
}
@Injectable()
@Pipe({
name: 'durationLocalized',
pure: true,
})
export class DurationLocalizedPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
frequencyPrefixes: {[iso6391Code: string]: string} = {
de: 'alle',
en: 'every',
es: 'cada',
pt: 'a cada',
fr: 'tous les',
cn: '每',
ru: 'kаждые',
};
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value An ISO 8601 duration string
* @param isFrequency Boolean indicating if this duration is to be interpreted as repeat frequency
*/
transform(value: string | unknown, isFrequency = false): string {
this.updateValue(value, isFrequency);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value, isFrequency);
});
}
return this.value;
}
updateValue(value: string | unknown, isFrequency = false): void {
if (typeof value !== 'string') {
logger.warn(`durationLocalized pipe unable to parse input: ${value}`);
return;
}
if (isFrequency) {
const fequencyPrefix = Object.keys(this.frequencyPrefixes).filter(element =>
this.locale.includes(element),
);
this.value = [
fequencyPrefix.length > 0 ? this.frequencyPrefixes[fequencyPrefix[0]] : this.frequencyPrefixes.en,
moment.duration(value).humanize(),
].join(' ');
} else {
this.value = moment.duration(value).humanize();
}
}
}
@Injectable()
@Pipe({
name: 'metersLocalized',
pure: false,
})
export class MetersLocalizedPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value = '';
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
transform(value: string | number | unknown): string {
this.updateValue(value);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value);
});
}
return this.value;
}
updateValue(value: string | number | unknown) {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`metersLocalized pipe unable to parse input: ${value}`);
return;
}
const imperialLocale = ['US', 'UK', 'LR', 'MM'].some(term => this.locale.includes(term));
const meters = typeof value === 'string' ? Number.parseFloat(value) : (value as number);
if (imperialLocale) {
const yards = meters * 1.0936;
const options = {
style: 'unit',
unit: yards >= 1760 ? 'mile' : 'yard',
maximumFractionDigits: yards >= 1760 ? 1 : 0,
} as unknown as Intl.NumberFormatOptions;
this.value = new Intl.NumberFormat(this.locale, options).format(yards >= 1760 ? yards / 1760 : yards);
} else {
const options = {
style: 'unit',
unit: meters >= 1000 ? 'kilometer' : 'meter',
maximumFractionDigits: meters >= 1000 ? 1 : 0,
} as unknown as Intl.NumberFormatOptions;
this.value = new Intl.NumberFormat(this.locale, options).format(
meters >= 1000 ? meters / 1000 : meters,
);
}
}
}
@Injectable()
@Pipe({
name: 'isNaN',
pure: true,
})
export class IsNaNPipe implements PipeTransform {
transform(value: unknown): boolean {
return Number.isNaN(value);
}
}
@Injectable()
@Pipe({
name: 'isNumeric',
pure: true,
})
export class IsNumericPipe implements PipeTransform {
transform(value: unknown): boolean {
return !Number.isNaN(
typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : Number.NaN,
);
}
}
@Injectable()
@Pipe({
name: 'numberLocalized',
pure: true,
})
export class NumberLocalizedPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value The number to be formatted
* @param formatOptions Formatting options to include.
* As specified by Intl.NumberFormatOptions as comma seperated key:value pairs.
*/
transform(value: string | number | unknown, formatOptions?: string): string {
this.updateValue(value, formatOptions);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value, formatOptions);
});
}
return this.value;
}
updateValue(value: string | number | unknown, formatOptions?: string): void {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`numberLocalized pipe unable to parse input: ${value}`);
return;
}
const options = formatOptions
?.split(',')
.map(element => element.split(':'))
.reduce(
(accumulator, [key, value_]) => ({
...accumulator,
[key.trim()]: value_.trim(),
}),
{},
) as Intl.NumberFormatOptions;
const float = typeof value === 'string' ? Number.parseFloat(value) : (value as number);
this.value = new Intl.NumberFormat(this.locale, options).format(float);
}
}
@Injectable()
@Pipe({
name: 'dateFormat',
pure: true,
})
export class DateLocalizedFormatPipe implements PipeTransform, OnDestroy {
locale: string;
onLangChange?: Subscription;
value: string;
constructor(private readonly translate: TranslateService) {
this.locale = translate.currentLang;
}
private _dispose(): void {
if (this.onLangChange?.closed === false) {
this.onLangChange?.unsubscribe();
}
}
ngOnDestroy(): void {
this._dispose();
}
/**
* @param value The date to be formatted
* @param formatOptions Dateformat options to include.
* As specified by Intl.DateTimeFormatOptions as comma seperated key:value pairs
* Default is year,month,day,hour and minute in numeric representation e.g. (en-US) "8/6/2021, 10:35"
*/
transform(value: string | unknown, formatOptions?: string): string {
this.updateValue(value, formatOptions);
this._dispose();
if (this.onLangChange?.closed === true) {
this.onLangChange = this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
this.locale = event.lang;
this.updateValue(value, formatOptions);
});
}
return this.value;
}
updateValue(value: string | Date | unknown, formatOptions?: string): void {
if (typeof value !== 'string' && Object.prototype.toString.call(value) !== '[object Date]') {
logger.warn(`dateFormat pipe unable to parse input: ${value}`);
return;
}
const options = formatOptions
?.split(',')
.map(element => element.split(':'))
.reduce(
(accumulator, [key, value_]) => ({
...accumulator,
[key.trim()]: value_.trim(),
}),
{},
) as Intl.DateTimeFormatOptions;
const date = typeof value === 'string' ? Date.parse(value) : (value as Date);
this.value = new Intl.DateTimeFormat(
this.locale,
options ?? {
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
).format(date);
}
}

View File

@@ -0,0 +1,47 @@
import {Injectable, Pipe, PipeTransform} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {logger} from '../../_helpers/ts-logger';
@Injectable()
@Pipe({
name: 'dateFormat',
pure: true,
})
export class DateLocalizedFormatPipe implements PipeTransform {
constructor(private readonly translate: TranslateService) {}
/**
* @param value The date to be formatted
* @param formatOptions Dateformat options to include.
* As specified by Intl.DateTimeFormatOptions as comma seperated key:value pairs
* Default is year,month,day,hour and minute in numeric representation e.g. (en-US) "8/6/2021, 10:35"
*/
transform(value: string | unknown, formatOptions?: string): string {
if (typeof value !== 'string' && Object.prototype.toString.call(value) !== '[object Date]') {
logger.warn(`dateFormat pipe unable to parse input: ${value}`);
return '';
}
const options = formatOptions
?.split(',')
.map(element => element.split(':'))
.reduce(
(accumulator, [key, value_]) => ({
...accumulator,
[key.trim()]: value_.trim(),
}),
{},
) as Intl.DateTimeFormatOptions;
const date = typeof value === 'string' ? Date.parse(value) : (value as Date);
return new Intl.DateTimeFormat(
this.translate.currentLang,
options ?? {
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
).format(date);
}
}

Some files were not shown because too many files have changed in this diff Show More