~vpzom/sona

572f571a565c7bea552236e38f671517ca2d2f6b — Colin Reeder 3 months ago 33f117e + aced55b
Merge branch 'media'
M core/Cargo.toml => core/Cargo.toml +1 -1
@@ 9,7 9,7 @@ edition = "2021"
chrono = "0.4.23"
dirs = "4.0.0"
rand = "0.8.5"
rusqlite = "0.28.0"
rusqlite = { version = "0.28.0", features = ["blob"] }
serde_json = "1.0.91"
strum = "0.24.1"
strum_macros = "0.24.3"

M core/src/lib.rs => core/src/lib.rs +86 -7
@@ 48,9 48,15 @@ pub struct DeckInfo {
}

#[derive(Debug, Clone)]
pub struct MediaHandle {
    row_id: i64,
}

#[derive(Debug, Clone)]
pub struct NoteInfo {
    pub id: i32,
    pub data: HashMap<String, String>,
    pub media: HashMap<String, MediaHandle>,
}

#[derive(Debug, Clone)]


@@ 266,7 272,7 @@ impl Library {
            if let Some(card) = query_opt(
                &mut self.db,
                &format!(
                    "SELECT note.id, note.data, card_spec.id, card_spec.front, card_spec.back FROM note, card_spec WHERE note.deck = ?1 AND card_spec.deck = ?1 AND NOT EXISTS(SELECT 1 FROM card_state WHERE note = note.id AND card_spec = card_spec.id) ORDER BY {} LIMIT 1",
                    "SELECT note.id, note.data, card_spec.id, card_spec.front, card_spec.back, (SELECT json_group_object(key, rowid) FROM note_media WHERE note=note.id) FROM note, card_spec WHERE note.deck = ?1 AND card_spec.deck = ?1 AND NOT EXISTS(SELECT 1 FROM card_state WHERE note = note.id AND card_spec = card_spec.id) ORDER BY {} LIMIT 1",
                    match new_order {
                        NewCardOrder::Id => "note.id, card_spec.id",
                        NewCardOrder::Random => "random()",


@@ 274,7 280,19 @@ impl Library {
                ),
                (deck_id,),
                |row| Ok(CardInfo {
                    note: NoteInfo { id: row.get(0)?, data: serde_json::from_str(&row.get::<_, String>(1)?).map_err(|_| Error::InconsistentDatabase)? },
                    note: NoteInfo {
                        id: row.get(0)?,
                        data: serde_json::from_str(&row.get::<_, String>(1)?).map_err(|_| Error::InconsistentDatabase)?,
                        media: match row.get::<_, Option<String>>(5)? {
                            None => Default::default(),
                            Some(value) => {
                                serde_json::from_str::<HashMap<String, i64>>(&value).map_err(|_| Error::InconsistentDatabase)?
                                    .into_iter()
                                    .map(|(key, value)| (key, MediaHandle { row_id: value }))
                                    .collect()
                            }
                        }
                    },
                    card_spec: CardSpecInfo {
                        id: row.get(2)?,
                        front_template: row.get(3)?,


@@ 294,10 312,22 @@ impl Library {

        let card = query_opt(
            &mut self.db,
            "SELECT note.id, note.data, card_spec.id, card_spec.front, card_spec.back FROM card_state, note, card_spec WHERE card_state.note = note.id AND card_state.card_spec = card_spec.id AND card_state.next_at < ?1 AND note.deck = ?2 ORDER BY (delay_until IS NOT NULL AND delay_until > ?3), next_at",
            "SELECT note.id, note.data, card_spec.id, card_spec.front, card_spec.back, (SELECT json_group_object(key, rowid) FROM note_media WHERE note=note.id) FROM card_state, note, card_spec WHERE card_state.note = note.id AND card_state.card_spec = card_spec.id AND card_state.next_at < ?1 AND note.deck = ?2 ORDER BY (delay_until IS NOT NULL AND delay_until > ?3), next_at",
            (day_end.timestamp(), deck_id, now.timestamp()),
            |row| Ok(CardInfo {
                note: NoteInfo { id: row.get(0)?, data: serde_json::from_str(&row.get::<_, String>(1)?).map_err(|_| Error::InconsistentDatabase)? },
                note: NoteInfo {
                    id: row.get(0)?,
                    data: serde_json::from_str(&row.get::<_, String>(1)?).map_err(|_| Error::InconsistentDatabase)?,
                    media: match row.get::<_, Option<String>>(5)? {
                        None => Default::default(),
                        Some(value) => {
                            serde_json::from_str::<HashMap<String, i64>>(&value).map_err(|_| Error::InconsistentDatabase)?
                                .into_iter()
                                .map(|(key, value)| (key, MediaHandle { row_id: value }))
                                .collect()
                        }
                    }
                },
                card_spec: CardSpecInfo {
                    id: row.get(2)?,
                    front_template: row.get(3)?,


@@ 459,7 489,7 @@ impl Library {
    }

    pub fn get_notes(&mut self, deck: i32) -> Result<Vec<NoteInfo>, Error> {
        let mut stmt = self.db.prepare("SELECT id, data FROM note WHERE deck=?")?;
        let mut stmt = self.db.prepare("SELECT id, data, (SELECT json_group_object(key, rowid) FROM note_media WHERE note=note.id) FROM note WHERE deck=?")?;
        let mut rows = stmt.query((deck,))?;

        let mut output = Vec::new();


@@ 469,13 499,21 @@ impl Library {
                id: row.get(0)?,
                data: serde_json::from_str(&row.get::<_, String>(1)?)
                    .map_err(|_| Error::InconsistentDatabase)?,
                media: match row.get::<_, Option<String>>(2)? {
                    None => Default::default(),
                    Some(value) => serde_json::from_str::<HashMap<String, i64>>(&value)
                        .map_err(|_| Error::InconsistentDatabase)?
                        .into_iter()
                        .map(|(key, value)| (key, MediaHandle { row_id: value }))
                        .collect(),
                },
            });
        }

        Ok(output)
    }

    pub fn edit_note(&mut self, id: i32, data: HashMap<String, String>) -> Result<NoteInfo, Error> {
    pub fn edit_note(&mut self, id: i32, data: &HashMap<String, String>) -> Result<(), Error> {
        let count = self.db.execute(
            "UPDATE note SET data=? WHERE id=?",
            (serde_json::to_string(&data).unwrap(), id),


@@ 484,10 522,38 @@ impl Library {
        if count < 1 {
            Err(Error::NotFound)
        } else {
            Ok(NoteInfo { id, data })
            Ok(())
        }
    }

    pub fn add_note_media(
        &mut self,
        note_id: i32,
        key: &str,
        mut stream: impl std::io::Read + std::io::Seek,
    ) -> Result<(), Error> {
        let size = stream.seek(std::io::SeekFrom::End(0))?;
        stream.seek(std::io::SeekFrom::Start(0))?;

        self.db.execute(
            "INSERT INTO note_media (note, key, content) VALUES (?, ?, ZEROBLOB(?)) ON CONFLICT (note, key) DO UPDATE SET content=excluded.content",
            (note_id, key, size),
        )?;
        let row_id = self.db.last_insert_rowid();

        let mut blob = self.db.blob_open(
            rusqlite::DatabaseName::Main,
            "note_media",
            "content",
            row_id,
            false,
        )?;

        std::io::copy(&mut stream, &mut blob)?;

        Ok(())
    }

    pub fn delete_note(&mut self, id: i32) -> Result<(), Error> {
        let trans = self.db.transaction()?;
        trans.execute("DELETE FROM card_state WHERE note=?", (id,))?;


@@ 546,6 612,19 @@ impl Library {
            |row| row.get(0),
        )?)
    }

    pub fn open_media(
        &self,
        handle: &MediaHandle,
    ) -> Result<impl std::io::Read + std::io::Seek + '_, Error> {
        Ok(self.db.blob_open(
            rusqlite::DatabaseName::Main,
            "note_media",
            "content",
            handle.row_id,
            true,
        )?)
    }
}

fn init_library(db: &mut rusqlite::Connection) -> Result<(), Error> {

M core/src/migrations.rs => core/src/migrations.rs +11 -1
@@ 6,7 6,7 @@ pub fn run_migrations(db: &mut rusqlite::Connection) -> Result<(), Error> {
            row.get(0)
        })?;

    let target_version = 1677342349061;
    let target_version = 1708134132590;

    if current_version == target_version {
        // already up to date, no need to run all this


@@ 125,6 125,16 @@ pub fn run_migrations(db: &mut rusqlite::Connection) -> Result<(), Error> {
        }),
    )?;

    migration(
        1677342349061,
        1708134132590,
        Box::new(|trans| {
            trans.execute("CREATE TABLE note_media (note INTEGER NOT NULL REFERENCES note, key TEXT NOT NULL, content BLOB NOT NULL, PRIMARY KEY (note, key)) STRICT", ())?;

            Ok(())
        }),
    )?;

    if current_version == target_version {
        Ok(())
    } else {

M gtk/Cargo.toml => gtk/Cargo.toml +1 -0
@@ 4,6 4,7 @@ version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0.79"
gio = "0.16.7"
gtk = "0.16.2"
pango = "0.16.5"

M gtk/src/main.rs => gtk/src/main.rs +0 -36
@@ 1,4 1,3 @@
use gtk::glib;
use gtk::prelude::*;
use std::borrow::Cow;
use std::sync::{Arc, Mutex};


@@ 354,38 353,3 @@ fn main() {

    <Root as relm::Widget>::run(library).unwrap();
}

fn handle_template(template: &str, note: &sona::NoteInfo) -> String {
    let mut output = String::new();
    let mut rest = template;

    while !rest.is_empty() {
        if let Some(start) = rest.find('{') {
            output.push_str(&rest[..start]);
            rest = &rest[start..];
            if let Some(end) = rest.find('}') {
                let content = &rest[1..end];
                rest = &rest[(end + 1)..];

                if content.starts_with("data.") {
                    let key = &content["data.".len()..];
                    output.push_str(&glib::markup_escape_text(
                        note.data
                            .get(key)
                            .map(|x| -> &str { &x })
                            .unwrap_or("[missing]"),
                    ));
                } else {
                    output.push_str("[missing]");
                }
            } else {
                return "Invalid card spec (mismatched braces)".to_owned();
            }
        } else {
            output.push_str(rest);
            rest = "";
        }
    }

    output
}

M gtk/src/pages/notes.rs => gtk/src/pages/notes.rs +35 -11
@@ 213,6 213,7 @@ impl relm::Widget for NotesPage {
mod children {
    use super::NotesPageMessage;
    use gtk::prelude::*;
    use std::collections::HashMap;
    use std::sync::{Arc, Mutex};

    pub struct NoteEditDialogModel {


@@ 224,6 225,7 @@ mod children {
        deck_id: i32,
        note_id: Option<i32>,
        fields: Vec<(String, String)>,
        media: HashMap<String, sona::MediaHandle>,

        changed: bool,



@@ 250,6 252,23 @@ mod children {
            ),
        ) -> NoteEditDialogModel {
            let note = src.2;

            let (note_id, fields, media) = match note {
                Some(note) => (
                    Some(note.id),
                    note.data
                        .into_iter()
                        .chain(std::iter::once(("".to_owned(), "".to_owned())))
                        .collect(),
                    note.media,
                ),
                None => (
                    None,
                    vec![("".to_owned(), "".to_owned())],
                    Default::default(),
                ),
            };

            NoteEditDialogModel {
                stream: relm.stream().clone(),



@@ 257,15 276,9 @@ mod children {
                library: src.3,

                deck_id: src.1,
                note_id: note.as_ref().map(|note| note.id),
                fields: match note {
                    Some(note) => note
                        .data
                        .into_iter()
                        .chain(std::iter::once(("".to_owned(), "".to_owned())))
                        .collect(),
                    None => vec![("".to_owned(), "".to_owned())],
                },
                note_id,
                fields,
                media,

                changed: false,



@@ 308,10 321,21 @@ mod children {
                    let fields = self.model.fields.iter().cloned().collect();
                    let mut library = self.model.library.lock().unwrap();
                    let note = match self.model.note_id {
                        Some(note_id) => library.edit_note(note_id, fields).unwrap(),
                        Some(note_id) => {
                            library.edit_note(note_id, &fields).unwrap();
                            sona::NoteInfo {
                                id: note_id,
                                data: fields,
                                media: self.model.media.clone(),
                            }
                        }
                        None => {
                            let id = library.add_note(self.model.deck_id, &fields).unwrap();
                            sona::NoteInfo { id, data: fields }
                            sona::NoteInfo {
                                id,
                                data: fields,
                                media: self.model.media.clone(),
                            }
                        }
                    };


M gtk/src/pages/study.rs => gtk/src/pages/study.rs +122 -10
@@ 1,4 1,5 @@
use gtk::prelude::*;
use gtk::{gdk_pixbuf, glib};
use std::sync::{Arc, Mutex};

pub struct StudyPageModel {


@@ 99,6 100,7 @@ impl relm::Widget for StudyPage {
    }

    view! {
        #[name="page"]
        gtk::Box {
            child: {
                expand: true


@@ 106,9 108,9 @@ impl relm::Widget for StudyPage {
            orientation: gtk::Orientation::Vertical,
            visible: self.model.current_card.is_some(),
            #[name="content"]
            gtk::Label {
                line_wrap: true,
                justify: gtk::Justification::Center,
            gtk::Box {
                halign: gtk::Align::Center,
                orientation: gtk::Orientation::Vertical,
            },
            gtk::Box {
                child: {


@@ 152,15 154,125 @@ impl relm::Widget for StudyPage {

impl StudyPage {
    fn update_content(&mut self) {
        for child in self.widgets.content.children() {
            self.widgets.content.remove(&child);
        }

        let space = self.widgets.page.toplevel().unwrap().allocation();

        let available_width = space.width() - 60; // ???
        let available_height = space.height() - 60;

        println!("{} {}", available_width, available_height);

        if let Some(card) = &self.model.current_card {
            self.widgets.content.set_markup(&crate::handle_template(
                if self.model.flipped {
                    &card.card_spec.back_template
            let template = if self.model.flipped {
                &card.card_spec.back_template
            } else {
                &card.card_spec.front_template
            };
            let note = &card.note;

            let mut current_text = String::new();

            let mut rest = &template[..];

            let commit_current_text = |current_text: &mut String| {
                if !current_text.is_empty() {
                    println!("committing text: {:?}", current_text);

                    let child = gtk::Label::builder()
                        .label(&current_text)
                        .use_markup(true)
                        .build();
                    self.widgets.content.add(&child);

                    current_text.clear();
                }
            };

            while !rest.is_empty() {
                if let Some(start) = rest.find('{') {
                    current_text.push_str(&rest[..start]);
                    rest = &rest[start..];
                    if let Some(end) = rest.find('}') {
                        let content = &rest[1..end];
                        rest = &rest[(end + 1)..];

                        if content.starts_with("data.") {
                            let key = &content["data.".len()..];
                            current_text.push_str(&glib::markup_escape_text(
                                note.data
                                    .get(key)
                                    .map(|x| -> &str { &x })
                                    .unwrap_or("[missing]"),
                            ));
                        } else if content.starts_with("media.") {
                            let key = &content["media.".len()..];
                            match note.media.get(key) {
                                Some(media) => {
                                    let res = self
                                        .model
                                        .library
                                        .lock()
                                        .unwrap()
                                        .open_media(media)
                                        .map_err(anyhow::Error::from)
                                        .and_then(|mut src| {
                                            use std::io::Read;

                                            let mut bytes = Vec::new();
                                            src.read_to_end(&mut bytes)?;

                                            let bytes = glib::Bytes::from_owned(bytes);

                                            Ok(gdk_pixbuf::Pixbuf::from_stream_at_scale(
                                                &gio::MemoryInputStream::from_bytes(&bytes),
                                                available_width,
                                                available_height,
                                                true,
                                                gio::Cancellable::NONE,
                                            )?)
                                        });

                                    match res {
                                        Ok(img) => {
                                            commit_current_text(&mut current_text);

                                            let img_view = gtk::Image::builder()
                                                .pixbuf(&img)
                                                .width_request(0)
                                                .height_request(0)
                                                .halign(gtk::Align::Center)
                                                .hexpand(true)
                                                .build();
                                            self.widgets.content.add(&img_view);
                                        }
                                        Err(err) => {
                                            eprintln!("Failed to load media: {:?}", err);
                                        }
                                    }
                                }
                                None => {
                                    current_text.push_str("[missing]");
                                }
                            }
                        } else {
                            current_text.push_str("[missing]");
                        }
                    } else {
                        current_text = "[Invalid card spec (mismatched braces)]".to_owned();
                        break;
                    }
                } else {
                    &card.card_spec.front_template
                },
                &card.note,
            ));
                    current_text.push_str(rest);
                    rest = "";
                }
            }

            commit_current_text(&mut current_text);
        }

        self.widgets.content.show_all();
    }
}