diff --git a/pylib/tools/genbackend.py b/pylib/tools/genbackend.py index 5ed89f86b..3074bde63 100755 --- a/pylib/tools/genbackend.py +++ b/pylib/tools/genbackend.py @@ -34,7 +34,7 @@ LABEL_REPEATED = 3 # messages we don't want to unroll in codegen SKIP_UNROLL_INPUT = {"TranslateString"} -SKIP_DECODE = {"Graphs"} +SKIP_DECODE = {"Graphs", "GetGraphPreferences"} def python_type(field): diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index aecfc79d8..5482b849f 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -18,6 +18,7 @@ import flask_cors # type: ignore from flask import Response, request from waitress.server import create_server +import anki.backend_pb2 as pb import aqt from anki import hooks from anki.rsbackend import from_json_bytes @@ -255,17 +256,29 @@ def graph_data() -> bytes: return aqt.mw.col.backend.graphs(search=args["search"], days=args["days"]) +def graph_preferences() -> bytes: + return aqt.mw.col.backend.get_graph_preferences() + + +def set_graph_preferences() -> None: + input = pb.GraphPreferences() + input.ParseFromString(request.data) + aqt.mw.col.backend.set_graph_preferences(input=input) + + def congrats_info() -> bytes: info = aqt.mw.col.backend.congrats_info() return info.SerializeToString() -post_handlers = dict( - graphData=graph_data, +post_handlers = { + "graphData": graph_data, + "graphPreferences": graph_preferences, + "setGraphPreferences": set_graph_preferences, # pylint: disable=unnecessary-lambda - i18nResources=lambda: aqt.mw.col.backend.i18n_resources(), - congratsInfo=congrats_info, -) + "i18nResources": lambda: aqt.mw.col.backend.i18n_resources(), + "congratsInfo": congrats_info, +} def handle_post(path: str) -> Response: @@ -274,12 +287,15 @@ def handle_post(path: str) -> Response: return flask.make_response("Collection not open", HTTPStatus.NOT_FOUND) if path in post_handlers: - data = post_handlers[path]() - response = flask.make_response(data) - response.headers["Content-Type"] = "application/binary" - return response + if data := post_handlers[path](): + response = flask.make_response(data) + response.headers["Content-Type"] = "application/binary" + else: + response = flask.make_response("", HTTPStatus.NO_CONTENT) else: - return flask.make_response( + response = flask.make_response( f"Unhandled post to {path}", HTTPStatus.FORBIDDEN, ) + + return response diff --git a/rslib/backend.proto b/rslib/backend.proto index 848a201b9..56d38dd96 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -116,6 +116,8 @@ service BackendService { rpc CardStats(CardID) returns (String); rpc Graphs(GraphsIn) returns (GraphsOut); + rpc GetGraphPreferences(Empty) returns (GraphPreferences); + rpc SetGraphPreferences(GraphPreferences) returns (Empty); // media @@ -1082,12 +1084,6 @@ message GraphsIn { } message GraphsOut { - enum Weekday { - SUNDAY = 0; - MONDAY = 1; - FRIDAY = 5; - SATURDAY = 6; - } repeated Card cards = 1; repeated RevlogEntry revlog = 2; uint32 days_elapsed = 3; @@ -1096,7 +1092,17 @@ message GraphsOut { uint32 scheduler_version = 5; /// Seconds to add to UTC timestamps to get local time. int32 local_offset_secs = 7; - Weekday first_weekday = 8; +} + +message GraphPreferences { + enum Weekday { + SUNDAY = 0; + MONDAY = 1; + FRIDAY = 5; + SATURDAY = 6; + } + Weekday calendar_first_day_of_week = 1; + bool card_counts_separate_inactive = 2; } message RevlogEntry { diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 92080e62c..71e1595de 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -676,6 +676,15 @@ impl BackendService for Backend { self.with_col(|col| col.graph_data_for_search(&input.search, input.days)) } + fn get_graph_preferences(&self, _input: pb::Empty) -> BackendResult { + self.with_col(|col| col.get_graph_preferences()) + } + + fn set_graph_preferences(&self, input: pb::GraphPreferences) -> BackendResult { + self.with_col(|col| col.set_graph_preferences(input)) + .map(Into::into) + } + // decks //----------------------------------------------- diff --git a/rslib/src/config.rs b/rslib/src/config.rs index 0495015dd..c76147100 100644 --- a/rslib/src/config.rs +++ b/rslib/src/config.rs @@ -47,7 +47,8 @@ pub(crate) enum ConfigKey { ShowRemainingDueCountsInStudy, ShowIntervalsAboveAnswerButtons, NewReviewMix, - FirstWeekday, + FirstDayOfWeek, + CardCountsSeparateInactive, AnswerTimeLimitSecs, ShowDayLearningCardsFirst, LastUnburiedDay, @@ -76,7 +77,8 @@ impl From for &'static str { ConfigKey::ShowRemainingDueCountsInStudy => "dueCounts", ConfigKey::ShowIntervalsAboveAnswerButtons => "estTimes", ConfigKey::NewReviewMix => "newSpread", - ConfigKey::FirstWeekday => "firstWeekday", + ConfigKey::FirstDayOfWeek => "firstDayOfWeek", + ConfigKey::CardCountsSeparateInactive => "cardCountsSeparateInactive", ConfigKey::AnswerTimeLimitSecs => "timeLim", ConfigKey::ShowDayLearningCardsFirst => "dayLearnFirst", ConfigKey::LastUnburiedDay => "lastUnburied", @@ -229,11 +231,24 @@ impl Collection { self.set_config(ConfigKey::NewReviewMix, &(mix as u8)) } - pub(crate) fn get_first_weekday(&self) -> Weekday { - self.get_config_optional(ConfigKey::FirstWeekday) + pub(crate) fn get_first_day_of_week(&self) -> Weekday { + self.get_config_optional(ConfigKey::FirstDayOfWeek) .unwrap_or(Weekday::Sunday) } + pub(crate) fn set_first_day_of_week(&self, weekday: Weekday) -> Result<()> { + self.set_config(ConfigKey::FirstDayOfWeek, &weekday) + } + + pub(crate) fn get_card_counts_separate_inactive(&self) -> bool { + self.get_config_optional(ConfigKey::CardCountsSeparateInactive) + .unwrap_or(true) + } + + pub(crate) fn set_card_counts_separate_inactive(&self, separate: bool) -> Result<()> { + self.set_config(ConfigKey::CardCountsSeparateInactive, &separate) + } + pub(crate) fn get_show_due_counts(&self) -> bool { self.get_config_optional(ConfigKey::ShowRemainingDueCountsInStudy) .unwrap_or(true) diff --git a/rslib/src/stats/graphs.rs b/rslib/src/stats/graphs.rs index 59243e13f..093aecb73 100644 --- a/rslib/src/stats/graphs.rs +++ b/rslib/src/stats/graphs.rs @@ -1,7 +1,9 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::{backend_proto as pb, prelude::*, revlog::RevlogEntry, search::SortMode}; +use crate::{ + backend_proto as pb, config::Weekday, prelude::*, revlog::RevlogEntry, search::SortMode, +}; impl Collection { pub(crate) fn graph_data_for_search( @@ -42,9 +44,26 @@ impl Collection { next_day_at_secs: timing.next_day_at as u32, scheduler_version: self.sched_ver() as u32, local_offset_secs: local_offset_secs as i32, - first_weekday: self.get_first_weekday() as i32, }) } + + pub(crate) fn get_graph_preferences(&self) -> Result { + Ok(pb::GraphPreferences { + calendar_first_day_of_week: self.get_first_day_of_week() as i32, + card_counts_separate_inactive: self.get_card_counts_separate_inactive(), + }) + } + + pub(crate) fn set_graph_preferences(&self, prefs: pb::GraphPreferences) -> Result<()> { + self.set_first_day_of_week(match prefs.calendar_first_day_of_week { + 1 => Weekday::Monday, + 5 => Weekday::Friday, + 6 => Weekday::Saturday, + _ => Weekday::Sunday, + })?; + self.set_card_counts_separate_inactive(prefs.card_counts_separate_inactive)?; + Ok(()) + } } impl From for pb::RevlogEntry { diff --git a/ts/graphs/BUILD.bazel b/ts/graphs/BUILD.bazel index 0db44c94d..34d8083bc 100644 --- a/ts/graphs/BUILD.bazel +++ b/ts/graphs/BUILD.bazel @@ -71,6 +71,7 @@ ts_library( "@npm//d3-transition", "@npm//lodash.debounce", "@npm//lodash.throttle", + "@npm//svelte", ], ) diff --git a/ts/graphs/CalendarGraph.svelte b/ts/graphs/CalendarGraph.svelte index c11606c09..0ad08d0b8 100644 --- a/ts/graphs/CalendarGraph.svelte +++ b/ts/graphs/CalendarGraph.svelte @@ -3,15 +3,18 @@ import AxisTicks from "./AxisTicks.svelte"; import { defaultGraphBounds, RevlogRange } from "./graph-helpers"; import { gatherData, renderCalendar } from "./calendar"; + import type { PreferenceStore } from "./preferences"; import type { GraphData } from "./calendar"; import type pb from "anki/backend_proto"; import type { I18n } from "anki/i18n"; export let sourceData: pb.BackendProto.GraphsOut | null = null; + export let preferences: PreferenceStore | null = null; export let revlogRange: RevlogRange; export let i18n: I18n; export let nightMode: boolean; + let { calendarFirstDayOfWeek } = preferences; let graphData: GraphData | null = null; let bounds = defaultGraphBounds(); @@ -25,7 +28,7 @@ let targetYear = maxYear; $: if (sourceData) { - graphData = gatherData(sourceData); + graphData = gatherData(sourceData, $calendarFirstDayOfWeek); } $: { @@ -49,7 +52,8 @@ targetYear, i18n, nightMode, - revlogRange + revlogRange, + calendarFirstDayOfWeek.set ); } diff --git a/ts/graphs/CardCounts.svelte b/ts/graphs/CardCounts.svelte index 3a8489632..7835f7e10 100644 --- a/ts/graphs/CardCounts.svelte +++ b/ts/graphs/CardCounts.svelte @@ -2,14 +2,15 @@ import { defaultGraphBounds } from "./graph-helpers"; import { gatherData, renderCards } from "./card-counts"; import type { GraphData, TableDatum } from "./card-counts"; + import type { PreferenceStore } from "./preferences"; import type pb from "anki/backend_proto"; import type { I18n } from "anki/i18n"; - import SeparateInactiveCheckbox from "./SeparateInactiveCheckbox.svelte"; export let sourceData: pb.BackendProto.GraphsOut; export let i18n: I18n; + export let preferences: PreferenceStore; - let separateInactive = true; + let { cardCountsSeparateInactive } = preferences; let svg = null as HTMLElement | SVGElement | null; let bounds = defaultGraphBounds(); @@ -20,10 +21,11 @@ let tableData = (null as unknown) as TableDatum[]; $: { - graphData = gatherData(sourceData, separateInactive, i18n); + graphData = gatherData(sourceData, $cardCountsSeparateInactive, i18n); tableData = renderCards(svg as any, bounds, graphData); } + const label = i18n.tr(i18n.TR.STATISTICS_COUNTS_SEPARATE_SUSPENDED_BURIED_CARDS); const total = i18n.tr(i18n.TR.STATISTICS_COUNTS_TOTAL_CARDS); @@ -56,7 +58,10 @@

{graphData.title}

- +
diff --git a/ts/graphs/GraphsPage.svelte b/ts/graphs/GraphsPage.svelte index 47b566175..8615c28e5 100644 --- a/ts/graphs/GraphsPage.svelte +++ b/ts/graphs/GraphsPage.svelte @@ -4,8 +4,10 @@ - - diff --git a/ts/graphs/calendar.ts b/ts/graphs/calendar.ts index 10e121b6b..02d6dbc1d 100644 --- a/ts/graphs/calendar.ts +++ b/ts/graphs/calendar.ts @@ -6,7 +6,7 @@ @typescript-eslint/no-explicit-any: "off", */ -import type pb from "anki/backend_proto"; +import pb from "anki/backend_proto"; import { interpolateBlues } from "d3-scale-chromatic"; import "d3-transition"; import { select, mouse } from "d3-selection"; @@ -41,7 +41,13 @@ interface DayDatum { date: Date; } -export function gatherData(data: pb.BackendProto.GraphsOut): GraphData { +type WeekdayType = pb.BackendProto.GraphPreferences.Weekday; +const Weekday = pb.BackendProto.GraphPreferences.Weekday; /* enum */ + +export function gatherData( + data: pb.BackendProto.GraphsOut, + firstDayOfWeek: WeekdayType +): GraphData { const reviewCount = new Map(); for (const review of data.revlog as pb.BackendProto.RevlogEntry[]) { @@ -56,17 +62,17 @@ export function gatherData(data: pb.BackendProto.GraphsOut): GraphData { } const timeFunction = - data.firstWeekday === 1 + firstDayOfWeek === Weekday.MONDAY ? timeMonday - : data.firstWeekday === 5 + : firstDayOfWeek === Weekday.FRIDAY ? timeFriday - : data.firstWeekday === 6 + : firstDayOfWeek === Weekday.SATURDAY ? timeSaturday : timeSunday; const weekdayLabels: number[] = []; for (let i = 0; i < 7; i++) { - weekdayLabels.push((data.firstWeekday + i) % 7); + weekdayLabels.push((firstDayOfWeek + i) % 7); } return { reviewCount, timeFunction, weekdayLabels }; @@ -79,7 +85,8 @@ export function renderCalendar( targetYear: number, i18n: I18n, nightMode: boolean, - revlogRange: RevlogRange + revlogRange: RevlogRange, + setFirstDayOfWeek: (d: number) => void ): void { const svg = select(svgElem); const now = new Date(); @@ -170,11 +177,19 @@ export function renderCalendar( .attr("height", height - 2) .attr("x", x(1)! - 3) .attr("y", (_d, index) => bounds.marginTop + index * height) + .attr("fill", nightMode ? "#ddd" : "black") .attr("dominant-baseline", "hanging") .attr("text-anchor", "end") .attr("font-size", "small") .attr("font-family", "monospace") - .style("user-select", "none"); + .style("user-select", "none") + .on("click", null) + .filter((d: number) => + [Weekday.SUNDAY, Weekday.MONDAY, Weekday.FRIDAY, Weekday.SATURDAY].includes( + d + ) + ) + .on("click", setFirstDayOfWeek); svg.select("g.days") .selectAll("rect") @@ -192,11 +207,5 @@ export function renderCalendar( .on("mouseout", hideTooltip) .transition() .duration(800) - .attr("fill", (d) => { - if (d.count === 0) { - return emptyColour; - } else { - return blues(d.count)!; - } - }); + .attr("fill", (d) => (d.count === 0 ? emptyColour : blues(d.count)!)); } diff --git a/ts/graphs/graph-helpers.ts b/ts/graphs/graph-helpers.ts index bed36f6d1..44934827f 100644 --- a/ts/graphs/graph-helpers.ts +++ b/ts/graphs/graph-helpers.ts @@ -8,6 +8,7 @@ import pb from "anki/backend_proto"; import type { Selection } from "d3-selection"; +import type { PreferencePayload } from "./preferences"; import { postRequest } from "anki/postrequest"; export async function getGraphData( @@ -19,6 +20,21 @@ export async function getGraphData( ); } +export async function getGraphPreferences(): Promise { + return pb.BackendProto.GraphPreferences.decode( + await postRequest("/_anki/graphPreferences", JSON.stringify({})) + ); +} + +export async function setGraphPreferences(prefs: PreferencePayload): Promise { + return (async (): Promise => { + await postRequest( + "/_anki/setGraphPreferences", + pb.BackendProto.GraphPreferences.encode(prefs).finish() + ); + })(); +} + // amount of data to fetch from backend export enum RevlogRange { Year = 1, diff --git a/ts/graphs/preferences.ts b/ts/graphs/preferences.ts new file mode 100644 index 000000000..5dad878a2 --- /dev/null +++ b/ts/graphs/preferences.ts @@ -0,0 +1,75 @@ +import pb from "anki/backend_proto"; +import { getGraphPreferences, setGraphPreferences } from "./graph-helpers"; +import { Writable, writable, get } from "svelte/store"; + +export interface CustomStore extends Writable { + subscribe: (getter: (value: T) => void) => () => void; + set: (value: T) => void; +} + +export type PreferenceStore = { + [K in keyof Omit]: CustomStore< + pb.BackendProto.GraphPreferences[K] + >; +}; + +export type PreferencePayload = { + [K in keyof Omit< + pb.BackendProto.GraphPreferences, + "toJSON" + >]: pb.BackendProto.GraphPreferences[K]; +}; + +function createPreference( + initialValue: T, + savePreferences: () => void +): CustomStore { + const { subscribe, set, update } = writable(initialValue); + + return { + subscribe, + set: (value: T): void => { + set(value); + savePreferences(); + }, + update: (updater: (value: T) => T): void => { + update(updater); + savePreferences(); + }, + }; +} + +function preparePreferences( + GraphPreferences: pb.BackendProto.GraphPreferences +): PreferenceStore { + const preferences: Partial = {}; + + function constructPreferences(): PreferencePayload { + const payload: Partial = {}; + + for (const key in preferences as PreferenceStore) { + payload[key] = get(preferences[key]); + } + + return payload as PreferencePayload; + } + + function savePreferences(): void { + setGraphPreferences(constructPreferences()); + } + + for (const [key, value] of Object.entries( + pb.BackendProto.GraphPreferences.toObject(GraphPreferences, { + defaults: true, + }) + )) { + preferences[key] = createPreference(value, savePreferences); + } + + return preferences as PreferenceStore; +} + +export async function getPreferences(): Promise { + const initialPreferences = await getGraphPreferences(); + return preparePreferences(initialPreferences); +} diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 4e8f96d8c..883e2130b 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "target": "es6", "module": "es6", - "lib": ["es2016", "es2019.array", "dom", "dom.iterable"], + "lib": ["es2017", "es2019.array", "dom", "dom.iterable"], "baseUrl": ".", "paths": { "anki/*": ["../bazel-bin/ts/lib/*"]