Merge pull request #3927 from NoelDeMartin/MOBILE-4304

MOBILE-4304: Replace WebSQL with sqlite-wasm
main
Dani Palou 2024-02-14 09:53:46 +01:00 committed by GitHub
commit f31eb838fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2469 additions and 1872 deletions

View File

@ -41,6 +41,17 @@ jobs:
working-directory: app
run: npm run build:test
- name: Generate SSL certificates
working-directory: app
run: |
mkdir ./ssl
openssl req -x509 -nodes \
-days 365 \
-newkey rsa:2048 \
-keyout ./ssl/certificate.key \
-out ./ssl/certificate.crt \
-subj="/O=Moodle"
- name: Build Behat plugin
working-directory: app
run: ./scripts/build-behat-plugin.js ../plugin
@ -113,6 +124,7 @@ jobs:
with:
key: build-${{ github.sha }}
path: |
app/ssl/**/*
app/node_modules/**/*
app/www/**/*
plugin/**/*
@ -157,6 +169,7 @@ jobs:
with:
key: build-${{ github.sha }}
path: |
app/ssl/**/*
app/node_modules/**/*
app/www/**/*
plugin/**/*
@ -164,16 +177,25 @@ jobs:
- name: Launch Docker images
working-directory: app
run: |
docker run -d --rm -p 8001:80 --name moodleapp -v ./www:/usr/share/nginx/html -v ./nginx.conf:/etc/nginx/conf.d/default.conf nginx:alpine
docker run -d --rm \
-p 8001:443 \
--name moodleapp \
-v ./www:/usr/share/nginx/html \
-v ./nginx.conf:/etc/nginx/conf.d/default.conf \
-v ./ssl/certificate.crt:/etc/ssl/certificate.crt \
-v ./ssl/certificate.key:/etc/ssl/certificate.key \
nginx:alpine
docker run -d --rm -p 8002:80 --name bigbluebutton moodlehq/bigbluebutton_mock:latest
- name: Initialise moodle-plugin-ci
run: |
composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4.3
git clone https://github.com/NoelDeMartin/moodle-plugin-ci --branch selenium-env ci
composer install -d ./ci
echo $(cd ci/bin; pwd) >> $GITHUB_PATH
echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH
sudo locale-gen en_AU.UTF-8
echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV
sed -i "58i\$CFG->behat_profiles['chrome']['capabilities'] = ['extra_capabilities' => ['chromeOptions' => ['args' => ['--ignore-certificate-errors', '--allow-running-insecure-content']]]];" ci/res/template/config.php.txt
- name: Install Behat Snapshots plugin
run: moodle-plugin-ci add-plugin NoelDeMartin/moodle-local_behatsnapshots
@ -184,7 +206,7 @@ jobs:
DB: pgsql
MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'main' }}
MOODLE_REPO: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle.git' }}
MOODLE_BEHAT_IONIC_WWWROOT: http://localhost:8001
MOODLE_BEHAT_IONIC_WWWROOT: https://localhost:8001
MOODLE_BEHAT_DEFAULT_BROWSER: chrome
- name: Update config
@ -194,6 +216,7 @@ jobs:
run: moodle-plugin-ci behat --auto-rerun 3 --profile chrome --tags="@app&&~@local&&$BEHAT_TAGS"
env:
BEHAT_TAGS: ${{ matrix.tags }}
MOODLE_BEHAT_SELENIUM_IMAGE: selenium/standalone-chrome:120.0
- name: Upload Snapshot failures
uses: actions/upload-artifact@v4

View File

@ -23,10 +23,17 @@ ARG build_command="npm run build:prod"
COPY . /app
RUN ${build_command}
# Generate SSL certificate
RUN mkdir /app/ssl
RUN openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /app/ssl/certificate.key -out /app/ssl/certificate.crt -subj="/O=Moodle"
## SERVE STAGE
FROM nginx:alpine as serve-stage
# Copy assets & config
COPY --from=build-stage /app/www /usr/share/nginx/html
COPY --from=build-stage /app/ssl/certificate.crt /etc/ssl/certificate.crt
COPY --from=build-stage /app/ssl/certificate.key /etc/ssl/certificate.key
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
HEALTHCHECK --interval=10s --timeout=4s CMD curl -f http://localhost/assets/env.json || exit 1
EXPOSE 443
HEALTHCHECK --interval=10s --timeout=4s CMD curl --insecure -f https://localhost/assets/env.json || exit 1

View File

@ -95,7 +95,11 @@
"options": {
"disableHostCheck": true,
"port": 8100,
"buildTarget": "app:build"
"buildTarget": "app:build",
"headers": {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
}
},
"configurations": {
"production": {

View File

@ -936,6 +936,38 @@ class behat_app extends behat_app_helper {
});
}
/**
* Check that the app opened a url.
*
* @Then /^the app should( not)? have opened url "([^"]+)"(?: with contents "([^"]+)")?(?: (once|\d+ times))?$/
* @param bool $not Whether to check if the app did not open the url
* @param string $urlpattern Url pattern
* @param string $contents Url contents
* @param string $times How many times the url should have been opened
*/
public function the_app_should_have_opened_url(bool $not, string $urlpattern, ?string $contents = null, ?string $times = null) {
if (is_null($times) || $times === 'once') {
$times = 1;
} else {
$times = intval(substr($times, 0, strlen($times) - 6));
}
$this->spin(function() use ($not, $urlpattern, $contents, $times) {
$result = $this->runtime_js("hasOpenedUrl('$urlpattern', '$contents', $times)");
// TODO process times
if ($not && $result === 'OK') {
throw new DriverException('Error, an url was opened that should not have');
}
if (!$not && $result !== 'OK') {
throw new DriverException('Error asserting that url was opened - ' . $result);
}
return true;
});
}
/**
* Switches to a newly-opened browser tab.
*

View File

@ -1,9 +1,23 @@
server {
listen 0.0.0.0:80;
listen 80;
listen 443 ssl;
root /usr/share/nginx/html;
server_tokens off;
access_log off;
# Configure SSL
if ($scheme = "http") {
return 301 https://$host$request_uri;
}
ssl_certificate /etc/ssl/certificate.crt;
ssl_certificate_key /etc/ssl/certificate.key;
ssl_protocols TLSv1.3;
# Enable OPFS
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";
location / {
try_files $uri $uri/ /index.html;
}

9
package-lock.json generated
View File

@ -54,6 +54,7 @@
"@moodlehq/phonegap-plugin-push": "4.0.0-moodle.7",
"@ngx-translate/core": "^15.0.0",
"@ngx-translate/http-loader": "^8.0.0",
"@sqlite.org/sqlite-wasm": "^3.45.0-build1",
"@types/chart.js": "^2.9.31",
"@types/cordova": "0.0.34",
"@types/dom-mediacapture-record": "1.0.7",
@ -8979,6 +8980,14 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@sqlite.org/sqlite-wasm": {
"version": "3.45.0-build1",
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.45.0-build1.tgz",
"integrity": "sha512-QAwE4n16t82g8kbhpuBzy6pzh7bm5VKziNKwQHmIPmtCBUk2AlUndsGS5qL8pAfOrrafXq9xILa0LdZkPFetgA==",
"bin": {
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@stencil/core": {
"version": "4.10.0",
"license": "MIT",

View File

@ -19,7 +19,7 @@
],
"scripts": {
"ng": "ng",
"start": "ionic serve --browser=$MOODLE_APP_BROWSER",
"start": "ionic serve --browser=$MOODLE_APP_BROWSER --ssl",
"serve:test": "NODE_ENV=testing ionic serve --no-open",
"build": "ionic build",
"build:prod": "NODE_ENV=production ionic build --prod",
@ -88,6 +88,7 @@
"@moodlehq/phonegap-plugin-push": "4.0.0-moodle.7",
"@ngx-translate/core": "^15.0.0",
"@ngx-translate/http-loader": "^8.0.0",
"@sqlite.org/sqlite-wasm": "^3.45.0-build1",
"@types/chart.js": "^2.9.31",
"@types/cordova": "0.0.34",
"@types/dom-mediacapture-record": "1.0.7",

View File

@ -0,0 +1,57 @@
diff --git a/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs b/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs
index b86a0aa..1be2b82 100644
--- a/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs
+++ b/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-bundler-friendly.mjs
@@ -533,7 +533,7 @@ var sqlite3InitModule = (() => {
wasmBinaryFile = locateFile(wasmBinaryFile);
}
} else {
- wasmBinaryFile = new URL('sqlite3.wasm', import.meta.url).href;
+ wasmBinaryFile = '/assets/lib/sqlite3/sqlite3.wasm';
}
function getBinary(file) {
@@ -10913,6 +10913,10 @@ var sqlite3InitModule = (() => {
}
},
+ lastInsertRowId: function () {
+ return capi.sqlite3_last_insert_rowid(affirmDbOpen(this).pointer);
+ },
+
dbFilename: function (dbName = 'main') {
return capi.sqlite3_db_filename(affirmDbOpen(this).pointer, dbName);
},
@@ -11877,12 +11881,14 @@ var sqlite3InitModule = (() => {
if (!hadColNames) rc.columnNames = [];
rc.callback = function (row, stmt) {
+ const rowId = rc.sql.includes('INSERT') ? db.lastInsertRowId() : undefined;
wState.post(
{
type: theCallback,
columnNames: rc.columnNames,
rowNumber: ++rowNumber,
row: row,
+ rowId,
},
wState.xfer,
);
@@ -12522,7 +12528,7 @@ var sqlite3InitModule = (() => {
return promiseResolve_(sqlite3);
};
const W = new Worker(
- new URL('sqlite3-opfs-async-proxy.js', import.meta.url),
+ '/assets/lib/sqlite3/sqlite3-opfs-async-proxy.js',
);
setTimeout(() => {
if (undefined === promiseWasRejected) {
@@ -13445,7 +13451,7 @@ var sqlite3InitModule = (() => {
});
return thePromise;
};
- installOpfsVfs.defaultProxyUri = 'sqlite3-opfs-async-proxy.js';
+ installOpfsVfs.defaultProxyUri = '/assets/lib/sqlite3/sqlite3-opfs-async-proxy.js';
globalThis.sqlite3ApiBootstrap.initializersAsync.push(
async (sqlite3) => {
try {

View File

@ -31,6 +31,8 @@ const ASSETS = {
'/src/core/features/h5p/assets': '/lib/h5p',
'/node_modules/ogv/dist': '/lib/ogv',
'/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css',
'/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm': '/lib/sqlite3/sqlite3.wasm',
'/node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js': '/lib/sqlite3/sqlite3-opfs-async-proxy.js',
};
module.exports = function(ctx) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -18,7 +18,9 @@ import { CoreSiteSchema } from '@services/sites';
* Database variables for AddonModScormOfflineProvider.
*/
export const ATTEMPTS_TABLE_NAME = 'addon_mod_scorm_offline_attempts';
export const ATTEMPTS_TABLE_PRIMARY_KEYS = ['scormid', 'userid', 'attempt'] as const;
export const TRACKS_TABLE_NAME = 'addon_mod_scorm_offline_scos_tracks';
export const TRACKS_TABLE_PRIMARY_KEYS = ['scormid', 'userid', 'attempt', 'scoid', 'element'] as const;
export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
name: 'AddonModScormOfflineProvider',
version: 1,
@ -58,7 +60,7 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
type: 'TEXT',
},
],
primaryKeys: ['scormid', 'userid', 'attempt'],
primaryKeys: [...ATTEMPTS_TABLE_PRIMARY_KEYS],
},
{
name: TRACKS_TABLE_NAME,
@ -101,7 +103,7 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
type: 'INTEGER',
},
],
primaryKeys: ['scormid', 'userid', 'attempt', 'scoid', 'element'],
primaryKeys: [...TRACKS_TABLE_PRIMARY_KEYS],
},
],
};
@ -125,6 +127,8 @@ export type AddonModScormAttemptDBRecord = AddonModScormOfflineDBCommonData & {
snapshot?: string | null;
};
export type AddonModScormAttemptDBPrimaryKeys = typeof ATTEMPTS_TABLE_PRIMARY_KEYS[number];
/**
* SCORM track data.
*/
@ -135,3 +139,5 @@ export type AddonModScormTrackDBRecord = AddonModScormOfflineDBCommonData & {
timemodified: number;
synced: number;
};
export type AddonModScormTrackDBPrimaryKeys = typeof TRACKS_TABLE_PRIMARY_KEYS[number];

View File

@ -13,7 +13,6 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreUser } from '@features/user/services/user';
import { CoreSites } from '@services/sites';
import { CoreSync } from '@services/sync';
@ -23,11 +22,15 @@ import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { CoreLogger } from '@singletons/logger';
import {
AddonModScormAttemptDBPrimaryKeys,
AddonModScormAttemptDBRecord,
AddonModScormOfflineDBCommonData,
AddonModScormTrackDBPrimaryKeys,
AddonModScormTrackDBRecord,
ATTEMPTS_TABLE_NAME,
ATTEMPTS_TABLE_PRIMARY_KEYS,
TRACKS_TABLE_NAME,
TRACKS_TABLE_PRIMARY_KEYS,
} from './database/scorm';
import {
AddonModScormDataEntry,
@ -38,6 +41,10 @@ import {
AddonModScormUserDataMap,
AddonModScormWSSco,
} from './scorm';
import { lazyMap, LazyMap } from '@/core/utils/lazy-map';
import { asyncInstance, AsyncInstance } from '@/core/utils/async-instance';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
/**
* Service to handle offline SCORM.
@ -47,8 +54,44 @@ export class AddonModScormOfflineProvider {
protected logger: CoreLogger;
protected tracksTables: LazyMap<
AsyncInstance<CoreDatabaseTable<AddonModScormTrackDBRecord, AddonModScormTrackDBPrimaryKeys, never>>
>;
protected attemptsTables: LazyMap<
AsyncInstance<CoreDatabaseTable<AddonModScormAttemptDBRecord, AddonModScormAttemptDBPrimaryKeys, never>>
>;
constructor() {
this.logger = CoreLogger.getInstance('AddonModScormOfflineProvider');
this.tracksTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<AddonModScormTrackDBRecord, AddonModScormTrackDBPrimaryKeys, never>(
TRACKS_TABLE_NAME,
{
siteId,
primaryKeyColumns: [...TRACKS_TABLE_PRIMARY_KEYS],
rowIdColumn: null,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.tracksTables[siteId],
},
),
),
);
this.attemptsTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<AddonModScormAttemptDBRecord, AddonModScormAttemptDBPrimaryKeys, never>(
ATTEMPTS_TABLE_NAME,
{
siteId,
primaryKeyColumns: [...ATTEMPTS_TABLE_PRIMARY_KEYS],
rowIdColumn: null,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.tracksTables[siteId],
},
),
),
);
}
/**
@ -76,40 +119,43 @@ export class AddonModScormOfflineProvider {
this.logger.debug(`Change attempt number from ${attempt} to ${newAttempt} in SCORM ${scormId}`);
// Update the attempt number.
const db = site.getDb();
const currentAttemptConditions: AddonModScormOfflineDBCommonData = {
scormid: scormId,
userid: userId,
attempt,
};
const newAttemptConditions: AddonModScormOfflineDBCommonData = {
scormid: scormId,
userid: userId,
attempt: newAttempt,
};
const newAttemptData: Partial<AddonModScormAttemptDBRecord> = {
attempt: newAttempt,
timemodified: CoreTimeUtils.timestamp(),
};
// Block the SCORM so it can't be synced.
CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scormId, 'changeAttemptNumber', site.id);
try {
await db.updateRecords(ATTEMPTS_TABLE_NAME, newAttemptData, currentAttemptConditions);
const currentAttemptConditions = {
sql: 'scormid = ? AND userid = ? AND attempt = ?',
sqlParams: [scormId, userId, attempt],
js: (record: AddonModScormOfflineDBCommonData) =>
record.scormid === scormId &&
record.userid === userId &&
record.attempt === attempt,
};
await this.attemptsTables[site.id].updateWhere(
{ attempt: newAttempt, timemodified: CoreTimeUtils.timestamp() },
currentAttemptConditions,
);
try {
// Now update the attempt number of all the tracks and mark them as not synced.
const newTrackData: Partial<AddonModScormTrackDBRecord> = {
attempt: newAttempt,
synced: 0,
};
await db.updateRecords(TRACKS_TABLE_NAME, newTrackData, currentAttemptConditions);
await this.tracksTables[site.id].updateWhere(
{ attempt: newAttempt, synced: 0 },
currentAttemptConditions,
);
} catch (error) {
// Failed to update the tracks, restore the old attempt number.
await db.updateRecords(ATTEMPTS_TABLE_NAME, { attempt }, newAttemptConditions);
await this.attemptsTables[site.id].updateWhere(
{ attempt },
{
sql: 'scormid = ? AND userid = ? AND attempt = ?',
sqlParams: [scormId, userId, newAttempt],
js: (attempt) =>
attempt.scormid === scormId &&
attempt.userid === userId &&
attempt.attempt === newAttempt,
},
);
throw error;
}
@ -148,7 +194,6 @@ export class AddonModScormOfflineProvider {
CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id);
// Create attempt in DB.
const db = site.getDb();
const entry: AddonModScormAttemptDBRecord = {
scormid: scorm.id,
userid: userId,
@ -166,7 +211,7 @@ export class AddonModScormOfflineProvider {
}
try {
await db.insertRecord(ATTEMPTS_TABLE_NAME, entry);
await this.attemptsTables[site.id].insert(entry);
// Store all the data in userData.
const promises: Promise<void>[] = [];
@ -204,16 +249,15 @@ export class AddonModScormOfflineProvider {
this.logger.debug(`Delete offline attempt ${attempt} in SCORM ${scormId}`);
const db = site.getDb();
const conditions: AddonModScormOfflineDBCommonData = {
const conditions = {
scormid: scormId,
userid: userId,
attempt,
};
await Promise.all([
db.deleteRecords(ATTEMPTS_TABLE_NAME, conditions),
db.deleteRecords(TRACKS_TABLE_NAME, conditions),
this.attemptsTables[site.id].delete(conditions),
this.tracksTables[site.id].delete(conditions),
]);
}
@ -280,9 +324,9 @@ export class AddonModScormOfflineProvider {
* @returns Promise resolved when the offline attempts are retrieved.
*/
async getAllAttempts(siteId?: string): Promise<AddonModScormOfflineAttempt[]> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
const attempts = await db.getAllRecords<AddonModScormAttemptDBRecord>(ATTEMPTS_TABLE_NAME);
const attempts = await this.attemptsTables[siteId].getMany();
return attempts.map((attempt) => this.parseAttempt(attempt));
}
@ -300,7 +344,7 @@ export class AddonModScormOfflineProvider {
const site = await CoreSites.getSite(siteId);
userId = userId || site.getUserId();
const attemptRecord = await site.getDb().getRecord<AddonModScormAttemptDBRecord>(ATTEMPTS_TABLE_NAME, {
const attemptRecord = await this.attemptsTables[site.id].getOneByPrimaryKey({
scormid: scormId,
userid: userId,
attempt,
@ -340,7 +384,7 @@ export class AddonModScormOfflineProvider {
const site = await CoreSites.getSite(siteId);
userId = userId || site.getUserId();
const attempts = await site.getDb().getRecords<AddonModScormAttemptDBRecord>(ATTEMPTS_TABLE_NAME, {
const attempts = await this.attemptsTables[site.id].getMany({
scormid: scormId,
userid: userId,
});
@ -428,7 +472,7 @@ export class AddonModScormOfflineProvider {
conditions.synced = 1;
}
const tracks = await site.getDb().getRecords<AddonModScormTrackDBRecord>(TRACKS_TABLE_NAME, conditions);
const tracks = await this.tracksTables[site.id].getMany(conditions);
return this.parseTracks(tracks);
}
@ -598,7 +642,6 @@ export class AddonModScormOfflineProvider {
userId = userId || site.getUserId();
const scoUserData = scoData?.userdata || {};
const db = site.getDb();
let lessonStatusInserted = false;
if (forceCompleted) {
@ -611,7 +654,16 @@ export class AddonModScormOfflineProvider {
if (scoUserData['cmi.core.lesson_status'] == 'incomplete') {
lessonStatusInserted = true;
await this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'completed');
await this.tracksTables[site.id].insert({
userid: userId,
scormid: scormId,
scoid: scoId,
attempt,
element: 'cmi.core.lesson_status',
value: JSON.stringify('completed'),
timemodified: CoreTimeUtils.timestamp(),
synced: 0,
});
}
}
}
@ -622,78 +674,32 @@ export class AddonModScormOfflineProvider {
}
try {
await this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value);
} catch (error) {
if (lessonStatusInserted) {
// Rollback previous insert.
await this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'incomplete');
}
throw error;
}
}
/**
* Insert a track in the DB.
*
* @param db Site's DB.
* @param userId User ID.
* @param scormId SCORM ID.
* @param scoId SCO ID.
* @param attempt Attempt number.
* @param element Name of the element to insert.
* @param value Value of the element to insert.
* @param synchronous True if insert should NOT return a promise. Please use it only if synchronous is a must.
* @returns Returns a promise if synchronous=false, otherwise returns a boolean.
*/
protected insertTrackToDB(
db: SQLiteDB,
userId: number,
scormId: number,
scoId: number,
attempt: number,
element: string,
value: AddonModScormDataValue | undefined,
synchronous: true,
): boolean;
protected insertTrackToDB(
db: SQLiteDB,
userId: number,
scormId: number,
scoId: number,
attempt: number,
element: string,
value?: AddonModScormDataValue,
synchronous?: false,
): Promise<number>;
protected insertTrackToDB(
db: SQLiteDB,
userId: number,
scormId: number,
scoId: number,
attempt: number,
element: string,
value?: AddonModScormDataValue,
synchronous?: boolean,
): boolean | Promise<number> {
const entry: AddonModScormTrackDBRecord = {
await this.tracksTables[site.id].insert({
userid: userId,
scormid: scormId,
scoid: scoId,
attempt,
element: element,
element,
value: value === undefined ? null : JSON.stringify(value),
timemodified: CoreTimeUtils.timestamp(),
synced: 0,
};
});
} catch (error) {
if (lessonStatusInserted) {
// Rollback previous insert.
await this.tracksTables[site.id].insert({
userid: userId,
scormid: scormId,
scoid: scoId,
attempt,
element: 'cmi.core.lesson_status',
value: JSON.stringify('incomplete'),
timemodified: CoreTimeUtils.timestamp(),
synced: 0,
});
}
if (synchronous) {
// The insert operation is always asynchronous, always return true.
db.insertRecord(TRACKS_TABLE_NAME, entry);
return true;
} else {
return db.insertRecord(TRACKS_TABLE_NAME, entry);
throw error;
}
}
@ -730,8 +736,7 @@ export class AddonModScormOfflineProvider {
}
const scoUserData = scoData?.userdata || {};
const db = CoreSites.getRequiredCurrentSite().getDb();
let lessonStatusInserted = false;
const siteId = CoreSites.getRequiredCurrentSite().id;
if (forceCompleted) {
if (element == 'cmi.core.lesson_status' && value == 'incomplete') {
@ -741,11 +746,16 @@ export class AddonModScormOfflineProvider {
}
if (element == 'cmi.core.score.raw') {
if (scoUserData['cmi.core.lesson_status'] == 'incomplete') {
lessonStatusInserted = true;
if (!this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'completed', true)) {
return false;
}
this.tracksTables[siteId].syncInsert({
userid: userId,
scormid: scormId,
scoid: scoId,
attempt,
element: 'cmi.core.lesson_status',
value: JSON.stringify('completed'),
timemodified: CoreTimeUtils.timestamp(),
synced: 0,
});
}
}
}
@ -755,15 +765,16 @@ export class AddonModScormOfflineProvider {
return true;
}
if (!this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value, true)) {
// Insert failed.
if (lessonStatusInserted) {
// Rollback previous insert.
this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'incomplete', true);
}
return false;
}
this.tracksTables[siteId].syncInsert({
userid: userId,
scormid: scormId,
scoid: scoId,
attempt,
element: element,
value: value === undefined ? null : JSON.stringify(value),
timemodified: CoreTimeUtils.timestamp(),
synced: 0,
});
return true;
}
@ -784,7 +795,7 @@ export class AddonModScormOfflineProvider {
this.logger.debug(`Mark SCO ${scoId} as synced for attempt ${attempt} in SCORM ${scormId}`);
await site.getDb().updateRecords(TRACKS_TABLE_NAME, { synced: 1 }, <Partial<AddonModScormTrackDBRecord>> {
await this.tracksTables[site.id].update({ synced: 1 }, {
scormid: scormId,
userid: userId,
attempt,
@ -971,10 +982,13 @@ export class AddonModScormOfflineProvider {
snapshot: JSON.stringify(this.removeDefaultData(userData)),
};
await site.getDb().updateRecords(ATTEMPTS_TABLE_NAME, newData, <Partial<AddonModScormAttemptDBRecord>> {
scormid: scormId,
userid: userId,
attempt,
await this.attemptsTables[site.id].updateWhere(newData, {
sql: 'scormid = ? AND userid = ? AND attempt = ?',
sqlParams: [scormId, userId, attempt],
js: (record: AddonModScormOfflineDBCommonData) =>
record.scormid === scormId &&
record.userid === userId &&
record.attempt === attempt,
});
}

View File

@ -4,6 +4,9 @@ Feature: Test appearance options of SCORM activity in app
As a student
I need appearance options to be applied properly
# SCORM iframes no longer work in the browser, hence the commented lines in this file.
# This should be reverted once MOBILE-4503 is solved.
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
@ -28,14 +31,14 @@ Feature: Test appearance options of SCORM activity in app
When I press "Current window SCORM" in the app
And I press "Enter" in the app
And I press "Disable fullscreen" in the app
Then the UI should match the snapshot
# Then the UI should match the snapshot
When I press the back button in the app
And I press the back button in the app
And I press "New window px SCORM" in the app
And I press "Enter" in the app
And I press "Disable fullscreen" in the app
Then the UI should match the snapshot
# Then the UI should match the snapshot
# SCORMs with percentage sizes are displayed with full size in the app. See MOBILE-3426 for details.
When I press the back button in the app
@ -43,7 +46,7 @@ Feature: Test appearance options of SCORM activity in app
And I press "New window perc SCORM" in the app
And I press "Enter" in the app
And I press "Disable fullscreen" in the app
Then the UI should match the snapshot
# Then the UI should match the snapshot
Scenario: Skip SCORM entry page if needed
Given the following "activities" exist:
@ -76,7 +79,7 @@ Feature: Test appearance options of SCORM activity in app
And I press the back button in the app
And I press "Always skip SCORM" in the app
And I press "Disable fullscreen" in the app
Then I should find "3 / 11" in the app
# Then I should find "3 / 11" in the app
Scenario: Disable preview mode
Given the following "activities" exist:

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,9 @@ Feature: Test basic usage of SCORM activity in app
As a student
I need basic SCORM functionality to work
# SCORM iframes no longer work in the browser, hence the commented lines in this file.
# This should be reverted once MOBILE-4503 is solved.
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
@ -17,35 +20,35 @@ Feature: Test basic usage of SCORM activity in app
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
Scenario: Resume progress when re-entering SCORM
Given the following "activities" exist:
| activity | name | intro | course | idnumber | packagefilepath |
| scorm | Basic SCORM | SCORM description | C1 | scorm | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12-mini.zip |
And I entered the course "Course 1" as "student1" in the app
When I press "Basic SCORM" in the app
And I press "Enter" in the app
And I press "Disable fullscreen" in the app
Then I should find "2 / 11" in the app
And I switch to "scorm_object" iframe
And I should see "Play of the game"
# Scenario: Resume progress when re-entering SCORM
# Given the following "activities" exist:
# | activity | name | intro | course | idnumber | packagefilepath |
# | scorm | Basic SCORM | SCORM description | C1 | scorm | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12-mini.zip |
# And I entered the course "Course 1" as "student1" in the app
# When I press "Basic SCORM" in the app
# And I press "Enter" in the app
# And I press "Disable fullscreen" in the app
# Then I should find "2 / 11" in the app
# And I switch to "scorm_object" iframe
# And I should see "Play of the game"
When I switch to the main frame
And I press "Next" in the app
And I press "Next" in the app
Then I should find "4 / 11" in the app
And I switch to "scorm_object" iframe
And I should see "Scoring"
# When I switch to the main frame
# And I press "Next" in the app
# And I press "Next" in the app
# Then I should find "4 / 11" in the app
# And I switch to "scorm_object" iframe
# And I should see "Scoring"
When I switch to the main frame
And I press the back button in the app
Then I should find "1" within "Number of attempts you have made" "ion-item" in the app
And I should find "3" within "Grade reported" "ion-item" in the app
# When I switch to the main frame
# And I press the back button in the app
# Then I should find "1" within "Number of attempts you have made" "ion-item" in the app
# And I should find "3" within "Grade reported" "ion-item" in the app
When I press "Enter" in the app
And I press "Disable fullscreen" in the app
Then I should find "5 / 11" in the app
And I switch to "scorm_object" iframe
And I should see "Other Scoring Systems"
# When I press "Enter" in the app
# And I press "Disable fullscreen" in the app
# Then I should find "5 / 11" in the app
# And I switch to "scorm_object" iframe
# And I should see "Other Scoring Systems"
Scenario: TOC displays the right status and opens the right SCO
Given the following "activities" exist:
@ -79,42 +82,42 @@ Feature: Test basic usage of SCORM activity in app
When I press "Close" in the app
And I press "Next" in the app
And I press "TOC" in the app
Then I should find "Completed" within "How to Play" "ion-item" in the app
And I should find "Not attempted" within "Par?" "ion-item" in the app
# Then I should find "Completed" within "How to Play" "ion-item" in the app
# And I should find "Not attempted" within "Par?" "ion-item" in the app
When I press "The Rules of Golf" in the app
Then I should find "6 / 11" in the app
And I switch to "scorm_object" iframe
And I should see "The Rules of Golf"
# And I switch to "scorm_object" iframe
# And I should see "The Rules of Golf"
When I switch to the main frame
And I press "TOC" in the app
Then I should find "Completed" within "How to Play" "ion-item" in the app
And I should find "Completed" within "Par?" "ion-item" in the app
And I should find "Not attempted" within "Keeping Score" "ion-item" in the app
And I should find "Not attempted" within "Other Scoring Systems" "ion-item" in the app
And I should find "Not attempted" within "The Rules of Golf" "ion-item" in the app
And I should find "Not attempted" within "Playing Golf Quiz" "ion-item" in the app
And I should find "Not attempted" within "How to Have Fun Playing Golf" "ion-item" in the app
And I should find "Not attempted" within "How to Make Friends Playing Golf" "ion-item" in the app
And I should find "Not attempted" within "Having Fun Quiz" "ion-item" in the app
# When I switch to the main frame
# And I press "TOC" in the app
# Then I should find "Completed" within "How to Play" "ion-item" in the app
# And I should find "Completed" within "Par?" "ion-item" in the app
# And I should find "Not attempted" within "Keeping Score" "ion-item" in the app
# And I should find "Not attempted" within "Other Scoring Systems" "ion-item" in the app
# And I should find "Not attempted" within "The Rules of Golf" "ion-item" in the app
# And I should find "Not attempted" within "Playing Golf Quiz" "ion-item" in the app
# And I should find "Not attempted" within "How to Have Fun Playing Golf" "ion-item" in the app
# And I should find "Not attempted" within "How to Make Friends Playing Golf" "ion-item" in the app
# And I should find "Not attempted" within "Having Fun Quiz" "ion-item" in the app
When I press "Close" in the app
And I press the back button in the app
Then I should find "Completed" within "How to Play" "ion-item" in the app
And I should find "Completed" within "Par?" "ion-item" in the app
And I should find "Not attempted" within "Keeping Score" "ion-item" in the app
And I should find "Not attempted" within "Other Scoring Systems" "ion-item" in the app
And I should find "Completed" within "The Rules of Golf" "ion-item" in the app
And I should find "Not attempted" within "Playing Golf Quiz" "ion-item" in the app
And I should find "Not attempted" within "How to Have Fun Playing Golf" "ion-item" in the app
And I should find "Not attempted" within "How to Make Friends Playing Golf" "ion-item" in the app
And I should find "Not attempted" within "Having Fun Quiz" "ion-item" in the app
# When I press "Close" in the app
# And I press the back button in the app
# Then I should find "Completed" within "How to Play" "ion-item" in the app
# And I should find "Completed" within "Par?" "ion-item" in the app
# And I should find "Not attempted" within "Keeping Score" "ion-item" in the app
# And I should find "Not attempted" within "Other Scoring Systems" "ion-item" in the app
# And I should find "Completed" within "The Rules of Golf" "ion-item" in the app
# And I should find "Not attempted" within "Playing Golf Quiz" "ion-item" in the app
# And I should find "Not attempted" within "How to Have Fun Playing Golf" "ion-item" in the app
# And I should find "Not attempted" within "How to Make Friends Playing Golf" "ion-item" in the app
# And I should find "Not attempted" within "Having Fun Quiz" "ion-item" in the app
When I press "How to Have Fun Playing Golf" in the app
Then I should find "9 / 11" in the app
And I switch to "scorm_object" iframe
And I should see "How to Have Fun Golfing"
# When I press "How to Have Fun Playing Golf" in the app
# Then I should find "9 / 11" in the app
# And I switch to "scorm_object" iframe
# And I should see "How to Have Fun Golfing"
Scenario: Preview SCORM
Given the following "activities" exist:
@ -142,19 +145,19 @@ Feature: Test basic usage of SCORM activity in app
When I press the back button in the app
Then I should find "1" within "Number of attempts you have made" "ion-item" in the app
And I should find "9" within "Grade reported" "ion-item" in the app
# And I should find "9" within "Grade reported" "ion-item" in the app
# Check that Preview doesn't start a new attempt.
When I press "Start a new attempt" in the app
And I press "Preview" in the app
And I press "Disable fullscreen" in the app
And I press "TOC" in the app
Then I should find "Complete" within "How to Play" "ion-item" in the app
And I should find "Complete" within "Having Fun Quiz" "ion-item" in the app
# # Check that Preview doesn't start a new attempt.
# When I press "Start a new attempt" in the app
# And I press "Preview" in the app
# And I press "Disable fullscreen" in the app
# And I press "TOC" in the app
# Then I should find "Complete" within "How to Play" "ion-item" in the app
# And I should find "Complete" within "Having Fun Quiz" "ion-item" in the app
When I press "Close" in the app
And I press the back button in the app
Then I should find "1" within "Number of attempts you have made" "ion-item" in the app
# When I press "Close" in the app
# And I press the back button in the app
# Then I should find "1" within "Number of attempts you have made" "ion-item" in the app
Scenario: Unsupported SCORM
Given the following "activities" exist:
@ -194,29 +197,29 @@ Feature: Test basic usage of SCORM activity in app
When I press "The first content (one SCO)" in the app
And I press "Disable fullscreen" in the app
And I switch to "scorm_object" iframe
And I click on "Common operations" "link"
And I click on "#set-lesson-status-button" "css_element"
And I click on "#ui-id-12" "css_element"
And I click on "#set-score-button" "css_element"
And I click on "#ui-id-26" "css_element"
And I press "Commit changes"
And I switch to the main frame
And I press "TOC" in the app
Then I should find "Passed" within "The first content (one SCO)" "ion-item" in the app
And I should not be able to press "SCO with prerequisite (first and secon SCO)" in the app
# And I click on "Common operations" "link"
# And I click on "#set-lesson-status-button" "css_element"
# And I click on "#ui-id-12" "css_element"
# And I click on "#set-score-button" "css_element"
# And I click on "#ui-id-26" "css_element"
# And I press "Commit changes"
# And I switch to the main frame
# And I press "TOC" in the app
# Then I should find "Passed" within "The first content (one SCO)" "ion-item" in the app
# And I should not be able to press "SCO with prerequisite (first and secon SCO)" in the app
When I press "The second content (one SCO too)" in the app
And I switch to "scorm_object" iframe
And I click on "Common operations" "link"
And I click on "#set-lesson-status-button" "css_element"
And I click on "#ui-id-13" "css_element"
And I click on "#set-score-button" "css_element"
And I click on "#ui-id-28" "css_element"
And I press "Commit changes"
And I switch to the main frame
And I press "TOC" in the app
Then I should find "Completed" within "The second content (one SCO too)" "ion-item" in the app
And I should be able to press "SCO with prerequisite (first and secon SCO)" in the app
# When I press "The second content (one SCO too)" in the app
# And I switch to "scorm_object" iframe
# And I click on "Common operations" "link"
# And I click on "#set-lesson-status-button" "css_element"
# And I click on "#ui-id-13" "css_element"
# And I click on "#set-score-button" "css_element"
# And I click on "#ui-id-28" "css_element"
# And I press "Commit changes"
# And I switch to the main frame
# And I press "TOC" in the app
# Then I should find "Completed" within "The second content (one SCO too)" "ion-item" in the app
# And I should be able to press "SCO with prerequisite (first and secon SCO)" in the app
@lms_from4.2
Scenario: View events are stored in the log

View File

@ -29,6 +29,7 @@ import {
import { CoreDebugDatabaseTable } from './debug-database-table';
import { CoreEagerDatabaseTable } from './eager-database-table';
import { CoreLazyDatabaseTable } from './lazy-database-table';
import { SubPartial } from '@/core/utils/types';
/**
* Database table proxy used to route database interactions through different implementations.
@ -38,16 +39,17 @@ import { CoreLazyDatabaseTable } from './lazy-database-table';
export class CoreDatabaseTableProxy<
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord = 'id',
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
RowIdColumn extends PrimaryKeyColumn = PrimaryKeyColumn,
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>,
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey> {
protected readonly DEFAULT_CACHING_STRATEGY = CoreDatabaseCachingStrategy.None;
protected target = asyncInstance<CoreDatabaseTable<DBRecord, PrimaryKeyColumn>>();
protected target = asyncInstance<CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn>>();
protected environmentObserver?: CoreEventObserver;
protected targetConstructors: Record<
CoreDatabaseCachingStrategy,
CoreDatabaseTableConstructor<DBRecord, PrimaryKeyColumn, PrimaryKey>
CoreDatabaseTableConstructor<DBRecord, PrimaryKeyColumn, RowIdColumn>
> = {
[CoreDatabaseCachingStrategy.Eager]: CoreEagerDatabaseTable,
[CoreDatabaseCachingStrategy.Lazy]: CoreLazyDatabaseTable,
@ -154,10 +156,17 @@ export class CoreDatabaseTableProxy<
/**
* @inheritdoc
*/
async insert(record: DBRecord): Promise<void> {
async insert(record: SubPartial<DBRecord, RowIdColumn>): Promise<number> {
return this.target.insert(record);
}
/**
* @inheritdoc
*/
syncInsert(record: SubPartial<DBRecord, RowIdColumn>): void {
this.target.syncInsert(record);
}
/**
* @inheritdoc
*/
@ -179,6 +188,13 @@ export class CoreDatabaseTableProxy<
return this.target.delete(conditions);
}
/**
* @inheritdoc
*/
async deleteWhere(conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
return this.target.deleteWhere(conditions);
}
/**
* @inheritdoc
*/
@ -239,7 +255,7 @@ export class CoreDatabaseTableProxy<
*
* @returns Target instance.
*/
protected async createTarget(): Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn>> {
protected async createTarget(): Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn>> {
const config = await this.getRuntimeConfig();
const table = this.createTable(config);
@ -252,7 +268,7 @@ export class CoreDatabaseTableProxy<
* @param config Database configuration.
* @returns Database table.
*/
protected createTable(config: Partial<CoreDatabaseConfiguration>): CoreDatabaseTable<DBRecord, PrimaryKeyColumn> {
protected createTable(config: Partial<CoreDatabaseConfiguration>): CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn> {
const DatabaseTable = this.targetConstructors[config.cachingStrategy ?? this.DEFAULT_CACHING_STRATEGY];
return new DatabaseTable(config, this.database, this.tableName, this.primaryKeyColumns);

View File

@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { SubPartial } from '@/core/utils/types';
import { CoreError } from '@classes/errors/error';
import { SQLiteDB, SQLiteDBRecordValue, SQLiteDBRecordValues } from '@classes/sqlitedb';
@ -21,13 +22,15 @@ import { SQLiteDB, SQLiteDBRecordValue, SQLiteDBRecordValues } from '@classes/sq
export class CoreDatabaseTable<
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord = 'id',
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
RowIdColumn extends PrimaryKeyColumn = PrimaryKeyColumn,
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>,
> {
protected config: Partial<CoreDatabaseConfiguration>;
protected database: SQLiteDB;
protected tableName: string;
protected primaryKeyColumns: PrimaryKeyColumn[];
protected rowIdColumn: RowIdColumn | null;
protected listeners: CoreDatabaseTableListener[] = [];
constructor(
@ -35,11 +38,13 @@ export class CoreDatabaseTable<
database: SQLiteDB,
tableName: string,
primaryKeyColumns?: PrimaryKeyColumn[],
rowIdColumn?: RowIdColumn | null,
) {
this.config = config;
this.database = database;
this.tableName = tableName;
this.primaryKeyColumns = primaryKeyColumns ?? ['id'] as PrimaryKeyColumn[];
this.rowIdColumn = rowIdColumn === null ? null : (rowIdColumn ?? 'id') as RowIdColumn;
}
/**
@ -253,9 +258,24 @@ export class CoreDatabaseTable<
* Insert a new record.
*
* @param record Database record.
* @returns New record row id.
*/
async insert(record: DBRecord): Promise<void> {
await this.database.insertRecord(this.tableName, record);
async insert(record: SubPartial<DBRecord, RowIdColumn>): Promise<number> {
const rowId = await this.database.insertRecord(this.tableName, record);
return rowId;
}
/**
* Insert a new record synchronously.
*
* @param record Database record.
*/
syncInsert(record: SubPartial<DBRecord, RowIdColumn>): void {
// The current database architecture does not support synchronous operations,
// so calling this method will mean that errors will be silenced. Because of that,
// this should only be called if using the asynchronous alternatives is not possible.
this.insert(record);
}
/**
@ -292,6 +312,18 @@ export class CoreDatabaseTable<
: await this.database.deleteRecords(this.tableName);
}
/**
* Delete records matching the given conditions.
*
* This method should be used when it's necessary to apply complex conditions; the simple `delete`
* method should be favored otherwise for better performance.
*
* @param conditions Matching conditions in SQL and JavaScript.
*/
async deleteWhere(conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
await this.database.deleteRecordsSelect(this.tableName, conditions.sql, conditions.sqlParams);
}
/**
* Delete a single record identified by its primary key.
*
@ -411,15 +443,17 @@ export interface CoreDatabaseTableListener {
export type CoreDatabaseTableConstructor<
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord = 'id',
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
RowIdColumn extends PrimaryKeyColumn = PrimaryKeyColumn,
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>,
> = {
new (
config: Partial<CoreDatabaseConfiguration>,
database: SQLiteDB,
tableName: string,
primaryKeyColumns?: PrimaryKeyColumn[]
): CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey>;
primaryKeyColumns?: PrimaryKeyColumn[],
rowIdColumn?: RowIdColumn | null,
): CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey>;
};

View File

@ -21,6 +21,7 @@ import {
CoreDatabaseReducer,
CoreDatabaseQueryOptions,
} from './database-table';
import { SubPartial } from '@/core/utils/types';
/**
* Database table proxy used to debug runtime operations.
@ -30,13 +31,14 @@ import {
export class CoreDebugDatabaseTable<
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord = 'id',
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
RowIdColumn extends PrimaryKeyColumn = PrimaryKeyColumn,
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>,
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey> {
protected target: CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey>;
protected target: CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey>;
protected logger: CoreLogger;
constructor(target: CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey>) {
constructor(target: CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey>) {
super(target.getConfig(), target.getDatabase(), target.getTableName(), target.getPrimaryKeyColumns());
this.target = target;
@ -48,7 +50,7 @@ export class CoreDebugDatabaseTable<
*
* @returns Table instance.
*/
getTarget(): CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
getTarget(): CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey> {
return this.target;
}
@ -152,7 +154,7 @@ export class CoreDebugDatabaseTable<
/**
* @inheritdoc
*/
insert(record: DBRecord): Promise<void> {
insert(record: SubPartial<DBRecord, RowIdColumn>): Promise<number> {
this.logger.log('insert', record);
return this.target.insert(record);
@ -185,6 +187,15 @@ export class CoreDebugDatabaseTable<
return this.target.delete(conditions);
}
/**
* @inheritdoc
*/
async deleteWhere(conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
this.logger.log('deleteWhere', conditions);
return this.target.deleteWhere(conditions);
}
/**
* @inheritdoc
*/

View File

@ -21,6 +21,7 @@ import {
CoreDatabaseReducer,
CoreDatabaseQueryOptions,
} from './database-table';
import { SubPartial } from '@/core/utils/types';
/**
* Wrapper used to improve performance by caching all the records for faster read operations.
@ -31,8 +32,9 @@ import {
export class CoreEagerDatabaseTable<
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord = 'id',
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreInMemoryDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
RowIdColumn extends PrimaryKeyColumn = PrimaryKeyColumn,
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>,
> extends CoreInMemoryDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey> {
protected records: Record<string, DBRecord> = {};
@ -153,12 +155,10 @@ export class CoreEagerDatabaseTable<
/**
* @inheritdoc
*/
async insert(record: DBRecord): Promise<void> {
await super.insert(record);
async insert(record: SubPartial<DBRecord, RowIdColumn>): Promise<number> {
const rowId = await this.insertAndRemember(record, this.records);
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record));
this.records[primaryKey] = record;
return rowId;
}
/**
@ -203,12 +203,27 @@ export class CoreEagerDatabaseTable<
return;
}
Object.entries(this.records).forEach(([id, record]) => {
Object.entries(this.records).forEach(([primaryKey, record]) => {
if (!this.recordMatches(record, conditions)) {
return;
}
delete this.records[id];
delete this.records[primaryKey];
});
}
/**
* @inheritdoc
*/
async deleteWhere(conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
await super.deleteWhere(conditions);
Object.entries(this.records).forEach(([primaryKey, record]) => {
if (!conditions.js(record)) {
return;
}
delete record[primaryKey];
});
}

View File

@ -16,6 +16,7 @@ import { CoreConstants } from '@/core/constants';
import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb';
import { CoreLogger } from '@singletons/logger';
import { CoreDatabaseTable, GetDBRecordPrimaryKey } from './database-table';
import { SubPartial } from '@/core/utils/types';
/**
* Database wrapper that caches database records in memory to speed up read operations.
@ -26,8 +27,9 @@ import { CoreDatabaseTable, GetDBRecordPrimaryKey } from './database-table';
export abstract class CoreInMemoryDatabaseTable<
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord = 'id',
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
RowIdColumn extends PrimaryKeyColumn = PrimaryKeyColumn,
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>,
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey> {
private static readonly ACTIVE_TABLES: WeakMap<SQLiteDB, Set<string>> = new WeakMap();
private static readonly LOGGER: CoreLogger = CoreLogger.getInstance('CoreInMemoryDatabaseTable');
@ -70,4 +72,28 @@ export abstract class CoreInMemoryDatabaseTable<
}
}
/**
* Insert a new record and store it in the given object.
*
* @param record Database record.
* @param records Records object.
* @returns New record row id.
*/
protected async insertAndRemember(
record: SubPartial<DBRecord, RowIdColumn>,
records: Record<string, DBRecord | null>,
): Promise<number> {
const rowId = await super.insert(record);
const completeRecord = (this.rowIdColumn && !(this.rowIdColumn in record))
? Object.assign({ [this.rowIdColumn]: rowId }, record) as DBRecord
: record as DBRecord;
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(completeRecord));
records[primaryKey] = completeRecord;
return rowId;
}
}

View File

@ -21,6 +21,7 @@ import {
GetDBRecordPrimaryKey,
CoreDatabaseQueryOptions,
} from './database-table';
import { SubPartial } from '@/core/utils/types';
/**
* Wrapper used to improve performance by caching records that are used often for faster read operations.
@ -31,8 +32,9 @@ import {
export class CoreLazyDatabaseTable<
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord = 'id',
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreInMemoryDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
RowIdColumn extends PrimaryKeyColumn = PrimaryKeyColumn,
PrimaryKey extends GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>,
> extends CoreInMemoryDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn, PrimaryKey> {
protected readonly DEFAULT_CACHE_LIFETIME = 60000;
@ -137,10 +139,10 @@ export class CoreLazyDatabaseTable<
/**
* @inheritdoc
*/
async insert(record: DBRecord): Promise<void> {
await super.insert(record);
async insert(record: SubPartial<DBRecord, RowIdColumn>): Promise<number> {
const rowId = await this.insertAndRemember(record, this.records);
this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record;
return rowId;
}
/**
@ -188,6 +190,21 @@ export class CoreLazyDatabaseTable<
}
}
/**
* @inheritdoc
*/
async deleteWhere(conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
await super.deleteWhere(conditions);
Object.entries(this.records).forEach(([primaryKey, record]) => {
if (!record || !conditions.js(record)) {
return;
}
this.records[primaryKey] = null;
});
}
/**
* @inheritdoc
*/

View File

@ -42,8 +42,10 @@ import { CoreDatabaseCachingStrategy } from '../database/database-table-proxy';
import {
CONFIG_TABLE,
CoreSiteConfigDBRecord,
CoreSiteLastViewedDBPrimaryKeys,
CoreSiteLastViewedDBRecord,
CoreSiteWSCacheRecord,
LAST_VIEWED_PRIMARY_KEYS,
LAST_VIEWED_TABLE,
WS_CACHE_TABLE,
} from '@services/database/sites';
@ -65,8 +67,8 @@ export class CoreSite extends CoreAuthenticatedSite {
protected db!: SQLiteDB;
protected cacheTable: AsyncInstance<CoreDatabaseTable<CoreSiteWSCacheRecord>>;
protected configTable: AsyncInstance<CoreDatabaseTable<CoreSiteConfigDBRecord, 'name'>>;
protected lastViewedTable: AsyncInstance<CoreDatabaseTable<CoreSiteLastViewedDBRecord, 'component' | 'id'>>;
protected configTable: AsyncInstance<CoreDatabaseTable<CoreSiteConfigDBRecord, 'name', never>>;
protected lastViewedTable: AsyncInstance<CoreDatabaseTable<CoreSiteLastViewedDBRecord, CoreSiteLastViewedDBPrimaryKeys>>;
protected lastAutoLogin = 0;
protected tokenPluginFileWorks?: boolean;
protected tokenPluginFileWorksPromise?: Promise<boolean>;
@ -99,18 +101,19 @@ export class CoreSite extends CoreAuthenticatedSite {
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
}));
this.configTable = asyncInstance(() => CoreSites.getSiteTable(CONFIG_TABLE, {
this.configTable = asyncInstance(() => CoreSites.getSiteTable<CoreSiteConfigDBRecord, 'name', never>(CONFIG_TABLE, {
siteId: this.getId(),
database: this.getDb(),
config: { cachingStrategy: CoreDatabaseCachingStrategy.Eager },
primaryKeyColumns: ['name'],
rowIdColumn: null,
}));
this.lastViewedTable = asyncInstance(() => CoreSites.getSiteTable(LAST_VIEWED_TABLE, {
siteId: this.getId(),
database: this.getDb(),
config: { cachingStrategy: CoreDatabaseCachingStrategy.Eager },
primaryKeyColumns: ['component', 'id'],
primaryKeyColumns: [...LAST_VIEWED_PRIMARY_KEYS],
}));
this.setInfo(otherData.info);
this.calculateOfflineDisabled();
@ -876,14 +879,9 @@ export class CoreSite extends CoreAuthenticatedSite {
return await this.lastViewedTable.getMany({ component });
}
const whereAndParams = SQLiteDB.getInOrEqual(ids);
whereAndParams.sql = 'id ' + whereAndParams.sql + ' AND component = ?';
whereAndParams.params.push(component);
return await this.lastViewedTable.getManyWhere({
sql: whereAndParams.sql,
sqlParams: whereAndParams.params,
sql: `id IN (${ids.map(() => '?').join(', ')}) AND component = ?`,
sqlParams: [...ids, component],
js: (record) => record.component === component && ids.includes(record.id),
});
} catch {

View File

@ -14,10 +14,7 @@
import { SQLiteObject } from '@awesome-cordova-plugins/sqlite/ngx';
import { SQLite } from '@singletons';
import { CoreError } from '@classes/errors/error';
import { CoreDB } from '@services/db';
import { CorePlatform } from '@services/platform';
type SQLiteDBColumnType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB';
@ -137,62 +134,13 @@ export interface SQLiteDBForeignKeySchema {
*/
export class SQLiteDB {
/**
* Constructs 'IN()' or '=' sql fragment
*
* @param items A single value or array of values for the expression. It doesn't accept objects.
* @param equal True means we want to equate to the constructed expression.
* @param onEmptyItems This defines the behavior when the array of items provided is empty. Defaults to false,
* meaning return empty. Other values will become part of the returned SQL fragment.
* @returns A list containing the constructed sql fragment and an array of parameters.
*/
static getInOrEqual(
items: SQLiteDBRecordValue | SQLiteDBRecordValue[],
equal: boolean = true,
onEmptyItems?: SQLiteDBRecordValue | null,
): SQLiteDBQueryParams {
let sql = '';
let params: SQLiteDBRecordValue[];
// Default behavior, return empty data on empty array.
if (Array.isArray(items) && !items.length && onEmptyItems === undefined) {
return { sql: '', params: [] };
}
// Handle onEmptyItems on empty array of items.
if (Array.isArray(items) && !items.length) {
if (onEmptyItems === null) { // Special case, NULL value.
sql = equal ? ' IS NULL' : ' IS NOT NULL';
return { sql, params: [] };
} else {
items = [onEmptyItems as SQLiteDBRecordValue]; // Rest of cases, prepare items for processing.
}
}
if (!Array.isArray(items) || items.length == 1) {
sql = equal ? '= ?' : '<> ?';
params = Array.isArray(items) ? items : [items];
} else {
const questionMarks = ',?'.repeat(items.length).substring(1);
sql = (equal ? '' : 'NOT ') + `IN (${questionMarks})`;
params = items;
}
return { sql, params };
}
db?: SQLiteObject;
promise!: Promise<void>;
/**
* Create and open the database.
*
* @param name Database name.
* @param db Database connection.
*/
constructor(public name: string) {
this.init();
}
constructor(public name: string, private db: SQLiteObject) {}
/**
* Add a column to an existing table.
@ -322,9 +270,7 @@ export class SQLiteDB {
* @returns Promise resolved when done.
*/
async close(): Promise<void> {
await this.ready();
await this.db?.close();
await this.db.close();
}
/**
@ -500,9 +446,7 @@ export class SQLiteDB {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async execute(sql: string, params?: SQLiteDBRecordValue[]): Promise<any> {
await this.ready();
return this.db?.executeSql(sql, params);
return this.db.executeSql(sql, params);
}
/**
@ -515,9 +459,7 @@ export class SQLiteDB {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async executeBatch(sqlStatements: (string | string[] | any)[]): Promise<void> {
await this.ready();
await this.db?.sqlBatch(sqlStatements);
await this.db.sqlBatch(sqlStatements);
}
/**
@ -798,25 +740,6 @@ export class SQLiteDB {
};
}
/**
* Initialize the database.
*/
init(): void {
this.promise = this.createDatabase().then(db => {
if (CoreDB.loggingEnabled()) {
const spies = this.getDatabaseSpies(db);
db = new Proxy(db, {
get: (target, property, receiver) => spies[property] ?? Reflect.get(target, property, receiver),
});
}
this.db = db;
return;
});
}
/**
* Insert a record into a table and return the "rowId" field.
*
@ -943,18 +866,7 @@ export class SQLiteDB {
* @returns Promise resolved when open.
*/
async open(): Promise<void> {
await this.ready();
await this.db?.open();
}
/**
* Wait for the DB to be ready.
*
* @returns Promise resolved when ready.
*/
ready(): Promise<void> {
return this.promise;
await this.db.open();
}
/**
@ -1139,83 +1051,6 @@ export class SQLiteDB {
return { sql, params };
}
/**
* Open a database connection.
*
* @returns Database.
*/
protected async createDatabase(): Promise<SQLiteObject> {
await CorePlatform.ready();
return SQLite.create({ name: this.name, location: 'default' });
}
/**
* Get database spy methods to intercept database calls and track logging information.
*
* @param db Database to spy.
* @returns Spy methods.
*/
protected getDatabaseSpies(db: SQLiteObject): Partial<SQLiteObject> {
const dbName = this.name;
return {
async executeSql(statement, params) {
const start = performance.now();
try {
const result = await db.executeSql(statement, params);
CoreDB.logQuery({
params,
sql: statement,
duration: performance.now() - start,
dbName,
});
return result;
} catch (error) {
CoreDB.logQuery({
params,
error,
sql: statement,
duration: performance.now() - start,
dbName,
});
throw error;
}
},
async sqlBatch(statements) {
const start = performance.now();
const sql = Array.isArray(statements)
? statements.join(' | ')
: String(statements);
try {
const result = await db.sqlBatch(statements);
CoreDB.logQuery({
sql,
duration: performance.now() - start,
dbName,
});
return result;
} catch (error) {
CoreDB.logQuery({
sql,
error,
duration: performance.now() - start,
dbName,
});
throw error;
}
},
};
}
}
export type SQLiteDBRecordValues = {

View File

@ -69,17 +69,17 @@ export class CoreInfiniteLoadingComponent implements OnChanges {
return;
}
// Wait until next tick to allow items to render and scroll content to grow.
await CoreUtils.nextTick();
const scrollElement = await this.hostElement.closest('ion-content')?.getScrollElement();
// Calculate distance from edge.
const content = this.hostElement.closest('ion-content');
if (!content) {
if (!scrollElement) {
return;
}
const scrollElement = await content.getScrollElement();
// Wait to allow items to render and scroll content to grow.
await CoreUtils.nextTick();
await CoreUtils.waitFor(() => scrollElement.scrollHeight > scrollElement.clientHeight, { timeout: 1000 });
// Calculate distance from edge.
const infiniteHeight = this.hostElement.getBoundingClientRect().height;
const scrollTop = scrollElement.scrollTop;
const height = scrollElement.offsetHeight;

View File

@ -27,7 +27,12 @@ import { makeSingleton, Translate } from '@singletons';
import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
import {
CoreCourseStatusDBRecord, CoreCourseViewedModulesDBRecord, COURSE_STATUS_TABLE, COURSE_VIEWED_MODULES_TABLE ,
CoreCourseStatusDBRecord,
CoreCourseViewedModulesDBPrimaryKeys,
CoreCourseViewedModulesDBRecord,
COURSE_STATUS_TABLE,
COURSE_VIEWED_MODULES_PRIMARY_KEYS,
COURSE_VIEWED_MODULES_TABLE,
} from './database/course';
import { CoreCourseOffline } from './course-offline';
import { CoreError } from '@classes/errors/error';
@ -51,7 +56,6 @@ import { lazyMap, LazyMap } from '@/core/utils/lazy-map';
import { asyncInstance, AsyncInstance } from '@/core/utils/async-instance';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
import { SQLiteDB } from '@classes/sqlitedb';
import { CorePlatform } from '@services/platform';
import { asyncObservable } from '@/core/utils/rxjs';
import { firstValueFrom } from 'rxjs';
@ -121,7 +125,9 @@ export class CoreCourseProvider {
protected logger: CoreLogger;
protected statusTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreCourseStatusDBRecord>>>;
protected viewedModulesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreCourseViewedModulesDBRecord, 'courseId' | 'cmId'>>>;
protected viewedModulesTables: LazyMap<
AsyncInstance<CoreDatabaseTable<CoreCourseViewedModulesDBRecord, CoreCourseViewedModulesDBPrimaryKeys, never>>
>;
constructor() {
this.logger = CoreLogger.getInstance('CoreCourseProvider');
@ -137,12 +143,16 @@ export class CoreCourseProvider {
this.viewedModulesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<CoreCourseViewedModulesDBRecord, 'courseId' | 'cmId'>(COURSE_VIEWED_MODULES_TABLE, {
() => CoreSites.getSiteTable<CoreCourseViewedModulesDBRecord, CoreCourseViewedModulesDBPrimaryKeys, never>(
COURSE_VIEWED_MODULES_TABLE,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
primaryKeyColumns: ['courseId', 'cmId'],
primaryKeyColumns: [...COURSE_VIEWED_MODULES_PRIMARY_KEYS],
rowIdColumn: null,
onDestroy: () => delete this.viewedModulesTables[siteId],
}),
},
),
),
);
}
@ -380,12 +390,9 @@ export class CoreCourseProvider {
}
const site = await CoreSites.getSite(siteId);
const whereAndParams = SQLiteDB.getInOrEqual(ids);
const entries = await this.viewedModulesTables[site.getId()].getManyWhere({
sql: 'cmId ' + whereAndParams.sql,
sqlParams: whereAndParams.params,
sql: `cmId IN (${ids.map(() => '?').join(', ')})`,
sqlParams: ids,
js: (record) => ids.includes(record.cmId),
});

View File

@ -19,6 +19,7 @@ import { CoreSiteSchema } from '@services/sites';
*/
export const COURSE_STATUS_TABLE = 'course_status';
export const COURSE_VIEWED_MODULES_TABLE = 'course_viewed_modules';
export const COURSE_VIEWED_MODULES_PRIMARY_KEYS = ['courseId', 'cmId'] as const;
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreCourseProvider',
version: 2,
@ -75,7 +76,7 @@ export const SITE_SCHEMA: CoreSiteSchema = {
type: 'INTEGER',
},
],
primaryKeys: ['courseId', 'cmId'],
primaryKeys: [...COURSE_VIEWED_MODULES_PRIMARY_KEYS],
},
],
};
@ -133,6 +134,8 @@ export type CoreCourseViewedModulesDBRecord = {
sectionId?: number;
};
export type CoreCourseViewedModulesDBPrimaryKeys = typeof COURSE_VIEWED_MODULES_PRIMARY_KEYS[number];
export type CoreCourseManualCompletionDBRecord = {
cmid: number;
completed: number;

View File

@ -1,219 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { SQLiteDB } from '@classes/sqlitedb';
import { DbTransaction, SQLiteObject } from '@awesome-cordova-plugins/sqlite/ngx';
import { CoreDB } from '@services/db';
/**
* Class to mock the interaction with the SQLite database.
*/
export class SQLiteDBMock extends SQLiteDB {
/**
* Create and open the database.
*
* @param name Database name.
*/
constructor(public name: string) {
super(name);
}
/**
* Close the database.
*
* @returns Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
close(): Promise<any> {
// WebSQL databases aren't closed.
return Promise.resolve();
}
/**
* Drop all the data in the database.
*
* @returns Promise resolved when done.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async emptyDatabase(): Promise<any> {
await this.ready();
return new Promise((resolve, reject): void => {
this.db?.transaction((tx) => {
// Query all tables from sqlite_master that we have created and can modify.
const args = [];
const query = `SELECT * FROM sqlite_master
WHERE name NOT LIKE 'sqlite\\_%' escape '\\' AND name NOT LIKE '\\_%' escape '\\'`;
tx.executeSql(query, args, (tx, result) => {
if (result.rows.length <= 0) {
// No tables to delete, stop.
resolve(null);
return;
}
// Drop all the tables.
const promises: Promise<void>[] = [];
for (let i = 0; i < result.rows.length; i++) {
promises.push(new Promise((resolve, reject): void => {
// Drop the table.
const name = JSON.stringify(result.rows.item(i).name);
tx.executeSql('DROP TABLE ' + name, [], resolve, reject);
}));
}
Promise.all(promises).then(resolve).catch(reject);
}, reject);
});
});
}
/**
* Execute a SQL query.
* IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that
* these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments.
*
* @param sql SQL query to execute.
* @param params Query parameters.
* @returns Promise resolved with the result.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async execute(sql: string, params?: any[]): Promise<any> {
await this.ready();
return new Promise((resolve, reject): void => {
// With WebSQL, all queries must be run in a transaction.
this.db?.transaction((tx) => {
tx.executeSql(
sql,
params,
(_, results) => resolve(results),
(_, error) => reject(new Error(`SQL failed: ${sql}, reason: ${error?.message}`)),
);
});
});
}
/**
* Execute a set of SQL queries. This operation is atomic.
* IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that
* these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments.
*
* @param sqlStatements SQL statements to execute.
* @returns Promise resolved with the result.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async executeBatch(sqlStatements: any[]): Promise<any> {
await this.ready();
return new Promise((resolve, reject): void => {
// Create a transaction to execute the queries.
this.db?.transaction((tx) => {
const promises: Promise<void>[] = [];
// Execute all the queries. Each statement can be a string or an array.
sqlStatements.forEach((statement) => {
promises.push(new Promise((resolve, reject): void => {
let query;
let params;
if (Array.isArray(statement)) {
query = statement[0];
params = statement[1];
} else {
query = statement;
params = null;
}
tx.executeSql(query, params, (_, results) => resolve(results), (_, error) => reject(error));
}));
});
// eslint-disable-next-line promise/catch-or-return
Promise.all(promises).then(resolve, reject);
});
});
}
/**
* Open the database. Only needed if it was closed before, a database is automatically opened when created.
*
* @returns Promise resolved when done.
*/
open(): Promise<void> {
// WebSQL databases can't closed, so the open method isn't needed.
return Promise.resolve();
}
/**
* @inheritdoc
*/
protected async createDatabase(): Promise<SQLiteObject> {
// This DB is for desktop apps, so use a big size to be sure it isn't filled.
return (window as unknown as WebSQLWindow).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024);
}
/**
* @inheritdoc
*/
protected getDatabaseSpies(db: SQLiteObject): Partial<SQLiteObject> {
const dbName = this.name;
return {
transaction: (callback) => db.transaction((transaction) => {
const transactionSpy: DbTransaction = {
executeSql(sql, params, success, error) {
const start = performance.now();
return transaction.executeSql(
sql,
params,
(...args) => {
CoreDB.logQuery({
sql,
params,
duration: performance.now() - start,
dbName,
});
return success?.(...args);
},
(...args) => {
CoreDB.logQuery({
sql,
params,
error: args[0],
duration: performance.now() - start,
dbName,
});
return error?.(...args);
},
);
},
};
return callback(transactionSpy);
}),
};
}
}
interface WebSQLWindow extends Window {
openDatabase(name: string, version: string, displayName: string, estimatedSize: number): SQLiteObject;
}

View File

@ -0,0 +1,134 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SQLiteObject } from '@awesome-cordova-plugins/sqlite/ngx';
import { CorePromisedValue } from '@classes/promised-value';
import { Sqlite3Worker1Promiser, sqlite3Worker1Promiser } from '@sqlite.org/sqlite-wasm';
/**
* Throw an error indicating that the given method hasn't been implemented.
*
* @param method Method name.
*/
function notImplemented(method: string): any {
throw new Error(`${method} method not implemented.`);
}
/**
* SQLiteObject adapter implemented using the sqlite-wasm package.
*/
export class WasmSQLiteObject implements SQLiteObject {
private name: string;
private promisedPromiser: CorePromisedValue<Sqlite3Worker1Promiser>;
private promiser: Sqlite3Worker1Promiser;
constructor(name: string) {
this.name = name;
this.promisedPromiser = new CorePromisedValue();
this.promiser = async (...args) => {
const promiser = await this.promisedPromiser;
return promiser.call(promiser, ...args);
};
}
/**
* Delete the database.
*/
async delete(): Promise<any> {
if (!this.promisedPromiser.isResolved()) {
await this.open();
}
await this.promiser('close', { unlink: true });
}
/**
* @inheritdoc
*/
async open(): Promise<any> {
const promiser = await new Promise<Sqlite3Worker1Promiser>((resolve) => {
const _promiser = sqlite3Worker1Promiser(() => resolve(_promiser));
});
await promiser('open', { filename: `file:${this.name}.sqlite3`, vfs: 'opfs' });
this.promisedPromiser.resolve(promiser);
}
/**
* @inheritdoc
*/
async close(): Promise<any> {
await this.promiser('close', {});
}
/**
* @inheritdoc
*/
async executeSql(statement: string, params?: any[] | undefined): Promise<any> {
let insertId: number | undefined = undefined;
const rows = [] as unknown[];
await this.promiser('exec', {
sql: statement,
bind: params,
callback({ row, columnNames, rowId }) {
if (!row) {
return;
}
insertId ||= rowId;
rows.push(columnNames.reduce((record, column, index) => {
record[column] = row[index];
return record;
}, {}));
},
});
return {
rows: {
item: (i: number) => rows[i],
length: rows.length,
},
rowsAffected: rows.length,
insertId,
};
}
/**
* @inheritdoc
*/
async sqlBatch(sqlStatements: any[]): Promise<any> {
await Promise.all(sqlStatements.map(sql => this.executeSql(sql)));
}
// These methods and properties are not used in our app,
// but still need to be declared to conform with the SQLiteObject interface.
_objectInstance = null; // eslint-disable-line @typescript-eslint/naming-convention
databaseFeatures = { isSQLitePluginDatabase: false };
openDBs = null;
addTransaction = () => notImplemented('SQLiteObject.addTransaction');
transaction = () => notImplemented('SQLiteObject.transaction');
readTransaction = () => notImplemented('SQLiteObject.readTransaction');
startNextTransaction = () => notImplemented('SQLiteObject.startNextTransaction');
abortallPendingTransactions = () => notImplemented('SQLiteObject.abortallPendingTransactions');
}

View File

@ -42,6 +42,8 @@ import { CorePlatform } from '@services/platform';
import { CoreLocalNotifications } from '@services/local-notifications';
import { CoreNative } from '@features/native/services/native';
import { SecureStorageMock } from '@features/emulator/classes/SecureStorage';
import { CoreDbProvider } from '@services/db';
import { CoreDbProviderMock } from '@features/emulator/services/db';
/**
* This module handles the emulation of Cordova plugins in browser and desktop.
@ -95,6 +97,10 @@ import { SecureStorageMock } from '@features/emulator/classes/SecureStorage';
? new LocalNotifications()
: new LocalNotificationsMock(),
},
{
provide: CoreDbProvider,
useFactory: (): CoreDbProvider => CorePlatform.is('cordova') ? new CoreDbProvider() : new CoreDbProviderMock(),
},
{
provide: APP_INITIALIZER,
useValue: async () => {

View File

@ -0,0 +1,47 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { asyncInstance } from '@/core/utils/async-instance';
import { SQLiteObject } from '@awesome-cordova-plugins/sqlite/ngx';
import { WasmSQLiteObject } from '@features/emulator/classes/wasm-sqlite-object';
import { CoreDbProvider } from '@services/db';
/**
* Emulates the database provider in the browser.
*/
export class CoreDbProviderMock extends CoreDbProvider {
/**
* @inheritdoc
*/
protected createDatabase(name: string): SQLiteObject {
return asyncInstance(async () => {
const db = new WasmSQLiteObject(name);
await db.open();
return db;
});
}
/**
* @inheritdoc
*/
protected async deleteDatabase(name: string): Promise<void> {
const db = new WasmSQLiteObject(name);
await db.delete();
}
}

View File

@ -201,14 +201,14 @@ export class CoreH5PFileStorage {
const result = await db.execute(query, queryArgs);
await Array.from(result.rows).map(async (entry: {foldername: string}) => {
await Promise.all(Array.from(result.rows).map(async (entry: {foldername: string}) => {
try {
// Delete the index.html.
await this.deleteContentIndex(entry.foldername, site.getId());
} catch {
// Ignore errors.
}
});
}));
}
/**

View File

@ -43,13 +43,86 @@ import { CoreH5PContentBeingSaved, CoreH5PLibraryBeingSaved } from './storage';
import { CoreH5PLibraryAddTo, CoreH5PLibraryMetadataSettings } from './validator';
import { CoreH5PMetadata } from './metadata';
import { Translate } from '@singletons';
import { SQLiteDB } from '@classes/sqlitedb';
import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance';
import { LazyMap, lazyMap } from '@/core/utils/lazy-map';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
import { SubPartial } from '@/core/utils/types';
/**
* Equivalent to Moodle's implementation of H5PFrameworkInterface.
*/
export class CoreH5PFramework {
protected contentTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PContentDBRecord>>>;
protected librariesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PLibraryDBRecord>>>;
protected libraryDependenciesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PLibraryDependencyDBRecord>>>;
protected contentsLibrariesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PContentsLibraryDBRecord>>>;
protected librariesCachedAssetsTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreH5PLibraryCachedAssetsDBRecord>>>;
constructor() {
this.contentTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(
CONTENT_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.contentTables[siteId],
},
),
),
);
this.librariesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(
LIBRARIES_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.librariesTables[siteId],
},
),
),
);
this.libraryDependenciesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(
LIBRARY_DEPENDENCIES_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.libraryDependenciesTables[siteId],
},
),
),
);
this.contentsLibrariesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(
CONTENTS_LIBRARIES_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.contentsLibrariesTables[siteId],
},
),
),
);
this.librariesCachedAssetsTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(
LIBRARIES_CACHEDASSETS_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.librariesCachedAssetsTables[siteId],
},
),
),
);
}
/**
* Will clear filtered params for all the content that uses the specified libraries.
* This means that the content dependencies will have to be rebuilt and the parameters re-filtered.
@ -63,12 +136,16 @@ export class CoreH5PFramework {
return;
}
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
const whereAndParams = SQLiteDB.getInOrEqual(libraryIds);
whereAndParams.sql = 'mainlibraryid ' + whereAndParams.sql;
await db.updateRecordsWhere(CONTENT_TABLE_NAME, { filtered: null }, whereAndParams.sql, whereAndParams.params);
await this.contentTables[siteId].updateWhere(
{ filtered: null },
{
sql: `mainlibraryid IN (${libraryIds.map(() => '?').join(', ')})`,
sqlParams: libraryIds,
js: record => libraryIds.includes(record.mainlibraryid),
},
);
}
/**
@ -79,20 +156,19 @@ export class CoreH5PFramework {
* @returns Promise resolved with the removed entries.
*/
async deleteCachedAssets(libraryId: number, siteId?: string): Promise<CoreH5PLibraryCachedAssetsDBRecord[]> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
// Get all the hashes that use this library.
const entries = await db.getRecords<CoreH5PLibraryCachedAssetsDBRecord>(
LIBRARIES_CACHEDASSETS_TABLE_NAME,
{ libraryid: libraryId },
);
const entries = await this.librariesCachedAssetsTables[siteId].getMany({ libraryid: libraryId });
const hashes = entries.map((entry) => entry.hash);
if (hashes.length) {
// Delete the entries from DB.
await db.deleteRecordsList(LIBRARIES_CACHEDASSETS_TABLE_NAME, 'hash', hashes);
await this.librariesCachedAssetsTables[siteId].deleteWhere({
sql: hashes.length === 1 ? 'hash = ?' : `hash IN (${hashes.map(() => '?').join(', ')})`,
sqlParams: hashes,
js: (record) => hashes.includes(record.hash),
});
}
return entries;
@ -106,8 +182,7 @@ export class CoreH5PFramework {
* @returns Promise resolved when done.
*/
async deleteContentData(id: number, siteId?: string): Promise<void> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
// The user content should be reset (instead of removed), because this method is called when H5P content needs
// to be updated too (and the previous states must be kept, but reset).
@ -115,7 +190,7 @@ export class CoreH5PFramework {
await Promise.all([
// Delete the content data.
db.deleteRecords(CONTENT_TABLE_NAME, { id }),
this.contentTables[siteId].deleteByPrimaryKey({ id }),
// Remove content library dependencies.
this.deleteLibraryUsage(id, siteId),
@ -130,9 +205,9 @@ export class CoreH5PFramework {
* @returns Promise resolved when done.
*/
async deleteLibrary(id: number, siteId?: string): Promise<void> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
await db.deleteRecords(LIBRARIES_TABLE_NAME, { id });
await this.librariesTables[siteId].deleteByPrimaryKey({ id });
}
/**
@ -143,9 +218,9 @@ export class CoreH5PFramework {
* @returns Promise resolved when done.
*/
async deleteLibraryDependencies(libraryId: number, siteId?: string): Promise<void> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
await db.deleteRecords(LIBRARY_DEPENDENCIES_TABLE_NAME, { libraryid: libraryId });
await this.libraryDependenciesTables[siteId].delete({ libraryid: libraryId });
}
/**
@ -156,9 +231,9 @@ export class CoreH5PFramework {
* @returns Promise resolved when done.
*/
async deleteLibraryUsage(id: number, siteId?: string): Promise<void> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
await db.deleteRecords(CONTENTS_LIBRARIES_TABLE_NAME, { h5pid: id });
await this.contentsLibrariesTables[siteId].delete({ h5pid: id });
}
/**
@ -168,9 +243,9 @@ export class CoreH5PFramework {
* @returns Promise resolved with the list of content data.
*/
async getAllContentData(siteId?: string): Promise<CoreH5PContentDBRecord[]> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
return db.getAllRecords<CoreH5PContentDBRecord>(CONTENT_TABLE_NAME);
return this.contentTables[siteId].getMany();
}
/**
@ -181,9 +256,9 @@ export class CoreH5PFramework {
* @returns Promise resolved with the content data.
*/
async getContentData(id: number, siteId?: string): Promise<CoreH5PContentDBRecord> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
return db.getRecord<CoreH5PContentDBRecord>(CONTENT_TABLE_NAME, { id });
return this.contentTables[siteId].getOneByPrimaryKey({ id });
}
/**
@ -194,18 +269,16 @@ export class CoreH5PFramework {
* @returns Promise resolved with the content data.
*/
async getContentDataByUrl(fileUrl: string, siteId?: string): Promise<CoreH5PContentDBRecord> {
const site = await CoreSites.getSite(siteId);
const db = site.getDb();
siteId ??= CoreSites.getCurrentSiteId();
// Try to use the folder name, it should be more reliable than the URL.
const folderName = await CoreH5P.h5pCore.h5pFS.getContentFolderNameByUrl(fileUrl, site.getId());
const folderName = await CoreH5P.h5pCore.h5pFS.getContentFolderNameByUrl(fileUrl, siteId);
try {
return await db.getRecord<CoreH5PContentDBRecord>(CONTENT_TABLE_NAME, { foldername: folderName });
return await this.contentTables[siteId].getOne({ foldername: folderName });
} catch (error) {
// Cannot get folder name, the h5p file was probably deleted. Just use the URL.
return db.getRecord<CoreH5PContentDBRecord>(CONTENT_TABLE_NAME, { fileurl: fileUrl });
return await this.contentTables[siteId].getOne({ fileurl: fileUrl });
}
}
@ -216,17 +289,19 @@ export class CoreH5PFramework {
* @returns Promise resolved with the latest library version data.
*/
async getLatestLibraryVersion(machineName: string, siteId?: string): Promise<CoreH5PLibraryParsedDBRecord> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
try {
const records = await db.getRecords<CoreH5PLibraryDBRecord>(
LIBRARIES_TABLE_NAME,
const records = await this.librariesTables[siteId].getMany(
{ machinename: machineName },
'majorversion DESC, minorversion DESC, patchversion DESC',
'*',
0,
1,
{
limit: 1,
sorting: [
{ majorversion: 'desc' },
{ minorversion: 'desc' },
{ patchversion: 'desc' },
],
},
);
if (records && records[0]) {
@ -254,13 +329,12 @@ export class CoreH5PFramework {
minorVersion?: string | number,
siteId?: string,
): Promise<CoreH5PLibraryParsedDBRecord> {
siteId ??= CoreSites.getCurrentSiteId();
const db = await CoreSites.getSiteDb(siteId);
const libraries = await db.getRecords<CoreH5PLibraryDBRecord>(LIBRARIES_TABLE_NAME, {
const libraries = await this.librariesTables[siteId].getMany({
machinename: machineName,
majorversion: majorVersion,
minorversion: minorVersion,
majorversion: majorVersion !== undefined ? Number(majorVersion) : undefined,
minorversion: minorVersion !== undefined ? Number(minorVersion) : undefined,
});
if (!libraries.length) {
@ -289,9 +363,9 @@ export class CoreH5PFramework {
* @returns Promise resolved with the library data, rejected if not found.
*/
async getLibraryById(id: number, siteId?: string): Promise<CoreH5PLibraryParsedDBRecord> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
const library = await db.getRecord<CoreH5PLibraryDBRecord>(LIBRARIES_TABLE_NAME, { id });
const library = await this.librariesTables[siteId].getOneByPrimaryKey({ id });
return this.parseLibDBData(library);
}
@ -669,17 +743,14 @@ export class CoreH5PFramework {
folderName: string,
siteId?: string,
): Promise<void> {
const db = await CoreSites.getSiteDb(siteId);
const targetSiteId = siteId ?? CoreSites.getCurrentSiteId();
await Promise.all(Object.keys(dependencies).map(async (key) => {
const data: Partial<CoreH5PLibraryCachedAssetsDBRecord> = {
await this.librariesCachedAssetsTables[targetSiteId].insert({
hash: key,
libraryid: dependencies[key].libraryId,
foldername: folderName,
};
await db.insertRecord(LIBRARIES_CACHEDASSETS_TABLE_NAME, data);
});
}));
}
@ -691,6 +762,8 @@ export class CoreH5PFramework {
* @returns Promise resolved when done.
*/
async saveLibraryData(libraryData: CoreH5PLibraryBeingSaved, siteId?: string): Promise<void> {
siteId ??= CoreSites.getCurrentSiteId();
// Some special properties needs some checking and converting before they can be saved.
const preloadedJS = this.libraryParameterValuesToCsv(libraryData, 'preloadedJs', 'path');
const preloadedCSS = this.libraryParameterValuesToCsv(libraryData, 'preloadedCss', 'path');
@ -708,10 +781,7 @@ export class CoreH5PFramework {
embedTypes = libraryData.embedTypes.join(', ');
}
const site = await CoreSites.getSite(siteId);
const db = site.getDb();
const data: Partial<CoreH5PLibraryDBRecord> = {
const data: SubPartial<CoreH5PLibraryDBRecord, 'id'> = {
title: libraryData.title,
machinename: libraryData.machineName,
majorversion: libraryData.majorVersion,
@ -733,16 +803,14 @@ export class CoreH5PFramework {
data.id = libraryData.libraryId;
}
await db.insertRecord(LIBRARIES_TABLE_NAME, data);
const libraryId = await this.librariesTables[siteId].insert(data);
if (!data.id) {
// New library. Get its ID.
const entry = await db.getRecord<CoreH5PLibraryDBRecord>(LIBRARIES_TABLE_NAME, data);
libraryData.libraryId = entry.id;
libraryData.libraryId = libraryId;
} else {
// Updated libary. Remove old dependencies.
await this.deleteLibraryDependencies(data.id, site.getId());
await this.deleteLibraryDependencies(data.id, siteId);
}
}
@ -761,8 +829,7 @@ export class CoreH5PFramework {
dependencyType: string,
siteId?: string,
): Promise<void> {
const db = await CoreSites.getSiteDb(siteId);
const targetSiteId = siteId ?? CoreSites.getCurrentSiteId();
await Promise.all(dependencies.map(async (dependency) => {
// Get the ID of the library.
@ -777,13 +844,15 @@ export class CoreH5PFramework {
}
// Create the relation.
const entry: Partial<CoreH5PLibraryDependencyDBRecord> = {
if (typeof library.libraryId !== 'string') {
throw new CoreError('Attempted to create dependencies of library without id');
}
await this.libraryDependenciesTables[targetSiteId].insert({
libraryid: library.libraryId,
requiredlibraryid: dependencyId,
dependencytype: dependencyType,
};
await db.insertRecord(LIBRARY_DEPENDENCIES_TABLE_NAME, entry);
});
}));
}
@ -800,8 +869,7 @@ export class CoreH5PFramework {
librariesInUse: {[key: string]: CoreH5PContentDepsTreeDependency},
siteId?: string,
): Promise<void> {
const db = await CoreSites.getSiteDb(siteId);
const targetSiteId = siteId ?? CoreSites.getCurrentSiteId();
// Calculate the CSS to drop.
const dropLibraryCssList: Record<string, string> = {};
@ -818,18 +886,17 @@ export class CoreH5PFramework {
}
}
// Now save the uusage.
// Now save the usage.
await Promise.all(Object.keys(librariesInUse).map((key) => {
const dependency = librariesInUse[key];
const data: Partial<CoreH5PContentsLibraryDBRecord> = {
return this.contentsLibrariesTables[targetSiteId].insert({
h5pid: id,
libraryid: dependency.library.libraryId,
dependencytype: dependency.type,
dropcss: dropLibraryCssList[dependency.library.machineName] ? 1 : 0,
weight: dependency.weight,
};
return db.insertRecord(CONTENTS_LIBRARIES_TABLE_NAME, data);
weight: dependency.weight ?? 0,
});
}));
}
@ -843,8 +910,7 @@ export class CoreH5PFramework {
* @returns Promise resolved with content ID.
*/
async updateContent(content: CoreH5PContentBeingSaved, folderName: string, fileUrl: string, siteId?: string): Promise<number> {
const db = await CoreSites.getSiteDb(siteId);
siteId ??= CoreSites.getCurrentSiteId();
// If the libraryid declared in the package is empty, get the latest version.
if (content.library && content.library.libraryId === undefined) {
@ -861,32 +927,31 @@ export class CoreH5PFramework {
content.params = JSON.stringify(params);
}
const data: Partial<CoreH5PContentDBRecord> = {
id: undefined,
jsoncontent: content.params,
if (typeof content.library?.libraryId !== 'number') {
throw new CoreError('Attempted to create content of library without id');
}
const data: SubPartial<CoreH5PContentDBRecord, 'id'> = {
jsoncontent: content.params ?? '{}',
mainlibraryid: content.library?.libraryId,
timemodified: Date.now(),
filtered: null,
foldername: folderName,
fileurl: fileUrl,
timecreated: undefined,
timecreated: Date.now(),
};
let contentId: number | undefined;
if (content.id !== undefined) {
data.id = content.id;
contentId = content.id;
} else {
data.timecreated = data.timemodified;
}
await db.insertRecord(CONTENT_TABLE_NAME, data);
const newContentId = await this.contentTables[siteId].insert(data);
if (!contentId) {
// New content. Get its ID.
const entry = await db.getRecord<CoreH5PContentDBRecord>(CONTENT_TABLE_NAME, data);
content.id = entry.id;
content.id = newContentId;
contentId = content.id;
}
@ -901,12 +966,9 @@ export class CoreH5PFramework {
* @param siteId Site ID. If not defined, current site.
*/
async updateContentFields(id: number, fields: Partial<CoreH5PContentDBRecord>, siteId?: string): Promise<void> {
siteId ??= CoreSites.getCurrentSiteId();
const db = await CoreSites.getSiteDb(siteId);
const data = Object.assign({}, fields);
await db.updateRecords(CONTENT_TABLE_NAME, data, { id });
await this.contentTables[siteId].update(fields, { id });
}
}

View File

@ -19,6 +19,7 @@ import { ModalController, Translate } from '@singletons';
import { FAQ_QRCODE_IMAGE_HTML, FAQ_URL_IMAGE_HTML, GET_STARTED_URL } from '@features/login/constants';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { SubPartial } from '@/core/utils/types';
/**
* Component that displays help to connect to a site.
@ -217,5 +218,5 @@ enum AnswerFormat {
* Question definition.
*/
type QuestionDefinition = Omit<Question, 'id' | 'answer'> & {
answer: Omit<Answer, 'class'> & Partial<Pick<Answer, 'class'>>;
answer: SubPartial<Answer, 'class'>;
};

View File

@ -21,8 +21,10 @@ import { CoreSiteSchema } from '@services/sites';
* Keep "addon" in some names for backwards compatibility.
*/
export const BADGE_TABLE_NAME = 'addon_pushnotifications_badge';
export const BADGE_TABLE_PRIMARY_KEYS = ['siteid', 'addon'] as const;
export const PENDING_UNREGISTER_TABLE_NAME = 'addon_pushnotifications_pending_unregister';
export const REGISTERED_DEVICES_TABLE_NAME = 'addon_pushnotifications_registered_devices_2';
export const REGISTERED_DEVICES_TABLE_PRIMARY_KEYS = ['appid', 'uuid'] as const;
export const APP_SCHEMA: CoreAppSchema = {
name: 'CorePushNotificationsProvider',
version: 1,
@ -43,7 +45,7 @@ export const APP_SCHEMA: CoreAppSchema = {
type: 'INTEGER',
},
],
primaryKeys: ['siteid', 'addon'],
primaryKeys: [...BADGE_TABLE_PRIMARY_KEYS],
},
{
name: PENDING_UNREGISTER_TABLE_NAME,
@ -109,7 +111,7 @@ export const SITE_SCHEMA: CoreSiteSchema = {
type: 'TEXT',
},
],
primaryKeys: ['appid', 'uuid'],
primaryKeys: [...REGISTERED_DEVICES_TABLE_PRIMARY_KEYS],
},
],
async migrate(db: SQLiteDB, oldVersion: number): Promise<void> {
@ -129,6 +131,8 @@ export type CorePushNotificationsBadgeDBRecord = {
number: number; // eslint-disable-line id-blacklist
};
export type CorePushNotificationsBadgeDBPrimaryKeys = typeof BADGE_TABLE_PRIMARY_KEYS[number];
/**
* Data stored in DB for pending unregisters.
*/
@ -152,3 +156,5 @@ export type CorePushNotificationsRegisteredDeviceDBRecord = {
pushid: string; // Push ID.
publickey?: string; // Public key.
};
export type CorePushNotificationsRegisteredDeviceDBPrimaryKeys = typeof REGISTERED_DEVICES_TABLE_PRIMARY_KEYS[number];

View File

@ -36,6 +36,10 @@ import {
CorePushNotificationsPendingUnregisterDBRecord,
CorePushNotificationsRegisteredDeviceDBRecord,
CorePushNotificationsBadgeDBRecord,
REGISTERED_DEVICES_TABLE_PRIMARY_KEYS,
CorePushNotificationsRegisteredDeviceDBPrimaryKeys,
CorePushNotificationsBadgeDBPrimaryKeys,
BADGE_TABLE_PRIMARY_KEYS,
} from './database/pushnotifications';
import { CoreError } from '@classes/errors/error';
import { CoreWSExternalWarning } from '@services/ws';
@ -61,23 +65,38 @@ export class CorePushNotificationsProvider {
protected logger: CoreLogger;
protected pushID?: string;
protected badgesTable = asyncInstance<CoreDatabaseTable<CorePushNotificationsBadgeDBRecord, 'siteid' | 'addon'>>();
protected badgesTable =
asyncInstance<CoreDatabaseTable<CorePushNotificationsBadgeDBRecord, CorePushNotificationsBadgeDBPrimaryKeys>>();
protected pendingUnregistersTable =
asyncInstance<CoreDatabaseTable<CorePushNotificationsPendingUnregisterDBRecord, 'siteid'>>();
protected registeredDevicesTables:
LazyMap<AsyncInstance<CoreDatabaseTable<CorePushNotificationsRegisteredDeviceDBRecord, 'appid' | 'uuid'>>>;
LazyMap<
AsyncInstance<
CoreDatabaseTable<
CorePushNotificationsRegisteredDeviceDBRecord,
CorePushNotificationsRegisteredDeviceDBPrimaryKeys,
never
>
>
>;
constructor() {
this.logger = CoreLogger.getInstance('CorePushNotificationsProvider');
this.registeredDevicesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<CorePushNotificationsRegisteredDeviceDBRecord, 'appid' | 'uuid'>(
() => CoreSites.getSiteTable<
CorePushNotificationsRegisteredDeviceDBRecord,
CorePushNotificationsRegisteredDeviceDBPrimaryKeys,
never
>(
REGISTERED_DEVICES_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
primaryKeyColumns: ['appid', 'uuid'],
primaryKeyColumns: [...REGISTERED_DEVICES_TABLE_PRIMARY_KEYS],
rowIdColumn: null,
onDestroy: () => delete this.registeredDevicesTables[siteId],
},
),
@ -190,11 +209,11 @@ export class CorePushNotificationsProvider {
}
const database = CoreApp.getDB();
const badgesTable = new CoreDatabaseTableProxy<CorePushNotificationsBadgeDBRecord, 'siteid' | 'addon'>(
const badgesTable = new CoreDatabaseTableProxy<CorePushNotificationsBadgeDBRecord, CorePushNotificationsBadgeDBPrimaryKeys>(
{ cachingStrategy: CoreDatabaseCachingStrategy.Eager },
database,
BADGE_TABLE_NAME,
['siteid', 'addon'],
[...BADGE_TABLE_PRIMARY_KEYS],
);
const pendingUnregistersTable = new CoreDatabaseTableProxy<CorePushNotificationsPendingUnregisterDBRecord, 'siteid'>(
{ cachingStrategy: CoreDatabaseCachingStrategy.Eager },

View File

@ -23,6 +23,10 @@ import { CorePlatform } from '@services/platform';
import { CoreConstants } from '@/core/constants';
import { CoreConfig } from '@services/config';
import { CoreEvents } from '@singletons/events';
import { lazyMap, LazyMap } from '@/core/utils/lazy-map';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { asyncInstance, AsyncInstance } from '@/core/utils/async-instance';
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
/**
* Units to set a reminder.
@ -61,6 +65,20 @@ export class CoreRemindersService {
static readonly DEFAULT_NOTIFICATION_TIME_SETTING = 'CoreRemindersDefaultNotification';
static readonly DEFAULT_NOTIFICATION_TIME_CHANGED = 'CoreRemindersDefaultNotificationChangedEvent';
protected remindersTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreReminderDBRecord>>>;
constructor() {
this.remindersTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(REMINDERS_TABLE, {
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.remindersTables[siteId],
}),
),
);
}
/**
* Initialize the service.
*
@ -103,13 +121,13 @@ export class CoreRemindersService {
* @returns Resolved when done. Rejected on failure.
*/
async addReminder(reminder: CoreReminderData, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
siteId ??= CoreSites.getCurrentSiteId();
const reminderId = await site.getDb().insertRecord(REMINDERS_TABLE, reminder);
const reminderId = await this.remindersTables[siteId].insert(reminder);
const reminderRecord: CoreReminderDBRecord = Object.assign(reminder, { id: reminderId });
await this.scheduleNotification(reminderRecord, site.getId());
await this.scheduleNotification(reminderRecord, siteId);
}
/**
@ -123,9 +141,9 @@ export class CoreRemindersService {
reminder: CoreReminderDBRecord,
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId);
siteId ??= CoreSites.getCurrentSiteId();
await site.getDb().updateRecords(REMINDERS_TABLE, reminder, { id: reminder.id });
await this.remindersTables[siteId].update(reminder, { id: reminder.id });
// Reschedule.
await this.scheduleNotification(reminder, siteId);
@ -162,9 +180,13 @@ export class CoreRemindersService {
* @returns Promise resolved when the reminder data is retrieved.
*/
async getAllReminders(siteId?: string): Promise<CoreReminderDBRecord[]> {
const site = await CoreSites.getSite(siteId);
siteId ??= CoreSites.getCurrentSiteId();
return site.getDb().getRecords(REMINDERS_TABLE, undefined, 'time ASC');
return this.remindersTables[siteId].getMany(undefined, {
sorting: [
{ time: 'asc' },
],
});
}
/**
@ -175,9 +197,13 @@ export class CoreRemindersService {
* @returns Promise resolved when the reminder data is retrieved.
*/
async getReminders(selector: CoreReminderSelector, siteId?: string): Promise<CoreReminderDBRecord[]> {
const site = await CoreSites.getSite(siteId);
siteId ??= CoreSites.getCurrentSiteId();
return site.getDb().getRecords(REMINDERS_TABLE, selector, 'time ASC');
return this.remindersTables[siteId].getMany(selector, {
sorting: [
{ time: 'asc' },
],
});
}
/**
@ -187,13 +213,13 @@ export class CoreRemindersService {
* @returns Promise resolved when the reminder data is retrieved.
*/
protected async getRemindersWithDefaultTime(siteId?: string): Promise<CoreReminderDBRecord[]> {
const site = await CoreSites.getSite(siteId);
siteId ??= CoreSites.getCurrentSiteId();
return site.getDb().getRecords<CoreReminderDBRecord>(
REMINDERS_TABLE,
{ timebefore: CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE },
'time ASC',
);
return this.remindersTables[siteId].getMany({ timebefore: CoreRemindersService.DEFAULT_REMINDER_TIMEBEFORE }, {
sorting: [
{ time: 'asc' },
],
});
}
/**
@ -204,15 +230,15 @@ export class CoreRemindersService {
* @returns Promise resolved when the notification is updated.
*/
async removeReminder(id: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
siteId ??= CoreSites.getCurrentSiteId();
const reminder = await site.getDb().getRecord<CoreReminderDBRecord>(REMINDERS_TABLE, { id });
const reminder = await this.remindersTables[siteId].getOneByPrimaryKey({ id });
if (this.isEnabled()) {
this.cancelReminder(id, reminder.component, site.getId());
this.cancelReminder(id, reminder.component, siteId);
}
await site.getDb().deleteRecords(REMINDERS_TABLE, { id });
await this.remindersTables[siteId].deleteByPrimaryKey({ id });
}
/**
@ -223,8 +249,7 @@ export class CoreRemindersService {
* @returns Promise resolved when the notification is updated.
*/
async removeReminders(selector: CoreReminderSelector, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
siteId = site.getId();
siteId ??= CoreSites.getCurrentSiteId();
if (this.isEnabled()) {
const reminders = await this.getReminders(selector, siteId);
@ -234,7 +259,7 @@ export class CoreRemindersService {
});
}
await site.getDb().deleteRecords(REMINDERS_TABLE, selector);
await this.remindersTables[siteId].delete(selector);
}
/**

View File

@ -18,6 +18,7 @@ import { CoreSiteSchema } from '@services/sites';
* Database variables for CoreSearchHistory service.
*/
export const SEARCH_HISTORY_TABLE_NAME = 'seach_history';
export const SEARCH_HISTORY_TABLE_PRIMARY_KEYS = ['searcharea', 'searchedtext'] as const;
export const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreSearchHistoryProvider',
version: 1,
@ -46,7 +47,7 @@ export const SITE_SCHEMA: CoreSiteSchema = {
notNull: true,
},
],
primaryKeys: ['searcharea', 'searchedtext'],
primaryKeys: [...SEARCH_HISTORY_TABLE_PRIMARY_KEYS],
},
],
};
@ -60,3 +61,5 @@ export type CoreSearchHistoryDBRecord = {
searchedtext: string; // Text of the performed search.
times: number; // Times search has been performed (if previously in history).
};
export type CoreSearchHistoryDBPrimaryKeys = typeof SEARCH_HISTORY_TABLE_PRIMARY_KEYS[number];

View File

@ -15,9 +15,17 @@
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreSearchHistoryDBRecord, SEARCH_HISTORY_TABLE_NAME } from './search-history-db';
import {
CoreSearchHistoryDBPrimaryKeys,
CoreSearchHistoryDBRecord,
SEARCH_HISTORY_TABLE_NAME,
SEARCH_HISTORY_TABLE_PRIMARY_KEYS,
} from './search-history-db';
import { makeSingleton } from '@singletons';
import { LazyMap, lazyMap } from '@/core/utils/lazy-map';
import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
/**
* Service that enables adding a history to a search box.
@ -27,6 +35,27 @@ export class CoreSearchHistoryProvider {
protected static readonly HISTORY_LIMIT = 10;
protected searchHistoryTables: LazyMap<
AsyncInstance<CoreDatabaseTable<CoreSearchHistoryDBRecord, CoreSearchHistoryDBPrimaryKeys, never>>
>;
constructor() {
this.searchHistoryTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<CoreSearchHistoryDBRecord, CoreSearchHistoryDBPrimaryKeys, never>(
SEARCH_HISTORY_TABLE_NAME,
{
siteId,
primaryKeyColumns: [...SEARCH_HISTORY_TABLE_PRIMARY_KEYS],
rowIdColumn: null,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.searchHistoryTables[siteId],
},
),
),
);
}
/**
* Get a search area history sorted by use.
*
@ -35,12 +64,9 @@ export class CoreSearchHistoryProvider {
* @returns Promise resolved with the list of items when done.
*/
async getSearchHistory(searchArea: string, siteId?: string): Promise<CoreSearchHistoryDBRecord[]> {
const site = await CoreSites.getSite(siteId);
const conditions = {
searcharea: searchArea,
};
siteId ??= CoreSites.getCurrentSiteId();
const history: CoreSearchHistoryDBRecord[] = await site.getDb().getRecords(SEARCH_HISTORY_TABLE_NAME, conditions);
const history = await this.searchHistoryTables[siteId].getMany({ searcharea: searchArea });
// Sorting by last used DESC.
return history.sort((a, b) => (b.lastused || 0) - (a.lastused || 0));
@ -50,10 +76,10 @@ export class CoreSearchHistoryProvider {
* Controls search limit and removes the last item if overflows.
*
* @param searchArea Search area to control
* @param db SQLite DB where to perform the search.
* @param siteId Site id.
* @returns Resolved when done.
*/
protected async controlSearchLimit(searchArea: string, db: SQLiteDB): Promise<void> {
protected async controlSearchLimit(searchArea: string, siteId: string): Promise<void> {
const items = await this.getSearchHistory(searchArea);
if (items.length > CoreSearchHistoryProvider.HISTORY_LIMIT) {
// Over the limit. Remove the last.
@ -62,12 +88,10 @@ export class CoreSearchHistoryProvider {
return;
}
const searchItem = {
await this.searchHistoryTables[siteId].delete({
searcharea: lastItem.searcharea,
searchedtext: lastItem.searchedtext,
};
await db.deleteRecords(SEARCH_HISTORY_TABLE_NAME, searchItem);
});
}
}
@ -76,23 +100,23 @@ export class CoreSearchHistoryProvider {
*
* @param searchArea Area where the search has been performed.
* @param text Text of the performed text.
* @param db SQLite DB where to perform the search.
* @param siteId Site id.
* @returns True if exists, false otherwise.
*/
protected async updateExistingItem(searchArea: string, text: string, db: SQLiteDB): Promise<boolean> {
protected async updateExistingItem(searchArea: string, text: string, siteId: string): Promise<boolean> {
const searchItem = {
searcharea: searchArea,
searchedtext: text,
};
try {
const existingItem: CoreSearchHistoryDBRecord = await db.getRecord(SEARCH_HISTORY_TABLE_NAME, searchItem);
const existingItem = await this.searchHistoryTables[siteId].getOne(searchItem);
// If item exist, update time and number of times searched.
existingItem.lastused = Date.now();
existingItem.times++;
await db.updateRecords(SEARCH_HISTORY_TABLE_NAME, existingItem, searchItem);
await this.searchHistoryTables[siteId].update(existingItem, searchItem);
return true;
} catch {
@ -109,23 +133,20 @@ export class CoreSearchHistoryProvider {
* @returns Resolved when done.
*/
async insertOrUpdateSearchText(searchArea: string, text: string, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
const db = site.getDb();
siteId ??= CoreSites.getCurrentSiteId();
const exists = await this.updateExistingItem(searchArea, text, db);
const exists = await this.updateExistingItem(searchArea, text, siteId);
if (!exists) {
// If item is new, control the history does not goes over the limit.
const searchItem: CoreSearchHistoryDBRecord = {
await this.searchHistoryTables[siteId].insert({
searcharea: searchArea,
searchedtext: text,
lastused: Date.now(),
times: 1,
};
});
await db.insertRecord(SEARCH_HISTORY_TABLE_NAME, searchItem);
await this.controlSearchLimit(searchArea, db);
await this.controlSearchLimit(searchArea, siteId);
}
}

View File

@ -16,7 +16,6 @@ import { Injectable } from '@angular/core';
import { FileEntry, DirectoryEntry } from '@awesome-cordova-plugins/file/ngx';
import { Md5 } from 'ts-md5/dist/md5';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreLogger } from '@singletons/logger';
import { CoreApp } from '@services/app';
import { CoreFile } from '@services/file';
@ -27,6 +26,9 @@ import { CoreEvents } from '@singletons/events';
import { makeSingleton } from '@singletons';
import { APP_SCHEMA, CoreSharedFilesDBRecord, SHARED_FILES_TABLE_NAME } from './database/sharedfiles';
import { CorePath } from '@singletons/path';
import { asyncInstance } from '@/core/utils/async-instance';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
/**
* Service to share files with the app.
@ -37,13 +39,10 @@ export class CoreSharedFilesProvider {
static readonly SHARED_FILES_FOLDER = 'sharedfiles';
protected logger: CoreLogger;
// Variables for DB.
protected appDB: Promise<SQLiteDB>;
protected resolveAppDB!: (appDB: SQLiteDB) => void;
protected sharedFilesTable = asyncInstance<CoreDatabaseTable<CoreSharedFilesDBRecord>>();
constructor() {
this.logger = CoreLogger.getInstance('CoreSharedFilesProvider');
this.appDB = new Promise(resolve => this.resolveAppDB = resolve);
}
/**
@ -58,7 +57,16 @@ export class CoreSharedFilesProvider {
// Ignore errors.
}
this.resolveAppDB(CoreApp.getDB());
const database = CoreApp.getDB();
const sharedFilesTable = new CoreDatabaseTableProxy<CoreSharedFilesDBRecord>(
{ cachingStrategy: CoreDatabaseCachingStrategy.None },
database,
SHARED_FILES_TABLE_NAME,
);
await sharedFilesTable.initialize();
this.sharedFilesTable.setInstance(sharedFilesTable);
}
/**
@ -199,9 +207,9 @@ export class CoreSharedFilesProvider {
* @returns Resolved if treated, rejected otherwise.
*/
protected async isFileTreated(fileId: string): Promise<CoreSharedFilesDBRecord> {
const db = await this.appDB;
const sharedFile = await this.sharedFilesTable.getOneByPrimaryKey({ id: fileId });
return db.getRecord(SHARED_FILES_TABLE_NAME, { id: fileId });
return sharedFile;
}
/**
@ -216,9 +224,7 @@ export class CoreSharedFilesProvider {
await this.isFileTreated(fileId);
} catch (err) {
// Doesn't exist, insert it.
const db = await this.appDB;
await db.insertRecord(SHARED_FILES_TABLE_NAME, { id: fileId });
await this.sharedFilesTable.insert({ id: fileId });
}
}
@ -259,9 +265,7 @@ export class CoreSharedFilesProvider {
* @returns Resolved when unmarked.
*/
protected async unmarkAsTreated(fileId: string): Promise<void> {
const db = await this.appDB;
await db.deleteRecords(SHARED_FILES_TABLE_NAME, { id: fileId });
await this.sharedFilesTable.deleteByPrimaryKey({ id: fileId });
}
}

View File

@ -49,7 +49,6 @@ export class CoreUserToursService {
{ cachingStrategy: CoreDatabaseCachingStrategy.Eager },
CoreApp.getDB(),
USER_TOURS_TABLE_NAME,
['id'],
);
await table.initialize();

View File

@ -19,8 +19,10 @@ import { CoreSiteSchema } from '@services/sites';
* Database variables for CoreFilepool service.
*/
export const QUEUE_TABLE_NAME = 'filepool_files_queue'; // Queue of files to download.
export const QUEUE_TABLE_PRIMARY_KEYS = ['siteId', 'fileId'] as const;
export const FILES_TABLE_NAME = 'filepool_files'; // Downloaded files.
export const LINKS_TABLE_NAME = 'filepool_files_links'; // Links between downloaded files and components.
export const LINKS_TABLE_PRIMARY_KEYS = ['fileId', 'component', 'componentId'] as const;
export const PACKAGES_TABLE_NAME = 'filepool_packages'; // Downloaded packages (sets of files).
export const APP_SCHEMA: CoreAppSchema = {
name: 'CoreFilepoolProvider',
@ -74,7 +76,7 @@ export const APP_SCHEMA: CoreAppSchema = {
type: 'TEXT',
},
],
primaryKeys: ['siteId', 'fileId'],
primaryKeys: [...QUEUE_TABLE_PRIMARY_KEYS],
},
],
};
@ -146,7 +148,7 @@ export const SITE_SCHEMA: CoreSiteSchema = {
type: 'TEXT',
},
],
primaryKeys: ['fileId', 'component', 'componentId'],
primaryKeys: [...LINKS_TABLE_PRIMARY_KEYS],
},
{
name: PACKAGES_TABLE_NAME,
@ -241,7 +243,7 @@ export type CoreFilepoolFileEntry = CoreFilepoolFileOptions & {
/**
* DB data for entry from file's queue.
*/
export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & {
export type CoreFilepoolQueueDBRecord = CoreFilepoolFileOptions & {
/**
* The site the file belongs to.
*/
@ -278,10 +280,12 @@ export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & {
links: string;
};
export type CoreFilepoolQueueDBPrimaryKeys = typeof QUEUE_TABLE_PRIMARY_KEYS[number];
/**
* Entry from the file's queue.
*/
export type CoreFilepoolQueueEntry = CoreFilepoolQueueDBEntry & {
export type CoreFilepoolQueueEntry = CoreFilepoolQueueDBRecord & {
/**
* File links (to link the file to components and componentIds).
*/
@ -356,8 +360,10 @@ export type CoreFilepoolComponentLink = {
/**
* Links table record type.
*/
export type CoreFilepoolLinksRecord = {
export type CoreFilepoolLinksDBRecord = {
fileId: string; // File Id.
component: string; // Component name.
componentId: number | string; // Component Id.
};
export type CoreFilepoolLinksDBPrimaryKeys = typeof LINKS_TABLE_PRIMARY_KEYS[number];

View File

@ -74,7 +74,22 @@ export const APP_SCHEMA: CoreAppSchema = {
};
export type CodeRequestsQueueItem = {
table: string;
table: typeof SITES_TABLE_NAME | typeof COMPONENTS_TABLE_NAME;
id: string;
deferreds: CorePromisedValue<number>[];
};
export type CoreLocalNotificationsSitesDBRecord = {
id: string;
code: number;
};
export type CoreLocalNotificationsComponentsDBRecord = {
id: string;
code: number;
};
export type CoreLocalNotificationsTriggeredDBRecord = {
id: number;
at: number;
};

View File

@ -28,6 +28,7 @@ export const SCHEMA_VERSIONS_TABLE_NAME = 'schema_versions';
export const WS_CACHE_TABLE = 'wscache_2';
export const CONFIG_TABLE = 'core_site_config';
export const LAST_VIEWED_TABLE = 'core_site_last_viewed';
export const LAST_VIEWED_PRIMARY_KEYS = ['component', 'id'] as const;
// Schema to register in App DB.
export const APP_SCHEMA: CoreAppSchema = {
@ -156,7 +157,7 @@ export const SITE_SCHEMA: CoreSiteSchema = {
type: 'INTEGER',
},
],
primaryKeys: ['component', 'id'],
primaryKeys: [...LAST_VIEWED_PRIMARY_KEYS],
},
],
};
@ -214,3 +215,5 @@ export type CoreSiteLastViewedDBRecord = {
timeaccess: number;
data?: string;
};
export type CoreSiteLastViewedDBPrimaryKeys = typeof LAST_VIEWED_PRIMARY_KEYS[number];

View File

@ -15,10 +15,11 @@
import { Injectable } from '@angular/core';
import { SQLiteDB } from '@classes/sqlitedb';
import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb';
import { CoreBrowser } from '@singletons/browser';
import { makeSingleton, SQLite } from '@singletons';
import { SQLite, makeSingleton } from '@singletons';
import { CorePlatform } from '@services/platform';
import { SQLiteObject } from '@awesome-cordova-plugins/sqlite/ngx';
import { asyncInstance } from '@/core/utils/async-instance';
const tableNameRegex = new RegExp([
'^SELECT.*FROM ([^ ]+)',
@ -208,45 +209,129 @@ export class CoreDbProvider {
*/
getDB(name: string, forceNew?: boolean): SQLiteDB {
if (this.dbInstances[name] === undefined || forceNew) {
if (CorePlatform.is('cordova')) {
this.dbInstances[name] = new SQLiteDB(name);
} else {
this.dbInstances[name] = new SQLiteDBMock(name);
let db = this.createDatabase(name);
if (this.loggingEnabled()) {
const spies = this.getDatabaseSpies(name, db);
db = new Proxy(db, {
get: (target, property, receiver) => spies[property] ?? Reflect.get(target, property, receiver),
}) as unknown as SQLiteObject;
}
this.dbInstances[name] = new SQLiteDB(name, db);
}
return this.dbInstances[name];
}
/**
* Create database connection.
*
* @param name Database name.
* @returns Database connection.
*/
protected createDatabase(name: string): SQLiteObject {
// Ideally, this method would return a Promise instead of resorting to Duck typing;
// but doing so would mean that the getDB() method should also return a promise.
// Given that it is heavily used throughout the app, we want to avoid it for now.
return asyncInstance(async () => {
await CorePlatform.ready();
return SQLite.create({ name, location: 'default' });
});
}
/**
* Delete a DB.
*
* @param name DB name.
* @returns Promise resolved when the DB is deleted.
*/
async deleteDB(name: string): Promise<void> {
if (this.dbInstances[name] !== undefined) {
// Close the database first.
await this.dbInstances[name].close();
const db = this.dbInstances[name];
delete this.dbInstances[name];
}
if (db instanceof SQLiteDBMock) {
// In WebSQL we cannot delete the database, just empty it.
return db.emptyDatabase();
} else {
return SQLite.deleteDatabase({
await this.deleteDatabase(name);
}
/**
* Delete database.
*
* @param name Database name.
*/
protected async deleteDatabase(name: string): Promise<void> {
await SQLite.deleteDatabase({
name,
location: 'default',
});
}
} else if (CorePlatform.is('cordova')) {
return SQLite.deleteDatabase({
name,
location: 'default',
/**
* Get database spy methods to intercept database calls and track logging information.
*
* @param dbName Database name.
* @param db Database to spy.
* @returns Spy methods.
*/
protected getDatabaseSpies(dbName: string, db: SQLiteObject): Partial<SQLiteObject> {
return {
async executeSql(statement, params) {
const start = performance.now();
try {
const result = await db.executeSql(statement, params);
CoreDB.logQuery({
params,
sql: statement,
duration: performance.now() - start,
dbName,
});
return result;
} catch (error) {
CoreDB.logQuery({
params,
error,
sql: statement,
duration: performance.now() - start,
dbName,
});
throw error;
}
},
async sqlBatch(statements) {
const start = performance.now();
const sql = Array.isArray(statements)
? statements.join(' | ')
: String(statements);
try {
const result = await db.sqlBatch(statements);
CoreDB.logQuery({
sql,
duration: performance.now() - start,
dbName,
});
return result;
} catch (error) {
CoreDB.logQuery({
sql,
error,
duration: performance.now() - start,
dbName,
});
throw error;
}
},
};
}
}

View File

@ -28,7 +28,6 @@ import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils, CoreUtilsOpenFileOptions } from '@services/utils/utils';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreError } from '@classes/errors/error';
import { CoreConstants } from '@/core/constants';
import { ApplicationInit, makeSingleton, NgZone, Translate } from '@singletons';
@ -42,10 +41,14 @@ import {
CoreFilepoolFileEntry,
CoreFilepoolComponentLink,
CoreFilepoolFileOptions,
CoreFilepoolLinksRecord,
CoreFilepoolLinksDBRecord,
CoreFilepoolPackageEntry,
CoreFilepoolQueueEntry,
CoreFilepoolQueueDBEntry,
CoreFilepoolQueueDBRecord,
CoreFilepoolLinksDBPrimaryKeys,
LINKS_TABLE_PRIMARY_KEYS,
CoreFilepoolQueueDBPrimaryKeys,
QUEUE_TABLE_PRIMARY_KEYS,
} from '@services/database/filepool';
import { CoreFileHelper } from './file-helper';
import { CoreUrl } from '@singletons/url';
@ -102,38 +105,44 @@ export class CoreFilepoolProvider {
// Variables to prevent downloading packages/files twice at the same time.
protected packagesPromises: { [s: string]: { [s: string]: Promise<void> } } = {};
protected filePromises: { [s: string]: { [s: string]: Promise<string> } } = {};
protected filesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreFilepoolFileEntry, 'fileId'>>>;
protected linksTables:
LazyMap<AsyncInstance<CoreDatabaseTable<CoreFilepoolLinksRecord, 'fileId' | 'component' | 'componentId'>>>;
protected filesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreFilepoolFileEntry, 'fileId', never>>>;
protected linksTables: LazyMap<
AsyncInstance<CoreDatabaseTable<CoreFilepoolLinksDBRecord, CoreFilepoolLinksDBPrimaryKeys, never>>
>;
protected packagesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreFilepoolPackageEntry>>>;
protected queueTable = asyncInstance<CoreDatabaseTable<CoreFilepoolQueueDBEntry, 'siteId' | 'fileId'>>();
protected queueTable = asyncInstance<CoreDatabaseTable<CoreFilepoolQueueDBRecord, CoreFilepoolQueueDBPrimaryKeys>>();
constructor() {
this.logger = CoreLogger.getInstance('CoreFilepoolProvider');
this.filesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<CoreFilepoolFileEntry, 'fileId'>(FILES_TABLE_NAME, {
() => CoreSites.getSiteTable<CoreFilepoolFileEntry, 'fileId', never>(FILES_TABLE_NAME, {
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.Lazy },
primaryKeyColumns: ['fileId'],
rowIdColumn: null,
onDestroy: () => delete this.filesTables[siteId],
}),
),
);
this.linksTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<CoreFilepoolLinksRecord, 'fileId' | 'component' | 'componentId'>(LINKS_TABLE_NAME, {
() => CoreSites.getSiteTable<CoreFilepoolLinksDBRecord, CoreFilepoolLinksDBPrimaryKeys, never>(
LINKS_TABLE_NAME,
{
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.Lazy },
primaryKeyColumns: ['fileId', 'component', 'componentId'],
primaryKeyColumns: [...LINKS_TABLE_PRIMARY_KEYS],
rowIdColumn: null,
onDestroy: () => delete this.linksTables[siteId],
}),
},
),
),
);
this.packagesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<CoreFilepoolPackageEntry, 'id'>(PACKAGES_TABLE_NAME, {
() => CoreSites.getSiteTable(PACKAGES_TABLE_NAME, {
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.Lazy },
onDestroy: () => delete this.packagesTables[siteId],
@ -168,11 +177,11 @@ export class CoreFilepoolProvider {
// Ignore errors.
}
const queueTable = new CoreDatabaseTableProxy<CoreFilepoolQueueDBEntry, 'siteId' | 'fileId'>(
const queueTable = new CoreDatabaseTableProxy<CoreFilepoolQueueDBRecord, CoreFilepoolQueueDBPrimaryKeys>(
{ cachingStrategy: CoreDatabaseCachingStrategy.Lazy },
CoreApp.getDB(),
QUEUE_TABLE_NAME,
['siteId','fileId'],
[...QUEUE_TABLE_PRIMARY_KEYS],
);
await queueTable.initialize();
@ -406,7 +415,7 @@ export class CoreFilepoolProvider {
return this.addToQueue(siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link);
}
const newData: Partial<CoreFilepoolQueueDBEntry> = {};
const newData: Partial<CoreFilepoolQueueDBRecord> = {};
let foundLink = false;
// We already have the file in queue, we update the priority and links.
@ -1245,7 +1254,7 @@ export class CoreFilepoolProvider {
siteId: string | undefined,
component: string,
componentId?: string | number,
): Promise<CoreFilepoolLinksRecord[]> {
): Promise<CoreFilepoolLinksDBRecord[]> {
siteId = siteId ?? CoreSites.getCurrentSiteId();
const conditions = {
component,
@ -1364,7 +1373,7 @@ export class CoreFilepoolProvider {
* @param fileId The file ID.
* @returns Promise resolved with the links.
*/
protected async getFileLinks(siteId: string, fileId: string): Promise<CoreFilepoolLinksRecord[]> {
protected async getFileLinks(siteId: string, fileId: string): Promise<CoreFilepoolLinksDBRecord[]> {
const items = await this.linksTables[siteId].getMany({ fileId });
items.forEach((item) => {
@ -2260,6 +2269,8 @@ export class CoreFilepoolProvider {
componentId?: string | number,
onlyUnknown: boolean = true,
): Promise<void> {
siteId = siteId ?? CoreSites.getCurrentSiteId();
const items = await this.getComponentFiles(siteId, component, componentId);
if (!items.length) {
@ -2267,23 +2278,15 @@ export class CoreFilepoolProvider {
return;
}
siteId = siteId ?? CoreSites.getCurrentSiteId();
const fileIds = items.map((item) => item.fileId);
const whereAndParams = SQLiteDB.getInOrEqual(fileIds);
whereAndParams.sql = 'fileId ' + whereAndParams.sql;
if (onlyUnknown) {
whereAndParams.sql += ' AND (' + CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL + ')';
}
await this.filesTables[siteId].updateWhere(
{ stale: 1 },
{
sql: whereAndParams.sql,
sqlParams: whereAndParams.params,
sql: onlyUnknown
? `fileId IN (${fileIds.map(() => '?').join(', ')}) AND (${CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL})`
: `fileId IN (${fileIds.map(() => '?').join(', ')})`,
sqlParams: fileIds,
js: record => fileIds.includes(record.fileId) && (
!onlyUnknown || CoreFilepoolProvider.FILE_IS_UNKNOWN_JS(record)
),

View File

@ -20,7 +20,6 @@ import { CoreApp } from '@services/app';
import { CoreConfig } from '@services/config';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreTextUtils } from '@services/utils/text';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreQueueRunner } from '@classes/queue-runner';
import { CoreError } from '@classes/errors/error';
import { CoreConstants } from '@/core/constants';
@ -32,10 +31,16 @@ import {
COMPONENTS_TABLE_NAME,
SITES_TABLE_NAME,
CodeRequestsQueueItem,
CoreLocalNotificationsTriggeredDBRecord,
CoreLocalNotificationsComponentsDBRecord,
CoreLocalNotificationsSitesDBRecord,
} from '@services/database/local-notifications';
import { CorePromisedValue } from '@classes/promised-value';
import { CorePlatform } from '@services/platform';
import { Push } from '@features/native/plugins';
import { AsyncInstance, asyncInstance } from '@/core/utils/async-instance';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
/**
* Service to handle local notifications.
@ -56,12 +61,11 @@ export class CoreLocalNotificationsProvider {
protected updateSubscription?: Subscription;
protected queueRunner: CoreQueueRunner; // Queue to decrease the number of concurrent calls to the plugin (see MOBILE-3477).
// Variables for DB.
protected appDB: Promise<SQLiteDB>;
protected resolveAppDB!: (appDB: SQLiteDB) => void;
protected sitesTable = asyncInstance<CoreDatabaseTable<CoreLocalNotificationsSitesDBRecord, 'id', never>>();
protected componentsTable = asyncInstance<CoreDatabaseTable<CoreLocalNotificationsComponentsDBRecord, 'id', never>>();
protected triggeredTable = asyncInstance<CoreDatabaseTable<CoreLocalNotificationsTriggeredDBRecord>>();
constructor() {
this.appDB = new Promise(resolve => this.resolveAppDB = resolve);
this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider');
this.queueRunner = new CoreQueueRunner(10);
}
@ -127,7 +131,36 @@ export class CoreLocalNotificationsProvider {
// Ignore errors.
}
this.resolveAppDB(CoreApp.getDB());
const database = CoreApp.getDB();
const sitesTable = new CoreDatabaseTableProxy<CoreLocalNotificationsSitesDBRecord, 'id', never>(
{ cachingStrategy: CoreDatabaseCachingStrategy.None },
database,
SITES_TABLE_NAME,
['id'],
null,
);
const componentsTable = new CoreDatabaseTableProxy<CoreLocalNotificationsComponentsDBRecord, 'id', never>(
{ cachingStrategy: CoreDatabaseCachingStrategy.None },
database,
COMPONENTS_TABLE_NAME,
['id'],
null,
);
const triggeredTable = new CoreDatabaseTableProxy<CoreLocalNotificationsTriggeredDBRecord>(
{ cachingStrategy: CoreDatabaseCachingStrategy.None },
database,
TRIGGERED_TABLE_NAME,
);
await Promise.all([
sitesTable.initialize(),
componentsTable.initialize(),
triggeredTable.initialize(),
]);
this.sitesTable.setInstance(sitesTable);
this.componentsTable.setInstance(componentsTable);
this.triggeredTable.setInstance(triggeredTable);
}
/**
@ -222,7 +255,10 @@ export class CoreLocalNotificationsProvider {
* @param id ID of the element to get its code.
* @returns Promise resolved when the code is retrieved.
*/
protected async getCode(table: string, id: string): Promise<number> {
protected async getCode(
table: AsyncInstance<CoreDatabaseTable<{ id: string; code: number }>>,
id: string,
): Promise<number> {
const key = table + '#' + id;
// Check if the code is already in memory.
@ -230,25 +266,27 @@ export class CoreLocalNotificationsProvider {
return this.codes[key];
}
const db = await this.appDB;
try {
// Check if we already have a code stored for that ID.
const entry = await db.getRecord<{id: string; code: number}>(table, { id: id });
const entry = await table.getOneByPrimaryKey({ id: id });
this.codes[key] = entry.code;
return entry.code;
} catch (err) {
// No code stored for that ID. Create a new code for it.
const entries = await db.getRecords<{id: string; code: number}>(table, undefined, 'code DESC');
const entries = await table.getMany(undefined, {
sorting: [
{ code: 'desc' },
],
});
let newCode = 0;
if (entries.length > 0) {
newCode = entries[0].code + 1;
}
await db.insertRecord(table, { id: id, code: newCode });
await table.insert({ id: id, code: newCode });
this.codes[key] = newCode;
return newCode;
@ -347,13 +385,12 @@ export class CoreLocalNotificationsProvider {
* @returns Promise resolved with a boolean indicating if promise is triggered (true) or not.
*/
async isTriggered(notification: ILocalNotification, useQueue: boolean = true): Promise<boolean> {
const db = await this.appDB;
if (notification.id === undefined) {
return false;
}
try {
const stored = await db.getRecord<{ id: number; at: number }>(
TRIGGERED_TABLE_NAME,
{ id: notification.id },
);
const stored = await this.triggeredTable.getOneByPrimaryKey({ id: notification.id });
let triggered = (notification.trigger && notification.trigger.at) || 0;
@ -439,7 +476,18 @@ export class CoreLocalNotificationsProvider {
}
// Get the code and resolve/reject all the promises of this request.
const code = await this.getCode(request.table, request.id);
const getCodeFromTable = async () => {
switch (request.table) {
case SITES_TABLE_NAME:
return this.getCode(this.sitesTable, request.id);
case COMPONENTS_TABLE_NAME:
return this.getCode(this.componentsTable, request.id);
default:
throw new Error(`Unknown local-notifications table: ${request.table}`);
}
};
const code = await getCodeFromTable();
request.deferreds.forEach((p) => {
p.resolve(code);
@ -506,9 +554,7 @@ export class CoreLocalNotificationsProvider {
* @returns Promise resolved when it is removed.
*/
async removeTriggered(id: number): Promise<void> {
const db = await this.appDB;
await db.deleteRecords(TRIGGERED_TABLE_NAME, { id: id });
await this.triggeredTable.deleteByPrimaryKey({ id });
}
/**
@ -518,7 +564,7 @@ export class CoreLocalNotificationsProvider {
* @param id ID of the element to get its code.
* @returns Promise resolved when the code is retrieved.
*/
protected requestCode(table: string, id: string): Promise<number> {
protected requestCode(table: typeof SITES_TABLE_NAME | typeof COMPONENTS_TABLE_NAME, id: string): Promise<number> {
const deferred = new CorePromisedValue<number>();
const key = table + '#' + id;
const isQueueEmpty = Object.keys(this.codeRequestsQueue).length == 0;
@ -529,8 +575,8 @@ export class CoreLocalNotificationsProvider {
} else {
// Add a pending request to the queue.
this.codeRequestsQueue[key] = {
table: table,
id: id,
table,
id,
deferreds: [deferred],
};
}
@ -664,7 +710,6 @@ export class CoreLocalNotificationsProvider {
* @returns Promise resolved when stored, rejected otherwise.
*/
async trigger(notification: ILocalNotification): Promise<number> {
const db = await this.appDB;
let time = Date.now();
if (notification.trigger?.at) {
// The type says "at" is a Date, but in Android we can receive timestamps instead.
@ -675,12 +720,10 @@ export class CoreLocalNotificationsProvider {
}
}
const entry = {
return this.triggeredTable.insert({
id: notification.id,
at: time,
};
return db.insertRecord(TRIGGERED_TABLE_NAME, entry);
});
}
/**
@ -691,12 +734,10 @@ export class CoreLocalNotificationsProvider {
* @returns Promise resolved when done.
*/
async updateComponentName(oldName: string, newName: string): Promise<void> {
const db = await this.appDB;
const oldId = COMPONENTS_TABLE_NAME + '#' + oldName;
const newId = COMPONENTS_TABLE_NAME + '#' + newName;
await db.updateRecords(COMPONENTS_TABLE_NAME, { id: newId }, { id: oldId });
await this.componentsTable.update({ id: newId }, { id: oldId });
}
}

View File

@ -93,7 +93,7 @@ export class CoreSitesProvider {
protected siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {};
protected pluginsSiteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {};
protected siteTables: Record<string, Record<string, CorePromisedValue<CoreDatabaseTable>>> = {};
protected schemasTables: Record<string, AsyncInstance<CoreDatabaseTable<SchemaVersionsDBEntry, 'name'>>> = {};
protected schemasTables: Record<string, AsyncInstance<CoreDatabaseTable<SchemaVersionsDBEntry, 'name', never>>> = {};
protected sitesTable = asyncInstance<CoreDatabaseTable<SiteDBEntry>>();
constructor(@Optional() @Inject(CORE_SITE_SCHEMAS) siteSchemas: CoreSiteSchema[][] | null) {
@ -211,7 +211,8 @@ export class CoreSitesProvider {
*/
async getSiteTable<
DBRecord extends SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord
PrimaryKeyColumn extends keyof DBRecord,
RowIdColumn extends PrimaryKeyColumn,
>(
tableName: string,
options: Partial<{
@ -219,9 +220,10 @@ export class CoreSitesProvider {
config: Partial<CoreDatabaseConfiguration>;
database: SQLiteDB;
primaryKeyColumns: PrimaryKeyColumn[];
rowIdColumn: RowIdColumn | null;
onDestroy(): void;
}> = {},
): Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn>> {
): Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn>> {
const siteId = options.siteId ?? this.getCurrentSiteId();
if (!(siteId in this.siteTables)) {
@ -231,11 +233,12 @@ export class CoreSitesProvider {
if (!(tableName in this.siteTables[siteId])) {
const promisedTable = this.siteTables[siteId][tableName] = new CorePromisedValue();
const database = options.database ?? await this.getSiteDb(siteId);
const table = new CoreDatabaseTableProxy<DBRecord, PrimaryKeyColumn>(
const table = new CoreDatabaseTableProxy<DBRecord, PrimaryKeyColumn, RowIdColumn>(
options.config ?? {},
database,
tableName,
options.primaryKeyColumns,
options.rowIdColumn,
);
options.onDestroy && table.addListener({ onDestroy: options.onDestroy });
@ -245,7 +248,7 @@ export class CoreSitesProvider {
promisedTable.resolve(table as unknown as CoreDatabaseTable);
}
return this.siteTables[siteId][tableName] as unknown as Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn>>;
return this.siteTables[siteId][tableName] as unknown as Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn, RowIdColumn>>;
}
/**
@ -2033,6 +2036,7 @@ export class CoreSitesProvider {
database: site.getDb(),
config: { cachingStrategy: CoreDatabaseCachingStrategy.Eager },
primaryKeyColumns: ['name'],
rowIdColumn: null,
onDestroy: () => delete this.schemasTables[siteId],
}),
);

View File

@ -1826,23 +1826,29 @@ export class CoreUtilsProvider {
* @param condition Condition.
* @returns Cancellable promise.
*/
waitFor(condition: () => boolean, interval: number = 50): CoreCancellablePromise<void> {
waitFor(condition: () => boolean): CoreCancellablePromise<void>;
waitFor(condition: () => boolean, options: CoreUtilsWaitOptions): CoreCancellablePromise<void>;
waitFor(condition: () => boolean, interval: number): CoreCancellablePromise<void>;
waitFor(condition: () => boolean, optionsOrInterval: CoreUtilsWaitOptions | number = {}): CoreCancellablePromise<void> {
const options = typeof optionsOrInterval === 'number' ? { interval: optionsOrInterval } : optionsOrInterval;
if (condition()) {
return CoreCancellablePromise.resolve();
}
const startTime = Date.now();
let intervalId: number | undefined;
return new CoreCancellablePromise<void>(
async (resolve) => {
intervalId = window.setInterval(() => {
if (!condition()) {
if (!condition() && (!options.timeout || (Date.now() - startTime < options.timeout))) {
return;
}
resolve();
window.clearInterval(intervalId);
}, interval);
}, options.interval ?? 50);
},
() => window.clearInterval(intervalId),
);
@ -1939,6 +1945,14 @@ export type CoreUtilsOpenInAppOptions = InAppBrowserOptions & {
originalUrl?: string; // Original URL to open (in case the URL was treated, e.g. to add a token or an auto-login).
};
/**
* Options for waiting.
*/
export type CoreUtilsWaitOptions = {
interval?: number;
timeout?: number;
};
/**
* Possible default picker actions.
*/

View File

@ -35,25 +35,20 @@ Feature: It opens files properly.
Then I should find "This file may not work as expected on this device" in the app
When I press "Open file" in the app
Then the app should have opened a browser tab with url "^blob:"
Then the app should have opened url "^blob:" with contents "Test resource A rtf.rtf file" once
When I switch to the browser tab opened by the app
Then I should see "Test resource A rtf.rtf file"
When I close the browser tab opened by the app
And I press "Open" in the app
When I press "Open" in the app
Then I should find "This file may not work as expected on this device" in the app
When I select "Don't show again." in the app
And I press "Open file" in the app
Then the app should have opened a browser tab with url "^blob:"
Then the app should have opened url "^blob:" with contents "Test resource A rtf.rtf file" 2 times
When I close the browser tab opened by the app
And I press "Open" in the app
Then the app should have opened a browser tab with url "^blob:"
When I press "Open" in the app
Then I should not find "This file may not work as expected on this device" in the app
And the app should have opened url "^blob:" with contents "Test resource A rtf.rtf file" 3 times
When I close the browser tab opened by the app
And I press the back button in the app
When I press the back button in the app
And I press "Test DOC" in the app
And I press "Open" in the app
Then I should find "This file may not work as expected on this device" in the app

View File

@ -23,11 +23,22 @@ export type Constructor<T> = { new(...args: any[]): T };
*/
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
/**
* Helper to get closure args.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type GetClosureArgs<T> = T extends (...args: infer TArgs) => any ? TArgs : never;
/**
* Helper type to flatten complex types.
*/
export type Pretty<T> = T extends infer U ? {[K in keyof U]: U[K]} : never;
/**
* Helper to convert some keys of an object to optional.
*/
export type SubPartial<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
/**
* Helper type to omit union.
* You can use it if need to omit an element from types union.

View File

@ -31,6 +31,8 @@ import { CoreNavigator, CoreNavigatorService } from '@services/navigator';
import { CoreSwipeNavigationDirective } from '@directives/swipe-navigation';
import { Swiper } from 'swiper';
import { LocalNotificationsMock } from '@features/emulator/services/local-notifications';
import { GetClosureArgs } from '@/core/utils/types';
import { CoreIframeComponent } from '@components/iframe/iframe';
/**
* Behat runtime servive with public API.
@ -39,6 +41,10 @@ import { LocalNotificationsMock } from '@features/emulator/services/local-notifi
export class TestingBehatRuntimeService {
protected initialized = false;
protected openedUrls: {
args: GetClosureArgs<Window['open']>;
contents?: string;
}[] = [];
get cronDelegate(): CoreCronDelegateService {
return CoreCronDelegate.instance;
@ -90,6 +96,17 @@ export class TestingBehatRuntimeService {
document.cookie = 'MoodleAppConfig=' + JSON.stringify(options.configOverrides);
CoreConfig.patchEnvironment(options.configOverrides, { patchDefault: true });
}
// Spy on window.open.
const originalOpen = window.open.bind(window);
window.open = (...args) => {
this.openedUrls.push({ args });
return originalOpen(...args);
};
// Reduce iframes timeout to speed up tests.
CoreIframeComponent.loadingTimeout = 1000;
}
/**
@ -274,6 +291,45 @@ export class TestingBehatRuntimeService {
}
}
/**
* Check whether the given url has been opened in the app.
*
* @param urlPattern Url pattern.
* @param contents Url contents.
* @param times How many times it should have been opened.
* @returns OK if successful, or ERROR: followed by message
*/
async hasOpenedUrl(urlPattern: string, contents: string, times: number): Promise<string> {
const urlRegExp = new RegExp(urlPattern);
const urlMatches = await Promise.all(this.openedUrls.map(async (openedUrl) => {
const renderedUrl = openedUrl.args[0]?.toString() ?? '';
if (!urlRegExp.test(renderedUrl)) {
return false;
}
if (contents && !('contents' in openedUrl)) {
const response = await fetch(renderedUrl);
openedUrl.contents = await response.text();
}
if (contents && contents !== openedUrl.contents) {
return false;
}
return true;
}));
if (urlMatches.filter(matches => !!matches).length === times) {
return 'OK';
}
return times === 1
? `ERROR: Url matching '${urlPattern}' with '${contents}' contents has not been opened once`
: `ERROR: Url matching '${urlPattern}' with '${contents}' contents has not been opened ${times} times`;
}
/**
* Load more items form an active list with infinite loader.
*

View File

@ -0,0 +1,23 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export {};
declare module '@sqlite.org/sqlite-wasm' {
export interface SqliteRowData {
rowId?: number;
}
}

93
src/types/sqlite-wasm.d.ts vendored 100644
View File

@ -0,0 +1,93 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Brand } from '@/core/utils/types';
// Can be removed when the following issue is fixed:
// https://github.com/sqlite/sqlite-wasm/issues/53
declare module '@sqlite.org/sqlite-wasm' {
export type SqliteDbId = Brand<unknown, 'SqliteDbId'>;
export interface SqliteRowData {
columnNames: string[];
row: SqlValue[] | undefined;
rowNumber: number | null;
}
export interface Sqlite3Worker1Messages {
close: {
args?: {
unlink?: boolean;
};
result: {
filename?: string;
};
};
'config-get': {
result: {
version: object;
bigIntEnabled: boolean;
vfsList: unknown;
};
};
exec: {
args: {
sql: string;
bind?: BindingSpec;
callback?(data: SqliteRowData): void | false;
};
};
open: {
args: {
filename: string;
vfs?: string;
};
result: {
dbId: SqliteDbId;
filename: string;
persistent: boolean;
vfs: string;
};
};
}
export interface Sqlite3Worker1PromiserConfig {
onready(): void;
worker?: unknown;
generateMessageId?(message: object): string;
debug?(...args: unknown[]): void;
onunhandled?(event: unknown): void;
}
export type Sqlite3Worker1PromiserMethodOptions<T extends keyof Sqlite3Worker1Messages> =
Sqlite3Worker1Messages[T] extends { args?: infer TArgs }
? { type: T; args: TArgs }
: { type: T; args?: Sqlite3Worker1Messages[T]['args'] };
export type Sqlite3Worker1Promiser =
(<T extends keyof Sqlite3Worker1Messages>(
type: T,
args: Sqlite3Worker1Messages[T]['args'],
) => Promise<Sqlite3Worker1Messages[T]['result']>) &
(<T extends keyof Sqlite3Worker1Messages>(
options: Sqlite3Worker1PromiserMethodOptions<T>,
) => Promise<Sqlite3Worker1Messages[T]['result']>);
export function sqlite3Worker1Promiser(): Sqlite3Worker1Promiser;
export function sqlite3Worker1Promiser(onready: () => void): Sqlite3Worker1Promiser;
export function sqlite3Worker1Promiser(config: Sqlite3Worker1PromiserOptions): Sqlite3Worker1Promiser;
}