experiment with exposing raw card/revlog data to frontend

This commit is contained in:
Damien Elmes 2020-06-22 13:33:53 +10:00
parent 510f8b86cb
commit 82568a1f3e
9 changed files with 205 additions and 58 deletions

View file

@ -992,6 +992,8 @@ message GraphsOut {
repeated HourGraphData hours = 2; repeated HourGraphData hours = 2;
TodayGraphData today = 3; TodayGraphData today = 3;
ButtonsGraphData buttons = 4; ButtonsGraphData buttons = 4;
repeated Card cards2 = 5;
repeated RevlogEntry revlog = 6;
} }
message CardsGraphData { message CardsGraphData {
@ -1029,3 +1031,15 @@ message ButtonsGraphData {
repeated uint32 young = 2; repeated uint32 young = 2;
repeated uint32 mature = 3; repeated uint32 mature = 3;
} }
message RevlogEntry {
int64 id = 1;
int64 cid = 2;
int32 usn = 3;
uint32 button_chosen = 4;
int32 interval = 5;
int32 last_interval = 6;
uint32 ease_factor = 7;
uint32 taken_millis = 8;
uint32 review_kind = 9;
}

View file

@ -31,6 +31,7 @@ LABEL_REPEATED = 3
# messages we don't want to unroll in codegen # messages we don't want to unroll in codegen
SKIP_UNROLL_INPUT = {"TranslateString"} SKIP_UNROLL_INPUT = {"TranslateString"}
SKIP_DECODE = {"Graphs"}
def python_type(field): def python_type(field):
@ -118,13 +119,25 @@ def render_method(method, idx):
else: else:
single_field = "" single_field = ""
return_type = f"pb.{method.output_type.name}" return_type = f"pb.{method.output_type.name}"
return f"""\
if method.name in SKIP_DECODE:
return_type = "bytes"
buf = f"""\
def {name}({input_args}) -> {return_type}: def {name}({input_args}) -> {return_type}:
{input_assign_outer}output = pb.{method.output_type.name}() {input_assign_outer}"""
if method.name in SKIP_DECODE:
buf += f"""return self._run_command({idx+1}, input)
"""
else:
buf += f"""output = pb.{method.output_type.name}()
output.ParseFromString(self._run_command({idx+1}, input)) output.ParseFromString(self._run_command({idx+1}, input))
return output{single_field} return output{single_field}
""" """
return buf
out = [] out = []
for idx, method in enumerate(pb._BACKENDSERVICE.methods): for idx, method in enumerate(pb._BACKENDSERVICE.methods):

View file

@ -1,6 +1,9 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import http.server import http.server
import re import re
import socket import socket
@ -9,6 +12,7 @@ import threading
from http import HTTPStatus from http import HTTPStatus
from typing import Optional from typing import Optional
import aqt
from anki.collection import Collection from anki.collection import Collection
from anki.utils import devMode from anki.utils import devMode
from aqt.qt import * from aqt.qt import *
@ -59,7 +63,8 @@ class MediaServer(threading.Thread):
def run(self): def run(self):
RequestHandler.mw = self.mw RequestHandler.mw = self.mw
self.server = ThreadedHTTPServer(("127.0.0.1", 0), RequestHandler) desired_port = int(os.getenv("ANKI_API_PORT", 0))
self.server = ThreadedHTTPServer(("127.0.0.1", desired_port), RequestHandler)
self._ready.set() self._ready.set()
self.server.serve_forever() self.server.serve_forever()
@ -74,7 +79,7 @@ class MediaServer(threading.Thread):
class RequestHandler(http.server.SimpleHTTPRequestHandler): class RequestHandler(http.server.SimpleHTTPRequestHandler):
timeout = 1 timeout = 1
mw: Optional[Collection] = None mw: Optional[aqt.main.AnkiQt] = None
def do_GET(self): def do_GET(self):
f = self.send_head() f = self.send_head()
@ -168,6 +173,32 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler):
return path return path
def do_POST(self):
if not self.path.startswith("/_anki/"):
self.send_error(HTTPStatus.NOT_FOUND, "Method not found")
return
cmd = self.path[len("/_anki/") :]
if cmd == "graphData":
data = graph_data(self.mw.col)
else:
self.send_error(HTTPStatus.NOT_FOUND, "Method not found")
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/binary")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def graph_data(col: Collection) -> bytes:
graphs = col.backend.graphs(search="", days=0)
return graphs
# work around Windows machines with incorrect mime type # work around Windows machines with incorrect mime type
RequestHandler.extensions_map[".css"] = "text/css" RequestHandler.extensions_map[".css"] = "text/css"

View file

@ -685,7 +685,7 @@ impl BackendService for Backend {
col.storage col.storage
.get_card(input.into()) .get_card(input.into())
.and_then(|opt| opt.ok_or(AnkiError::NotFound)) .and_then(|opt| opt.ok_or(AnkiError::NotFound))
.map(card_to_pb) .map(Into::into)
}) })
} }
@ -1700,7 +1700,8 @@ fn sort_kind_from_pb(kind: i32) -> SortKind {
} }
} }
fn card_to_pb(c: Card) -> pb::Card { impl From<Card> for pb::Card {
fn from(c: Card) -> Self {
pb::Card { pb::Card {
id: c.id.0, id: c.id.0,
nid: c.nid.0, nid: c.nid.0,
@ -1722,6 +1723,7 @@ fn card_to_pb(c: Card) -> pb::Card {
data: c.data, data: c.data,
} }
} }
}
fn pbcard_to_native(c: pb::Card) -> Result<Card> { fn pbcard_to_native(c: pb::Card) -> Result<Card> {
let ctype = CardType::try_from(c.ctype as u8) let ctype = CardType::try_from(c.ctype as u8)

View file

@ -1,6 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::serde::default_on_invalid;
use crate::{define_newtype, prelude::*}; use crate::{define_newtype, prelude::*};
use num_enum::TryFromPrimitive; use num_enum::TryFromPrimitive;
use serde::Deserialize; use serde::Deserialize;
@ -30,7 +31,7 @@ pub struct RevlogEntry {
/// Amount of milliseconds taken to answer the card. /// Amount of milliseconds taken to answer the card.
#[serde(rename = "time")] #[serde(rename = "time")]
pub taken_millis: u32, pub taken_millis: u32,
#[serde(rename = "type")] #[serde(rename = "type", default, deserialize_with = "default_on_invalid")]
pub review_kind: RevlogReviewKind, pub review_kind: RevlogReviewKind,
} }

View file

@ -11,6 +11,7 @@ use crate::collection::Collection;
use crate::config::SortKind; use crate::config::SortKind;
use crate::err::Result; use crate::err::Result;
use crate::search::parser::parse; use crate::search::parser::parse;
use rusqlite::NO_PARAMS;
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub enum SortMode { pub enum SortMode {
@ -87,6 +88,31 @@ impl Collection {
Ok(ids) Ok(ids)
} }
/// Place the matched card ids into a temporary 'search_cids' table
/// instead of returning them. Use clear_searched_cards() to remove it.
pub(crate) fn search_cards_into_table(&mut self, search: &str) -> Result<()> {
let top_node = Node::Group(parse(search)?);
let writer = SqlWriter::new(self);
let (mut sql, args) = writer.build_cards_query(&top_node, RequiredTable::Cards)?;
self.storage.db.execute_batch(concat!(
"drop table if exists search_cids;",
"create temporary table search_cids (id integer primary key not null);"
))?;
let sql = format!("insert into search_cids {}", sql);
self.storage.db.prepare(&sql)?.execute(&args)?;
Ok(())
}
pub(crate) fn clear_searched_cards(&mut self) -> Result<()> {
self.storage
.db
.execute("drop table if exists search_cids", NO_PARAMS)?;
Ok(())
}
/// If the sort mode is based on a config setting, look it up. /// If the sort mode is based on a config setting, look it up.
fn resolve_config_sort(&self, mode: &mut SortMode) { fn resolve_config_sort(&self, mode: &mut SortMode) {
if mode == &SortMode::FromConfig { if mode == &SortMode::FromConfig {

View file

@ -8,7 +8,6 @@ use crate::{
prelude::*, prelude::*,
revlog::{RevlogEntry, RevlogReviewKind}, revlog::{RevlogEntry, RevlogReviewKind},
sched::cutoff::SchedTimingToday, sched::cutoff::SchedTimingToday,
search::SortMode,
}; };
struct GraphsContext { struct GraphsContext {
@ -27,6 +26,8 @@ struct AllStats {
buttons: pb::ButtonsGraphData, buttons: pb::ButtonsGraphData,
hours: Vec<pb::HourGraphData>, hours: Vec<pb::HourGraphData>,
cards: pb::CardsGraphData, cards: pb::CardsGraphData,
cards2: Vec<Card>,
revlog: Vec<pb::RevlogEntry>,
} }
impl Default for AllStats { impl Default for AllStats {
@ -41,6 +42,8 @@ impl Default for AllStats {
buttons, buttons,
hours: vec![Default::default(); 24], hours: vec![Default::default(); 24],
cards: Default::default(), cards: Default::default(),
cards2: vec![],
revlog: vec![],
} }
} }
} }
@ -52,6 +55,8 @@ impl From<AllStats> for pb::GraphsOut {
hours: s.hours, hours: s.hours,
today: Some(s.today), today: Some(s.today),
buttons: Some(s.buttons), buttons: Some(s.buttons),
cards2: s.cards2.into_iter().map(Into::into).collect(),
revlog: s.revlog,
} }
} }
} }
@ -217,13 +222,15 @@ impl Collection {
search: &str, search: &str,
days: u32, days: u32,
) -> Result<pb::GraphsOut> { ) -> Result<pb::GraphsOut> {
let cids = self.search_cards(search, SortMode::NoOrder)?; self.search_cards_into_table(search)?;
let stats = self.graph_data(&cids, days)?; let i = std::time::Instant::now();
println!("{:#?}", stats); let all = search.trim().is_empty();
Ok(stats.into()) let stats = self.graph_data(all, days)?;
let stats = stats.into();
Ok(stats)
} }
fn graph_data(&self, cids: &[CardID], days: u32) -> Result<AllStats> { fn graph_data(&self, all: bool, days: u32) -> Result<AllStats> {
let timing = self.timing_today()?; let timing = self.timing_today()?;
let revlog_start = TimestampSecs(if days > 0 { let revlog_start = TimestampSecs(if days > 0 {
timing.next_day_at - (((days as i64) + 1) * 86_400) timing.next_day_at - (((days as i64) + 1) * 86_400)
@ -242,17 +249,35 @@ impl Collection {
stats: AllStats::default(), stats: AllStats::default(),
}; };
for cid in cids { let cards = self.storage.all_searched_cards()?;
let card = self.storage.get_card(*cid)?.ok_or(AnkiError::NotFound)?; let revlog = if all {
ctx.observe_card(&card); self.storage.get_all_revlog_entries(revlog_start)?
} else {
self.storage self.storage
.for_each_revlog_entry_of_card(*cid, revlog_start, |entry| { .get_revlog_entries_for_searched_cards(revlog_start)?
Ok(ctx.observe_review(entry)) };
})?;
}
ctx.stats.cards.note_count = self.storage.note_ids_of_cards(cids)?.len() as u32; ctx.stats.cards2 = cards;
ctx.stats.revlog = revlog;
// ctx.stats.cards.note_count = self.storage.note_ids_of_cards(cids)?.len() as u32;
Ok(ctx.stats) Ok(ctx.stats)
} }
} }
impl From<RevlogEntry> for pb::RevlogEntry {
fn from(e: RevlogEntry) -> Self {
pb::RevlogEntry {
id: e.id.0,
cid: e.cid.0,
usn: e.usn.0,
button_chosen: e.button_chosen as u32,
interval: e.interval,
last_interval: e.last_interval,
ease_factor: e.ease_factor,
taken_millis: e.taken_millis,
review_kind: e.review_kind as u32,
}
}
}

View file

@ -250,6 +250,33 @@ impl super::SqliteStorage {
} }
Ok(nids) Ok(nids)
} }
pub(crate) fn for_each_card_in_search<F>(&self, mut func: F) -> Result<()>
where
F: FnMut(&Card) -> Result<()>,
{
let mut stmt = self.db.prepare_cached(concat!(
include_str!("get_card.sql"),
" where id in (select id from search_cids)"
))?;
let mut rows = stmt.query(NO_PARAMS)?;
while let Some(row) = rows.next()? {
let entry = row_to_card(row)?;
func(&entry)?
}
Ok(())
}
pub(crate) fn all_searched_cards(&self) -> Result<Vec<Card>> {
self.db
.prepare_cached(concat!(
include_str!("get_card.sql"),
" where id in (select id from search_cids)"
))?
.query_and_then(NO_PARAMS, |r| row_to_card(r).map_err(Into::into))?
.collect()
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -4,6 +4,7 @@
use super::SqliteStorage; use super::SqliteStorage;
use crate::err::Result; use crate::err::Result;
use crate::{ use crate::{
backend_proto as pb,
prelude::*, prelude::*,
revlog::{RevlogEntry, RevlogReviewKind}, revlog::{RevlogEntry, RevlogReviewKind},
}; };
@ -34,7 +35,7 @@ fn row_to_revlog_entry(row: &Row) -> Result<RevlogEntry> {
last_interval: row.get(5)?, last_interval: row.get(5)?,
ease_factor: row.get(6)?, ease_factor: row.get(6)?,
taken_millis: row.get(7)?, taken_millis: row.get(7)?,
review_kind: row.get(8)?, review_kind: row.get(8).unwrap_or_default(),
}) })
} }
@ -85,24 +86,31 @@ impl SqliteStorage {
.collect() .collect()
} }
pub(crate) fn for_each_revlog_entry_of_card<F>( pub(crate) fn get_revlog_entries_for_searched_cards(
&self, &self,
cid: CardID, after: TimestampSecs,
from: TimestampSecs, ) -> Result<Vec<pb::RevlogEntry>> {
mut func: F, self.db
) -> Result<()> .prepare_cached(concat!(
where include_str!("get.sql"),
F: FnMut(&RevlogEntry) -> Result<()>, " where cid in (select id from search_cids) and id >= ?"
{ ))?
let mut stmt = self .query_and_then(&[after.0 * 1000], |r| {
.db row_to_revlog_entry(r).map(Into::into)
.prepare_cached(concat!(include_str!("get.sql"), " where cid=? and id>=?"))?; })?
let mut rows = stmt.query(&[cid.0, from.0 * 1000])?; .collect()
while let Some(row) = rows.next()? {
let entry = row_to_revlog_entry(row)?;
func(&entry)?
} }
Ok(()) /// This includes entries from deleted cards.
pub(crate) fn get_all_revlog_entries(
&self,
after: TimestampSecs,
) -> Result<Vec<pb::RevlogEntry>> {
self.db
.prepare_cached(concat!(include_str!("get.sql"), " where id >= ?"))?
.query_and_then(&[after.0 * 1000], |r| {
row_to_revlog_entry(r).map(Into::into)
})?
.collect()
} }
} }