chore: merge develop
This commit is contained in:
commit
0650293a18
84 changed files with 1941 additions and 1475 deletions
|
|
@ -19,7 +19,7 @@ from frappe.desk.doctype.form_tour.form_tour import get_onboarding_ui_tours
|
|||
from frappe.desk.doctype.route_history.route_history import frequently_visited_links
|
||||
from frappe.desk.form.load import get_meta_bundle
|
||||
from frappe.email.inbox import get_email_accounts
|
||||
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
|
||||
from frappe.integrations.frappe_providers.frappecloud_billing import current_site_info, is_fc_site
|
||||
from frappe.model.base_document import get_controller
|
||||
from frappe.permissions import has_permission
|
||||
from frappe.query_builder import DocType
|
||||
|
|
@ -125,6 +125,8 @@ def get_bootinfo():
|
|||
bootinfo.setup_wizard_completed_apps = get_setup_wizard_completed_apps() or []
|
||||
bootinfo.desktop_icon_urls = get_desktop_icon_urls()
|
||||
bootinfo.desktop_icon_style = get_icon_style() or "Subtle"
|
||||
if bootinfo.is_fc_site:
|
||||
bootinfo.site_info = current_site_info()
|
||||
return bootinfo
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# License: MIT. See LICENSE
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from rq.command import send_stop_job_command
|
||||
from rq.exceptions import InvalidJobOperation
|
||||
|
|
@ -102,7 +103,7 @@ class DataImport(Document):
|
|||
self.payload_count = len(payloads)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_preview_from_template(self, import_file=None, google_sheets_url=None):
|
||||
def get_preview_from_template(self, import_file: str | None = None, google_sheets_url: str | None = None):
|
||||
if import_file:
|
||||
self.import_file = import_file
|
||||
self.set_delimiters_flag()
|
||||
|
|
@ -203,7 +204,13 @@ def start_import(data_import):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_template(doctype, export_fields=None, export_records=None, export_filters=None, file_type="CSV"):
|
||||
def download_template(
|
||||
doctype: str,
|
||||
export_fields: str | dict[str, list[str]] | None = None,
|
||||
export_records: str | None = None,
|
||||
export_filters: str | dict[str, Any] | list[list[Any]] | None = None,
|
||||
file_type: str = "CSV",
|
||||
):
|
||||
"""
|
||||
Download template from Exporter
|
||||
:param doctype: Document Type
|
||||
|
|
|
|||
|
|
@ -93,15 +93,19 @@ class Importer:
|
|||
return
|
||||
|
||||
# setup import log
|
||||
import_log = (
|
||||
frappe.get_all(
|
||||
"Data Import Log",
|
||||
fields=["row_indexes", "success", "log_index"],
|
||||
filters={"data_import": self.data_import.name},
|
||||
order_by="log_index",
|
||||
# Only use import log for retry/resume when Data Import is persisted in DB.
|
||||
# For bench data-import (CLI), the doc is never inserted, so we must not reuse logs
|
||||
import_log = []
|
||||
if self.data_import.name and frappe.db.exists("Data Import", self.data_import.name):
|
||||
import_log = (
|
||||
frappe.get_all(
|
||||
"Data Import Log",
|
||||
fields=["row_indexes", "success", "log_index"],
|
||||
filters={"data_import": self.data_import.name},
|
||||
order_by="log_index",
|
||||
)
|
||||
or []
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
log_index = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class DeletedDocument(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def restore(name, alert=True):
|
||||
def restore(name: str | int, alert: bool = True):
|
||||
deleted = frappe.get_doc("Deleted Document", name)
|
||||
|
||||
if deleted.restored:
|
||||
|
|
@ -69,7 +69,7 @@ def restore(name, alert=True):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def bulk_restore(docnames):
|
||||
def bulk_restore(docnames: str | list[str]):
|
||||
docnames = frappe.parse_json(docnames)
|
||||
message = _("Restoring Deleted Document")
|
||||
restored, invalid, failed = [], [], []
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ def has_unseen_error_log():
|
|||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_log_doctypes(doctype, txt, searchfield, start, page_len, filters):
|
||||
def get_log_doctypes(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: list):
|
||||
filters = filters or []
|
||||
|
||||
filters.extend(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os
|
|||
from pathlib import Path
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.modules.export_file import delete_folder
|
||||
|
||||
|
|
@ -89,6 +90,10 @@ class ModuleDef(Document):
|
|||
frappe.clear_cache()
|
||||
frappe.setup_module_map()
|
||||
|
||||
def before_rename(self, old, new, merge=False):
|
||||
if not self.custom:
|
||||
frappe.throw(_("Only Custom Modules can be renamed."))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_installed_apps():
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ def serialize_request(request):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_indexes(indexes):
|
||||
def add_indexes(indexes: str):
|
||||
frappe.only_for("Administrator")
|
||||
indexes = json.loads(indexes)
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,9 @@ def get_users(role):
|
|||
# searches for active employees
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def role_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
def role_query(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: list | dict | str
|
||||
):
|
||||
return frappe.get_all(
|
||||
"Role",
|
||||
limit_start=start,
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ def get_all_queued_jobs():
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def stop_job(job_id):
|
||||
def stop_job(job_id: str):
|
||||
frappe.get_doc("RQ Job", job_id).stop_job()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ def validate_receiver_nos(receiver_list):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_contact_number(contact_name, ref_doctype, ref_name):
|
||||
def get_contact_number(contact_name: str, ref_doctype: str, ref_name: str):
|
||||
"Return mobile number of the given contact."
|
||||
number = frappe.db.sql(
|
||||
"""select mobile_no, phone from tabContact
|
||||
|
|
@ -62,7 +62,7 @@ def get_contact_number(contact_name, ref_doctype, ref_name):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_sms(receiver_list, msg, sender_name="", success_msg=True):
|
||||
def send_sms(receiver_list: str | list[str], msg: str, sender_name: str = "", success_msg: bool = True):
|
||||
send_sms_hook_methods = frappe.get_hooks("send_sms")
|
||||
if send_sms_hook_methods:
|
||||
return frappe.get_attr(send_sms_hook_methods[-1])(receiver_list, msg, sender_name, success_msg)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# License: MIT. See LICENSE
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
|
@ -85,7 +86,7 @@ def send_user_permissions(bootinfo):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_user_permissions(user=None):
|
||||
def get_user_permissions(user: str | None = None):
|
||||
"""Get all users permissions for the user as a dict of doctype"""
|
||||
# if this is called from client-side,
|
||||
# user can access only his/her user permissions
|
||||
|
|
@ -160,7 +161,9 @@ def user_permission_exists(user, allow, for_value, applicable_for=None):
|
|||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_applicable_for_doctype_list(doctype, txt, searchfield, start, page_len, filters):
|
||||
def get_applicable_for_doctype_list(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict[str, Any]
|
||||
):
|
||||
actual_doctype = filters.get("doctype")
|
||||
linked_doctypes_map = get_linked_doctypes(actual_doctype, True)
|
||||
|
||||
|
|
@ -192,7 +195,7 @@ def get_permitted_documents(doctype):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_applicable_doc_perm(user, doctype, docname):
|
||||
def check_applicable_doc_perm(user: str, doctype: str, docname: str | int):
|
||||
frappe.only_for("System Manager")
|
||||
applicable = []
|
||||
doc_exists = frappe.get_all(
|
||||
|
|
@ -224,7 +227,7 @@ def check_applicable_doc_perm(user, doctype, docname):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def clear_user_permissions(user, for_doctype):
|
||||
def clear_user_permissions(user: str, for_doctype: str):
|
||||
frappe.only_for("System Manager")
|
||||
total = frappe.db.count("User Permission", {"user": user, "allow": for_doctype})
|
||||
|
||||
|
|
@ -242,7 +245,7 @@ def clear_user_permissions(user, for_doctype):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_user_permissions(data):
|
||||
def add_user_permissions(data: str | dict[str, Any]):
|
||||
"""Add and update the user permissions"""
|
||||
frappe.only_for("System Manager")
|
||||
if isinstance(data, str):
|
||||
|
|
|
|||
|
|
@ -84,13 +84,14 @@ class UserType(Document):
|
|||
title=_("Permission Error"),
|
||||
)
|
||||
|
||||
if not limit:
|
||||
frappe.throw(
|
||||
if limit is None:
|
||||
frappe.msgprint(
|
||||
_("The limit has not set for the user type {0} in the site config file.").format(
|
||||
frappe.bold(self.name)
|
||||
),
|
||||
title=_("Set Limit"),
|
||||
)
|
||||
return
|
||||
|
||||
if self.user_doctypes and len(self.user_doctypes) > limit:
|
||||
frappe.throw(
|
||||
|
|
@ -218,7 +219,9 @@ def get_non_standard_user_types():
|
|||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters):
|
||||
def get_user_linked_doctypes(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | list | str
|
||||
):
|
||||
modules = [d.get("module_name") for d in get_modules_from_app("frappe")]
|
||||
|
||||
filters = [
|
||||
|
|
@ -254,7 +257,7 @@ def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_user_id(parent):
|
||||
def get_user_id(parent: str):
|
||||
data = (
|
||||
frappe.get_all(
|
||||
"DocField",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ class CustomHTMLBlock(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_custom_blocks_for_user(doctype, txt, searchfield, start, page_len, filters):
|
||||
def get_custom_blocks_for_user(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | str | list
|
||||
):
|
||||
# return logged in users private blocks and all public blocks
|
||||
customHTMLBlock = DocType("Custom HTML Block")
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,18 @@ def save_layout(user: str, layout: str, new_icons: str):
|
|||
return {"layout": layout}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_layout():
|
||||
"""Return the current user's saved desktop layout. Used on desk load to avoid stale cached HTML."""
|
||||
try:
|
||||
doc = frappe.get_doc("Desktop Layout", frappe.session.user)
|
||||
if doc.layout:
|
||||
return json.loads(doc.layout)
|
||||
except frappe.DoesNotExistError:
|
||||
frappe.clear_last_message()
|
||||
return None
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_layout():
|
||||
return frappe.delete_doc_if_exists("Desktop Layout", frappe.session.user)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class OnboardingStep(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_onboarding_steps(ob_steps):
|
||||
def get_onboarding_steps(ob_steps: str):
|
||||
steps = []
|
||||
for s in json.loads(ob_steps):
|
||||
doc = frappe.get_doc("Onboarding Step", s.get("step"))
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class SystemConsole(Document):
|
|||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def execute_code(doc):
|
||||
def execute_code(doc: str):
|
||||
console = frappe.get_doc(json.loads(doc))
|
||||
console.run()
|
||||
return console.as_dict()
|
||||
|
|
|
|||
|
|
@ -173,5 +173,5 @@ def has_permission(doc, ptype="read", user=None):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def new_todo(description):
|
||||
def new_todo(description: str):
|
||||
frappe.get_doc({"doctype": "ToDo", "description": description}).insert()
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ def create_workspace_sidebar_for_workspaces():
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_sidebar_items(sidebar_title, sidebar_items):
|
||||
def add_sidebar_items(sidebar_title: str, sidebar_items: str):
|
||||
sidebar_items = loads(sidebar_items)
|
||||
title = f"{sidebar_title}-{frappe.session.user}"
|
||||
w = frappe.get_doc("Workspace Sidebar", sidebar_title)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import frappe
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_task(args, field_map):
|
||||
def update_task(args: str, field_map: str):
|
||||
"""Updates Doc (called via gantt) based on passed `field_map`"""
|
||||
args = frappe._dict(json.loads(args))
|
||||
field_map = frappe._dict(json.loads(field_map))
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from frappe.utils import get_link_to_form
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def toggle_like(doctype, name, add=False):
|
||||
def toggle_like(doctype: str, name: str, add: str | bool = False):
|
||||
"""Adds / removes the current user in the `__liked_by` property of the given document.
|
||||
If column does not exist, will add it in the database.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from frappe.www.printview import set_title_values_for_link_and_dynamic_link_fiel
|
|||
|
||||
@frappe.whitelist()
|
||||
@http_cache(max_age=60 * 10)
|
||||
def get_preview_data(doctype, docname):
|
||||
def get_preview_data(doctype: str, docname: str | int):
|
||||
preview_fields = []
|
||||
meta = frappe.get_meta(doctype)
|
||||
if not meta.show_preview_popup:
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@
|
|||
margin-top: 60px;
|
||||
padding: 20px;
|
||||
}
|
||||
.icons-container:has(.sidebar-card){
|
||||
margin-top: 20px;
|
||||
.sidebar-card{
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
.modal
|
||||
.modal-body .icons-container,.folder-icon .icons-container {
|
||||
padding:0px;
|
||||
|
|
@ -500,31 +506,72 @@
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.title-widget{
|
||||
display: inline-block;
|
||||
.title-widget {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.5rem;
|
||||
cursor: text;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-input-label{
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
color: var(--neutral-white);
|
||||
line-height: 22px;
|
||||
z-index: 1;
|
||||
pointers-events: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.title-input-wrapper{
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.title-widget--read-only {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.title-input-wrapper input{
|
||||
border: 1px solid transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: none;
|
||||
.title-widget--editable:hover .title-input-label {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.desktop-modal-heading .title-widget--read-only .title-input-label:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.desktop-modal-heading .title-widget .title-input-label {
|
||||
color: var(--neutral-white);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1.3;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.desktop-modal-heading .title-widget--editable:hover .title-input-label {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.title-input-wrapper {
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.desktop-modal-heading .title-input-wrapper .title-input {
|
||||
color: var(--neutral-white);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1.3;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
outline: none;
|
||||
min-width: 80px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.desktop-modal-heading .title-input-wrapper .title-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.desktop-modal-heading .title-input-wrapper .title-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.title-input-mirror {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
font-size: var(--text-2xl);
|
||||
font-family: inherit;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<!-- jinja -->
|
||||
<div class="desktop-wrapper">
|
||||
<header class="navbar navbar-expand navbar-container" role="navigation">
|
||||
<header class="desktop-navbar navbar navbar-expand navbar-container" role="navigation">
|
||||
<div class="navbar-home">
|
||||
<img
|
||||
id="brand-logo"
|
||||
|
|
@ -65,5 +65,5 @@
|
|||
{{ _("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden" id="desktop-layout">{{ desktop_layout }}</pre>
|
||||
<div class="hidden" id="desktop-layout">{{ desktop_layout | safe }}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ function get_route(desktop_icon) {
|
|||
if (workspaces) {
|
||||
let args = {
|
||||
type: "workspace",
|
||||
name: first_link.link_to,
|
||||
name: workspaces.title,
|
||||
public: workspaces.public ? 1 : 0,
|
||||
route_options: {
|
||||
sidebar: desktop_icon.label,
|
||||
|
|
@ -176,8 +176,7 @@ class DesktopPage {
|
|||
this.desktop_menu_items = [];
|
||||
}
|
||||
update() {
|
||||
this.make(this.page);
|
||||
this.setup();
|
||||
this.make();
|
||||
}
|
||||
prepare() {
|
||||
this.apps_icons = [];
|
||||
|
|
@ -220,7 +219,7 @@ class DesktopPage {
|
|||
let saved_layout = JSON.parse(localStorage.getItem(`${frappe.session.user}:desktop`));
|
||||
if (!this.data && saved_layout) {
|
||||
this.save_layout(saved_layout);
|
||||
} else if (Object.keys(this.data).length != 0) {
|
||||
} else if (this.data && Array.isArray(this.data) && this.data.length > 0) {
|
||||
frappe.desktop_icons = this.data;
|
||||
} else {
|
||||
frappe.desktop_icons = frappe.boot.desktop_icons;
|
||||
|
|
@ -246,10 +245,23 @@ class DesktopPage {
|
|||
make() {
|
||||
this.page.page_head.hide();
|
||||
$(this.page.body).empty();
|
||||
this.awesomebar_setup = false;
|
||||
$(frappe.render_template("desktop")).appendTo(this.page.body);
|
||||
if (!this.data) {
|
||||
this.data = JSON.parse($("#desktop-layout").text());
|
||||
if (this.data !== undefined) {
|
||||
this.render();
|
||||
} else {
|
||||
const me = this;
|
||||
frappe.call({
|
||||
method: "frappe.desk.doctype.desktop_layout.desktop_layout.get_layout",
|
||||
callback: function (r) {
|
||||
me.data = r.message;
|
||||
me.render();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.sync_layout();
|
||||
this.prepare();
|
||||
this.wrapper = this.page.body.find(".desktop-container");
|
||||
|
|
@ -265,8 +277,8 @@ class DesktopPage {
|
|||
if (this.edit_mode) {
|
||||
this.start_editing_layout();
|
||||
}
|
||||
this.setup();
|
||||
}
|
||||
|
||||
setup() {
|
||||
$(document).trigger("desktop_screen", { desktop: this });
|
||||
this.setup_avatar();
|
||||
|
|
@ -1026,7 +1038,8 @@ class DesktopIcon {
|
|||
let modal = frappe.desktop_utils.create_desktop_modal(me);
|
||||
modal.setup(me.icon_title, me.child_icons, 4);
|
||||
let $title = modal.modal.find(".modal-title");
|
||||
let title = new InlineEditor($title, this.icon_data.label, function (
|
||||
const edit_mode = frappe.pages["desktop"].desktop_page.edit_mode;
|
||||
let title = new InlineEditor($title, this.icon_data.label, edit_mode, function (
|
||||
old_value,
|
||||
new_value
|
||||
) {
|
||||
|
|
@ -1038,6 +1051,10 @@ class DesktopIcon {
|
|||
add_icons_to_folder(new_value, folder_icons);
|
||||
|
||||
frappe.pages["desktop"].desktop_page.update();
|
||||
|
||||
if (!frappe.pages["desktop"].desktop_page.edit_mode) {
|
||||
frappe.pages["desktop"].desktop_page.save_layout(frappe.desktop_icons, []);
|
||||
}
|
||||
});
|
||||
modal.show();
|
||||
});
|
||||
|
|
@ -1226,52 +1243,106 @@ class IconsPane {
|
|||
}
|
||||
|
||||
class InlineEditor {
|
||||
constructor(container, initialValue = "", onRename = () => {}) {
|
||||
constructor(container, initialValue = "", editMode = false, onRename = () => {}) {
|
||||
this.container = container;
|
||||
this.initialValue = initialValue;
|
||||
this.onRename = onRename;
|
||||
this.currentValue = initialValue;
|
||||
this.editMode = editMode;
|
||||
this.onRename = typeof editMode === "function" ? editMode : onRename;
|
||||
if (typeof editMode === "function") {
|
||||
this.editMode = false;
|
||||
}
|
||||
this.isEditing = false;
|
||||
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
render() {
|
||||
const tooltip = this.editMode ? __("Click to edit") : "";
|
||||
const editableClass = this.editMode ? "title-widget--editable" : "title-widget--read-only";
|
||||
this.container.html(`
|
||||
<div class="title-widget">
|
||||
<div class="title-input-label">
|
||||
<span>${__(this.initialValue)}</span>
|
||||
</div>
|
||||
<div class="title-input-wrapper">
|
||||
<input class="title-input">
|
||||
<div class="title-widget ${editableClass}" title="${tooltip}">
|
||||
<span class="title-input-label">${__(this.currentValue)}</span>
|
||||
<div class="title-input-wrapper" style="display: none;">
|
||||
<input type="text" class="title-input" />
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
this.$widget = this.container.find(".title-widget");
|
||||
this.input = this.container.find(".title-input");
|
||||
this.label = this.container.find(".title-input-label");
|
||||
this.wrapper = this.container.find(".title-input-wrapper");
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.container.on("click", () => {
|
||||
if (frappe.pages["desktop"].desktop_page.edit_mode) {
|
||||
this.label.css("visibility", "hidden");
|
||||
this.input.focus().select();
|
||||
}
|
||||
this.label.on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!frappe.pages["desktop"].desktop_page.edit_mode) return;
|
||||
this.startEditing();
|
||||
});
|
||||
|
||||
this.input.on("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
const newValue = this.input.val().trim();
|
||||
this.input.css("display", "none");
|
||||
this.label.css("visibility", "visible");
|
||||
this.label.find("span").text(newValue);
|
||||
|
||||
this.onRename(this.initialValue, newValue, this);
|
||||
this.input.on("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
this.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
this.input.on("blur", () => {
|
||||
this.label.css("visibility", "visible");
|
||||
this.commit();
|
||||
});
|
||||
|
||||
this.input.on("input", () => {
|
||||
this.resizeInput();
|
||||
});
|
||||
}
|
||||
|
||||
startEditing() {
|
||||
if (this.isEditing) return;
|
||||
this.isEditing = true;
|
||||
this.initialValue = this.currentValue;
|
||||
this.label.hide();
|
||||
this.wrapper.show();
|
||||
this.input.val(this.currentValue);
|
||||
this.input.css("width", "4px");
|
||||
this.resizeInput();
|
||||
this.input.focus().select();
|
||||
}
|
||||
|
||||
commit() {
|
||||
if (!this.isEditing) return;
|
||||
this.isEditing = false;
|
||||
const newValue = this.input.val().trim();
|
||||
const effective = newValue || this.initialValue;
|
||||
this.label.text(effective).show();
|
||||
this.wrapper.hide();
|
||||
this.input.val(effective);
|
||||
this.currentValue = effective;
|
||||
if (effective !== this.initialValue) {
|
||||
this.onRename(this.initialValue, effective, this);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (!this.isEditing) return;
|
||||
this.isEditing = false;
|
||||
this.label.text(this.initialValue).show();
|
||||
this.wrapper.hide();
|
||||
this.input.val(this.currentValue);
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
resizeInput() {
|
||||
const mirror = $("<span>")
|
||||
.addClass("title-input-mirror")
|
||||
.text(this.input.val() || "");
|
||||
this.$widget.append(mirror);
|
||||
const textWidth = mirror.get(0).offsetWidth;
|
||||
mirror.remove();
|
||||
this.input.css("width", Math.max(80, textWidth + 20) + "px");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ def get_context(context):
|
|||
brand_logo = frappe.get_hooks("app_logo_url", app_name="frappe")[0]
|
||||
context.brand_logo = brand_logo
|
||||
try:
|
||||
context.desktop_layout = frappe.get_doc("Desktop Layout", frappe.session.user).layout or {}
|
||||
layout = frappe.get_doc("Desktop Layout", frappe.session.user).layout
|
||||
context.desktop_layout = layout if layout else "[]"
|
||||
except frappe.DoesNotExistError:
|
||||
frappe.clear_last_message()
|
||||
context.desktop_layout = {}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ def search_widget(
|
|||
start: int = 0,
|
||||
page_length: int = 10,
|
||||
filters: str | None | dict | list = None,
|
||||
filter_fields=None,
|
||||
filter_fields: str | None = None,
|
||||
as_dict: bool = False,
|
||||
reference_doctype: str | None = None,
|
||||
ignore_user_permissions: bool = False,
|
||||
|
|
@ -372,7 +372,7 @@ def relevance_sorter(key, query, as_dict):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_names_for_mentions(search_term):
|
||||
def get_names_for_mentions(search_term: str):
|
||||
users_for_mentions = frappe.cache.get_value("users_for_mentions", get_users_for_mentions)
|
||||
user_groups = frappe.cache.get_value("user_groups", get_user_groups)
|
||||
|
||||
|
|
@ -408,7 +408,7 @@ def get_user_groups():
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_link_title(doctype, docname):
|
||||
def get_link_title(doctype: str, docname: str | int):
|
||||
meta = frappe.get_meta(doctype)
|
||||
|
||||
if meta.show_title_field_in_link:
|
||||
|
|
|
|||
|
|
@ -158,33 +158,35 @@ def sendmail(
|
|||
"""Send email using user's default **Email Account** or global default **Email Account**.
|
||||
|
||||
|
||||
:param recipients: List of recipients.
|
||||
:param sender: Email sender. Default is current user or default outgoing account.
|
||||
:param subject: Email Subject.
|
||||
:param message: (or `content`) Email Content.
|
||||
:param as_markdown: Convert content markdown to HTML.
|
||||
:param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true
|
||||
:param send_priority: Priority for Email Queue, default 1.
|
||||
:param reference_doctype: (or `doctype`) Append as communication to this DocType.
|
||||
:param reference_name: (or `name`) Append as communication to this document name.
|
||||
:param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe`
|
||||
:param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict).
|
||||
:param attachments: List of attachments.
|
||||
:param reply_to: Reply-To Email Address.
|
||||
:param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email.
|
||||
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
|
||||
:param send_after: Send after the given datetime.
|
||||
:param expose_recipients: Display all recipients in the footer message - "This email was sent to"
|
||||
:param communication: Communication link to be set in Email Queue record
|
||||
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
|
||||
:param template: Name of html template from templates/emails folder
|
||||
:param args: Arguments for rendering the template
|
||||
:param header: Append header in email
|
||||
:param with_container: Wraps email inside a styled container
|
||||
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
|
||||
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
|
||||
:param raw_html: Whether to treat email template as a complete HTML file
|
||||
:param add_css: Whether to add CSS from hooks/email_css to the email template
|
||||
:param recipients: List of recipients.
|
||||
:param sender: Email sender. Default is current user or default outgoing account.
|
||||
:param subject: Email Subject.
|
||||
:param message: (or `content`) Email Content.
|
||||
:param as_markdown: Convert content markdown to HTML.
|
||||
:param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true
|
||||
:param send_priority: Priority for Email Queue, default 1.
|
||||
:param reference_doctype: (or `doctype`) Append as communication to this DocType.
|
||||
:param reference_name: (or `name`) Append as communication to this document name.
|
||||
:param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe`
|
||||
:param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict).
|
||||
:param attachments: List of attachments.
|
||||
:param reply_to: Reply-To Email Address.
|
||||
:param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email.
|
||||
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
|
||||
:param send_after: Send after the given datetime.
|
||||
:param expose_recipients: Controls recipient visibility. "header" shows all TO recipients in the To header.
|
||||
"footer" adds "This email was sent to..." text in footer. None (default) hides TO recipients from each other.
|
||||
Note: CC header is always visible regardless of this setting (as per email semantics).
|
||||
:param communication: Communication link to be set in Email Queue record
|
||||
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
|
||||
:param template: Name of html template from templates/emails folder
|
||||
:param args: Arguments for rendering the template
|
||||
:param header: Append header in email
|
||||
:param with_container: Wraps email inside a styled container
|
||||
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
|
||||
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
|
||||
:param raw_html: Whether to treat email template as a complete HTML file
|
||||
:param add_css: Whether to add CSS from hooks/email_css to the email template
|
||||
"""
|
||||
|
||||
from frappe.utils.jinja import get_email_from_template
|
||||
|
|
|
|||
|
|
@ -65,8 +65,12 @@
|
|||
"always_use_account_email_id_as_sender",
|
||||
"always_use_account_name_as_sender_name",
|
||||
"send_unsubscribe_message",
|
||||
"add_x_original_from",
|
||||
"track_email_status",
|
||||
"headers_section",
|
||||
"column_break_mcbu",
|
||||
"add_x_original_from",
|
||||
"add_reply_to_header",
|
||||
"reply_to_addresses",
|
||||
"outgoing_mail_settings",
|
||||
"use_tls",
|
||||
"use_ssl_for_outgoing",
|
||||
|
|
@ -84,9 +88,11 @@
|
|||
"set_footer",
|
||||
"footer",
|
||||
"brand_logo",
|
||||
"section_break_jdoz",
|
||||
"uidvalidity",
|
||||
"uidnext",
|
||||
"no_failed"
|
||||
"no_failed",
|
||||
"column_break_wojv"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -161,7 +167,8 @@
|
|||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Domain",
|
||||
"options": "Email Domain"
|
||||
"options": "Email Domain",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.domain",
|
||||
|
|
@ -714,13 +721,46 @@
|
|||
"fieldname": "add_x_original_from",
|
||||
"fieldtype": "Check",
|
||||
"label": "Add X-Original-From header"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "add_reply_to_header",
|
||||
"fieldtype": "Check",
|
||||
"label": "Add Reply-To header"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "headers_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Headers"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jdoz",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_wojv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_mcbu",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.add_reply_to_header",
|
||||
"description": "Addresses added here will be used as the Reply-To header for outgoing emails sent from this account.",
|
||||
"fieldname": "reply_to_addresses",
|
||||
"fieldtype": "Table",
|
||||
"ignore_xss_filter": 1,
|
||||
"label": "Reply-To Addresses",
|
||||
"options": "Reply To Address"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-inbox",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-02-04 15:50:27.898578",
|
||||
"modified": "2026-02-06 11:39:39.412130",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Email Account",
|
||||
|
|
|
|||
|
|
@ -58,8 +58,10 @@ class EmailAccount(Document):
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.email.doctype.imap_folder.imap_folder import IMAPFolder
|
||||
from frappe.email.doctype.reply_to_address.reply_to_address import ReplyToAddress
|
||||
from frappe.types import DF
|
||||
|
||||
add_reply_to_header: DF.Check
|
||||
add_signature: DF.Check
|
||||
add_x_original_from: DF.Check
|
||||
always_bcc: DF.Data | None
|
||||
|
|
@ -102,6 +104,7 @@ class EmailAccount(Document):
|
|||
no_smtp_authentication: DF.Check
|
||||
notify_if_unreplied: DF.Check
|
||||
password: DF.Password | None
|
||||
reply_to_addresses: DF.Table[ReplyToAddress]
|
||||
send_notification_to: DF.SmallText | None
|
||||
send_unsubscribe_message: DF.Check
|
||||
sent_folder_name: DF.Data | None
|
||||
|
|
@ -179,6 +182,7 @@ class EmailAccount(Document):
|
|||
):
|
||||
if validate_oauth or self.password or self.smtp_server in ("127.0.0.1", "localhost"):
|
||||
if self.enable_incoming:
|
||||
self.flags.validate_imap_pop_connection = True
|
||||
self.get_incoming_server()
|
||||
self.no_failed = 0
|
||||
|
||||
|
|
@ -202,6 +206,9 @@ class EmailAccount(Document):
|
|||
if folder.append_to not in valid_doctypes:
|
||||
frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes)))
|
||||
|
||||
if self.enable_outgoing:
|
||||
self.validate_reply_to_addresses()
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_frappe_mail_settings(self):
|
||||
if self.service == "Frappe Mail":
|
||||
|
|
@ -212,8 +219,15 @@ class EmailAccount(Document):
|
|||
if not self.smtp_server:
|
||||
frappe.throw(_("SMTP Server is required"))
|
||||
|
||||
server = self.get_smtp_server()
|
||||
return server.session
|
||||
self.flags.validate_smtp_connection = True
|
||||
self.get_smtp_server().session
|
||||
del self._smtp_server_instance
|
||||
|
||||
def validate_reply_to_addresses(self) -> None:
|
||||
for reply_to in self.reply_to_addresses:
|
||||
if not reply_to.email:
|
||||
frappe.throw(_("Reply To email is required"))
|
||||
validate_email_address(reply_to.email, True)
|
||||
|
||||
def before_save(self):
|
||||
messages = []
|
||||
|
|
@ -304,6 +318,9 @@ class EmailAccount(Document):
|
|||
if not args.get("host"):
|
||||
frappe.throw(_("{0} is required").format("Email Server"))
|
||||
|
||||
if self.flags.validate_imap_pop_connection:
|
||||
args.timeout = 15
|
||||
|
||||
email_server = EmailServer(frappe._dict(args))
|
||||
self.check_email_server_connection(email_server, in_receive)
|
||||
|
||||
|
|
@ -447,16 +464,16 @@ class EmailAccount(Document):
|
|||
:param match_by_email: Find account using emailID
|
||||
:param match_by_doctype: Find account by matching `Append To` doctype
|
||||
"""
|
||||
doc = cls.find_one_by_filters(enable_incoming=1, email_id=match_by_email)
|
||||
if doc:
|
||||
if doc := cls.find_default_incoming():
|
||||
return doc
|
||||
|
||||
doc = cls.find_one_by_filters(enable_incoming=1, append_to=match_by_doctype)
|
||||
if doc:
|
||||
if match_by_email and (doc := cls.find_one_by_filters(enable_incoming=1, email_id=match_by_email)):
|
||||
return doc
|
||||
|
||||
doc = cls.find_default_incoming()
|
||||
return doc
|
||||
if match_by_doctype and (
|
||||
doc := cls.find_one_by_filters(enable_incoming=1, append_to=match_by_doctype)
|
||||
):
|
||||
return doc
|
||||
|
||||
@classmethod
|
||||
def find_default_incoming(cls):
|
||||
|
|
@ -507,7 +524,7 @@ class EmailAccount(Document):
|
|||
return oauth_token.get_password("access_token") if oauth_token else None
|
||||
|
||||
def sendmail_config(self):
|
||||
return {
|
||||
config = {
|
||||
"email_account": self.name,
|
||||
"server": self.smtp_server,
|
||||
"port": cint(self.smtp_port),
|
||||
|
|
@ -519,6 +536,11 @@ class EmailAccount(Document):
|
|||
"access_token": self.get_access_token(),
|
||||
}
|
||||
|
||||
if self.flags.validate_smtp_connection:
|
||||
config["timeout"] = 15
|
||||
|
||||
return config
|
||||
|
||||
def get_smtp_server(self):
|
||||
"""Get SMTPServer (wrapper around actual smtplib object) for this account.
|
||||
|
||||
|
|
@ -799,7 +821,14 @@ class EmailAccount(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_append_to(doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None):
|
||||
def get_append_to(
|
||||
doctype: str | None = None,
|
||||
txt: str | None = None,
|
||||
searchfield: str | None = None,
|
||||
start: int | None = None,
|
||||
page_len: int | None = None,
|
||||
filters: list | dict | str | None = None,
|
||||
):
|
||||
txt = txt if txt else ""
|
||||
|
||||
filters = {"istable": 0, "issingle": 0, "email_append_to": 1}
|
||||
|
|
@ -1032,7 +1061,7 @@ def remove_user_email_inbox(email_account):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_email_password(email_account, password):
|
||||
def set_email_password(email_account: str, password: str):
|
||||
account = frappe.get_doc("Email Account", email_account)
|
||||
if account.awaiting_password and account.auth_method != "OAuth":
|
||||
account.awaiting_password = 0
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ class EmailDomain(Document):
|
|||
conn_method = poplib.POP3_SSL if self.use_ssl else poplib.POP3
|
||||
|
||||
self.use_starttls = cint(self.use_imap and self.use_starttls and not self.use_ssl)
|
||||
incoming_conn = conn_method(self.email_server, port=self.incoming_port, timeout=30)
|
||||
incoming_conn = conn_method(self.email_server, port=self.incoming_port, timeout=15)
|
||||
incoming_conn.logout() if self.use_imap else incoming_conn.quit()
|
||||
|
||||
@handle_error("outgoing")
|
||||
|
|
@ -124,4 +124,4 @@ class EmailDomain(Document):
|
|||
elif self.use_tls:
|
||||
self.smtp_port = self.smtp_port or 587
|
||||
|
||||
conn_method((self.smtp_server or ""), cint(self.smtp_port), timeout=30).quit()
|
||||
conn_method((self.smtp_server or ""), cint(self.smtp_port), timeout=15).quit()
|
||||
|
|
|
|||
|
|
@ -106,14 +106,14 @@ class EmailGroup(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def import_from(name, doctype):
|
||||
def import_from(name: str | int, doctype: str):
|
||||
nlist = frappe.get_doc("Email Group", name)
|
||||
if nlist.has_permission("write"):
|
||||
return nlist.import_from(doctype)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_subscribers(name, email_list):
|
||||
def add_subscribers(name: str | int, email_list: str | list[str] | tuple[str, ...]):
|
||||
if not isinstance(email_list, list | tuple):
|
||||
email_list = email_list.replace(",", "\n").split("\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -306,7 +306,8 @@ class SendMailContext:
|
|||
recipient.update_db(status="Sent", commit=True)
|
||||
|
||||
def get_message_object(self, message):
|
||||
return Parser(policy=SMTP).parsestr(message)
|
||||
policy = SMTP.clone(refold_source="none")
|
||||
return Parser(policy=policy).parsestr(message)
|
||||
|
||||
def message_placeholder(self, placeholder_key):
|
||||
# sourcery skip: avoid-builtin-shadow
|
||||
|
|
@ -459,7 +460,7 @@ def retry_sending(queues: str | list[str]):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_now(name, force_send: bool = False):
|
||||
def send_now(name: str | int, force_send: bool = False):
|
||||
record = EmailQueue.find(name)
|
||||
if record:
|
||||
record.check_permission()
|
||||
|
|
@ -467,7 +468,7 @@ def send_now(name, force_send: bool = False):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def toggle_sending(enable):
|
||||
def toggle_sending(enable: bool | int | str):
|
||||
frappe.only_for("System Manager")
|
||||
frappe.db.set_default("suspend_email_queue", 0 if sbool(enable) else 1)
|
||||
|
||||
|
|
@ -539,7 +540,11 @@ class QueueBuilder:
|
|||
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
|
||||
:param send_after: Send this email after the given datetime. If value is in integer, then `send_after` will be the automatically set to no of days from current date.
|
||||
:param communication: Communication link to be set in Email Queue record
|
||||
:param queue_separately: Queue each email separately
|
||||
:param queue_separately: Queue each email separately (one per recipient). When True, each TO recipient
|
||||
receives an individual email. Note: If CC/BCC are provided with queue_separately=True, CC/BCC
|
||||
recipients will receive one email for each TO recipient(duplicates), as each TO email is a separate message
|
||||
that includes CC/BCC. To avoid this, either don't use queue_separately, or add CC/BCC recipients
|
||||
to the recipients list instead.
|
||||
:param is_notification: Marks email as notification so will not trigger notifications from system
|
||||
:param add_unsubscribe_link: Send unsubscribe link in the footer of the Email, default 1.
|
||||
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
|
||||
|
|
@ -795,6 +800,15 @@ class QueueBuilder:
|
|||
)
|
||||
|
||||
def send_emails(self, queue_data, final_recipients):
|
||||
"""
|
||||
Send emails to recipients separately.
|
||||
|
||||
Note: CC/BCC recipients are included in each email sent to TO recipients.
|
||||
This means CC/BCC will receive one email per TO recipient. This is expected
|
||||
behavior because queue_separately creates individual emails for each TO
|
||||
recipient, and CC/BCC are copied on each individual email.
|
||||
|
||||
"""
|
||||
# This is used to bulk send emails from same sender to multiple recipients separately
|
||||
# This re-uses smtp server instance to minimize the cost of new session creation
|
||||
frappe_mail_client = None
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
# License: MIT. See LICENSE
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
|
@ -66,7 +67,7 @@ class EmailTemplate(Document):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_email_template(template_name, doc, sender=None):
|
||||
def get_email_template(template_name: str, doc: str | dict[str, Any], sender: str | None = None):
|
||||
"""Return the processed HTML of a email template with the given doc"""
|
||||
|
||||
email_template = frappe.get_doc("Email Template", template_name)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class Notification(Document):
|
|||
# START: PreviewRenderer API
|
||||
|
||||
@frappe.whitelist()
|
||||
def preview_meets_condition(self, preview_document):
|
||||
def preview_meets_condition(self, preview_document: str):
|
||||
if not self.condition and not self.filters:
|
||||
return _("Yes")
|
||||
try:
|
||||
|
|
@ -107,7 +107,7 @@ class Notification(Document):
|
|||
return _("Failed to evaluate conditions: {}").format(e)
|
||||
|
||||
@frappe.whitelist()
|
||||
def preview_message(self, preview_document):
|
||||
def preview_message(self, preview_document: str):
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.document_type, preview_document)
|
||||
context = get_context(doc)
|
||||
|
|
@ -124,7 +124,7 @@ class Notification(Document):
|
|||
return _("Failed to render message: {}").format(e)
|
||||
|
||||
@frappe.whitelist()
|
||||
def preview_subject(self, preview_document):
|
||||
def preview_subject(self, preview_document: str):
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.document_type, preview_document)
|
||||
context = get_context(doc)
|
||||
|
|
@ -730,7 +730,7 @@ def clear_notification_cache():
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_documents_for_today(notification):
|
||||
def get_documents_for_today(notification: str):
|
||||
notification = frappe.get_doc("Notification", notification)
|
||||
notification.check_permission("read")
|
||||
return [d.name for d in notification.get_documents_for_today()]
|
||||
|
|
|
|||
0
frappe/email/doctype/reply_to_address/__init__.py
Normal file
0
frappe/email/doctype/reply_to_address/__init__.py
Normal file
47
frappe/email/doctype/reply_to_address/reply_to_address.json
Normal file
47
frappe/email/doctype/reply_to_address/reply_to_address.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2026-02-06 11:33:22.774848",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"_name",
|
||||
"column_break_xtxq",
|
||||
"email"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xtxq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Email",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-06 11:35:05.181524",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Reply To Address",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
24
frappe/email/doctype/reply_to_address/reply_to_address.py
Normal file
24
frappe/email/doctype/reply_to_address/reply_to_address.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class ReplyToAddress(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
_name: DF.Data | None
|
||||
email: DF.Data
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
@ -9,6 +9,7 @@ import re
|
|||
from email import policy
|
||||
from email.header import Header
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.utils import formataddr
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import frappe
|
||||
|
|
@ -25,6 +26,7 @@ from frappe.utils import (
|
|||
split_emails,
|
||||
strip,
|
||||
to_markdown,
|
||||
validate_email_address,
|
||||
)
|
||||
from frappe.utils.pdf import get_pdf
|
||||
|
||||
|
|
@ -268,13 +270,12 @@ class EMail:
|
|||
|
||||
def validate(self):
|
||||
"""validate the Email Addresses"""
|
||||
from frappe.utils import validate_email_address
|
||||
|
||||
if not self.sender:
|
||||
self.sender = self.email_account.default_sender
|
||||
|
||||
validate_email_address(strip(self.sender), True)
|
||||
self.reply_to = validate_email_address(strip(self.reply_to) or self.sender, True)
|
||||
self.validate_reply_to()
|
||||
|
||||
if self.email_account.add_x_original_from:
|
||||
self.set_header("X-Original-From", self.sender)
|
||||
|
|
@ -289,6 +290,23 @@ class EMail:
|
|||
for e in self.recipients + (self.cc or []) + (self.bcc or []):
|
||||
validate_email_address(e, True)
|
||||
|
||||
def validate_reply_to(self) -> None:
|
||||
if not self.email_account.add_reply_to_header:
|
||||
self.reply_to = None
|
||||
return
|
||||
|
||||
if self.email_account.reply_to_addresses:
|
||||
valid_addresses = [
|
||||
formataddr((reply_to._name, reply_to.email))
|
||||
for reply_to in self.email_account.reply_to_addresses
|
||||
if reply_to.email and validate_email_address(reply_to.email, True)
|
||||
]
|
||||
self.reply_to = ", ".join(valid_addresses) if valid_addresses else None
|
||||
return
|
||||
|
||||
fallback = strip(self.reply_to) or self.sender
|
||||
self.reply_to = validate_email_address(fallback, True)
|
||||
|
||||
def replace_sender(self):
|
||||
if cint(self.email_account.always_use_account_email_id_as_sender):
|
||||
sender_name, _ = parse_addr(self.sender)
|
||||
|
|
@ -337,7 +355,8 @@ class EMail:
|
|||
"To": ", ".join(self.recipients) if self.expose_recipients == "header" else "<!--recipient-->",
|
||||
"Date": email.utils.formatdate(),
|
||||
"Reply-To": self.reply_to if self.reply_to else None,
|
||||
"CC": ", ".join(self.cc) if self.cc and self.expose_recipients == "header" else None,
|
||||
# cc should always be visible - as that is the semantic meaning of cc, this should not be dependent on expose_recipients
|
||||
"CC": ", ".join(self.cc) if self.cc else None,
|
||||
"X-Frappe-Site": get_url(),
|
||||
}
|
||||
|
||||
|
|
@ -418,7 +437,13 @@ def get_formatted_html(
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_email_html(template, args, subject, header=None, with_container=False):
|
||||
def get_email_html(
|
||||
template: str,
|
||||
args: str,
|
||||
subject: str,
|
||||
header: str | list | None = None,
|
||||
with_container: str | int | bool = False,
|
||||
):
|
||||
import json
|
||||
|
||||
with_container = cint(with_container)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ def get_email_accounts(user=None):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_email_flag_queue(names, action):
|
||||
def create_email_flag_queue(names: str, action: str):
|
||||
"""create email flag queue to mark email either as read or unread"""
|
||||
|
||||
def mark_as_seen_unseen(name, action):
|
||||
|
|
|
|||
|
|
@ -69,7 +69,10 @@ class EmailServer:
|
|||
def __init__(self, args=None):
|
||||
self.retry_limit = 3
|
||||
self.retry_count = 0
|
||||
|
||||
self.settings = args or frappe._dict()
|
||||
self.pop_timeout = self.settings.timeout or frappe.conf.pop_timeout
|
||||
self.imap_timeout = self.settings.timeout or frappe.conf.imap_timeout
|
||||
|
||||
def connect(self):
|
||||
"""Connect to **Email Account**."""
|
||||
|
|
@ -82,12 +85,12 @@ class EmailServer:
|
|||
self.imap = imaplib.IMAP4_SSL(
|
||||
self.settings.host,
|
||||
self.settings.incoming_port,
|
||||
timeout=frappe.conf.pop_timeout,
|
||||
timeout=self.imap_timeout,
|
||||
ssl_context=ssl.create_default_context(),
|
||||
)
|
||||
else:
|
||||
self.imap = imaplib.IMAP4(
|
||||
self.settings.host, self.settings.incoming_port, timeout=frappe.conf.pop_timeout
|
||||
self.settings.host, self.settings.incoming_port, timeout=self.imap_timeout
|
||||
)
|
||||
|
||||
if cint(self.settings.use_starttls):
|
||||
|
|
@ -119,12 +122,12 @@ class EmailServer:
|
|||
self.pop = poplib.POP3_SSL(
|
||||
self.settings.host,
|
||||
self.settings.incoming_port,
|
||||
timeout=frappe.conf.pop_timeout,
|
||||
timeout=self.pop_timeout,
|
||||
context=ssl.create_default_context(),
|
||||
)
|
||||
else:
|
||||
self.pop = poplib.POP3(
|
||||
self.settings.host, self.settings.incoming_port, timeout=frappe.conf.pop_timeout
|
||||
self.settings.host, self.settings.incoming_port, timeout=self.pop_timeout
|
||||
)
|
||||
|
||||
if self.settings.use_oauth:
|
||||
|
|
@ -799,15 +802,25 @@ class InboundMail(Email):
|
|||
return self._reference_document
|
||||
|
||||
reference_document = ""
|
||||
parent = self.parent_email_queue() or self.parent_communication()
|
||||
parent_email_queue = self.parent_email_queue()
|
||||
parent_communication = self.parent_communication()
|
||||
|
||||
if parent and parent.reference_doctype:
|
||||
parent = None
|
||||
if parent_email_queue and parent_email_queue.reference_doctype:
|
||||
parent = parent_email_queue
|
||||
elif parent_communication and parent_communication.reference_doctype:
|
||||
parent = parent_communication
|
||||
|
||||
if parent:
|
||||
reference_doctype, reference_name = parent.reference_doctype, parent.reference_name
|
||||
reference_document = self.get_doc(reference_doctype, reference_name, ignore_error=True)
|
||||
|
||||
if not reference_document and self.email_account.append_to:
|
||||
reference_document = self.match_record_by_subject_and_sender(self.email_account.append_to)
|
||||
|
||||
if not reference_document and self.is_reply_to_system_sent_mail():
|
||||
reference_document = parent_communication
|
||||
|
||||
self._reference_document = reference_document or ""
|
||||
return self._reference_document
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class SMTPServer:
|
|||
use_ssl=None,
|
||||
use_oauth=0,
|
||||
access_token=None,
|
||||
timeout=2 * 60,
|
||||
):
|
||||
self.login = login
|
||||
self.email_account = email_account
|
||||
|
|
@ -37,6 +38,7 @@ class SMTPServer:
|
|||
self.use_oauth = use_oauth
|
||||
self.access_token = access_token
|
||||
self._session = None
|
||||
self.timeout = timeout
|
||||
|
||||
if not self.server:
|
||||
frappe.msgprint(
|
||||
|
|
@ -72,7 +74,7 @@ class SMTPServer:
|
|||
SMTP = smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
|
||||
|
||||
try:
|
||||
_session = SMTP(self.server, self.port, timeout=2 * 60)
|
||||
_session = SMTP(self.server, self.port, timeout=self.timeout)
|
||||
if not _session:
|
||||
frappe.msgprint(
|
||||
_("Could not connect to outgoing email server"), raise_exception=frappe.OutgoingEmailError
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ page_js = {"setup-wizard": "public/js/frappe/setup_wizard.js"}
|
|||
# website
|
||||
app_include_js = [
|
||||
"libs.bundle.js",
|
||||
"billing.bundle.js",
|
||||
"desk.bundle.js",
|
||||
"list.bundle.js",
|
||||
"form.bundle.js",
|
||||
"controls.bundle.js",
|
||||
"report.bundle.js",
|
||||
"telemetry.bundle.js",
|
||||
"billing.bundle.js",
|
||||
]
|
||||
|
||||
app_include_css = [
|
||||
|
|
|
|||
1681
frappe/locale/es.po
1681
frappe/locale/es.po
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@ msgstr ""
|
|||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: developers@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-02-15 09:42+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 19:56\n"
|
||||
"PO-Revision-Date: 2026-02-18 20:54\n"
|
||||
"Last-Translator: developers@frappe.io\n"
|
||||
"Language-Team: Persian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
|
@ -1633,7 +1633,7 @@ msgstr "تراز کردن مقدار"
|
|||
#: frappe/custom/doctype/custom_field/custom_field.json
|
||||
#: frappe/custom/doctype/customize_form_field/customize_form_field.json
|
||||
msgid "Alignment"
|
||||
msgstr ""
|
||||
msgstr "ترازبندی"
|
||||
|
||||
#. Name of a role
|
||||
#. Option for the 'Frequency' (Select) field in DocType 'Scheduled Job Type'
|
||||
|
|
@ -5466,7 +5466,7 @@ msgstr ""
|
|||
#. Name of a DocType
|
||||
#: frappe/contacts/doctype/contact_email/contact_email.json
|
||||
msgid "Contact Email"
|
||||
msgstr "تماس با ایمیل"
|
||||
msgstr "ایمیل مخاطب"
|
||||
|
||||
#. Label of the phone_nos (Table) field in DocType 'Contact'
|
||||
#: frappe/contacts/doctype/contact/contact.json
|
||||
|
|
@ -23239,7 +23239,7 @@ msgstr "جستجو در یک نوع سند"
|
|||
|
||||
#: frappe/public/js/form_builder/components/SearchBox.vue:8
|
||||
msgid "Search properties..."
|
||||
msgstr ""
|
||||
msgstr "جستجوی ویژگیها..."
|
||||
|
||||
#: frappe/templates/includes/search_box.html:8
|
||||
msgid "Search results for"
|
||||
|
|
@ -31938,7 +31938,7 @@ msgstr "{0} از {1} تا {2}"
|
|||
|
||||
#: frappe/public/js/frappe/form/footer/version_timeline_content_builder.js:170
|
||||
msgid "{0} from {1} to {2} in row #{3}"
|
||||
msgstr "{0} از {1} تا {2} در ردیف #{3}"
|
||||
msgstr "{0} از {1} به {2} در ردیف #{3}"
|
||||
|
||||
#: frappe/public/js/frappe/utils/pretty_date.js:29
|
||||
msgid "{0} h"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ msgstr ""
|
|||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: developers@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-02-15 09:42+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 19:57\n"
|
||||
"PO-Revision-Date: 2026-02-18 20:54\n"
|
||||
"Last-Translator: developers@frappe.io\n"
|
||||
"Language-Team: Burmese\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
|
@ -50,7 +50,7 @@ msgstr ""
|
|||
|
||||
#: frappe/utils/csvutils.py:246
|
||||
msgid "\"{0}\" is not a valid Google Sheets URL"
|
||||
msgstr ""
|
||||
msgstr ""{0}" သည်မှန်ကန်သော Google Sheets URL မဟုတ်ပါ"
|
||||
|
||||
#: frappe/public/js/frappe/ui/toolbar/tag_utils.js:21
|
||||
#: frappe/public/js/frappe/ui/toolbar/tag_utils.js:22
|
||||
|
|
@ -63,12 +63,12 @@ msgstr ""
|
|||
|
||||
#: frappe/public/js/frappe/ui/toolbar/about.js:11
|
||||
msgid "© Frappe Technologies Pvt. Ltd. and contributors"
|
||||
msgstr ""
|
||||
msgstr "© Frappe Technologies Pvt. Ltd. နှင့် ပူးပေါင်းလုပ်ဆောင်သူများ"
|
||||
|
||||
#. Label of the head_html (Code) field in DocType 'Website Settings'
|
||||
#: frappe/website/doctype/website_settings/website_settings.json
|
||||
msgid "<head> HTML"
|
||||
msgstr ""
|
||||
msgstr "<head> HTML ကို"
|
||||
|
||||
#: frappe/database/query.py:2275
|
||||
msgid "'*' is only allowed in {0} SQL function(s)"
|
||||
|
|
@ -100,7 +100,7 @@ msgstr ""
|
|||
|
||||
#: frappe/utils/__init__.py:249
|
||||
msgid "'{0}' is not a valid URL"
|
||||
msgstr ""
|
||||
msgstr "'{0}' သည် မှန်ကန်သော URL မဟုတ်ပါ။"
|
||||
|
||||
#: frappe/core/doctype/doctype/doctype.py:1380
|
||||
msgid "'{0}' not allowed for type {1} in row {2}"
|
||||
|
|
@ -146,7 +146,7 @@ msgstr ""
|
|||
|
||||
#: frappe/public/js/frappe/form/grid_row.js:891
|
||||
msgid "1 = True & 0 = False"
|
||||
msgstr ""
|
||||
msgstr "၁ = မှန် နှင့် ၀ = မှား"
|
||||
|
||||
#. Description of the 'Fraction Units' (Int) field in DocType 'Currency'
|
||||
#: frappe/geo/doctype/currency/currency.json
|
||||
|
|
@ -172,7 +172,7 @@ msgstr "လွန်ခဲ့သော ၁ ရက်က"
|
|||
|
||||
#: frappe/public/js/frappe/form/reminders.js:17
|
||||
msgid "1 hour"
|
||||
msgstr ""
|
||||
msgstr "၁ နာရီ"
|
||||
|
||||
#: frappe/public/js/frappe/utils/pretty_date.js:52
|
||||
#: frappe/tests/test_utils.py:904
|
||||
|
|
@ -195,7 +195,7 @@ msgstr "၁ သို့ ၂"
|
|||
|
||||
#: frappe/public/js/frappe/data_import/data_exporter.js:228
|
||||
msgid "1 record will be exported"
|
||||
msgstr ""
|
||||
msgstr "မှတ်တမ်း ၁ခုကို ထုတ်ပေးပါမည်။"
|
||||
|
||||
#: frappe/public/js/frappe/form/footer/version_timeline_content_builder.js:325
|
||||
msgctxt "User removed row from child table"
|
||||
|
|
@ -243,7 +243,7 @@ msgstr "လွန်ခဲ့တဲ့ ၃ မိနစ်က"
|
|||
|
||||
#: frappe/public/js/frappe/form/reminders.js:16
|
||||
msgid "30 minutes"
|
||||
msgstr ""
|
||||
msgstr "မိနစ် 30"
|
||||
|
||||
#: frappe/public/js/frappe/form/reminders.js:18
|
||||
msgid "4 hours"
|
||||
|
|
@ -251,7 +251,7 @@ msgstr ""
|
|||
|
||||
#: frappe/public/js/frappe/data_import/data_exporter.js:37
|
||||
msgid "5 Records"
|
||||
msgstr ""
|
||||
msgstr "မှတ်တမ်း ၅ ခု"
|
||||
|
||||
#: frappe/tests/test_utils.py:907
|
||||
msgid "5 days ago"
|
||||
|
|
@ -259,7 +259,7 @@ msgstr "လွန်ခဲ့သော ၅ ရက်"
|
|||
|
||||
#: frappe/desk/doctype/bulk_update/bulk_update.py:36
|
||||
msgid "; not allowed in condition"
|
||||
msgstr ""
|
||||
msgstr "အခြေအနေအရ ခွင့်မပြုပါ"
|
||||
|
||||
#. Option for the 'Condition' (Select) field in DocType 'Document Naming Rule
|
||||
#. Condition'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ msgstr ""
|
|||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: developers@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-02-15 09:42+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 19:56\n"
|
||||
"PO-Revision-Date: 2026-02-18 20:54\n"
|
||||
"Last-Translator: developers@frappe.io\n"
|
||||
"Language-Team: Portuguese, Brazilian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
|
@ -5149,7 +5149,7 @@ msgstr ""
|
|||
#. Label of the company_name (Data) field in DocType 'Contact'
|
||||
#: frappe/contacts/doctype/contact/contact.json
|
||||
msgid "Company Name"
|
||||
msgstr ""
|
||||
msgstr "Nome da Empresa"
|
||||
|
||||
#: frappe/core/doctype/server_script/server_script.js:14
|
||||
#: frappe/custom/doctype/client_script/client_script.js:60
|
||||
|
|
@ -9995,7 +9995,7 @@ msgstr ""
|
|||
#. Label of the fax (Data) field in DocType 'Address'
|
||||
#: frappe/contacts/doctype/address/address.json
|
||||
msgid "Fax"
|
||||
msgstr ""
|
||||
msgstr "Fax"
|
||||
|
||||
#: frappe/public/js/frappe/form/templates/form_sidebar.html:73
|
||||
msgid "Feedback"
|
||||
|
|
@ -16448,7 +16448,7 @@ msgstr ""
|
|||
#. Log'
|
||||
#: frappe/core/doctype/permission_log/permission_log.json
|
||||
msgid "More Info"
|
||||
msgstr ""
|
||||
msgstr "Mais Informações"
|
||||
|
||||
#. Label of the more_info (Section Break) field in DocType 'Contact'
|
||||
#. Label of the additional_info (Section Break) field in DocType 'Activity Log'
|
||||
|
|
@ -16990,7 +16990,7 @@ msgstr ""
|
|||
#: frappe/public/js/frappe/widgets/widget_dialog.js:72
|
||||
#: frappe/website/doctype/web_form/web_form.py:439
|
||||
msgid "New {0}"
|
||||
msgstr ""
|
||||
msgstr "Novo {0}"
|
||||
|
||||
#: frappe/public/js/frappe/views/reports/query_report.js:394
|
||||
msgid "New {0} Created"
|
||||
|
|
@ -18383,12 +18383,12 @@ msgstr ""
|
|||
#: frappe/desk/doctype/event/event.json frappe/desk/doctype/todo/todo.json
|
||||
#: frappe/workflow/doctype/workflow_action/workflow_action.json
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Aberto"
|
||||
|
||||
#: frappe/desk/doctype/todo/todo_list.js:14
|
||||
msgctxt "Access"
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Aberto"
|
||||
|
||||
#: frappe/desk/page/desktop/desktop.js:489
|
||||
#: frappe/desk/page/desktop/desktop.js:498
|
||||
|
|
@ -18705,7 +18705,7 @@ msgstr ""
|
|||
|
||||
#: frappe/public/js/frappe/form/templates/form_dashboard.html:5
|
||||
msgid "Overview"
|
||||
msgstr ""
|
||||
msgstr "Visão Geral"
|
||||
|
||||
#. Option for the 'Method' (Select) field in DocType 'Recorder'
|
||||
#: frappe/core/doctype/recorder/recorder.json
|
||||
|
|
@ -18857,7 +18857,7 @@ msgstr ""
|
|||
#: frappe/public/js/print_format_builder/PrintFormatSection.vue:63
|
||||
#: frappe/website/doctype/web_form_field/web_form_field.json
|
||||
msgid "Page Break"
|
||||
msgstr ""
|
||||
msgstr "Quebra de Página"
|
||||
|
||||
#. Option for the 'Content Type' (Select) field in DocType 'Web Page'
|
||||
#: frappe/website/doctype/web_page/web_page.js:92
|
||||
|
|
@ -19274,7 +19274,7 @@ msgstr ""
|
|||
#. Option for the 'Type' (Select) field in DocType 'Dashboard Chart'
|
||||
#: frappe/desk/doctype/dashboard_chart/dashboard_chart.json
|
||||
msgid "Percentage"
|
||||
msgstr ""
|
||||
msgstr "Porcentagem"
|
||||
|
||||
#. Label of the dynamic_date_period (Select) field in DocType 'Auto Email
|
||||
#. Report'
|
||||
|
|
@ -20304,7 +20304,7 @@ msgstr ""
|
|||
#. Label of the print_heading (Data) field in DocType 'Print Heading'
|
||||
#: frappe/printing/doctype/print_heading/print_heading.json
|
||||
msgid "Print Heading"
|
||||
msgstr ""
|
||||
msgstr "Cabeçalho de Impressão"
|
||||
|
||||
#. Label of the print_hide (Check) field in DocType 'DocField'
|
||||
#. Label of the print_hide (Check) field in DocType 'Custom Field'
|
||||
|
|
@ -20346,7 +20346,7 @@ msgstr ""
|
|||
#: frappe/public/js/frappe/form/print_utils.js:119
|
||||
#: frappe/public/js/frappe/form/templates/print_layout.html:35
|
||||
msgid "Print Settings"
|
||||
msgstr ""
|
||||
msgstr "Configurações de Impressão"
|
||||
|
||||
#. Label of the print_style_section (Section Break) field in DocType 'Print
|
||||
#. Settings'
|
||||
|
|
@ -21753,7 +21753,7 @@ msgstr ""
|
|||
#: frappe/contacts/doctype/contact/contact.json
|
||||
#: frappe/core/doctype/communication/communication.json
|
||||
msgid "Replied"
|
||||
msgstr ""
|
||||
msgstr "Respondido"
|
||||
|
||||
#. Label of the reply (Text Editor) field in DocType 'Discussion Reply'
|
||||
#: frappe/core/doctype/communication/communication.js:57
|
||||
|
|
@ -24329,7 +24329,7 @@ msgstr ""
|
|||
|
||||
#: frappe/public/js/frappe/form/templates/address_list.html:31
|
||||
msgid "Shipping Address"
|
||||
msgstr ""
|
||||
msgstr "Endereço de Entrega"
|
||||
|
||||
#. Option for the 'Address Type' (Select) field in DocType 'Address'
|
||||
#: frappe/contacts/doctype/address/address.json
|
||||
|
|
@ -27680,7 +27680,7 @@ msgstr ""
|
|||
#: frappe/public/js/frappe/views/reports/query_report.js:1367
|
||||
#: frappe/public/js/frappe/views/reports/report_view.js:1554
|
||||
msgid "Total"
|
||||
msgstr ""
|
||||
msgstr "Total"
|
||||
|
||||
#. Label of the total_background_workers (Int) field in DocType 'System Health
|
||||
#. Report'
|
||||
|
|
@ -27730,7 +27730,7 @@ msgstr ""
|
|||
|
||||
#: frappe/public/js/frappe/views/reports/report_view.js:1254
|
||||
msgid "Totals"
|
||||
msgstr ""
|
||||
msgstr "Totais"
|
||||
|
||||
#: frappe/public/js/frappe/views/reports/report_view.js:1229
|
||||
msgid "Totals Row"
|
||||
|
|
@ -29456,7 +29456,7 @@ msgstr ""
|
|||
#. Option for the 'Address Type' (Select) field in DocType 'Address'
|
||||
#: frappe/contacts/doctype/address/address.json
|
||||
msgid "Warehouse"
|
||||
msgstr ""
|
||||
msgstr "Armazém"
|
||||
|
||||
#. Option for the 'Button Color' (Select) field in DocType 'DocField'
|
||||
#. Option for the 'Button Color' (Select) field in DocType 'Custom Field'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ msgstr ""
|
|||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: developers@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-02-15 09:42+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 19:56\n"
|
||||
"PO-Revision-Date: 2026-02-18 20:54\n"
|
||||
"Last-Translator: developers@frappe.io\n"
|
||||
"Language-Team: Serbian (Cyrillic)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
|
@ -1415,7 +1415,7 @@ msgstr "Додај видео-конференцију"
|
|||
#. Label of the add_x_original_from (Check) field in DocType 'Email Account'
|
||||
#: frappe/email/doctype/email_account/email_account.json
|
||||
msgid "Add X-Original-From header"
|
||||
msgstr ""
|
||||
msgstr "Додај заглавље X-Original-From"
|
||||
|
||||
#: frappe/public/js/frappe/ui/filters/filter_list.js:299
|
||||
msgid "Add a Filter"
|
||||
|
|
@ -6453,7 +6453,7 @@ msgstr "Прилагоди поље обрасца"
|
|||
#: frappe/public/js/frappe/list/list_view.js:1997
|
||||
msgctxt "Customize qucik filters of List View"
|
||||
msgid "Customize Quick Filters"
|
||||
msgstr ""
|
||||
msgstr "Прилагоди брзе филтере"
|
||||
|
||||
#. Description of a Card Break in the Build Workspace
|
||||
#: frappe/core/workspace/build/build.json
|
||||
|
|
@ -24636,7 +24636,7 @@ msgstr "Прикажи контролну таблу"
|
|||
#. Label of the show_description_on_click (Check) field in DocType 'DocField'
|
||||
#: frappe/core/doctype/docfield/docfield.json
|
||||
msgid "Show Description on Click"
|
||||
msgstr ""
|
||||
msgstr "Прикажи опис на клик"
|
||||
|
||||
#. Label of the show_document (Button) field in DocType 'Access Log'
|
||||
#: frappe/core/doctype/access_log/access_log.json
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ msgstr ""
|
|||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: developers@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-02-15 09:42+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 19:57\n"
|
||||
"PO-Revision-Date: 2026-02-18 20:54\n"
|
||||
"Last-Translator: developers@frappe.io\n"
|
||||
"Language-Team: Serbian (Latin)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
|
@ -1416,7 +1416,7 @@ msgstr "Dodaj video-konferenciju"
|
|||
#. Label of the add_x_original_from (Check) field in DocType 'Email Account'
|
||||
#: frappe/email/doctype/email_account/email_account.json
|
||||
msgid "Add X-Original-From header"
|
||||
msgstr ""
|
||||
msgstr "Dodaj zaglavlje X-Original-Form"
|
||||
|
||||
#: frappe/public/js/frappe/ui/filters/filter_list.js:299
|
||||
msgid "Add a Filter"
|
||||
|
|
@ -6454,7 +6454,7 @@ msgstr "Prilagodi polje obrasca"
|
|||
#: frappe/public/js/frappe/list/list_view.js:1997
|
||||
msgctxt "Customize qucik filters of List View"
|
||||
msgid "Customize Quick Filters"
|
||||
msgstr ""
|
||||
msgstr "Prilagodi brze filtere"
|
||||
|
||||
#. Description of a Card Break in the Build Workspace
|
||||
#: frappe/core/workspace/build/build.json
|
||||
|
|
@ -24637,7 +24637,7 @@ msgstr "Prikaži kontrolnu tablu"
|
|||
#. Label of the show_description_on_click (Check) field in DocType 'DocField'
|
||||
#: frappe/core/doctype/docfield/docfield.json
|
||||
msgid "Show Description on Click"
|
||||
msgstr ""
|
||||
msgstr "Prikaži opis na klik"
|
||||
|
||||
#. Label of the show_document (Button) field in DocType 'Access Log'
|
||||
#: frappe/core/doctype/access_log/access_log.json
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ msgstr ""
|
|||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: developers@frappe.io\n"
|
||||
"POT-Creation-Date: 2026-02-15 09:42+0000\n"
|
||||
"PO-Revision-Date: 2026-02-16 19:56\n"
|
||||
"PO-Revision-Date: 2026-02-18 20:54\n"
|
||||
"Last-Translator: developers@frappe.io\n"
|
||||
"Language-Team: Swedish\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
|
@ -18912,7 +18912,7 @@ msgstr "Utdata"
|
|||
|
||||
#: frappe/public/js/frappe/form/templates/form_dashboard.html:5
|
||||
msgid "Overview"
|
||||
msgstr "Recension "
|
||||
msgstr "Översikt"
|
||||
|
||||
#. Option for the 'Method' (Select) field in DocType 'Recorder'
|
||||
#: frappe/core/doctype/recorder/recorder.json
|
||||
|
|
@ -23287,12 +23287,12 @@ msgstr "Omfatning"
|
|||
#: frappe/integrations/doctype/oauth_client/oauth_client.json
|
||||
#: frappe/integrations/doctype/token_cache/token_cache.json
|
||||
msgid "Scopes"
|
||||
msgstr "Omfattningar"
|
||||
msgstr "Omfång"
|
||||
|
||||
#. Label of the scopes_supported (Small Text) field in DocType 'OAuth Settings'
|
||||
#: frappe/integrations/doctype/oauth_settings/oauth_settings.json
|
||||
msgid "Scopes Supported"
|
||||
msgstr "Omfattningar som Stöds"
|
||||
msgstr "Omfång som Stöds"
|
||||
|
||||
#. Label of the report_script (Code) field in DocType 'Report'
|
||||
#. Label of the script (Code) field in DocType 'Server Script'
|
||||
|
|
@ -25688,7 +25688,7 @@ msgstr "Steg för att verifiera din inloggning"
|
|||
#: frappe/core/doctype/docfield/docfield.json
|
||||
#: frappe/public/js/frappe/form/grid_row.js:456
|
||||
msgid "Sticky"
|
||||
msgstr "Klistrad"
|
||||
msgstr "Fast"
|
||||
|
||||
#: frappe/core/doctype/recorder/recorder_list.js:87
|
||||
msgid "Stop"
|
||||
|
|
@ -29565,7 +29565,7 @@ msgstr "Visa Fil"
|
|||
|
||||
#: frappe/public/js/frappe/ui/notifications/notifications.js:260
|
||||
msgid "View Full Log"
|
||||
msgstr "Visa Full Logg"
|
||||
msgstr "Visa Fullständig Logg"
|
||||
|
||||
#: frappe/public/js/frappe/views/treeview.js:494
|
||||
#: frappe/public/js/frappe/widgets/quick_list_widget.js:258
|
||||
|
|
@ -29575,7 +29575,7 @@ msgstr "Visa Lista"
|
|||
#. Name of a DocType
|
||||
#: frappe/core/doctype/view_log/view_log.json
|
||||
msgid "View Log"
|
||||
msgstr "Visa Logg"
|
||||
msgstr "Visning Logg"
|
||||
|
||||
#: frappe/core/doctype/user/user.js:143
|
||||
#: frappe/core/doctype/user_permission/user_permission.js:26
|
||||
|
|
|
|||
|
|
@ -258,4 +258,6 @@ execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Productivity")
|
|||
frappe.patches.v16_0.unset_standard_field_for_auto_generated_icons
|
||||
execute:from frappe.email.doctype.notification.notification import install_notification_templates; install_notification_templates()
|
||||
execute:frappe.db.set_value("Email Account", {}, "add_x_original_from", 1)
|
||||
frappe.patches.v16_0.fix_myanmar_language_name
|
||||
frappe.patches.v16_0.fix_myanmar_language_name
|
||||
execute:frappe.db.set_value("Email Account", {}, "add_reply_to_header", 1)
|
||||
frappe.patches.v16_0.set_reply_to_header
|
||||
|
|
|
|||
15
frappe/patches/v16_0/set_reply_to_header.py
Normal file
15
frappe/patches/v16_0/set_reply_to_header.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import frappe
|
||||
|
||||
|
||||
def execute() -> None:
|
||||
accounts = frappe.db.get_all("Email Account", {"enable_incoming": 1, "enable_outgoing": 1}, pluck="name")
|
||||
for account in accounts:
|
||||
doc = frappe.get_doc("Email Account", account)
|
||||
|
||||
if doc.reply_to_addresses:
|
||||
continue
|
||||
|
||||
doc.append("reply_to_addresses", {"email": doc.email_id})
|
||||
doc.flags.ignore_mandatory = True
|
||||
doc.flags.ignore_validate = True # Ignore SMTP/IMAP validation
|
||||
doc.save()
|
||||
|
|
@ -2,146 +2,83 @@ let frappeCloudBaseEndpoint = "https://frappecloud.com";
|
|||
let isFCUser = false;
|
||||
|
||||
$(document).ready(function () {
|
||||
if (
|
||||
frappe.boot.is_fc_site &&
|
||||
!!frappe.boot.setup_complete &&
|
||||
!frappe.is_mobile() &&
|
||||
frappe.user.has_role("System Manager")
|
||||
) {
|
||||
frappe.call({
|
||||
method: "frappe.integrations.frappe_providers.frappecloud_billing.current_site_info",
|
||||
callback: (r) => {
|
||||
if (!r?.message) return;
|
||||
const response = frappe.boot.site_info;
|
||||
const trial_end_date = new Date(response.trial_end_date);
|
||||
frappeCloudBaseEndpoint = response.base_url;
|
||||
isFCUser = response.is_fc_user;
|
||||
|
||||
const response = r.message;
|
||||
const trial_end_date = new Date(response.trial_end_date);
|
||||
frappeCloudBaseEndpoint = response.base_url;
|
||||
isFCUser = response.is_fc_user;
|
||||
|
||||
if (response.trial_end_date && trial_end_date > new Date()) {
|
||||
if ($(".layout-main-section").closest("#page-desktop").length === 0) {
|
||||
$(".layout-main-section").before(
|
||||
generateTrialSubscriptionBanner(response.trial_end_date)
|
||||
);
|
||||
}
|
||||
}
|
||||
addManageBillingDropdown();
|
||||
|
||||
$(".login-to-fc, .upgrade-plan-button").on("click", function () {
|
||||
openFrappeCloudDashboard();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function setErrorMessage(message) {
|
||||
$("#fc-login-error").text(message);
|
||||
}
|
||||
|
||||
function addManageBillingDropdown() {
|
||||
$(document).on("desktop_screen", function (event, data) {
|
||||
data.desktop.add_menu_item({
|
||||
label: __("Manage Billing"),
|
||||
icon: "receipt-text",
|
||||
condition: function () {
|
||||
return frappe.boot.sysdefaults.demo_company;
|
||||
},
|
||||
onClick: function () {
|
||||
return openFrappeCloudDashboard();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
function openFrappeCloudDashboard() {
|
||||
window.open(`${frappeCloudBaseEndpoint}/dashboard/sites/${frappe.boot.sitename}`, "_blank");
|
||||
}
|
||||
|
||||
function generateTrialSubscriptionBanner(trialEndDate) {
|
||||
const trial_end_date = new Date(trialEndDate);
|
||||
const today = new Date();
|
||||
const diffTime = trial_end_date - today;
|
||||
const trial_end_days = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
const trial_end_string =
|
||||
trial_end_days > 1 ? `${trial_end_days} days` : `${trial_end_days} day`;
|
||||
|
||||
return $(`
|
||||
<style>
|
||||
.trial-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--subtle-accent);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
.trial-banner > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.trial-banner .info-icon {
|
||||
margin: auto 0;
|
||||
}
|
||||
.trial-banner > div > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.trial-banner .title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.trial-banner .description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.trial-banner .upgrade-plan-button {
|
||||
height: fit-content;
|
||||
background-color: var(--fg-color);
|
||||
border: 1px solid var(--gray-300);
|
||||
color: var(--gray-800);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
.trial-banner .upgrade-plan-button:hover {
|
||||
border-color: var(--gray-400);
|
||||
}
|
||||
</style>
|
||||
<div class="trial-banner px-3 py-2 m-2 mt-4">
|
||||
<div>
|
||||
<svg class="info-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3360_13841)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 14.25C11.4518 14.25 14.25 11.4518 14.25 8C14.25 4.54822 11.4518 1.75 8 1.75C4.54822 1.75 1.75 4.54822 1.75 8C1.75 11.4518 4.54822 14.25 8 14.25ZM8 15.25C12.0041 15.25 15.25 12.0041 15.25 8C15.25 3.99594 12.0041 0.75 8 0.75C3.99594 0.75 0.75 3.99594 0.75 8C0.75 12.0041 3.99594 15.25 8 15.25ZM8 5.75C8.48325 5.75 8.875 5.35825 8.875 4.875C8.875 4.39175 8.48325 4 8 4C7.51675 4 7.125 4.39175 7.125 4.875C7.125 5.35825 7.51675 5.75 8 5.75ZM8.5 7.43555C8.5 7.1594 8.27614 6.93555 8 6.93555C7.72386 6.93555 7.5 7.1594 7.5 7.43555V11.143C7.5 11.4191 7.72386 11.643 8 11.643C8.27614 11.643 8.5 11.4191 8.5 11.143V7.43555Z" fill="#171717"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3360_13841">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="title">
|
||||
Your trial ends in ${trial_end_string}.
|
||||
</span>
|
||||
<span class="description">
|
||||
${
|
||||
isFCUser
|
||||
? "Please upgrade for uninterrupted services"
|
||||
: "Please contact your system administrator to upgrade your plan."
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
isFCUser
|
||||
? `<button type="button"
|
||||
class="upgrade-plan-button px-2 py-1"
|
||||
>
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.2641 1C5.5758 1 4.97583 1.46845 4.80889 2.1362L3.57555 7.06953C3.33887 8.01625 4.05491 8.93333 5.03077 8.93333H7.50682L6.72168 14.4293C6.68838 14.6624 6.82229 14.8872 7.04319 14.9689C7.26408 15.0507 7.51204 14.9671 7.63849 14.7684L13.2161 6.00354C13.6398 5.33782 13.1616 4.46667 12.3725 4.46667H9.59038L10.3017 1.62127C10.3391 1.4719 10.3055 1.31365 10.2108 1.19229C10.116 1.07094 9.97063 1 9.81666 1H6.2641ZM5.77903 2.37873C5.83468 2.15615 6.03467 2 6.2641 2H9.17627L8.46492 4.8454C8.42758 4.99477 8.46114 5.15302 8.55589 5.27437C8.65064 5.39573 8.79602 5.46667 8.94999 5.46667H12.3725L8.0395 12.2757L8.5783 8.50404C8.5988 8.36056 8.55602 8.21523 8.46105 8.10573C8.36608 7.99623 8.22827 7.93333 8.08332 7.93333H5.03077C4.70548 7.93333 4.4668 7.62764 4.5457 7.31207L5.77903 2.37873Z" fill="currentColor"/>
|
||||
</svg>
|
||||
${__("Upgrade plan")}
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`);
|
||||
const banner_message = isFCUser
|
||||
? "Please upgrade for uninterrupted services"
|
||||
: "Please contact your system administrator to upgrade your plan.";
|
||||
let card_args = {
|
||||
title: `Your trial ends in ${trial_end_string}`,
|
||||
message: banner_message,
|
||||
outline: true,
|
||||
close_button: true,
|
||||
popper: true,
|
||||
primary_button_alignment: "right",
|
||||
dismiss_key: `${frappe.boot.site_info.name}_trial_card_time`,
|
||||
dismiss_it_for: "day",
|
||||
};
|
||||
if (isFCUser) {
|
||||
$.extend(card_args, {
|
||||
primary_action_label: "Upgrade",
|
||||
primary_action_suffix_icon: "square-arrow-out-up-right",
|
||||
styles: {
|
||||
"sidebar-card-button-bg-color": "var(--surface-gray-2)",
|
||||
"sidebar-card-button-color": "var(--ink-gray-7)",
|
||||
"sidebar-card-button-outline": "var(--ink-gray-7)",
|
||||
},
|
||||
primary_action: () => {
|
||||
openFrappeCloudDashboard();
|
||||
},
|
||||
});
|
||||
}
|
||||
$(document).on("desktop_screen", function (event, data) {
|
||||
if (
|
||||
frappe.boot.is_fc_site &&
|
||||
!!frappe.boot.setup_complete &&
|
||||
!frappe.is_mobile() &&
|
||||
frappe.user.has_role("System Manager")
|
||||
) {
|
||||
if (response.trial_end_date && trial_end_date > new Date()) {
|
||||
card_args.parent = $(".icons-container").first();
|
||||
let banner_card = new frappe.ui.SidebarCard(card_args);
|
||||
}
|
||||
addManageBillingDropdown(data.desktop);
|
||||
|
||||
$(".login-to-fc, .upgrade-plan-button").on("click", function () {
|
||||
openFrappeCloudDashboard();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setErrorMessage(message) {
|
||||
$("#fc-login-error").text(message);
|
||||
}
|
||||
|
||||
function addManageBillingDropdown(desktop) {
|
||||
desktop.add_menu_item({
|
||||
label: __("Manage Billing"),
|
||||
icon: "receipt-text",
|
||||
condition: function () {
|
||||
return frappe.boot.is_fc_site;
|
||||
},
|
||||
onClick: function () {
|
||||
return openFrappeCloudDashboard();
|
||||
},
|
||||
});
|
||||
}
|
||||
function openFrappeCloudDashboard() {
|
||||
window.open(
|
||||
`${frappeCloudBaseEndpoint}/dashboard/sites/${frappe.boot.site_info.name}`,
|
||||
"_blank"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ frappe.ui.form.Footer = class FormFooter {
|
|||
const needs_scroll = scroll_height > client_height;
|
||||
const is_scrolled = scroll_top > 50;
|
||||
$button.toggleClass("show", needs_scroll && is_scrolled);
|
||||
$button.css("right", frappe.is_mobile() && needs_scroll && is_scrolled ? "20px" : "");
|
||||
}
|
||||
make_comment_box() {
|
||||
this.frm.comment_box = frappe.ui.form.make_control({
|
||||
|
|
|
|||
|
|
@ -522,12 +522,11 @@ export default class Grid {
|
|||
this.grid_rows = [];
|
||||
}
|
||||
|
||||
this.truncate_rows();
|
||||
/** @type {Record<string, GridRow>} */
|
||||
this.grid_rows_by_docname = {};
|
||||
|
||||
this.grid_pagination.update_page_numbers();
|
||||
this.render_result_rows($rows, false);
|
||||
this.render_result_rows($rows);
|
||||
this.grid_pagination.check_page_number();
|
||||
this.wrapper.find(".grid-empty").toggleClass("hidden", Boolean(this.data.length));
|
||||
|
||||
|
|
@ -553,14 +552,30 @@ export default class Grid {
|
|||
this.wrapper.trigger("change");
|
||||
}
|
||||
|
||||
render_result_rows($rows, append_row) {
|
||||
render_result_rows($rows) {
|
||||
if (!$rows) {
|
||||
$rows = $(this.parent).find(".rows");
|
||||
}
|
||||
|
||||
let result_length = this.grid_pagination.get_result_length();
|
||||
let page_index = this.grid_pagination.page_index;
|
||||
let page_length = this.grid_pagination.page_length;
|
||||
let page_start = (page_index - 1) * page_length;
|
||||
if (!this.grid_rows) {
|
||||
return;
|
||||
}
|
||||
for (var ri = (page_index - 1) * page_length; ri < result_length; ri++) {
|
||||
|
||||
// index existing rows by doc object reference for identity-based matching
|
||||
let rows_by_doc = new Map();
|
||||
for (let row of this.grid_rows) {
|
||||
if (row?.doc) {
|
||||
rows_by_doc.set(row.doc, row);
|
||||
}
|
||||
}
|
||||
|
||||
let matched_rows = new Set();
|
||||
|
||||
for (var ri = page_start; ri < result_length; ri++) {
|
||||
var d = this.data[ri];
|
||||
if (!d) {
|
||||
return;
|
||||
|
|
@ -571,10 +586,10 @@ export default class Grid {
|
|||
if (d.name === undefined) {
|
||||
d.name = this.get_random_name();
|
||||
}
|
||||
let grid_row;
|
||||
if (this.grid_rows[ri] && !append_row) {
|
||||
grid_row = this.grid_rows[ri];
|
||||
grid_row.doc = d;
|
||||
|
||||
let grid_row = rows_by_doc.get(d);
|
||||
if (grid_row) {
|
||||
matched_rows.add(grid_row);
|
||||
grid_row.refresh();
|
||||
} else {
|
||||
grid_row = new GridRow({
|
||||
|
|
@ -585,16 +600,50 @@ export default class Grid {
|
|||
frm: this.frm,
|
||||
grid: this,
|
||||
});
|
||||
this.grid_rows[ri] = grid_row;
|
||||
}
|
||||
|
||||
this.grid_rows[ri] = grid_row;
|
||||
this.grid_rows_by_docname[d.name] = grid_row;
|
||||
}
|
||||
|
||||
// remove stale / invisible rows
|
||||
for (let [, row] of rows_by_doc) {
|
||||
if (!matched_rows.has(row)) {
|
||||
row.wrapper.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// reorder DOM from the first mismatch onward
|
||||
let $children = $rows.children();
|
||||
let page_count = result_length - page_start;
|
||||
let reorder_from = -1;
|
||||
for (let i = 0; i < page_count; i++) {
|
||||
if ($children.get(i) !== this.grid_rows[page_start + i].wrapper.get(0)) {
|
||||
reorder_from = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (reorder_from >= 0) {
|
||||
for (let ri = page_start + reorder_from; ri < result_length; ri++) {
|
||||
$rows.append(this.grid_rows[ri].wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
// clear non-visible slots to prevent duplicates and stale references
|
||||
for (let i = 0; i < this.grid_rows.length; i++) {
|
||||
if (i < page_start || i >= result_length) {
|
||||
delete this.grid_rows[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (this.grid_rows.length > this.data.length) {
|
||||
this.grid_rows.length = this.data.length;
|
||||
}
|
||||
}
|
||||
|
||||
setup_toolbar() {
|
||||
if (this.is_editable()) {
|
||||
this.wrapper.find(".grid-footer").toggle(true);
|
||||
const is_editable = this.is_editable();
|
||||
if (is_editable) {
|
||||
this.wrapper.find(".grid-footer").removeClass("hidden");
|
||||
|
||||
const num_selected_rows = this.get_selected_children().length;
|
||||
// show, hide buttons to add rows
|
||||
|
|
@ -619,23 +668,12 @@ export default class Grid {
|
|||
this.grid_rows.length < this.grid_pagination.page_length &&
|
||||
!this.df.allow_bulk_edit
|
||||
) {
|
||||
this.wrapper.find(".grid-footer").toggle(false);
|
||||
this.wrapper.find(".grid-footer").addClass("hidden");
|
||||
}
|
||||
|
||||
this.wrapper
|
||||
.find(".grid-add-row, .grid-add-multiple-rows, .grid-upload")
|
||||
.toggle(this.is_editable());
|
||||
}
|
||||
|
||||
truncate_rows() {
|
||||
if (this.grid_rows.length > this.data.length) {
|
||||
// remove extra rows
|
||||
for (var i = this.data.length; i < this.grid_rows.length; i++) {
|
||||
var grid_row = this.grid_rows[i];
|
||||
if (grid_row) grid_row.wrapper.remove();
|
||||
}
|
||||
this.grid_rows.splice(this.data.length);
|
||||
}
|
||||
.toggleClass("hidden", !is_editable);
|
||||
}
|
||||
|
||||
setup_fields() {
|
||||
|
|
@ -1333,7 +1371,9 @@ export default class Grid {
|
|||
}
|
||||
|
||||
for (let row of this.grid_rows) {
|
||||
let docfield = row?.docfields?.find((d) => d.fieldname === fieldname);
|
||||
if (!row) continue;
|
||||
|
||||
let docfield = row.docfields?.find((d) => d.fieldname === fieldname);
|
||||
if (docfield) {
|
||||
docfield[property] = value;
|
||||
} else {
|
||||
|
|
@ -1357,7 +1397,7 @@ export default class Grid {
|
|||
get_current_row(target) {
|
||||
let current_row = null;
|
||||
for (let i = 0; i < this.grid_rows.length; i++) {
|
||||
if (this.grid_rows[i].wrapper.get(0).contains(target)) {
|
||||
if (this.grid_rows[i]?.wrapper.get(0).contains(target)) {
|
||||
current_row = i;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,8 +150,7 @@ export default class GridPagination {
|
|||
} else {
|
||||
this.page_index = index;
|
||||
}
|
||||
let $rows = $(this.grid.parent).find(".rows").empty();
|
||||
this.grid.render_result_rows($rows, true);
|
||||
this.grid.render_result_rows();
|
||||
if (this.$page_number) {
|
||||
this.$page_number.val(index);
|
||||
this.$page_number.css("width", (index.toString().length + 1) * 8 + "px");
|
||||
|
|
|
|||
|
|
@ -53,25 +53,14 @@ export default class GridRow {
|
|||
this.wrapper.appendTo(this.parent);
|
||||
}
|
||||
|
||||
set_docfields(update = false) {
|
||||
set_docfields() {
|
||||
if (this.doc && this.parent_df.options) {
|
||||
frappe.meta.make_docfield_copy_for(
|
||||
this.docfields = frappe.meta.get_docfields(
|
||||
this.parent_df.options,
|
||||
this.doc.name,
|
||||
this.docfields
|
||||
null,
|
||||
this.grid.docfields
|
||||
);
|
||||
const docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name);
|
||||
if (update) {
|
||||
// to maintain references
|
||||
this.docfields.forEach((df) => {
|
||||
Object.assign(
|
||||
df,
|
||||
docfields.find((d) => d.fieldname === df.fieldname)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.docfields = docfields;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,11 +187,6 @@ export default class GridRow {
|
|||
);
|
||||
}
|
||||
refresh() {
|
||||
// update docfields for new record
|
||||
if (this.frm && this.doc && this.doc.__islocal) {
|
||||
this.set_docfields(true);
|
||||
}
|
||||
|
||||
if (this.frm && this.doc) {
|
||||
this.doc = locals[this.doc.doctype][this.doc.name];
|
||||
}
|
||||
|
|
@ -787,9 +771,7 @@ export default class GridRow {
|
|||
}
|
||||
});
|
||||
|
||||
let current_grid = $(
|
||||
`div[data-fieldname="${this.grid.df.fieldname}"] .form-grid-container`
|
||||
);
|
||||
let current_grid = this.grid.wrapper.find(".form-grid-container");
|
||||
if (total_colsize > 10) {
|
||||
current_grid.addClass("column-limit-reached");
|
||||
} else if (current_grid.hasClass("column-limit-reached")) {
|
||||
|
|
@ -1293,11 +1275,14 @@ export default class GridRow {
|
|||
return false;
|
||||
}
|
||||
|
||||
base.toggle_editable_row();
|
||||
var input = base.columns[fieldname].field.$input;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
field.parse_validate_and_set_in_model(field.get_input_value()).then(() => {
|
||||
base.toggle_editable_row();
|
||||
const input = base.columns[fieldname].field.$input;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -1595,6 +1580,12 @@ export default class GridRow {
|
|||
|
||||
const currency = frappe.meta.get_field_currency(df, this.doc);
|
||||
const symbol = window.get_currency_symbol(currency);
|
||||
|
||||
// skip if compound symbols like in case of EGP - "£ or ج."
|
||||
if (symbol && (symbol.includes(" or ") || symbol.length > 3)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const show_on_right =
|
||||
cint(frappe.model.get_value(":Currency", currency, "symbol_on_right")) === 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class InfoCard {
|
|||
trigger: $(this.label_span).find("svg").get(0),
|
||||
close_button: true,
|
||||
popper: true,
|
||||
primary_button_width: "full",
|
||||
};
|
||||
if (this.df.documentation_url) {
|
||||
card_args.primary_action_label = "Read More";
|
||||
|
|
|
|||
|
|
@ -107,22 +107,27 @@ export default class ListSettings {
|
|||
}
|
||||
let is_sortable = idx == 0 ? `` : `sortable`;
|
||||
let show_sortable_handle = idx == 0 ? `hide` : ``;
|
||||
let can_remove = idx == 0 || is_status_field(me.fields[idx]) ? `hide` : ``;
|
||||
let can_remove = idx == 0 || is_status_field(me.fields[idx]) ? `hide` : `d-flex`;
|
||||
|
||||
fields += `
|
||||
<div class="control-input flex align-center form-control fields_order ${is_sortable}"
|
||||
style="display: block; margin-bottom: 5px;" data-fieldname="${me.fields[idx].fieldname}"
|
||||
data-label="${me.fields[idx].label}" data-type="${me.fields[idx].type}">
|
||||
<div class="control-input form-control fields_order ${is_sortable} flex"
|
||||
style="margin-bottom: 5px; padding-bottom: 1.5px;"
|
||||
data-fieldname="${me.fields[idx].fieldname}"
|
||||
data-label="${me.fields[idx].label}"
|
||||
data-type="${me.fields[idx].type}">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-1">
|
||||
<div class="row flex-fill align-items-center">
|
||||
<div class="col-1 d-flex align-items-center justify-content-center px-1">
|
||||
${frappe.utils.icon("drag", "xs", "", "", "sortable-handle " + show_sortable_handle)}
|
||||
</div>
|
||||
<div class="col-10" style="padding-left:0px;">
|
||||
|
||||
<div class="col d-flex align-items-center px-0">
|
||||
${__(me.fields[idx].label, null, me.doctype)}
|
||||
</div>
|
||||
<div class="col-1 ${can_remove} pl-0 pl-sm-3">
|
||||
<a class="text-muted remove-field" data-fieldname="${me.fields[idx].fieldname}">
|
||||
|
||||
<div class="col-1 d-flex align-items-center justify-content-center px-0">
|
||||
<a class="text-muted remove-field align-items-center ${can_remove}"
|
||||
data-fieldname="${me.fields[idx].fieldname}">
|
||||
${frappe.utils.icon("x", "xs")}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ $.extend(frappe.meta, {
|
|||
};
|
||||
},
|
||||
|
||||
get_docfields: function (doctype, name, filters) {
|
||||
var docfield_map = frappe.meta.get_docfield_copy(doctype, name);
|
||||
get_docfields: function (doctype, name, filters, docfield_list = null) {
|
||||
var docfield_map = frappe.meta.get_docfield_copy(doctype, name, docfield_list);
|
||||
|
||||
var docfields = frappe.meta.sort_docfields(docfield_map);
|
||||
|
||||
|
|
@ -125,11 +125,11 @@ $.extend(frappe.meta, {
|
|||
});
|
||||
},
|
||||
|
||||
get_docfield_copy: function (doctype, name) {
|
||||
get_docfield_copy: function (doctype, name, docfield_list = null) {
|
||||
if (!name) return frappe.meta.docfield_map[doctype];
|
||||
|
||||
if (!(frappe.meta.docfield_copy[doctype] && frappe.meta.docfield_copy[doctype][name])) {
|
||||
frappe.meta.make_docfield_copy_for(doctype, name);
|
||||
frappe.meta.make_docfield_copy_for(doctype, name, docfield_list);
|
||||
}
|
||||
|
||||
return frappe.meta.docfield_copy[doctype][name];
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ Object.assign(frappe.model, {
|
|||
}
|
||||
|
||||
// child table, override each row and append new rows if required
|
||||
const incoming_names = new Set(updated_doc[fieldname].map((d) => d.name));
|
||||
for (let i = 0; i < updated_doc[fieldname].length; i++) {
|
||||
let updated_child_doc = updated_doc[fieldname][i];
|
||||
let local_child_doc_in_parent = local_parent_doc[fieldname][i];
|
||||
|
|
@ -143,8 +144,12 @@ Object.assign(frappe.model, {
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if (local_child_doc_in_parent) {
|
||||
// deleted and added again
|
||||
if (
|
||||
local_child_doc_in_parent &&
|
||||
!incoming_names.has(local_child_doc_in_parent.name)
|
||||
) {
|
||||
// row at this position is truly deleted/replaced — safe to
|
||||
// reuse the object for the incoming row
|
||||
if (!locals[updated_child_doc.doctype])
|
||||
locals[updated_child_doc.doctype] = {};
|
||||
|
||||
|
|
@ -157,21 +162,30 @@ Object.assign(frappe.model, {
|
|||
|
||||
// if incoming row is not registered, register it
|
||||
if (!locals[updated_child_doc.doctype][updated_child_doc.name]) {
|
||||
const old_name = local_child_doc_in_parent.name;
|
||||
|
||||
// detach old key
|
||||
delete locals[updated_child_doc.doctype][
|
||||
local_child_doc_in_parent.name
|
||||
];
|
||||
delete locals[updated_child_doc.doctype][old_name];
|
||||
|
||||
// re-attach with new name
|
||||
locals[updated_child_doc.doctype][updated_child_doc.name] =
|
||||
local_child_doc_in_parent;
|
||||
|
||||
// migrate per-row docfield overrides to new name
|
||||
const dc = frappe.meta.docfield_copy[updated_child_doc.doctype];
|
||||
if (dc?.[old_name]) {
|
||||
dc[updated_child_doc.name] = dc[old_name];
|
||||
delete dc[old_name];
|
||||
}
|
||||
}
|
||||
|
||||
// row exists, just copy the values
|
||||
Object.assign(local_child_doc_in_parent, updated_child_doc);
|
||||
clear_keys(updated_child_doc, local_child_doc_in_parent);
|
||||
} else {
|
||||
local_parent_doc[fieldname].push(updated_child_doc);
|
||||
// row at this position is needed at a different index
|
||||
// (or no row here) — create a fresh local entry
|
||||
local_parent_doc[fieldname][i] = updated_child_doc;
|
||||
if (!updated_child_doc.parent) updated_child_doc.parent = updated_doc.name;
|
||||
frappe.model.add_to_locals(updated_child_doc);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ frappe.ui.Sidebar = class Sidebar {
|
|||
this.workspace_sidebar_items = updated_items;
|
||||
}
|
||||
setup(workspace_title) {
|
||||
$(document).trigger("sidebar_setup", { sidebar: this });
|
||||
this.sidebar_title = workspace_title;
|
||||
this.check_for_private_workspace(workspace_title);
|
||||
this.workspace_title = this.sidebar_title.toLowerCase();
|
||||
|
|
@ -110,11 +111,9 @@ frappe.ui.Sidebar = class Sidebar {
|
|||
this.add_sidebar_cards();
|
||||
}
|
||||
add_card(card) {
|
||||
if (
|
||||
this.desktop_menu_items &&
|
||||
this.desktop_menu_items.find((i) => i.to_title_case === card.title)
|
||||
)
|
||||
return;
|
||||
if (this.cards && this.cards.find((i) => i.title === card.title)) return;
|
||||
card.parent = this.wrapper.find(".body-sidebar-cards");
|
||||
delete card.styles;
|
||||
this.cards.push(card);
|
||||
}
|
||||
add_sidebar_cards() {
|
||||
|
|
|
|||
|
|
@ -10,15 +10,16 @@
|
|||
{% } else { %}
|
||||
<div class="card-title-container card-close-button">
|
||||
{%= frappe.utils.icon(card.icon, "sm", "", "", "card-icon") %}
|
||||
<span class="flex flex-column">
|
||||
<span class="flex flex-column w-100">
|
||||
<div class="sidebar-card-title">{{ card.title }}</div>
|
||||
<div class="sidebar-card-description">{{ card.message }}</div>
|
||||
</span>
|
||||
{%= frappe.utils.icon("x","sm", "", "", "card-icon cursor-pointer") %}
|
||||
{%= frappe.utils.icon("x","sm", "", "", "card-icon cursor-pointer close-button") %}
|
||||
</div>
|
||||
{% } %}
|
||||
{% if(card.primary_action_label) { %}
|
||||
<button class="sidebar-card-button btn">
|
||||
<div class="sidebar-card-actions flex">
|
||||
{% if(card.primary_action_label) { %}
|
||||
<button class="sidebar-card-button btn {%= card.primary_button_width ? 'w-100' : '' %}">
|
||||
{% if (card.primary_action_icon) %}
|
||||
{{ frappe.utils.icon(card.primary_action_icon, "sm", "", "", "", true) }}
|
||||
{% } %}
|
||||
|
|
@ -28,4 +29,5 @@
|
|||
{% endif %}
|
||||
</button>
|
||||
{% } %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
import { createPopper } from "@popperjs/core";
|
||||
frappe.provide("frappe.ui");
|
||||
|
||||
// icon, title, message, condition, primary_action_label, primary_action
|
||||
frappe.ui.SidebarCard = class SidebarCard {
|
||||
constructor(opts) {
|
||||
Object.assign(this, opts);
|
||||
this.alignment_style_map = {
|
||||
right: "flex-end",
|
||||
left: "flex-start",
|
||||
};
|
||||
this.dismiss_intervals = {
|
||||
minute: 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
this.make(opts);
|
||||
this.setup();
|
||||
this.display = false;
|
||||
this.set_styles();
|
||||
}
|
||||
make() {
|
||||
|
|
@ -19,6 +27,13 @@ frappe.ui.SidebarCard = class SidebarCard {
|
|||
card: this,
|
||||
})
|
||||
);
|
||||
if (this.dismiss_it_for) {
|
||||
const next_time_for_show = localStorage.getItem(this.get_dismiss_key());
|
||||
if (next_time_for_show && Date.now() < Number(next_time_for_show)) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.popper) {
|
||||
this.popper = createPopper($(this.trigger).get(0), $(this.parent).get(0), {
|
||||
modifiers: [
|
||||
|
|
@ -31,10 +46,17 @@ frappe.ui.SidebarCard = class SidebarCard {
|
|||
],
|
||||
});
|
||||
}
|
||||
if (this.outline) {
|
||||
this.card.addClass("card-outline");
|
||||
this.card.removeClass("px-2 py-2");
|
||||
}
|
||||
this.card.prependTo(this.parent);
|
||||
this.set_button_alignment();
|
||||
this.show();
|
||||
}
|
||||
setup() {
|
||||
this.setup_primary_action();
|
||||
this.setup_close_button();
|
||||
}
|
||||
toggle() {
|
||||
if (this.display) {
|
||||
|
|
@ -46,11 +68,18 @@ frappe.ui.SidebarCard = class SidebarCard {
|
|||
hide() {
|
||||
this.display = false;
|
||||
this.parent.removeAttr("data-show");
|
||||
this.card.removeClass("d-inline-flex");
|
||||
this.card.addClass("hidden");
|
||||
}
|
||||
show() {
|
||||
this.display = true;
|
||||
this.parent.attr("data-show", "");
|
||||
this.popper.update();
|
||||
this.popper && this.popper.update();
|
||||
this.card.addClass("d-inline-flex");
|
||||
this.card.removeClass("hidden");
|
||||
}
|
||||
get_dismiss_key() {
|
||||
return this.dismiss_key || "card_next_show_time";
|
||||
}
|
||||
setup_primary_action() {
|
||||
const me = this;
|
||||
|
|
@ -59,6 +88,19 @@ frappe.ui.SidebarCard = class SidebarCard {
|
|||
me.primary_action(event);
|
||||
});
|
||||
}
|
||||
setup_close_button() {
|
||||
const me = this;
|
||||
if (this.close_button) {
|
||||
this.card.find(".close-button").on("click", function () {
|
||||
if (me.dismiss_it_for) {
|
||||
let next_show_time = Date.now() + me.dismiss_intervals[me.dismiss_it_for];
|
||||
|
||||
localStorage.setItem(me.get_dismiss_key(), next_show_time);
|
||||
}
|
||||
me.toggle();
|
||||
});
|
||||
}
|
||||
}
|
||||
set_styles() {
|
||||
if (this.styles) {
|
||||
const $root = $(":root");
|
||||
|
|
@ -67,4 +109,11 @@ frappe.ui.SidebarCard = class SidebarCard {
|
|||
}
|
||||
}
|
||||
}
|
||||
set_button_alignment() {
|
||||
if (this.primary_button_alignment) {
|
||||
this.card
|
||||
.find(".sidebar-card-actions")
|
||||
.css("justifyContent", this.alignment_style_map[this.primary_button_alignment]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ frappe.ui.SidebarHeader = class SidebarHeader {
|
|||
const me = this;
|
||||
this.sibling_workspaces = this.fetch_related_icons();
|
||||
this.dropdown_items = [
|
||||
{
|
||||
name: "desktop",
|
||||
label: __("Desktop"),
|
||||
icon: "layout-grid",
|
||||
onClick: function (el) {
|
||||
frappe.set_route("/desk");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workspaces",
|
||||
label: "Workspaces",
|
||||
|
|
@ -16,14 +24,6 @@ frappe.ui.SidebarHeader = class SidebarHeader {
|
|||
},
|
||||
items: this.sibling_workspaces,
|
||||
},
|
||||
{
|
||||
name: "desktop",
|
||||
label: __("Desktop"),
|
||||
icon: "layout-grid",
|
||||
onClick: function (el) {
|
||||
frappe.set_route("/desk");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edit-sidebar",
|
||||
label: __("Edit Sidebar"),
|
||||
|
|
@ -47,12 +47,6 @@ frappe.ui.SidebarHeader = class SidebarHeader {
|
|||
if (frappe.boot.desk_settings.notifications) {
|
||||
let is_dark = frappe.ui.get_current_theme() === "dark";
|
||||
this.dropdown_items.push(
|
||||
{
|
||||
name: "help",
|
||||
label: "Help",
|
||||
icon: "info",
|
||||
items: this.get_help_siblings(),
|
||||
},
|
||||
{
|
||||
label: "Session Defaults",
|
||||
action: "frappe.ui.toolbar.setup_session_defaults()",
|
||||
|
|
@ -76,6 +70,12 @@ frappe.ui.SidebarHeader = class SidebarHeader {
|
|||
action: "new frappe.ui.ThemeSwitcher().show()",
|
||||
is_standard: 1,
|
||||
icon: is_dark ? "sun" : "moon",
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
label: "Help",
|
||||
icon: "info",
|
||||
items: this.get_help_siblings(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,7 +234,6 @@ frappe.ui.sidebar_item.TypeSectionBreak = class SectionBreakSidebarItem extends
|
|||
} else {
|
||||
$(me.wrapper.find(".section-break")).addClass("hidden");
|
||||
$(me.wrapper.find(".divider")).removeClass("hidden");
|
||||
$(me.wrapper).removeAttr("data-original-title");
|
||||
me.old_state = me.collapsed;
|
||||
me.open();
|
||||
if (me.item.indent) {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ frappe.search.AwesomeBar = class AwesomeBar {
|
|||
setTimeout(() => input.focus(), 10);
|
||||
});
|
||||
|
||||
let search_modal_body = `<div class="align-baseline flex py-2 px-1 relative navbar-modal-wrapper">
|
||||
<div class="modal-search-icon absolute pr-2 pl-2">${frappe.utils.icon("search")}</div>
|
||||
let search_modal_body = `<div class="align-baseline flex p-2 relative navbar-modal-wrapper">
|
||||
<input
|
||||
id="navbar-search"
|
||||
type="text"
|
||||
|
|
@ -41,16 +40,18 @@ frappe.search.AwesomeBar = class AwesomeBar {
|
|||
let search_modal_footer = `<div class="awesomebar-modal-footer flex justify-between w-100">
|
||||
<div class="help-navigation">
|
||||
<span class="help-item-navigate">
|
||||
<span class="help-item">${frappe.utils.icon("arrow-up")}</span>
|
||||
<span class="help-item">${frappe.utils.icon("arrow-down")}</span>
|
||||
<span class="help-item">${frappe.utils.icon("arrow-up", "xs")}</span>
|
||||
<span class="help-item">${frappe.utils.icon("arrow-down", "xs")}</span>
|
||||
<span>${__("to navigate")}</span>
|
||||
</span>
|
||||
<span class="help-item-navigate">
|
||||
<span class="help-item">${frappe.utils.icon("corner-down-left")}</span>
|
||||
<span class="help-item">${frappe.utils.icon("corner-down-left", "xs")}</span>
|
||||
<span>${__("to select")}</span>
|
||||
</span>
|
||||
<span class="help-item help-item-esc">${__("esc")}</span>
|
||||
<span>${__("to close")}</span>
|
||||
<span class="help-item-navigate">
|
||||
<span class="help-item help-item-escape">${__("esc")}</span>
|
||||
<span>${__("to close")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pointer">${frappe.utils.icon("circle-question-mark")}</div>
|
||||
</div>`;
|
||||
|
|
@ -219,45 +220,20 @@ frappe.search.AwesomeBar = class AwesomeBar {
|
|||
}
|
||||
|
||||
show_help() {
|
||||
const txt =
|
||||
'<table class="table table-bordered">\
|
||||
<tr><td style="width: 50%">' +
|
||||
__("Create a new record") +
|
||||
"</td><td>" +
|
||||
__("new type of document") +
|
||||
"</td></tr>\
|
||||
<tr><td>" +
|
||||
__("List a document type") +
|
||||
"</td><td>" +
|
||||
__("document type..., e.g. customer") +
|
||||
"</td></tr>\
|
||||
<tr><td>" +
|
||||
__("Search in a document type") +
|
||||
"</td><td>" +
|
||||
__("text in document type") +
|
||||
"</td></tr>\
|
||||
<tr><td>" +
|
||||
__("Tags") +
|
||||
"</td><td>" +
|
||||
__("tag name..., e.g. #tag") +
|
||||
"</td></tr>\
|
||||
<tr><td>" +
|
||||
__("Open a module or tool") +
|
||||
"</td><td>" +
|
||||
__("module name...") +
|
||||
"</td></tr>\
|
||||
<tr><td>" +
|
||||
__("Open in new tab") +
|
||||
"</td><td>" +
|
||||
(frappe.utils.is_mac() ? "⌘ + Enter" : "Ctrl + Enter") +
|
||||
"</td></tr>\
|
||||
<tr><td>" +
|
||||
__("Calculate") +
|
||||
"</td><td>" +
|
||||
__("e.g. (55 + 434) / 4 or =Math.sin(Math.PI/2)...") +
|
||||
"</td></tr>\
|
||||
</table>";
|
||||
frappe.msgprint(txt, __("Search Help"));
|
||||
const help_data = [
|
||||
[__("Create a new record"), __("new type of document")],
|
||||
[__("List a document type"), __("document type..., e.g. customer")],
|
||||
[__("Search in a document type"), __("text in document type")],
|
||||
[__("Tags"), __("tag name..., e.g. #tag")],
|
||||
[__("Open a module or tool"), __("module name...")],
|
||||
[__("Open in new tab"), frappe.utils.is_mac() ? "⌘ + Enter" : "Ctrl + Enter"],
|
||||
[__("Calculate"), __("e.g. (55 + 434) / 4")],
|
||||
];
|
||||
frappe.msgprint({
|
||||
message: help_data,
|
||||
title: __("Search Help"),
|
||||
as_table: true,
|
||||
});
|
||||
}
|
||||
|
||||
set_specifics(txt, end_txt) {
|
||||
|
|
|
|||
|
|
@ -180,76 +180,40 @@ frappe.views.Workspace = class Workspace {
|
|||
this._page = current_page;
|
||||
const me = this;
|
||||
let header_dropdown = `${__(this._page.name)}`;
|
||||
let menu_items = [
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "edit",
|
||||
onClick: async () => {
|
||||
if (!this.editor || !this.editor.readOnly) return;
|
||||
this.is_read_only = false;
|
||||
await this.editor.readOnly.toggle();
|
||||
this.editor.isReady.then(() => {
|
||||
this.setup_customization_buttons(this._page);
|
||||
this.make_blocks_sortable();
|
||||
});
|
||||
},
|
||||
condition: () => {
|
||||
return current_page.is_editable;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "New",
|
||||
icon: "plus",
|
||||
onClick: function () {
|
||||
me.initialize_new_page(true);
|
||||
},
|
||||
condition: () => {
|
||||
return me.has_create_access;
|
||||
},
|
||||
},
|
||||
];
|
||||
if (frappe.is_mobile()) {
|
||||
frappe.breadcrumbs.add({
|
||||
type: "Custom",
|
||||
label: header_dropdown + `${frappe.utils.icon("chevron-down")}`,
|
||||
route: "#",
|
||||
menu_items: menu_items,
|
||||
});
|
||||
} else {
|
||||
frappe.breadcrumbs.add({
|
||||
type: "Custom",
|
||||
label: header_dropdown,
|
||||
route: "#",
|
||||
});
|
||||
if (!this.add_workspace_controls) {
|
||||
let workspace_actions_button = this.page.add_action_icon("ellipsis", "", "");
|
||||
$(workspace_actions_button).removeAttr("data-original-title");
|
||||
$(workspace_actions_button).removeClass("btn-default");
|
||||
frappe.ui.create_menu({
|
||||
parent: $(workspace_actions_button),
|
||||
open_on_left: true,
|
||||
size: "fit-content",
|
||||
menu_items: [
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "edit",
|
||||
onClick: async () => {
|
||||
if (!this.editor || !this.editor.readOnly) return;
|
||||
this.is_read_only = false;
|
||||
await this.editor.readOnly.toggle();
|
||||
this.editor.isReady.then(() => {
|
||||
this.setup_customization_buttons(this._page);
|
||||
this.make_blocks_sortable();
|
||||
});
|
||||
},
|
||||
condition: () => {
|
||||
return current_page.is_editable;
|
||||
},
|
||||
frappe.breadcrumbs.add({
|
||||
type: "Custom",
|
||||
label: header_dropdown,
|
||||
route: "#",
|
||||
});
|
||||
if (!this.add_workspace_controls) {
|
||||
this.workspace_actions_button = this.page.add_action_icon("ellipsis", "", "");
|
||||
|
||||
$(this.workspace_actions_button).removeAttr("data-original-title");
|
||||
$(this.workspace_actions_button).removeClass("btn-default");
|
||||
frappe.ui.create_menu({
|
||||
parent: $(this.workspace_actions_button),
|
||||
open_on_left: true,
|
||||
size: "fit-content",
|
||||
menu_items: [
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "edit",
|
||||
onClick: async () => {
|
||||
if (!this.editor || !this.editor.readOnly) return;
|
||||
this.is_read_only = false;
|
||||
await this.editor.readOnly.toggle();
|
||||
this.editor.isReady.then(() => {
|
||||
this.setup_customization_buttons(this._page);
|
||||
this.make_blocks_sortable();
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
this.add_workspace_controls = true;
|
||||
}
|
||||
condition: () => {
|
||||
return current_page.is_editable;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
this.add_workspace_controls = true;
|
||||
}
|
||||
|
||||
this.wrapper.find(".workspace-header").hide();
|
||||
|
|
@ -416,6 +380,7 @@ frappe.views.Workspace = class Workspace {
|
|||
frappe.set_route(`workspace/${page.name}`);
|
||||
});
|
||||
}
|
||||
$(this.workspace_actions_button).remove();
|
||||
}
|
||||
|
||||
make_blocks_sortable() {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
.row-index {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.row-index {
|
||||
left: 31px;
|
||||
|
|
@ -140,6 +141,7 @@
|
|||
position: sticky;
|
||||
left: 0;
|
||||
background-color: var(--fg-color);
|
||||
z-index: 1;
|
||||
}
|
||||
.row-index {
|
||||
left: 31px;
|
||||
|
|
@ -313,6 +315,8 @@
|
|||
|
||||
.link-btn {
|
||||
background-color: var(--bg-color);
|
||||
height: calc(100% - 4px);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
|
|
@ -381,12 +385,7 @@
|
|||
line-height: 1.3 !important;
|
||||
}
|
||||
}
|
||||
.data-row {
|
||||
div[data-fieldname="options"],
|
||||
div[data-fieldtype="Text Editor"] {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-static-col {
|
||||
background-color: var(--fg-color);
|
||||
&.sticky-grid-col {
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ body.modal-open[style^="padding-right"] {
|
|||
top: 0;
|
||||
z-index: 3;
|
||||
background: inherit;
|
||||
padding: var(--padding-sm) var(--padding-lg);
|
||||
// padding-bottom: 0;
|
||||
padding: var(--padding-sm) var(--padding-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.modal-title {
|
||||
|
|
@ -66,7 +65,7 @@ body.modal-open[style^="padding-right"] {
|
|||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--padding-md) var(--padding-lg);
|
||||
padding: var(--padding-md) var(--padding-md);
|
||||
.form-layout:first-child > .form-page {
|
||||
.visible-section:first-child {
|
||||
padding-top: 0;
|
||||
|
|
@ -93,7 +92,7 @@ body.modal-open[style^="padding-right"] {
|
|||
bottom: 0;
|
||||
z-index: 1;
|
||||
background: inherit;
|
||||
padding: var(--padding-md) var(--padding-lg);
|
||||
padding: var(--padding-md);
|
||||
justify-content: space-between;
|
||||
|
||||
button {
|
||||
|
|
|
|||
|
|
@ -492,7 +492,7 @@
|
|||
position: fixed;
|
||||
right: calc(var(--form-sidebar-width, 277px) + 20px);
|
||||
bottom: 20px;
|
||||
z-index: 1050;
|
||||
z-index: 1030;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
|
|
|
|||
|
|
@ -493,7 +493,7 @@ input.list-header-checkbox {
|
|||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
padding: 0 var(--padding-xs);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-selector .btn-group {
|
||||
|
|
|
|||
|
|
@ -86,12 +86,8 @@
|
|||
}
|
||||
|
||||
.navbar-modal-wrapper {
|
||||
.modal-search-icon {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#navbar-search {
|
||||
padding-left: 42px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.awesomplete {
|
||||
|
|
@ -127,17 +123,23 @@
|
|||
.awesomebar-modal-footer {
|
||||
font-size: 12px;
|
||||
.help-navigation {
|
||||
display: flex;
|
||||
gap: var(--margin-md);
|
||||
.help-item-navigate {
|
||||
margin-right: 1rem;
|
||||
display: flex;
|
||||
gap: var(--margin-xs);
|
||||
align-items: center;
|
||||
}
|
||||
.help-item {
|
||||
background-color: var(--border-color);
|
||||
padding: 2px;
|
||||
margin-right: 0.25rem;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
font-size: x-small;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.help-item-esc {
|
||||
.help-item-escape {
|
||||
padding: 2px 4px;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
:root {
|
||||
--sidebar-card-button-outline: var(--surface-blue-3);
|
||||
--sidebar-card-button-bg-color: var(var(--surface-blue-2));
|
||||
--sidebar-card-button-bg-color: var(--surface-blue-2);
|
||||
--sidebar-card-button-color: var(--ink-blue-3);
|
||||
}
|
||||
.card-title-container {
|
||||
|
|
@ -54,3 +54,11 @@
|
|||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-outline {
|
||||
border: 1px solid;
|
||||
box-shadow: none;
|
||||
border-color: var(--outline-gray-2, #e2e2e2);
|
||||
border-radius: calc(var(--border-radius-lg) + 2px);
|
||||
padding: calc(var(--padding-md) - 3px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class SQLiteSearchIndexMissingError(Exception):
|
|||
|
||||
# Search Configuration Constants
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
MAX_RERANK_CANDIDATES = 500
|
||||
SNIPPET_LENGTH = 64
|
||||
MIN_WORD_LENGTH = 4
|
||||
MAX_EDIT_DISTANCE = 3
|
||||
|
|
@ -375,12 +376,17 @@ class SQLiteSearch(ABC):
|
|||
|
||||
# Process this doctype in batches
|
||||
last_indexed_modified = doctype_progress.get("last_indexed_modified")
|
||||
last_indexed_name = doctype_progress.get("last_indexed_name")
|
||||
progress_field = "creation"
|
||||
batch_count = 0
|
||||
|
||||
while True:
|
||||
# Get batch of documents
|
||||
docs = self.get_documents_paginated(
|
||||
doctype, limit=batch_size, last_indexed_modified=last_indexed_modified
|
||||
doctype,
|
||||
limit=batch_size,
|
||||
last_indexed_modified=last_indexed_modified,
|
||||
last_indexed_name=last_indexed_name,
|
||||
)
|
||||
|
||||
if not docs:
|
||||
|
|
@ -398,13 +404,12 @@ class SQLiteSearch(ABC):
|
|||
if documents:
|
||||
self._index_documents(documents)
|
||||
|
||||
# Update progress with last processed document's modification time
|
||||
# Use hardcoded 'modified' field since it's reliable in all Frappe doctypes
|
||||
last_doc_modified = docs[-1]["modified"]
|
||||
|
||||
# Update progress with last processed document cursor
|
||||
last_doc_modified = docs[-1].get(progress_field) or docs[-1].get("modified")
|
||||
last_doc_name = docs[-1]["name"]
|
||||
self._update_index_progress(doctype, last_doc_name, last_doc_modified, len(documents))
|
||||
last_indexed_modified = last_doc_modified
|
||||
last_indexed_name = last_doc_name
|
||||
|
||||
batch_count += 1
|
||||
|
||||
|
|
@ -614,34 +619,48 @@ class SQLiteSearch(ABC):
|
|||
|
||||
return records
|
||||
|
||||
def get_documents_paginated(self, doctype, limit=1000, last_indexed_modified=None):
|
||||
def get_documents_paginated(
|
||||
self, doctype, limit=1000, last_indexed_modified=None, last_indexed_name=None
|
||||
):
|
||||
"""Get records for a specific doctype with pagination support."""
|
||||
config = self.doc_configs.get(doctype)
|
||||
if not config:
|
||||
return []
|
||||
|
||||
filters = config.get("filters", {}).copy()
|
||||
sort_field = "creation"
|
||||
|
||||
# Ensure 'modified' field is always included for progress tracking
|
||||
# Ensure cursor fields are included for progress tracking
|
||||
fields = config["fields"].copy()
|
||||
if sort_field not in fields:
|
||||
fields.append(sort_field)
|
||||
if "modified" not in fields:
|
||||
fields.append("modified")
|
||||
if "name" not in fields:
|
||||
fields.append("name")
|
||||
|
||||
# Build query with proper ordering and pagination
|
||||
# Order by modified field for reliable resume capability
|
||||
# Order by cursor field with name as tie-breaker for stable pagination
|
||||
query = frappe.qb.get_query(
|
||||
doctype,
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
order_by="creation ASC, name ASC", # Secondary sort by name for consistency
|
||||
order_by=f"{sort_field} ASC, name ASC",
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# If resuming from a specific timestamp, filter by modification time
|
||||
# This is more reliable than name-based filtering for VARCHAR names
|
||||
# If resuming from a checkpoint, continue from cursor position.
|
||||
# Include name tie-breaker to avoid skipping docs with same timestamp.
|
||||
if last_indexed_modified:
|
||||
Table = frappe.qb.DocType(doctype)
|
||||
query = query.where(Table.modified > last_indexed_modified)
|
||||
sort_column = getattr(Table, sort_field)
|
||||
if last_indexed_name:
|
||||
query = query.where(
|
||||
(sort_column > last_indexed_modified)
|
||||
| ((sort_column == last_indexed_modified) & (Table.name > last_indexed_name))
|
||||
)
|
||||
else:
|
||||
query = query.where(sort_column > last_indexed_modified)
|
||||
|
||||
docs = query.run(as_dict=True)
|
||||
|
||||
|
|
@ -854,6 +873,8 @@ class SQLiteSearch(ABC):
|
|||
|
||||
select_clause = ",\n ".join(select_fields)
|
||||
|
||||
candidate_limit = max(MAX_SEARCH_RESULTS, MAX_RERANK_CANDIDATES)
|
||||
|
||||
if title_only:
|
||||
sql = f"""
|
||||
SELECT
|
||||
|
|
@ -866,12 +887,12 @@ class SQLiteSearch(ABC):
|
|||
ORDER BY bm25_score
|
||||
LIMIT ?
|
||||
"""
|
||||
return self.sql(sql, (fts_query, fts_query, *filter_params, MAX_SEARCH_RESULTS), read_only=True)
|
||||
return self.sql(sql, (fts_query, fts_query, *filter_params, candidate_limit), read_only=True)
|
||||
else:
|
||||
params = []
|
||||
if "content" in text_fields:
|
||||
params.append(SNIPPET_LENGTH)
|
||||
params.extend([fts_query, *filter_params, MAX_SEARCH_RESULTS])
|
||||
params.extend([fts_query, *filter_params, candidate_limit])
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
|
|
@ -883,7 +904,6 @@ class SQLiteSearch(ABC):
|
|||
ORDER BY bm25_score
|
||||
LIMIT ?
|
||||
"""
|
||||
print(sql)
|
||||
return self.sql(sql, params, read_only=True)
|
||||
|
||||
def _process_search_results(self, raw_results, query):
|
||||
|
|
@ -923,13 +943,19 @@ class SQLiteSearch(ABC):
|
|||
processed_results.append(result)
|
||||
|
||||
# Sort by custom score (descending - higher is better)
|
||||
processed_results.sort(key=lambda x: x["score"], reverse=True)
|
||||
processed_results.sort(
|
||||
key=lambda x: (
|
||||
-x["score"],
|
||||
x["bm25_score"] if x["bm25_score"] is not None else float("inf"),
|
||||
x["original_rank"],
|
||||
)
|
||||
)
|
||||
|
||||
# Add modified ranking after custom scoring
|
||||
for i, result in enumerate(processed_results):
|
||||
result["modified_rank"] = i + 1
|
||||
|
||||
return processed_results
|
||||
return processed_results[:MAX_SEARCH_RESULTS]
|
||||
|
||||
def get_scoring_pipeline(self):
|
||||
"""
|
||||
|
|
@ -984,13 +1010,22 @@ class SQLiteSearch(ABC):
|
|||
|
||||
def _get_base_score(self, row, query):
|
||||
"""Calculate the base score from BM25."""
|
||||
bm25_score = abs(row["bm25_score"]) if row["bm25_score"] is not None else 0
|
||||
return 1.0 / (1.0 + bm25_score) if bm25_score > 0 else 0.5
|
||||
bm25_score = row["bm25_score"]
|
||||
if bm25_score is None:
|
||||
return 0.5
|
||||
|
||||
# FTS5 BM25 is better when smaller, so don't normalize with abs().
|
||||
# Clamp non-positive scores to a strong base to avoid unstable boosts.
|
||||
if bm25_score <= 0:
|
||||
return 1.0
|
||||
|
||||
return 1.0 / (1.0 + bm25_score)
|
||||
|
||||
def _get_title_boost(self, row, query, query_words):
|
||||
"""Calculate the title matching boost based on percentage of words matched."""
|
||||
original_title = (row["original_title"] or "").lower()
|
||||
query_lower = query.lower()
|
||||
title_tokens = set(re.findall(r"\w+", original_title))
|
||||
|
||||
# Check for exact phrase match first (highest boost)
|
||||
if query_lower in original_title:
|
||||
|
|
@ -1002,7 +1037,7 @@ class SQLiteSearch(ABC):
|
|||
|
||||
matched_words = 0
|
||||
for word in query_words:
|
||||
if word.lower() in original_title:
|
||||
if word.lower() in title_tokens:
|
||||
matched_words += 1
|
||||
|
||||
if matched_words == 0:
|
||||
|
|
|
|||
|
|
@ -82,8 +82,53 @@ class TestEmail(IntegrationTestCase):
|
|||
self.assertEqual(len(queue_recipients), 2)
|
||||
self.assertTrue("Unsubscribe" in frappe.safe_decode(frappe.flags.sent_mail))
|
||||
|
||||
def test_cc_header(self):
|
||||
# test if sending with cc's makes it into header
|
||||
def test_cc_header_always_visible(self):
|
||||
"""Test that CC header is always visible regardless of expose_recipients setting.
|
||||
|
||||
CC (Carbon Copy) should always be visible to all recipients as per email semantics.
|
||||
This enables 'Reply All' functionality. If sender wants hidden recipients, they should use BCC.
|
||||
"""
|
||||
frappe.sendmail(
|
||||
recipients=["test@example.com"],
|
||||
cc=["test1@example.com"],
|
||||
sender="admin@example.com",
|
||||
reference_doctype="User",
|
||||
reference_name="Administrator",
|
||||
subject="Testing CC Header Visibility",
|
||||
message="CC should be visible without expose_recipients",
|
||||
unsubscribe_message="Unsubscribe",
|
||||
# No expose_recipients set - CC should still be visible
|
||||
)
|
||||
email_queue = frappe.db.sql(
|
||||
"""select name from `tabEmail Queue` where status='Not Sent'""", as_dict=1
|
||||
)
|
||||
self.assertEqual(len(email_queue), 1)
|
||||
queue_recipients = [
|
||||
r.recipient
|
||||
for r in frappe.db.sql(
|
||||
"""select recipient from `tabEmail Queue Recipient`
|
||||
where status='Not Sent'""",
|
||||
as_dict=1,
|
||||
)
|
||||
]
|
||||
self.assertTrue("test@example.com" in queue_recipients)
|
||||
self.assertTrue("test1@example.com" in queue_recipients)
|
||||
|
||||
message = frappe.db.sql(
|
||||
"""select message from `tabEmail Queue`
|
||||
where status='Not Sent'""",
|
||||
as_dict=1,
|
||||
)[0].message
|
||||
# CC should be visible even without expose_recipients
|
||||
self.assertTrue("CC: test1@example.com" in message)
|
||||
# TO should use placeholder (hidden) when expose_recipients is not set
|
||||
self.assertTrue("To: <!--recipient-->" in message)
|
||||
|
||||
def test_cc_header_with_expose_recipients(self):
|
||||
"""Test CC and TO visibility when expose_recipients='header' is set.
|
||||
|
||||
With expose_recipients='header', both TO and CC should be visible in headers.
|
||||
"""
|
||||
frappe.sendmail(
|
||||
recipients=["test@example.com"],
|
||||
cc=["test1@example.com"],
|
||||
|
|
@ -115,6 +160,7 @@ class TestEmail(IntegrationTestCase):
|
|||
where status='Not Sent'""",
|
||||
as_dict=1,
|
||||
)[0].message
|
||||
# Both TO and CC should be visible with expose_recipients="header"
|
||||
self.assertTrue("To: test@example.com" in message)
|
||||
self.assertTrue("CC: test1@example.com" in message)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import csv
|
|||
import json
|
||||
from csv import Sniffer
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
|
@ -104,7 +105,7 @@ def read_csv_content(fcontent, use_sniffer: bool = False):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_csv_to_client(args):
|
||||
def send_csv_to_client(args: str | dict[str, Any]):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from difflib import unified_diff
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe.utils import pretty_date
|
||||
|
|
@ -44,7 +45,9 @@ def _get_value_from_version(version_name: int | str, fieldname: str):
|
|||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def version_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
def version_query(
|
||||
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict[str, Any]
|
||||
):
|
||||
version_filters = {
|
||||
"docname": filters["docname"],
|
||||
"ref_doctype": filters["ref_doctype"],
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ def get_file_name(fname, optional_suffix):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_attachments(doctype, name, attachments):
|
||||
def add_attachments(doctype: str, name: str | int, attachments: str | list[str]):
|
||||
"""Add attachments to the given DocType"""
|
||||
if isinstance(attachments, str):
|
||||
attachments = json.loads(attachments)
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@ def delete_for_document(doc):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def search(text, start=0, limit=20, doctype=""):
|
||||
def search(text: str, start: int = 0, limit: int = 20, doctype: str = ""):
|
||||
"""
|
||||
Search for given text in __global_search
|
||||
:param text: phrase to be searched
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pypdf import PdfWriter
|
|||
import frappe
|
||||
from frappe import _
|
||||
from frappe.core.doctype.access_log.access_log import make_access_log
|
||||
from frappe.model.document import Document
|
||||
from frappe.translate import print_language
|
||||
from frappe.utils.jinja import render_template
|
||||
from frappe.utils.pdf import get_pdf
|
||||
|
|
@ -224,11 +225,11 @@ from frappe.deprecation_dumpster import read_multi_pdf
|
|||
def download_pdf(
|
||||
doctype: str,
|
||||
name: str,
|
||||
format=None,
|
||||
doc=None,
|
||||
no_letterhead=0,
|
||||
language=None,
|
||||
letterhead=None,
|
||||
format: str | None = None,
|
||||
doc: Document | None = None,
|
||||
no_letterhead: bool | int = 0,
|
||||
language: str | None = None,
|
||||
letterhead: str | None = None,
|
||||
pdf_generator: Literal["wkhtmltopdf", "chrome"] | None = None,
|
||||
):
|
||||
if pdf_generator is None:
|
||||
|
|
@ -255,7 +256,7 @@ def download_pdf(
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def report_to_pdf(html, orientation="Landscape"):
|
||||
def report_to_pdf(html: str, orientation: str = "Landscape"):
|
||||
make_access_log(file_type="PDF", method="PDF", page=html)
|
||||
frappe.local.response.filename = "report.pdf"
|
||||
frappe.local.response.filecontent = get_pdf(
|
||||
|
|
@ -313,7 +314,13 @@ def render_letterhead_for_print(letterhead: str | None = None, doc: dict | str |
|
|||
|
||||
@frappe.whitelist()
|
||||
def print_by_server(
|
||||
doctype, name, printer_setting, print_format=None, doc=None, no_letterhead=0, file_path=None
|
||||
doctype: str,
|
||||
name: str | int,
|
||||
printer_setting: str,
|
||||
print_format: str | None = None,
|
||||
doc: Document | None = None,
|
||||
no_letterhead: bool | int = 0,
|
||||
file_path: str | None = None,
|
||||
):
|
||||
print_settings = frappe.get_doc("Network Printer Settings", printer_setting)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -138,7 +138,9 @@ def attach_print(
|
|||
if print_format and print_format != "Standard":
|
||||
print_format_doc = frappe.get_cached_doc("Print Format", print_format)
|
||||
is_weasyprint_print_format = not (
|
||||
print_format_doc.custom_format or print_format_doc.get("print_designer_print_format")
|
||||
print_format_doc.custom_format
|
||||
or print_format_doc.get("print_format_builder")
|
||||
or print_format_doc.get("print_designer_print_format")
|
||||
)
|
||||
|
||||
with print_language(lang or frappe.local.lang):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from orjson import JSONDecodeError
|
||||
|
||||
|
|
@ -23,7 +24,15 @@ def is_enabled() -> bool:
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def capture(event_name, site=None, app=None, user=None, captured_at=None, properties=None, interval=None):
|
||||
def capture(
|
||||
event_name: str,
|
||||
site: str | None = None,
|
||||
app: str | None = None,
|
||||
user: str | None = None,
|
||||
captured_at: str | None = None,
|
||||
properties: dict[str, Any] | None = None,
|
||||
interval: int | str | None = None,
|
||||
):
|
||||
if not is_enabled():
|
||||
return
|
||||
|
||||
|
|
@ -45,7 +54,7 @@ def capture(event_name, site=None, app=None, user=None, captured_at=None, proper
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def bulk_capture(events):
|
||||
def bulk_capture(events: str | list[dict[str, Any]]):
|
||||
if not is_enabled():
|
||||
return
|
||||
|
||||
|
|
@ -226,7 +235,9 @@ class EventQueue:
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_debug_info(fetch_events=None, fetch_rate_limited_events=None):
|
||||
def get_debug_info(
|
||||
fetch_events: int | str | bool | None = None, fetch_rate_limited_events: int | str | bool | None = None
|
||||
):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
info = frappe._dict()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
|
|
@ -620,7 +621,7 @@ def get_web_form_module(doc):
|
|||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@rate_limit(key="web_form", limit=10, seconds=60)
|
||||
def accept(web_form, data):
|
||||
def accept(web_form: str, data: str):
|
||||
"""Save the web form"""
|
||||
data = frappe._dict(json.loads(data))
|
||||
|
||||
|
|
@ -737,7 +738,7 @@ def delete(web_form_name: str, docname: str | int):
|
|||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_multiple(web_form_name: str, docnames):
|
||||
def delete_multiple(web_form_name: str, docnames: str):
|
||||
web_form = frappe.get_lazy_doc("Web Form", web_form_name)
|
||||
|
||||
docnames = json.loads(docnames)
|
||||
|
|
|
|||
|
|
@ -43,15 +43,15 @@ class WebPageView(Document):
|
|||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def make_view_log(
|
||||
referrer=None,
|
||||
browser=None,
|
||||
version=None,
|
||||
user_tz=None,
|
||||
source=None,
|
||||
campaign=None,
|
||||
medium=None,
|
||||
content=None,
|
||||
visitor_id=None,
|
||||
referrer: str | None = None,
|
||||
browser: str | None = None,
|
||||
version: str | None = None,
|
||||
user_tz: str | None = None,
|
||||
source: str | None = None,
|
||||
campaign: str | None = None,
|
||||
medium: str | None = None,
|
||||
content: str | None = None,
|
||||
visitor_id: str | None = None,
|
||||
):
|
||||
if not is_tracking_enabled():
|
||||
return
|
||||
|
|
@ -100,7 +100,7 @@ def make_view_log(
|
|||
|
||||
@frappe.whitelist()
|
||||
@redis_cache(ttl=5 * 60)
|
||||
def get_page_view_count(path):
|
||||
def get_page_view_count(path: str):
|
||||
return frappe.db.count("Web Page View", filters={"path": path})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue