/src/**/*.ts'
22 | ]
23 | }
24 |
25 | export default config
26 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.11.0",
3 | "npmClient": "yarn",
4 | "packages": [
5 | "packages/*"
6 | ],
7 | "command": {
8 | "publish": {
9 | "allowBranch": [
10 | "master"
11 | ],
12 | "ignoreChanges": [
13 | "*.md"
14 | ],
15 | "verifyAccess": false,
16 | "conventionalCommits": true,
17 | "message": "chore(release): publish %s"
18 | }
19 | },
20 | "useWorkspaces": true
21 | }
22 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'packages/core/**/*.{js,ts}': [
3 | 'yarn core lint:fix',
4 | () => 'yarn core tsc',
5 | 'yarn core test:staged',
6 | () => 'yarn core docs',
7 | 'git add docs'
8 | ],
9 | 'packages/express/**/*.{js,ts}': [
10 | 'yarn express lint:fix',
11 | () => 'yarn express tsc',
12 | 'yarn express test:staged'
13 | ],
14 | 'packages/media/**/*.{js,ts,vue}': [
15 | 'yarn media lint:fix',
16 | () => 'yarn media tsc',
17 | 'yarn media test:staged'
18 | ],
19 | './*.{js,ts}': [
20 | 'yarn lint:root --fix'
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "library-api",
3 | "private": true,
4 | "workspaces": {
5 | "packages": [
6 | "packages/*"
7 | ],
8 | "nohoist": [
9 | "**/electron",
10 | "**/electron/**"
11 | ]
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/BenShelton/library-api"
16 | },
17 | "scripts": {
18 | "prepare": "husky install",
19 | "bootstrap": "yarn install && lerna bootstrap && yarn build",
20 | "build": "lerna run build --parallel",
21 | "clean": "lerna clean -y",
22 | "lint": "lerna run lint --parallel",
23 | "lint:root": "eslint ./*.{js,ts}",
24 | "test": "lerna run test --parallel",
25 | "test:ci": "yarn lint:root && lerna run test:ci --parallel",
26 | "tsc": "lerna run tsc --parallel",
27 | "docs": "lerna run docs --parallel",
28 | "core": "yarn workspace @library-api/core",
29 | "express": "yarn workspace @library-api/express",
30 | "media": "yarn workspace @library-api/media"
31 | },
32 | "devDependencies": {
33 | "@commitlint/cli": "^12.1.4",
34 | "@commitlint/config-conventional": "^12.1.4",
35 | "@types/jest": "^26.0.23",
36 | "@types/node": "^14.15.4",
37 | "@typescript-eslint/eslint-plugin": "^4.27.0",
38 | "@typescript-eslint/parser": "^4.27.0",
39 | "commitizen": "^4.2.4",
40 | "cz-conventional-changelog": "^3.3.0",
41 | "eslint": "^7.29.0",
42 | "eslint-config-standard": "^16.0.3",
43 | "eslint-plugin-import": "^2.23.4",
44 | "eslint-plugin-jest": "^24.3.6",
45 | "eslint-plugin-node": "^11.1.0",
46 | "eslint-plugin-promise": "^5.1.0",
47 | "gh-release": "^6.0.0",
48 | "husky": "^6.0.0",
49 | "jest": "^27.0.4",
50 | "lerna": "^4.0.0",
51 | "lint-staged": "^11.0.0",
52 | "ts-jest": "^27.0.3",
53 | "ts-node": "^10.0.0",
54 | "tsc-watch": "^4.4.0",
55 | "typedoc": "^0.21.0",
56 | "typedoc-plugin-markdown": "^3.10.0",
57 | "typescript": "^4.3.4",
58 | "vls": "^0.7.4"
59 | },
60 | "license": "MIT",
61 | "engines": {
62 | "node": "^14",
63 | "yarn": "^1.22"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/packages/core/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const baseConfig = require('../../.eslintrc.js')
2 |
3 | /**
4 | * @type { import('eslint').Linter.Config }
5 | */
6 | module.exports = {
7 | ...baseConfig
8 | }
9 |
--------------------------------------------------------------------------------
/packages/core/README.md:
--------------------------------------------------------------------------------
1 |
2 | Library Core
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Core tools for use with other Library API packages.
16 |
17 |
18 |
19 |
20 | ## Description
21 |
22 | These can also be used to build your own app based on this API.
23 |
24 | This package is brought to you by [Library API](../../README.md).
25 |
26 | ## Installing
27 |
28 | Please note that core tools are built to run on a Node environment as it requires a filesystem to store downloaded files.
29 |
30 | You can install with your preferred package manager:
31 |
32 | ```bash
33 | # NPM
34 | npm install @library-api/core
35 |
36 | # Yarn
37 | yarn add @library-api/core
38 | ```
39 |
40 | ## Documentation
41 |
42 | View the documentation [here](https://benshelton.github.io/library-api/core/)
43 |
44 | ## Development
45 |
46 | Run the following commands to get started. If you are running from the root directory you can add `media` to run these (for example `yarn core build` instead of just `yarn build`):
47 |
48 | ```bash
49 | # Build (outputs to /dist)
50 | yarn build
51 |
52 | # Lint files
53 | yarn lint
54 |
55 | # Run test suite
56 | yarn test
57 |
58 | # Run Type Checking Service
59 | yarn tsc
60 | ```
61 |
--------------------------------------------------------------------------------
/packages/core/jest.config.ts:
--------------------------------------------------------------------------------
1 | import { Config } from '@jest/types'
2 | import { pathsToModuleNameMapper } from 'ts-jest/utils'
3 |
4 | import baseConfig from '../../jest.config.base'
5 | import { compilerOptions } from './test/tsconfig.json'
6 |
7 | const config: Config.InitialOptions = {
8 | ...baseConfig,
9 |
10 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' })
11 | }
12 |
13 | export default config
14 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@library-api/core",
3 | "version": "0.11.0",
4 | "description": "Core tools for use with @library-api packages.",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "files": [
8 | "dist",
9 | "types"
10 | ],
11 | "publishConfig": {
12 | "access": "public"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "https://github.com/BenShelton/library-api",
17 | "directory": "packages/core"
18 | },
19 | "scripts": {
20 | "build": "tsc --build tsconfig.json",
21 | "lint": "eslint . --ext .js,.ts",
22 | "lint:fix": "yarn lint --fix",
23 | "test": "jest --config=jest.config.ts",
24 | "test:bail": "yarn test --bail",
25 | "test:coverage": "yarn test --coverage",
26 | "test:ci": "yarn lint && yarn tsc && yarn test:bail",
27 | "test:watch": "yarn test --watch-all -t",
28 | "test:staged": "yarn test --bail --findRelatedTests",
29 | "tsc": "tsc --noEmit",
30 | "docs": "typedoc"
31 | },
32 | "dependencies": {
33 | "node-fetch": "^2.6.1",
34 | "sqlite": "^4.0.23",
35 | "sqlite3": "^5.0.2",
36 | "unzipper": "^0.10.11"
37 | },
38 | "devDependencies": {
39 | "@types/node-fetch": "^2.5.10",
40 | "@types/sqlite3": "^3.1.7",
41 | "@types/unzipper": "^0.10.3"
42 | },
43 | "author": {
44 | "name": "BenShelton",
45 | "email": "bensheltonjones@gmail.com"
46 | },
47 | "keywords": [
48 | "jw",
49 | "media",
50 | "library",
51 | "meetings",
52 | "publications",
53 | "api",
54 | "core"
55 | ],
56 | "license": "MIT"
57 | }
58 |
--------------------------------------------------------------------------------
/packages/core/src/catalog.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path'
2 |
3 | import { downloadCatalog, downloadMediaCatalog } from './download'
4 | import { getLanguageById } from './language'
5 | import { checkExists } from './utils'
6 | import { MediaCatalog } from './classes/Media'
7 |
8 | /**
9 | * @todo Check for latest version, currently just checks existence of file.
10 | *
11 | * Checks whether the currently downloaded catalog is the latest version & updates it if not.
12 | *
13 | * @param path The path to download the catalog to.
14 | *
15 | * @returns `true` if the catalog was updated, `false` if it was already the latest version.
16 | */
17 | export async function updateCatalog (path: string): Promise {
18 | const exists = await checkExists(path)
19 | if (!exists) {
20 | await downloadCatalog(path)
21 | return true
22 | }
23 | return false
24 | }
25 |
26 | /**
27 | * @todo Check for latest version, currently just checks existence of file.
28 | *
29 | * Retrieves a media catalog file and returns a {@link MediaCatalog} instance if it exists.
30 | * Will download the file if it is not yet downloaded.
31 | *
32 | * @param dir The directory where media catalogs are to be stored.
33 | * @param languageId The Meps Language Id to use.
34 | *
35 | * @returns A {@link MediaCatalog} if it exists, `null` if not found.
36 | */
37 | export async function getMediaCatalog (dir: string, languageId: number): Promise {
38 | const language = getLanguageById(languageId)
39 | if (!language) return null
40 | const code = language.symbol
41 | const path = join(dir, `media-catalog-${code}.ndjson`)
42 | const exists = await checkExists(path)
43 | if (!exists) {
44 | try {
45 | await downloadMediaCatalog(code, path)
46 | } catch (err) {
47 | return null
48 | }
49 | }
50 | return new MediaCatalog({ path, languageId })
51 | }
52 |
--------------------------------------------------------------------------------
/packages/core/src/classes/Media.ts:
--------------------------------------------------------------------------------
1 | import { MediaCatalogMapper } from './Mapper'
2 | import { readLines } from '../utils'
3 | import { SONG_PUBLICATION } from '../constants'
4 |
5 | import { MediaDetailsDTO, MediaCatalogDatabaseDTO } from '../../types/dto'
6 | import { MediaCatalogCtor, MediaCatalogRow } from '../../types/media'
7 |
8 | export class MediaCatalog {
9 | private _database: MediaCatalogDatabaseDTO | null = null
10 | private _mapper: MediaCatalogMapper
11 | path: string
12 | languageId: number
13 |
14 | /**
15 | * @param {Object} ctor See {@link MediaCatalogCtor}
16 | */
17 | constructor ({ path, languageId }: MediaCatalogCtor) {
18 | this.path = path
19 | this.languageId = languageId
20 | this._mapper = new MediaCatalogMapper()
21 | }
22 |
23 | /**
24 | * Loads the media catalog from the NDJSON file and transforms it into an object that is easier to work with
25 | *
26 | * Will cache the loaded database so subsequent calls will be quicker.
27 | */
28 | async getDatabase (): Promise {
29 | if (this._database) return this._database
30 | const newDatabase: MediaCatalogDatabaseDTO = {
31 | version: 0,
32 | categories: [],
33 | mediaItems: []
34 | }
35 | await readLines(this.path, line => {
36 | const json: MediaCatalogRow = JSON.parse(line)
37 | switch (json.type) {
38 | case 'catalogSchemaVersion':
39 | newDatabase.version = json.o
40 | break
41 | case 'category':
42 | newDatabase.categories.push(json.o)
43 | break
44 | case 'media-item': {
45 | const details = json.o
46 | const { lsr, sqr } = details.images
47 | newDatabase.mediaItems.push({
48 | languageAgnosticId: details.languageAgnosticNaturalKey,
49 | id: details.naturalKey,
50 | doc: 'docID' in details.keyParts ? details.keyParts.docID : details.keyParts.pubSymbol,
51 | issue: 'issueDate' in details.keyParts ? details.keyParts.issueDate : undefined,
52 | track: details.keyParts.track,
53 | format: details.keyParts.formatCode,
54 | key: details.primaryCategory,
55 | title: details.title,
56 | duration: details.duration,
57 | image: lsr ? (lsr.xl || lsr.lg || lsr.md || lsr.sm || lsr.xs) : '',
58 | imageSqr: sqr ? (sqr.xl || sqr.lg || sqr.md || sqr.sm || sqr.xs) : ''
59 | })
60 | }
61 | }
62 | })
63 | this._database = newDatabase
64 | return newDatabase
65 | }
66 |
67 | /**
68 | * Retrieves information about a video from the media catalog.
69 | * The video details found within a publication's database contain limited information about the video itself.
70 | * Most of this information is contained within a separate file which is divided per language.
71 | *
72 | * This method allows passing in the video returned from the publication to get more details from the media catalog.
73 | * The returned image will be of the highest quality available (biggest size).
74 | *
75 | * @param video Pass in a returned {@link VideoDTO} from another method, see example.
76 | *
77 | * @returns MediaDetails if they exist, `null` if they are not found.
78 | *
79 | * @example
80 | * ```ts
81 | * const video = publication.getVideo(...)
82 | * const details = await mediaCatalog.getMediaDetails(video)
83 | * ```
84 | */
85 | async getMediaDetails ({ doc, issue, track }: { doc: string | number, issue: string | number, track: string | number }): Promise {
86 | const db = await this.getDatabase()
87 | const issueNum = Number(issue || 0)
88 | const detailIssue = issueNum ? String(issueNum).substr(0, 6) : null
89 | const details = db.mediaItems.find(item => {
90 | if (detailIssue) {
91 | if (detailIssue !== item.issue) return false
92 | }
93 | if (typeof item.doc === 'number') {
94 | if (item.doc !== +doc) return false
95 | } else {
96 | if (item.doc !== String(doc)) return false
97 | }
98 | if (item.track !== +track) return false
99 |
100 | return true
101 | })
102 | if (!details) return null
103 | return this._mapper.MapMediaDetails(details)
104 | }
105 |
106 | /**
107 | * Retrieves the video MediaDetails of a chosen song number.
108 | *
109 | * @param track The number of the track.
110 | *
111 | * @returns MediaDetails if they exist, `null` if they are not found.
112 | */
113 | async getSongDetails (track: number): Promise {
114 | return this.getMediaDetails({ ...SONG_PUBLICATION, track })
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/packages/core/src/classes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Database'
2 | export * from './Mapper'
3 | export * from './Publication'
4 |
--------------------------------------------------------------------------------
/packages/core/src/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The URL used as a base for downloading publications.
3 | *
4 | * This is the site hosting most of the files.
5 | */
6 | export const PUBLICATION_URL = 'https://download-a.akamaihd.net'
7 |
8 | /**
9 | * The URL of the current catalog.
10 | */
11 | export const CATALOG_URL = `${PUBLICATION_URL}/meps/jwl/current/catalogs/v3/catalog.db.gz` as const
12 |
13 | /**
14 | * The URL used as for checking media options.
15 | *
16 | * This returns a list of options of download qualities based on the passed in params.
17 | */
18 | export const MEDIA_URL = 'https://api.hag27.com/GETPUBMEDIALINKS'
19 |
20 | /**
21 | * The URL of all the media catalogs.
22 | *
23 | * These are NDJSON files that list images and other metadata used for media found within publications.
24 | */
25 | export const MEDIA_CATALOGS_URL = 'https://app.jw-cdn.org/catalogs/media'
26 |
27 | /**
28 | * Integer enums used to refer to certain publication types in the catalog database.
29 | */
30 | export enum PUBLICATION_TYPES {
31 | WATCHTOWER = 14,
32 | OCLM = 30
33 | }
34 |
35 | /**
36 | * Integer enums used to refer to certain article types in a publication database.
37 | */
38 | export enum PUBLICATION_CLASSES {
39 | WATCHTOWER_ARTICLE = 40,
40 | OCLM_WEEK = 106
41 | }
42 |
43 | /**
44 | * Params for the current songbook publication, without the track.
45 | *
46 | * Used internally to provide methods which only require a track in order to retrieve a song.
47 | */
48 | export const SONG_PUBLICATION = {
49 | type: 'pub',
50 | doc: 'sjjm',
51 | issue: 0
52 | } as const
53 |
--------------------------------------------------------------------------------
/packages/core/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './classes'
2 |
3 | export * from './catalog'
4 | export * from './constants'
5 | export * from './download'
6 | export * from './language'
7 | export * from './utils'
8 |
--------------------------------------------------------------------------------
/packages/core/src/language.ts:
--------------------------------------------------------------------------------
1 | import { LanguageMapper } from './classes'
2 | import languages from './data/languages.json'
3 |
4 | import { LanguageDTO } from '../types/dto'
5 |
6 | /**
7 | * Retrieves a list of all languages currently supported.
8 | *
9 | * @returns An array of languages.
10 | */
11 | export function getLanguages (): LanguageDTO[] {
12 | return new LanguageMapper().MapLanguages(languages)
13 | }
14 |
15 | /**
16 | * Searches for the specified language based on the provided id.
17 | *
18 | * @param id The Meps Language Id to search for.
19 | *
20 | * @returns The language if it was found, `null` if it does not exist.
21 | */
22 | export function getLanguageById (id: number): LanguageDTO | null {
23 | const language = languages.find(l => l.LanguageId === id)
24 | if (!language) return null
25 | return new LanguageMapper().MapLanguage(language)
26 | }
27 |
--------------------------------------------------------------------------------
/packages/core/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { constants, createReadStream } from 'fs'
2 | import { mkdir, rm, access } from 'fs/promises'
3 | import { createInterface } from 'readline'
4 |
5 | /**
6 | * Creates the specified directory. Will create parent directories if missing.
7 | *
8 | * @param dir The directory to create.
9 | */
10 | export function createDir (dir: string): Promise {
11 | return mkdir(dir, { recursive: true })
12 | }
13 |
14 | /**
15 | * Removes the entire specified directory, similar to `rm -rf {dir}`.
16 | *
17 | * @param dir The directory to remove.
18 | */
19 | export function emptyDir (dir: string): Promise {
20 | return rm(dir, { recursive: true, force: true })
21 | }
22 |
23 | /**
24 | * Checks if a file exists.
25 | *
26 | * @param path The path to the file.
27 | * @returns `true` if the file exists, `false` if it does not.
28 | */
29 | export async function checkExists (path: string): Promise {
30 | try {
31 | await access(path, constants.F_OK)
32 | return true
33 | } catch (err) {
34 | return false
35 | }
36 | }
37 |
38 | /**
39 | * Reads a file line by line and allows running a callback for each line.
40 | *
41 | * @param path The path to the file.
42 | * @param cb The callback to apply for each line.
43 | */
44 | export async function readLines (path: string, cb: (line: string) => void): Promise {
45 | const stream = createReadStream(path)
46 | const rl = createInterface({
47 | input: stream,
48 | crlfDelay: Infinity
49 | })
50 | for await (const line of rl) {
51 | if (line) cb(line)
52 | }
53 | }
54 |
55 | /**
56 | * Validates that the passed in `date` is a string of `yyyy-mm-dd` format.
57 | *
58 | * @param date The date to validate.
59 | * @returns `true` if the date is valid, `false` if not.
60 | */
61 | export function isValidDate (date: unknown): date is string {
62 | if (!date) return false
63 | if (typeof date !== 'string') return false
64 | if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return false
65 | return true
66 | }
67 |
--------------------------------------------------------------------------------
/packages/core/test/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: '../.eslintrc.js',
3 | plugins: [
4 | 'jest'
5 | ],
6 | env: {
7 | 'jest/globals': true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/core/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "baseUrl": "../",
6 | "rootDir": "../",
7 | "paths": {
8 | "@/*": [
9 | "src/*"
10 | ]
11 | },
12 | "types": [
13 | "node",
14 | "jest"
15 | ]
16 | },
17 | "include": [
18 | ".",
19 | "../src"
20 | ],
21 | "exclude": []
22 | }
23 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "rootDir": "src",
6 | "outDir": "dist",
7 | "importHelpers": false,
8 | "tsBuildInfoFile": "dist/.tsbuildinfo",
9 | "resolveJsonModule": true
10 | },
11 | "include": [
12 | "src/**/*.ts",
13 | "src/**/*.json"
14 | ],
15 | "exclude": [
16 | "**/*.spec.ts"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/packages/core/typedoc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type { import("typedoc").TypeDocOptions }
3 | */
4 | module.exports = {
5 | name: 'Library Core',
6 | entryPoints: [
7 | 'src/index.ts',
8 | 'types'
9 | ],
10 | out: '../../docs/core',
11 | plugin: 'typedoc-plugin-markdown',
12 | excludePrivate: true,
13 | excludeInternal: true,
14 | readme: 'none',
15 | gitRevision: 'master'
16 | }
17 |
--------------------------------------------------------------------------------
/packages/core/types/database.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The raw database columns when using a publication query.
3 | */
4 | export interface PublicationRow {
5 | /**
6 | * The filename used on the storage servers and for the internal database of a publication.
7 | */
8 | NameFragment: string
9 | PublicationTypeId: number
10 | PubMepsLanguageId: number
11 | }
12 |
13 | /**
14 | * The raw database columns when using an image query.
15 | */
16 | export interface ImageRow {
17 | MultimediaId: number
18 | ContextTitle: string
19 | Caption: string
20 | /**
21 | * The path within the contents directory of a publication to access this image.
22 | */
23 | FilePath: string
24 | CategoryType: number
25 | }
26 |
27 | /**
28 | * The raw database columns when using a media details query.
29 | */
30 | export interface MediaDetailsRow {
31 | Id: number
32 | Title: string
33 | NameFragment: string
34 | Width: number
35 | Height: number
36 | }
37 |
38 | interface VideoRowBase {
39 | MultimediaId: number
40 | Track: number
41 | /**
42 | * Will be `0` if no issue exists.
43 | */
44 | IssueTagNumber: number
45 | }
46 |
47 | /**
48 | * The raw database columns when using a video query that returns a `pub` type video.
49 | */
50 | export interface VideoRowPub extends VideoRowBase {
51 | KeySymbol: string
52 | MepsDocumentId: null
53 | }
54 |
55 | /**
56 | * The raw database columns when using a video query that returns a `doc` type video.
57 | */
58 | export interface VideoRowDoc extends VideoRowBase {
59 | KeySymbol: null
60 | MepsDocumentId: number
61 | }
62 |
63 | /**
64 | * A union of the raw database columns when using a video query that returns any type video.
65 | */
66 | export type VideoRow = VideoRowPub | VideoRowDoc
67 |
68 | /**
69 | * Either an image or video row based on `DataType`.
70 | */
71 | export type DocumentMediaRow =
72 | | (ImageRow & { DataType: 0 })
73 | | (VideoRow & { DataType: 2 | 3 })
74 |
75 | /**
76 | * The raw database columns when using an article query.
77 | */
78 | export interface ArticleRow {
79 | DocumentId: number
80 | ContextTitle: string
81 | Title: string
82 | }
83 |
84 | /**
85 | * The raw database columns when reading the JSON export of the `Language` table from `mepsunit.db`.
86 | *
87 | * The export can be found in `data/languages.json`.
88 | */
89 | export interface LanguageRow {
90 | LanguageId: number
91 | Symbol: string
92 | EnglishName: string
93 | VernacularName: string
94 | IsoName: string
95 | IsoAlpha2Code: string
96 | IsoAlpha3Code: string
97 | PrimaryIetfCode: string
98 | PrimaryFallbackLanguageId: number
99 | IsSignLanguage: number
100 | ScriptId: number
101 | AssociatedTextLanguageId: number
102 | }
103 |
104 | /**
105 | * The raw database columns when using a relation publications query.
106 | */
107 | interface RelatedPublicationRow {
108 | RefMepsDocumentId: number
109 | RefBeginParagraphOrdinal: number | null
110 | RefEndParagraphOrdinal: number | null
111 | }
112 |
--------------------------------------------------------------------------------
/packages/core/types/hag.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * One of the returned links when requesting a video from the external Media API endpoint.
3 | *
4 | * These types may not be extensive but include what we need.
5 | */
6 | interface MediaPubLink {
7 | title: string
8 | file: {
9 | url: string
10 | stream: string
11 | modifiedDatetime: string
12 | checksum: string
13 | }
14 | filesize: number
15 | trackImage: {
16 | url: string
17 | modifiedDatetime: string
18 | checksum: null
19 | }
20 | markers: null
21 | label: '240p' | '480p' | '720p'
22 | track: number
23 | hasTrack: boolean
24 | pub: string
25 | docid: number
26 | booknum: number
27 | mimetype: string
28 | edition: string
29 | editionDescr: string
30 | format: string
31 | formatDescr: string
32 | specialty: string
33 | specialtyDescr: string
34 | subtitled: boolean
35 | frameWidth: number
36 | frameHeight: number
37 | frameRate: number
38 | duration: number
39 | bitRate: number
40 | }
41 |
42 | /**
43 | * The returned data when requesting a video from the external Media API endpoint.
44 | */
45 | export interface GetMediaPubLinks {
46 | pubName: string
47 | parentPubName: string
48 | booknum: null
49 | pub: string
50 | issue: string
51 | formattedDate: string[]
52 | track: number
53 | specialty: string
54 | pubImage: {
55 | url: string
56 | modifiedDatetime: string
57 | checksum: null
58 | }
59 | languages: {
60 | [key: string]: {
61 | name: string
62 | direction: string
63 | locale: string
64 | }
65 | }
66 | files: {
67 | [key: string]: {
68 | MP4: MediaPubLink[]
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/packages/core/types/publication.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The types of publications that can be accessed.
3 | * - `wt`: Watchtower
4 | * - `oclm`: Our Christian Life & Ministry Workbook
5 | * - `other`: Any other publication
6 | */
7 | export type PublicationType = 'wt' | 'oclm' | 'other'
8 |
9 | /**
10 | * Constructor params for {@link Publication} class.
11 | */
12 | export interface PublicationCtor {
13 | /**
14 | * The filename of the downloaded publication.
15 | * This must be the NameFragment as it is used the internal database too.
16 | *
17 | * @example 'w_E_202012'
18 | */
19 | filename: string
20 | /**
21 | * The directory where the publication is located.
22 | *
23 | * @example 'downloads/publications'
24 | */
25 | dir: string
26 | /**
27 | * @see PublicationType
28 | */
29 | type: PublicationType
30 | /**
31 | * The Meps Language Id of this publication.
32 | *
33 | * @default 0 (English)
34 | */
35 | languageId?: number
36 | }
37 |
--------------------------------------------------------------------------------
/packages/express/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const baseConfig = require('../../.eslintrc.js')
2 |
3 | /**
4 | * @type { import('eslint').Linter.Config }
5 | */
6 | module.exports = {
7 | ...baseConfig
8 | }
9 |
--------------------------------------------------------------------------------
/packages/express/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | # [0.11.0](https://github.com/BenShelton/library-api/compare/v0.10.1...v0.11.0) (2021-07-26)
7 |
8 |
9 | ### Features
10 |
11 | * **express:** add endpoint for related publications ([fae1815](https://github.com/BenShelton/library-api/commit/fae18159a50f97f12dc12766820fe2e25ae86867))
12 | * **express:** add endpoint for retrieving related media ([a790166](https://github.com/BenShelton/library-api/commit/a7901667d312eb0defd9f34b0bf0af1d8b8a3565))
13 |
14 |
15 |
16 |
17 |
18 | # [0.10.0](https://github.com/BenShelton/library-api/compare/v0.9.1...v0.10.0) (2021-06-23)
19 |
20 |
21 | ### Features
22 |
23 | * **express:** use new media catalog when getting media details ([2d21c24](https://github.com/BenShelton/library-api/commit/2d21c240c90085191cbfc9281213894b34009e2c)), closes [#55](https://github.com/BenShelton/library-api/issues/55)
24 |
25 |
26 |
27 |
28 |
29 | # [0.9.0](https://github.com/BenShelton/library-api/compare/v0.8.0...v0.9.0) (2021-06-10)
30 |
31 |
32 | ### Features
33 |
34 | * **express:** add optional language support on existing endpoints ([7426ec0](https://github.com/BenShelton/library-api/commit/7426ec05df594400b438d76cce2f139c9c13185d)), closes [#12](https://github.com/BenShelton/library-api/issues/12)
35 |
36 |
37 |
38 |
39 |
40 | # [0.8.0](https://github.com/BenShelton/library-api/compare/v0.7.0...v0.8.0) (2021-06-08)
41 |
42 | **Note:** Version bump only for package @library-api/express
43 |
44 |
45 |
46 |
47 |
48 | # [0.7.0](https://github.com/BenShelton/library-api/compare/v0.6.2...v0.7.0) (2021-06-01)
49 |
50 | **Note:** Version bump only for package @library-api/express
51 |
52 |
53 |
54 |
55 |
56 | # [0.6.0](https://github.com/BenShelton/library-api/compare/v0.5.0...v0.6.0) (2021-05-30)
57 |
58 | **Note:** Version bump only for package @library-api/express
59 |
60 |
61 |
62 |
63 |
64 | # [0.5.0](https://github.com/BenShelton/library-api/compare/v0.4.0...v0.5.0) (2021-05-21)
65 |
66 | **Note:** Version bump only for package @library-api/express
67 |
68 |
69 |
70 |
71 |
72 | # [0.4.0](https://github.com/BenShelton/library-api/compare/v0.3.1...v0.4.0) (2021-05-15)
73 |
74 | **Note:** Version bump only for package @library-api/express
75 |
76 |
77 |
78 |
79 |
80 | ## [0.3.1](https://github.com/BenShelton/library-api/compare/v0.3.0...v0.3.1) (2021-05-12)
81 |
82 | **Note:** Version bump only for package @library-api/express
83 |
84 |
85 |
86 |
87 |
88 | # 0.3.0 (2021-05-11)
89 |
90 |
91 | ### Bug Fixes
92 |
93 | * **express/download:** correct error message for /video ([b634708](https://github.com/BenShelton/library-api/commit/b63470844fdd5e340ec5ab427df1339b9b00780b))
94 | * **packages/express:** use relative imports as we lack path mapping ([5c45e08](https://github.com/BenShelton/library-api/commit/5c45e0894830cc3f42fd3c2d4170e81d46b9a0f8))
95 |
96 |
97 | ### Features
98 |
99 | * **core:** add id to images/videos, rename video id to doc ([9ec9e8b](https://github.com/BenShelton/library-api/commit/9ec9e8ba6608a4234aab6183b81e87a7c0b0950d))
100 | * **core:** add id to mediaDetails, allow passing a full video into getMediaDetails for convenience ([2cb6afb](https://github.com/BenShelton/library-api/commit/2cb6afb4e34b46127ccd53a74d588e27258b5495))
101 | * **core:** rename downloadVideoStream to getVideoStream, add downloadVideoStream which writes file ([cddd05d](https://github.com/BenShelton/library-api/commit/cddd05df59e6595cc446bdf590ae1643ae09ee99))
102 | * **express:** add /media/details endpoint ([d20ec3f](https://github.com/BenShelton/library-api/commit/d20ec3fb9ffd5affc894f618014a8a23eb3b973e))
103 | * add oclm publication class ([eab8b92](https://github.com/BenShelton/library-api/commit/eab8b926d2d0457890ffeaad5821a56dc27dc1cc))
104 | * add parsing for different types of video, add 'doc' video type ([f53907d](https://github.com/BenShelton/library-api/commit/f53907d01eb7b234bf048696f2f9135e94580306))
105 | * extract shared functionality into core package ([169ea29](https://github.com/BenShelton/library-api/commit/169ea29eacf0048d2de3e0b8101372531fdc24fe))
106 |
--------------------------------------------------------------------------------
/packages/express/README.md:
--------------------------------------------------------------------------------
1 |
2 | Library Express
3 |
4 |
5 |
6 | An Express API for easily accessing information related to meetings and publications of Jehovah's Witnesses.
7 |
8 |
9 |
10 |
11 | ## Description
12 |
13 | This package is brought to you by [Library API](../../README.md).
14 |
15 | ## Documentation
16 |
17 | View the documentation [here](https://benshelton.github.io/library-api/express/)
18 |
19 | ## Development
20 |
21 | Run the following commands to get started. If you are running from the root directory you can add `express` to run these (for example `yarn express dev` instead of just `yarn dev`):
22 |
23 | ```bash
24 | # Start server with hot reload for development
25 | yarn dev
26 |
27 | # Build (outputs to /dist)
28 | yarn build
29 |
30 | # Lint files
31 | yarn lint
32 |
33 | # Run test suite
34 | yarn test
35 |
36 | # Run Type Checking Service
37 | yarn tsc
38 | ```
39 |
--------------------------------------------------------------------------------
/packages/express/jest.config.ts:
--------------------------------------------------------------------------------
1 | import { Config } from '@jest/types'
2 | import { pathsToModuleNameMapper } from 'ts-jest/utils'
3 |
4 | import baseConfig from '../../jest.config.base'
5 | import { compilerOptions } from './test/tsconfig.json'
6 |
7 | const config: Config.InitialOptions = {
8 | ...baseConfig,
9 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' })
10 | }
11 |
12 | export default config
13 |
--------------------------------------------------------------------------------
/packages/express/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@library-api/express",
3 | "private": true,
4 | "version": "0.11.0",
5 | "description": "An Express API for easily accessing information related to meetings and publications of Jehovah's Witnesses.",
6 | "main": "dist/server.js",
7 | "types": "dist/server.d.ts",
8 | "files": [
9 | "dist",
10 | "types"
11 | ],
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/BenShelton/library-api",
15 | "directory": "packages/express"
16 | },
17 | "scripts": {
18 | "build": "tsc --build tsconfig.json",
19 | "dev": "tsc-watch --build --incremental --onSuccess \"yarn start\"",
20 | "start": "node dist/server.js",
21 | "lint": "eslint . --ext .js,.ts",
22 | "lint:fix": "yarn lint --fix",
23 | "test": "jest --config=jest.config.ts",
24 | "test:bail": "yarn test --bail",
25 | "test:coverage": "yarn test --coverage",
26 | "test:ci": "yarn lint && yarn tsc && yarn test:bail",
27 | "test:watch": "yarn test --watch-all -t",
28 | "test:staged": "yarn test --bail --findRelatedTests",
29 | "tsc": "tsc --noEmit"
30 | },
31 | "dependencies": {
32 | "@library-api/core": "^0.11.0",
33 | "express": "^4.17.1"
34 | },
35 | "devDependencies": {
36 | "@types/express": "^4.17.12"
37 | },
38 | "author": {
39 | "name": "BenShelton",
40 | "email": "bensheltonjones@gmail.com"
41 | },
42 | "keywords": [
43 | "jw",
44 | "media",
45 | "library",
46 | "meetings",
47 | "publications",
48 | "api",
49 | "express"
50 | ],
51 | "license": "MIT"
52 | }
53 |
--------------------------------------------------------------------------------
/packages/express/src/constants.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 |
3 | export const ROOT_DIR = path.join(__dirname, '..')
4 | export const DOWNLOAD_DIR = path.join(ROOT_DIR, 'downloads')
5 | export const CATALOG_FILE = 'catalog.db'
6 | export const CATALOG_PATH = path.join(DOWNLOAD_DIR, CATALOG_FILE)
7 |
--------------------------------------------------------------------------------
/packages/express/src/router/catalog.ts:
--------------------------------------------------------------------------------
1 |
2 | import { Router } from 'express'
3 | import { updateCatalog } from '@library-api/core'
4 |
5 | import { CATALOG_PATH } from '../constants'
6 |
7 | import { Catalog } from '../../types/api'
8 |
9 | const router = Router()
10 |
11 | router.post('/update', async (req, res) => {
12 | const updated = await updateCatalog(CATALOG_PATH)
13 | const message = updated ? 'Updated' : 'Already Up To Date'
14 | res.json({ message } as Catalog.Update.Response)
15 | })
16 |
17 | export const catalog = router
18 |
--------------------------------------------------------------------------------
/packages/express/src/router/download.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import { join } from 'path'
3 | import { createReadStream } from 'fs'
4 | import { checkExists, getVideoStream } from '@library-api/core'
5 |
6 | import { DOWNLOAD_DIR } from '../constants'
7 |
8 | import { Download } from '../../types/api'
9 |
10 | const router = Router()
11 |
12 | router.get('/image', (req, res) => {
13 | const { publication, file } = req.query as Partial
14 | if (!publication || !file) return res.status(401).json({ message: 'Publication and File are required' })
15 | const imagePath = join(DOWNLOAD_DIR, publication, 'contents', file)
16 | if (!checkExists(imagePath)) return res.status(404).json({ message: 'No Image Found' })
17 | return createReadStream(imagePath).pipe(res)
18 | })
19 |
20 | router.get('/video', async (req, res) => {
21 | const { type, doc, track, issue, languageId } = req.query as Partial
22 | if (!type || !doc || !track || !issue) return res.status(401).json({ message: 'Type, Doc, Track and Issue are required' })
23 | const language = Number(languageId || 0)
24 | if (isNaN(language)) {
25 | return res.status(401).json({ message: 'LanguageId must be a number' })
26 | }
27 | const stream = await getVideoStream({ type, doc, track, issue, languageId: language })
28 | if (!stream) return res.status(404).json({ message: 'No Video Found' })
29 | return stream.pipe(res)
30 | })
31 |
32 | export const download = router
33 |
--------------------------------------------------------------------------------
/packages/express/src/router/index.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import { CatalogDatabase } from '@library-api/core'
3 |
4 | import { catalog } from './catalog'
5 | import { download } from './download'
6 | import { media } from './media'
7 | import { publication } from './publication'
8 | import { CATALOG_PATH } from '../constants'
9 |
10 | const router = Router()
11 |
12 | router.get('/monthly-publications', async (req, res) => {
13 | const db = new CatalogDatabase(CATALOG_PATH)
14 | const results = db.getMonthlyPublications()
15 | res.json(results)
16 | })
17 |
18 | router.use('/catalog', catalog)
19 | router.use('/download', download)
20 | router.use('/media', media)
21 | router.use('/publication', publication)
22 |
23 | // handle 404
24 | router.use((req, res) => {
25 | res.status(404).json({ message: 'Not found' })
26 | })
27 |
28 | export { router }
29 |
--------------------------------------------------------------------------------
/packages/express/src/router/media.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import { isValidDate, CatalogDatabase, getMediaCatalog } from '@library-api/core'
3 | import { ImageDTO, VideoDTO } from '@library-api/core/types/dto'
4 |
5 | import { CATALOG_PATH, DOWNLOAD_DIR } from '../constants'
6 |
7 | import { Media, ImageDTOWithURL, VideoDTOWithURL } from '../../types/api'
8 |
9 | const router = Router()
10 |
11 | function addImageURL (image: ImageDTO): ImageDTOWithURL {
12 | return {
13 | ...image,
14 | url: `/download/image?publication=${image.filename}&file=${image.filePath}`
15 | }
16 | }
17 |
18 | function addVideoURL (video: VideoDTO): VideoDTOWithURL {
19 | const urlSearchParams = new URLSearchParams({
20 | type: video.type,
21 | doc: String(video.doc),
22 | track: String(video.track),
23 | issue: String(video.issue)
24 | })
25 | return {
26 | ...video,
27 | url: '/download/video?' + urlSearchParams.toString()
28 | }
29 | }
30 |
31 | router.get('/watchtower', async (req, res) => {
32 | const { date, languageId } = req.query as Partial
33 | if (!isValidDate(date)) return res.status(401).json({ message: 'Invalid Date' })
34 |
35 | const language = Number(languageId || 0)
36 | if (isNaN(language)) {
37 | return res.status(401).json({ message: 'LanguageId must be a number' })
38 | }
39 |
40 | const db = new CatalogDatabase(CATALOG_PATH)
41 | const publication = await db.getPublication(date, DOWNLOAD_DIR, 'wt', language)
42 | if (!publication) return res.status(404).json({ message: 'No Watchtower Found' })
43 |
44 | const images = (await publication.getImages(date))
45 | .map(image => addImageURL(image))
46 | const videos = (await publication.getVideos(date))
47 | .map(video => addVideoURL(video))
48 |
49 | const response: Media.Watchtower.Response = {
50 | message: {
51 | images,
52 | videos
53 | }
54 | }
55 |
56 | return res.json(response)
57 | })
58 |
59 | router.get('/oclm', async (req, res) => {
60 | const { date, languageId } = req.query as Partial
61 | if (!isValidDate(date)) return res.status(401).json({ message: 'Invalid Date' })
62 |
63 | const language = Number(languageId || 0)
64 | if (isNaN(language)) {
65 | return res.status(401).json({ message: 'LanguageId must be a number' })
66 | }
67 |
68 | const db = new CatalogDatabase(CATALOG_PATH)
69 | const publication = await db.getPublication(date, DOWNLOAD_DIR, 'oclm', language)
70 | if (!publication) return res.status(404).json({ message: 'No OCLM Workbook Found' })
71 |
72 | const images = (await publication.getImages(date))
73 | .map(image => addImageURL(image))
74 | const videos = (await publication.getVideos(date))
75 | .map(video => addVideoURL(video))
76 |
77 | const response: Media.OCLM.Response = {
78 | message: {
79 | images,
80 | videos
81 | }
82 | }
83 |
84 | return res.json(response)
85 | })
86 |
87 | router.get('/details', async (req, res) => {
88 | const { type, doc, issue, track, languageId } = req.query as Partial
89 | if (type !== 'doc' && type !== 'pub') {
90 | return res.status(401).json({ message: 'Type must be one of "doc" or "pub"' })
91 | }
92 | if (doc === undefined || issue === undefined || track === undefined) {
93 | return res.status(401).json({ message: 'Doc, Issue & Track are required' })
94 | }
95 | const language = Number(languageId || 0)
96 | if (isNaN(language)) {
97 | return res.status(401).json({ message: 'LanguageId must be a number' })
98 | }
99 |
100 | const catalog = await getMediaCatalog(DOWNLOAD_DIR, language)
101 | if (!catalog) return res.status(404).json({ message: 'No Media Catalog Found' })
102 | const details = await catalog.getMediaDetails({ doc, issue, track })
103 | if (!details) return res.status(404).json({ message: 'No Media Details Found' })
104 |
105 | const response: Media.Details.Response = {
106 | message: {
107 | details
108 | }
109 | }
110 |
111 | return res.json(response)
112 | })
113 |
114 | export const media = router
115 |
--------------------------------------------------------------------------------
/packages/express/src/router/publication.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express'
2 | import { isValidDate, CatalogDatabase } from '@library-api/core'
3 | import { ImageDTO, VideoDTO } from '@library-api/core/types/dto'
4 |
5 | import { CATALOG_PATH, DOWNLOAD_DIR } from '../constants'
6 |
7 | import { Publication } from '../../types/api'
8 |
9 | const router = Router()
10 |
11 | router.get('/:type/related-publications', async (req, res) => {
12 | const { type } = req.params
13 | if (type !== 'wt' && type !== 'oclm') return res.status(401).json({ message: 'Route should be /wt/related-publications or /oclm/related-publications' })
14 | const { date, languageId } = req.query as Partial
15 | if (!isValidDate(date)) return res.status(401).json({ message: 'Invalid Date' })
16 |
17 | const language = Number(languageId || 0)
18 | if (isNaN(language)) {
19 | return res.status(401).json({ message: 'LanguageId must be a number' })
20 | }
21 |
22 | const db = new CatalogDatabase(CATALOG_PATH)
23 | const publication = await db.getPublication(date, DOWNLOAD_DIR, type, language)
24 | if (!publication) return res.status(404).json({ message: 'No Publication Found' })
25 |
26 | const publications = await publication.getRelatedPublications(date)
27 |
28 | const response: Publication.RelatedPublications.Response = {
29 | message: {
30 | publications
31 | }
32 | }
33 |
34 | return res.json(response)
35 | })
36 |
37 | router.get('/:type/related-media', async (req, res) => {
38 | const { type } = req.params
39 | if (type !== 'wt' && type !== 'oclm') return res.status(401).json({ message: 'Route should be /wt/related-media or /oclm/related-media' })
40 | const { date, languageId } = req.query as Partial
41 | if (!isValidDate(date)) return res.status(401).json({ message: 'Invalid Date' })
42 |
43 | const language = Number(languageId || 0)
44 | if (isNaN(language)) {
45 | return res.status(401).json({ message: 'LanguageId must be a number' })
46 | }
47 |
48 | const db = new CatalogDatabase(CATALOG_PATH)
49 | const publication = await db.getPublication(date, DOWNLOAD_DIR, type, language)
50 | if (!publication) return res.status(404).json({ message: 'No Publication Found' })
51 |
52 | const publications = await publication.getRelatedPublications(date)
53 |
54 | const images: ImageDTO[] = []
55 | const videos: VideoDTO[] = []
56 |
57 | for (const publication of publications) {
58 | const media = await db.getRelatedPublicationMedia(publication, DOWNLOAD_DIR, language)
59 | if (media) {
60 | images.push(...media.images)
61 | videos.push(...media.videos)
62 | }
63 | }
64 |
65 | const response: Publication.RelatedMedia.Response = {
66 | message: {
67 | media: {
68 | images,
69 | videos
70 | }
71 | }
72 | }
73 |
74 | return res.json(response)
75 | })
76 |
77 | export const publication = router
78 |
--------------------------------------------------------------------------------
/packages/express/src/server.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import { createDir, updateCatalog } from '@library-api/core'
3 |
4 | import { router } from './router'
5 | import { CATALOG_PATH, DOWNLOAD_DIR } from './constants'
6 |
7 | const PORT = 3000
8 |
9 | ;(async () => {
10 | // setup system files
11 | await createDir(DOWNLOAD_DIR)
12 | await updateCatalog(CATALOG_PATH)
13 |
14 | const app = express()
15 | app.use(router)
16 |
17 | app.listen(PORT, () => {
18 | console.log(`Listening on port ${PORT}`)
19 | })
20 | })()
21 |
--------------------------------------------------------------------------------
/packages/express/test/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: '../.eslintrc.js',
3 | plugins: [
4 | 'jest'
5 | ],
6 | env: {
7 | 'jest/globals': true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/express/test/server.spec.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import { mocked } from 'ts-jest/utils'
3 |
4 | import '@/server'
5 | import * as core from '@library-api/core'
6 | import { DOWNLOAD_DIR } from '@/constants'
7 |
8 | jest.mock('@library-api/core')
9 | jest.mock('express', () => {
10 | return {
11 | ...jest.requireActual('express'),
12 | __esModule: true,
13 | default: jest.fn().mockReturnValue({
14 | use: jest.fn(),
15 | listen: jest.fn()
16 | })
17 | }
18 | })
19 |
20 | const mockedCore = mocked(core, true)
21 | const mockedExpress = mocked(express, true)
22 |
23 | describe('Server', () => {
24 | test('should setup system files', () => {
25 | expect(mockedCore.createDir).lastCalledWith(DOWNLOAD_DIR)
26 | })
27 |
28 | test('should start express', () => {
29 | expect(mockedExpress).toBeCalledTimes(1)
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/packages/express/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "baseUrl": "../",
6 | "rootDir": "../",
7 | "paths": {
8 | "@/*": [
9 | "src/*"
10 | ]
11 | },
12 | "types": [
13 | "node",
14 | "jest"
15 | ]
16 | },
17 | "include": [
18 | ".",
19 | "../src"
20 | ],
21 | "exclude": []
22 | }
23 |
--------------------------------------------------------------------------------
/packages/express/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "rootDir": "src",
6 | "outDir": "dist",
7 | "tsBuildInfoFile": "dist/.tsbuildinfo"
8 | },
9 | "include": [
10 | "src"
11 | ],
12 | "exclude": [
13 | "**/*.spec.ts"
14 | ],
15 | "references": [
16 | {
17 | "path": "../core/tsconfig.json"
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/express/types/api.d.ts:
--------------------------------------------------------------------------------
1 | import { ImageDTO, MediaDetailsDTO, RelatedPublicationDTO, VideoDTO } from '@library-api/core/types/dto'
2 | import { PublicationType } from '@library-api/core/types/publication'
3 |
4 | export interface ImageDTOWithURL extends ImageDTO {
5 | url: string
6 | }
7 |
8 | export interface VideoDTOWithURL extends VideoDTO {
9 | url: string
10 | }
11 |
12 | export namespace Catalog {
13 | export namespace Update {
14 | export interface Response {
15 | message: 'Updated' | 'Already Up To Date'
16 | }
17 | }
18 | }
19 |
20 | export namespace Download {
21 | export namespace Image {
22 | export interface QueryParams {
23 | publication: string
24 | file: string
25 | }
26 | }
27 | export namespace Video {
28 | export interface QueryParams {
29 | type: VideoDTO['type']
30 | doc: string
31 | track: string
32 | issue: string
33 | languageId?: string
34 | }
35 | }
36 | }
37 |
38 | export namespace Media {
39 | export namespace Watchtower {
40 | export interface QueryParams {
41 | date: string
42 | languageId?: string
43 | }
44 | export interface Response {
45 | message: {
46 | images: ImageDTOWithURL[]
47 | videos: VideoDTOWithURL[]
48 | }
49 | }
50 | }
51 | export namespace OCLM {
52 | export interface QueryParams {
53 | date: string
54 | languageId?: string
55 | }
56 | export interface Response {
57 | message: {
58 | images: ImageDTOWithURL[]
59 | videos: VideoDTOWithURL[]
60 | }
61 | }
62 | }
63 | export namespace Details {
64 | export interface QueryParams {
65 | type: VideoDTO['type']
66 | doc: string | number
67 | issue: number
68 | track: number
69 | languageId?: string
70 | }
71 | export interface Response {
72 | message: {
73 | details: MediaDetailsDTO
74 | }
75 | }
76 | }
77 | }
78 |
79 | export namespace Publication {
80 | export namespace RelatedPublications {
81 | export interface Params {
82 | type: PublicationType
83 | }
84 | export interface QueryParams {
85 | date: string
86 | languageId?: string
87 | }
88 | export interface Response {
89 | message: {
90 | publications: RelatedPublicationDTO[]
91 | }
92 | }
93 | }
94 |
95 | export namespace RelatedMedia {
96 | export interface Params {
97 | type: PublicationType
98 | }
99 | export interface QueryParams {
100 | date: string
101 | languageId?: string
102 | }
103 | export interface Response {
104 | message: {
105 | media: {
106 | images: ImageDTO[]
107 | videos: VideoDTO[]
108 | }
109 | }
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/packages/media/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const baseConfig = require('../../.eslintrc.js')
2 |
3 | /**
4 | * @type { import('eslint').Linter.Config }
5 | */
6 | module.exports = {
7 | ...baseConfig,
8 | rules: {
9 | 'no-console': 'error'
10 | },
11 | overrides: [
12 | ...baseConfig.overrides,
13 | {
14 | files: 'scripts/**/*.js',
15 | rules: {
16 | 'no-console': 'off'
17 | }
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/media/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | A cross-platform desktop application that simplifies showing media for meetings of Jehovah's Witnesses.
7 |
8 |
9 |
10 |
11 | ## Description
12 |
13 | Finally, non-Windows users can now also display media in a more professional way.
14 |
15 | This package is brought to you by [Library API](../../README.md).
16 |
17 | ## Download
18 |
19 | You can download the latest versions from the [latest releases page](https://github.com/BenShelton/library-api/releases/latest). For Mac you want to `.dmg` file (not the one ending `.blockmap`). You can view more detailed installation instructions [here](https://benshelton.github.io/library-api/media/#installation).
20 |
21 | For other systems you can package this yourself using the `package` command (see [Development](#development).
22 |
23 | ## Documentation
24 |
25 | View the documentation [here](https://benshelton.github.io/library-api/media/).
26 |
27 | ## Development
28 |
29 | Run the following commands to get started. If you are running from the root directory you can add `media` to run these (for example `yarn media dev` instead of just `yarn dev`):
30 |
31 | ```bash
32 | # Start server with hot reload for development
33 | yarn dev
34 |
35 | # Build (outputs to /dist)
36 | yarn build
37 |
38 | # Package the app for testing locally
39 | # Note that local versions will raise app-update errors on startup, they can be ignored
40 | yarn package
41 |
42 | # Lint files
43 | yarn lint
44 |
45 | # Run test suite
46 | yarn test
47 |
48 | # Run Type Checking Service
49 | yarn tsc
50 | ```
51 |
--------------------------------------------------------------------------------
/packages/media/app/main/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const baseConfig = require('../../.eslintrc.js')
2 |
3 | /**
4 | * @type { import('eslint').Linter.Config }
5 | */
6 | module.exports = {
7 | ...baseConfig
8 | }
9 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/constants.ts:
--------------------------------------------------------------------------------
1 | import { app } from 'electron'
2 | import path from 'path'
3 |
4 | export const ROOT_DIR = app.getPath('userData')
5 | export const DOWNLOAD_DIR = path.join(ROOT_DIR, 'downloads')
6 | export const VIDEO_DIR = path.join(DOWNLOAD_DIR, 'videos')
7 | export const CATALOG_FILE = 'catalog.db'
8 | export const CATALOG_PATH = path.join(DOWNLOAD_DIR, CATALOG_FILE)
9 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/directories.ts:
--------------------------------------------------------------------------------
1 | import { readdir, unlink } from 'fs/promises'
2 | import { join } from 'path'
3 | import { createDir } from '@library-api/core'
4 |
5 | import { DOWNLOAD_DIR, VIDEO_DIR } from './constants'
6 |
7 | export async function initDirectories (): Promise {
8 | await createDir(DOWNLOAD_DIR)
9 | await createDir(VIDEO_DIR)
10 | // TODO: Remove this when Media Catalogs are updateable
11 | const files = await readdir(DOWNLOAD_DIR)
12 | for (const file of files) {
13 | if (file.endsWith('.ndjson')) {
14 | await unlink(join(DOWNLOAD_DIR, file))
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/events.ts:
--------------------------------------------------------------------------------
1 | import { app } from 'electron'
2 |
3 | import { refocusWindows } from './window'
4 |
5 | export function initEvents (): void {
6 | app.on('activate', async () => {
7 | await refocusWindows()
8 | })
9 |
10 | app.on('second-instance', async () => {
11 | await refocusWindows()
12 | })
13 |
14 | app.on('window-all-closed', () => {
15 | app.quit()
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/index.ts:
--------------------------------------------------------------------------------
1 | import { app } from 'electron'
2 |
3 | import { initDirectories } from './directories'
4 | import { initEvents } from './events'
5 | import { initIPC } from './ipc'
6 | import { initLogger } from './logger'
7 | import { initMenu } from './menu'
8 | import { initUpdater } from './updater'
9 | import { createWindows } from './window'
10 |
11 | (async () => {
12 | // only allow a single instance
13 | if (!app.requestSingleInstanceLock()) {
14 | app.quit()
15 | return
16 | }
17 |
18 | // configure app
19 | initLogger()
20 | initMenu()
21 | initEvents()
22 | await initDirectories()
23 |
24 | // setup everything else
25 | await app.whenReady()
26 | initIPC()
27 | initUpdater()
28 |
29 | // start app
30 | await createWindows()
31 | })()
32 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/logger.ts:
--------------------------------------------------------------------------------
1 | import electron, { app, shell } from 'electron'
2 | import log from 'electron-log'
3 |
4 | function submitIssue ({ versions, message }: { versions?: { app: string, os: string }, message: string}): void {
5 | const issueVersions = versions || { app: 'unknown', os: 'unknown' }
6 | if (issueVersions.app === 'unknown') {
7 | try {
8 | issueVersions.app = `${app.name} ${app.getVersion()}`
9 | } catch {}
10 | }
11 | const body = '\n' +
12 | '**Packages & versions**\n' +
13 | `Library Media ${issueVersions.app}\n` +
14 | `OS: ${issueVersions.os}\n\n` +
15 | '**Describe the bug**\n' +
16 | `${message}\n`
17 | const url = new URL('https://github.com/BenShelton/library-api/issues/new')
18 | url.searchParams.append('title', '[Bug] Error report for Library Media')
19 | url.searchParams.append('body', body)
20 | shell.openExternal(url.toString())
21 | }
22 |
23 | export async function showErrorDialog (error: Error, versions?: { app: string, os: string }): Promise {
24 | const result = await electron.dialog.showMessageBox({
25 | title: 'An error occurred',
26 | message: error.message,
27 | detail: error.stack,
28 | type: 'error',
29 | buttons: ['Ignore', 'Report', 'Exit']
30 | })
31 | if (result.response === 1) {
32 | submitIssue({
33 | versions,
34 | message: 'Error:\n' +
35 | '```\n' +
36 | `${error.stack}\n` +
37 | '```'
38 | })
39 | return
40 | }
41 |
42 | if (result.response === 2) {
43 | electron.app.quit()
44 | }
45 | }
46 |
47 | export function initLogger (): void {
48 | log.catchErrors({
49 | showDialog: false,
50 | onError: showErrorDialog
51 | })
52 | }
53 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/menu.ts:
--------------------------------------------------------------------------------
1 | import { app, Menu, shell } from 'electron'
2 | import { MenuItemConstructorOptions } from 'electron/main'
3 |
4 | const isMac = process.platform === 'darwin'
5 |
6 | const template: MenuItemConstructorOptions[] = []
7 |
8 | if (isMac) {
9 | template.push({
10 | label: app.name,
11 | submenu: [
12 | { role: 'about' },
13 | { type: 'separator' },
14 | { role: 'services' },
15 | { type: 'separator' },
16 | { role: 'hide' },
17 | { type: 'separator' },
18 | { role: 'quit' }
19 | ]
20 | })
21 | }
22 | template.push(
23 | {
24 | label: 'File',
25 | submenu: [
26 | isMac ? { role: 'close' } : { role: 'quit' }
27 | ]
28 | },
29 | {
30 | label: 'View',
31 | submenu: [
32 | { role: 'togglefullscreen' }
33 | ]
34 | },
35 | {
36 | label: 'Window',
37 | submenu: [
38 | { role: 'minimize' },
39 | { role: 'zoom' }
40 | ]
41 | },
42 | {
43 | role: 'help',
44 | submenu: [
45 | {
46 | label: 'Open Change Log',
47 | click: async () => {
48 | await shell.openExternal('https://github.com/BenShelton/library-api/blob/master/packages/media/CHANGELOG.md')
49 | }
50 | },
51 | {
52 | label: 'Open Documentation',
53 | click: async () => {
54 | await shell.openExternal('https://benshelton.github.io/library-api/media/')
55 | }
56 | }
57 | ]
58 | }
59 | )
60 |
61 | export function initMenu (): void {
62 | const menu = Menu.buildFromTemplate(template)
63 | Menu.setApplicationMenu(menu)
64 | }
65 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/updater.ts:
--------------------------------------------------------------------------------
1 | import log from 'electron-log'
2 | import { autoUpdater } from 'electron-updater'
3 |
4 | export function initUpdater (): void {
5 | autoUpdater.logger = log
6 | autoUpdater.checkForUpdatesAndNotify()
7 | }
8 |
--------------------------------------------------------------------------------
/packages/media/app/main/src/window.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow } from 'electron'
2 | import { URL } from 'url'
3 | import { join } from 'path'
4 | import { checkExists } from '@library-api/core'
5 | import AspectRatioBrowserWindow from 'electron-aspect-ratio-browser-window'
6 |
7 | import { store } from 'shared/src/store'
8 |
9 | import { CATALOG_PATH } from './constants'
10 |
11 | let controlWindow: BrowserWindow | null = null
12 | let displayWindow: BrowserWindow | null = null
13 |
14 | const pageUrl = (import.meta.env.VITE_DEV_SERVER_URL as string | undefined) ||
15 | new URL('../renderer/dist/index.html', 'file://' + __dirname).toString()
16 | const preload = join(__dirname, '../../preload/dist/index.cjs')
17 |
18 | function checkDevTools (window: BrowserWindow): void {
19 | if (!import.meta.env.DEV) return
20 | window.webContents.openDevTools({ mode: 'right' })
21 | }
22 |
23 | export async function createControlWindow (): Promise {
24 | const storeKey = 'controlWindow'
25 | const windowSettings = store.get(storeKey)
26 | controlWindow = new BrowserWindow({
27 | ...windowSettings,
28 | title: 'Control Panel',
29 | show: true,
30 | webPreferences: {
31 | preload
32 | }
33 | })
34 |
35 | checkDevTools(controlWindow)
36 | controlWindow.on('close', () => {
37 | if (controlWindow) {
38 | const { x, y, width, height } = controlWindow.getBounds()
39 | store.set('controlWindow', { x, y, width, height })
40 | }
41 | })
42 | controlWindow.on('closed', () => {
43 | controlWindow = null
44 | displayWindow?.close()
45 | })
46 |
47 | const catalogExists = await checkExists(CATALOG_PATH)
48 |
49 | await controlWindow.loadURL(pageUrl + '#' + (catalogExists ? 'control-panel' : 'intro'))
50 | return controlWindow
51 | }
52 |
53 | export async function createDisplayWindow (): Promise {
54 | const storeKey = 'displayWindow'
55 | const windowSettings = store.get(storeKey)
56 | displayWindow = new AspectRatioBrowserWindow({
57 | ...windowSettings,
58 | title: 'Display',
59 | show: true,
60 | // this prevents Zoom from showing this window as an option
61 | // alwaysOnTop: true,
62 | frame: false,
63 | // TODO: Enable this when fixed, see https://github.com/electron/electron/issues/28686
64 | // roundedCorners: false,
65 | webPreferences: {
66 | preload,
67 | // TODO: This is so we can use local video src in the renderer, there should be a better way
68 | webSecurity: false
69 | }
70 | })
71 |
72 | displayWindow.setAspectRatio(16 / 9)
73 |
74 | // checkDevTools(displayWindow)
75 | displayWindow.on('close', () => {
76 | if (displayWindow) {
77 | const { x, y, width, height } = displayWindow.getBounds()
78 | store.set('displayWindow', { x, y, width, height })
79 | }
80 | })
81 | displayWindow.on('closed', () => {
82 | displayWindow = null
83 | })
84 |
85 | await displayWindow.loadURL(pageUrl + '#display')
86 | return displayWindow
87 | }
88 |
89 | export async function createWindows (): Promise<[BrowserWindow, BrowserWindow]> {
90 | return Promise.all([
91 | createControlWindow(),
92 | createDisplayWindow()
93 | ])
94 | }
95 |
96 | async function getWindow (window: BrowserWindow | null, createFn: () => Promise): Promise {
97 | if (window && !window.isDestroyed()) {
98 | return window
99 | } else {
100 | return createFn()
101 | }
102 | }
103 |
104 | export async function getControlWindow (): Promise {
105 | return getWindow(controlWindow, createControlWindow)
106 | }
107 |
108 | export async function getDisplayWindow (): Promise {
109 | return getWindow(displayWindow, createDisplayWindow)
110 | }
111 |
112 | function refocusWindow (window: BrowserWindow): void {
113 | if (window.isMinimized()) window.restore()
114 | window.focus()
115 | }
116 |
117 | export async function refocusControlWindow (): Promise {
118 | const window = await getControlWindow()
119 | refocusWindow(window)
120 | return window
121 | }
122 |
123 | export async function refocusDisplayWindow (): Promise {
124 | const window = await getDisplayWindow()
125 | refocusWindow(window)
126 | return window
127 | }
128 |
129 | export async function refocusWindows (): Promise<[BrowserWindow, BrowserWindow]> {
130 | return Promise.all([
131 | refocusControlWindow(),
132 | refocusDisplayWindow()
133 | ])
134 | }
135 |
--------------------------------------------------------------------------------
/packages/media/app/main/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "rootDir": "..",
6 | "module": "es2020",
7 | "paths": {
8 | "@/*": [
9 | "src/*"
10 | ],
11 | "shared/*": [
12 | "../shared/*"
13 | ]
14 | },
15 | },
16 | "include": [
17 | "src/**/*.ts",
18 | "../shared/**/*.ts"
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/media/app/main/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path'
2 | import { builtinModules } from 'module'
3 | import { defineConfig } from 'vite'
4 |
5 | const PACKAGE_ROOT = __dirname
6 |
7 | export default defineConfig({
8 | root: PACKAGE_ROOT,
9 | resolve: {
10 | alias: {
11 | '@/': join(PACKAGE_ROOT, 'src') + '/',
12 | shared: join(PACKAGE_ROOT, '..', 'shared'),
13 | '@library-api/core': join(PACKAGE_ROOT, '..', '..', '..', 'core', 'src', 'index.ts')
14 | }
15 | },
16 | build: {
17 | sourcemap: 'inline',
18 | target: 'node14',
19 | outDir: 'dist',
20 | assetsDir: '.',
21 | minify: process.env.MODE === 'development' ? false : undefined, // undefined must set default value
22 | lib: {
23 | entry: 'src/index.ts',
24 | formats: ['cjs']
25 | },
26 | rollupOptions: {
27 | external: [
28 | 'electron',
29 | ...builtinModules,
30 | // core modules
31 | 'sqlite',
32 | 'sqlite3',
33 | 'node-fetch',
34 | 'unzipper'
35 | ],
36 | output: {
37 | entryFileNames: '[name].cjs'
38 | }
39 | },
40 | emptyOutDir: true
41 | }
42 | })
43 |
--------------------------------------------------------------------------------
/packages/media/app/preload/src/index.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, ipcRenderer } from 'electron'
2 | import { functions } from 'electron-log'
3 | import { getLanguages } from '@library-api/core'
4 |
5 | import { store } from 'shared/src/store'
6 |
7 | import { ElectronApi, StoreApi } from 'shared/types/electron-api'
8 |
9 | const electronApiKey: keyof Window = 'electron'
10 | const electronApi: ElectronApi = {
11 | invoke (channel, args) {
12 | return ipcRenderer.invoke(channel, args)
13 | },
14 | send (channel, args) {
15 | ipcRenderer.send(channel, args)
16 | },
17 | on (channel, cb) {
18 | ipcRenderer.on(channel, (_event, args) => cb(args))
19 | }
20 | }
21 |
22 | const storeApiKey: keyof Window = 'store'
23 | const storeApi: StoreApi = {
24 | get (key: string, defaultValue?: unknown) {
25 | return store.get(key, defaultValue)
26 | },
27 | set (key, value) {
28 | store.set(key, value)
29 | },
30 | watch (key, cb) {
31 | return store.onDidChange(key, cb)
32 | }
33 | }
34 |
35 | const logApiKey: keyof Window = 'log'
36 |
37 | const languagesApiKey: keyof Window = 'languages'
38 | const languages = getLanguages()
39 |
40 | contextBridge.exposeInMainWorld(electronApiKey, electronApi)
41 | contextBridge.exposeInMainWorld(storeApiKey, storeApi)
42 | contextBridge.exposeInMainWorld(logApiKey, functions)
43 | contextBridge.exposeInMainWorld(languagesApiKey, languages)
44 |
--------------------------------------------------------------------------------
/packages/media/app/preload/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "rootDir": "..",
6 | "module": "es2020",
7 | "paths": {
8 | "@/*": [
9 | "src/*"
10 | ],
11 | "shared/*": [
12 | "../shared/*"
13 | ]
14 | },
15 | "lib": [
16 | "esnext",
17 | "dom",
18 | "dom.iterable"
19 | ]
20 | },
21 | "include": [
22 | "src/**/*.ts",
23 | "../shared/**/*.ts"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/packages/media/app/preload/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path'
2 | import { builtinModules } from 'module'
3 | import { defineConfig } from 'vite'
4 |
5 | const PACKAGE_ROOT = __dirname
6 |
7 | export default defineConfig({
8 | root: PACKAGE_ROOT,
9 | resolve: {
10 | alias: {
11 | '@/': join(PACKAGE_ROOT, 'src') + '/',
12 | shared: join(PACKAGE_ROOT, '..', 'shared'),
13 | '@library-api/core': join(PACKAGE_ROOT, '..', '..', '..', 'core', 'src', 'index.ts')
14 | }
15 | },
16 | build: {
17 | sourcemap: 'inline',
18 | target: 'chrome89',
19 | outDir: 'dist',
20 | assetsDir: '.',
21 | minify: process.env.MODE === 'development' ? false : undefined, // undefined must set default value
22 | lib: {
23 | entry: 'src/index.ts',
24 | formats: ['cjs']
25 | },
26 | rollupOptions: {
27 | external: [
28 | 'electron',
29 | ...builtinModules,
30 | // core modules
31 | 'sqlite',
32 | 'sqlite3',
33 | 'node-fetch',
34 | 'unzipper'
35 | ],
36 | output: {
37 | entryFileNames: '[name].cjs'
38 | }
39 | },
40 | emptyOutDir: true
41 | }
42 | })
43 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | const baseConfig = require('../../.eslintrc.js')
3 |
4 | /**
5 | * @type { import('eslint').Linter.Config }
6 | */
7 | module.exports = {
8 | ...baseConfig,
9 | extends: [
10 | ...baseConfig.extends,
11 | 'plugin:vue/vue3-recommended'
12 | ],
13 | env: {
14 | browser: true,
15 | node: false
16 | },
17 | ignorePatterns: [
18 | ...baseConfig.ignorePatterns,
19 | 'index.html'
20 | ],
21 | overrides: [
22 | ...baseConfig.overrides,
23 | {
24 | files: ['src/composables/**/*.ts'],
25 | rules: {
26 | '@typescript-eslint/explicit-module-boundary-types': 'off'
27 | }
28 | }
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
14 |
15 |
126 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/Quicksand.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BenShelton/library-api/bafb9731bc1166069f3bc6dff4796f31fd5907ed/packages/media/app/renderer/src/assets/Quicksand.ttf
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/logo-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BenShelton/library-api/bafb9731bc1166069f3bc6dff4796f31fd5907ed/packages/media/app/renderer/src/assets/logo-banner.png
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/logo-line.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BenShelton/library-api/bafb9731bc1166069f3bc6dff4796f31fd5907ed/packages/media/app/renderer/src/assets/logo-line.png
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/media.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/picker.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/assets/song.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/components/Controls.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Nothing Displaying
5 |
6 |
7 |
8 | Hide
9 |
10 |
11 |
12 |
13 |
14 |
36 |
37 |
51 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/components/Navbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
12 |
16 |
21 |
25 |
26 |
27 |
28 |
29 |
42 |
43 |
61 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/components/NavbarBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
11 |
15 |
16 |
17 |
18 |
19 |
32 |
33 |
48 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/components/PreviewMedia.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
13 |
14 |
15 |
23 |
26 |
27 |
28 |
29 |
90 |
91 |
153 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/composables/store.ts:
--------------------------------------------------------------------------------
1 | import { onBeforeUnmount, reactive, toRaw, watch } from 'vue'
2 |
3 | import { StoreDefinition } from 'shared/types/store'
4 |
5 | export function useStore (key: Key) {
6 | const store = reactive(window.store.get(key))
7 |
8 | watch(store, (newValue) => {
9 | window.store.set(key, toRaw(newValue) as StoreDefinition[Key])
10 | })
11 | const unwatch = window.store.watch(key, (newValue) => {
12 | Object.assign(store, newValue)
13 | })
14 | onBeforeUnmount(() => {
15 | unwatch()
16 | })
17 |
18 | return {
19 | store
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 |
3 | import App from '@/App.vue'
4 | import router from '@/router'
5 |
6 | createApp(App)
7 | .use(router)
8 | .mount('#app')
9 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/pages/ControlPanel/Index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
38 |
39 |
49 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/pages/ControlPanel/Picker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Choose File
5 |
6 |
Select a media file from your file system to display
7 |
Only some file extensions are supported
8 |
9 | Choose File
10 |
11 |
12 |
13 |
14 |
36 |
37 |
45 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/pages/ControlPanel/Settings.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | SETTINGS
5 |
6 |
Media
7 |
8 |
9 |
10 | Choose the language for publications & songs
11 |
12 |
15 |
21 |
22 |
23 |
24 |
25 | Hide images not normally shown during the meeting (cover images, publication covers etc.)
26 |
27 |
30 |
31 | Show all
32 |
33 |
34 | Only show relevant
35 |
36 |
37 |
38 |
39 |
Reset
40 |
41 |
42 |
43 | Removes all downloaded data (catalog, publications, videos etc.)
44 | May fix issues with corrupted publications not showing properly.
45 | This will send you back to the intro screen.
46 |
47 |
51 | CLEAR DOWNLOADS
52 |
53 |
54 |
55 |
56 |
57 |
58 |
100 |
101 |
129 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/pages/ControlPanel/Song.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Choose Song
5 |
6 |
Select a song from the dropdown to load it
7 |
11 |
17 |
18 |
19 |
27 |
28 |
29 |
30 |
103 |
104 |
113 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/pages/Display.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 | Nothing Displaying
8 |
9 | Right click to hide/show this help text (on some devices this is a 2-finger click)
10 |
11 | Click and drag from inside this window to move it
12 | Click and drag the corners to resize it
13 |
14 |
15 |
19 |
26 |
27 |
28 |
29 |
30 |
76 |
77 |
103 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/pages/Intro.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Welcome to Library Media
5 |
In order to start you will need to download the Publication Catalog. This is a large file (about 500MB) so it may take some time to download
6 |
10 | Download Catalog
11 |
12 |
16 |
17 |
18 | Downloading Catalog, please wait...
19 |
20 |
21 |
22 |
23 |
24 |
25 |
65 |
66 |
99 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/router/index.ts:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
2 |
3 | import Intro from '@/pages/Intro.vue'
4 | import ControlPanel from '@/pages/ControlPanel/Index.vue'
5 | import Media from '@/pages/ControlPanel/Media.vue'
6 | import Song from '@/pages/ControlPanel/Song.vue'
7 | import Picker from '@/pages/ControlPanel/Picker.vue'
8 | import Settings from '@/pages/ControlPanel/Settings.vue'
9 | import Display from '@/pages/Display.vue'
10 |
11 | const routes: RouteRecordRaw[] = [
12 | {
13 | path: '/intro',
14 | name: 'Intro',
15 | component: Intro
16 | },
17 | {
18 | path: '/control-panel',
19 | name: 'ControlPanel',
20 | component: ControlPanel,
21 | children: [
22 | {
23 | path: '',
24 | redirect: { name: 'Media' }
25 | },
26 | {
27 | path: 'media',
28 | name: 'Media',
29 | component: Media
30 | },
31 | {
32 | path: 'song',
33 | name: 'Song',
34 | component: Song
35 | },
36 | {
37 | path: 'picker',
38 | name: 'Picker',
39 | component: Picker
40 | },
41 | {
42 | path: 'settings',
43 | name: 'Settings',
44 | component: Settings
45 | }
46 | ]
47 | },
48 | {
49 | path: '/display',
50 | name: 'Display',
51 | component: Display
52 | }
53 | ]
54 |
55 | export default createRouter({
56 | routes,
57 | history: createWebHashHistory()
58 | })
59 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | import { SelectOption } from 'types/select'
2 |
3 | /**
4 | * Returns the most recent previous Monday
5 | */
6 | export function closestPreviousMonday (date: Date): Date {
7 | date.setHours(12)
8 | const day = date.getDay()
9 | date.setDate(date.getDate() - (day === 0 ? 6 : day - 1))
10 | return date
11 | }
12 |
13 | /**
14 | * Returns true if the passed in date is a Saturday or Sunday
15 | */
16 | export function isWeekend (date: Date): boolean {
17 | const day = date.getDay()
18 | return day === 0 || day === 6
19 | }
20 |
21 | /**
22 | * Returns an array of select options for every week of the year, starting on Monday
23 | *
24 | * @param year number | string
25 | */
26 | export function getMondaysOfYear (year: number | string): SelectOption[] {
27 | const d = new Date(year + '-01-01')
28 | const mondays: SelectOption[] = []
29 | // get the first Monday in the year
30 | while (d.getDay() !== 1) {
31 | d.setDate(d.getDate() + 1)
32 | }
33 | // avoid timezone offsets
34 | d.setHours(12)
35 | // get all the other Mondays in the year
36 | while (d.getFullYear() === year) {
37 | const sunday = new Date(d)
38 | sunday.setDate(d.getDate() + 6)
39 | const options = { month: 'short', day: 'numeric' } as const
40 | const text = `${d.toLocaleDateString(undefined, options)} - ${sunday.toLocaleDateString(undefined, options)}`
41 | mondays.push({ value: formatISODate(d), text })
42 | d.setDate(d.getDate() + 7)
43 | }
44 | return mondays
45 | }
46 |
47 | /**
48 | * Returns a `yyyy-mm-dd` string from a date
49 | *
50 | * @param date Date
51 | */
52 | export function formatISODate (date: Date): string {
53 | return date.toISOString().slice(0, 10)
54 | }
55 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "rootDir": "..",
6 | "composite": false,
7 | "module": "es2020",
8 | "paths": {
9 | "@/*": [
10 | "src/*"
11 | ],
12 | "shared/*": [
13 | "../shared/*"
14 | ]
15 | },
16 | "lib": [
17 | "esnext",
18 | "dom",
19 | "dom.iterable"
20 | ]
21 | },
22 | "include": [
23 | "src/**/*.vue",
24 | "src/**/*.ts",
25 | "types/**/*.d.ts",
26 | "../shared/**/*.ts"
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/types/select.d.ts:
--------------------------------------------------------------------------------
1 | export interface SelectOption {
2 | value: T
3 | text: string
4 | }
5 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/types/shims-vue.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.vue' {
2 | import { defineComponent } from 'vue'
3 | const component: ReturnType
4 | export default component
5 | }
6 |
--------------------------------------------------------------------------------
/packages/media/app/renderer/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | import { join } from 'path'
4 | import { defineConfig } from 'vite'
5 | import vue from '@vitejs/plugin-vue'
6 |
7 | const PACKAGE_ROOT = __dirname
8 |
9 | export default defineConfig({
10 | root: PACKAGE_ROOT,
11 | resolve: {
12 | alias: {
13 | '@/': join(PACKAGE_ROOT, 'src') + '/',
14 | shared: join(PACKAGE_ROOT, '..', 'shared')
15 | }
16 | },
17 | plugins: [
18 | vue({
19 | template: {
20 | transformAssetUrls: {
21 | img: ['src'],
22 | NavbarBtn: ['src']
23 | }
24 | }
25 | })
26 | ],
27 | base: '',
28 | build: {
29 | sourcemap: true,
30 | target: 'chrome89',
31 | polyfillDynamicImport: false,
32 | outDir: 'dist',
33 | assetsDir: '.'
34 | }
35 | })
36 |
--------------------------------------------------------------------------------
/packages/media/app/shared/src/extensions.ts:
--------------------------------------------------------------------------------
1 | export const imageExtensions = [
2 | 'jpg', 'jpeg',
3 | 'gif',
4 | 'png',
5 | 'bmp',
6 | 'svg',
7 | 'webp',
8 | 'ico'
9 | ]
10 |
11 | export const videoExtensions = [
12 | 'mp4', 'm4p', 'm4v',
13 | 'ogg',
14 | 'mov',
15 | 'webm'
16 | ]
17 |
--------------------------------------------------------------------------------
/packages/media/app/shared/src/store.ts:
--------------------------------------------------------------------------------
1 | import Store from 'electron-store'
2 |
3 | import { StoreDefinition } from '../types/store'
4 |
5 | const devToolsWidth = import.meta.env.DEV ? 350 : 0
6 |
7 | export const store = new Store({
8 | defaults: {
9 | controlWindow: {
10 | x: 20,
11 | y: 20,
12 | width: 450 + devToolsWidth,
13 | height: 600
14 | },
15 | displayWindow: {
16 | x: 500 + devToolsWidth,
17 | y: 20,
18 | width: 800,
19 | height: 450
20 | },
21 | controlPanel: {
22 | showImages: 'display',
23 | languageId: 0
24 | }
25 | },
26 | migrations: {
27 | '0.7.0': store => {
28 | store.set('controlPanel', { showImages: 'display' })
29 | },
30 | '0.8.0': store => {
31 | store.set('controlPanel.languageId', 0)
32 | }
33 | }
34 | })
35 |
--------------------------------------------------------------------------------
/packages/media/app/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "rootDir": ".",
6 | "module": "es2020"
7 | },
8 | "include": [
9 | "src/**/*.ts",
10 | "types/**/*.ts"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/packages/media/app/shared/types/electron-api.d.ts:
--------------------------------------------------------------------------------
1 | import { StoreDefinition } from './store'
2 |
3 | export interface ElectronApi {
4 | invoke (channel: string, args?: T['Args']): Promise
5 | send (channel: string, args?: T['Args']): void
6 | on (channel: string, cb: (args: T['Args']) => void): void
7 | }
8 |
9 | export interface StoreApi {
10 | get (key: Key, defaultValue?: StoreDefinition[Key]): StoreDefinition[Key]
11 | get (key: Key, defaultValue?: Value): Value
12 | set (key: Key, value: StoreDefinition[Key]): void
13 | watch (key: Key, cb: (newValue?: StoreDefinition[Key]) => void): () => void
14 | }
15 |
--------------------------------------------------------------------------------
/packages/media/app/shared/types/ipc.d.ts:
--------------------------------------------------------------------------------
1 | import { ImageDTO, MediaDetailsDTO, VideoDTO } from '@library-api/core/types/dto'
2 |
3 | interface MediaDisplayProps {
4 | id: string
5 | src: string
6 | text: string
7 | downloaded: boolean
8 | }
9 |
10 | export type VideoDetails = MediaDisplayProps & { details: MediaDetailsDTO }
11 |
12 | export type IPCImageDTO = ImageDTO & MediaDisplayProps
13 |
14 | export type IPCVideoDTO = VideoDTO & VideoDetails
15 |
16 | interface Invoke {
17 | Args?: unknown
18 | Response: unknown
19 | }
20 |
21 | interface Send {
22 | Args?: unknown
23 | }
24 |
25 | export interface CatalogUpdate extends Invoke {
26 | Response: boolean
27 | }
28 |
29 | export interface PublicationMedia extends Invoke {
30 | Args: {
31 | date: string
32 | type: 'wt' | 'oclm'
33 | languageId: number
34 | }
35 | Response: { videos: IPCVideoDTO[], images: IPCImageDTO[] } | null
36 | }
37 |
38 | export interface DownloadVideo extends Invoke {
39 | Args: {
40 | type: VideoDTO['type']
41 | doc: string | number
42 | track: number
43 | issue: number
44 | languageId: number
45 | details: MediaDetailsDTO
46 | }
47 | Response: void
48 | }
49 |
50 | export interface DownloadSong extends Invoke {
51 | Args: {
52 | details: MediaDetailsDTO
53 | track: number
54 | languageId: number
55 | }
56 | Response: void
57 | }
58 |
59 | export interface SongDetails extends Invoke {
60 | Args: {
61 | track: number
62 | languageId: number
63 | }
64 | Response: VideoDetails
65 | }
66 |
67 | export interface SettingsClearDownloads extends Invoke {
68 | Args: void
69 | Response: void
70 | }
71 |
72 | export interface MediaPick extends Invoke {
73 | Args?: never
74 | Response: boolean
75 | }
76 |
77 | export interface MediaImage extends Send {
78 | Args: {
79 | src: string
80 | }
81 | }
82 |
83 | export interface MediaVideo extends Send {
84 | Args: {
85 | details: MediaDetailsDTO
86 | }
87 | }
88 |
89 | export interface MediaClear extends Send {
90 | Args?: never
91 | }
92 |
93 | export interface DisplayMedia extends Send {
94 | Args: {
95 | src: string
96 | }
97 | }
98 |
99 | export interface DisplayClear extends Send {
100 | Args?: never
101 | }
102 |
--------------------------------------------------------------------------------
/packages/media/app/shared/types/store.d.ts:
--------------------------------------------------------------------------------
1 | interface WindowPosition {
2 | x: number
3 | y: number
4 | width: number
5 | height: number
6 | }
7 |
8 | export interface StoreDefinition {
9 | controlWindow: WindowPosition
10 | displayWindow: WindowPosition
11 | controlPanel: {
12 | showImages: 'all' | 'display'
13 | languageId: number
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/media/app/shared/types/window.d.ts:
--------------------------------------------------------------------------------
1 | declare interface Window {
2 | electron: Readonly
3 | log: import('electron-log').LogFunctions
4 | store: import('./electron-api').StoreApi
5 | languages: (import('@library-api/core/types/dto').LanguageDTO)[]
6 | }
7 |
--------------------------------------------------------------------------------
/packages/media/buildResources/entitlements.mac.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.cs.allow-unsigned-executable-memory
6 |
7 | com.apple.security.cs.disable-library-validation
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/packages/media/buildResources/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BenShelton/library-api/bafb9731bc1166069f3bc6dff4796f31fd5907ed/packages/media/buildResources/icon.png
--------------------------------------------------------------------------------
/packages/media/electron-builder.package.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type { import('electron-builder').Configuration }
3 | */
4 | const config = {
5 | productName: 'Library Media',
6 | directories: {
7 | output: 'dist',
8 | buildResources: 'buildResources'
9 | },
10 | files: [
11 | 'app/**/dist/**'
12 | ],
13 | mac: {
14 | hardenedRuntime: true,
15 | gatekeeperAssess: false,
16 | entitlements: 'buildResources/entitlements.mac.plist',
17 | entitlementsInherit: 'buildResources/entitlements.mac.plist'
18 | }
19 | }
20 |
21 | module.exports = config
22 |
--------------------------------------------------------------------------------
/packages/media/electron-builder.release.config.js:
--------------------------------------------------------------------------------
1 | const packageConfig = require('./electron-builder.package.config')
2 |
3 | /**
4 | * @type { import('electron-builder').Configuration }
5 | */
6 | const config = {
7 | ...packageConfig,
8 | dmg: {
9 | sign: false
10 | },
11 | forceCodeSigning: true,
12 | afterSign: 'scripts/notarize.js',
13 | electronUpdaterCompatibility: '>= 2.16',
14 | publish: {
15 | provider: 'github',
16 | releaseType: 'release'
17 | }
18 | }
19 |
20 | module.exports = config
21 |
--------------------------------------------------------------------------------
/packages/media/jest.config.ts:
--------------------------------------------------------------------------------
1 | import { Config } from '@jest/types'
2 | import { pathsToModuleNameMapper } from 'ts-jest/utils'
3 |
4 | import baseConfig from '../../jest.config.base'
5 | import { compilerOptions } from './test/tsconfig.json'
6 |
7 | const config: Config.InitialOptions = {
8 | ...baseConfig,
9 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' })
10 | }
11 |
12 | export default config
13 |
--------------------------------------------------------------------------------
/packages/media/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@library-api/media",
3 | "productName": "Library Media",
4 | "private": true,
5 | "version": "0.11.0",
6 | "description": "A cross-platform application that simplifies showing media for meetings of Jehovah's Witnesses.",
7 | "main": "app/main/dist/index.cjs",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/BenShelton/library-api",
11 | "directory": "packages/media"
12 | },
13 | "scripts": {
14 | "build": "node scripts/build.js",
15 | "dev": "node scripts/watch.js",
16 | "start": "electron dist/index.js",
17 | "package": "yarn build && electron-builder build --dir --config electron-builder.package.config.js",
18 | "release": "electron-builder build -m --config electron-builder.release.config.js",
19 | "lint": "eslint . --ext .js,.ts,.vue",
20 | "lint:fix": "yarn lint --fix",
21 | "test": "jest --config=jest.config.ts",
22 | "test:bail": "yarn test --bail",
23 | "test:ci": "yarn lint && yarn tsc && yarn test:bail",
24 | "test:watch": "yarn test --watch-all -t",
25 | "test:staged": "yarn test --bail --findRelatedTests",
26 | "tsc": "yarn tsc:main && yarn tsc:preload && yarn tsc:renderer",
27 | "tsc:main": "tsc -p app/main/tsconfig.json",
28 | "tsc:preload": "tsc -p app/preload/tsconfig.json",
29 | "tsc:renderer": "vue-tsc --noEmit -p app/renderer/tsconfig.json"
30 | },
31 | "dependencies": {
32 | "@library-api/core": "^0.11.0",
33 | "electron-aspect-ratio-browser-window": "^1.0.2",
34 | "electron-log": "^4.3.5",
35 | "electron-store": "^8.0.0",
36 | "electron-updater": "^4.3.9",
37 | "vue": "3.0.11",
38 | "vue-router": "^4.0.9"
39 | },
40 | "devDependencies": {
41 | "@vitejs/plugin-vue": "^1.2.3",
42 | "@vue/compiler-sfc": "3.0.11",
43 | "dotenv": "^10.0.0",
44 | "electron": "^13.1.2",
45 | "electron-builder": "^22.11.7",
46 | "electron-notarize": "^1.0.0",
47 | "eslint-plugin-vue": "^7.11.1",
48 | "vite": "^2.3.8",
49 | "vue-tsc": "0.1.0"
50 | },
51 | "author": {
52 | "name": "BenShelton",
53 | "email": "bensheltonjones@gmail.com"
54 | },
55 | "keywords": [
56 | "jw",
57 | "media",
58 | "library",
59 | "meetings",
60 | "publications",
61 | "api",
62 | "electron",
63 | "cross-platform"
64 | ],
65 | "license": "MIT"
66 | }
67 |
--------------------------------------------------------------------------------
/packages/media/scripts/build.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/node
2 | const { build } = require('vite')
3 | const { dirname } = require('path')
4 |
5 | /** @type 'production' | 'development' | 'test' */
6 | const mode = process.env.MODE = process.env.MODE || 'production'
7 |
8 | const packagesConfigs = [
9 | 'app/main/vite.config.ts',
10 | 'app/preload/vite.config.ts',
11 | 'app/renderer/vite.config.ts'
12 | ]
13 |
14 | /**
15 | * Run `vite build` for config file
16 | */
17 | const buildByConfig = (configFile) => build({ configFile, mode });
18 | (async () => {
19 | try {
20 | const totalTimeLabel = 'Total bundling time'
21 | console.time(totalTimeLabel)
22 |
23 | for (const packageConfigPath of packagesConfigs) {
24 | const consoleGroupName = `${dirname(packageConfigPath)}/`
25 | console.group(consoleGroupName)
26 |
27 | const timeLabel = 'Bundling time'
28 | console.time(timeLabel)
29 |
30 | await buildByConfig(packageConfigPath)
31 |
32 | console.timeEnd(timeLabel)
33 | console.groupEnd()
34 | console.log('\n') // Just for pretty print
35 | }
36 | console.timeEnd(totalTimeLabel)
37 | } catch (e) {
38 | console.error(e)
39 | process.exit(1)
40 | }
41 | })()
42 |
--------------------------------------------------------------------------------
/packages/media/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config()
2 | const { notarize } = require('electron-notarize')
3 |
4 | /**
5 | * @param { import('electron-builder').AfterPackContext } context
6 | */
7 | module.exports = async function notarizing (context) {
8 | const { electronPlatformName, appOutDir } = context
9 | if (electronPlatformName !== 'darwin') {
10 | return
11 | }
12 |
13 | const appName = context.packager.appInfo.productFilename
14 |
15 | return await notarize({
16 | appBundleId: 'com.benshelton.library-media',
17 | appPath: `${appOutDir}/${appName}.app`,
18 | appleId: process.env.APPLE_ID,
19 | appleIdPassword: process.env.APPLE_ID_PASS
20 | })
21 | }
22 |
--------------------------------------------------------------------------------
/packages/media/scripts/watch.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/node
2 |
3 | const { createServer, build, createLogger } = require('vite')
4 | const electronPath = require('electron')
5 | const { spawn } = require('child_process')
6 |
7 | /** @type 'production' | 'development' | 'test' */
8 | const mode = process.env.MODE = process.env.MODE || 'development'
9 |
10 | /** @type { import('vite').LogLevel } */
11 | const LOG_LEVEL = 'warn'
12 |
13 | /** @type { import('vite').InlineConfig } */
14 | const sharedConfig = {
15 | mode,
16 | build: {
17 | watch: {}
18 | },
19 | logLevel: LOG_LEVEL
20 | }
21 |
22 | /**
23 | * @returns { Promise | import('vite').RollupWatcher> }
24 | */
25 | const getWatcher = ({ name, configFile, writeBundle }) => {
26 | return build({
27 | ...sharedConfig,
28 | configFile,
29 | plugins: [{ name, writeBundle }]
30 | })
31 | }
32 |
33 | /**
34 | * Start or restart App when source files are changed
35 | * @param { import('vite').ViteDevServer } viteDevServer
36 | * @returns { Promise | import('vite').RollupWatcher> }
37 | */
38 | const setupMainPackageWatcher = (viteDevServer) => {
39 | // Write a value to an environment variable to pass it to the main process.
40 | {
41 | const protocol = `http${viteDevServer.config.server.https ? 's' : ''}:`
42 | const host = viteDevServer.config.server.host || 'localhost'
43 | const port = viteDevServer.config.server.port // Vite searches for and occupies the first free port: 3000, 3001, 3002 and so on
44 | const path = '/'
45 | process.env.VITE_DEV_SERVER_URL = `${protocol}//${host}:${port}${path}`
46 | }
47 |
48 | const logger = createLogger(LOG_LEVEL, {
49 | prefix: '[main]'
50 | })
51 |
52 | /** @type { ChildProcessWithoutNullStreams | null } */
53 | let spawnProcess = null
54 |
55 | return getWatcher({
56 | name: 'reload-app-on-main-package-change',
57 | configFile: 'app/main/vite.config.ts',
58 | writeBundle () {
59 | if (spawnProcess !== null) {
60 | spawnProcess.kill('SIGINT')
61 | spawnProcess = null
62 | }
63 |
64 | spawnProcess = spawn(String(electronPath), ['.'])
65 |
66 | spawnProcess.stdout.on('data', d => d.toString().trim() && logger.warn(d.toString(), { timestamp: true }))
67 | spawnProcess.stderr.on('data', d => d.toString().trim() && logger.error(d.toString(), { timestamp: true }))
68 | }
69 | })
70 | }
71 |
72 | /**
73 | * Start or restart App when source files are changed
74 | * @param { import('vite').ViteDevServer } viteDevServer
75 | * @returns { Promise | import('vite').RollupWatcher> }
76 | */
77 | const setupPreloadPackageWatcher = (viteDevServer) => {
78 | return getWatcher({
79 | name: 'reload-page-on-preload-package-change',
80 | configFile: 'app/preload/vite.config.ts',
81 | writeBundle () {
82 | viteDevServer.ws.send({
83 | type: 'full-reload'
84 | })
85 | }
86 | })
87 | };
88 |
89 | (async () => {
90 | try {
91 | const viteDevServer = await createServer({
92 | ...sharedConfig,
93 | configFile: 'app/renderer/vite.config.ts'
94 | })
95 |
96 | await viteDevServer.listen()
97 |
98 | await setupPreloadPackageWatcher(viteDevServer)
99 | await setupMainPackageWatcher(viteDevServer)
100 | } catch (e) {
101 | console.error(e)
102 | process.exit(1)
103 | }
104 | })()
105 |
--------------------------------------------------------------------------------
/packages/media/test/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: '../.eslintrc.js',
3 | plugins: [
4 | 'jest'
5 | ],
6 | env: {
7 | 'jest/globals': true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/media/test/renderer/utils/date.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | closestPreviousMonday,
3 | isWeekend
4 | } from '@renderer/utils/date'
5 |
6 | /**
7 | * '2021-05-17' = Mon 17th May 2021
8 | * '2021-05-18' = Tue 18th May 2021
9 | * '2021-05-19' = Wed 19th May 2021
10 | * '2021-05-20' = Thu 20th May 2021
11 | * '2021-05-21' = Fri 21st May 2021
12 | * '2021-05-22' = Sat 22nd May 2021
13 | * '2021-05-23' = Sun 23rd May 2021
14 | * '2021-05-24' = Mon 24th May 2021
15 | */
16 |
17 | describe('Renderer/Utils: Date', () => {
18 | describe('closestPreviousMonday', () => {
19 | test('should return the closest previous monday', () => {
20 | expect(closestPreviousMonday(new Date('2021-05-17')).toISOString()).toContain('2021-05-17')
21 | expect(closestPreviousMonday(new Date('2021-05-18')).toISOString()).toContain('2021-05-17')
22 | expect(closestPreviousMonday(new Date('2021-05-19')).toISOString()).toContain('2021-05-17')
23 | expect(closestPreviousMonday(new Date('2021-05-20')).toISOString()).toContain('2021-05-17')
24 | expect(closestPreviousMonday(new Date('2021-05-21')).toISOString()).toContain('2021-05-17')
25 | expect(closestPreviousMonday(new Date('2021-05-22')).toISOString()).toContain('2021-05-17')
26 | expect(closestPreviousMonday(new Date('2021-05-23')).toISOString()).toContain('2021-05-17')
27 | expect(closestPreviousMonday(new Date('2021-05-24')).toISOString()).toContain('2021-05-24')
28 |
29 | expect(closestPreviousMonday(new Date('2021-01-01')).toISOString()).toContain('2020-12-28')
30 | })
31 | })
32 |
33 | describe('isWeekend', () => {
34 | test('should return true if a weekend', () => {
35 | expect(isWeekend(new Date('2021-05-17'))).toBe(false)
36 | expect(isWeekend(new Date('2021-05-18'))).toBe(false)
37 | expect(isWeekend(new Date('2021-05-19'))).toBe(false)
38 | expect(isWeekend(new Date('2021-05-20'))).toBe(false)
39 | expect(isWeekend(new Date('2021-05-21'))).toBe(false)
40 | expect(isWeekend(new Date('2021-05-22'))).toBe(true)
41 | expect(isWeekend(new Date('2021-05-23'))).toBe(true)
42 | expect(isWeekend(new Date('2021-05-24'))).toBe(false)
43 | })
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/packages/media/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "baseUrl": "../",
6 | "rootDir": "../",
7 | "paths": {
8 | "@main/*": [
9 | "app/main/src/*"
10 | ],
11 | "@preload/*": [
12 | "app/preload/src/*"
13 | ],
14 | "@renderer/*": [
15 | "app/renderer/src/*"
16 | ],
17 | "types/*": [
18 | "app/renderer/types/*"
19 | ]
20 | },
21 | "types": [
22 | "node",
23 | "jest"
24 | ]
25 | },
26 | "include": [
27 | ".",
28 | "../app"
29 | ],
30 | "exclude": []
31 | }
32 |
--------------------------------------------------------------------------------
/packages/media/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "noEmit": true,
6 | "types": [
7 | "vite/client",
8 | "node"
9 | ]
10 | },
11 | "include": [],
12 | "exclude": [],
13 | "references": [
14 | {
15 | "path": "../core/tsconfig.json"
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "esModuleInterop": true,
5 | "allowSyntheticDefaultImports": true,
6 | "target": "es2020",
7 | "noImplicitAny": true,
8 | "moduleResolution": "node",
9 | "importHelpers": true,
10 | "sourceMap": true,
11 | "declaration": true,
12 | "declarationMap": true,
13 | "composite": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "baseUrl": ".",
16 | "resolveJsonModule": true,
17 | "strict": true,
18 | "types": [
19 | "node"
20 | ]
21 | },
22 | "include": [],
23 | "exclude": []
24 | }
25 |
--------------------------------------------------------------------------------
/vetur.config.js:
--------------------------------------------------------------------------------
1 | /** @type { import('vls').VeturConfig } */
2 | module.exports = {
3 | settings: {
4 | 'vetur.useWorkspaceDependencies': true,
5 | 'vetur.experimental.templateInterpolationService': true,
6 | 'vetur.validation.template': false,
7 | 'vetur.validation.templateProps': true
8 | },
9 | projects: [
10 | {
11 | root: './packages/media/app/renderer',
12 | package: '../../package.json',
13 | tsconfig: './tsconfig.json'
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------