mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
use perform_op() for deck drag&drop
This commit is contained in:
parent
b1f4ea562a
commit
6a11c0398c
10 changed files with 112 additions and 116 deletions
|
@ -151,3 +151,8 @@ browsing-changed-new-position =
|
||||||
[one] Changed position of { $count } new card.
|
[one] Changed position of { $count } new card.
|
||||||
*[other] Changed position of { $count } new cards.
|
*[other] Changed position of { $count } new cards.
|
||||||
}
|
}
|
||||||
|
browsing-reparented-decks =
|
||||||
|
{ $count ->
|
||||||
|
[one] Renamed { $count } deck.
|
||||||
|
*[other] Renamed { $count } decks.
|
||||||
|
}
|
||||||
|
|
|
@ -19,5 +19,3 @@ undo-update-card = Update Card
|
||||||
undo-update-deck = Update Deck
|
undo-update-deck = Update Deck
|
||||||
undo-forget-card = Forget Card
|
undo-forget-card = Forget Card
|
||||||
undo-set-flag = Set Flag
|
undo-set-flag = Set Flag
|
||||||
# when dragging/dropping tags and decks in the sidebar
|
|
||||||
undo-reparent = Change Parent
|
|
||||||
|
|
|
@ -271,11 +271,13 @@ class DeckManager:
|
||||||
# Drag/drop
|
# Drag/drop
|
||||||
#############################################################
|
#############################################################
|
||||||
|
|
||||||
def drag_drop_decks(self, source_decks: List[DeckID], target_deck: DeckID) -> None:
|
def reparent(
|
||||||
"""Rename one or more source decks that were dropped on `target_deck`.
|
self, deck_ids: Sequence[DeckID], new_parent: DeckID
|
||||||
If target_deck is 0, decks will be placed at the top level."""
|
) -> OpChangesWithCount:
|
||||||
self.col._backend.drag_drop_decks(
|
"""Rename one or more source decks that were dropped on `new_parent`.
|
||||||
source_deck_ids=source_decks, target_deck_id=target_deck
|
If new_parent is 0, decks will be placed at the top level."""
|
||||||
|
return self.col._backend.reparent_decks(
|
||||||
|
deck_ids=deck_ids, new_parent=new_parent
|
||||||
)
|
)
|
||||||
|
|
||||||
# legacy
|
# legacy
|
||||||
|
@ -286,7 +288,7 @@ class DeckManager:
|
||||||
onto = 0
|
onto = 0
|
||||||
else:
|
else:
|
||||||
onto = int(ontoDeckDid)
|
onto = int(ontoDeckDid)
|
||||||
self.drag_drop_decks([int(draggedDeckDid)], onto)
|
self.reparent([int(draggedDeckDid)], onto)
|
||||||
|
|
||||||
# Deck configurations
|
# Deck configurations
|
||||||
#############################################################
|
#############################################################
|
||||||
|
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
from anki.decks import DeckID
|
||||||
from anki.lang import TR
|
from anki.lang import TR
|
||||||
from aqt import AnkiQt, QWidget
|
from aqt import AnkiQt, QWidget
|
||||||
from aqt.utils import tooltip, tr
|
from aqt.utils import tooltip, tr
|
||||||
|
@ -14,7 +15,7 @@ def remove_decks(
|
||||||
*,
|
*,
|
||||||
mw: AnkiQt,
|
mw: AnkiQt,
|
||||||
parent: QWidget,
|
parent: QWidget,
|
||||||
deck_ids: Sequence[int],
|
deck_ids: Sequence[DeckID],
|
||||||
) -> None:
|
) -> None:
|
||||||
mw.perform_op(
|
mw.perform_op(
|
||||||
lambda: mw.col.decks.remove(deck_ids),
|
lambda: mw.col.decks.remove(deck_ids),
|
||||||
|
@ -22,3 +23,14 @@ def remove_decks(
|
||||||
tr(TR.BROWSING_CARDS_DELETED, count=out.count), parent=parent
|
tr(TR.BROWSING_CARDS_DELETED, count=out.count), parent=parent
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reparent_decks(
|
||||||
|
*, mw: AnkiQt, parent: QWidget, deck_ids: Sequence[DeckID], new_parent: DeckID
|
||||||
|
) -> None:
|
||||||
|
mw.perform_op(
|
||||||
|
lambda: mw.col.decks.reparent(deck_ids=deck_ids, new_parent=new_parent),
|
||||||
|
success=lambda out: tooltip(
|
||||||
|
tr(TR.BROWSING_REPARENTED_DECKS, count=out.count), parent=parent
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
# 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
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import Future
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
@ -13,7 +13,7 @@ from anki.decks import DeckTreeNode
|
||||||
from anki.errors import DeckIsFilteredError
|
from anki.errors import DeckIsFilteredError
|
||||||
from anki.utils import intTime
|
from anki.utils import intTime
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.deck_ops import remove_decks
|
from aqt.deck_ops import remove_decks, reparent_decks
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.sound import av_player
|
from aqt.sound import av_player
|
||||||
from aqt.toolbar import BottomBar
|
from aqt.toolbar import BottomBar
|
||||||
|
@ -304,21 +304,7 @@ class DeckBrowser:
|
||||||
self._renderPage(reuse=True)
|
self._renderPage(reuse=True)
|
||||||
|
|
||||||
def _handle_drag_and_drop(self, source: int, target: int) -> None:
|
def _handle_drag_and_drop(self, source: int, target: int) -> None:
|
||||||
def process() -> None:
|
reparent_decks(mw=self.mw, parent=self.mw, deck_ids=[source], new_parent=target)
|
||||||
self.mw.col.decks.drag_drop_decks([source], target)
|
|
||||||
|
|
||||||
def on_done(fut: Future) -> None:
|
|
||||||
try:
|
|
||||||
fut.result()
|
|
||||||
except Exception as e:
|
|
||||||
showWarning(str(e))
|
|
||||||
return
|
|
||||||
|
|
||||||
self.mw.update_undo_actions()
|
|
||||||
gui_hooks.sidebar_should_refresh_decks()
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
self.mw.taskman.with_progress(process, on_done)
|
|
||||||
|
|
||||||
def _delete(self, did: int) -> None:
|
def _delete(self, did: int) -> None:
|
||||||
remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did])
|
remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did])
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import Future
|
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
||||||
|
|
||||||
|
@ -16,7 +15,7 @@ from anki.tags import TagTreeNode
|
||||||
from anki.types import assert_exhaustive
|
from anki.types import assert_exhaustive
|
||||||
from aqt import colors, gui_hooks
|
from aqt import colors, gui_hooks
|
||||||
from aqt.clayout import CardLayout
|
from aqt.clayout import CardLayout
|
||||||
from aqt.deck_ops import remove_decks
|
from aqt.deck_ops import remove_decks, reparent_decks
|
||||||
from aqt.models import Models
|
from aqt.models import Models
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.tag_ops import remove_tags_for_all_notes, rename_tag, reparent_tags
|
from aqt.tag_ops import remove_tags_for_all_notes, rename_tag, reparent_tags
|
||||||
|
@ -604,30 +603,18 @@ class SidebarTreeView(QTreeView):
|
||||||
def _handle_drag_drop_decks(
|
def _handle_drag_drop_decks(
|
||||||
self, sources: List[SidebarItem], target: SidebarItem
|
self, sources: List[SidebarItem], target: SidebarItem
|
||||||
) -> bool:
|
) -> bool:
|
||||||
source_ids = [
|
deck_ids = [
|
||||||
source.id for source in sources if source.item_type == SidebarItemType.DECK
|
source.id for source in sources if source.item_type == SidebarItemType.DECK
|
||||||
]
|
]
|
||||||
if not source_ids:
|
if not deck_ids:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def on_done(fut: Future) -> None:
|
new_parent = target.id
|
||||||
self.browser.model.endReset()
|
|
||||||
try:
|
|
||||||
fut.result()
|
|
||||||
except Exception as e:
|
|
||||||
showWarning(str(e))
|
|
||||||
return
|
|
||||||
self.refresh()
|
|
||||||
self.mw.deckBrowser.refresh()
|
|
||||||
self.mw.update_undo_actions()
|
|
||||||
|
|
||||||
def on_save() -> None:
|
reparent_decks(
|
||||||
self.browser.model.beginReset()
|
mw=self.mw, parent=self.browser, deck_ids=deck_ids, new_parent=new_parent
|
||||||
self.mw.taskman.with_progress(
|
)
|
||||||
lambda: self.col.decks.drag_drop_decks(source_ids, target.id), on_done
|
|
||||||
)
|
|
||||||
|
|
||||||
self.browser.editor.call_after_note_saved(on_save)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _handle_drag_drop_tags(
|
def _handle_drag_drop_tags(
|
||||||
|
|
|
@ -140,7 +140,7 @@ service DecksService {
|
||||||
rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames);
|
rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames);
|
||||||
rpc NewDeckLegacy(Bool) returns (Json);
|
rpc NewDeckLegacy(Bool) returns (Json);
|
||||||
rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount);
|
rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount);
|
||||||
rpc DragDropDecks(DragDropDecksIn) returns (OpChanges);
|
rpc ReparentDecks(ReparentDecksIn) returns (OpChangesWithCount);
|
||||||
rpc RenameDeck(RenameDeckIn) returns (OpChanges);
|
rpc RenameDeck(RenameDeckIn) returns (OpChanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1108,9 +1108,9 @@ message GetDeckNamesIn {
|
||||||
bool include_filtered = 2;
|
bool include_filtered = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DragDropDecksIn {
|
message ReparentDecksIn {
|
||||||
repeated int64 source_deck_ids = 1;
|
repeated int64 deck_ids = 1;
|
||||||
int64 target_deck_id = 2;
|
int64 new_parent = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message NoteIsDuplicateOrEmptyOut {
|
message NoteIsDuplicateOrEmptyOut {
|
||||||
|
|
|
@ -114,14 +114,14 @@ impl DecksService for Backend {
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drag_drop_decks(&self, input: pb::DragDropDecksIn) -> Result<pb::OpChanges> {
|
fn reparent_decks(&self, input: pb::ReparentDecksIn) -> Result<pb::OpChangesWithCount> {
|
||||||
let source_dids: Vec<_> = input.source_deck_ids.into_iter().map(Into::into).collect();
|
let deck_ids: Vec<_> = input.deck_ids.into_iter().map(Into::into).collect();
|
||||||
let target_did = if input.target_deck_id == 0 {
|
let new_parent = if input.new_parent == 0 {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(input.target_deck_id.into())
|
Some(input.new_parent.into())
|
||||||
};
|
};
|
||||||
self.with_col(|col| col.drag_drop_decks(&source_dids, target_did))
|
self.with_col(|col| col.reparent_decks(&deck_ids, new_parent))
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -230,21 +230,21 @@ pub(crate) fn immediate_parent_name(machine_name: &str) -> Option<&str> {
|
||||||
|
|
||||||
/// Determine name to rename a deck to, when `dragged` is dropped on `dropped`.
|
/// Determine name to rename a deck to, when `dragged` is dropped on `dropped`.
|
||||||
/// `dropped` being unset represents a drop at the top or bottom of the deck list.
|
/// `dropped` being unset represents a drop at the top or bottom of the deck list.
|
||||||
/// The returned name should be used to rename `dragged`, and may be unchanged.
|
/// The returned name should be used to rename `dragged`.
|
||||||
/// Arguments are expected in 'machine' form with an \x1f separator.
|
/// Arguments are expected in 'machine' form with an \x1f separator.
|
||||||
pub(crate) fn drag_drop_deck_name(dragged: &str, dropped: Option<&str>) -> String {
|
pub(crate) fn reparented_name(dragged: &str, dropped: Option<&str>) -> Option<String> {
|
||||||
let dragged_base = dragged.rsplit('\x1f').next().unwrap();
|
let dragged_base = dragged.rsplit('\x1f').next().unwrap();
|
||||||
if let Some(dropped) = dropped {
|
if let Some(dropped) = dropped {
|
||||||
if dropped.starts_with(dragged) {
|
if dropped.starts_with(dragged) {
|
||||||
// foo onto foo::bar, or foo onto itself -> no-op
|
// foo onto foo::bar, or foo onto itself -> no-op
|
||||||
dragged.to_string()
|
None
|
||||||
} else {
|
} else {
|
||||||
// foo::bar onto baz -> baz::bar
|
// foo::bar onto baz -> baz::bar
|
||||||
format!("{}\x1f{}", dropped, dragged_base)
|
Some(format!("{}\x1f{}", dropped, dragged_base))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// foo::bar onto top level -> bar
|
// foo::bar onto top level -> bar
|
||||||
dragged_base.into()
|
Some(dragged_base.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -618,59 +618,64 @@ impl Collection {
|
||||||
self.update_single_deck_undoable(deck, original)
|
self.update_single_deck_undoable(deck, original)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drag_drop_decks(
|
pub fn reparent_decks(
|
||||||
&mut self,
|
&mut self,
|
||||||
source_decks: &[DeckID],
|
deck_ids: &[DeckID],
|
||||||
target: Option<DeckID>,
|
new_parent: Option<DeckID>,
|
||||||
) -> Result<OpOutput<()>> {
|
) -> Result<OpOutput<usize>> {
|
||||||
let usn = self.usn()?;
|
self.transact(Op::ReparentDeck, |col| {
|
||||||
self.transact(Op::RenameDeck, |col| {
|
col.reparent_decks_inner(deck_ids, new_parent)
|
||||||
let target_deck;
|
})
|
||||||
let mut target_name = None;
|
}
|
||||||
if let Some(target) = target {
|
|
||||||
if let Some(target) = col.storage.get_deck(target)? {
|
|
||||||
if target.is_filtered() {
|
|
||||||
return Err(AnkiError::DeckIsFiltered);
|
|
||||||
}
|
|
||||||
target_deck = target;
|
|
||||||
target_name = Some(target_deck.name.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for source in source_decks {
|
pub fn reparent_decks_inner(
|
||||||
if let Some(mut source) = col.storage.get_deck(*source)? {
|
&mut self,
|
||||||
let new_name = drag_drop_deck_name(&source.name, target_name);
|
deck_ids: &[DeckID],
|
||||||
if new_name == source.name {
|
new_parent: Option<DeckID>,
|
||||||
continue;
|
) -> Result<usize> {
|
||||||
}
|
let usn = self.usn()?;
|
||||||
let orig = source.clone();
|
let target_deck;
|
||||||
|
let mut target_name = None;
|
||||||
|
if let Some(target) = new_parent {
|
||||||
|
if let Some(target) = self.storage.get_deck(target)? {
|
||||||
|
if target.is_filtered() {
|
||||||
|
return Err(AnkiError::DeckIsFiltered);
|
||||||
|
}
|
||||||
|
target_deck = target;
|
||||||
|
target_name = Some(target_deck.name.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
for deck in deck_ids {
|
||||||
|
if let Some(mut deck) = self.storage.get_deck(*deck)? {
|
||||||
|
if let Some(new_name) = reparented_name(&deck.name, target_name) {
|
||||||
|
count += 1;
|
||||||
|
let orig = deck.clone();
|
||||||
|
|
||||||
// this is basically update_deck_inner(), except:
|
// this is basically update_deck_inner(), except:
|
||||||
// - we skip the normalization in prepare_for_update()
|
// - we skip the normalization in prepare_for_update()
|
||||||
// - we skip the match_or_create_parents() step
|
// - we skip the match_or_create_parents() step
|
||||||
|
// - we skip the final create_missing_parents(), as we don't allow parent->child
|
||||||
|
// renames
|
||||||
|
|
||||||
source.set_modified(usn);
|
deck.set_modified(usn);
|
||||||
source.name = new_name;
|
deck.name = new_name;
|
||||||
col.ensure_deck_name_unique(&mut source, usn)?;
|
self.ensure_deck_name_unique(&mut deck, usn)?;
|
||||||
col.rename_child_decks(&orig, &source.name, usn)?;
|
self.rename_child_decks(&orig, &deck.name, usn)?;
|
||||||
col.update_single_deck_undoable(&mut source, orig)?;
|
self.update_single_deck_undoable(&mut deck, orig)?;
|
||||||
|
|
||||||
// after updating, we need to ensure all grandparents exist, which may not be the case
|
|
||||||
// in the parent->child case
|
|
||||||
// FIXME: maybe we only need to do this once at the end of the loop?
|
|
||||||
col.create_missing_parents(&source.name, usn)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(count)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::{human_deck_name_to_native, immediate_parent_name, normalize_native_name};
|
use super::{human_deck_name_to_native, immediate_parent_name, normalize_native_name};
|
||||||
use crate::decks::drag_drop_deck_name;
|
use crate::decks::reparented_name;
|
||||||
use crate::{
|
use crate::{
|
||||||
collection::{open_test_collection, Collection},
|
collection::{open_test_collection, Collection},
|
||||||
err::Result,
|
err::Result,
|
||||||
|
@ -843,32 +848,31 @@ mod test {
|
||||||
fn n(s: &str) -> String {
|
fn n(s: &str) -> String {
|
||||||
s.replace(":", "\x1f")
|
s.replace(":", "\x1f")
|
||||||
}
|
}
|
||||||
assert_eq!(drag_drop_deck_name("drag", Some("drop")), n("drop:drag"));
|
fn n_opt(s: &str) -> Option<String> {
|
||||||
assert_eq!(&drag_drop_deck_name("drag", None), "drag");
|
Some(n(s))
|
||||||
assert_eq!(&drag_drop_deck_name(&n("drag:child"), None), "child");
|
}
|
||||||
|
|
||||||
|
assert_eq!(reparented_name("drag", Some("drop")), n_opt("drop:drag"));
|
||||||
|
assert_eq!(reparented_name("drag", None), n_opt("drag"));
|
||||||
|
assert_eq!(reparented_name(&n("drag:child"), None), n_opt("child"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
drag_drop_deck_name(&n("drag:child"), Some(&n("drop:deck"))),
|
reparented_name(&n("drag:child"), Some(&n("drop:deck"))),
|
||||||
n("drop:deck:child")
|
n_opt("drop:deck:child")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
drag_drop_deck_name(&n("drag:child"), Some("drag")),
|
reparented_name(&n("drag:child"), Some("drag")),
|
||||||
n("drag:child")
|
n_opt("drag:child")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
drag_drop_deck_name(&n("drag:child:grandchild"), Some("drag")),
|
reparented_name(&n("drag:child:grandchild"), Some("drag")),
|
||||||
n("drag:grandchild")
|
n_opt("drag:grandchild")
|
||||||
);
|
);
|
||||||
// while the renaming code should be able to cope with renaming a parent to a child,
|
// drops to child not supported
|
||||||
// it's not often useful and can be difficult for the user to clean up if done accidentally,
|
|
||||||
// so it should be a no-op
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
drag_drop_deck_name(&n("drag"), Some(&n("drag:child:grandchild"))),
|
reparented_name(&n("drag"), Some(&n("drag:child:grandchild"))),
|
||||||
n("drag")
|
None
|
||||||
);
|
);
|
||||||
// name doesn't change when deck dropped on itself
|
// name doesn't change when deck dropped on itself
|
||||||
assert_eq!(
|
assert_eq!(reparented_name(&n("foo:bar"), Some(&n("foo:bar"))), None);
|
||||||
drag_drop_deck_name(&n("foo:bar"), Some(&n("foo:bar"))),
|
|
||||||
n("foo:bar")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ pub enum Op {
|
||||||
RemoveNote,
|
RemoveNote,
|
||||||
RemoveTag,
|
RemoveTag,
|
||||||
RenameDeck,
|
RenameDeck,
|
||||||
|
ReparentDeck,
|
||||||
RenameTag,
|
RenameTag,
|
||||||
ReparentTag,
|
ReparentTag,
|
||||||
ScheduleAsNew,
|
ScheduleAsNew,
|
||||||
|
@ -57,7 +58,8 @@ impl Op {
|
||||||
Op::SortCards => TR::BrowsingReschedule,
|
Op::SortCards => TR::BrowsingReschedule,
|
||||||
Op::RenameTag => TR::ActionsRenameTag,
|
Op::RenameTag => TR::ActionsRenameTag,
|
||||||
Op::RemoveTag => TR::ActionsRemoveTag,
|
Op::RemoveTag => TR::ActionsRemoveTag,
|
||||||
Op::ReparentTag => TR::UndoReparent,
|
Op::ReparentTag => TR::ActionsRenameTag,
|
||||||
|
Op::ReparentDeck => TR::ActionsRenameDeck,
|
||||||
};
|
};
|
||||||
|
|
||||||
i18n.tr(key).to_string()
|
i18n.tr(key).to_string()
|
||||||
|
|
Loading…
Reference in a new issue