Hierarchical tags

This commit is contained in:
abdo 2021-01-06 17:04:03 +03:00
parent 8c6d0e6229
commit b276ce3dd5
18 changed files with 757 additions and 238 deletions

View file

@ -25,6 +25,7 @@ actions-red-flag = Red Flag
actions-rename = Rename
actions-rename-deck = Rename Deck
actions-rename-tag = Rename Tag
actions-remove-tag = Remove Tag
actions-replay-audio = Replay Audio
actions-reposition = Reposition
actions-save = Save

View file

@ -42,7 +42,8 @@ SchedTimingToday = pb.SchedTimingTodayOut
BuiltinSortKind = pb.BuiltinSearchOrder.BuiltinSortKind
BackendCard = pb.Card
BackendNote = pb.Note
TagUsnTuple = pb.TagUsnTuple
Tag = pb.Tag
TagTreeNode = pb.TagTreeNode
NoteType = pb.NoteType
DeckTreeNode = pb.DeckTreeNode
StockNoteType = pb.StockNoteType

View file

@ -25,7 +25,7 @@ class TagManager:
# all tags
def all(self) -> List[str]:
return [t.tag for t in self.col.backend.all_tags()]
return [t.name for t in self.col.backend.all_tags()]
def __repr__(self) -> str:
d = dict(self.__dict__)
@ -34,7 +34,7 @@ class TagManager:
# # List of (tag, usn)
def allItems(self) -> List[Tuple[str, int]]:
return [(t.tag, t.usn) for t in self.col.backend.all_tags()]
return [(t.name, t.usn) for t in self.col.backend.all_tags()]
# Registering and fetching tags
#############################################################
@ -63,11 +63,7 @@ class TagManager:
lim = ""
clear = True
self.register(
set(
self.split(
" ".join(self.col.db.list("select distinct tags from notes" + lim))
)
),
self.col.backend.get_note_tags(nids),
clear=clear,
)

View file

@ -21,7 +21,7 @@ from anki.consts import *
from anki.lang import without_unicode_isolation
from anki.models import NoteType
from anki.notes import Note
from anki.rsbackend import ConcatSeparator, DeckTreeNode, InvalidInput
from anki.rsbackend import ConcatSeparator, DeckTreeNode, InvalidInput, TagTreeNode
from anki.stats import CardStats
from anki.utils import htmlToTextLine, ids2str, isMac, isWin
from aqt import AnkiQt, gui_hooks
@ -1132,15 +1132,39 @@ QTableView {{ gridline-color: {grid} }}
root.addChild(item)
def _userTagTree(self, root) -> None:
assert self.col
for t in self.col.tags.all():
item = SidebarItem(
t,
":/icons/tag.svg",
lambda t=t: self.setFilter("tag", t), # type: ignore
item_type=SidebarItemType.TAG,
)
root.addChild(item)
tree = self.col.backend.tag_tree()
def fillGroups(root, nodes: Sequence[TagTreeNode], head=""):
for node in nodes:
def set_filter():
full_name = head + node.name # pylint: disable=cell-var-from-loop
return lambda: self.setFilter("tag", full_name)
def toggle_expand():
tid = node.tag_id # pylint: disable=cell-var-from-loop
def toggle(_):
tag = self.mw.col.backend.get_tag(tid)
tag.config.browser_collapsed = not tag.config.browser_collapsed
self.mw.col.backend.update_tag(tag)
return toggle
item = SidebarItem(
node.name,
":/icons/tag.svg",
set_filter(),
toggle_expand(),
not node.collapsed,
item_type=SidebarItemType.TAG,
id=node.tag_id,
)
root.addChild(item)
newhead = head + node.name + "::"
fillGroups(item, node.children, newhead)
fillGroups(root, tree.children)
def _decksTree(self, root) -> None:
tree = self.col.decks.deck_tree()

View file

@ -75,6 +75,7 @@ class ResetReason(enum.Enum):
EditorBridgeCmd = "editorBridgeCmd"
BrowserSetDeck = "browserSetDeck"
BrowserAddTags = "browserAddTags"
BrowserRemoveTags = "browserRemoveTags"
BrowserSuspend = "browserSuspend"
BrowserReposition = "browserReposition"
BrowserReschedule = "browserReschedule"

View file

@ -76,7 +76,10 @@ class NewSidebarTreeView(SidebarTreeViewBase):
(tr(TR.ACTIONS_RENAME), self.rename_deck),
(tr(TR.ACTIONS_DELETE), self.delete_deck),
),
SidebarItemType.TAG: ((tr(TR.ACTIONS_RENAME), self.rename_tag),),
SidebarItemType.TAG: (
(tr(TR.ACTIONS_RENAME), self.rename_tag),
(tr(TR.ACTIONS_DELETE), self.remove_tag),
),
}
def onContextMenu(self, point: QPoint) -> None:
@ -111,16 +114,37 @@ class NewSidebarTreeView(SidebarTreeViewBase):
self.browser.maybeRefreshSidebar()
self.mw.deckBrowser.refresh()
def remove_tag(self, item: "aqt.browser.SidebarItem") -> None:
self.browser.editor.saveNow(lambda: self._remove_tag(item))
def _remove_tag(self, item: "aqt.browser.SidebarItem") -> None:
old_name = self.mw.col.backend.get_tag(item.id).name
def do_remove():
self.mw.col.backend.clear_tag(old_name)
self.col.tags.rename_tag(old_name, "")
def on_done(fut: Future):
self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self)
self.browser.model.endReset()
fut.result()
self.browser.maybeRefreshSidebar()
self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG))
self.browser.model.beginReset()
self.mw.taskman.run_in_background(do_remove, on_done)
def rename_tag(self, item: "aqt.browser.SidebarItem") -> None:
self.browser.editor.saveNow(lambda: self._rename_tag(item))
def _rename_tag(self, item: "aqt.browser.SidebarItem") -> None:
old_name = item.name
old_name = self.mw.col.backend.get_tag(item.id).name
new_name = getOnlyText(tr(TR.ACTIONS_NEW_NAME), default=old_name)
if new_name == old_name or not new_name:
return
def do_rename():
self.mw.col.backend.clear_tag(old_name)
return self.col.tags.rename_tag(old_name, new_name)
def on_done(fut: Future):
@ -132,7 +156,7 @@ class NewSidebarTreeView(SidebarTreeViewBase):
showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY))
return
self.browser.clearUnusedTags()
self.browser.maybeRefreshSidebar()
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
self.browser.model.beginReset()

View file

@ -66,158 +66,166 @@ message DeckConfigID {
int64 dcid = 1;
}
message TagID {
int64 tid = 1;
}
// New style RPC definitions
///////////////////////////////////////////////////////////
service BackendService {
rpc LatestProgress(Empty) returns (Progress);
rpc SetWantsAbort(Empty) returns (Empty);
rpc LatestProgress (Empty) returns (Progress);
rpc SetWantsAbort (Empty) returns (Empty);
// card rendering
// card rendering
rpc ExtractAVTags(ExtractAVTagsIn) returns (ExtractAVTagsOut);
rpc ExtractLatex(ExtractLatexIn) returns (ExtractLatexOut);
rpc GetEmptyCards(Empty) returns (EmptyCardsReport);
rpc RenderExistingCard(RenderExistingCardIn) returns (RenderCardOut);
rpc RenderUncommittedCard(RenderUncommittedCardIn) returns (RenderCardOut);
rpc StripAVTags(String) returns (String);
rpc ExtractAVTags (ExtractAVTagsIn) returns (ExtractAVTagsOut);
rpc ExtractLatex (ExtractLatexIn) returns (ExtractLatexOut);
rpc GetEmptyCards (Empty) returns (EmptyCardsReport);
rpc RenderExistingCard (RenderExistingCardIn) returns (RenderCardOut);
rpc RenderUncommittedCard (RenderUncommittedCardIn) returns (RenderCardOut);
rpc StripAVTags (String) returns (String);
// searching
// searching
rpc NormalizeSearch(String) returns (String);
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut);
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
rpc NegateSearch(String) returns (String);
rpc ConcatenateSearches(ConcatenateSearchesIn) returns (String);
rpc ReplaceSearchTerm(ReplaceSearchTermIn) returns (String);
rpc FindAndReplace(FindAndReplaceIn) returns (UInt32);
rpc NormalizeSearch (String) returns (String);
rpc SearchCards (SearchCardsIn) returns (SearchCardsOut);
rpc SearchNotes (SearchNotesIn) returns (SearchNotesOut);
rpc NegateSearch (String) returns (String);
rpc ConcatenateSearches (ConcatenateSearchesIn) returns (String);
rpc ReplaceSearchTerm (ReplaceSearchTermIn) returns (String);
rpc FindAndReplace (FindAndReplaceIn) returns (UInt32);
// scheduling
// scheduling
rpc LocalMinutesWest(Int64) returns (Int32);
rpc SetLocalMinutesWest(Int32) returns (Empty);
rpc SchedTimingToday(Empty) returns (SchedTimingTodayOut);
rpc StudiedToday(Empty) returns (String);
rpc StudiedTodayMessage(StudiedTodayMessageIn) returns (String);
rpc UpdateStats(UpdateStatsIn) returns (Empty);
rpc ExtendLimits(ExtendLimitsIn) returns (Empty);
rpc CountsForDeckToday(DeckID) returns (CountsForDeckTodayOut);
rpc CongratsInfo(Empty) returns (CongratsInfoOut);
rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (Empty);
rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty);
rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (Empty);
rpc EmptyFilteredDeck(DeckID) returns (Empty);
rpc RebuildFilteredDeck(DeckID) returns (UInt32);
rpc ScheduleCardsAsReviews(ScheduleCardsAsReviewsIn) returns (Empty);
rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (Empty);
rpc SortCards(SortCardsIn) returns (Empty);
rpc SortDeck(SortDeckIn) returns (Empty);
rpc LocalMinutesWest (Int64) returns (Int32);
rpc SetLocalMinutesWest (Int32) returns (Empty);
rpc SchedTimingToday (Empty) returns (SchedTimingTodayOut);
rpc StudiedToday (Empty) returns (String);
rpc StudiedTodayMessage (StudiedTodayMessageIn) returns (String);
rpc UpdateStats (UpdateStatsIn) returns (Empty);
rpc ExtendLimits (ExtendLimitsIn) returns (Empty);
rpc CountsForDeckToday (DeckID) returns (CountsForDeckTodayOut);
rpc CongratsInfo (Empty) returns (CongratsInfoOut);
rpc RestoreBuriedAndSuspendedCards (CardIDs) returns (Empty);
rpc UnburyCardsInCurrentDeck (UnburyCardsInCurrentDeckIn) returns (Empty);
rpc BuryOrSuspendCards (BuryOrSuspendCardsIn) returns (Empty);
rpc EmptyFilteredDeck (DeckID) returns (Empty);
rpc RebuildFilteredDeck (DeckID) returns (UInt32);
rpc ScheduleCardsAsReviews (ScheduleCardsAsReviewsIn) returns (Empty);
rpc ScheduleCardsAsNew (ScheduleCardsAsNewIn) returns (Empty);
rpc SortCards (SortCardsIn) returns (Empty);
rpc SortDeck (SortDeckIn) returns (Empty);
// stats
// stats
rpc CardStats(CardID) returns (String);
rpc Graphs(GraphsIn) returns (GraphsOut);
rpc CardStats (CardID) returns (String);
rpc Graphs(GraphsIn) returns (GraphsOut);
// media
// media
rpc CheckMedia(Empty) returns (CheckMediaOut);
rpc TrashMediaFiles(TrashMediaFilesIn) returns (Empty);
rpc AddMediaFile(AddMediaFileIn) returns (String);
rpc EmptyTrash(Empty) returns (Empty);
rpc RestoreTrash(Empty) returns (Empty);
rpc CheckMedia (Empty) returns (CheckMediaOut);
rpc TrashMediaFiles (TrashMediaFilesIn) returns (Empty);
rpc AddMediaFile (AddMediaFileIn) returns (String);
rpc EmptyTrash (Empty) returns (Empty);
rpc RestoreTrash (Empty) returns (Empty);
// decks
// decks
rpc AddOrUpdateDeckLegacy(AddOrUpdateDeckLegacyIn) returns (DeckID);
rpc DeckTree(DeckTreeIn) returns (DeckTreeNode);
rpc DeckTreeLegacy(Empty) returns (Json);
rpc GetAllDecksLegacy(Empty) returns (Json);
rpc GetDeckIDByName(String) returns (DeckID);
rpc GetDeckLegacy(DeckID) returns (Json);
rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames);
rpc NewDeckLegacy(Bool) returns (Json);
rpc RemoveDeck(DeckID) returns (Empty);
rpc AddOrUpdateDeckLegacy (AddOrUpdateDeckLegacyIn) returns (DeckID);
rpc DeckTree (DeckTreeIn) returns (DeckTreeNode);
rpc DeckTreeLegacy (Empty) returns (Json);
rpc GetAllDecksLegacy (Empty) returns (Json);
rpc GetDeckIDByName (String) returns (DeckID);
rpc GetDeckLegacy (DeckID) returns (Json);
rpc GetDeckNames (GetDeckNamesIn) returns (DeckNames);
rpc NewDeckLegacy (Bool) returns (Json);
rpc RemoveDeck (DeckID) returns (Empty);
// deck config
// deck config
rpc AddOrUpdateDeckConfigLegacy(AddOrUpdateDeckConfigLegacyIn)
returns (DeckConfigID);
rpc AllDeckConfigLegacy(Empty) returns (Json);
rpc GetDeckConfigLegacy(DeckConfigID) returns (Json);
rpc NewDeckConfigLegacy(Empty) returns (Json);
rpc RemoveDeckConfig(DeckConfigID) returns (Empty);
rpc AddOrUpdateDeckConfigLegacy (AddOrUpdateDeckConfigLegacyIn) returns (DeckConfigID);
rpc AllDeckConfigLegacy (Empty) returns (Json);
rpc GetDeckConfigLegacy (DeckConfigID) returns (Json);
rpc NewDeckConfigLegacy (Empty) returns (Json);
rpc RemoveDeckConfig (DeckConfigID) returns (Empty);
// cards
// cards
rpc GetCard(CardID) returns (Card);
rpc UpdateCard(Card) returns (Empty);
rpc AddCard(Card) returns (CardID);
rpc RemoveCards(RemoveCardsIn) returns (Empty);
rpc SetDeck(SetDeckIn) returns (Empty);
rpc GetCard (CardID) returns (Card);
rpc UpdateCard (Card) returns (Empty);
rpc AddCard (Card) returns (CardID);
rpc RemoveCards (RemoveCardsIn) returns (Empty);
rpc SetDeck (SetDeckIn) returns (Empty);
// notes
// notes
rpc NewNote(NoteTypeID) returns (Note);
rpc AddNote(AddNoteIn) returns (NoteID);
rpc UpdateNote(Note) returns (Empty);
rpc GetNote(NoteID) returns (Note);
rpc RemoveNotes(RemoveNotesIn) returns (Empty);
rpc AddNoteTags(AddNoteTagsIn) returns (UInt32);
rpc UpdateNoteTags(UpdateNoteTagsIn) returns (UInt32);
rpc ClozeNumbersInNote(Note) returns (ClozeNumbersInNoteOut);
rpc AfterNoteUpdates(AfterNoteUpdatesIn) returns (Empty);
rpc FieldNamesForNotes(FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
rpc NoteIsDuplicateOrEmpty(Note) returns (NoteIsDuplicateOrEmptyOut);
rpc CardsOfNote(NoteID) returns (CardIDs);
rpc NewNote (NoteTypeID) returns (Note);
rpc AddNote (AddNoteIn) returns (NoteID);
rpc UpdateNote (Note) returns (Empty);
rpc GetNote (NoteID) returns (Note);
rpc RemoveNotes (RemoveNotesIn) returns (Empty);
rpc AddNoteTags (AddNoteTagsIn) returns (UInt32);
rpc UpdateNoteTags (UpdateNoteTagsIn) returns (UInt32);
rpc GetNoteTags(GetNoteTagsIn) returns (GetNoteTagsOut);
rpc ClozeNumbersInNote (Note) returns (ClozeNumbersInNoteOut);
rpc AfterNoteUpdates (AfterNoteUpdatesIn) returns (Empty);
rpc FieldNamesForNotes (FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
rpc NoteIsDuplicateOrEmpty (Note) returns (NoteIsDuplicateOrEmptyOut);
rpc CardsOfNote (NoteID) returns (CardIDs);
// note types
// note types
rpc AddOrUpdateNotetype(AddOrUpdateNotetypeIn) returns (NoteTypeID);
rpc GetStockNotetypeLegacy(GetStockNotetypeIn) returns (Json);
rpc GetNotetypeLegacy(NoteTypeID) returns (Json);
rpc GetNotetypeNames(Empty) returns (NoteTypeNames);
rpc GetNotetypeNamesAndCounts(Empty) returns (NoteTypeUseCounts);
rpc GetNotetypeIDByName(String) returns (NoteTypeID);
rpc RemoveNotetype(NoteTypeID) returns (Empty);
rpc AddOrUpdateNotetype (AddOrUpdateNotetypeIn) returns (NoteTypeID);
rpc GetStockNotetypeLegacy (GetStockNotetypeIn) returns (Json);
rpc GetNotetypeLegacy (NoteTypeID) returns (Json);
rpc GetNotetypeNames (Empty) returns (NoteTypeNames);
rpc GetNotetypeNamesAndCounts (Empty) returns (NoteTypeUseCounts);
rpc GetNotetypeIDByName (String) returns (NoteTypeID);
rpc RemoveNotetype (NoteTypeID) returns (Empty);
// collection
// collection
rpc OpenCollection(OpenCollectionIn) returns (Empty);
rpc CloseCollection(CloseCollectionIn) returns (Empty);
rpc CheckDatabase(Empty) returns (CheckDatabaseOut);
rpc OpenCollection (OpenCollectionIn) returns (Empty);
rpc CloseCollection (CloseCollectionIn) returns (Empty);
rpc CheckDatabase (Empty) returns (CheckDatabaseOut);
// sync
// sync
rpc SyncMedia(SyncAuth) returns (Empty);
rpc AbortSync(Empty) returns (Empty);
rpc AbortMediaSync(Empty) returns (Empty);
rpc BeforeUpload(Empty) returns (Empty);
rpc SyncLogin(SyncLoginIn) returns (SyncAuth);
rpc SyncStatus(SyncAuth) returns (SyncStatusOut);
rpc SyncCollection(SyncAuth) returns (SyncCollectionOut);
rpc FullUpload(SyncAuth) returns (Empty);
rpc FullDownload(SyncAuth) returns (Empty);
rpc SyncMedia (SyncAuth) returns (Empty);
rpc AbortSync (Empty) returns (Empty);
rpc AbortMediaSync (Empty) returns (Empty);
rpc BeforeUpload (Empty) returns (Empty);
rpc SyncLogin (SyncLoginIn) returns (SyncAuth);
rpc SyncStatus (SyncAuth) returns (SyncStatusOut);
rpc SyncCollection (SyncAuth) returns (SyncCollectionOut);
rpc FullUpload (SyncAuth) returns (Empty);
rpc FullDownload (SyncAuth) returns (Empty);
// translation/messages
// translation/messages
rpc TranslateString(TranslateStringIn) returns (String);
rpc FormatTimespan(FormatTimespanIn) returns (String);
rpc I18nResources(Empty) returns (Json);
rpc TranslateString (TranslateStringIn) returns (String);
rpc FormatTimespan (FormatTimespanIn) returns (String);
rpc I18nResources (Empty) returns (Json);
// tags
// tags
rpc RegisterTags(RegisterTagsIn) returns (Bool);
rpc AllTags(Empty) returns (AllTagsOut);
rpc RegisterTags (RegisterTagsIn) returns (Bool);
rpc AllTags (Empty) returns (AllTagsOut);
rpc GetTag (TagID) returns (Tag);
rpc UpdateTag (Tag) returns (Bool);
rpc ClearTag (String) returns (Bool);
rpc TagTree (Empty) returns (TagTreeNode);
// config/preferences
// config/preferences
rpc GetConfigJson(String) returns (Json);
rpc SetConfigJson(SetConfigJsonIn) returns (Empty);
rpc RemoveConfig(String) returns (Empty);
rpc SetAllConfig(Json) returns (Empty);
rpc GetAllConfig(Empty) returns (Json);
rpc GetPreferences(Empty) returns (Preferences);
rpc SetPreferences(Preferences) returns (Empty);
rpc GetConfigJson (String) returns (Json);
rpc SetConfigJson (SetConfigJsonIn) returns (Empty);
rpc RemoveConfig (String) returns (Empty);
rpc SetAllConfig (Json) returns (Empty);
rpc GetAllConfig (Empty) returns (Json);
rpc GetPreferences (Empty) returns (Preferences);
rpc SetPreferences (Preferences) returns (Empty);
}
// Protobuf stored in .anki2 files
@ -785,18 +793,32 @@ message RegisterTagsIn {
}
message AllTagsOut {
repeated TagUsnTuple tags = 1;
repeated Tag tags = 1;
}
message TagUsnTuple {
string tag = 1;
sint32 usn = 2;
message TagConfig {
bool browser_collapsed = 1;
}
message Tag {
int64 id = 1;
string name = 2;
sint32 usn = 3;
TagConfig config = 4;
}
message GetChangedTagsOut {
repeated string tags = 1;
}
message TagTreeNode {
int64 tag_id = 1;
string name = 2;
repeated TagTreeNode children = 3;
uint32 level = 5;
bool collapsed = 4;
}
message SetConfigJsonIn {
string key = 1;
bytes value_json = 2;
@ -903,6 +925,14 @@ message UpdateNoteTagsIn {
bool regex = 4;
}
message GetNoteTagsIn {
repeated int64 nids = 1;
}
message GetNoteTagsOut {
repeated string tags = 1;
}
message CheckDatabaseOut {
repeated string problems = 1;
}

View file

@ -44,6 +44,7 @@ use crate::{
get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress,
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage,
},
tags::TagID,
template::RenderedNode,
text::{extract_av_tags, strip_av_tags, AVTag},
timestamp::TimestampSecs,
@ -919,6 +920,14 @@ impl BackendService for Backend {
})
}
fn get_note_tags(&self, input: pb::GetNoteTagsIn) -> BackendResult<pb::GetNoteTagsOut> {
self.with_col(|col| {
Ok(pb::GetNoteTagsOut {
tags: col.storage.get_note_tags(to_nids(input.nids))?,
})
})
}
fn cloze_numbers_in_note(&self, note: pb::Note) -> BackendResult<pb::ClozeNumbersInNoteOut> {
let mut set = HashSet::with_capacity(4);
for field in &note.fields {
@ -1286,14 +1295,28 @@ impl BackendService for Backend {
//-------------------------------------------------------------------
fn all_tags(&self, _input: Empty) -> BackendResult<pb::AllTagsOut> {
let tags = self.with_col(|col| col.storage.all_tags())?;
let tags: Vec<_> = tags
.into_iter()
.map(|(tag, usn)| pb::TagUsnTuple { tag, usn: usn.0 })
.collect();
let tags: Vec<pb::Tag> =
self.with_col(|col| Ok(col.all_tags()?.into_iter().map(|t| t.into()).collect()))?;
Ok(pb::AllTagsOut { tags })
}
fn get_tag(&self, input: pb::TagId) -> BackendResult<pb::Tag> {
self.with_col(|col| {
if let Some(tag) = col.storage.get_tag(TagID(input.tid))? {
Ok(tag.into())
} else {
Err(AnkiError::NotFound)
}
})
}
fn update_tag(&self, tag: pb::Tag) -> BackendResult<pb::Bool> {
self.with_col(|col| {
col.update_tag(&tag.into())?;
Ok(pb::Bool { val: true })
})
}
fn register_tags(&self, input: pb::RegisterTagsIn) -> BackendResult<pb::Bool> {
self.with_col(|col| {
col.transact(None, |col| {
@ -1308,6 +1331,19 @@ impl BackendService for Backend {
})
}
fn clear_tag(&self, tag: pb::String) -> BackendResult<pb::Bool> {
self.with_col(|col| {
col.transact(None, |col| {
col.clear_tag(tag.val.as_str())?;
Ok(pb::Bool { val: true })
})
})
}
fn tag_tree(&self, _input: Empty) -> Result<pb::TagTreeNode> {
self.with_col(|col| col.tag_tree())
}
// config/preferences
//-------------------------------------------------------------------

View file

@ -155,7 +155,22 @@ impl Note {
pub(crate) fn replace_tags<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool {
let mut changed = false;
for tag in &mut self.tags {
if let Cow::Owned(rep) = re.replace_all(tag, repl.by_ref()) {
if let Cow::Owned(rep) = re.replace_all(tag, |caps: &regex::Captures| {
if let Some(expanded) = repl.by_ref().no_expansion() {
if expanded.trim().is_empty() {
"".to_string()
} else {
// include "::" if it was matched
format!(
"{}{}",
expanded,
caps.get(caps.len() - 1).map_or("", |m| m.as_str())
)
}
} else {
tag.to_string()
}
}) {
*tag = rep;
changed = true;
}

View file

@ -10,8 +10,9 @@ use crate::{
notes::field_checksum,
notetype::NoteTypeID,
storage::ids_to_string,
tags::human_tag_name_to_native,
text::{
escape_sql, is_glob, matches_glob, normalize_to_nfc, strip_html_preserving_media_filenames,
is_glob, matches_glob, normalize_to_nfc, strip_html_preserving_media_filenames,
to_custom_re, to_re, to_sql, to_text, without_combining,
},
timestamp::TimestampSecs,
@ -194,19 +195,17 @@ impl SqlWriter<'_> {
write!(self.sql, "false").unwrap();
} else {
match text {
"none" => write!(self.sql, "n.tags = ''").unwrap(),
"*" => write!(self.sql, "true").unwrap(),
s => {
if is_glob(s) {
write!(self.sql, "n.tags regexp ?").unwrap();
let re = &to_custom_re(s, r"\S");
self.args.push(format!("(?i).* {} .*", re));
} else if let Some(tag) = self.col.storage.preferred_tag_case(&to_text(s))? {
write!(self.sql, "n.tags like ? escape '\\'").unwrap();
self.args.push(format!("% {} %", escape_sql(&tag)));
} else {
write!(self.sql, "false").unwrap();
}
"none" => {
write!(self.sql, "n.tags = ''").unwrap();
}
"*" => {
write!(self.sql, "true").unwrap();
}
text => {
write!(self.sql, "n.tags regexp ?").unwrap();
let re = &to_custom_re(text, r"\S");
let native_name = human_tag_name_to_native(re);
self.args.push(format!("(?i).* {}(\x1f| ).*", native_name));
}
}
}
@ -568,7 +567,6 @@ mod test {
collection::{open_collection, Collection},
i18n::I18n,
log,
types::Usn,
};
use std::{fs, path::PathBuf};
use tempfile::tempdir;
@ -678,26 +676,27 @@ mod test {
// dupes
assert_eq!(s(ctx, "dupe:123,test"), ("(n.id in ())".into(), vec![]));
// if registered, searches with canonical
ctx.transact(None, |col| col.register_tag("One", Usn(-1)))
.unwrap();
// tags
assert_eq!(
s(ctx, r"tag:one"),
(
"(n.tags like ? escape '\\')".into(),
vec![r"% One %".into()]
"(n.tags regexp ?)".into(),
vec!["(?i).* one(\x1f| ).*".into()]
)
);
assert_eq!(
s(ctx, r"tag:foo::bar"),
(
"(n.tags regexp ?)".into(),
vec!["(?i).* foo\x1fbar(\x1f| ).*".into()]
)
);
// unregistered tags without wildcards won't match
assert_eq!(s(ctx, "tag:unknown"), ("(false)".into(), vec![]));
// wildcards force a regexp search
assert_eq!(
s(ctx, r"tag:o*n\*et%w%oth_re\_e"),
(
"(n.tags regexp ?)".into(),
vec![r"(?i).* o\S*n\*et%w%oth\Sre_e .*".into()]
vec!["(?i).* o\\S*n\\*et%w%oth\\Sre_e(\u{1f}| ).*".into()]
)
);
assert_eq!(s(ctx, "tag:none"), ("(n.tags = '')".into(), vec![]));

View file

@ -5,7 +5,7 @@ use crate::{
err::Result,
notes::{Note, NoteID},
notetype::NoteTypeID,
tags::{join_tags, split_tags},
tags::{human_tag_name_to_native, join_tags, native_tag_name_to_human, split_tags},
timestamp::TimestampMillis,
};
use rusqlite::{params, Row, NO_PARAMS};
@ -18,6 +18,11 @@ pub(crate) fn join_fields(fields: &[String]) -> String {
fields.join("\x1f")
}
fn native_tags_str(tags: &[String]) -> String {
let s: Vec<_> = tags.iter().map(|t| human_tag_name_to_native(t)).collect();
join_tags(&s)
}
fn row_to_note(row: &Row) -> Result<Note> {
Ok(Note {
id: row.get(0)?,
@ -26,7 +31,7 @@ fn row_to_note(row: &Row) -> Result<Note> {
mtime: row.get(3)?,
usn: row.get(4)?,
tags: split_tags(row.get_raw(5).as_str()?)
.map(Into::into)
.map(|t| native_tag_name_to_human(t))
.collect(),
fields: split_fields(row.get_raw(6).as_str()?),
sort_field: None,
@ -52,7 +57,7 @@ impl super::SqliteStorage {
note.notetype_id,
note.mtime,
note.usn,
join_tags(&note.tags),
native_tags_str(&note.tags),
join_fields(&note.fields()),
note.sort_field.as_ref().unwrap(),
note.checksum.unwrap(),
@ -70,7 +75,7 @@ impl super::SqliteStorage {
note.notetype_id,
note.mtime,
note.usn,
join_tags(&note.tags),
native_tags_str(&note.tags),
join_fields(&note.fields()),
note.sort_field.as_ref().unwrap(),
note.checksum.unwrap(),
@ -88,7 +93,7 @@ impl super::SqliteStorage {
note.notetype_id,
note.mtime,
note.usn,
join_tags(&note.tags),
native_tags_str(&note.tags),
join_fields(&note.fields()),
note.sort_field.as_ref().unwrap(),
note.checksum.unwrap(),
@ -156,4 +161,70 @@ impl super::SqliteStorage {
.query_row(NO_PARAMS, |r| r.get(0))
.map_err(Into::into)
}
// get distinct note tags in human form
pub(crate) fn get_note_tags(&self, nids: Vec<NoteID>) -> Result<Vec<String>> {
if nids.is_empty() {
self.db
.prepare_cached("select distinct tags from notes")?
.query_and_then(NO_PARAMS, |r| {
let t = r.get_raw(0).as_str()?;
Ok(native_tag_name_to_human(t))
})?
.collect()
} else {
self.db
.prepare_cached("select distinct tags from notes where id in ?")?
.query_and_then(nids, |r| {
let t = r.get_raw(0).as_str()?;
Ok(native_tag_name_to_human(t))
})?
.collect()
}
}
pub(super) fn upgrade_notes_to_schema17(&self) -> Result<()> {
let notes: Result<Vec<(NoteID, String)>> = self
.db
.prepare_cached("select id, tags from notes")?
.query_and_then(NO_PARAMS, |row| -> Result<(NoteID, String)> {
let id = NoteID(row.get_raw(0).as_i64()?);
let tags: Vec<String> = split_tags(row.get_raw(1).as_str()?)
.map(|t| human_tag_name_to_native(t))
.collect();
let tags = join_tags(&tags);
Ok((id, tags))
})?
.collect();
notes?.into_iter().try_for_each(|(id, tags)| -> Result<_> {
self.db
.prepare_cached("update notes set tags = ? where id = ?")?
.execute(params![tags, id])?;
Ok(())
})?;
Ok(())
}
pub(super) fn downgrade_notes_from_schema17(&self) -> Result<()> {
let notes: Result<Vec<(NoteID, String)>> = self
.db
.prepare_cached("select id, tags from notes")?
.query_and_then(NO_PARAMS, |row| -> Result<(NoteID, String)> {
let id = NoteID(row.get_raw(0).as_i64()?);
let tags: Vec<String> = split_tags(row.get_raw(1).as_str()?)
.map(|t| native_tag_name_to_human(t))
.collect();
let tags = join_tags(&tags);
Ok((id, tags))
})?
.collect();
notes?.into_iter().try_for_each(|(id, tags)| -> Result<_> {
self.db
.prepare_cached("update notes set tags = ? where id = ?")?
.execute(params![tags, id])?;
Ok(())
})?;
Ok(())
}
}

View file

@ -1,3 +1,4 @@
INSERT
OR IGNORE INTO tags (tag, usn)
VALUES (?, ?)
insert
or replace into tags (id, name, usn, config)
values
(?, ?, ?, ?)

View file

@ -0,0 +1,13 @@
select
case
when ?1 in (
select
id
from tags
) then (
select
max(id) + 1
from tags
)
else ?1
end;

View file

@ -2,36 +2,98 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::SqliteStorage;
use crate::{err::Result, types::Usn};
use rusqlite::{params, NO_PARAMS};
use crate::{
err::Result,
tags::{human_tag_name_to_native, native_tag_name_to_human, Tag, TagConfig, TagID},
timestamp::TimestampMillis,
types::Usn,
};
use prost::Message;
use rusqlite::{params, Row, NO_PARAMS};
use std::collections::HashMap;
fn row_to_tag(row: &Row) -> Result<Tag> {
let config = TagConfig::decode(row.get_raw(3).as_blob()?)?;
Ok(Tag {
id: row.get(0)?,
name: row.get(1)?,
usn: row.get(2)?,
config,
})
}
impl SqliteStorage {
pub(crate) fn all_tags(&self) -> Result<Vec<(String, Usn)>> {
pub(crate) fn all_tags(&self) -> Result<Vec<Tag>> {
self.db
.prepare_cached("select tag, usn from tags")?
.query_and_then(NO_PARAMS, |row| -> Result<_> {
Ok((row.get(0)?, row.get(1)?))
.prepare_cached("select id, name, usn, config from tags")?
.query_and_then(NO_PARAMS, row_to_tag)?
.collect()
}
/// Get all tags in human form, sorted by name
pub(crate) fn all_tags_sorted(&self) -> Result<Vec<Tag>> {
self.db
.prepare_cached("select id, name, usn, config from tags order by name")?
.query_and_then(NO_PARAMS, |row| {
let mut tag = row_to_tag(row)?;
tag.name = native_tag_name_to_human(&tag.name);
Ok(tag)
})?
.collect()
}
pub(crate) fn register_tag(&self, tag: &str, usn: Usn) -> Result<()> {
pub(crate) fn get_tag(&self, id: TagID) -> Result<Option<Tag>> {
self.db
.prepare_cached("select id, name, usn, config from tags where id = ?")?
.query_and_then(&[id], |row| {
let mut tag = row_to_tag(row)?;
tag.name = native_tag_name_to_human(&tag.name);
Ok(tag)
})?
.next()
.transpose()
}
fn alloc_id(&self) -> rusqlite::Result<TagID> {
self.db
.prepare_cached(include_str!("alloc_id.sql"))?
.query_row(&[TimestampMillis::now()], |r| r.get(0))
}
pub(crate) fn register_tag(&self, tag: &mut Tag) -> Result<()> {
let mut config = vec![];
tag.config.encode(&mut config)?;
tag.id = self.alloc_id()?;
self.update_tag(tag)?;
Ok(())
}
pub(crate) fn update_tag(&self, tag: &Tag) -> Result<()> {
let mut config = vec![];
tag.config.encode(&mut config)?;
self.db
.prepare_cached(include_str!("add.sql"))?
.execute(params![tag, usn])?;
.execute(params![tag.id, tag.name, tag.usn, config])?;
Ok(())
}
pub(crate) fn preferred_tag_case(&self, tag: &str) -> Result<Option<String>> {
self.db
.prepare_cached("select tag from tags where tag = ?")?
.prepare_cached("select name from tags where name = ?")?
.query_and_then(params![tag], |row| row.get(0))?
.next()
.transpose()
.map_err(Into::into)
}
pub(crate) fn clear_tag(&self, tag: &str) -> Result<()> {
self.db
.prepare_cached("delete from tags where name regexp ?")?
.execute(&[format!("^{}($|\x1f)", regex::escape(tag))])?;
Ok(())
}
pub(crate) fn clear_tags(&self) -> Result<()> {
self.db.execute("delete from tags", NO_PARAMS)?;
Ok(())
@ -48,7 +110,7 @@ impl SqliteStorage {
pub(crate) fn tags_pending_sync(&self, usn: Usn) -> Result<Vec<String>> {
self.db
.prepare_cached(&format!(
"select tag from tags where {}",
"select name from tags where {}",
usn.pending_object_clause()
))?
.query_and_then(&[usn], |r| r.get(0).map_err(Into::into))?
@ -58,7 +120,7 @@ impl SqliteStorage {
pub(crate) fn update_tag_usns(&self, tags: &[String], new_usn: Usn) -> Result<()> {
let mut stmt = self
.db
.prepare_cached("update tags set usn=? where tag=?")?;
.prepare_cached("update tags set usn=? where name=?")?;
for tag in tags {
stmt.execute(params![new_usn, tag])?;
}
@ -75,8 +137,11 @@ impl SqliteStorage {
serde_json::from_str(row.get_raw(0).as_str()?).map_err(Into::into);
tags
})?;
let mut stmt = self
.db
.prepare_cached("insert or ignore into tags (tag, usn) values (?, ?)")?;
for (tag, usn) in tags.into_iter() {
self.register_tag(&tag, usn)?;
stmt.execute(params![tag, usn])?;
}
self.db.execute_batch("update col set tags=''")?;
@ -85,11 +150,49 @@ impl SqliteStorage {
pub(super) fn downgrade_tags_from_schema14(&self) -> Result<()> {
let alltags = self.all_tags()?;
let tagsmap: HashMap<String, Usn> = alltags.into_iter().collect();
let tagsmap: HashMap<String, Usn> = alltags.into_iter().map(|t| (t.name, t.usn)).collect();
self.db.execute(
"update col set tags=?",
params![serde_json::to_string(&tagsmap)?],
)?;
Ok(())
}
pub(super) fn upgrade_tags_to_schema17(&self) -> Result<()> {
let tags = self
.db
.prepare_cached("select tag, usn from tags")?
.query_and_then(NO_PARAMS, |r| {
Ok(Tag {
name: r.get(0)?,
usn: r.get(1)?,
..Default::default()
})
})?
.collect::<Result<Vec<Tag>>>()?;
self.db.execute_batch(
"
drop table tags;
create table tags (
id integer primary key not null,
name text not null collate unicase,
usn integer not null,
config blob not null
);
",
)?;
tags.into_iter().try_for_each(|mut tag| -> Result<()> {
tag.name = human_tag_name_to_native(&tag.name);
self.register_tag(&mut tag)
})
}
pub(super) fn downgrade_tags_from_schema17(&self) -> Result<()> {
let tags = self.all_tags()?;
self.clear_tags()?;
tags.into_iter().try_for_each(|mut tag| -> Result<()> {
tag.name = native_tag_name_to_human(&tag.name);
self.register_tag(&mut tag)
})
}
}

View file

@ -6,7 +6,7 @@ pub(super) const SCHEMA_MIN_VERSION: u8 = 11;
/// The version new files are initially created with.
pub(super) const SCHEMA_STARTING_VERSION: u8 = 11;
/// The maximum schema version we can open.
pub(super) const SCHEMA_MAX_VERSION: u8 = 16;
pub(super) const SCHEMA_MAX_VERSION: u8 = 17;
use super::SqliteStorage;
use crate::err::Result;
@ -31,6 +31,11 @@ impl SqliteStorage {
self.upgrade_deck_conf_to_schema16(server)?;
self.db.execute_batch("update col set ver = 16")?;
}
if ver < 17 {
self.upgrade_tags_to_schema17()?;
self.upgrade_notes_to_schema17()?;
self.db.execute_batch("update col set ver = 17")?;
}
Ok(())
}
@ -42,7 +47,9 @@ impl SqliteStorage {
self.downgrade_decks_from_schema15()?;
self.downgrade_notetypes_from_schema15()?;
self.downgrade_config_from_schema14()?;
self.downgrade_tags_from_schema17()?;
self.downgrade_tags_from_schema14()?;
self.downgrade_notes_from_schema17()?;
self.db
.execute_batch(include_str!("schema11_downgrade.sql"))?;

View file

@ -14,7 +14,7 @@ use crate::{
prelude::*,
revlog::RevlogEntry,
serde::{default_on_invalid, deserialize_int_from_number},
tags::{join_tags, split_tags},
tags::{join_tags, split_tags, Tag},
version::sync_client_version,
};
use flate2::write::GzEncoder;
@ -888,7 +888,11 @@ impl Collection {
fn merge_tags(&self, tags: Vec<String>, latest_usn: Usn) -> Result<()> {
for tag in tags {
self.register_tag(&tag, latest_usn)?;
self.register_tag(Tag {
name: tag,
usn: latest_usn,
..Default::default()
})?;
}
Ok(())
}
@ -1338,12 +1342,12 @@ mod test {
col1.storage
.all_tags()?
.into_iter()
.map(|t| t.0)
.map(|t| t.name)
.collect::<Vec<_>>(),
col2.storage
.all_tags()?
.into_iter()
.map(|t| t.0)
.map(|t| t.name)
.collect::<Vec<_>>()
);

View file

@ -1,17 +1,64 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub use crate::backend_proto::TagConfig;
use crate::{
backend_proto::{Tag as TagProto, TagTreeNode},
collection::Collection,
define_newtype,
err::{AnkiError, Result},
notes::{NoteID, TransformNoteOutput},
text::to_re,
{text::normalize_to_nfc, types::Usn},
text::{normalize_to_nfc, to_re},
types::Usn,
};
use regex::{NoExpand, Regex, Replacer};
use std::{borrow::Cow, collections::HashSet};
use std::{borrow::Cow, collections::HashSet, iter::Peekable};
use unicase::UniCase;
define_newtype!(TagID, i64);
#[derive(Debug, Clone, PartialEq)]
pub struct Tag {
pub id: TagID,
pub name: String,
pub usn: Usn,
pub config: TagConfig,
}
impl Default for Tag {
fn default() -> Self {
Tag {
id: TagID(0),
name: "".to_string(),
usn: Usn(-1),
config: Default::default(),
}
}
}
impl From<Tag> for TagProto {
fn from(t: Tag) -> Self {
TagProto {
id: t.id.0,
name: t.name,
usn: t.usn.0,
config: Some(t.config),
}
}
}
impl From<TagProto> for Tag {
fn from(t: TagProto) -> Self {
Tag {
id: TagID(t.id),
name: t.name,
usn: Usn(t.usn),
config: t.config.unwrap(),
}
}
}
pub(crate) fn split_tags(tags: &str) -> impl Iterator<Item = &str> {
tags.split(is_tag_separator).filter(|tag| !tag.is_empty())
}
@ -32,31 +79,117 @@ fn invalid_char_for_tag(c: char) -> bool {
c.is_ascii_control() || is_tag_separator(c) || c == '"'
}
fn normalized_tag_name_component(comp: &str) -> Cow<str> {
let mut out = normalize_to_nfc(comp);
if out.contains(invalid_char_for_tag) {
out = out.replace(invalid_char_for_tag, "").into();
}
let trimmed = out.trim();
if trimmed.is_empty() {
"blank".to_string().into()
} else if trimmed.len() != out.len() {
trimmed.to_string().into()
} else {
out
}
}
pub(crate) fn human_tag_name_to_native(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for comp in name.split("::") {
out.push_str(&normalized_tag_name_component(comp));
out.push('\x1f');
}
out.trim_end_matches('\x1f').into()
}
pub(crate) fn native_tag_name_to_human(name: &str) -> String {
name.replace('\x1f', "::")
}
fn immediate_parent_name(native_name: &str) -> Option<&str> {
native_name.rsplitn(2, '\x1f').nth(1)
}
fn tags_to_tree(tags: Vec<Tag>) -> TagTreeNode {
let mut top = TagTreeNode::default();
let mut it = tags.into_iter().peekable();
add_child_nodes(&mut it, &mut top);
top
}
fn add_child_nodes(tags: &mut Peekable<impl Iterator<Item = Tag>>, parent: &mut TagTreeNode) {
while let Some(tag) = tags.peek() {
let split_name: Vec<_> = tag.name.split("::").collect();
match split_name.len() as u32 {
l if l <= parent.level => {
// next item is at a higher level
return;
}
l if l == parent.level + 1 => {
// next item is an immediate descendent of parent
parent.children.push(TagTreeNode {
tag_id: tag.id.0,
name: (*split_name.last().unwrap()).into(),
children: vec![],
level: parent.level + 1,
collapsed: tag.config.browser_collapsed,
});
tags.next();
}
_ => {
// next item is at a lower level
if let Some(last_child) = parent.children.last_mut() {
add_child_nodes(tags, last_child)
} else {
// immediate parent is missing
tags.next();
}
}
}
}
}
impl Collection {
pub fn all_tags(&self) -> Result<Vec<Tag>> {
self.storage
.all_tags()?
.into_iter()
.map(|t| {
Ok(Tag {
name: native_tag_name_to_human(&t.name),
..t
})
})
.collect()
}
pub fn tag_tree(&mut self) -> Result<TagTreeNode> {
let tags = self.storage.all_tags_sorted()?;
let tree = tags_to_tree(tags);
Ok(tree)
}
/// Given a list of tags, fix case, ordering and duplicates.
/// Returns true if any new tags were added.
pub(crate) fn canonify_tags(&self, tags: Vec<String>, usn: Usn) -> Result<(Vec<String>, bool)> {
let mut seen = HashSet::new();
let mut added = false;
let mut tags: Vec<_> = tags
.iter()
.flat_map(|t| split_tags(t))
.map(|s| normalize_to_nfc(&s))
.collect();
let mut tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
for tag in &mut tags {
if tag.contains(invalid_char_for_tag) {
*tag = tag.replace(invalid_char_for_tag, "").into();
}
if tag.trim().is_empty() {
let t = self.register_tag(Tag {
name: tag.to_string(),
usn,
..Default::default()
})?;
if t.0.is_empty() {
continue;
}
let tag = self.register_tag(tag, usn)?;
if matches!(tag, Cow::Borrowed(_)) {
added = true;
}
seen.insert(UniCase::new(tag));
added |= t.1;
seen.insert(UniCase::new(t.0));
}
// exit early if no non-empty tags
@ -75,12 +208,40 @@ impl Collection {
Ok((tags, added))
}
pub(crate) fn register_tag<'a>(&self, tag: &'a str, usn: Usn) -> Result<Cow<'a, str>> {
if let Some(preferred) = self.storage.preferred_tag_case(tag)? {
Ok(preferred.into())
fn create_missing_tag_parents(&self, mut native_name: &str, usn: Usn) -> Result<bool> {
let mut added = false;
while let Some(parent_name) = immediate_parent_name(native_name) {
if self.storage.preferred_tag_case(&parent_name)?.is_none() {
let mut t = Tag {
name: parent_name.to_string(),
usn,
..Default::default()
};
self.storage.register_tag(&mut t)?;
added = true;
}
native_name = parent_name;
}
Ok(added)
}
pub(crate) fn register_tag<'a>(&self, tag: Tag) -> Result<(Cow<'a, str>, bool)> {
let native_name = human_tag_name_to_native(&tag.name);
if native_name.is_empty() {
return Ok(("".into(), false));
}
let added_parents = self.create_missing_tag_parents(&native_name, tag.usn)?;
if let Some(preferred) = self.storage.preferred_tag_case(&native_name)? {
Ok((native_tag_name_to_human(&preferred).into(), added_parents))
} else {
self.storage.register_tag(tag, usn)?;
Ok(tag.into())
let mut t = Tag {
name: native_name.clone(),
usn: tag.usn,
config: tag.config,
..Default::default()
};
self.storage.register_tag(&mut t)?;
Ok((native_tag_name_to_human(&native_name).into(), true))
}
}
@ -90,14 +251,31 @@ impl Collection {
self.storage.clear_tags()?;
}
for tag in split_tags(tags) {
let tag = self.register_tag(tag, usn)?;
if matches!(tag, Cow::Borrowed(_)) {
changed = true;
}
let t = self.register_tag(Tag {
name: tag.to_string(),
usn,
..Default::default()
})?;
changed |= t.1;
}
Ok(changed)
}
pub(crate) fn update_tag(&self, tag: &Tag) -> Result<()> {
let native_name = human_tag_name_to_native(&tag.name);
self.storage.update_tag(&Tag {
id: tag.id,
name: native_name,
usn: tag.usn,
config: tag.config.clone(),
})
}
pub(crate) fn clear_tag(&self, tag: &str) -> Result<()> {
let native_name = human_tag_name_to_native(tag);
self.storage.clear_tag(&native_name)
}
fn replace_tags_for_notes_inner<R: Replacer>(
&mut self,
nids: &[NoteID],
@ -135,11 +313,10 @@ impl Collection {
let tags = split_tags(tags)
.map(|tag| {
let tag = if regex { tag.into() } else { to_re(tag) };
Regex::new(&format!("(?i)^{}$", tag))
Regex::new(&format!("(?i)^{}(::.*)?", tag))
.map_err(|_| AnkiError::invalid_input("invalid regex"))
})
.collect::<Result<Vec<Regex>>>()?;
if !regex {
self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl))
} else {
@ -222,6 +399,11 @@ mod test {
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["one", "two"]);
// note.tags is in human form
note.tags = vec!["foo::bar".into()];
col.update_note(&mut note)?;
assert_eq!(&note.tags, &["foo::bar"]);
Ok(())
}
@ -263,6 +445,26 @@ mod test {
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["cee"]);
let mut note = col.storage.get_note(note.id)?.unwrap();
note.tags = vec![
"foo::bar".into(),
"foo::bar::foo".into(),
"bar::foo".into(),
"bar::foo::bar".into(),
];
col.update_note(&mut note)?;
col.replace_tags_for_notes(&[note.id], "bar::foo", "foo::bar", false)?;
let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["foo::bar", "foo::bar::bar", "foo::bar::foo",]);
// missing tag parents are registered too when registering their children
col.storage.clear_tags()?;
let mut note = col.storage.get_note(note.id)?.unwrap();
note.tags = vec!["animal::mammal::cat".into()];
col.update_note(&mut note)?;
let tags: Vec<String> = col.all_tags()?.into_iter().map(|t| t.name).collect();
assert_eq!(&tags, &["animal::mammal", "animal", "animal::mammal::cat"]);
Ok(())
}
}

View file

@ -323,14 +323,6 @@ pub(crate) fn to_text(txt: &str) -> Cow<str> {
RE.replace_all(&txt, "$1")
}
/// Escape characters special to SQL: \%_
pub(crate) fn escape_sql(txt: &str) -> Cow<str> {
lazy_static! {
static ref RE: Regex = Regex::new(r"[\\%_]").unwrap();
}
RE.replace_all(&txt, r"\$0")
}
/// Compare text with a possible glob, folding case.
pub(crate) fn matches_glob(text: &str, search: &str) -> bool {
if is_glob(search) {
@ -399,7 +391,6 @@ mod test {
assert_eq!(&to_custom_re("f_o*", r"\d"), r"f\do\d*");
assert_eq!(&to_sql("%f_o*"), r"\%f_o%");
assert_eq!(&to_text(r"\*\_*_"), "*_*_");
assert_eq!(&escape_sql(r"1\2%3_"), r"1\\2\%3\_");
assert!(is_glob(r"\\\\_"));
assert!(!is_glob(r"\\\_"));
assert!(matches_glob("foo*bar123", r"foo\*bar*"));