@danhachuel/thunderbolt 0.7.16 → 0.7.17
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 +188 -7
- package/hermes_ui/pipeline_worker.py +21 -1
- package/hermes_ui/script_documents.py +121 -5
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -83,7 +83,7 @@ from hermes_ui.channel_import import build_channel_template_xlsx, channel_is_dup
|
|
|
83
83
|
from hermes_ui.drafts import list_drafts, save_draft
|
|
84
84
|
from hermes_ui.automation_worker import load_worker_status
|
|
85
85
|
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
|
|
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, update_json, write_json
|
|
87
87
|
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
88
|
from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
|
|
89
89
|
from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
|
|
@@ -115,7 +115,7 @@ from hermes_ui.api_key_tests import test_apify_credentials, test_influencer_data
|
|
|
115
115
|
from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
|
|
116
116
|
from hermes_ui.update_manager import check_version, restart_current_process, update_to_latest
|
|
117
117
|
|
|
118
|
-
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
118
|
+
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
119
|
from hermes_ui.script_generation import generate_script_document
|
|
120
120
|
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
121
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
@@ -698,6 +698,52 @@ def blueprint_for_channel(channel: dict) -> dict[str, Any]:
|
|
|
698
698
|
return {"id": blueprint_id, "name": blueprint_id}
|
|
699
699
|
|
|
700
700
|
|
|
701
|
+
def _script_channel(record: dict[str, Any], channels: list[dict[str, Any]]) -> dict[str, Any]:
|
|
702
|
+
channel_id = str(record.get("channel_id") or "").strip()
|
|
703
|
+
channel_name = str(record.get("channel_name") or record.get("channel") or "").strip().casefold()
|
|
704
|
+
return next(
|
|
705
|
+
(
|
|
706
|
+
channel
|
|
707
|
+
for channel in channels
|
|
708
|
+
if isinstance(channel, dict)
|
|
709
|
+
and (
|
|
710
|
+
(channel_id and str(channel.get("id") or "").strip() == channel_id)
|
|
711
|
+
or (channel_name and str(channel.get("name") or "").strip().casefold() == channel_name)
|
|
712
|
+
)
|
|
713
|
+
),
|
|
714
|
+
{},
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _backfill_saved_script_blueprints(records: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
|
719
|
+
"""Attach every saved video script to the Blueprint configured on its channel."""
|
|
720
|
+
channels = [item for item in read_json("channels.json", []) if isinstance(item, dict)]
|
|
721
|
+
updated_records: list[dict[str, Any]] = []
|
|
722
|
+
for record in records if records is not None else list_script_documents():
|
|
723
|
+
if str(record.get("document_type") or "video_script").strip() != "video_script":
|
|
724
|
+
continue
|
|
725
|
+
channel = _script_channel(record, channels)
|
|
726
|
+
if not channel:
|
|
727
|
+
continue
|
|
728
|
+
blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
729
|
+
blueprint = blueprint_for_channel(channel)
|
|
730
|
+
blueprint_name = str(blueprint.get("name") or blueprint_id or "SEM BLUEPRINT CONFIGURADO").strip()
|
|
731
|
+
current_id = str(record.get("blueprint_id") or "").strip()
|
|
732
|
+
current_name = str(record.get("blueprint_name") or record.get("blueprint") or "").strip()
|
|
733
|
+
if current_id == blueprint_id and current_name == blueprint_name:
|
|
734
|
+
updated_records.append(record)
|
|
735
|
+
continue
|
|
736
|
+
try:
|
|
737
|
+
updated = update_script_document_metadata(
|
|
738
|
+
str(record.get("id") or ""),
|
|
739
|
+
{"blueprint_id": blueprint_id, "blueprint": blueprint_name},
|
|
740
|
+
)
|
|
741
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
742
|
+
updated = {**record, "blueprint_id": blueprint_id, "blueprint_name": blueprint_name}
|
|
743
|
+
updated_records.append(updated)
|
|
744
|
+
return updated_records
|
|
745
|
+
|
|
746
|
+
|
|
701
747
|
def channel_video_language(channel: dict[str, Any] | None, fallback: str = "pt") -> str:
|
|
702
748
|
"""Return the channel's canonical video language, preserving legacy labels."""
|
|
703
749
|
if not isinstance(channel, dict):
|
|
@@ -3355,10 +3401,29 @@ def _create_video_task_from_saved_script(record: dict[str, Any], channel: dict[s
|
|
|
3355
3401
|
{
|
|
3356
3402
|
**payload,
|
|
3357
3403
|
"topic_source": "saved_script",
|
|
3404
|
+
"script_document_id": str(record.get("id") or ""),
|
|
3358
3405
|
"channel_payloads": {str(channel.get("id") or ""): payload},
|
|
3359
3406
|
},
|
|
3360
3407
|
)
|
|
3361
|
-
|
|
3408
|
+
tasks = create_tasks_for_batch(batch)
|
|
3409
|
+
for task in tasks:
|
|
3410
|
+
task["script_document_id"] = str(record.get("id") or "")
|
|
3411
|
+
task["manual_start_required"] = True
|
|
3412
|
+
task["created_at"] = str(record.get("created_at") or task.get("created_at") or now())
|
|
3413
|
+
task["updated_at"] = now()
|
|
3414
|
+
if tasks:
|
|
3415
|
+
task_ids = {str(task.get("id") or "") for task in tasks}
|
|
3416
|
+
created_by_id = {str(task.get("id") or ""): task for task in tasks}
|
|
3417
|
+
|
|
3418
|
+
def persist_created_metadata(entries: Any) -> None:
|
|
3419
|
+
if not isinstance(entries, list):
|
|
3420
|
+
return
|
|
3421
|
+
for entry in entries:
|
|
3422
|
+
if isinstance(entry, dict) and str(entry.get("id") or "") in task_ids:
|
|
3423
|
+
entry.update(created_by_id[str(entry.get("id") or "")])
|
|
3424
|
+
|
|
3425
|
+
update_json("tasks.json", [], persist_created_metadata)
|
|
3426
|
+
return tasks
|
|
3362
3427
|
|
|
3363
3428
|
|
|
3364
3429
|
def render_video_from_draft(prefix: str = "new_video") -> None:
|
|
@@ -4172,7 +4237,7 @@ def render_scripts():
|
|
|
4172
4237
|
|
|
4173
4238
|
with history_tab:
|
|
4174
4239
|
st.caption(f"Histórico persistente: `{script_dir}` · índice em `{STORAGE / 'state' / 'scripts.json'}`")
|
|
4175
|
-
records = list_script_documents()
|
|
4240
|
+
records = _backfill_saved_script_blueprints(list_script_documents())
|
|
4176
4241
|
if not records:
|
|
4177
4242
|
st.info("Ainda não existem roteiros ou letras guardados.")
|
|
4178
4243
|
for record in records[:50]:
|
|
@@ -4182,9 +4247,54 @@ def render_scripts():
|
|
|
4182
4247
|
stored_path = Path(str(record.get("path") or ""))
|
|
4183
4248
|
content = read_script_document(record)
|
|
4184
4249
|
if content:
|
|
4185
|
-
|
|
4250
|
+
document_id = str(record.get("id") or "")
|
|
4251
|
+
edit_key = f"editing_script_{document_id}"
|
|
4252
|
+
if st.session_state.get(edit_key):
|
|
4253
|
+
edited_content = st.text_area("Markdown editável", value=content, height=320, key=f"stored_script_edit_{document_id}")
|
|
4254
|
+
edit_save_col, edit_cancel_col = st.columns(2, gap="small")
|
|
4255
|
+
with edit_save_col:
|
|
4256
|
+
if st.button("Guardar edição", type="primary", width="stretch", key=f"save_script_edit_{document_id}"):
|
|
4257
|
+
try:
|
|
4258
|
+
update_script_document(document_id, edited_content)
|
|
4259
|
+
st.session_state.pop(edit_key, None)
|
|
4260
|
+
st.success("Roteiro actualizado.")
|
|
4261
|
+
st.rerun()
|
|
4262
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
4263
|
+
st.error(f"Não foi possível actualizar o roteiro: {exc}")
|
|
4264
|
+
with edit_cancel_col:
|
|
4265
|
+
if st.button("Cancelar edição", width="stretch", key=f"cancel_script_edit_{document_id}"):
|
|
4266
|
+
st.session_state.pop(edit_key, None)
|
|
4267
|
+
st.rerun()
|
|
4268
|
+
else:
|
|
4269
|
+
st.text_area("Conteúdo", value=content, height=220, key=f"stored_script_{document_id}")
|
|
4186
4270
|
if stored_path.is_file():
|
|
4187
|
-
|
|
4271
|
+
download_col, edit_col, delete_col = st.columns([1.25, 0.75, 0.75], gap="small")
|
|
4272
|
+
with download_col:
|
|
4273
|
+
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")
|
|
4274
|
+
with edit_col:
|
|
4275
|
+
if st.button("Editar", key=f"edit_script_{document_id}", width="stretch"):
|
|
4276
|
+
st.session_state[edit_key] = True
|
|
4277
|
+
st.rerun()
|
|
4278
|
+
with delete_col:
|
|
4279
|
+
confirm_delete_key = f"confirm_delete_script_{document_id}"
|
|
4280
|
+
if st.button("Apagar", key=f"delete_script_{document_id}", type="primary", width="stretch"):
|
|
4281
|
+
st.session_state[confirm_delete_key] = True
|
|
4282
|
+
st.rerun()
|
|
4283
|
+
if st.session_state.get(confirm_delete_key):
|
|
4284
|
+
st.warning("Apagar este roteiro do histórico e do storage?")
|
|
4285
|
+
confirm_col, cancel_col = st.columns(2, gap="small")
|
|
4286
|
+
with confirm_col:
|
|
4287
|
+
if st.button("Confirmar apagar", key=f"confirm_delete_script_button_{document_id}", type="primary", width="stretch"):
|
|
4288
|
+
try:
|
|
4289
|
+
delete_script_document(document_id)
|
|
4290
|
+
st.session_state.pop(confirm_delete_key, None)
|
|
4291
|
+
st.rerun()
|
|
4292
|
+
except (OSError, ValueError) as exc:
|
|
4293
|
+
st.error(f"Não foi possível apagar o roteiro: {exc}")
|
|
4294
|
+
with cancel_col:
|
|
4295
|
+
if st.button("Cancelar", key=f"cancel_delete_script_{document_id}", width="stretch"):
|
|
4296
|
+
st.session_state.pop(confirm_delete_key, None)
|
|
4297
|
+
st.rerun()
|
|
4188
4298
|
else:
|
|
4189
4299
|
st.warning("O ficheiro deste registo já não está disponível no storage.")
|
|
4190
4300
|
|
|
@@ -5002,10 +5112,79 @@ def task_platform(task: dict[str, Any]) -> str:
|
|
|
5002
5112
|
return "tiktok" if format_value in {"portrait", "portrait 9:16", "9:16"} else "youtube"
|
|
5003
5113
|
|
|
5004
5114
|
|
|
5115
|
+
def _saved_script_task_matches(task: dict[str, Any], record: dict[str, Any], channel: dict[str, Any], content: str) -> bool:
|
|
5116
|
+
document_id = str(record.get("id") or "").strip()
|
|
5117
|
+
if document_id and str(task.get("script_document_id") or "").strip() == document_id:
|
|
5118
|
+
return True
|
|
5119
|
+
if str(task.get("topic_source") or "").strip() != "saved_script":
|
|
5120
|
+
return False
|
|
5121
|
+
if str(task.get("channel_id") or "").strip() != str(channel.get("id") or "").strip():
|
|
5122
|
+
return False
|
|
5123
|
+
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
5124
|
+
saved_content = str(generation_settings.get("video_script") or task.get("video_script") or "").strip()
|
|
5125
|
+
if saved_content and saved_content == content.strip():
|
|
5126
|
+
return True
|
|
5127
|
+
title = str(record.get("title") or "").strip()
|
|
5128
|
+
return bool(title and str(task.get("title") or task.get("topic") or "").strip() == title)
|
|
5129
|
+
|
|
5130
|
+
|
|
5131
|
+
def _sync_saved_scripts_to_youtube_automation() -> None:
|
|
5132
|
+
"""Expose every saved video script as one manual YouTube task, without duplicates."""
|
|
5133
|
+
records = _backfill_saved_script_blueprints(list_script_documents())
|
|
5134
|
+
channels = [item for item in read_json("channels.json", []) if isinstance(item, dict)]
|
|
5135
|
+
existing_tasks = load_video_tasks_for_catalog()
|
|
5136
|
+
for raw_record in records:
|
|
5137
|
+
if str(raw_record.get("document_type") or "video_script").strip() != "video_script":
|
|
5138
|
+
continue
|
|
5139
|
+
content = read_script_document(raw_record)
|
|
5140
|
+
if not content:
|
|
5141
|
+
continue
|
|
5142
|
+
channel = _script_channel(raw_record, channels)
|
|
5143
|
+
if not channel or task_platform({"platform": channel.get("platform", "youtube"), "channel_id": channel.get("id")}) != "youtube":
|
|
5144
|
+
continue
|
|
5145
|
+
record = normalise_saved_script({**raw_record, "channel_id": str(channel.get("id") or "")}, content)
|
|
5146
|
+
matched_task = next((task for task in existing_tasks if _saved_script_task_matches(task, record, channel, content)), None)
|
|
5147
|
+
if matched_task:
|
|
5148
|
+
if str(matched_task.get("script_document_id") or "") != str(record.get("id") or "") or (
|
|
5149
|
+
str(matched_task.get("state") or "") == "to_do" and not bool(matched_task.get("manual_start_required", False))
|
|
5150
|
+
):
|
|
5151
|
+
matched_id = str(matched_task.get("id") or "")
|
|
5152
|
+
|
|
5153
|
+
def mark_existing_script(entries: Any) -> None:
|
|
5154
|
+
if not isinstance(entries, list):
|
|
5155
|
+
return
|
|
5156
|
+
for entry in entries:
|
|
5157
|
+
if isinstance(entry, dict) and str(entry.get("id") or "") == matched_id:
|
|
5158
|
+
entry["script_document_id"] = str(record.get("id") or "")
|
|
5159
|
+
if str(entry.get("state") or "") == "to_do":
|
|
5160
|
+
entry["manual_start_required"] = True
|
|
5161
|
+
|
|
5162
|
+
update_json("tasks.json", [], mark_existing_script)
|
|
5163
|
+
continue
|
|
5164
|
+
created = _create_video_task_from_saved_script(record, channel, {})
|
|
5165
|
+
existing_tasks.extend(created)
|
|
5166
|
+
|
|
5167
|
+
|
|
5168
|
+
def _automation_created_at(task: dict[str, Any]) -> tuple[datetime, str]:
|
|
5169
|
+
value = str(task.get("created_at") or "").strip()
|
|
5170
|
+
try:
|
|
5171
|
+
parsed = datetime.fromisoformat(value)
|
|
5172
|
+
if parsed.tzinfo is None:
|
|
5173
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
5174
|
+
except ValueError:
|
|
5175
|
+
parsed = datetime.min.replace(tzinfo=timezone.utc)
|
|
5176
|
+
return parsed.astimezone(timezone.utc), str(task.get("id") or "")
|
|
5177
|
+
|
|
5178
|
+
|
|
5005
5179
|
def load_automation_tasks_for_platform(platform_name: str) -> list[dict[str, Any]]:
|
|
5006
5180
|
"""Return only automation/catalog tasks belonging to one publishing platform."""
|
|
5007
5181
|
target = str(platform_name or "").strip().casefold()
|
|
5008
|
-
|
|
5182
|
+
if target == "youtube":
|
|
5183
|
+
_sync_saved_scripts_to_youtube_automation()
|
|
5184
|
+
return sorted(
|
|
5185
|
+
[task for task in load_video_tasks_for_catalog() if task_platform(task) == target],
|
|
5186
|
+
key=_automation_created_at,
|
|
5187
|
+
)
|
|
5009
5188
|
|
|
5010
5189
|
|
|
5011
5190
|
def _task_thumbnail_path(task: dict[str, Any]) -> Path | None:
|
|
@@ -5126,6 +5305,8 @@ def _start_pipeline_task(task_id: str, state: str) -> bool:
|
|
|
5126
5305
|
"""Start or retry a video task from any platform automation card."""
|
|
5127
5306
|
if state in {"blocked", "failed"}:
|
|
5128
5307
|
updated = retry_task_with_current_settings(task_id)
|
|
5308
|
+
if updated and bool(updated.get("manual_start_required", False)):
|
|
5309
|
+
updated = transition_task(task_id, "doing")
|
|
5129
5310
|
else:
|
|
5130
5311
|
updated = transition_task(task_id, "doing")
|
|
5131
5312
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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