Merge pull request #4043 from alfonso-salces/MOBILE-4547

MOBILE-4547 blog: Support offline blog
main
Dani Palou 2024-08-19 14:02:06 +02:00 committed by GitHub
commit 660636d96f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1251 additions and 153 deletions

View File

@ -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);
},
},
],

View File

@ -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';

View File

@ -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<boolean>(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<number | CoreFileUploaderStoreFilesResult> {
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<void> {
async saveEntry(params: AddonBlogEditEntrySaveEntryParams): Promise<void> {
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<AddonBlogEditEntryFormattedOfflinePost | undefined> {
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<AddonBlogPost, 'id'> & { id?: number };
type AddonBlogEditEntrySaveEntryParams = {
created?: number;
attachmentsId?: number | CoreFileUploaderStoreFilesResult;
forceOffline?: boolean;
};
type AddonBlogEditEntryFormattedOfflinePost = Omit<
AddonBlogEditEntryPost, | 'attachment' | 'attachmentfiles' | 'rating' | 'format' | 'usermodified' | 'module'
> & { attachmentfiles?: CoreFileEntry[] };

View File

@ -7,6 +7,11 @@
<h1>{{ title | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<core-context-menu>
<core-context-menu-item [hidden]="syncHidden()" [priority]="400" [content]="'core.settings.synchronizenow' | translate"
(action)="fetchEntries(true, true, true)" [iconAction]="syncIcon" [closeOnClick]="false" />
</core-context-menu>
<core-user-menu-button />
</ion-buttons>
</ion-toolbar>
@ -14,11 +19,11 @@
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refresh($event.target)">
<ion-refresher slot="fixed" [disabled]="!loaded()" (ionRefresh)="refresh(true, $event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<core-loading [hideUntil]="loaded()">
@if (showMyEntriesToggle) {
<ion-item class="ion-text-wrap">
<ion-toggle [(ngModel)]="onlyMyEntries" (ionChange)="onlyMyEntriesToggleChanged(onlyMyEntries)">
@ -27,8 +32,17 @@
</ion-item>
}
@for (entry of entries; track entry.id) {
<div class="entry ion-padding-start ion-padding-top ion-padding-end" [id]="'entry-' + entry.id">
@if (hasOfflineDataToSync()) {
<ion-card class="core-warning-card">
<ion-item>
<ion-icon name="fas-triangle-exclamation" slot="start" aria-hidden="true" />
<ion-label>{{ 'core.hasdatatosync' | translate:{ $a: 'addon.blog.blog' | translate } }}</ion-label>
</ion-item>
</ion-card>
}
@for (entry of entries; track getEntryTemplateId(entry)) {
<div class="entry ion-padding-start ion-padding-top ion-padding-end" [id]="getEntryTemplateId(entry)">
<div class="entry-subject flex ion-text-wrap ion-justify-content-between ion-align-items-center">
<h3>
<core-format-text [text]="entry.subject" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId"
@ -38,14 +52,29 @@
<ion-badge color="warning"> {{ 'addon.blog.publishtonoone' | translate }} </ion-badge>
</span>
}
@if (!getEntryId(entry) || entry.updatedOffline) {
<span class="entry-draft">
<ion-badge color="light">
<ion-icon name="fas-clock" [attr.aria-label]="'core.lastmodified' | translate" />
{{ 'core.notsent' | translate }}
</ion-badge>
</span>
}
</h3>
@if (entry.userid === currentUserId && optionsAvailable) {
@if (entry.userid === currentUserId && optionsAvailable && !entry.deleted) {
<ion-button fill="clear" [ariaLabel]="'core.displayoptions' | translate" (click)="showEntryActionsPopover($event, entry)"
class="entry-options">
<ion-icon slot="icon-only" aria-hidden="true" name="ellipsis-vertical" />
</ion-button>
}
@if (entry.deleted) {
<ion-button slot="end" fill="clear" color="danger" (click)="undoDelete(entry)" [ariaLabel]="'core.restore' | translate">
<ion-icon name="fas-rotate-left" slot="icon-only" aria-hidden="true" />
</ion-button>
}
</div>
<div class="entry-creation-info flex ion-align-items-center">
@ -64,8 +93,8 @@
<div class="entry-summary" [collapsible-item]="64">
<div class="ion-margin-bottom">
<core-format-text [text]="entry.summary" [component]="component" [componentId]="entry.id" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="entry.courseid" />
<core-format-text [text]="entry.summary" [component]="component" [componentId]="getEntryId(entry)"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="entry.courseid" />
</div>
@if (tagsEnabled && entry.tags && entry.tags!.length > 0) {
@ -77,9 +106,7 @@
</ion-item>
}
@for (file of entry.attachmentfiles; track $index) {
<core-file [file]="file" [component]="this.component" [componentId]="entry.id" />
}
<core-files [files]="entry.attachmentfiles" [component]="component" [componentId]="getEntryId(entry) ?? entry.created" />
@if (entry.uniquehash) {
<ion-item [href]="entry.uniquehash" core-link [detail]="true" lines="none">
@ -106,8 +133,8 @@
</ion-item>
}
@if (commentsEnabled) {
<core-comments [component]="this.component" [itemId]="entry.id" area="format_blog" [instanceId]="entry.userid"
@if (getEntryId(entry) && commentsEnabled) {
<core-comments [component]="this.component" [itemId]="getEntryId(entry)" area="format_blog" [instanceId]="entry.userid"
contextLevel="user" [showItem]="true" [courseId]="entry.courseid" />
}
@ -121,12 +148,13 @@
</core-loading>
<!-- Create a blog entry. -->
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end"
*ngIf="loaded && optionsAvailable && (!filter.userid || (filter.userid && currentUserId === filter.userid))">
@if ((filter.userid === currentUserId || showMyEntriesToggle) && loaded() && optionsAvailable) {
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end">
<ion-fab-button (click)="createNewEntry()" [attr.aria-label]="'addon.blog.addnewentry' | translate">
<ion-icon name="fas-pen-to-square" aria-hidden="true" />
<span class="sr-only">{{ 'addon.blog.addnewentry' | translate }}</span>
</ion-fab-button>
</ion-fab>
}
</ion-content>

View File

@ -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<void> {
protected async fetchEntries(refresh: boolean, showSyncErrors = false, sync?: boolean): Promise<void> {
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;
}
await Promise.all(result.entries.map(async (entry: AddonBlogPostFormatted) => AddonBlog.formatEntry(entry)));
// 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;
}
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.summary = CoreFileHelper.replacePluginfileUrls(entry.summary, entry.summaryfiles || []);
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
async deleteEntry(entryToRemove: AddonBlogOfflinePostFormatted | AddonBlogPostFormatted): Promise<void> {
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<void> {
async showEntryActionsPopover(event: Event, entry: AddonBlogPostFormatted | AddonBlogOfflinePostFormatted): Promise<void> {
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<void> {
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.
};

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<AddonBlogOfflineEntry | undefined> {
const site = await CoreSites.getSite(siteId);
const record = await CoreUtils.ignoreErrors(
site.getDb().getRecord<AddonBlogOfflineEntry>(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<AddonBlogOfflineEntry[]> {
const site = await CoreSites.getSite(siteId);
const records = await site.getDb().getRecords<AddonBlogOfflineEntry>(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<string> {
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<CoreFileEntry[]> {
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<AddonBlogOfflineEntryDBRecord, 'id'> & { id?: number };
export const AddonBlogOffline = makeSingleton(AddonBlogOfflineService);

View File

@ -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<AddonBlogSyncResult> {
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<void> {
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<void> {
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<AddonBlogSyncResult> {
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<AddonBlogSyncResult> {
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<void> {
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<number | undefined> {
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<AddonBlogSyncGetPendingToSyncEntries> {
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<AddonBlogOfflineEntryDBRecord, 'id'|'options'>
& { options: AddonBlogAddEntryOption[]; id: number };
export type AddonBlogSyncGetPendingToSyncEntries = { entries: AddonBlogOfflineEntry[]; result: AddonBlogSyncResult };

View File

@ -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<number> {
async addEntry(
{ created, forceOffline, ...params }: AddonBlogAddEntryWSParams & { created: number; forceOffline?: boolean },
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId);
return await site.write<number>('core_blog_add_entry', params);
const storeOffline = async (): Promise<void> => {
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<void> {
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(params: AddonBlogUpdateEntryWSParams, siteId?: string): Promise<void> {
async updateEntry(
{ forceOffline, created, ...params }: AddonBlogUpdateEntryParams,
siteId?: string,
): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.write('core_blog_update_entry', params);
const storeOffline = async (): Promise<void> => {
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 updateEntryOnline(wsParams: AddonBlogUpdateEntryWSParams, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
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<AddonBlogDeleteEntryWSResponse> {
const site = await CoreSites.getSite(siteId);
async deleteEntry({ subject, ...params }: AddonBlogDeleteEntryWSParams & { subject: string }, siteId?: string): Promise<void> {
try {
if (!CoreNetwork.isOnline()) {
return await AddonBlogOffline.markEntryAsRemoved({ id: params.entryid, subject }, siteId);
}
return await site.write('core_blog_delete_entry', params);
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;
}
}
/**
* Delete entry online.
*
* @param wsParams Params expected by the webservice.
* @param siteId Site ID.
*/
async deleteEntryOnline(wsParams: AddonBlogDeleteEntryWSParams, siteId?: string): Promise<void> {
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<AddonBlogOfflinePostFormatted> {
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<void> {
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<AddonBlogPostFormatted, 'id'>;
export type AddonBlogUpdateEntryParams = AddonBlogUpdateEntryWSParams & {
attachments?: string;
forceOffline?: boolean;
created: number;
};

View File

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

View File

@ -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<void> {
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);