From 0a8a696b52be84b6e419b60e130ac8f684d596dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 18 Mar 2025 15:49:04 +0100 Subject: [PATCH 1/3] MOBILE-4575 user: Sort user initials depending on translation --- scripts/langindex.json | 1 + src/core/features/user/lang.json | 1 + .../features/user/services/user-helper.ts | 19 ++++-- .../features/user/tests/user-helper.test.ts | 61 ++++++++++++++++++- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index ed1547c569e..ec2048a61ae 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2666,6 +2666,7 @@ "core.user.emailagain": "moodle", "core.user.errorloaduser": "local_moodlemobileapp", "core.user.firstname": "moodle", + "core.user.fullnamedisplay": "moodle", "core.user.idnumber": "moodle", "core.user.institution": "moodle", "core.user.interests": "moodle", diff --git a/src/core/features/user/lang.json b/src/core/features/user/lang.json index c64bc38e29d..17c85a94907 100644 --- a/src/core/features/user/lang.json +++ b/src/core/features/user/lang.json @@ -18,6 +18,7 @@ "emailagain": "Email (again)", "errorloaduser": "Error loading user.", "firstname": "First name", + "fullnamedisplay": "{{$a.firstname}} {{$a.lastname}}", "idnumber": "ID number", "institution": "Institution", "interests": "Interests", diff --git a/src/core/features/user/services/user-helper.ts b/src/core/features/user/services/user-helper.ts index 6730b300b4f..fed010f5c90 100644 --- a/src/core/features/user/services/user-helper.ts +++ b/src/core/features/user/services/user-helper.ts @@ -108,6 +108,16 @@ export class CoreUserHelperProvider { * @returns User initials. */ async getUserInitialsFromParts(parts: CoreUserNameParts): Promise { + const nameFields = ['firstname', 'lastname']; + const dummyUser = { + firstname: 'firstname', + lastname: 'lastname', + }; + const nameFormat = Translate.instant('core.user.fullnamedisplay', { $a:dummyUser }); + const availableFieldsSorted = nameFields + .filter((field) => nameFormat.indexOf(field) >= 0) + .sort((a, b) => nameFormat.indexOf(a) - nameFormat.indexOf(b)); + if (!parts.firstname && !parts.lastname) { if (!parts.fullname && parts.userId) { const user = await CoreUser.getProfile(parts.userId, undefined, true); @@ -115,8 +125,8 @@ export class CoreUserHelperProvider { } if (parts.fullname) { + // It's a complete workaround. const split = parts.fullname.split(' '); - parts.firstname = split[0]; if (split.length > 1) { parts.lastname = split[split.length - 1]; @@ -124,11 +134,10 @@ export class CoreUserHelperProvider { } } - if (!parts.firstname && !parts.lastname) { - return 'UNK'; - } + const initials = availableFieldsSorted.reduce((initials, fieldName) => + initials + (parts[fieldName]?.charAt(0) ?? ''), ''); - return (parts.firstname?.charAt(0) || '') + (parts.lastname?.charAt(0) || ''); + return initials || 'UNK'; } /** diff --git a/src/core/features/user/tests/user-helper.test.ts b/src/core/features/user/tests/user-helper.test.ts index 64258bd5f7b..5f9d8eea9e5 100644 --- a/src/core/features/user/tests/user-helper.test.ts +++ b/src/core/features/user/tests/user-helper.test.ts @@ -12,9 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { mockSingleton, mockTranslate } from '@/testing/utils'; import { CoreUserHelper } from '../services/user-helper'; +import { CoreUser } from '../services/user'; describe('getUserInitialsFromParts', () => { + beforeEach(() => { + mockTranslate({ + 'core.user.fullnamedisplay': '{{$a.firstname}} {{$a.lastname}}', + }); + }); + it('should return initials based on firstname and lastname', async () => { const parts = { firstname: 'John', @@ -23,13 +31,53 @@ describe('getUserInitialsFromParts', () => { userId: 123, }; - const result = await CoreUserHelper.getUserInitialsFromParts(parts); + let result = await CoreUserHelper.getUserInitialsFromParts(parts); expect(result).toEqual('JD'); + + mockTranslate({ + 'core.user.fullnamedisplay': '{{$a.lastname}} {{$a.firstname}}', + }); + + result = await CoreUserHelper.getUserInitialsFromParts(parts); + + expect(result).toEqual('DJ'); + + mockTranslate({ + 'core.user.fullnamedisplay': '{{$a.lastname}}', + }); + + result = await CoreUserHelper.getUserInitialsFromParts(parts); + + expect(result).toEqual('D'); + + mockTranslate({ + 'core.user.fullnamedisplay': '{{$a.firstname}}', + }); + + result = await CoreUserHelper.getUserInitialsFromParts(parts); + + expect(result).toEqual('J'); + + mockTranslate({ + 'core.user.fullnamedisplay': '{{$a.noname}}', + }); + + result = await CoreUserHelper.getUserInitialsFromParts(parts); + + expect(result).toEqual('UNK'); }); it('should return initials based on fullname if firstname and lastname are missing', async () => { - let parts = { + mockSingleton(CoreUser, { + getProfile: () => Promise.resolve({ + firstname: 'John', + lastname: 'Doe', + fullname: 'John Doe', + id: 123, + }) }); + + let parts: { firstname?: string; lastname?: string; fullname?: string; userId?: number } = { firstname: '', lastname: '', fullname: 'John Doe', @@ -44,12 +92,19 @@ describe('getUserInitialsFromParts', () => { firstname: '', lastname: '', fullname: 'John Fitzgerald Doe', - userId: 123, }; result = await CoreUserHelper.getUserInitialsFromParts(parts); expect(result).toEqual('JD'); + + mockTranslate({ + 'core.user.fullnamedisplay': '{{$a.lastname}} {{$a.firstname}}', + }); + + result = await CoreUserHelper.getUserInitialsFromParts(parts); + + expect(result).toEqual('DJ'); }); it('should return UNK string if empty parts', async () => { From 310a35cbead4d315b5164c6cf4e87ea7459e0ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 20 Mar 2025 12:54:28 +0100 Subject: [PATCH 2/3] user cache --- src/core/classes/stored-cache.ts | 171 ++++++++++++++++++ .../features/user/services/database/user.ts | 10 +- src/core/features/user/services/user.ts | 47 ++--- 3 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 src/core/classes/stored-cache.ts diff --git a/src/core/classes/stored-cache.ts b/src/core/classes/stored-cache.ts new file mode 100644 index 00000000000..73fe0feaa50 --- /dev/null +++ b/src/core/classes/stored-cache.ts @@ -0,0 +1,171 @@ +// (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 { CoreSites, CoreSiteSchema } from '@services/sites'; +import { CoreText } from '@singletons/text'; +import { SQLiteDB } from './sqlitedb'; +import { CorePromiseUtils } from '@singletons/promise-utils'; + +/** + * A cache to store values in database. + * + * The data is organized by "entries" that are identified by an ID. Each entry can have multiple values stored, + * and each value has its own timemodified. + * + * Values expire after a certain time. + */ +export class CoreStoredCache { + + constructor(protected tableName: string) { + + } + + /** + * Clear the cache. Erasing all the entries. + * + * @param siteId ID of the site. If not defined, use current site. + */ + async clear(siteId?: string): Promise { + const db = await this.getDb(siteId); + + await db.deleteRecords(this.tableName); + } + + /** + * Get all the data stored in the cache for a certain id. + * + * @param id The ID to identify the entry. + * @param siteId ID of the site. If not defined, use current site. + * @returns The data from the cache. Undefined if not found. + */ + async getEntry(id: number, siteId?: string): Promise { + const db = await this.getDb(siteId); + + const record = await db.getRecord(this.tableName, { id }); + + return CoreText.parseJSON(record.data); + } + + /** + * Invalidate all the cached data for a certain entry. + * + * @param id The ID to identify the entry. + * @param siteId ID of the site. If not defined, use current site. + */ + async invalidate(id: number, siteId?: string): Promise { + const db = await this.getDb(siteId); + + await db.updateRecords(this.tableName, { timemodified: 0 }, { id }); + } + + /** + * Update the status of a module in the "cache". + * + * @param id The ID to identify the entry. + * @param value Value to set. + * @param siteId ID of the site. If not defined, use current site. + * @returns The set value. + */ + async setEntry( + id: number, + value: T, + siteId?: string, + ): Promise { + const db = await this.getDb(siteId); + + let entry = await CorePromiseUtils.ignoreErrors(this.getEntry(id, siteId), { id }); + + entry = { + ...entry, + ...value, + }; + + const record: CoreStoredCacheRecord = { + id, + timemodified: Date.now(), + data: JSON.stringify(entry), + }; + + await db.insertRecord(this.tableName, record); + } + + /** + * Delete an entry from the cache. + * + * @param id ID of the entry to delete. + * @param siteId ID of the site. If not defined, use current site. + */ + async deleteEntry(id: number, siteId?: string): Promise { + const db = await this.getDb(siteId); + + await db.deleteRecords(this.tableName, { id }); + } + + /** + * Get the database to use. + * + * @param siteId ID of the site. If not defined, use current site. + * @returns Database. + */ + protected async getDb(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb(); + } + +} + +/** + * Helper function to get the schema to store cache in the database. + * + * @param schemaName Name of the schema. + * @param tableName Name of the table. + * @returns Schema. + */ +export function getStoredCacheDBSchema(schemaName: string, tableName: string): CoreSiteSchema { + return { + name: schemaName, + version: 1, + canBeCleared: [tableName], + tables: [ + { + name: tableName, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'data', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + }, + ], + }; +} + +/** + * Stored cache entry. + */ +type CoreStoredCacheRecord = { + id: number; + data: string; + timemodified: number; +}; diff --git a/src/core/features/user/services/database/user.ts b/src/core/features/user/services/database/user.ts index 21bf974bbe3..3ce0fb56dc0 100644 --- a/src/core/features/user/services/database/user.ts +++ b/src/core/features/user/services/database/user.ts @@ -14,6 +14,7 @@ import { CoreSiteSchema } from '@services/sites'; import { CoreUserBasicData } from '../user'; +import { getStoredCacheDBSchema } from '@classes/stored-cache'; /** * Database variables for CoreUser service. @@ -33,18 +34,21 @@ export const CORE_USER_CACHE_SITE_SCHEMA: CoreSiteSchema = { primaryKey: true, }, { - name: 'fullname', + name: 'data', type: 'TEXT', }, { - name: 'profileimageurl', - type: 'TEXT', + name: 'timemodified', + type: 'INTEGER', }, ], }, ], }; +export const USERS_CACHE_TABLE_NAME = 'users_cache'; +export const CORE_USER_CACHE_SITE_SCHEMA = getStoredCacheDBSchema('CoreUser', USERS_CACHE_TABLE_NAME); + /** * Database variables for CoreUserOffline service. */ diff --git a/src/core/features/user/services/user.ts b/src/core/features/user/services/user.ts index a11cf5fb184..647ca6a0222 100644 --- a/src/core/features/user/services/user.ts +++ b/src/core/features/user/services/user.ts @@ -25,13 +25,14 @@ import { makeSingleton, Translate } from '@singletons'; import { CoreEvents, CoreEventSiteData, CoreEventUserDeletedData, CoreEventUserSuspendedData } from '@singletons/events'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { CoreError } from '@classes/errors/error'; -import { USERS_TABLE_NAME, CoreUserDBRecord } from './database/user'; +import { USERS_CACHE_TABLE_NAME, CoreUserDBRecord } from './database/user'; import { CoreUrl } from '@singletons/url'; import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site'; import { CoreCacheUpdateFrequency, CoreConstants } from '@/core/constants'; import { CorePromiseUtils } from '@singletons/promise-utils'; import { CoreTextFormat } from '@singletons/text'; import { CORE_USER_PROFILE_REFRESHED, CORE_USER_PROFILE_PICTURE_UPDATED, CORE_USER_PARTICIPANTS_LIST_LIMIT } from '../constants'; +import { CoreStoredCache } from '@classes/stored-cache'; declare module '@singletons/events' { @@ -61,6 +62,7 @@ export class CoreUserProvider { static readonly PARTICIPANTS_LIST_LIMIT = CORE_USER_PARTICIPANTS_LIST_LIMIT; protected logger: CoreLogger; + protected userCache = new CoreStoredCache(USERS_CACHE_TABLE_NAME); constructor() { this.logger = CoreLogger.getInstance('CoreUserProvider'); @@ -160,7 +162,7 @@ export class CoreUserProvider { await Promise.all([ this.invalidateUserCache(userId, site.getId()), - site.getDb().deleteRecords(USERS_TABLE_NAME, { id: userId }), + this.userCache.deleteEntry(userId, site.getId()), ]); } @@ -303,9 +305,7 @@ export class CoreUserProvider { * @returns Promise resolve when the user is retrieved. */ protected async getUserFromLocalDb(userId: number, siteId?: string): Promise { - const site = await CoreSites.getSite(siteId); - - return site.getDb().getRecord(USERS_TABLE_NAME, { id: userId }); + return await this.userCache.getEntry(userId, siteId); } /** @@ -382,7 +382,8 @@ export class CoreUserProvider { if ('country' in user && user.country) { user.country = CoreCountries.getCountryName(user.country); } - this.storeUser(user.id, user.fullname, user.profileimageurl); + + this.storeUser(user); return user; } @@ -706,25 +707,26 @@ export class CoreUserProvider { /** * Store user basic information in local DB to be retrieved if the WS call fails. * - * @param userId User ID. - * @param fullname User full name. - * @param avatar User avatar URL. + * @param user User to store. * @param siteId ID of the site. If not defined, use current site. */ - protected async storeUser(userId: number, fullname: string, avatar?: string, siteId?: string): Promise { - if (!userId) { + protected async storeUser(user: CoreUserBasicData, siteId?: string): Promise { + if (!user.id) { return; } - const site = await CoreSites.getSite(siteId); - + // Filter and map data to store. const userRecord: CoreUserDBRecord = { - id: userId, - fullname: fullname, - profileimageurl: avatar, + id: user.id, + fullname: user.fullname, + profileimageurl: user.profileimageurl, + firstname: user.firstname, + lastname: user.lastname, + initials: user.initials, }; - await site.getDb().insertRecord(USERS_TABLE_NAME, userRecord); + + await this.userCache.setEntry(user.id, userRecord, siteId); } /** @@ -733,9 +735,8 @@ export class CoreUserProvider { * @param users Users to store. * @param siteId ID of the site. If not defined, use current site. */ - async storeUsers(users: CoreUserBasicData[], siteId?: string): Promise { - await Promise.all(users.map((user) => - this.storeUser(Number(user.id), user.fullname, user.profileimageurl, siteId))); + async storeUsers(users: CoreUserDBRecord[], siteId?: string): Promise { + await Promise.all(users.map((user) => this.storeUser(user, siteId))); } /** @@ -853,7 +854,11 @@ export type CoreUserProfilePictureUpdatedData = { export type CoreUserBasicData = { id: number; // ID of the user. fullname: string; // The fullname of the user. - profileimageurl?: string; // User image profile URL - big version. + profileimageurl: string; // User image profile URL - big version. + firstname?: string; // The first name(s) of the user. + lastname?: string; // The family name of the user. + lastaccess?: number; + initials?: string; // Initials. }; /** From 0c7dfca60771ffeedcac932f372fffc8e75db484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 20 Mar 2025 13:45:43 +0100 Subject: [PATCH 3/3] 2 --- .../features/user/services/user-helper.ts | 98 ++++++++++++------- src/core/features/user/services/user.ts | 40 +++++++- 2 files changed, 99 insertions(+), 39 deletions(-) diff --git a/src/core/features/user/services/user-helper.ts b/src/core/features/user/services/user-helper.ts index fed010f5c90..a8bfe63d3f0 100644 --- a/src/core/features/user/services/user-helper.ts +++ b/src/core/features/user/services/user-helper.ts @@ -17,7 +17,7 @@ import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; import { makeSingleton, Translate } from '@singletons'; -import { CoreUser, CoreUserProfile, CoreUserRole } from './user'; +import { CoreUser, CoreUserBasicData, CoreUserProfile, CoreUserRole } from './user'; import { CoreTime } from '@singletons/time'; /** @@ -90,24 +90,8 @@ export class CoreUserHelperProvider { * * @param user User object. * @returns User initials. - * @deprecated since 4.4. Use getUserInitialsFromParts instead. */ getUserInitials(user: Partial): string { - if (!user.firstname && !user.lastname) { - // @TODO: Use local info or check WS to get initials from. - return ''; - } - - return (user.firstname?.charAt(0) || '') + (user.lastname?.charAt(0) || ''); - } - - /** - * Get the user initials. - * - * @param parts User name parts. Containing firstname, lastname, fullname and userId. - * @returns User initials. - */ - async getUserInitialsFromParts(parts: CoreUserNameParts): Promise { const nameFields = ['firstname', 'lastname']; const dummyUser = { firstname: 'firstname', @@ -118,28 +102,30 @@ export class CoreUserHelperProvider { .filter((field) => nameFormat.indexOf(field) >= 0) .sort((a, b) => nameFormat.indexOf(a) - nameFormat.indexOf(b)); - if (!parts.firstname && !parts.lastname) { - if (!parts.fullname && parts.userId) { - const user = await CoreUser.getProfile(parts.userId, undefined, true); - parts.fullname = user.fullname || ''; - } - - if (parts.fullname) { - // It's a complete workaround. - const split = parts.fullname.split(' '); - parts.firstname = split[0]; - if (split.length > 1) { - parts.lastname = split[split.length - 1]; - } - } - } - const initials = availableFieldsSorted.reduce((initials, fieldName) => - initials + (parts[fieldName]?.charAt(0) ?? ''), ''); + initials + (user[fieldName]?.charAt(0) ?? ''), ''); return initials || 'UNK'; } + /** + * Get the user initials. + * + * @param parts User name parts. Containing firstname, lastname, fullname and userId. + * @returns User initials. + */ + async getUserInitialsFromParts(parts: CoreUserNameParts): Promise { + const initials = this.getUserInitials(parts); + if (initials !== 'UNK' || !parts.userId) { + return initials; + } + const user = await CoreUser.getProfile(parts.userId, undefined, false); + console.error(user, parts.userId); + + return user.initials || 'UNK'; + + } + /** * Translates legacy timezone names. * @@ -151,8 +137,52 @@ export class CoreUserHelperProvider { return CoreTime.translateLegacyTimezone(tz); } + normalizeBasicFields(profile: CoreUserDenormalized): T { + let normalized = { + id: profile.id ?? profile.userid ?? 0, + fullname: profile.fullname ?? profile.userfullname ?? '', + profileimageurl: profile.profileimageurl ?? profile.userprofileimageurl ?? + profile.userpictureurl ?? profile.profileimageurlsmall ?? profile.urls?.profileimage ?? '', + } as T; + + delete profile.userid; + delete profile.userfullname; + delete profile.userpictureurl; + delete profile.userprofileimageurl; + delete profile.profileimageurlsmall; + delete profile.urls; + + normalized = { ...profile, ...normalized }; + + if (normalized.id === 0) { + throw new Error('Invalid user ID'); + } + + normalized.initials = CoreUserHelper.getUserInitials(profile); + + return normalized; + } + } export const CoreUserHelper = makeSingleton(CoreUserHelperProvider); type CoreUserNameParts = { firstname?: string; lastname?: string; fullname?: string; userId?: number }; + +type CoreUserDenormalized = CoreUserBasicData & { + id?: number; + userid?: number; + + initials?: string; // Initials. + + fullname?: string; + userfullname?: string; + + profileimageurl?: string; + userpictureurl?: string; + userprofileimageurl?: string; + profileimageurlsmall?: string; + urls?: { + profileimage?: string; + }; +}; diff --git a/src/core/features/user/services/user.ts b/src/core/features/user/services/user.ts index 647ca6a0222..05522f463b1 100644 --- a/src/core/features/user/services/user.ts +++ b/src/core/features/user/services/user.ts @@ -33,6 +33,7 @@ import { CorePromiseUtils } from '@singletons/promise-utils'; import { CoreTextFormat } from '@singletons/text'; import { CORE_USER_PROFILE_REFRESHED, CORE_USER_PROFILE_PICTURE_UPDATED, CORE_USER_PARTICIPANTS_LIST_LIMIT } from '../constants'; import { CoreStoredCache } from '@classes/stored-cache'; +import { CoreUserHelper } from './user-helper'; declare module '@singletons/events' { @@ -349,7 +350,7 @@ export class CoreUserProvider { let users: CoreUserDescriptionExporter[] | CoreUserCourseProfile[] | undefined; // Determine WS and data to use. - if (courseId && courseId != site.getSiteHomeId()) { + if (courseId && courseId !== site.getSiteHomeId()) { this.logger.debug(`Get participant with ID '${userId}' in course '${courseId}`); const params: CoreUserGetCourseUserProfilesWSParams = { @@ -362,6 +363,7 @@ export class CoreUserProvider { }; users = await site.read('core_user_get_course_user_profiles', params, preSets); + console.error('core_user_get_course_user_profiles', users); } else { this.logger.debug(`Get user with ID '${userId}'`); @@ -371,6 +373,7 @@ export class CoreUserProvider { }; users = await site.read('core_user_get_users_by_field', params, preSets); + console.error('core_user_get_users_by_field', users); } if (users.length === 0) { @@ -378,8 +381,8 @@ export class CoreUserProvider { throw new CoreError('Cannot retrieve user info.'); } - const user = users[0]; - if ('country' in user && user.country) { + const user = CoreUserHelper.normalizeBasicFields(users[0]); + if (user.country) { user.country = CoreCountries.getCountryName(user.country); } @@ -725,6 +728,7 @@ export class CoreUserProvider { initials: user.initials, }; + console.error(userRecord); await this.userCache.setEntry(user.id, userRecord, siteId); } @@ -854,10 +858,9 @@ export type CoreUserProfilePictureUpdatedData = { export type CoreUserBasicData = { id: number; // ID of the user. fullname: string; // The fullname of the user. - profileimageurl: string; // User image profile URL - big version. + profileimageurl?: string; // User image profile URL - big version. firstname?: string; // The first name(s) of the user. lastname?: string; // The family name of the user. - lastaccess?: number; initials?: string; // Initials. }; @@ -911,6 +914,33 @@ export type CoreUserEnrolledCourse = { shortname: string; // Shortname of the course. }; +export type CoreUserNormalized = { + id: number; // ID of the user. + fullname: string; // The fullname of the user. + profileimageurl: string; // User image profile URL - big version. + username?: string; // The username. + firstname?: string; // The first name(s) of the user. + lastname?: string; // The family name of the user. + initials?: string; // Initials, added by the app. + email?: string; // An email address - allow email as root@localhost. + address?: string; // Postal address. + phone1?: string; // Phone 1. + phone2?: string; // Phone 2. + department?: string; // Department. + idnumber?: string; // An arbitrary ID code number perhaps from the institution. + interests?: string; // User interests (separated by commas). + firstaccess?: number; // First access to the site (0 if never). + lastaccess?: number; // Last access to the site (0 if never). + timezone?: string; // Timezone code such as Australia/Perth, or 99 for default. + trackforums?: number; // @since 4.4. Whether the user is tracking forums. + description?: string; // User profile description. + city?: string; // Home city of the user. + url?: string; // URL of the user. + country?: string; // Home country code of the user, such as AU or CZ. + customfields?: CoreUserProfileField[]; // User custom fields (also known as user profile fields). + preferences?: CoreUserPreference[]; // Users preferences. +}; + /** * Type for exporting a user description. * This relates to LMS core_user_external::user_description, do not modify unless the exporter changes.