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;
TodayGraphData today = 3;
ButtonsGraphData buttons = 4;
repeated Card cards2 = 5;
repeated RevlogEntry revlog = 6;
}
message CardsGraphData {
@ -1029,3 +1031,15 @@ message ButtonsGraphData {
repeated uint32 young = 2;
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
SKIP_UNROLL_INPUT = {"TranslateString"}
SKIP_DECODE = {"Graphs"}
def python_type(field):
@ -118,13 +119,25 @@ def render_method(method, idx):
else:
single_field = ""
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}:
{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))
return output{single_field}
"""
return buf
out = []
for idx, method in enumerate(pb._BACKENDSERVICE.methods):

View file

@ -1,6 +1,9 @@
# Copyright: Ankitects Pty Ltd and contributors
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import http.server
import re
import socket
@ -9,6 +12,7 @@ import threading
from http import HTTPStatus
from typing import Optional
import aqt
from anki.collection import Collection
from anki.utils import devMode
from aqt.qt import *
@ -59,7 +63,8 @@ class MediaServer(threading.Thread):
def run(self):
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.server.serve_forever()
@ -74,7 +79,7 @@ class MediaServer(threading.Thread):
class RequestHandler(http.server.SimpleHTTPRequestHandler):
timeout = 1
mw: Optional[Collection] = None
mw: Optional[aqt.main.AnkiQt] = None
def do_GET(self):
f = self.send_head()
@ -168,6 +173,32 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler):
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
RequestHandler.extensions_map[".css"] = "text/css"

View file

@ -685,7 +685,7 @@ impl BackendService for Backend {
col.storage
.get_card(input.into())
.and_then(|opt| opt.ok_or(AnkiError::NotFound))
.map(card_to_pb)
.map(Into::into)
})
}
@ -1700,26 +1700,28 @@ fn sort_kind_from_pb(kind: i32) -> SortKind {
}
}
fn card_to_pb(c: Card) -> pb::Card {
pb::Card {
id: c.id.0,
nid: c.nid.0,
did: c.did.0,
ord: c.ord as u32,
mtime: c.mtime.0,
usn: c.usn.0,
ctype: c.ctype as u32,
queue: c.queue as i32,
due: c.due,
ivl: c.ivl,
factor: c.factor as u32,
reps: c.reps,
lapses: c.lapses,
left: c.left,
odue: c.odue,
odid: c.odid.0,
flags: c.flags as u32,
data: c.data,
impl From<Card> for pb::Card {
fn from(c: Card) -> Self {
pb::Card {
id: c.id.0,
nid: c.nid.0,
did: c.did.0,
ord: c.ord as u32,
mtime: c.mtime.0,
usn: c.usn.0,
ctype: c.ctype as u32,
queue: c.queue as i32,
due: c.due,
ivl: c.ivl,
factor: c.factor as u32,
reps: c.reps,
lapses: c.lapses,
left: c.left,
odue: c.odue,
odid: c.odid.0,
flags: c.flags as u32,
data: c.data,
}
}
}

View file

@ -1,6 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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 num_enum::TryFromPrimitive;
use serde::Deserialize;
@ -30,7 +31,7 @@ pub struct RevlogEntry {
/// Amount of milliseconds taken to answer the card.
#[serde(rename = "time")]
pub taken_millis: u32,
#[serde(rename = "type")]
#[serde(rename = "type", default, deserialize_with = "default_on_invalid")]
pub review_kind: RevlogReviewKind,
}

View file

@ -11,6 +11,7 @@ use crate::collection::Collection;
use crate::config::SortKind;
use crate::err::Result;
use crate::search::parser::parse;
use rusqlite::NO_PARAMS;
#[derive(Debug, PartialEq, Clone)]
pub enum SortMode {
@ -87,6 +88,31 @@ impl Collection {
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.
fn resolve_config_sort(&self, mode: &mut SortMode) {
if mode == &SortMode::FromConfig {

View file

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

View file

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