Merge pull request #3145 from dpalou/MOBILE-3998

Mobile 3998
main
Pau Ferrer Ocaña 2022-02-28 16:07:35 +01:00 committed by GitHub
commit 8600f741bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 566 additions and 129 deletions

View File

@ -92,7 +92,7 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp
/** /**
* Open the book in a certain chapter. * Open the book in a certain chapter.
* *
* @param chapterId Chapter to open, undefined for first chapter. * @param chapterId Chapter to open, undefined for last chapter viewed.
*/ */
openBook(chapterId?: number): void { openBook(chapterId?: number): void {
CoreNavigator.navigate('contents', { CoreNavigator.navigate('contents', {

View File

@ -53,7 +53,7 @@ export class AddonModBookContentsPage implements OnInit, OnDestroy {
@ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent; @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent;
title!: string; title = '';
cmId!: number; cmId!: number;
courseId!: number; courseId!: number;
initialChapterId?: number; initialChapterId?: number;
@ -147,6 +147,8 @@ export class AddonModBookContentsPage implements OnInit, OnDestroy {
} else { } else {
this.warning = ''; this.warning = '';
} }
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
} finally { } finally {
this.loaded = true; this.loaded = true;
} }
@ -279,7 +281,7 @@ export class AddonModBookContentsPage implements OnInit, OnDestroy {
} }
if (this.book) { if (this.book) {
AddonModBook.storeLastChapterViewed(this.book.id, chapterId); AddonModBook.storeLastChapterViewed(this.book.id, chapterId, this.courseId);
} }
if (!this.module) { if (!this.module) {

View File

@ -26,11 +26,6 @@ import { CoreTextUtils } from '@services/utils/text';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreFile } from '@services/file'; import { CoreFile } from '@services/file';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { lazyMap, LazyMap } from '@/core/utils/lazy-map';
import { asyncInstance, AsyncInstance } from '@/core/utils/async-instance';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { AddonModBookLastChapterViewedDBRecord, LAST_CHAPTER_VIEWED_TABLE } from './database/book';
import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy';
/** /**
* Constants to define how the chapters and subchapters of a book should be displayed in that table of contents. * Constants to define how the chapters and subchapters of a book should be displayed in that table of contents.
@ -61,20 +56,6 @@ export class AddonModBookProvider {
static readonly COMPONENT = 'mmaModBook'; static readonly COMPONENT = 'mmaModBook';
protected lastChapterViewedTables: LazyMap<AsyncInstance<CoreDatabaseTable<AddonModBookLastChapterViewedDBRecord>>>;
constructor() {
this.lastChapterViewedTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable(LAST_CHAPTER_VIEWED_TABLE, {
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
onDestroy: () => delete this.lastChapterViewedTables[siteId],
}),
),
);
}
/** /**
* Get a book by course module ID. * Get a book by course module ID.
* *
@ -243,14 +224,12 @@ export class AddonModBookProvider {
* @return Promise resolved with last chapter viewed, undefined if none. * @return Promise resolved with last chapter viewed, undefined if none.
*/ */
async getLastChapterViewed(id: number, siteId?: string): Promise<number | undefined> { async getLastChapterViewed(id: number, siteId?: string): Promise<number | undefined> {
try { const site = await CoreSites.getSite(siteId);
const site = await CoreSites.getSite(siteId); const entry = await site.getLastViewed(AddonModBookProvider.COMPONENT, id);
const entry = await this.lastChapterViewedTables[site.getId()].getOneByPrimaryKey({ id });
return entry.chapterid; const chapterId = Number(entry?.value);
} catch {
// No last chapter viewed. return isNaN(chapterId) ? undefined : chapterId;
}
} }
/** /**
@ -405,13 +384,14 @@ export class AddonModBookProvider {
* *
* @param id Book instance ID. * @param id Book instance ID.
* @param chapterId Chapter ID. * @param chapterId Chapter ID.
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved with last chapter viewed, undefined if none. * @return Promise resolved with last chapter viewed, undefined if none.
*/ */
async storeLastChapterViewed(id: number, chapterId: number, siteId?: string): Promise<void> { async storeLastChapterViewed(id: number, chapterId: number, courseId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
await this.lastChapterViewedTables[site.getId()].insert({ id, chapterid: chapterId }); await site.storeLastViewed(AddonModBookProvider.COMPONENT, id, chapterId, String(courseId));
} }
} }

View File

@ -1,9 +1,5 @@
<!-- Buttons to add to the header. --> <!-- Buttons to add to the header. -->
<core-navbar-buttons slot="end"> <core-navbar-buttons slot="end">
<ion-button *ngIf="!showLoading" (click)="showToc()" aria-haspopup="true" [attr.aria-label]="'addon.mod_imscp.toc' | translate">
<ion-icon name="fas-bookmark" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate"> <ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-info-circle" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>
@ -13,24 +9,35 @@
<core-loading [hideUntil]="!showLoading" class="safe-area-padding core-loading-full-height"> <core-loading [hideUntil]="!showLoading" class="safe-area-padding core-loading-full-height">
<!-- Activity info. --> <!-- Activity info. -->
<core-course-module-info [module]="module"> <core-course-module-info [module]="module" [description]="description" [component]="component" [componentId]="componentId"
[courseId]="courseId">
</core-course-module-info> </core-course-module-info>
<ion-card class="core-warning-card" *ngIf="warning"> <ion-list>
<ion-item> <ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon> <ion-label>
<ion-label><span [innerHTML]="warning"></span></ion-label> <h2>{{ 'addon.mod_imscp.toc' | translate }}</h2>
</ion-label>
</ion-item> </ion-item>
</ion-card>
<div class="addon-mod-imscp-container"> <ion-item class="ion-text-wrap" *ngFor="let item of items" (click)="openImscp(item.href)" button detail="true">
<core-iframe *ngIf="!showLoading" [src]="src" [showFullscreenOnToolbar]="true" [autoFullscreenOnRotate]="true"></core-iframe> <ion-label [class.core-bold]="!item.href">
</div> <p>
<span class="ion-padding-start" *ngFor="let i of getNumberForPadding(item.level)"></span>
{{item.title}}
</p>
</ion-label>
</ion-item>
</ion-list>
<div collapsible-footer *ngIf="!showLoading" slot="fixed"> <div collapsible-footer *ngIf="!showLoading" slot="fixed">
<!-- TODO Add a contents page to avoid having both bars. Please add here start/resume buttons. --> <div class="list-item-limited-width">
<core-navigation-bar *ngIf="navigationItems.length > 1" [items]="navigationItems" (action)="loadItem($event)"> <ion-button class="ion-margin ion-text-wrap" expand="block" (click)="openImscp()">
</core-navigation-bar> <span *ngIf="!hasStarted">{{ 'core.start' | translate }}</span>
<span *ngIf="hasStarted">{{ 'core.resume' | translate }}</span>
</ion-button>
</div>
<core-course-module-navigation [courseId]="courseId" [currentModule]="module" (completionChanged)="onCompletionChange()"> <core-course-module-navigation [courseId]="courseId" [currentModule]="module" (completionChanged)="onCompletionChange()">
</core-course-module-navigation> </core-course-module-navigation>
</div> </div>

View File

@ -13,14 +13,11 @@
// limitations under the License. // limitations under the License.
import { Component, OnInit, Optional } from '@angular/core'; import { Component, OnInit, Optional } from '@angular/core';
import { CoreSilentError } from '@classes/errors/silenterror';
import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar';
import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/main-resource-component'; import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/main-resource-component';
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
import { CoreCourse } from '@features/course/services/course'; import { CoreCourse } from '@features/course/services/course';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreNavigator } from '@services/navigator';
import { AddonModImscpProvider, AddonModImscp, AddonModImscpTocItem } from '../../services/imscp'; import { AddonModImscpProvider, AddonModImscp, AddonModImscpTocItem } from '../../services/imscp';
import { AddonModImscpTocComponent } from '../toc/toc';
/** /**
* Component that displays a IMSCP. * Component that displays a IMSCP.
@ -33,13 +30,9 @@ import { AddonModImscpTocComponent } from '../toc/toc';
export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit { export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit {
component = AddonModImscpProvider.COMPONENT; component = AddonModImscpProvider.COMPONENT;
src = '';
warning = '';
navigationItems: CoreNavigationBarItem<AddonModImscpTocItem>[] = [];
protected items: AddonModImscpTocItem[] = []; items: AddonModImscpTocItem[] = [];
protected currentHref?: string; hasStarted = false;
protected displayDescription = false;
constructor(@Optional() courseContentsPage?: CoreCourseContentsPage) { constructor(@Optional() courseContentsPage?: CoreCourseContentsPage) {
super('AddonModImscpIndexComponent', courseContentsPage); super('AddonModImscpIndexComponent', courseContentsPage);
@ -73,85 +66,68 @@ export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceCom
/** /**
* @inheritdoc * @inheritdoc
*/ */
protected async fetchContent(refresh = false): Promise<void> { protected async fetchContent(): Promise<void> {
const downloadResult = await this.downloadResourceIfNeeded(refresh); await Promise.all([
this.loadImscp(),
const imscp = await AddonModImscp.getImscp(this.courseId, this.module.id); this.loadTOC(),
this.description = imscp.intro; ]);
this.dataRetrieved.emit(imscp);
// Get contents. No need to refresh, it has been done in downloadResourceIfNeeded.
const contents = await CoreCourse.getModuleContents(this.module);
this.items = AddonModImscp.createItemList(contents);
if (this.items.length && this.currentHref === undefined) {
this.currentHref = this.items[0].href;
}
try {
await this.loadItemHref(this.currentHref);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_imscp.deploymenterror', true);
throw new CoreSilentError(error);
}
this.warning = downloadResult.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error!) : '';
} }
/** /**
* Loads an item. * Load IMSCP data.
* *
* @param itemHref Item Href.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async loadItemHref(itemHref?: string): Promise<void> { protected async loadImscp(): Promise<void> {
const src = await AddonModImscp.getIframeSrc(this.module, itemHref); const imscp = await AddonModImscp.getImscp(this.courseId, this.module.id);
this.currentHref = itemHref;
this.navigationItems = this.items.map((item) => ({ this.dataRetrieved.emit(imscp);
item: item,
current: item.href == this.currentHref,
enabled: !!item.href,
}));
if (this.src && src == this.src) { this.dataRetrieved.emit(imscp);
// Re-loading same page. Set it to empty and then re-set the src in the next digest so it detects it has changed.
this.src = ''; this.description = imscp.intro;
setTimeout(() => {
this.src = src; const lastViewed = await AddonModImscp.getLastItemViewed(imscp.id);
}); this.hasStarted = lastViewed !== undefined;
} else {
this.src = src;
}
} }
/** /**
* Loads an item. * Load book TOC.
* *
* @param item Item. * @return Promise resolved when done.
*/ */
loadItem(item: AddonModImscpTocItem): void { protected async loadTOC(): Promise<void> {
this.loadItemHref(item.href); // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded.
const contents = await CoreCourse.getModuleContents(this.module, this.courseId);
this.items = AddonModImscp.createItemList(contents);
} }
/** /**
* Show the TOC. * Open IMSCP book with a certain item.
*
* @param href Item href to open, undefined for last item seen.
*/ */
async showToc(): Promise<void> { openImscp(href?: string): void {
// Create the toc modal. CoreNavigator.navigate('view', {
const modalData = await CoreDomUtils.openSideModal<string>({ params: {
component: AddonModImscpTocComponent, cmId: this.module.id,
componentProps: { courseId: this.courseId,
items: this.items, initialHref: href,
selected: this.currentHref,
}, },
}); });
if (modalData) { this.hasStarted = true;
this.loadItemHref(modalData); }
}
/**
* Get dummy array for padding.
*
* @param n Array length.
* @return Dummy array with n elements.
*/
getNumberForPadding(n: number): number[] {
return new Array(n);
} }
} }

View File

@ -24,6 +24,10 @@ const routes: Routes = [
path: ':courseId/:cmId', path: ':courseId/:cmId',
component: AddonModImscpIndexPage, component: AddonModImscpIndexPage,
}, },
{
path: ':courseId/:cmId/view',
loadChildren: () => import('./pages/view/view.module').then(m => m.AddonModImscpViewPageModule),
},
]; ];
@NgModule({ @NgModule({

View File

@ -0,0 +1,39 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>
<h1>
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">
</core-format-text>
</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button *ngIf="loaded" (click)="showToc()" aria-haspopup="true" [attr.aria-label]="'addon.mod_imscp.toc' | translate">
<ion-icon name="fas-bookmark" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="safe-area-padding core-loading-full-height">
<ion-card class="core-warning-card" *ngIf="warning">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon>
<ion-label><span [innerHTML]="warning"></span></ion-label>
</ion-item>
</ion-card>
<div class="addon-mod-imscp-container">
<core-iframe *ngIf="loaded" [src]="src" [showFullscreenOnToolbar]="true" [autoFullscreenOnRotate]="true"></core-iframe>
</div>
</core-loading>
<core-navigation-bar collapsible-footer *ngIf="loaded && navigationItems.length > 1 && false" [items]="navigationItems"
(action)="loadItem($event)">
</core-navigation-bar>
</ion-content>

View File

@ -0,0 +1,38 @@
// (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 { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonModImscpViewPage } from './view';
const routes: Routes = [
{
path: '',
component: AddonModImscpViewPage,
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CoreSharedModule,
],
declarations: [
AddonModImscpViewPage,
],
exports: [RouterModule],
})
export class AddonModImscpViewPageModule {}

View File

@ -0,0 +1,289 @@
// (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 { CoreConstants } from '@/core/constants';
import { Component, OnInit } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar';
import { CoreCourseResourceDownloadResult } from '@features/course/classes/main-resource-component';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseModuleData } from '@features/course/services/course-helper';
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
import { IonRefresher } from '@ionic/angular';
import { CoreApp } from '@services/app';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { AddonModImscpTocComponent } from '../../components/toc/toc';
import { AddonModImscp, AddonModImscpImscp, AddonModImscpTocItem } from '../../services/imscp';
/**
* Page that displays a IMSCP content.
*/
@Component({
selector: 'page-addon-mod-imscp-view',
templateUrl: 'view.html',
})
export class AddonModImscpViewPage implements OnInit {
title = '';
cmId!: number;
courseId!: number;
initialItemHref?: string;
src = '';
warning = '';
navigationItems: CoreNavigationBarItem<AddonModImscpTocItem>[] = [];
loaded = false;
protected module?: CoreCourseModuleData;
protected imscp?: AddonModImscpImscp;
protected items: AddonModImscpTocItem[] = [];
protected currentHref?: string;
/**
* @inheritdoc
*/
ngOnInit(): void {
try {
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.initialItemHref = CoreNavigator.getRouteParam('initialHref');
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
return;
}
this.fetchContent();
}
/**
* Download IMSCP contents and load the current item.
*
* @param refresh Whether we're refreshing data.
* @return Promise resolved when done.
*/
protected async fetchContent(refresh = false): Promise<void> {
try {
const { module, imscp } = await this.loadImscpData();
this.title = imscp.name;
const downloadResult = await this.downloadResourceIfNeeded(module, refresh);
// Get contents. No need to refresh, it has been done in downloadResourceIfNeeded.
const contents = await CoreCourse.getModuleContents(module, this.courseId);
this.items = AddonModImscp.createItemList(contents);
if (this.items.length) {
if (this.initialItemHref) {
// Check it's valid.
if (this.items.some(item => item.href === this.initialItemHref)) {
this.currentHref = this.initialItemHref;
}
}
if (this.currentHref === undefined) {
// Get last viewed.
const lastViewedHref = await AddonModImscp.getLastItemViewed(imscp.id);
if (lastViewedHref !== undefined) {
this.currentHref = lastViewedHref;
} else {
// Use first one.
this.currentHref = this.items[0].href;
}
}
}
if (this.currentHref === undefined) {
throw new CoreError('Empty TOC');
}
try {
await this.loadItemHref(this.currentHref);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_imscp.deploymenterror', true);
return;
}
if (downloadResult?.failed) {
const error = CoreTextUtils.getErrorMessageFromError(downloadResult.error) || downloadResult.error;
this.warning = Translate.instant('core.errordownloadingsomefiles') + (error ? ' ' + error : '');
} else {
this.warning = '';
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
} finally {
this.loaded = true;
}
}
/**
* Load IMSCP data from WS.
*
* @return Promise resolved when done.
*/
async loadImscpData(): Promise<{ module: CoreCourseModuleData; imscp: AddonModImscpImscp }> {
this.module = await CoreCourse.getModule(this.cmId, this.courseId);
this.imscp = await AddonModImscp.getImscp(this.courseId, this.cmId);
return {
module: this.module,
imscp: this.imscp,
};
}
/**
* Download a resource if needed.
* If the download call fails the promise won't be rejected, but the error will be included in the returned object.
* If module.contents cannot be loaded then the Promise will be rejected.
*
* @param refresh Whether we're refreshing data.
* @return Promise resolved when done.
*/
protected async downloadResourceIfNeeded(
module: CoreCourseModuleData,
refresh = false,
): Promise<CoreCourseResourceDownloadResult> {
const result: CoreCourseResourceDownloadResult = {
failed: false,
};
let contentsAlreadyLoaded = false;
// Get module status to determine if it needs to be downloaded.
const status = await CoreCourseModulePrefetchDelegate.getModuleStatus(module, this.courseId, undefined, refresh);
if (status !== CoreConstants.DOWNLOADED) {
// Download content. This function also loads module contents if needed.
try {
await CoreCourseModulePrefetchDelegate.downloadModule(module, this.courseId);
// If we reach here it means the download process already loaded the contents, no need to do it again.
contentsAlreadyLoaded = true;
} catch (error) {
// Mark download as failed but go on since the main files could have been downloaded.
result.failed = true;
result.error = error;
}
}
if (!module.contents?.length || (refresh && !contentsAlreadyLoaded)) {
// Try to load the contents.
const ignoreCache = refresh && CoreApp.isOnline();
try {
await CoreCourse.loadModuleContents(module, undefined, undefined, false, ignoreCache);
} catch (error) {
// Error loading contents. If we ignored cache, try to get the cached value.
if (ignoreCache && !module.contents) {
await CoreCourse.loadModuleContents(module);
} else if (!module.contents) {
// Not able to load contents, throw the error.
throw error;
}
}
}
return result;
}
/**
* Refresh the data.
*
* @param refresher Refresher.
* @return Promise resolved when done.
*/
async doRefresh(refresher?: IonRefresher): Promise<void> {
await CoreUtils.ignoreErrors(Promise.all([
AddonModImscp.invalidateContent(this.cmId, this.courseId),
CoreCourseModulePrefetchDelegate.invalidateCourseUpdates(this.courseId), // To detect if IMSCP was updated.
]));
await CoreUtils.ignoreErrors(this.fetchContent(true));
refresher?.complete();
}
/**
* Loads an item.
*
* @param itemHref Item Href.
* @return Promise resolved when done.
*/
async loadItemHref(itemHref: string): Promise<void> {
if (!this.module) {
return;
}
const src = await AddonModImscp.getIframeSrc(this.module, itemHref);
this.currentHref = itemHref;
this.navigationItems = this.items.map((item) => ({
item: item,
current: item.href == this.currentHref,
enabled: !!item.href,
}));
if (this.src && src == this.src) {
// Re-loading same page. Set it to empty and then re-set the src in the next digest so it detects it has changed.
this.src = '';
setTimeout(() => {
this.src = src;
});
} else {
this.src = src;
}
if (this.imscp) {
AddonModImscp.storeLastItemViewed(this.imscp.id, itemHref, this.courseId);
}
}
/**
* Loads an item.
*
* @param item Item.
*/
loadItem(item: AddonModImscpTocItem): void {
this.loadItemHref(item.href);
}
/**
* Show the TOC.
*/
async showToc(): Promise<void> {
// Create the toc modal.
const itemHref = await CoreDomUtils.openSideModal<string>({
component: AddonModImscpTocComponent,
componentProps: {
items: this.items,
selected: this.currentHref,
},
});
if (itemHref) {
this.loadItemHref(itemHref);
}
}
}

View File

@ -168,29 +168,21 @@ export class AddonModImscpProvider {
* Get src of a imscp item. * Get src of a imscp item.
* *
* @param module The module object. * @param module The module object.
* @param itemHref Href of item to get. If not defined, gets src of main item. * @param itemHref Href of item to get.
* @return Promise resolved with the item src. * @return Promise resolved with the item src.
*/ */
async getIframeSrc(module: CoreCourseModuleData, itemHref?: string): Promise<string> { async getIframeSrc(module: CoreCourseModuleData, itemHref: string): Promise<string> {
const contents = await CoreCourse.getModuleContents(module);
if (!itemHref) {
const toc = this.getToc(contents);
if (!toc.length) {
throw new CoreError('Empty TOC');
}
itemHref = toc[0].href;
}
const siteId = CoreSites.getCurrentSiteId(); const siteId = CoreSites.getCurrentSiteId();
try { try {
const dirPath = await CoreFilepool.getPackageDirUrlByUrl(siteId, module.url!); const dirPath = await CoreFilepool.getPackageDirUrlByUrl(siteId, module.url || '');
return CoreTextUtils.concatenatePaths(dirPath, itemHref); return CoreTextUtils.concatenatePaths(dirPath, itemHref);
} catch (error) { } catch (error) {
// Error getting directory, there was an error downloading or we're in browser. Return online URL if connected. // Error getting directory, there was an error downloading or we're in browser. Return online URL if connected.
if (CoreApp.isOnline()) { if (CoreApp.isOnline()) {
const contents = await CoreCourse.getModuleContents(module);
const indexUrl = this.getFileUrlFromContents(contents, itemHref); const indexUrl = this.getFileUrlFromContents(contents, itemHref);
if (indexUrl) { if (indexUrl) {
@ -204,6 +196,20 @@ export class AddonModImscpProvider {
} }
} }
/**
* Get last item viewed's href in the app for a IMSCP.
*
* @param id IMSCP instance ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with last item viewed's href, undefined if none.
*/
async getLastItemViewed(id: number, siteId?: string): Promise<string | undefined> {
const site = await CoreSites.getSite(siteId);
const entry = await site.getLastViewed(AddonModImscpProvider.COMPONENT, id);
return entry?.value;
}
/** /**
* Invalidate the prefetched content. * Invalidate the prefetched content.
* *
@ -285,6 +291,21 @@ export class AddonModImscpProvider {
); );
} }
/**
* Store last item viewed in the app for a IMSCP.
*
* @param id IMSCP instance ID.
* @param href Item href.
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with last item viewed, undefined if none.
*/
async storeLastItemViewed(id: number, href: string, courseId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.storeLastViewed(AddonModImscpProvider.COMPONENT, id, href, String(courseId));
}
} }
export const AddonModImscp = makeSingleton(AddonModImscpProvider); export const AddonModImscp = makeSingleton(AddonModImscpProvider);

View File

@ -82,6 +82,7 @@ export class CoreSite {
// Variables for the database. // Variables for the database.
static readonly WS_CACHE_TABLE = 'wscache_2'; static readonly WS_CACHE_TABLE = 'wscache_2';
static readonly CONFIG_TABLE = 'core_site_config'; static readonly CONFIG_TABLE = 'core_site_config';
static readonly LAST_VIEWED_TABLE = 'core_site_last_viewed';
static readonly MINIMUM_MOODLE_VERSION = '3.5'; static readonly MINIMUM_MOODLE_VERSION = '3.5';
@ -110,6 +111,7 @@ export class CoreSite {
protected db?: SQLiteDB; protected db?: SQLiteDB;
protected cacheTable: AsyncInstance<CoreDatabaseTable<CoreSiteWSCacheRecord>>; protected cacheTable: AsyncInstance<CoreDatabaseTable<CoreSiteWSCacheRecord>>;
protected configTable: AsyncInstance<CoreDatabaseTable<CoreSiteConfigDBRecord, 'name'>>; protected configTable: AsyncInstance<CoreDatabaseTable<CoreSiteConfigDBRecord, 'name'>>;
protected lastViewedTable: AsyncInstance<CoreDatabaseTable<CoreSiteLastViewedDBRecord, 'component' | 'id'>>;
protected cleanUnicode = false; protected cleanUnicode = false;
protected lastAutoLogin = 0; protected lastAutoLogin = 0;
protected offlineDisabled = false; protected offlineDisabled = false;
@ -154,6 +156,12 @@ export class CoreSite {
config: { cachingStrategy: CoreDatabaseCachingStrategy.Eager }, config: { cachingStrategy: CoreDatabaseCachingStrategy.Eager },
primaryKeyColumns: ['name'], primaryKeyColumns: ['name'],
})); }));
this.lastViewedTable = asyncInstance(() => CoreSites.getSiteTable(CoreSite.LAST_VIEWED_TABLE, {
siteId: this.getId(),
database: this.getDb(),
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
primaryKeyColumns: ['component', 'id'],
}));
this.setInfo(infos); this.setInfo(infos);
this.calculateOfflineDisabled(); this.calculateOfflineDisabled();
@ -1955,6 +1963,49 @@ export class CoreSite {
return this.containsUrl(url); return this.containsUrl(url);
} }
/**
* Deletes last viewed records based on some conditions.
*
* @param conditions Conditions.
* @return Promise resolved when done.
*/
async deleteLastViewed(conditions?: Partial<CoreSiteLastViewedDBRecord>): Promise<void> {
await this.lastViewedTable.delete(conditions);
}
/**
* Get a last viewed record for a component+id.
*
* @param component The component.
* @param id ID.
* @return Resolves with last viewed record, undefined if not found.
*/
async getLastViewed(component: string, id: number): Promise<CoreSiteLastViewedDBRecord | undefined> {
try {
return await this.lastViewedTable.getOneByPrimaryKey({ component, id });
} catch (error) {
// Not found.
}
}
/**
* Store a last viewed record.
*
* @param component The component.
* @param id ID.
* @param value Last viewed item value.
* @param data Other data.
* @return Promise resolved when done.
*/
async storeLastViewed(component: string, id: number, value: string | number, data?: string): Promise<void> {
await this.lastViewedTable.insert({
component,
id,
value: String(value),
data,
});
}
} }
/** /**
@ -2281,3 +2332,10 @@ export type CoreSiteWSCacheRecord = {
component?: string; component?: string;
componentId?: number; componentId?: number;
}; };
export type CoreSiteLastViewedDBRecord = {
component: string;
id: number;
value: string;
data?: string;
};

View File

@ -78,7 +78,7 @@ export const APP_SCHEMA: CoreAppSchema = {
// Schema to register for Site DB. // Schema to register for Site DB.
export const SITE_SCHEMA: CoreSiteSchema = { export const SITE_SCHEMA: CoreSiteSchema = {
name: 'CoreSitesProvider', name: 'CoreSitesProvider',
version: 2, version: 3,
canBeCleared: [CoreSite.WS_CACHE_TABLE], canBeCleared: [CoreSite.WS_CACHE_TABLE],
tables: [ tables: [
{ {
@ -125,6 +125,29 @@ export const SITE_SCHEMA: CoreSiteSchema = {
}, },
], ],
}, },
{
name: CoreSite.LAST_VIEWED_TABLE,
columns: [
{
name: 'component',
type: 'TEXT',
},
{
name: 'id',
type: 'INTEGER',
},
{
name: 'value',
type: 'TEXT',
notNull: true,
},
{
name: 'data',
type: 'TEXT',
},
],
primaryKeys: ['component', 'id'],
},
], ],
async migrate(db: SQLiteDB, oldVersion: number): Promise<void> { async migrate(db: SQLiteDB, oldVersion: number): Promise<void> {
if (oldVersion < 2) { if (oldVersion < 2) {