using Microsoft.AspNetCore.Components; using Blazored.LocalStorage; using Blog3000.Shared; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using System.Net.Http; using Markdig.Extensions.Tables; using Blog3000.Client.Services; using System.Security.Cryptography; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; namespace Blog3000.Client { /// /// TODO: Add all localStorage Ops regarding BlogsPosts here /// - Updater /// - Topic-Fetcher /// - ... /// public class BlogDb: IDisposable { public static string DBKEY_PREFIX_HDR = "blogdbhdr-"; public static string DBKEY_PREFIX_CONF = "blogdbconf-"; public static string DBKEY_PREFIX_BLOGCACHE = "blogcache-"; public static string DBKEY_PREFIX_IMGCACHE = "imgcached-"; public event EventHandler BlogPostsChanged; private readonly ILocalStorageService localStorage; private readonly ISyncLocalStorageService syncLocalStorage; private readonly NetworkStatus networkStatus; private readonly HttpClient httpClient; private readonly ImgCacheStorage cache; public BlogDb(ILocalStorageService ls, ISyncLocalStorageService sls, ImgCacheStorage cache, NetworkStatus ns, HttpClient http) { this.localStorage = ls; this.syncLocalStorage = sls; this.networkStatus = ns; this.cache = cache; this.httpClient = http; localStorage.Changed += LocalStorage_Changed; } public void Dispose() { localStorage.Changed -= LocalStorage_Changed; } public enum FetchStatus { OK, NotFound, NetError, Offline } private bool? _isEagerLoading = null; public async Task IsEagerLoading() { if (_isEagerLoading != null) return _isEagerLoading == true; _isEagerLoading = await localStorage.GetItemAsync($"{DBKEY_PREFIX_CONF}IsEagerLoading") == true; return _isEagerLoading == true; } public void SetEagerLoading(bool enabled) { _isEagerLoading = enabled; localStorage.SetItemAsync($"{DBKEY_PREFIX_CONF}IsEagerLoading", enabled); } public async Task AreImagesCached(string postId) { var imgsLoaded = await localStorage.GetItemAsync($"{DBKEY_PREFIX_IMGCACHE}{postId}"); if (imgsLoaded != null && true == imgsLoaded) { // Seems to have loaded once, check if still in cache var bh = await GetHeaderAsync(postId); if (bh == null) return false; // Should not occur but in case if (bh.ImageRefs != null) { var urls = new List(); foreach (var img in bh.ImageRefs) { // Images are cached by their id var url = $"BlogPosts/{postId}/imgref/{img.Key}"; urls.Add(url); } var s = await cache.AreCached(urls.ToArray()); System.Diagnostics.Debug.WriteLine($"ImageCacheCheck: Found {s} for {postId}"); // Not in cache (anymore)? if (!s) return false; } return true; } return false; } /// /// Get headers of all posts available (previously copied to localStorage) /// /// public async IAsyncEnumerable GetHeadersAsync() { var res = await localStorage.GetItemAsync>(BlogDb.DBKEY_PREFIX_HDR + "remotePosts"); if (res != null) { foreach (var b in res) { yield return b; } } } /// /// Get a header of posts available (previously copied to localStorage) /// /// /// public async Task GetHeaderAsync(string id) { if (id != null) { var res = await localStorage.GetItemAsync>(BlogDb.DBKEY_PREFIX_HDR + "remotePosts"); if (res != null) { foreach (var b in res) { if (id.Equals(b.Id)) return b; } } } return null; } /// /// Get headers from locally cached posts /// /// public async IAsyncEnumerable GetHeadersFromCachedPostsAsync() { int n = await localStorage.LengthAsync(); for (int i = 0; i < n; i++) { var key = await localStorage.KeyAsync(i); if (key != null && key.StartsWith(BlogDb.DBKEY_PREFIX_BLOGCACHE)) { var bpt = await localStorage.GetItemAsync(key); if (bpt != null) { var bp = bpt.ToBlogPost(); yield return bp; } } } } /// /// Get a locally cached post. Optionally fetch images from remote. Optionally fetch post from remote. /// /// /// /// when true try loading uncached post, or in non-eager-loading mode refresh cached post, which has been changed TBD!! /// public async Task<(BlogPost, FetchStatus)> GetCachedPostAsync(string postId, bool tryCacheImgs, bool tryRemote = false) { var res = await localStorage.GetItemAsync($"{DBKEY_PREFIX_BLOGCACHE}{postId}"); bool needsSync = false; if (!(await IsEagerLoading()) && tryRemote && res != null && networkStatus.IsOnline /* makes no sense if offline */) { var bph = await GetHeaderAsync(postId); if (bph != null) { if (! String.Equals(bph.Checksum, res.Checksum)) { // Invalidate the result System.Diagnostics.Debug.WriteLine("Post has been changed, and not refresh via auto-eagerloading, so forceing sync"); needsSync = true; } } } if (!needsSync) { // // This check is only needed, when the post is cached, since syncing // includeds the images anyway if tryCacheImgs==true // var imgsLoaded = await AreImagesCached(postId); if (!imgsLoaded && tryCacheImgs) { if (networkStatus.IsOnline) { System.Diagnostics.Debug.WriteLine("Images not chached, forcing sync"); needsSync = true; } else { System.Diagnostics.Debug.WriteLine("Images not chached, but offline, so not forcing sync"); } } } var status = FetchStatus.OK; if (needsSync || res == null) { if (!tryRemote) { status = FetchStatus.NotFound; } else { var (sb, sres) = await SyncPost(postId, tryCacheImgs, false); switch (sres) { case SyncStatus.OK: res = sb; break; case SyncStatus.NetError: status = FetchStatus.NetError; break; case SyncStatus.Offline: status = FetchStatus.Offline; break; case SyncStatus.NotFound: status = FetchStatus.NotFound; break; default: throw new InvalidOperationException("unknown result"); } if (status == FetchStatus.NotFound) { // When remote say not-found, ensure we won't handout cached data anymore res = null; } else if (status != FetchStatus.OK && res != null) { // We can use the cached version, so return a Offline-Status event in Net-Error-case status = FetchStatus.Offline; } } } return (res, status); } /// /// Get locally cached posts withText /// /// public async IAsyncEnumerable GetCachedPostsAsync() { int n = await localStorage.LengthAsync(); for (int i = 0; i < n; i++) { var key = await localStorage.KeyAsync(i); if (key != null && key.StartsWith(BlogDb.DBKEY_PREFIX_BLOGCACHE)) { var bpt = await localStorage.GetItemAsync(key); yield return bpt; } } } public enum SyncStatus { OK, Offline, NotFound, NetError } /// /// Sync a remote post with the local cache /// /// /// /// /// public async Task<(BlogPost, SyncStatus)> SyncPost(string id, bool includeImgs, bool removeOld=true) { var status = SyncStatus.OK; BlogPost res = null; if (networkStatus.IsOnline) { try { if (removeOld) { //var oldPost = await localStorage.GetItemAsync($"{DBKEY_PREFIX}{id}"); //if (oldPost != null) //{ // if (oldPost.ImageRefs != null) // { // foreach (var k in oldPost.ImageRefs.Keys) // { // await localStorage.RemoveItemAsync($"{DBKEY_IMG_PREFIX}{id}-{k}"); // } // } //} var urls = await cache.MatchAll($"BlogPosts/{id}/imgref"); if (urls != null) { foreach (var u in urls) { await cache.Delete(u); } } await Task.Yield(); await localStorage.RemoveItemAsync($"{DBKEY_PREFIX_IMGCACHE}{id}"); await localStorage.RemoveItemAsync($"{DBKEY_PREFIX_BLOGCACHE}{id}"); } System.Diagnostics.Debug.WriteLine($"Syncing post {id}"); var newPost = await httpClient.GetJsonAsync($"BlogPosts/{id}"); await Task.Yield(); // Any effect after non-localhost-network-awaits? if (newPost != null) { if (includeImgs) { if ((newPost.ImageRefs?.Count ?? 0) > 0) { //foreach (var k in newPost.ImageRefs.Keys) //{ // try // { // System.Diagnostics.Debug.WriteLine($"Fetching {k} for {id}"); // var f = await httpClient.GetByteArrayAsync($"BlogPosts/{id}/imgref/{k}"); // await localStorage.SetItemAsync($"{BlogDb.DBKEY_IMG_PREFIX}{id}-{k}", f); // } // catch (HttpRequestException ex) // { // System.Diagnostics.Debug.WriteLine($"BlogPosts: error fetching img {ex.GetStatusCode()} from remote"); // } //} var urls = new List(); foreach (var k in newPost.ImageRefs.Keys) { var u = $"BlogPosts/{id}/imgref/{k}"; urls.Add(u); } System.Diagnostics.Debug.WriteLine($"BlogPosts: caching {urls.Count} imgs from remote... "); await cache.AddAll(urls.ToArray()); await Task.Yield(); System.Diagnostics.Debug.WriteLine($".... done"); } else { System.Diagnostics.Debug.WriteLine($"BlogPosts: no images to cache done"); } await localStorage.SetItemAsync($"{BlogDb.DBKEY_PREFIX_IMGCACHE}{id}", true); } await localStorage.SetItemAsync(BlogDb.DBKEY_PREFIX_BLOGCACHE + id, newPost); System.Diagnostics.Debug.WriteLine($"BlogPosts: fetched {id} from remote"); res= newPost; } else { status = SyncStatus.NotFound; } } catch (HttpRequestException ex) { System.Diagnostics.Debug.WriteLine($"BlogPosts: error fetching {ex.GetStatusCode()} from remote"); // TODO: Find better way to extract statuscode if (("" + ex.ToString()).Contains("404")) status = SyncStatus.NotFound; else status = SyncStatus.NetError; } } else { status = SyncStatus.Offline; } return (res, status); } private void LocalStorage_Changed(object sender, ChangedEventArgs e) { // Notify if a cached posts were changed if (e.Key.StartsWith(BlogDb.DBKEY_PREFIX_BLOGCACHE)) Task.Run(() => { System.Diagnostics.Debug.WriteLine("LocalStorage CachedPosts updated event -> emitting blogDbChanged Event"); BlogPostsChanged?.Invoke(this, EventArgs.Empty); }); // Notify if header-list was changed if (e.Key.StartsWith(BlogDb.DBKEY_PREFIX_HDR)) Task.Run(() => { System.Diagnostics.Debug.WriteLine("LocalStorage Headers updated event -> emitting blogDbChanged Event"); BlogPostsChanged?.Invoke(this, EventArgs.Empty); }); } } }