Merge pull request #934 from hgiesel/graphprefs

Add GraphsPreferences API to graphs for setting persistent preferences
This commit is contained in:
Damien Elmes 2021-01-23 21:24:41 +10:00 committed by GitHub
commit 2a875ffc55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 231 additions and 57 deletions

View file

@ -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):

View file

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

View file

@ -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 {

View file

@ -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<pb::GraphPreferences> {
self.with_col(|col| col.get_graph_preferences())
}
fn set_graph_preferences(&self, input: pb::GraphPreferences) -> BackendResult<Empty> {
self.with_col(|col| col.set_graph_preferences(input))
.map(Into::into)
}
// decks
//-----------------------------------------------

View file

@ -47,7 +47,8 @@ pub(crate) enum ConfigKey {
ShowRemainingDueCountsInStudy,
ShowIntervalsAboveAnswerButtons,
NewReviewMix,
FirstWeekday,
FirstDayOfWeek,
CardCountsSeparateInactive,
AnswerTimeLimitSecs,
ShowDayLearningCardsFirst,
LastUnburiedDay,
@ -76,7 +77,8 @@ impl From<ConfigKey> 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)

View file

@ -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<pb::GraphPreferences> {
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<RevlogEntry> for pb::RevlogEntry {

View file

@ -71,6 +71,7 @@ ts_library(
"@npm//d3-transition",
"@npm//lodash.debounce",
"@npm//lodash.throttle",
"@npm//svelte",
],
)

View file

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

View file

@ -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);
</script>
@ -56,7 +58,10 @@
<h1>{graphData.title}</h1>
<div class="range-box-inner">
<SeparateInactiveCheckbox bind:separateInactive {i18n} />
<label>
<input type="checkbox" bind:checked={$cardCountsSeparateInactive} />
{label}
</label>
</div>
<div class="counts-outer">

View file

@ -4,8 +4,10 @@
<script lang="typescript">
import type { SvelteComponent } from "svelte/internal";
import type { I18n } from "anki/i18n";
import type { PreferenceStore } from "./preferences";
import type pb from "anki/backend_proto";
import { getGraphData, RevlogRange } from "./graph-helpers";
import { getPreferences } from "./preferences";
export let i18n: I18n;
export let nightMode: boolean;
@ -17,12 +19,18 @@
let active = false;
let sourceData: pb.BackendProto.GraphsOut | null = null;
let preferences: PreferenceStore | null = null;
let revlogRange: RevlogRange;
const preferencesPromise = getPreferences();
const refreshWith = async (search: string, days: number) => {
active = true;
try {
sourceData = await getGraphData(search, days);
[sourceData, preferences] = await Promise.all([
getGraphData(search, days),
preferencesPromise,
]);
revlogRange = days > 365 || days === 0 ? RevlogRange.All : RevlogRange.Year;
} catch (e) {
sourceData = null;
@ -54,6 +62,7 @@
<svelte:component
this={graph}
{sourceData}
{preferences}
{revlogRange}
{i18n}
{nightMode} />

View file

@ -1,10 +0,0 @@
<script lang="typescript">
import type { I18n } from "anki/i18n";
export let i18n: I18n;
export let separateInactive: boolean = true;
const label = i18n.tr(i18n.TR.STATISTICS_COUNTS_SEPARATE_SUSPENDED_BURIED_CARDS);
</script>
<label> <input type="checkbox" bind:checked={separateInactive} /> {label} </label>

View file

@ -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<number, number>();
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)!));
}

View file

@ -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<pb.BackendProto.GraphPreferences> {
return pb.BackendProto.GraphPreferences.decode(
await postRequest("/_anki/graphPreferences", JSON.stringify({}))
);
}
export async function setGraphPreferences(prefs: PreferencePayload): Promise<void> {
return (async (): Promise<void> => {
await postRequest(
"/_anki/setGraphPreferences",
pb.BackendProto.GraphPreferences.encode(prefs).finish()
);
})();
}
// amount of data to fetch from backend
export enum RevlogRange {
Year = 1,

75
ts/graphs/preferences.ts Normal file
View file

@ -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<T> extends Writable<T> {
subscribe: (getter: (value: T) => void) => () => void;
set: (value: T) => void;
}
export type PreferenceStore = {
[K in keyof Omit<pb.BackendProto.GraphPreferences, "toJSON">]: CustomStore<
pb.BackendProto.GraphPreferences[K]
>;
};
export type PreferencePayload = {
[K in keyof Omit<
pb.BackendProto.GraphPreferences,
"toJSON"
>]: pb.BackendProto.GraphPreferences[K];
};
function createPreference<T>(
initialValue: T,
savePreferences: () => void
): CustomStore<T> {
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<PreferenceStore> = {};
function constructPreferences(): PreferencePayload {
const payload: Partial<PreferencePayload> = {};
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<PreferenceStore> {
const initialPreferences = await getGraphPreferences();
return preparePreferences(initialPreferences);
}

View file

@ -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/*"]