karaoke-eternal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +49 -0
  3. package/assets/app.ico +0 -0
  4. package/assets/app.png +0 -0
  5. package/assets/favicon.ico +0 -0
  6. package/assets/mic-blackTemplate.png +0 -0
  7. package/assets/mic-blackTemplate@2x.png +0 -0
  8. package/assets/mic-white.png +0 -0
  9. package/assets/mic-white@2x.png +0 -0
  10. package/assets/robots.txt +2 -0
  11. package/build/267.4be526e3a94d53aeceae.js +1 -0
  12. package/build/591.4be526e3a94d53aeceae.js +1 -0
  13. package/build/598.4be526e3a94d53aeceae.css +5 -0
  14. package/build/598.4be526e3a94d53aeceae.js +1 -0
  15. package/build/799.4be526e3a94d53aeceae.js +1 -0
  16. package/build/7ce9eb3fe454f54745a4.woff2 +0 -0
  17. package/build/845.4be526e3a94d53aeceae.css +132 -0
  18. package/build/845.4be526e3a94d53aeceae.js +1 -0
  19. package/build/a35814dd9eb496e3d7cc.woff2 +0 -0
  20. package/build/e419b95dccb58b362811.woff2 +0 -0
  21. package/build/index.html +1 -0
  22. package/build/licenses.txt +1400 -0
  23. package/build/main.4be526e3a94d53aeceae.css +2034 -0
  24. package/build/main.4be526e3a94d53aeceae.js +1 -0
  25. package/package.json +144 -0
  26. package/server/Library/Library.js +340 -0
  27. package/server/Library/index.js +3 -0
  28. package/server/Library/ipc.js +18 -0
  29. package/server/Library/router.js +27 -0
  30. package/server/Library/socket.js +47 -0
  31. package/server/Media/Media.js +207 -0
  32. package/server/Media/index.js +3 -0
  33. package/server/Media/ipc.js +19 -0
  34. package/server/Media/router.js +99 -0
  35. package/server/Player/socket.js +78 -0
  36. package/server/Prefs/Prefs.js +165 -0
  37. package/server/Prefs/index.js +3 -0
  38. package/server/Prefs/router.js +124 -0
  39. package/server/Prefs/socket.js +68 -0
  40. package/server/Queue/Queue.js +208 -0
  41. package/server/Queue/index.js +3 -0
  42. package/server/Queue/socket.js +99 -0
  43. package/server/Rooms/Rooms.js +114 -0
  44. package/server/Rooms/index.js +3 -0
  45. package/server/Rooms/router.js +146 -0
  46. package/server/Scanner/FileScanner/FileScanner.js +225 -0
  47. package/server/Scanner/FileScanner/getConfig.js +35 -0
  48. package/server/Scanner/FileScanner/getFiles.js +63 -0
  49. package/server/Scanner/FileScanner/index.js +3 -0
  50. package/server/Scanner/MetaParser/MetaParser.js +49 -0
  51. package/server/Scanner/MetaParser/defaultMiddleware.js +197 -0
  52. package/server/Scanner/MetaParser/index.js +3 -0
  53. package/server/Scanner/Scanner.js +33 -0
  54. package/server/User/User.js +139 -0
  55. package/server/User/index.js +3 -0
  56. package/server/User/router.js +442 -0
  57. package/server/lib/Database.js +55 -0
  58. package/server/lib/IPCBridge.js +115 -0
  59. package/server/lib/Log.js +71 -0
  60. package/server/lib/bcrypt.js +24 -0
  61. package/server/lib/cli.js +136 -0
  62. package/server/lib/electron.js +81 -0
  63. package/server/lib/getCdgName.js +20 -0
  64. package/server/lib/getDevMiddleware.js +51 -0
  65. package/server/lib/getFolders.js +10 -0
  66. package/server/lib/getHotMiddleware.js +27 -0
  67. package/server/lib/getIPAddress.js +16 -0
  68. package/server/lib/getPermutations.js +21 -0
  69. package/server/lib/getWindowsDrives.js +30 -0
  70. package/server/lib/parseCookie.js +12 -0
  71. package/server/lib/pushQueuesAndLibrary.js +29 -0
  72. package/server/lib/schemas/001-initial-schema.sql +98 -0
  73. package/server/lib/schemas/002-replaygain.sql +9 -0
  74. package/server/lib/schemas/003-queue-linked-list.sql +16 -0
  75. package/server/main.js +135 -0
  76. package/server/scannerWorker.js +58 -0
  77. package/server/serverWorker.js +242 -0
  78. package/server/socket.js +173 -0
  79. package/shared/actionTypes.js +103 -0
@@ -0,0 +1,225 @@
1
+ const path = require('path')
2
+ const log = require('../../lib/Log')('FileScanner')
3
+ const musicMeta = require('music-metadata')
4
+ const getFiles = require('./getFiles')
5
+ const getConfig = require('./getConfig')
6
+ const getPerms = require('../../lib/getPermutations')
7
+ const getCdgName = require('../../lib/getCdgName')
8
+ const Media = require('../../Media')
9
+ const MetaParser = require('../MetaParser')
10
+ const Scanner = require('../Scanner')
11
+ const IPC = require('../../lib/IPCBridge')
12
+ const {
13
+ LIBRARY_MATCH_SONG,
14
+ MEDIA_ADD,
15
+ MEDIA_CLEANUP,
16
+ MEDIA_REMOVE,
17
+ MEDIA_UPDATE,
18
+ } = require('../../../shared/actionTypes')
19
+
20
+ const searchExts = ['mp4', 'm4a', 'mp3']
21
+ const searchExtPerms = searchExts.reduce((perms, ext) => perms.concat(getPerms(ext)), [])
22
+
23
+ class FileScanner extends Scanner {
24
+ constructor (prefs) {
25
+ super()
26
+ this.paths = prefs.paths
27
+ }
28
+
29
+ async scan () {
30
+ const files = new Map() // pathId => [files]
31
+ const valid = [] // mediaIds
32
+ let i = 0 // file counter
33
+ let numTotal = 0
34
+ let numAdded = 0
35
+
36
+ // get list of files from all paths
37
+ for (const pathId of this.paths.result) {
38
+ const basePath = this.paths.entities[pathId].path
39
+
40
+ this.emitStatus(`Listing folder ${this.paths.result.indexOf(pathId) + 1} of ${this.paths.result.length}`, 0)
41
+ log.info('Searching path: %s', basePath)
42
+
43
+ try {
44
+ const list = await getFiles(basePath, file => searchExtPerms.some(ext => file.endsWith('.' + ext)))
45
+ files.set(pathId, list)
46
+ numTotal += list.length
47
+
48
+ log.info(' => found %s files with valid extensions %s',
49
+ list.length.toLocaleString(),
50
+ JSON.stringify(searchExts)
51
+ )
52
+ } catch (err) {
53
+ log.error(` => ${err.message} (path offline)`)
54
+ }
55
+
56
+ if (this.isCanceling) {
57
+ return
58
+ }
59
+ } // end for
60
+
61
+ log.info('Processing %s files', numTotal.toLocaleString())
62
+
63
+ for (const [pathId, list] of files) {
64
+ let lastDir
65
+
66
+ for (const item of list) {
67
+ i++
68
+ log.info('[%s/%s] %s', i, numTotal, item.file)
69
+ this.emitStatus(`Scanning media (${i.toLocaleString()} of ${numTotal.toLocaleString()})`, (i / numTotal) * 100)
70
+
71
+ const dir = path.dirname(item.file)
72
+
73
+ if (lastDir !== dir) {
74
+ lastDir = dir
75
+
76
+ // (re)init parser with this folder's config, if any
77
+ const cfg = getConfig(dir, this.paths.entities[pathId].path)
78
+ this.parser = new MetaParser(cfg)
79
+ }
80
+
81
+ // process file
82
+ try {
83
+ const res = await this.process(item, pathId)
84
+
85
+ // success
86
+ valid.push(res.mediaId)
87
+ if (res.isNew) numAdded++
88
+ } catch (err) {
89
+ log.warn(` => ${err.message}: ${item.file}`)
90
+ }
91
+
92
+ if (this.isCanceling) {
93
+ this.emitStatus(`Stopped (${numAdded} new media)`, 100, false)
94
+ return
95
+ }
96
+ } // end for
97
+ } // end for
98
+
99
+ log.info('Processed %s valid media files', valid.length.toLocaleString())
100
+
101
+ const numRemoved = await this.removeInvalid(valid, Array.from(files.keys()))
102
+
103
+ this.emitStatus(`Finished (${numAdded} new, ${numRemoved} removed, ${valid.length} total media)`, 100, false)
104
+
105
+ await IPC.req({ type: MEDIA_CLEANUP })
106
+ }
107
+
108
+ async process (item, pathId) {
109
+ const data = await musicMeta.parseFile(item.file, {
110
+ duration: true,
111
+ skipCovers: true,
112
+ })
113
+
114
+ if (!data.format.duration) {
115
+ throw new Error('could not determine duration')
116
+ }
117
+
118
+ log.verbose(' => duration: %s:%s',
119
+ Math.floor(data.format.duration / 60),
120
+ Math.round(data.format.duration % 60, 10).toString().padStart(2, '0')
121
+ )
122
+
123
+ // run MetaParser
124
+ const pathInfo = path.parse(item.file)
125
+ const parsed = this.parser({
126
+ dir: pathInfo.dir,
127
+ dirSep: path.sep,
128
+ name: pathInfo.name,
129
+ data: data.common,
130
+ })
131
+
132
+ // get artistId and songId
133
+ const match = await IPC.req({ type: LIBRARY_MATCH_SONG, payload: parsed })
134
+
135
+ const media = {
136
+ songId: match.songId,
137
+ pathId,
138
+ // normalize relPath to forward slashes with no leading slash
139
+ relPath: item.file.substring(this.paths.entities[pathId].path.length).replace(/\\/g, '/').replace(/^\//, ''),
140
+ duration: Math.round(data.format.duration),
141
+ rgTrackGain: data.common.replaygain_track_gain ? data.common.replaygain_track_gain.dB : null,
142
+ rgTrackPeak: data.common.replaygain_track_peak ? data.common.replaygain_track_peak.ratio : null,
143
+ }
144
+
145
+ // need to look for .cdg if this is an audio-only file
146
+ if (!/\.mp4/i.test(path.extname(item.file))) {
147
+ if (!await getCdgName(item.file)) {
148
+ throw new Error('no .cdg sidecar found; skipping')
149
+ }
150
+
151
+ log.verbose(' => found .cdg sidecar')
152
+ }
153
+
154
+ // file already in database?
155
+ const res = await Media.search({
156
+ pathId,
157
+ relPath: media.relPath,
158
+ })
159
+
160
+ log.verbose(' => %s db result(s)', res.result.length)
161
+
162
+ if (res.result.length) {
163
+ const row = res.entities[res.result[0]]
164
+ const diff = {}
165
+
166
+ // did anything change?
167
+ Object.keys(media).forEach(key => {
168
+ if (media[key] !== row[key]) diff[key] = media[key]
169
+ })
170
+
171
+ if (Object.keys(diff).length) {
172
+ await IPC.req({
173
+ type: MEDIA_UPDATE,
174
+ payload: {
175
+ mediaId: row.mediaId,
176
+ dateUpdated: Math.round(new Date().getTime() / 1000), // seconds
177
+ ...diff,
178
+ }
179
+ })
180
+
181
+ log.info(' => updated: %s', Object.keys(diff).join(', '))
182
+ } else {
183
+ log.info(' => ok')
184
+ }
185
+
186
+ return { mediaId: row.mediaId, isNew: false }
187
+ } // end if
188
+
189
+ // new media
190
+ // ---------
191
+ media.dateAdded = Math.round(new Date().getTime() / 1000) // seconds
192
+ log.info(' => new: %s', JSON.stringify(match))
193
+
194
+ return {
195
+ mediaId: await IPC.req({ type: MEDIA_ADD, payload: media }),
196
+ isNew: true
197
+ }
198
+ }
199
+
200
+ async removeInvalid (validMedia = [], validPaths = []) {
201
+ log.info('Searching for invalid media entries')
202
+
203
+ const res = await Media.search()
204
+ const invalidMedia = []
205
+
206
+ res.result.forEach(mediaId => {
207
+ // just validated or in an offline path?
208
+ if (validMedia.includes(mediaId) || !validPaths.includes(res.entities[mediaId].pathId)) {
209
+ return
210
+ }
211
+
212
+ invalidMedia.push(mediaId)
213
+ })
214
+
215
+ log.info(`Found ${invalidMedia.length} invalid media entries`)
216
+
217
+ if (invalidMedia.length) {
218
+ await IPC.req({ type: MEDIA_REMOVE, payload: invalidMedia })
219
+ }
220
+
221
+ return invalidMedia.length
222
+ }
223
+ }
224
+
225
+ module.exports = FileScanner
@@ -0,0 +1,35 @@
1
+ const path = require('path')
2
+ const log = require('../../lib/Log')('FileScanner')
3
+ const fs = require('fs')
4
+ const { NodeVM } = require('vm2')
5
+ const CONFIG = '_kes.v1.js'
6
+
7
+ // search each folder from dir up to baseDir
8
+ function getConfig (dir, baseDir) {
9
+ dir = path.normalize(dir)
10
+ baseDir = path.normalize(baseDir)
11
+ const cfgPath = path.resolve(dir, CONFIG)
12
+
13
+ try {
14
+ const userScript = fs.readFileSync(cfgPath, 'utf-8')
15
+ log.info(' => using config: %s', cfgPath)
16
+
17
+ try {
18
+ const vm = new NodeVM({ wrapper: 'none' })
19
+ return vm.run(userScript)
20
+ } catch (err) {
21
+ log.error(err)
22
+ }
23
+ } catch (err) {
24
+ log.verbose(` => no config in ${dir}`)
25
+ }
26
+
27
+ if (dir === baseDir) {
28
+ return
29
+ }
30
+
31
+ // try parent dir
32
+ return getConfig(path.resolve(dir, '..'), baseDir)
33
+ }
34
+
35
+ module.exports = getConfig
@@ -0,0 +1,63 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const log = require('../../lib/Log')('FileScanner:getFiles')
4
+
5
+ /**
6
+ * Silly promise wrapper for synchronous walker
7
+ *
8
+ * We want a synchronous walker for performance, but FileScanner runs
9
+ * the walker in a loop, which will block the (async) socket.io status
10
+ * emissions unless we use setTimeout here. @todo is there a better way?
11
+ *
12
+ * @param {string} dir path to recursively list
13
+ * @param {function} filterFn filter function applied to each file
14
+ * @return {array} array of objects with path and stat properties
15
+ */
16
+ function getFiles (dir, filterFn) {
17
+ return new Promise((resolve, reject) => {
18
+ setTimeout(() => {
19
+ try {
20
+ resolve(walkSync(dir, filterFn))
21
+ } catch (err) {
22
+ reject(err)
23
+ }
24
+ }, 0)
25
+ })
26
+ }
27
+
28
+ /**
29
+ * Directory walker that only throws if parent directory
30
+ * can't be read. Errors stat-ing children are only logged.
31
+ */
32
+ function walkSync (dir, filterFn) {
33
+ let results = []
34
+ const list = fs.readdirSync(dir)
35
+
36
+ list.forEach(file => {
37
+ let stats
38
+ file = path.join(dir, file)
39
+
40
+ try {
41
+ stats = fs.statSync(file)
42
+ } catch (err) {
43
+ log.warn(err.message)
44
+ return
45
+ }
46
+
47
+ if (stats && stats.isDirectory()) {
48
+ try {
49
+ results = results.concat(walkSync(file, filterFn))
50
+ } catch (err) {
51
+ log.warn(err.message)
52
+ }
53
+ } else {
54
+ if (!filterFn || filterFn(file)) {
55
+ results.push({ file, stats })
56
+ }
57
+ }
58
+ })
59
+
60
+ return results
61
+ }
62
+
63
+ module.exports = getFiles
@@ -0,0 +1,3 @@
1
+ const FileScanner = require('./FileScanner')
2
+
3
+ module.exports = FileScanner
@@ -0,0 +1,49 @@
1
+ const { composeSync } = require('ctx-compose')
2
+ const defaultMiddleware = require('./defaultMiddleware')
3
+ const defaultParser = compose(...defaultMiddleware.values())
4
+
5
+ function compose (...args) {
6
+ const flattened = args.reduce(
7
+ (accumulator, currentValue) => accumulator.concat(currentValue), []
8
+ )
9
+
10
+ return composeSync(flattened)
11
+ }
12
+
13
+ // default parser creator
14
+ function getDefaultParser (cfg = {}) {
15
+ if (typeof cfg.articles === 'undefined') {
16
+ cfg.articles = ['A', 'An', 'The']
17
+ }
18
+
19
+ return (ctx, next) => {
20
+ Object.assign(ctx.cfg, cfg)
21
+ return defaultParser(ctx, next)
22
+ }
23
+ }
24
+
25
+ class MetaParser {
26
+ constructor (cfg = {}) {
27
+ const parser = typeof cfg === 'function'
28
+ ? cfg({ compose, getDefaultParser, defaultMiddleware })
29
+ : getDefaultParser(cfg)
30
+
31
+ return input => {
32
+ const ctx = { ...input, cfg }
33
+ parser(ctx)
34
+
35
+ if (!ctx.artist || !ctx.title) {
36
+ throw new Error('could not determine artist or title')
37
+ }
38
+
39
+ return {
40
+ artist: ctx.artist,
41
+ artistNorm: ctx.artistNorm || ctx.artist,
42
+ title: ctx.title,
43
+ titleNorm: ctx.titleNorm || ctx.title,
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ module.exports = MetaParser
@@ -0,0 +1,197 @@
1
+ const m = module.exports = new Map()
2
+
3
+ // ----------------------
4
+ // begin middleware stack
5
+ // ----------------------
6
+
7
+ m.set('normalize whitespace', (ctx, next) => {
8
+ ctx.name = ctx.name.replace(/_/g, ' ') // underscores to spaces
9
+ ctx.name = ctx.name.replace(/ {2,}/g, ' ') // multiple spaces to single
10
+ next()
11
+ })
12
+
13
+ m.set('de-karaoke', (ctx, next) => {
14
+ // 'karaoke' or 'vocal' surrounded by (), [], or {}
15
+ ctx.name = ctx.name.replace(/[([{](?=[^([{]*$).*(?:karaoke|vocal).*[)\]}]/i, '')
16
+ next()
17
+ })
18
+
19
+ // --------------
20
+ // parse
21
+ // --------------
22
+
23
+ // detect delimiter and split to parts
24
+ m.set('split', (ctx, next) => {
25
+ const inTheStyleOf = ctx.name.match(/ in the style of /i)
26
+
27
+ ctx.cfg = {
28
+ delimiter: inTheStyleOf ? inTheStyleOf[0] : '-',
29
+ artistOnLeft: !inTheStyleOf,
30
+ ...ctx.cfg,
31
+ }
32
+
33
+ // allow leading and/or trailing space when searching for delimiter,
34
+ // then pick the match with the most whitespace (longest match) as it's
35
+ // most likely to be the actual delimiter rather than a false positive
36
+ const d = ctx.cfg.delimiter instanceof RegExp ? ctx.cfg.delimiter : new RegExp(` ?${ctx.cfg.delimiter} ?`, 'g')
37
+ const matches = ctx.name.match(d)
38
+
39
+ if (!matches) {
40
+ throw new Error('no artist/title delimiter in filename')
41
+ }
42
+
43
+ const longest = matches.reduce((a, b) => a.length > b.length ? a : b)
44
+ ctx.parts = ctx.name.split(longest)
45
+
46
+ if (ctx.parts.length < 2) {
47
+ throw new Error('no artist/title delimiter in filename')
48
+ }
49
+
50
+ next()
51
+ })
52
+
53
+ m.set('clean parts', cleanParts([
54
+ /^\d*\.?$/, // looks like a track number
55
+ /^\W*$/, // all non-word chars
56
+ /^[a-zA-Z]{2,4}[ -]?\d{1,}/i, // 2-4 letters followed by 1 or more digits
57
+ ]))
58
+
59
+ // set title
60
+ m.set('set title', (ctx, next) => {
61
+ // skip if already set
62
+ if (ctx.title) return next()
63
+
64
+ // @todo this assumes delimiter won't appear in title
65
+ ctx.title = ctx.cfg.artistOnLeft ? ctx.parts.pop() : ctx.parts.shift()
66
+ ctx.title = ctx.title.trim()
67
+ next()
68
+ })
69
+
70
+ // set arist
71
+ m.set('set artist', (ctx, next) => {
72
+ // skip if already set
73
+ if (ctx.artist) return next()
74
+
75
+ ctx.artist = ctx.parts.join(ctx.cfg.delimiter)
76
+ ctx.artist = ctx.artist.trim()
77
+ next()
78
+ })
79
+
80
+ // -----------
81
+ // post
82
+ // -----------
83
+
84
+ // remove any surrounding quotes
85
+ m.set('remove quotes', (ctx, next) => {
86
+ ctx.artist = ctx.artist.replace(/^['|"](.*)['|"]$/, '$1')
87
+ ctx.title = ctx.title.replace(/^['|"](.*)['|"]$/, '$1')
88
+ next()
89
+ })
90
+
91
+ // some artist-specific tweaks
92
+ m.set('artist tweaks', (ctx, next) => {
93
+ // Last, First [Middle] -> First [Middle] Last
94
+ ctx.artist = ctx.artist.replace(/^(\w+), (\w+ ?\w+)$/ig, '$2 $1')
95
+
96
+ // featuring/feat/ft -> ft.
97
+ ctx.artist = ctx.artist.replace(/ featuring /i, ' ft. ')
98
+ ctx.artist = ctx.artist.replace(/ f(ea)?t\.? /i, ' ft. ')
99
+ next()
100
+ })
101
+
102
+ // move leading articles to end
103
+ m.set('move leading articles', (ctx, next) => {
104
+ ctx.artist = moveArticles(ctx.artist, ctx.cfg.articles)
105
+ ctx.title = moveArticles(ctx.title, ctx.cfg.articles)
106
+ next()
107
+ })
108
+
109
+ // ---------
110
+ // normalize
111
+ // ---------
112
+ m.set('normalize artist', (ctx, next) => {
113
+ // skip if already set
114
+ if (ctx.artistNorm) return next()
115
+
116
+ ctx.artistNorm = normalizeStr(ctx.artist, ctx.cfg.articles)
117
+ next()
118
+ })
119
+
120
+ m.set('normalize title', (ctx, next) => {
121
+ // skip if already set
122
+ if (ctx.titleNorm) return next()
123
+
124
+ ctx.titleNorm = normalizeStr(ctx.title, ctx.cfg.articles)
125
+ next()
126
+ })
127
+
128
+ // ---------------------
129
+ // end middleware stack
130
+ // ---------------------
131
+
132
+ // clean left-to-right until a valid part is encountered (or only 2 parts left)
133
+ function cleanParts (patterns) {
134
+ return function (ctx, next) {
135
+ for (let i = 0; i < ctx.parts.length; i++) {
136
+ if (patterns.some(exp => exp.test(ctx.parts[i].trim())) && ctx.parts.length > 2) {
137
+ ctx.parts.shift()
138
+ i--
139
+ } else break
140
+ }
141
+
142
+ next()
143
+ }
144
+ }
145
+
146
+ function normalizeStr (str, articles) {
147
+ str = removeArticles(str, articles)
148
+ .normalize('NFD')
149
+ .replace(/[\u0300-\u036f]/g, '')
150
+ .replace(' & ', ' and ') // normalize ampersand
151
+ .replace(/[^\w\s]|_/g, '') // remove punctuation
152
+
153
+ return str
154
+ }
155
+
156
+ // move leading articles to end (but before any parantheses)
157
+ function moveArticles (str, articles) {
158
+ if (!Array.isArray(articles)) return str
159
+
160
+ for (const article of articles) {
161
+ const search = article + ' '
162
+
163
+ // leading article?
164
+ if (new RegExp(`^${search}`, 'i').test(str)) {
165
+ const parens = /[([{].*$/.exec(str)
166
+
167
+ if (parens) {
168
+ str = str.substring(search.length, parens.index - search.length)
169
+ .trim() + `, ${article} ${parens[0]}`
170
+ } else {
171
+ str = str.substring(search.length) + `, ${article}`
172
+ }
173
+
174
+ // only replace one article per string
175
+ continue
176
+ }
177
+ }
178
+
179
+ return str.trim()
180
+ }
181
+
182
+ function removeArticles (str, articles) {
183
+ for (const article of articles) {
184
+ const leading = new RegExp(`^${article} `, 'i')
185
+ const trailing = new RegExp(`, ${article}$`, 'i')
186
+
187
+ if (leading.test(str)) {
188
+ str = str.replace(leading, '')
189
+ continue // only replace one article per string
190
+ } else if (trailing.test(str)) {
191
+ str = str.replace(trailing, '')
192
+ continue // only replace one article per string
193
+ }
194
+ }
195
+
196
+ return str.trim()
197
+ }
@@ -0,0 +1,3 @@
1
+ const MetaParser = require('./MetaParser')
2
+
3
+ module.exports = MetaParser
@@ -0,0 +1,33 @@
1
+ const IPC = require('../lib/IPCBridge')
2
+ const {
3
+ SCANNER_WORKER_STATUS,
4
+ } = require('../../shared/actionTypes')
5
+
6
+ class Scanner {
7
+ constructor () {
8
+ this.isCanceling = false
9
+ this.emitStatus = this.getStatusEmitter()
10
+ }
11
+
12
+ cancel () {
13
+ this.isCanceling = true
14
+ }
15
+
16
+ getStatusEmitter () {
17
+ return (text, pct, isScanning = true) => {
18
+ IPC.send({
19
+ type: SCANNER_WORKER_STATUS,
20
+ payload: {
21
+ isScanning,
22
+ pct,
23
+ text,
24
+ },
25
+ meta: {
26
+ noAck: true,
27
+ }
28
+ })
29
+ }
30
+ }
31
+ }
32
+
33
+ module.exports = Scanner