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 = Rename
actions-rename-deck = Rename Deck actions-rename-deck = Rename Deck
actions-rename-tag = Rename Tag actions-rename-tag = Rename Tag
actions-remove-tag = Remove Tag
actions-replay-audio = Replay Audio actions-replay-audio = Replay Audio
actions-reposition = Reposition actions-reposition = Reposition
actions-save = Save actions-save = Save

View file

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

View file

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

View file

@ -21,7 +21,7 @@ from anki.consts import *
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.models import NoteType from anki.models import NoteType
from anki.notes import Note 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.stats import CardStats
from anki.utils import htmlToTextLine, ids2str, isMac, isWin from anki.utils import htmlToTextLine, ids2str, isMac, isWin
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
@ -1132,15 +1132,39 @@ QTableView {{ gridline-color: {grid} }}
root.addChild(item) root.addChild(item)
def _userTagTree(self, root) -> None: def _userTagTree(self, root) -> None:
assert self.col tree = self.col.backend.tag_tree()
for t in self.col.tags.all():
item = SidebarItem( def fillGroups(root, nodes: Sequence[TagTreeNode], head=""):
t, for node in nodes:
":/icons/tag.svg",
lambda t=t: self.setFilter("tag", t), # type: ignore def set_filter():
item_type=SidebarItemType.TAG, full_name = head + node.name # pylint: disable=cell-var-from-loop
) return lambda: self.setFilter("tag", full_name)
root.addChild(item)
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: def _decksTree(self, root) -> None:
tree = self.col.decks.deck_tree() tree = self.col.decks.deck_tree()

View file

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

View file

@ -76,7 +76,10 @@ class NewSidebarTreeView(SidebarTreeViewBase):
(tr(TR.ACTIONS_RENAME), self.rename_deck), (tr(TR.ACTIONS_RENAME), self.rename_deck),
(tr(TR.ACTIONS_DELETE), self.delete_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: def onContextMenu(self, point: QPoint) -> None:
@ -111,16 +114,37 @@ class NewSidebarTreeView(SidebarTreeViewBase):
self.browser.maybeRefreshSidebar() self.browser.maybeRefreshSidebar()
self.mw.deckBrowser.refresh() 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: def rename_tag(self, item: "aqt.browser.SidebarItem") -> None:
self.browser.editor.saveNow(lambda: self._rename_tag(item)) self.browser.editor.saveNow(lambda: self._rename_tag(item))
def _rename_tag(self, item: "aqt.browser.SidebarItem") -> None: 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) new_name = getOnlyText(tr(TR.ACTIONS_NEW_NAME), default=old_name)
if new_name == old_name or not new_name: if new_name == old_name or not new_name:
return return
def do_rename(): def do_rename():
self.mw.col.backend.clear_tag(old_name)
return self.col.tags.rename_tag(old_name, new_name) return self.col.tags.rename_tag(old_name, new_name)
def on_done(fut: Future): def on_done(fut: Future):
@ -132,7 +156,7 @@ class NewSidebarTreeView(SidebarTreeViewBase):
showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY)) showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY))
return return
self.browser.clearUnusedTags() self.browser.maybeRefreshSidebar()
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG)) self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
self.browser.model.beginReset() self.browser.model.beginReset()

View file

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

View file

@ -44,6 +44,7 @@ use crate::{
get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress,
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage, SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage,
}, },
tags::TagID,
template::RenderedNode, template::RenderedNode,
text::{extract_av_tags, strip_av_tags, AVTag}, text::{extract_av_tags, strip_av_tags, AVTag},
timestamp::TimestampSecs, 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> { fn cloze_numbers_in_note(&self, note: pb::Note) -> BackendResult<pb::ClozeNumbersInNoteOut> {
let mut set = HashSet::with_capacity(4); let mut set = HashSet::with_capacity(4);
for field in &note.fields { for field in &note.fields {
@ -1286,14 +1295,28 @@ impl BackendService for Backend {
//------------------------------------------------------------------- //-------------------------------------------------------------------
fn all_tags(&self, _input: Empty) -> BackendResult<pb::AllTagsOut> { fn all_tags(&self, _input: Empty) -> BackendResult<pb::AllTagsOut> {
let tags = self.with_col(|col| col.storage.all_tags())?; let tags: Vec<pb::Tag> =
let tags: Vec<_> = tags self.with_col(|col| Ok(col.all_tags()?.into_iter().map(|t| t.into()).collect()))?;
.into_iter()
.map(|(tag, usn)| pb::TagUsnTuple { tag, usn: usn.0 })
.collect();
Ok(pb::AllTagsOut { tags }) 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> { fn register_tags(&self, input: pb::RegisterTagsIn) -> BackendResult<pb::Bool> {
self.with_col(|col| { self.with_col(|col| {
col.transact(None, |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 // 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 { pub(crate) fn replace_tags<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool {
let mut changed = false; let mut changed = false;
for tag in &mut self.tags { 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; *tag = rep;
changed = true; changed = true;
} }

View file

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

View file

@ -5,7 +5,7 @@ use crate::{
err::Result, err::Result,
notes::{Note, NoteID}, notes::{Note, NoteID},
notetype::NoteTypeID, notetype::NoteTypeID,
tags::{join_tags, split_tags}, tags::{human_tag_name_to_native, join_tags, native_tag_name_to_human, split_tags},
timestamp::TimestampMillis, timestamp::TimestampMillis,
}; };
use rusqlite::{params, Row, NO_PARAMS}; use rusqlite::{params, Row, NO_PARAMS};
@ -18,6 +18,11 @@ pub(crate) fn join_fields(fields: &[String]) -> String {
fields.join("\x1f") 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> { fn row_to_note(row: &Row) -> Result<Note> {
Ok(Note { Ok(Note {
id: row.get(0)?, id: row.get(0)?,
@ -26,7 +31,7 @@ fn row_to_note(row: &Row) -> Result<Note> {
mtime: row.get(3)?, mtime: row.get(3)?,
usn: row.get(4)?, usn: row.get(4)?,
tags: split_tags(row.get_raw(5).as_str()?) tags: split_tags(row.get_raw(5).as_str()?)
.map(Into::into) .map(|t| native_tag_name_to_human(t))
.collect(), .collect(),
fields: split_fields(row.get_raw(6).as_str()?), fields: split_fields(row.get_raw(6).as_str()?),
sort_field: None, sort_field: None,
@ -52,7 +57,7 @@ impl super::SqliteStorage {
note.notetype_id, note.notetype_id,
note.mtime, note.mtime,
note.usn, note.usn,
join_tags(&note.tags), native_tags_str(&note.tags),
join_fields(&note.fields()), join_fields(&note.fields()),
note.sort_field.as_ref().unwrap(), note.sort_field.as_ref().unwrap(),
note.checksum.unwrap(), note.checksum.unwrap(),
@ -70,7 +75,7 @@ impl super::SqliteStorage {
note.notetype_id, note.notetype_id,
note.mtime, note.mtime,
note.usn, note.usn,
join_tags(&note.tags), native_tags_str(&note.tags),
join_fields(&note.fields()), join_fields(&note.fields()),
note.sort_field.as_ref().unwrap(), note.sort_field.as_ref().unwrap(),
note.checksum.unwrap(), note.checksum.unwrap(),
@ -88,7 +93,7 @@ impl super::SqliteStorage {
note.notetype_id, note.notetype_id,
note.mtime, note.mtime,
note.usn, note.usn,
join_tags(&note.tags), native_tags_str(&note.tags),
join_fields(&note.fields()), join_fields(&note.fields()),
note.sort_field.as_ref().unwrap(), note.sort_field.as_ref().unwrap(),
note.checksum.unwrap(), note.checksum.unwrap(),
@ -156,4 +161,70 @@ impl super::SqliteStorage {
.query_row(NO_PARAMS, |r| r.get(0)) .query_row(NO_PARAMS, |r| r.get(0))
.map_err(Into::into) .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 insert
OR IGNORE INTO tags (tag, usn) or replace into tags (id, name, usn, config)
VALUES (?, ?) 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 // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::SqliteStorage; use super::SqliteStorage;
use crate::{err::Result, types::Usn}; use crate::{
use rusqlite::{params, NO_PARAMS}; 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; 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 { impl SqliteStorage {
pub(crate) fn all_tags(&self) -> Result<Vec<(String, Usn)>> { pub(crate) fn all_tags(&self) -> Result<Vec<Tag>> {
self.db self.db
.prepare_cached("select tag, usn from tags")? .prepare_cached("select id, name, usn, config from tags")?
.query_and_then(NO_PARAMS, |row| -> Result<_> { .query_and_then(NO_PARAMS, row_to_tag)?
Ok((row.get(0)?, row.get(1)?)) .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() .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 self.db
.prepare_cached(include_str!("add.sql"))? .prepare_cached(include_str!("add.sql"))?
.execute(params![tag, usn])?; .execute(params![tag.id, tag.name, tag.usn, config])?;
Ok(()) Ok(())
} }
pub(crate) fn preferred_tag_case(&self, tag: &str) -> Result<Option<String>> { pub(crate) fn preferred_tag_case(&self, tag: &str) -> Result<Option<String>> {
self.db 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))? .query_and_then(params![tag], |row| row.get(0))?
.next() .next()
.transpose() .transpose()
.map_err(Into::into) .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<()> { pub(crate) fn clear_tags(&self) -> Result<()> {
self.db.execute("delete from tags", NO_PARAMS)?; self.db.execute("delete from tags", NO_PARAMS)?;
Ok(()) Ok(())
@ -48,7 +110,7 @@ impl SqliteStorage {
pub(crate) fn tags_pending_sync(&self, usn: Usn) -> Result<Vec<String>> { pub(crate) fn tags_pending_sync(&self, usn: Usn) -> Result<Vec<String>> {
self.db self.db
.prepare_cached(&format!( .prepare_cached(&format!(
"select tag from tags where {}", "select name from tags where {}",
usn.pending_object_clause() usn.pending_object_clause()
))? ))?
.query_and_then(&[usn], |r| r.get(0).map_err(Into::into))? .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<()> { pub(crate) fn update_tag_usns(&self, tags: &[String], new_usn: Usn) -> Result<()> {
let mut stmt = self let mut stmt = self
.db .db
.prepare_cached("update tags set usn=? where tag=?")?; .prepare_cached("update tags set usn=? where name=?")?;
for tag in tags { for tag in tags {
stmt.execute(params![new_usn, tag])?; 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); serde_json::from_str(row.get_raw(0).as_str()?).map_err(Into::into);
tags tags
})?; })?;
let mut stmt = self
.db
.prepare_cached("insert or ignore into tags (tag, usn) values (?, ?)")?;
for (tag, usn) in tags.into_iter() { 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=''")?; self.db.execute_batch("update col set tags=''")?;
@ -85,11 +150,49 @@ impl SqliteStorage {
pub(super) fn downgrade_tags_from_schema14(&self) -> Result<()> { pub(super) fn downgrade_tags_from_schema14(&self) -> Result<()> {
let alltags = self.all_tags()?; 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( self.db.execute(
"update col set tags=?", "update col set tags=?",
params![serde_json::to_string(&tagsmap)?], params![serde_json::to_string(&tagsmap)?],
)?; )?;
Ok(()) 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. /// The version new files are initially created with.
pub(super) const SCHEMA_STARTING_VERSION: u8 = 11; pub(super) const SCHEMA_STARTING_VERSION: u8 = 11;
/// The maximum schema version we can open. /// 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 super::SqliteStorage;
use crate::err::Result; use crate::err::Result;
@ -31,6 +31,11 @@ impl SqliteStorage {
self.upgrade_deck_conf_to_schema16(server)?; self.upgrade_deck_conf_to_schema16(server)?;
self.db.execute_batch("update col set ver = 16")?; 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(()) Ok(())
} }
@ -42,7 +47,9 @@ impl SqliteStorage {
self.downgrade_decks_from_schema15()?; self.downgrade_decks_from_schema15()?;
self.downgrade_notetypes_from_schema15()?; self.downgrade_notetypes_from_schema15()?;
self.downgrade_config_from_schema14()?; self.downgrade_config_from_schema14()?;
self.downgrade_tags_from_schema17()?;
self.downgrade_tags_from_schema14()?; self.downgrade_tags_from_schema14()?;
self.downgrade_notes_from_schema17()?;
self.db self.db
.execute_batch(include_str!("schema11_downgrade.sql"))?; .execute_batch(include_str!("schema11_downgrade.sql"))?;

View file

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

View file

@ -1,17 +1,64 @@
// 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
pub use crate::backend_proto::TagConfig;
use crate::{ use crate::{
backend_proto::{Tag as TagProto, TagTreeNode},
collection::Collection, collection::Collection,
define_newtype,
err::{AnkiError, Result}, err::{AnkiError, Result},
notes::{NoteID, TransformNoteOutput}, notes::{NoteID, TransformNoteOutput},
text::to_re, text::{normalize_to_nfc, to_re},
{text::normalize_to_nfc, types::Usn}, types::Usn,
}; };
use regex::{NoExpand, Regex, Replacer}; use regex::{NoExpand, Regex, Replacer};
use std::{borrow::Cow, collections::HashSet}; use std::{borrow::Cow, collections::HashSet, iter::Peekable};
use unicase::UniCase; 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> { pub(crate) fn split_tags(tags: &str) -> impl Iterator<Item = &str> {
tags.split(is_tag_separator).filter(|tag| !tag.is_empty()) 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 == '"' 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 { 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. /// Given a list of tags, fix case, ordering and duplicates.
/// Returns true if any new tags were added. /// Returns true if any new tags were added.
pub(crate) fn canonify_tags(&self, tags: Vec<String>, usn: Usn) -> Result<(Vec<String>, bool)> { pub(crate) fn canonify_tags(&self, tags: Vec<String>, usn: Usn) -> Result<(Vec<String>, bool)> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let mut added = false; let mut added = false;
let mut tags: Vec<_> = tags let mut tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
.iter()
.flat_map(|t| split_tags(t))
.map(|s| normalize_to_nfc(&s))
.collect();
for tag in &mut tags { for tag in &mut tags {
if tag.contains(invalid_char_for_tag) { let t = self.register_tag(Tag {
*tag = tag.replace(invalid_char_for_tag, "").into(); name: tag.to_string(),
} usn,
if tag.trim().is_empty() { ..Default::default()
})?;
if t.0.is_empty() {
continue; continue;
} }
let tag = self.register_tag(tag, usn)?; added |= t.1;
if matches!(tag, Cow::Borrowed(_)) { seen.insert(UniCase::new(t.0));
added = true;
}
seen.insert(UniCase::new(tag));
} }
// exit early if no non-empty tags // exit early if no non-empty tags
@ -75,12 +208,40 @@ impl Collection {
Ok((tags, added)) Ok((tags, added))
} }
pub(crate) fn register_tag<'a>(&self, tag: &'a str, usn: Usn) -> Result<Cow<'a, str>> { fn create_missing_tag_parents(&self, mut native_name: &str, usn: Usn) -> Result<bool> {
if let Some(preferred) = self.storage.preferred_tag_case(tag)? { let mut added = false;
Ok(preferred.into()) 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 { } else {
self.storage.register_tag(tag, usn)?; let mut t = Tag {
Ok(tag.into()) 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()?; self.storage.clear_tags()?;
} }
for tag in split_tags(tags) { for tag in split_tags(tags) {
let tag = self.register_tag(tag, usn)?; let t = self.register_tag(Tag {
if matches!(tag, Cow::Borrowed(_)) { name: tag.to_string(),
changed = true; usn,
} ..Default::default()
})?;
changed |= t.1;
} }
Ok(changed) 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>( fn replace_tags_for_notes_inner<R: Replacer>(
&mut self, &mut self,
nids: &[NoteID], nids: &[NoteID],
@ -135,11 +313,10 @@ impl Collection {
let tags = split_tags(tags) let tags = split_tags(tags)
.map(|tag| { .map(|tag| {
let tag = if regex { tag.into() } else { to_re(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")) .map_err(|_| AnkiError::invalid_input("invalid regex"))
}) })
.collect::<Result<Vec<Regex>>>()?; .collect::<Result<Vec<Regex>>>()?;
if !regex { if !regex {
self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl)) self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl))
} else { } else {
@ -222,6 +399,11 @@ mod test {
col.update_note(&mut note)?; col.update_note(&mut note)?;
assert_eq!(&note.tags, &["one", "two"]); 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(()) Ok(())
} }
@ -263,6 +445,26 @@ mod test {
let note = col.storage.get_note(note.id)?.unwrap(); let note = col.storage.get_note(note.id)?.unwrap();
assert_eq!(&note.tags, &["cee"]); 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(()) Ok(())
} }
} }

View file

@ -323,14 +323,6 @@ pub(crate) fn to_text(txt: &str) -> Cow<str> {
RE.replace_all(&txt, "$1") 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. /// Compare text with a possible glob, folding case.
pub(crate) fn matches_glob(text: &str, search: &str) -> bool { pub(crate) fn matches_glob(text: &str, search: &str) -> bool {
if is_glob(search) { 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_custom_re("f_o*", r"\d"), r"f\do\d*");
assert_eq!(&to_sql("%f_o*"), r"\%f_o%"); assert_eq!(&to_sql("%f_o*"), r"\%f_o%");
assert_eq!(&to_text(r"\*\_*_"), "*_*_"); 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!(!is_glob(r"\\\_")); assert!(!is_glob(r"\\\_"));
assert!(matches_glob("foo*bar123", r"foo\*bar*")); assert!(matches_glob("foo*bar123", r"foo\*bar*"));