diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index e2f05b275..e07435e9c 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -12,6 +12,8 @@ browsing-browser-appearance = Browser Appearance browsing-browser-options = Browser Options browsing-buried = Buried browsing-card = Card +# Exactly one character representing 'Cards'; should differ from browsing-note-initial. +browsing-card-initial = C browsing-card-list = Card List browsing-card-state = Card State browsing-cards-cant-be-manually-moved-into = Cards can't be manually moved into a filtered deck. @@ -63,6 +65,8 @@ browsing-new = (new) browsing-new-note-type = New note type: browsing-no-flag = No Flag browsing-note = Note +# Exactly one character representing 'Notes'; should differ from browsing-card-initial. +browsing-note-initial = N browsing-notes-tagged = Notes tagged. browsing-nothing = Nothing browsing-only-new-cards-can-be-repositioned = Only new cards can be repositioned. diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 63ab55e4e..d46fd75d2 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -497,8 +497,22 @@ class Collection: reverse: bool = False, ) -> Sequence[CardId]: """Return card ids matching the provided search. + To programmatically construct a search string, see .build_search_string(). - To define a sort order, see _build_sort_mode(). + + If order=True, use the sort order stored in the collection config + If order=False, do no ordering + + If order is a string, that text is added after 'order by' in the sql statement. + You must add ' asc' or ' desc' to the order, as Anki will replace asc with + desc and vice versa when reverse is set in the collection config, eg + order="c.ivl asc, c.due desc". + + If order is a BuiltinSort.Kind value, sort using that builtin sort, eg + col.find_cards("", order=BuiltinSort.Kind.CARD_DUE) + + The reverse argument only applies when a BuiltinSort.Kind is provided; + otherwise the collection config defines whether reverse is set or not. """ mode = _build_sort_mode(order, reverse) return cast( @@ -512,8 +526,9 @@ class Collection: reverse: bool = False, ) -> Sequence[NoteId]: """Return note ids matching the provided search. + To programmatically construct a search string, see .build_search_string(). - To define a sort order, see _build_sort_mode(). + The order parameter is documented in .find_cards(). """ mode = _build_sort_mode(order, reverse) return cast( @@ -1072,22 +1087,6 @@ def _build_sort_mode( order: Union[bool, str, BuiltinSort.Kind.V], reverse: bool, ) -> _pb.SortOrder: - """Return a SortOrder object for use in find_cards() or find_notes(). - - If order=True, use the sort order stored in the collection config - If order=False, do no ordering - - If order is a string, that text is added after 'order by' in the sql statement. - You must add ' asc' or ' desc' to the order, as Anki will replace asc with - desc and vice versa when reverse is set in the collection config, eg - order="c.ivl asc, c.due desc". - - If order is a BuiltinSort.Kind value, sort using that builtin sort, eg - col.find_cards("", order=BuiltinSort.Kind.CARD_DUE) - - The reverse argument only applies when a BuiltinSort.Kind is provided; - otherwise the collection config defines whether reverse is set or not. - """ if isinstance(order, str): return _pb.SortOrder(custom=order) elif isinstance(order, bool): diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index e1da7001c..5084b9213 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -37,6 +37,7 @@ from aqt.scheduling_ops import ( unsuspend_cards, ) from aqt.sidebar import SidebarTreeView +from aqt.switch import Switch from aqt.table import Table from aqt.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes from aqt.utils import ( @@ -329,9 +330,9 @@ class Browser(QMainWindow): selected = self.table.len_selection() cur = self.table.len() tr_title = ( - tr.browsing_window_title - if self.table.is_card_state() - else tr.browsing_window_title_notes + tr.browsing_window_title_notes + if self.table.is_notes_mode() + else tr.browsing_window_title ) self.setWindowTitle( without_unicode_isolation(tr_title(total=cur, selected=selected)) @@ -374,10 +375,11 @@ class Browser(QMainWindow): def setup_table(self) -> None: self.table = Table(self) - self.form.radio_cards.setChecked(self.table.is_card_state()) - self.form.radio_notes.setChecked(not self.table.is_card_state()) self.table.set_view(self.form.tableView) - qconnect(self.form.radio_cards.toggled, self.on_table_state_changed) + switch = Switch(11, tr.browsing_card_initial(), tr.browsing_note_initial()) + switch.setChecked(self.table.is_notes_mode()) + qconnect(switch.toggled, self.on_table_state_changed) + self.form.gridLayout.addWidget(switch, 0, 0) def setupEditor(self) -> None: def add_preview_button(leftbuttons: List[str], editor: Editor) -> None: @@ -430,10 +432,10 @@ class Browser(QMainWindow): self._update_flags_menu() gui_hooks.browser_did_change_row(self) - @ensure_editor_saved_on_trigger - def on_table_state_changed(self) -> None: + @ensure_editor_saved + def on_table_state_changed(self, checked: bool) -> None: self.mw.progress.start() - self.table.toggle_state(self.form.radio_cards.isChecked(), self._lastSearchTxt) + self.table.toggle_state(checked, self._lastSearchTxt) self.mw.progress.finish() # Sidebar diff --git a/qt/aqt/forms/browser.ui b/qt/aqt/forms/browser.ui index 5ed6506fd..76217999f 100644 --- a/qt/aqt/forms/browser.ui +++ b/qt/aqt/forms/browser.ui @@ -91,7 +91,7 @@ 0 - + @@ -109,30 +109,6 @@ - - - - 5 - - - - - qt_accel_cards - - - true - - - - - - - qt_accel_notes - - - - - diff --git a/qt/aqt/switch.py b/qt/aqt/switch.py new file mode 100644 index 000000000..83afdcd99 --- /dev/null +++ b/qt/aqt/switch.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from aqt import colors +from aqt.qt import * +from aqt.theme import theme_manager + + +class Switch(QAbstractButton): + """A horizontal slider to toggle between two states which can be denoted by short strings. + The left state is the default and corresponds to isChecked=False. + """ + + _margin: int = 2 + + def __init__( + self, + radius: int = 10, + left_label: str = "", + right_label: str = "", + parent: QWidget = None, + ) -> None: + super().__init__(parent=parent) + self.setCheckable(True) + super().setChecked(False) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self._left_label = left_label + self._right_label = right_label + self._path_radius = radius + self._knob_radius = radius - self._margin + self._left_position = self._position = self._path_radius + self._margin + self._right_position = 3 * self._path_radius + self._margin + + @pyqtProperty(int) # type: ignore + def position(self) -> int: + return self._position + + @position.setter # type: ignore + def position(self, position: int) -> None: + self._position = position + self.update() + + @property + def start_position(self) -> int: + return self._left_position if self.isChecked() else self._right_position + + @property + def end_position(self) -> int: + return self._right_position if self.isChecked() else self._left_position + + @property + def label(self) -> str: + return self._right_label if self.isChecked() else self._left_label + + def sizeHint(self) -> QSize: + return QSize( + 4 * self._path_radius + 2 * self._margin, + 2 * self._path_radius + 2 * self._margin, + ) + + def setChecked(self, checked: bool) -> None: + super().setChecked(checked) + self._position = self.end_position + self.update() + + def paintEvent(self, _event: QPaintEvent) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing, True) + painter.setPen(Qt.NoPen) + self._paint_path(painter) + self._paint_knob(painter) + self._paint_label(painter) + + def _paint_path(self, painter: QPainter) -> None: + painter.setBrush(QBrush(theme_manager.qcolor(colors.FRAME_BG))) + rectangle = QRectF( + self._margin, + self._margin, + self.width() - 2 * self._margin, + self.height() - 2 * self._margin, + ) + painter.drawRoundedRect(rectangle, self._path_radius, self._path_radius) + + def _current_knob_rectangle(self) -> QRectF: + return QRectF( + self.position - self._knob_radius, # type: ignore + 2 * self._margin, + 2 * self._knob_radius, + 2 * self._knob_radius, + ) + + def _paint_knob(self, painter: QPainter) -> None: + painter.setBrush(QBrush(theme_manager.qcolor(colors.HIGHLIGHT_BG))) + painter.drawEllipse(self._current_knob_rectangle()) + + def _paint_label(self, painter: QPainter) -> None: + painter.setPen(QColor("white")) + font = painter.font() + font.setPixelSize(int(1.5 * self._knob_radius)) + painter.setFont(font) + painter.drawText(self._current_knob_rectangle(), Qt.AlignCenter, self.label) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + super().mouseReleaseEvent(event) + if event.button() == Qt.LeftButton: + animation = QPropertyAnimation(self, b"position", self) + animation.setDuration(100) + animation.setStartValue(self.start_position) + animation.setEndValue(self.end_position) + animation.start() + + def enterEvent(self, event: QEvent) -> None: + self.setCursor(Qt.PointingHandCursor) + super().enterEvent(event) diff --git a/qt/aqt/table.py b/qt/aqt/table.py index 2154427df..6173a533c 100644 --- a/qt/aqt/table.py +++ b/qt/aqt/table.py @@ -48,8 +48,7 @@ class SearchContext: search: str browser: aqt.browser.Browser order: Union[bool, str] = True - # if set, provided card ids will be used instead of the regular search - # fixme: legacy support for card_ids? + # if set, provided ids will be used instead of the regular search ids: Optional[Sequence[ItemId]] = None @@ -94,8 +93,8 @@ class Table: def has_next(self) -> bool: return self.has_current() and self._current().row() < self.len() - 1 - def is_card_state(self) -> bool: - return self._state.is_card_state() + def is_notes_mode(self) -> bool: + return self._state.is_notes_mode() # Get objects @@ -194,15 +193,15 @@ class Table: self._model.search(SearchContext(search=txt, browser=self.browser)) self._restore_selection(self._intersected_selection) - def toggle_state(self, is_card_state: bool, last_search: str) -> None: - if is_card_state == self.is_card_state(): + def toggle_state(self, is_notes_mode: bool, last_search: str) -> None: + if is_notes_mode == self.is_notes_mode(): return self._save_selection() self._state = self._model.toggle_state( SearchContext(search=last_search, browser=self.browser) ) self.col.set_config_bool( - Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, not self.is_card_state() + Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode() ) self._set_sort_indicator() self._set_column_sizes() @@ -328,14 +327,14 @@ class Table: def _on_context_menu(self, _point: QPoint) -> None: menu = QMenu() - if self.is_card_state(): - main = self.browser.form.menu_Cards - other = self.browser.form.menu_Notes - other_name = tr.qt_accel_notes() - else: + if self.is_notes_mode(): main = self.browser.form.menu_Notes other = self.browser.form.menu_Cards other_name = tr.qt_accel_cards() + else: + main = self.browser.form.menu_Cards + other = self.browser.form.menu_Notes + other_name = tr.qt_accel_notes() for action in main.actions(): menu.addAction(action) menu.addSeparator() @@ -417,7 +416,8 @@ class Table: current = current or rows[0] self._select_rows(rows) self._set_current(current) - self._scroll_to_row(current) + # editor may pop up and hide the row later on + QTimer.singleShot(100, lambda: self._scroll_to_row(current)) if self.len_selection() == 0: # no row change will fire self.browser.onRowChanged(QItemSelection(), QItemSelection()) @@ -431,7 +431,7 @@ class Table: if rows: if len(rows) < self.SELECTION_LIMIT: return rows - if current in rows: + if current and current in rows: return [current] return rows[0:1] return [current if current else 0] @@ -453,17 +453,19 @@ class Table: selected_rows = self._model.get_item_rows( self._state.get_new_items(self._selected_items) ) - current_row = self._current_item and self._model.get_item_row( - self._state.get_new_item(self._current_item) - ) + current_row = None + if self._current_item: + if new_current := self._state.get_new_items([self._current_item]): + current_row = self._model.get_item_row(new_current[0]) return selected_rows, current_row # Move def _scroll_to_row(self, row: int) -> None: """Scroll vertically to row.""" - position = self._view.rowViewportPosition(row) - visible = 0 <= position < self._view.viewport().height() + top_border = self._view.rowViewportPosition(row) + bottom_border = top_border + self._view.rowHeight(0) + visible = top_border >= 0 and bottom_border < self._view.viewport().height() if not visible: horizontal = self._view.horizontalScrollBar().value() self._view.scrollTo(self._model.index(row, 0), self._view.PositionAtCenter) @@ -527,9 +529,9 @@ class ItemState(ABC): def __init__(self, col: Collection) -> None: self.col = col - def is_card_state(self) -> bool: - """Return True if the state is a CardState.""" - return isinstance(self, CardState) + def is_notes_mode(self) -> bool: + """Return True if the state is a NoteState.""" + return isinstance(self, NoteState) # Stateless Helpers @@ -543,6 +545,8 @@ class ItemState(ABC): # Columns and sorting + # abstractproperty is deprecated but used due to mypy limitations + # (https://github.com/python/mypy/issues/1362) @abstractproperty def columns(self) -> List[Tuple[str, str]]: """Return all for the state available columns.""" @@ -605,15 +609,9 @@ class ItemState(ABC): def toggle_state(self) -> ItemState: """Return an instance of the other state.""" - @abstractmethod - def get_new_item(self, old_item: ItemId) -> ItemId: - """Given an id from the other state, return the corresponding id for - this state.""" - @abstractmethod def get_new_items(self, old_items: Sequence[ItemId]) -> ItemList: - """Given a list of ids from the other state, return the corresponding - ids for this state.""" + """Given a list of ids from the other state, return the corresponding ids for this state.""" class CardState(ItemState): @@ -705,9 +703,6 @@ class CardState(ItemState): def toggle_state(self) -> NoteState: return NoteState(self.col) - def get_new_item(self, old_item: ItemId) -> CardId: - return super().card_ids_from_note_ids([old_item])[0] - def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[CardId]: return super().card_ids_from_note_ids(old_items) @@ -725,11 +720,13 @@ class NoteState(ItemState): def _load_columns(self) -> None: self._columns = [ ("note", tr.browsing_note()), - ("noteCards", tr.qt_accel_cards().replace("&", "")), + ("noteCards", tr.editing_cards()), ("noteCrt", tr.browsing_created()), ("noteEase", tr.browsing_average_ease()), ("noteFld", tr.browsing_sort_field()), + ("noteLapses", tr.scheduling_lapses()), ("noteMod", tr.search_note_modified()), + ("noteReps", tr.scheduling_reviews()), ("noteTags", tr.editing_tags()), ] self._columns.sort(key=itemgetter(1)) @@ -793,9 +790,6 @@ class NoteState(ItemState): def toggle_state(self) -> CardState: return CardState(self.col) - def get_new_item(self, old_item: ItemId) -> NoteId: - return super().note_ids_from_card_ids([old_item])[0] - def get_new_items(self, old_items: Sequence[ItemId]) -> Sequence[NoteId]: return super().note_ids_from_card_ids(old_items) @@ -811,6 +805,8 @@ class Cell: class CellRow: + is_deleted: bool = False + def __init__( self, cells: Generator[Tuple[str, bool], None, None], @@ -842,7 +838,9 @@ class CellRow: @staticmethod def deleted(length: int) -> CellRow: - return CellRow.generic(length, tr.browsing_row_deleted()) + row = CellRow.generic(length, tr.browsing_row_deleted()) + row.is_deleted = True + return row def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, str]]: @@ -896,7 +894,7 @@ class DataModel(QAbstractTableModel): self._rows[item] = self._fetch_row_from_backend(item) return self._rows[item] - def _fetch_row_from_backend(self, item: int) -> CellRow: + def _fetch_row_from_backend(self, item: ItemId) -> CellRow: try: row = CellRow(*self.col.browser_row_for_id(item)) except NotFoundError: @@ -904,8 +902,9 @@ class DataModel(QAbstractTableModel): except Exception as e: return CellRow.generic(self.len_columns(), str(e)) - # fixme: hook needs state - gui_hooks.browser_did_fetch_row(item, row, self._state.active_columns) + gui_hooks.browser_did_fetch_row( + item, self._state.is_notes_mode(), row, self._state.active_columns + ) return row # Reset @@ -1113,6 +1112,8 @@ class DataModel(QAbstractTableModel): return None def flags(self, index: QModelIndex) -> Qt.ItemFlags: + if self.get_row(index).is_deleted: + return Qt.ItemFlags(Qt.NoItemFlags) return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable) diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index d10af0831..7a268c97d 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -406,7 +406,12 @@ hooks = [ ), Hook( name="browser_did_fetch_row", - args=["card_id: int", "row: aqt.table.CellRow", "columns: Sequence[str]"], + args=[ + "card_or_note_id: aqt.table.ItemId", + "is_note: bool", + "row: aqt.table.CellRow", + "columns: Sequence[str]", + ], doc="""Allows you to add or modify content to a row in the browser. You can mutate the row object to change what is displayed. Any columns the diff --git a/rslib/backend.proto b/rslib/backend.proto index 25e2a4ed0..e87af0a61 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -810,18 +810,20 @@ message SortOrder { NOTE_CARDS = 0; NOTE_CREATION = 1; NOTE_EASE = 2; - NOTE_MOD = 3; - NOTE_FIELD = 4; - NOTE_TAGS = 5; - NOTETYPE = 6; - CARD_MOD = 7; - CARD_REPS = 8; - CARD_DUE = 9; - CARD_EASE = 10; - CARD_LAPSES = 11; - CARD_INTERVAL = 12; - CARD_DECK = 13; - CARD_TEMPLATE = 14; + NOTE_FIELD = 3; + NOTE_LAPSES = 4; + NOTE_MOD = 5; + NOTE_REPS = 6; + NOTE_TAGS = 7; + NOTETYPE = 8; + CARD_MOD = 9; + CARD_REPS = 10; + CARD_DUE = 11; + CARD_EASE = 12; + CARD_LAPSES = 13; + CARD_INTERVAL = 14; + CARD_DECK = 15; + CARD_TEMPLATE = 16; } Kind kind = 1; bool reverse = 2; diff --git a/rslib/src/backend/search/mod.rs b/rslib/src/backend/search/mod.rs index d88d2f9ce..8a5e781cc 100644 --- a/rslib/src/backend/search/mod.rs +++ b/rslib/src/backend/search/mod.rs @@ -100,8 +100,10 @@ impl From for SortKind { SortKindProto::NoteCards => SortKind::NoteCards, SortKindProto::NoteCreation => SortKind::NoteCreation, SortKindProto::NoteEase => SortKind::NoteEase, + SortKindProto::NoteLapses => SortKind::NoteLapses, SortKindProto::NoteMod => SortKind::NoteMod, SortKindProto::NoteField => SortKind::NoteField, + SortKindProto::NoteReps => SortKind::NoteReps, SortKindProto::NoteTags => SortKind::NoteTags, SortKindProto::Notetype => SortKind::Notetype, SortKindProto::CardMod => SortKind::CardMod, diff --git a/rslib/src/browser_rows.rs b/rslib/src/browser_rows.rs index ae46b74b7..06388db7a 100644 --- a/rslib/src/browser_rows.rs +++ b/rslib/src/browser_rows.rs @@ -428,7 +428,9 @@ impl RowContext for NoteRowContext<'_> { "noteCrt" => self.note_creation_str(), "noteEase" => self.note_ease_str(), "noteFld" => self.note_field_str(), + "noteLapses" => self.cards.iter().map(|c| c.lapses).sum::().to_string(), "noteMod" => self.note.mtime.date_string(), + "noteReps" => self.cards.iter().map(|c| c.reps).sum::().to_string(), "noteTags" => self.note.tags.join(" "), _ => "".to_string(), }) diff --git a/rslib/src/config/mod.rs b/rslib/src/config/mod.rs index 722d0104f..0c61b3f77 100644 --- a/rslib/src/config/mod.rs +++ b/rslib/src/config/mod.rs @@ -275,9 +275,11 @@ pub enum SortKind { #[serde(rename = "noteCrt")] NoteCreation, NoteEase, + NoteLapses, NoteMod, #[serde(rename = "noteFld")] NoteField, + NoteReps, #[serde(rename = "note")] Notetype, NoteTags, diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 2b54e285c..05619cf45 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -91,10 +91,12 @@ impl SortKind { SortKind::NoteCards | SortKind::NoteCreation | SortKind::NoteEase - | SortKind::NoteMod | SortKind::NoteField - | SortKind::Notetype - | SortKind::NoteTags => RequiredTable::Notes, + | SortKind::NoteLapses + | SortKind::NoteMod + | SortKind::NoteReps + | SortKind::NoteTags + | SortKind::Notetype => RequiredTable::Notes, SortKind::CardTemplate => RequiredTable::CardsAndNotes, SortKind::CardMod | SortKind::CardReps @@ -250,11 +252,12 @@ fn card_order_from_sortkind(kind: SortKind) -> Cow<'static, str> { fn note_order_from_sortkind(kind: SortKind) -> Cow<'static, str> { match kind { - SortKind::NoteCards => "(select pos from sort_order where nid = n.id) asc".into(), + SortKind::NoteCards | SortKind::NoteEase | SortKind::NoteLapses | SortKind::NoteReps => { + "(select pos from sort_order where nid = n.id) asc".into() + } SortKind::NoteCreation => "n.id asc".into(), - SortKind::NoteEase => "(select pos from sort_order where nid = n.id) asc".into(), - SortKind::NoteMod => "n.mod asc".into(), SortKind::NoteField => "n.sfld collate nocase asc".into(), + SortKind::NoteMod => "n.mod asc".into(), SortKind::NoteTags => "n.tags asc".into(), SortKind::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), _ => "".into(), @@ -265,10 +268,12 @@ fn prepare_sort(col: &mut Collection, kind: SortKind) -> Result<()> { use SortKind::*; let sql = match kind { CardDeck => include_str!("deck_order.sql"), - Notetype => include_str!("notetype_order.sql"), CardTemplate => include_str!("template_order.sql"), NoteCards => include_str!("note_cards_order.sql"), NoteEase => include_str!("note_ease_order.sql"), + NoteLapses => include_str!("note_lapses_order.sql"), + NoteReps => include_str!("note_reps_order.sql"), + Notetype => include_str!("notetype_order.sql"), _ => return Ok(()), }; diff --git a/rslib/src/search/note_lapses_order.sql b/rslib/src/search/note_lapses_order.sql new file mode 100644 index 000000000..061b271e2 --- /dev/null +++ b/rslib/src/search/note_lapses_order.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS sort_order; +CREATE TEMPORARY TABLE sort_order ( + pos integer PRIMARY KEY, + nid integer NOT NULL UNIQUE +); +INSERT INTO sort_order (nid) +SELECT nid +FROM cards +GROUP BY nid +ORDER BY SUM(lapses); \ No newline at end of file diff --git a/rslib/src/search/note_reps_order.sql b/rslib/src/search/note_reps_order.sql new file mode 100644 index 000000000..833fdeb65 --- /dev/null +++ b/rslib/src/search/note_reps_order.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS sort_order; +CREATE TEMPORARY TABLE sort_order ( + pos integer PRIMARY KEY, + nid integer NOT NULL UNIQUE +); +INSERT INTO sort_order (nid) +SELECT nid +FROM cards +GROUP BY nid +ORDER BY SUM(reps); \ No newline at end of file