chore: merge develop

This commit is contained in:
Gursheen Anand 2026-02-20 07:43:16 +05:30
commit 0650293a18
84 changed files with 1941 additions and 1475 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = [], [], []

View file

@ -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(

View file

@ -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():

View file

@ -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)

View file

@ -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,

View file

@ -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()

View file

@ -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)

View file

@ -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):

View file

@ -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",

View file

@ -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")

View file

@ -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)

View file

@ -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"))

View file

@ -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()

View file

@ -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()

View file

@ -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)

View file

@ -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))

View file

@ -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.

View file

@ -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:

View file

@ -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;
}

View file

@ -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>

View file

@ -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");
}
}

View file

@ -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 = {}

View file

@ -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:

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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()]

View 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": []
}

View 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

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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 = [

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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 "&quot;{0}&quot; သည်မှန်ကန်သော 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 "&copy; Frappe Technologies Pvt. Ltd. and contributors"
msgstr ""
msgstr "&copy; Frappe Technologies Pvt. Ltd. နှင့် ပူးပေါင်းလုပ်ဆောင်သူများ"
#. Label of the head_html (Code) field in DocType 'Website Settings'
#: frappe/website/doctype/website_settings/website_settings.json
msgid "&lt;head&gt; HTML"
msgstr ""
msgstr "&lt;head&gt; 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'

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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()

View file

@ -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"
);
}

View file

@ -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({

View file

@ -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;
}
}

View file

@ -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");

View file

@ -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;

View file

@ -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";

View file

@ -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>

View file

@ -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];

View file

@ -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);
}

View file

@ -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() {

View file

@ -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>

View file

@ -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]);
}
}
};

View file

@ -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(),
}
);
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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() {

View file

@ -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 {

View file

@ -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 {

View file

@ -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);

View file

@ -493,7 +493,7 @@ input.list-header-checkbox {
.filter-section {
display: flex;
padding: 0 var(--padding-xs);
padding: 0;
}
.filter-selector .btn-group {

View file

@ -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;
}
}
}

View file

@ -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);
}

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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"],

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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):

View file

@ -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()

View file

@ -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)

View file

@ -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})