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