diff --git a/ftl/core/actions.ftl b/ftl/core/actions.ftl index 11be42642..01039426e 100644 --- a/ftl/core/actions.ftl +++ b/ftl/core/actions.ftl @@ -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 diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 1653bfe88..fb9e43329 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -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 diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index f4f8008e3..ffd288e15 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -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, ) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index af9c99ade..63ffdf002 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -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() diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 1ca0f11f9..0f4aa2dbc 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -75,6 +75,7 @@ class ResetReason(enum.Enum): EditorBridgeCmd = "editorBridgeCmd" BrowserSetDeck = "browserSetDeck" BrowserAddTags = "browserAddTags" + BrowserRemoveTags = "browserRemoveTags" BrowserSuspend = "browserSuspend" BrowserReposition = "browserReposition" BrowserReschedule = "browserReschedule" diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 4103a44df..f616bf7f1 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -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() diff --git a/rslib/backend.proto b/rslib/backend.proto index a99743240..10a3d0a95 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -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; } diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 22868773f..ab78d8f0d 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -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 { + 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 { let mut set = HashSet::with_capacity(4); for field in ¬e.fields { @@ -1286,14 +1295,28 @@ impl BackendService for Backend { //------------------------------------------------------------------- fn all_tags(&self, _input: Empty) -> BackendResult { - 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 = + 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 { + 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 { + self.with_col(|col| { + col.update_tag(&tag.into())?; + Ok(pb::Bool { val: true }) + }) + } + fn register_tags(&self, input: pb::RegisterTagsIn) -> BackendResult { self.with_col(|col| { col.transact(None, |col| { @@ -1308,6 +1331,19 @@ impl BackendService for Backend { }) } + fn clear_tag(&self, tag: pb::String) -> BackendResult { + 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 { + self.with_col(|col| col.tag_tree()) + } + // config/preferences //------------------------------------------------------------------- diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index 2abbf9f3f..a1606ac2e 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -155,7 +155,22 @@ impl Note { pub(crate) fn replace_tags(&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: ®ex::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; } diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index a29502d63..aba59b8dd 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -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![])); diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index eb921b1c6..2ddaa8655 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -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 { Ok(Note { id: row.get(0)?, @@ -26,7 +31,7 @@ fn row_to_note(row: &Row) -> Result { 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(¬e.tags), + native_tags_str(¬e.tags), join_fields(¬e.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(¬e.tags), + native_tags_str(¬e.tags), join_fields(¬e.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(¬e.tags), + native_tags_str(¬e.tags), join_fields(¬e.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) -> Result> { + 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> = 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 = 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> = 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 = 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(()) + } } diff --git a/rslib/src/storage/tag/add.sql b/rslib/src/storage/tag/add.sql index 211807a5f..16ee33d85 100644 --- a/rslib/src/storage/tag/add.sql +++ b/rslib/src/storage/tag/add.sql @@ -1,3 +1,4 @@ -INSERT - OR IGNORE INTO tags (tag, usn) -VALUES (?, ?) \ No newline at end of file +insert + or replace into tags (id, name, usn, config) +values + (?, ?, ?, ?) diff --git a/rslib/src/storage/tag/alloc_id.sql b/rslib/src/storage/tag/alloc_id.sql new file mode 100644 index 000000000..989c43749 --- /dev/null +++ b/rslib/src/storage/tag/alloc_id.sql @@ -0,0 +1,13 @@ +select + case + when ?1 in ( + select + id + from tags + ) then ( + select + max(id) + 1 + from tags + ) + else ?1 + end; \ No newline at end of file diff --git a/rslib/src/storage/tag/mod.rs b/rslib/src/storage/tag/mod.rs index 4d8da1984..0aa958cce 100644 --- a/rslib/src/storage/tag/mod.rs +++ b/rslib/src/storage/tag/mod.rs @@ -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 { + 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> { + pub(crate) fn all_tags(&self) -> Result> { 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> { + 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> { + 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 { + 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> { 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> { 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 = alltags.into_iter().collect(); + let tagsmap: HashMap = 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::>>()?; + 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) + }) + } } diff --git a/rslib/src/storage/upgrades/mod.rs b/rslib/src/storage/upgrades/mod.rs index 2d1896945..1167ecf59 100644 --- a/rslib/src/storage/upgrades/mod.rs +++ b/rslib/src/storage/upgrades/mod.rs @@ -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"))?; diff --git a/rslib/src/sync/mod.rs b/rslib/src/sync/mod.rs index d8b718912..0a992f179 100644 --- a/rslib/src/sync/mod.rs +++ b/rslib/src/sync/mod.rs @@ -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, 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::>(), col2.storage .all_tags()? .into_iter() - .map(|t| t.0) + .map(|t| t.name) .collect::>() ); diff --git a/rslib/src/tags.rs b/rslib/src/tags.rs index e0443c7b0..5c18d6879 100644 --- a/rslib/src/tags.rs +++ b/rslib/src/tags.rs @@ -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 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 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 { 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 { + 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) -> 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>, 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> { + 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 { + 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, usn: Usn) -> Result<(Vec, 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> { - 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 { + 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( &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::>>()?; - 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!(¬e.tags, &["one", "two"]); + // note.tags is in human form + note.tags = vec!["foo::bar".into()]; + col.update_note(&mut note)?; + assert_eq!(¬e.tags, &["foo::bar"]); + Ok(()) } @@ -263,6 +445,26 @@ mod test { let note = col.storage.get_note(note.id)?.unwrap(); assert_eq!(¬e.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!(¬e.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 = col.all_tags()?.into_iter().map(|t| t.name).collect(); + assert_eq!(&tags, &["animal::mammal", "animal", "animal::mammal::cat"]); + Ok(()) } } diff --git a/rslib/src/text.rs b/rslib/src/text.rs index 0f892ecd0..70264b4c6 100644 --- a/rslib/src/text.rs +++ b/rslib/src/text.rs @@ -323,14 +323,6 @@ pub(crate) fn to_text(txt: &str) -> Cow { RE.replace_all(&txt, "$1") } -/// Escape characters special to SQL: \%_ -pub(crate) fn escape_sql(txt: &str) -> Cow { - 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*"));