mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22: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-deck = Rename Deck
|
||||
actions-rename-tag = Rename Tag
|
||||
actions-remove-tag = Remove Tag
|
||||
actions-replay-audio = Replay Audio
|
||||
actions-reposition = Reposition
|
||||
actions-save = Save
|
||||
|
|
|
@ -42,7 +42,8 @@ SchedTimingToday = pb.SchedTimingTodayOut
|
|||
BuiltinSortKind = pb.BuiltinSearchOrder.BuiltinSortKind
|
||||
BackendCard = pb.Card
|
||||
BackendNote = pb.Note
|
||||
TagUsnTuple = pb.TagUsnTuple
|
||||
Tag = pb.Tag
|
||||
TagTreeNode = pb.TagTreeNode
|
||||
NoteType = pb.NoteType
|
||||
DeckTreeNode = pb.DeckTreeNode
|
||||
StockNoteType = pb.StockNoteType
|
||||
|
|
|
@ -25,7 +25,7 @@ class TagManager:
|
|||
|
||||
# all tags
|
||||
def all(self) -> List[str]:
|
||||
return [t.tag for t in self.col.backend.all_tags()]
|
||||
return [t.name for t in self.col.backend.all_tags()]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
d = dict(self.__dict__)
|
||||
|
@ -34,7 +34,7 @@ class TagManager:
|
|||
|
||||
# # List of (tag, usn)
|
||||
def allItems(self) -> List[Tuple[str, int]]:
|
||||
return [(t.tag, t.usn) for t in self.col.backend.all_tags()]
|
||||
return [(t.name, t.usn) for t in self.col.backend.all_tags()]
|
||||
|
||||
# Registering and fetching tags
|
||||
#############################################################
|
||||
|
@ -63,11 +63,7 @@ class TagManager:
|
|||
lim = ""
|
||||
clear = True
|
||||
self.register(
|
||||
set(
|
||||
self.split(
|
||||
" ".join(self.col.db.list("select distinct tags from notes" + lim))
|
||||
)
|
||||
),
|
||||
self.col.backend.get_note_tags(nids),
|
||||
clear=clear,
|
||||
)
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ from anki.consts import *
|
|||
from anki.lang import without_unicode_isolation
|
||||
from anki.models import NoteType
|
||||
from anki.notes import Note
|
||||
from anki.rsbackend import ConcatSeparator, DeckTreeNode, InvalidInput
|
||||
from anki.rsbackend import ConcatSeparator, DeckTreeNode, InvalidInput, TagTreeNode
|
||||
from anki.stats import CardStats
|
||||
from anki.utils import htmlToTextLine, ids2str, isMac, isWin
|
||||
from aqt import AnkiQt, gui_hooks
|
||||
|
@ -1132,15 +1132,39 @@ QTableView {{ gridline-color: {grid} }}
|
|||
root.addChild(item)
|
||||
|
||||
def _userTagTree(self, root) -> None:
|
||||
assert self.col
|
||||
for t in self.col.tags.all():
|
||||
tree = self.col.backend.tag_tree()
|
||||
|
||||
def fillGroups(root, nodes: Sequence[TagTreeNode], head=""):
|
||||
for node in nodes:
|
||||
|
||||
def set_filter():
|
||||
full_name = head + node.name # pylint: disable=cell-var-from-loop
|
||||
return lambda: self.setFilter("tag", full_name)
|
||||
|
||||
def toggle_expand():
|
||||
tid = node.tag_id # pylint: disable=cell-var-from-loop
|
||||
|
||||
def toggle(_):
|
||||
tag = self.mw.col.backend.get_tag(tid)
|
||||
tag.config.browser_collapsed = not tag.config.browser_collapsed
|
||||
self.mw.col.backend.update_tag(tag)
|
||||
|
||||
return toggle
|
||||
|
||||
item = SidebarItem(
|
||||
t,
|
||||
node.name,
|
||||
":/icons/tag.svg",
|
||||
lambda t=t: self.setFilter("tag", t), # type: ignore
|
||||
set_filter(),
|
||||
toggle_expand(),
|
||||
not node.collapsed,
|
||||
item_type=SidebarItemType.TAG,
|
||||
id=node.tag_id,
|
||||
)
|
||||
root.addChild(item)
|
||||
newhead = head + node.name + "::"
|
||||
fillGroups(item, node.children, newhead)
|
||||
|
||||
fillGroups(root, tree.children)
|
||||
|
||||
def _decksTree(self, root) -> None:
|
||||
tree = self.col.decks.deck_tree()
|
||||
|
|
|
@ -75,6 +75,7 @@ class ResetReason(enum.Enum):
|
|||
EditorBridgeCmd = "editorBridgeCmd"
|
||||
BrowserSetDeck = "browserSetDeck"
|
||||
BrowserAddTags = "browserAddTags"
|
||||
BrowserRemoveTags = "browserRemoveTags"
|
||||
BrowserSuspend = "browserSuspend"
|
||||
BrowserReposition = "browserReposition"
|
||||
BrowserReschedule = "browserReschedule"
|
||||
|
|
|
@ -76,7 +76,10 @@ class NewSidebarTreeView(SidebarTreeViewBase):
|
|||
(tr(TR.ACTIONS_RENAME), self.rename_deck),
|
||||
(tr(TR.ACTIONS_DELETE), self.delete_deck),
|
||||
),
|
||||
SidebarItemType.TAG: ((tr(TR.ACTIONS_RENAME), self.rename_tag),),
|
||||
SidebarItemType.TAG: (
|
||||
(tr(TR.ACTIONS_RENAME), self.rename_tag),
|
||||
(tr(TR.ACTIONS_DELETE), self.remove_tag),
|
||||
),
|
||||
}
|
||||
|
||||
def onContextMenu(self, point: QPoint) -> None:
|
||||
|
@ -111,16 +114,37 @@ class NewSidebarTreeView(SidebarTreeViewBase):
|
|||
self.browser.maybeRefreshSidebar()
|
||||
self.mw.deckBrowser.refresh()
|
||||
|
||||
def remove_tag(self, item: "aqt.browser.SidebarItem") -> None:
|
||||
self.browser.editor.saveNow(lambda: self._remove_tag(item))
|
||||
|
||||
def _remove_tag(self, item: "aqt.browser.SidebarItem") -> None:
|
||||
old_name = self.mw.col.backend.get_tag(item.id).name
|
||||
|
||||
def do_remove():
|
||||
self.mw.col.backend.clear_tag(old_name)
|
||||
self.col.tags.rename_tag(old_name, "")
|
||||
|
||||
def on_done(fut: Future):
|
||||
self.mw.requireReset(reason=ResetReason.BrowserRemoveTags, context=self)
|
||||
self.browser.model.endReset()
|
||||
fut.result()
|
||||
self.browser.maybeRefreshSidebar()
|
||||
|
||||
self.mw.checkpoint(tr(TR.ACTIONS_REMOVE_TAG))
|
||||
self.browser.model.beginReset()
|
||||
self.mw.taskman.run_in_background(do_remove, on_done)
|
||||
|
||||
def rename_tag(self, item: "aqt.browser.SidebarItem") -> None:
|
||||
self.browser.editor.saveNow(lambda: self._rename_tag(item))
|
||||
|
||||
def _rename_tag(self, item: "aqt.browser.SidebarItem") -> None:
|
||||
old_name = item.name
|
||||
old_name = self.mw.col.backend.get_tag(item.id).name
|
||||
new_name = getOnlyText(tr(TR.ACTIONS_NEW_NAME), default=old_name)
|
||||
if new_name == old_name or not new_name:
|
||||
return
|
||||
|
||||
def do_rename():
|
||||
self.mw.col.backend.clear_tag(old_name)
|
||||
return self.col.tags.rename_tag(old_name, new_name)
|
||||
|
||||
def on_done(fut: Future):
|
||||
|
@ -132,7 +156,7 @@ class NewSidebarTreeView(SidebarTreeViewBase):
|
|||
showInfo(tr(TR.BROWSING_TAG_RENAME_WARNING_EMPTY))
|
||||
return
|
||||
|
||||
self.browser.clearUnusedTags()
|
||||
self.browser.maybeRefreshSidebar()
|
||||
|
||||
self.mw.checkpoint(tr(TR.ACTIONS_RENAME_TAG))
|
||||
self.browser.model.beginReset()
|
||||
|
|
|
@ -66,6 +66,10 @@ message DeckConfigID {
|
|||
int64 dcid = 1;
|
||||
}
|
||||
|
||||
message TagID {
|
||||
int64 tid = 1;
|
||||
}
|
||||
|
||||
// New style RPC definitions
|
||||
///////////////////////////////////////////////////////////
|
||||
|
||||
|
@ -140,8 +144,7 @@ service BackendService {
|
|||
|
||||
// deck config
|
||||
|
||||
rpc AddOrUpdateDeckConfigLegacy(AddOrUpdateDeckConfigLegacyIn)
|
||||
returns (DeckConfigID);
|
||||
rpc AddOrUpdateDeckConfigLegacy (AddOrUpdateDeckConfigLegacyIn) returns (DeckConfigID);
|
||||
rpc AllDeckConfigLegacy (Empty) returns (Json);
|
||||
rpc GetDeckConfigLegacy (DeckConfigID) returns (Json);
|
||||
rpc NewDeckConfigLegacy (Empty) returns (Json);
|
||||
|
@ -164,6 +167,7 @@ service BackendService {
|
|||
rpc RemoveNotes (RemoveNotesIn) returns (Empty);
|
||||
rpc AddNoteTags (AddNoteTagsIn) returns (UInt32);
|
||||
rpc UpdateNoteTags (UpdateNoteTagsIn) returns (UInt32);
|
||||
rpc GetNoteTags(GetNoteTagsIn) returns (GetNoteTagsOut);
|
||||
rpc ClozeNumbersInNote (Note) returns (ClozeNumbersInNoteOut);
|
||||
rpc AfterNoteUpdates (AfterNoteUpdatesIn) returns (Empty);
|
||||
rpc FieldNamesForNotes (FieldNamesForNotesIn) returns (FieldNamesForNotesOut);
|
||||
|
@ -208,6 +212,10 @@ service BackendService {
|
|||
|
||||
rpc RegisterTags (RegisterTagsIn) returns (Bool);
|
||||
rpc AllTags (Empty) returns (AllTagsOut);
|
||||
rpc GetTag (TagID) returns (Tag);
|
||||
rpc UpdateTag (Tag) returns (Bool);
|
||||
rpc ClearTag (String) returns (Bool);
|
||||
rpc TagTree (Empty) returns (TagTreeNode);
|
||||
|
||||
// config/preferences
|
||||
|
||||
|
@ -785,18 +793,32 @@ message RegisterTagsIn {
|
|||
}
|
||||
|
||||
message AllTagsOut {
|
||||
repeated TagUsnTuple tags = 1;
|
||||
repeated Tag tags = 1;
|
||||
}
|
||||
|
||||
message TagUsnTuple {
|
||||
string tag = 1;
|
||||
sint32 usn = 2;
|
||||
message TagConfig {
|
||||
bool browser_collapsed = 1;
|
||||
}
|
||||
|
||||
message Tag {
|
||||
int64 id = 1;
|
||||
string name = 2;
|
||||
sint32 usn = 3;
|
||||
TagConfig config = 4;
|
||||
}
|
||||
|
||||
message GetChangedTagsOut {
|
||||
repeated string tags = 1;
|
||||
}
|
||||
|
||||
message TagTreeNode {
|
||||
int64 tag_id = 1;
|
||||
string name = 2;
|
||||
repeated TagTreeNode children = 3;
|
||||
uint32 level = 5;
|
||||
bool collapsed = 4;
|
||||
}
|
||||
|
||||
message SetConfigJsonIn {
|
||||
string key = 1;
|
||||
bytes value_json = 2;
|
||||
|
@ -903,6 +925,14 @@ message UpdateNoteTagsIn {
|
|||
bool regex = 4;
|
||||
}
|
||||
|
||||
message GetNoteTagsIn {
|
||||
repeated int64 nids = 1;
|
||||
}
|
||||
|
||||
message GetNoteTagsOut {
|
||||
repeated string tags = 1;
|
||||
}
|
||||
|
||||
message CheckDatabaseOut {
|
||||
repeated string problems = 1;
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ use crate::{
|
|||
get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress,
|
||||
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage,
|
||||
},
|
||||
tags::TagID,
|
||||
template::RenderedNode,
|
||||
text::{extract_av_tags, strip_av_tags, AVTag},
|
||||
timestamp::TimestampSecs,
|
||||
|
@ -919,6 +920,14 @@ impl BackendService for Backend {
|
|||
})
|
||||
}
|
||||
|
||||
fn get_note_tags(&self, input: pb::GetNoteTagsIn) -> BackendResult<pb::GetNoteTagsOut> {
|
||||
self.with_col(|col| {
|
||||
Ok(pb::GetNoteTagsOut {
|
||||
tags: col.storage.get_note_tags(to_nids(input.nids))?,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn cloze_numbers_in_note(&self, note: pb::Note) -> BackendResult<pb::ClozeNumbersInNoteOut> {
|
||||
let mut set = HashSet::with_capacity(4);
|
||||
for field in ¬e.fields {
|
||||
|
@ -1286,14 +1295,28 @@ impl BackendService for Backend {
|
|||
//-------------------------------------------------------------------
|
||||
|
||||
fn all_tags(&self, _input: Empty) -> BackendResult<pb::AllTagsOut> {
|
||||
let tags = self.with_col(|col| col.storage.all_tags())?;
|
||||
let tags: Vec<_> = tags
|
||||
.into_iter()
|
||||
.map(|(tag, usn)| pb::TagUsnTuple { tag, usn: usn.0 })
|
||||
.collect();
|
||||
let tags: Vec<pb::Tag> =
|
||||
self.with_col(|col| Ok(col.all_tags()?.into_iter().map(|t| t.into()).collect()))?;
|
||||
Ok(pb::AllTagsOut { tags })
|
||||
}
|
||||
|
||||
fn get_tag(&self, input: pb::TagId) -> BackendResult<pb::Tag> {
|
||||
self.with_col(|col| {
|
||||
if let Some(tag) = col.storage.get_tag(TagID(input.tid))? {
|
||||
Ok(tag.into())
|
||||
} else {
|
||||
Err(AnkiError::NotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_tag(&self, tag: pb::Tag) -> BackendResult<pb::Bool> {
|
||||
self.with_col(|col| {
|
||||
col.update_tag(&tag.into())?;
|
||||
Ok(pb::Bool { val: true })
|
||||
})
|
||||
}
|
||||
|
||||
fn register_tags(&self, input: pb::RegisterTagsIn) -> BackendResult<pb::Bool> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
|
@ -1308,6 +1331,19 @@ impl BackendService for Backend {
|
|||
})
|
||||
}
|
||||
|
||||
fn clear_tag(&self, tag: pb::String) -> BackendResult<pb::Bool> {
|
||||
self.with_col(|col| {
|
||||
col.transact(None, |col| {
|
||||
col.clear_tag(tag.val.as_str())?;
|
||||
Ok(pb::Bool { val: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn tag_tree(&self, _input: Empty) -> Result<pb::TagTreeNode> {
|
||||
self.with_col(|col| col.tag_tree())
|
||||
}
|
||||
|
||||
// config/preferences
|
||||
//-------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -155,7 +155,22 @@ impl Note {
|
|||
pub(crate) fn replace_tags<T: Replacer>(&mut self, re: &Regex, mut repl: T) -> bool {
|
||||
let mut changed = false;
|
||||
for tag in &mut self.tags {
|
||||
if let Cow::Owned(rep) = re.replace_all(tag, repl.by_ref()) {
|
||||
if let Cow::Owned(rep) = re.replace_all(tag, |caps: ®ex::Captures| {
|
||||
if let Some(expanded) = repl.by_ref().no_expansion() {
|
||||
if expanded.trim().is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
// include "::" if it was matched
|
||||
format!(
|
||||
"{}{}",
|
||||
expanded,
|
||||
caps.get(caps.len() - 1).map_or("", |m| m.as_str())
|
||||
)
|
||||
}
|
||||
} else {
|
||||
tag.to_string()
|
||||
}
|
||||
}) {
|
||||
*tag = rep;
|
||||
changed = true;
|
||||
}
|
||||
|
|
|
@ -10,8 +10,9 @@ use crate::{
|
|||
notes::field_checksum,
|
||||
notetype::NoteTypeID,
|
||||
storage::ids_to_string,
|
||||
tags::human_tag_name_to_native,
|
||||
text::{
|
||||
escape_sql, is_glob, matches_glob, normalize_to_nfc, strip_html_preserving_media_filenames,
|
||||
is_glob, matches_glob, normalize_to_nfc, strip_html_preserving_media_filenames,
|
||||
to_custom_re, to_re, to_sql, to_text, without_combining,
|
||||
},
|
||||
timestamp::TimestampSecs,
|
||||
|
@ -194,19 +195,17 @@ impl SqlWriter<'_> {
|
|||
write!(self.sql, "false").unwrap();
|
||||
} else {
|
||||
match text {
|
||||
"none" => write!(self.sql, "n.tags = ''").unwrap(),
|
||||
"*" => write!(self.sql, "true").unwrap(),
|
||||
s => {
|
||||
if is_glob(s) {
|
||||
write!(self.sql, "n.tags regexp ?").unwrap();
|
||||
let re = &to_custom_re(s, r"\S");
|
||||
self.args.push(format!("(?i).* {} .*", re));
|
||||
} else if let Some(tag) = self.col.storage.preferred_tag_case(&to_text(s))? {
|
||||
write!(self.sql, "n.tags like ? escape '\\'").unwrap();
|
||||
self.args.push(format!("% {} %", escape_sql(&tag)));
|
||||
} else {
|
||||
write!(self.sql, "false").unwrap();
|
||||
"none" => {
|
||||
write!(self.sql, "n.tags = ''").unwrap();
|
||||
}
|
||||
"*" => {
|
||||
write!(self.sql, "true").unwrap();
|
||||
}
|
||||
text => {
|
||||
write!(self.sql, "n.tags regexp ?").unwrap();
|
||||
let re = &to_custom_re(text, r"\S");
|
||||
let native_name = human_tag_name_to_native(re);
|
||||
self.args.push(format!("(?i).* {}(\x1f| ).*", native_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -568,7 +567,6 @@ mod test {
|
|||
collection::{open_collection, Collection},
|
||||
i18n::I18n,
|
||||
log,
|
||||
types::Usn,
|
||||
};
|
||||
use std::{fs, path::PathBuf};
|
||||
use tempfile::tempdir;
|
||||
|
@ -678,26 +676,27 @@ mod test {
|
|||
// dupes
|
||||
assert_eq!(s(ctx, "dupe:123,test"), ("(n.id in ())".into(), vec![]));
|
||||
|
||||
// if registered, searches with canonical
|
||||
ctx.transact(None, |col| col.register_tag("One", Usn(-1)))
|
||||
.unwrap();
|
||||
// tags
|
||||
assert_eq!(
|
||||
s(ctx, r"tag:one"),
|
||||
(
|
||||
"(n.tags like ? escape '\\')".into(),
|
||||
vec![r"% One %".into()]
|
||||
"(n.tags regexp ?)".into(),
|
||||
vec!["(?i).* one(\x1f| ).*".into()]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
s(ctx, r"tag:foo::bar"),
|
||||
(
|
||||
"(n.tags regexp ?)".into(),
|
||||
vec!["(?i).* foo\x1fbar(\x1f| ).*".into()]
|
||||
)
|
||||
);
|
||||
|
||||
// unregistered tags without wildcards won't match
|
||||
assert_eq!(s(ctx, "tag:unknown"), ("(false)".into(), vec![]));
|
||||
|
||||
// wildcards force a regexp search
|
||||
assert_eq!(
|
||||
s(ctx, r"tag:o*n\*et%w%oth_re\_e"),
|
||||
(
|
||||
"(n.tags regexp ?)".into(),
|
||||
vec![r"(?i).* o\S*n\*et%w%oth\Sre_e .*".into()]
|
||||
vec!["(?i).* o\\S*n\\*et%w%oth\\Sre_e(\u{1f}| ).*".into()]
|
||||
)
|
||||
);
|
||||
assert_eq!(s(ctx, "tag:none"), ("(n.tags = '')".into(), vec![]));
|
||||
|
|
|
@ -5,7 +5,7 @@ use crate::{
|
|||
err::Result,
|
||||
notes::{Note, NoteID},
|
||||
notetype::NoteTypeID,
|
||||
tags::{join_tags, split_tags},
|
||||
tags::{human_tag_name_to_native, join_tags, native_tag_name_to_human, split_tags},
|
||||
timestamp::TimestampMillis,
|
||||
};
|
||||
use rusqlite::{params, Row, NO_PARAMS};
|
||||
|
@ -18,6 +18,11 @@ pub(crate) fn join_fields(fields: &[String]) -> String {
|
|||
fields.join("\x1f")
|
||||
}
|
||||
|
||||
fn native_tags_str(tags: &[String]) -> String {
|
||||
let s: Vec<_> = tags.iter().map(|t| human_tag_name_to_native(t)).collect();
|
||||
join_tags(&s)
|
||||
}
|
||||
|
||||
fn row_to_note(row: &Row) -> Result<Note> {
|
||||
Ok(Note {
|
||||
id: row.get(0)?,
|
||||
|
@ -26,7 +31,7 @@ fn row_to_note(row: &Row) -> Result<Note> {
|
|||
mtime: row.get(3)?,
|
||||
usn: row.get(4)?,
|
||||
tags: split_tags(row.get_raw(5).as_str()?)
|
||||
.map(Into::into)
|
||||
.map(|t| native_tag_name_to_human(t))
|
||||
.collect(),
|
||||
fields: split_fields(row.get_raw(6).as_str()?),
|
||||
sort_field: None,
|
||||
|
@ -52,7 +57,7 @@ impl super::SqliteStorage {
|
|||
note.notetype_id,
|
||||
note.mtime,
|
||||
note.usn,
|
||||
join_tags(¬e.tags),
|
||||
native_tags_str(¬e.tags),
|
||||
join_fields(¬e.fields()),
|
||||
note.sort_field.as_ref().unwrap(),
|
||||
note.checksum.unwrap(),
|
||||
|
@ -70,7 +75,7 @@ impl super::SqliteStorage {
|
|||
note.notetype_id,
|
||||
note.mtime,
|
||||
note.usn,
|
||||
join_tags(¬e.tags),
|
||||
native_tags_str(¬e.tags),
|
||||
join_fields(¬e.fields()),
|
||||
note.sort_field.as_ref().unwrap(),
|
||||
note.checksum.unwrap(),
|
||||
|
@ -88,7 +93,7 @@ impl super::SqliteStorage {
|
|||
note.notetype_id,
|
||||
note.mtime,
|
||||
note.usn,
|
||||
join_tags(¬e.tags),
|
||||
native_tags_str(¬e.tags),
|
||||
join_fields(¬e.fields()),
|
||||
note.sort_field.as_ref().unwrap(),
|
||||
note.checksum.unwrap(),
|
||||
|
@ -156,4 +161,70 @@ impl super::SqliteStorage {
|
|||
.query_row(NO_PARAMS, |r| r.get(0))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
// get distinct note tags in human form
|
||||
pub(crate) fn get_note_tags(&self, nids: Vec<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
|
||||
OR IGNORE INTO tags (tag, usn)
|
||||
VALUES (?, ?)
|
||||
insert
|
||||
or replace into tags (id, name, usn, config)
|
||||
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
|
||||
|
||||
use super::SqliteStorage;
|
||||
use crate::{err::Result, types::Usn};
|
||||
use rusqlite::{params, NO_PARAMS};
|
||||
use crate::{
|
||||
err::Result,
|
||||
tags::{human_tag_name_to_native, native_tag_name_to_human, Tag, TagConfig, TagID},
|
||||
timestamp::TimestampMillis,
|
||||
types::Usn,
|
||||
};
|
||||
use prost::Message;
|
||||
use rusqlite::{params, Row, NO_PARAMS};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn row_to_tag(row: &Row) -> Result<Tag> {
|
||||
let config = TagConfig::decode(row.get_raw(3).as_blob()?)?;
|
||||
Ok(Tag {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
usn: row.get(2)?,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
impl SqliteStorage {
|
||||
pub(crate) fn all_tags(&self) -> Result<Vec<(String, Usn)>> {
|
||||
pub(crate) fn all_tags(&self) -> Result<Vec<Tag>> {
|
||||
self.db
|
||||
.prepare_cached("select tag, usn from tags")?
|
||||
.query_and_then(NO_PARAMS, |row| -> Result<_> {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
.prepare_cached("select id, name, usn, config from tags")?
|
||||
.query_and_then(NO_PARAMS, row_to_tag)?
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all tags in human form, sorted by name
|
||||
pub(crate) fn all_tags_sorted(&self) -> Result<Vec<Tag>> {
|
||||
self.db
|
||||
.prepare_cached("select id, name, usn, config from tags order by name")?
|
||||
.query_and_then(NO_PARAMS, |row| {
|
||||
let mut tag = row_to_tag(row)?;
|
||||
tag.name = native_tag_name_to_human(&tag.name);
|
||||
Ok(tag)
|
||||
})?
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn register_tag(&self, tag: &str, usn: Usn) -> Result<()> {
|
||||
pub(crate) fn get_tag(&self, id: TagID) -> Result<Option<Tag>> {
|
||||
self.db
|
||||
.prepare_cached("select id, name, usn, config from tags where id = ?")?
|
||||
.query_and_then(&[id], |row| {
|
||||
let mut tag = row_to_tag(row)?;
|
||||
tag.name = native_tag_name_to_human(&tag.name);
|
||||
Ok(tag)
|
||||
})?
|
||||
.next()
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn alloc_id(&self) -> rusqlite::Result<TagID> {
|
||||
self.db
|
||||
.prepare_cached(include_str!("alloc_id.sql"))?
|
||||
.query_row(&[TimestampMillis::now()], |r| r.get(0))
|
||||
}
|
||||
|
||||
pub(crate) fn register_tag(&self, tag: &mut Tag) -> Result<()> {
|
||||
let mut config = vec![];
|
||||
tag.config.encode(&mut config)?;
|
||||
tag.id = self.alloc_id()?;
|
||||
self.update_tag(tag)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn update_tag(&self, tag: &Tag) -> Result<()> {
|
||||
let mut config = vec![];
|
||||
tag.config.encode(&mut config)?;
|
||||
self.db
|
||||
.prepare_cached(include_str!("add.sql"))?
|
||||
.execute(params![tag, usn])?;
|
||||
.execute(params![tag.id, tag.name, tag.usn, config])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn preferred_tag_case(&self, tag: &str) -> Result<Option<String>> {
|
||||
self.db
|
||||
.prepare_cached("select tag from tags where tag = ?")?
|
||||
.prepare_cached("select name from tags where name = ?")?
|
||||
.query_and_then(params![tag], |row| row.get(0))?
|
||||
.next()
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_tag(&self, tag: &str) -> Result<()> {
|
||||
self.db
|
||||
.prepare_cached("delete from tags where name regexp ?")?
|
||||
.execute(&[format!("^{}($|\x1f)", regex::escape(tag))])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_tags(&self) -> Result<()> {
|
||||
self.db.execute("delete from tags", NO_PARAMS)?;
|
||||
Ok(())
|
||||
|
@ -48,7 +110,7 @@ impl SqliteStorage {
|
|||
pub(crate) fn tags_pending_sync(&self, usn: Usn) -> Result<Vec<String>> {
|
||||
self.db
|
||||
.prepare_cached(&format!(
|
||||
"select tag from tags where {}",
|
||||
"select name from tags where {}",
|
||||
usn.pending_object_clause()
|
||||
))?
|
||||
.query_and_then(&[usn], |r| r.get(0).map_err(Into::into))?
|
||||
|
@ -58,7 +120,7 @@ impl SqliteStorage {
|
|||
pub(crate) fn update_tag_usns(&self, tags: &[String], new_usn: Usn) -> Result<()> {
|
||||
let mut stmt = self
|
||||
.db
|
||||
.prepare_cached("update tags set usn=? where tag=?")?;
|
||||
.prepare_cached("update tags set usn=? where name=?")?;
|
||||
for tag in tags {
|
||||
stmt.execute(params![new_usn, tag])?;
|
||||
}
|
||||
|
@ -75,8 +137,11 @@ impl SqliteStorage {
|
|||
serde_json::from_str(row.get_raw(0).as_str()?).map_err(Into::into);
|
||||
tags
|
||||
})?;
|
||||
let mut stmt = self
|
||||
.db
|
||||
.prepare_cached("insert or ignore into tags (tag, usn) values (?, ?)")?;
|
||||
for (tag, usn) in tags.into_iter() {
|
||||
self.register_tag(&tag, usn)?;
|
||||
stmt.execute(params![tag, usn])?;
|
||||
}
|
||||
self.db.execute_batch("update col set tags=''")?;
|
||||
|
||||
|
@ -85,11 +150,49 @@ impl SqliteStorage {
|
|||
|
||||
pub(super) fn downgrade_tags_from_schema14(&self) -> Result<()> {
|
||||
let alltags = self.all_tags()?;
|
||||
let tagsmap: HashMap<String, Usn> = alltags.into_iter().collect();
|
||||
let tagsmap: HashMap<String, Usn> = alltags.into_iter().map(|t| (t.name, t.usn)).collect();
|
||||
self.db.execute(
|
||||
"update col set tags=?",
|
||||
params![serde_json::to_string(&tagsmap)?],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn upgrade_tags_to_schema17(&self) -> Result<()> {
|
||||
let tags = self
|
||||
.db
|
||||
.prepare_cached("select tag, usn from tags")?
|
||||
.query_and_then(NO_PARAMS, |r| {
|
||||
Ok(Tag {
|
||||
name: r.get(0)?,
|
||||
usn: r.get(1)?,
|
||||
..Default::default()
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<Tag>>>()?;
|
||||
self.db.execute_batch(
|
||||
"
|
||||
drop table tags;
|
||||
create table tags (
|
||||
id integer primary key not null,
|
||||
name text not null collate unicase,
|
||||
usn integer not null,
|
||||
config blob not null
|
||||
);
|
||||
",
|
||||
)?;
|
||||
tags.into_iter().try_for_each(|mut tag| -> Result<()> {
|
||||
tag.name = human_tag_name_to_native(&tag.name);
|
||||
self.register_tag(&mut tag)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn downgrade_tags_from_schema17(&self) -> Result<()> {
|
||||
let tags = self.all_tags()?;
|
||||
self.clear_tags()?;
|
||||
tags.into_iter().try_for_each(|mut tag| -> Result<()> {
|
||||
tag.name = native_tag_name_to_human(&tag.name);
|
||||
self.register_tag(&mut tag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ pub(super) const SCHEMA_MIN_VERSION: u8 = 11;
|
|||
/// The version new files are initially created with.
|
||||
pub(super) const SCHEMA_STARTING_VERSION: u8 = 11;
|
||||
/// The maximum schema version we can open.
|
||||
pub(super) const SCHEMA_MAX_VERSION: u8 = 16;
|
||||
pub(super) const SCHEMA_MAX_VERSION: u8 = 17;
|
||||
|
||||
use super::SqliteStorage;
|
||||
use crate::err::Result;
|
||||
|
@ -31,6 +31,11 @@ impl SqliteStorage {
|
|||
self.upgrade_deck_conf_to_schema16(server)?;
|
||||
self.db.execute_batch("update col set ver = 16")?;
|
||||
}
|
||||
if ver < 17 {
|
||||
self.upgrade_tags_to_schema17()?;
|
||||
self.upgrade_notes_to_schema17()?;
|
||||
self.db.execute_batch("update col set ver = 17")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -42,7 +47,9 @@ impl SqliteStorage {
|
|||
self.downgrade_decks_from_schema15()?;
|
||||
self.downgrade_notetypes_from_schema15()?;
|
||||
self.downgrade_config_from_schema14()?;
|
||||
self.downgrade_tags_from_schema17()?;
|
||||
self.downgrade_tags_from_schema14()?;
|
||||
self.downgrade_notes_from_schema17()?;
|
||||
self.db
|
||||
.execute_batch(include_str!("schema11_downgrade.sql"))?;
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ use crate::{
|
|||
prelude::*,
|
||||
revlog::RevlogEntry,
|
||||
serde::{default_on_invalid, deserialize_int_from_number},
|
||||
tags::{join_tags, split_tags},
|
||||
tags::{join_tags, split_tags, Tag},
|
||||
version::sync_client_version,
|
||||
};
|
||||
use flate2::write::GzEncoder;
|
||||
|
@ -888,7 +888,11 @@ impl Collection {
|
|||
|
||||
fn merge_tags(&self, tags: Vec<String>, latest_usn: Usn) -> Result<()> {
|
||||
for tag in tags {
|
||||
self.register_tag(&tag, latest_usn)?;
|
||||
self.register_tag(Tag {
|
||||
name: tag,
|
||||
usn: latest_usn,
|
||||
..Default::default()
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1338,12 +1342,12 @@ mod test {
|
|||
col1.storage
|
||||
.all_tags()?
|
||||
.into_iter()
|
||||
.map(|t| t.0)
|
||||
.map(|t| t.name)
|
||||
.collect::<Vec<_>>(),
|
||||
col2.storage
|
||||
.all_tags()?
|
||||
.into_iter()
|
||||
.map(|t| t.0)
|
||||
.map(|t| t.name)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
|
|
|
@ -1,17 +1,64 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
|
||||
pub use crate::backend_proto::TagConfig;
|
||||
use crate::{
|
||||
backend_proto::{Tag as TagProto, TagTreeNode},
|
||||
collection::Collection,
|
||||
define_newtype,
|
||||
err::{AnkiError, Result},
|
||||
notes::{NoteID, TransformNoteOutput},
|
||||
text::to_re,
|
||||
{text::normalize_to_nfc, types::Usn},
|
||||
text::{normalize_to_nfc, to_re},
|
||||
types::Usn,
|
||||
};
|
||||
|
||||
use regex::{NoExpand, Regex, Replacer};
|
||||
use std::{borrow::Cow, collections::HashSet};
|
||||
use std::{borrow::Cow, collections::HashSet, iter::Peekable};
|
||||
use unicase::UniCase;
|
||||
|
||||
define_newtype!(TagID, i64);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub id: TagID,
|
||||
pub name: String,
|
||||
pub usn: Usn,
|
||||
pub config: TagConfig,
|
||||
}
|
||||
|
||||
impl Default for Tag {
|
||||
fn default() -> Self {
|
||||
Tag {
|
||||
id: TagID(0),
|
||||
name: "".to_string(),
|
||||
usn: Usn(-1),
|
||||
config: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Tag> for TagProto {
|
||||
fn from(t: Tag) -> Self {
|
||||
TagProto {
|
||||
id: t.id.0,
|
||||
name: t.name,
|
||||
usn: t.usn.0,
|
||||
config: Some(t.config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TagProto> for Tag {
|
||||
fn from(t: TagProto) -> Self {
|
||||
Tag {
|
||||
id: TagID(t.id),
|
||||
name: t.name,
|
||||
usn: Usn(t.usn),
|
||||
config: t.config.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_tags(tags: &str) -> impl Iterator<Item = &str> {
|
||||
tags.split(is_tag_separator).filter(|tag| !tag.is_empty())
|
||||
}
|
||||
|
@ -32,31 +79,117 @@ fn invalid_char_for_tag(c: char) -> bool {
|
|||
c.is_ascii_control() || is_tag_separator(c) || c == '"'
|
||||
}
|
||||
|
||||
fn normalized_tag_name_component(comp: &str) -> Cow<str> {
|
||||
let mut out = normalize_to_nfc(comp);
|
||||
if out.contains(invalid_char_for_tag) {
|
||||
out = out.replace(invalid_char_for_tag, "").into();
|
||||
}
|
||||
let trimmed = out.trim();
|
||||
if trimmed.is_empty() {
|
||||
"blank".to_string().into()
|
||||
} else if trimmed.len() != out.len() {
|
||||
trimmed.to_string().into()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn human_tag_name_to_native(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
for comp in name.split("::") {
|
||||
out.push_str(&normalized_tag_name_component(comp));
|
||||
out.push('\x1f');
|
||||
}
|
||||
out.trim_end_matches('\x1f').into()
|
||||
}
|
||||
|
||||
pub(crate) fn native_tag_name_to_human(name: &str) -> String {
|
||||
name.replace('\x1f', "::")
|
||||
}
|
||||
|
||||
fn immediate_parent_name(native_name: &str) -> Option<&str> {
|
||||
native_name.rsplitn(2, '\x1f').nth(1)
|
||||
}
|
||||
|
||||
fn tags_to_tree(tags: Vec<Tag>) -> TagTreeNode {
|
||||
let mut top = TagTreeNode::default();
|
||||
let mut it = tags.into_iter().peekable();
|
||||
add_child_nodes(&mut it, &mut top);
|
||||
|
||||
top
|
||||
}
|
||||
|
||||
fn add_child_nodes(tags: &mut Peekable<impl Iterator<Item = Tag>>, parent: &mut TagTreeNode) {
|
||||
while let Some(tag) = tags.peek() {
|
||||
let split_name: Vec<_> = tag.name.split("::").collect();
|
||||
match split_name.len() as u32 {
|
||||
l if l <= parent.level => {
|
||||
// next item is at a higher level
|
||||
return;
|
||||
}
|
||||
l if l == parent.level + 1 => {
|
||||
// next item is an immediate descendent of parent
|
||||
parent.children.push(TagTreeNode {
|
||||
tag_id: tag.id.0,
|
||||
name: (*split_name.last().unwrap()).into(),
|
||||
children: vec![],
|
||||
level: parent.level + 1,
|
||||
collapsed: tag.config.browser_collapsed,
|
||||
});
|
||||
tags.next();
|
||||
}
|
||||
_ => {
|
||||
// next item is at a lower level
|
||||
if let Some(last_child) = parent.children.last_mut() {
|
||||
add_child_nodes(tags, last_child)
|
||||
} else {
|
||||
// immediate parent is missing
|
||||
tags.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub fn all_tags(&self) -> Result<Vec<Tag>> {
|
||||
self.storage
|
||||
.all_tags()?
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
Ok(Tag {
|
||||
name: native_tag_name_to_human(&t.name),
|
||||
..t
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn tag_tree(&mut self) -> Result<TagTreeNode> {
|
||||
let tags = self.storage.all_tags_sorted()?;
|
||||
let tree = tags_to_tree(tags);
|
||||
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// Given a list of tags, fix case, ordering and duplicates.
|
||||
/// Returns true if any new tags were added.
|
||||
pub(crate) fn canonify_tags(&self, tags: Vec<String>, usn: Usn) -> Result<(Vec<String>, bool)> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut added = false;
|
||||
|
||||
let mut tags: Vec<_> = tags
|
||||
.iter()
|
||||
.flat_map(|t| split_tags(t))
|
||||
.map(|s| normalize_to_nfc(&s))
|
||||
.collect();
|
||||
|
||||
let mut tags: Vec<_> = tags.iter().flat_map(|t| split_tags(t)).collect();
|
||||
for tag in &mut tags {
|
||||
if tag.contains(invalid_char_for_tag) {
|
||||
*tag = tag.replace(invalid_char_for_tag, "").into();
|
||||
}
|
||||
if tag.trim().is_empty() {
|
||||
let t = self.register_tag(Tag {
|
||||
name: tag.to_string(),
|
||||
usn,
|
||||
..Default::default()
|
||||
})?;
|
||||
if t.0.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tag = self.register_tag(tag, usn)?;
|
||||
if matches!(tag, Cow::Borrowed(_)) {
|
||||
added = true;
|
||||
}
|
||||
seen.insert(UniCase::new(tag));
|
||||
added |= t.1;
|
||||
seen.insert(UniCase::new(t.0));
|
||||
}
|
||||
|
||||
// exit early if no non-empty tags
|
||||
|
@ -75,12 +208,40 @@ impl Collection {
|
|||
Ok((tags, added))
|
||||
}
|
||||
|
||||
pub(crate) fn register_tag<'a>(&self, tag: &'a str, usn: Usn) -> Result<Cow<'a, str>> {
|
||||
if let Some(preferred) = self.storage.preferred_tag_case(tag)? {
|
||||
Ok(preferred.into())
|
||||
fn create_missing_tag_parents(&self, mut native_name: &str, usn: Usn) -> Result<bool> {
|
||||
let mut added = false;
|
||||
while let Some(parent_name) = immediate_parent_name(native_name) {
|
||||
if self.storage.preferred_tag_case(&parent_name)?.is_none() {
|
||||
let mut t = Tag {
|
||||
name: parent_name.to_string(),
|
||||
usn,
|
||||
..Default::default()
|
||||
};
|
||||
self.storage.register_tag(&mut t)?;
|
||||
added = true;
|
||||
}
|
||||
native_name = parent_name;
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
pub(crate) fn register_tag<'a>(&self, tag: Tag) -> Result<(Cow<'a, str>, bool)> {
|
||||
let native_name = human_tag_name_to_native(&tag.name);
|
||||
if native_name.is_empty() {
|
||||
return Ok(("".into(), false));
|
||||
}
|
||||
let added_parents = self.create_missing_tag_parents(&native_name, tag.usn)?;
|
||||
if let Some(preferred) = self.storage.preferred_tag_case(&native_name)? {
|
||||
Ok((native_tag_name_to_human(&preferred).into(), added_parents))
|
||||
} else {
|
||||
self.storage.register_tag(tag, usn)?;
|
||||
Ok(tag.into())
|
||||
let mut t = Tag {
|
||||
name: native_name.clone(),
|
||||
usn: tag.usn,
|
||||
config: tag.config,
|
||||
..Default::default()
|
||||
};
|
||||
self.storage.register_tag(&mut t)?;
|
||||
Ok((native_tag_name_to_human(&native_name).into(), true))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -90,14 +251,31 @@ impl Collection {
|
|||
self.storage.clear_tags()?;
|
||||
}
|
||||
for tag in split_tags(tags) {
|
||||
let tag = self.register_tag(tag, usn)?;
|
||||
if matches!(tag, Cow::Borrowed(_)) {
|
||||
changed = true;
|
||||
}
|
||||
let t = self.register_tag(Tag {
|
||||
name: tag.to_string(),
|
||||
usn,
|
||||
..Default::default()
|
||||
})?;
|
||||
changed |= t.1;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub(crate) fn update_tag(&self, tag: &Tag) -> Result<()> {
|
||||
let native_name = human_tag_name_to_native(&tag.name);
|
||||
self.storage.update_tag(&Tag {
|
||||
id: tag.id,
|
||||
name: native_name,
|
||||
usn: tag.usn,
|
||||
config: tag.config.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn clear_tag(&self, tag: &str) -> Result<()> {
|
||||
let native_name = human_tag_name_to_native(tag);
|
||||
self.storage.clear_tag(&native_name)
|
||||
}
|
||||
|
||||
fn replace_tags_for_notes_inner<R: Replacer>(
|
||||
&mut self,
|
||||
nids: &[NoteID],
|
||||
|
@ -135,11 +313,10 @@ impl Collection {
|
|||
let tags = split_tags(tags)
|
||||
.map(|tag| {
|
||||
let tag = if regex { tag.into() } else { to_re(tag) };
|
||||
Regex::new(&format!("(?i)^{}$", tag))
|
||||
Regex::new(&format!("(?i)^{}(::.*)?", tag))
|
||||
.map_err(|_| AnkiError::invalid_input("invalid regex"))
|
||||
})
|
||||
.collect::<Result<Vec<Regex>>>()?;
|
||||
|
||||
if !regex {
|
||||
self.replace_tags_for_notes_inner(nids, &tags, NoExpand(repl))
|
||||
} else {
|
||||
|
@ -222,6 +399,11 @@ mod test {
|
|||
col.update_note(&mut note)?;
|
||||
assert_eq!(¬e.tags, &["one", "two"]);
|
||||
|
||||
// note.tags is in human form
|
||||
note.tags = vec!["foo::bar".into()];
|
||||
col.update_note(&mut note)?;
|
||||
assert_eq!(¬e.tags, &["foo::bar"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -263,6 +445,26 @@ mod test {
|
|||
let note = col.storage.get_note(note.id)?.unwrap();
|
||||
assert_eq!(¬e.tags, &["cee"]);
|
||||
|
||||
let mut note = col.storage.get_note(note.id)?.unwrap();
|
||||
note.tags = vec![
|
||||
"foo::bar".into(),
|
||||
"foo::bar::foo".into(),
|
||||
"bar::foo".into(),
|
||||
"bar::foo::bar".into(),
|
||||
];
|
||||
col.update_note(&mut note)?;
|
||||
col.replace_tags_for_notes(&[note.id], "bar::foo", "foo::bar", false)?;
|
||||
let note = col.storage.get_note(note.id)?.unwrap();
|
||||
assert_eq!(¬e.tags, &["foo::bar", "foo::bar::bar", "foo::bar::foo",]);
|
||||
|
||||
// missing tag parents are registered too when registering their children
|
||||
col.storage.clear_tags()?;
|
||||
let mut note = col.storage.get_note(note.id)?.unwrap();
|
||||
note.tags = vec!["animal::mammal::cat".into()];
|
||||
col.update_note(&mut note)?;
|
||||
let tags: Vec<String> = col.all_tags()?.into_iter().map(|t| t.name).collect();
|
||||
assert_eq!(&tags, &["animal::mammal", "animal", "animal::mammal::cat"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -323,14 +323,6 @@ pub(crate) fn to_text(txt: &str) -> Cow<str> {
|
|||
RE.replace_all(&txt, "$1")
|
||||
}
|
||||
|
||||
/// Escape characters special to SQL: \%_
|
||||
pub(crate) fn escape_sql(txt: &str) -> Cow<str> {
|
||||
lazy_static! {
|
||||
static ref RE: Regex = Regex::new(r"[\\%_]").unwrap();
|
||||
}
|
||||
RE.replace_all(&txt, r"\$0")
|
||||
}
|
||||
|
||||
/// Compare text with a possible glob, folding case.
|
||||
pub(crate) fn matches_glob(text: &str, search: &str) -> bool {
|
||||
if is_glob(search) {
|
||||
|
@ -399,7 +391,6 @@ mod test {
|
|||
assert_eq!(&to_custom_re("f_o*", r"\d"), r"f\do\d*");
|
||||
assert_eq!(&to_sql("%f_o*"), r"\%f_o%");
|
||||
assert_eq!(&to_text(r"\*\_*_"), "*_*_");
|
||||
assert_eq!(&escape_sql(r"1\2%3_"), r"1\\2\%3\_");
|
||||
assert!(is_glob(r"\\\\_"));
|
||||
assert!(!is_glob(r"\\\_"));
|
||||
assert!(matches_glob("foo*bar123", r"foo\*bar*"));
|
||||
|
|
Loading…
Reference in a new issue