Files
RuDS/specs/editor_media.allium

311 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- allium: 1
-- bDS Media Editor View
-- Scope: UI content area — media editing surface
-- Distilled from: MediaEditor.tsx
-- Describes the layout and behaviour of the media editor rendered in
-- the main content area when a media tab is active.
use "./media.allium" as media
use "./post.allium" as post
use "./i18n.allium" as i18n
-- ─── Media editor ─────────────────────────────────────────────
value MediaEditorView {
media_id: String
is_image: Boolean
file_name: String -- originalName, read-only
mime_type: String -- read-only
file_size: String -- formatted, read-only
dimensions: String? -- "W x H" if width/height exist, read-only
title: String? -- editable text input
alt_text: String? -- editable text input
caption: String? -- editable textarea (3 rows)
tags: String? -- comma-separated text input
author: String? -- editable text input
language: String? -- select from supported languages
translations: List<MediaTranslationItem>
linked_posts: List<LinkedPostItem>
}
value MediaTranslationItem {
language: String
flag_emoji: String
title: String?
alt_text: String?
caption: String?
}
value LinkedPostItem {
post_id: String
title: String
}
value PostPickerOverlay {
search_query: String
results: List<PostPickerResult>
overflow_count: Integer? -- shown as "and N more" if > 0
}
surface PostPickerOverlaySurface {
context overlay: PostPickerOverlay
exposes:
overlay.search_query
for result in overlay.results:
result.post_id
result.title
overlay.overflow_count when overlay.overflow_count != null
provides:
PostPickerResultClicked(media_id, post_id)
}
value PostPickerResult {
post_id: String
title: String
}
config {
media_post_picker_max_results: Integer = 10
}
surface MediaEditorSurface {
context editor: MediaEditorView
exposes:
editor.file_name
editor.mime_type
editor.file_size
editor.dimensions when editor.dimensions != null
editor.is_image
editor.title
editor.alt_text
editor.caption
editor.tags
editor.author
editor.language
for t in editor.translations:
t.language
t.flag_emoji
t.title
for lp in editor.linked_posts:
lp.post_id
lp.title
provides:
MediaAIImageAnalysisRequested(editor.media_id)
when editor.is_image
MediaDetectLanguageRequested(editor.media_id)
MediaTranslateMetadataRequested(editor.media_id, target_language)
MediaReplaceFileRequested(editor.media_id)
MediaSaveRequested(editor.media_id)
MediaDeleteRequested(editor.media_id)
MediaLinkToPostRequested(editor.media_id)
MediaTranslationEditClicked(editor.media_id, language)
MediaTranslationRefreshClicked(editor.media_id, language)
MediaUnlinkPostRequested(editor.media_id, post_id)
MediaDeleteConfirmed(editor.media_id)
TranslationEditModalSaved(editor.media_id, language, title, alt, caption)
@guarantee HeaderLayout
-- Header bar with media display name.
-- Actions (right side): Quick Actions dropdown, Replace File button,
-- Save button, Delete button (danger style).
@guarantee QuickActionsDropdown
-- Dropdown menu in header with three entries:
-- AI Image Analysis (robot icon) — only shown for image/* MIME types.
-- Detect Language (magnifier icon).
-- Translate Metadata (globe icon).
@guarantee PreviewArea
-- Images: rendered at full resolution via the /media-file/:id route
-- (serves the original file; falls back to the large thumbnail for
-- formats browsers cannot display inline) with cache-busting
-- timestamp. Never upscaled beyond natural size. The sidebar keeps
-- using small thumbnails.
-- Non-images: SVG file icon placeholder + original filename text.
@guarantee MetadataForm
-- Form fields in order:
-- File Name (disabled input), MIME Type (disabled input),
-- Size + Dimensions row (disabled inputs),
-- Title (text input), Alt Text (text input), Caption (textarea, 3 rows),
-- Tags (comma-separated text input), Author (text input),
-- Language (select from supported languages).
@guarantee TranslationsSection
-- Shown only when language is set.
-- List of existing translations: flag emoji + language name + title.
-- Per-translation actions: click to edit (opens modal), refresh button.
-- No delete action; translations are only removed via the scripting
-- API (script.allium bds.media.delete_translation) or when the
-- media item itself is deleted.
-- "No translations" message when list is empty.
@guarantee TranslationEditModal
-- Editing a translation opens a modal dialog ("Edit Translation"), not
-- an inline form. Hidden language field plus Title, Alt Text, and
-- Caption (textarea) inputs. Footer: Cancel and Save buttons.
-- Save persists the translation; Cancel/close discards.
@guarantee LinkedPostsSection
-- "Link to Post" button opens inline post picker overlay.
-- List of currently linked posts with document icon. Click navigates to post tab.
-- Per-post unlink button (×).
-- "Not linked to any posts" message when list is empty.
@guarantee PostPickerOverlay
-- Inline overlay positioned near "Link to Post" button (not a modal).
-- Search input filtering unlinked posts by title.
-- Up to config.media_post_picker_max_results results displayed.
-- "and N more" text when total exceeds limit.
-- Click result: links media to selected post, closes overlay.
@guarantee NoAutoSave
-- Unlike the post editor, media editor requires explicit Save button.
}
-- ─── Media editor actions ─────────────────────────────────────
rule MediaSave {
when: MediaSaveRequested(media_id)
let item = media/Media{id: media_id}
ensures: UpdateMediaRequested(item, editor_field_changes(media_id))
-- Persists all form fields; the engine update rewrites the
-- sidecar and FTS entry (media.allium UpdateMedia,
-- engine_side_effects.allium UpdateMediaSideEffects)
}
rule MediaAIImageAnalysis {
when: MediaAIImageAnalysisRequested(media_id)
-- Only available for image/* MIME types (editor.is_image guard
-- on the provides entry; button hidden for non-images).
ensures: AISuggestionRequested(entity_type: "media", entity_id: media_id)
-- Shared flow: action_patterns.allium MediaAISuggestionFlow —
-- airplane gating, image model over the 448x448 AI thumbnail,
-- AISuggestionsModal with title/alt/caption, accepted fields
-- trigger an explicit save.
}
rule MediaDetectLanguage {
when: MediaDetectLanguageRequested(media_id)
let item = media/Media{id: media_id}
requires: active_endpoint_configured
-- airplane-mode gating: ai.allium AirplaneModeGating
let detected = ai_detect_language(concat(item.title, item.alt, item.caption))
-- title model over the concatenated metadata text
ensures: UpdateMediaRequested(item, language_change(detected))
-- persisted immediately — no modal, no confirmation; the
-- engine update rewrites the sidecar
}
rule MediaTranslateMetadata {
when: MediaTranslateMetadataRequested(media_id, target_language)
let item = media/Media{id: media_id}
requires: active_endpoint_configured
-- target_language comes from the language picker modal (same
-- pattern as post translate).
let source_language = item.language
?? ai_detect_language(concat(item.title, item.alt, item.caption))
-- step 1: detect the source language first when unset
-- (auto-persisted, as in MediaDetectLanguage)
let translated = ai_translate_media_metadata(item, source_language, target_language)
-- step 2: translate title, alt, caption via the title model
ensures: UpsertMediaTranslationRequested(item, target_language,
translated.title, translated.alt, translated.caption)
-- creates/updates the translation record and writes the
-- translated sidecar {path}.{lang}.meta (media.allium
-- UpsertMediaTranslation)
}
rule MediaReplaceFile {
when: MediaReplaceFileRequested(media_id)
let item = media/Media{id: media_id}
let source = native_file_dialog_selection()
-- native file dialog, no MIME type filter; cancel aborts
ensures: ReplaceMediaFileRequested(item, source)
-- copies over the existing media file path; images regenerate
-- thumbnails synchronously (media.allium ReplaceMediaFile)
ensures: MediaPreviewRefreshed(media_id)
-- preview reloads with a cache-busting timestamp query param
}
rule MediaDeleteAction {
when: MediaDeleteRequested(media_id)
let item = media/Media{id: media_id}
ensures: ConfirmDeleteModalOpened(media_id, item.linked_posts)
-- custom modal (not a native dialog): media display name,
-- linked post count and list; Cancel + Delete (destructive
-- red style) — action_patterns.allium
-- confirmation_assignments: media_delete
}
rule MediaDeleteExecute {
when: MediaDeleteConfirmed(media_id)
let item = media/Media{id: media_id}
ensures: DeleteMediaRequested(item)
-- file, sidecar, thumbnails, translations, post-media links,
-- FTS entry: media.allium DeleteMedia +
-- engine_side_effects.allium DeleteMediaSideEffects
ensures: closeTab(media_id)
-- sidebar removes the item reactively
}
rule MediaLinkToPost {
when: MediaLinkToPostRequested(media_id)
ensures: PostPickerOverlayOpened(media_id)
-- inline overlay near the button (not a modal): search input
-- filters unlinked posts by title, at most
-- config.media_post_picker_max_results results, "and N more"
-- when the total exceeds the limit
}
rule MediaLinkToPostSelected {
when: PostPickerResultClicked(media_id, post_id)
let item = media/Media{id: media_id}
ensures: MediaLinkedToPost(item, post/Post{id: post_id})
-- creates the post-media link and updates sidecar
-- linkedPostIds; the linked posts list refreshes reactively
ensures: PostPickerOverlayClosed(media_id)
}
rule MediaUnlinkPost {
when: MediaUnlinkPostRequested(media_id, post_id)
let item = media/Media{id: media_id}
-- No confirmation (action_patterns.allium
-- confirmation_assignments: media_unlink)
ensures: MediaUnlinkedFromPost(item, post_id)
-- removes the post-media link row and updates sidecar
-- linkedPostIds
}
rule MediaTranslationEdit {
when: MediaTranslationEditClicked(media_id, language)
ensures: TranslationEditModalOpened(media_id, language)
-- "Edit Translation" modal pre-filled with the translation's
-- title, alt, and caption; Cancel/close discards
}
rule MediaTranslationEditSave {
when: TranslationEditModalSaved(media_id, language, title, alt, caption)
let item = media/Media{id: media_id}
ensures: UpsertMediaTranslationRequested(item, language, title, alt, caption)
-- persists the DB record + translated sidecar
-- {path}.{lang}.meta
}
rule MediaTranslationRefresh {
when: MediaTranslationRefreshClicked(media_id, language)
let item = media/Media{id: media_id}
requires: active_endpoint_configured
let translated = ai_translate_media_metadata(item, item.language, language)
-- re-translates from the source language via the title model
ensures: UpsertMediaTranslationRequested(item, language,
translated.title, translated.alt, translated.caption)
-- overwrites the existing translation fields and rewrites the
-- translated sidecar
}