@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 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
- return create_tasks_for_batch(batch)
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
- st.text_area("Conteúdo", value=content, height=220, key=f"stored_script_{record.get('id')}")
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
- 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')}")
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
- return [task for task in load_video_tasks_for_catalog() if task_platform(task) == target]
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 = 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.17",
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",