Optionally export deck and notetype with CSV

This commit is contained in:
RumovZ 2022-06-04 11:01:04 +02:00
parent fc31478196
commit 6cd10e858e
12 changed files with 215 additions and 38 deletions

View file

@ -38,3 +38,5 @@ exporting-processed-media-files =
[one] Processed { $count } media file...
*[other] Processed { $count } media files...
}
exporting-include-deck = Include deck
exporting-include-notetype = Include notetype

View file

@ -176,7 +176,9 @@ message ExportNoteCsvRequest {
string out_path = 1;
bool with_html = 2;
bool with_tags = 3;
ExportLimit limit = 4;
bool with_deck = 4;
bool with_notetype = 5;
ExportLimit limit = 6;
}
message ExportLimit {

View file

@ -423,11 +423,15 @@ class Collection(DeprecatedNamesMixin):
limit: ExportLimit,
with_html: bool,
with_tags: bool,
with_deck: bool,
with_notetype: bool,
) -> int:
return self._backend.export_note_csv(
out_path=out_path,
with_html=with_html,
with_tags=with_tags,
with_deck=with_deck,
with_notetype=with_notetype,
limit=pb_export_limit(limit),
)

View file

@ -98,6 +98,9 @@ class ExportDialog(QDialog):
self.frm.includeHTML.setVisible(False)
# show deck list?
self.frm.deck.setVisible(not self.isVerbatim)
# used by the new export screen
self.frm.includeDeck.setVisible(False)
self.frm.includeNotetype.setVisible(False)
def accept(self) -> None:
self.exporter.includeSched = self.frm.includeSched.isChecked()

View file

@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>563</width>
<height>245</height>
<width>610</width>
<height>348</height>
</rect>
</property>
<property name="windowTitle">
@ -87,6 +87,26 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="includeDeck">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>exporting_include_deck</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="includeNotetype">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>exporting_include_notetype</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="includeHTML">
<property name="text">

View file

@ -91,6 +91,8 @@ class ExportDialog(QDialog):
self.frm.includeMedia.setVisible(self.exporter.show_include_media)
self.frm.includeTags.setVisible(self.exporter.show_include_tags)
self.frm.includeHTML.setVisible(self.exporter.show_include_html)
self.frm.includeDeck.setVisible(self.exporter.show_include_deck)
self.frm.includeNotetype.setVisible(self.exporter.show_include_notetype)
self.frm.legacy_support.setVisible(self.exporter.show_legacy_support)
self.frm.deck.setVisible(self.exporter.show_deck_list)
@ -135,6 +137,8 @@ class ExportDialog(QDialog):
include_media=self.frm.includeMedia.isChecked(),
include_tags=self.frm.includeTags.isChecked(),
include_html=self.frm.includeHTML.isChecked(),
include_deck=self.frm.includeDeck.isChecked(),
include_notetype=self.frm.includeNotetype.isChecked(),
legacy_support=self.frm.legacy_support.isChecked(),
limit=limit,
)
@ -165,6 +169,8 @@ class Options:
include_media: bool
include_tags: bool
include_html: bool
include_deck: bool
include_notetype: bool
legacy_support: bool
limit: ExportLimit
@ -177,6 +183,8 @@ class Exporter(ABC):
show_include_tags = False
show_include_html = False
show_legacy_support = False
show_include_deck = False
show_include_notetype = False
@staticmethod
@abstractmethod
@ -255,6 +263,8 @@ class NoteCsvExporter(Exporter):
show_deck_list = True
show_include_html = True
show_include_tags = True
show_include_deck = True
show_include_notetype = True
@staticmethod
def name() -> str:
@ -269,6 +279,8 @@ class NoteCsvExporter(Exporter):
limit=options.limit,
with_html=options.include_html,
with_tags=options.include_tags,
with_deck=options.include_deck,
with_notetype=options.include_notetype,
),
success=lambda count: tooltip(
tr.exporting_note_exported(count=count), parent=mw

View file

@ -93,16 +93,8 @@ impl ImportExportService for Backend {
}
fn export_note_csv(&self, input: pb::ExportNoteCsvRequest) -> Result<pb::UInt32> {
self.with_col(|col| {
col.export_note_csv(
&input.out_path,
SearchNode::from(input.limit.unwrap_or_default()),
input.with_html,
input.with_tags,
self.export_progress_fn(),
)
})
.map(Into::into)
self.with_col(|col| col.export_note_csv(input, self.export_progress_fn()))
.map(Into::into)
}
fn export_card_csv(&self, input: pb::ExportCardCsvRequest) -> Result<pb::UInt32> {

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::{borrow::Cow, fs::File, io::Write};
use std::{borrow::Cow, collections::HashMap, fs::File, io::Write, sync::Arc};
use itertools::Itertools;
use lazy_static::lazy_static;
@ -9,10 +9,11 @@ use regex::Regex;
use super::metadata::Delimiter;
use crate::{
backend_proto::ExportNoteCsvRequest,
import_export::{ExportProgress, IncrementableProgress},
notetype::RenderCardOutput,
prelude::*,
search::SortMode,
search::{SearchNode, SortMode},
template::RenderedNode,
text::{html_to_text_line, CowMapping},
};
@ -45,21 +46,19 @@ impl Collection {
pub fn export_note_csv(
&mut self,
path: &str,
search: impl TryIntoSearch,
with_html: bool,
with_tags: bool,
mut request: ExportNoteCsvRequest,
progress_fn: impl 'static + FnMut(ExportProgress, bool) -> bool,
) -> Result<usize> {
let mut progress = IncrementableProgress::new(progress_fn);
progress.call(ExportProgress::File)?;
let mut incrementor = progress.incrementor(ExportProgress::Notes);
let mut writer = file_writer_with_header(path)?;
self.search_notes_into_table(search)?;
self.search_notes_into_table(request.search_node())?;
let ctx = NoteContext::new(&request, self)?;
let mut writer = note_file_writer_with_header(&request.out_path, &ctx)?;
self.storage.for_each_note_in_search(|note| {
incrementor.increment()?;
writer.write_record(note_record(&note, with_html, with_tags))?;
writer.write_record(ctx.record(&note))?;
Ok(())
})?;
writer.flush()?;
@ -79,15 +78,41 @@ impl Collection {
fn file_writer_with_header(path: &str) -> Result<csv::Writer<File>> {
let mut file = File::create(path)?;
write_header(&mut file)?;
write_file_header(&mut file)?;
Ok(csv::WriterBuilder::new()
.delimiter(DELIMITER.byte())
.flexible(true)
.from_writer(file))
}
fn write_header(writer: &mut impl Write) -> Result<()> {
write!(writer, "#separator:{}\n#html:true\n", DELIMITER.name())?;
fn write_file_header(writer: &mut impl Write) -> Result<()> {
writeln!(writer, "#separator:{}", DELIMITER.name())?;
writeln!(writer, "#html:true")?;
Ok(())
}
fn note_file_writer_with_header(path: &str, ctx: &NoteContext) -> Result<csv::Writer<File>> {
let mut file = File::create(path)?;
write_note_file_header(&mut file, ctx)?;
Ok(csv::WriterBuilder::new()
.delimiter(DELIMITER.byte())
.from_writer(file))
}
fn write_note_file_header(writer: &mut impl Write, ctx: &NoteContext) -> Result<()> {
write_file_header(writer)?;
write_column_header(ctx, writer)
}
fn write_column_header(ctx: &NoteContext, writer: &mut impl Write) -> Result<()> {
for (name, column) in [
("notetype", ctx.notetype_column()),
("deck", ctx.deck_column()),
("tags", ctx.tags_column()),
] {
if let Some(index) = column {
writeln!(writer, "#{name} column:{index}")?;
}
}
Ok(())
}
@ -117,18 +142,6 @@ fn rendered_nodes_to_str(nodes: &[RenderedNode]) -> String {
.join("")
}
fn note_record(note: &Note, with_html: bool, with_tags: bool) -> Vec<String> {
let mut fields: Vec<_> = note
.fields()
.iter()
.map(|f| field_to_record_field(f, with_html))
.collect();
if with_tags {
fields.push(note.tags.join(" "));
}
fields
}
fn field_to_record_field(field: &str, with_html: bool) -> String {
let mut text = strip_redundant_sections(field);
if !with_html {
@ -157,3 +170,92 @@ fn strip_answer_side_question(text: &str) -> Cow<str> {
}
RE.replace_all(text.as_ref(), "")
}
struct NoteContext {
with_html: bool,
with_tags: bool,
with_deck: bool,
with_notetype: bool,
notetypes: HashMap<NotetypeId, Arc<Notetype>>,
deck_ids: HashMap<NoteId, DeckId>,
deck_names: HashMap<DeckId, String>,
field_columns: usize,
}
impl NoteContext {
/// Caller must have searched notes into table.
fn new(request: &ExportNoteCsvRequest, col: &mut Collection) -> Result<Self> {
let notetypes = col.get_all_notetypes_of_search_notes()?;
let field_columns = notetypes
.values()
.map(|nt| nt.fields.len())
.max()
.unwrap_or_default();
let deck_ids = col.storage.all_decks_of_search_notes()?;
let deck_names = HashMap::from_iter(col.storage.get_all_deck_names()?.into_iter());
Ok(Self {
with_html: request.with_html,
with_tags: request.with_tags,
with_deck: request.with_deck,
with_notetype: request.with_notetype,
notetypes,
field_columns,
deck_ids,
deck_names,
})
}
fn notetype_column(&self) -> Option<usize> {
self.with_notetype.then(|| 1)
}
fn deck_column(&self) -> Option<usize> {
self.with_deck
.then(|| 1 + self.notetype_column().unwrap_or_default())
}
fn tags_column(&self) -> Option<usize> {
self.with_tags
.then(|| 1 + self.deck_column().unwrap_or_default() + self.field_columns)
}
fn record<'i, 's: 'i, 'n: 'i>(&'s self, note: &'n Note) -> impl Iterator<Item = String> + 'i {
self.notetype_name(note)
.into_iter()
.chain(self.deck_name(note).into_iter())
.chain(self.note_fields(note).into_iter())
.chain(self.with_tags.then(|| note.tags.join(" ")).into_iter())
}
fn notetype_name(&self, note: &Note) -> Option<String> {
self.with_notetype.then(|| {
self.notetypes
.get(&note.notetype_id)
.map_or(String::new(), |nt| nt.name.clone())
})
}
fn deck_name(&self, note: &Note) -> Option<String> {
self.with_deck.then(|| {
self.deck_ids
.get(&note.id)
.and_then(|did| self.deck_names.get(did))
.map_or(String::new(), |name| name.clone())
})
}
fn note_fields<'n>(&self, note: &'n Note) -> impl Iterator<Item = String> + 'n {
let with_html = self.with_html;
note.fields()
.iter()
.map(move |f| field_to_record_field(f, with_html))
.pad_using(self.field_columns, |_| String::new())
}
}
impl ExportNoteCsvRequest {
fn search_node(&mut self) -> SearchNode {
SearchNode::from(self.limit.take().unwrap_or_default())
}
}

View file

@ -218,6 +218,21 @@ impl Collection {
.collect()
}
pub fn get_all_notetypes_of_search_notes(
&mut self,
) -> Result<HashMap<NotetypeId, Arc<Notetype>>> {
self.storage
.all_notetypes_of_search_notes()?
.into_iter()
.map(|ntid| {
self.get_notetype(ntid)
.transpose()
.unwrap()
.map(|nt| (ntid, nt))
})
.collect()
}
pub fn remove_notetype(&mut self, ntid: NotetypeId) -> Result<OpOutput<()>> {
self.transact(Op::RemoveNotetype, |col| col.remove_notetype_inner(ntid))
}

View file

@ -0,0 +1,8 @@
SELECT nid,
did
FROM cards
WHERE ord = 0
AND nid IN (
SELECT nid
FROM search_nids
)

View file

@ -131,6 +131,14 @@ impl SqliteStorage {
.collect()
}
/// Returns the deck id of the first card of every searched note.
pub(crate) fn all_decks_of_search_notes(&self) -> Result<HashMap<NoteId, DeckId>> {
self.db
.prepare_cached(include_str!("all_decks_of_search_notes.sql"))?
.query_and_then([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect()
}
// caller should ensure name unique
pub(crate) fn add_deck(&self, deck: &mut Deck) -> Result<()> {
assert!(deck.id.0 == 0);

View file

@ -116,6 +116,15 @@ impl SqliteStorage {
.collect()
}
pub(crate) fn all_notetypes_of_search_notes(&self) -> Result<Vec<NotetypeId>> {
self.db
.prepare_cached(
"SELECT DISTINCT mid FROM notes WHERE id IN (SELECT nid FROM search_nids)",
)?
.query_and_then([], |r| Ok(r.get(0)?))?
.collect()
}
pub fn get_all_notetype_names(&self) -> Result<Vec<(NotetypeId, String)>> {
self.db
.prepare_cached(include_str!("get_notetype_names.sql"))?