├── 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 |
--------------------------------------------------------------------------------