diff --git a/.travis.yml b/.travis.yml index 499152424..f82e73c91 100644 --- a/.travis.yml +++ b/.travis.yml @@ -46,7 +46,7 @@ jobs: - tools - platform-tools - build-tools-29.0.3 - - android-28 + - android-29 - extra-google-google_play_services - extra-google-m2repository - extra-android-m2repository diff --git a/src/addons/mod/data/services/database/data.ts b/src/addons/mod/data/services/database/data.ts index 203274e78..8b4c8a150 100644 --- a/src/addons/mod/data/services/database/data.ts +++ b/src/addons/mod/data/services/database/data.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { SQLiteDB } from '@classes/sqlitedb'; import { CoreSiteSchema } from '@services/sites'; import { AddonModDataAction } from '../data'; @@ -59,14 +58,6 @@ export const ADDON_MOD_DATA_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { primaryKeys: ['dataid', 'entryid', 'action'], }, ], - async migrate(db: SQLiteDB, oldVersion: number): Promise { - if (oldVersion > 0) { - return; - } - - // Move the records from the old table. - await db.migrateTable('addon_mod_data_entry', DATA_ENTRY_TABLE); - }, }; /** diff --git a/src/addons/mod/forum/pages/discussion/discussion.page.ts b/src/addons/mod/forum/pages/discussion/discussion.page.ts index 1f04eef18..edf8d9fdd 100644 --- a/src/addons/mod/forum/pages/discussion/discussion.page.ts +++ b/src/addons/mod/forum/pages/discussion/discussion.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ContextLevel } from '@/core/constants'; +import { ContextLevel, CoreConstants } from '@/core/constants'; import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; @@ -88,8 +88,8 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes isprivatereply: false, }; - refreshIcon = 'spinner'; - syncIcon = 'spinner'; + refreshIcon = CoreConstants.ICON_LOADING; + syncIcon = CoreConstants.ICON_LOADING; discussionStr = ''; component = AddonModForumProvider.COMPONENT; cmId!: number; @@ -509,8 +509,8 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes CoreDomUtils.showErrorModal(error); } finally { this.discussionLoaded = true; - this.refreshIcon = 'refresh'; - this.syncIcon = 'sync'; + this.refreshIcon = CoreConstants.ICON_REFRESH; + this.syncIcon = CoreConstants.ICON_SYNC; if (forceMarkAsRead || (hasUnreadPosts && this.trackPosts)) { // // Add log in Moodle and mark unread posts as readed. @@ -630,8 +630,8 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes */ refreshPosts(sync?: boolean, showErrors?: boolean): Promise { this.content.scrollToTop(); - this.refreshIcon = 'spinner'; - this.syncIcon = 'spinner'; + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; const promises = [ AddonModForum.invalidateForumData(this.courseId), diff --git a/src/addons/mod/forum/services/forum.ts b/src/addons/mod/forum/services/forum.ts index 66dbf417c..e860ef5de 100644 --- a/src/addons/mod/forum/services/forum.ts +++ b/src/addons/mod/forum/services/forum.ts @@ -18,6 +18,7 @@ import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; import { CoreFileEntry } from '@features/fileuploader/services/fileuploader'; import { CoreRatingInfo } from '@features/rating/services/rating'; +import { CoreTagItem } from '@features/tag/services/tag'; import { CoreUser } from '@features/user/services/user'; import { CoreApp } from '@services/app'; import { CoreFilepool } from '@services/filepool'; @@ -1513,18 +1514,7 @@ export type AddonModForumLegacyPost = { userpictureurl?: string; // Post author picture. deleted: boolean; // This post has been removed. isprivatereply: boolean; // The post is a private reply. - tags?: { // Tags. - id: number; // Tag id. - name: string; // Tag name. - rawname: string; // The raw, unnormalised name for the tag as entered by users. - isstandard: boolean; // Whether this tag is standard. - tagcollid: number; // Tag collection id. - taginstanceid: number; // Tag instance id. - taginstancecontextid: number; // Context the tag instance belongs to. - itemid: number; // Id of the record tagged. - ordering: number; // Tag ordering. - flag: number; // Whether the tag is flagged as inappropriate. - }[]; + tags?: CoreTagItem[]; // Tags. }; /** diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index a3c6ba745..5aa0ad1bb 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -31,6 +31,7 @@ import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; import { AddonModSurveyModule } from './survey/survey.module'; import { AddonModScormModule } from './scorm/scorm.module'; import { AddonModChoiceModule } from './choice/choice.module'; +import { AddonModWikiModule } from './wiki/wiki.module'; @NgModule({ imports: [ @@ -51,6 +52,7 @@ import { AddonModChoiceModule } from './choice/choice.module'; AddonModSurveyModule, AddonModScormModule, AddonModChoiceModule, + AddonModWikiModule, ], }) export class AddonModModule { } diff --git a/src/addons/mod/wiki/components/components.module.ts b/src/addons/mod/wiki/components/components.module.ts new file mode 100644 index 000000000..92b38259d --- /dev/null +++ b/src/addons/mod/wiki/components/components.module.ts @@ -0,0 +1,40 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { CoreTagComponentsModule } from '@features/tag/components/components.module'; +import { AddonModWikiIndexComponent } from './index/index'; +import { AddonModWikiMapModalComponent } from './map/map'; +import { AddonModWikiSubwikiPickerComponent } from './subwiki-picker/subwiki-picker'; + +@NgModule({ + declarations: [ + AddonModWikiIndexComponent, + AddonModWikiSubwikiPickerComponent, + AddonModWikiMapModalComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + CoreTagComponentsModule, + ], + exports: [ + AddonModWikiIndexComponent, + AddonModWikiSubwikiPickerComponent, + AddonModWikiMapModalComponent, + ], +}) +export class AddonModWikiComponentsModule {} diff --git a/src/addons/mod/wiki/components/index/addon-mod-wiki-index.html b/src/addons/mod/wiki/components/index/addon-mod-wiki-index.html new file mode 100644 index 000000000..4885fe49c --- /dev/null +++ b/src/addons/mod/wiki/components/index/addon-mod-wiki-index.html @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + {{ 'core.hasdatatosync' | translate:{$a: pageStr} }} + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + + + + + + + + {{ pageWarning }} + + +
+
+
+ + + + +
+ +
+ {{ 'core.tag.tags' | translate }}: + +
+
+
+ + + + + + +
diff --git a/src/addons/mod/wiki/components/index/index.scss b/src/addons/mod/wiki/components/index/index.scss new file mode 100644 index 000000000..7b98cde4c --- /dev/null +++ b/src/addons/mod/wiki/components/index/index.scss @@ -0,0 +1,63 @@ +@import "~theme/globals"; + +$addon-mod-wiki-toc-level-padding: 12px !default; + +:host { + --addon-mod-wiki-newentry-link-color: var(--red); + --addon-mod-wiki-toc-border-color: var(--gray-dark); + --addon-mod-wiki-toc-background-color: var(--gray-light); + + background-color: var(--ion-item-background); + + .addon-mod_wiki-page-content { + background-color: var(--ion-item-background); + border-top: 1px solid var(--gray); + padding-bottom: 10px; + } + + .addon-mod_wiki-page-content core-format-text ::ng-deep { + .wiki-toc { + margin: 16px; + padding: 8px; + border: 1px solid var(--addon-mod-wiki-toc-border-color); + background: var(--addon-mod-wiki-toc-background-color); + p { + color: var(--ion-text-color); + } + } + + .wiki-toc-title { + font-size: 1.1em; + font-variant: small-caps; + text-align: center; + } + + .wiki-toc-section { + padding: 0; + margin: 2px 8px; + } + + .wiki-toc-section-2 { + @include padding-horizontal($addon-mod-wiki-toc-level-padding, null); + } + + .wiki-toc-section-3 { + @include padding-horizontal($addon-mod-wiki-toc-level-padding * 2, null); + } + + .wiki_newentry { + color: var(--addon-mod-wiki-newentry-link-color); + font-style: italic; + } + + /* Hide edit section links */ + .addon-mod_wiki-noedit a.wiki_edit_section { + display: none; + } + } +} + +:host-context(body.dark) { + --addon-mod-wiki-newentry-link-color: var(--red-light); + --addon-mod-wiki-toc-background-color: var(--gray-darker); +} diff --git a/src/addons/mod/wiki/components/index/index.ts b/src/addons/mod/wiki/components/index/index.ts new file mode 100644 index 000000000..b32c69121 --- /dev/null +++ b/src/addons/mod/wiki/components/index/index.ts @@ -0,0 +1,1055 @@ +// (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 { Component, Optional, Input, OnInit, OnDestroy } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreTag, CoreTagItem } from '@features/tag/services/tag'; +import { CoreUser } from '@features/user/services/user'; +import { IonContent } from '@ionic/angular'; +import { CoreGroup, CoreGroups } from '@services/groups'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, PopoverController, Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { Md5 } from 'ts-md5'; +import { AddonModWikiPageDBRecord } from '../../services/database/wiki'; +import { + AddonModWiki, + AddonModWikiPageContents, + AddonModWikiProvider, + AddonModWikiSubwiki, + AddonModWikiSubwikiListData, + AddonModWikiSubwikiListGrouping, + AddonModWikiSubwikiListSubwiki, + AddonModWikiSubwikiPage, + AddonModWikiWiki, +} from '../../services/wiki'; +import { AddonModWikiOffline } from '../../services/wiki-offline'; +import { + AddonModWikiAutoSyncData, + AddonModWikiSync, + AddonModWikiSyncProvider, + AddonModWikiSyncWikiResult, + AddonModWikiSyncWikiSubwiki, +} from '../../services/wiki-sync'; +import { AddonModWikiMapModalComponent } from '../map/map'; +import { AddonModWikiSubwikiPickerComponent } from '../subwiki-picker/subwiki-picker'; + +/** + * Component that displays a wiki entry page. + */ +@Component({ + selector: 'addon-mod-wiki-index', + templateUrl: 'addon-mod-wiki-index.html', + styleUrls: ['index.scss'], +}) +export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + @Input() action?: string; + @Input() pageId?: number; + @Input() pageTitle?: string; + @Input() subwikiId?: number; + @Input() userId?: number; + @Input() groupId?: number; + + component = AddonModWikiProvider.COMPONENT; + componentId?: number; + moduleName = 'wiki'; + groupWiki = false; + + wiki?: AddonModWikiWiki; // The wiki instance. + isMainPage = false; // Whether the user is viewing wiki's main page (just entered the wiki). + canEdit = false; // Whether user can edit the page. + pageStr = ''; + pageWarning?: string; // Message telling that the page was discarded. + loadedSubwikis: AddonModWikiSubwiki[] = []; // The loaded subwikis. + pageIsOffline = false; // Whether the loaded page is an offline page. + pageContent?: string; // Page content to display. + tagsEnabled = false; + currentPageObj?: AddonModWikiPageContents | AddonModWikiPageDBRecord; // Object of the current loaded page. + tags: CoreTagItem[] = []; + subwikiData: AddonModWikiSubwikiListData = { // Data for the subwiki selector. + subwikiSelected: 0, + userSelected: 0, + groupSelected: 0, + subwikis: [], + count: 0, + }; + + protected syncEventName = AddonModWikiSyncProvider.AUTO_SYNCED; + protected currentSubwiki?: AddonModWikiSubwiki; // Current selected subwiki. + protected currentPage?: number; // Current loaded page ID. + protected subwikiPages?: (AddonModWikiSubwikiPage | AddonModWikiPageDBRecord)[]; // List of subwiki pages. + protected newPageObserver?: CoreEventObserver; // Observer to check for new pages. + protected manualSyncObserver?: CoreEventObserver; // An observer to watch for manual sync events. + protected ignoreManualSyncEvent = false; // Whether manual sync event should be ignored. + protected currentUserId?: number; // Current user ID. + protected currentPath!: string; + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModLessonIndexComponent', content, courseContentsPage); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.pageStr = Translate.instant('addon.mod_wiki.wikipage'); + this.tagsEnabled = CoreTag.areTagsAvailableInSite(); + this.currentUserId = CoreSites.getCurrentSiteUserId(); + this.isMainPage = !this.pageId && !this.pageTitle; + this.currentPage = this.pageId; + this.currentPath = CoreNavigator.getCurrentPath(); + this.listenEvents(); + + try { + await this.loadContent(false, true); + } finally { + if (this.action == 'map') { + this.openMap(); + } + } + + if (!this.wiki) { + return; + } + + if (!this.pageId) { + try { + await AddonModWiki.logView(this.wiki.id, this.wiki.name); + + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } catch (error) { + // Ignore errors. + } + } else { + CoreUtils.ignoreErrors(AddonModWiki.logPageView(this.pageId, this.wiki.id, this.wiki.name)); + } + } + + /** + * Listen to events. + */ + protected listenEvents(): void { + // Listen for manual sync events. + this.manualSyncObserver = CoreEvents.on(AddonModWikiSyncProvider.MANUAL_SYNCED, (data) => { + if (!data || !this.wiki || data.wikiId != this.wiki.id) { + return; + } + + if (this.ignoreManualSyncEvent) { + // Event needs to be ignored. + this.ignoreManualSyncEvent = false; + + return; + } + + if (this.currentSubwiki) { + this.checkPageCreatedOrDiscarded(data.subwikis[this.currentSubwiki.id]); + } + + if (!this.pageWarning) { + this.showLoadingAndFetch(false, false); + } + }, this.siteId); + } + + /** + * Check if the current page was created or discarded. + * + * @param data Data about created and deleted pages. + */ + protected checkPageCreatedOrDiscarded(data?: AddonModWikiSyncWikiSubwiki): void { + if (this.currentPage || !data) { + return; + } + + // This is an offline page. Check if the page was created. + const page = data.created.find((page) => page.title == this.pageTitle); + if (page) { + // Page was created, set the ID so it's retrieved from server. + this.currentPage = page.pageId; + this.pageIsOffline = false; + } else { + // Page not found in created list, check if it was discarded. + const page = data.discarded.find((page) => page.title == this.pageTitle); + if (page) { + // Page discarded, show warning. + this.pageWarning = page.warning; + this.pageContent = ''; + this.pageIsOffline = false; + this.hasOffline = false; + } + } + } + + /** + * @inheritdoc + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + try { + // Get the wiki instance. + this.wiki = await AddonModWiki.getWiki(this.courseId, this.module.id); + + if (this.pageContent === undefined) { + // Page not loaded yet, emit the data to update the page title. + this.dataRetrieved.emit(this.wiki); + } + AddonModWiki.wikiPageOpened(this.wiki.id, this.currentPath); + + if (sync) { + // Try to synchronize the wiki. + await CoreUtils.ignoreErrors(this.syncActivity(showErrors)); + } + + if (this.pageWarning) { + // Page discarded, stop getting data. + return; + } + + // Get module instance if it's empty. + if (!this.module.id) { + this.module = await CoreCourse.getModule(this.wiki.coursemodule, this.wiki.course, undefined, true); + } + + this.description = this.wiki.intro || this.module.description; + this.externalUrl = this.module.url; + this.componentId = this.module.id; + + await this.fetchSubwikis(this.wiki.id); + + // Get the subwiki list data from the cache. + const subwikiList = AddonModWiki.getSubwikiList(this.wiki.id); + + if (!subwikiList) { + // Not found in cache, create a new one. + // Get real groupmode, in case it's forced by the course. + const groupInfo = await CoreGroups.getActivityGroupInfo(this.wiki.coursemodule); + + await this.createSubwikiList(groupInfo.groups); + } else { + this.subwikiData.count = subwikiList.count; + this.setSelectedWiki(this.subwikiId, this.userId, this.groupId); + + // If nothing was selected using nav params, use the selected from cache. + if (!this.isAnySubwikiSelected()) { + this.setSelectedWiki(subwikiList.subwikiSelected, subwikiList.userSelected, subwikiList.groupSelected); + } + + this.subwikiData.subwikis = subwikiList.subwikis; + } + + if (!this.isAnySubwikiSelected() || this.subwikiData.count <= 0) { + throw new CoreError(Translate.instant('addon.mod_wiki.errornowikiavailable')); + } + + await this.fetchWikiPage(); + } catch (error) { + if (this.pageWarning) { + // Warning is already shown in screen, no need to show a modal. + return; + } + + throw error; + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Get wiki page contents. + * + * @param pageId Page to get. + * @return Promise resolved with the page data. + */ + protected async fetchPageContents(pageId: number): Promise; + protected async fetchPageContents(): Promise; + protected async fetchPageContents(pageId?: number): Promise; + protected async fetchPageContents(pageId?: number): Promise { + if (pageId) { + // Online page. + this.pageIsOffline = false; + + return AddonModWiki.getPageContents(pageId, { cmId: this.module.id }); + } + + // No page ID but we received a title. This means we're trying to load an offline page. + try { + const title = this.pageTitle || this.wiki!.firstpagetitle!; + + const offlinePage = await AddonModWikiOffline.getNewPage( + title, + this.currentSubwiki!.id, + this.currentSubwiki!.wikiid, + this.currentSubwiki!.userid, + this.currentSubwiki!.groupid, + ); + + this.pageIsOffline = true; + if (!this.newPageObserver) { + // It's an offline page, listen for new pages event to detect if the user goes to Edit and submits the page. + this.newPageObserver = CoreEvents.on(AddonModWikiProvider.PAGE_CREATED_EVENT, async (data) => { + if (data.subwikiId != this.currentSubwiki?.id || data.pageTitle != title) { + return; + } + + // The page has been submitted. Get the page from the server. + this.currentPage = data.pageId; + + // Stop listening for new page events. + this.newPageObserver!.off(); + this.newPageObserver = undefined; + + await this.showLoadingAndFetch(true, false); + + if (this.currentPage) { + CoreUtils.ignoreErrors(AddonModWiki.logPageView(this.currentPage, this.wiki!.id, this.wiki!.name)); + } + }, CoreSites.getCurrentSiteId()); + } + + return offlinePage; + } catch { + // Page not found, ignore. + } + } + + /** + * Fetch the list of pages of a subwiki. + * + * @param subwiki Subwiki. + */ + protected async fetchSubwikiPages(subwiki: AddonModWikiSubwiki): Promise { + const subwikiPages = await AddonModWiki.getSubwikiPages(subwiki.wikiid, { + groupId: subwiki.groupid, + userId: subwiki.userid, + cmId: this.module.id, + }); + + // If no page specified, search first page. + if (!this.currentPage && !this.pageTitle) { + const firstPage = subwikiPages.find((page) => page.firstpage ); + if (firstPage) { + this.currentPage = firstPage.id; + this.pageTitle = firstPage.title; + } + } + + // Now get the offline pages. + const dbPages = await AddonModWikiOffline.getSubwikiNewPages(subwiki.id, subwiki.wikiid, subwiki.userid, subwiki.groupid); + + // If no page specified, search page title in the offline pages. + if (!this.currentPage) { + const searchTitle = this.pageTitle ? this.pageTitle : this.wiki!.firstpagetitle; + const pageExists = dbPages.some((page) => page.title == searchTitle); + + if (pageExists) { + this.pageTitle = searchTitle; + } + } + + this.subwikiPages = AddonModWiki.sortPagesByTitle( + (<(AddonModWikiSubwikiPage | AddonModWikiPageDBRecord)[]> subwikiPages).concat(dbPages), + ); + + // Reject if no currentPage selected from the subwikis given (if no subwikis available, do not reject). + if (!this.currentPage && !this.pageTitle && this.subwikiPages.length > 0) { + throw new CoreError(); + } + } + + /** + * Get the subwikis. + * + * @param wikiId Wiki ID. + */ + protected async fetchSubwikis(wikiId: number): Promise { + this.loadedSubwikis = await AddonModWiki.getSubwikis(wikiId, { cmId: this.module.id }); + + this.hasOffline = await AddonModWikiOffline.subwikisHaveOfflineData(this.loadedSubwikis); + } + + /** + * Fetch the page to be shown. + * + * @return Promise resolved when done. + */ + protected async fetchWikiPage(): Promise { + // Search the current Subwiki. + this.currentSubwiki = this.loadedSubwikis.find((subwiki) => this.isSubwikiSelected(subwiki)); + + if (!this.currentSubwiki) { + throw new CoreError(); + } + + this.setSelectedWiki(this.currentSubwiki.id, this.currentSubwiki.userid, this.currentSubwiki.groupid); + + await this.fetchSubwikiPages(this.currentSubwiki); + + // Check can edit before to have the value if there's no valid page. + this.canEdit = this.currentSubwiki.canedit; + + const pageContents = await this.fetchPageContents(this.currentPage); + + if (pageContents) { + this.dataRetrieved.emit(pageContents.title); + this.setSelectedWiki(pageContents.subwikiid, pageContents.userid, pageContents.groupid); + + this.pageTitle = pageContents.title; + this.pageContent = this.replaceEditLinks(pageContents.cachedcontent); + this.canEdit = !!pageContents.caneditpage; + this.currentPageObj = pageContents; + this.tags = ('tags' in pageContents && pageContents.tags) || []; + } + } + + /** + * Get path to the wiki home view. If cannot determine or it's current view, return undefined. + * + * @return The path of the home view + */ + protected getWikiHomeView(): string | undefined { + if (!this.wiki) { + return; + } + + return AddonModWiki.getFirstWikiPageOpened(this.wiki.id, this.currentPath); + } + + /** + * Open the view to create the first page of the wiki. + */ + protected goToCreateFirstPage(): void { + CoreNavigator.navigate('../../edit', { + params: { + pageTitle: this.wiki!.firstpagetitle, + wikiId: this.currentSubwiki?.wikiid, + userId: this.currentSubwiki?.userid, + groupId: this.currentSubwiki?.groupid, + }, + }); + } + + /** + * Open the view to edit the current page. + */ + goToEditPage(): void { + if (!this.canEdit) { + return; + } + + if (this.currentPageObj) { + // Current page exists, go to edit it. + const pageParams: Params = { + pageTitle: this.currentPageObj.title, + subwikiId: this.currentPageObj.subwikiid, + }; + + if ('id' in this.currentPageObj) { + pageParams.pageId = this.currentPageObj.id; + } + + if (this.currentSubwiki) { + pageParams.wikiId = this.currentSubwiki.wikiid; + pageParams.userId = this.currentSubwiki.userid; + pageParams.groupId = this.currentSubwiki.groupid; + } + + CoreNavigator.navigate('../../edit', { params: pageParams }); + } else if (this.currentSubwiki) { + // No page loaded, the wiki doesn't have first page. + this.goToCreateFirstPage(); + } + } + + /** + * Go to the view to create a new page. + */ + goToNewPage(): void { + if (!this.canEdit) { + return; + } + + if (this.currentPageObj) { + // Current page exists, go to edit it. + const pageParams: Params = { + subwikiId: this.currentPageObj.subwikiid, + }; + + if (this.currentSubwiki) { + pageParams.wikiId = this.currentSubwiki.wikiid; + pageParams.userId = this.currentSubwiki.userid; + pageParams.groupId = this.currentSubwiki.groupid; + } + + CoreNavigator.navigate('../../edit', { params: pageParams }); + } else if (this.currentSubwiki) { + // No page loaded, the wiki doesn't have first page. + this.goToCreateFirstPage(); + } + } + + /** + * Go to view a certain page. + * + * @param page Page to view. + */ + protected async goToPage(page: AddonModWikiSubwikiPage | AddonModWikiPageDBRecord): Promise { + if (!('id' in page)) { + // It's an offline page. Check if we are already in the same offline page. + if (this.currentPage || !this.pageTitle || page.title != this.pageTitle) { + this.openPageOrSubwiki({ + pageTitle: page.title, + subwikiId: page.subwikiid, + }); + } + } else if (this.currentPage != page.id) { + // Add a new State. + const pageContents = await this.fetchPageContents(page.id); + + this.openPageOrSubwiki({ + pageTitle: pageContents.title, + pageId: pageContents.id, + subwikiId: page.subwikiid, + }); + } + } + + /** + * Open a page or a subwiki in the current wiki. + * + * @param options Options + * @return Promise. + */ + protected async openPageOrSubwiki(options: AddonModWikiOpenPageOptions): Promise { + const hash = Md5.hashAsciiStr(JSON.stringify({ + ...options, + timestamp: Date.now(), + })); + + await CoreNavigator.navigate(`../${hash}`, { + params: { + module: this.module, + ...options, + }, + }); + } + + /** + * Show the map. + */ + async openMap(): Promise { + // Create the toc modal. + const modal = await ModalController.create({ + component: AddonModWikiMapModalComponent, + componentProps: { + pages: this.subwikiPages, + homeView: this.getWikiHomeView(), + moduleId: this.module.id, + courseId: this.courseId, + selectedTitle: this.currentPageObj && this.currentPageObj.title, + }, + cssClass: 'core-modal-lateral', + showBackdrop: true, + backdropDismiss: true, + // @todo enterAnimation: 'core-modal-lateral-transition', + // @todo leaveAnimation: 'core-modal-lateral-transition', + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + + if (result.data) { + if (result.data.type == 'home') { + // Go back to the initial page of the wiki. + CoreNavigator.navigateToSitePath(result.data.goto); + } else { + this.goToPage(result.data.goto); + } + } + + } + + /** + * Go to the page to view a certain subwiki. + * + * @param subwikiId Subwiki ID. + * @param userId User ID of the subwiki. + * @param groupId Group ID of the subwiki. + * @param canEdit Whether the subwiki can be edited. + */ + goToSubwiki(subwikiId: number, userId: number, groupId: number, canEdit: boolean): void { + // Check if the subwiki is disabled. + if (subwikiId <= 0 && !canEdit) { + return; + } + + if (subwikiId != this.currentSubwiki!.id || userId != this.currentSubwiki!.userid || + groupId != this.currentSubwiki!.groupid) { + + this.openPageOrSubwiki({ + subwikiId: subwikiId, + userId: userId, + groupId: groupId, + }); + } + } + + /** + * Checks if there is any subwiki selected. + * + * @return Whether there is any subwiki selected. + */ + protected isAnySubwikiSelected(): boolean { + return this.subwikiData.subwikiSelected > 0 || this.subwikiData.userSelected > 0 || this.subwikiData.groupSelected > 0; + } + + /** + * Checks if the given subwiki is the one picked on the subwiki picker. + * + * @param subwiki Subwiki to check. + * @return Whether it's the selected subwiki. + */ + protected isSubwikiSelected(subwiki: AddonModWikiSubwiki): boolean { + if (subwiki.id > 0 && this.subwikiData.subwikiSelected > 0) { + return subwiki.id == this.subwikiData.subwikiSelected; + } + + return subwiki.userid == this.subwikiData.userSelected && subwiki.groupid == this.subwikiData.groupSelected; + } + + /** + * Replace edit links to have full url. + * + * @param content Content to treat. + * @return Treated content. + */ + protected replaceEditLinks(content: string): string { + content = content.trim(); + + if (content.length > 0) { + const editUrl = CoreTextUtils.concatenatePaths(CoreSites.getCurrentSite()!.getURL(), '/mod/wiki/edit.php'); + content = content.replace(/href="edit\.php/g, 'href="' + editUrl); + } + + return content; + } + + /** + * Sets the selected subwiki for the subwiki picker. + * + * @param subwikiId Subwiki ID to select. + * @param userId User ID of the subwiki to select. + * @param groupId Group ID of the subwiki to select. + */ + protected setSelectedWiki(subwikiId: number | undefined, userId: number | undefined, groupId: number | undefined): void { + this.subwikiData.subwikiSelected = AddonModWikiOffline.convertToPositiveNumber(subwikiId); + this.subwikiData.userSelected = AddonModWikiOffline.convertToPositiveNumber(userId); + this.subwikiData.groupSelected = AddonModWikiOffline.convertToPositiveNumber(groupId); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModWikiSyncWikiResult): boolean { + if (result.updated) { + // Trigger event. + this.ignoreManualSyncEvent = true; + CoreEvents.trigger(AddonModWikiSyncProvider.MANUAL_SYNCED, { + ...result, + wikiId: this.wiki!.id, + }); + } + + if (this.currentSubwiki) { + this.checkPageCreatedOrDiscarded(result.subwikis[this.currentSubwiki.id]); + } + + return result.updated; + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + const editedPageData = AddonModWiki.consumeEditedPageData(); + if (!editedPageData) { + return; + } + + // User has just edited a page. Check if it's the current page. + if (this.pageId && editedPageData.pageId === this.pageId) { + this.showLoadingAndRefresh(true, false); + + return; + } + + const sameSubwiki = this.currentSubwiki && + ((this.currentSubwiki.id && this.currentSubwiki.id === editedPageData.subwikiId) || + (this.currentSubwiki.userid === editedPageData.userId && this.currentSubwiki.groupid === editedPageData.groupId)); + + if (sameSubwiki && editedPageData.pageTitle === this.pageTitle) { + this.showLoadingAndRefresh(true, false); + + return; + } + + // Not same page or we cannot tell. Open the page. + this.openPageOrSubwiki({ + pageId: editedPageData.pageId, + pageTitle: editedPageData.pageTitle, + subwikiId: editedPageData.subwikiId, + userId: editedPageData.wikiId, + groupId: editedPageData.groupId, + }); + + if (editedPageData.pageId && (!this.pageContent || this.pageContent.indexOf('/mod/wiki/create.php') != -1)) { + // Refresh current page anyway because the new page could have been created using the create link. + this.showLoadingAndRefresh(true, false); + } + } + + /** + * @inheritdoc + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModWiki.invalidateWikiData(this.courseId)); + + if (this.wiki) { + promises.push(AddonModWiki.invalidateSubwikis(this.wiki.id)); + promises.push(CoreGroups.invalidateActivityAllowedGroups(this.wiki.coursemodule)); + promises.push(CoreGroups.invalidateActivityGroupMode(this.wiki.coursemodule)); + } + + if (this.currentSubwiki) { + promises.push(AddonModWiki.invalidateSubwikiPages(this.currentSubwiki.wikiid)); + promises.push(AddonModWiki.invalidateSubwikiFiles(this.currentSubwiki.wikiid)); + } + + if (this.currentPage) { + promises.push(AddonModWiki.invalidatePage(this.currentPage)); + } + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + protected isRefreshSyncNeeded(syncEventData: AddonModWikiAutoSyncData): boolean { + if (this.currentSubwiki && syncEventData.subwikiId == this.currentSubwiki.id && + syncEventData.wikiId == this.currentSubwiki.wikiid && syncEventData.userId == this.currentSubwiki.userid && + syncEventData.groupId == this.currentSubwiki.groupid) { + + if (this.isCurrentView && syncEventData.warnings && syncEventData.warnings.length) { + // Show warnings. + CoreDomUtils.showErrorModal(syncEventData.warnings[0]); + } + + // Check if current page was created or discarded. + this.checkPageCreatedOrDiscarded(syncEventData); + } + + return !this.pageWarning; + } + + /** + * Show the TOC. + * + * @param event Event. + */ + async showSubwikiPicker(event: MouseEvent): Promise { + const popover = await PopoverController.create({ + component: AddonModWikiSubwikiPickerComponent, + componentProps: { + subwikis: this.subwikiData.subwikis, + currentSubwiki: this.currentSubwiki, + }, + event, + }); + + await popover.present(); + + const result = await popover.onDidDismiss(); + + if (result.data) { + this.goToSubwiki(result.data.id, result.data.userid, result.data.groupid, result.data.canedit); + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModWikiSync.syncWiki(this.wiki!.id, this.courseId, this.wiki!.coursemodule); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.manualSyncObserver?.off(); + this.newPageObserver?.off(); + if (this.wiki) { + AddonModWiki.wikiPageClosed(this.wiki.id, this.currentPath); + } + } + + /** + * Create the subwiki list for the selector and store it in the cache. + * + * @param userGroups Groups. + * @return Promise resolved when done. + */ + protected async createSubwikiList(userGroups?: CoreGroup[]): Promise { + const subwikiList: AddonModWikiSubwikiListSubwiki[] = []; + let allParticipants = false; + let showMyGroupsLabel = false; + let multiLevelList = false; + + this.subwikiData.subwikis = []; + this.setSelectedWiki(this.subwikiId, this.userId, this.groupId); + this.subwikiData.count = 0; + + // Add the subwikis to the subwikiList. + await Promise.all(this.loadedSubwikis.map(async (subwiki) => { + let groupLabel = ''; + + if (subwiki.groupid == 0 && subwiki.userid == 0) { + // Add 'All participants' subwiki if needed at the start. + if (!allParticipants) { + subwikiList.unshift({ + name: Translate.instant('core.allparticipants'), + id: subwiki.id, + userid: subwiki.userid, + groupid: subwiki.groupid, + groupLabel: '', + canedit: subwiki.canedit, + }); + allParticipants = true; + } + } else { + if (subwiki.groupid != 0 && userGroups && userGroups.length > 0) { + // Get groupLabel if it has groupId. + const group = userGroups.find(group => group.id == subwiki.groupid); + groupLabel = group?.name || ''; + } else { + groupLabel = Translate.instant('addon.mod_wiki.notingroup'); + } + + if (subwiki.userid != 0) { + if (!multiLevelList && subwiki.groupid != 0) { + multiLevelList = true; + } + + // Get user if it has userId. + const user = await CoreUser.getProfile(subwiki.userid, this.courseId, true); + + subwikiList.push({ + name: user.fullname, + id: subwiki.id, + userid: subwiki.userid, + groupid: subwiki.groupid, + groupLabel: groupLabel, + canedit: subwiki.canedit, + }); + + } else { + subwikiList.push({ + name: groupLabel, + id: subwiki.id, + userid: subwiki.userid, + groupid: subwiki.groupid, + groupLabel: groupLabel, + canedit: subwiki.canedit, + }); + showMyGroupsLabel = true; + } + } + })); + + this.fillSubwikiData(subwikiList, showMyGroupsLabel, multiLevelList); + } + + /** + * Fill the subwiki data. + * + * @param subwikiList List of subwikis. + * @param showMyGroupsLabel Whether subwikis should be grouped in "My groups" and "Other groups". + * @param multiLevelList Whether it's a multi level list. + */ + protected fillSubwikiData( + subwikiList: AddonModWikiSubwikiListSubwiki[], + showMyGroupsLabel: boolean, + multiLevelList: boolean, + ): void { + subwikiList.sort((a, b) => a.groupid - b.groupid); + + this.groupWiki = showMyGroupsLabel; + this.subwikiData.count = subwikiList.length; + + // If no subwiki is received as view param, select always the most appropiate. + if ((!this.subwikiId || (!this.userId && !this.groupId)) && !this.isAnySubwikiSelected() && subwikiList.length > 0) { + let firstCanEdit: number | undefined; + let candidateNoFirstPage: number | undefined; + let candidateFirstPage: number | undefined; + + for (const i in subwikiList) { + const subwiki = subwikiList[i]; + + if (subwiki.canedit) { + let candidateSubwikiId: number | undefined; + if (subwiki.userid > 0) { + // Check if it's the current user. + if (this.currentUserId == subwiki.userid) { + candidateSubwikiId = subwiki.id; + } + } else if (subwiki.groupid > 0) { + // Check if it's a current user' group. + if (showMyGroupsLabel) { + candidateSubwikiId = subwiki.id; + } + } else if (subwiki.id > 0) { + candidateSubwikiId = subwiki.id; + } + + if (typeof candidateSubwikiId != 'undefined') { + if (candidateSubwikiId > 0) { + // Subwiki found and created, no need to keep looking. + candidateFirstPage = Number(i); + break; + } else if (typeof candidateNoFirstPage == 'undefined') { + candidateNoFirstPage = Number(i); + } + } else if (typeof firstCanEdit == 'undefined') { + firstCanEdit = Number(i); + } + } + } + + let subWikiToTake: number; + if (typeof candidateFirstPage != 'undefined') { + // Take the candidate that already has the first page created. + subWikiToTake = candidateFirstPage; + } else if (typeof candidateNoFirstPage != 'undefined') { + // No first page created, take the first candidate. + subWikiToTake = candidateNoFirstPage; + } else if (typeof firstCanEdit != 'undefined') { + // None selected, take the first the user can edit. + subWikiToTake = firstCanEdit; + } else { + // Otherwise take the very first. + subWikiToTake = 0; + } + + const subwiki = subwikiList[subWikiToTake]; + if (typeof subwiki != 'undefined') { + this.setSelectedWiki(subwiki.id, subwiki.userid, subwiki.groupid); + } + } + + if (multiLevelList) { + // As we loop over each subwiki, add it to the current group + let groupValue = -1; + let grouping: AddonModWikiSubwikiListGrouping; + + subwikiList.forEach((subwiki) => { + // Should we create a new grouping? + if (subwiki.groupid !== groupValue) { + grouping = { label: subwiki.groupLabel, subwikis: [] }; + groupValue = subwiki.groupid; + + this.subwikiData.subwikis.push(grouping); + } + + // Add the subwiki to the currently active grouping. + grouping.subwikis.push(subwiki); + }); + } else if (showMyGroupsLabel) { + const noGrouping: AddonModWikiSubwikiListGrouping = { label: '', subwikis: [] }; + const myGroupsGrouping: AddonModWikiSubwikiListGrouping = { label: Translate.instant('core.mygroups'), subwikis: [] }; + const otherGroupsGrouping: AddonModWikiSubwikiListGrouping = { + label: Translate.instant('core.othergroups'), + subwikis: [], + }; + + // As we loop over each subwiki, add it to the current group + subwikiList.forEach((subwiki) => { + // Add the subwiki to the currently active grouping. + if (typeof subwiki.canedit == 'undefined') { + noGrouping.subwikis.push(subwiki); + } else if (subwiki.canedit) { + myGroupsGrouping.subwikis.push(subwiki); + } else { + otherGroupsGrouping.subwikis.push(subwiki); + } + }); + + // Add each grouping to the subwikis + if (noGrouping.subwikis.length > 0) { + this.subwikiData.subwikis.push(noGrouping); + } + if (myGroupsGrouping.subwikis.length > 0) { + this.subwikiData.subwikis.push(myGroupsGrouping); + } + if (otherGroupsGrouping.subwikis.length > 0) { + this.subwikiData.subwikis.push(otherGroupsGrouping); + } + } else { + this.subwikiData.subwikis.push({ label: '', subwikis: subwikiList }); + } + + AddonModWiki.setSubwikiList( + this.wiki!.id, + this.subwikiData.subwikis, + this.subwikiData.count, + this.subwikiData.subwikiSelected, + this.subwikiData.userSelected, + this.subwikiData.groupSelected, + ); + } + +} + +type AddonModWikiOpenPageOptions = { + subwikiId?: number; + pageTitle?: string; + pageId?: number; + userId?: number; + groupId?: number; +}; diff --git a/src/addons/mod/wiki/components/map/map.html b/src/addons/mod/wiki/components/map/map.html new file mode 100644 index 000000000..56ad87d73 --- /dev/null +++ b/src/addons/mod/wiki/components/map/map.html @@ -0,0 +1,39 @@ + + + {{ 'addon.mod_wiki.map' | translate }} + + + + + + + + + + diff --git a/src/addons/mod/wiki/components/map/map.ts b/src/addons/mod/wiki/components/map/map.ts new file mode 100644 index 000000000..f4127c631 --- /dev/null +++ b/src/addons/mod/wiki/components/map/map.ts @@ -0,0 +1,105 @@ +// (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 { Component, Input, OnInit } from '@angular/core'; +import { ModalController } from '@singletons'; +import { AddonModWikiPageDBRecord } from '../../services/database/wiki'; +import { AddonModWikiSubwikiPage } from '../../services/wiki'; + +/** + * Modal to display the map of a Wiki. + */ +@Component({ + selector: 'page-addon-mod-wiki-map', + templateUrl: 'map.html', +}) +export class AddonModWikiMapModalComponent implements OnInit { + + @Input() pages: (AddonModWikiSubwikiPage | AddonModWikiPageDBRecord)[] = []; + @Input() selectedTitle?: string; + @Input() moduleId?: number; + @Input() courseId?: number; + @Input() homeView?: string; + + map: AddonModWikiPagesMapLetter[] = []; // Map of pages, categorized by letter. + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.constructMap(); + } + + /** + * Function called when a page is clicked. + * + * @param page Clicked page. + */ + goToPage(page: AddonModWikiSubwikiPage | AddonModWikiPageDBRecord): void { + ModalController.dismiss({ type: 'page', goto: page }); + } + + /** + * Go back to the initial page of the wiki. + */ + goToWikiHome(): void { + ModalController.dismiss({ type: 'home', goto: this.homeView }); + } + + /** + * Construct the map of pages. + * + * @param pages List of pages. + */ + protected constructMap(): void { + let letter: AddonModWikiPagesMapLetter; + let initialLetter: string; + + this.map = []; + this.pages.sort((a, b) => { + const compareA = a.title.toLowerCase().trim(); + const compareB = b.title.toLowerCase().trim(); + + return compareA.localeCompare(compareB); + }); + + this.pages.forEach((page) => { + const letterCandidate = page.title.charAt(0).toLocaleUpperCase(); + + // Should we create a new grouping? + if (letterCandidate !== initialLetter) { + initialLetter = letterCandidate; + letter = { label: letterCandidate, pages: [] }; + + this.map.push(letter); + } + + // Add the subwiki to the currently active grouping. + letter.pages.push(page); + }); + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.dismiss(); + } + +} + +type AddonModWikiPagesMapLetter = { + label: string; + pages: (AddonModWikiSubwikiPage | AddonModWikiPageDBRecord)[]; +}; diff --git a/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html b/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html new file mode 100644 index 000000000..41eb324b0 --- /dev/null +++ b/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html @@ -0,0 +1,13 @@ + + + + {{ group.label }} + + + {{ subwiki.name }} + + + + diff --git a/src/addons/mod/wiki/components/subwiki-picker/subwiki-picker.ts b/src/addons/mod/wiki/components/subwiki-picker/subwiki-picker.ts new file mode 100644 index 000000000..beb98788c --- /dev/null +++ b/src/addons/mod/wiki/components/subwiki-picker/subwiki-picker.ts @@ -0,0 +1,61 @@ +// (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 { Component, Input } from '@angular/core'; +import { PopoverController } from '@singletons'; +import { AddonModWikiSubwiki, AddonModWikiSubwikiListGrouping } from '../../services/wiki'; + +/** + * Component to display the a list of subwikis in a wiki. + */ +@Component({ + selector: 'addon-mod-wiki-subwiki-picker', + templateUrl: 'addon-mod-wiki-subwiki-picker.html', +}) +export class AddonModWikiSubwikiPickerComponent { + + @Input() subwikis: AddonModWikiSubwikiListGrouping[] = []; + @Input() currentSubwiki!: AddonModWikiSubwiki; + + /** + * Checks if the given subwiki is the one currently selected. + * + * @param subwiki Subwiki to check. + * @return Whether it's the selected subwiki. + */ + isSubwikiSelected(subwiki: AddonModWikiSubwiki): boolean { + + if (subwiki.id > 0 && this.currentSubwiki.id > 0) { + return subwiki.id == this.currentSubwiki.id; + } + + return subwiki.userid == this.currentSubwiki.userid && subwiki.groupid == this.currentSubwiki.groupid; + } + + /** + * Function called when a subwiki is clicked. + * + * @param subwiki The subwiki to open. + */ + openSubwiki(subwiki: AddonModWikiSubwiki): void { + // Check if the subwiki is disabled. + if (subwiki.id > 0 || subwiki.canedit) { + // Check if it isn't current subwiki. + if (subwiki != this.currentSubwiki) { + PopoverController.dismiss(subwiki); + } + } + } + +} diff --git a/src/addons/mod/wiki/lang.json b/src/addons/mod/wiki/lang.json new file mode 100644 index 000000000..246965b9c --- /dev/null +++ b/src/addons/mod/wiki/lang.json @@ -0,0 +1,22 @@ +{ + "cannoteditpage": "You can not edit this page.", + "createpage": "Create page", + "editingpage": "Editing this page '{{$a}}'", + "errorloadingpage": "An error occurred while loading the page.", + "errornowikiavailable": "This wiki does not have any content yet.", + "gowikihome": "Go to the wiki first page", + "map": "Map", + "modulenameplural": "Wikis", + "newpagehdr": "New page", + "newpagetitle": "New page title", + "nocontent": "There is no content for this page", + "notingroup": "Not in group", + "pageexists": "This page already exists.", + "pagename": "Page name", + "subwiki": "Sub-wiki", + "tagarea_wiki_pages": "Wiki pages", + "titleshouldnotbeempty": "The title should not be empty", + "viewpage": "View page", + "wikipage": "Wiki page", + "wrongversionlock": "Another user has edited this page while you were editing and your content is obsolete." +} \ No newline at end of file diff --git a/src/addons/mod/wiki/pages/edit/edit.html b/src/addons/mod/wiki/pages/edit/edit.html new file mode 100644 index 000000000..eab756228 --- /dev/null +++ b/src/addons/mod/wiki/pages/edit/edit.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + {{ 'core.save' | translate }} + + + + + + +
+ + + + + + + + + + + + + + + {{ 'addon.mod_wiki.wrongversionlock' | translate }} + + +
+
+
diff --git a/src/addons/mod/wiki/pages/edit/edit.ts b/src/addons/mod/wiki/pages/edit/edit.ts new file mode 100644 index 000000000..312c77050 --- /dev/null +++ b/src/addons/mod/wiki/pages/edit/edit.ts @@ -0,0 +1,448 @@ +// (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 { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { FormControl, FormGroup, FormBuilder } from '@angular/forms'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourse } from '@features/course/services/course'; +import { CanLeave } from '@guards/can-leave'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreForms } from '@singletons/form'; +import { AddonModWiki, AddonModWikiProvider } from '../../services/wiki'; +import { AddonModWikiOffline } from '../../services/wiki-offline'; +import { AddonModWikiSync } from '../../services/wiki-sync'; + +/** + * Page that allows adding or editing a wiki page. + */ +@Component({ + selector: 'page-addon-mod-wiki-edit', + templateUrl: 'edit.html', +}) +export class AddonModWikiEditPage implements OnInit, OnDestroy, CanLeave { + + @ViewChild('editPageForm') formElement?: ElementRef; + + cmId!: number; // Course module ID. + courseId!: number; // Course the wiki belongs to. + title?: string; // Title to display. + pageForm?: FormGroup; // The form group. + contentControl?: FormControl; // The FormControl for the page content. + canEditTitle = false; // Whether title can be edited. + loaded = false; // Whether the data has been loaded. + component = AddonModWikiProvider.COMPONENT; // Component to link the files to. + wrongVersionLock = false; // Whether the page lock doesn't match the initial one. + editorExtraParams: Record = {}; + + protected subwikiId?: number; // Subwiki ID the page belongs to. + protected wikiId?: number; // Wiki ID the page belongs to. + protected pageId?: number; // The page ID (if editing a page). + protected section?: string; // The section being edited. + protected groupId?: number; // The group the subwiki belongs to. + protected userId?: number; // The user the subwiki belongs to. + protected blockId?: string; // ID to block the subwiki. + protected editing = false; // Whether the user is editing a page (true) or creating a new one (false). + protected editOffline = false; // Whether the user is editing an offline page. + protected subwikiFiles: CoreWSExternalFile[] = []; // List of files of the subwiki. + protected originalContent?: string; // The original page content. + protected version?: number; // Page version. + protected renewLockInterval?: number; // An interval to renew the lock every certain time. + protected forceLeave = false; // To allow leaving the page without checking for changes. + protected isDestroyed = false; // Whether the page has been destroyed. + + constructor( + protected formBuilder: FormBuilder, + ) { } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.cmId = CoreNavigator.getRouteNumberParam('cmId')!; + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.subwikiId = CoreNavigator.getRouteNumberParam('subwikiId'); + this.wikiId = CoreNavigator.getRouteNumberParam('wikiId'); + this.pageId = CoreNavigator.getRouteNumberParam('pageId'); + this.section = CoreNavigator.getRouteParam('section'); + this.groupId = CoreNavigator.getRouteNumberParam('groupId'); + this.userId = CoreNavigator.getRouteNumberParam('userId'); + + let pageTitle = CoreNavigator.getRouteParam('pageTitle'); + pageTitle = pageTitle ? pageTitle.replace(/\+/g, ' ') : ''; + + this.canEditTitle = !pageTitle; + this.title = pageTitle ? + Translate.instant('addon.mod_wiki.editingpage', { $a: pageTitle }) : + Translate.instant('addon.mod_wiki.newpagehdr'); + this.blockId = AddonModWikiSync.getSubwikiBlockId(this.subwikiId, this.wikiId, this.userId, this.groupId); + + // Create the form group and its controls. + this.contentControl = this.formBuilder.control(''); + this.pageForm = this.formBuilder.group({ + title: pageTitle, + }); + this.pageForm.addControl('text', this.contentControl); + + // Block the wiki so it cannot be synced. + CoreSync.blockOperation(this.component, this.blockId); + + if (this.pageId) { + this.editorExtraParams.pageid = this.pageId; + + if (this.section) { + this.editorExtraParams.section = this.section; + } + } else if (pageTitle) { + this.editorExtraParams.pagetitle = pageTitle; + } + + try { + const success = await this.fetchWikiPageData(); + + if (success && !this.isDestroyed) { + // Block the subwiki now that we have blockId for sure. + const newBlockId = AddonModWikiSync.getSubwikiBlockId(this.subwikiId, this.wikiId, this.userId, this.groupId); + if (newBlockId != this.blockId) { + CoreSync.unblockOperation(this.component, this.blockId); + this.blockId = newBlockId; + CoreSync.blockOperation(this.component, this.blockId); + } + } + } finally { + this.loaded = true; + } + } + + /** + * Convenience function to get wiki page data. + * + * @return Promise resolved with boolean: whether it was successful. + */ + protected async fetchWikiPageData(): Promise { + let canEdit = false; + let fetchFailed = false; + + try { + // Wait for sync to be over (if any). + const syncResult = await AddonModWikiSync.waitForSync(this.blockId!); + + if (this.pageId) { + // Editing a page that already exists. + this.canEditTitle = false; + this.editing = true; + this.editOffline = false; // Cannot edit pages in offline. + + // Get page contents to obtain title and editing permission + const pageContents = await AddonModWiki.getPageContents(this.pageId, { cmId: this.cmId }); + + this.pageForm!.controls.title.setValue(pageContents.title); // Set the title in the form group. + this.wikiId = pageContents.wikiid; + this.subwikiId = pageContents.subwikiid; + this.title = Translate.instant('addon.mod_wiki.editingpage', { $a: pageContents.title }); + this.groupId = pageContents.groupid; + this.userId = pageContents.userid; + canEdit = pageContents.caneditpage; + + // Get subwiki files, needed to replace URLs for rich text editor. + this.subwikiFiles = await AddonModWiki.getSubwikiFiles(this.wikiId, { + groupId: this.groupId, + userId: this.userId, + cmId: this.cmId, + }); + + // Get editable text of the page/section. + const editContents = await AddonModWiki.getPageForEditing(this.pageId, this.section); + + // Get the original page contents, treating file URLs if needed. + const content = CoreTextUtils.replacePluginfileUrls(editContents.content || '', this.subwikiFiles); + + this.contentControl!.setValue(content); + this.originalContent = content; + this.version = editContents.version; + + if (canEdit) { + // Renew the lock every certain time. + this.renewLockInterval = window.setInterval(() => { + this.renewLock(); + }, AddonModWikiProvider.RENEW_LOCK_TIME); + } + } else { + const pageTitle = this.pageForm!.controls.title.value; + this.editing = false; + canEdit = !!this.blockId; // If no blockId, the user cannot edit the page. + + // Make sure we have the wiki ID. + if (!this.wikiId) { + const module = await CoreCourse.getModule(this.cmId, this.courseId, undefined, true); + + this.wikiId = module.instance; + } + + if (pageTitle) { + // Title is set, it could be editing an offline page or creating a new page using an edit link. + // First of all, verify if this page was created in the current sync. + if (syncResult) { + const page = syncResult.created.find((page) => page.title == pageTitle); + + if (page && page.pageId > 0) { + // Page was created, now it exists in the site. + this.pageId = page.pageId; + + return this.fetchWikiPageData(); + } + } + + // Check if there's already some offline data for this page. + const page = await CoreUtils.ignoreErrors( + AddonModWikiOffline.getNewPage(pageTitle, this.subwikiId, this.wikiId, this.userId, this.groupId), + ); + + if (page) { + // Load offline content. + this.contentControl!.setValue(page.cachedcontent); + this.originalContent = page.cachedcontent; + this.editOffline = true; + } else { + // No offline data found. + this.editOffline = false; + } + } else { + this.editOffline = false; + } + } + + return true; + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error getting wiki data.'); + fetchFailed = true; + + // Go back. + this.forceLeavePage(); + + return false; + } finally { + if (!canEdit && !fetchFailed) { + // Cannot edit, show alert and go back. + CoreDomUtils.showAlert(Translate.instant('core.notice'), Translate.instant('addon.mod_wiki.cannoteditpage')); + this.forceLeavePage(); + } + } + } + + /** + * Force leaving the page, without checking for changes. + */ + protected forceLeavePage(): void { + this.forceLeave = true; + CoreNavigator.back(); + } + + /** + * Navigate to a page. + * + * @param title Page title. + */ + protected goToPage(title: string): void { + // Not the firstpage. + AddonModWiki.setEditedPageData({ + cmId: this.cmId, + courseId: this.courseId, + pageId: this.pageId, + pageTitle: title, + wikiId: this.wikiId!, + subwikiId: this.subwikiId, + userId: this.userId, + groupId: this.groupId, + }); + + this.forceLeavePage(); + } + + /** + * Check if data has changed. + * + * @return Whether data has changed. + */ + protected hasDataChanged(): boolean { + const values = this.pageForm!.value; + + return !(this.originalContent == values.text || (!this.editing && !values.text && !values.title)); + } + + /** + * @inheritdoc + */ + async canLeave(): Promise { + if (this.forceLeave) { + return true; + } + + // Check if data has changed. + if (this.hasDataChanged()) { + await CoreDomUtils.showConfirm(Translate.instant('core.confirmcanceledit')); + } + + CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + + return true; + } + + /** + * @inheritdoc + */ + ionViewDidLeave(): void { + // When going back, the ionViewDidEnter of the previous page should be called before this ionViewDidLeave. + // But just in case, use a timeout to make sure it does. + setTimeout(() => { + // Remove the edited page data (if any) if the previous page isn't a wiki page. + AddonModWiki.consumeEditedPageData(); + }, 200); + } + + /** + * Save the data. + */ + async save(): Promise { + const values = this.pageForm!.value; + const title = values.title; + let text = values.text; + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + text = CoreTextUtils.restorePluginfileUrls(text, this.subwikiFiles); + text = CoreTextUtils.formatHtmlLines(text); + + try { + if (this.editing) { + // Edit existing page. + await AddonModWiki.editPage(this.pageId!, text, this.section); + + CoreForms.triggerFormSubmittedEvent(this.formElement, true, CoreSites.getCurrentSiteId()); + + // Invalidate page since it changed. + await AddonModWiki.invalidatePage(this.pageId!); + + return this.goToPage(title); + } + + // Creating a new page. + if (!title) { + // Title is mandatory, stop. + modal.dismiss(); + CoreDomUtils.showAlert( + Translate.instant('core.notice'), + Translate.instant('addon.mod_wiki.titleshouldnotbeempty'), + ); + + return; + } + + if (!this.editOffline) { + // Check if the user has an offline page with the same title. + const page = await CoreUtils.ignoreErrors( + AddonModWikiOffline.getNewPage(title, this.subwikiId, this.wikiId, this.userId, this.groupId), + ); + + if (page) { + // There's a page with same title, reject with error message. + throw new CoreError(Translate.instant('addon.mod_wiki.pageexists')); + } + } + + // Try to send the page. + const id = await AddonModWiki.newPage(title, text, { + subwikiId: this.subwikiId, + wikiId: this.wikiId, + userId: this.userId, + groupId: this.groupId, + cmId: this.cmId, + }); + + CoreForms.triggerFormSubmittedEvent(this.formElement, id > 0, CoreSites.getCurrentSiteId()); + + if (id <= 0) { + // Page stored in offline. Go to see the offline page. + return this.goToPage(title); + } + + // Page was created, get its data and go to the page. + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'wiki' }); + this.pageId = id; + + const pageContents = await AddonModWiki.getPageContents(this.pageId, { cmId: this.cmId }); + + const promises: Promise[] = []; + this.wikiId = pageContents.wikiid; + + // Invalidate subwiki pages since there are new. + promises.push(AddonModWiki.invalidateSubwikiPages(this.wikiId)); + if (!this.subwikiId) { + // Subwiki was not created, invalidate subwikis as well. + promises.push(AddonModWiki.invalidateSubwikis(this.wikiId)); + } + + this.subwikiId = pageContents.subwikiid; + this.userId = pageContents.userid; + this.groupId = pageContents.groupid; + + await CoreUtils.ignoreErrors(Promise.all(promises)); + + // Notify page created. + CoreEvents.trigger(AddonModWikiProvider.PAGE_CREATED_EVENT, { + pageId: this.pageId, + subwikiId: this.subwikiId, + pageTitle: title, + }, CoreSites.getCurrentSiteId()); + + this.goToPage(title); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error saving wiki data.'); + } finally { + modal.dismiss(); + } + } + + /** + * Renew lock and control versions. + */ + protected async renewLock(): Promise { + const response = await AddonModWiki.getPageForEditing(this.pageId!, this.section, true); + + if (response.version && this.version != response.version) { + this.wrongVersionLock = true; + } + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.isDestroyed = true; + clearInterval(this.renewLockInterval); + + // Unblock the subwiki. + if (this.blockId) { + CoreSync.unblockOperation(this.component, this.blockId); + } + } + +} diff --git a/src/addons/mod/wiki/pages/index/index.html b/src/addons/mod/wiki/pages/index/index.html new file mode 100644 index 000000000..a5e7f3c41 --- /dev/null +++ b/src/addons/mod/wiki/pages/index/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/wiki/pages/index/index.ts b/src/addons/mod/wiki/pages/index/index.ts new file mode 100644 index 000000000..3b6058055 --- /dev/null +++ b/src/addons/mod/wiki/pages/index/index.ts @@ -0,0 +1,83 @@ +// (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 { Component, OnInit, ViewChild } from '@angular/core'; +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModWikiIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a wiki page. + */ +@Component({ + selector: 'page-addon-mod-wiki-index', + templateUrl: 'index.html', +}) +export class AddonModWikiIndexPage extends CoreCourseModuleMainActivityPage implements OnInit { + + @ViewChild(AddonModWikiIndexComponent) activityComponent?: AddonModWikiIndexComponent; + + action?: string; + pageId?: number; + pageTitle?: string; + subwikiId?: number; + userId?: number; + groupId?: number; + + /** + * @inheritdoc + */ + ngOnInit(): void { + super.ngOnInit(); + + this.action = CoreNavigator.getRouteParam('action') || 'page'; + this.pageId = CoreNavigator.getRouteNumberParam('pageId'); + this.pageTitle = CoreNavigator.getRouteParam('pageTitle'); + this.subwikiId = CoreNavigator.getRouteNumberParam('subwikiId'); + this.userId = CoreNavigator.getRouteNumberParam('userId'); + this.groupId = CoreNavigator.getRouteNumberParam('groupId'); + + this.title = this.pageTitle || this.module.name; + } + + /** + * Update some data based on the data received. + * + * @param data The data received. + */ + updateData(data: { name: string } | string): void { + if (typeof data == 'string') { + // We received the title to display. + this.title = data; + } else { + // We received a wiki instance. + this.title = this.pageTitle || data.name || this.title; + } + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.activityComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.activityComponent?.ionViewDidLeave(); + } + +} diff --git a/src/addons/mod/wiki/services/database/wiki.ts b/src/addons/mod/wiki/services/database/wiki.ts new file mode 100644 index 000000000..551628a0e --- /dev/null +++ b/src/addons/mod/wiki/services/database/wiki.ts @@ -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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModWikiOfflineProvider. + */ +export const NEW_PAGES_TABLE_NAME = 'addon_mod_wiki_new_pages_store'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModWikiOfflineProvider', + version: 1, + tables: [ + { + name: NEW_PAGES_TABLE_NAME, + columns: [ + { + name: 'wikiid', + type: 'INTEGER', + }, + { + name: 'subwikiid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'groupid', + type: 'INTEGER', + }, + { + name: 'title', + type: 'TEXT', + }, + { + name: 'cachedcontent', + type: 'TEXT', + }, + { + name: 'contentformat', + type: 'TEXT', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'caneditpage', + type: 'INTEGER', + }, + ], + primaryKeys: ['wikiid', 'subwikiid', 'userid', 'groupid', 'title'], + }, + ], +}; + +/** + * Wiki new page data. + */ +export type AddonModWikiPageDBRecord = { + wikiid: number; + subwikiid: number; + userid: number; + groupid: number; + title: string; + cachedcontent: string; + contentformat: string; + courseid?: null; // Currently not used. + timecreated: number; + timemodified: number; + caneditpage: number; +}; diff --git a/src/addons/mod/wiki/services/handlers/create-link.ts b/src/addons/mod/wiki/services/handlers/create-link.ts new file mode 100644 index 000000000..473a2c0e9 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/create-link.ts @@ -0,0 +1,151 @@ +// (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 { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CoreError } from '@classes/errors/error'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModWikiIndexPage } from '../../pages/index'; +import { AddonModWiki } from '../wiki'; +import { AddonModWikiModuleHandlerService } from './module'; + +/** + * Handler to treat links to create a wiki page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiCreateLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModWikiCreateLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModWiki'; + pattern = /\/mod\/wiki\/create\.php.*([&?]swid=\d+)/; + + /** + * Check if the current view is a wiki page of the same wiki. + * + * @param route Activated route if current route is wiki index page, null otherwise. + * @param subwikiId Subwiki ID to check. + * @param siteId Site ID. + * @return Promise resolved with boolean: whether current view belongs to the same wiki. + */ + protected async currentStateIsSameWiki(route: ActivatedRoute | null, subwikiId: number, siteId: string): Promise { + if (!route) { + // Current view isn't wiki index. + return false; + } + + const params = route.snapshot.params; + const queryParams = route.snapshot.queryParams; + + if (queryParams.subwikiId == subwikiId) { + // Same subwiki, so it's same wiki. + return true; + } + + const options = { + cmId: params.cmId, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }; + + if (queryParams.pageId) { + // Get the page contents to check the subwiki. + try { + const page = await AddonModWiki.getPageContents(queryParams.pageId, options); + + return page.subwikiid == subwikiId; + } catch { + // Not found, check next case. + } + } + + try { + // Get the wiki. + const wiki = await AddonModWiki.getWiki(params.courseId, params.cmId, options); + + // Check if the subwiki belongs to this wiki. + return await AddonModWiki.wikiHasSubwiki(wiki.id, subwikiId, options); + } catch { + // Not found, return false. + return false; + } + } + + /** + * @inheritdoc + */ + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + ): CoreContentLinksAction[] | Promise { + courseId = Number(courseId || params.courseid || params.cid); + + return [{ + action: async (siteId: string) => { + const modal = await CoreDomUtils.showModalLoading(); + + try { + const route = CoreNavigator.getCurrentRoute({ pageComponent: AddonModWikiIndexPage }); + const subwikiId = parseInt(params.swid, 10); + const wikiId = parseInt(params.wid, 10); + let moduleId: number; + + // Check if the link is inside the same wiki. + const isSameWiki = await this.currentStateIsSameWiki(route, subwikiId, siteId); + + if (isSameWiki) { + // User is seeing the wiki, we can get the module from the wiki params. + moduleId = route!.snapshot.params.cmId; + courseId = route!.snapshot.params.courseId; + } else if (wikiId) { + // The URL specifies which wiki it belongs to. Get the module. + const module = await CoreCourse.getModuleBasicInfoByInstance(wikiId, 'wiki', siteId); + + moduleId = module.id; + courseId = module.course; + } else { + // Not enough data. + throw new CoreError(); + } + + // Open the page. + CoreNavigator.navigateToSitePath( + AddonModWikiModuleHandlerService.PAGE_NAME + `/${courseId}/${moduleId}/edit`, + { + params: { + pageTitle: params.title, + subwikiId: subwikiId, + }, + siteId, + }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_wiki.errorloadingpage', true); + } finally { + modal.dismiss(); + } + }, + }]; + } + +} + +export const AddonModWikiCreateLinkHandler = makeSingleton(AddonModWikiCreateLinkHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/edit-link.ts b/src/addons/mod/wiki/services/handlers/edit-link.ts new file mode 100644 index 000000000..6a164a954 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/edit-link.ts @@ -0,0 +1,84 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModWiki } from '../wiki'; +import { AddonModWikiModuleHandlerService } from './module'; + +/** + * Handler to treat links to edit a wiki page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiEditLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModWikiEditLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModWiki'; + pattern = /\/mod\/wiki\/edit\.php.*([&?]pageid=\d+)/; + + /** + * @inheritdoc + */ + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + ): CoreContentLinksAction[] | Promise { + + return [{ + action: async (siteId: string) => { + const modal = await CoreDomUtils.showModalLoading(); + + try { + const pageId = Number(params.pageid); + + const pageContents = await AddonModWiki.getPageContents(pageId, { siteId }); + + const module = await CoreCourse.getModuleBasicInfoByInstance(pageContents.wikiid, 'wiki', siteId); + + let section = ''; + if (typeof params.section != 'undefined') { + section = params.section.replace(/\+/g, ' '); + } + + courseId = module.course || courseId || Number(params.courseid || params.cid); + + CoreNavigator.navigateToSitePath( + AddonModWikiModuleHandlerService.PAGE_NAME + `/${courseId}/${module.id}/edit`, + { + params: { + section: section, + pageId: pageId, + }, + siteId, + }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_wiki.errorloadingpage', true); + } finally { + modal.dismiss(); + } + }, + }]; + } + +} + +export const AddonModWikiEditLinkHandler = makeSingleton(AddonModWikiEditLinkHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/index-link.ts b/src/addons/mod/wiki/services/handlers/index-link.ts new file mode 100644 index 000000000..50c5c4531 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/index-link.ts @@ -0,0 +1,33 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to wiki index. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModWikiIndexLinkHandler'; + + constructor() { + super('AddonModWiki', 'wiki', 'wid'); + } + +} + +export const AddonModWikiIndexLinkHandler = makeSingleton(AddonModWikiIndexLinkHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/list-link.ts b/src/addons/mod/wiki/services/handlers/list-link.ts new file mode 100644 index 000000000..e2e999c29 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/list-link.ts @@ -0,0 +1,33 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to wiki list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModWikiListLinkHandler'; + + constructor() { + super('AddonModWiki', 'wiki'); + } + +} + +export const AddonModWikiListLinkHandler = makeSingleton(AddonModWikiListLinkHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/module.ts b/src/addons/mod/wiki/services/handlers/module.ts new file mode 100644 index 000000000..917ad30d1 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/module.ts @@ -0,0 +1,84 @@ +// (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 { Injectable, Type } from '@angular/core'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonModWikiIndexComponent } from '../../components/index'; + +/** + * Handler to support wiki modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_wiki'; + + name = 'AddonModWiki'; + modName = 'wiki'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: false, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: false, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_RATE]: false, + [CoreConstants.FEATURE_COMMENT]: true, + }; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_wiki-handler', + showDownloadButton: true, + action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) => { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = `/${courseId}/${module.id}/page/root`; + + CoreNavigator.navigateToSitePath(AddonModWikiModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModWikiIndexComponent; + } + +} + +export const AddonModWikiModuleHandler = makeSingleton(AddonModWikiModuleHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/page-or-map-link.ts b/src/addons/mod/wiki/services/handlers/page-or-map-link.ts new file mode 100644 index 000000000..0aea30fbf --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/page-or-map-link.ts @@ -0,0 +1,109 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { Md5 } from 'ts-md5'; +import { AddonModWiki } from '../wiki'; +import { AddonModWikiModuleHandlerService } from './module'; + +/** + * Handler to treat links to a wiki page or the wiki map. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiPageOrMapLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModWikiPageOrMapLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModWiki'; + pattern = /\/mod\/wiki\/(view|map)\.php.*([&?]pageid=\d+)/; + + /** + * @inheritdoc + */ + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + ): CoreContentLinksAction[] | Promise { + + courseId = Number(courseId || params.courseid || params.cid); + + return [{ + action: async (siteId: string) => { + const modal = await CoreDomUtils.showModalLoading(); + const pageId = parseInt(params.pageid, 10); + const action = url.indexOf('mod/wiki/map.php') != -1 ? 'map' : 'page'; + + try { + // Get the page data to obtain wikiId, subwikiId, etc. + const page = await AddonModWiki.getPageContents(pageId, { siteId }); + + const module = await CoreCourse.getModuleBasicInfoByInstance(page.wikiid, 'wiki', siteId); + + const hash = Md5.hashAsciiStr(JSON.stringify({ + pageId: page.id, + pageTitle: page.title, + subwikiId: page.subwikiid, + action: action, + timestamp: Date.now(), + })); + courseId = courseId || module.course; + + CoreNavigator.navigateToSitePath( + AddonModWikiModuleHandlerService.PAGE_NAME + `/${courseId}/${module.id}/page/${hash}`, + { + params: { + pageId: page.id, + pageTitle: page.title, + subwikiId: page.subwikiid, + action: action, + }, + siteId, + }, + ); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_wiki.errorloadingpage', true); + } finally { + modal.dismiss(); + } + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Record): Promise { + const isMap = url.indexOf('mod/wiki/map.php') != -1; + + if (params.id && !isMap) { + // ID param is more prioritary than pageid in index page, it's a index URL. + return false; + } else if (isMap && typeof params.option != 'undefined' && params.option != '5') { + // Map link but the option isn't "Page list", not supported. + return false; + } + + return true; + } + +} + +export const AddonModWikiPageOrMapLinkHandler = makeSingleton(AddonModWikiPageOrMapLinkHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/prefetch.ts b/src/addons/mod/wiki/services/handlers/prefetch.ts new file mode 100644 index 000000000..fee992462 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/prefetch.ts @@ -0,0 +1,206 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroups } from '@services/groups'; +import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModWiki, AddonModWikiProvider, AddonModWikiSubwikiPage } from '../wiki'; +import { AddonModWikiSync, AddonModWikiSyncWikiResult } from '../wiki-sync'; + +/** + * Handler to prefetch wikis. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModWiki'; + modName = 'wiki'; + component = AddonModWikiProvider.COMPONENT; + updatesNames = /^.*files$|^pages$/; + + /** + * Returns a list of pages that can be downloaded. + * + * @param module The module object returned by WS. + * @param courseId The course ID. + * @param options Other options. + * @return List of pages. + */ + protected async getAllPages( + module: CoreCourseAnyModuleData, + courseId: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + try { + const wiki = await AddonModWiki.getWiki(courseId, module.id, options); + + return await AddonModWiki.getWikiPageList(wiki, options); + } catch { + // Wiki not found, return empty list. + return []; + } + } + + /** + * @inheritdoc + */ + async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise { + const promises: Promise[] = []; + const siteId = CoreSites.getCurrentSiteId(); + + promises.push(this.getFiles(module, courseId, single, siteId).then((files) => + CorePluginFileDelegate.getFilesDownloadSize(files))); + + promises.push(this.getAllPages(module, courseId, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }).then((pages) => { + let size = 0; + + pages.forEach((page) => { + if (page.contentsize) { + size = size + page.contentsize; + } + }); + + return { size: size, total: true }; + })); + + const sizes = await Promise.all(promises); + + return { + size: sizes[0].size + sizes[1].size, + total: sizes[0].total && sizes[1].total, + }; + } + + /** + * @inheritdoc + */ + async getFiles( + module: CoreCourseAnyModuleData, + courseId: number, + single?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + try { + const wiki = await AddonModWiki.getWiki(courseId, module.id, { siteId }); + + const introFiles = this.getIntroFilesFromInstance(module, wiki); + + const files = await AddonModWiki.getWikiFileList(wiki, { siteId }); + + return introFiles.concat(files); + } catch { + // Wiki not found, return empty list. + return []; + } + } + + /** + * @inheritdoc + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModWiki.invalidateContent(moduleId, courseId); + } + + /** + * @inheritdoc + */ + async prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise { + // Get the download time of the package before starting the download (otherwise we'd always get current time). + const siteId = CoreSites.getCurrentSiteId(); + + const data = await CoreUtils.ignoreErrors(CoreFilepool.getPackageData(siteId, this.component, module.id)); + + const downloadTime = data?.downloadTime || 0; + + return this.prefetchPackage(module, courseId, this.prefetchWiki.bind(this, module, courseId, single, downloadTime, siteId)); + } + + /** + * Prefetch a wiki. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param downloadTime The previous download time, 0 if no previous download. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchWiki( + module: CoreCourseAnyModuleData, + courseId: number, + single: boolean, + downloadTime: number, + siteId: string, + ): Promise { + const userId = CoreSites.getCurrentSiteUserId(); + + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + // Get the list of pages. + const pages = await this.getAllPages(module, courseId, commonOptions); + const promises: Promise[] = []; + + pages.forEach((page) => { + // Fetch page contents if it needs to be fetched. + if (page.timemodified > downloadTime) { + promises.push(AddonModWiki.getPageContents(page.id, modOptions)); + } + }); + + // Fetch group data. + promises.push(CoreGroups.getActivityGroupInfo(module.id, false, userId, siteId)); + + // Fetch info to provide wiki links. + promises.push(AddonModWiki.getWiki(courseId, module.id, { siteId }).then((wiki) => + CoreCourseHelper.getModuleCourseIdByInstance(wiki.id, 'wiki', siteId))); + + // Get related page files and fetch them. + promises.push(this.getFiles(module, courseId, single, siteId).then((files) => + CoreFilepool.addFilesToQueue(siteId, files, this.component, module.id))); + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModWikiSync.syncWiki(module.instance!, module.course, module.id, siteId); + } + +} + +export const AddonModWikiPrefetchHandler = makeSingleton(AddonModWikiPrefetchHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/sync-cron.ts b/src/addons/mod/wiki/services/handlers/sync-cron.ts new file mode 100644 index 000000000..16848b150 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/sync-cron.ts @@ -0,0 +1,44 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModWikiSync } from '../wiki-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModWikiSyncCronHandler'; + + /** + * @inheritdoc + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModWikiSync.syncAllWikis(siteId, force); + } + + /** + * @inheritdoc + */ + getInterval(): number { + return AddonModWikiSync.syncInterval; + } + +} + +export const AddonModWikiSyncCronHandler = makeSingleton(AddonModWikiSyncCronHandlerService); diff --git a/src/addons/mod/wiki/services/handlers/tag-area.ts b/src/addons/mod/wiki/services/handlers/tag-area.ts new file mode 100644 index 000000000..878c3de19 --- /dev/null +++ b/src/addons/mod/wiki/services/handlers/tag-area.ts @@ -0,0 +1,53 @@ +// (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 { Injectable, Type } from '@angular/core'; +import { CoreTagFeedComponent } from '@features/tag/components/feed/feed'; +import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate'; +import { CoreTagFeedElement, CoreTagHelper } from '@features/tag/services/tag-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support tags. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiTagAreaHandlerService implements CoreTagAreaHandler { + + name = 'AddonModWikiTagAreaHandler'; + type = 'mod_wiki/wiki_pages'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + parseContent(content: string): CoreTagFeedElement[] { + return CoreTagHelper.parseFeedContent(content); + } + + /** + * @inheritdoc + */ + getComponent(): Type { + return CoreTagFeedComponent; + } + +} + +export const AddonModWikiTagAreaHandler = makeSingleton(AddonModWikiTagAreaHandlerService); diff --git a/src/addons/mod/wiki/services/wiki-offline.ts b/src/addons/mod/wiki/services/wiki-offline.ts new file mode 100644 index 000000000..69288f758 --- /dev/null +++ b/src/addons/mod/wiki/services/wiki-offline.ts @@ -0,0 +1,233 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { AddonModWikiPageDBRecord, NEW_PAGES_TABLE_NAME } from './database/wiki'; +import { AddonModWikiSubwiki } from './wiki'; + +/** + * Service to handle offline wiki. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiOfflineProvider { + + /** + * Convert a value to a positive number. If not a number or less than 0, 0 will be returned. + * + * @param value Value to convert. + * @return Converted value. + */ + convertToPositiveNumber(value: string | number | undefined): number { + value = Number(value); + + return value > 0 ? value : 0; + } + + /** + * Delete a new page. + * + * @param title Title of the page. + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used create subwiki if not informed. + * @param userId User ID. Optional, will be used create subwiki if not informed. + * @param groupId Group ID. Optional, will be used create subwiki if not informed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if deleted, rejected if failure. + */ + async deleteNewPage( + title: string, + subwikiId?: number, + wikiId?: number, + userId?: number, + groupId?: number, + siteId?: string, + ): Promise { + + const site = await CoreSites.getSite(siteId); + + subwikiId = this.convertToPositiveNumber(subwikiId); + wikiId = this.convertToPositiveNumber(wikiId); + userId = this.convertToPositiveNumber(userId); + groupId = this.convertToPositiveNumber(groupId); + + await site.getDb().deleteRecords(NEW_PAGES_TABLE_NAME, > { + subwikiid: subwikiId, + wikiid: wikiId, + userid: userId, + groupid: groupId, + title: title, + }); + } + + /** + * Get all the stored new pages from all the wikis. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with pages. + */ + async getAllNewPages(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.getDb().getAllRecords(NEW_PAGES_TABLE_NAME); + } + + /** + * Get a stored new page. + * + * @param title Title of the page. + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used create subwiki if not informed. + * @param userId User ID. Optional, will be used create subwiki if not informed. + * @param groupId Group ID. Optional, will be used create subwiki if not informed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with page. + */ + async getNewPage( + title: string, + subwikiId?: number, + wikiId?: number, + userId?: number, + groupId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + subwikiId = this.convertToPositiveNumber(subwikiId); + wikiId = this.convertToPositiveNumber(wikiId); + userId = this.convertToPositiveNumber(userId); + groupId = this.convertToPositiveNumber(groupId); + + return site.getDb().getRecord(NEW_PAGES_TABLE_NAME, > { + subwikiid: subwikiId, + wikiid: wikiId, + userid: userId, + groupid: groupId, + title: title, + }); + } + + /** + * Get all the stored new pages from a certain subwiki. + * + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used create subwiki if not informed. + * @param userId User ID. Optional, will be used create subwiki if not informed. + * @param groupId Group ID. Optional, will be used create subwiki if not informed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with pages. + */ + async getSubwikiNewPages( + subwikiId?: number, + wikiId?: number, + userId?: number, + groupId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + subwikiId = this.convertToPositiveNumber(subwikiId); + wikiId = this.convertToPositiveNumber(wikiId); + userId = this.convertToPositiveNumber(userId); + groupId = this.convertToPositiveNumber(groupId); + + return site.getDb().getRecords(NEW_PAGES_TABLE_NAME, > { + subwikiid: subwikiId, + wikiid: wikiId, + userid: userId, + groupid: groupId, + }); + } + + /** + * Get all the stored new pages from a list of subwikis. + * + * @param subwikis List of subwiki. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with pages. + */ + async getSubwikisNewPages(subwikis: AddonModWikiSubwiki[], siteId?: string): Promise { + let pages: AddonModWikiPageDBRecord[] = []; + + await Promise.all(subwikis.map(async (subwiki) => { + const subwikiPages = await this.getSubwikiNewPages(subwiki.id, subwiki.wikiid, subwiki.userid, subwiki.groupid, siteId); + + pages = pages.concat(subwikiPages); + })); + + return pages; + } + + /** + * Save a new page to be sent later. + * + * @param title Title of the page. + * @param content Content of the page. + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used create subwiki if not informed. + * @param userId User ID. Optional, will be used create subwiki if not informed. + * @param groupId Group ID. Optional, will be used create subwiki if not informed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async saveNewPage( + title: string, + content: string, + subwikiId?: number, + wikiId?: number, + userId?: number, + groupId?: number, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const now = Date.now(); + const entry: AddonModWikiPageDBRecord = { + title: title, + cachedcontent: content, + subwikiid: this.convertToPositiveNumber(subwikiId), + wikiid: this.convertToPositiveNumber(wikiId), + userid: this.convertToPositiveNumber(userId), + groupid: this.convertToPositiveNumber(groupId), + contentformat: 'html', + timecreated: now, + timemodified: now, + caneditpage: 1, + }; + + await site.getDb().insertRecord(NEW_PAGES_TABLE_NAME, entry); + } + + /** + * Check if a list of subwikis have offline data stored. + * + * @param subwikis List of subwikis. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it has offline data. + */ + async subwikisHaveOfflineData(subwikis: AddonModWikiSubwiki[], siteId?: string): Promise { + try { + const pages = await this.getSubwikisNewPages(subwikis, siteId); + + return !!pages.length; + } catch { + // Error, return false. + return false; + } + } + +} + +export const AddonModWikiOffline = makeSingleton(AddonModWikiOfflineProvider); diff --git a/src/addons/mod/wiki/services/wiki-sync.ts b/src/addons/mod/wiki/services/wiki-sync.ts new file mode 100644 index 000000000..0f9375231 --- /dev/null +++ b/src/addons/mod/wiki/services/wiki-sync.ts @@ -0,0 +1,417 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreSyncBaseProvider, CoreSyncBlockedError } from '@classes/base-sync'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreGroups } from '@services/groups'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModWikiPageDBRecord } from './database/wiki'; +import { AddonModWiki, AddonModWikiProvider } from './wiki'; +import { AddonModWikiOffline } from './wiki-offline'; + +/** + * Service to sync wikis. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_wiki_autom_synced'; + static readonly MANUAL_SYNCED = 'addon_mod_wiki_manual_synced'; + + protected componentTranslatableString = 'wiki'; + + constructor() { + super('AddonModWikiSyncProvider'); + } + + /** + * Get a string to identify a subwiki. If it doesn't have a subwiki ID it will be identified by wiki ID, user ID and group ID. + * + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param userId User ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param groupId Group ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @return Identifier. + */ + getSubwikiBlockId(subwikiId?: number, wikiId?: number, userId?: number, groupId?: number): string { + subwikiId = AddonModWikiOffline.convertToPositiveNumber(subwikiId); + + if (subwikiId && subwikiId > 0) { + return String(subwikiId); + } + + wikiId = AddonModWikiOffline.convertToPositiveNumber(wikiId); + userId = AddonModWikiOffline.convertToPositiveNumber(userId); + groupId = AddonModWikiOffline.convertToPositiveNumber(groupId); + + return `${wikiId}:${userId}:${groupId}`; + } + + /** + * Try to synchronize all the wikis in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllWikis(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all wikis', this.syncAllWikisFunc.bind(this, !!force), siteId); + } + + /** + * Sync all wikis on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllWikisFunc(force: boolean, siteId: string): Promise { + // Get all the pages created in offline. + const pages = await AddonModWikiOffline.getAllNewPages(siteId); + + const subwikis: Record = {}; + + // Sync all subwikis. + await Promise.all(pages.map(async (page) => { + const index = this.getSubwikiBlockId(page.subwikiid, page.wikiid, page.userid, page.groupid); + + if (subwikis[index]) { + // Already synced. + return; + } + + subwikis[index] = true; + + const result = force ? + await this.syncSubwiki(page.subwikiid, page.wikiid, page.userid, page.groupid, siteId) : + await this.syncSubwikiIfNeeded(page.subwikiid, page.wikiid, page.userid, page.groupid, siteId); + + if (result?.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModWikiSyncProvider.AUTO_SYNCED, { + siteId: siteId, + subwikiId: page.subwikiid, + wikiId: page.wikiid, + userId: page.userid, + groupId: page.groupid, + created: result.created, + discarded: result.discarded, + warnings: result.warnings, + }); + } + })); + } + + /** + * Sync a subwiki only if a certain time has passed since the last time. + * + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param userId User ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param groupId Group ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when subwiki is synced or doesn't need to be synced. + */ + async syncSubwikiIfNeeded( + subwikiId: number, + wikiId?: number, + userId?: number, + groupId?: number, + siteId?: string, + ): Promise { + + const blockId = this.getSubwikiBlockId(subwikiId, wikiId, userId, groupId); + + const needed = await this.isSyncNeeded(blockId, siteId); + + if (needed) { + return this.syncSubwiki(subwikiId, wikiId, userId, groupId, siteId); + } + } + + /** + * Synchronize a subwiki. + * + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param userId User ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param groupId Group ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncSubwiki( + subwikiId: number, + wikiId?: number, + userId?: number, + groupId?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const subwikiBlockId = this.getSubwikiBlockId(subwikiId, wikiId, userId, groupId); + + if (this.isSyncing(subwikiBlockId, siteId)) { + // There's already a sync ongoing for this subwiki, return the promise. + return this.getOngoingSync(subwikiBlockId, siteId)!; + } + + // Verify that subwiki isn't blocked. + if (CoreSync.isBlocked(AddonModWikiProvider.COMPONENT, subwikiBlockId, siteId)) { + this.logger.debug(`Cannot sync subwiki ${subwikiBlockId} because it is blocked.`); + + throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug(`Try to sync subwiki ${subwikiBlockId}`); + + return this.addOngoingSync(subwikiBlockId, this.performSyncSubwiki(subwikiId, wikiId, userId, groupId, siteId), siteId); + } + + /** + * Synchronize a subwiki. + * + * @param subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param wikiId Wiki ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param userId User ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param groupId Group ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async performSyncSubwiki( + subwikiId: number, + wikiId?: number, + userId?: number, + groupId?: number, + siteId?: string, + ): Promise { + const result: AddonModWikiSyncSubwikiResult = { + warnings: [], + updated: false, + created: [], + discarded: [], + }; + const subwikiBlockId = this.getSubwikiBlockId(subwikiId, wikiId, userId, groupId); + + // Get offline pages to be sent. + const pages = await CoreUtils.ignoreErrors( + AddonModWikiOffline.getSubwikiNewPages(subwikiId, wikiId, userId, groupId, siteId), + [], + ); + + if (!pages || !pages.length) { + // Nothing to sync. + await CoreUtils.ignoreErrors(this.setSyncTime(subwikiBlockId, siteId)); + + return result; + } + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + // Send the pages. + await Promise.all(pages.map(async (page) => { + try { + const pageId = await AddonModWiki.newPageOnline(page.title, page.cachedcontent, { + subwikiId, + wikiId, + userId, + groupId, + siteId, + }); + + result.updated = true; + result.created.push({ + pageId: pageId, + title: page.title, + }); + + // Delete the local page. + await AddonModWikiOffline.deleteNewPage(page.title, subwikiId, wikiId, userId, groupId, siteId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, reject. + throw error; + } + + // The WebService has thrown an error, this means that the page cannot be submitted. Delete it. + await AddonModWikiOffline.deleteNewPage(page.title, subwikiId, wikiId, userId, groupId, siteId); + + result.updated = true; + + // Page deleted, add the page to discarded pages and add a warning. + const warning = Translate.instant('core.warningofflinedatadeleted', { + component: Translate.instant('addon.mod_wiki.wikipage'), + name: page.title, + error: CoreTextUtils.getErrorMessageFromError(error), + }); + + result.discarded.push({ + title: page.title, + warning: warning, + }); + + result.warnings.push(warning); + } + })); + + // Sync finished, set sync time. + await CoreUtils.ignoreErrors(this.setSyncTime(subwikiBlockId, siteId)); + + return result; + } + + /** + * Tries to synchronize a wiki. + * + * @param wikiId Wiki ID. + * @param courseId Course ID. + * @param cmId Wiki course module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncWiki(wikiId: number, courseId?: number, cmId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Sync offline logs. + await CoreUtils.ignoreErrors(CoreCourseLogHelper.syncActivity(AddonModWikiProvider.COMPONENT, wikiId, siteId)); + + // Sync is done at subwiki level, get all the subwikis. + const subwikis = await AddonModWiki.getSubwikis(wikiId, { cmId, siteId }); + + const result: AddonModWikiSyncWikiResult = { + warnings: [], + updated: false, + subwikis: {}, + siteId: siteId, + }; + + await Promise.all(subwikis.map(async (subwiki) => { + const data = await this.syncSubwiki(subwiki.id, subwiki.wikiid, subwiki.userid, subwiki.groupid, siteId); + + if (data && data.updated) { + result.warnings = result.warnings.concat(data.warnings); + result.updated = true; + result.subwikis[subwiki.id] = { + created: data.created, + discarded: data.discarded, + }; + } + })); + + if (result.updated) { + const promises: Promise[] = []; + + // Something has changed, invalidate data. + if (wikiId) { + promises.push(AddonModWiki.invalidateSubwikis(wikiId)); + promises.push(AddonModWiki.invalidateSubwikiPages(wikiId)); + promises.push(AddonModWiki.invalidateSubwikiFiles(wikiId)); + } + if (courseId) { + promises.push(AddonModWiki.invalidateWikiData(courseId)); + } + if (cmId) { + promises.push(CoreGroups.invalidateActivityAllowedGroups(cmId)); + promises.push(CoreGroups.invalidateActivityGroupMode(cmId)); + } + + await CoreUtils.ignoreErrors(Promise.all(promises)); + } + + return result; + } + +} + +export const AddonModWikiSync = makeSingleton(AddonModWikiSyncProvider); + +/** + * Data returned by a subwiki sync. + */ +export type AddonModWikiSyncSubwikiResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether data was updated in the site. + created: AddonModWikiCreatedPage[]; // List of created pages. + discarded: AddonModWikiDiscardedPage[]; // List of discarded pages. +}; + +/** + * Data returned by a wiki sync. + */ +export type AddonModWikiSyncWikiResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether data was updated in the site. + subwikis: { + [subwikiId: number]: { // List of subwikis. + created: AddonModWikiCreatedPage[]; + discarded: AddonModWikiDiscardedPage[]; + }; + }; + siteId: string; // Site ID. +}; + +/** + * Data returned by a wiki sync for each subwiki synced. + */ +export type AddonModWikiSyncWikiSubwiki = { + created: AddonModWikiCreatedPage[]; + discarded: AddonModWikiDiscardedPage[]; +}; + +/** + * Data to identify a page created in sync. + */ +export type AddonModWikiCreatedPage = { + pageId: number; + title: string; +}; + +/** + * Data to identify a page discarded in sync. + */ +export type AddonModWikiDiscardedPage = { + title: string; + warning: string; +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModWikiAutoSyncData = { + siteId: string; + subwikiId: number; + wikiId: number; + userId: number; + groupId: number; + created: AddonModWikiCreatedPage[]; + discarded: AddonModWikiDiscardedPage[]; + warnings: string[]; +}; + +/** + * Data passed to MANUAL_SYNCED event. + */ +export type AddonModWikiManualSyncData = AddonModWikiSyncWikiResult & { + wikiId: number; +}; diff --git a/src/addons/mod/wiki/services/wiki.ts b/src/addons/mod/wiki/services/wiki.ts new file mode 100644 index 000000000..da636b222 --- /dev/null +++ b/src/addons/mod/wiki/services/wiki.ts @@ -0,0 +1,1227 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreTagItem } from '@features/tag/services/tag'; +import { CoreApp } from '@services/app'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModWikiPageDBRecord } from './database/wiki'; +import { AddonModWikiOffline } from './wiki-offline'; +import { AddonModWikiAutoSyncData, AddonModWikiManualSyncData, AddonModWikiSyncProvider } from './wiki-sync'; + +const ROOT_CACHE_KEY = 'mmaModWiki:'; + +/** + * Service that provides some features for wikis. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModWikiProvider { + + static readonly COMPONENT = 'mmaModWiki'; + static readonly PAGE_CREATED_EVENT = 'addon_mod_wiki_page_created'; + static readonly RENEW_LOCK_TIME = 30000; // Milliseconds. + + protected subwikiListsCache: {[wikiId: number]: AddonModWikiSubwikiListData} = {}; + protected wikiFirstViewedPage: Record> = {}; + protected editedPage?: AddonModWikiEditedPageData; + + constructor() { + // Clear subwiki lists cache on logout. + CoreEvents.on(CoreEvents.LOGIN, () => { + this.clearSubwikiList(); + }); + } + + /** + * Clear subwiki list cache for a certain wiki or all of them. + * + * @param wikiId wiki Id, if not provided all will be cleared. + */ + clearSubwikiList(wikiId?: number): void { + if (typeof wikiId == 'undefined') { + this.subwikiListsCache = {}; + } else { + delete this.subwikiListsCache[wikiId]; + } + } + + /** + * Delete and return the edited page data if any. + * + * @return Edited page data, undefined if no data. + */ + consumeEditedPageData(): AddonModWikiEditedPageData | undefined { + const editedPage = this.editedPage; + delete this.editedPage; + + return editedPage; + } + + /** + * Save wiki contents on a page or section. + * + * @param pageId Page ID. + * @param content content to be saved. + * @param section section to get. + * @return Promise resolved with the page ID. + */ + async editPage(pageId: number, content: string, section?: string, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModWikiEditPageWSParams = { + pageid: pageId, + content: content, + }; + + if (section) { + params.section = section; + } + + const response = await site.write('mod_wiki_edit_page', params); + + return response.pageid; + } + + /** + * Get the first page opened for a wiki in the app if it isn't the current one. + * + * @param wikiId Wiki ID. + * @param path Path. + */ + getFirstWikiPageOpened(wikiId: number, path: string): string | undefined { + const tab = CoreNavigator.getMainMenuTabFromPath(path); + if (!tab) { + return; + } + + if (this.wikiFirstViewedPage[tab] && this.wikiFirstViewedPage[tab][wikiId] !== path) { + return this.wikiFirstViewedPage[tab][wikiId]; + } + } + + /** + * Get a wiki page contents. + * + * @param pageId Page ID. + * @param options Other options. + * @return Promise resolved with the page data. + */ + async getPageContents(pageId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWikiGetPageContentsWSParams = { + pageid: pageId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getPageContentsCacheKey(pageId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModWikiProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_wiki_get_page_contents', params, preSets); + + return response.page; + } + + /** + * Get cache key for wiki Pages Contents WS calls. + * + * @param pageId Wiki Page ID. + * @return Cache key. + */ + protected getPageContentsCacheKey(pageId: number): string { + return ROOT_CACHE_KEY + 'page:' + pageId; + } + + /** + * Get a wiki page contents for editing. It does not cache calls. + * + * @param pageId Page ID. + * @param section Section to get. + * @param lockOnly Just renew lock and not return content. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with page contents. + */ + async getPageForEditing( + pageId: number, + section?: string, + lockOnly?: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const params: AddonModWikiGetPageForEditingWSParams = { + pageid: pageId, + }; + if (section) { + params.section = section; + } + + // This parameter requires Moodle 3.2. It saves network usage. + if (lockOnly && site.isVersionGreaterEqualThan('3.2')) { + params.lockonly = true; + } + + const response = await site.write('mod_wiki_get_page_for_editing', params); + + return response.pagesection; + } + + /** + * Gets the list of files from a specific subwiki. + * + * @param wikiId Wiki ID. + * @param options Other options. + * @return Promise resolved with subwiki files. + */ + async getSubwikiFiles(wikiId: number, options: AddonModWikiGetSubwikiFilesOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const groupId = options.groupId || -1; + const userId = options.userId || 0; + + const params: AddonModWikiGetSubwikiFilesWSParams = { + wikiid: wikiId, + groupid: groupId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubwikiFilesCacheKey(wikiId, groupId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModWikiProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_wiki_get_subwiki_files', params, preSets); + + return response.files; + } + + /** + * Get cache key for wiki Subwiki Files WS calls. + * + * @param wikiId Wiki ID. + * @param groupId Group ID. + * @param userId User ID. + * @return Cache key. + */ + protected getSubwikiFilesCacheKey(wikiId: number, groupId: number, userId: number): string { + return this.getSubwikiFilesCacheKeyPrefix(wikiId) + ':' + groupId + ':' + userId; + } + + /** + * Get cache key for all wiki Subwiki Files WS calls. + * + * @param wikiId Wiki ID. + * @return Cache key. + */ + protected getSubwikiFilesCacheKeyPrefix(wikiId: number): string { + return ROOT_CACHE_KEY + 'subwikifiles:' + wikiId; + } + + /** + * Get a list of subwikis and related data for a certain wiki from the cache. + * + * @param wikiId wiki Id + * @return Subwiki list and related data. + */ + getSubwikiList(wikiId: number): AddonModWikiSubwikiListData { + return this.subwikiListsCache[wikiId]; + } + + /** + * Get the list of Pages of a SubWiki. + * + * @param wikiId Wiki ID. + * @param options Other options. + * @return Promise resolved with wiki subwiki pages. + */ + async getSubwikiPages(wikiId: number, options: AddonModWikiGetSubwikiPagesOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const groupId = options.groupId || -1; + const userId = options.userId || 0; + const sortBy = options.sortBy || 'title'; + const sortDirection = options.sortDirection || 'ASC'; + + const params: AddonModWikiGetSubwikiPagesWSParams = { + wikiid: wikiId, + groupid: groupId, + userid: userId, + options: { + sortby: sortBy, + sortdirection: sortDirection, + includecontent: options.includeContent ? 1 : 0, + }, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubwikiPagesCacheKey(wikiId, groupId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModWikiProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_wiki_get_subwiki_pages', params, preSets); + + return response.pages; + } + + /** + * Get cache key for wiki Subwiki Pages WS calls. + * + * @param wikiId Wiki ID. + * @param groupId Group ID. + * @param userId User ID. + * @return Cache key. + */ + protected getSubwikiPagesCacheKey(wikiId: number, groupId: number, userId: number): string { + return this.getSubwikiPagesCacheKeyPrefix(wikiId) + ':' + groupId + ':' + userId; + } + + /** + * Get cache key for all wiki Subwiki Pages WS calls. + * + * @param wikiId Wiki ID. + * @return Cache key. + */ + protected getSubwikiPagesCacheKeyPrefix(wikiId: number): string { + return ROOT_CACHE_KEY + 'subwikipages:' + wikiId; + } + + /** + * Get all the subwikis of a wiki. + * + * @param wikiId Wiki ID. + * @param options Other options. + * @return Promise resolved with subwikis. + */ + async getSubwikis(wikiId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWikiGetSubwikisWSParams = { + wikiid: wikiId, + }; + const preSets = { + cacheKey: this.getSubwikisCacheKey(wikiId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModWikiProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_wiki_get_subwikis', params, preSets); + + return response.subwikis; + } + + /** + * Get cache key for get wiki subWikis WS calls. + * + * @param wikiId Wiki ID. + * @return Cache key. + */ + protected getSubwikisCacheKey(wikiId: number): string { + return ROOT_CACHE_KEY + 'subwikis:' + wikiId; + } + + /** + * Get a wiki by module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the wiki is retrieved. + */ + getWiki(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getWikiByField(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a wiki with key=value. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the wiki is retrieved. + */ + protected async getWikiByField( + courseId: number, + key: string, + value: unknown, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWikiGetWikisByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getWikiDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModWikiProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_wiki_get_wikis_by_courses', params, preSets); + + const currentWiki = response.wikis.find((wiki) => wiki[key] == value); + if (currentWiki) { + return currentWiki; + } + + throw new CoreError('Wiki not found.'); + } + + /** + * Get a wiki by wiki ID. + * + * @param courseId Course ID. + * @param id Wiki ID. + * @param options Other options. + * @return Promise resolved when the wiki is retrieved. + */ + getWikiById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getWikiByField(courseId, 'id', id, options); + } + + /** + * Get cache key for wiki data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getWikiDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'wiki:' + courseId; + } + + /** + * Gets a list of files to download for a wiki, using a format similar to module.contents from get_course_contents. + * + * @param wiki Wiki. + * @param options Other options. + * @return Promise resolved with the list of files. + */ + async getWikiFileList(wiki: AddonModWikiWiki, options: CoreSitesCommonWSOptions = {}): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + let files: CoreWSExternalFile[] = []; + const modOptions = { + cmId: wiki.coursemodule, + ...options, // Include all options. + }; + + const subwikis = await this.getSubwikis(wiki.id, modOptions); + + await Promise.all(subwikis.map(async (subwiki) => { + const subwikiOptions = { + groupId: subwiki.groupid, + userId: subwiki.userid, + ...modOptions, // Include all options. + }; + + const subwikiFiles = await this.getSubwikiFiles(subwiki.wikiid, subwikiOptions); + + files = files.concat(subwikiFiles); + })); + + return files; + } + + /** + * Gets a list of all pages for a Wiki. + * + * @param wiki Wiki. + * @param options Other options. + * @return Page list. + */ + async getWikiPageList(wiki: AddonModWikiWiki, options: CoreSitesCommonWSOptions = {}): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + let pages: AddonModWikiSubwikiPage[] = []; + const modOptions = { + cmId: wiki.coursemodule, + ...options, // Include all options. + }; + + const subwikis = await this.getSubwikis(wiki.id, modOptions); + + await Promise.all(subwikis.map(async (subwiki) => { + const subwikiPages = await this.getSubwikiPages(subwiki.wikiid, { + groupId: subwiki.groupid, + userId: subwiki.userid, + ...modOptions, // Include all options. + }); + + pages = pages.concat(subwikiPages); + })); + + return pages; + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use invalidateFiles. + * + * @param moduleId The module ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const wiki = await this.getWiki(courseId, moduleId, { siteId }); + + await Promise.all([ + this.invalidateWikiData(courseId, siteId), + this.invalidateSubwikis(wiki.id, siteId), + this.invalidateSubwikiPages(wiki.id, siteId), + this.invalidateSubwikiFiles(wiki.id, siteId), + ]); + } + + /** + * Invalidates page content WS call for a certain page. + * + * @param pageId Wiki Page ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidatePage(pageId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getPageContentsCacheKey(pageId)); + } + + /** + * Invalidates all the subwiki files WS calls for a certain wiki. + * + * @param wikiId Wiki ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubwikiFiles(wikiId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getSubwikiFilesCacheKeyPrefix(wikiId)); + } + + /** + * Invalidates all the subwiki pages WS calls for a certain wiki. + * + * @param wikiId Wiki ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubwikiPages(wikiId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getSubwikiPagesCacheKeyPrefix(wikiId)); + } + + /** + * Invalidates all the get subwikis WS calls for a certain wiki. + * + * @param wikiId Wiki ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSubwikis(wikiId: number, siteId?: string): Promise { + this.clearSubwikiList(wikiId); + + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getSubwikisCacheKey(wikiId)); + } + + /** + * Invalidates wiki data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateWikiData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getWikiDataCacheKey(courseId)); + } + + /** + * Check if a page title is already used. + * + * @param wikiId Wiki ID. + * @param subwikiId Subwiki ID. + * @param title Page title. + * @param options Other options. + * @return Promise resolved with true if used, resolved with false if not used or cannot determine. + */ + async isTitleUsed( + wikiId: number, + subwikiId: number, + title: string, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + try { + // First get the subwiki. + const subwikis = await this.getSubwikis(wikiId, options); + + // Search the subwiki. + const subwiki = subwikis.find((subwiki) => subwiki.id == subwikiId); + + if (!subwiki) { + return false; + } + + // Now get all the pages of the subwiki. + const pages = await this.getSubwikiPages(wikiId, { + groupId: subwiki.groupid, + userId: subwiki.userid, + ...options, // Include all options. + }); + + // Check if there's any page with the same title. + const page = pages.find((page) => page.title == title); + + return !!page; + } catch { + return false; + } + } + + /** + * Report a wiki page as being viewed. + * + * @param id Page ID. + * @param wikiId Wiki ID. + * @param name Name of the wiki. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logPageView(id: number, wikiId: number, name?: string, siteId?: string): Promise { + const params: AddonModWikiViewPageWSParams = { + pageid: id, + }; + + return CoreCourseLogHelper.logSingle( + 'mod_wiki_view_page', + params, + AddonModWikiProvider.COMPONENT, + wikiId, + name, + 'wiki', + params, + siteId, + ); + } + + /** + * Report the wiki as being viewed. + * + * @param id Wiki ID. + * @param name Name of the wiki. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logView(id: number, name?: string, siteId?: string): Promise { + const params: AddonModWikiViewWikiWSParams = { + wikiid: id, + }; + + return CoreCourseLogHelper.logSingle( + 'mod_wiki_view_wiki', + params, + AddonModWikiProvider.COMPONENT, + id, + name, + 'wiki', + {}, + siteId, + ); + } + + /** + * Create a new page on a subwiki. + * + * @param title Title to create the page. + * @param content Content to save on the page. + * @param options Other options. + * @return Promise resolved with page ID if page was created in server, -1 if stored in device. + */ + async newPage(title: string, content: string, options: AddonModWikiNewPageOptions = {}): Promise { + + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + // Convenience function to store a new page to be synchronized later. + const storeOffline = async (): Promise => { + if (options.wikiId && options.subwikiId) { + // We have wiki ID, check if there's already an online page with this title and subwiki. + const used = await CoreUtils.ignoreErrors(this.isTitleUsed(options.wikiId, options.subwikiId, title, { + cmId: options.cmId, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId: options.siteId, + })); + + if (used) { + throw new CoreError(Translate.instant('addon.mod_wiki.pageexists')); + } + } + + await AddonModWikiOffline.saveNewPage( + title, + content, + options.subwikiId, + options.wikiId, + options.userId, + options.groupId, + options.siteId, + ); + + return -1; + }; + + if (!CoreApp.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // Discard stored content for this page. If it exists it means the user is editing it. + await AddonModWikiOffline.deleteNewPage( + title, + options.subwikiId, + options.wikiId, + options.userId, + options.groupId, + options.siteId, + ); + + try { + // Try to create it in online. + return this.newPageOnline(title, content, options); + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the page cannot be added. + throw error; + } + + // Couldn't connect to server, store in offline. + return storeOffline(); + } + } + + /** + * Create a new page on a subwiki. It will fail if offline or cannot connect. + * + * @param title Title to create the page. + * @param content Content to save on the page. + * @param options Other options. + * @return Promise resolved with the page ID if created, rejected otherwise. + */ + async newPageOnline(title: string, content: string, options: AddonModWikiNewPageOnlineOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModWikiNewPageWSParams = { + title: title, + content: content, + contentformat: 'html', + }; + + const subwikiId = AddonModWikiOffline.convertToPositiveNumber(options.subwikiId); + const wikiId = AddonModWikiOffline.convertToPositiveNumber(options.wikiId); + + if (subwikiId && subwikiId > 0) { + params.subwikiid = subwikiId; + } else if (wikiId) { + params.wikiid = wikiId; + params.userid = AddonModWikiOffline.convertToPositiveNumber(options.userId); + params.groupid = AddonModWikiOffline.convertToPositiveNumber(options.groupId); + } + + const response = await site.write('mod_wiki_new_page', params); + + return response.pageid; + } + + /** + * Set edited page data. + * + * @param data Data. + */ + setEditedPageData(data: AddonModWikiEditedPageData): void { + this.editedPage = data; + } + + /** + * Save subwiki list for a wiki to the cache. + * + * @param wikiId Wiki Id. + * @param subwikis List of subwikis. + * @param count Number of subwikis in the subwikis list. + * @param subwikiId Subwiki Id currently selected. + * @param userId User Id currently selected. + * @param groupId Group Id currently selected. + */ + setSubwikiList( + wikiId: number, + subwikis: AddonModWikiSubwikiListGrouping[], + count: number, + subwikiId: number, + userId: number, + groupId: number, + ): void { + this.subwikiListsCache[wikiId] = { + count: count, + subwikiSelected: subwikiId, + userSelected: userId, + groupSelected: groupId, + subwikis: subwikis, + }; + } + + /** + * Sort an array of wiki pages by title. + * + * @param pages Pages to sort. + * @param desc True to sort in descendent order, false to sort in ascendent order. Defaults to false. + * @return Sorted pages. + */ + sortPagesByTitle( + pages: T[], + desc?: boolean, + ): T[] { + return pages.sort((a, b) => { + let result = a.title >= b.title ? 1 : -1; + + if (desc) { + result = -result; + } + + return result; + }); + } + + /** + * Check if a wiki has a certain subwiki. + * + * @param wikiId Wiki ID. + * @param subwikiId Subwiki ID to search. + * @param options Other options. + * @return Promise resolved with true if it has subwiki, resolved with false otherwise. + */ + async wikiHasSubwiki(wikiId: number, subwikiId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + try { + // Get the subwikis to check if any of them matches the one passed as param. + const subwikis = await this.getSubwikis(wikiId, options); + + const subwiki = subwikis.find((subwiki) => subwiki.id == subwikiId); + + return !!subwiki; + } catch { + // Not found, return false. + return false; + } + } + + /** + * If this page is the first opened page for a wiki, remove the stored path so it's no longer the first viewed page. + * + * @param wikiId Wiki ID. + * @param path Path. + */ + wikiPageClosed(wikiId: number, path: string): void { + const tab = CoreNavigator.getMainMenuTabFromPath(path); + if (!tab) { + return; + } + + this.wikiFirstViewedPage[tab] = this.wikiFirstViewedPage[tab] || {}; + + if (this.wikiFirstViewedPage[tab][wikiId] === path) { + delete this.wikiFirstViewedPage[tab][wikiId]; + } + } + + /** + * If this page is the first opened page for a wiki, save its path so we can go back to it. + * + * @param wikiId Wiki ID. + * @param path Path. + */ + wikiPageOpened(wikiId: number, path: string): void { + const tab = CoreNavigator.getMainMenuTabFromPath(path); + if (!tab) { + return; + } + + this.wikiFirstViewedPage[tab] = this.wikiFirstViewedPage[tab] || {}; + + if (this.wikiFirstViewedPage[tab][wikiId]) { + // There's already an opened page for this wiki. + return; + } + + this.wikiFirstViewedPage[tab][wikiId] = path; + } + +} + +export const AddonModWiki = makeSingleton(AddonModWikiProvider); + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModWikiProvider.PAGE_CREATED_EVENT]: AddonModWikiPageCreatedData; + [AddonModWikiSyncProvider.AUTO_SYNCED]: AddonModWikiAutoSyncData; + [AddonModWikiSyncProvider.MANUAL_SYNCED]: AddonModWikiManualSyncData; + } + +} + +/** + * Options to pass to getSubwikiFiles. + */ +export type AddonModWikiGetSubwikiFilesOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User to get files from. + groupId?: number; // Group to get files from. +}; + +/** + * Options to pass to getSubwikiPages. + */ +export type AddonModWikiGetSubwikiPagesOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User to get pages from. + groupId?: number; // Group to get pages from. + sortBy?: string; // The attribute to sort the returned list. Defaults to 'title'. + sortDirection?: string; // Direction to sort the returned list (ASC | DESC). Defaults to 'ASC'. + includeContent?: boolean; // Whether the pages have to include their content. +}; + +/** + * Options to pass to newPageOnline. + */ +export type AddonModWikiNewPageOnlineOptions = { + subwikiId?: number; // Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + wikiId?: number; // Wiki ID. Optional, will be used to create a new subwiki if subwikiId not supplied. + userId?: number; // User ID. Optional, will be used to create a new subwiki if subwikiId not supplied. + groupId?: number; // Group ID. Optional, will be used to create a new subwiki if subwikiId not supplied. + siteId?: string; // Site ID. If not defined, current site. +}; + +/** + * Options to pass to newPage. + */ +export type AddonModWikiNewPageOptions = AddonModWikiNewPageOnlineOptions & { + cmId?: number; // Module ID. +}; + +export type AddonModWikiSubwikiListData = { + count: number; // Number of subwikis. + subwikiSelected: number; // Subwiki ID currently selected. + userSelected: number; // User of the subwiki currently selected. + groupSelected: number; // Group of the subwiki currently selected. + subwikis: AddonModWikiSubwikiListGrouping[]; // List of subwikis, grouped by a certain label. +}; + +export type AddonModWikiSubwikiListGrouping = { + label: string; + subwikis: AddonModWikiSubwikiListSubwiki[]; +}; + +export type AddonModWikiSubwikiListSubwiki = { + name: string; + id: number; + userid: number; + groupid: number; + groupLabel: string; + canedit: boolean; +}; + +/** + * Params of mod_wiki_edit_page WS. + */ +export type AddonModWikiEditPageWSParams = { + pageid: number; // Page ID. + content: string; // Page contents. + section?: string; // Section page title. +}; + +/** + * Data returned by mod_wiki_edit_page WS. + */ +export type AddonModWikiEditPageWSResponse = { + pageid: number; // Edited page id. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_wiki_get_page_contents WS. + */ +export type AddonModWikiGetPageContentsWSParams = { + pageid: number; // Page ID. +}; + +/** + * Data returned by mod_wiki_get_page_contents WS. + */ +export type AddonModWikiGetPageContentsWSResponse = { + page: AddonModWikiPageContents; // Page. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Page data returned by mod_wiki_get_page_contents WS. + */ +export type AddonModWikiPageContents = { + id: number; // Page ID. + wikiid: number; // Page's wiki ID. + subwikiid: number; // Page's subwiki ID. + groupid: number; // Page's group ID. + userid: number; // Page's user ID. + title: string; // Page title. + cachedcontent: string; // Page contents. + contentformat?: number; // Cachedcontent format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + caneditpage: boolean; // True if user can edit the page. + version?: number; // Latest version of the page. + tags?: CoreTagItem[]; // Tags. +}; + +/** + * Params of mod_wiki_get_page_for_editing WS. + */ +export type AddonModWikiGetPageForEditingWSParams = { + pageid: number; // Page ID to edit. + section?: string; // Section page title. + lockonly?: boolean; // Just renew lock and not return content. +}; + +/** + * Data returned by mod_wiki_get_page_for_editing WS. + */ +export type AddonModWikiGetPageForEditingWSResponse = { + pagesection: AddonModWikiWSEditPageSection; +}; + +/** + * Page section data returned by mod_wiki_get_page_for_editing WS. + */ +export type AddonModWikiWSEditPageSection = { + content?: string; // The contents of the page-section to be edited. + contentformat?: string; // Format of the original content of the page. + version: number; // Latest version of the page. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_wiki_get_subwiki_files WS. + */ +export type AddonModWikiGetSubwikiFilesWSParams = { + wikiid: number; // Wiki instance ID. + groupid?: number; // Subwiki's group ID, -1 means current group. It will be ignored if the wiki doesn't use groups. + userid?: number; // Subwiki's user ID, 0 means current user. It will be ignored in collaborative wikis. +}; + +/** + * Data returned by mod_wiki_get_subwiki_files WS. + */ +export type AddonModWikiGetSubwikiFilesWSResponse = { + files: CoreWSExternalFile[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_wiki_get_subwiki_pages WS. + */ +export type AddonModWikiGetSubwikiPagesWSParams = { + wikiid: number; // Wiki instance ID. + groupid?: number; // Subwiki's group ID, -1 means current group. It will be ignored if the wiki doesn't use groups. + userid?: number; // Subwiki's user ID, 0 means current user. It will be ignored in collaborative wikis. + options?: { + sortby?: string; // Field to sort by (id, title, ...). + sortdirection?: string; // Sort direction: ASC or DESC. + includecontent?: number; // Include each page contents or just the contents size. + }; // Options. +}; + +/** + * Data returned by mod_wiki_get_subwiki_pages WS. + */ +export type AddonModWikiGetSubwikiPagesWSResponse = { + pages: AddonModWikiSubwikiPage[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Page data returned by mod_wiki_get_subwiki_pages WS. + */ +export type AddonModWikiSubwikiPage = { + id: number; // Page ID. + subwikiid: number; // Page's subwiki ID. + title: string; // Page title. + timecreated: number; // Time of creation. + timemodified: number; // Time of last modification. + timerendered: number; // Time of last renderization. + userid: number; // ID of the user that last modified the page. + pageviews: number; // Number of times the page has been viewed. + readonly: number; // 1 if readonly, 0 otherwise. + caneditpage: boolean; // True if user can edit the page. + firstpage: boolean; // True if it's the first page. + cachedcontent?: string; // Page contents. + contentformat?: number; // Cachedcontent format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + contentsize?: number; // Size of page contents in bytes (doesn't include size of attached files). + tags?: CoreTagItem[]; // Tags. +}; + +/** + * Params of mod_wiki_get_subwikis WS. + */ +export type AddonModWikiGetSubwikisWSParams = { + wikiid: number; // Wiki instance ID. +}; + +/** + * Data returned by mod_wiki_get_subwikis WS. + */ +export type AddonModWikiGetSubwikisWSResponse = { + subwikis: AddonModWikiSubwiki[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Subwiki data returned by mod_wiki_get_subwikis WS. + */ +export type AddonModWikiSubwiki = { + id: number; // Subwiki ID. + wikiid: number; // Wiki ID. + groupid: number; // Group ID. + userid: number; // User ID. + canedit: boolean; // True if user can edit the subwiki. +}; + +/** + * Params of mod_wiki_get_wikis_by_courses WS. + */ +export type AddonModWikiGetWikisByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_wiki_get_wikis_by_courses WS. + */ +export type AddonModWikiGetWikisByCoursesWSResponse = { + wikis: AddonModWikiWiki[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Wiki data returned by mod_wiki_get_wikis_by_courses WS. + */ +export type AddonModWikiWiki = { + id: number; // Wiki ID. + coursemodule: number; // Course module ID. + course: number; // Course ID. + name: string; // Wiki name. + intro?: string; // Wiki intro. + introformat?: number; // Wiki intro format. format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; + timecreated?: number; // Time of creation. + timemodified?: number; // Time of last modification. + firstpagetitle?: string; // First page title. + wikimode?: string; // Wiki mode (individual, collaborative). + defaultformat?: string; // Wiki's default format (html, creole, nwiki). + forceformat?: number; // 1 if format is forced, 0 otherwise. + editbegin?: number; // Edit begin. + editend?: number; // Edit end. + section?: number; // Course section ID. + visible?: number; // 1 if visible, 0 otherwise. + groupmode?: number; // Group mode. + groupingid?: number; // Group ID. + cancreatepages: boolean; // True if user can create pages. +}; + +/** + * Params of mod_wiki_view_page WS. + */ +export type AddonModWikiViewPageWSParams = { + pageid: number; // Wiki page ID. +}; + +/** + * Params of mod_wiki_view_wiki WS. + */ +export type AddonModWikiViewWikiWSParams = { + wikiid: number; // Wiki instance ID. +}; + +/** + * Params of mod_wiki_new_page WS. + */ +export type AddonModWikiNewPageWSParams = { + title: string; // New page title. + content: string; // Page contents. + contentformat?: string; // Page contents format. If an invalid format is provided, default wiki format is used. + subwikiid?: number; // Page's subwiki ID. + wikiid?: number; // Page's wiki ID. Used if subwiki does not exists. + userid?: number; // Subwiki's user ID. Used if subwiki does not exists. + groupid?: number; // Subwiki's group ID. Used if subwiki does not exists. +}; + +/** + * Data returned by mod_wiki_new_page WS. + */ +export type AddonModWikiNewPageWSResponse = { + pageid: number; // New page id. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data passed to PAGE_CREATED event. + */ +export type AddonModWikiPageCreatedData = { + pageId: number; + subwikiId: number; + pageTitle: string; +}; + +/** + * Data about a page that was just edited. + */ +export type AddonModWikiEditedPageData = { + cmId: number; + courseId: number; + wikiId: number; + pageTitle: string; + subwikiId?: number; + userId?: number; + groupId?: number; + pageId?: number; +}; diff --git a/src/addons/mod/wiki/wiki-lazy.module.ts b/src/addons/mod/wiki/wiki-lazy.module.ts new file mode 100644 index 000000000..37015b8f4 --- /dev/null +++ b/src/addons/mod/wiki/wiki-lazy.module.ts @@ -0,0 +1,48 @@ +// (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 { AddonModWikiComponentsModule } from './components/components.module'; +import { AddonModWikiIndexPage } from './pages/index/index'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CanLeaveGuard } from '@guards/can-leave'; +import { AddonModWikiEditPage } from './pages/edit/edit'; + +const routes: Routes = [ + { + path: ':courseId/:cmId/page/:hash', + component: AddonModWikiIndexPage, + }, + { + path: ':courseId/:cmId/edit', + component: AddonModWikiEditPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModWikiComponentsModule, + CoreEditorComponentsModule, + ], + declarations: [ + AddonModWikiIndexPage, + AddonModWikiEditPage, + ], +}) +export class AddonModWikiLazyModule {} diff --git a/src/addons/mod/wiki/wiki.module.ts b/src/addons/mod/wiki/wiki.module.ts new file mode 100644 index 000000000..409c64783 --- /dev/null +++ b/src/addons/mod/wiki/wiki.module.ts @@ -0,0 +1,81 @@ +// (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 { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModWikiComponentsModule } from './components/components.module'; +import { OFFLINE_SITE_SCHEMA } from './services/database/wiki'; +import { AddonModWikiCreateLinkHandler } from './services/handlers/create-link'; +import { AddonModWikiEditLinkHandler } from './services/handlers/edit-link'; +import { AddonModWikiIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModWikiListLinkHandler } from './services/handlers/list-link'; +import { AddonModWikiModuleHandler, AddonModWikiModuleHandlerService } from './services/handlers/module'; +import { AddonModWikiPageOrMapLinkHandler } from './services/handlers/page-or-map-link'; +import { AddonModWikiPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModWikiSyncCronHandler } from './services/handlers/sync-cron'; +import { AddonModWikiTagAreaHandler } from './services/handlers/tag-area'; +import { AddonModWikiProvider } from './services/wiki'; +import { AddonModWikiOfflineProvider } from './services/wiki-offline'; +import { AddonModWikiSyncProvider } from './services/wiki-sync'; + +export const ADDON_MOD_WIKI_SERVICES: Type[] = [ + AddonModWikiProvider, + AddonModWikiOfflineProvider, + AddonModWikiSyncProvider, +]; + +const routes: Routes = [ + { + path: AddonModWikiModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./wiki-lazy.module').then(m => m.AddonModWikiLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModWikiComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModWikiModuleHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModWikiPrefetchHandler.instance); + CoreCronDelegate.register(AddonModWikiSyncCronHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModWikiIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModWikiListLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModWikiCreateLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModWikiEditLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModWikiPageOrMapLinkHandler.instance); + CoreTagAreaDelegate.registerHandler(AddonModWikiTagAreaHandler.instance); + }, + }, + ], +}) +export class AddonModWikiModule {} diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 4c1deb91b..9455e4c6a 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -17,6 +17,8 @@ import { SQLiteObject } from '@ionic-native/sqlite/ngx'; import { SQLite, Platform } from '@singletons'; import { CoreError } from '@classes/errors/error'; +type SQLiteDBColumnType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB'; + /** * Schema of a table. */ @@ -64,7 +66,7 @@ export interface SQLiteDBColumnSchema { /** * Column's type. */ - type?: 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB'; + type?: SQLiteDBColumnType; /** * Whether the column is a primary key. Use it only if primary key is a single column. @@ -145,6 +147,30 @@ export class SQLiteDB { this.init(); } + /** + * Add a column to an existing table. + * + * @param table Table name. + * @param column Name of the column to add. + * @param type Type of the column to add. + * @param constraints Other constraints (e.g. NOT NULL). + * @return Promise resolved when done. + */ + async addColumn(table: string, column: string, type: SQLiteDBColumnType, constraints?: string): Promise { + constraints = constraints || ''; + + try { + await this.execute(`ALTER TABLE ${table} ADD COLUMN ${column} ${type} ${constraints}`); + } catch (error) { + if (error && error.code == 5 && error?.message.indexOf('duplicate column name') != -1) { + // Column already exists. + return; + } + + throw error; + } + } + /** * Helper function to create a table if it doesn't exist. * @@ -839,25 +865,19 @@ export class SQLiteDB { * * @param table The database table to be inserted into. * @param source The database table to get the records from. - * @param conditions The conditions to build the where clause. Must not contain numeric indexes. - * @param fields A comma separated list of fields to return. * @return Promise resolved when done. */ async insertRecordsFrom( table: string, source: string, - conditions?: SQLiteDBRecordValues, - fields: string = '*', ): Promise { - const selectAndParams = this.whereClause(conditions); - const select = selectAndParams.sql ? 'WHERE ' + selectAndParams.sql : ''; - const params = selectAndParams.params; + const records = await this.getAllRecords(source); - await this.execute(`INSERT INTO ${table} SELECT ${fields} FROM ${source} ${select}`, params); + await Promise.all(records.map((record) => this.insertRecord(table, record))); } /** - * Helper migration function for tables. + * Migrate all the data from a table to another table. * It will check if old table exists and drop it when finished. * * @param oldTable Old table name. diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 3ec49affb..cad7df44a 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -141,7 +141,7 @@ import { ADDON_MOD_RESOURCE_SERVICES } from '@addons/mod/resource/resource.modul import { ADDON_MOD_SCORM_SERVICES } from '@addons/mod/scorm/scorm.module'; import { ADDON_MOD_SURVEY_SERVICES } from '@addons/mod/survey/survey.module'; import { ADDON_MOD_URL_SERVICES } from '@addons/mod/url/url.module'; -// @todo import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module'; +import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module'; // @todo import { ADDON_MOD_WORKSHOP_SERVICES } from '@addons/mod/workshop/workshop.module'; import { ADDON_NOTES_SERVICES } from '@addons/notes/notes.module'; import { ADDON_NOTIFICATIONS_SERVICES } from '@addons/notifications/notifications.module'; @@ -307,7 +307,7 @@ export class CoreCompileProvider { ...ADDON_MOD_SCORM_SERVICES, ...ADDON_MOD_SURVEY_SERVICES, ...ADDON_MOD_URL_SERVICES, - // @todo ...ADDON_MOD_WIKI_SERVICES, + ...ADDON_MOD_WIKI_SERVICES, // @todo ...ADDON_MOD_WORKSHOP_SERVICES, ...ADDON_NOTES_SERVICES, ...ADDON_NOTIFICATIONS_SERVICES, diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index de3db9eef..36e5ef902 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -42,6 +42,7 @@ import { CoreCronDelegate } from '@services/cron'; import { CoreCourseLogCronHandler } from './handlers/log-cron'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; import { CoreCourseAutoSyncData, CoreCourseSyncProvider } from './sync'; +import { CoreTagItem } from '@features/tag/services/tag'; const ROOT_CACHE_KEY = 'mmCourse:'; @@ -1473,18 +1474,7 @@ export type CoreCourseModuleContentFile = { userid: number; // User who added this content to moodle. author: string; // Content owner. license: string; // Content license. - tags?: { // Tags. - id: number; // Tag id. - name: string; // Tag name. - rawname: string; // The raw, unnormalised name for the tag as entered by users. - isstandard: boolean; // Whether this tag is standard. - tagcollid: number; // Tag collection id. - taginstanceid: number; // Tag instance id. - taginstancecontextid: number; // Context the tag instance belongs to. - itemid: number; // Id of the record tagged. - ordering: number; // Tag ordering. - flag: number; // Whether the tag is flagged as inappropriate. - }[]; + tags?: CoreTagItem[]; // Tags. }; /** diff --git a/src/core/features/h5p/services/database/h5p.ts b/src/core/features/h5p/services/database/h5p.ts index a324a1ae2..4ff57daa5 100644 --- a/src/core/features/h5p/services/database/h5p.ts +++ b/src/core/features/h5p/services/database/h5p.ts @@ -20,7 +20,7 @@ import { CoreSiteSchema } from '@services/sites'; */ // DB table names. export const CONTENT_TABLE_NAME = 'h5p_content'; // H5P content. -export const LIBRARIES_TABLE_NAME = 'h5p_libraries_2'; // Installed libraries. +export const LIBRARIES_TABLE_NAME = 'h5p_libraries'; // Installed libraries. export const LIBRARY_DEPENDENCIES_TABLE_NAME = 'h5p_library_dependencies'; // Library dependencies. export const CONTENTS_LIBRARIES_TABLE_NAME = 'h5p_contents_libraries'; // Which library is used in which content. export const LIBRARIES_CACHEDASSETS_TABLE_NAME = 'h5p_libraries_cachedassets'; // H5P cached library assets. @@ -249,8 +249,8 @@ export const SITE_SCHEMA: CoreSiteSchema = { return; } - // Move the records from the old table. - await db.migrateTable('h5p_libraries', LIBRARIES_TABLE_NAME); + // Add the metadata column to the table. + await db.addColumn(LIBRARIES_TABLE_NAME, 'metadatasettings', 'TEXT'); }, }; diff --git a/src/core/features/siteplugins/components/module-index/module-index.ts b/src/core/features/siteplugins/components/module-index/module-index.ts index c3d5c322d..40061f0ec 100644 --- a/src/core/features/siteplugins/components/module-index/module-index.ts +++ b/src/core/features/siteplugins/components/module-index/module-index.ts @@ -79,7 +79,7 @@ export class CoreSitePluginsModuleIndexComponent implements OnInit, OnDestroy, C * Component being initialized. */ ngOnInit(): void { - this.refreshIcon = 'spinner'; + this.refreshIcon = CoreConstants.ICON_LOADING; if (!this.module) { return; diff --git a/src/core/features/tag/services/tag.ts b/src/core/features/tag/services/tag.ts index 13018d59e..4d1d6bd77 100644 --- a/src/core/features/tag/services/tag.ts +++ b/src/core/features/tag/services/tag.ts @@ -428,14 +428,14 @@ export type CoreTagIndex = { * Structure of a tag item returned by WS. */ export type CoreTagItem = { - id: number; - name: string; - rawname: string; - isstandard: boolean; - tagcollid: number; - taginstanceid: number; - taginstancecontextid: number; - itemid: number; - ordering: number; - flag: number; + id: number; // Tag id. + name: string; // Tag name. + rawname: string; // The raw, unnormalised name for the tag as entered by users. + isstandard: boolean; // Whether this tag is standard. + tagcollid: number; // Tag collection id. + taginstanceid: number; // Tag instance id. + taginstancecontextid: number; // Context the tag instance belongs to. + itemid: number; // Id of the record tagged. + ordering: number; // Tag ordering. + flag: number; // Whether the tag is flagged as inappropriate. }; diff --git a/src/core/services/app.ts b/src/core/services/app.ts index 05b6a8181..3859c2236 100644 --- a/src/core/services/app.ts +++ b/src/core/services/app.ts @@ -151,7 +151,7 @@ export class CoreAppProvider { if (schema.tables) { await this.db.createTablesFromSchema(schema.tables); } - if (schema.migrate) { + if (schema.migrate && oldVersion > 0) { await schema.migrate(this.db, oldVersion); } diff --git a/src/core/services/database/sites.ts b/src/core/services/database/sites.ts index 4f4968ec6..857bdba4b 100644 --- a/src/core/services/database/sites.ts +++ b/src/core/services/database/sites.ts @@ -144,7 +144,7 @@ export const SITE_SCHEMA: CoreSiteSchema = { }, ], async migrate(db: SQLiteDB, oldVersion: number): Promise { - if (oldVersion && oldVersion < 2) { + if (oldVersion < 2) { await db.migrateTable('wscache', CoreSite.WS_CACHE_TABLE, (record) => ({ id: record.id, data: record.data, diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index aa808443a..ab361fcfa 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -96,8 +96,17 @@ export class CoreNavigatorService { * @return Current main menu tab or null if the current route is not using the main menu. */ getCurrentMainMenuTab(): string | null { - const currentPath = this.getCurrentPath(); - const matches = /^\/main\/([^/]+).*$/.exec(currentPath); + return this.getMainMenuTabFromPath(this.getCurrentPath()); + } + + /** + * Get main menu tab from a path. + * + * @param path The path to check. + * @return Path's main menu tab or null if the path is not using the main menu. + */ + getMainMenuTabFromPath(path: string): string | null { + const matches = /^\/main\/([^/]+).*$/.exec(path); return matches?.[1] ?? null; } diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 63c7e9014..f2be5fb5a 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -1581,7 +1581,7 @@ export class CoreSitesProvider { if (schema.tables) { await db.createTablesFromSchema(schema.tables); } - if (schema.migrate) { + if (schema.migrate && oldVersion > 0) { await schema.migrate(db, oldVersion, site.id); }