mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 14:32:22 -04:00
Hierarchical tags
This commit is contained in:
parent
8c6d0e6229
commit
b276ce3dd5
18 changed files with 757 additions and 238 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ¬e.fields {
|
for field in ¬e.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
|
||||||
//-------------------------------------------------------------------
|
//-------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -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: ®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;
|
*tag = rep;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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![]));
|
||||||
|
|
|
@ -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(¬e.tags),
|
native_tags_str(¬e.tags),
|
||||||
join_fields(¬e.fields()),
|
join_fields(¬e.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(¬e.tags),
|
native_tags_str(¬e.tags),
|
||||||
join_fields(¬e.fields()),
|
join_fields(¬e.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(¬e.tags),
|
native_tags_str(¬e.tags),
|
||||||
join_fields(¬e.fields()),
|
join_fields(¬e.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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
INSERT
|
insert
|
||||||
OR IGNORE INTO tags (tag, usn)
|
or replace into tags (id, name, usn, config)
|
||||||
VALUES (?, ?)
|
values
|
||||||
|
(?, ?, ?, ?)
|
||||||
|
|
13
rslib/src/storage/tag/alloc_id.sql
Normal file
13
rslib/src/storage/tag/alloc_id.sql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
select
|
||||||
|
case
|
||||||
|
when ?1 in (
|
||||||
|
select
|
||||||
|
id
|
||||||
|
from tags
|
||||||
|
) then (
|
||||||
|
select
|
||||||
|
max(id) + 1
|
||||||
|
from tags
|
||||||
|
)
|
||||||
|
else ?1
|
||||||
|
end;
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"))?;
|
||||||
|
|
||||||
|
|
|
@ -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<_>>()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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!(¬e.tags, &["one", "two"]);
|
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(())
|
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!(¬e.tags, &["cee"]);
|
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<String> = col.all_tags()?.into_iter().map(|t| t.name).collect();
|
||||||
|
assert_eq!(&tags, &["animal::mammal", "animal", "animal::mammal::cat"]);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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*"));
|
||||||
|
|
Loading…
Reference in a new issue