diff --git a/package.json b/package.json index b327382..2c8ce33 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@types/markdown-it": "^13.0.0", "@types/natural-compare": "^1.4.1", "angular-resize-event": "^3.2.0", + "angular-svg-icon": "^17.0.0", "bootstrap": "^5.3.2", "fuzzr": "https://github.com/isark2/fuzzr#v0.3.1", "markdown-it": "^13.0.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f40b13a..6be2bca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ ts-rs = "6.2.1" [dependencies] steamlocate = "1.2.1" -tauri = { version = "1.2", features = [ "http-request", "dialog-open", "global-shortcut-all", "dialog-save", "updater", "system-tray"] } +tauri = { version = "1.2", features = [ "dialog-message", "http-request", "dialog-open", "global-shortcut-all", "dialog-save", "updater", "system-tray"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" Underlayer = { git = "https://git.isark.me/isark/Underlay.git" } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d3f55d3..52cdd41 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -103,8 +103,9 @@ fn save_plan_at_path(path: PathBuf, plan: Plan) -> bool { } #[tauri::command] -fn save_plan_at_store(name: String, plan: Plan) -> Option { - Storage::save_plan_at_store_path(&name, plan).ok() +fn save_plan_at_store(name: String, plan: Plan, allow_overwrite: bool) -> Option { + log::trace!("plan etag {:?}", plan.metadata.latest_server_etag); + Storage::save_plan_at_store_path(&name, plan, allow_overwrite).ok() } #[tauri::command] @@ -150,9 +151,9 @@ fn main() { ); } - // app.get_window("Overlay") - // .expect("Could not get main overlay window") - // .open_devtools(); + app.get_window("Overlay") + .expect("Could not get main overlay window") + .open_devtools(); Ok(()) }) diff --git a/src-tauri/src/plan.rs b/src-tauri/src/plan.rs index 60574a3..858adac 100644 --- a/src-tauri/src/plan.rs +++ b/src-tauri/src/plan.rs @@ -3,18 +3,19 @@ use std::{collections::HashMap, path::PathBuf}; use serde::{ser::SerializeStruct, Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct -Plan { +pub struct Plan { plan: Vec, current: usize, #[serde(flatten)] - metadata: PlanMetadata, + pub metadata: PlanMetadata, } #[derive(Deserialize, Debug, Clone)] pub struct PlanMetadata { stored_path: Option, update_url: Option, + pub latest_server_etag: Option, + identifier: Option, } impl PlanMetadata { @@ -26,19 +27,21 @@ impl PlanMetadata { impl Serialize for PlanMetadata { fn serialize(&self, serializer: S) -> Result where - S: serde::Serializer, + S: serde::Serializer, { - let mut state = serializer.serialize_struct("PlanMetadata", 3)?; - + let mut state = serializer.serialize_struct("PlanMetadata", 5)?; + state.serialize_field("update_url", &self.update_url)?; state.serialize_field("stored_path", &self.stored_path)?; - + state.serialize_field("latest_server_etag", &self.latest_server_etag)?; + state.serialize_field("identifier", &self.identifier)?; + if let Some(path) = &self.stored_path { if let Some(name) = path.file_name() { state.serialize_field("name", &name.to_str())?; } } - + state.end() } } @@ -128,6 +131,8 @@ pub fn convert_old(path: PathBuf) -> Option { metadata: PlanMetadata { stored_path: None, update_url: None, + latest_server_etag: None, + identifier: None, }, }) } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 2b27ab7..2c3cade 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -91,7 +91,7 @@ impl Storage { }; if save_local { - match Self::save_plan_at_store_path(path.file_name()?.to_str()?, plan.clone()) { + match Self::save_plan_at_store_path(path.file_name()?.to_str()?, plan.clone(), false) { Ok(_) => (), Err(_e) => { log::error!("Could not save plan at store path during load"); @@ -102,7 +102,7 @@ impl Storage { Some(plan) } - pub fn save_plan_at_store_path(file_name: &str, mut plan: Plan) -> Result> { + pub fn save_plan_at_store_path(file_name: &str, mut plan: Plan, allow_overwrite: bool) -> Result> { let plan_dir = match Self::plan_dir() { Some(dir) => dir, None => return Err("No plan dir".into()), @@ -111,10 +111,14 @@ impl Storage { let file_path = plan_dir.join(file_name).with_extension("json"); //Disallow overwriting. - if file_path.exists() { + if !allow_overwrite && file_path.exists() { return Err("File already exists".into()); } + if allow_overwrite && file_path.exists() { + log::info!("Overwriting plan : {file_path:?}"); + } + //TODO: Determine necessity plan.set_stored_path(Some(file_path.clone())); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2a96534..c98f51a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,8 @@ "allowlist": { "dialog": { "open": true, - "save": true + "save": true, + "message": true }, "globalShortcut": { "all": true diff --git a/src/app/_models/plan.ts b/src/app/_models/plan.ts index ce8a204..33e38fa 100644 --- a/src/app/_models/plan.ts +++ b/src/app/_models/plan.ts @@ -7,12 +7,16 @@ export interface PlanInterface { stored_path?: string; update_url?: string; name?: string; + latest_server_etag?: string; + identifier?: string, } export interface PlanMetadata { stored_path?: string; update_url?: string; name: string; + latest_server_etag?: string; + identifier?: string; } export class Plan { @@ -20,16 +24,23 @@ export class Plan { current: number; update_url?: string; name?: string; + latest_server_etag?: string; + identifier?: string; private path?: string; private selfSaveSubject: Subject = new Subject(); + constructor(plan: PlanInterface) { this.plan = plan.plan; this.current = plan.current; if (plan.stored_path) { this.path = plan.stored_path; } + this.update_url = plan.update_url; + this.name = plan.name; + this.latest_server_etag = plan.latest_server_etag; + this.identifier = plan.identifier; this.selfSaveSubject.pipe(debounceTime(500)).subscribe(() => this.directSelfSave()); } @@ -57,6 +68,8 @@ export class Plan { current: this.current, stored_path: this.path, update_url: this.update_url, + latest_server_etag: this.latest_server_etag, + identifier: this.identifier, }; } diff --git a/src/app/_services/overlay.service.ts b/src/app/_services/overlay.service.ts index 360a4dd..54c3cd5 100644 --- a/src/app/_services/overlay.service.ts +++ b/src/app/_services/overlay.service.ts @@ -32,12 +32,18 @@ export class OverlayService { this.isOverlay = appWindow.label === "Overlay"; } - registerInitialBinds() { - this.shortcuts.register(this.configService.config.toggleOverlay).subscribe((_shortcut) => { - this.onToggleOverlay() + registerInitialBinds(retry: boolean = true) { + this.shortcuts.register(this.configService.config.toggleOverlay).subscribe({ + next: (_shortcut) => { + this.onToggleOverlay() + }, + error: (err) => { + console.error("error binding overlay toggle", err); + this.shortcuts.force_unbind(this.configService.config.toggleOverlay).subscribe(() => this.registerInitialBinds(false)) + } }); } - + onOverlayStateChange(event: Event) { this.interactable = event.payload.Interactable != null; if (event.payload.Hidden) { this.visible = false } else { this.visible = true }; diff --git a/src/app/_services/plan.service.ts b/src/app/_services/plan.service.ts index f5cfeb1..6f88c6e 100644 --- a/src/app/_services/plan.service.ts +++ b/src/app/_services/plan.service.ts @@ -1,10 +1,20 @@ import { Injectable } from '@angular/core'; import { invoke } from '@tauri-apps/api'; -import { Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs'; +import { EMPTY, Observable, ReplaySubject, Subject, from, map, switchMap, tap } from 'rxjs'; import { Plan, PlanInterface, PlanMetadata } from '../_models/plan'; import { MatDialog } from '@angular/material/dialog'; import { UrlDialog } from '../plan-display/url-dialog.component'; import { fetch } from '@tauri-apps/api/http'; +import { Response } from '@tauri-apps/api/http'; + +export class UrlError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} @Injectable({ providedIn: 'root' @@ -74,8 +84,33 @@ export class PlanService { return from(invoke('save_plan_at_path', { path, plan: plan.toInterface() })); } - public savePlanAtStore(name: string, plan: Plan) { - return from(invoke('save_plan_at_store', { name, plan: plan.toInterface() })).pipe(tap(() => this.loadStoredPlans())); + public checkForPlanUpdate(plan: Plan | PlanMetadata): Observable { + if (!plan.update_url) return EMPTY; + + return from(fetch( + plan.update_url, + { + method: 'HEAD', + timeout: 10 + })).pipe(switchMap(response => { + this.validateResponse(response); + + if (response.headers['etag'] === plan.latest_server_etag) { + console.log("Same ETag, no update needed.") + return EMPTY; + } + + console.log("Got new ETAG, updating plan.", response, plan); + return this._loadFromUrl(plan.update_url!, plan.name!, false); + })); + } + + public savePlanAtStore(name: string, plan: Plan, allowOverwrite: boolean = false) { + console.log("save at store: ", plan); + return from(invoke('save_plan_at_store', { name, plan: plan.toInterface(), allowOverwrite })).pipe(tap(() => { + console.log("saved plan at store pipe tap"); + this.loadStoredPlans(); + })); } private _loadFromUrl(url: string, name: string, save_local: boolean): Observable { @@ -86,7 +121,16 @@ export class PlanService { method: 'GET', timeout: 10 })).pipe(map(response => { - return new Plan(response.data as PlanInterface); + this.validateResponse(response); + + const plan = new Plan(response.data as PlanInterface); + const etag = response.headers['etag']; + if (etag) { + plan.latest_server_etag = etag; + console.log("got etag: ", etag); + } + + return plan; })).pipe(tap(plan => { plan.update_url = url; plan.name = name; @@ -97,6 +141,12 @@ export class PlanService { })); } + private validateResponse(response: Response) { + if (!response.ok) { + throw new UrlError(response.status, "Error fetching plan from URL, status " + response.status + ", URL: " + response.url + " with body:" + response.data); + } + } + private loadBasePlan() { from(invoke('base_plan')).subscribe(plan => { plan.plan.forEach(elem => { elem.edited = false; }); @@ -106,6 +156,7 @@ export class PlanService { private loadStoredPlans() { from(invoke('enumerate_stored_plans')).subscribe(plans => { + console.log("updating stored plans"); this._storedPlansSubject.next(plans); }) } diff --git a/src/app/_services/shortcut.service.ts b/src/app/_services/shortcut.service.ts index a35fe74..a6ff3dd 100644 --- a/src/app/_services/shortcut.service.ts +++ b/src/app/_services/shortcut.service.ts @@ -1,6 +1,6 @@ import { Injectable, NgZone } from '@angular/core'; import { ShortcutHandler, register, unregister } from '@tauri-apps/api/globalShortcut'; -import { Observable, Subscriber } from 'rxjs'; +import { Observable, Subscriber, from } from 'rxjs'; @Injectable({ providedIn: 'root' @@ -8,7 +8,7 @@ import { Observable, Subscriber } from 'rxjs'; export class ShortcutService { private internalHandlers: Map, () => void]> = new Map, () => void]>(); - constructor(private zone: NgZone) {} + constructor(private zone: NgZone) { } register(shortcut: string) { return new Observable((subscriber) => { @@ -21,12 +21,12 @@ export class ShortcutService { }; this.internalHandlers.set(shortcut, [originalHandler, subscriber, teardown]); - - register(shortcut, originalHandler) + register(shortcut, originalHandler).catch(e => subscriber.error(e)); return teardown; }); } + rebind_from_to(previousShortcut: string, nextShortcut: string) { let [oldHandler, subscriber, teardown] = this.internalHandlers.get(previousShortcut)!; @@ -43,4 +43,10 @@ export class ShortcutService { this.internalHandlers.set(nextShortcut, [oldHandler, subscriber, teardown]); } + + + ///No safety checks + force_unbind(shortcut: string) { + return from(unregister(shortcut)); + } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7460502..ab3db66 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -16,6 +16,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { MAT_DIALOG_DEFAULT_OPTIONS } from "@angular/material/dialog"; import { TooltipComponent } from "./tooltip/tooltip.component"; import { HttpClientModule } from "@angular/common/http"; +import { AngularSvgIconModule } from "angular-svg-icon"; // import { GemFinderComponent } from "./gem-finder/gem-finder.component"; export function initializeApp(configService: ConfigService) { @@ -40,7 +41,8 @@ export function initializeApp(configService: ConfigService) { SettingsComponent, MatTabsModule, TooltipComponent, - HttpClientModule + HttpClientModule, + AngularSvgIconModule.forRoot(), ], providers: [ { diff --git a/src/app/plan-display/plan-display.component.html b/src/app/plan-display/plan-display.component.html index fa5d828..ab43c59 100644 --- a/src/app/plan-display/plan-display.component.html +++ b/src/app/plan-display/plan-display.component.html @@ -6,9 +6,8 @@ [style.transform]="transform()" [style.width]="rect.width + 'px'" [style.height]="rect.height + 'px'" [class]="specialClasses()" (wheel)="onScroll($event)" #targetRef> -
-
-
+
+
-
- - - - +
+ + + + + + + + +
diff --git a/src/app/plan-display/plan-display.component.scss b/src/app/plan-display/plan-display.component.scss index 67e197b..ac81a23 100644 --- a/src/app/plan-display/plan-display.component.scss +++ b/src/app/plan-display/plan-display.component.scss @@ -167,9 +167,28 @@ notes { .enumerated { display: block; overflow: hidden; + flex-grow: 1; mat-list { max-height: 100%; overflow-y: scroll; } +} + +.planActionButton { + align-items: center; + justify-content: center; + padding-bottom: 1px; +} + +.nice { + svg { + fill: green; + } +} + +.notnice { + svg { + fill: red; + } } \ No newline at end of file diff --git a/src/app/plan-display/plan-display.component.ts b/src/app/plan-display/plan-display.component.ts index ce13d88..887d035 100644 --- a/src/app/plan-display/plan-display.component.ts +++ b/src/app/plan-display/plan-display.component.ts @@ -5,7 +5,7 @@ import { ConfigService } from '../_services/config.service'; import { Rect } from '../_models/generated/Rect'; import { ShortcutService } from '../_services/shortcut.service'; import { CarouselComponent } from '../carousel/carousel.component'; -import { PlanService } from '../_services/plan.service'; +import { PlanService, UrlError } from '../_services/plan.service'; import { Plan, PlanElement, PlanMetadata } from '../_models/plan'; import { WorldAreaService } from '../_services/world-area.service'; import { WorldArea } from '../_models/world-area'; @@ -22,7 +22,6 @@ import { Event } from '@tauri-apps/api/event'; styleUrls: ['./plan-display.component.scss'] }) export class PlanDisplayComponent implements AfterViewInit, OnInit { - @Input() backgroundColor?: String; draggable: boolean = true; rect?: Rect; @@ -44,6 +43,9 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit { currentPlan?: Plan; previousPlans: PlanMetadata[] = []; + checkingPlanUpdate: boolean = false; + + recentUpdateAttempts: Map = new Map(); constructor(private events: EventsService, public configService: ConfigService, private cdr: ChangeDetectorRef, private shortcut: ShortcutService, public planService: PlanService, public worldAreaService: WorldAreaService, public overlayService: OverlayService, private zone: NgZone) { window.addEventListener("resize", () => { @@ -53,6 +55,7 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit { }); this.planService.getStoredPlans().subscribe(plans => { + console.log("got new stored plans"); this.previousPlans = plans; }) @@ -66,6 +69,10 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit { this.registerOnZoneEnter(); } + get disablePlans(): boolean { + return this.checkingPlanUpdate; + } + registerOnZoneEnter() { appWindow.listen("entered", (entered) => { if (this.currentPlan) { @@ -80,7 +87,6 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit { }); } - windowInitHandler() { if (window.innerWidth > 0) { this.ngAfterViewInit(); @@ -304,7 +310,7 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit { this.planService.loadFromUrl().subscribe(plan => { if (plan) { this.planService.savePlanAtStore(plan.name!, plan).subscribe((path) => { - if(path) { + if (path) { plan.setPath(path); } }); @@ -312,4 +318,66 @@ export class PlanDisplayComponent implements AfterViewInit, OnInit { } }); } + + checkForPlanUpdate(plan: PlanMetadata) { + this.checkingPlanUpdate = true; + + this.planService.checkForPlanUpdate(plan).subscribe({ + next: updatedPlan => { + console.log("check update: ", updatedPlan); + this.planService.loadPlanFromPath(plan.stored_path!, false).subscribe(oldPlan => { + console.log("oldPlan", oldPlan); + const newCurrent = commonUUIDToNewCurrent(oldPlan, updatedPlan); + updatedPlan.current = newCurrent; + //TODO: Interface to determine if we want to save the plan or not (allow not doing it in case user regrets... Also needs position error fix). + this.planService.savePlanAtStore(oldPlan.name!, updatedPlan, true).subscribe(); + }) + }, + complete: () => { + this.checkingPlanUpdate = false + this.recentUpdateAttempts.set(plan.update_url!, new Date()); + }, + error: (err) => { + this.recentUpdateAttempts.set(plan.update_url!, err); + if (err instanceof UrlError) { + alert("Error retrieving plan update (" + err.status + "): " + err.message); + } else if (err instanceof Error) { + alert("Unexpected error: " + err.message); + } + + this.checkingPlanUpdate = false; + } + }); + } + + isErr(plan: PlanMetadata) { + return this.recentUpdateAttempts.get(plan.update_url!) instanceof UrlError; + } + + hasUpdate(plan: PlanMetadata): any { + return this.recentUpdateAttempts.has(plan.update_url!); + } + + recentPlanTitle(plan: PlanMetadata) { + if (!this.hasUpdate(plan)) return "Check for update"; + if (this.hasUpdate(plan) && !this.isErr(plan)) return "Up to date"; + if (this.hasUpdate(plan) && this.isErr(plan)) return "Error updating"; + return ""; + } +} + +function commonUUIDToNewCurrent(oldPlan: Plan, updatedPlan: Plan): number { + let bestOldCurrent = oldPlan.current; + let newIndex = 0; + //Attempt current plan element's UUID first, otherwise step backwards until we find a match + while (bestOldCurrent >= 0) { + const tempNewIndex = updatedPlan.plan.findIndex(e => e.uuid === oldPlan.plan[bestOldCurrent].uuid); + if (tempNewIndex !== -1) { + newIndex = tempNewIndex; + break; + } + bestOldCurrent--; + } + + return newIndex; } diff --git a/src/app/plan-display/plan-display.module.ts b/src/app/plan-display/plan-display.module.ts index cf1d854..66dd1c3 100644 --- a/src/app/plan-display/plan-display.module.ts +++ b/src/app/plan-display/plan-display.module.ts @@ -14,6 +14,8 @@ import { MatListModule } from '@angular/material/list'; import { ScalableComponent } from '../Scalable/scalable.component'; import {MatTooltipModule} from '@angular/material/tooltip'; import { TooltipComponent } from '../tooltip/tooltip.component'; +import { AngularSvgIconModule } from 'angular-svg-icon'; +import { ScrollingModule } from '@angular/cdk/scrolling'; @NgModule({ declarations: [ PlanDisplayComponent @@ -33,7 +35,9 @@ import { TooltipComponent } from '../tooltip/tooltip.component'; MatListModule, ScalableComponent, MatTooltipModule, - TooltipComponent + TooltipComponent, + AngularSvgIconModule, + ScrollingModule ], exports: [ PlanDisplayComponent diff --git a/src/assets/material-check.svg b/src/assets/material-check.svg new file mode 100644 index 0000000..1655d12 --- /dev/null +++ b/src/assets/material-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/material-sync.svg b/src/assets/material-sync.svg new file mode 100644 index 0000000..30c7af7 --- /dev/null +++ b/src/assets/material-sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/material-warning.svg b/src/assets/material-warning.svg new file mode 100644 index 0000000..238299e --- /dev/null +++ b/src/assets/material-warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 41a6b5e..2c1d11e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2883,6 +2883,13 @@ angular-resize-event@^3.2.0: dependencies: tslib "^2.3.0" +angular-svg-icon@^17.0.0: + version "17.0.0" + resolved "https://registry.yarnpkg.com/angular-svg-icon/-/angular-svg-icon-17.0.0.tgz#656e54a6ef1dc8322b5cde649798fb5ce2349b4a" + integrity sha512-cTHz79nzVcfUSBPwclFRwvAsWYbo3rRTse45lnzmHdCV0GjTLUNE3C1yQwBAnHqlH4eN0sl+mbVpZh8YeJxYug== + dependencies: + tslib "^2.3.1" + ansi-colors@4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -6877,6 +6884,11 @@ tslib@^2.1.0, tslib@^2.3.0, tslib@^2.6.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== +tslib@^2.3.1: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tuf-js@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-1.1.7.tgz#21b7ae92a9373015be77dfe0cb282a80ec3bbe43"