File size: 17,051 Bytes
65b0b93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
"""工作区状态的可复用规则,不包含 NiceGUI 渲染。"""

from dataclasses import dataclass
from typing import Any

from core.workspace import (
    add_history_entry,
    append_workspace_query,
    clone_workspace,
    empty_history,
    merge_favorites,
    merge_history,
    merge_workspaces,
    normalize_favorites,
    sync_selected_entries,
    workspace_signature,
)
from platform_utils import nsfw_allowed
from webui.helpers import sanitize_restored_config


def set_selection_meta(controller: Any, tag: str, origin: str, source: str = '') -> None:
    """记录标签的来源,供写入工作区时补齐可追溯信息。"""
    controller._pending_selection_meta[tag] = {
        'origin': origin,
        'source': source,
    }


def push_undo_snapshot(controller: Any, *, limit: int = 30) -> bool:
    """在工作区内容变化前保存快照;相同快照不重复入栈。"""
    snapshot = clone_workspace(controller.workspace_state)
    signature = workspace_signature(snapshot)
    if controller._undo_stack and workspace_signature(controller._undo_stack[-1]) == signature:
        return False
    controller._undo_stack.append(snapshot)
    controller._undo_stack = controller._undo_stack[-limit:]
    controller._redo_stack.clear()
    controller._update_undo_buttons()
    return True


def selected_tags(controller: Any) -> list[str]:
    """合并结果表与工作区额外标签,稳定保留用户选择顺序。"""
    table_tags = [row['tag'] for row in controller.result_table.selected] if controller.result_table else []
    seen = set(table_tags)
    extra_pool = set(controller.chip_extra_selected)
    extra = [tag for tag in controller._selected_order if tag in extra_pool and tag not in seen]
    seen.update(extra)
    extra.extend(sorted(tag for tag in extra_pool if tag not in seen))
    return table_tags + extra


def collect_config_state(controller: Any, config_version: int) -> dict:
    """从页面控件采集可持久化的工作区配置。"""
    return {
        'version': config_version,
        'top_k': int(controller.input_top_k.value) if controller.input_top_k else 10,
        'limit': int(controller.input_limit.value) if controller.input_limit else 80,
        'popularity_weight': float(controller.input_weight.value) if controller.input_weight else 0.15,
        'show_nsfw': bool(controller.input_nsfw.value) if controller.input_nsfw else False,
        'use_segmentation': bool(controller.input_segment.value) if controller.input_segment else True,
        'selected_layers': dict(controller.selected_layers),
        'selected_cats': dict(controller.selected_cats),
        'sw_semantic': bool(controller.sw_semantic.value) if controller.sw_semantic else False,
        'sw_layer': bool(controller.sw_layer.value) if controller.sw_layer else False,
        'sw_source': bool(controller.sw_source.value) if controller.sw_source else False,
        'prompt_format': controller.prompt_format,
        'rows_per_page': controller._get_rows_per_page(),
        'search_query': controller.search_input.value if controller.search_input else '',
        'dismissed_announcement_version': controller.dismissed_announcement_version,
        'search_mode': controller.input_search_mode.value if controller.input_search_mode else '自定义',
        'group_mode': controller.input_group_mode.value if controller.input_group_mode else 'off',
        'max_per_group': int(controller.input_max_per_group.value) if controller.input_max_per_group else 2,
    }


def apply_config_state(
    controller: Any,
    config: object,
    announcement_version: str,
) -> None:
    """将已校验的配置按原有顺序同步回控件和工作区状态。"""
    config = sanitize_restored_config(config if isinstance(config, dict) else {})

    dismissed_version = config.get('dismissed_announcement_version', '')
    controller.dismissed_announcement_version = dismissed_version
    if controller.announcement_banner:
        controller.announcement_banner.set_visibility(
            dismissed_version != announcement_version
        )

    if controller.input_search_mode and 'search_mode' in config:
        controller.input_search_mode.set_value(config['search_mode'])
    if controller.input_top_k and 'top_k' in config:
        controller.input_top_k.set_value(config['top_k'])
    if controller.input_limit and 'limit' in config:
        controller.input_limit.set_value(config['limit'])
    if controller.input_weight and 'popularity_weight' in config:
        controller.input_weight.set_value(config['popularity_weight'])
    if controller.input_segment and 'use_segmentation' in config:
        controller.input_segment.set_value(config['use_segmentation'])
    if controller.input_group_mode and 'group_mode' in config:
        controller.input_group_mode.set_value(config['group_mode'])
    if controller.input_max_per_group and 'max_per_group' in config:
        controller.input_max_per_group.set_value(config['max_per_group'])
    if nsfw_allowed() and controller.input_nsfw and 'show_nsfw' in config:
        controller.input_nsfw.set_value(config['show_nsfw'])

    for layer, value in config.get('selected_layers', {}).items():
        if layer in controller.selected_layers:
            controller.selected_layers[layer] = bool(value)
            if layer in controller._layer_checkboxes:
                controller._layer_checkboxes[layer].set_value(bool(value))
    for category, value in config.get('selected_cats', {}).items():
        if category in controller.selected_cats:
            controller.selected_cats[category] = bool(value)
            if category in controller._cat_checkboxes:
                controller._cat_checkboxes[category].set_value(bool(value))

    if controller.sw_semantic and 'sw_semantic' in config:
        controller.sw_semantic.set_value(config['sw_semantic'])
    if controller.sw_layer and 'sw_layer' in config:
        controller.sw_layer.set_value(config['sw_layer'])
    if controller.sw_source and 'sw_source' in config:
        controller.sw_source.set_value(config['sw_source'])
    if 'prompt_format' in config:
        controller._apply_prompt_format(config['prompt_format'])
    if 'rows_per_page' in config:
        controller._set_rows_per_page(config['rows_per_page'])
    if controller.search_input and config.get('search_query'):
        controller.search_input.set_value(config['search_query'])
    controller._update_table_columns()


def current_search_settings(controller: Any) -> dict:
    """采集一条历史记录所需的搜索设置。"""
    return {
        'search_mode': controller.input_search_mode.value if controller.input_search_mode else '自定义',
        'top_k': int(controller.input_top_k.value) if controller.input_top_k else 10,
        'limit': int(controller.input_limit.value) if controller.input_limit else 80,
        'popularity_weight': float(controller.input_weight.value) if controller.input_weight else 0.15,
        'show_nsfw': bool(controller.input_nsfw.value) if controller.input_nsfw else False,
        'use_segmentation': bool(controller.input_segment.value) if controller.input_segment else True,
        'target_layers': [key for key, value in controller.selected_layers.items() if value],
        'target_categories': [key for key, value in controller.selected_cats.items() if value],
        'group_mode': controller.input_group_mode.value if controller.input_group_mode else 'off',
        'max_per_group': int(controller.input_max_per_group.value) if controller.input_max_per_group else 2,
    }


def apply_search_settings(
    controller: Any,
    settings: object,
    search_mode_options: tuple[str, ...],
) -> None:
    """恢复历史/收藏的搜索设置,并保留原有的最后保存动作。"""
    if not isinstance(settings, dict):
        return
    controller._applying_preset = True
    try:
        mode = settings.get('search_mode')
        if controller.input_search_mode and mode in search_mode_options:
            controller.input_search_mode.set_value(mode)
        if controller.input_top_k and isinstance(settings.get('top_k'), int):
            controller.input_top_k.set_value(settings['top_k'])
        if controller.input_limit and isinstance(settings.get('limit'), int):
            controller.input_limit.set_value(settings['limit'])
        if controller.input_weight and isinstance(settings.get('popularity_weight'), (int, float)):
            controller.input_weight.set_value(settings['popularity_weight'])
        if controller.input_segment and isinstance(settings.get('use_segmentation'), bool):
            controller.input_segment.set_value(settings['use_segmentation'])
        if controller.input_group_mode and settings.get('group_mode') in ('off', 'expand', 'diverse'):
            controller.input_group_mode.set_value(settings['group_mode'])
        if controller.input_max_per_group and isinstance(settings.get('max_per_group'), int):
            controller.input_max_per_group.set_value(settings['max_per_group'])
        if nsfw_allowed() and controller.input_nsfw and isinstance(settings.get('show_nsfw'), bool):
            controller.input_nsfw.set_value(settings['show_nsfw'])

        layers = settings.get('target_layers')
        if isinstance(layers, list):
            selected = set(layers)
            for layer in controller.selected_layers:
                value = layer in selected
                controller.selected_layers[layer] = value
                if layer in controller._layer_checkboxes:
                    controller._layer_checkboxes[layer].set_value(value)
        categories = settings.get('target_categories')
        if isinstance(categories, list):
            selected = set(categories)
            for category in controller.selected_cats:
                value = category in selected
                controller.selected_cats[category] = value
                if category in controller._cat_checkboxes:
                    controller._cat_checkboxes[category].set_value(value)
    finally:
        controller._applying_preset = False
    controller._save_config()


def apply_workspace_state(
    controller: Any,
    workspace: dict,
    artist_origins: set[str],
    *,
    persist: bool = True,
    refresh_recommendations: bool = True,
) -> None:
    """将版本化工作区同步至页面状态;渲染仍通过控制器回调完成。"""
    controller.workspace_state = clone_workspace(workspace)
    selected = controller.workspace_state['selected']
    tags = [item['tag'] for item in selected]
    tag_set = set(tags)
    controller._selected_order = list(tags)
    controller.tag_weights = {item['tag']: item.get('weight', 1.0) for item in selected}
    controller._pending_selection_meta = {
        item['tag']: {
            'origin': item.get('origin', 'unknown'),
            'source': item.get('source', ''),
        }
        for item in selected
    }
    controller._workspace_artist_tags = {
        item['tag'] for item in selected
        if item.get('origin') in artist_origins
    }

    table_tags = {row['tag'] for row in controller.result_table.rows} if controller.result_table else set()
    controller.chip_extra_selected.clear()
    controller.chip_extra_selected.update(tag for tag in tags if tag not in table_tags)
    if controller.result_table is not None:
        controller.result_table.selected = [
            row for row in controller.result_table.rows if row.get('tag') in tag_set
        ]
    controller._apply_prompt_format(controller.workspace_state.get('prompt_format', 'sdxl'))
    controller._render_selected_chips()
    controller._render_prompt_pending()
    controller._render_concept_coverage()
    if controller.selection_count_label is not None:
        controller.selection_count_label.text = str(len(tags))
    if controller.results_section is not None:
        controller.results_section.set_visibility(bool(tags) or bool(controller.full_table_data))

    if persist:
        controller._save_staged_tags()
        controller._save_config()
    if refresh_recommendations:
        show_nsfw = bool(controller.input_nsfw.value) if controller.input_nsfw else False
        controller._last_recommendation_seed_tags = []
        controller._refresh_recommendations_if_seed_changed(tags, show_nsfw)


def pop_undo_workspace(controller: Any) -> dict | None:
    """执行撤销栈的纯状态转移,返回需要应用的目标工作区。"""
    if not controller._undo_stack:
        return None
    controller._redo_stack.append(clone_workspace(controller.workspace_state))
    return controller._undo_stack.pop()


def pop_redo_workspace(controller: Any, *, limit: int = 30) -> dict | None:
    """执行重做栈的纯状态转移,返回需要应用的目标工作区。"""
    if not controller._redo_stack:
        return None
    controller._undo_stack.append(clone_workspace(controller.workspace_state))
    controller._undo_stack = controller._undo_stack[-limit:]
    return controller._redo_stack.pop()


def sync_workspace_selection(controller: Any, artist_origins: set[str]) -> list[str]:
    """把当前选择、权重和来源元数据写入版本化工作区状态。"""
    tags = controller._get_selected_tags()
    controller._selected_order = list(tags)
    cn_names = {tag: controller._get_cn_name_for_tag(tag) for tag in tags}
    controller.workspace_state['prompt_format'] = controller.prompt_format
    controller.workspace_state = sync_selected_entries(
        controller.workspace_state,
        tags,
        controller.tag_weights,
        cn_names,
        controller._pending_selection_meta,
    )
    controller._workspace_artist_tags = {
        item['tag'] for item in controller.workspace_state['selected']
        if item.get('origin') in artist_origins
    }
    return tags


def record_search_history_state(controller: Any, query: str) -> None:
    """将当前查询追加到工作区和历史;持久化仍由页面调用方执行。"""
    settings = controller._current_search_settings()
    controller.workspace_state = append_workspace_query(
        controller.workspace_state,
        query,
        settings,
    )
    controller._save_staged_tags()
    controller.search_history = add_history_entry(
        controller.search_history,
        query,
        settings,
        controller.workspace_state,
    )


def remove_history_entry(controller: Any, history_id: object) -> None:
    """按历史 ID 删除一条记录。"""
    controller.search_history['items'] = [
        entry for entry in controller.search_history.get('items', [])
        if entry.get('history_id') != history_id
    ]


def clear_history(controller: Any) -> None:
    """重置搜索历史,不影响当前工作区或收藏。"""
    controller.search_history = empty_history()


def replace_favorites_safely(controller: Any, favorites: dict) -> bool:
    """先尝试保存新收藏;失败时恢复内存中的旧收藏。"""
    previous = controller.favorites
    controller.favorites = favorites
    if controller._save_favorites():
        controller._update_workspace_counts()
        return True
    controller.favorites = previous
    controller._storage_session_dirty.discard('favorites')
    return False


@dataclass(frozen=True)
class BackupImportPlan:
    """规范化备份在页面应用前的纯状态变更计划。"""

    favorites: dict
    workspace: dict | None
    history: dict | None
    config: dict | None
    message: str


def merge_imported_favorite(current_favorites: dict, favorite: object) -> tuple[dict, list[str]]:
    """将单收藏导出内容规范化后合并到当前收藏。"""
    normalized, warnings = normalize_favorites({
        'schema_version': 1,
        'items': [favorite],
    })
    return merge_favorites(current_favorites, normalized), warnings


def build_backup_import_plan(
    current_workspace: dict,
    current_history: dict,
    current_favorites: dict,
    backup: dict,
    mode: str,
) -> BackupImportPlan:
    """生成备份导入方案;合并模式不改变当前配置和同标签权重。"""
    if mode == 'favorites_only':
        return BackupImportPlan(
            favorites=merge_favorites(current_favorites, backup['favorites']),
            workspace=None,
            history=None,
            config=None,
            message='收藏已合并导入',
        )
    if mode == 'overwrite':
        return BackupImportPlan(
            favorites=backup['favorites'],
            workspace=backup['workspace'],
            history=backup['history'],
            config=backup['config'],
            message='本地数据已由备份覆盖',
        )
    return BackupImportPlan(
        favorites=merge_favorites(current_favorites, backup['favorites']),
        workspace=merge_workspaces(current_workspace, backup['workspace']),
        history=merge_history(current_history, backup['history']),
        config=None,
        message='备份已合并;当前标签权重和配置保持不变',
    )