Skip to content

Commit 4d1debd

Browse files
committed
Session pins, presentable Media, Schedule sessions + runner, .tpschedule
Songs — session-only pins („Fixează sus"): - App-global PinStore (@observable, in-memory → clears on quit); pinned songs float into a "Fixate" group on top of BOTH list and grid, pin toggle in the context menu + hover toggle on grid cards, clear-all in the group header. No SwiftData field — deliberately ephemeral. Media — rebuilt as a presentable module (not a storage browser): - Type tabs (Toate|Foto|Video|Audio) + rich grid: video thumbnails with duration badges, audio artwork, search on LibraryManager.mediaLibraryQuery; double-click/Enter presents. Old toolbar filter + MediaDetailPanel removed; selection flows through LibraryManager.selectedMediaItem (no notifications). - MediaPresenter = THE one present path (grid, panel, session runner); fullscreen IMAGES now project (decoded inside the security scope into LiveContent.mediaImage, fit/fill honored); video fixed to use the shared VideoPlayerService (the old local instance never reached the output). - Panel now mirrors Bible/Songs anatomy: PresentationPreviewCard with pendingMedia, prev/next stepping over the grid's exact ordering, StyleQuickSettings .media section (loop + fit/fill), PanelFooter format. - MediaItem.durationSeconds (additive) probed async at import + backfilled. Schedule — full sessions referencing real library content: - ScheduleItem.payloadJSON stores stable refs (song = HistoryStore.songKey + optional arrangement, bible = translation + book/chapter/verse numbers, media = id + name fallback); title/content stay display snapshots. - SessionService: per-itemType resolver registry (extensible), append/create; missing content resolves to a greyed ⚠ row that navigation skips. - SessionRunner (app-global) is the ONLY presenter for schedule items: slide-by-slide next/prev (songs expand at present time with current song options), click-to-jump, one-shot Show; ScheduleView + SchedulePreviewPanel rewritten around it (duplicated show logic deleted). - „Adaugă la sesiune" right-click menu shared by Bible verses, songs and media („Sesiune nouă…" creates a dated session instantly). - Add-item sheet gains a real media picker; move up/down; start/stop. Sessions import/export (.tpschedule): - Flat versioned JSON (SessionArchive, format marker REQUIRED on import) with a requiredMedia manifest; media re-links by id then name on import, unresolved files listed to the operator. UTI com.robyrew.toppresenter.schedule. Tabs: manual rename removed everywhere — auto-naming only, Schedule tab titles itself from the selected session (name + date, unit-tested). Tests: 142 pass (pins, media filter/stepping, session payload/resolution/ runner navigation, archive round-trip, tab naming). AGENTS.md updated.
1 parent 6b72877 commit 4d1debd

26 files changed

Lines changed: 2375 additions & 663 deletions

AGENTS.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ TopPresenter/
3030
│ ├── Constants.swift WindowIdentifiers, SupportedBibleFormat, SupportedExportFormat,
3131
│ │ USFMBookIDs, BibleBookCategory, PresentationDefaults
3232
│ ├── DataMigration.swift SchemaV1 + TopPresenterMigrationPlan (SchemaMigrationPlan)
33-
│ ├── LibraryManager.swift @Observable — Bible & Song navigation, search, verse caching
33+
│ ├── LibraryManager.swift @Observable — Bible & Song navigation, search, verse caching,
34+
│ │ selectedMediaItem/selectedSchedule mirrors (drive tab title + panels)
35+
│ ├── PinStore.swift @Observable — session-only song pins („Fixează sus"); APP-GLOBAL
36+
│ │ (one per app, injected beside historyStore), in-memory only
3437
│ └── PresentationManager.swift @Observable — live output state, screen management,
3538
│ freeze/black/clear, all display settings (UserDefaults)
3639
@@ -57,6 +60,15 @@ TopPresenter/
5760
│ │ └── PowerPointSongImporter.swift (native Swift ZIP/XML parser)
5861
│ ├── Export/
5962
│ │ └── ExportService.swift Bible (JSON/TXT/CSV) + Song (JSON/XML/TXT)
63+
│ ├── Media/
64+
│ │ └── MediaPresenter.swift THE one way a MediaItem goes live (grid, panel, runner)
65+
│ ├── Sessions/
66+
│ │ ├── SessionModels.swift SessionItemPayload (stable refs) + Draft + Resolution
67+
│ │ ├── SessionService.swift resolver REGISTRY (SessionItemResolving per itemType),
68+
│ │ │ append/create; new item kinds plug in here
69+
│ │ ├── SessionRunner.swift @Observable APP-GLOBAL runner — THE one presenter for
70+
│ │ │ schedule items (ScheduleView + panel never call pm.show*)
71+
│ │ └── SessionArchive.swift .tpschedule flat-JSON import/export + requiredMedia manifest
6072
│ ├── Audio/
6173
│ │ └── AudioPlayerManager.swift @Observable — AVAudioPlayer wrapper
6274
│ └── Video/
@@ -77,8 +89,8 @@ TopPresenter/
7789
│ │ └── CustomSlidesPreviewPanel.swift
7890
│ ├── Bible/ BibleView.swift, BibleExportSheet.swift
7991
│ ├── Songs/ SongsView.swift
80-
│ ├── Media/ MediaView.swift
81-
│ ├── Schedule/ ScheduleView.swift
92+
│ ├── Media/ MediaView.swift (presentable grid), MediaLibrary.swift (MediaKind + filter)
93+
│ ├── Schedule/ ScheduleView.swift, AddToSessionMenu.swift (shared right-click fragment)
8294
│ ├── CustomSlides/ CustomSlidesView.swift
8395
│ ├── Presentation/ PresentationOutputView.swift, TextBoxLayout.swift
8496
│ ├── Import/ BatchImportSheet.swift, BatchExportSheet.swift
@@ -181,8 +193,11 @@ TopPresenter/
181193
- The editor is called **"Editor de Teme"** everywhere (sheet title, toolbar, menu, footer button)
182194
- Editor tabs: Layout / Text / Fundal / **Tranziții** — NO output/hardware settings in the editor; screen/window-level/transition/disconnect live in Settings (⌘,) ▸ Proiecție (`ProjectionSettingsTab`) AND compactly in the right bar's **Ieșire** disclosure (StyleQuickSettings `.output`, beneath General). Themes describe the LOOK, Settings describe the DEVICE
183195
- **Per-presenter options** (`ContentOptions` keyed "bible"/"song"/"text", theme-persisted, resilient decoding): text transform (none/upper/lower), uppercase reference/title. Applied at RENDER time via `pm.displayFields(main:reference:translation:subtitle:contentKey:)` — output uses the live content key, the preview card uses its panel's `formatHint`. Extend ContentOptions (with decodeIfPresent defaults) when a presenter needs a new option
184-
- Media module output prefs (NOT theme): `videoLoopsByDefault`, `fullscreenVideoFillRaw` — live in Settings ▸ Proiecție ▸ Media
185-
- Toolbar rules: per-view items are conditional on `appState.selectedSidebarItem`; the Media filter Picker binds `@AppStorage("mediaTypeFilter")` which MediaView reads (never write UserDefaults directly from toolbar bindings); Freeze sits next to Black/Clear in the presentation group
196+
- Media module output prefs (NOT theme): `videoLoopsByDefault`, `fullscreenVideoFillRaw` — Settings ▸ Proiecție ▸ Media AND the Media panel's StyleQuickSettings `.media` section
197+
- Toolbar rules: per-view items are conditional on `appState.selectedSidebarItem`; the media kind filter lives in MediaView's OWN header (segmented `@AppStorage("mediaTypeFilter")` — the old toolbar Picker was removed, one filter UI); Freeze sits next to Black/Clear in the presentation group
198+
- **Tabs auto-name only (v10.4)** — the manual "Rename Tab" toolbar button/alert was REMOVED; `autoTabTitle` in MainControlView derives per module (bible: "(RO) EDC100 – ref", songs, media, schedule: session name + date via the testable `MainControlView.scheduleTabDetail(name:date:)`). Don't reintroduce `@SceneStorage("tabCustomName")`
199+
- **Media is a PRESENTABLE module (v10.4)**: MediaView = type tabs (Toate|Foto|Video|Audio) + rich grid (video/audio `durationSeconds` badges, artwork thumbnails via async `MediaThumbnailFactory`) + search on `libraryManager.mediaLibraryQuery`; selection = `libraryManager.selectedMediaItem` (NO notifications). The panel mirrors Bible/Songs anatomy and steps prev/next through `MediaLibrary.filter(...)` — THE one ordering shared with the grid. ALL "present media" paths go through `MediaPresenter.present` (fullscreen image = `pm.showMedia(kind:"image")` decodes the NSImage inside the caller's security scope → `LiveContent.mediaImage`; video = shared `VideoPlayerService`; audio = plays only, never claims the output). New media kinds = a `MediaKind` case + classify rule + icon
200+
- **Sessions (v10.4)**: `ScheduleItem.payloadJSON` (= `SessionItemPayload`, resilient Codable) stores STABLE refs — song `HistoryStore.songKey` (+ optional versionID/versionName), bible translation-abbrev + book/chapter/verse numbers, media id + name fallback; `title/content/subtitle` remain display SNAPSHOTS. Resolution via `SessionService.resolve` (registry of `SessionItemResolving` per itemType — extend there, don't grow a switch); misses → `.missing` (greyed row + ⚠, runner skips). `SessionRunner` (app-global @Observable) is THE ONLY presenter for schedule items — slide-by-slide next/prev (songs expand via `buildSongSlides` with the CURRENT song options at present time), jump-to-item, `presentOnce` for one-shots. „Adaugă la sesiune" = shared `AddToSessionMenu` fragment in the Bible verse / song / media context menus („Sesiune nouă…" creates instantly, no sheet). `.tpschedule` = FLAT JSON (`SessionArchive`, schemaVersion 1, format "TopPresenter Session" REQUIRED on import) + `requiredMedia` manifest; media re-links by id→name on import — media files are NOT embedded
186201
- **Sidebar (v10.2)** = `SidebarItem.contentItems` (bible/songs/media/schedule/customSlides) in the top `List`, + a PINNED bottom group (`utilityItems` = `.history`, `.account` as selectable destinations, plus a **Settings** button via `@Environment(\.openSettings)`). `.history``HistoryView`, `.account``ProfileView` (local prefs, `@AppStorage` only — no login) route through `ContentAreaView`; both return `EmptyView` in `PreviewPanelView` and the preview column is HIDDEN for them in `MainControlView` (full-width). Any new switch over `SidebarItem` must handle `.history` + `.account`
187202
- **Single output window**: locate it via `presentationWindows` (plural) and call `dedupePresentationWindows()` (closes extras) at the top of `showPresentationWindow`/`movePresentationWindow`/`positionOnScreen` + after the launch auto-open (guarded by `hasPresentationWindow`); the presentation `WindowGroup` is `.restorationBehavior(.disabled)`. This killed the "two overlapping outputs" (state-restoration + auto-open) bug — don't reintroduce an unguarded `openWindow(.presentation)`
188203
- **Song verified flag + edit log (v10.2)**: `Song.verified` (Bool, round-trips through GOAT — `songDictV2` writes `"verified"`, `TopPresenterSongImporter` reads it), `Song.modifiedDate` (drives the Recente sort), `Song.editLogJSON`→`editLog: [SongEditEntry]` (coarse change log, INTERNAL — not exported). The song editor snapshots the song to GOAT on open (`ExportService.exportSongToTopPresenterJSON`); **Renunță** reverts via `ImportService.applyResult(_:to:modelContext:)` (the GOAT→Song builder extracted from `createSongFromResult` — clears + rebuilds versions/sections, reused by import too); **Gata** diffs old↔new via `ImportService.summarizeChanges(old:new:)` → appends edit-log entries. `SectionEditorCard` uses `@FocusState` so clicking a section drives the editor preview. Library: verified badge in `songBadges`, "Doar verificate" filter + `verificat`/✓ search token, sort header chips (`SongSortKey` = A-Z/Artist/Carte/Limbă/Recente). Song slide thumbnails have PREVIEW + trash (delete = remove the section behind the slide, `.confirmationDialog`)
@@ -342,8 +357,9 @@ Unsigned builds require users to right-click → Open, or run `xattr -cr TopPres
342357
|-------------|-----------|-------|
343358
| Bible | `"format"` | `"TopPresenter Bible"` |
344359
| Songs | `"format"` | `"TopPresenter Songs"` |
360+
| Session (.tpschedule) | `"format"` | `"TopPresenter Session"` |
345361

346-
All TopPresenter exports embed this identifier so importers can reliably distinguish them from generic JSON.
362+
All TopPresenter exports embed this identifier so importers can reliably distinguish them from generic JSON. Import MUST check the field is PRESENT (strict probe) — resilient decoders default it, which would accept foreign JSON. UTIs: `com.robyrew.toppresenter.theme` (package) + `com.robyrew.toppresenter.schedule` (public.json).
347363

348364
---
349365

Info.plist

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,23 @@
3939
</array>
4040
</dict>
4141
</dict>
42+
<dict>
43+
<key>UTTypeIdentifier</key>
44+
<string>com.robyrew.toppresenter.schedule</string>
45+
<key>UTTypeDescription</key>
46+
<string>TopPresenter Session</string>
47+
<key>UTTypeConformsTo</key>
48+
<array>
49+
<string>public.json</string>
50+
</array>
51+
<key>UTTypeTagSpecification</key>
52+
<dict>
53+
<key>public.filename-extension</key>
54+
<array>
55+
<string>tpschedule</string>
56+
</array>
57+
</dict>
58+
</dict>
4259
</array>
4360
<key>CFBundleDocumentTypes</key>
4461
<array>
@@ -52,6 +69,16 @@
5269
<string>com.robyrew.toppresenter.theme</string>
5370
</array>
5471
</dict>
72+
<dict>
73+
<key>CFBundleTypeName</key>
74+
<string>TopPresenter Session</string>
75+
<key>CFBundleTypeRole</key>
76+
<string>Viewer</string>
77+
<key>LSItemContentTypes</key>
78+
<array>
79+
<string>com.robyrew.toppresenter.schedule</string>
80+
</array>
81+
</dict>
5582
</array>
5683
</dict>
5784
</plist>

TopPresenter/Core/AppCommands.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,9 @@ extension Notification.Name {
249249
static let deleteBibleModule = Notification.Name("TopPresenter.deleteBibleModule")
250250
static let deleteSongCollection = Notification.Name("TopPresenter.deleteSongCollection")
251251

252-
// Panel communication (content area → preview panel)
253-
static let mediaItemSelected = Notification.Name("TopPresenter.mediaItemSelected")
254-
static let scheduleSelected = Notification.Name("TopPresenter.scheduleSelected")
252+
// Panel communication (content area → preview panel).
253+
// Media/schedule selection now flows through LibraryManager (selectedMediaItem /
254+
// selectedSchedule) — no notification round-trips.
255255
static let slideSelected = Notification.Name("TopPresenter.slideSelected")
256256

257257
// Content area toolbar actions

TopPresenter/Core/LibraryManager.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,16 @@ final class LibraryManager {
5656
var songSlideCount: Int = 1
5757

5858
// MARK: - Media State
59-
/// The media item highlighted in the Media view — drives the tab title.
59+
/// The media item highlighted in the Media view — drives the tab title and
60+
/// the right panel's preview + prev/next stepping.
6061
var selectedMediaItem: MediaItem?
62+
/// Media grid search — on the manager so the panel steps the SAME filtered
63+
/// ordering the grid shows (mirrors songLibraryQuery).
64+
var mediaLibraryQuery: String = ""
65+
66+
// MARK: - Schedule State
67+
/// The session highlighted in the Schedule view — drives the tab title.
68+
var selectedSchedule: ServiceSchedule?
6169

6270
// MARK: - Bible Navigation
6371
/// Switch translation while staying on the same passage where possible.

TopPresenter/Core/PinStore.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// PinStore.swift
3+
// TopPresenter
4+
//
5+
// Session-only song pins („Fixează sus"). App-global — one store shared by every
6+
// window/tab (LibraryManager is per-window, so pins there would diverge) — and
7+
// deliberately in-memory only: pins clear when the app quits, by construction.
8+
// No SwiftData field, no UserDefaults. Keyed by Song.id (ephemeral ⇒ UUID is fine).
9+
//
10+
11+
import Foundation
12+
import Observation
13+
14+
@Observable
15+
final class PinStore {
16+
/// IDs of the songs pinned for this app run.
17+
private(set) var pinnedSongIDs: Set<UUID> = []
18+
19+
var hasPins: Bool { !pinnedSongIDs.isEmpty }
20+
21+
func isPinned(_ id: UUID) -> Bool { pinnedSongIDs.contains(id) }
22+
23+
func togglePin(_ id: UUID) {
24+
if pinnedSongIDs.contains(id) {
25+
pinnedSongIDs.remove(id)
26+
} else {
27+
pinnedSongIDs.insert(id)
28+
}
29+
}
30+
31+
func clearPins() { pinnedSongIDs.removeAll() }
32+
33+
/// Splits an already-filtered/sorted list into (pinned, rest), preserving the
34+
/// input order in both halves. Pure + static so it's unit-testable.
35+
/// (MainActor like its callers — Song is a @Model.)
36+
static func partition(_ songs: [Song], pinnedIDs: Set<UUID>) -> (pinned: [Song], rest: [Song]) {
37+
guard !pinnedIDs.isEmpty else { return ([], songs) }
38+
var pinned: [Song] = []
39+
var rest: [Song] = []
40+
for song in songs {
41+
if pinnedIDs.contains(song.id) { pinned.append(song) } else { rest.append(song) }
42+
}
43+
return (pinned, rest)
44+
}
45+
}

TopPresenter/Core/PresentationManager.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2840,9 +2840,17 @@ final class PresentationManager {
28402840

28412841
/// Marks the output as showing full-screen video.
28422842
func showVideo() {
2843+
showMedia(kind: "video", url: nil)
2844+
}
2845+
2846+
/// Marks the output as showing full-screen media (image or video). Images are
2847+
/// decoded HERE — inside the caller's security scope — so the output window
2848+
/// never re-opens the sandboxed file; video renders via the shared VideoPlayerService.
2849+
func showMedia(kind: String, url: URL?) {
28432850
guard !isFrozen else { return }
2851+
let image: NSImage? = (kind == "image") ? url.flatMap { NSImage(contentsOf: $0) } : nil
28442852
presentContent { [self] in
2845-
liveContent.setVideo()
2853+
liveContent.setMedia(kind: kind, url: url, image: image)
28462854
liveContent.isLive = true
28472855
isBlackScreen = false
28482856
}

TopPresenter/Models/PresentationModels.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ final class MediaItem {
4848
@Attribute(.externalStorage) var thumbnailData: Data?
4949
/// Security-scoped bookmark data for persistent file access
5050
@Attribute(.externalStorage) var bookmarkData: Data?
51+
/// Playable length in seconds (video/audio; 0 = unknown/not applicable).
52+
/// Additive with inline default — lightweight migration, backfilled lazily.
53+
var durationSeconds: Double = 0
5154

5255
init(name: String, filePath: String, mediaType: String) {
5356
self.id = UUID()
@@ -207,7 +210,11 @@ final class ScheduleItem {
207210
var content: String
208211
var subtitle: String
209212
var order: Int
210-
var referenceID: String? // UUID string of the referenced Bible verse or song
213+
var referenceID: String? // legacy, unused — kept for stored data compatibility
214+
/// Stable library reference (SessionItemPayload as JSON). "{}" for legacy
215+
/// free-form items. Additive with inline default — lightweight migration.
216+
/// title/content/subtitle stay as DISPLAY SNAPSHOTS captured at add-time.
217+
var payloadJSON: String = "{}"
211218

212219
var schedule: ServiceSchedule?
213220

@@ -260,6 +267,13 @@ final class LiveContent {
260267
/// deck, item within schedule) — drives "show only on first/last slide".
261268
var slideIndex: Int = 0
262269
var slideCount: Int = 1
270+
/// Live full-screen media: "image" | "video" (audio never claims the output).
271+
var mediaKind: String = ""
272+
/// Resolved URL of the live full-screen media (image rendering reads it).
273+
var mediaURL: URL? = nil
274+
/// Image decoded at present time (inside the file's security scope), so the
275+
/// output window never has to re-open the sandboxed file itself.
276+
var mediaImage: NSImage? = nil
263277

264278
var isFirstSlide: Bool { slideIndex <= 0 }
265279
var isLastSlide: Bool { slideIndex >= slideCount - 1 }
@@ -294,6 +308,9 @@ final class LiveContent {
294308
clearSongMeta()
295309
slideIndex = 0
296310
slideCount = 1
311+
mediaKind = ""
312+
mediaURL = nil
313+
mediaImage = nil
297314
}
298315

299316
private func clearRichSources() {
@@ -361,13 +378,22 @@ final class LiveContent {
361378
}
362379

363380
func setVideo() {
381+
setMedia(kind: "video", url: nil)
382+
}
383+
384+
/// Marks the output as showing full-screen media (image or video). Audio
385+
/// plays through AudioPlayerManager and never claims the visual output.
386+
func setMedia(kind: String, url: URL?, image: NSImage? = nil) {
364387
self.mainText = ""
365388
self.reference = ""
366389
self.subtitle = ""
367390
self.translationName = ""
368391
self.contentType = .media
369392
self.mainRuns = []
370393
clearRichSources()
394+
self.mediaKind = kind
395+
self.mediaURL = url
396+
self.mediaImage = image
371397
}
372398
}
373399

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// MediaPresenter.swift
3+
// TopPresenter
4+
//
5+
// THE one way a MediaItem goes live — used by the Media grid (double-click/Enter),
6+
// the right panel's Proiectează button, and the session runner. Handles security
7+
// scope, the loop default, and kind dispatch: image/video claim the visual output;
8+
// audio plays through AudioPlayerManager and leaves the output untouched.
9+
//
10+
11+
import Foundation
12+
import AVFoundation
13+
14+
enum MediaPresenter {
15+
/// Present a media item on the live output (image/video) or play it (audio).
16+
static func present(_ item: MediaItem,
17+
pm: PresentationManager,
18+
video: VideoPlayerService,
19+
audio: AudioPlayerManager) {
20+
guard let url = item.resolvedURL else { return }
21+
let accessing = url.startAccessingSecurityScopedResource()
22+
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
23+
24+
switch MediaKind(rawValue: item.mediaType) ?? .image {
25+
case .image:
26+
pm.showMedia(kind: "image", url: url)
27+
case .video:
28+
video.loadVideo(url: url)
29+
video.isLooping = pm.videoLoopsByDefault
30+
video.play()
31+
pm.showMedia(kind: "video", url: url)
32+
case .audio:
33+
audio.loadAudio(url: url)
34+
audio.play()
35+
}
36+
backfillDurationIfNeeded(item, url: url)
37+
}
38+
39+
/// Use an image (or GIF/video) as the global output background.
40+
static func setAsBackground(_ item: MediaItem, pm: PresentationManager) {
41+
guard let url = item.resolvedURL else { return }
42+
let accessing = url.startAccessingSecurityScopedResource()
43+
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
44+
pm.setBackgroundMedia(from: url)
45+
}
46+
47+
/// Lazily fill `durationSeconds` for video/audio imported before the field
48+
/// existed (or when import-time probing failed). Fire-and-forget.
49+
static func backfillDurationIfNeeded(_ item: MediaItem, url: URL) {
50+
guard item.durationSeconds <= 0, item.mediaType != "image" else { return }
51+
Task { @MainActor in
52+
let asset = AVURLAsset(url: url)
53+
if let duration = try? await asset.load(.duration) {
54+
let seconds = CMTimeGetSeconds(duration)
55+
if seconds.isFinite, seconds > 0 { item.durationSeconds = seconds }
56+
}
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)