mirror of
https://github.com/gavvvr/obsidian-timecodes-plugin.git
synced 2026-07-22 12:00:24 +00:00
Compare commits
No commits in common. "main" and "0.0.1" have entirely different histories.
24 changed files with 124 additions and 451 deletions
68
.github/workflows/build-ci-runner-image.yml
vendored
68
.github/workflows/build-ci-runner-image.yml
vendored
|
|
@ -1,68 +0,0 @@
|
|||
name: Docker image for CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/build-ci-runner-image.yml'
|
||||
- 'docker/ci-runner.Dockerfile'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
nodejs_version:
|
||||
description: 'Node.js version to use'
|
||||
type: string
|
||||
required: true
|
||||
obsidian_version:
|
||||
description: 'Obsidian version to test with'
|
||||
type: string
|
||||
required: true
|
||||
debian_version:
|
||||
description: 'Debian codename version to use'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
env:
|
||||
NODEJS_VERSION: ${{ inputs.nodejs_version || '24' }}
|
||||
OBSIDIAN_VERSION: ${{ inputs.obsidian_version || '1.12.7' }}
|
||||
DEBIAN_VERSION: ${{ inputs.debian_version || 'trixie' }}
|
||||
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository }}/ci-runner
|
||||
DOCKERFILE_PATH: docker/ci-runner.Dockerfile
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare metadata for tags and labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=node-${{ env.NODEJS_VERSION }}-obsidian-${{ env.OBSIDIAN_VERSION }}-${{ env.DEBIAN_VERSION }}-slim
|
||||
labels: |
|
||||
org.opencontainers.image.title: Obsidian plugins CI runner
|
||||
org.opencontainers.image.description=Docker image for building and testing obsidian plugins (with E2E tests support)
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: ${{ env.DOCKERFILE_PATH }}
|
||||
build-args: |
|
||||
NODEJS_VERSION=${{ env.NODEJS_VERSION }}
|
||||
OBSIDIAN_VERSION=${{ env.OBSIDIAN_VERSION }}
|
||||
DEBIAN_VERSION=${{ env.DEBIAN_VERSION }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
|
|
@ -7,60 +7,45 @@ on:
|
|||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
Build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/${{ github.repository }}/ci-runner:node-24-obsidian-1.12.7-trixie-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Environment details
|
||||
run: pnpm dlx envinfo --system --binaries
|
||||
run: pnpm version
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Test
|
||||
run: pnpm run test:unit
|
||||
- name: E2E tests
|
||||
run: xvfb-run pnpm run test
|
||||
working-directory: e2e
|
||||
- name: Upload unit-test coverage data
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lcov-unit.info
|
||||
name: lcov.info
|
||||
path: out/coverage/lcov.info
|
||||
- name: Upload E2E-test coverage data
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lcov-e2e.info
|
||||
path: e2e/out/coverage/lcov.info
|
||||
|
||||
SonarScan:
|
||||
needs: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
- name: Download unit-tests coverage data
|
||||
uses: actions/download-artifact@v5
|
||||
- name: Download coverage data
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lcov-unit.info
|
||||
path: coverage/unit/
|
||||
- name: Download E2E-tests coverage data
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: lcov-e2e.info
|
||||
path: coverage/e2e/
|
||||
name: lcov.info
|
||||
path: coverage/
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
uses: SonarSource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
|
|
|||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -15,15 +15,14 @@ jobs:
|
|||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Environment details
|
||||
run: pnpm dlx envinfo --system --binaries
|
||||
run: pnpm version
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Timecodes Plugin for Obsidian
|
||||
# Obsidian Timecodes Plugin
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=gavvvr_obsidian-timecodes-plugin)
|
||||
[][installation-instructions]
|
||||
[](https://webdriver.io/)
|
||||
|
||||
|
||||
[installation-instructions]: https://help.obsidian.md/Extending+Obsidian/Community+plugins#Install+a+community+plugin
|
||||
[installation-instructions]: https://help.obsidian.md/Advanced+topics/Third-party+plugins#Discover+and+install+community+plugins
|
||||
|
||||
Turns raw timecodes (`MM:SS`/`HH:MM:SS`) into clickable links in Obsidian reading mode.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
ARG NODEJS_VERSION=22
|
||||
ARG DEBIAN_VERSION=trixie
|
||||
ARG BASE_IMAGE=node:${NODEJS_VERSION}-${DEBIAN_VERSION}-slim
|
||||
ARG TARGET_IMAGE_PLATFORM=linux/amd64
|
||||
|
||||
ARG OBSIDIAN_VERSION=1.9.14
|
||||
ARG OBSIDIAN_APPIMAGE_FILE=Obsidian-${OBSIDIAN_VERSION}.AppImage
|
||||
|
||||
FROM busybox:latest AS obsidian-downloader
|
||||
ARG OBSIDIAN_VERSION
|
||||
ARG OBSIDIAN_APPIMAGE_FILE
|
||||
RUN wget https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/${OBSIDIAN_APPIMAGE_FILE}
|
||||
|
||||
FROM ${BASE_IMAGE} AS obsidian-extractor-amd64
|
||||
ARG OBSIDIAN_APPIMAGE_FILE
|
||||
COPY --from=obsidian-downloader /${OBSIDIAN_APPIMAGE_FILE} /${OBSIDIAN_APPIMAGE_FILE}
|
||||
RUN chmod +x ${OBSIDIAN_APPIMAGE_FILE}
|
||||
RUN ./${OBSIDIAN_APPIMAGE_FILE} --appimage-extract
|
||||
|
||||
# `--appimage-extract` won't work on Apple Silicon, hence using a special extraction stage
|
||||
FROM --platform=${TARGET_IMAGE_PLATFORM} ${BASE_IMAGE} AS obsidian-extractor-arm64
|
||||
ARG OBSIDIAN_APPIMAGE_FILE
|
||||
RUN apt-get update && \
|
||||
apt-get install -y squashfs-tools && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=obsidian-downloader /${OBSIDIAN_APPIMAGE_FILE} /${OBSIDIAN_APPIMAGE_FILE}
|
||||
RUN offset=$(grep -aob 'hsqs' ${OBSIDIAN_APPIMAGE_FILE} | tail -n1 | cut -d: -f1) && \
|
||||
dd if=${OBSIDIAN_APPIMAGE_FILE} of=image.squashfs bs=4M iflag=skip_bytes,count_bytes skip=$offset status=progress && \
|
||||
unsquashfs image.squashfs
|
||||
|
||||
FROM obsidian-extractor-${TARGETARCH} AS obsidian-extractor
|
||||
|
||||
FROM --platform=${TARGET_IMAGE_PLATFORM} ${BASE_IMAGE}
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends --no-install-suggests \
|
||||
libgtk-3-0 libnss3 libgbm1 libasound2 \
|
||||
xvfb xauth && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=obsidian-extractor /squashfs-root /obsidian-appimage-extracted
|
||||
|
||||
ENV OBSIDIAN_BINARY_PATH=/obsidian-appimage-extracted/obsidian
|
||||
ENV OBSIDIAN_NO_SANDBOX=true
|
||||
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# for running locally as: docker run --rm -it -v ${PWD}:/plugin local-ci-runner
|
||||
WORKDIR /plugin
|
||||
CMD ["sh", "-c", "pnpm i && cd e2e && xvfb-run pnpm run test"]
|
||||
|
|
@ -1,4 +1,2 @@
|
|||
import * as path from 'node:path'
|
||||
|
||||
export const TIMECODES_PLUGIN_ID = 'timecodes'
|
||||
export const TEST_VAULT_DIR = path.resolve(process.cwd(), '.e2e_test_vault')
|
||||
export const TIMECODES_PLUGIN_ID = 'timecodes-plugin'
|
||||
export const TEST_VAULT_DIR = '.e2e_test_vault'
|
||||
|
|
|
|||
|
|
@ -3,27 +3,19 @@
|
|||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"pretest": "pnpm --dir .. run build:coverage",
|
||||
"pretest": "pnpm --dir .. run build",
|
||||
"test": "wdio run wdio.conf.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/istanbul-lib-coverage": "2.0.6",
|
||||
"@types/istanbul-lib-report": "3.0.3",
|
||||
"@types/istanbul-lib-source-maps": "4.0.4",
|
||||
"@types/istanbul-reports": "3.0.4",
|
||||
"@wdio/cli": "9.27.2",
|
||||
"@wdio/electron-service": "^10.0.0",
|
||||
"@wdio/globals": "9.27.2",
|
||||
"@wdio/local-runner": "9.27.2",
|
||||
"@wdio/mocha-framework": "9.27.2",
|
||||
"@wdio/spec-reporter": "9.27.2",
|
||||
"electron": "39.8.3",
|
||||
"expect-webdriverio": "5.6.7",
|
||||
"istanbul-lib-coverage": "3.2.2",
|
||||
"istanbul-lib-report": "3.0.1",
|
||||
"istanbul-lib-source-maps": "5.0.6",
|
||||
"istanbul-reports": "3.2.0",
|
||||
"obsidian": "1.12.3",
|
||||
"webdriverio": "9.27.2"
|
||||
"@wdio/cli": "9.5.7",
|
||||
"@wdio/globals": "9.5.7",
|
||||
"@wdio/local-runner": "9.5.7",
|
||||
"@wdio/mocha-framework": "9.5.0",
|
||||
"@wdio/spec-reporter": "9.5.0",
|
||||
"electron": "32.2.5",
|
||||
"expect-webdriverio": "5.0.5",
|
||||
"obsidian": "1.7.2",
|
||||
"wdio-electron-service": "7.3.1",
|
||||
"webdriverio": "9.5.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ describe('Markdown view post-processor', () => {
|
|||
const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]')
|
||||
await expect(link).toBePresent()
|
||||
const href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ describe('Markdown view post-processor', () => {
|
|||
const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]')
|
||||
await expect(link).toBePresent()
|
||||
const href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ describe('Markdown view post-processor', () => {
|
|||
const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]')
|
||||
await expect(link).toBePresent()
|
||||
const href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ describe('Markdown view post-processor', () => {
|
|||
const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]')
|
||||
await expect(link).toBePresent()
|
||||
const href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|||
const link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]')
|
||||
await expect(link).toBePresent()
|
||||
const href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -102,17 +102,17 @@ https://www.youtube.com/watch?v=k_ItB5btREU
|
|||
let link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="14:27"]')
|
||||
await expect(link).toBePresent()
|
||||
let href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/Z5n9VK3sOnI?t=867')
|
||||
await expect(href).toBe('https://youtu.be/Z5n9VK3sOnI?t=867')
|
||||
|
||||
link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="4:07"]')
|
||||
await expect(link).toBePresent()
|
||||
href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/Lipf5piizZc?t=247')
|
||||
await expect(href).toBe('https://youtu.be/Lipf5piizZc?t=247')
|
||||
|
||||
link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]')
|
||||
await expect(link).toBePresent()
|
||||
href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -140,17 +140,17 @@ referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
|||
let link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="14:27"]')
|
||||
await expect(link).toBePresent()
|
||||
let href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/Z5n9VK3sOnI?t=867')
|
||||
await expect(href).toBe('https://youtu.be/Z5n9VK3sOnI?t=867')
|
||||
|
||||
link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="4:07"]')
|
||||
await expect(link).toBePresent()
|
||||
href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/Lipf5piizZc?t=247')
|
||||
await expect(href).toBe('https://youtu.be/Lipf5piizZc?t=247')
|
||||
|
||||
link = $('//div[contains(@class, "markdown-preview-section")]//a[text()="22:53"]')
|
||||
await expect(link).toBePresent()
|
||||
href = await link.getAttribute('href')
|
||||
expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
await expect(href).toBe('https://youtu.be/k_ItB5btREU?t=1373')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -173,10 +173,11 @@ https://www.youtube.com/watch?v=k_ItB5btREU
|
|||
// eslint-disable-next-line wdio/no-pause
|
||||
await browser.pause(100)
|
||||
|
||||
const textLinesFromAllNoteParagraphs = $$('.markdown-preview-section p:not(.mod-ui)')
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-base-to-string
|
||||
const textFromAllNoteParagraphs = await $$('.markdown-preview-section p:not(.mod-ui)')
|
||||
.map(p => p.getText())
|
||||
const textFromAllNoteParagraphs = (await textLinesFromAllNoteParagraphs).join('\n\n')
|
||||
expect(textFromAllNoteParagraphs).toBe(noteContent)
|
||||
.join('\n\n')
|
||||
await expect(textFromAllNoteParagraphs).toBe(noteContent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ObsidianApp {
|
|||
await fs.copyFile('../out/main.js', `${targetPluginsDir}/main.js`)
|
||||
|
||||
await this.switchToMainWindow()
|
||||
await $('button=Trust author and enable plugins').click()
|
||||
await this.closeModal('Trust vault modal')
|
||||
}
|
||||
|
||||
|
|
@ -35,11 +36,11 @@ class ObsidianApp {
|
|||
}
|
||||
|
||||
private async activatePlugin(pluginId: string) {
|
||||
await browser.execute((timecodesPluginId: string) => {
|
||||
await browser.execute((imgurPluginId: string) => {
|
||||
// @ts-expect-error 'app' exists in Obsidian
|
||||
declare const app: App
|
||||
app.plugins.setEnable(true)
|
||||
app.plugins.enablePlugin(timecodesPluginId)
|
||||
app.plugins.enablePlugin(imgurPluginId)
|
||||
}, pluginId)
|
||||
}
|
||||
|
||||
|
|
|
|||
6
e2e/types/require-electron.d.ts
vendored
6
e2e/types/require-electron.d.ts
vendored
|
|
@ -1,6 +0,0 @@
|
|||
namespace NodeJS {
|
||||
interface Require {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-function-type
|
||||
(module: 'electron'): typeof import('electron')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import type { CoverageMapData } from 'istanbul-lib-coverage'
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { createCoverageMap } from 'istanbul-lib-coverage'
|
||||
import { createContext } from 'istanbul-lib-report'
|
||||
import { createSourceMapStore } from 'istanbul-lib-source-maps'
|
||||
import * as reports from 'istanbul-reports'
|
||||
|
||||
export async function exportCoverageToLcov() {
|
||||
const coverageMap = createCoverageMap(await collectCoverageData())
|
||||
const remappedCoverageMap = await createSourceMapStore().transformCoverage(coverageMap)
|
||||
|
||||
const context = createContext({
|
||||
coverageMap: remappedCoverageMap,
|
||||
dir: 'out/coverage',
|
||||
sourceFinder: (filePath: string) => {
|
||||
return fs.readFileSync(restorePathRelativeToMainPluginProject(filePath), 'utf-8')
|
||||
},
|
||||
})
|
||||
reports.create('lcov').execute(context)
|
||||
}
|
||||
|
||||
const cwd = process.cwd()
|
||||
|
||||
function restorePathRelativeToMainPluginProject(filePath: string) {
|
||||
const relativePart = path.relative(cwd, filePath)
|
||||
return path.join(path.resolve(cwd, '..'), relativePart)
|
||||
}
|
||||
|
||||
async function collectCoverageData() {
|
||||
const coverageData = await browser.execute(() => {
|
||||
return window.__coverage__
|
||||
})
|
||||
if (!coverageData) throw new Error('No coverage data found!')
|
||||
return coverageData
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__coverage__?: CoverageMapData
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
/// <reference types="@wdio/electron-service" />
|
||||
/// <reference types="wdio-electron-service" />
|
||||
import ObsidianApp from './specs/pageobjects/obsidian-app.page'
|
||||
import { exportCoverageToLcov } from './utils/coverage'
|
||||
|
||||
const debug = process.env.DEBUG
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const obsidianBinaryPath = process.env.OBSIDIAN_BINARY_PATH
|
||||
const obsidianNoSandbox = process.env.OBSIDIAN_NO_SANDBOX === 'true'
|
||||
|
||||
export const config: WebdriverIO.Config = {
|
||||
runner: 'local',
|
||||
specs: ['./specs/*.e2e.ts'],
|
||||
|
|
@ -16,10 +12,11 @@ export const config: WebdriverIO.Config = {
|
|||
capabilities: [
|
||||
{
|
||||
browserName: 'electron',
|
||||
browserVersion: '32.2.5',
|
||||
'wdio:electronServiceOptions': {
|
||||
// custom application args
|
||||
appBinaryPath: obsidianBinaryPath ?? '/Applications/Obsidian.app/Contents/MacOS/Obsidian',
|
||||
appArgs: [obsidianNoSandbox ? '--no-sandbox' : '--sandbox'],
|
||||
appBinaryPath: '/Applications/Obsidian.app/Contents/MacOS/Obsidian',
|
||||
appArgs: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -41,7 +38,4 @@ export const config: WebdriverIO.Config = {
|
|||
await ObsidianApp.createAndOpenFreshVault()
|
||||
await ObsidianApp.activateTimecodesPlugin()
|
||||
},
|
||||
afterSuite: async () => {
|
||||
await exportCoverageToLcov()
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default tseslint.config(
|
|||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
stylistic.configs.customize({
|
||||
flat: true,
|
||||
jsx: false,
|
||||
indent: 2,
|
||||
quotes: 'single',
|
||||
|
|
@ -20,13 +21,6 @@ export default tseslint.config(
|
|||
braceStyle: '1tbs',
|
||||
quoteProps: 'as-needed',
|
||||
}),
|
||||
{
|
||||
// Apply `no-console` only to files inside `specific-directory/`
|
||||
files: ['src/**/*'],
|
||||
rules: {
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@stylistic/curly-newline': ['error', { consistent: true }],
|
||||
|
|
@ -50,17 +44,17 @@ export default tseslint.config(
|
|||
type: 'natural',
|
||||
order: 'asc',
|
||||
ignoreCase: false,
|
||||
newlinesBetween: 1,
|
||||
newlinesBetween: 'always',
|
||||
environment: 'node',
|
||||
groups: [
|
||||
'type-import',
|
||||
'value-builtin',
|
||||
'value-external',
|
||||
'type-internal',
|
||||
'value-internal',
|
||||
['type-parent', 'type-sibling', 'type-index'],
|
||||
['value-parent', 'value-sibling', 'value-index'],
|
||||
'ts-equals-import',
|
||||
'type',
|
||||
'builtin',
|
||||
'external',
|
||||
'internal-type',
|
||||
'internal',
|
||||
['parent-type', 'sibling-type', 'index-type'],
|
||||
['parent', 'sibling', 'index'],
|
||||
'object',
|
||||
'unknown',
|
||||
],
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "timecodes",
|
||||
"name": "Timecodes",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.1",
|
||||
"minAppVersion": "0.13.8",
|
||||
"description": "Converts raw text timecodes into clickable URLs if a note contains a link to a video",
|
||||
"author": "Kirill Gavrilov",
|
||||
|
|
|
|||
34
package.json
34
package.json
|
|
@ -5,7 +5,6 @@
|
|||
"scripts": {
|
||||
"dev": "node scripts/dev.js",
|
||||
"build": "tsc -noEmit -skipLibCheck && node scripts/esbuild.build.js production",
|
||||
"build:coverage": "node scripts/esbuild.build.js coverage",
|
||||
"test:eslint": "eslint --cache --cache-location out/.eslintcache",
|
||||
"test:unit": "vitest --coverage"
|
||||
},
|
||||
|
|
@ -16,27 +15,22 @@
|
|||
],
|
||||
"author": "Kirill Gavrilov",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@types/istanbul-lib-instrument": "1.7.8",
|
||||
"@types/node": "24.13.1",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@eslint/js": "9.18.0",
|
||||
"@stylistic/eslint-plugin": "2.13.0",
|
||||
"@types/node": "22.10.7",
|
||||
"@vitest/coverage-v8": "3.0.3",
|
||||
"enquirer": "2.4.1",
|
||||
"esbuild": "0.28.0",
|
||||
"eslint": "10.4.1",
|
||||
"eslint-plugin-perfectionist": "5.9.0",
|
||||
"eslint-plugin-wdio": "9.27.2",
|
||||
"globals": "17.6.0",
|
||||
"istanbul-lib-instrument": "6.0.3",
|
||||
"obsidian": "1.12.3",
|
||||
"esbuild": "0.24.2",
|
||||
"eslint": "9.18.0",
|
||||
"eslint-plugin-perfectionist": "4.7.0",
|
||||
"eslint-plugin-wdio": "9.4.4",
|
||||
"globals": "15.14.0",
|
||||
"obsidian": "1.7.2",
|
||||
"obsidian-utils": "0.10.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vitest": "4.1.8"
|
||||
"typescript": "5.7.3",
|
||||
"typescript-eslint": "8.21.0",
|
||||
"vitest": "3.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.2"
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
packages:
|
||||
- 'e2e'
|
||||
allowBuilds:
|
||||
edgedriver: false
|
||||
electron: false
|
||||
esbuild: false
|
||||
geckodriver: false
|
||||
minimumReleaseAgeExclude:
|
||||
- '@types/node@24.13.1'
|
||||
|
|
@ -5,30 +5,21 @@ import process from 'node:process'
|
|||
import esbuild from 'esbuild'
|
||||
|
||||
import { sharedEsbuildConfig } from './esbuild.config.js'
|
||||
import { instrumentWithSourceMaps } from './utils/instrument.js'
|
||||
|
||||
const config = sharedEsbuildConfig
|
||||
|
||||
const prod = process.argv[2] === 'production'
|
||||
const coverage = process.argv[2] === 'coverage'
|
||||
|
||||
const context = await esbuild.context({
|
||||
...config,
|
||||
...prod
|
||||
? { minify: true, sourcemap: false }
|
||||
: {},
|
||||
...coverage
|
||||
? { minify: false, sourcemap: 'inline' }
|
||||
: {},
|
||||
})
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild()
|
||||
process.exit(0)
|
||||
} else if (coverage) {
|
||||
await context.rebuild()
|
||||
instrumentWithSourceMaps('out/main.js', 'out/main.js')
|
||||
process.exit(0)
|
||||
} else {
|
||||
await context.watch()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
// @ts-check
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { createInstrumenter } from 'istanbul-lib-instrument'
|
||||
|
||||
/**
|
||||
* Instrument a JavaScript file with Istanbul coverage instrumentation
|
||||
* while preserving and remapping original sourcemaps
|
||||
* @param {string} inputFile
|
||||
* @param {string} outputFile
|
||||
*/
|
||||
export function instrumentWithSourceMaps(inputFile, outputFile) {
|
||||
const code = fs.readFileSync(inputFile, 'utf8')
|
||||
|
||||
// Extract the original sourcemap from the bundled file
|
||||
const sourceMapMatch
|
||||
= code.match(/\/\/# sourceMappingURL=data:application\/json;base64,(.+)/)
|
||||
if (!sourceMapMatch) throw new Error(`No sourcemap found in ${inputFile}`)
|
||||
|
||||
const sourceMapJson = Buffer.from(sourceMapMatch[1].trim(), 'base64').toString('utf8')
|
||||
const originalSourceMap = JSON.parse(sourceMapJson)
|
||||
|
||||
const instrumenter = createInstrumenter({
|
||||
esModules: false, // esbuild outputs Obsidian plugins as CommonJS
|
||||
compact: false,
|
||||
preserveComments: true,
|
||||
produceSourceMap: true,
|
||||
autoWrap: true,
|
||||
})
|
||||
|
||||
const instrumentedCode = instrumenter.instrumentSync(
|
||||
code,
|
||||
inputFile,
|
||||
originalSourceMap, // <- This is critical for sourcemaps remapping!
|
||||
)
|
||||
const sourceMap = instrumenter.lastSourceMap()
|
||||
|
||||
let output = instrumentedCode
|
||||
if (!sourceMap) throw new Error('No source map found!')
|
||||
|
||||
const base64SourceMap = Buffer.from(JSON.stringify(sourceMap)).toString('base64')
|
||||
output += `\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`
|
||||
|
||||
fs.writeFileSync(outputFile, output, 'utf8')
|
||||
|
||||
console.log(`✓ Instrumented: ${inputFile} -> ${outputFile}`)
|
||||
}
|
||||
|
|
@ -6,4 +6,4 @@ sonar.exclusions=**/*.test.ts
|
|||
sonar.tests=src
|
||||
sonar.test.inclusions=**/*.test.ts
|
||||
|
||||
sonar.javascript.lcov.reportPaths=./coverage/unit/lcov.info,./coverage/e2e/lcov.info
|
||||
sonar.javascript.lcov.reportPaths=./coverage/lcov.info
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
import { MarkdownPostProcessorContext, Plugin } from 'obsidian'
|
||||
import { Plugin } from 'obsidian'
|
||||
|
||||
import { turnRawTimecodesIntoClickableLinks } from './ui/timecodes-md-post-processor'
|
||||
|
||||
export default class TimecodesPlugin extends Plugin {
|
||||
override onload() {
|
||||
this.registerMarkdownPostProcessor(
|
||||
(root: HTMLElement, ctx: MarkdownPostProcessorContext) => {
|
||||
requestAnimationFrame(() => {
|
||||
turnRawTimecodesIntoClickableLinks(root, ctx)
|
||||
})
|
||||
},
|
||||
)
|
||||
this.registerMarkdownPostProcessor(turnRawTimecodesIntoClickableLinks)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
import { MarkdownPostProcessorContext } from 'obsidian'
|
||||
|
||||
import { Timecode } from '../types'
|
||||
import { timecodeToSeconds } from '../utils/timecode-converter'
|
||||
import { TIMECODE_REGEXP, matchesIterator as timeCodeMatcher } from '../utils/timecode-parser'
|
||||
import { createTimecodedYouTubeLink, findYouTubeVideoId } from '../utils/youtube-links'
|
||||
|
||||
let latestRequiredEnricher: TextTimecodeEnricher | null = null
|
||||
let latestDetectedVideoId: string | null = null
|
||||
let latestNoteBeingProcessed: string | null = null
|
||||
|
||||
/*
|
||||
* From my observations, MarkdownPostProcessor:
|
||||
* - gets called once for each paragraph in a note
|
||||
* - doesn't get called again on subsequent switch to view-mode unless there was a change in paragraph
|
||||
*/
|
||||
export function turnRawTimecodesIntoClickableLinks(
|
||||
root: HTMLElement,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
|
|
@ -20,11 +14,11 @@ export function turnRawTimecodesIntoClickableLinks(
|
|||
const currentNoteSourcePath = ctx.sourcePath
|
||||
|
||||
if (latestNoteBeingProcessed !== null && latestNoteBeingProcessed !== currentNoteSourcePath) {
|
||||
latestRequiredEnricher = null
|
||||
latestDetectedVideoId = null
|
||||
}
|
||||
latestNoteBeingProcessed = currentNoteSourcePath
|
||||
|
||||
let node: Node | null
|
||||
let node: Node | null = null
|
||||
const nodesWithTimecodes: Text[] = []
|
||||
const nodeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ALL)
|
||||
while ((node = nodeWalker.nextNode()) != null) {
|
||||
|
|
@ -37,9 +31,6 @@ export function turnRawTimecodesIntoClickableLinks(
|
|||
if (elementNode instanceof HTMLIFrameElement) {
|
||||
textForFindingVideoLink = elementNode.src
|
||||
}
|
||||
if (elementNode instanceof HTMLMediaElement) {
|
||||
latestRequiredEnricher = localMediaEnricherFor(elementNode)
|
||||
}
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const textNode = node as Text
|
||||
|
||||
|
|
@ -50,7 +41,7 @@ export function turnRawTimecodesIntoClickableLinks(
|
|||
const detectedVideoId = findYouTubeVideoId(textForFindingVideoLink)
|
||||
|
||||
if (detectedVideoId) {
|
||||
latestRequiredEnricher = youtubeEnricherFor(detectedVideoId)
|
||||
latestDetectedVideoId = detectedVideoId
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,79 +51,61 @@ export function turnRawTimecodesIntoClickableLinks(
|
|||
if (!nodeTextContent) continue
|
||||
const textNodeContainsTimecodes = TIMECODE_REGEXP.test(nodeTextContent)
|
||||
|
||||
if (textNodeContainsTimecodes && latestRequiredEnricher !== null) {
|
||||
if (textNodeContainsTimecodes) {
|
||||
nodesWithTimecodes.push(textNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestRequiredEnricher === null) return
|
||||
if (!latestDetectedVideoId) return
|
||||
|
||||
for (const node of nodesWithTimecodes) {
|
||||
enrichTextNodeWithClickableTimecodes(node, latestRequiredEnricher)
|
||||
enrichTextNodeWithClickableTimecodes(node, latestDetectedVideoId)
|
||||
}
|
||||
}
|
||||
|
||||
function enrichTextNodeWithClickableTimecodes(node: Text, strategy: TextTimecodeEnricher): void {
|
||||
function enrichTextNodeWithClickableTimecodes(node: Text, videoId: string): void {
|
||||
const textNode = node as ChildNode
|
||||
const textContent = textNode.textContent
|
||||
if (textContent === null) return
|
||||
|
||||
const textFragmentEnrichedWithLinks = createTextFragmentEnrichedWithLinks(textContent, strategy)
|
||||
const textFragmentEnrichedWithLinks = createTextFragmentEnrichedWithLinks(textContent, videoId)
|
||||
textNode.replaceWith(textFragmentEnrichedWithLinks)
|
||||
}
|
||||
|
||||
const createTextFragmentEnrichedWithLinks
|
||||
= (rawTextContent: string, strategy: TextTimecodeEnricher): DocumentFragment => {
|
||||
const textFragmentEnrichedWithLinks = document.createDocumentFragment()
|
||||
let lastIndex = 0
|
||||
let match: { index: number, fullMatch: string, timecode: Timecode } | null
|
||||
const matcher = timeCodeMatcher(rawTextContent)
|
||||
= (rawTextContent: string, videoId: string): DocumentFragment => {
|
||||
const textFragmentEnrichedWithLinks = document.createDocumentFragment()
|
||||
let lastIndex = 0
|
||||
let match: { index: number, fullMatch: string, timecode: Timecode } | null
|
||||
const matcher = timeCodeMatcher(rawTextContent)
|
||||
console.log('Raw text input', rawTextContent)
|
||||
|
||||
while ((match = matcher.next()) !== null) {
|
||||
const fullMatch = match.fullMatch
|
||||
while ((match = matcher.next()) !== null) {
|
||||
const fullMatch = match.fullMatch
|
||||
|
||||
const theTextBefore = rawTextContent.slice(lastIndex, match.index)
|
||||
textFragmentEnrichedWithLinks.appendChild(document.createTextNode(theTextBefore))
|
||||
const theTextBefore = rawTextContent.slice(lastIndex, match.index)
|
||||
textFragmentEnrichedWithLinks.appendChild(document.createTextNode(theTextBefore))
|
||||
|
||||
const timecodeLinkEl = strategy.composeLinkElementWithTimecode(fullMatch, match.timecode)
|
||||
textFragmentEnrichedWithLinks.appendChild(timecodeLinkEl)
|
||||
const timecodeLinkEl = composeYouTubeLinkElement(videoId, fullMatch, match.timecode)
|
||||
textFragmentEnrichedWithLinks.appendChild(timecodeLinkEl)
|
||||
|
||||
lastIndex = match.index + fullMatch.length
|
||||
}
|
||||
const theTextAfter = rawTextContent.slice(lastIndex)
|
||||
textFragmentEnrichedWithLinks.appendChild(document.createTextNode(theTextAfter))
|
||||
return textFragmentEnrichedWithLinks
|
||||
lastIndex = match.index + fullMatch.length
|
||||
}
|
||||
|
||||
interface TextTimecodeEnricher {
|
||||
composeLinkElementWithTimecode: (raw: string, t: Timecode) => HTMLAnchorElement
|
||||
const theTextAfter = rawTextContent.slice(lastIndex)
|
||||
textFragmentEnrichedWithLinks.appendChild(document.createTextNode(theTextAfter))
|
||||
return textFragmentEnrichedWithLinks
|
||||
}
|
||||
|
||||
const youtubeEnricherFor = (videoId: string): TextTimecodeEnricher => ({
|
||||
composeLinkElementWithTimecode: (rawTimecodeText, timecode) => {
|
||||
const timecodedLink = createTimecodedYouTubeLink(videoId, timecode)
|
||||
const link = document.createElement('a')
|
||||
link.rel = 'noopener nofollow'
|
||||
link.className = 'external-link'
|
||||
link.href = timecodedLink
|
||||
link.target = '_blank'
|
||||
link.textContent = rawTimecodeText
|
||||
link.ariaLabel = timecodedLink
|
||||
return link
|
||||
},
|
||||
})
|
||||
|
||||
const localMediaEnricherFor = (media: HTMLMediaElement): TextTimecodeEnricher => ({
|
||||
composeLinkElementWithTimecode: (raw, timecode) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = '#'
|
||||
link.textContent = raw
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
media.currentTime = timecodeToSeconds(timecode)
|
||||
void media.play()
|
||||
})
|
||||
return link
|
||||
},
|
||||
})
|
||||
const composeYouTubeLinkElement
|
||||
= (videoId: string, rawTimecodeText: string, timecode: Timecode): HTMLAnchorElement => {
|
||||
const timecodedLink = createTimecodedYouTubeLink(videoId, timecode)
|
||||
const link = document.createElement('a')
|
||||
link.rel = 'noopener nofollow'
|
||||
link.className = 'external-link'
|
||||
link.href = timecodedLink
|
||||
link.target = '_blank'
|
||||
link.textContent = rawTimecodeText
|
||||
link.ariaLabel = timecodedLink
|
||||
return link
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": ".",
|
||||
"target": "es6",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES6",
|
||||
"noImplicitAny": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
{
|
||||
"0.0.1": "0.13.8",
|
||||
"0.0.2": "0.13.8"
|
||||
"0.0.1": "0.13.8"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,6 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: 'virtual-obsidian',
|
||||
enforce: 'pre',
|
||||
resolveId(id: string) {
|
||||
if (id === 'obsidian') {
|
||||
return '\0obsidian'
|
||||
}
|
||||
},
|
||||
load(id: string) {
|
||||
if (id === '\0obsidian') {
|
||||
return `
|
||||
export class Plugin {}
|
||||
export default {}`
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
test: {
|
||||
isolate: false,
|
||||
pool: 'threads',
|
||||
|
|
|
|||
Loading…
Reference in a new issue