- Панель «Подключённые базы»: клик по пользователю показывает его базы, удаление отмеченных с подтверждением - Поиск по пользователям и базам (галочки сохраняются при фильтрации) - Опциональное дублирование add/delete в ZIP-профиль (автопоиск папки <user>.*) - Устойчивое чтение ibases.v8i (UTF-8 BOM/без/cp1251) - Интерфейс переведён на русский, окно резиновое - Фикс: сброс прокрутки списка наверх при поиске - Новая иконка download.ico, spec теперь версионируется Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
584 lines
30 KiB
Python
584 lines
30 KiB
Python
import os
|
||
import re
|
||
import tkinter as tk
|
||
from tkinter import messagebox, filedialog
|
||
import pyodbc
|
||
import uuid
|
||
import sys
|
||
|
||
_BANNER = ("""
|
||
|
||
|
||
....:::::::::
|
||
::::::::::-----::::::
|
||
::::------------=--------::..
|
||
:::--=--============---==+===-:::.
|
||
::---========++=++++======+++++==--:.:
|
||
:--==++++++++++++++++++========---==-::.
|
||
--==++++++++++++++++++++=====---:::-==--::
|
||
:-==+**++++++++++++++++++====------::-==-::
|
||
-==+**++++++++++++++++++++=====--------=-::
|
||
-=++*++++++++++++++++++++++=========-----::
|
||
-=+++++++++++++++++++++++++===========----:
|
||
:=++++++++++++++++++++++++++==========---::
|
||
-=++++++++++++++++++===+++++===-----====--
|
||
-=++++++++++++++++++++==+++++=====-----==-
|
||
-++++++++++++++++++++++++++++++++====-----
|
||
==+++++++*#####*******++++++++++++++++=--=
|
||
+===++++++****##%%%%##*****++++**###%##*+==-
|
||
+*++=++++****#%##%@%%%%###***++*#%%%%%###*===-
|
||
+*++=++++**####*#%@%#%%%##**++*#%%%@@%+*%%*+=-
|
||
*++=+++++**+****####%%##*++==+#%%#%%#+++*+==-
|
||
+*+++++++++++****###****++=====*####*++=====-
|
||
+*=+++++++++++++++++++++++=======+**++=-----
|
||
+*=+++++++++++++++++***++++==-=====+++==----
|
||
++=+++++++++++++++*****+++==---===========--
|
||
+=+++++++++++++*****+++++===---++======--=-
|
||
==++++++++++**********+++++==--+++++=====-
|
||
-==+++++++++*********##*******==++++++===-
|
||
-==++++++++*****#**##*####**+====++++++==
|
||
#%%%%%===+++++++++***##*****####**++++++==+++=-
|
||
*+*#%%%#*+====++++++++**#%%%##****##********+====-
|
||
++++++*##%@%#+++**====+++++++*##############***###*+====-
|
||
++******###%%@@@@@%####*+=+==+++++**************+++++=+*++===-
|
||
#***+**#*###%@@@@@@@@@@@@@%###*+=+==++++*****++*********++==++++=-=+=+=-
|
||
#*#%%%###%%@@@@@@@@@@@@@@@@%###*++++++++**+************+++========*****++==-
|
||
%%%######%%####%@@@@@@@@@@@@@@%%**+++++++++++++++***+++=========*###******+++++==
|
||
%%@@@@%%%%%%%%%#%%@@@@@@@@@@@@@@%#**+*+**++++++++++++++++=====+%###########***++++*
|
||
%@@@@@@@@@%%@@%%%%%%%@@@@@@@@@@@@@@###*****++++++*+++++++===+**#%####%###%####***+***
|
||
%%%@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@%####**++++***++++*#####*%##%####%#%%%%##*******
|
||
%%%%@%@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@%%%%%%##%%%@%%####*%%#%####%#%%%%%#########
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%##%#*#%#%####%##%%%%%%###%#%##
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%#%%###%######%##%#%%%%%%%%%%%##
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%####%%#####%##%%%%%%%%%%%%%###
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%####%%#%%##%#%%%%%%%%%%@%@%###
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%#####%%%#%%%%#%%%%%%%@@@@@@@%%##
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%#%%%%##%%#%%%%%%%%%%%%@@@@@@@%%#**
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%@@@%####%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@@@@@@%%%#**
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%@@@@%%%%%%%#%%%%%%%##%%%%%%%%%%%%%%%%%@@@@@@%%%%##**
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%@@@@%%@@@@@@@%%%%%%##%%%%%%%###%%%%%%%%%%%%%%%%%@@%%%%%%%%##*+
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%@@@@@@@@%%%%@@@@@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%###%%*#
|
||
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@%@@@%@@@@@@@%%%@%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@%%%%
|
||
""")
|
||
|
||
# В оконном режиме (сборка без консоли) sys.stdout может быть None — не роняем старт
|
||
if getattr(sys, "stdout", None):
|
||
try:
|
||
print(_BANNER)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class RemoteFolderSelector(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("Добавление баз 1С пользователям")
|
||
self.geometry("760x780")
|
||
self.minsize(700, 640)
|
||
|
||
# --- Состояние ---
|
||
self.ip_address = None # None/"" => локальный ПК, иначе IP удалённого ПК
|
||
self.active_user = None # пользователь, чьи базы показаны в панели "Подключённые базы"
|
||
self.user_vars = {} # имя пользователя -> BooleanVar (галочка)
|
||
self.db_vars = {} # имя базы из SQL -> BooleanVar (галочка)
|
||
self.conn_vars = {} # имя подключённой базы активного пользователя -> BooleanVar
|
||
|
||
# Компоновка окна: две колонки сверху, широкая кнопка "Добавить" снизу
|
||
self.grid_columnconfigure(0, weight=1)
|
||
self.grid_columnconfigure(1, weight=1)
|
||
self.grid_rowconfigure(0, weight=1)
|
||
|
||
self.frame_left = tk.Frame(self)
|
||
self.frame_left.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
|
||
|
||
self.frame_right = tk.Frame(self)
|
||
self.frame_right.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
|
||
|
||
self._build_left_panel()
|
||
self._build_right_panel()
|
||
|
||
# Большая кнопка добавления снизу, на всю ширину окна
|
||
self.add_button = tk.Button(
|
||
self, text="Добавить отмеченные базы отмеченным пользователям",
|
||
font=("Segoe UI", 11, "bold"), command=self.add_database_to_ibases
|
||
)
|
||
self.add_button.grid(row=1, column=0, columnspan=2, padx=10, pady=(0, 12), sticky="ew")
|
||
|
||
# По умолчанию грузим пользователей локального ПК
|
||
self.load_users()
|
||
|
||
# ------------------------------------------------------------------ UI
|
||
|
||
def _build_left_panel(self):
|
||
f = self.frame_left
|
||
f.grid_columnconfigure(0, weight=1)
|
||
|
||
tk.Label(f, text="IP-адрес (необязательно):").grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
||
self.ip_entry = tk.Entry(f)
|
||
self.ip_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||
|
||
tk.Button(f, text="Загрузить пользователей", command=self.load_users)\
|
||
.grid(row=2, column=0, padx=5, pady=5, sticky="ew")
|
||
|
||
# Поиск по пользователям
|
||
tk.Label(f, text="Поиск пользователей:").grid(row=3, column=0, padx=5, pady=(5, 0), sticky="w")
|
||
self.user_search_var = tk.StringVar()
|
||
self.user_search_var.trace_add("write", lambda *a: self._render_checklist(
|
||
self.user_inner, self.user_vars, self.user_search_var.get(), self.on_user_click))
|
||
tk.Entry(f, textvariable=self.user_search_var)\
|
||
.grid(row=4, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||
|
||
# Отметить все / снять все (с учётом фильтра)
|
||
bar = tk.Frame(f)
|
||
bar.grid(row=5, column=0, padx=5, pady=(0, 3), sticky="w")
|
||
tk.Button(bar, text="Отметить все",
|
||
command=lambda: self._set_all(self.user_vars, self.user_search_var.get(), True))\
|
||
.pack(side="left", padx=(0, 4))
|
||
tk.Button(bar, text="Снять все",
|
||
command=lambda: self._set_all(self.user_vars, self.user_search_var.get(), False))\
|
||
.pack(side="left")
|
||
|
||
# Список пользователей
|
||
self.user_container, self.user_inner = self._create_scrollable_checklist(f, height=150)
|
||
self.user_container.grid(row=6, column=0, padx=5, pady=5, sticky="nsew")
|
||
f.grid_rowconfigure(6, weight=1)
|
||
|
||
# Панель подключённых баз активного пользователя
|
||
self.conn_label = tk.Label(f, text="Подключённые базы: (выберите пользователя)")
|
||
self.conn_label.grid(row=7, column=0, padx=5, pady=(8, 0), sticky="w")
|
||
|
||
bar2 = tk.Frame(f)
|
||
bar2.grid(row=8, column=0, padx=5, pady=(0, 3), sticky="w")
|
||
tk.Button(bar2, text="Отметить все",
|
||
command=lambda: self._set_all(self.conn_vars, "", True)).pack(side="left", padx=(0, 4))
|
||
tk.Button(bar2, text="Снять все",
|
||
command=lambda: self._set_all(self.conn_vars, "", False)).pack(side="left")
|
||
|
||
self.conn_container, self.conn_inner = self._create_scrollable_checklist(f, height=150)
|
||
self.conn_container.grid(row=9, column=0, padx=5, pady=5, sticky="nsew")
|
||
f.grid_rowconfigure(9, weight=1)
|
||
|
||
tk.Button(f, text="Удалить отмеченные базы у пользователя", command=self.delete_connected_bases)\
|
||
.grid(row=10, column=0, padx=5, pady=5, sticky="ew")
|
||
|
||
# Опция дублирования в ZIP (roaming) профиль
|
||
self.zip_enabled = tk.BooleanVar(value=False)
|
||
tk.Checkbutton(f, text="Дублировать изменения в ZIP-профиль пользователя",
|
||
variable=self.zip_enabled, command=self._toggle_zip)\
|
||
.grid(row=11, column=0, padx=5, pady=(8, 0), sticky="w")
|
||
|
||
zf = tk.Frame(f)
|
||
zf.grid(row=12, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||
zf.grid_columnconfigure(0, weight=1)
|
||
self.zip_path_var = tk.StringVar(value=r"Z:\ZIP_User_Profiles")
|
||
self.zip_path_entry = tk.Entry(zf, textvariable=self.zip_path_var, state="disabled")
|
||
self.zip_path_entry.grid(row=0, column=0, sticky="ew", padx=(0, 4))
|
||
self.zip_browse_btn = tk.Button(zf, text="Обзор...", state="disabled", command=self._browse_zip)
|
||
self.zip_browse_btn.grid(row=0, column=1)
|
||
|
||
def _build_right_panel(self):
|
||
f = self.frame_right
|
||
f.grid_columnconfigure(0, weight=1)
|
||
|
||
tk.Label(f, text="SQL-сервер:").grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
||
self.server_entry = tk.Entry(f)
|
||
self.server_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||
self.server_entry.insert(0, "SERVERSQL")
|
||
|
||
tk.Label(f, text="Логин SQL:").grid(row=2, column=0, padx=5, pady=(5, 0), sticky="w")
|
||
self.login_entry = tk.Entry(f)
|
||
self.login_entry.grid(row=3, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||
self.login_entry.insert(0, "sa")
|
||
|
||
tk.Label(f, text="Пароль SQL:").grid(row=4, column=0, padx=5, pady=(5, 0), sticky="w")
|
||
self.password_entry = tk.Entry(f, show="*")
|
||
self.password_entry.grid(row=5, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||
|
||
tk.Button(f, text="Загрузить базы", command=self.load_databases)\
|
||
.grid(row=6, column=0, padx=5, pady=5, sticky="ew")
|
||
|
||
# Поиск по базам
|
||
tk.Label(f, text="Поиск баз:").grid(row=7, column=0, padx=5, pady=(5, 0), sticky="w")
|
||
self.db_search_var = tk.StringVar()
|
||
self.db_search_var.trace_add("write", lambda *a: self._render_checklist(
|
||
self.db_inner, self.db_vars, self.db_search_var.get()))
|
||
tk.Entry(f, textvariable=self.db_search_var)\
|
||
.grid(row=8, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||
|
||
bar = tk.Frame(f)
|
||
bar.grid(row=9, column=0, padx=5, pady=(0, 3), sticky="w")
|
||
tk.Button(bar, text="Отметить все",
|
||
command=lambda: self._set_all(self.db_vars, self.db_search_var.get(), True))\
|
||
.pack(side="left", padx=(0, 4))
|
||
tk.Button(bar, text="Снять все",
|
||
command=lambda: self._set_all(self.db_vars, self.db_search_var.get(), False))\
|
||
.pack(side="left")
|
||
|
||
self.db_container, self.db_inner = self._create_scrollable_checklist(f, height=380)
|
||
self.db_container.grid(row=10, column=0, padx=5, pady=5, sticky="nsew")
|
||
f.grid_rowconfigure(10, weight=1)
|
||
|
||
def _create_scrollable_checklist(self, parent, width=220, height=180):
|
||
"""Прокручиваемая область для чекбоксов. Возвращает (container, inner)."""
|
||
container = tk.Frame(parent, borderwidth=1, relief="sunken")
|
||
canvas = tk.Canvas(container, width=width, height=height, highlightthickness=0)
|
||
scrollbar = tk.Scrollbar(container, orient="vertical", command=canvas.yview)
|
||
inner = tk.Frame(canvas)
|
||
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=inner, anchor="nw")
|
||
canvas.configure(yscrollcommand=scrollbar.set)
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
scrollbar.pack(side="right", fill="y")
|
||
|
||
def _on_mousewheel(event):
|
||
canvas.yview_scroll(int(-event.delta / 120), "units")
|
||
|
||
canvas.bind("<Enter>", lambda e: canvas.bind_all("<MouseWheel>", _on_mousewheel))
|
||
canvas.bind("<Leave>", lambda e: canvas.unbind_all("<MouseWheel>"))
|
||
return container, inner
|
||
|
||
def _load_items(self, vars_dict, items):
|
||
"""Создаём свежий набор BooleanVar по списку items (галочки сбрасываются)."""
|
||
vars_dict.clear()
|
||
for item in items:
|
||
vars_dict[item] = tk.BooleanVar(value=False)
|
||
|
||
def _render_checklist(self, inner, vars_dict, filter_text="", on_click=None):
|
||
"""Рисуем чекбоксы, показывая только элементы, подходящие под фильтр.
|
||
Переменные (галочки) сохраняются между перерисовками — состояние не теряется."""
|
||
for child in inner.winfo_children():
|
||
child.destroy()
|
||
ft = (filter_text or "").lower()
|
||
for name, var in vars_dict.items():
|
||
if ft and ft not in name.lower():
|
||
continue
|
||
cmd = (lambda n=name: on_click(n)) if on_click else None
|
||
tk.Checkbutton(inner, text=name, variable=var, anchor="w", command=cmd)\
|
||
.pack(fill="x", anchor="w")
|
||
# После перерисовки (например, при вводе в поиск) возвращаем прокрутку наверх,
|
||
# иначе список остаётся проскроллен вниз и результат поиска не виден.
|
||
inner.master.yview_moveto(0)
|
||
|
||
def _set_all(self, vars_dict, filter_text, value):
|
||
"""Отмечаем/снимаем все чекбоксы, попадающие под текущий фильтр."""
|
||
ft = (filter_text or "").lower()
|
||
for name, var in vars_dict.items():
|
||
if ft and ft not in name.lower():
|
||
continue
|
||
var.set(value)
|
||
|
||
def _get_checked(self, vars_dict):
|
||
return [name for name, var in vars_dict.items() if var.get()]
|
||
|
||
def _toggle_zip(self):
|
||
state = "normal" if self.zip_enabled.get() else "disabled"
|
||
self.zip_path_entry.config(state=state)
|
||
self.zip_browse_btn.config(state=state)
|
||
|
||
def _browse_zip(self):
|
||
path = filedialog.askdirectory(title="Каталог с ZIP-профилями пользователей")
|
||
if path:
|
||
self.zip_path_var.set(os.path.normpath(path))
|
||
|
||
# -------------------------------------------------------------- Загрузка
|
||
|
||
def load_users(self):
|
||
"""Загружаем список пользователей с локального ПК или с удалённого по IP."""
|
||
self.ip_address = self.ip_entry.get().strip()
|
||
if not self.ip_address:
|
||
base = r"C:\Users"
|
||
else:
|
||
base = f"\\\\{self.ip_address}\\C$\\Users"
|
||
try:
|
||
folders = [f for f in os.listdir(base) if os.path.isdir(os.path.join(base, f))]
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось загрузить пользователей:\n{e}")
|
||
return
|
||
|
||
self._load_items(self.user_vars, folders)
|
||
self._render_checklist(self.user_inner, self.user_vars, self.user_search_var.get(), self.on_user_click)
|
||
|
||
# Сбрасываем панель подключённых баз — она относилась к прежнему источнику
|
||
self.active_user = None
|
||
self.conn_vars.clear()
|
||
self._render_checklist(self.conn_inner, self.conn_vars, "")
|
||
self.conn_label.config(text="Подключённые базы: (выберите пользователя)")
|
||
|
||
def load_databases(self):
|
||
"""Подключаемся к SQL-серверу и загружаем список баз."""
|
||
server = self.server_entry.get().strip()
|
||
login = self.login_entry.get()
|
||
password = self.password_entry.get()
|
||
try:
|
||
connection_string = f"DRIVER={{SQL Server}};SERVER={server};UID={login};PWD={password};"
|
||
connection = pyodbc.connect(connection_string)
|
||
cursor = connection.cursor()
|
||
cursor.execute("SELECT name FROM sys.databases")
|
||
databases = cursor.fetchall()
|
||
cursor.close()
|
||
connection.close()
|
||
except Exception as e:
|
||
messagebox.showerror("Ошибка", f"Не удалось подключиться к SQL-серверу:\n{e}")
|
||
return
|
||
|
||
# Служебные базы SQL Server скрываем — это не базы 1С
|
||
system_dbs = {"master", "tempdb", "model", "msdb"}
|
||
names = [db[0] for db in databases if db[0].lower() not in system_dbs]
|
||
self._load_items(self.db_vars, names)
|
||
self._render_checklist(self.db_inner, self.db_vars, self.db_search_var.get())
|
||
|
||
def on_user_click(self, user):
|
||
"""Клик по пользователю — делаем его активным и показываем его подключённые базы."""
|
||
self.load_connected_bases(user)
|
||
|
||
def load_connected_bases(self, user):
|
||
"""Читаем ibases.v8i пользователя и показываем список его баз в панели."""
|
||
self.active_user = user
|
||
self.ip_address = self.ip_entry.get().strip()
|
||
path = self.get_ibases_path(user)
|
||
text = self._read_ibases(path)
|
||
_, sections = self._parse_ibases(text)
|
||
names = [name for name, _ in sections]
|
||
self._load_items(self.conn_vars, names)
|
||
self._render_checklist(self.conn_inner, self.conn_vars, "")
|
||
suffix = "" if names else " (баз нет)"
|
||
self.conn_label.config(text=f"Подключённые базы: {user}{suffix}")
|
||
|
||
# --------------------------------------------------------------- Пути
|
||
|
||
def get_ibases_path(self, user):
|
||
"""Путь к ibases.v8i пользователя (локально либо по IP)."""
|
||
if not self.ip_address:
|
||
base_path = f"C:\\Users\\{user}\\AppData\\Roaming\\1C\\1CEStart"
|
||
else:
|
||
base_path = f"\\\\{self.ip_address}\\C$\\Users\\{user}\\AppData\\Roaming\\1C\\1CEStart"
|
||
return os.path.join(base_path, "ibases.v8i")
|
||
|
||
def get_zip_ibases_path(self, user):
|
||
"""Путь к ibases.v8i внутри ZIP-профиля пользователя.
|
||
Папку профиля ищем автопоиском: <user> либо <user>.* (например <user>.V6).
|
||
Возвращаем None, если корень недоступен или профиль не найден."""
|
||
root = self.zip_path_var.get().strip()
|
||
if not root or not os.path.isdir(root):
|
||
return None
|
||
u = user.lower()
|
||
candidates = []
|
||
try:
|
||
for name in os.listdir(root):
|
||
if not os.path.isdir(os.path.join(root, name)):
|
||
continue
|
||
low = name.lower()
|
||
if low == u or low.startswith(u + "."):
|
||
candidates.append(name)
|
||
except Exception:
|
||
return None
|
||
if not candidates:
|
||
return None
|
||
|
||
# Приоритет: точное <user>.V6, затем любой суффикс .*, затем совпадение без суффикса
|
||
def score(n):
|
||
low = n.lower()
|
||
if low == u + ".v6":
|
||
return 0
|
||
if "." in low:
|
||
return 1
|
||
return 2
|
||
candidates.sort(key=score)
|
||
chosen = candidates[0]
|
||
return os.path.join(root, chosen, "AppData", "Roaming", "1C", "1CEStart", "ibases.v8i")
|
||
|
||
# ------------------------------------------------------- Чтение/запись v8i
|
||
|
||
def _read_ibases(self, path):
|
||
"""Читаем ibases.v8i, устойчиво к кодировке (UTF-8 с BOM / без / cp1251)."""
|
||
if not os.path.exists(path):
|
||
return ""
|
||
for enc in ("utf-8-sig", "cp1251"):
|
||
try:
|
||
with open(path, "r", encoding=enc) as f:
|
||
return f.read()
|
||
except UnicodeDecodeError:
|
||
continue
|
||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||
return f.read()
|
||
|
||
def _parse_ibases(self, text):
|
||
"""Разбираем содержимое на (преамбула, [(имя_базы, блок_текста), ...]).
|
||
Секция начинается со строки вида [ИмяБазы]."""
|
||
header_re = re.compile(r"^\s*\[(.+?)\]\s*$")
|
||
preamble = []
|
||
sections = [] # список [имя, [строки]]
|
||
current = None
|
||
for line in text.splitlines(keepends=True):
|
||
m = header_re.match(line)
|
||
if m:
|
||
current = [m.group(1), [line]]
|
||
sections.append(current)
|
||
elif current is None:
|
||
preamble.append(line)
|
||
else:
|
||
current[1].append(line)
|
||
return "".join(preamble), [(name, "".join(ls)) for name, ls in sections]
|
||
|
||
def build_base_entry(self, base, server):
|
||
"""Формируем запись одной базы для ibases.v8i."""
|
||
return (
|
||
f"\n[{base}]\n"
|
||
f'Connect=Srvr="{server}";Ref="{base}";\n'
|
||
f"ID={uuid.uuid4()}\n"
|
||
"OrderInList=255\n"
|
||
"Folder=/\n"
|
||
"OrderInTree=16640\n"
|
||
"External=0\n"
|
||
"ClientConnectionSpeed=Normal\n"
|
||
"App=Auto\n"
|
||
"WA=1\n"
|
||
"Version=8.3\n"
|
||
"DefaultApp=ThickClient\n"
|
||
"DisableLocalSpeechToText=0\n"
|
||
)
|
||
|
||
def _add_entries_to_file(self, ibases_path, bases, server):
|
||
"""Добавляем базы в один файл ibases.v8i (с фильтром дублей).
|
||
Возвращаем (добавлено, пропущено)."""
|
||
base_dir = os.path.dirname(ibases_path)
|
||
if not os.path.exists(base_dir):
|
||
os.makedirs(base_dir)
|
||
|
||
existing = self._read_ibases(ibases_path)
|
||
entries = []
|
||
added = skipped = 0
|
||
for base in bases:
|
||
connect_line = f'Connect=Srvr="{server}";Ref="{base}";'
|
||
if connect_line in existing or any(connect_line in e for e in entries):
|
||
skipped += 1
|
||
continue
|
||
entries.append(self.build_base_entry(base, server))
|
||
added += 1
|
||
if entries:
|
||
with open(ibases_path, "a", encoding="utf-8") as f:
|
||
f.write("".join(entries))
|
||
return added, skipped
|
||
|
||
def _delete_entries_from_file(self, ibases_path, base_names):
|
||
"""Удаляем указанные базы из одного файла ibases.v8i. Возвращаем число удалённых."""
|
||
if not os.path.exists(ibases_path):
|
||
return 0
|
||
text = self._read_ibases(ibases_path)
|
||
preamble, sections = self._parse_ibases(text)
|
||
target = {b.lower() for b in base_names}
|
||
kept = [blk for (name, blk) in sections if name.lower() not in target]
|
||
removed = len(sections) - len(kept)
|
||
if removed:
|
||
with open(ibases_path, "w", encoding="utf-8") as f:
|
||
f.write(preamble + "".join(kept))
|
||
return removed
|
||
|
||
# ----------------------------------------------------------- Действия
|
||
|
||
def add_database_to_ibases(self):
|
||
"""Добавляем отмеченные базы всем отмеченным пользователям (+ ZIP по галочке)."""
|
||
self.ip_address = self.ip_entry.get().strip()
|
||
users = self._get_checked(self.user_vars)
|
||
bases = self._get_checked(self.db_vars)
|
||
server = self.server_entry.get().strip()
|
||
|
||
if not users:
|
||
messagebox.showerror("Ошибка", "Не отмечен ни один пользователь!")
|
||
return
|
||
if not bases:
|
||
messagebox.showerror("Ошибка", "Не отмечена ни одна база!")
|
||
return
|
||
if not server:
|
||
messagebox.showerror("Ошибка", "Не указан SQL-сервер!")
|
||
return
|
||
|
||
use_zip = self.zip_enabled.get()
|
||
added = skipped = zadded = zskipped = 0
|
||
errors = []
|
||
|
||
for user in users:
|
||
try:
|
||
a, s = self._add_entries_to_file(self.get_ibases_path(user), bases, server)
|
||
added += a
|
||
skipped += s
|
||
except Exception as e:
|
||
errors.append(f"{user}: {e}")
|
||
if use_zip:
|
||
zp = self.get_zip_ibases_path(user)
|
||
if zp is None:
|
||
errors.append(f"{user}: ZIP-профиль не найден")
|
||
else:
|
||
try:
|
||
a, s = self._add_entries_to_file(zp, bases, server)
|
||
zadded += a
|
||
zskipped += s
|
||
except Exception as e:
|
||
errors.append(f"{user} (ZIP): {e}")
|
||
|
||
# Обновим панель подключённых баз, если активный пользователь среди затронутых
|
||
if self.active_user in users:
|
||
self.load_connected_bases(self.active_user)
|
||
|
||
summary = (
|
||
f"Пользователей: {len(users)}, баз отмечено: {len(bases)}\n"
|
||
f"Локально — добавлено: {added}, пропущено (уже есть): {skipped}"
|
||
)
|
||
if use_zip:
|
||
summary += f"\nZIP-профили — добавлено: {zadded}, пропущено: {zskipped}"
|
||
self._report(summary, errors)
|
||
|
||
def delete_connected_bases(self):
|
||
"""Удаляем отмеченные подключённые базы у активного пользователя (+ ZIP по галочке)."""
|
||
user = self.active_user
|
||
if not user:
|
||
messagebox.showerror("Ошибка", "Сначала выберите пользователя слева.")
|
||
return
|
||
bases = self._get_checked(self.conn_vars)
|
||
if not bases:
|
||
messagebox.showerror("Ошибка", "Не отмечена ни одна база для удаления!")
|
||
return
|
||
|
||
if not messagebox.askyesno(
|
||
"Подтверждение",
|
||
f"Удалить {len(bases)} баз(ы) у пользователя «{user}»?\n\n" + "\n".join(bases)
|
||
):
|
||
return
|
||
|
||
self.ip_address = self.ip_entry.get().strip()
|
||
use_zip = self.zip_enabled.get()
|
||
removed_local = removed_zip = 0
|
||
errors = []
|
||
|
||
try:
|
||
removed_local = self._delete_entries_from_file(self.get_ibases_path(user), bases)
|
||
except Exception as e:
|
||
errors.append(f"{user}: {e}")
|
||
if use_zip:
|
||
zp = self.get_zip_ibases_path(user)
|
||
if zp is None:
|
||
errors.append(f"{user}: ZIP-профиль не найден")
|
||
else:
|
||
try:
|
||
removed_zip = self._delete_entries_from_file(zp, bases)
|
||
except Exception as e:
|
||
errors.append(f"{user} (ZIP): {e}")
|
||
|
||
self.load_connected_bases(user)
|
||
|
||
summary = f"Пользователь: {user}\nУдалено локально: {removed_local}"
|
||
if use_zip:
|
||
summary += f"\nУдалено в ZIP-профиле: {removed_zip}"
|
||
self._report(summary, errors)
|
||
|
||
def _report(self, summary, errors):
|
||
if errors:
|
||
messagebox.showwarning("Готово с ошибками", summary + "\n\nОшибки:\n" + "\n".join(errors))
|
||
else:
|
||
messagebox.showinfo("Готово", summary)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = RemoteFolderSelector()
|
||
app.mainloop()
|