diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py
index a3641fd95..3b0b16666 100644
--- a/qt/aqt/deckbrowser.py
+++ b/qt/aqt/deckbrowser.py
@@ -65,7 +65,7 @@ class DeckBrowser:
def _linkHandler(self, url):
if ":" in url:
- (cmd, arg) = url.split(":")
+ (cmd, arg) = url.split(":", 1)
else:
cmd = url
if cmd == "open":
diff --git a/qt/aqt/stats.py b/qt/aqt/stats.py
index 055a81424..2903f1917 100644
--- a/qt/aqt/stats.py
+++ b/qt/aqt/stats.py
@@ -51,6 +51,7 @@ class NewDeckStats(QDialog):
gui_hooks.stats_dialog_will_show(self)
self.show()
self.refresh()
+ self.form.web.set_bridge_command(self._on_bridge_cmd, self)
self.activateWindow()
def reject(self):
@@ -89,6 +90,14 @@ class NewDeckStats(QDialog):
def changeScope(self, type):
pass
+ def _on_bridge_cmd(self, cmd: str) -> bool:
+ if cmd.startswith("browserSearch"):
+ _, query = cmd.split(":", 1)
+ browser = aqt.dialogs.open("Browser", self.mw)
+ browser.search_for(query)
+
+ return False
+
def refresh(self):
self.form.web.load_ts_page("graphs")
diff --git a/rslib/backend.proto b/rslib/backend.proto
index 56d38dd96..1dcb9dac5 100644
--- a/rslib/backend.proto
+++ b/rslib/backend.proto
@@ -1103,6 +1103,7 @@ message GraphPreferences {
}
Weekday calendar_first_day_of_week = 1;
bool card_counts_separate_inactive = 2;
+ bool browser_links_supported = 3;
}
message RevlogEntry {
diff --git a/rslib/src/stats/graphs.rs b/rslib/src/stats/graphs.rs
index 093aecb73..704625575 100644
--- a/rslib/src/stats/graphs.rs
+++ b/rslib/src/stats/graphs.rs
@@ -51,6 +51,7 @@ impl Collection {
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(),
+ browser_links_supported: true,
})
}
diff --git a/ts/graphs/AddedGraph.svelte b/ts/graphs/AddedGraph.svelte
index 7d9485611..1f541fd99 100644
--- a/ts/graphs/AddedGraph.svelte
+++ b/ts/graphs/AddedGraph.svelte
@@ -9,13 +9,19 @@
import HistogramGraph from "./HistogramGraph.svelte";
import GraphRangeRadios from "./GraphRangeRadios.svelte";
import TableData from "./TableData.svelte";
+ import { createEventDispatcher } from "svelte";
+ import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n;
+ export let preferences: PreferenceStore;
let histogramData = null as HistogramData | null;
let tableData: TableDatum[] = [];
let graphRange: GraphRange = GraphRange.Month;
+ let { browserLinksSupported } = preferences;
+
+ const dispatch = createEventDispatcher();
let addedData: GraphData | null = null;
$: if (sourceData) {
@@ -23,7 +29,13 @@
}
$: if (addedData) {
- [histogramData, tableData] = buildHistogram(addedData, graphRange, i18n);
+ [histogramData, tableData] = buildHistogram(
+ addedData,
+ graphRange,
+ i18n,
+ dispatch,
+ $browserLinksSupported
+ );
}
const title = i18n.tr(i18n.TR.STATISTICS_ADDED_TITLE);
diff --git a/ts/graphs/CalendarGraph.svelte b/ts/graphs/CalendarGraph.svelte
index 0ad08d0b8..5296f6027 100644
--- a/ts/graphs/CalendarGraph.svelte
+++ b/ts/graphs/CalendarGraph.svelte
@@ -1,4 +1,5 @@
{#if controller}
@@ -65,7 +73,8 @@
{preferences}
{revlogRange}
{i18n}
- {nightMode} />
+ {nightMode}
+ on:search={browserSearch} />
{/each}
{/if}
diff --git a/ts/graphs/IntervalsGraph.svelte b/ts/graphs/IntervalsGraph.svelte
index 5f94b9fc2..f77348bae 100644
--- a/ts/graphs/IntervalsGraph.svelte
+++ b/ts/graphs/IntervalsGraph.svelte
@@ -12,21 +12,33 @@
import HistogramGraph from "./HistogramGraph.svelte";
import type { TableDatum } from "./graph-helpers";
import TableData from "./TableData.svelte";
+ import { createEventDispatcher } from "svelte";
+ import type { PreferenceStore } from "./preferences";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n;
+ export let preferences: PreferenceStore;
+
+ const dispatch = createEventDispatcher();
let intervalData: IntervalGraphData | null = null;
let histogramData = null as HistogramData | null;
let tableData: TableDatum[] = [];
let range = IntervalRange.Percentile95;
+ let { browserLinksSupported } = preferences;
$: if (sourceData) {
intervalData = gatherIntervalData(sourceData);
}
$: if (intervalData) {
- [histogramData, tableData] = prepareIntervalData(intervalData, range, i18n);
+ [histogramData, tableData] = prepareIntervalData(
+ intervalData,
+ range,
+ i18n,
+ dispatch,
+ $browserLinksSupported
+ );
}
const title = i18n.tr(i18n.TR.STATISTICS_INTERVALS_TITLE);
diff --git a/ts/graphs/added.ts b/ts/graphs/added.ts
index 74caf2206..a68fd875f 100644
--- a/ts/graphs/added.ts
+++ b/ts/graphs/added.ts
@@ -7,7 +7,7 @@
*/
import type pb from "anki/backend_proto";
-import { extent, histogram, sum } from "d3-array";
+import { extent, histogram, sum, Bin } from "d3-array";
import { scaleLinear, scaleSequential } from "d3-scale";
import type { HistogramData } from "./histogram-graph";
import { interpolateBlues } from "d3-scale-chromatic";
@@ -28,10 +28,23 @@ export function gatherData(data: pb.BackendProto.GraphsOut): GraphData {
return { daysAdded };
}
+function makeQuery(start: number, end: number): string {
+ const include = `"added:${start}"`;
+
+ if (start === 1) {
+ return include;
+ }
+
+ const exclude = `-"added:${end}"`;
+ return `${include} AND ${exclude}`;
+}
+
export function buildHistogram(
data: GraphData,
range: GraphRange,
- i18n: I18n
+ i18n: I18n,
+ dispatch: any,
+ browserLinksSupported: boolean
): [HistogramData | null, TableDatum[]] {
// get min/max
const total = data.daysAdded.length;
@@ -102,8 +115,23 @@ export function buildHistogram(
return `${day}:
${cards}
${total}: ${totalCards}`;
}
+ function onClick(bin: Bin): void {
+ const start = Math.abs(bin.x0!) + 1;
+ const end = Math.abs(bin.x1!) + 1;
+ const query = makeQuery(start, end);
+ dispatch("search", { query });
+ }
+
return [
- { scale, bins, total: totalInPeriod, hoverText, colourScale, showArea: true },
+ {
+ scale,
+ bins,
+ total: totalInPeriod,
+ hoverText,
+ onClick: browserLinksSupported ? onClick : null,
+ colourScale,
+ showArea: true,
+ },
tableData,
];
}
diff --git a/ts/graphs/calendar.ts b/ts/graphs/calendar.ts
index 02d6dbc1d..0dae1a530 100644
--- a/ts/graphs/calendar.ts
+++ b/ts/graphs/calendar.ts
@@ -82,6 +82,7 @@ export function renderCalendar(
svgElem: SVGElement,
bounds: GraphBounds,
sourceData: GraphData,
+ dispatch: any,
targetYear: number,
i18n: I18n,
nightMode: boolean,
@@ -205,6 +206,14 @@ export function renderCalendar(
showTooltip(tooltipText(d), x, y);
})
.on("mouseout", hideTooltip)
+ .attr("class", (d: any): string => {
+ return d.count > 0 ? "clickable" : "";
+ })
+ .on("click", function (this: any, d: any) {
+ if (d.count > 0) {
+ dispatch("search", { query: `"prop:rated=${d.day}"` });
+ }
+ })
.transition()
.duration(800)
.attr("fill", (d) => (d.count === 0 ? emptyColour : blues(d.count)!));
diff --git a/ts/graphs/card-counts.ts b/ts/graphs/card-counts.ts
index f45bca720..0eb2fba8f 100644
--- a/ts/graphs/card-counts.ts
+++ b/ts/graphs/card-counts.ts
@@ -23,7 +23,7 @@ import type { GraphBounds } from "./graph-helpers";
import { cumsum } from "d3-array";
import type { I18n } from "anki/i18n";
-type Count = [string, number, boolean];
+type Count = [string, number, boolean, string];
export interface GraphData {
title: string;
counts: Count[];
@@ -86,18 +86,51 @@ function countCards(
}
}
+ const extraQuery = separateInactive ? 'AND -("is:buried" OR "is:suspended")' : "";
+
const counts: Count[] = [
- [i18n.tr(i18n.TR.STATISTICS_COUNTS_NEW_CARDS), newCards, true],
- [i18n.tr(i18n.TR.STATISTICS_COUNTS_LEARNING_CARDS), learn, true],
- [i18n.tr(i18n.TR.STATISTICS_COUNTS_RELEARNING_CARDS), relearn, true],
- [i18n.tr(i18n.TR.STATISTICS_COUNTS_YOUNG_CARDS), young, true],
- [i18n.tr(i18n.TR.STATISTICS_COUNTS_MATURE_CARDS), mature, true],
+ [
+ i18n.tr(i18n.TR.STATISTICS_COUNTS_NEW_CARDS),
+ newCards,
+ true,
+ `"is:new"${extraQuery}`,
+ ],
+ [
+ i18n.tr(i18n.TR.STATISTICS_COUNTS_LEARNING_CARDS),
+ learn,
+ true,
+ `(-"is:review" AND "is:learn")${extraQuery}`,
+ ],
+ [
+ i18n.tr(i18n.TR.STATISTICS_COUNTS_RELEARNING_CARDS),
+ relearn,
+ true,
+ `("is:review" AND "is:learn")${extraQuery}`,
+ ],
+ [
+ i18n.tr(i18n.TR.STATISTICS_COUNTS_YOUNG_CARDS),
+ young,
+ true,
+ `("is:review" AND -"is:learn") AND "prop:ivl<21"${extraQuery}`,
+ ],
+ [
+ i18n.tr(i18n.TR.STATISTICS_COUNTS_MATURE_CARDS),
+ mature,
+ true,
+ `("is:review" -"is:learn") AND "prop:ivl>=21"${extraQuery}`,
+ ],
[
i18n.tr(i18n.TR.STATISTICS_COUNTS_SUSPENDED_CARDS),
suspended,
separateInactive,
+ '"is:suspended"',
+ ],
+ [
+ i18n.tr(i18n.TR.STATISTICS_COUNTS_BURIED_CARDS),
+ buried,
+ separateInactive,
+ '"is:buried"',
],
- [i18n.tr(i18n.TR.STATISTICS_COUNTS_BURIED_CARDS), buried, separateInactive],
];
return counts;
@@ -132,6 +165,7 @@ export interface SummedDatum {
count: number;
// show up in the table
show: boolean;
+ query: string;
// running total
total: number;
}
@@ -139,6 +173,7 @@ export interface SummedDatum {
export interface TableDatum {
label: string;
count: number;
+ query: string;
percent: string;
colour: string;
}
@@ -155,6 +190,7 @@ export function renderCards(
label: count[0],
count: count[1],
show: count[2],
+ query: count[3],
idx,
total: n,
} as SummedDatum;
@@ -205,6 +241,7 @@ export function renderCards(
count: d.count,
percent: `${percent}%`,
colour: barColours[idx],
+ query: d.query,
} as TableDatum)
: [];
});
diff --git a/ts/graphs/ease.ts b/ts/graphs/ease.ts
index 872efa77d..9468f7c04 100644
--- a/ts/graphs/ease.ts
+++ b/ts/graphs/ease.ts
@@ -7,7 +7,7 @@
*/
import type pb from "anki/backend_proto";
-import { extent, histogram, sum } from "d3-array";
+import { extent, histogram, sum, Bin } from "d3-array";
import { scaleLinear, scaleSequential } from "d3-scale";
import { CardType } from "anki/cards";
import type { HistogramData } from "./histogram-graph";
@@ -26,9 +26,22 @@ export function gatherData(data: pb.BackendProto.GraphsOut): GraphData {
return { eases };
}
+function makeQuery(start: number, end: number): string {
+ if (start === end) {
+ return `"prop:ease=${start / 100}"`;
+ }
+
+ const fromQuery = `"prop:ease>=${start / 100}"`;
+ const tillQuery = `"prop:ease<${end / 100}"`;
+
+ return `${fromQuery} AND ${tillQuery}`;
+}
+
export function prepareData(
data: GraphData,
- i18n: I18n
+ i18n: I18n,
+ dispatch: any,
+ browserLinksSupported: boolean
): [HistogramData | null, TableDatum[]] {
// get min/max
const allEases = data.eases;
@@ -61,6 +74,13 @@ export function prepareData(
});
}
+ function onClick(bin: Bin): void {
+ const start = bin.x0!;
+ const end = bin.x1! - 1;
+ const query = makeQuery(start, end);
+ dispatch("search", { query });
+ }
+
const xTickFormat = (num: number): string => `${num.toFixed(0)}%`;
const tableData = [
{
@@ -70,7 +90,16 @@ export function prepareData(
];
return [
- { scale, bins, total, hoverText, colourScale, showArea: false, xTickFormat },
+ {
+ scale,
+ bins,
+ total,
+ hoverText,
+ onClick: browserLinksSupported ? onClick : null,
+ colourScale,
+ showArea: false,
+ xTickFormat,
+ },
tableData,
];
}
diff --git a/ts/graphs/future-due.ts b/ts/graphs/future-due.ts
index 1ae73c0bf..fbd0f880f 100644
--- a/ts/graphs/future-due.ts
+++ b/ts/graphs/future-due.ts
@@ -72,11 +72,23 @@ export interface FutureDueOut {
tableData: TableDatum[];
}
+function makeQuery(start: number, end: number): string {
+ if (start === end) {
+ return `"prop:due=${start}"`;
+ } else {
+ const fromQuery = `"prop:due>=${start}"`;
+ const tillQuery = `"prop:due<=${end}"`;
+ return `${fromQuery} AND ${tillQuery}`;
+ }
+}
+
export function buildHistogram(
sourceData: GraphData,
range: GraphRange,
backlog: boolean,
- i18n: I18n
+ i18n: I18n,
+ dispatch: any,
+ browserLinksSupported: boolean
): FutureDueOut {
const output = { histogramData: null, tableData: [] };
// get min/max
@@ -145,6 +157,13 @@ export function buildHistogram(
return `${days}:
${cards}
${totalLabel}: ${cumulative}`;
}
+ function onClick(bin: Bin): void {
+ const start = bin.x0!;
+ const end = bin.x1! - 1;
+ const query = makeQuery(start, end);
+ dispatch("search", { query });
+ }
+
const periodDays = xMax! - xMin!;
const tableData = [
{
@@ -171,6 +190,7 @@ export function buildHistogram(
bins,
total,
hoverText,
+ onClick: browserLinksSupported ? onClick : null,
showArea: true,
colourScale,
binValue,
diff --git a/ts/graphs/graphs.scss b/ts/graphs/graphs.scss
index 75c4eddf7..bd2aa4615 100644
--- a/ts/graphs/graphs.scss
+++ b/ts/graphs/graphs.scss
@@ -197,3 +197,7 @@
.no-focus-outline:focus {
outline: 0;
}
+
+.clickable {
+ cursor: pointer;
+}
diff --git a/ts/graphs/histogram-graph.ts b/ts/graphs/histogram-graph.ts
index aa067c66a..4cf1a0d76 100644
--- a/ts/graphs/histogram-graph.ts
+++ b/ts/graphs/histogram-graph.ts
@@ -25,6 +25,7 @@ export interface HistogramData {
cumulative: number,
percent: number
) => string;
+ onClick: ((data: Bin) => void) | null;
showArea: boolean;
colourScale: ScaleSequential;
binValue?: (bin: Bin) => number;
@@ -131,7 +132,7 @@ export function histogramGraph(
"d",
area()
.curve(curveBasis)
- .x((d, idx) => {
+ .x((_d, idx) => {
if (idx === 0) {
return x(data.bins[0].x0!)!;
} else {
@@ -144,7 +145,8 @@ export function histogramGraph(
}
// hover/tooltip
- svg.select("g.hoverzone")
+ const hoverzone = svg
+ .select("g.hoverzone")
.selectAll("rect")
.data(data.bins)
.join("rect")
@@ -152,10 +154,14 @@ export function histogramGraph(
.attr("y", () => y(yMax!)!)
.attr("width", barWidth)
.attr("height", () => y(0)! - y(yMax!)!)
- .on("mousemove", function (this: any, d: any, idx) {
+ .on("mousemove", function (this: any, _d: any, idx: number) {
const [x, y] = mouse(document.body);
const pct = data.showArea ? (areaData[idx + 1] / data.total) * 100 : 0;
showTooltip(data.hoverText(data, idx, areaData[idx + 1], pct), x, y);
})
.on("mouseout", hideTooltip);
+
+ if (data.onClick) {
+ hoverzone.attr("class", "clickable").on("click", data.onClick);
+ }
}
diff --git a/ts/graphs/intervals.ts b/ts/graphs/intervals.ts
index 624701f60..91741e3dc 100644
--- a/ts/graphs/intervals.ts
+++ b/ts/graphs/intervals.ts
@@ -7,7 +7,7 @@
*/
import type pb from "anki/backend_proto";
-import { extent, histogram, quantile, sum, mean } from "d3-array";
+import { extent, histogram, quantile, sum, mean, Bin } from "d3-array";
import { scaleLinear, scaleSequential } from "d3-scale";
import { CardType } from "anki/cards";
import type { HistogramData } from "./histogram-graph";
@@ -56,10 +56,23 @@ export function intervalLabel(
}
}
+function makeQuery(start: number, end: number): string {
+ if (start === end) {
+ return `"prop:ivl=${start}"`;
+ }
+
+ const fromQuery = `"prop:ivl>=${start}"`;
+ const tillQuery = `"prop:ivl<=${end}"`;
+
+ return `${fromQuery} AND ${tillQuery}`;
+}
+
export function prepareIntervalData(
data: IntervalGraphData,
range: IntervalRange,
- i18n: I18n
+ i18n: I18n,
+ dispatch: any,
+ browserLinksSupported: boolean
): [HistogramData | null, TableDatum[]] {
// get min/max
const allIntervals = data.intervals;
@@ -134,6 +147,13 @@ export function prepareIntervalData(
return `${interval}
${total}: \u200e${percent.toFixed(1)}%`;
}
+ function onClick(bin: Bin): void {
+ const start = bin.x0!;
+ const end = bin.x1! - 1;
+ const query = makeQuery(start, end);
+ dispatch("search", { query });
+ }
+
const meanInterval = Math.round(mean(allIntervals) ?? 0);
const meanIntervalString = timeSpan(i18n, meanInterval * 86400, false);
const tableData = [
@@ -142,8 +162,17 @@ export function prepareIntervalData(
value: meanIntervalString,
},
];
+
return [
- { scale, bins, total: totalInPeriod, hoverText, colourScale, showArea: true },
+ {
+ scale,
+ bins,
+ total: totalInPeriod,
+ hoverText,
+ onClick: browserLinksSupported ? onClick : null,
+ colourScale,
+ showArea: true,
+ },
tableData,
];
}
diff --git a/ts/lib/bridgecommand.ts b/ts/lib/bridgecommand.ts
index b7a0f721b..927e7dd03 100644
--- a/ts/lib/bridgecommand.ts
+++ b/ts/lib/bridgecommand.ts
@@ -1,7 +1,17 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+declare global {
+ interface Window {
+ bridgeCommand(command: string, callback?: (value: T) => void): void;
+ }
+}
+
/// HTML tag pointing to a bridge command.
export function bridgeLink(command: string, label: string): string {
return `${label}`;
}
+
+export function bridgeCommand(command: string, callback?: (value: T) => void): void {
+ window.bridgeCommand(command, callback);
+}