├── license.txt ├── pibicard ├── patches.txt ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── modules.txt ├── overrides │ ├── __init__.py │ └── contact.py ├── pibicard │ └── __init__.py ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── __init__.py ├── translations │ └── es.csv ├── public │ └── js │ │ ├── contact.js │ │ └── contact_list.js ├── hooks.py └── fixtures │ └── custom_field.json ├── .gitignore ├── requirements.txt ├── setup.py ├── MANIFEST.in └── README.md /license.txt: -------------------------------------------------------------------------------- 1 | License: MIT -------------------------------------------------------------------------------- /pibicard/patches.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibicard/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibicard/modules.txt: -------------------------------------------------------------------------------- 1 | pibiCARD -------------------------------------------------------------------------------- /pibicard/overrides/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibicard/pibicard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibicard/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibicard/templates/pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pibicard/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = '0.0.1' 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | pibicard/docs/current -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # frappe -- https://github.com/frappe/frappe is installed via 'bench init' 2 | frappe 3 | qrcode 4 | pillow 5 | six 6 | uuid 7 | vobject -------------------------------------------------------------------------------- /pibicard/config/desktop.py: -------------------------------------------------------------------------------- 1 | from frappe import _ 2 | 3 | def get_data(): 4 | return [ 5 | { 6 | "module_name": "pibiCARD", 7 | "color": "grey", 8 | "icon": "octicon octicon-file-directory", 9 | "type": "module", 10 | "label": _("pibiCARD") 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /pibicard/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/pibicard" 6 | # docs_base_url = "https://[org_name].github.io/pibicard" 7 | # headline = "App that does everything" 8 | # sub_heading = "Yes, you got that right the first time, everything" 9 | 10 | def get_context(context): 11 | context.brand_html = "pibiCARD" 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("requirements.txt") as f: 4 | install_requires = f.read().strip().split("\n") 5 | 6 | # get version from __version__ variable in pibicard/__init__.py 7 | from pibicard import __version__ as version 8 | 9 | setup( 10 | name="pibicard", 11 | version=version, 12 | description="cardDAV Frappe App", 13 | author="pibiCo", 14 | author_email="pibico.sl@gmail.com", 15 | packages=find_packages(), 16 | zip_safe=False, 17 | include_package_data=True, 18 | install_requires=install_requires 19 | ) 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include pibicard *.css 8 | recursive-include pibicard *.csv 9 | recursive-include pibicard *.html 10 | recursive-include pibicard *.ico 11 | recursive-include pibicard *.js 12 | recursive-include pibicard *.json 13 | recursive-include pibicard *.md 14 | recursive-include pibicard *.png 15 | recursive-include pibicard *.py 16 | recursive-include pibicard *.svg 17 | recursive-include pibicard *.txt 18 | recursive-exclude pibicard *.pyc -------------------------------------------------------------------------------- /pibicard/translations/es.csv: -------------------------------------------------------------------------------- 1 | CR Section,Sección vCard&QR 2 | QR Preview,Vista Previa QR 3 | QR Code,Código QR 4 | vCard Text,Texto vCard 5 | Import vCard,Importar vCard 6 | Importing Contacts,Importando Contactos 7 | Generate vCard Book,Generar Libro vCard 8 | No contacts selected,No se han seleccionado contactos 9 | Integrating vCard Book,Integrando Libro vCard 10 | vCard Create,Crear vCard 11 | vCard Download,Descargar vCard 12 | vCard QR Code,QR vCard 13 | First generate the vCard,Primero genera la vCard 14 | vCard Integration,Integración de vCard 15 | vCard generated successfully,vCard generada con éxito 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## pibiCARD 2 | 3 | cardDAV Frappe App 4 | 5 | #### License 6 | 7 | MIT 8 | 9 | ### Description 10 | 11 | The pibiCard custom app is designed to extend the functionality of the Frappe framework and ERPNext by integrating it with a CardDAV server, such as NextCloud, and enhancing core DocTypes like Contact if you provide the hooks, js and py files. This app enables you to synchronize contacts between your Frappe instance and a CardDAV server. 12 | 13 | ### Configuration 14 | 15 | To configure the pibiCard custom app, you need to include the following keys in your site_config.json file: 16 | 17 | carddav: The URL of the CardDAV server in the form of "https://domain.com/remote.php/dav/addressbooks/users/{username}/contacts/" 18 | carduser: The username for the CardDAV server 19 | cardkey: The API key or password for the CardDAV server 20 | 21 | Here's an example of how your site_config.json should look like: 22 | 23 | { 24 | "db_name": "your_database_name", 25 | "db_password": "your_database_password", 26 | 27 | ... 28 | 29 | "carddav": "https://domain.com/remote.php/dav/addressbooks/users/username/contacts/", 30 | "carduser": "your_carddav_username", 31 | "cardkey": "your_carddav_api_key_or_password" 32 | } 33 | 34 | Replace the placeholders with your actual values. 35 | 36 | ### Features 37 | #### CardDAV Integration 38 | 39 | Once the custom app is installed and configured, it will automatically synchronize contacts between your Frappe instance and the specified CardDAV server. You can create, update, and delete contacts in your Frappe instance, and the changes will be reflected in the CardDAV server, and vice versa. For that you need to use the vCard button on Contact Doctype Form. 40 | 41 | #### Conclusion 42 | 43 | The pibiCard custom app enhances the Frappe framework and ERPNext by providing seamless integration with a CardDAV server. -------------------------------------------------------------------------------- /pibicard/public/js/contact.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Contact', { 2 | onload: function (frm) { 3 | let ai_web_site = document.querySelector('.frappe-control[data-fieldname="ai_web_site"]'); 4 | let ai_notes = document.querySelector('.frappe-control[data-fieldname="ai_notes"]'); 5 | if (ai_web_site) { 6 | document.querySelector('.frappe-control[data-fieldname="cr_web_site"]').style.display = 'none' 7 | } 8 | if (ai_notes) { 9 | document.querySelector('.frappe-control[data-fieldname="cr_notes"]').style.display = 'none' 10 | } 11 | var template = ''; 12 | 13 | if (frm.doc.__islocal) { 14 | // QR Preview 15 | template = ''; 16 | frm.set_df_property('cr_qr_preview', 'options', frappe.render_template(template)); 17 | frm.refresh_field('cr_qr_preview'); 18 | } else { 19 | // QR Preview 20 | template = ''; 21 | frm.set_df_property('cr_qr_preview', 'options', frappe.render_template(template)); 22 | frm.refresh_field('cr_qr_preview'); 23 | } 24 | }, 25 | refresh: function (frm) { 26 | 27 | if (!frm.doc.__islocal) { 28 | frm.add_custom_button(__("vCard Create"), function () { 29 | frappe.call({ 30 | method: 'pibicard.overrides.contact.build_vcard', 31 | args: { 32 | contact_name: frm.doc.name 33 | }, 34 | callback: function (response) { 35 | if (response.message) { 36 | frm.set_value('cr_vcard_text', response.message); 37 | frappe.show_alert({ 38 | message: __('vCard generated successfully.'), 39 | indicator: 'green' 40 | }); 41 | } 42 | } 43 | }); 44 | }, __("vCard")); 45 | // 46 | if (frm.doc.cr_vcard_text) { 47 | frm.add_custom_button(__("vCard Download"), function () { 48 | // Create a Blob from the vCard text 49 | var vcardBlob = new Blob([frm.doc.cr_vcard_text], { type: "text/vcard;charset=utf-8" }); 50 | // Create a temporary download link 51 | var downloadLink = document.createElement('a'); 52 | downloadLink.href = window.URL.createObjectURL(vcardBlob); 53 | downloadLink.download = frm.doc.first_name + '_' + frm.doc.last_name + '.vcf'; 54 | // Append the link to the document, click it, and remove it 55 | document.body.appendChild(downloadLink); 56 | downloadLink.click(); 57 | document.body.removeChild(downloadLink); 58 | }, __("vCard")); 59 | 60 | frm.add_custom_button(__("vCard QR Code"), function () { 61 | var logo = null; 62 | if (frm.doc.cr_logo) { 63 | logo = frm.doc.cr_logo; 64 | } else { 65 | logo = null; 66 | } 67 | if (frm.doc.cr_vcard_text) { 68 | frappe.call({ 69 | method: 'pibicard.overrides.contact.get_qrcode', 70 | args: { 71 | input_data: frm.doc.cr_vcard_text, 72 | logo: logo 73 | }, 74 | callback: function (response) { 75 | if (response.message) { 76 | frm.set_value('cr_qr_code', response.message); 77 | frappe.show_alert({ 78 | message: __('QR Code generated successfully.'), 79 | indicator: 'green' 80 | }); 81 | var template = ''; 82 | frm.set_df_property('cr_qr_preview', 'options', frappe.render_template(template)); 83 | frm.refresh_field('cr_qr_preview'); 84 | 85 | frm.save(); 86 | } 87 | } 88 | }); 89 | } else { 90 | frappe.throw({ 91 | message: __('First generate the vCard'), 92 | indicator: 'red' 93 | }); 94 | } 95 | }, __("vCard")); 96 | // 97 | if (frm.doc.cr_vcard_text) { 98 | frm.add_custom_button(__("vCard Integration"), function () { 99 | const vcard_text = frm.doc.cr_vcard_text; 100 | 101 | frappe.call({ 102 | method: 'pibicard.overrides.contact.upload_vcard_to_carddav', 103 | args: { 104 | vcard_string: vcard_text 105 | }, 106 | callback: function(res) { 107 | if (res.message){ 108 | console.log(res.message.response); 109 | frappe.msgprint(`cardDAV server: ${res.message.response}`); 110 | } 111 | } 112 | }); 113 | 114 | }, __("vCard")); 115 | } 116 | } 117 | } 118 | } 119 | }); -------------------------------------------------------------------------------- /pibicard/public/js/contact_list.js: -------------------------------------------------------------------------------- 1 | frappe.listview_settings['Contact'] = { 2 | onload: function(listview) { 3 | listview.page.add_menu_item(__('Import vCard'), function() { 4 | // Create a file input element 5 | let fileInput = document.createElement('input'); 6 | fileInput.type = 'file'; 7 | fileInput.accept = '.vcf'; 8 | 9 | fileInput.onchange = function() { 10 | let file = fileInput.files[0]; 11 | // Use FileReader to read the file's content 12 | let reader = new FileReader(); 13 | reader.onload = function(e) { 14 | let vcfContent = e.target.result; 15 | // Call your server-side method 16 | frappe.realtime.on('vcf_upload_progress', function(data) { 17 | // Use the progress data sent from the server to display the progress 18 | console.log(data.progress, data.name); 19 | frappe.show_progress(__('Uploading/Synchronizing VCards...'), data.progress * 100, 100, data.name); 20 | }); 21 | // 22 | frappe.call({ 23 | method: 'pibicard.overrides.contact.create_contacts_from_vcf', 24 | args: { 25 | vcf_content: vcfContent 26 | }, 27 | callback: function(response) { 28 | try { 29 | if (response.exc) { 30 | // Error handling if the server method fails 31 | frappe.msgprint(__('Failed to import VCF file. Please make sure it is a well-formed vCard file.')); 32 | } else { 33 | // Refresh the list view 34 | listview.refresh(); 35 | } 36 | } finally { 37 | let isUploading = false; // Clear the flag 38 | } 39 | }, 40 | always: function() { 41 | frappe.hide_progress(); 42 | } 43 | }); 44 | }; 45 | reader.readAsText(file); 46 | }; 47 | // Trigger the file input dialog 48 | fileInput.click(); 49 | }); 50 | 51 | // Add a menu item for generating contact book 52 | listview.page.add_menu_item(__('Generate vCard Book'), function() { 53 | var selected_contacts = listview.get_checked_items(); 54 | 55 | if (selected_contacts.length === 0) { 56 | frappe.msgprint(__('No contacts selected.')); 57 | return; 58 | } 59 | 60 | // Create an array to store the vCards 61 | var vcards = []; 62 | 63 | // Iterate over selected contacts 64 | selected_contacts.forEach(function(contact) { 65 | // Call the server-side method to build vCard for each contact 66 | frappe.call({ 67 | method: 'pibicard.overrides.contact.build_vcard', 68 | args: { 69 | contact_name: contact.name 70 | }, 71 | callback: function(response) { 72 | if (response.message) { 73 | // Append the vCard to the array 74 | vcards.push(response.message); 75 | 76 | // Check if all vCards have been generated 77 | if (vcards.length === selected_contacts.length) { 78 | // Combine vCards into a single contact book file 79 | var contact_book = vcards.join('\n\n'); 80 | 81 | // Trigger the file download 82 | var blob = new Blob([contact_book], { type: 'text/vcard' }); 83 | var url = URL.createObjectURL(blob); 84 | var a = document.createElement('a'); 85 | a.href = url; 86 | a.download = 'contact_book.vcf'; 87 | a.click(); 88 | } 89 | } else if (response.exc) { 90 | // Error handling if the server method fails 91 | frappe.msgprint(__('Failed to generate vCard for contact {0}.', [contact.name])); 92 | } 93 | } 94 | }); 95 | }); 96 | }); 97 | 98 | // Add a menu item for integrating contact book 99 | listview.page.add_menu_item(__('Integrate Vcard Book'), function() { 100 | 101 | var selected_contacts = listview.get_checked_items(); 102 | if (selected_contacts.length === 0) { 103 | frappe.msgprint(__('No contacts selected.')); 104 | return; 105 | } 106 | 107 | var contact_names = selected_contacts.map(function(contact) { 108 | return contact.name; 109 | }); 110 | frappe.msgprint(__("This will last some time. Wait")); 111 | // 112 | frappe.call({ 113 | method: 'pibicard.overrides.contact.upload_vcards_to_carddav', 114 | args: { 115 | 'contact_names': JSON.stringify(contact_names) 116 | }, 117 | callback: function(response) { 118 | frappe.realtime.on('upload_vcards_progress', function(data) { 119 | // Use the progress data sent from the server to display the progress 120 | console.log(data.progress, data.name); 121 | frappe.show_progress(__('Uploading/Synchronizing VCards'), data.progress * 100, 100, data.name); 122 | }); 123 | }, 124 | always: function() { 125 | // Hide the progress bar when job is finished 126 | frappe.hide_progress(); 127 | } 128 | }); 129 | }); 130 | } 131 | } -------------------------------------------------------------------------------- /pibicard/hooks.py: -------------------------------------------------------------------------------- 1 | from . import __version__ as app_version 2 | 3 | app_name = "pibicard" 4 | app_title = "pibiCARD" 5 | app_publisher = "pibiCo" 6 | app_description = "cardDAV Frappe App" 7 | app_icon = "octicon octicon-file-directory" 8 | app_color = "grey" 9 | app_email = "pibico.sl@gmail.com" 10 | app_license = "MIT" 11 | 12 | # Includes in 13 | # ------------------ 14 | 15 | # include js, css files in header of desk.html 16 | # app_include_css = "/assets/pibicard/css/pibicard.css" 17 | # app_include_js = "/assets/pibicard/js/pibicard.js" 18 | 19 | # include js, css files in header of web template 20 | # web_include_css = "/assets/pibicard/css/pibicard.css" 21 | # web_include_js = "/assets/pibicard/js/pibicard.js" 22 | 23 | # include custom scss in every website theme (without file extension ".scss") 24 | # website_theme_scss = "pibicard/public/scss/website" 25 | 26 | # include js, css files in header of web form 27 | # webform_include_js = {"doctype": "public/js/doctype.js"} 28 | # webform_include_css = {"doctype": "public/css/doctype.css"} 29 | 30 | # include js in page 31 | # page_js = {"page" : "public/js/file.js"} 32 | 33 | # include js in doctype views 34 | # doctype_js = {"doctype" : "public/js/doctype.js"} 35 | doctype_js = { 36 | "Contact": [ 37 | "public/js/contact.js" 38 | ] 39 | } 40 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 41 | doctype_list_js = { 42 | "Contact": "public/js/contact_list.js" 43 | } 44 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 45 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 46 | 47 | # Home Pages 48 | # ---------- 49 | 50 | # application home page (will override Website Settings) 51 | # home_page = "login" 52 | 53 | # website user home page (by Role) 54 | # role_home_page = { 55 | # "Role": "home_page" 56 | # } 57 | 58 | # Generators 59 | # ---------- 60 | 61 | # automatically create page for each record of this doctype 62 | # website_generators = ["Web Page"] 63 | 64 | # Installation 65 | # ------------ 66 | 67 | # before_install = "pibicard.install.before_install" 68 | # after_install = "pibicard.install.after_install" 69 | 70 | # Uninstallation 71 | # ------------ 72 | 73 | # before_uninstall = "pibicard.uninstall.before_uninstall" 74 | # after_uninstall = "pibicard.uninstall.after_uninstall" 75 | 76 | # Desk Notifications 77 | # ------------------ 78 | # See frappe.core.notifications.get_notification_config 79 | 80 | # notification_config = "pibicard.notifications.get_notification_config" 81 | 82 | # Permissions 83 | # ----------- 84 | # Permissions evaluated in scripted ways 85 | 86 | # permission_query_conditions = { 87 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 88 | # } 89 | # 90 | # has_permission = { 91 | # "Event": "frappe.desk.doctype.event.event.has_permission", 92 | # } 93 | 94 | # DocType Class 95 | # --------------- 96 | # Override standard doctype classes 97 | 98 | # override_doctype_class = { 99 | # "ToDo": "custom_app.overrides.CustomToDo" 100 | # } 101 | override_doctype_class = { 102 | "Contact": "pibicard.overrides.contact.CustomContact", 103 | } 104 | 105 | # Document Events 106 | # --------------- 107 | # Hook on document methods and events 108 | 109 | # doc_events = { 110 | # "*": { 111 | # "on_update": "method", 112 | # "on_cancel": "method", 113 | # "on_trash": "method" 114 | # } 115 | # } 116 | 117 | # Scheduled Tasks 118 | # --------------- 119 | 120 | scheduler_events = { 121 | "all": [ 122 | #"pibicard.overrides.contact.schedule_synchronization", 123 | #"pibicard.overrides.contact.synchronize_carddav_contacts" 124 | ], 125 | # "daily": [ 126 | # "pibicard.tasks.daily" 127 | # ], 128 | # "hourly": [ 129 | # "pibicard.tasks.hourly" 130 | # ], 131 | # "weekly": [ 132 | # "pibicard.tasks.weekly" 133 | # ] 134 | # "monthly": [ 135 | # "pibicard.tasks.monthly" 136 | # ] 137 | } 138 | 139 | # Testing 140 | # ------- 141 | 142 | # before_tests = "pibicard.install.before_tests" 143 | 144 | # Overriding Methods 145 | # ------------------------------ 146 | # 147 | # override_whitelisted_methods = { 148 | # "frappe.desk.doctype.event.event.get_events": "pibicard.event.get_events" 149 | # } 150 | # 151 | # each overriding function accepts a `data` argument; 152 | # generated from the base implementation of the doctype dashboard, 153 | # along with any modifications made in other Frappe apps 154 | # override_doctype_dashboards = { 155 | # "Task": "pibicard.task.get_dashboard_data" 156 | # } 157 | 158 | # exempt linked doctypes from being automatically cancelled 159 | # 160 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 161 | 162 | # Request Events 163 | # ---------------- 164 | # before_request = ["pibicard.utils.before_request"] 165 | # after_request = ["pibicard.utils.after_request"] 166 | 167 | # Job Events 168 | # ---------- 169 | # before_job = ["pibicard.utils.before_job"] 170 | # after_job = ["pibicard.utils.after_job"] 171 | 172 | # User Data Protection 173 | # -------------------- 174 | 175 | user_data_fields = [ 176 | { 177 | "doctype": "{doctype_1}", 178 | "filter_by": "{filter_by}", 179 | "redact_fields": ["{field_1}", "{field_2}"], 180 | "partial": 1, 181 | }, 182 | { 183 | "doctype": "{doctype_2}", 184 | "filter_by": "{filter_by}", 185 | "partial": 1, 186 | }, 187 | { 188 | "doctype": "{doctype_3}", 189 | "strict": False, 190 | }, 191 | { 192 | "doctype": "{doctype_4}" 193 | } 194 | ] 195 | 196 | # Authentication and authorization 197 | # -------------------------------- 198 | 199 | # auth_hooks = [ 200 | # "pibicard.auth.validate" 201 | # ] 202 | 203 | fixtures = [ 204 | { 205 | "dt": "Custom Field", 206 | "filters": [["dt", "in", ("Contact")], ["name", "like", "%cr_%"]] 207 | } 208 | ] -------------------------------------------------------------------------------- /pibicard/fixtures/custom_field.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "allow_in_quick_entry": 0, 4 | "allow_on_submit": 0, 5 | "bold": 0, 6 | "collapsible": 0, 7 | "collapsible_depends_on": null, 8 | "columns": 0, 9 | "default": null, 10 | "depends_on": "", 11 | "description": null, 12 | "docstatus": 0, 13 | "doctype": "Custom Field", 14 | "dt": "Contact", 15 | "fetch_from": null, 16 | "fetch_if_empty": 0, 17 | "fieldname": "cr_notes", 18 | "fieldtype": "Small Text", 19 | "hidden": 0, 20 | "hide_border": 0, 21 | "hide_days": 0, 22 | "hide_seconds": 0, 23 | "ignore_user_permissions": 0, 24 | "ignore_xss_filter": 0, 25 | "in_global_search": 0, 26 | "in_list_view": 0, 27 | "in_preview": 0, 28 | "in_standard_filter": 0, 29 | "insert_after": "sync_with_google_contacts", 30 | "is_system_generated": 0, 31 | "is_virtual": 0, 32 | "label": "Notes", 33 | "length": 0, 34 | "link_filters": null, 35 | "mandatory_depends_on": null, 36 | "modified": "2023-08-23 22:17:39.694052", 37 | "module": null, 38 | "name": "Contact-cr_notes", 39 | "no_copy": 0, 40 | "non_negative": 0, 41 | "options": null, 42 | "permlevel": 0, 43 | "placeholder": null, 44 | "precision": "", 45 | "print_hide": 0, 46 | "print_hide_if_no_value": 0, 47 | "print_width": null, 48 | "read_only": 0, 49 | "read_only_depends_on": null, 50 | "report_hide": 0, 51 | "reqd": 0, 52 | "search_index": 0, 53 | "show_dashboard": 0, 54 | "sort_options": 0, 55 | "translatable": 0, 56 | "unique": 0, 57 | "width": null 58 | }, 59 | { 60 | "allow_in_quick_entry": 0, 61 | "allow_on_submit": 0, 62 | "bold": 0, 63 | "collapsible": 0, 64 | "collapsible_depends_on": null, 65 | "columns": 0, 66 | "default": null, 67 | "depends_on": "", 68 | "description": null, 69 | "docstatus": 0, 70 | "doctype": "Custom Field", 71 | "dt": "Contact", 72 | "fetch_from": null, 73 | "fetch_if_empty": 0, 74 | "fieldname": "cr_web_site", 75 | "fieldtype": "Data", 76 | "hidden": 0, 77 | "hide_border": 0, 78 | "hide_days": 0, 79 | "hide_seconds": 0, 80 | "ignore_user_permissions": 0, 81 | "ignore_xss_filter": 0, 82 | "in_global_search": 0, 83 | "in_list_view": 0, 84 | "in_preview": 0, 85 | "in_standard_filter": 0, 86 | "insert_after": "company_name", 87 | "is_system_generated": 0, 88 | "is_virtual": 0, 89 | "label": "Web Site", 90 | "length": 0, 91 | "link_filters": null, 92 | "mandatory_depends_on": null, 93 | "modified": "2023-08-23 22:17:51.353626", 94 | "module": null, 95 | "name": "Contact-cr_web_site", 96 | "no_copy": 0, 97 | "non_negative": 0, 98 | "options": null, 99 | "permlevel": 0, 100 | "placeholder": null, 101 | "precision": "", 102 | "print_hide": 0, 103 | "print_hide_if_no_value": 0, 104 | "print_width": null, 105 | "read_only": 0, 106 | "read_only_depends_on": null, 107 | "report_hide": 0, 108 | "reqd": 0, 109 | "search_index": 0, 110 | "show_dashboard": 0, 111 | "sort_options": 0, 112 | "translatable": 0, 113 | "unique": 0, 114 | "width": null 115 | }, 116 | { 117 | "allow_in_quick_entry": 0, 118 | "allow_on_submit": 0, 119 | "bold": 0, 120 | "collapsible": 0, 121 | "collapsible_depends_on": null, 122 | "columns": 0, 123 | "default": null, 124 | "depends_on": null, 125 | "description": null, 126 | "docstatus": 0, 127 | "doctype": "Custom Field", 128 | "dt": "Contact", 129 | "fetch_from": null, 130 | "fetch_if_empty": 0, 131 | "fieldname": "ai_ocr", 132 | "fieldtype": "Text", 133 | "hidden": 0, 134 | "hide_border": 0, 135 | "hide_days": 0, 136 | "hide_seconds": 0, 137 | "ignore_user_permissions": 0, 138 | "ignore_xss_filter": 0, 139 | "in_global_search": 0, 140 | "in_list_view": 0, 141 | "in_preview": 0, 142 | "in_standard_filter": 0, 143 | "insert_after": "ai_cb01", 144 | "is_system_generated": 0, 145 | "is_virtual": 0, 146 | "label": "OCR Extract", 147 | "length": 0, 148 | "link_filters": null, 149 | "mandatory_depends_on": null, 150 | "modified": "2024-12-08 16:10:40.233537", 151 | "module": "pibiAID", 152 | "name": "Contact-custom_ai_ocr_extract", 153 | "no_copy": 0, 154 | "non_negative": 0, 155 | "options": null, 156 | "permlevel": 0, 157 | "placeholder": null, 158 | "precision": "", 159 | "print_hide": 0, 160 | "print_hide_if_no_value": 0, 161 | "print_width": null, 162 | "read_only": 1, 163 | "read_only_depends_on": null, 164 | "report_hide": 0, 165 | "reqd": 0, 166 | "search_index": 0, 167 | "show_dashboard": 0, 168 | "sort_options": 0, 169 | "translatable": 0, 170 | "unique": 0, 171 | "width": null 172 | }, 173 | { 174 | "allow_in_quick_entry": 0, 175 | "allow_on_submit": 0, 176 | "bold": 0, 177 | "collapsible": 0, 178 | "collapsible_depends_on": null, 179 | "columns": 0, 180 | "default": null, 181 | "depends_on": null, 182 | "description": null, 183 | "docstatus": 0, 184 | "doctype": "Custom Field", 185 | "dt": "Contact", 186 | "fetch_from": null, 187 | "fetch_if_empty": 0, 188 | "fieldname": "cr_section", 189 | "fieldtype": "Section Break", 190 | "hidden": 0, 191 | "hide_border": 0, 192 | "hide_days": 0, 193 | "hide_seconds": 0, 194 | "ignore_user_permissions": 0, 195 | "ignore_xss_filter": 0, 196 | "in_global_search": 0, 197 | "in_list_view": 0, 198 | "in_preview": 0, 199 | "in_standard_filter": 0, 200 | "insert_after": "pulled_from_google_contacts", 201 | "is_system_generated": 0, 202 | "is_virtual": 0, 203 | "label": "CR Section", 204 | "length": 0, 205 | "link_filters": null, 206 | "mandatory_depends_on": null, 207 | "modified": "2023-07-29 23:54:53.277642", 208 | "module": null, 209 | "name": "Contact-cr_section", 210 | "no_copy": 0, 211 | "non_negative": 0, 212 | "options": null, 213 | "permlevel": 0, 214 | "placeholder": null, 215 | "precision": "", 216 | "print_hide": 0, 217 | "print_hide_if_no_value": 0, 218 | "print_width": null, 219 | "read_only": 0, 220 | "read_only_depends_on": null, 221 | "report_hide": 0, 222 | "reqd": 0, 223 | "search_index": 0, 224 | "show_dashboard": 0, 225 | "sort_options": 0, 226 | "translatable": 0, 227 | "unique": 0, 228 | "width": null 229 | }, 230 | { 231 | "allow_in_quick_entry": 0, 232 | "allow_on_submit": 0, 233 | "bold": 0, 234 | "collapsible": 0, 235 | "collapsible_depends_on": null, 236 | "columns": 0, 237 | "default": null, 238 | "depends_on": null, 239 | "description": null, 240 | "docstatus": 0, 241 | "doctype": "Custom Field", 242 | "dt": "Contact", 243 | "fetch_from": null, 244 | "fetch_if_empty": 0, 245 | "fieldname": "cr_logo", 246 | "fieldtype": "Attach Image", 247 | "hidden": 0, 248 | "hide_border": 0, 249 | "hide_days": 0, 250 | "hide_seconds": 0, 251 | "ignore_user_permissions": 0, 252 | "ignore_xss_filter": 0, 253 | "in_global_search": 0, 254 | "in_list_view": 0, 255 | "in_preview": 0, 256 | "in_standard_filter": 0, 257 | "insert_after": "cr_section", 258 | "is_system_generated": 0, 259 | "is_virtual": 0, 260 | "label": "CR Logo", 261 | "length": 0, 262 | "link_filters": null, 263 | "mandatory_depends_on": null, 264 | "modified": "2023-07-30 00:00:13.067807", 265 | "module": null, 266 | "name": "Contact-cr_logo", 267 | "no_copy": 0, 268 | "non_negative": 0, 269 | "options": null, 270 | "permlevel": 0, 271 | "placeholder": null, 272 | "precision": "", 273 | "print_hide": 0, 274 | "print_hide_if_no_value": 0, 275 | "print_width": null, 276 | "read_only": 0, 277 | "read_only_depends_on": null, 278 | "report_hide": 0, 279 | "reqd": 0, 280 | "search_index": 0, 281 | "show_dashboard": 0, 282 | "sort_options": 0, 283 | "translatable": 0, 284 | "unique": 0, 285 | "width": null 286 | }, 287 | { 288 | "allow_in_quick_entry": 0, 289 | "allow_on_submit": 0, 290 | "bold": 0, 291 | "collapsible": 0, 292 | "collapsible_depends_on": null, 293 | "columns": 0, 294 | "default": null, 295 | "depends_on": null, 296 | "description": null, 297 | "docstatus": 0, 298 | "doctype": "Custom Field", 299 | "dt": "Contact", 300 | "fetch_from": null, 301 | "fetch_if_empty": 0, 302 | "fieldname": "cr_qr_code", 303 | "fieldtype": "Long Text", 304 | "hidden": 1, 305 | "hide_border": 0, 306 | "hide_days": 0, 307 | "hide_seconds": 0, 308 | "ignore_user_permissions": 0, 309 | "ignore_xss_filter": 0, 310 | "in_global_search": 0, 311 | "in_list_view": 0, 312 | "in_preview": 0, 313 | "in_standard_filter": 0, 314 | "insert_after": "cr_logo", 315 | "is_system_generated": 0, 316 | "is_virtual": 0, 317 | "label": "QR Code", 318 | "length": 0, 319 | "link_filters": null, 320 | "mandatory_depends_on": null, 321 | "modified": "2023-07-30 00:02:16.959802", 322 | "module": null, 323 | "name": "Contact-cr_qr_code", 324 | "no_copy": 0, 325 | "non_negative": 0, 326 | "options": null, 327 | "permlevel": 0, 328 | "placeholder": null, 329 | "precision": "", 330 | "print_hide": 0, 331 | "print_hide_if_no_value": 0, 332 | "print_width": null, 333 | "read_only": 0, 334 | "read_only_depends_on": null, 335 | "report_hide": 0, 336 | "reqd": 0, 337 | "search_index": 0, 338 | "show_dashboard": 0, 339 | "sort_options": 0, 340 | "translatable": 0, 341 | "unique": 0, 342 | "width": null 343 | }, 344 | { 345 | "allow_in_quick_entry": 0, 346 | "allow_on_submit": 0, 347 | "bold": 0, 348 | "collapsible": 0, 349 | "collapsible_depends_on": null, 350 | "columns": 0, 351 | "default": null, 352 | "depends_on": null, 353 | "description": null, 354 | "docstatus": 0, 355 | "doctype": "Custom Field", 356 | "dt": "Contact", 357 | "fetch_from": null, 358 | "fetch_if_empty": 0, 359 | "fieldname": "cr_qr_preview", 360 | "fieldtype": "HTML", 361 | "hidden": 0, 362 | "hide_border": 0, 363 | "hide_days": 0, 364 | "hide_seconds": 0, 365 | "ignore_user_permissions": 0, 366 | "ignore_xss_filter": 0, 367 | "in_global_search": 0, 368 | "in_list_view": 0, 369 | "in_preview": 0, 370 | "in_standard_filter": 0, 371 | "insert_after": "cr_qr_code", 372 | "is_system_generated": 0, 373 | "is_virtual": 0, 374 | "label": "QR Preview", 375 | "length": 0, 376 | "link_filters": null, 377 | "mandatory_depends_on": null, 378 | "modified": "2023-07-30 00:03:35.178798", 379 | "module": null, 380 | "name": "Contact-cr_qr_preview", 381 | "no_copy": 0, 382 | "non_negative": 0, 383 | "options": null, 384 | "permlevel": 0, 385 | "placeholder": null, 386 | "precision": "", 387 | "print_hide": 0, 388 | "print_hide_if_no_value": 0, 389 | "print_width": null, 390 | "read_only": 1, 391 | "read_only_depends_on": null, 392 | "report_hide": 0, 393 | "reqd": 0, 394 | "search_index": 0, 395 | "show_dashboard": 0, 396 | "sort_options": 0, 397 | "translatable": 0, 398 | "unique": 0, 399 | "width": null 400 | }, 401 | { 402 | "allow_in_quick_entry": 0, 403 | "allow_on_submit": 0, 404 | "bold": 0, 405 | "collapsible": 0, 406 | "collapsible_depends_on": null, 407 | "columns": 0, 408 | "default": null, 409 | "depends_on": null, 410 | "description": null, 411 | "docstatus": 0, 412 | "doctype": "Custom Field", 413 | "dt": "Contact", 414 | "fetch_from": null, 415 | "fetch_if_empty": 0, 416 | "fieldname": "cr_cb01", 417 | "fieldtype": "Column Break", 418 | "hidden": 0, 419 | "hide_border": 0, 420 | "hide_days": 0, 421 | "hide_seconds": 0, 422 | "ignore_user_permissions": 0, 423 | "ignore_xss_filter": 0, 424 | "in_global_search": 0, 425 | "in_list_view": 0, 426 | "in_preview": 0, 427 | "in_standard_filter": 0, 428 | "insert_after": "cr_qr_preview", 429 | "is_system_generated": 0, 430 | "is_virtual": 0, 431 | "label": "", 432 | "length": 0, 433 | "link_filters": null, 434 | "mandatory_depends_on": null, 435 | "modified": "2023-07-30 00:13:14.145173", 436 | "module": null, 437 | "name": "Contact-cr_cb01", 438 | "no_copy": 0, 439 | "non_negative": 0, 440 | "options": null, 441 | "permlevel": 0, 442 | "placeholder": null, 443 | "precision": "", 444 | "print_hide": 0, 445 | "print_hide_if_no_value": 0, 446 | "print_width": null, 447 | "read_only": 0, 448 | "read_only_depends_on": null, 449 | "report_hide": 0, 450 | "reqd": 0, 451 | "search_index": 0, 452 | "show_dashboard": 0, 453 | "sort_options": 0, 454 | "translatable": 0, 455 | "unique": 0, 456 | "width": null 457 | }, 458 | { 459 | "allow_in_quick_entry": 0, 460 | "allow_on_submit": 0, 461 | "bold": 0, 462 | "collapsible": 0, 463 | "collapsible_depends_on": null, 464 | "columns": 0, 465 | "default": null, 466 | "depends_on": null, 467 | "description": null, 468 | "docstatus": 0, 469 | "doctype": "Custom Field", 470 | "dt": "Contact", 471 | "fetch_from": null, 472 | "fetch_if_empty": 0, 473 | "fieldname": "cr_vcard_text", 474 | "fieldtype": "Small Text", 475 | "hidden": 0, 476 | "hide_border": 0, 477 | "hide_days": 0, 478 | "hide_seconds": 0, 479 | "ignore_user_permissions": 0, 480 | "ignore_xss_filter": 0, 481 | "in_global_search": 0, 482 | "in_list_view": 0, 483 | "in_preview": 0, 484 | "in_standard_filter": 0, 485 | "insert_after": "cr_cb01", 486 | "is_system_generated": 0, 487 | "is_virtual": 0, 488 | "label": "vCard Text", 489 | "length": 0, 490 | "link_filters": null, 491 | "mandatory_depends_on": null, 492 | "modified": "2023-07-30 00:51:18.339133", 493 | "module": null, 494 | "name": "Contact-cr_vcard_text", 495 | "no_copy": 0, 496 | "non_negative": 0, 497 | "options": null, 498 | "permlevel": 0, 499 | "placeholder": null, 500 | "precision": "", 501 | "print_hide": 0, 502 | "print_hide_if_no_value": 0, 503 | "print_width": null, 504 | "read_only": 1, 505 | "read_only_depends_on": null, 506 | "report_hide": 0, 507 | "reqd": 0, 508 | "search_index": 0, 509 | "show_dashboard": 0, 510 | "sort_options": 0, 511 | "translatable": 0, 512 | "unique": 0, 513 | "width": null 514 | } 515 | ] -------------------------------------------------------------------------------- /pibicard/overrides/contact.py: -------------------------------------------------------------------------------- 1 | # -*- coding: latin-1 -*- 2 | # Copyright (c) 2023, pibiCo and contributors 3 | # For license information, please see license.txt 4 | 5 | import frappe 6 | from frappe import _ 7 | 8 | from frappe.contacts.doctype.contact.contact import Contact 9 | from frappe.utils.background_jobs import enqueue 10 | import os 11 | import uuid 12 | from frappe import _ 13 | from frappe.utils import get_files_path, get_url, random_string 14 | 15 | from PIL import Image 16 | import qrcode 17 | from qrcode.image.styledpil import StyledPilImage 18 | from qrcode.image.styles.moduledrawers import SquareModuleDrawer, GappedSquareModuleDrawer, HorizontalBarsDrawer, RoundedModuleDrawer 19 | from qrcode.image.styles.colormasks import RadialGradiantColorMask 20 | 21 | import base64 22 | from io import BytesIO 23 | 24 | import requests 25 | from requests.auth import HTTPBasicAuth 26 | import vobject 27 | 28 | import re 29 | import json 30 | import html 31 | 32 | from datetime import datetime 33 | 34 | # Fetch CardDAV details 35 | url = frappe.conf.get('carddav') 36 | username = frappe.conf.get('carduser') 37 | password = frappe.conf.get('cardkey') 38 | 39 | class CustomContact(Contact): 40 | """ 41 | Inherit contact and extend it. 42 | """ 43 | def after_insert(self): 44 | # Skip execution if the contact is being created from a VCF file 45 | if self.flags.get('from_vcf'): 46 | return 47 | 48 | @frappe.whitelist() 49 | def enqueue_upload_vcards_to_carddav(contact_names): 50 | # Convert the received JSON string back into a Python list 51 | contact_names = json.loads(contact_names) 52 | 53 | # Start a new background job and return its ID 54 | job = enqueue( 55 | upload_vcards_to_carddav, 56 | queue='long', 57 | timeout=14400, 58 | is_async=True, 59 | job_name="Upload vCard to CardDAV server", 60 | contact_names=contact_names) 61 | return job.id 62 | 63 | def upload_vcards_to_carddav(contact_names): 64 | total_contacts = len(contact_names) 65 | 66 | for i, contact_name in enumerate(contact_names, 1): 67 | contact = frappe.get_doc("Contact", contact_name) 68 | 69 | # If vcard_string is empty, generate it with build_vcard method 70 | if not contact.cr_vcard_text: 71 | contact.cr_vcard_text = build_vcard(contact_name) 72 | 73 | upload_vcard_to_carddav( 74 | contact.cr_vcard_text 75 | ) 76 | # Calculate progress percentage and publish realtime 77 | progress = (i / total_contacts) * 100 78 | frappe.publish_realtime('upload_vcards_progress', {'progress': progress, 'name': contact_name}, user=frappe.session.user) 79 | 80 | @frappe.whitelist() 81 | def build_vcard(contact_name): 82 | # With the data of a Frappe contact, build a vCard in version 3.0 83 | c = frappe.get_doc('Contact', contact_name) 84 | 85 | vcard = ['BEGIN:VCARD', 'VERSION:3.0'] 86 | txt = "PRODID:-//{}".format('pibifeed') 87 | 88 | vcard.append(txt) 89 | 90 | if not c.cr_vcard_text is None: 91 | existing = vobject.readOne(c.cr_vcard_text) 92 | uid = existing.uid.value 93 | else: 94 | uid = uuid.uuid4().hex 95 | 96 | txt = "UID:{}".format(uid) 97 | vcard.append(txt) 98 | 99 | txt = "N:{};{};{};{};".format(nn(c.last_name), nn(c.first_name), nn(c.middle_name), nn(c.salutation)) 100 | vcard.append(txt) 101 | 102 | txt = "FN:{}".format(c.name) 103 | vcard.append(txt) 104 | 105 | if c.designation: 106 | txt = 'TITLE:{}'.format(c.designation) 107 | vcard.append(txt) 108 | if c.company_name: 109 | txt = 'ORG:{}'.format(c.company_name) 110 | if c.department: 111 | txt += ';{}'.format(c.department) 112 | vcard.append(txt) 113 | 114 | if c.gender: 115 | txt = "X-GENDER:{}".format(c.gender) 116 | vcard.append(txt) 117 | 118 | if c.image and c.image.startswith('/files/'): 119 | urlphoto = frappe.utils.get_url() + c.image 120 | txt = "PHOTO;TYPE=JPEG;ENCODING=BASE64:{}".format(urlphoto) 121 | vcard.append(txt) 122 | 123 | if c.address: 124 | add = frappe.get_doc('Address', c.address) 125 | txt = 'ADR:;;{};{};{};{};{}'.format(nn(add.address_line1), nn(add.city), 126 | nn(add.state), nn(add.pincode), nn(add.country)) 127 | vcard.append(txt) 128 | 129 | if c.email_id: 130 | txt = 'EMAIL;TYPE=INTERNET:{}'.format(c.email_id) 131 | vcard.append(txt) 132 | 133 | if c.phone: 134 | txt = 'TEL;TYPE=VOICE,WORK:{}'.format(c.phone) 135 | vcard.append(txt) 136 | if c.mobile_no: 137 | txt = 'TEL;TYPE=VOICE,CELL:{}'.format(c.mobile_no) 138 | vcard.append(txt) 139 | 140 | # Add Notes and Website (URL) either in pibiAID or pibiCARD 141 | if hasattr(c, 'ai_notes') and c.ai_notes: 142 | txt = 'NOTE:{}'.format(c.ai_notes.replace('\n', ' ').replace('\r', '')) 143 | vcard.append(txt) 144 | if hasattr(c, 'cr_notes') and c.cr_notes: 145 | txt = 'NOTE:{}'.format(c.cr_notes.replace('\n', ' ').replace('\r', '')) 146 | vcard.append(txt) 147 | 148 | if hasattr(c, 'ai_web_site') and c.ai_web_site: 149 | txt = 'URL:{}'.format(c.ai_web_site) 150 | vcard.append(txt) 151 | if hasattr(c, 'cr_web_site') and c.cr_web_site: 152 | txt = 'URL:{}'.format(c.cr_web_site) 153 | vcard.append(txt) 154 | 155 | datetime = c.modified.strftime("%Y%m%dT%H%M%SZ") 156 | txt = 'REV:{}'.format(datetime) 157 | vcard.append(txt) 158 | 159 | vcard.append('END:VCARD') 160 | c.cr_vcard_text = '\n'.join(vcard) 161 | c.save() 162 | 163 | return '\n'.join(vcard) 164 | 165 | def nn(value): 166 | return value or '' 167 | 168 | @frappe.whitelist() 169 | def get_qrcode(input_data, logo): 170 | qr = qrcode.QRCode( 171 | version=1, 172 | box_size=6, 173 | border=1 174 | ) 175 | qr.add_data(input_data) 176 | qr.make(fit=True) 177 | path = frappe.utils.get_bench_path() 178 | site_name = frappe.utils.get_url().replace("http://","").replace("https://","") 179 | if ":" in site_name: 180 | pos = site_name.find(":") 181 | site_name = site_name[:pos] 182 | 183 | if logo: 184 | if not 'private' in logo: 185 | embedded_image_path = os.path.join(path, "sites", site_name, 'public', logo[1:]) 186 | else: 187 | embedded_image_path = os.path.join(path, "sites", site_name, logo[1:]) 188 | 189 | with Image.open(embedded_image_path) as embed_img: 190 | embedded = embed_img.copy() 191 | 192 | else: 193 | embedded = None 194 | 195 | if embedded: 196 | img = qr.make_image(image_factory=StyledPilImage, color_mask=RadialGradiantColorMask(back_color=(255, 255, 255), center_color=(70, 130, 180), edge_color=(34, 34, 34)), module_drawer=RoundedModuleDrawer(), eye_drawer=SquareModuleDrawer(), embeded_image=embedded) 197 | else: 198 | img = qr.make_image(image_factory=StyledPilImage, color_mask=RadialGradiantColorMask(back_color=(255, 255, 255), center_color=(70, 130, 180), edge_color=(34, 34, 34)), module_drawer=RoundedModuleDrawer(), eye_drawer=SquareModuleDrawer()) 199 | 200 | temp = BytesIO() 201 | img.save(temp, "PNG") 202 | temp.seek(0) 203 | b64 = base64.b64encode(temp.read()) 204 | return "data:image/png;base64,{0}".format(b64.decode("utf-8")) 205 | 206 | @frappe.whitelist() 207 | def get_site_config_values(keys): 208 | keys = keys.split(',') # Convert the comma-separated string to a list 209 | values = {} 210 | for key in keys: 211 | values[key] = frappe.conf.get(key) 212 | return values 213 | 214 | @frappe.whitelist() 215 | def upload_vcard_to_carddav(vcard_string): 216 | # Parse the vCard string into a vobject 217 | vcard = vobject.readOne(vcard_string) 218 | # Get the vCard UID 219 | uid = vcard.uid.value 220 | if '-' in uid: 221 | frappe.msgprint(_("vCards must be generated in Frappe to get synchronized")) 222 | frappe.msgprint(vcard_string) 223 | return 224 | 225 | vcard_url = f"{url}/{uid}.vcf" 226 | # Check if vCard exists 227 | response = requests.get(vcard_url, auth=HTTPBasicAuth(username, password)) 228 | 229 | if response.status_code in [200, 404]: 230 | response = requests.put(vcard_url, data=vcard_string, headers={"Content-Type": "text/vcard"}, auth=HTTPBasicAuth(username, password)) 231 | frappe.msgprint(f"vCard for {uid} created/updated successfully {response.status_code}") 232 | else: 233 | frappe.msgprint(f"vCard for {uid}: {response.status_code} {response.text}") 234 | 235 | @frappe.whitelist() 236 | def create_contacts_from_vcf(vcf_content): 237 | contact_names = [] 238 | try: 239 | total_contacts = sum(1 for _ in vobject.readComponents(vcf_content)) 240 | 241 | for i, vcard in enumerate(vobject.readComponents(vcf_content)): 242 | try: 243 | # Extract names 244 | first_name = "" 245 | last_name = "" 246 | if hasattr(vcard, "n"): 247 | first_name = str(vcard.n.value.given) if hasattr(vcard.n.value, "given") else "" 248 | last_name = str(vcard.n.value.family) if hasattr(vcard.n.value, "family") else "" 249 | elif hasattr(vcard, "fn"): 250 | first_name = str(vcard.fn.value) 251 | else: 252 | first_name = "Contact" 253 | last_name = "No Name" 254 | 255 | # Get full name 256 | full_name = str(vcard.fn.value) if hasattr(vcard, "fn") else f"{first_name} {last_name}".strip() 257 | 258 | # Handle organization safely 259 | org_value = "" 260 | dept_value = "" 261 | if hasattr(vcard, "org"): 262 | if isinstance(vcard.org.value, list): 263 | org_value = str(vcard.org.value[0]) if vcard.org.value else "" 264 | dept_value = str(vcard.org.value[1]) if len(vcard.org.value) > 1 else "" 265 | else: 266 | org_value = str(vcard.org.value) 267 | 268 | # Handle designation 269 | designation = str(vcard.title.value) if hasattr(vcard, "title") else "" 270 | 271 | # Create new contact 272 | new_contact = frappe.get_doc({ 273 | 'doctype': 'Contact', 274 | 'first_name': first_name, 275 | 'last_name': last_name, 276 | 'full_name': full_name, 277 | 'company_name': org_value, 278 | 'department': dept_value, 279 | 'designation': designation, 280 | 'phone_nos': [], 281 | 'email_ids': [] 282 | }) 283 | 284 | # Handle email 285 | if hasattr(vcard, "email"): 286 | email_value = str(vcard.email.value) 287 | new_contact.append('email_ids', { 288 | 'doctype': 'Contact Email', 289 | 'email_id': email_value, 290 | 'is_primary': 1 291 | }) 292 | 293 | # Handle phone numbers 294 | if hasattr(vcard, "tel"): 295 | for tel in vcard.tel_list: 296 | phone_number = re.sub(r'\D', '', str(tel.value)) if tel.value else "" 297 | if phone_number: 298 | new_contact.append('phone_nos', { 299 | 'doctype': 'Contact Phone', 300 | 'phone': phone_number 301 | }) 302 | 303 | # Save original vCard text 304 | vcf_text = [] 305 | vcf_text.append("BEGIN:VCARD") 306 | vcf_text.append("VERSION:3.0") 307 | vcf_text.append(f"N:{last_name};{first_name};;;") 308 | vcf_text.append(f"FN:{full_name}") 309 | if org_value: 310 | vcf_text.append(f"ORG:{org_value}") 311 | if designation: 312 | vcf_text.append(f"TITLE:{designation}") 313 | if hasattr(vcard, "email"): 314 | vcf_text.append(f"EMAIL;TYPE=INTERNET:{str(vcard.email.value)}") 315 | if hasattr(vcard, "tel"): 316 | for tel in vcard.tel_list: 317 | vcf_text.append(f"TEL:{str(tel.value)}") 318 | vcf_text.append("END:VCARD") 319 | 320 | new_contact.cr_vcard_text = "\n".join(vcf_text) 321 | 322 | # Check for existing contact 323 | if not frappe.db.exists("Contact", {"full_name": full_name}): 324 | new_contact.flags.from_vcf = True 325 | new_contact.insert(ignore_permissions=True) 326 | contact_names.append(new_contact.name) 327 | 328 | # Update progress 329 | frappe.publish_realtime("vcf_upload_progress", 330 | {"progress": (i + 1) / total_contacts * 100, 331 | "name": full_name}, 332 | user=frappe.session.user) 333 | 334 | except Exception as e: 335 | frappe.log_error(message=f"Contact: {full_name}", title="VCF Processing Error") 336 | continue 337 | 338 | frappe.db.commit() 339 | return contact_names 340 | 341 | except Exception as e: 342 | frappe.log_error(message=str(e)[:100], title="VCF Import Error") 343 | return [] 344 | 345 | def update_contact_from_vcard(contact, vcard): 346 | """ 347 | Update Frappe Contact based on provided vCard. 348 | 349 | Args: 350 | - contact (dict): Existing Frappe Contact to be updated. 351 | - vcard (vobject): vCard object containing contact details. 352 | """ 353 | 354 | # Extracting attributes from vCard 355 | first_name = vcard.n.value.given if hasattr(vcard.n.value, "given") else "" 356 | last_name = vcard.n.value.family if hasattr(vcard.n.value, "family") else "" 357 | note_value = vcard.note.value if hasattr(vcard, "note") else None 358 | url_value = vcard.url.value if hasattr(vcard, "url") else None 359 | 360 | # Fetch the existing Frappe Contact 361 | contact_doc = frappe.get_doc("Contact", contact.name) 362 | 363 | # Update the Frappe Contact's attributes 364 | contact_doc.first_name = first_name 365 | contact_doc.last_name = last_name 366 | 367 | # Set custom fields for URL and NOTE if they exist 368 | if url_value and hasattr(contact_doc, 'ai_web_site'): 369 | contact_doc.ai_web_site = url_value 370 | elif url_value and hasattr(contact_doc, 'cr_web_site'): # Only if ai_web_site wasn't set 371 | contact_doc.cr_web_site = url_value 372 | 373 | if note_value and hasattr(contact_doc, 'ai_notes'): 374 | contact_doc.ai_notes = note_value 375 | elif note_value and hasattr(contact_doc, 'cr_notes'): # Only if ai_notes wasn't set 376 | contact_doc.cr_notes = note_value 377 | 378 | email_value = vcard.email.value if hasattr(vcard, "email") else None 379 | 380 | # Here, you might want logic to update email or add if not present 381 | # Simplifying for this example: 382 | if email_value: 383 | contact_doc.set("email_ids", [{ 384 | 'doctype': 'Contact Email', 385 | 'email_id': email_value, 386 | 'is_primary': 1 387 | }]) 388 | 389 | # Handling for phone numbers, similar logic might be applied to update existing numbers or add new ones 390 | if hasattr(vcard, "tel"): 391 | for tel in vcard.tel_list: 392 | phone_number = tel.value or "" 393 | contact_doc.append('phone_nos', { 394 | 'doctype': 'Contact Phone', 395 | 'phone': phone_number 396 | }) 397 | else: 398 | for key in vcard.contents: 399 | if key.startswith('item') and key.endswith('.TEL'): 400 | phone_number = vcard.contents[key][0].value or "" 401 | contact_doc.append('phone_nos', { 402 | 'doctype': 'Contact Phone', 403 | 'phone': phone_number 404 | }) 405 | 406 | # Generate the full_name 407 | full_name = vcard.fn.value or f"{first_name} {last_name}".strip() 408 | contact_doc.full_name = full_name 409 | vcard_text_content = vcard.serialize() 410 | contact_doc.cr_vcard_text = vcard_text_content 411 | 412 | # Save the updates 413 | contact_doc.save() 414 | 415 | # Return updated contact (optional) 416 | return contact_doc 417 | 418 | def preprocess_vcard(vcard_string): 419 | # Split vCard string into lines 420 | lines = vcard_string.split("\n") 421 | # Remove the unwanted line 422 | lines = [line for line in lines if "_$!!$_" not in line] 423 | # Join the lines back to a string 424 | vcard_string = "\n".join(lines) 425 | 426 | return vcard_string 427 | 428 | def synchronize_carddav_contacts(): 429 | """Synchronize contacts from CardDAV server to Frappe.""" 430 | # Fetch all vCards from CardDAV server 431 | all_vcards = fetch_vcards_from_carddav(url, username, password) 432 | 433 | for i, vcard_string in enumerate(all_vcards, 1): 434 | # Preprocess vCard string 435 | vcard_string = preprocess_vcard(vcard_string) 436 | vcard = vobject.readOne(vcard_string) 437 | #print(vcard.serialize()) 438 | uid = vcard.uid.value if hasattr(vcard, 'uid') else None 439 | if not uid: 440 | continue # If no UID, skip to the next vCard 441 | 442 | # Check if contact exists in Frappe 443 | contact_exists = frappe.db.exists("Contact", {"cr_vcard_text": ["LIKE", "%UID:" + uid + "%"]}) 444 | 445 | if not contact_exists: 446 | #print("Creating") 447 | create_contacts_from_vcf(vcard_string) 448 | continue 449 | 450 | # If contact exists, fetch it 451 | contact = frappe.get_doc("Contact", contact_exists) 452 | 453 | # Compare the modification time 454 | if '-' in vcard.rev.value: 455 | carddav_mod_time = datetime.strptime(vcard.rev.value, "%Y-%m-%dT%H:%M:%SZ") 456 | else: 457 | carddav_mod_time = datetime.strptime(vcard.rev.value, "%Y%m%dT%H%M%SZ") 458 | 459 | frappe_mod_time = contact.modified 460 | # If CardDAV contact is newer, update the Frappe contact 461 | gap = 60 # sec is a gap between time in CardDAV Server and Frappe Server 462 | if carddav_mod_time.timestamp() + gap > frappe_mod_time.timestamp(): 463 | #print("Updating") 464 | update_contact_from_vcard(contact, vcard) 465 | 466 | def fetch_vcards_from_carddav(url, username, password): 467 | headers={ 468 | 'Depth': '1', 469 | 'Content-Type': 'text/xml; charset=UTF-8', 470 | 'User-Agent': 'Python CardDAV Client' 471 | } 472 | xml_body = """ 473 | 474 | 475 | 476 | 477 | 478 | """ 479 | response = requests.request( 480 | 'PROPFIND', 481 | url, 482 | headers=headers, 483 | data=xml_body, 484 | auth=HTTPBasicAuth(username, password) 485 | ) 486 | if response.status_code != 207: 487 | raise Exception(f"Failed to fetch contacts. Response code: {response.status_code}") 488 | 489 | # Decode HTML entities from the response text 490 | decoded_response = html.unescape(response.text) 491 | 492 | # Extract vCards from the decoded response 493 | vcards = re.findall(r'BEGIN:VCARD.*?END:VCARD', decoded_response, re.DOTALL) 494 | return vcards 495 | 496 | @frappe.whitelist() 497 | def schedule_synchronization(): 498 | enqueue( 499 | synchronize_carddav_contacts, 500 | queue='short', 501 | timeout=300, 502 | is_async=True, 503 | job_name="Synchronize CardDAV contacts" 504 | ) 505 | --------------------------------------------------------------------------------