From f5892bb310dca6384ba4978929c70da001a8a5ae Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 16 Jul 2026 16:39:40 +0800 Subject: [PATCH] feat(product-suite): autosave prompt edits --- app/gui/tabs/product_suite.py | 165 +++++++++++++++++++- docs/routes.md | 3 +- docs/tasks/T-638.md | 7 +- tests/test_product_suite_gui.py | 268 ++++++++++++++++++++++++++++++++ 4 files changed, 433 insertions(+), 10 deletions(-) diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index b2844d5..8a65a93 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -64,8 +64,72 @@ from ..workers import ( ORIGINAL_DOWNLOAD_CONCURRENCY = 2 ORIGINAL_CHECK_STATE_ROLE = Qt.UserRole + 1 +PROMPT_AUTOSAVE_DELAY_MS = 500 _PRODUCT_SUITE_THREAD_REFS = {} _URL_RE = re.compile(r"https?://[^\s,,;;))\]]+", re.IGNORECASE) + + +class AutoHeightPlainTextEdit(QPlainTextEdit): + """A plain-text editor that delegates scrolling to its containing page.""" + + def __init__(self, parent=None, minimum_height=96): + super().__init__(parent) + self._minimum_content_height = max(1, int(minimum_height)) + self._height_update_pending = False + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFixedHeight(self._minimum_content_height) + self.document().contentsChanged.connect(self.schedule_height_update) + self.document().documentLayout().documentSizeChanged.connect( + self.schedule_height_update + ) + self.schedule_height_update() + + def schedule_height_update(self, *args): + if self._height_update_pending: + return + self._height_update_pending = True + QTimer.singleShot(0, self.refresh_height) + + def refresh_height(self): + self._height_update_pending = False + text_height = 0.0 + block = self.document().firstBlock() + while block.isValid(): + layout = block.layout() + line_count = max(1, layout.lineCount()) + for index in range(line_count): + line = layout.lineAt(index) + text_height += ( + line.height() + if line.isValid() + else self.fontMetrics().lineSpacing() + ) + block = block.next() + margins = self.contentsMargins() + target = int( + text_height + + (self.document().documentMargin() * 2) + + margins.top() + + margins.bottom() + + (self.frameWidth() * 2) + + 6 + ) + target = max(self._minimum_content_height, target) + if self.height() != target: + self.setFixedHeight(target) + self.updateGeometry() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.schedule_height_update() + + def showEvent(self, event): + super().showEvent(event) + self.schedule_height_update() + + def _asset_usable(asset): path = str(getattr(asset, "local_path", "") or "") return ( @@ -618,6 +682,7 @@ class SuiteTaskState: project_id: int = None project_binding_state: str = "" prompt: str = "" + last_saved_prompt: str = "" settings: dict = field(default_factory=product_suite.default_suite_settings) current_job_ids: list = field(default_factory=list) show_history: bool = False @@ -673,6 +738,7 @@ class ProductSuiteTab(QWidget): self._next_key = 1 self._next_serial = 1 self._displayed_state = None + self._prompt_save_timers = {} self._original_list_context = None self._loading = False self._result_refresh_pending = False @@ -985,11 +1051,9 @@ class ProductSuiteTab(QWidget): self.prompt_settings_button.setToolTip("编辑并预览套图最终提示词") title_row.addWidget(self.prompt_settings_button) layout.addLayout(title_row) - self.prompt_edit = QPlainTextEdit() + self.prompt_edit = AutoHeightPlainTextEdit() self.prompt_edit.setObjectName("suitePromptEdit") self.prompt_edit.setPlaceholderText("输入产品名称、卖点、目标人群、使用场景和禁用元素") - self.prompt_edit.setMinimumHeight(96) - self.prompt_edit.setMaximumHeight(112) layout.addWidget(self.prompt_edit) helper = QLabel("内容越具体,生成的商品套图越稳定。AI 帮写不会阻塞其他套图任务。") helper.setWordWrap(True) @@ -1198,6 +1262,7 @@ class ProductSuiteTab(QWidget): project_id=int(project.id), project_binding_state=project.binding_state, prompt=str(project.draft_prompt or ""), + last_saved_prompt=str(project.draft_prompt or ""), settings=product_suite.normalize_suite_settings( image_studio.project_suite_settings(project) ), @@ -1240,6 +1305,9 @@ class ProductSuiteTab(QWidget): state = self._states.get(key) if state is None: return + if state is self._displayed_state: + self._save_controls_to_state(state) + self._flush_prompt_save(state) project = self._state_project(state) draft_action = None if image_studio.is_draft_project(project) and image_studio.project_has_content( @@ -1278,6 +1346,7 @@ class ProductSuiteTab(QWidget): image_studio.discard_empty_draft_project(project.id, path=self.db_path) except Exception as exc: self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger") + self._release_prompt_save_timer(state) self._retired_states.append(state) self._states.pop(state.key, None) self.task_tabs.removeTab(index) @@ -1313,6 +1382,7 @@ class ProductSuiteTab(QWidget): return if self._displayed_state is not None: self._save_controls_to_state(self._displayed_state) + self._flush_prompt_save(self._displayed_state) state = self._state_for_index(index) self._displayed_state = state if state is not None: @@ -1433,6 +1503,7 @@ class ProductSuiteTab(QWidget): self._loading = False self._update_context_actions(state) return + self._flush_prompt_save(state) try: project = image_studio.bind_draft_project( state.project_id, @@ -1484,8 +1555,10 @@ class ProductSuiteTab(QWidget): self._update_context_actions(state) def _clear_project_binding(self, state): + self._flush_prompt_save(state) state.project_id = None state.project_binding_state = "" + state.last_saved_prompt = "" state.current_job_ids = [] state.done = state.failed = state.total = 0 state.started_at = None @@ -1547,6 +1620,7 @@ class ProductSuiteTab(QWidget): if state is None: return None if state.project_id is not None: + self._flush_prompt_save(state) return self._state_project(state) if state.item_id: return self._bind_project(state) @@ -1564,6 +1638,7 @@ class ProductSuiteTab(QWidget): project = image_studio.create_or_get_project( account, item_id=state.item_id, + draft_prompt=state.prompt, path=self.db_path, ) except Exception as exc: @@ -1571,13 +1646,17 @@ class ProductSuiteTab(QWidget): return None state.project_id = int(project.id) state.project_binding_state = project.binding_state + stored_prompt = str(project.draft_prompt or "") if load_existing and previous_id != state.project_id: - state.prompt = str(project.draft_prompt or "") + state.prompt = stored_prompt state.settings = product_suite.normalize_suite_settings( image_studio.project_suite_settings(project) ) if state is self._displayed_state: self._load_state(state) + state.last_saved_prompt = stored_prompt + if state.prompt != stored_prompt: + self._flush_prompt_save(state) return project def _create_draft_project(self, state): @@ -1596,6 +1675,7 @@ class ProductSuiteTab(QWidget): state.project_id = int(project.id) state.project_binding_state = project.binding_state state.item_id = "" + state.last_saved_prompt = str(project.draft_prompt or "") self._persist_state(state) self._set_task_title(state) if state is self._displayed_state: @@ -1603,15 +1683,75 @@ class ProductSuiteTab(QWidget): self._status("已创建临时草稿,可继续添加本地图片", "info") return project - def _persist_state(self, state): - if state.project_id is None: + def _prompt_save_timer(self, state): + timer = self._prompt_save_timers.get(state.key) + if timer is None: + timer = QTimer(self) + timer.setSingleShot(True) + timer.setInterval(PROMPT_AUTOSAVE_DELAY_MS) + timer.timeout.connect( + lambda key=state.key: self._on_prompt_autosave_timeout(key) + ) + self._prompt_save_timers[state.key] = timer + return timer + + def _schedule_prompt_save(self, state): + if state is None or state.project_id is None: return + if state.prompt == state.last_saved_prompt: + self._cancel_prompt_save(state) + return + self._prompt_save_timer(state).start() + + def _cancel_prompt_save(self, state): + timer = self._prompt_save_timers.get(state.key) if state is not None else None + if timer is not None: + timer.stop() + + def _release_prompt_save_timer(self, state): + timer = ( + self._prompt_save_timers.pop(state.key, None) + if state is not None + else None + ) + if timer is not None: + timer.stop() + timer.deleteLater() + + def _on_prompt_autosave_timeout(self, state_key): + state = self._states.get(state_key) + if state is not None: + self._persist_prompt(state) + + def _persist_prompt(self, state): + if state is None or state.project_id is None: + return True + prompt = str(state.prompt or "") + if prompt == state.last_saved_prompt: + return True try: - image_studio.update_project_prompt( + project = image_studio.update_project_prompt( state.project_id, - state.prompt, + prompt, path=self.db_path, ) + if project is None: + raise image_studio.ImageStudioError("商品套图项目不存在") + except Exception as exc: + self._status("商品卖点自动保存失败:%s" % _user_error(exc), "danger") + return False + state.last_saved_prompt = prompt + return True + + def _flush_prompt_save(self, state): + self._cancel_prompt_save(state) + return self._persist_prompt(state) + + def _persist_state(self, state): + if state.project_id is None: + return True + prompt_saved = self._flush_prompt_save(state) + try: image_studio.update_project_suite_settings( state.project_id, state.settings, @@ -1619,6 +1759,8 @@ class ProductSuiteTab(QWidget): ) except Exception as exc: self._status("商品套图设置保存失败:%s" % _user_error(exc), "danger") + return False + return prompt_saved def _on_settings_changed(self, value=None): if self._loading or self._displayed_state is None: @@ -1655,6 +1797,7 @@ class ProductSuiteTab(QWidget): return state = self._displayed_state state.prompt = self.prompt_edit.toPlainText() + self._schedule_prompt_save(state) def open_prompt_settings(self, checked=False): state = self._displayed_state @@ -2754,10 +2897,16 @@ class ProductSuiteTab(QWidget): self._refresh_results(self._displayed_state) def closeEvent(self, event): + if self._displayed_state is not None: + self._save_controls_to_state(self._displayed_state) + for state in list(self._states.values()): + self._flush_prompt_save(state) for state in list(self._states.values()) + list(self._retired_states): for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker): if worker is not None and hasattr(worker, "cancel"): worker.cancel() for worker, thread in list(state.downloads.values()): worker.cancel() + for state in list(self._states.values()) + list(self._retired_states): + self._release_prompt_save_timer(state) super().closeEvent(event) diff --git a/docs/routes.md b/docs/routes.md index 4995e50..685ae3d 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -198,7 +198,8 @@ - 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。 - 二级套图任务标签使用独立紧凑样式,不继承主模块 Tab 的大尺寸点击区。上下文栏左侧集中「历史生成 / 打开结果文件夹 / 添加图片」,右侧集中账号、商品 ID 和拉取入口;常见 11~13 位商品 ID 不得裁切,长账号可通过 tooltip 查看完整名称。 -- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。 +- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`,用户停止输入约 500ms 后自动保存,切换任务、关闭任务或程序前同步补保存。未创建项目时只保留在当前任务内存,不因输入文字自动创建临时草稿。 +- 「商品卖点与要求」关闭内部横向和纵向滚动条,输入框按完整换行内容自然增高;文本删除后可缩回最低高度,页面过长时统一由左侧 `suiteConfigScroll` 滚动。 - 商品原图最多16张。前6个槽位固定显示主图与参考1~5;列表关闭内部滚动条,按可用宽度换行并自然向下展开,由左侧配置区统一滚动。支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。 - 每张真实原图左上角提供独立勾选框,标题行显示「已选 N 张 / 全选 / 反选」;添加占位图不参与选择。勾选只在当前任务界面内临时保留,普通刷新和排序按资产 ID 保留,切换任务或删除成功后清空。右键或 Delete 可批量移除,确认框说明准确数量、主图变化及非破坏性边界;生成中或勾选项仍在下载时整批阻断。移除只删除当前项目的本地资产记录,不删除本地源文件或蝦皮线上图片。 - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 diff --git a/docs/tasks/T-638.md b/docs/tasks/T-638.md index a55deb7..9aa8e3a 100644 --- a/docs/tasks/T-638.md +++ b/docs/tasks/T-638.md @@ -3,7 +3,7 @@ id: T-638 title: 商品套图卖点输入自动保存与自适应高度 phase: 7 deps: [T-637] -status: TODO +status: DONE created: 2026-07-16 --- @@ -105,3 +105,8 @@ git diff --check - 不修改 CDP、蝦皮主图拉取、①导入采集、②AI生成、③更新蝦皮、④账号管理或⑤设置。 ## 执行记录 + +- 2026-07-16:⑥商品套图为每个 `SuiteTaskState` 增加独立的 500ms 单次保存定时器和最近成功保存快照;连续输入只写最终内容,定时回调按 `state.key` 找回原任务,不依赖届时显示中的任务。切换任务、关闭任务、关闭程序、切换项目、创建项目和开始本地工作前会停止待执行定时并同步补保存。 +- 2026-07-16:未创建项目时卖点只保存在当前任务内存,不因输入文字创建临时草稿;创建正式项目或临时草稿时把当前卖点写入 `draft_prompt`。AI 帮写结果继续通过统一项目持久化入口立即保存,失败时保留内存文本并显示中文 danger 状态。 +- 2026-07-16:新增 `AutoHeightPlainTextEdit`,关闭内部横向和纵向滚动条,按每个实际换行的文本行高度动态增高;空内容保持 96px,删除内容可缩回,宽度变化时重新计算,由现有 `suiteConfigScroll` 统一承载页面滚动。同步更新 `docs/routes.md`。 +- 2026-07-16:`tests/test_product_suite_gui.py` 新增 6 项场景测试,覆盖防抖去重、任务切换/关闭不串写、无项目不建草稿、保存失败、AI 帮写保存和自适应高度。定向 25 项通过;隔离工作树全量 524 项 unittest、Ruff、compileall、`git diff --check` 全部通过。当前主工作区全量测试仅有 3 项原有封面默认模板 `papa1` 改名导致的失败,该未提交用户改动未纳入本任务。 diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 75f0775..c670b68 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -20,6 +20,7 @@ from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QLabel, QListWidgetItem, QPushButton from app.gui.tabs.product_suite import ( + AutoHeightPlainTextEdit, ORIGINAL_CHECK_STATE_ROLE, ProductOriginalDelegate, ProductOriginalList, @@ -555,6 +556,273 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_prompt_autosaves_after_debounce_and_ignores_unchanged_text(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + project = image_studio.create_or_get_project( + account, + item_id="51100639510", + draft_prompt="原卖点", + path=config["db_path"], + ) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.prompt = project.draft_prompt + state.last_saved_prompt = project.draft_prompt + tab._load_state(state) + + original_update = image_studio.update_project_prompt + with mock.patch( + "app.gui.tabs.product_suite.image_studio.update_project_prompt", + wraps=original_update, + ) as update_prompt: + tab.prompt_edit.setPlainText("第一版卖点") + tab.prompt_edit.setPlainText("最终卖点") + QTest.qWait(650) + self.app.processEvents() + + stored = image_studio.get_project(project.id, path=config["db_path"]) + self.assertEqual("最终卖点", stored.draft_prompt) + self.assertEqual(1, update_prompt.call_count) + + QTest.qWait(600) + self.app.processEvents() + self.assertEqual(1, update_prompt.call_count) + + self.assert_removed(temp_dir) + + def test_prompt_switch_and_close_flush_to_the_correct_projects(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + first_project = image_studio.create_or_get_project( + account, + item_id="51100639510", + path=config["db_path"], + ) + second_project = image_studio.create_or_get_project( + account, + item_id="51100639511", + path=config["db_path"], + ) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + first_state = tab._displayed_state + first_state.account_alias = "alias-a" + first_state.item_id = first_project.item_id + first_state.project_id = first_project.id + first_state.project_binding_state = first_project.binding_state + tab._load_state(first_state) + tab.prompt_edit.setPlainText("商品一卖点") + + second_state = tab.add_task(inherit=False) + second_state.account_alias = "alias-a" + second_state.item_id = second_project.item_id + second_state.project_id = second_project.id + second_state.project_binding_state = second_project.binding_state + tab._load_state(second_state) + tab.prompt_edit.setPlainText("商品二卖点") + tab.task_tabs.setCurrentIndex(0) + self.app.processEvents() + + self.assertEqual( + "商品一卖点", + image_studio.get_project( + first_project.id, + path=config["db_path"], + ).draft_prompt, + ) + self.assertEqual( + "商品二卖点", + image_studio.get_project( + second_project.id, + path=config["db_path"], + ).draft_prompt, + ) + + tab.prompt_edit.setPlainText("商品一关闭任务前卖点") + tab.close_task(0) + self.app.processEvents() + self.assertEqual( + "商品一关闭任务前卖点", + image_studio.get_project( + first_project.id, + path=config["db_path"], + ).draft_prompt, + ) + + self.assertIs(second_state, tab._displayed_state) + tab.prompt_edit.setPlainText("商品二关闭程序前卖点") + tab.close() + self.app.processEvents() + self.assertEqual( + "商品二关闭程序前卖点", + image_studio.get_project( + second_project.id, + path=config["db_path"], + ).draft_prompt, + ) + + self.assert_removed(temp_dir) + + def test_prompt_without_project_stays_in_memory_until_draft_creation(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=config) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + + tab.prompt_edit.setPlainText("尚未建立项目的卖点") + QTest.qWait(650) + self.app.processEvents() + + self.assertEqual([], image_studio.list_projects(path=config["db_path"])) + draft = tab._create_draft_project(state) + self.assertIsNotNone(draft) + stored = image_studio.get_project(draft.id, path=config["db_path"]) + self.assertEqual("尚未建立项目的卖点", stored.draft_prompt) + self.assertEqual("尚未建立项目的卖点", state.last_saved_prompt) + + self.assert_removed(temp_dir) + + def test_prompt_autosave_failure_keeps_memory_text_and_reports_chinese_error(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + project = image_studio.create_or_get_project( + account, + item_id="51100639510", + path=config["db_path"], + ) + status = mock.Mock() + tab = ProductSuiteTab( + config=config, + db_path=config["db_path"], + status_callback=status, + ) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + tab._load_state(state) + + with mock.patch( + "app.gui.tabs.product_suite.image_studio.update_project_prompt", + side_effect=OSError("disk unavailable"), + ): + tab.prompt_edit.setPlainText("保存失败仍保留") + QTest.qWait(650) + self.app.processEvents() + + self.assertEqual("保存失败仍保留", state.prompt) + self.assertEqual("", state.last_saved_prompt) + self.assertTrue( + any( + "商品卖点自动保存失败" in str(call.args[0]) + for call in status.call_args_list + ) + ) + self.assertTrue( + any(call.kwargs.get("level") == "danger" for call in status.call_args_list) + ) + + self.assert_removed(temp_dir) + + def test_ai_write_result_saves_prompt_without_an_extra_user_action(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + account = accounts.create_account( + "主店", + "alias-a", + debug_port=9222, + config=config, + ) + project = image_studio.create_or_get_project( + account, + item_id="51100639510", + draft_prompt="原卖点", + path=config["db_path"], + ) + tab = ProductSuiteTab(config=config, db_path=config["db_path"]) + self.addCleanup(tab.close) + state = tab._displayed_state + state.account_alias = "alias-a" + state.item_id = project.item_id + state.project_id = project.id + state.project_binding_state = project.binding_state + state.prompt = project.draft_prompt + state.last_saved_prompt = project.draft_prompt + state.ai_prompt_snapshot = project.draft_prompt + tab._load_state(state) + + tab._on_ai_write_finished( + state, + {"ok": True, "cancelled": False, "text": "AI生成的新卖点"}, + ) + + stored = image_studio.get_project(project.id, path=config["db_path"]) + self.assertEqual("AI生成的新卖点", stored.draft_prompt) + self.assertEqual("AI生成的新卖点", state.last_saved_prompt) + self.assertEqual("AI生成的新卖点", tab.prompt_edit.toPlainText()) + + self.assert_removed(temp_dir) + + def test_prompt_edit_expands_shrinks_and_reflows_without_internal_scrollbars(self): + edit = AutoHeightPlainTextEdit() + self.addCleanup(edit.close) + edit.resize(420, 96) + edit.show() + self.app.processEvents() + + self.assertEqual(Qt.ScrollBarAlwaysOff, edit.horizontalScrollBarPolicy()) + self.assertEqual(Qt.ScrollBarAlwaysOff, edit.verticalScrollBarPolicy()) + self.assertGreaterEqual(edit.height(), 96) + minimum_height = edit.height() + + edit.setPlainText("\n".join("第%d行商品卖点" % index for index in range(1, 13))) + QTest.qWait(50) + self.app.processEvents() + expanded_height = edit.height() + self.assertGreater(expanded_height, minimum_height) + + edit.clear() + QTest.qWait(50) + self.app.processEvents() + self.assertEqual(minimum_height, edit.height()) + + edit.setPlainText("这是一段用于测试窗口变窄后自动换行的商品卖点内容。" * 16) + edit.setFixedWidth(420) + QTest.qWait(50) + self.app.processEvents() + wide_height = edit.height() + edit.setFixedWidth(180) + QTest.qWait(50) + self.app.processEvents() + self.assertGreater(edit.height(), wide_height) + def test_project_settings_and_result_history_use_existing_backend(self): with self.make_temp_dir() as temp_dir: config = self._config(temp_dir)