diff --git a/frappe/core/doctype/tag/tag.py b/frappe/core/doctype/tag/tag.py index cc8e17e8d2..d45a14aa35 100644 --- a/frappe/core/doctype/tag/tag.py +++ b/frappe/core/doctype/tag/tag.py @@ -1,11 +1,133 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies and contributors +# Copyright (c) 2019, Frappe Technologies and contributors # For license information, please see license.txt from __future__ import unicode_literals import frappe +import json from frappe.model.document import Document +from frappe.utils.global_tags import update_global_tags +from frappe import _ class Tag(Document): - def validate(self): - self.tag_name = self.tag_name.title() + + def on_trash(self): + if self.count > 0: + frappe.throw(_("Cannot delete Tag {0} since it is linked to Documents.").format(frappe.bold(self.name))) + +def check_user_tags(dt): + "if the user does not have a tags column, then it creates one" + try: + frappe.db.sql("select `_user_tags` from `tab%s` limit 1" % dt) + except Exception as e: + if frappe.db.is_column_missing(e): + DocTags(dt).setup() + +@frappe.whitelist() +def add_tag(tag, dt, dn, color=None): + "adds a new tag to a record, and creates the Tag master" + DocTags(dt).add(dn, tag) + + return tag + +@frappe.whitelist() +def remove_tag(tag, dt, dn): + "removes tag from the record" + DocTags(dt).remove(dn, tag) + +@frappe.whitelist() +def get_tagged_docs(doctype, tag): + frappe.has_permission(doctype, throw=True) + + return frappe.db.sql("""SELECT name + FROM `tab{0}` + WHERE _user_tags LIKE '%{1}%'""".format(doctype, tag)) + +@frappe.whitelist() +def get_tags(doctype, txt, cat_tags): + tags = json.loads(cat_tags) + tag = frappe.get_list("Tag", filters=[["name", "like", "%{}%".format(txt)]]) + tags.extend([t.name for t in tag]) + + return sorted(filter(lambda t: t and txt.lower() in t.lower(), list(set(tags)))) + +class DocTags: + """Tags for a particular doctype""" + def __init__(self, dt): + self.dt = dt + + def get_tag_fields(self): + """returns tag_fields property""" + return frappe.db.get_value('DocType', self.dt, 'tag_fields') + + def get_tags(self, dn): + """returns tag for a particular item""" + return (frappe.db.get_value(self.dt, dn, '_user_tags', ignore=1) or '').strip() + + def add(self, dn, tag): + """add a new user tag""" + tl = self.get_tags(dn).split(',') + if not tag in tl: + tl.append(tag) + if not frappe.db.exists("Tag", tag): + frappe.get_doc({"doctype": "Tag", "name": tag, "count": 1}).insert(ignore_permissions=True) + else: + update_tag_count(tags=tag) + self.update(dn, tl) + + def remove(self, dn, tag): + """remove a user tag""" + tl = self.get_tags(dn).split(',') + update_tag_count(tags=tag, increment=False) + self.update(dn, filter(lambda x:x.lower()!=tag.lower(), tl)) + + def remove_all(self, dn): + """remove all user tags (call before delete)""" + update_tag_count(tags=tag, increment=False, dt=self.dt, dn=dn) + self.update(dn, []) + + def update(self, dn, tl): + """updates the _user_tag column in the table""" + + if not tl: + tags = '' + else: + tl = list(set(filter(lambda x: x, tl))) + tags = ',' + ','.join(tl) + try: + frappe.db.sql("update `tab%s` set _user_tags=%s where name=%s" % \ + (self.dt,'%s','%s'), (tags , dn)) + doc= frappe.get_doc(self.dt, dn) + update_global_tags(doc, tags) + except Exception as e: + if frappe.db.is_column_missing(e): + if not tags: + # no tags, nothing to do + return + + self.setup() + self.update(dn, tl) + else: raise + + def setup(self): + """adds the _user_tags column if not exists""" + from frappe.database.schema import add_column + add_column(self.dt, "_user_tags", "Data") + +def update_tag_count(tags, increment=True, dt=None, dn=None): + """ + Used to Increase or Decrease the count of documents linked with a certain tag + """ + _user_tags = [tags] + if tags == [] and dt and dn: + _user_tags = frappe.db.get_value(dt, dn, '_user_tags', ignore=1).split(",") + _user_tags = [t.strip() for t in _user_tags if t] + + for tag in _user_tags: + tag_count = frappe.db.get_value("Tag", tag, "count") + if increment: + tag_count+=1 + else: + tag_count-=1 + + frappe.db.set_value("Tag", tag, "count", tag_count) diff --git a/frappe/core/doctype/tag_category/tag_category.js b/frappe/core/doctype/tag_category/tag_category.js deleted file mode 100644 index e01dad063d..0000000000 --- a/frappe/core/doctype/tag_category/tag_category.js +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2016, Frappe Technologies and contributors -// For license information, please see license.txt -frappe.ui.form.on('Tag', { - tag_name:function(frm){ - for (var i = 0 ;i { if(me.initialized && !me.refreshing) { return frappe.call({ - method: 'frappe.desk.tags.add_tag', + method: "frappe.desk.doctype.tag.tag.add_tag", args: me.get_args(tag), callback: function(r) { var user_tags = me.user_tags ? me.user_tags.split(",") : []; user_tags.push(tag) me.user_tags = user_tags.join(","); me.on_change && me.on_change(me.user_tags); + frappe.global_tags.utils.set_tags(); } }); } @@ -49,13 +50,14 @@ frappe.ui.TagEditor = Class.extend({ onTagRemove: (tag) => { if(!me.refreshing) { return frappe.call({ - method: 'frappe.desk.tags.remove_tag', + method: "frappe.desk.doctype.tag.tag.remove_tag", args: me.get_args(tag), callback: function(r) { var user_tags = me.user_tags.split(","); user_tags.splice(user_tags.indexOf(tag), 1); me.user_tags = user_tags.join(","); me.on_change && me.on_change(me.user_tags); + frappe.global_tags.utils.set_tags(); } }); } @@ -82,7 +84,7 @@ frappe.ui.TagEditor = Class.extend({ $input.on("input", function(e) { var value = e.target.value; frappe.call({ - method:"frappe.desk.tags.get_tags", + method: "frappe.desk.doctype.tag.tag.get_tags", args:{ doctype: me.frm.doctype, txt: value.toLowerCase(), diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index 412117f49e..bb5f7edbe5 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -1,6 +1,7 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // MIT License. See license.txt frappe.provide('frappe.search'); +frappe.provide('frappe.global_tags'); frappe.search.AwesomeBar = Class.extend({ setup: function(element) { @@ -140,6 +141,8 @@ frappe.search.AwesomeBar = Class.extend({ __("document type..., e.g. customer")+'\ '+__("Search in a document type")+''+ __("text in document type")+'\ + '+__("Tags")+''+ + __("tag name..., e.g. #tag")+'\ '+__("Open a module or tool")+''+ __("module name...")+'\ '+__("Calculate")+''+ @@ -177,6 +180,9 @@ frappe.search.AwesomeBar = Class.extend({ frappe.search.utils.get_recent_pages(txt || ""), frappe.search.utils.get_executables(txt) ); + if (txt.charAt(0) === "#") { + options = frappe.global_tags.utils.get_tags(txt); + } var out = this.deduplicate(options); return out.sort(function(a, b) { return b.index - a.index; @@ -215,6 +221,11 @@ frappe.search.AwesomeBar = Class.extend({ make_global_search: function(txt) { var me = this; + + if (txt.charAt(0) === "#") { + return; + } + this.options.push({ label: __("Search for '{0}'", [txt.bold()]), value: __("Search for '{0}'", [txt]), diff --git a/frappe/public/js/frappe/ui/toolbar/global_tags.js b/frappe/public/js/frappe/ui/toolbar/global_tags.js new file mode 100644 index 0000000000..05bc5d8a0e --- /dev/null +++ b/frappe/public/js/frappe/ui/toolbar/global_tags.js @@ -0,0 +1,136 @@ +// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors +// MIT License. See license.txt + +frappe.provide("frappe.global_tags"); +frappe.provide("locals.global_tags"); + +frappe.global_tags.GlobalTagsDialog = class GlobalTags { + constructor(opts) { + $.extend(this, opts); + this.show(); + } + + show() { + if (!this.dialog) { + this.make_dialog(); + } + + $(this.dialog.body).html( + `
+ ${__("Loading")}... +
`); + + this.dialog.show(); + } + + make_dialog() { + let title = __("Tag {0}", ["#".concat(this.tag)]); + + this.dialog = new frappe.ui.Dialog({ + hide_on_page_refresh: true, + minimizable: true, + title: title + }); + + this.dialog.on_page_show = () => { + this.get_documents_for_tag() + .then(() => this.make_html()); + }; + } + + make_html() { + const results = this.results; + let html = ''; + + const linked_doctypes = Object.keys(results); + + if (linked_doctypes.length === 0) { + html = __("Not Linked to any record"); + } else { + html = linked_doctypes.map(doctype => { + const docs = results[doctype]; + return ` +
+ ${this.make_doc_head(doctype)} + ${docs.map(doc => this.make_doc_row(doc.name, doctype, doc.title)).join('')} +
+ `; + }).join(''); + } + + $(this.dialog.body).html(html); + } + + get_documents_for_tag() { + return new Promise((resolve) => { + frappe.call({ + method: "frappe.utils.global_tags.get_documents_for_tag", + args: { + tag: this.tag + }, + callback: (r) => { + this.results = r.message; + resolve(); + } + }); + }); + } + + make_doc_head(heading) { + return ` +
+
${__(heading)}
+
+ `; + } + + make_doc_row(docname, doctype, title) { + return `
+
+ +
+

${title}

+
+
+
`; + } +}; + +frappe.global_tags.utils = { + get_tags: function(txt) { + txt = txt.slice(1); + let out = []; + + for (let i in locals.global_tags) { + let tag = locals.global_tags[i]; + let level = frappe.search.utils.fuzzy_search(txt, tag); + if (level) { + out.push({ + type: "Tag", + label: __("#{0}", [frappe.search.utils.bolden_match_part(__(tag), txt)]), + value: __("#{0}", [__(tag)]), + index: 1 + level, + match: tag, + onclick: function() { + new frappe.global_tags.GlobalTagsDialog({"tag": tag}); + } + }); + } + } + + return out; + }, + + set_tags: function() { + frappe.call({ + method: "frappe.utils.global_tags.get_tags_list_for_awesomebar", + callback: function(r) { + if (r && r.message) { + locals.global_tags = $.extend([], r.message); + } + } + }); + } +} \ No newline at end of file diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index d30676ad53..4d267a452b 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -242,10 +242,6 @@ def update_global_search(doc): if doc.get(field.fieldname) and field.fieldtype not in frappe.model.table_fields: content.append(get_formatted_value(doc.get(field.fieldname), field)) - tags = (doc.get('_user_tags') or '').strip() - if tags: - content.extend(list(filter(lambda x: x, tags.split(',')))) - # Get children for child in doc.meta.get_table_fields(): for d in doc.get(child.fieldname): diff --git a/frappe/utils/global_tags.py b/frappe/utils/global_tags.py new file mode 100644 index 0000000000..d187824efd --- /dev/null +++ b/frappe/utils/global_tags.py @@ -0,0 +1,94 @@ +# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals + +import frappe + +def setup_global_tags_table(): + """ + Creates __global_search table + :return: + """ + frappe.db.create_global_tags_table() + +def reset(): + """ + Deletes all data in __global_tags + :return: + """ + frappe.db.sql('DELETE FROM `__global_tags`') + +def delete_tags_for_document(doc): + """ + Delete the __global_tags entry of a document that has + been deleted + :param doc: Deleted document + """ + frappe.db.sql("""DELETE + FROM `__global_search` + WHERE doctype = %s + AND name = %s""", (doc.doctype, doc.name)) + +def update_global_tags(doc, tags): + """ + Adds tags for documents + :param doc: Document to be added to global tags + """ + if frappe.local.conf.get('disable_global_tags') or not doc.get("_user_tags"): + return + + value = { + "doctype": doc.doctype, + "name": doc.name, + "title": (doc.get_title() or '')[:int(frappe.db.VARCHAR_LEN)], + "tags": tags.lower() + } + + frappe.db.multisql({ + 'mariadb': '''INSERT INTO `__global_tags` + (`doctype`, `name`, `title`, `tags`) + VALUES (%(doctype)s, %(name)s, %(title)s, %(tags)s) + ON DUPLICATE key UPDATE + `tags`=%(tags)s + ''', + 'postgres': '''INSERT INTO `__global_tags` + (`doctype`, `name`, `title`, `tags`) + VALUES (%(doctype)s, %(name)s, %(title)s, %(tags)s) + ON CONFLICT("doctype", "name") DO UPDATE SET + `tags`=%(tags)s + ''' + }, value) + +@frappe.whitelist() +def get_documents_for_tag(tag): + """ + Search for given text in __global_tags + :param tag: tag to be searched + """ + # remove hastag # from tag + results = {} + tag = frappe.db.escape('%{0}%'.format(tag.lower()), False) + + common_query = ''' + SELECT `doctype`, `name`, `title`, `tags` + FROM `__global_tags` + WHERE `tags` LIKE {tag} + ''' + + result = frappe.db.multisql({ + 'mariadb': common_query.format(tag=tag), + 'postgres': common_query.format(tag=tag) + }, as_dict=True) + + for res in result: + if res.doctype in results.keys(): + results[res.doctype].append(res) + else: + results[res.doctype] = [res] + + return results + +@frappe.whitelist() +def get_tags_list_for_awesomebar(): + return [t.name for t in frappe.get_list("Tag")] \ No newline at end of file