diff --git a/apps/app/src/components/use-tracks.tsx b/apps/app/src/components/use-tracks.tsx index 8c9fea0..2e8df66 100644 --- a/apps/app/src/components/use-tracks.tsx +++ b/apps/app/src/components/use-tracks.tsx @@ -169,7 +169,7 @@ function toWebpageID(wid: string) { return webpageTrackPrefix + wid; } function toWebpageUrl(wid: string) { - return `webview://${toWebpageID(wid)}}`; + return `webview://${toWebpageID(wid)}`; } function getWebpageIDFromTrackID(id: string) { if (!id.startsWith(webpageTrackPrefix)) return null; @@ -187,13 +187,19 @@ export function dedupeWebsiteTrack( return website.filter((t) => !local.some(({ wid }) => wid === t.wid)); } -export function toTrackLabel(t: TextTrack, idx: number) { +interface SortableTrack { + language?: string; + label?: string; + kind: string; +} + +export function toTrackLabel(t: SortableTrack, idx: number) { return ( t.label || langCodeToLabel(t.language) || `${upperFirst(t.kind)} ${idx + 1}` ); } -export function sortTrack(a: TextTrack | null, b: TextTrack | null) { +export function sortTrack(a: SortableTrack | null, b: SortableTrack | null) { if (a && b) { // if with item.track.language, sort by item.track.language if (a.language && b.language) { diff --git a/apps/app/src/info/track-info.ts b/apps/app/src/info/track-info.ts index cd4c16c..f48e780 100644 --- a/apps/app/src/info/track-info.ts +++ b/apps/app/src/info/track-info.ts @@ -1,13 +1,26 @@ import { fileURLToPath } from "url"; import type { ParsedCaptionsResult } from "media-captions"; import { TFile } from "obsidian"; -import { toFileInfo, type FileInfo } from "@/lib/iter-sibling"; +import { toFileInfo, type FileInfo } from "@/lib/file-info"; import { format } from "@/lib/lang/lang"; import { noHash, toURL } from "@/lib/url"; const allowedProtocols = new Set(["https:", "http:", "file:"]); -type TextTrackKind = "captions" | "subtitles"; +export type TextTrackKind = "captions" | "subtitles"; +export const textTrackFmField = { + subtitles: { + singular: "subtitle", + plural: "subtitles", + }, + captions: { + singular: "caption", + plural: "captions", + }, +} as const; +export const textTrackKinds = Object.keys( + textTrackFmField, +) as (keyof typeof textTrackFmField)[]; /** * for those extracted from web, provide a unique id from web provider @@ -102,13 +115,9 @@ function parseURL( let { language, type } = fromHash; // parse as a file - const name = src.pathname.split("/").pop() ?? ""; - const extension = name.split(".").pop() ?? ""; - if (name && extension && isSupportedCaptionExt(extension)) { - const { language: lang } = parseTrackFromBasename( - // remove extension from filename - name.slice(0, -extension.length - 1), - ); + const { basename, extension } = toFileInfo(src.pathname, "/"); + if (isSupportedCaptionExt(extension)) { + const { language: lang } = parseTrackFromBasename(basename); if (lang) language ??= lang; type ??= extension; return { wid, language, label, type, kind, src }; @@ -160,7 +169,7 @@ export function toTrack( subpath = "", alias, }: Partial<{ - kind: "captions" | "subtitles"; + kind: TextTrackKind; subpath: string; alias: string; }> = {}, @@ -185,3 +194,7 @@ export function getTrackInfoID({ wid, src }: TextTrackInfo) { const srcURL = src instanceof URL ? noHash(src) : src.path; return { id: `${type}:${srcURL}`, wid }; } + +export function isVaultTrack(trackID: string) { + return trackID.startsWith("file:"); +} diff --git a/apps/app/src/lib/file-info.ts b/apps/app/src/lib/file-info.ts new file mode 100644 index 0000000..633ff0e --- /dev/null +++ b/apps/app/src/lib/file-info.ts @@ -0,0 +1,30 @@ +export interface FileInfo { + extension: string; + basename: string; + path: string; +} + +export function toFileInfo( + filepath: string, + sep = "/", +): FileInfo & { name: string; parent: string } { + const filename = filepath.split(sep).pop()!; + const extension = filename.split(".").pop()!; + const info = { + name: filename, + path: filepath, + parent: filepath.slice(0, -filename.length - 1), + }; + if (extension === filename) { + return { + extension: "", + basename: filename, + ...info, + }; + } + return { + extension, + basename: filename.slice(0, -extension.length - 1), + ...info, + }; +} diff --git a/apps/app/src/lib/iter-sibling.ts b/apps/app/src/lib/iter-sibling.ts index 5147e61..018c8d8 100644 --- a/apps/app/src/lib/iter-sibling.ts +++ b/apps/app/src/lib/iter-sibling.ts @@ -1,28 +1,7 @@ import path from "@/lib/path"; import { getFsPromise } from "@/web/session/utils"; - -export interface FileInfo { - extension: string; - basename: string; - path: string; -} - -export function toFileInfo(filepath: string): FileInfo { - const name = path.basename(filepath); - const segs = name.split("."); - if (segs.length === 1) - return { - extension: "", - basename: name, - path: filepath, - }; - - return { - extension: segs.at(-1)!, - basename: segs.slice(0, -1).join("."), - path: filepath, - }; -} +import type { FileInfo } from "./file-info"; +import { toFileInfo } from "./file-info"; /** * @param exclude names of files under the directory to exclude @@ -37,6 +16,6 @@ export async function* iterSiblings( const dir = await fs.opendir(dirPath, { encoding: "utf-8" }); for await (const f of dir) { if (!(f.isFile() || f.isSymbolicLink()) || excludeSet.has(f.name)) continue; - yield toFileInfo(path.join(dirPath, f.name)); + yield toFileInfo(path.join(dirPath, f.name), path.sep); } } diff --git a/apps/app/src/media-note/note-index/extract.ts b/apps/app/src/media-note/note-index/extract.ts index 3a61b09..a2e99fa 100644 --- a/apps/app/src/media-note/note-index/extract.ts +++ b/apps/app/src/media-note/note-index/extract.ts @@ -29,7 +29,7 @@ export function getMediaNoteMeta( return { src, get data() { - return parseMediaNoteMeta(meta, ctx); + return parseMediaNoteMeta(meta); }, }; } diff --git a/apps/app/src/media-note/note-index/index.ts b/apps/app/src/media-note/note-index/indexer.ts similarity index 72% rename from apps/app/src/media-note/note-index/index.ts rename to apps/app/src/media-note/note-index/indexer.ts index 38eead9..55a10d9 100644 --- a/apps/app/src/media-note/note-index/index.ts +++ b/apps/app/src/media-note/note-index/indexer.ts @@ -1,15 +1,16 @@ import type { MediaPlayerInstance } from "@vidstack/react"; -import { Component, debounce } from "obsidian"; import type { MetadataCache, Vault, TFile } from "obsidian"; +import { Component, debounce } from "obsidian"; import { getMediaInfoID, isFileMediaInfo } from "@/info/media-info"; import { type MediaInfo } from "@/info/media-info"; import { checkMediaType } from "@/info/media-type"; -import { getTrackInfoID, type TextTrackInfo } from "@/info/track-info"; +import type { TextTrackInfo } from "@/info/track-info"; import { iterateFiles } from "@/lib/iterate-files"; -import { waitUntilResolve } from "@/lib/meta-resolve"; +import { waitUntilResolve as waitUntilMetaInited } from "@/lib/meta-resolve"; import { normalizeFilename } from "@/lib/norm"; import type { PlayerComponent } from "@/media-view/base"; import type MxPlugin from "@/mx-main"; +import { TranscriptIndex } from "../../transcript/handle/indexer"; import { mediaTitle } from "../title"; import type { MediaSourceFieldType } from "./def"; import type { ParsedMediaNoteMetadata } from "./extract"; @@ -25,38 +26,31 @@ declare module "obsidian" { interface MetadataCache { on(name: "finished", callback: () => any, ctx?: any): EventRef; on(name: "initialized", callback: () => any, ctx?: any): EventRef; - on( - name: "mx:transcript-changed", - callback: (trackIDs: Set, mediaID: string) => any, - ctx?: any, - ): EventRef; - trigger( - name: "mx:transcript-changed", - trackIDs: Set, - mediaID: string, - ): void; initialized: boolean; } } - export class MediaNoteIndex extends Component { app; constructor(public plugin: MxPlugin) { super(); this.app = plugin.app; + this.transcript = this.addChild(new TranscriptIndex(this.plugin)); } + // media - note map is one-to-one private noteToMediaIndex = new Map(); private mediaToNoteIndex = new Map(); - private mediaToTrackIndex = new Map(); - private trackToMediaIndex = new Map(); + private transcript; getLinkedTextTracks(media: MediaInfo) { - return this.mediaToTrackIndex.get(getMediaInfoID(media)) ?? []; + const note = this.findNote(media); + if (!note) return []; + return this.transcript.getLinkedTextTracks(note); } - getLinkedMedia(track: TextTrackInfo) { - return this.trackToMediaIndex.get(getTrackInfoID(track).id) ?? []; + getLinkedMedia(track: TextTrackInfo): MediaInfo[] { + const notes = this.transcript.getLinkedMediaNotes(track); + return notes.map((note) => this.findMedia(note)!); } findNote(media: MediaInfo): TFile | null { @@ -66,9 +60,10 @@ export class MediaNoteIndex extends Component { return this.noteToMediaIndex.get(note.path); } - private onResolve() { + private onResolved() { this.noteToMediaIndex.clear(); this.mediaToNoteIndex.clear(); + this.transcript.clear(); const ctx = { metadataCache: this.app.metadataCache, vault: this.app.vault, @@ -95,10 +90,27 @@ export class MediaNoteIndex extends Component { const mediaInfo = this.noteToMediaIndex.get(oldPath)!; this.noteToMediaIndex.delete(oldPath); this.noteToMediaIndex.set(file.path, mediaInfo); - // mediaToNoteIndex don't need to update + // media(track)ToNoteIndex don't need to update // since TFile pointer is not changed }), ); + this.register( + this.transcript.on("changed", (updated, note) => { + const mediaInfo = this.findMedia(note); + if (!mediaInfo) { + console.warn( + "Media not found for note while responding to transcript change", + note.path, + ); + return; + } + this.plugin.app.metadataCache.trigger( + "mx:transcript-changed", + updated, + getMediaInfoID(mediaInfo), + ); + }), + ); } /** @@ -167,41 +179,13 @@ export class MediaNoteIndex extends Component { return newNote; } - private resetTracks(mediaID: string): string[] { - const tracks = this.mediaToTrackIndex.get(mediaID); - if (!tracks) return []; - const affected = tracks.map((track) => getTrackInfoID(track).id); - this.mediaToTrackIndex.delete(mediaID); - tracks.forEach((track) => { - const trackID = getTrackInfoID(track).id; - const linkedMedia = this.trackToMediaIndex.get(trackID); - if (!linkedMedia) return; - const filteredMedia = linkedMedia.filter( - (media) => getMediaInfoID(media) !== mediaID, - ); - if (filteredMedia.length > 0) { - this.trackToMediaIndex.set(trackID, filteredMedia); - } else { - this.trackToMediaIndex.delete(trackID); - } - }); - return affected; - } - - removeMediaNote(toRemove: TFile) { - const mediaInfo = this.noteToMediaIndex.get(toRemove.path)!; + removeMediaNote(note: TFile) { + const mediaInfo = this.noteToMediaIndex.get(note.path)!; if (!mediaInfo) return; - this.noteToMediaIndex.delete(toRemove.path); + this.noteToMediaIndex.delete(note.path); const mediaID = getMediaInfoID(mediaInfo); this.mediaToNoteIndex.delete(mediaID); - const affected = this.resetTracks(mediaID); - if (affected.length > 0) { - this.app.metadataCache.trigger( - "mx:transcript-changed", - new Set(affected), - mediaID, - ); - } + this.transcript.remove(note); } addMediaNote(meta: ParsedMediaNoteMetadata, newNote: TFile) { const mediaID = getMediaInfoID(meta.src); @@ -215,32 +199,12 @@ export class MediaNoteIndex extends Component { return; this.noteToMediaIndex.set(newNote.path, meta.src); this.mediaToNoteIndex.set(mediaID, newNote); - const affected = new Set(this.resetTracks(mediaID)); - const { textTracks } = meta.data; - if (textTracks.length > 0) { - this.mediaToTrackIndex.set(mediaID, textTracks); - textTracks.forEach((track) => { - const trackID = getTrackInfoID(track).id; - affected.add(trackID); - const linkedMedia = [ - meta.src, - ...(this.trackToMediaIndex.get(trackID) ?? []), - ]; - this.trackToMediaIndex.set(trackID, linkedMedia); - }); - } - if (affected.size > 0) { - this.app.metadataCache.trigger( - "mx:transcript-changed", - affected, - mediaID, - ); - } + this.transcript.add(newNote, meta.data.textTracks); } onload(): void { - waitUntilResolve(this.app.metadataCache, this).then(() => { - this.onResolve(); + waitUntilMetaInited(this.app.metadataCache, this).then(() => { + this.onResolved(); }); } } diff --git a/apps/app/src/media-note/note-index/parse.ts b/apps/app/src/media-note/note-index/parse.ts index f9c8055..f4eada2 100644 --- a/apps/app/src/media-note/note-index/parse.ts +++ b/apps/app/src/media-note/note-index/parse.ts @@ -1,21 +1,13 @@ -import type { CachedMetadata, MetadataCache } from "obsidian"; -import type { TextTrackInfo } from "@/info/track-info"; +import type { CachedMetadata } from "obsidian"; +import type { MetaTextTrackInfo } from "@/transcript/handle/meta"; import { parseTextTrackFields } from "@/transcript/handle/meta"; export interface MediaNoteMeta { - textTracks: TextTrackInfo[]; + textTracks: MetaTextTrackInfo[]; } -export function parseMediaNoteMeta( - meta: CachedMetadata, - { - metadataCache, - sourcePath, - }: { metadataCache: MetadataCache; sourcePath: string }, -): MediaNoteMeta { +export function parseMediaNoteMeta(meta: CachedMetadata): MediaNoteMeta { return { - textTracks: parseTextTrackFields(meta, (linkpath) => - metadataCache.getFirstLinkpathDest(linkpath, sourcePath), - ), + textTracks: parseTextTrackFields(meta), }; } diff --git a/apps/app/src/media-note/title.ts b/apps/app/src/media-note/title.ts index c474eb4..5bbfc87 100644 --- a/apps/app/src/media-note/title.ts +++ b/apps/app/src/media-note/title.ts @@ -2,14 +2,13 @@ import type { MediaState } from "@vidstack/react"; import { isFileMediaInfo, type MediaInfo } from "@/info/media-info"; import { type MediaURL } from "@/info/media-url"; import { MediaHost } from "@/info/supported"; +import { toFileInfo } from "@/lib/file-info"; export function urlTitle(url: MediaURL, playerState?: MediaState) { if (playerState?.title) return playerState.title; if (url.isFileUrl) { - const filenameSeg = url.pathname.split("/").pop()?.split("."); - filenameSeg?.pop(); - const basename = filenameSeg?.join("."); - if (basename) return basename; + const { basename, extension } = toFileInfo(url.pathname); + if (extension) return basename; } if (url.type !== MediaHost.Generic && url.id) { return `${url.type}: ${url.id}`; diff --git a/apps/app/src/media-view/base.tsx b/apps/app/src/media-view/base.tsx index 6157af7..d3d3a53 100644 --- a/apps/app/src/media-view/base.tsx +++ b/apps/app/src/media-view/base.tsx @@ -3,6 +3,7 @@ import { type Component, type Menu, type View, type ItemView } from "obsidian"; import type ReactDOM from "react-dom/client"; import type { MediaViewStoreApi } from "@/components/context"; import { dedupeWebsiteTrack } from "@/components/use-tracks"; +import { toFileInfo } from "@/lib/file-info"; import type { PaneMenuSource } from "@/lib/menu"; import { toURL } from "@/lib/url"; import { saveScreenshot } from "@/media-note/timestamp/screenshot"; @@ -37,12 +38,9 @@ declare module "obsidian" { export function titleFromUrl(src: string): string { const url = toURL(src); if (!url) return ""; - const { pathname } = url; - if (!pathname) return ""; - const finalPath = pathname.split("/").pop(); - if (!finalPath) return ""; - // remove extension - return decodeURI(finalPath.split(".").slice(0, -1).join(".")); + const { basename, extension } = toFileInfo(url.pathname); + if (!extension) return ""; + return decodeURI(basename); } export function addAction(player: PlayerComponent & ItemView) { diff --git a/apps/app/src/media-view/file-view.tsx b/apps/app/src/media-view/file-view.tsx index 072ce4e..f48185e 100644 --- a/apps/app/src/media-view/file-view.tsx +++ b/apps/app/src/media-view/file-view.tsx @@ -7,7 +7,7 @@ import type { FileMediaInfo } from "@/info/media-info"; import { checkMediaType } from "@/info/media-type"; import type { PaneMenuSource } from "@/lib/menu"; import { handleWindowMigration } from "@/lib/window-migration"; -import { handleTrackUpdate } from "@/media-note/note-index"; +import { handleTrackUpdate } from "@/media-note/note-index/indexer"; import type MediaExtended from "@/mx-main"; import { type PlayerComponent, addAction, onPaneMenu } from "./base"; import type { MediaFileViewType } from "./view-type"; diff --git a/apps/app/src/media-view/menu/transcript.ts b/apps/app/src/media-view/menu/transcript.ts index 4b087ce..edc844d 100644 --- a/apps/app/src/media-view/menu/transcript.ts +++ b/apps/app/src/media-view/menu/transcript.ts @@ -1,15 +1,15 @@ import { upperFirst } from "lodash-es"; import type { Menu } from "obsidian"; import { Notice, normalizePath, TFile } from "obsidian"; +import { sortTrack } from "@/components/use-tracks"; import { MediaURL } from "@/info/media-url"; -import type { WebsiteTextTrack } from "@/info/track-info"; +import { textTrackFmField, type WebsiteTextTrack } from "@/info/track-info"; import { getSaveFolder } from "@/lib/folder"; import { langCodeToLabel } from "@/lib/lang/lang"; import { normalizeFilename } from "@/lib/norm"; import { WebiviewMediaProvider } from "@/lib/remote-player/provider"; import { uniq } from "@/lib/uniq"; import { mediaTitle } from "@/media-note/title"; -import { textTrackFmField } from "@/transcript/handle/meta"; import { stringifyTrack } from "@/transcript/stringify"; import type { PlayerContext } from "."; @@ -27,7 +27,7 @@ export function transcriptMenu(menu: Menu, ctx: PlayerContext) { .setIcon("subtitles") .setSubmenu(); - tracks.forEach((t, idx) => { + tracks.sort(sortTrack).forEach((t, idx) => { const label = t.label || langCodeToLabel(t.language) || diff --git a/apps/app/src/media-view/remote-view.tsx b/apps/app/src/media-view/remote-view.tsx index 62d04df..e3e008e 100644 --- a/apps/app/src/media-view/remote-view.tsx +++ b/apps/app/src/media-view/remote-view.tsx @@ -14,8 +14,8 @@ import { MediaURL } from "@/info/media-url"; import type { PaneMenuSource } from "@/lib/menu"; import { updateTitle } from "@/lib/view-title"; import { handleWindowMigration } from "@/lib/window-migration"; -import { handleTrackUpdate } from "@/media-note/note-index"; import { compare } from "@/media-note/note-index/def"; +import { handleTrackUpdate } from "@/media-note/note-index/indexer"; import type MediaExtended from "@/mx-main"; import type { PlayerComponent } from "./base"; import { addAction, onPaneMenu } from "./base"; diff --git a/apps/app/src/mx-main.ts b/apps/app/src/mx-main.ts index 42edc7b..7f5e618 100644 --- a/apps/app/src/mx-main.ts +++ b/apps/app/src/mx-main.ts @@ -17,7 +17,7 @@ import { onExternalLinkClick, onInternalLinkClick, } from "./media-note/link-click"; -import { MediaNoteIndex } from "./media-note/note-index"; +import { MediaNoteIndex } from "./media-note/note-index/indexer"; import { PlaylistIndex } from "./media-note/playlist"; import { MediaFileEmbed } from "./media-view/file-embed"; import { AudioFileView, VideoFileView } from "./media-view/file-view"; @@ -43,7 +43,7 @@ import { createSettingsStore } from "./settings/def"; import { MxSettingTabs } from "./settings/tab"; import { initSwitcher } from "./switcher"; import { registerTranscriptView } from "./transcript"; -import { TranscriptLoader } from "./transcript/handle"; +import { TranscriptLoader } from "./transcript/handle/loader"; import { BilibiliRequestHacker } from "./web/bili-req"; import { modifySession } from "./web/session"; import { resolveMxProtocol } from "./web/url-match"; diff --git a/apps/app/src/transcript/handle/indexer.ts b/apps/app/src/transcript/handle/indexer.ts new file mode 100644 index 0000000..009614a --- /dev/null +++ b/apps/app/src/transcript/handle/indexer.ts @@ -0,0 +1,163 @@ +import type { TFile } from "obsidian"; +import { Component } from "obsidian"; +import { getTrackInfoID, isVaultTrack } from "@/info/track-info"; +import type { TextTrackInfo } from "@/info/track-info"; +import { createEventEmitter } from "@/lib/emitter"; +import type MxPlugin from "@/mx-main"; +import { isUnresolvedTrackLink, resolveTrackLink } from "./meta"; +import type { MetaTextTrackInfo, UnresolvedTrackLink } from "./meta"; + +declare module "obsidian" { + interface MetadataCache { + on( + name: "mx:transcript-changed", + callback: (trackIDs: Set, mediaID: string) => any, + ctx?: any, + ): EventRef; + trigger( + name: "mx:transcript-changed", + trackIDs: Set, + mediaID: string, + ): void; + } +} +export class TranscriptIndex extends Component { + app; + constructor(public plugin: MxPlugin) { + super(); + this.app = plugin.app; + } + + private noteToTrack = new Map(); + + #event = createEventEmitter<{ + changed: (updated: Set, note: TFile) => void; + }>(); + on(event: "changed", cb: (updated: Set, note: TFile) => void) { + return this.#event.on("changed", cb); + } + + private trackIDToNote = new Map>(); + + onload(): void { + this.registerEvent( + this.app.vault.on("rename", (file, oldPath) => { + if (!this.noteToTrack.has(oldPath)) return; + const tracks = this.noteToTrack.get(oldPath); + if (tracks) { + this.noteToTrack.delete(oldPath); + this.noteToTrack.set(file.path, tracks); + } + // media(track)ToNoteIndex don't need to update + // since TFile pointer is not changed + }), + ); + this.registerEvent( + this.app.metadataCache.on("resolve", this.onResolve, this), + ); + } + + #resolveLink(note: TFile, track: UnresolvedTrackLink) { + const resolved = resolveTrackLink(track, note.path, this.app); + if (!resolved) return null; + const id = getTrackInfoID(resolved).id; + const notes = this.trackIDToNote.get(id) ?? new Set(); + notes.add(note); + this.trackIDToNote.set(id, notes); + return id; + } + + onResolve(note: TFile) { + const tracks = this.noteToTrack.get(note.path); + if (!tracks) return; + const removed = new Set(); + this.trackIDToNote.forEach((notes, trackID, map) => { + if (!notes.has(note) || !isVaultTrack(trackID)) return; + removed.add(trackID); + notes.delete(note); + if (notes.size > 0) { + map.set(trackID, notes); + } else { + map.delete(trackID); + } + }); + const added = tracks + .filter(isUnresolvedTrackLink) + .map((track) => this.#resolveLink(note, track)) + .filter((id): id is string => !!id); + + const unchanged = new Set(added.filter((x) => removed.has(x))); + const updated = new Set( + [...added, ...removed].filter((x) => !unchanged.has(x)), + ); + if (updated.size > 0) this.#event.emit("changed", updated, note); + } + + add(note: TFile, tracks: MetaTextTrackInfo[]) { + const removed = this.#remove(note); + this.noteToTrack.set(note.path, tracks); + const added = new Set(); + tracks.forEach((track) => { + let trackID: string | null; + if (isUnresolvedTrackLink(track)) { + trackID = this.#resolveLink(note, track); + } else { + trackID = getTrackInfoID(track).id; + const notes = this.trackIDToNote.get(trackID) ?? new Set(); + notes.add(note); + this.trackIDToNote.set(getTrackInfoID(track).id, notes); + } + if (trackID) added.add(trackID); + }); + const unchanged = new Set(removed.filter((x) => added.has(x))); + const updated = new Set( + [...added, ...removed].filter((x) => !unchanged.has(x)), + ); + if (updated.size > 0) this.#event.emit("changed", updated, note); + } + + /** + * @returns affected track's ids + */ + #remove(note: TFile) { + this.noteToTrack.delete(note.path); + const affected: string[] = []; + this.trackIDToNote.forEach((notes, trackID, map) => { + if (!notes.has(note)) return; + affected.push(trackID); + notes.delete(note); + if (notes.size > 0) { + map.set(trackID, notes); + } else { + map.delete(trackID); + } + }); + return affected; + } + + remove(note: TFile) { + const updated = new Set(this.#remove(note)); + if (updated.size > 0) this.#event.emit("changed", updated, note); + } + + clear() { + this.noteToTrack.clear(); + this.trackIDToNote.clear(); + } + + getLinkedTextTracks(note: TFile): TextTrackInfo[] { + const tracks = this.noteToTrack.get(note.path); + if (!tracks) return []; + return tracks + .map((t) => + isUnresolvedTrackLink(t) ? resolveTrackLink(t, note.path, this.app) : t, + ) + .filter((t): t is TextTrackInfo => !!t); + } + getLinkedMediaNotes(track: TextTrackInfo): TFile[] { + const trackID = getTrackInfoID(track).id; + const notes = this.trackIDToNote.get(trackID); + if (!notes) return []; + return [...notes]; + } +} diff --git a/apps/app/src/transcript/handle/index.ts b/apps/app/src/transcript/handle/loader.ts similarity index 98% rename from apps/app/src/transcript/handle/index.ts rename to apps/app/src/transcript/handle/loader.ts index d0b5a85..9b4b0f5 100644 --- a/apps/app/src/transcript/handle/index.ts +++ b/apps/app/src/transcript/handle/loader.ts @@ -13,7 +13,7 @@ import type { TextTrackInfo, } from "@/info/track-info"; import { isSupportedCaptionExt } from "@/info/track-info"; -import type { FileInfo } from "@/lib/iter-sibling"; +import type { FileInfo } from "@/lib/file-info"; import type MxPlugin from "@/mx-main"; import { readFile } from "@/web/session/utils"; import { parseTrack } from "../stringify"; diff --git a/apps/app/src/transcript/handle/meta.ts b/apps/app/src/transcript/handle/meta.ts index cba254c..2b4ba3c 100644 --- a/apps/app/src/transcript/handle/meta.ts +++ b/apps/app/src/transcript/handle/meta.ts @@ -1,28 +1,41 @@ import { parseLinktext } from "obsidian"; -import type { TFile, CachedMetadata } from "obsidian"; -import { parseTrackUrl, toTrack, type TextTrackInfo } from "@/info/track-info"; - -export const textTrackFmField = { - subtitles: { - singular: "subtitle", - plural: "subtitles", - }, - captions: { - singular: "caption", - plural: "captions", - }, -} as const; -const textTrackKinds = Object.keys( +import type { App, CachedMetadata, TFile } from "obsidian"; +import { + parseTrackUrl, textTrackFmField, -) as (keyof typeof textTrackFmField)[]; + textTrackKinds, + toTrack, +} from "@/info/track-info"; +import type { + LocalTrack, + TextTrackInfo, + TextTrackKind, +} from "@/info/track-info"; + +export type UnresolvedTrackLink = { + type: "link"; + path: string; + subpath: string; + alias?: string; + kind: TextTrackKind; +}; + +export function isUnresolvedTrackLink( + info: MetaTextTrackInfo, +): info is UnresolvedTrackLink { + return (info as UnresolvedTrackLink).type === "link"; +} + +export type MetaTextTrackInfo = TextTrackInfo | UnresolvedTrackLink; -export function parseTextTrackFields( - meta: CachedMetadata, - resolveFile: (linkpath: string) => TFile | null, -) { +/** + * @returns TextTrackInfo with FileInfo being source. + * FileInfo contains unresolved link path. + */ +export function parseTextTrackFields(meta: CachedMetadata) { const { frontmatter, frontmatterLinks } = meta; if (!frontmatter) return []; - return textTrackKinds.reduce((info, kind) => { + return textTrackKinds.reduce((info, kind) => { const field = textTrackFmField[kind]; let value: unknown = frontmatter[field.plural]; if ( @@ -49,14 +62,13 @@ export function parseTextTrackFields( const fmLink = frontmatterLinks?.find((l) => l.key === key); if (fmLink) { const { path, subpath } = parseLinktext(fmLink.link); - const file = resolveFile(path); - if (!file) return null; - const track = toTrack(file, { - kind, + return { + type: "link", + path, subpath, + kind, alias: fmLink.displayText, - }); - if (track) return track; + } satisfies UnresolvedTrackLink; } else { const url = parseTrackUrl(value, { kind }); if (url) return url; @@ -66,3 +78,13 @@ export function parseTextTrackFields( return info; }, []); } + +export function resolveTrackLink( + { alias, kind, path, subpath }: UnresolvedTrackLink, + sourcePath: string, + { metadataCache }: App, +): LocalTrack | null { + const file = metadataCache.getFirstLinkpathDest(path, sourcePath); + if (!file) return null; + return toTrack(file, { kind, subpath, alias }); +} diff --git a/apps/app/src/transcript/handle/resolve/media.ts b/apps/app/src/transcript/handle/resolve/media.ts index 2b15f1d..63c9b3a 100644 --- a/apps/app/src/transcript/handle/resolve/media.ts +++ b/apps/app/src/transcript/handle/resolve/media.ts @@ -9,7 +9,7 @@ import { } from "@/info/media-type"; import { MediaURL } from "@/info/media-url"; import type { LocalTrack } from "@/info/track-info"; -import type { FileInfo } from "@/lib/iter-sibling"; +import type { FileInfo } from "@/lib/file-info"; import { iterSiblings } from "@/lib/iter-sibling"; import path from "@/lib/path"; diff --git a/apps/app/src/transcript/handle/resolve/track.ts b/apps/app/src/transcript/handle/resolve/track.ts index 7bfae0e..9cb9199 100644 --- a/apps/app/src/transcript/handle/resolve/track.ts +++ b/apps/app/src/transcript/handle/resolve/track.ts @@ -1,8 +1,8 @@ import { TFile } from "obsidian"; import type { MediaURL } from "@/info/media-url"; import { getCaptionExts, toTrack, type LocalTrack } from "@/info/track-info"; +import { toFileInfo, type FileInfo } from "@/lib/file-info"; import { groupBy } from "@/lib/group-by"; -import type { FileInfo } from "@/lib/iter-sibling"; import { iterSiblings } from "@/lib/iter-sibling"; import path from "@/lib/path"; @@ -28,9 +28,11 @@ function dedupeTracks(tracks: LocalTrack[]) { export async function resolveLocalTracks(media: MediaURL) { const filePath = media.filePath; if (!filePath || !media.inferredType) return []; - const mediaName = path.basename(filePath); - const mediaBaseame = mediaName.split(".").slice(0, -1).join("."); - const parentDir = path.dirname(filePath); + const { + name: mediaName, + basename: mediaBaseame, + parent: parentDir, + } = toFileInfo(filePath, path.sep); try { const tracks: LocalTrack[] = []; diff --git a/apps/app/src/transcript/view/context.tsx b/apps/app/src/transcript/view/context.tsx index c6c2b79..64956f3 100644 --- a/apps/app/src/transcript/view/context.tsx +++ b/apps/app/src/transcript/view/context.tsx @@ -60,7 +60,6 @@ export function createTranscriptViewStore() { const now = new Set(cueIds); if (prev.size === now.size && [...prev].every((id) => now.has(id))) return; - console.log("updateActiveCues", cueIds); set({ activeCueIDs: now }); }, setLinkedMedia(media) {