@danhachuel/thunderbolt 0.7.16 → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/main.py CHANGED
@@ -35,7 +35,6 @@ from typing import Any
35
35
 
36
36
  import requests
37
37
  import streamlit as st
38
- import streamlit.components.v1 as components
39
38
 
40
39
  ROOT = Path(__file__).resolve().parents[1]
41
40
  if str(ROOT) not in sys.path:
@@ -83,7 +82,7 @@ from hermes_ui.channel_import import build_channel_template_xlsx, channel_is_dup
83
82
  from hermes_ui.drafts import list_drafts, save_draft
84
83
  from hermes_ui.automation_worker import load_worker_status
85
84
  from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
86
- from hermes_ui.storage import BLUEPRINTS, DEFAULT_LLM_PROVIDER, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, atomic_write, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
85
+ from hermes_ui.storage import BLUEPRINTS, DEFAULT_LLM_PROVIDER, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, atomic_write, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, update_json, write_json
87
86
  from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
88
87
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
89
88
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
@@ -115,7 +114,7 @@ from hermes_ui.api_key_tests import test_apify_credentials, test_influencer_data
115
114
  from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
116
115
  from hermes_ui.update_manager import check_version, restart_current_process, update_to_latest
117
116
 
118
- from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
117
+ from hermes_ui.script_documents import delete_script_document, list_script_documents, read_script_document, save_script_document, script_storage_path, update_script_document, update_script_document_metadata
119
118
  from hermes_ui.script_generation import generate_script_document
120
119
  from hermes_ui.video_length import DEFAULT_AVERAGE_VIDEO_TIME, channel_video_length, channel_video_time_value, default_average_video_time, length_generation_settings, words_from_channel_time
121
120
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
@@ -268,8 +267,8 @@ ensure_storage()
268
267
  st.set_page_config(page_title="Thunderbolt", page_icon="T", layout="wide", initial_sidebar_state="expanded")
269
268
 
270
269
  theme_base = (st.get_option("theme.base") or "dark").lower()
271
- components.html(
272
- """
270
+ st.iframe(
271
+ srcdoc="""
273
272
  <script>
274
273
  (() => {
275
274
  const STYLE_ID = "thunderbolt-light-theme-style";
@@ -698,6 +697,52 @@ def blueprint_for_channel(channel: dict) -> dict[str, Any]:
698
697
  return {"id": blueprint_id, "name": blueprint_id}
699
698
 
700
699
 
700
+ def _script_channel(record: dict[str, Any], channels: list[dict[str, Any]]) -> dict[str, Any]:
701
+ channel_id = str(record.get("channel_id") or "").strip()
702
+ channel_name = str(record.get("channel_name") or record.get("channel") or "").strip().casefold()
703
+ return next(
704
+ (
705
+ channel
706
+ for channel in channels
707
+ if isinstance(channel, dict)
708
+ and (
709
+ (channel_id and str(channel.get("id") or "").strip() == channel_id)
710
+ or (channel_name and str(channel.get("name") or "").strip().casefold() == channel_name)
711
+ )
712
+ ),
713
+ {},
714
+ )
715
+
716
+
717
+ def _backfill_saved_script_blueprints(records: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
718
+ """Attach every saved video script to the Blueprint configured on its channel."""
719
+ channels = [item for item in read_json("channels.json", []) if isinstance(item, dict)]
720
+ updated_records: list[dict[str, Any]] = []
721
+ for record in records if records is not None else list_script_documents():
722
+ if str(record.get("document_type") or "video_script").strip() != "video_script":
723
+ continue
724
+ channel = _script_channel(record, channels)
725
+ if not channel:
726
+ continue
727
+ blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
728
+ blueprint = blueprint_for_channel(channel)
729
+ blueprint_name = str(blueprint.get("name") or blueprint_id or "SEM BLUEPRINT CONFIGURADO").strip()
730
+ current_id = str(record.get("blueprint_id") or "").strip()
731
+ current_name = str(record.get("blueprint_name") or record.get("blueprint") or "").strip()
732
+ if current_id == blueprint_id and current_name == blueprint_name:
733
+ updated_records.append(record)
734
+ continue
735
+ try:
736
+ updated = update_script_document_metadata(
737
+ str(record.get("id") or ""),
738
+ {"blueprint_id": blueprint_id, "blueprint": blueprint_name},
739
+ )
740
+ except (FileNotFoundError, OSError, ValueError):
741
+ updated = {**record, "blueprint_id": blueprint_id, "blueprint_name": blueprint_name}
742
+ updated_records.append(updated)
743
+ return updated_records
744
+
745
+
701
746
  def channel_video_language(channel: dict[str, Any] | None, fallback: str = "pt") -> str:
702
747
  """Return the channel's canonical video language, preserving legacy labels."""
703
748
  if not isinstance(channel, dict):
@@ -3355,10 +3400,29 @@ def _create_video_task_from_saved_script(record: dict[str, Any], channel: dict[s
3355
3400
  {
3356
3401
  **payload,
3357
3402
  "topic_source": "saved_script",
3403
+ "script_document_id": str(record.get("id") or ""),
3358
3404
  "channel_payloads": {str(channel.get("id") or ""): payload},
3359
3405
  },
3360
3406
  )
3361
- return create_tasks_for_batch(batch)
3407
+ tasks = create_tasks_for_batch(batch)
3408
+ for task in tasks:
3409
+ task["script_document_id"] = str(record.get("id") or "")
3410
+ task["manual_start_required"] = True
3411
+ task["created_at"] = str(record.get("created_at") or task.get("created_at") or now())
3412
+ task["updated_at"] = now()
3413
+ if tasks:
3414
+ task_ids = {str(task.get("id") or "") for task in tasks}
3415
+ created_by_id = {str(task.get("id") or ""): task for task in tasks}
3416
+
3417
+ def persist_created_metadata(entries: Any) -> None:
3418
+ if not isinstance(entries, list):
3419
+ return
3420
+ for entry in entries:
3421
+ if isinstance(entry, dict) and str(entry.get("id") or "") in task_ids:
3422
+ entry.update(created_by_id[str(entry.get("id") or "")])
3423
+
3424
+ update_json("tasks.json", [], persist_created_metadata)
3425
+ return tasks
3362
3426
 
3363
3427
 
3364
3428
  def render_video_from_draft(prefix: str = "new_video") -> None:
@@ -4172,7 +4236,7 @@ def render_scripts():
4172
4236
 
4173
4237
  with history_tab:
4174
4238
  st.caption(f"Histórico persistente: `{script_dir}` · índice em `{STORAGE / 'state' / 'scripts.json'}`")
4175
- records = list_script_documents()
4239
+ records = _backfill_saved_script_blueprints(list_script_documents())
4176
4240
  if not records:
4177
4241
  st.info("Ainda não existem roteiros ou letras guardados.")
4178
4242
  for record in records[:50]:
@@ -4182,9 +4246,54 @@ def render_scripts():
4182
4246
  stored_path = Path(str(record.get("path") or ""))
4183
4247
  content = read_script_document(record)
4184
4248
  if content:
4185
- st.text_area("Conteúdo", value=content, height=220, key=f"stored_script_{record.get('id')}")
4249
+ document_id = str(record.get("id") or "")
4250
+ edit_key = f"editing_script_{document_id}"
4251
+ if st.session_state.get(edit_key):
4252
+ edited_content = st.text_area("Markdown editável", value=content, height=320, key=f"stored_script_edit_{document_id}")
4253
+ edit_save_col, edit_cancel_col = st.columns(2, gap="small")
4254
+ with edit_save_col:
4255
+ if st.button("Guardar edição", type="primary", width="stretch", key=f"save_script_edit_{document_id}"):
4256
+ try:
4257
+ update_script_document(document_id, edited_content)
4258
+ st.session_state.pop(edit_key, None)
4259
+ st.success("Roteiro actualizado.")
4260
+ st.rerun()
4261
+ except (FileNotFoundError, OSError, ValueError) as exc:
4262
+ st.error(f"Não foi possível actualizar o roteiro: {exc}")
4263
+ with edit_cancel_col:
4264
+ if st.button("Cancelar edição", width="stretch", key=f"cancel_script_edit_{document_id}"):
4265
+ st.session_state.pop(edit_key, None)
4266
+ st.rerun()
4267
+ else:
4268
+ st.text_area("Conteúdo", value=content, height=220, key=f"stored_script_{document_id}")
4186
4269
  if stored_path.is_file():
4187
- st.download_button("Descarregar Markdown", data=stored_path.read_bytes(), file_name=stored_path.name, mime="text/markdown", key=f"download_script_{record.get('id')}")
4270
+ download_col, edit_col, delete_col = st.columns([1.25, 0.75, 0.75], gap="small")
4271
+ with download_col:
4272
+ st.download_button("Descarregar Markdown", data=stored_path.read_bytes(), file_name=stored_path.name, mime="text/markdown", key=f"download_script_{document_id}", width="stretch")
4273
+ with edit_col:
4274
+ if st.button("Editar", key=f"edit_script_{document_id}", width="stretch"):
4275
+ st.session_state[edit_key] = True
4276
+ st.rerun()
4277
+ with delete_col:
4278
+ confirm_delete_key = f"confirm_delete_script_{document_id}"
4279
+ if st.button("Apagar", key=f"delete_script_{document_id}", type="primary", width="stretch"):
4280
+ st.session_state[confirm_delete_key] = True
4281
+ st.rerun()
4282
+ if st.session_state.get(confirm_delete_key):
4283
+ st.warning("Apagar este roteiro do histórico e do storage?")
4284
+ confirm_col, cancel_col = st.columns(2, gap="small")
4285
+ with confirm_col:
4286
+ if st.button("Confirmar apagar", key=f"confirm_delete_script_button_{document_id}", type="primary", width="stretch"):
4287
+ try:
4288
+ delete_script_document(document_id)
4289
+ st.session_state.pop(confirm_delete_key, None)
4290
+ st.rerun()
4291
+ except (OSError, ValueError) as exc:
4292
+ st.error(f"Não foi possível apagar o roteiro: {exc}")
4293
+ with cancel_col:
4294
+ if st.button("Cancelar", key=f"cancel_delete_script_{document_id}", width="stretch"):
4295
+ st.session_state.pop(confirm_delete_key, None)
4296
+ st.rerun()
4188
4297
  else:
4189
4298
  st.warning("O ficheiro deste registo já não está disponível no storage.")
4190
4299
 
@@ -5002,10 +5111,79 @@ def task_platform(task: dict[str, Any]) -> str:
5002
5111
  return "tiktok" if format_value in {"portrait", "portrait 9:16", "9:16"} else "youtube"
5003
5112
 
5004
5113
 
5114
+ def _saved_script_task_matches(task: dict[str, Any], record: dict[str, Any], channel: dict[str, Any], content: str) -> bool:
5115
+ document_id = str(record.get("id") or "").strip()
5116
+ if document_id and str(task.get("script_document_id") or "").strip() == document_id:
5117
+ return True
5118
+ if str(task.get("topic_source") or "").strip() != "saved_script":
5119
+ return False
5120
+ if str(task.get("channel_id") or "").strip() != str(channel.get("id") or "").strip():
5121
+ return False
5122
+ generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
5123
+ saved_content = str(generation_settings.get("video_script") or task.get("video_script") or "").strip()
5124
+ if saved_content and saved_content == content.strip():
5125
+ return True
5126
+ title = str(record.get("title") or "").strip()
5127
+ return bool(title and str(task.get("title") or task.get("topic") or "").strip() == title)
5128
+
5129
+
5130
+ def _sync_saved_scripts_to_youtube_automation() -> None:
5131
+ """Expose every saved video script as one manual YouTube task, without duplicates."""
5132
+ records = _backfill_saved_script_blueprints(list_script_documents())
5133
+ channels = [item for item in read_json("channels.json", []) if isinstance(item, dict)]
5134
+ existing_tasks = load_video_tasks_for_catalog()
5135
+ for raw_record in records:
5136
+ if str(raw_record.get("document_type") or "video_script").strip() != "video_script":
5137
+ continue
5138
+ content = read_script_document(raw_record)
5139
+ if not content:
5140
+ continue
5141
+ channel = _script_channel(raw_record, channels)
5142
+ if not channel or task_platform({"platform": channel.get("platform", "youtube"), "channel_id": channel.get("id")}) != "youtube":
5143
+ continue
5144
+ record = normalise_saved_script({**raw_record, "channel_id": str(channel.get("id") or "")}, content)
5145
+ matched_task = next((task for task in existing_tasks if _saved_script_task_matches(task, record, channel, content)), None)
5146
+ if matched_task:
5147
+ if str(matched_task.get("script_document_id") or "") != str(record.get("id") or "") or (
5148
+ str(matched_task.get("state") or "") == "to_do" and not bool(matched_task.get("manual_start_required", False))
5149
+ ):
5150
+ matched_id = str(matched_task.get("id") or "")
5151
+
5152
+ def mark_existing_script(entries: Any) -> None:
5153
+ if not isinstance(entries, list):
5154
+ return
5155
+ for entry in entries:
5156
+ if isinstance(entry, dict) and str(entry.get("id") or "") == matched_id:
5157
+ entry["script_document_id"] = str(record.get("id") or "")
5158
+ if str(entry.get("state") or "") == "to_do":
5159
+ entry["manual_start_required"] = True
5160
+
5161
+ update_json("tasks.json", [], mark_existing_script)
5162
+ continue
5163
+ created = _create_video_task_from_saved_script(record, channel, {})
5164
+ existing_tasks.extend(created)
5165
+
5166
+
5167
+ def _automation_created_at(task: dict[str, Any]) -> tuple[datetime, str]:
5168
+ value = str(task.get("created_at") or "").strip()
5169
+ try:
5170
+ parsed = datetime.fromisoformat(value)
5171
+ if parsed.tzinfo is None:
5172
+ parsed = parsed.replace(tzinfo=timezone.utc)
5173
+ except ValueError:
5174
+ parsed = datetime.min.replace(tzinfo=timezone.utc)
5175
+ return parsed.astimezone(timezone.utc), str(task.get("id") or "")
5176
+
5177
+
5005
5178
  def load_automation_tasks_for_platform(platform_name: str) -> list[dict[str, Any]]:
5006
5179
  """Return only automation/catalog tasks belonging to one publishing platform."""
5007
5180
  target = str(platform_name or "").strip().casefold()
5008
- return [task for task in load_video_tasks_for_catalog() if task_platform(task) == target]
5181
+ if target == "youtube":
5182
+ _sync_saved_scripts_to_youtube_automation()
5183
+ return sorted(
5184
+ [task for task in load_video_tasks_for_catalog() if task_platform(task) == target],
5185
+ key=_automation_created_at,
5186
+ )
5009
5187
 
5010
5188
 
5011
5189
  def _task_thumbnail_path(task: dict[str, Any]) -> Path | None:
@@ -5126,6 +5304,8 @@ def _start_pipeline_task(task_id: str, state: str) -> bool:
5126
5304
  """Start or retry a video task from any platform automation card."""
5127
5305
  if state in {"blocked", "failed"}:
5128
5306
  updated = retry_task_with_current_settings(task_id)
5307
+ if updated and bool(updated.get("manual_start_required", False)):
5308
+ updated = transition_task(task_id, "doing")
5129
5309
  else:
5130
5310
  updated = transition_task(task_id, "doing")
5131
5311
  if not updated:
@@ -297,6 +297,26 @@ def _update(task_id: str, **updates: Any) -> dict[str, Any]:
297
297
  orchestration=updated.get("orchestration") or {},
298
298
  )
299
299
  return updated
300
+
301
+
302
+ def _next_runnable_task(tasks: Any) -> dict[str, Any] | None:
303
+ """Return the first active task, excluding saved scripts awaiting Start."""
304
+ if not isinstance(tasks, list):
305
+ return None
306
+ return next(
307
+ (
308
+ task
309
+ for task in tasks
310
+ if isinstance(task, dict)
311
+ and (
312
+ task.get("state") == "doing"
313
+ or (task.get("state") == "to_do" and not bool(task.get("manual_start_required", False)))
314
+ )
315
+ ),
316
+ None,
317
+ )
318
+
319
+
300
320
  def _channel_for_task(task: dict[str, Any]) -> dict[str, Any]:
301
321
  channel_id = str(task.get("channel_id") or "")
302
322
  return next((channel for channel in read_json("channels.json", []) if str(channel.get("id")) == channel_id), {})
@@ -1641,7 +1661,7 @@ def run_once() -> dict[str, Any]:
1641
1661
  _worker_heartbeat(session_info_health_error=type(exc).__name__)
1642
1662
  recovered = _recover_stale_tasks()
1643
1663
  tasks = read_json("tasks.json", [])
1644
- candidate = next((task for task in tasks if isinstance(task, dict) and task.get("state") in {"to_do", "doing"}), None)
1664
+ candidate = _next_runnable_task(tasks)
1645
1665
  if not candidate:
1646
1666
  _worker_heartbeat(last_task_id=None, last_error="", status="idle", stage="idle", progress=0, recovered_task_ids=recovered)
1647
1667
  return {"ok": True, "status": "idle", "recovered_task_ids": recovered}
@@ -25,6 +25,73 @@ def script_storage_path() -> Path:
25
25
  return path
26
26
 
27
27
 
28
+ def _history_records() -> list[dict[str, Any]]:
29
+ history = read_json(SCRIPT_HISTORY_FILE, [])
30
+ return [item for item in history if isinstance(item, dict)] if isinstance(history, list) else []
31
+
32
+
33
+ def _record_index(history: list[dict[str, Any]], document_id: str) -> int:
34
+ return next((index for index, item in enumerate(history) if str(item.get("id") or "") == document_id), -1)
35
+
36
+
37
+ def _front_matter_values(markdown: str) -> dict[str, str]:
38
+ text = str(markdown or "")
39
+ if not text.startswith("---"):
40
+ return {}
41
+ parts = text.split("---", 2)
42
+ if len(parts) != 3:
43
+ return {}
44
+ values: dict[str, str] = {}
45
+ for line in parts[1].splitlines():
46
+ key, separator, value = line.partition(":")
47
+ if separator:
48
+ values[key.strip()] = value.strip()
49
+ return values
50
+
51
+
52
+ def _with_front_matter(markdown: str, updates: dict[str, Any]) -> str:
53
+ text = str(markdown or "")
54
+ if text.startswith("---"):
55
+ parts = text.split("---", 2)
56
+ if len(parts) == 3:
57
+ lines = parts[1].splitlines()
58
+ update_keys = {str(key): str(value or "") for key, value in updates.items()}
59
+ seen: set[str] = set()
60
+ output: list[str] = []
61
+ for line in lines:
62
+ key, separator, _value = line.partition(":")
63
+ normalized_key = key.strip()
64
+ if separator and normalized_key in update_keys:
65
+ output.append(f"{normalized_key}: {update_keys[normalized_key]}")
66
+ seen.add(normalized_key)
67
+ else:
68
+ output.append(line)
69
+ for key, value in update_keys.items():
70
+ if key not in seen:
71
+ output.append(f"{key}: {value}")
72
+ return "---\n" + "\n".join(output) + "\n---" + parts[2]
73
+ front_matter = ["---", *[f"{key}: {str(value or '')}" for key, value in updates.items()], "---", ""]
74
+ return "\n".join(front_matter) + text.lstrip("\r\n")
75
+
76
+
77
+ def _merge_record_front_matter(record: dict[str, Any], values: dict[str, str]) -> dict[str, Any]:
78
+ merged = dict(record)
79
+ field_mapping = {
80
+ "id": "id",
81
+ "type": "document_type",
82
+ "title": "title",
83
+ "language": "language",
84
+ "channel": "channel_name",
85
+ "blueprint_id": "blueprint_id",
86
+ "blueprint": "blueprint_name",
87
+ "created_at": "created_at",
88
+ }
89
+ for source, target in field_mapping.items():
90
+ if source in values:
91
+ merged[target] = values[source]
92
+ return merged
93
+
94
+
28
95
  def save_script_document(document: dict[str, Any]) -> dict[str, Any]:
29
96
  content = str(document.get("content") or "").strip()
30
97
  if not content:
@@ -63,17 +130,14 @@ def save_script_document(document: dict[str, Any]) -> dict[str, Any]:
63
130
  "created_at": created_at,
64
131
  "content": content,
65
132
  }
66
- history = read_json(SCRIPT_HISTORY_FILE, [])
67
- if not isinstance(history, list):
68
- history = []
133
+ history = _history_records()
69
134
  history.insert(0, {key: value for key, value in record.items() if key != "content"})
70
135
  write_json(SCRIPT_HISTORY_FILE, history[:200])
71
136
  return record
72
137
 
73
138
 
74
139
  def list_script_documents() -> list[dict[str, Any]]:
75
- history = read_json(SCRIPT_HISTORY_FILE, [])
76
- return [item for item in history if isinstance(item, dict)] if isinstance(history, list) else []
140
+ return _history_records()
77
141
 
78
142
 
79
143
  def read_script_document(record: dict[str, Any]) -> str:
@@ -81,3 +145,55 @@ def read_script_document(record: dict[str, Any]) -> str:
81
145
  if not path.is_file():
82
146
  return ""
83
147
  return path.read_text(encoding="utf-8")
148
+
149
+
150
+ def update_script_document(document_id: str, content: str) -> dict[str, Any]:
151
+ """Replace one saved Markdown document and refresh its indexed front matter."""
152
+ if not str(content or "").strip():
153
+ raise ValueError("O documento não pode ser guardado vazio.")
154
+ history = _history_records()
155
+ index = _record_index(history, str(document_id))
156
+ if index < 0:
157
+ raise ValueError("O roteiro já não existe no histórico.")
158
+ record = history[index]
159
+ path = Path(str(record.get("path") or ""))
160
+ if not path.is_file():
161
+ raise FileNotFoundError("O ficheiro do roteiro já não está disponível no storage.")
162
+ normalized = str(content).rstrip() + "\n"
163
+ path.write_text(normalized, encoding="utf-8")
164
+ history[index] = _merge_record_front_matter(record, _front_matter_values(normalized))
165
+ write_json(SCRIPT_HISTORY_FILE, history)
166
+ return history[index]
167
+
168
+
169
+ def update_script_document_metadata(document_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
170
+ """Update front-matter fields in place without changing the Markdown body."""
171
+ history = _history_records()
172
+ index = _record_index(history, str(document_id))
173
+ if index < 0:
174
+ raise ValueError("O roteiro já não existe no histórico.")
175
+ record = history[index]
176
+ path = Path(str(record.get("path") or ""))
177
+ if not path.is_file():
178
+ raise FileNotFoundError("O ficheiro do roteiro já não está disponível no storage.")
179
+ updated_markdown = _with_front_matter(path.read_text(encoding="utf-8"), metadata)
180
+ path.write_text(updated_markdown.rstrip() + "\n", encoding="utf-8")
181
+ history[index] = _merge_record_front_matter(record, _front_matter_values(updated_markdown))
182
+ write_json(SCRIPT_HISTORY_FILE, history)
183
+ return history[index]
184
+
185
+
186
+ def delete_script_document(document_id: str) -> bool:
187
+ """Remove a saved document from the history index and its Markdown file."""
188
+ history = _history_records()
189
+ index = _record_index(history, str(document_id))
190
+ if index < 0:
191
+ return False
192
+ record = history.pop(index)
193
+ path = Path(str(record.get("path") or ""))
194
+ try:
195
+ if path.is_file():
196
+ path.unlink()
197
+ finally:
198
+ write_json(SCRIPT_HISTORY_FILE, history)
199
+ return True
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.16",
3
+ "version": "0.7.18",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",