From fa32fde524c91fbea599541235ea0933151f2442 Mon Sep 17 00:00:00 2001 From: Alfonso Salces Date: Mon, 8 Apr 2024 09:42:48 +0200 Subject: [PATCH] MOBILE-4547 blog: Support offline blog --- src/addons/blog/blog.module.ts | 10 + src/addons/blog/constants.ts | 3 + .../blog/pages/edit-entry/edit-entry.ts | 212 ++++++++++--- src/addons/blog/pages/index/index.html | 56 +++- src/addons/blog/pages/index/index.ts | 281 +++++++++++++----- src/addons/blog/services/blog-offline.ts | 179 +++++++++++ src/addons/blog/services/blog-sync.ts | 251 ++++++++++++++++ src/addons/blog/services/blog.ts | 268 ++++++++++++++++- src/addons/blog/services/database/blog.ts | 93 ++++++ .../blog/services/handlers/sync-cron.ts | 51 ++++ 10 files changed, 1251 insertions(+), 153 deletions(-) create mode 100644 src/addons/blog/services/blog-offline.ts create mode 100644 src/addons/blog/services/blog-sync.ts create mode 100644 src/addons/blog/services/database/blog.ts create mode 100644 src/addons/blog/services/handlers/sync-cron.ts diff --git a/src/addons/blog/blog.module.ts b/src/addons/blog/blog.module.ts index 49dac76c555..06959bdf308 100644 --- a/src/addons/blog/blog.module.ts +++ b/src/addons/blog/blog.module.ts @@ -29,6 +29,10 @@ import { AddonBlogMainMenuHandler } from './services/handlers/mainmenu'; import { AddonBlogTagAreaHandler } from './services/handlers/tag-area'; import { AddonBlogUserHandler } from './services/handlers/user'; import { ADDON_BLOG_MAINMENU_PAGE_NAME } from './constants'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { BLOG_OFFLINE_SITE_SCHEMA } from './services/database/blog'; +import { CoreCronDelegate } from '@services/cron'; +import { AddonBlogSyncCronHandler } from './services/handlers/sync-cron'; const routes: Routes = [ { @@ -44,6 +48,11 @@ const routes: Routes = [ CoreCourseIndexRoutingModule.forChild({ children: routes }), ], providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [BLOG_OFFLINE_SITE_SCHEMA], + multi: true, + }, { provide: APP_INITIALIZER, multi: true, @@ -54,6 +63,7 @@ const routes: Routes = [ CoreUserDelegate.registerHandler(AddonBlogUserHandler.instance); CoreTagAreaDelegate.registerHandler(AddonBlogTagAreaHandler.instance); CoreCourseOptionsDelegate.registerHandler(AddonBlogCourseOptionHandler.instance); + CoreCronDelegate.register(AddonBlogSyncCronHandler.instance); }, }, ], diff --git a/src/addons/blog/constants.ts b/src/addons/blog/constants.ts index de45e0da168..69a3e040a6d 100644 --- a/src/addons/blog/constants.ts +++ b/src/addons/blog/constants.ts @@ -14,3 +14,6 @@ export const ADDON_BLOG_MAINMENU_PAGE_NAME = 'blog'; export const ADDON_BLOG_ENTRY_UPDATED = 'blog_entry_updated'; +export const ADDON_BLOG_AUTO_SYNCED = 'addon_blog_autom_synced'; +export const ADDON_BLOG_MANUAL_SYNCED = 'addon_blog_manual_synced'; +export const ADDON_BLOG_SYNC_ID = 'blog'; diff --git a/src/addons/blog/pages/edit-entry/edit-entry.ts b/src/addons/blog/pages/edit-entry/edit-entry.ts index 8dca5c6c99d..9eaba2f330a 100644 --- a/src/addons/blog/pages/edit-entry/edit-entry.ts +++ b/src/addons/blog/pages/edit-entry/edit-entry.ts @@ -13,7 +13,7 @@ // limitations under the License. import { ContextLevel } from '@/core/constants'; import { CoreSharedModule } from '@/core/shared.module'; -import { ADDON_BLOG_ENTRY_UPDATED } from '@addons/blog/constants'; +import { ADDON_BLOG_ENTRY_UPDATED, ADDON_BLOG_SYNC_ID } from '@addons/blog/constants'; import { AddonBlog, AddonBlogAddEntryOption, @@ -22,7 +22,9 @@ import { AddonBlogProvider, AddonBlogPublishState, } from '@addons/blog/services/blog'; -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { AddonBlogOffline } from '@addons/blog/services/blog-offline'; +import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { AddonBlogSync } from '@addons/blog/services/blog-sync'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { CoreError } from '@classes/errors/error'; import { CoreCommentsComponentsModule } from '@features/comments/components/components.module'; @@ -30,18 +32,21 @@ import { CoreCourse } from '@features/course/services/course'; import { CoreCourseHelper, CoreCourseModuleData } from '@features/course/services/course-helper'; import { CoreCourseBasicData } from '@features/courses/services/courses'; import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; -import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; import { CoreTagComponentsModule } from '@features/tag/components/components.module'; import { CanLeave } from '@guards/can-leave'; import { CoreLoadings } from '@services/loadings'; import { CoreNavigator } from '@services/navigator'; +import { CoreNetwork } from '@services/network'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; -import { CoreWSFile } from '@services/ws'; import { Translate } from '@singletons'; import { CoreEvents } from '@singletons/events'; import { CoreForms } from '@singletons/form'; +import { CoreFileEntry } from '@services/file-helper'; +import { CoreTimeUtils } from '@services/utils/time'; @Component({ selector: 'addon-blog-edit-entry', @@ -54,7 +59,7 @@ import { CoreForms } from '@singletons/form'; CoreTagComponentsModule, ], }) -export class AddonBlogEditEntryPage implements CanLeave, OnInit { +export class AddonBlogEditEntryPage implements CanLeave, OnInit, OnDestroy { @ViewChild('editEntryForm') formElement!: ElementRef; @@ -70,11 +75,11 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { associateWithModule: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), }); - entry?: AddonBlogPost; + entry?: AddonBlogPost | AddonBlogEditEntryFormattedOfflinePost; loaded = false; maxFiles = 99; - initialFiles: CoreWSFile[] = []; - files: CoreWSFile[] = []; + initialFiles: CoreFileEntry[] = []; + files: CoreFileEntry[] = []; courseId?: number; modId?: number; userId?: number; @@ -88,6 +93,7 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { component = AddonBlogProvider.COMPONENT; siteHomeId?: number; forceLeave = false; + isOfflineEntry = false; /** * Gives if the form is not pristine. (only for existing entries) @@ -130,15 +136,17 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { return CoreNavigator.back(); } - const entryId = CoreNavigator.getRouteNumberParam('id'); + const entryId = CoreNavigator.getRouteParam('id'); const lastModified = CoreNavigator.getRouteNumberParam('lastModified'); const filters: AddonBlogFilter | undefined = CoreNavigator.getRouteParam('filters'); const courseId = CoreNavigator.getRouteNumberParam('courseId'); const cmId = CoreNavigator.getRouteNumberParam('cmId'); this.userId = CoreNavigator.getRouteNumberParam('userId'); this.siteHomeId = CoreSites.getCurrentSiteHomeId(); + this.isOfflineEntry = entryId?.startsWith('new-') ?? false; + const entryIdParsed = Number(entryId); - if (!entryId) { + if (entryIdParsed === 0) { this.loaded = true; try { @@ -162,11 +170,27 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { } try { - this.entry = await this.getEntry({ filters, lastModified, entryId }); - this.files = this.entry.attachmentfiles ?? []; + await AddonBlogSync.waitForSync(ADDON_BLOG_SYNC_ID); + + if (!this.isOfflineEntry) { + const offlineContent = await this.getFormattedBlogOfflineEntry({ id: entryIdParsed }); + this.entry = offlineContent ?? await this.getEntry({ filters, lastModified, entryId: entryIdParsed }); + } else { + this.entry = await this.getFormattedBlogOfflineEntry({ created: Number(entryId?.slice(4)) }); + + if (!this.entry) { + throw new CoreError('This offline entry no longer exists.'); + } + } + + this.files = [...(this.entry.attachmentfiles ?? [])]; this.initialFiles = [...this.files]; - this.courseId = this.courseId || this.entry.courseid; - this.modId = CoreNavigator.getRouteNumberParam('cmId') || this.entry.coursemoduleid; + + if (this.entry) { + CoreSync.blockOperation(AddonBlogProvider.COMPONENT, this.entry.id ?? this.entry.created); + this.courseId = this.courseId || this.entry.courseid; + this.modId = CoreNavigator.getRouteNumberParam('cmId') || this.entry.coursemoduleid; + } if (this.courseId) { this.form.controls.associateWithCourse.setValue(true); @@ -198,6 +222,17 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { this.loaded = true; } + /** + * @inheritdoc + */ + ngOnDestroy(): void { + if (!this.entry) { + return; + } + + CoreSync.unblockOperation(AddonBlogProvider.COMPONENT, this.entry.id ?? this.entry.created); + } + /** * Retrieves blog entry. * @@ -270,14 +305,27 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { const loading = await CoreLoadings.show('core.sending', true); - if (this.entry) { + if (this.entry?.id) { try { + if (!CoreNetwork.isOnline()) { + const attachmentsId = await this.uploadOrStoreFiles({ entryId: this.entry.id }); + + return await this.saveEntry({ attachmentsId }); + } + if (!CoreFileUploader.areFileListDifferent(this.files, this.initialFiles)) { - return await this.saveEntry(); + return await this.saveEntry({}); } const { attachmentsid } = await AddonBlog.prepareEntryForEdition({ entryid: this.entry.id }); - const removedFiles = CoreFileUploader.getFilesToDelete(this.initialFiles, this.files); + + const lastModified = CoreNavigator.getRouteNumberParam('lastModified'); + const filters: AddonBlogFilter | undefined = CoreNavigator.getRouteParam('filters'); + const entry = this.entry && 'attachment' in this.entry + ? this.entry + : await CoreUtils.ignoreErrors(this.getEntry({ filters, lastModified, entryId: this.entry.id })); + + const removedFiles = CoreFileUploader.getFilesToDelete(entry?.attachmentfiles ?? [], this.files); if (removedFiles.length) { await CoreFileUploader.deleteDraftFiles(attachmentsid, removedFiles); @@ -285,30 +333,65 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { await CoreFileUploader.uploadFiles(attachmentsid, this.files); - return await this.saveEntry(attachmentsid); + return await this.saveEntry({ attachmentsId: attachmentsid }); } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'Error updating entry.'); + if (CoreUtils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + CoreDomUtils.showErrorModalDefault(error, 'Error updating entry.'); + + return; + } + + const attachmentsId = await this.uploadOrStoreFiles({ entryId: this.entry.id, forceStorage: true }); + + return await this.saveEntry({ attachmentsId, forceOffline: true }); } finally { await loading.dismiss(); } - - return; } + const created = this.entry?.created ?? CoreTimeUtils.timestamp(); + try { if (!this.files.length) { - return await this.saveEntry(); + return await this.saveEntry({ created }); } - const attachmentId = await CoreFileUploader.uploadOrReuploadFiles(this.files, this.component); - await this.saveEntry(attachmentId); + const attachmentsId = await this.uploadOrStoreFiles({ created }); + await this.saveEntry({ created, attachmentsId }); } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'Error creating entry.'); + if (CoreUtils.isWebServiceError(error)) { + // It's a WebService error, the user cannot send the message so don't store it. + CoreDomUtils.showErrorModalDefault(error, 'Error creating entry.'); + + return; + } + + const attachmentsId = await this.uploadOrStoreFiles({ created, forceStorage: true }); + + return await this.saveEntry({ attachmentsId, forceOffline: true }); } finally { await loading.dismiss(); } } + /** + * Upload or store locally files. + * + * @param param Folder where files will be located. + * @returns folder where files will be located. + */ + async uploadOrStoreFiles(param: AddonBlogEditEntryUploadOrStoreFilesParam): Promise { + if (CoreNetwork.isOnline() && !param.forceStorage) { + return await CoreFileUploader.uploadOrReuploadFiles(this.files, this.component); + } + + const folder = 'entryId' in param ? { id: param.entryId } : { created: param.created }; + const folderPath = await AddonBlogOffline.getOfflineEntryFilesFolderPath(folder); + + return await CoreFileUploader.storeFilesToUpload(folderPath, this.files); + } + /** * Expand or collapse associations. */ @@ -336,27 +419,13 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { return true; } - /** - * Add attachment to options list. - * - * @param attachmentsId Attachment ID. - * @param options Options list. - */ - addAttachments(attachmentsId: number | undefined, options: AddonBlogAddEntryOption[]): void { - if (attachmentsId === undefined) { - return; - } - - options.push({ name: 'attachmentsid', value: attachmentsId }); - } - /** * Create or update entry. * - * @param attachmentsId Attachments. + * @param params Creation date and attachments ID. * @returns Promise resolved when done. */ - async saveEntry(attachmentsId?: number): Promise { + async saveEntry(params: AddonBlogEditEntrySaveEntryParams): Promise { const { summary, subject, publishState } = this.form.value; if (!summary || !subject || !publishState) { @@ -369,11 +438,30 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { { name: 'modassoc', value: this.form.controls.associateWithModule.value && this.modId ? this.modId : 0 }, ]; - this.addAttachments(attachmentsId, options); + if (params.attachmentsId) { + options.push({ name: 'attachmentsid', value: params.attachmentsId }); + } - this.entry - ? await AddonBlog.updateEntry({ subject, summary, summaryformat: 1, options , entryid: this.entry.id }) - : await AddonBlog.addEntry({ subject, summary, summaryformat: 1, options }); + if (!this.entry?.id) { + await AddonBlog.addEntry({ + subject, + summary, + summaryformat: 1, + options, + created: params.created ?? CoreTimeUtils.timestamp(), + forceOffline: params.forceOffline, + }); + } else { + await AddonBlog.updateEntry({ + subject, + summary, + summaryformat: 1, + options, + forceOffline: params.forceOffline, + entryid: this.entry.id, + created: this.entry.created, + }); + } CoreEvents.trigger(ADDON_BLOG_ENTRY_UPDATED); this.forceLeave = true; @@ -382,10 +470,36 @@ export class AddonBlogEditEntryPage implements CanLeave, OnInit { return CoreNavigator.back(); } + /** + * Retrieves a formatted blog offline entry. + * + * @param params Entry creation date or entry ID. + * @returns Formatted entry. + */ + async getFormattedBlogOfflineEntry( + params: AddonBlogEditGetFormattedBlogOfflineEntryParams, + ): Promise { + const entryRecord = await AddonBlogOffline.getOfflineEntry(params); + + return entryRecord ? await AddonBlog.formatOfflineEntry(entryRecord) : undefined; + } + } -type AddonBlogEditEntryGetEntryParams = { - entryId: number; - filters?: AddonBlogFilter; - lastModified?: number; +type AddonBlogEditGetFormattedBlogOfflineEntryParams = { id: number } | { created: number }; + +type AddonBlogEditEntryUploadOrStoreFilesParam = ({ entryId: number } | { created: number }) & { forceStorage?: boolean }; + +type AddonBlogEditEntryGetEntryParams = { entryId: number; filters?: AddonBlogFilter; lastModified?: number }; + +type AddonBlogEditEntryPost = Omit & { id?: number }; + +type AddonBlogEditEntrySaveEntryParams = { + created?: number; + attachmentsId?: number | CoreFileUploaderStoreFilesResult; + forceOffline?: boolean; }; + +type AddonBlogEditEntryFormattedOfflinePost = Omit< + AddonBlogEditEntryPost, | 'attachment' | 'attachmentfiles' | 'rating' | 'format' | 'usermodified' | 'module' +> & { attachmentfiles?: CoreFileEntry[] }; diff --git a/src/addons/blog/pages/index/index.html b/src/addons/blog/pages/index/index.html index 3331c974de0..b3efd571f2e 100644 --- a/src/addons/blog/pages/index/index.html +++ b/src/addons/blog/pages/index/index.html @@ -7,6 +7,11 @@

{{ title | translate }}

+ + + + @@ -14,11 +19,11 @@

{{ title | translate }}

- + - + @if (showMyEntriesToggle) { @@ -27,8 +32,17 @@

{{ title | translate }}

} - @for (entry of entries; track entry.id) { -
+ @if (hasOfflineDataToSync()) { + + + + + } + + @for (entry of entries; track getEntryTemplateId(entry)) { +

{{ 'addon.blog.publishtonoone' | translate }} } + + @if (!getEntryId(entry) || entry.updatedOffline) { + + + + {{ 'core.notsent' | translate }} + + + }

- @if (entry.userid === currentUserId && optionsAvailable) { + @if (entry.userid === currentUserId && optionsAvailable && !entry.deleted) { } + + @if (entry.deleted) { + + + }
@@ -64,8 +93,8 @@

- +
@if (tagsEnabled && entry.tags && entry.tags!.length > 0) { @@ -77,9 +106,7 @@

} - @for (file of entry.attachmentfiles; track $index) { - - } + @if (entry.uniquehash) { @@ -106,8 +133,8 @@

} - @if (commentsEnabled) { - } @@ -121,12 +148,13 @@

- + @if ((filter.userid === currentUserId || showMyEntriesToggle) && loaded() && optionsAvailable) { + + } diff --git a/src/addons/blog/pages/index/index.ts b/src/addons/blog/pages/index/index.ts index 6a100b720f0..1bdab2551b8 100644 --- a/src/addons/blog/pages/index/index.ts +++ b/src/addons/blog/pages/index/index.ts @@ -12,19 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ContextLevel } from '@/core/constants'; -import { ADDON_BLOG_ENTRY_UPDATED } from '@addons/blog/constants'; -import { AddonBlog, AddonBlogFilter, AddonBlogPost, AddonBlogProvider } from '@addons/blog/services/blog'; -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ContextLevel, CoreConstants } from '@/core/constants'; +import { + ADDON_BLOG_AUTO_SYNCED, + ADDON_BLOG_ENTRY_UPDATED, + ADDON_BLOG_MANUAL_SYNCED, +} from '@addons/blog/constants'; +import { + AddonBlog, + AddonBlogFilter, + AddonBlogOfflinePostFormatted, + AddonBlogPostFormatted, + AddonBlogProvider, +} from '@addons/blog/services/blog'; +import { AddonBlogOffline, AddonBlogOfflineEntry } from '@addons/blog/services/blog-offline'; +import { AddonBlogSync } from '@addons/blog/services/blog-sync'; +import { Component, computed, OnDestroy, OnInit, signal } from '@angular/core'; import { CoreComments } from '@features/comments/services/comments'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; import { CoreTag } from '@features/tag/services/tag'; -import { CoreUser, CoreUserProfile } from '@features/user/services/user'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; import { CoreNavigator } from '@services/navigator'; +import { CoreNetwork } from '@services/network'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreFileHelper } from '@services/file-helper'; import { CoreUrl } from '@singletons/url'; import { CoreUtils } from '@services/utils/utils'; import { CoreArray } from '@singletons/array'; @@ -32,6 +43,7 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreTime } from '@singletons/time'; import { CorePopovers } from '@services/popovers'; import { CoreLoadings } from '@services/loadings'; +import { Subscription } from 'rxjs'; /** * Page that displays the list of blog entries. @@ -50,10 +62,13 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { protected siteHomeId: number; protected logView: () => void; - loaded = false; + loaded = signal(false); canLoadMore = false; loadMoreError = false; - entries: AddonBlogPostFormatted[] = []; + entries: (AddonBlogOfflinePostFormatted | AddonBlogPostFormatted)[] = []; + entriesToRemove: { id: number; subject: string }[] = []; + entriesToUpdate: AddonBlogOfflineEntry[] = []; + offlineEntries: AddonBlogOfflineEntry[] = []; currentUserId: number; showMyEntriesToggle = false; onlyMyEntries = false; @@ -63,11 +78,20 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { contextLevel: ContextLevel = ContextLevel.SYSTEM; contextInstanceId = 0; entryUpdateObserver: CoreEventObserver; + syncObserver: CoreEventObserver; + onlineObserver: Subscription; optionsAvailable = false; + hasOfflineDataToSync = signal(false); + isOnline = signal(false); + siteId: string; + syncIcon = CoreConstants.ICON_SYNC; + syncHidden = computed(() => !this.loaded() || !this.isOnline() || !this.hasOfflineDataToSync()); constructor() { this.currentUserId = CoreSites.getCurrentSiteUserId(); this.siteHomeId = CoreSites.getCurrentSiteHomeId(); + this.siteId = CoreSites.getCurrentSiteId(); + this.isOnline.set(CoreNetwork.isOnline()); this.logView = CoreTime.once(async () => { await CoreUtils.ignoreErrors(AddonBlog.logView(this.filter)); @@ -89,10 +113,35 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { }); this.entryUpdateObserver = CoreEvents.on(ADDON_BLOG_ENTRY_UPDATED, async () => { - this.loaded = false; + this.loaded.set(false); await CoreUtils.ignoreErrors(this.refresh()); - this.loaded = true; + this.loaded.set(true); }); + + this.syncObserver = CoreEvents.onMultiple([ADDON_BLOG_MANUAL_SYNCED, ADDON_BLOG_AUTO_SYNCED], async ({ source }) => { + if (this === source) { + return; + } + + this.loaded.set(false); + await CoreUtils.ignoreErrors(this.refresh(false)); + this.loaded.set(true); + }); + + // Refresh online status when changes. + this.onlineObserver = CoreNetwork.onChange().subscribe(async () => { + this.isOnline.set(CoreNetwork.isOnline()); + }); + } + + /** + * Retrieves an unique id to be used in template. + * + * @param entry Entry. + * @returns Entry template ID. + */ + getEntryTemplateId(entry: AddonBlogOfflinePostFormatted | AddonBlogPostFormatted): string { + return 'entry-' + ('id' in entry && entry.id ? entry.id : ('created-' + entry.created)); } /** @@ -156,23 +205,52 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { const deepLinkManager = new CoreMainMenuDeepLinkManager(); deepLinkManager.treatLink(); - await this.fetchEntries(); + await this.fetchEntries(false, false, true); this.optionsAvailable = await AddonBlog.isEditingEnabled(); } + /** + * Retrieves entry id or undefined. + * + * @param entry Entry. + * @returns Entry id or undefined. + */ + getEntryId(entry: AddonBlogPostFormatted | AddonBlogOfflinePostFormatted): number | undefined { + return this.isOnlineEntry(entry) ? entry.id : undefined; + } + /** * Fetch blog entries. * * @param refresh Empty events array first. * @returns Promise with the entries. */ - protected async fetchEntries(refresh: boolean = false): Promise { + protected async fetchEntries(refresh: boolean, showSyncErrors = false, sync?: boolean): Promise { this.loadMoreError = false; if (refresh) { this.pageLoaded = 0; } + if (this.isOnline() && sync) { + // Try to synchronize offline events. + try { + const result = await AddonBlogSync.syncEntriesForSite(CoreSites.getCurrentSiteId()); + + if (result.warnings && result.warnings.length) { + CoreDomUtils.showAlert(undefined, result.warnings[0]); + } + + if (result.updated) { + CoreEvents.trigger(ADDON_BLOG_MANUAL_SYNCED, { ...result, source: this }); + } + } catch (error) { + if (showSyncErrors) { + CoreDomUtils.showErrorModalDefault(error, 'core.errorsync', true); + } + } + } + try { const result = await AddonBlog.getEntries( this.filter, @@ -184,59 +262,72 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { }, ); - const promises = result.entries.map(async (entry: AddonBlogPostFormatted) => { - switch (entry.publishstate) { - case 'draft': - entry.publishTranslated = 'publishtonoone'; - break; - case 'site': - entry.publishTranslated = 'publishtosite'; - break; - case 'public': - entry.publishTranslated = 'publishtoworld'; - break; - default: - entry.publishTranslated = 'privacy:unknown'; - break; - } - - // Calculate the context. This code was inspired by calendar events, Moodle doesn't do this for blogs. - if (entry.moduleid || entry.coursemoduleid) { - entry.contextLevel = ContextLevel.MODULE; - entry.contextInstanceId = entry.moduleid || entry.coursemoduleid; - } else if (entry.courseid) { - entry.contextLevel = ContextLevel.COURSE; - entry.contextInstanceId = entry.courseid; - } else { - entry.contextLevel = ContextLevel.USER; - entry.contextInstanceId = entry.userid; - } + await Promise.all(result.entries.map(async (entry: AddonBlogPostFormatted) => AddonBlog.formatEntry(entry))); - entry.summary = CoreFileHelper.replacePluginfileUrls(entry.summary, entry.summaryfiles || []); + this.entries = refresh + ? result.entries + : this.entries.concat(result.entries).sort((a, b) => { + if ('id' in a && !('id' in b)) { + return 1; + } else if ('id' in b && !('id' in a)) { + return -1; + } - entry.user = await CoreUtils.ignoreErrors(CoreUser.getProfile(entry.userid, entry.courseid, true)); - }); - - if (refresh) { - this.entries = result.entries; - } else { - this.entries = CoreArray.unique(this.entries - .concat(result.entries), 'id') - .sort((a, b) => b.created - a.created); - } + return b.created - a.created; + }); this.canLoadMore = result.totalentries > this.entries.length; + await this.loadOfflineEntries(this.pageLoaded === 0); + this.entries = CoreArray.unique(this.entries, 'id'); + this.pageLoaded++; - await Promise.all(promises); this.logView(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true); this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. } finally { - this.loaded = true; + this.loaded.set(true); } } + /** + * Load offline entries and format them. + * + * @param loadCreated Load offline entries to create or not. + */ + async loadOfflineEntries(loadCreated: boolean): Promise { + if (loadCreated) { + this.offlineEntries = await AddonBlogOffline.getOfflineEntries(this.filter); + this.entriesToUpdate = this.offlineEntries.filter(entry => !!entry.id); + this.entriesToRemove = await AddonBlogOffline.getEntriesToRemove(); + const entriesToCreate = this.offlineEntries.filter(entry => !entry.id); + + const formattedEntries = await Promise.all(entriesToCreate.map(async (entryToCreate) => + await AddonBlog.formatOfflineEntry(entryToCreate))); + + this.entries = [...formattedEntries, ...this.entries]; + } + + if (this.entriesToUpdate.length) { + this.entries = await Promise.all(this.entries.map(async (entry) => { + const entryToUpdate = this.entriesToUpdate.find(entryToUpdate => + this.isOnlineEntry(entry) && entryToUpdate.id === entry.id); + + return !entryToUpdate || !('id' in entry) ? entry : await AddonBlog.formatOfflineEntry(entryToUpdate, entry); + })); + } + + for (const entryToRemove of this.entriesToRemove) { + const foundEntry = this.entries.find(entry => ('id' in entry && entry.id === entryToRemove.id)); + + if (foundEntry) { + foundEntry.deleted = true; + } + } + + this.hasOfflineDataToSync.set(this.offlineEntries.length > 0 || this.entriesToRemove.length > 0); + } + /** * Toggle between showing only my entries or not. * @@ -257,6 +348,16 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { } } + /** + * Check if provided entry is online. + * + * @param entry Entry. + * @returns Whether it's an online entry. + */ + isOnlineEntry(entry: AddonBlogOfflinePostFormatted | AddonBlogPostFormatted): entry is AddonBlogPostFormatted { + return 'id' in entry; + } + /** * Function to load more entries. * @@ -264,7 +365,7 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { * @returns Resolved when done. */ loadMore(infiniteComplete?: () => void): Promise { - return this.fetchEntries().finally(() => { + return this.fetchEntries(false).finally(() => { infiniteComplete && infiniteComplete(); }); } @@ -272,11 +373,21 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { /** * Refresh blog entries on PTR. * + * @param sync Sync entries. * @param refresher Refresher instance. */ - async refresh(refresher?: HTMLIonRefresherElement): Promise { - const promises = this.entries.map((entry) => - CoreComments.invalidateCommentsData(ContextLevel.USER, entry.userid, this.component, entry.id, 'format_blog')); + async refresh(sync = true, refresher?: HTMLIonRefresherElement): Promise { + const promises = this.entries.map((entry) => { + if (this.isOnlineEntry(entry)) { + return CoreComments.invalidateCommentsData( + ContextLevel.USER, + entry.userid, + this.component, + entry.id, + 'format_blog', + ); + } + }); promises.push(AddonBlog.invalidateEntries(this.filter)); @@ -291,7 +402,7 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { } await CoreUtils.allPromises(promises); - await this.fetchEntries(true); + await this.fetchEntries(true, false, sync); refresher?.complete(); } @@ -303,15 +414,21 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { } /** - * Delete entry by id. + * Delete entry. * - * @param id Entry id. + * @param entryToRemove Entry. */ - async deleteEntry(id: number): Promise { + async deleteEntry(entryToRemove: AddonBlogOfflinePostFormatted | AddonBlogPostFormatted): Promise { const loading = await CoreLoadings.show(); + try { - await AddonBlog.deleteEntry({ entryid: id }); - await this.refresh(); + if ('id' in entryToRemove && entryToRemove.id) { + await AddonBlog.deleteEntry({ entryid: entryToRemove.id, subject: entryToRemove.subject }); + } else { + await AddonBlogOffline.deleteOfflineEntryRecord({ created: entryToRemove.created }); + } + + CoreEvents.trigger(ADDON_BLOG_ENTRY_UPDATED); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.blog.errorloadentries', true); } finally { @@ -323,8 +440,9 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { * Show the context menu. * * @param event Click Event. + * @param entry Entry to remove. */ - async showEntryActionsPopover(event: Event, entry: AddonBlogPostFormatted): Promise { + async showEntryActionsPopover(event: Event, entry: AddonBlogPostFormatted | AddonBlogOfflinePostFormatted): Promise { event.preventDefault(); event.stopPropagation(); @@ -337,36 +455,41 @@ export class AddonBlogIndexPage implements OnInit, OnDestroy { }); switch (popoverData) { - case 'edit': - await CoreNavigator.navigateToSitePath(`blog/edit/${entry.id}`, { - params: this.filter.cmid - ? { cmId: this.filter.cmid, filters: this.filter, lastModified: entry.lastmodified } - : { filters: this.filter, lastModified: entry.lastmodified }, + case 'edit': { + await CoreNavigator.navigateToSitePath(`blog/edit/${this.isOnlineEntry(entry) && entry.id + ? entry.id + : 'new-' + entry.created}`, { + params: this.filter.cmid + ? { cmId: this.filter.cmid, filters: this.filter, lastModified: entry.lastmodified } + : { filters: this.filter, lastModified: entry.lastmodified }, }); break; + } case 'delete': - await this.deleteEntry(entry.id); + await this.deleteEntry(entry); break; default: break; } } + /** + * Undo entry deletion. + * + * @param entry Entry to prevent deletion. + */ + async undoDelete(entry: AddonBlogOfflinePostFormatted | AddonBlogPostFormatted): Promise { + await AddonBlogOffline.unmarkEntryAsRemoved('id' in entry ? entry.id : entry.created); + CoreEvents.trigger(ADDON_BLOG_ENTRY_UPDATED); + } + /** * @inheritdoc */ ngOnDestroy(): void { this.entryUpdateObserver.off(); + this.syncObserver.off(); + this.onlineObserver.unsubscribe(); } } - -/** - * Blog post with some calculated data. - */ -type AddonBlogPostFormatted = AddonBlogPost & { - publishTranslated?: string; // Calculated in the app. Key of the string to translate the publish state of the post. - user?: CoreUserProfile; // Calculated in the app. Data of the user that wrote the post. - contextLevel?: ContextLevel; // Calculated in the app. The context level of the entry. - contextInstanceId?: number; // Calculated in the app. The context instance id. -}; diff --git a/src/addons/blog/services/blog-offline.ts b/src/addons/blog/services/blog-offline.ts new file mode 100644 index 00000000000..3f584e7d4a3 --- /dev/null +++ b/src/addons/blog/services/blog-offline.ts @@ -0,0 +1,179 @@ +// (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 { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreFile } from '@services/file'; +import { CoreFileEntry } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CorePath } from '@singletons/path'; +import { AddonBlogFilter } from './blog'; +import { + AddonBlogOfflineEntryDBRecord, + OFFLINE_BLOG_ENTRIES_REMOVED_TABLE_NAME, + OFFLINE_BLOG_ENTRIES_TABLE_NAME, +} from './database/blog'; + +/** + * Service to handle offline blog. + */ +@Injectable({ providedIn: 'root' }) +export class AddonBlogOfflineService { + + /** + * Delete an offline entry. + * + * @param params Entry creation date or ID. + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved if deleted, rejected if failure. + */ + async deleteOfflineEntryRecord(params: AddonBlogOfflineParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const conditions = 'id' in params ? { id: params.id } : { created: params.created }; + await site.getDb().deleteRecords(OFFLINE_BLOG_ENTRIES_TABLE_NAME, conditions); + } + + /** + * Mark entry to be removed. + * + * @param id Entry ID. + * @param siteId Site ID. + * + * @returns Promise resolved if stored, rejected if failure. + */ + async markEntryAsRemoved(params: { id: number; subject: string }, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + await site.getDb().insertRecord(OFFLINE_BLOG_ENTRIES_REMOVED_TABLE_NAME, params); + } + + /** + * Unmark entry to be removed. + * + * @param id Entry ID. + * @param siteId Site ID. + * + * @returns Promise resolved if stored, rejected if failure. + */ + async unmarkEntryAsRemoved(id: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + await site.getDb().deleteRecords(OFFLINE_BLOG_ENTRIES_REMOVED_TABLE_NAME, { id }); + } + + /** + * Retrieves entries pending to be removed. + * + * @param siteId Site ID. + * + * @returns list of entries to remove. + */ + async getEntriesToRemove(siteId?: string): Promise<{ id: number; subject: string }[]> { + const site = await CoreSites.getSite(siteId); + + return await site.getDb().getAllRecords<{ id: number; subject: string }>(OFFLINE_BLOG_ENTRIES_REMOVED_TABLE_NAME); + } + + /** + * Save an offline entry to be sent later. + * + * @param entry Entry. + * @param siteId Site ID. + * + * @returns Promise resolved if stored, rejected if failure. + */ + async addOfflineEntry(entry: AddonBlogOfflineEntry, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + await site.getDb().insertRecord(OFFLINE_BLOG_ENTRIES_TABLE_NAME, { ...entry, id: entry.id ?? -entry.created }); + } + + /** + * Retrieves if there are any offline entry. + * + * @param filter Entry id. + * + * @returns Has offline entries. + */ + async getOfflineEntry(filter: { id?: number; created?: number }, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const record = await CoreUtils.ignoreErrors( + site.getDb().getRecord(OFFLINE_BLOG_ENTRIES_TABLE_NAME, filter), + ); + + if (record && 'id' in record && record.id && record.id < 0) { + delete record.id; + } + + return record; + } + + /** + * Retrieves offline entries. + * + * @param filters Filters. + * @param siteId Site ID. + * + * @returns Offline entries. + */ + async getOfflineEntries(filters?: AddonBlogFilter, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const records = await site.getDb().getRecords(OFFLINE_BLOG_ENTRIES_TABLE_NAME, filters); + + return records.map(record => { + if ('id' in record && record.id && record.id < 0) { + delete record.id; + } + + return record; + }); + } + + /** + * Get offline entry files folder path. + * + * @param params Entry creation date or entry ID. + * @returns path. + */ + async getOfflineEntryFilesFolderPath(params: AddonBlogOfflineParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const siteFolderPath = CoreFile.getSiteFolder(site.id); + const folder = 'created' in params ? 'created-' + params.created : params.id; + + return CorePath.concatenatePaths(siteFolderPath, 'offlineblog/' + folder); + } + + /** + * Retrieve a list of offline files stored. + * + * @param folderName Folder name. + * @param siteId Site ID. + * @returns Offline files for the provided folder name. + */ + async getOfflineFiles(folderName: AddonBlogOfflineParams, siteId?: string): Promise { + try { + const folderPath = await AddonBlogOffline.getOfflineEntryFilesFolderPath(folderName, siteId); + + return await CoreFileUploader.getStoredFiles(folderPath); + } catch (error) { + return []; + } + } + +} + +export type AddonBlogOfflineParams = { id: number } | { created: number }; + +export type AddonBlogOfflineEntry = Omit & { id?: number }; + +export const AddonBlogOffline = makeSingleton(AddonBlogOfflineService); diff --git a/src/addons/blog/services/blog-sync.ts b/src/addons/blog/services/blog-sync.ts new file mode 100644 index 00000000000..aaa51260266 --- /dev/null +++ b/src/addons/blog/services/blog-sync.ts @@ -0,0 +1,251 @@ +// (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 { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync, CoreSyncResult } from '@services/sync'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { ADDON_BLOG_AUTO_SYNCED, ADDON_BLOG_SYNC_ID } from '../constants'; +import { AddonBlog, AddonBlogAddEntryOption, AddonBlogAddEntryWSParams, AddonBlogProvider } from './blog'; +import { AddonBlogOffline, AddonBlogOfflineEntry } from './blog-offline'; +import { AddonBlogOfflineEntryDBRecord } from './database/blog'; + +/** + * Service to sync blog. + */ + @Injectable({ providedIn: 'root' }) + export class AddonBlogSyncProvider extends CoreSyncBaseProvider { + + protected componentTranslatableString = 'addon.blog.blog'; + + constructor() { + super('AddonBlogSyncService'); + } + + /** + * Try to synchronize all the entries in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Force sync. + * @returns Promise resolved if sync is successful, rejected if sync fails. + */ + async syncAllEntries(siteId?: string, force?: boolean): Promise { + await this.syncOnSites('All entries', (siteId) => this.syncAllEntriesFunc(siteId, !!force), siteId); + } + + /** + * Sync all entries on a site. + * + * @param siteId Site ID to sync. + * @param force Force sync. + */ + protected async syncAllEntriesFunc(siteId: string, force = false): Promise { + const needed = force ? true : await this.isSyncNeeded(ADDON_BLOG_SYNC_ID, siteId); + + if (!needed) { + return; + } + + const result = await this.syncEntriesForSite(siteId); + + if (!result.updated) { + return; + } + + CoreEvents.trigger(ADDON_BLOG_AUTO_SYNCED, undefined, siteId); + } + + /** + * Perform entries syncronization for specified site. + * + * @param siteId Site id. + * @returns Syncronization result. + */ + async syncEntriesForSite(siteId: string): Promise { + const currentSyncPromise = this.getOngoingSync(ADDON_BLOG_SYNC_ID, siteId); + + if (currentSyncPromise) { + return currentSyncPromise; + } + + this.logger.debug('Try to sync ' + ADDON_BLOG_SYNC_ID + ' in site ' + siteId); + + return await this.addOngoingSync(ADDON_BLOG_SYNC_ID, this.performEntriesSync(siteId), siteId); + } + + /** + * Performs entries syncronization. + * + * @param siteId Site ID. + * @returns Syncronization result. + */ + async performEntriesSync(siteId: string): Promise { + const result: AddonBlogSyncResult = { updated: false, warnings: [] }; + const entriesToSync = await this.syncEntriesToRemove(siteId); + + for (const entry of entriesToSync.entries) { + if (CoreSync.isBlocked(AddonBlogProvider.COMPONENT, entry.id ?? entry.created, siteId)) { + this.logger.debug('Cannot sync entry ' + entry.created + ' because it is blocked.'); + + throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + const formattedEntry: AddonBlogAddEntryWSParams = { + subject: entry.subject, + summary: entry.summary, + summaryformat: entry.summaryformat, + options: JSON.parse(entry.options), + }; + + try { + if (entry.id) { + await this.syncUpdatedEntry({ ...entry, id: entry.id, options: formattedEntry.options }, siteId); + result.updated = true; + continue; + } + + const draftId = await this.uploadAttachments({ created: entry.created, options: formattedEntry.options }, siteId); + const option = formattedEntry.options.find(option => option.name === 'attachmentsid'); + + if (draftId) { + option ? option.value = draftId : formattedEntry.options.push({ name: 'attachmentsid', value: draftId }); + } + + await AddonBlog.addEntryOnline(formattedEntry, siteId); + await AddonBlogOffline.deleteOfflineEntryRecord({ created: entry.created }, siteId); + result.updated = true; + } catch (error) { + if (!error || !CoreUtils.isWebServiceError(error)) { + throw error; + } + + await AddonBlogOffline.deleteOfflineEntryRecord(entry.id ? { id: entry.id } : { created: entry.created }, siteId); + this.addOfflineDataDeletedWarning(result.warnings, entry.subject, error); + result.updated = true; + } + } + + return result; + } + + /** + * Sync offline blog entry. + * + * @param entry Entry to update. + * @param siteId Site ID. + */ + protected async syncUpdatedEntry(entry: AddonBlogSyncEntryToSync, siteId?: string): Promise { + const { attachmentsid } = await AddonBlog.prepareEntryForEdition({ entryid: entry.id }, siteId); + await this.uploadAttachments({ entryId: entry.id, attachmentsId: attachmentsid, options: entry.options }, siteId); + const optionsAttachmentsId = entry.options.find(option => option.name === 'attachmentsid'); + + if (optionsAttachmentsId) { + optionsAttachmentsId.value = attachmentsid; + } else { + entry.options.push({ name: 'attachmentsid', value: attachmentsid }); + } + + const { options, subject, summary, summaryformat, id } = entry; + await AddonBlog.updateEntryOnline({ options, subject, summary, summaryformat, entryid: id }, siteId); + await AddonBlogOffline.deleteOfflineEntryRecord({ id }, siteId); + } + + /** + * Upload attachments. + * + * @param params entry creation date or entry ID and attachments ID. + * + * @returns draftId. + */ + protected async uploadAttachments(params: AddonBlogSyncUploadAttachmentsParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + const folder = 'created' in params ? { created: params.created } : { id: params.entryId }; + const offlineFiles = await AddonBlogOffline.getOfflineFiles(folder, site.id); + + if ('created' in params) { + return await CoreFileUploader.uploadOrReuploadFiles( + offlineFiles, + AddonBlogProvider.COMPONENT, + params.created, + site.id, + ); + } + + const { entries } = await AddonBlog.getEntries( + { entryid: params.entryId }, + { readingStrategy: CoreSitesReadingStrategy.PREFER_NETWORK, siteId: site.id }, + ); + + const onlineEntry = entries.find(entry => entry.id === params.entryId); + const attachments = AddonBlog.getAttachmentFilesFromOptions(params.options); + const filesToDelete = CoreFileUploader.getFilesToDelete(onlineEntry?.attachmentfiles ?? [], attachments.online); + + if (filesToDelete.length) { + await CoreFileUploader.deleteDraftFiles(params.attachmentsId, filesToDelete, site.id); + } + + await CoreFileUploader.uploadFiles(params.attachmentsId, [...attachments.online, ...offlineFiles], site.id); + } + + /** + * Sync entries to remove. + * + * @param siteId Site ID. + * @returns Entries to remove and result. + */ + protected async syncEntriesToRemove(siteId?: string): Promise { + let entriesToSync = await AddonBlogOffline.getOfflineEntries(undefined, siteId); + const entriesToBeRemoved = await AddonBlogOffline.getEntriesToRemove(siteId); + const warnings = []; + + await Promise.all(entriesToBeRemoved.map(async (entry) => { + try { + await AddonBlog.deleteEntryOnline({ entryid: entry.id }, siteId); + const entriesPendingToSync = entriesToSync.filter(entryToSync => entryToSync.id !== entry.id); + + if (entriesPendingToSync.length !== entriesToSync.length) { + await AddonBlogOffline.deleteOfflineEntryRecord({ id: entry.id }, siteId); + entriesToSync = entriesPendingToSync; + } + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + throw error; + } + + await AddonBlogOffline.unmarkEntryAsRemoved(entry.id, siteId); + this.addOfflineDataDeletedWarning(warnings, entry.subject, error); + } + })); + + return { entries: entriesToSync, result: { updated: entriesToBeRemoved.length > 0, warnings } }; + } + +} + +export const AddonBlogSync = makeSingleton(AddonBlogSyncProvider); + +export type AddonBlogSyncResult = CoreSyncResult; + +export type AddonBlogSyncUploadAttachmentsParams = + ({ entryId: number; attachmentsId: number } | { created: number }) + & { options: AddonBlogAddEntryOption[] }; + +export type AddonBlogSyncEntryToSync = Omit + & { options: AddonBlogAddEntryOption[]; id: number }; + +export type AddonBlogSyncGetPendingToSyncEntries = { entries: AddonBlogOfflineEntry[]; result: AddonBlogSyncResult }; diff --git a/src/addons/blog/services/blog.ts b/src/addons/blog/services/blog.ts index 3311e209ede..7fbfa09985c 100644 --- a/src/addons/blog/services/blog.ts +++ b/src/addons/blog/services/blog.ts @@ -12,14 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { ContextLevel } from '@/core/constants'; import { Injectable } from '@angular/core'; import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site'; import { CoreSite } from '@classes/sites/site'; +import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; import { CoreTagItem } from '@features/tag/services/tag'; +import { CoreUser, CoreUserProfile } from '@features/user/services/user'; +import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; +import { CoreNetwork } from '@services/network'; import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; +import { AddonBlogOffline, AddonBlogOfflineEntry } from './blog-offline'; const ROOT_CACHE_KEY = 'addonBlog:'; @@ -91,10 +98,48 @@ export class AddonBlogProvider { * @returns Entry id. * @since 4.4 */ - async addEntry(params: AddonBlogAddEntryWSParams, siteId?: string): Promise { + async addEntry( + { created, forceOffline, ...params }: AddonBlogAddEntryWSParams & { created: number; forceOffline?: boolean }, + siteId?: string, + ): Promise { const site = await CoreSites.getSite(siteId); - return await site.write('core_blog_add_entry', params); + const storeOffline = async (): Promise => { + await AddonBlogOffline.addOfflineEntry({ + ...params, + userid: site.getUserId(), + lastmodified: created, + options: JSON.stringify(params.options), + created, + }); + }; + + if (forceOffline || !CoreNetwork.isOnline()) { + return await storeOffline(); + } + + try { + await this.addEntryOnline(params, siteId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return await storeOffline(); + } + + // The WebService has thrown an error, reject. + throw error; + } + } + + /** + * Add entry online. + * + * @param wsParams Params expected by the webservice. + * @param siteId Site ID. + */ + async addEntryOnline(wsParams: AddonBlogAddEntryWSParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + await site.write('core_blog_add_entry', wsParams); } /** @@ -103,10 +148,54 @@ export class AddonBlogProvider { * @param params WS Params. * @param siteId Site ID of the entry. * @since 4.4 + * @returns void + */ + async updateEntry( + { forceOffline, created, ...params }: AddonBlogUpdateEntryParams, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + const storeOffline = async (): Promise => { + const content = { + subject: params.subject, + summary: params.summary, + summaryformat: params.summaryformat, + userid: site.getUserId(), + lastmodified: CoreTimeUtils.timestamp(), + options: JSON.stringify(params.options), + created, + }; + + await AddonBlogOffline.addOfflineEntry({ ...content, id: params.entryid }); + }; + + if (forceOffline || !CoreNetwork.isOnline()) { + return await storeOffline(); + } + + try { + await this.updateEntryOnline(params, siteId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return await storeOffline(); + } + + // The WebService has thrown an error, reject. + throw error; + } + } + + /** + * Update entry online. + * + * @param wsParams Params expected by the webservice. + * @param siteId Site ID. */ - async updateEntry(params: AddonBlogUpdateEntryWSParams, siteId?: string): Promise { + async updateEntryOnline(wsParams: AddonBlogUpdateEntryWSParams, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - await site.write('core_blog_update_entry', params); + await site.write('core_blog_update_entry', wsParams); } /** @@ -134,10 +223,33 @@ export class AddonBlogProvider { * @returns Entry deleted successfully or not. * @since 4.4 */ - async deleteEntry(params: AddonBlogDeleteEntryWSParams, siteId?: string): Promise { - const site = await CoreSites.getSite(siteId); + async deleteEntry({ subject, ...params }: AddonBlogDeleteEntryWSParams & { subject: string }, siteId?: string): Promise { + try { + if (!CoreNetwork.isOnline()) { + return await AddonBlogOffline.markEntryAsRemoved({ id: params.entryid, subject }, siteId); + } + + await this.deleteEntryOnline(params, siteId); + await CoreUtils.ignoreErrors(AddonBlogOffline.unmarkEntryAsRemoved(params.entryid)); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return await AddonBlogOffline.markEntryAsRemoved({ id: params.entryid, subject }, siteId); + } + + throw error; + } + } - return await site.write('core_blog_delete_entry', params); + /** + * Delete entry online. + * + * @param wsParams Params expected by the webservice. + * @param siteId Site ID. + */ + async deleteEntryOnline(wsParams: AddonBlogDeleteEntryWSParams, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + await site.write('core_blog_delete_entry', wsParams); } /** @@ -183,6 +295,113 @@ export class AddonBlogProvider { return site.write('core_blog_view_entries', data); } + /** + * Format local stored entries to required data structure. + * + * @param offlineEntry Offline entry data. + * @param entry Entry. + * @returns Formatted entry. + */ + async formatOfflineEntry( + offlineEntry: AddonBlogOfflineEntry, + entry?: AddonBlogPostFormatted, + ): Promise { + const options: AddonBlogAddEntryOption[] = JSON.parse(offlineEntry.options); + const moduleId = options?.find(option => option.name === 'modassoc')?.value as number | undefined; + const courseId = options?.find(option => option.name === 'courseassoc')?.value as number | undefined; + const tags = options?.find(option => option.name === 'tags')?.value as string | undefined; + const publishState = options?.find(option => option.name === 'publishstate')?.value as AddonBlogPublishState + ?? AddonBlogPublishState.draft; + const user = await CoreUtils.ignoreErrors(CoreUser.getProfile(offlineEntry.userid, courseId, true)); + const folder = 'id' in offlineEntry && offlineEntry.id ? { id: offlineEntry.id } : { created: offlineEntry.created }; + const offlineFiles = await AddonBlogOffline.getOfflineFiles(folder); + const optionsFiles = this.getAttachmentFilesFromOptions(options); + const attachmentFiles = [...optionsFiles.online, ...offlineFiles]; + + return { + ...offlineEntry, + publishstate: publishState, + publishTranslated: this.getPublishTranslated(publishState), + user, + tags: tags?.length ? JSON.parse(tags) : [], + coursemoduleid: moduleId ?? 0, + courseid: courseId ?? 0, + attachmentfiles: attachmentFiles, + userid: user?.id ?? 0, + moduleid: moduleId ?? 0, + summaryfiles: [], + uniquehash: '', + module: entry?.module, + groupid: 0, + content: offlineEntry.summary, + updatedOffline: true, + }; + } + + /** + * Retrieves publish state translated. + * + * @param state Publish state. + * @returns Translated state. + */ + getPublishTranslated(state?: string): string { + switch (state) { + case 'draft': + return 'publishtonoone'; + case 'site': + return 'publishtosite'; + case 'public': + return 'publishtoworld'; + default: + return 'privacy:unknown'; + } + } + + /** + * Format provided entry to AddonBlogPostFormatted. + */ + async formatEntry(entry: AddonBlogPostFormatted): Promise { + entry.publishTranslated = this.getPublishTranslated(entry.publishstate); + + // Calculate the context. This code was inspired by calendar events, Moodle doesn't do this for blogs. + if (entry.moduleid || entry.coursemoduleid) { + entry.contextLevel = ContextLevel.MODULE; + entry.contextInstanceId = entry.moduleid || entry.coursemoduleid; + } else if (entry.courseid) { + entry.contextLevel = ContextLevel.COURSE; + entry.contextInstanceId = entry.courseid; + } else { + entry.contextLevel = ContextLevel.USER; + entry.contextInstanceId = entry.userid; + } + + entry.summary = CoreFileHelper.replacePluginfileUrls(entry.summary, entry.summaryfiles || []); + entry.user = await CoreUtils.ignoreErrors(CoreUser.getProfile(entry.userid, entry.courseid, true)); + } + + /** + * Get attachments files from options object. + * + * @param options Entry options. + * @returns attachmentsId. + */ + getAttachmentFilesFromOptions(options: AddonBlogAddEntryOption[]): CoreFileUploaderStoreFilesResult { + const attachmentsId = options.find(option => option.name === 'attachmentsid'); + + if (!attachmentsId) { + return { online: [], offline: 0 }; + } + + switch(typeof attachmentsId.value) { + case 'object': + return attachmentsId.value; + case 'string': + return JSON.parse(attachmentsId.value); + default: + return { online: [], offline: 0 }; + } + } + } export const AddonBlog = makeSingleton(AddonBlogProvider); @@ -218,7 +437,7 @@ export type CoreBlogGetEntriesWSResponse = { /** * Data returned by blog's post_exporter. */ -export type AddonBlogPost = { +export interface AddonBlogPost { id: number; // Post/entry id. module: string; // Where it was published the post (blog, blog_external...). userid: number; // Post author. @@ -241,7 +460,7 @@ export type AddonBlogPost = { summaryfiles: CoreWSExternalFile[]; // Summaryfiles. attachmentfiles?: CoreWSExternalFile[]; // Attachmentfiles. tags?: CoreTagItem[]; // @since 3.7. Tags. -}; +} /** * Params of core_blog_view_entries WS. @@ -282,14 +501,14 @@ export type AddonBlogAddEntryWSParams = { options: AddonBlogAddEntryOption[]; }; -export type AddonBlogUpdateEntryWSParams = AddonBlogAddEntryWSParams & { entryid: number }; +export type AddonBlogUpdateEntryWSParams = AddonBlogAddEntryWSParams & ({ entryid: number }); /** * Add entry options. */ export type AddonBlogAddEntryOption = { name: 'inlineattachmentsid' | 'attachmentsid' | 'publishstate' | 'courseassoc' | 'modassoc' | 'tags'; - value: string | number; + value: string | number | CoreFileUploaderStoreFilesResult; }; /** @@ -335,6 +554,33 @@ export type AddonBlogGetEntriesOptions = CoreSitesCommonWSOptions & { page?: number; }; +export type AddonBlogUndoDelete = { created: number } | { id: number }; + export const AddonBlogPublishState = { draft: 'draft', site: 'site', public: 'public' } as const; // eslint-disable-next-line @typescript-eslint/no-redeclare export type AddonBlogPublishState = typeof AddonBlogPublishState[keyof typeof AddonBlogPublishState]; + +/** + * Blog post with some calculated data. + */ +export type AddonBlogPostFormatted = Omit< + AddonBlogPost, 'attachment' | 'attachmentfiles' | 'usermodified' | 'format' | 'rating' | 'module' +> & { + publishTranslated?: string; // Calculated in the app. Key of the string to translate the publish state of the post. + user?: CoreUserProfile; // Calculated in the app. Data of the user that wrote the post. + contextLevel?: ContextLevel; // Calculated in the app. The context level of the entry. + contextInstanceId?: number; // Calculated in the app. The context instance id. + coursemoduleid: number; // Course module id where the post was created. + attachmentfiles?: CoreFileEntry[]; // Attachmentfiles. + module?: string; + deleted?: boolean; + updatedOffline?: boolean; +}; + +export type AddonBlogOfflinePostFormatted = Omit; + +export type AddonBlogUpdateEntryParams = AddonBlogUpdateEntryWSParams & { + attachments?: string; + forceOffline?: boolean; + created: number; +}; diff --git a/src/addons/blog/services/database/blog.ts b/src/addons/blog/services/database/blog.ts new file mode 100644 index 00000000000..43c46ae50c8 --- /dev/null +++ b/src/addons/blog/services/database/blog.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 AddonBlogOfflineService. + */ +export const OFFLINE_BLOG_ENTRIES_TABLE_NAME = 'addon_blog_entries'; +export const OFFLINE_BLOG_ENTRIES_REMOVED_TABLE_NAME = 'addon_blog_entries_removed'; + +export const BLOG_OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonBlogOfflineService', + version: 1, + tables: [ + { + name: OFFLINE_BLOG_ENTRIES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'subject', + type: 'TEXT', + }, + { + name: 'summary', + type: 'TEXT', + }, + { + name: 'summaryformat', + type: 'INTEGER', + }, + { + name: 'created', + type: 'INTEGER', + }, + { + name: 'lastmodified', + type: 'INTEGER', + }, + { + name: 'options', + type: 'TEXT', + }, + ], + primaryKeys: ['id'], + }, + { + name: OFFLINE_BLOG_ENTRIES_REMOVED_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + }, + { + name: 'subject', + type: 'TEXT', + }, + ], + primaryKeys: ['id'], + }, + ], +}; + +/** + * Blog offline entry. + */ +export type AddonBlogOfflineEntryDBRecord = { + id: number; + userid: number; + subject: string; + summary: string; + summaryformat: number; + created: number; + lastmodified: number; + options: string; +}; diff --git a/src/addons/blog/services/handlers/sync-cron.ts b/src/addons/blog/services/handlers/sync-cron.ts new file mode 100644 index 00000000000..3d92f0ffc4f --- /dev/null +++ b/src/addons/blog/services/handlers/sync-cron.ts @@ -0,0 +1,51 @@ +// (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 { AddonBlogSync } from '../blog-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonBlogSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonBlogSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @returns Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonBlogSync.syncAllEntries(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @returns Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonBlogSync.syncInterval; + } + +} + +export const AddonBlogSyncCronHandler = makeSingleton(AddonBlogSyncCronHandlerService);